@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 +18 -0
- package/client/components/message-list.js +32 -6
- package/client/components/message-viewer.js +15 -43
- package/client/compose/compose.css +64 -0
- package/client/compose/compose.js +94 -1
- package/client/lib/api-client.js +6 -0
- package/client/lib/mailxapi.js +9 -0
- 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 +165 -78
- package/unwedge.cmd +1 -1
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
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
//
|
|
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);
|
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/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;
|
|
@@ -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 '
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 ('
|
|
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
|
-
/**
|
|
1123
|
-
*
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
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
|
|
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
|
|
1201
|
+
agg.set(email, { name: name || "", cnt: 1, last: date || 0 });
|
|
1159
1202
|
}
|
|
1160
1203
|
};
|
|
1161
|
-
// Sent folder
|
|
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
|
|
1226
|
+
bump(a.name || "", a.address || a.email || "", date);
|
|
1184
1227
|
}
|
|
1185
1228
|
}
|
|
1186
1229
|
}
|
|
1187
|
-
// Other folders:
|
|
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
|
|
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
|
|
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
|
-
|
|
1217
|
-
const
|
|
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
|
|
1264
|
+
WHERE id = ?`);
|
|
1227
1265
|
for (const [email, info] of agg) {
|
|
1228
|
-
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);
|
|
1229
1267
|
if (!existing) {
|
|
1230
|
-
insStmt.run(info.
|
|
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
|
-
|
|
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 ||
|
|
1245
|
-
console.log(` [contacts] seed: ${added} new + ${bumped} refreshed (
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
1267
|
-
//
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
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
|
|
1286
|
-
WHEN 'google' THEN 30
|
|
1287
|
-
WHEN '
|
|
1288
|
-
ELSE
|
|
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\
|
|
1
|
+
rmdir C:\Users\Bob\.claude\session-env\7299facd-d726-4f8a-8e7a-dbd852680c95 /s /q
|