Guide
Build Shell-Integrated Email Autocomplete
Standard address books only search contacts you've explicitly saved. This autocomplete searches everyone you've ever emailed — contacts, one-off recipients, CC'd colleagues — ranked by interaction frequency. Bind it to Ctrl+E in bash, zsh, or fish for instant access. Works across all major email providers.
Written by Pouya Sanooei Software Engineer
Reviewed by Nick Barraclough
Why build your own autocomplete
A typical business inbox contains 200-500 unique email addresses across the from, to, and cc fields. Your contacts list holds maybe 50-100 of those. The other 150-400 are people you’ve corresponded with but never saved: project collaborators, support threads, one-time introductions.
Standard autocomplete misses all of them. This guide builds an autocomplete that searches every address you’ve ever interacted with, ranked by how often you email each person. The 20-30 people you actually email regularly float to the top.
Build the address index
The address index merges two data sources into a single searchable list: structured contacts from your address book and every unique address extracted from email headers. Contacts take priority because they carry richer metadata — given name, surname, and company — while email-derived records fill in the 60-80% of addresses that were never saved as contacts.
The Nylas CLI pulls both sources in JSON format. The contacts list command returns saved contacts with structured fields, while email list returns raw messages whose from, to, and cc headers contain every address you’ve interacted with. A jq merge deduplicates by email address and keeps the contact-sourced record when both exist.
# Get structured contacts
nylas contacts list --json --limit 500 | jq '[.[] | {
email: .emails[0].email,
name: (.given_name + " " + .surname),
source: "contacts"
}]' > contact_addrs.json
# Get every unique address 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_addrs.json
# Merge: contacts take priority over email-derived records
jq -s '
(.[0] + .[1]) | group_by(.email) | map(
sort_by(if .source == "contacts" then 0 else 1 end) | .[0]
)' contact_addrs.json email_addrs.json > all_addrs.jsonRank by interaction frequency
Frequency ranking counts how many times each address appears across from, to, and cc fields, then sorts the index so your most-contacted people appear first. In a typical 1,000-message sample, the top 10 addresses account for roughly 40% of all interactions, making frequency the single most useful sort key for autocomplete.
The script below counts every occurrence of each address across all fetched messages, outputs a frequency table as JSON, then merges those counts back into the address index. Addresses with zero matches (contacts you’ve saved but never emailed) sort to the bottom rather than disappearing entirely.
# Count appearances of each address across all messages
nylas email list --json --limit 1000 | jq '
[.[] | [.from[0].email, (.to[].email), ((.cc // [])[].email)] | .[]]
| group_by(.) | map({email: .[0], freq: length})
| sort_by(-.freq)' > freq.json
# Merge frequency into address index
jq -s '
(.[1] | map({(.email): .freq}) | add // {}) as $f |
[.[0][] | . + {freq: ($f[.email] // 0)}]
| sort_by(-.freq)' all_addrs.json freq.json > ranked_addrs.json
echo "Indexed $(jq length ranked_addrs.json) addresses"Bash + fzf autocomplete
fzf is a general-purpose fuzzy finder with over 67,000 GitHub stars that turns any newline-delimited list into an interactive, searchable menu inside the terminal. Piping the address index into fzf gives you instant, typo-tolerant email lookup without leaving your shell session.
The script below caches addresses in a TSV file at ~/.nylas/autocomplete-cache.tsv and refreshes only when the cache is older than 60 minutes. On a cold start, building the cache from 500 contacts and 500 messages takes 2-3 seconds; subsequent launches read the local file and open fzf in under 100 milliseconds.
#!/bin/bash
# email-autocomplete.sh — fuzzy email picker with fzf
CACHE="$HOME/.nylas/autocomplete-cache.tsv"
# Refresh if older than 1 hour or missing
if [[ ! -f "$CACHE" ]] || [[ $(find "$CACHE" -mmin +60 2>/dev/null) ]]; then
mkdir -p "$HOME/.nylas"
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
# Interactive fuzzy search
SELECTED=$(cat "$CACHE" | fzf \
--prompt="To: " \
--header="Type to search contacts (ESC to cancel)" \
--delimiter=$'\t' \
--with-nth=1,2 \
--preview='echo "Email: {1}\nName: {2}"')
if [[ -n "$SELECTED" ]]; then
echo "$SELECTED" | cut -f1
fiShell keybindings for bash, zsh, and fish
Shell keybindings let you launch the email autocomplete picker with a single keystroke — Ctrl+E — instead of typing the script path manually. Each of the three major shells (bash, zsh, and fish) uses a different binding API, but all achieve the same result: pressing the key combo opens fzf, and selecting a contact pipes the address directly into nylas email send --to.
According to the 2024 Stack Overflow Developer Survey, roughly 47% of developers use bash, 29% use zsh (the macOS default since Catalina), and 5% use fish. The bindings below cover all three. Each wrapper function calls the autocomplete script, captures the selected email address, and passes it as the --to argument.
# === zsh (add to ~/.zshrc) ===
nylas-to() {
local email
email=$(~/.config/nylas/email-autocomplete.sh)
if [[ -n "$email" ]]; then
nylas email send --to "$email" "$@"
fi
}
bindkey -s '^E' 'nylas-to\n'
# === bash (add to ~/.bashrc) ===
nylas-to() {
local email
email=$(~/.config/nylas/email-autocomplete.sh)
if [[ -n "$email" ]]; then
nylas email send --to "$email" "$@"
fi
}
bind '"\C-e": "nylas-to\n"'
# === fish (add to ~/.config/fish/config.fish) ===
function nylas-to
set email (~/.config/nylas/email-autocomplete.sh)
if test -n "$email"
nylas email send --to $email $argv
end
end
bind \ce nylas-toAfter adding the binding, reload your shell (source ~/.zshrc) and press Ctrl+E. The fuzzy picker launches, you type a few characters, select a contact, and the send command runs.
Python with rapidfuzz
rapidfuzz is a C++-accelerated fuzzy string matching library for Python that handles typos, partial matches, and out-of-order tokens at over 10,000 queries per second on a single core. It implements the weighted ratio (WRatio) scorer, which normalizes strings by length before comparing — making it effective for email addresses where a domain like @company.com shouldn’t dominate the match score.
The script below builds an address book by calling nylas contacts list and nylas email list, deduplicates by address, then runs a WRatio fuzzy search with a minimum score threshold of 50 out of 100. Install rapidfuzz with pip install rapidfuzz before running.
#!/usr/bin/env python3
"""Fuzzy email autocomplete using rapidfuzz."""
import json, subprocess, sys
from rapidfuzz import fuzz, process
def build_book():
contacts = json.loads(subprocess.run(
["nylas", "contacts", "list", "--json", "--limit", "500"],
capture_output=True, text=True, check=True
).stdout)
emails = json.loads(subprocess.run(
["nylas", "email", "list", "--json", "--limit", "500"],
capture_output=True, text=True, check=True
).stdout)
book = {}
for c in contacts:
if c.get("emails"):
addr = c["emails"][0]["email"]
name = f"{c.get('given_name', '')} {c.get('surname', '')}".strip()
book[addr] = name
for msg in emails:
for p in [msg["from"][0]] + msg.get("to", []) + msg.get("cc", []):
addr = p.get("email", "")
if addr and addr not in book:
book[addr] = p.get("name", "")
return book
def search(query, book, limit=10):
entries = [f"{e} ({n})" if n else e for e, n in book.items()]
return [(m, s) for m, s, _ in process.extract(
query, entries, scorer=fuzz.WRatio, limit=limit
) if s > 50]
book = build_book()
query = sys.argv[1] if len(sys.argv) > 1 else input("Search: ")
for match, score in search(query, book):
print(f" {match} (score: {score})")TypeScript with Fuse.js
Fuse.js is a lightweight fuzzy search library (under 10 KB gzipped) that runs in both Node.js and browsers. It supports weighted keys, so you can assign name matches a weight of 0.4, email matches a weight of 0.4, and domain matches a lower weight of 0.2 — preventing @gmail.com from dominating results when 30-40% of addresses share the same domain.
The implementation below builds a contact book from Nylas CLI output, configures Fuse.js with a match threshold of 0.4 (where 0.0 is an exact match and 1.0 matches anything), and returns the top 10 results sorted by relevance score. Install Fuse.js with npm install fuse.js before running.
import Fuse from "fuse.js";
import { execFileSync } from "node:child_process";
interface Entry { email: string; name: string; domain: string; freq: number }
function buildBook(): Entry[] {
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, Entry>();
for (const c of contacts) {
const addr = c.emails?.[0]?.email;
if (addr) book.set(addr, {
email: addr,
name: [c.given_name, c.surname].filter(Boolean).join(" "),
domain: addr.split("@")[1] || "", freq: 0,
});
}
for (const msg of emails) {
for (const p of [msg.from[0], ...(msg.to || []), ...(msg.cc || [])]) {
const addr = p?.email;
if (!addr) continue;
const existing = book.get(addr);
if (existing) existing.freq++;
else book.set(addr, { email: addr, name: p.name || "",
domain: addr.split("@")[1] || "", freq: 1 });
}
}
return [...book.values()].sort((a, b) => b.freq - a.freq);
}
const fuse = new Fuse(buildBook(), {
keys: [
{ name: "name", weight: 0.4 },
{ name: "email", weight: 0.4 },
{ name: "domain", weight: 0.2 },
],
threshold: 0.4,
includeScore: true,
});
const query = process.argv[2] || "sarah";
const results = fuse.search(query).slice(0, 10);
for (const { item, score } of results) {
console.log(` ${item.email} (${item.name}) — score: ${score?.toFixed(3)}`);
}Cache and refresh strategy
Caching the address index locally avoids repeated API calls and keeps autocomplete responsive. A full rebuild from 500 contacts and 1,000 messages takes 2-4 seconds over the network, while reading a local TSV cache completes in under 50 milliseconds. The strategies below balance freshness against speed — a cron-based background refresh every 30 minutes keeps the cache current without blocking interactive use.
- Background refresh via cron:
*/30 * * * * ~/.config/nylas/email-autocomplete.sh --refreshkeeps the cache fresh without blocking interactive use. - Force refresh: Add a
--refreshflag that deletes the cache before rebuilding. Useful after connecting a new email account. - Incremental updates: Fetch only the latest 50 messages with
--limit 50, extract new addresses, and append to the cache.
Next steps
- Automate draft creation — after picking a contact, generate a pre-filled draft from a template
- Personalize outbound email — send directly with merge fields and timezone-aware scheduling
- Parse signatures for enrichment — add job titles and company info to your autocomplete index
- Command reference — every flag, subcommand, and example
- fzf -- general-purpose command-line fuzzy finder — the canonical fuzzy-match UX shell autocomplete copies
- fzf scoring algorithm (algo.go) — the actual ranking heuristic (bonus weights, gap penalties) for matches
- RFC 5322 §3.4 -- Address Specification — the address grammar your contact index needs to normalize before matching