@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 +2 -0
- package/client/app.js +27 -3
- package/client/components/message-viewer.js +15 -43
- package/client/compose/compose.css +64 -0
- package/client/compose/compose.js +106 -1
- package/client/index.html +1 -1
- package/client/lib/api-client.js +6 -0
- package/client/lib/mailxapi.js +9 -0
- package/client/package.json +1 -1
- package/package.json +1 -1
- package/packages/mailx-imap/index.js +2 -2
- package/packages/mailx-service/index.d.ts +19 -0
- package/packages/mailx-service/index.js +90 -3
- package/packages/mailx-service/jsonrpc.js +8 -0
- package/packages/mailx-store/db.d.ts +46 -13
- package/packages/mailx-store/db.js +218 -45
- package/unwedge.cmd +1 -1
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
|
|
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
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
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 =
|
|
376
|
+
unsubBtn.textContent = "Unsubscribe";
|
|
376
377
|
unsubBtn.removeAttribute("title");
|
|
377
378
|
unsubBtn.href = httpUrl || mailUrl || "#";
|
|
378
|
-
unsubBtn.onclick =
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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="
|
|
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">
|
package/client/lib/api-client.js
CHANGED
|
@@ -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
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -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/client/package.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
/**
|
|
200
|
-
*
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
-
/**
|
|
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 '
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 ('
|
|
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
|
-
/**
|
|
1102
|
-
*
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
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
|
-
|
|
1136
|
-
|
|
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 ('
|
|
1161
|
-
const updStmt = this.db.prepare(
|
|
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
|
|
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
|
|
1169
|
-
updStmt.run(info.cnt, info.last, info.name, info.name, now,
|
|
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
|
-
/**
|
|
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\
|
|
1
|
+
rmdir C:\Users\Bob\.claude\session-env\7299facd-d726-4f8a-8e7a-dbd852680c95 /s /q
|