@bobfrankston/mailx 1.0.432 → 1.0.434

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/client/app.js CHANGED
@@ -1238,6 +1238,24 @@ searchInput?.addEventListener("keydown", (e) => {
1238
1238
  setTitle("mailx");
1239
1239
  }
1240
1240
  });
1241
+ // Re-run the active search when the scope dropdown or "server too" checkbox
1242
+ // flips. Without this, switching all/current/server after typing the query
1243
+ // left the old result set on screen — the controls looked like they did
1244
+ // nothing. Treat the change as `immediate=true` so the user sees the new
1245
+ // scope's results without having to retype Enter; clear any client-side
1246
+ // filter-hidden flags from the prior "current folder" path so the row set
1247
+ // resets cleanly.
1248
+ function rerunActiveSearch() {
1249
+ if (!searchInput || searchInput.value.trim() === "")
1250
+ return;
1251
+ const body = document.getElementById("ml-body");
1252
+ if (body)
1253
+ body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
1254
+ clearTimeout(searchTimeout);
1255
+ doSearch(true);
1256
+ }
1257
+ searchScope?.addEventListener("change", rerunActiveSearch);
1258
+ document.getElementById("search-server-too")?.addEventListener("change", rerunActiveSearch);
1241
1259
  // Message state handles move/delete — no manual event listener needed
1242
1260
  // ── Folder filter ──
1243
1261
  const ftFilterInput = document.getElementById("ft-filter-input");
@@ -160,6 +160,19 @@ function selectRange(from, to) {
160
160
  for (let i = lo; i <= hi; i++)
161
161
  rows[i].classList.add("selected");
162
162
  }
163
+ /** The row to anchor a shift-click range against. `lastClickedRow` is the
164
+ * primary anchor, but it can become a detached DOM node after a list
165
+ * re-render (folder switch, sort, search reload, paging) — `selectRange`
166
+ * would then no-op. Fall back to whichever live row is `.selected` (the
167
+ * one in the viewer) before giving up. */
168
+ function resolveShiftAnchor() {
169
+ if (lastClickedRow?.isConnected)
170
+ return lastClickedRow;
171
+ const body = document.getElementById("ml-body");
172
+ if (!body)
173
+ return null;
174
+ return body.querySelector(".ml-row.selected");
175
+ }
163
176
  const timeFmt = { hour: "2-digit", minute: "2-digit", hour12: false };
164
177
  const dateFmt = { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false };
165
178
  const dateFmtSameYear = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false };
@@ -789,12 +802,25 @@ function appendMessages(body, accountId, items) {
789
802
  updateBulkBar();
790
803
  return;
791
804
  }
792
- if (e.shiftKey && lastClickedRow) {
793
- clearSelection();
794
- selectRange(lastClickedRow, row);
795
- lastClickedRow = row;
796
- row.classList.remove("unread");
797
- focusMessage(msgAccountId, msg);
805
+ if (e.shiftKey) {
806
+ const anchor = resolveShiftAnchor();
807
+ if (anchor) {
808
+ clearSelection();
809
+ selectRange(anchor, row);
810
+ lastClickedRow = row;
811
+ row.classList.remove("unread");
812
+ focusMessage(msgAccountId, msg);
813
+ }
814
+ else {
815
+ // No anchor available (first click of the session, or list
816
+ // was just rebuilt with no selection). Treat as a plain
817
+ // click so the user gets visible feedback rather than a
818
+ // silent no-op.
819
+ clearSelection();
820
+ focusRow(row, msgAccountId, msg);
821
+ lastClickedRow = row;
822
+ row.classList.remove("unread");
823
+ }
798
824
  }
799
825
  else if (e.ctrlKey || e.metaKey) {
800
826
  row.classList.toggle("selected");
@@ -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);
@@ -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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.432",
3
+ "version": "1.0.434",
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
- * with two distinctions the prior version missed:
201
- * 1. Sent-folder messages: harvest only To/Cc/Bcc (the user's *own*
202
- * From-address gets skipped — no point pre-filling our own
203
- * address into autocomplete). These are tagged `source='sent'`.
204
- * 2. Other-folder messages: harvest From + To + Cc + Bcc, tagged
205
- * `source='received'`. Lower priority than 'sent' or 'google'
206
- * in searchContacts ranking.
207
- * Sent wins on insert conflict, so a person you've corresponded with
208
- * ends up tagged 'sent' (more authoritative). Junk addresses (noreply,
209
- * mailer-daemon, postmaster, *-bounces) are dropped at seed time so
210
- * they never even enter autocomplete. */
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;
@@ -115,7 +115,7 @@ const SCHEMA = `
115
115
 
116
116
  CREATE TABLE IF NOT EXISTS contacts (
117
117
  id INTEGER PRIMARY KEY AUTOINCREMENT,
118
- source TEXT NOT NULL DEFAULT 'sent',
118
+ source TEXT NOT NULL DEFAULT 'discovered',
119
119
  google_id TEXT,
120
120
  name TEXT DEFAULT '',
121
121
  email TEXT NOT NULL,
@@ -123,7 +123,7 @@ const SCHEMA = `
123
123
  last_used INTEGER DEFAULT 0,
124
124
  use_count INTEGER DEFAULT 0,
125
125
  updated_at INTEGER NOT NULL,
126
- UNIQUE(email)
126
+ UNIQUE(source, email, name)
127
127
  );
128
128
 
129
129
  CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
@@ -296,6 +296,41 @@ export class MailxDB {
296
296
  // this column landed. One UPDATE + an id roundtrip per row — cheap
297
297
  // at our row counts, runs once per DB upgrade.
298
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
+ }
299
334
  // Post-migration sanity check: verify the columns we actually read in
300
335
  // SELECTs exist. If any migration silently failed (stale driver, DB
301
336
  // file locked, permission error), later code would throw cryptic
@@ -1105,45 +1140,55 @@ export class MailxDB {
1105
1140
  // ── Contacts ──
1106
1141
  /** Record an address used in sent mail */
1107
1142
  recordSentAddress(name, email) {
1108
- // Don't pollute the contacts table with non-addresses. Anything without
1109
- // an @ or without a TLD-ish tail would show up in autocomplete and end
1110
- // up back in To/Cc headers as "Name <not an email>".
1143
+ // Don't pollute the contacts table with non-addresses.
1111
1144
  if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
1112
1145
  return;
1146
+ const lower = email.toLowerCase();
1147
+ if (this.isAddressDenylisted(lower))
1148
+ return;
1113
1149
  const now = Date.now();
1114
- 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);
1115
1154
  if (existing) {
1116
- 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);
1117
1156
  }
1118
1157
  else {
1119
- 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);
1120
1159
  }
1121
1160
  }
1122
- /** Seed contacts from every address that appears in any cached message,
1123
- * with two distinctions the prior version missed:
1124
- * 1. Sent-folder messages: harvest only To/Cc/Bcc (the user's *own*
1125
- * From-address gets skipped — no point pre-filling our own
1126
- * address into autocomplete). These are tagged `source='sent'`.
1127
- * 2. Other-folder messages: harvest From + To + Cc + Bcc, tagged
1128
- * `source='received'`. Lower priority than 'sent' or 'google'
1129
- * in searchContacts ranking.
1130
- * Sent wins on insert conflict, so a person you've corresponded with
1131
- * ends up tagged 'sent' (more authoritative). Junk addresses (noreply,
1132
- * mailer-daemon, postmaster, *-bounces) are dropped at seed time so
1133
- * they never even enter autocomplete. */
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. */
1134
1180
  seedContactsFromMessages() {
1135
1181
  const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
1136
1182
  const now = Date.now();
1137
- // Aggregator keyed by lowercased email. `tag` is 'sent' or 'received'
1138
- // — sent wins on conflict because it implies the user wrote to that
1139
- // address (more authoritative than passive observation).
1140
1183
  const agg = new Map();
1141
- const bump = (name, address, date, tag) => {
1184
+ const bump = (name, address, date) => {
1142
1185
  const email = (address || "").trim().toLowerCase();
1143
1186
  if (!email || !VALID.test(email))
1144
1187
  return;
1145
1188
  if (isJunkContact(email, name))
1146
1189
  return;
1190
+ if (this.isAddressDenylisted(email))
1191
+ return;
1147
1192
  const e = agg.get(email);
1148
1193
  if (e) {
1149
1194
  e.cnt++;
@@ -1151,14 +1196,12 @@ export class MailxDB {
1151
1196
  e.last = date;
1152
1197
  if (!e.name && name)
1153
1198
  e.name = name;
1154
- if (tag === "sent")
1155
- e.tag = "sent"; // upgrade
1156
1199
  }
1157
1200
  else {
1158
- agg.set(email, { name: name || "", cnt: 1, last: date || 0, tag });
1201
+ agg.set(email, { name: name || "", cnt: 1, last: date || 0 });
1159
1202
  }
1160
1203
  };
1161
- // Sent folder rows: skip the From (it's us), harvest the recipients.
1204
+ // Sent folder: recipients only (skip the user's own From address).
1162
1205
  const sentRows = this.db.prepare(`SELECT m.to_json, m.cc_json, m.bcc_json, m.date
1163
1206
  FROM messages m
1164
1207
  JOIN folders f ON m.folder_id = f.id
@@ -1180,18 +1223,18 @@ export class MailxDB {
1180
1223
  for (const a of parsed) {
1181
1224
  if (!a)
1182
1225
  continue;
1183
- bump(a.name || "", a.address || a.email || "", date, "sent");
1226
+ bump(a.name || "", a.address || a.email || "", date);
1184
1227
  }
1185
1228
  }
1186
1229
  }
1187
- // Other folders: harvest everything.
1230
+ // Other folders: From + recipients.
1188
1231
  const recvRows = this.db.prepare(`SELECT m.from_name, m.from_address, m.to_json, m.cc_json, m.bcc_json, m.date
1189
1232
  FROM messages m
1190
1233
  LEFT JOIN folders f ON m.folder_id = f.id
1191
1234
  WHERE f.special_use IS NULL OR f.special_use != 'sent'`).all();
1192
1235
  for (const r of recvRows) {
1193
1236
  const date = r.date || 0;
1194
- bump(r.from_name, r.from_address, date, "received");
1237
+ bump(r.from_name, r.from_address, date);
1195
1238
  for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
1196
1239
  if (!field)
1197
1240
  continue;
@@ -1207,74 +1250,113 @@ export class MailxDB {
1207
1250
  for (const a of parsed) {
1208
1251
  if (!a)
1209
1252
  continue;
1210
- bump(a.name || "", a.address || a.email || "", date, "received");
1253
+ bump(a.name || "", a.address || a.email || "", date);
1211
1254
  }
1212
1255
  }
1213
1256
  }
1214
1257
  let added = 0;
1215
1258
  let bumped = 0;
1216
- let upgraded = 0;
1217
- const insStmt = this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES (?, ?, ?, ?, ?, ?)");
1218
- // Refresh seed-derived rows ('sent' or 'received') with fresh counters.
1219
- // Allow upgrading a 'received' row to 'sent' if the seed now sees it
1220
- // in the Sent folder. 'google' rows stay untouched — they're owned
1221
- // by syncGoogleContacts and carry curated names/orgs.
1222
- const updStmt = this.db.prepare(`UPDATE contacts SET source = ?, use_count = ?,
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 = ?,
1223
1261
  last_used = max(last_used, ?),
1224
1262
  name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END,
1225
1263
  updated_at = ?
1226
- WHERE email = ? AND source IN ('sent', 'received')`);
1264
+ WHERE id = ?`);
1227
1265
  for (const [email, info] of agg) {
1228
- 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);
1229
1267
  if (!existing) {
1230
- insStmt.run(info.tag, info.name, email, info.last, info.cnt, now);
1268
+ insStmt.run(info.name, email, info.last, info.cnt, now);
1231
1269
  added++;
1232
1270
  }
1233
- else if (existing.source === "google") {
1234
- // Don't overwrite curated address-book rows; recordSentAddress
1235
- // already bumps their use_count when actual sends happen.
1236
- }
1237
1271
  else {
1238
- if (existing.source !== info.tag && info.tag === "sent")
1239
- upgraded++;
1240
- updStmt.run(info.tag, info.cnt, info.last, info.name, info.name, now, email);
1272
+ updStmt.run(info.cnt, info.last, info.name, info.name, now, existing.id);
1241
1273
  bumped++;
1242
1274
  }
1243
1275
  }
1244
- if (added > 0 || upgraded > 0) {
1245
- console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (${upgraded} upgraded received→sent)`);
1276
+ if (added > 0 || bumped > 0) {
1277
+ console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (discovered)`);
1246
1278
  }
1247
1279
  return added;
1248
1280
  }
1249
- /** 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. */
1250
1347
  searchContacts(query, limit = 10) {
1251
1348
  query = (query || "").trim();
1252
1349
  if (!query)
1253
1350
  return [];
1254
- // Ranking: prefix matches beat substring matches, then recency-weighted
1255
- // use_count within a tier. Recency decay: half-life of 30 days, so a
1256
- // contact used today edges out one from months ago even with a lower
1257
- // raw use_count. Computed in JS since SQLite lacks exp/log.
1258
- //
1259
- // Wrapped in try/catch + simple-query fallback so a (hypothetical) SQL
1260
- // edge case on exotic input can never leave autocomplete showing blank.
1261
- // The rank-0 baseline is identical behavior to the original query.
1262
1351
  const substr = `%${query}%`;
1263
1352
  let rows;
1264
1353
  try {
1265
1354
  const prefixQ = `${query}%`;
1266
- // match_rank combines two signals: how the query matches the
1267
- // contact (name prefix > local-part prefix > substring), and
1268
- // where the contact came from (curated Google address book >
1269
- // someone you've sent to > someone who emailed you). Without
1270
- // the source bonus, the seeded "received" rows — newsletters,
1271
- // mailing lists, no-reply senders — drown out the People API
1272
- // contacts because they have higher use_count from the corpus
1273
- // aggregation. The +30 / +20 / 0 spread is large enough that a
1274
- // google-source row with weak query match still beats a
1275
- // received-source row with the same query match, but small
1276
- // enough that within a tier the recency-weighted use_count
1277
- // (computed in JS below, magnitude ~10000×) still differentiates.
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.
1278
1360
  rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
1279
1361
  (CASE
1280
1362
  WHEN lower(name) LIKE lower(?) THEN 3
@@ -1282,15 +1364,15 @@ export class MailxDB {
1282
1364
  WHEN email LIKE ? OR name LIKE ? THEN 1
1283
1365
  ELSE 0
1284
1366
  END) +
1285
- (CASE source
1286
- WHEN 'google' THEN 30
1287
- WHEN 'sent' THEN 20
1288
- ELSE 0
1367
+ (CASE
1368
+ WHEN source = 'google' THEN 30
1369
+ WHEN source = 'discovered' THEN 0
1370
+ ELSE 40
1289
1371
  END) AS match_rank
1290
1372
  FROM contacts
1291
1373
  WHERE email LIKE ? OR name LIKE ?
1292
1374
  ORDER BY match_rank DESC, use_count DESC, last_used DESC
1293
- LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit);
1375
+ LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit * 2);
1294
1376
  }
1295
1377
  catch (e) {
1296
1378
  console.error(` [searchContacts] ranked query failed (${e?.message}) — falling back to simple LIKE`);
@@ -1298,13 +1380,18 @@ export class MailxDB {
1298
1380
  FROM contacts
1299
1381
  WHERE email LIKE ? OR name LIKE ?
1300
1382
  ORDER BY use_count DESC, last_used DESC
1301
- LIMIT ?`).all(substr, substr, limit);
1383
+ LIMIT ?`).all(substr, substr, limit * 2);
1302
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()));
1303
1389
  const now = Date.now();
1304
1390
  const HALF_LIFE_MS = 30 * 86400_000;
1305
1391
  const score = (r) => (r.match_rank || 0) * 10_000
1306
1392
  + (r.use_count || 0) * Math.pow(0.5, Math.max(0, now - (r.last_used || 0)) / HALF_LIFE_MS);
1307
1393
  rows.sort((a, b) => score(b) - score(a));
1394
+ rows = rows.slice(0, limit);
1308
1395
  return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
1309
1396
  }
1310
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