@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.
@@ -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();
@@ -69,6 +69,8 @@ declare const DEFAULT_ALLOWLIST: {
69
69
  senders: string[];
70
70
  domains: string[];
71
71
  recipients: string[];
72
+ flaggedSenders: string[];
73
+ flaggedDomains: string[];
72
74
  };
73
75
  /** Load account configs */
74
76
  export declare function loadAccounts(): AccountConfig[];
@@ -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
- const substr = `%${query}%`;
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 email LIKE ? OR name LIKE ?
1474
+ WHERE ${tokenWhere}
1460
1475
  ORDER BY match_rank DESC, use_count DESC, last_used DESC
1461
- LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit * 2);
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 email LIKE ? OR name LIKE ?
1482
+ WHERE ${tokenWhere}
1468
1483
  ORDER BY use_count DESC, last_used DESC
1469
- LIMIT ?`).all(substr, substr, limit * 2);
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