@bobfrankston/mailx 1.0.436 → 1.0.438
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/bin/mailx.js +5 -0
- package/client/app.js +42 -7
- package/client/components/message-viewer.js +63 -12
- package/client/compose/compose.html +1 -0
- package/client/compose/compose.js +49 -1
- package/client/index.html +5 -0
- package/client/lib/api-client.js +9 -0
- package/client/lib/mailxapi.js +9 -0
- package/client/styles/components.css +11 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +79 -37
- package/packages/mailx-imap/index.js +356 -499
- package/packages/mailx-service/index.d.ts +27 -0
- package/packages/mailx-service/index.js +186 -4
- package/packages/mailx-service/jsonrpc.js +6 -0
- package/packages/mailx-settings/index.d.ts +2 -0
- package/packages/mailx-settings/index.js +10 -0
- package/packages/mailx-store/db.js +22 -7
|
@@ -59,8 +59,35 @@ export declare class MailxService {
|
|
|
59
59
|
status: number;
|
|
60
60
|
statusText: string;
|
|
61
61
|
}>;
|
|
62
|
+
/** Per-session map: editId → temp file path + watcher cleanup.
|
|
63
|
+
* Lives in memory only — cleared when the user closes compose or sends. */
|
|
64
|
+
private wordEdits;
|
|
65
|
+
/** Hand the current compose body off to Microsoft Word for editing. Writes
|
|
66
|
+
* the HTML to `~/.mailx/external-edit/<editId>.html`, opens it via the
|
|
67
|
+
* default OS handler (Word on Windows when .html is associated; otherwise
|
|
68
|
+
* the user's chosen editor for HTML), and starts an fs.watch that emits
|
|
69
|
+
* `wordEditUpdated` when Word saves. The compose UI listens for that
|
|
70
|
+
* event and reloads the editor.
|
|
71
|
+
*
|
|
72
|
+
* Windows-only by current default — on Mac/Linux there's no equivalent
|
|
73
|
+
* reliable round-trip. The compose toolbar should hide the button on
|
|
74
|
+
* non-win32 platforms. */
|
|
75
|
+
openInWord(editId: string, html: string): Promise<{
|
|
76
|
+
ok: boolean;
|
|
77
|
+
path: string;
|
|
78
|
+
opener: string;
|
|
79
|
+
}>;
|
|
80
|
+
/** End external editing. Stops the watcher, removes the temp file.
|
|
81
|
+
* Caller is the compose UI when the user closes the window or sends. */
|
|
82
|
+
closeWordEdit(editId: string): Promise<void>;
|
|
62
83
|
updateFlags(accountId: string, uid: number, flags: string[]): Promise<void>;
|
|
63
84
|
allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): Promise<void>;
|
|
85
|
+
/** Mark a sender or domain as suspect. Surfaced in the remote-content
|
|
86
|
+
* banner as a red warning on subsequent messages. Toggle: calling with
|
|
87
|
+
* the same value removes it. Returns the new state for UI feedback. */
|
|
88
|
+
flagSenderOrDomain(type: "sender" | "domain", value: string): Promise<{
|
|
89
|
+
flagged: boolean;
|
|
90
|
+
}>;
|
|
64
91
|
search(q: string, page?: number, pageSize?: number, scope?: string, accountId?: string, folderId?: number): Promise<any>;
|
|
65
92
|
rebuildSearchIndex(): number;
|
|
66
93
|
getSyncPending(): {
|
|
@@ -460,10 +460,19 @@ export class MailxService {
|
|
|
460
460
|
// hides the Source button on every just-opened message.
|
|
461
461
|
const refreshed = this.db.getMessageByUid(accountId, uid, folderId);
|
|
462
462
|
const emlPath = refreshed?.bodyPath || envelope.bodyPath || "";
|
|
463
|
+
// Flag check — surfaced in the remote-content banner as a red
|
|
464
|
+
// warning when the sender's address or domain is on the user's
|
|
465
|
+
// flagged list. Cheap lookup; loadAllowlist is already cached.
|
|
466
|
+
const allowList = loadAllowlist();
|
|
467
|
+
const senderAddr = (envelope.from?.address || "").toLowerCase();
|
|
468
|
+
const senderDomain = senderAddr.split("@")[1] || "";
|
|
469
|
+
const isFlagged = !!((allowList.flaggedSenders || []).some((s) => (s || "").toLowerCase() === senderAddr) ||
|
|
470
|
+
(allowList.flaggedDomains || []).some((d) => (d || "").toLowerCase() === senderDomain));
|
|
463
471
|
return {
|
|
464
472
|
...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
|
|
465
473
|
attachments, emlPath, deliveredTo, returnPath,
|
|
466
474
|
listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
|
|
475
|
+
isFlagged,
|
|
467
476
|
};
|
|
468
477
|
}
|
|
469
478
|
/** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
|
|
@@ -506,6 +515,158 @@ export class MailxService {
|
|
|
506
515
|
}
|
|
507
516
|
return { ok: resp.ok, status: resp.status, statusText: resp.statusText };
|
|
508
517
|
}
|
|
518
|
+
// ── External edit in Microsoft Word ──
|
|
519
|
+
/** Per-session map: editId → temp file path + watcher cleanup.
|
|
520
|
+
* Lives in memory only — cleared when the user closes compose or sends. */
|
|
521
|
+
wordEdits = new Map();
|
|
522
|
+
/** Hand the current compose body off to Microsoft Word for editing. Writes
|
|
523
|
+
* the HTML to `~/.mailx/external-edit/<editId>.html`, opens it via the
|
|
524
|
+
* default OS handler (Word on Windows when .html is associated; otherwise
|
|
525
|
+
* the user's chosen editor for HTML), and starts an fs.watch that emits
|
|
526
|
+
* `wordEditUpdated` when Word saves. The compose UI listens for that
|
|
527
|
+
* event and reloads the editor.
|
|
528
|
+
*
|
|
529
|
+
* Windows-only by current default — on Mac/Linux there's no equivalent
|
|
530
|
+
* reliable round-trip. The compose toolbar should hide the button on
|
|
531
|
+
* non-win32 platforms. */
|
|
532
|
+
async openInWord(editId, html) {
|
|
533
|
+
const dir = path.join(getConfigDir(), "external-edit");
|
|
534
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
535
|
+
const filePath = path.join(dir, `${editId}.html`);
|
|
536
|
+
// Wrap in a minimal HTML doc so Word picks up encoding + treats the
|
|
537
|
+
// body content as the document. Word imports the <body> contents and
|
|
538
|
+
// converts them to its own model; saving HTML preserves enough of
|
|
539
|
+
// the structure for re-import (paragraphs, links, basic formatting).
|
|
540
|
+
const wrapped = `<!doctype html>
|
|
541
|
+
<html><head><meta charset="utf-8"><title>mailx draft</title></head>
|
|
542
|
+
<body>${html || "<p></p>"}</body></html>
|
|
543
|
+
`;
|
|
544
|
+
fs.writeFileSync(filePath, wrapped, "utf-8");
|
|
545
|
+
// Stop any existing watcher for this edit (re-open re-arms cleanly).
|
|
546
|
+
this.wordEdits.get(editId)?.stop();
|
|
547
|
+
// Try Word explicitly first; on failure (Word not installed, exec not
|
|
548
|
+
// in PATH) fall back to the OS default handler so the user still gets
|
|
549
|
+
// *some* editor. Report which one ran so the UI can say "Opening in
|
|
550
|
+
// Word…" vs "Opening in default editor…".
|
|
551
|
+
const { spawnSync, spawn } = await import("node:child_process");
|
|
552
|
+
const tryLaunch = (cmd, args) => {
|
|
553
|
+
try {
|
|
554
|
+
const r = spawnSync(cmd, args, { stdio: "ignore", windowsHide: true });
|
|
555
|
+
return r.status === 0 && !r.error;
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
// Editor preference: settings.externalEditor in `~/.mailx/config.jsonc`
|
|
562
|
+
// can be "word" | "libreoffice" | "auto" (default). Auto means try
|
|
563
|
+
// Word first, then LibreOffice, then OS default — gives Word users the
|
|
564
|
+
// expected experience while still working when Word isn't installed.
|
|
565
|
+
// LibreOffice tends to round-trip email-shaped HTML cleaner than
|
|
566
|
+
// Word, so users on either platform may want to flip it via the
|
|
567
|
+
// config editor.
|
|
568
|
+
const settings = loadSettings() || {};
|
|
569
|
+
const pref = (settings.externalEditor || "auto");
|
|
570
|
+
const tryWord = () => {
|
|
571
|
+
if (process.platform === "win32")
|
|
572
|
+
return tryLaunch("cmd", ["/c", "start", "", "/B", "winword.exe", filePath]);
|
|
573
|
+
if (process.platform === "darwin")
|
|
574
|
+
return tryLaunch("open", ["-a", "Microsoft Word", filePath]);
|
|
575
|
+
return false; // no MS Word on Linux
|
|
576
|
+
};
|
|
577
|
+
const tryLibreOffice = () => {
|
|
578
|
+
if (process.platform === "win32")
|
|
579
|
+
return tryLaunch("cmd", ["/c", "start", "", "/B", "soffice.exe", "--writer", filePath]);
|
|
580
|
+
if (process.platform === "darwin")
|
|
581
|
+
return tryLaunch("open", ["-a", "LibreOffice", filePath]);
|
|
582
|
+
return tryLaunch("soffice", ["--writer", filePath]) || tryLaunch("libreoffice", ["--writer", filePath]);
|
|
583
|
+
};
|
|
584
|
+
const tryDefault = () => {
|
|
585
|
+
if (process.platform === "win32")
|
|
586
|
+
return tryLaunch("cmd", ["/c", "start", "", filePath]);
|
|
587
|
+
if (process.platform === "darwin")
|
|
588
|
+
return tryLaunch("open", [filePath]);
|
|
589
|
+
return tryLaunch("xdg-open", [filePath]);
|
|
590
|
+
};
|
|
591
|
+
let opener = "none";
|
|
592
|
+
const order = pref === "libreoffice"
|
|
593
|
+
? [["libreoffice", tryLibreOffice], ["word", tryWord], ["default", tryDefault]]
|
|
594
|
+
: pref === "word"
|
|
595
|
+
? [["word", tryWord], ["default", tryDefault]]
|
|
596
|
+
: [["word", tryWord], ["libreoffice", tryLibreOffice], ["default", tryDefault]]; // auto
|
|
597
|
+
for (const [name, fn] of order) {
|
|
598
|
+
if (fn()) {
|
|
599
|
+
opener = name;
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (opener === "none") {
|
|
604
|
+
console.error(` [word-edit] no editor found on this platform — file written to ${filePath}`);
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
console.log(` [word-edit] opened ${filePath} via ${opener}`);
|
|
608
|
+
}
|
|
609
|
+
// Watch for save events. fs.watch on Windows fires multiple events
|
|
610
|
+
// per save (rename + change for atomic replacement); debounce so the
|
|
611
|
+
// UI only reloads once per save. Watch the directory rather than the
|
|
612
|
+
// file directly because Word writes via temp-file rename, which can
|
|
613
|
+
// invalidate a file-level watch.
|
|
614
|
+
let debounce = null;
|
|
615
|
+
let lastSize = -1;
|
|
616
|
+
const watcher = fs.watch(dir, (eventType, name) => {
|
|
617
|
+
if (name !== `${editId}.html`)
|
|
618
|
+
return;
|
|
619
|
+
if (debounce)
|
|
620
|
+
clearTimeout(debounce);
|
|
621
|
+
debounce = setTimeout(() => {
|
|
622
|
+
let stat;
|
|
623
|
+
try {
|
|
624
|
+
stat = fs.statSync(filePath);
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
// Skip duplicate events with the same size — Word fires several
|
|
630
|
+
// change notifications per save and we only want one reload.
|
|
631
|
+
if (stat.size === lastSize)
|
|
632
|
+
return;
|
|
633
|
+
lastSize = stat.size;
|
|
634
|
+
try {
|
|
635
|
+
const updatedHtml = fs.readFileSync(filePath, "utf-8");
|
|
636
|
+
// Strip the wrapper to extract just the body content.
|
|
637
|
+
const bodyMatch = updatedHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
638
|
+
const inner = bodyMatch ? bodyMatch[1] : updatedHtml;
|
|
639
|
+
this.imapManager.emit("wordEditUpdated", { editId, html: inner });
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
console.error(` [word-edit] read after save failed: ${e.message}`);
|
|
643
|
+
}
|
|
644
|
+
}, 300);
|
|
645
|
+
});
|
|
646
|
+
const stop = () => {
|
|
647
|
+
try {
|
|
648
|
+
watcher.close();
|
|
649
|
+
}
|
|
650
|
+
catch { /* */ }
|
|
651
|
+
if (debounce)
|
|
652
|
+
clearTimeout(debounce);
|
|
653
|
+
};
|
|
654
|
+
this.wordEdits.set(editId, { path: filePath, stop });
|
|
655
|
+
return { ok: opener !== "none", path: filePath, opener };
|
|
656
|
+
}
|
|
657
|
+
/** End external editing. Stops the watcher, removes the temp file.
|
|
658
|
+
* Caller is the compose UI when the user closes the window or sends. */
|
|
659
|
+
async closeWordEdit(editId) {
|
|
660
|
+
const entry = this.wordEdits.get(editId);
|
|
661
|
+
if (!entry)
|
|
662
|
+
return;
|
|
663
|
+
entry.stop();
|
|
664
|
+
this.wordEdits.delete(editId);
|
|
665
|
+
try {
|
|
666
|
+
fs.unlinkSync(entry.path);
|
|
667
|
+
}
|
|
668
|
+
catch { /* file already gone — fine */ }
|
|
669
|
+
}
|
|
509
670
|
async updateFlags(accountId, uid, flags) {
|
|
510
671
|
const envelope = this.db.getMessageByUid(accountId, uid);
|
|
511
672
|
await this.imapManager.updateFlagsLocal(accountId, uid, envelope?.folderId || 0, flags);
|
|
@@ -526,6 +687,27 @@ export class MailxService {
|
|
|
526
687
|
await saveAllowlist(list);
|
|
527
688
|
console.log(` [allow] Added ${type}: ${value}`);
|
|
528
689
|
}
|
|
690
|
+
/** Mark a sender or domain as suspect. Surfaced in the remote-content
|
|
691
|
+
* banner as a red warning on subsequent messages. Toggle: calling with
|
|
692
|
+
* the same value removes it. Returns the new state for UI feedback. */
|
|
693
|
+
async flagSenderOrDomain(type, value) {
|
|
694
|
+
const list = loadAllowlist();
|
|
695
|
+
const key = type === "sender" ? "flaggedSenders" : "flaggedDomains";
|
|
696
|
+
if (!Array.isArray(list[key]))
|
|
697
|
+
list[key] = [];
|
|
698
|
+
const lower = (value || "").toLowerCase();
|
|
699
|
+
const idx = list[key].findIndex((v) => (v || "").toLowerCase() === lower);
|
|
700
|
+
if (idx >= 0) {
|
|
701
|
+
list[key].splice(idx, 1);
|
|
702
|
+
await saveAllowlist(list);
|
|
703
|
+
console.log(` [flag] Removed ${type}: ${value}`);
|
|
704
|
+
return { flagged: false };
|
|
705
|
+
}
|
|
706
|
+
list[key].push(value);
|
|
707
|
+
await saveAllowlist(list);
|
|
708
|
+
console.log(` [flag] Added ${type}: ${value}`);
|
|
709
|
+
return { flagged: true };
|
|
710
|
+
}
|
|
529
711
|
// ── Search ──
|
|
530
712
|
async search(q, page = 1, pageSize = 50, scope = "all", accountId, folderId) {
|
|
531
713
|
q = (q || "").trim();
|
|
@@ -1454,7 +1636,7 @@ export class MailxService {
|
|
|
1454
1636
|
// ── Folder management ──
|
|
1455
1637
|
async createFolder(accountId, parentPath, name) {
|
|
1456
1638
|
const fullPath = parentPath ? `${parentPath}.${name}` : name;
|
|
1457
|
-
const client = this.imapManager.createPublicClient(accountId);
|
|
1639
|
+
const client = await this.imapManager.createPublicClient(accountId);
|
|
1458
1640
|
try {
|
|
1459
1641
|
await client.createmailbox(fullPath);
|
|
1460
1642
|
await this.imapManager.syncFolders(accountId, client);
|
|
@@ -1474,7 +1656,7 @@ export class MailxService {
|
|
|
1474
1656
|
const parts = folder.path.split(folder.delimiter || ".");
|
|
1475
1657
|
parts[parts.length - 1] = newName;
|
|
1476
1658
|
const newPath = parts.join(folder.delimiter || ".");
|
|
1477
|
-
const client = this.imapManager.createPublicClient(accountId);
|
|
1659
|
+
const client = await this.imapManager.createPublicClient(accountId);
|
|
1478
1660
|
try {
|
|
1479
1661
|
if (client.renameMailbox) {
|
|
1480
1662
|
await client.renameMailbox(folder.path, newPath);
|
|
@@ -1498,7 +1680,7 @@ export class MailxService {
|
|
|
1498
1680
|
const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
|
|
1499
1681
|
if (!folder)
|
|
1500
1682
|
throw new Error("Folder not found");
|
|
1501
|
-
const client = this.imapManager.createPublicClient(accountId);
|
|
1683
|
+
const client = await this.imapManager.createPublicClient(accountId);
|
|
1502
1684
|
try {
|
|
1503
1685
|
try {
|
|
1504
1686
|
if (client.deleteMailbox) {
|
|
@@ -1551,7 +1733,7 @@ export class MailxService {
|
|
|
1551
1733
|
this.imapManager.emit?.("folderCountsChanged", accountId, {});
|
|
1552
1734
|
}
|
|
1553
1735
|
catch { /* non-fatal */ }
|
|
1554
|
-
const client = this.imapManager.createPublicClient(accountId);
|
|
1736
|
+
const client = await this.imapManager.createPublicClient(accountId);
|
|
1555
1737
|
try {
|
|
1556
1738
|
const uids = await client.getUids(folder.path);
|
|
1557
1739
|
for (const uid of uids)
|
|
@@ -175,6 +175,10 @@ async function dispatchAction(svc, action, p) {
|
|
|
175
175
|
return { content: await svc.readConfigHelp(p.name) };
|
|
176
176
|
case "unsubscribeOneClick":
|
|
177
177
|
return await svc.unsubscribeOneClick(p.url);
|
|
178
|
+
case "openInWord":
|
|
179
|
+
return await svc.openInWord(p.editId, p.html);
|
|
180
|
+
case "closeWordEdit":
|
|
181
|
+
return await svc.closeWordEdit(p.editId);
|
|
178
182
|
// Client-side tracing — lets webview / iframe code ship events to the
|
|
179
183
|
// Node log so a "compose→send→vanished" report can be diagnosed without
|
|
180
184
|
// opening devtools. Every call shows up as `[client] <tag> <data>` in
|
|
@@ -192,6 +196,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
192
196
|
case "allowRemoteContent":
|
|
193
197
|
svc.allowRemoteContent(p.type, p.value);
|
|
194
198
|
return { ok: true };
|
|
199
|
+
case "flagSenderOrDomain":
|
|
200
|
+
return await svc.flagSenderOrDomain(p.type, p.value);
|
|
195
201
|
case "getVersion": {
|
|
196
202
|
try {
|
|
197
203
|
const settings = svc.getSettings();
|
|
@@ -439,6 +439,14 @@ const DEFAULT_ALLOWLIST = {
|
|
|
439
439
|
senders: [],
|
|
440
440
|
domains: [],
|
|
441
441
|
recipients: [],
|
|
442
|
+
// Flagged senders/domains — surfaced as a red warning on the
|
|
443
|
+
// remote-content banner. Distinct from the positive lists above:
|
|
444
|
+
// these don't auto-allow anything, they make the banner louder when
|
|
445
|
+
// a known-suspicious correspondent's mail shows up. Future: a shared
|
|
446
|
+
// GitHub list users can opt in to (community-flagged phishing /
|
|
447
|
+
// tracker-heavy senders) — see TODO.md.
|
|
448
|
+
flaggedSenders: [],
|
|
449
|
+
flaggedDomains: [],
|
|
442
450
|
};
|
|
443
451
|
// ── Public API ──
|
|
444
452
|
/** Load account configs */
|
|
@@ -620,6 +628,8 @@ export async function saveAllowlist(list) {
|
|
|
620
628
|
senders: mergeArrays(list.senders || [], cloud.senders || []),
|
|
621
629
|
domains: mergeArrays(list.domains || [], cloud.domains || []),
|
|
622
630
|
recipients: mergeArrays(list.recipients || [], cloud.recipients || []),
|
|
631
|
+
flaggedSenders: mergeArrays(list.flaggedSenders || [], cloud.flaggedSenders || []),
|
|
632
|
+
flaggedDomains: mergeArrays(list.flaggedDomains || [], cloud.flaggedDomains || []),
|
|
623
633
|
};
|
|
624
634
|
}
|
|
625
635
|
}
|
|
@@ -287,6 +287,10 @@ export class MailxDB {
|
|
|
287
287
|
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid)");
|
|
288
288
|
}
|
|
289
289
|
catch { /* already exists */ }
|
|
290
|
+
// bcc_json: pre-existing DBs predate this column. Without the migration
|
|
291
|
+
// every contacts seed pass throws "no such column: m.bcc_json" and the
|
|
292
|
+
// local autocomplete corpus stays empty.
|
|
293
|
+
this.addColumnIfMissing("messages", "bcc_json", "TEXT DEFAULT '[]'");
|
|
290
294
|
// calendar_events: recurring_event_id carries the Google Calendar
|
|
291
295
|
// series id when the event is an expanded instance of a recurrence.
|
|
292
296
|
// Filters like "hide recurring events" check this column.
|
|
@@ -343,7 +347,7 @@ export class MailxDB {
|
|
|
343
347
|
* runs at startup). The user-facing message names the recovery command. */
|
|
344
348
|
verifySchema() {
|
|
345
349
|
const required = {
|
|
346
|
-
messages: ["thread_id", "provider_id", "uuid"],
|
|
350
|
+
messages: ["thread_id", "provider_id", "uuid", "bcc_json"],
|
|
347
351
|
calendar_events: ["recurring_event_id", "html_link"],
|
|
348
352
|
};
|
|
349
353
|
for (const [table, cols] of Object.entries(required)) {
|
|
@@ -1434,10 +1438,21 @@ export class MailxDB {
|
|
|
1434
1438
|
query = (query || "").trim();
|
|
1435
1439
|
if (!query)
|
|
1436
1440
|
return [];
|
|
1437
|
-
|
|
1441
|
+
// Split into whitespace-separated tokens. Each token must appear in
|
|
1442
|
+
// name or email — order- and adjacency-independent. So "eleanor elkin"
|
|
1443
|
+
// matches "Eleanor Elkin", "Elkin, Eleanor", "Eleanor M Elkin", and
|
|
1444
|
+
// "elkin@eleanor.example". The first token gets the prefix bonus for
|
|
1445
|
+
// ranking; remaining tokens just have to be present.
|
|
1446
|
+
const tokens = query.split(/\s+/).filter(Boolean);
|
|
1447
|
+
const firstSubstr = `%${tokens[0]}%`;
|
|
1448
|
+
const firstPrefix = `${tokens[0]}%`;
|
|
1449
|
+
const tokenWhere = tokens.map(() => "(name LIKE ? OR email LIKE ?)").join(" AND ");
|
|
1450
|
+
const tokenParams = [];
|
|
1451
|
+
for (const t of tokens) {
|
|
1452
|
+
tokenParams.push(`%${t}%`, `%${t}%`);
|
|
1453
|
+
}
|
|
1438
1454
|
let rows;
|
|
1439
1455
|
try {
|
|
1440
|
-
const prefixQ = `${query}%`;
|
|
1441
1456
|
// Source tier: anything not in the two reserved system sources
|
|
1442
1457
|
// ('google', 'discovered') is preferred-tier — i.e. came out of
|
|
1443
1458
|
// contacts.jsonc#preferred[]. The user's `source: "work"` /
|
|
@@ -1456,17 +1471,17 @@ export class MailxDB {
|
|
|
1456
1471
|
ELSE 40
|
|
1457
1472
|
END) AS match_rank
|
|
1458
1473
|
FROM contacts
|
|
1459
|
-
WHERE
|
|
1474
|
+
WHERE ${tokenWhere}
|
|
1460
1475
|
ORDER BY match_rank DESC, use_count DESC, last_used DESC
|
|
1461
|
-
LIMIT ?`).all(
|
|
1476
|
+
LIMIT ?`).all(firstPrefix, firstPrefix, firstSubstr, firstSubstr, ...tokenParams, limit * 2);
|
|
1462
1477
|
}
|
|
1463
1478
|
catch (e) {
|
|
1464
1479
|
console.error(` [searchContacts] ranked query failed (${e?.message}) — falling back to simple LIKE`);
|
|
1465
1480
|
rows = this.db.prepare(`SELECT name, email, source, use_count, last_used, 0 AS match_rank
|
|
1466
1481
|
FROM contacts
|
|
1467
|
-
WHERE
|
|
1482
|
+
WHERE ${tokenWhere}
|
|
1468
1483
|
ORDER BY use_count DESC, last_used DESC
|
|
1469
|
-
LIMIT ?`).all(
|
|
1484
|
+
LIMIT ?`).all(...tokenParams, limit * 2);
|
|
1470
1485
|
}
|
|
1471
1486
|
// Filter out denylisted emails as a defense-in-depth — applyContactsConfig
|
|
1472
1487
|
// already purges discovered rows on denylist, but a Google sync that
|