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 with Gmail, Outlook, Exchange, Yahoo, iCloud, and IMAP.
By Pouya Sanooei
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
Merge contacts (structured names) with email history (every unique address). Contacts take priority because they have richer metadata.
# 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
Alphabetical ordering is useless. Frequency ranking puts the people you actually email at the top:
# 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 turns any list into a fuzzy-searchable menu. The script below caches the address list and refreshes hourly.
#!/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
Bind the autocomplete to Ctrl+E so you can invoke it mid-command. Each shell has a different binding syntax:
# === zsh (add to ~/.zshrc) ===
nylas-to() {
local email
email=$(~/.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=$(~/.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 (~/.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
For programmatic use inside scripts or tools, rapidfuzz handles typos, partial matches, and out-of-order tokens at 10,000+ queries/second. Install with pip install rapidfuzz.
#!/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 supports weighted keys so you can prioritize name matches (weight 0.4) over domain matches (weight 0.2). Install with npm install fuse.js.
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
- Background refresh via cron:
*/30 * * * * ~/.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