Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,21 @@ graph LR
cmd = 'khard', args = { 'email', '-p', '--remove-first-line', '-A', 'work' },
notmuch_path = 'work',
from = 'Your Name <you@company.com>',
resolver = {
email_pattern = 'first.last', -- 'first.last' | 'flast' | 'first_last' | 'firstlast' | 'last.first'
domain = 'company.com',
normalize_suffixes = true, -- strip trailing 'K'/'XX'/'I' suffixes before matching
transliterate = true, -- ä→a, ö→o etc.
ldap = { -- omit to disable LDAP stage
cmd = 'ldap_owa_query', args = {}, account_arg = 'work', timeout = 10000,
},
},
},
personal = {
cmd = 'khard', args = { 'email', '-p', '--remove-first-line', '-A', 'personal' },
notmuch_path = 'personal@gmail.com',
from = 'Your Name <you@gmail.com>',
-- no resolver block: ,mC uses khard only for personal
},
},
},
Expand Down Expand Up @@ -275,29 +285,37 @@ require('nvim-mail').setup({
['company%.com'] = 'work',
['gmail%.com'] = 'personal',
},
-- Per-account khard address books
-- Per-account khard address books + optional resolver block
accounts = {
work = {
cmd = 'khard',
args = { 'email', '-p', '--remove-first-line', '-A', 'work' },
notmuch_path = 'work',
resolver = {
email_pattern = 'first.last', -- 'first.last'|'flast'|'first_last'|'firstlast'|'last.first'
domain = 'company.com',
normalize_suffixes = true,
transliterate = true,
ldap = { cmd = 'ldap_owa_query', args = {}, account_arg = 'work', timeout = 10000 },
},
},
personal = {
cmd = 'khard',
args = { 'email', '-p', '--remove-first-line', '-A', 'personal' },
notmuch_path = 'you@gmail.com',
-- no resolver: khard-only for personal account
},
},
work_domain = 'company.com',
},
})
```

Resolution priority per account:
1. **khard** — account-scoped address book (`-A work` or `-A personal`)
2. **notmuch** — scoped by `path:work/**` or `path:you@gmail.com/**`
3. **Corporate pattern** — `first.last@work_domain` (work account only)
4. **LDAP** — DavMail corporate directory (work account only)
Resolution pipeline per account (`,mC`):
1. **khard** — always runs, account-scoped (`-A work` etc.)
2. **notmuch** — only if `resolver.email_pattern` + `resolver.domain` are set; scoped by `notmuch_path`
3. **LDAP** — only if `resolver.ldap` is configured; command + args from config

Accounts without a `resolver` block skip stages 2 and 3 entirely.

### Contacts picker (`<leader>sK` / `,mK`)

Expand All @@ -317,13 +335,13 @@ Loads khard contacts instantly (~300ms). `Ctrl+e` merges notmuch addresses on de

### Contact resolution (`,mC`)

Resolves display names in `To:`/`Cc:`/`Bcc:` to emails using a 3-stage pipeline:
Resolves display names in `To:`/`Cc:`/`Bcc:` to emails using a per-account 3-stage pipeline:

1. **khard** — local vcard store, 3 name variants (original, suffix-stripped, first+last)
2. **notmuch** — corporate email pattern (`first.last@company.com`) with transliteration (ä→a, ö→o) and name validation
3. **ldap** — DavMail LDAP as last resort, shows `⏳ LDAP lookup for: <name>` warning
2. **notmuch** — only if `resolver.email_pattern` + `resolver.domain` are set; builds candidates per configured pattern (`first.last`, `flast`, `first_last`, `firstlast`, `last.first`) with optional transliteration (ä→a, ö→o) and suffix normalisation; scoped by `notmuch_path`
3. **ldap** — only if `resolver.ldap` is configured; shows `⏳ LDAP lookup for: <name>` warning

Successful notmuch and ldap results are auto-saved to khard for instant lookup next time.
Accounts without a `resolver` block use khard only. Successful notmuch and ldap results are auto-saved to khard for instant lookup next time.

Features:
- Live results as you type (nm-livesearch async streaming)
Expand Down
27 changes: 26 additions & 1 deletion lua/nvim-mail/contacts.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ local M = {}
M.config = {
-- Per-account contact sources
accounts = {
-- Each account may include an optional `resolver` block that controls
-- how ,mC resolves display names to emails (stages 2 + 3).
-- Accounts without a resolver block use khard only (stage 1).
--
-- Example:
-- work = {
-- cmd = 'khard',
-- args = { 'email', '-p', '--remove-first-line', '-A', 'work' },
-- notmuch_path = 'work',
-- from = 'Your Name <you@company.com>',
-- resolver = {
-- email_pattern = 'first.last', -- 'first.last'|'flast'|'first_last'|'firstlast'|'last.first'
-- domain = 'company.com',
-- normalize_suffixes = true, -- strip 'K'/'XX'/'I' suffixes before matching
-- transliterate = true, -- ä→a, ö→o etc.
-- ldap = { -- omit to disable LDAP stage
-- cmd = 'ldap_owa_query',
-- args = {},
-- account_arg = 'work',
-- timeout = 10000,
-- },
-- },
-- },
work = {
cmd = 'khard',
args = { 'email', '-p', '--remove-first-line', '-A', 'work' },
Expand All @@ -25,7 +48,9 @@ M.config = {
calendar_map = {}, -- e.g. { ['Calendar'] = 'work', ['monkeyxite@gmail.com'] = 'personal' }
-- Account → From address (used to inject From: in calendar reply)
account_from = {}, -- e.g. { work = 'Jonny Hou <jonny.hou@ericsson.com>', personal = 'Jonny Hou <monkeyxite@gmail.com>' }
-- Work email domain for notmuch Ericsson-style pattern matching
-- DEPRECATED: use resolver.domain on the relevant accounts entry instead.
-- If set (and not 'example.com'), a legacy resolver is synthesised for
-- backwards compatibility and a one-time deprecation warning is emitted.
work_domain = 'example.com',
-- notmuch address search (searches all indexed mail)
notmuch = true,
Expand Down
55 changes: 42 additions & 13 deletions lua/nvim-mail/resolve.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,58 @@ function M.tr(s)
end))
end

--- Strip Ericsson-style suffixes: "Kevin Li K" → "Kevin Li", "Magnus Lundgren X" → "Magnus Lundgren"
--- Strip name suffixes: "Kevin Li K" → "Kevin Li", "Magnus Lundgren X" → "Magnus Lundgren".
--- Strips single/double uppercase word suffixes and Roman-numeral suffixes.
function M.normalize_name(n)
return vim.trim(n:gsub('%s+[A-Z][A-Z]?$', ''):gsub('%s+I+$', ''))
end

--- Build Ericsson email candidates from a name (first.last, first.mid.last variants).
--- Returns list of candidate email prefixes (without @ericsson.com).
function M.ericsson_candidates(name)
local norm = M.normalize_name(name)
local parts = vim.split(norm, ' ', { trimempty = true })
--- Build email-prefix candidates from a display name.
--- @param name string Display name, e.g. "Anders Björk" or "Kevin K Li"
--- @param pattern string One of: 'first.last' | 'flast' | 'first_last' | 'firstlast' | 'last.first'
--- @param opts table? { transliterate: bool, normalize_suffixes: bool }
--- transliterate=true applies ä→a, ö→o etc. before pattern building
--- normalize_suffixes=true strips single/double uppercase word suffixes first
---
--- Multi-word names: parts[1] is first name, parts[#parts] is last name, any
--- middle parts are treated as middle names. For the 'first.last' pattern, a
--- three-part name generates three variants (first.mid.last, first.last.mid,
--- first.last) to cover common corporate conventions. Other patterns use only
--- first and last to avoid combinatorial noise.
function M.build_candidates(name, pattern, opts)
opts = opts or {}
local processed = opts.normalize_suffixes and M.normalize_name(name) or vim.trim(name)
local parts = vim.split(processed, ' ', { trimempty = true })
if #parts < 2 then return {} end
local first = M.tr(parts[1]:lower())
local last = M.tr(parts[#parts]:lower())
local xfm = opts.transliterate and M.tr or function(s) return s end
local first = xfm(vim.fn.tolower(parts[1]))
local last = xfm(vim.fn.tolower(parts[#parts]))
local candidates = {}
if #parts == 3 then
local mid = M.tr(parts[2]:lower())
candidates[#candidates+1] = first..'.'..mid..'.'..last
candidates[#candidates+1] = first..'.'..last..'.'..mid
if pattern == 'first.last' then
if #parts == 3 then
local mid = xfm(vim.fn.tolower(parts[2]))
candidates[#candidates+1] = first..'.'..mid..'.'..last
candidates[#candidates+1] = first..'.'..last..'.'..mid
end
candidates[#candidates+1] = first..'.'..last
elseif pattern == 'flast' then
candidates[#candidates+1] = first:sub(1, 1)..last
elseif pattern == 'first_last' then
candidates[#candidates+1] = first..'_'..last
elseif pattern == 'firstlast' then
candidates[#candidates+1] = first..last
elseif pattern == 'last.first' then
candidates[#candidates+1] = last..'.'..first
end
candidates[#candidates+1] = first..'.'..last
return candidates
end

--- Build Ericsson-style candidates (first.last with transliteration and suffix stripping).
--- Kept for backwards compatibility; delegates to build_candidates.
function M.ericsson_candidates(name)
return M.build_candidates(name, 'first.last', { transliterate = true, normalize_suffixes = true })
end

--- Validate that a notmuch display name contains both first and longest-surname words.
function M.validate_notmuch_match(display_name, name)
local norm = M.normalize_name(name)
Expand Down
Loading
Loading