@bobfrankston/mailx 1.0.430 → 1.0.433

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,8 @@ mailx
15
15
 
16
16
  Requires Node.js 22 or later.
17
17
 
18
+ The package installs two equivalent commands: **`mailx`** and **`bobmail`**. They're the same binary — `bobmail` is provided as an alternative for environments where `mailx` collides with another tool (most commonly the BSD `mailx`/Heirloom mailx at `/usr/bin/mailx` on macOS and some Linux distributions). Use whichever name doesn't conflict on your system.
19
+
18
20
  On Windows, the native WebView2 app launches automatically via IPC (no HTTP server needed). Use `mailx --server` for browser mode at `http://127.0.0.1:9333`.
19
21
 
20
22
  ## First-Time Setup
package/client/app.js CHANGED
@@ -492,16 +492,40 @@ document.getElementById("btn-restart-quick")?.addEventListener("click", async ()
492
492
  if (restartDropdown)
493
493
  restartDropdown.hidden = true;
494
494
  if (isApp) {
495
- // Android: check for updates before reloading
495
+ // Android has no daemon only the WebView. Reload-the-page is the
496
+ // right action there. Desktop IPC mode is a different story below.
496
497
  if (window.mailxapi?.platform === "android") {
497
498
  const f = document.createElement("iframe");
498
499
  f.style.display = "none";
499
500
  f.src = "mailxapi://checkUpdate";
500
501
  document.body.appendChild(f);
501
502
  setTimeout(() => f.remove(), 100);
503
+ location.reload();
504
+ return;
505
+ }
506
+ // Desktop IPC mode: there IS a daemon (the --daemon child of mailx)
507
+ // running mailx-service / mailx-imap / mailx-store. Just calling
508
+ // location.reload() reloads the WebView but the daemon keeps running
509
+ // the old code, so daemon-side changes (sync, store, IPC handlers)
510
+ // don't get picked up. Trigger restartDaemon — it spawns a fresh
511
+ // `mailx` process, hands off the instance.json slot, then gracefully
512
+ // shuts down the current daemon. The UI reloads after a short delay
513
+ // so the new daemon's WebView replaces this one.
514
+ const statusSync = document.getElementById("status-sync");
515
+ if (statusSync)
516
+ statusSync.textContent = "Restarting...";
517
+ const ipc = window.mailxapi;
518
+ if (ipc?.restartDaemon) {
519
+ try {
520
+ await ipc.restartDaemon();
521
+ }
522
+ catch { /* daemon shutting down */ }
523
+ setTimeout(() => location.reload(), 2000);
524
+ }
525
+ else {
526
+ // Older host with no restartDaemon IPC — fall back to UI reload.
527
+ location.reload();
502
528
  }
503
- // IPC mode: reload the UI (no server to restart)
504
- location.reload();
505
529
  }
506
530
  else {
507
531
  const statusSync = document.getElementById("status-sync");
@@ -358,60 +358,32 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
358
358
  }
359
359
  headerEl.querySelector(".mv-date").textContent = new Date(msg.date).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
360
360
  // Unsubscribe button (upper right of header).
361
- // Priority:
362
- // 1. RFC 8058 one-click (HTTPS + List-Unsubscribe-Post header) — POST server-side
363
- // 2. HTTPS URL open in a new tab (two-click flow, usually a confirmation page)
364
- // 3. mailto: URL open a pre-filled compose window (so the unsubscribe
365
- // reply gets sent from the correct mailx account, not the OS default
366
- // mail handler)
361
+ // - HTTPS URL: open externally (same path as right-click → open link
362
+ // in a new window). Earlier code attempted RFC 8058 one-click POST
363
+ // first, but the IPC path was failing silently the button "moved
364
+ // a tad" (CSS :active) but no visible feedback. User preference:
365
+ // just open the link, the way the working right-click does.
366
+ // - mailto: URL: open a pre-filled compose window so the reply gets
367
+ // sent from the correct mailx account, not the OS default mail
368
+ // handler.
367
369
  const unsubBtn = document.getElementById("mv-unsubscribe");
368
370
  const httpUrl = msg.listUnsubscribeHttp || "";
369
371
  const mailUrl = msg.listUnsubscribeMail || "";
370
- const oneClick = !!msg.listUnsubscribeOneClick;
371
372
  const anyUrl = httpUrl || mailUrl || msg.listUnsubscribe || "";
372
373
  if (unsubBtn) {
373
374
  if (anyUrl) {
374
375
  unsubBtn.hidden = false;
375
- unsubBtn.textContent = oneClick && httpUrl ? "Unsubscribe (1-click)" : "Unsubscribe";
376
+ unsubBtn.textContent = "Unsubscribe";
376
377
  unsubBtn.removeAttribute("title");
377
378
  unsubBtn.href = httpUrl || mailUrl || "#";
378
- unsubBtn.onclick = async (e) => {
379
+ unsubBtn.onclick = (e) => {
379
380
  e.preventDefault();
380
- const status = document.getElementById("status-sync");
381
- // Always attempt POST first when there's an HTTPS URL,
382
- // regardless of whether the message advertised the
383
- // `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
384
- // header. Many senders provide a POST-capable endpoint
385
- // without setting the Post header. Server side already
386
- // falls back to GET internally on 4xx, so trying POST
387
- // first never makes things worse and avoids a tab full
388
- // of "are you sure?" ceremony when the endpoint would
389
- // have just accepted POST.
390
381
  if (httpUrl) {
391
- unsubBtn.textContent = "Unsubscribing…";
392
- try {
393
- const { unsubscribeOneClick } = await import("../lib/api-client.js");
394
- const r = await unsubscribeOneClick(httpUrl);
395
- if (r?.ok) {
396
- unsubBtn.textContent = "Unsubscribed";
397
- if (status)
398
- status.textContent = `Unsubscribed (HTTP ${r.status})`;
399
- }
400
- else {
401
- unsubBtn.textContent = `Failed: HTTP ${r?.status ?? "?"}`;
402
- if (status)
403
- status.textContent = `Unsubscribe failed: ${r?.status} ${r?.statusText || ""}`.trim();
404
- // Last-resort fallback: open the URL in a tab so
405
- // the user can complete the flow manually.
406
- window.open(httpUrl, "_blank");
407
- }
408
- }
409
- catch (err) {
410
- unsubBtn.textContent = "Unsubscribe failed";
411
- if (status)
412
- status.textContent = `Unsubscribe error: ${err.message}`;
413
- window.open(httpUrl, "_blank");
414
- }
382
+ const api = window.mailxapi;
383
+ if (api?.openExternal)
384
+ api.openExternal(httpUrl);
385
+ else
386
+ window.open(httpUrl, "_blank", "noopener,noreferrer");
415
387
  return;
416
388
  }
417
389
  if (mailUrl) {
@@ -262,6 +262,70 @@ body {
262
262
  font-size: 0.8em;
263
263
  }
264
264
 
265
+ /* Source badge in the autocomplete row. Custom user tags from
266
+ * contacts.jsonc#preferred[] (e.g. "work", "family") flow through verbatim;
267
+ * system sources show as 'google' / 'discovered' / 'preferred'. Inline-block
268
+ * so the small uppercased text doesn't push layout. */
269
+ .ac-item-source {
270
+ color: var(--color-text-muted);
271
+ font-size: 0.7em;
272
+ text-transform: uppercase;
273
+ letter-spacing: 0.04em;
274
+ opacity: 0.7;
275
+ margin-top: 2px;
276
+ }
277
+
278
+ /* Modal overlay used by Add-to-preferred dialog. Lightweight; shares no
279
+ * CSS with the main app's modal because compose runs in its own document. */
280
+ .modal-overlay {
281
+ position: fixed;
282
+ inset: 0;
283
+ background: rgba(0,0,0,0.4);
284
+ display: flex;
285
+ align-items: center;
286
+ justify-content: center;
287
+ z-index: 1000;
288
+ }
289
+ .modal-overlay .modal {
290
+ background: var(--color-bg-surface);
291
+ border-radius: var(--radius-md);
292
+ padding: var(--gap-md);
293
+ min-width: 360px;
294
+ max-width: 90%;
295
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
296
+ & h3 { margin: 0 0 var(--gap-sm); }
297
+ & p.muted { color: var(--color-text-muted); font-size: 0.85em; margin: 0 0 var(--gap-sm); }
298
+ & label {
299
+ display: block;
300
+ margin-bottom: var(--gap-sm);
301
+ font-size: var(--font-size-sm);
302
+ & input {
303
+ display: block;
304
+ width: 100%;
305
+ padding: 4px 6px;
306
+ margin-top: 2px;
307
+ background: var(--color-bg-input);
308
+ border: 1px solid var(--color-border);
309
+ border-radius: var(--radius-sm);
310
+ color: var(--color-text);
311
+ }
312
+ }
313
+ & .modal-actions {
314
+ display: flex;
315
+ justify-content: flex-end;
316
+ gap: var(--gap-sm);
317
+ margin-top: var(--gap-sm);
318
+ & button.primary {
319
+ background: var(--color-primary, #4a90e2);
320
+ color: white;
321
+ border: none;
322
+ padding: 4px 12px;
323
+ border-radius: var(--radius-sm);
324
+ cursor: pointer;
325
+ }
326
+ }
327
+ }
328
+
265
329
  /* ── tiptap editor ── */
266
330
  .editor-tiptap {
267
331
  display: flex;
@@ -4,7 +4,8 @@
4
4
  * Receives init data via window.opener.postMessage or URL params.
5
5
  */
6
6
  import { createEditor } from "./editor.js";
7
- import { getSettings, getAccounts, searchContacts, saveDraft as apiSaveDraft, deleteDraft, logClientEvent } from "../lib/api-client.js";
7
+ import { getSettings, getAccounts, searchContacts, saveDraft as apiSaveDraft, deleteDraft, logClientEvent, addPreferredContact, addToDenylist } from "../lib/api-client.js";
8
+ import { showContextMenu } from "../components/context-menu.js";
8
9
  // Very first line the iframe runs — if this doesn't reach Node, the iframe
9
10
  // itself isn't loading or the bridge is completely broken.
10
11
  logClientEvent("compose-module-loaded", { href: location.href, version: window.mailxVersion || "?" });
@@ -263,6 +264,77 @@ function smartTab(current) {
263
264
  editor.focus();
264
265
  }
265
266
  // ── Autocomplete ──
267
+ /** Right-click on an autocomplete row → contextual actions. Two paths:
268
+ * - Add to preferred (small modal: name / email / source-tag / org → write to
269
+ * contacts.jsonc#preferred[])
270
+ * - Never suggest this address (write to contacts.jsonc#denylist[]; the
271
+ * service-side handler purges any matching discovered rows on apply) */
272
+ function showAutocompleteContextMenu(e, row) {
273
+ showContextMenu(e.clientX, e.clientY, [
274
+ {
275
+ label: "Add to preferred…",
276
+ action: () => openAddToPreferredModal(row),
277
+ },
278
+ {
279
+ label: "Never suggest this address",
280
+ action: async () => {
281
+ try {
282
+ await addToDenylist(row.email);
283
+ }
284
+ catch (err) {
285
+ alert(`Failed to add to denylist: ${err?.message || err}`);
286
+ }
287
+ },
288
+ },
289
+ ]);
290
+ }
291
+ function openAddToPreferredModal(prefill) {
292
+ const overlay = document.createElement("div");
293
+ overlay.className = "modal-overlay";
294
+ overlay.innerHTML = `
295
+ <div class="modal" role="dialog" aria-label="Add to preferred contacts">
296
+ <h3>Add to preferred</h3>
297
+ <p class="muted">Saved to <code>contacts.jsonc</code> on your shared drive.</p>
298
+ <label>Name <input type="text" id="pf-name" /></label>
299
+ <label>Email <input type="email" id="pf-email" /></label>
300
+ <label>Source tag <input type="text" id="pf-source" placeholder="(optional — e.g. work, family)" /></label>
301
+ <label>Organization <input type="text" id="pf-org" placeholder="(optional)" /></label>
302
+ <div class="modal-actions">
303
+ <button id="pf-cancel">Cancel</button>
304
+ <button id="pf-save" class="primary">Save</button>
305
+ </div>
306
+ </div>
307
+ `;
308
+ document.body.appendChild(overlay);
309
+ overlay.querySelector("#pf-name").value = prefill.name || "";
310
+ overlay.querySelector("#pf-email").value = prefill.email || "";
311
+ // Pre-fill source from existing tag if it's already a custom one (not a system source).
312
+ const sysSources = new Set(["google", "discovered", "preferred", ""]);
313
+ const initSource = sysSources.has(prefill.source || "") ? "" : prefill.source;
314
+ overlay.querySelector("#pf-source").value = initSource;
315
+ const close = () => overlay.remove();
316
+ overlay.querySelector("#pf-cancel").addEventListener("click", close);
317
+ overlay.addEventListener("click", (ev) => { if (ev.target === overlay)
318
+ close(); });
319
+ overlay.querySelector("#pf-save").addEventListener("click", async () => {
320
+ const name = overlay.querySelector("#pf-name").value.trim();
321
+ const email = overlay.querySelector("#pf-email").value.trim();
322
+ const source = overlay.querySelector("#pf-source").value.trim();
323
+ const org = overlay.querySelector("#pf-org").value.trim();
324
+ if (!email) {
325
+ alert("Email is required.");
326
+ return;
327
+ }
328
+ try {
329
+ await addPreferredContact({ name, email, source, organization: org });
330
+ close();
331
+ }
332
+ catch (err) {
333
+ alert(`Failed to save: ${err?.message || err}`);
334
+ }
335
+ });
336
+ overlay.querySelector("#pf-name").focus();
337
+ }
266
338
  function setupAutocomplete(input) {
267
339
  let dropdown = null;
268
340
  let activeIndex = -1;
@@ -318,9 +390,20 @@ function setupAutocomplete(input) {
318
390
  const emailEl = document.createElement("span");
319
391
  emailEl.className = "ac-item-email";
320
392
  emailEl.textContent = results[i].email;
393
+ // Source badge — shows where this row came from. Custom
394
+ // user tags from contacts.jsonc preferred entries
395
+ // (`source: "work"`, `source: "family"`) flow through
396
+ // verbatim; system sources show as 'google' / 'discovered'
397
+ // / 'preferred'. Helps disambiguate two rows with the
398
+ // same email but different names (Bob's wife vs Bob Smith).
399
+ const sourceEl = document.createElement("span");
400
+ sourceEl.className = "ac-item-source";
401
+ sourceEl.textContent = results[i].source || "";
321
402
  item.appendChild(nameEl);
322
403
  if (results[i].name)
323
404
  item.appendChild(emailEl);
405
+ if (results[i].source)
406
+ item.appendChild(sourceEl);
324
407
  item.addEventListener("mousedown", (e) => {
325
408
  e.preventDefault();
326
409
  const display = results[i].name
@@ -328,6 +411,16 @@ function setupAutocomplete(input) {
328
411
  : results[i].email;
329
412
  replaceLastToken(display);
330
413
  });
414
+ // Right-click → contextual actions on this autocomplete
415
+ // row. Two paths: promote to preferred (writes to
416
+ // contacts.jsonc#preferred[]) or denylist (writes to
417
+ // contacts.jsonc#denylist[] and purges any matching
418
+ // discovered rows). Both round-trip through cloudWrite.
419
+ item.addEventListener("contextmenu", (e) => {
420
+ e.preventDefault();
421
+ e.stopPropagation();
422
+ showAutocompleteContextMenu(e, results[i]);
423
+ });
331
424
  dropdown.appendChild(item);
332
425
  }
333
426
  input.parentElement.appendChild(dropdown);
@@ -974,7 +1067,13 @@ function formatSize(n) {
974
1067
  return `${(n / 1024).toFixed(1)} KB`;
975
1068
  return `${(n / (1024 * 1024)).toFixed(1)} MB`;
976
1069
  }
1070
+ /** Set when the user clicks Attach — the native file picker eats the Esc
1071
+ * press, but on Windows WebView2 the keydown can still spill to the page
1072
+ * and trip the document-level Esc-closes-compose handler. While this flag
1073
+ * is set, that handler short-circuits. Cleared shortly after click. */
1074
+ let attachJustClicked = 0;
977
1075
  document.getElementById("btn-attach")?.addEventListener("click", () => {
1076
+ attachJustClicked = Date.now();
978
1077
  fileInput?.click();
979
1078
  });
980
1079
  async function ingestFiles(files) {
@@ -1065,6 +1164,12 @@ document.addEventListener("keydown", (e) => {
1065
1164
  document.getElementById("btn-send")?.click();
1066
1165
  }
1067
1166
  if (e.key === "Escape") {
1167
+ // If the user just clicked Attach, the native file picker is up.
1168
+ // The picker swallows the Esc that dismissed it, but the keydown can
1169
+ // still bubble here on WebView2 — closing the whole compose. Suppress
1170
+ // for a short window after the attach click.
1171
+ if (Date.now() - attachJustClicked < 1500)
1172
+ return;
1068
1173
  e.preventDefault();
1069
1174
  handleCloseRequest();
1070
1175
  }
package/client/index.html CHANGED
@@ -70,7 +70,7 @@
70
70
  <span class="tb-icon">⚡</span><span class="tb-label"> Restart ▾</span>
71
71
  </button>
72
72
  <div class="tb-menu-dropdown" id="restart-dropdown" hidden>
73
- <button class="tb-menu-item" id="btn-restart-quick" title="Reload the page">Reload</button>
73
+ <button class="tb-menu-item" id="btn-restart-quick" title="Restart the mailx daemon and reload the UI — picks up new code after a build">Restart</button>
74
74
  <button class="tb-menu-item" id="btn-update" title="Check for updates and install if available">Check for updates</button>
75
75
  <button class="tb-menu-item" id="btn-rebuild" title="Wipe local DB and message cache, re-download everything. Accounts and settings are preserved. Safe and fast.">Rebuild local cache</button>
76
76
  <hr class="tb-menu-sep">
@@ -206,6 +206,12 @@ export function upsertContact(name, email) {
206
206
  export function deleteContact(email) {
207
207
  return ipc().deleteContact(email);
208
208
  }
209
+ export function addPreferredContact(entry) {
210
+ return ipc().addPreferredContact(entry.name, entry.email, entry.source, entry.organization);
211
+ }
212
+ export function addToDenylist(email) {
213
+ return ipc().addToDenylist(email);
214
+ }
209
215
  export function openLocalPath(which) {
210
216
  return ipc().openLocalPath(which);
211
217
  }
@@ -166,6 +166,15 @@
166
166
  deleteContact: function(email) {
167
167
  return callNode("deleteContact", { email: email });
168
168
  },
169
+ addPreferredContact: function(name, email, source, organization) {
170
+ return callNode("addPreferredContact", {
171
+ name: name || "", email: email,
172
+ source: source || "", organization: organization || "",
173
+ });
174
+ },
175
+ addToDenylist: function(email) {
176
+ return callNode("addToDenylist", { email: email });
177
+ },
169
178
  openLocalPath: function(which) {
170
179
  return callNode("openLocalPath", { which: which });
171
180
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-client",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.430",
3
+ "version": "1.0.433",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -3513,7 +3513,7 @@ export class ImapManager extends EventEmitter {
3513
3513
  * configChanged so the UI can show a "restart to apply" banner. Uses
3514
3514
  * a debounce to coalesce rapid writes from save tools. */
3515
3515
  watchConfigFiles() {
3516
- const files = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc"];
3516
+ const files = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
3517
3517
  const configDir = getConfigDir();
3518
3518
  const debounce = new Map();
3519
3519
  // Cache the last-seen normalized content per file. fs.watch fires on
@@ -3579,7 +3579,7 @@ export class ImapManager extends EventEmitter {
3579
3579
  // compare to local, and write-through on difference. The local
3580
3580
  // fs.watch above then picks up the write and emits configChanged.
3581
3581
  // config.jsonc is per-machine / local-only — never polled.
3582
- const cloudFiles = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc"];
3582
+ const cloudFiles = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "contacts.jsonc"];
3583
3583
  const CLOUD_POLL_MS = 3 * 60 * 1000;
3584
3584
  // normalize() reused from the fs.watch block above — same intent:
3585
3585
  // cloud round-trips that re-wrap newlines / add a trailing newline are
@@ -11,6 +11,25 @@ export declare class MailxService {
11
11
  private imapManager;
12
12
  private _accountsCache;
13
13
  constructor(db: MailxDB, imapManager: ImapManager);
14
+ /** Read contacts.jsonc from cloud + apply preferred/denylist to the DB.
15
+ * No-op if the file is missing or empty. */
16
+ loadContactsConfig(): Promise<{
17
+ preferred: number;
18
+ purged: number;
19
+ conflicts: string[];
20
+ } | null>;
21
+ /** Append an entry to contacts.jsonc#preferred[] and write back to cloud,
22
+ * then re-apply. Mutates the file in place — preserves existing entries
23
+ * and the user's hand-formatting where the parser permits. */
24
+ addPreferredContact(entry: {
25
+ name: string;
26
+ email: string;
27
+ source?: string;
28
+ organization?: string;
29
+ }): Promise<void>;
30
+ /** Append an email to contacts.jsonc#denylist[] and write back to cloud,
31
+ * then re-apply (which purges any matching discovered rows). */
32
+ addToDenylist(email: string): Promise<void>;
14
33
  /** Return accounts from cache — load once, reuse until configChanged. */
15
34
  private getCachedAccounts;
16
35
  getAccounts(): any[];
@@ -112,7 +112,94 @@ export class MailxService {
112
112
  this.imapManager.on?.("configChanged", (filename) => {
113
113
  if (filename === "accounts.jsonc")
114
114
  this._accountsCache = null;
115
+ if (filename === "contacts.jsonc") {
116
+ this.loadContactsConfig().catch(e => console.error(` [contacts] reload failed: ${e?.message || e}`));
117
+ }
115
118
  });
119
+ // Initial load of contacts.jsonc — fire-and-forget; missing file is fine.
120
+ this.loadContactsConfig().catch(() => { });
121
+ }
122
+ /** Read contacts.jsonc from cloud + apply preferred/denylist to the DB.
123
+ * No-op if the file is missing or empty. */
124
+ async loadContactsConfig() {
125
+ let raw = null;
126
+ try {
127
+ const { cloudRead } = await import("@bobfrankston/mailx-settings");
128
+ raw = await cloudRead("contacts.jsonc");
129
+ }
130
+ catch { /* cloud unavailable — leave config empty */ }
131
+ if (!raw) {
132
+ // No file yet. Make sure the in-memory denylist is empty.
133
+ this.db.applyContactsConfig({ preferred: [], denylist: [] });
134
+ return null;
135
+ }
136
+ const { parse: parseJsonc } = await import("jsonc-parser");
137
+ const errors = [];
138
+ const cfg = parseJsonc(raw, errors, { allowTrailingComma: true });
139
+ if (errors.length) {
140
+ console.error(` [contacts] contacts.jsonc has parse errors — applying empty config: ${errors.map((e) => e.error).join(", ")}`);
141
+ this.db.applyContactsConfig({ preferred: [], denylist: [] });
142
+ return null;
143
+ }
144
+ return this.db.applyContactsConfig(cfg || {});
145
+ }
146
+ /** Append an entry to contacts.jsonc#preferred[] and write back to cloud,
147
+ * then re-apply. Mutates the file in place — preserves existing entries
148
+ * and the user's hand-formatting where the parser permits. */
149
+ async addPreferredContact(entry) {
150
+ const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
151
+ const { parse: parseJsonc } = await import("jsonc-parser");
152
+ let cfg = {};
153
+ const raw = await cloudRead("contacts.jsonc");
154
+ if (raw) {
155
+ try {
156
+ cfg = parseJsonc(raw, [], { allowTrailingComma: true }) || {};
157
+ }
158
+ catch {
159
+ cfg = {};
160
+ }
161
+ }
162
+ if (!Array.isArray(cfg.preferred))
163
+ cfg.preferred = [];
164
+ // Dedup: skip if an entry with the same email + name + source already exists.
165
+ const dupKey = `${(entry.source || "preferred").toLowerCase()}|${entry.email.toLowerCase()}|${(entry.name || "").toLowerCase()}`;
166
+ const exists = cfg.preferred.some((e) => `${(e?.source || "preferred").toLowerCase()}|${(e?.email || "").toLowerCase()}|${(e?.name || "").toLowerCase()}` === dupKey);
167
+ if (!exists) {
168
+ const row = { name: entry.name || "", email: entry.email };
169
+ if (entry.source)
170
+ row.source = entry.source;
171
+ if (entry.organization)
172
+ row.organization = entry.organization;
173
+ cfg.preferred.push(row);
174
+ }
175
+ await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
176
+ await this.loadContactsConfig();
177
+ }
178
+ /** Append an email to contacts.jsonc#denylist[] and write back to cloud,
179
+ * then re-apply (which purges any matching discovered rows). */
180
+ async addToDenylist(email) {
181
+ const lower = (email || "").trim().toLowerCase();
182
+ if (!lower)
183
+ return;
184
+ const { cloudRead, cloudWrite } = await import("@bobfrankston/mailx-settings");
185
+ const { parse: parseJsonc } = await import("jsonc-parser");
186
+ let cfg = {};
187
+ const raw = await cloudRead("contacts.jsonc");
188
+ if (raw) {
189
+ try {
190
+ cfg = parseJsonc(raw, [], { allowTrailingComma: true }) || {};
191
+ }
192
+ catch {
193
+ cfg = {};
194
+ }
195
+ }
196
+ if (!Array.isArray(cfg.denylist))
197
+ cfg.denylist = [];
198
+ if (!cfg.denylist.some((e) => (e || "").toLowerCase() === lower)) {
199
+ cfg.denylist.push(email);
200
+ }
201
+ await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
202
+ await this.loadContactsConfig();
116
203
  }
117
204
  /** Return accounts from cache — load once, reuse until configChanged. */
118
205
  getCachedAccounts() {
@@ -1597,7 +1684,7 @@ export class MailxService {
1597
1684
  * Names are whitelisted so the UI can't read arbitrary files.
1598
1685
  * `config.jsonc` is the local per-machine config (not cloud-synced). */
1599
1686
  async readJsoncFile(name) {
1600
- const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc"];
1687
+ const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
1601
1688
  if (!WHITELIST.includes(name))
1602
1689
  throw new Error(`File not allowed: ${name}`);
1603
1690
  if (name === "config.jsonc") {
@@ -1615,7 +1702,7 @@ export class MailxService {
1615
1702
  /** Return the help section for a named config file, extracted from docs/config-help.md.
1616
1703
  * Matches a level-2 heading whose text equals the filename. Returns markdown. */
1617
1704
  async readConfigHelp(name) {
1618
- const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc"];
1705
+ const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
1619
1706
  if (!WHITELIST.includes(name))
1620
1707
  return "";
1621
1708
  // Look in the repo root (dev) and in the installed package dir (production).
@@ -1654,7 +1741,7 @@ export class MailxService {
1654
1741
  /** Write a JSONC config file. Validates that the content parses as JSONC
1655
1742
  * (loosely — strips comments/trailing commas) before writing. */
1656
1743
  async writeJsoncFile(name, content) {
1657
- const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc"];
1744
+ const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
1658
1745
  if (!WHITELIST.includes(name))
1659
1746
  throw new Error(`File not allowed: ${name}`);
1660
1747
  // Validate the content parses before writing
@@ -152,6 +152,14 @@ async function dispatchAction(svc, action, p) {
152
152
  return svc.upsertContact(p.name, p.email);
153
153
  case "deleteContact":
154
154
  return svc.deleteContact(p.email);
155
+ case "addPreferredContact":
156
+ await svc.addPreferredContact({ name: p.name, email: p.email, source: p.source, organization: p.organization });
157
+ return { ok: true };
158
+ case "addToDenylist":
159
+ await svc.addToDenylist(p.email);
160
+ return { ok: true };
161
+ case "loadContactsConfig":
162
+ return { result: await svc.loadContactsConfig() };
155
163
  case "openLocalPath":
156
164
  return await svc.openLocalPath(p.which);
157
165
  case "getThreadMessages":
@@ -196,20 +196,53 @@ export declare class MailxDB {
196
196
  rollbackTransaction(): void;
197
197
  /** Record an address used in sent mail */
198
198
  recordSentAddress(name: string, email: string): void;
199
- /** Seed contacts from every address that appears in any cached message
200
- * From, To, Cc, Bcc across all folders. The original implementation
201
- * only scanned `from_address`, so addresses you'd sent to (To/Cc on
202
- * outgoing mail) never made it into autocomplete unless they had also
203
- * written back to you. That left compose unable to suggest people you
204
- * email regularly but who never reply. This pass walks the JSON arrays
205
- * too. Dedup by lowercased email; per-address `use_count` is the total
206
- * occurrences across the corpus, `last_used` is the most recent message
207
- * date in any field. Existing rows have their counters bumped (instead of
208
- * the old skip-if-exists behavior) so periodic re-seeds keep recency
209
- * fresh — `received`-tagged rows get usage data from observed traffic;
210
- * `sent` and `google` rows are left alone (they're authoritative). */
199
+ /** True if `email` (lowercased) appears in the active denylist. Cached
200
+ * in-memory; refreshed on contacts.jsonc reload via setContactsDenylist. */
201
+ private _denylist;
202
+ isAddressDenylisted(emailLower: string): boolean;
203
+ setContactsDenylist(emails: string[]): void;
204
+ /** Seed `discovered`-tier contacts from every address that appears in
205
+ * any cached message From / To / Cc / Bcc across all folders. One row
206
+ * per email; first non-empty name observed wins. Sent-folder rows skip
207
+ * the From (it's us). Junk addresses (noreply, mailer-daemon, *-bounces)
208
+ * and denylisted addresses are dropped at seed time so they never enter
209
+ * autocomplete.
210
+ *
211
+ * Discovered is a single tier; sub-distinctions like sent-vs-received
212
+ * collapse here because the user-facing UI shows them as one "discovered"
213
+ * source. Recency-weighted use_count differentiates within the tier. */
211
214
  seedContactsFromMessages(): number;
212
- /** Search contacts by name or email prefix */
215
+ /** Apply the contents of contacts.jsonc replaces all preferred-tier rows
216
+ * with the entries in `preferred[]`, sets the in-memory denylist, and
217
+ * purges any discovered rows whose email is now denylisted. Preferred
218
+ * rows are *not* auto-purged on denylist hit — if the user explicitly
219
+ * added them they win that conflict; we just log a warning. */
220
+ applyContactsConfig(cfg: {
221
+ preferred?: {
222
+ name?: string;
223
+ email: string;
224
+ source?: string;
225
+ organization?: string;
226
+ org?: string;
227
+ }[];
228
+ denylist?: string[];
229
+ }): {
230
+ preferred: number;
231
+ purged: number;
232
+ conflicts: string[];
233
+ };
234
+ /** Search contacts by name or email prefix.
235
+ *
236
+ * Source-tier bonus is what makes the curated address book win against
237
+ * passive corpus harvest. Anything in `contacts.jsonc#preferred[]` (any
238
+ * source value other than the two reserved system sources) gets the
239
+ * highest tier — that's the user's explicit address book and overrides
240
+ * Google. Google sits in the middle (the auto-synced address book).
241
+ * `discovered` is the corpus-harvested floor.
242
+ *
243
+ * Multi-name-per-email is supported: the same email can carry distinct
244
+ * (source, name) rows — Bob's wife and Bob Smith both at bob@example.com
245
+ * surface as two rows, each typing-completable by their own name. */
213
246
  searchContacts(query: string, limit?: number): {
214
247
  name: string;
215
248
  email: string;
@@ -7,6 +7,27 @@ import { DatabaseSync } from "node:sqlite";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import * as path from "node:path";
9
9
  import * as fs from "node:fs";
10
+ /** Addresses that have no business in autocomplete. Standard automated
11
+ * sender patterns plus name-side hints ("MAILER-DAEMON" etc.). The exact
12
+ * match list keeps surprise low; the regex catches the long tail of
13
+ * *-bounces@, no-reply variants, and listserv-style addresses. */
14
+ const JUNK_LOCAL_RE = /^(no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|abuse|automated|bounce(s|d)?|list-?(server|admin|owner|manager)?|notification|notifications?|admin@.*automated|root|daemon|nobody|undisclosed)$/i;
15
+ const JUNK_LOCAL_SUFFIX_RE = /(-bounces|\+bounces|-noreply|-no-reply|-notifications?|-mailer)$/i;
16
+ function isJunkContact(email, name) {
17
+ const local = email.split("@")[0] || "";
18
+ if (JUNK_LOCAL_RE.test(local))
19
+ return true;
20
+ if (JUNK_LOCAL_SUFFIX_RE.test(local))
21
+ return true;
22
+ // Bare numeric / hex addresses (rotating IDs from automated systems)
23
+ // — three or fewer chars is too short to be useful regardless.
24
+ if (local.length < 2)
25
+ return true;
26
+ const lname = (name || "").trim().toLowerCase();
27
+ if (lname.includes("mailer-daemon") || lname.includes("postmaster"))
28
+ return true;
29
+ return false;
30
+ }
10
31
  const SCHEMA = `
11
32
  CREATE TABLE IF NOT EXISTS accounts (
12
33
  id TEXT PRIMARY KEY,
@@ -94,7 +115,7 @@ const SCHEMA = `
94
115
 
95
116
  CREATE TABLE IF NOT EXISTS contacts (
96
117
  id INTEGER PRIMARY KEY AUTOINCREMENT,
97
- source TEXT NOT NULL DEFAULT 'sent',
118
+ source TEXT NOT NULL DEFAULT 'discovered',
98
119
  google_id TEXT,
99
120
  name TEXT DEFAULT '',
100
121
  email TEXT NOT NULL,
@@ -102,7 +123,7 @@ const SCHEMA = `
102
123
  last_used INTEGER DEFAULT 0,
103
124
  use_count INTEGER DEFAULT 0,
104
125
  updated_at INTEGER NOT NULL,
105
- UNIQUE(email)
126
+ UNIQUE(source, email, name)
106
127
  );
107
128
 
108
129
  CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
@@ -275,6 +296,41 @@ export class MailxDB {
275
296
  // this column landed. One UPDATE + an id roundtrip per row — cheap
276
297
  // at our row counts, runs once per DB upgrade.
277
298
  this.backfillUuids();
299
+ // One-shot contacts table reset: the contacts schema's UNIQUE constraint
300
+ // was widened from `(email)` to `(source, email, name)` so the same
301
+ // address can carry multiple distinct (name, source) entries — Bob's
302
+ // wife at bob@example.com and a separate `Bob Smith <bob@example.com>`
303
+ // for work, both legitimate. Old rows with the email-only unique key
304
+ // would block the new inserts. Per user "don't migrate, start fresh":
305
+ // drop the old table, recreate it via SCHEMA, reseed from messages on
306
+ // next sync. Gated by a kv flag so we run exactly once per machine.
307
+ const contactsResetFlag = this.getKv("schema", "contacts_v2");
308
+ if (!contactsResetFlag) {
309
+ try {
310
+ this.db.exec("DROP TABLE IF EXISTS contacts");
311
+ this.db.exec(`
312
+ CREATE TABLE contacts (
313
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
314
+ source TEXT NOT NULL DEFAULT 'discovered',
315
+ google_id TEXT,
316
+ name TEXT DEFAULT '',
317
+ email TEXT NOT NULL,
318
+ organization TEXT DEFAULT '',
319
+ last_used INTEGER DEFAULT 0,
320
+ use_count INTEGER DEFAULT 0,
321
+ updated_at INTEGER NOT NULL,
322
+ UNIQUE(source, email, name)
323
+ );
324
+ CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
325
+ CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
326
+ `);
327
+ this.setKv("schema", "contacts_v2", String(Date.now()));
328
+ console.log(" [db] contacts table reset to v2 schema (multi-name-per-email)");
329
+ }
330
+ catch (e) {
331
+ console.error(` [db] contacts v2 reset failed: ${e.message}`);
332
+ }
333
+ }
278
334
  // Post-migration sanity check: verify the columns we actually read in
279
335
  // SELECTs exist. If any migration silently failed (stale driver, DB
280
336
  // file locked, permission error), later code would throw cryptic
@@ -1084,42 +1140,55 @@ export class MailxDB {
1084
1140
  // ── Contacts ──
1085
1141
  /** Record an address used in sent mail */
1086
1142
  recordSentAddress(name, email) {
1087
- // Don't pollute the contacts table with non-addresses. Anything without
1088
- // an @ or without a TLD-ish tail would show up in autocomplete and end
1089
- // up back in To/Cc headers as "Name <not an email>".
1143
+ // Don't pollute the contacts table with non-addresses.
1090
1144
  if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
1091
1145
  return;
1146
+ const lower = email.toLowerCase();
1147
+ if (this.isAddressDenylisted(lower))
1148
+ return;
1092
1149
  const now = Date.now();
1093
- const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(email);
1150
+ // discovered tier holds one row per email bump if present, else
1151
+ // insert. Doesn't touch preferred or google rows for the same email;
1152
+ // those are independent address-book entries the user/Google curates.
1153
+ const existing = this.db.prepare("SELECT id, name FROM contacts WHERE source = 'discovered' AND lower(email) = ?").get(lower);
1094
1154
  if (existing) {
1095
- this.db.prepare("UPDATE contacts SET name = CASE WHEN ? != '' THEN ? ELSE name END, last_used = ?, use_count = use_count + 1, updated_at = ? WHERE email = ?").run(name, name, now, now, email);
1155
+ this.db.prepare("UPDATE contacts SET name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END, last_used = ?, use_count = use_count + 1, updated_at = ? WHERE id = ?").run(name, name, now, now, existing.id);
1096
1156
  }
1097
1157
  else {
1098
- this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('sent', ?, ?, ?, 1, ?)").run(name, email, now, now);
1158
+ this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, 1, ?)").run(name || "", email, now, now);
1099
1159
  }
1100
1160
  }
1101
- /** Seed contacts from every address that appears in any cached message
1102
- * From, To, Cc, Bcc across all folders. The original implementation
1103
- * only scanned `from_address`, so addresses you'd sent to (To/Cc on
1104
- * outgoing mail) never made it into autocomplete unless they had also
1105
- * written back to you. That left compose unable to suggest people you
1106
- * email regularly but who never reply. This pass walks the JSON arrays
1107
- * too. Dedup by lowercased email; per-address `use_count` is the total
1108
- * occurrences across the corpus, `last_used` is the most recent message
1109
- * date in any field. Existing rows have their counters bumped (instead of
1110
- * the old skip-if-exists behavior) so periodic re-seeds keep recency
1111
- * fresh`received`-tagged rows get usage data from observed traffic;
1112
- * `sent` and `google` rows are left alone (they're authoritative). */
1161
+ /** True if `email` (lowercased) appears in the active denylist. Cached
1162
+ * in-memory; refreshed on contacts.jsonc reload via setContactsDenylist. */
1163
+ _denylist = new Set();
1164
+ isAddressDenylisted(emailLower) {
1165
+ return this._denylist.has(emailLower);
1166
+ }
1167
+ setContactsDenylist(emails) {
1168
+ this._denylist = new Set(emails.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
1169
+ }
1170
+ /** Seed `discovered`-tier contacts from every address that appears in
1171
+ * any cached message From / To / Cc / Bcc across all folders. One row
1172
+ * per email; first non-empty name observed wins. Sent-folder rows skip
1173
+ * the From (it's us). Junk addresses (noreply, mailer-daemon, *-bounces)
1174
+ * and denylisted addresses are dropped at seed time so they never enter
1175
+ * autocomplete.
1176
+ *
1177
+ * Discovered is a single tier; sub-distinctions like sent-vs-received
1178
+ * collapse here because the user-facing UI shows them as one "discovered"
1179
+ * source. Recency-weighted use_count differentiates within the tier. */
1113
1180
  seedContactsFromMessages() {
1114
1181
  const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
1115
1182
  const now = Date.now();
1116
- // Per-email aggregator. `name` keeps the first non-empty display name
1117
- // we see so autocomplete shows "Alice <alice@…>" not just the address.
1118
1183
  const agg = new Map();
1119
1184
  const bump = (name, address, date) => {
1120
1185
  const email = (address || "").trim().toLowerCase();
1121
1186
  if (!email || !VALID.test(email))
1122
1187
  return;
1188
+ if (isJunkContact(email, name))
1189
+ return;
1190
+ if (this.isAddressDenylisted(email))
1191
+ return;
1123
1192
  const e = agg.get(email);
1124
1193
  if (e) {
1125
1194
  e.cnt++;
@@ -1132,8 +1201,38 @@ export class MailxDB {
1132
1201
  agg.set(email, { name: name || "", cnt: 1, last: date || 0 });
1133
1202
  }
1134
1203
  };
1135
- const rows = this.db.prepare("SELECT from_name, from_address, to_json, cc_json, bcc_json, date FROM messages").all();
1136
- for (const r of rows) {
1204
+ // Sent folder: recipients only (skip the user's own From address).
1205
+ const sentRows = this.db.prepare(`SELECT m.to_json, m.cc_json, m.bcc_json, m.date
1206
+ FROM messages m
1207
+ JOIN folders f ON m.folder_id = f.id
1208
+ WHERE f.special_use = 'sent'`).all();
1209
+ for (const r of sentRows) {
1210
+ const date = r.date || 0;
1211
+ for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
1212
+ if (!field)
1213
+ continue;
1214
+ let parsed;
1215
+ try {
1216
+ parsed = JSON.parse(field);
1217
+ }
1218
+ catch {
1219
+ continue;
1220
+ }
1221
+ if (!Array.isArray(parsed))
1222
+ continue;
1223
+ for (const a of parsed) {
1224
+ if (!a)
1225
+ continue;
1226
+ bump(a.name || "", a.address || a.email || "", date);
1227
+ }
1228
+ }
1229
+ }
1230
+ // Other folders: From + recipients.
1231
+ const recvRows = this.db.prepare(`SELECT m.from_name, m.from_address, m.to_json, m.cc_json, m.bcc_json, m.date
1232
+ FROM messages m
1233
+ LEFT JOIN folders f ON m.folder_id = f.id
1234
+ WHERE f.special_use IS NULL OR f.special_use != 'sent'`).all();
1235
+ for (const r of recvRows) {
1137
1236
  const date = r.date || 0;
1138
1237
  bump(r.from_name, r.from_address, date);
1139
1238
  for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
@@ -1157,54 +1256,123 @@ export class MailxDB {
1157
1256
  }
1158
1257
  let added = 0;
1159
1258
  let bumped = 0;
1160
- const insStmt = this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)");
1161
- const updStmt = this.db.prepare("UPDATE contacts SET use_count = ?, last_used = max(last_used, ?), name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END, updated_at = ? WHERE email = ? AND source = 'received'");
1259
+ const insStmt = this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('discovered', ?, ?, ?, ?, ?)");
1260
+ const updStmt = this.db.prepare(`UPDATE contacts SET use_count = ?,
1261
+ last_used = max(last_used, ?),
1262
+ name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END,
1263
+ updated_at = ?
1264
+ WHERE id = ?`);
1162
1265
  for (const [email, info] of agg) {
1163
- const existing = this.db.prepare("SELECT id, source FROM contacts WHERE email = ?").get(email);
1266
+ const existing = this.db.prepare("SELECT id FROM contacts WHERE source = 'discovered' AND lower(email) = ?").get(email);
1164
1267
  if (!existing) {
1165
1268
  insStmt.run(info.name, email, info.last, info.cnt, now);
1166
1269
  added++;
1167
1270
  }
1168
- else if (existing.source === "received") {
1169
- updStmt.run(info.cnt, info.last, info.name, info.name, now, email);
1271
+ else {
1272
+ updStmt.run(info.cnt, info.last, info.name, info.name, now, existing.id);
1170
1273
  bumped++;
1171
1274
  }
1172
- // 'sent' and 'google' rows are authoritative — leave their
1173
- // use_count alone. recordSentAddress bumps 'sent' rows on actual
1174
- // sends, syncGoogleContacts owns 'google' rows.
1175
1275
  }
1176
- if (bumped > 0)
1177
- console.log(` [contacts] seed: ${added} new + ${bumped} refreshed`);
1276
+ if (added > 0 || bumped > 0) {
1277
+ console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (discovered)`);
1278
+ }
1178
1279
  return added;
1179
1280
  }
1180
- /** Search contacts by name or email prefix */
1281
+ /** Apply the contents of contacts.jsonc replaces all preferred-tier rows
1282
+ * with the entries in `preferred[]`, sets the in-memory denylist, and
1283
+ * purges any discovered rows whose email is now denylisted. Preferred
1284
+ * rows are *not* auto-purged on denylist hit — if the user explicitly
1285
+ * added them they win that conflict; we just log a warning. */
1286
+ applyContactsConfig(cfg) {
1287
+ const preferred = Array.isArray(cfg.preferred) ? cfg.preferred : [];
1288
+ const denylist = Array.isArray(cfg.denylist) ? cfg.denylist : [];
1289
+ this.setContactsDenylist(denylist);
1290
+ // Wipe and rewrite preferred-tier rows owned by contacts.jsonc.
1291
+ // The address-book UI's legacy `upsertContact` still writes
1292
+ // source='manual' rows; those are owned by the address-book code
1293
+ // path, not contacts.jsonc, so we leave them alone here.
1294
+ this.db.exec("DELETE FROM contacts WHERE source NOT IN ('google', 'discovered', 'manual')");
1295
+ const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
1296
+ const now = Date.now();
1297
+ const ins = this.db.prepare(`INSERT OR IGNORE INTO contacts (source, name, email, organization, last_used, use_count, updated_at)
1298
+ VALUES (?, ?, ?, ?, 0, 0, ?)`);
1299
+ const denySet = new Set(denylist.map(e => (e || "").trim().toLowerCase()).filter(Boolean));
1300
+ const conflicts = [];
1301
+ let inserted = 0;
1302
+ for (const entry of preferred) {
1303
+ if (!entry)
1304
+ continue;
1305
+ const email = (entry.email || "").trim();
1306
+ if (!email || !VALID.test(email))
1307
+ continue;
1308
+ if (denySet.has(email.toLowerCase())) {
1309
+ conflicts.push(email);
1310
+ continue;
1311
+ }
1312
+ const source = (entry.source || "preferred").trim() || "preferred";
1313
+ const name = (entry.name || "").trim();
1314
+ const org = (entry.organization || entry.org || "").trim();
1315
+ try {
1316
+ const r = ins.run(source, name, email, org, now);
1317
+ if (r.changes)
1318
+ inserted++;
1319
+ }
1320
+ catch { /* dup row, skip */ }
1321
+ }
1322
+ // Purge discovered rows for any denylisted email.
1323
+ const purge = this.db.prepare("DELETE FROM contacts WHERE source = 'discovered' AND lower(email) = ?");
1324
+ let purged = 0;
1325
+ for (const e of denySet) {
1326
+ const r = purge.run(e);
1327
+ purged += Number(r.changes || 0);
1328
+ }
1329
+ if (conflicts.length > 0) {
1330
+ console.warn(` [contacts] config: ${conflicts.length} preferred entries also appear in denylist — denylist wins, entries skipped: ${conflicts.join(", ")}`);
1331
+ }
1332
+ console.log(` [contacts] config applied: ${inserted} preferred row(s), ${denySet.size} denylisted, ${purged} discovered row(s) purged`);
1333
+ return { preferred: inserted, purged, conflicts };
1334
+ }
1335
+ /** Search contacts by name or email prefix.
1336
+ *
1337
+ * Source-tier bonus is what makes the curated address book win against
1338
+ * passive corpus harvest. Anything in `contacts.jsonc#preferred[]` (any
1339
+ * source value other than the two reserved system sources) gets the
1340
+ * highest tier — that's the user's explicit address book and overrides
1341
+ * Google. Google sits in the middle (the auto-synced address book).
1342
+ * `discovered` is the corpus-harvested floor.
1343
+ *
1344
+ * Multi-name-per-email is supported: the same email can carry distinct
1345
+ * (source, name) rows — Bob's wife and Bob Smith both at bob@example.com
1346
+ * surface as two rows, each typing-completable by their own name. */
1181
1347
  searchContacts(query, limit = 10) {
1182
1348
  query = (query || "").trim();
1183
1349
  if (!query)
1184
1350
  return [];
1185
- // Ranking: prefix matches beat substring matches, then recency-weighted
1186
- // use_count within a tier. Recency decay: half-life of 30 days, so a
1187
- // contact used today edges out one from months ago even with a lower
1188
- // raw use_count. Computed in JS since SQLite lacks exp/log.
1189
- //
1190
- // Wrapped in try/catch + simple-query fallback so a (hypothetical) SQL
1191
- // edge case on exotic input can never leave autocomplete showing blank.
1192
- // The rank-0 baseline is identical behavior to the original query.
1193
1351
  const substr = `%${query}%`;
1194
1352
  let rows;
1195
1353
  try {
1196
1354
  const prefixQ = `${query}%`;
1355
+ // Source tier: anything not in the two reserved system sources
1356
+ // ('google', 'discovered') is preferred-tier — i.e. came out of
1357
+ // contacts.jsonc#preferred[]. The user's `source: "work"` /
1358
+ // `source: "family"` tags all rank +40 alongside the default
1359
+ // `preferred` label.
1197
1360
  rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
1198
1361
  (CASE
1199
1362
  WHEN lower(name) LIKE lower(?) THEN 3
1200
1363
  WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
1201
1364
  WHEN email LIKE ? OR name LIKE ? THEN 1
1202
1365
  ELSE 0
1366
+ END) +
1367
+ (CASE
1368
+ WHEN source = 'google' THEN 30
1369
+ WHEN source = 'discovered' THEN 0
1370
+ ELSE 40
1203
1371
  END) AS match_rank
1204
1372
  FROM contacts
1205
1373
  WHERE email LIKE ? OR name LIKE ?
1206
1374
  ORDER BY match_rank DESC, use_count DESC, last_used DESC
1207
- LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit);
1375
+ LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit * 2);
1208
1376
  }
1209
1377
  catch (e) {
1210
1378
  console.error(` [searchContacts] ranked query failed (${e?.message}) — falling back to simple LIKE`);
@@ -1212,13 +1380,18 @@ export class MailxDB {
1212
1380
  FROM contacts
1213
1381
  WHERE email LIKE ? OR name LIKE ?
1214
1382
  ORDER BY use_count DESC, last_used DESC
1215
- LIMIT ?`).all(substr, substr, limit);
1383
+ LIMIT ?`).all(substr, substr, limit * 2);
1216
1384
  }
1385
+ // Filter out denylisted emails as a defense-in-depth — applyContactsConfig
1386
+ // already purges discovered rows on denylist, but a Google sync that
1387
+ // reintroduced a denylisted address would otherwise leak through.
1388
+ rows = rows.filter(r => !this.isAddressDenylisted((r.email || "").toLowerCase()));
1217
1389
  const now = Date.now();
1218
1390
  const HALF_LIFE_MS = 30 * 86400_000;
1219
1391
  const score = (r) => (r.match_rank || 0) * 10_000
1220
1392
  + (r.use_count || 0) * Math.pow(0.5, Math.max(0, now - (r.last_used || 0)) / HALF_LIFE_MS);
1221
1393
  rows.sort((a, b) => score(b) - score(a));
1394
+ rows = rows.slice(0, limit);
1222
1395
  return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
1223
1396
  }
1224
1397
  /** List all contacts (address-book view) with pagination + optional filter. */
package/unwedge.cmd CHANGED
@@ -1 +1 @@
1
- rmdir C:\Users\Bob\.claude\session-env\5a053d1d-7856-4e74-9b2b-23a4e3262aed /s /q
1
+ rmdir C:\Users\Bob\.claude\session-env\7299facd-d726-4f8a-8e7a-dbd852680c95 /s /q