Guide

Build Email Autocomplete from the CLI

Most email autocomplete only searches your address book. This searches everyone you have ever emailed — contacts, one-off recipients, CC'd colleagues — ranked by how frequently you interact with them. Build it with fzf for quick terminal use, or rapidfuzz/Fuse.js for integration into tools.

Why build your own autocomplete

Most address books only include people you’ve explicitly saved as contacts. But you’ve emailed hundreds of people who aren’t in your contacts — project collaborators, support threads, one-time introductions. They live in your sent folder and CC fields, invisible to standard autocomplete.

This guide builds an autocomplete that searches all of them. It pulls structured contacts from nylas contacts list, extracts every unique address from your email history, merges the two lists with contacts taking priority, and ranks results by interaction frequency. The people you email most appear first, not alphabetically but by relevance.

Three implementation options are covered: Bash with fzf for instant terminal use, Python with rapidfuzz for script integration, and TypeScript with Fuse.js for embedding in tools or web UIs.

Build the address book

The address book merges two data sources. Contacts give you structured names and email addresses. Email history gives you every unique sender and recipient you’ve interacted with — including people who were never saved as contacts. When the same address appears in both sources, the contact record takes priority because it has richer metadata.

# Get structured contacts
nylas contacts list --json --limit 500 | jq '[.[] | {
  email: .emails[0].email,
  name: (.given_name + " " + .surname),
  source: "contacts"
}]' > contact_addresses.json

# Get unique senders/recipients from email history
nylas email list --json --limit 1000 | jq '[
  [.[] | (.from[0] // empty), (.to[]? // empty), (.cc[]? // empty)]
  | .[]
  | {email: .email, name: (.name // ""), source: "email"}
] | unique_by(.email)' > email_addresses.json

# Merge: contacts take priority
jq -s '
  (.[0] + .[1]) | group_by(.email) | map(
    sort_by(if .source == "contacts" then 0 else 1 end) | .[0]
  )' contact_addresses.json email_addresses.json > all_addresses.json

The first command extracts email and name from your contacts. The second collects every unique address from the from, to, and cc fields across your email history. The merge step groups by email address, sorts so that contact-sourced records come first, and keeps only the best record for each address.

Rank by frequency

Raw alphabetical or insertion-order lists are useless for autocomplete. You want the people you email most to appear first. This step counts how many times each address appears across all your sent and received messages, then attaches that frequency score to each entry in your address book.

# Count how often each address appears in sent/received email
nylas email list --json --limit 1000 | jq '
  [.[] | [.from[0].email, (.to[].email), ((.cc // [])[].email)] | .[]]
  | group_by(.)
  | map({email: .[0], frequency: length})
  | sort_by(-.frequency)' > frequency.json

# Merge frequency into address book
jq -s '
  (.[1] | map({(.email): .frequency}) | add // {}) as $freq_map |
  [.[0][] | . + {frequency: ($freq_map[.email] // 0)}]
  | sort_by(-.frequency)' all_addresses.json frequency.json > ranked_addresses.json

The first command flattens all from, to, and cc addresses into a single list, groups identical addresses, and counts occurrences. The second merges those counts into your address book. Addresses without any email history get a frequency of zero. The final output is sorted by frequency descending — your most frequent contacts at the top.

Bash + fzf autocomplete

fzf is a terminal fuzzy finder that turns any list into an interactive, searchable menu. Pipe your address book into it and you get instant autocomplete with typo tolerance. The script below caches the address list and refreshes it hourly.

#!/bin/bash
# email-autocomplete.sh — fuzzy email picker with fzf

CACHE="$HOME/.nylas/autocomplete-cache.json"

# Refresh cache if older than 1 hour or missing
if [[ ! -f "$CACHE" ]] || [[ $(find "$CACHE" -mmin +60 2>/dev/null) ]]; then
  nylas contacts list --json --limit 500 | jq -r '
    .[] | [.emails[0].email, (.given_name + " " + .surname)] | @tsv
  ' > "$CACHE.tmp"

  nylas email list --json --limit 500 | jq -r '
    [.[] | .from[0] | [.email, (.name // "")]] | .[] | @tsv
  ' >> "$CACHE.tmp"

  sort -u -t$'\t' -k1,1 "$CACHE.tmp" > "$CACHE"
  rm "$CACHE.tmp"
fi

# Fuzzy search with fzf
SELECTED=$(cat "$CACHE" | fzf \
  --prompt="To: " \
  --header="Type to search contacts" \
  --delimiter=$'\t' \
  --with-nth=1,2 \
  --preview='echo "Email: {1}\nName: {2}"')

if [[ -n "$SELECTED" ]]; then
  EMAIL=$(echo "$SELECTED" | cut -f1)
  echo "$EMAIL"
  # Optionally pipe directly to send
  # nylas email send --to "$EMAIL"
fi

The --with-nth=1,2 flag tells fzf to display both the email and name columns but return the full line. The --preview flag shows a formatted preview panel as you navigate results. The cache check uses find -mmin +60 to refresh only when the cache is stale.

To bind this to a key in your shell, add a keybinding to your ~/.zshrc or ~/.bashrc:

# Bind Ctrl+E to email autocomplete in zsh
bindkey -s '^E' 'nylas email send --to "$(~/.nylas/email-autocomplete.sh)"\n'

Python with fuzzy matching

For programmatic use — inside a larger script, a CLI tool, or an API — rapidfuzz provides fast fuzzy string matching in Python. It handles typos, partial matches, and out-of-order tokens. Install with pip install rapidfuzz.

#!/usr/bin/env python3
"""Fuzzy email autocomplete using rapidfuzz."""
import json
import subprocess
import sys
from rapidfuzz import fuzz, process


def build_address_book():
    """Merge contacts and email history into a single address book."""
    contacts = json.loads(subprocess.run(
        ["nylas", "contacts", "list", "--json", "--limit", "500"],
        capture_output=True, text=True, check=True
    ).stdout)

    emails_raw = json.loads(subprocess.run(
        ["nylas", "email", "list", "--json", "--limit", "500"],
        capture_output=True, text=True, check=True
    ).stdout)

    address_book = {}

    # Contacts first — they have the richest metadata
    for contact in contacts:
        if contact.get("emails"):
            email = contact["emails"][0]["email"]
            name = f"{contact.get('given_name', '')} {contact.get('surname', '')}".strip()
            address_book[email] = name

    # Then backfill from email history
    for msg in emails_raw:
        participants = [msg["from"][0]] + msg.get("to", []) + msg.get("cc", [])
        for person in participants:
            email = person.get("email", "")
            if email and email not in address_book:
                address_book[email] = person.get("name", "")

    return address_book


def search(query, address_book, limit=10):
    """Fuzzy search the address book. Returns (match, score) tuples."""
    entries = [
        f"{email} ({name})" if name else email
        for email, name in address_book.items()
    ]
    results = process.extract(query, entries, scorer=fuzz.WRatio, limit=limit)
    return [(match, score) for match, score, _ in results if score > 50]


if __name__ == "__main__":
    book = build_address_book()
    query = sys.argv[1] if len(sys.argv) > 1 else input("Search: ")

    matches = search(query, book)
    if not matches:
        print("  No matches found.")
    else:
        for match, score in matches:
            print(f"  {match} (score: {score})")

The WRatio scorer handles the widest range of input variations — partial matches, transpositions, and different word orders. A score threshold of 50 filters out low-quality matches. Increase it to 70 for stricter results or decrease to 30 if you want broader recall.

TypeScript with Fuse.js

Fuse.js is a lightweight fuzzy search library that works in Node.js and the browser. It supports weighted keys, so you can prioritize name matches over domain matches, and configurable thresholds for match strictness. Install with npm install fuse.js.

import Fuse from "fuse.js";
import { execFileSync } from "node:child_process";

interface AddressEntry {
  email: string;
  name: string;
  domain: string;
  frequency: number;
}

function buildAddressBook(): AddressEntry[] {
  const contacts = JSON.parse(
    execFileSync("nylas", ["contacts", "list", "--json", "--limit", "500"]).toString()
  );

  const emails = JSON.parse(
    execFileSync("nylas", ["email", "list", "--json", "--limit", "500"]).toString()
  );

  const book = new Map<string, AddressEntry>();

  // Contacts first
  for (const contact of contacts) {
    const email = contact.emails?.[0]?.email;
    if (email) {
      book.set(email, {
        email,
        name: [contact.given_name, contact.surname].filter(Boolean).join(" "),
        domain: email.split("@")[1] || "",
        frequency: 0,
      });
    }
  }

  // Backfill from email history and count frequency
  for (const msg of emails) {
    const participants = [msg.from[0], ...(msg.to || []), ...(msg.cc || [])];
    for (const person of participants) {
      const addr = person?.email;
      if (!addr) continue;
      const existing = book.get(addr);
      if (existing) {
        existing.frequency += 1;
      } else {
        book.set(addr, {
          email: addr,
          name: person.name || "",
          domain: addr.split("@")[1] || "",
          frequency: 1,
        });
      }
    }
  }

  return [...book.values()].sort((a, b) => b.frequency - a.frequency);
}

// Configure Fuse.js with weighted keys
const fuse = new Fuse(buildAddressBook(), {
  keys: [
    { name: "name", weight: 0.4 },
    { name: "email", weight: 0.4 },
    { name: "domain", weight: 0.2 },
  ],
  threshold: 0.4,
  includeScore: true,
});

// Search
const query = process.argv[2] || "sarah";
const results = fuse.search(query);

if (results.length === 0) {
  console.log("  No matches found.");
} else {
  results.slice(0, 10).forEach(({ item, score }) => {
    console.log(`  ${item.email} (${item.name}) — score: ${score?.toFixed(3)}`);
  });
}

The threshold controls match strictness — 0 requires a perfect match, 1 matches anything. A value of 0.4 provides a good balance between recall and precision. The weighted keys ensure that a name match like “sarah” ranks higher than a domain match like “sarahtech.com”.

Shell integration

Wrap the fzf-based autocomplete into a shell function so you can invoke it as part of your email workflow. The function below picks an address and passes it directly to nylas email send.

# Add to ~/.zshrc
nylas-to() {
  local email
  email=$(~/.nylas/email-autocomplete.sh)
  if [[ -n "$email" ]]; then
    nylas email send --to "$email" "$@"
  fi
}

# Usage: type 'nylas-to' and fzf launches, then the email goes to send
# You can also pass additional flags:
#   nylas-to --subject "Quick question" --body "Hey, are you free tomorrow?"

After adding this to your ~/.zshrc, run source ~/.zshrc to load it. Then type nylas-to to launch the interactive picker. Select a contact, and the send command runs with any additional flags you pass.

Cache and refresh strategy

The Bash script above already caches to ~/.nylas/autocomplete-cache.json with a one-hour TTL. Here are additional strategies for keeping your autocomplete fresh without slowing down every invocation.

Background refresh. Use a cron job or systemd timer to refresh the cache periodically, so the interactive picker never waits on API calls:

# Refresh autocomplete cache every 30 minutes
# Add to crontab with: crontab -e
*/30 * * * * ~/.nylas/email-autocomplete.sh --refresh > /dev/null 2>&1

Force refresh. Add a --refresh flag to the script that deletes the cache before rebuilding. Useful after importing new contacts or connecting a new email account:

# Add to the top of email-autocomplete.sh, after the CACHE= line
if [[ "$1" == "--refresh" ]]; then
  rm -f "$CACHE"
  echo "Cache cleared. Rebuilding..."
fi

Incremental updates. Instead of rebuilding the full cache, append new addresses from recent emails. Use nylas email list --json --limit 50 to fetch only the latest messages, extract addresses, and merge into the existing cache. This keeps the cache current without re-fetching your entire history.

Next steps

Now that you can quickly look up any address, automate the next step — creating drafts for the people you select:

  • Auto-Create Email Drafts — generate pre-filled drafts from templates and contact context, with batch creation for follow-ups after meetings.
  • Map Organization Contacts — score relationship strength, find warm introduction paths, and detect single-threaded risk from email data.
  • CRM Email Workflows — the full 8-guide series covering extraction, organization, enrichment, and action on email data.