@bobfrankston/mailx 1.0.437 → 1.0.439

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.
@@ -6,6 +6,18 @@
6
6
  import { MailxDB } from "@bobfrankston/mailx-store";
7
7
  import { ImapManager } from "@bobfrankston/mailx-imap";
8
8
  import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings, AiTransformRequest, AiTransformResponse } from "@bobfrankston/mailx-types";
9
+ interface ReputationResult {
10
+ flagged: boolean;
11
+ listedCount: number;
12
+ checkedCount: number;
13
+ sources: Array<{
14
+ service: string;
15
+ flagged: boolean;
16
+ verdict: string;
17
+ }>;
18
+ verdict: string;
19
+ service: string;
20
+ }
9
21
  export declare class MailxService {
10
22
  private db;
11
23
  private imapManager;
@@ -59,8 +71,59 @@ export declare class MailxService {
59
71
  status: number;
60
72
  statusText: string;
61
73
  }>;
74
+ /** Per-session map: editId → temp file path + watcher cleanup.
75
+ * Lives in memory only — cleared when the user closes compose or sends. */
76
+ private wordEdits;
77
+ /** Hand the current compose body off to Microsoft Word for editing. Writes
78
+ * the HTML to `~/.mailx/external-edit/<editId>.html`, opens it via the
79
+ * default OS handler (Word on Windows when .html is associated; otherwise
80
+ * the user's chosen editor for HTML), and starts an fs.watch that emits
81
+ * `wordEditUpdated` when Word saves. The compose UI listens for that
82
+ * event and reloads the editor.
83
+ *
84
+ * Windows-only by current default — on Mac/Linux there's no equivalent
85
+ * reliable round-trip. The compose toolbar should hide the button on
86
+ * non-win32 platforms. */
87
+ openInWord(editId: string, html: string): Promise<{
88
+ ok: boolean;
89
+ path: string;
90
+ opener: string;
91
+ }>;
92
+ /** End external editing. Stops the watcher, removes the temp file.
93
+ * Caller is the compose UI when the user closes the window or sends. */
94
+ closeWordEdit(editId: string): Promise<void>;
62
95
  updateFlags(accountId: string, uid: number, flags: string[]): Promise<void>;
63
96
  allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): Promise<void>;
97
+ /** Domain-reputation cache. Lookups are fast (~50ms each, three in
98
+ * parallel) but we still don't want to redo them on every render of
99
+ * the same sender's mail. Five-minute TTL — long enough that scrolling
100
+ * a folder fans out one query set, short enough that a newly-listed
101
+ * domain surfaces within minutes. */
102
+ private reputationCache;
103
+ private static readonly REPUTATION_TTL_MS;
104
+ private static readonly REPUTATION_TIMEOUT_MS;
105
+ /** Check a domain against three free no-key DNS blocklists in parallel:
106
+ *
107
+ * Spamhaus DBL — `<d>.dbl.spamhaus.org` spam/phish/malware
108
+ * SURBL multi — `<d>.multi.surbl.org` mixed (ph/mw/abuse)
109
+ * URIBL multi — `<d>.multi.uribl.com` black/grey/red lists
110
+ *
111
+ * Each lookup is bounded at 500 ms; missing/slow services are treated
112
+ * as "unknown" (don't poison the cache). Returns the aggregate plus
113
+ * the per-service detail so the UI can show "N of 3 services flag
114
+ * this domain" with the contributing source list.
115
+ *
116
+ * Privacy: each query leaks the bare domain to that DNSBL's
117
+ * infrastructure plus the user's local resolver. Opt-in via Settings.
118
+ *
119
+ * No API keys, free for personal use across all three services. */
120
+ checkDomainReputation(domain: string): Promise<ReputationResult | null>;
121
+ /** Mark a sender or domain as suspect. Surfaced in the remote-content
122
+ * banner as a red warning on subsequent messages. Toggle: calling with
123
+ * the same value removes it. Returns the new state for UI feedback. */
124
+ flagSenderOrDomain(type: "sender" | "domain", value: string): Promise<{
125
+ flagged: boolean;
126
+ }>;
64
127
  search(q: string, page?: number, pageSize?: number, scope?: string, accountId?: string, folderId?: number): Promise<any>;
65
128
  rebuildSearchIndex(): number;
66
129
  getSyncPending(): {
@@ -284,4 +347,5 @@ export declare class MailxService {
284
347
  * default false. Returns empty text + reason when disabled or on error. */
285
348
  aiTransform(req: AiTransformRequest): Promise<AiTransformResponse>;
286
349
  }
350
+ export {};
287
351
  //# sourceMappingURL=index.d.ts.map
@@ -460,10 +460,30 @@ 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));
471
+ // External reputation check — Spamhaus DBL + SURBL + URIBL in parallel.
472
+ // Off by default (privacy: the domain leaks to those DNSBLs and the
473
+ // user's local resolver). User opts in via Settings → "Check sender
474
+ // reputation". Each lookup is bounded at 500 ms; the whole check is
475
+ // bounded by the slowest, ~500 ms worst case.
476
+ let reputation = null;
477
+ const settings = loadSettings() || {};
478
+ if (settings.checkDomainReputation && senderDomain && hasRemoteContent) {
479
+ reputation = await this.checkDomainReputation(senderDomain);
480
+ }
463
481
  return {
464
482
  ...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
465
483
  attachments, emlPath, deliveredTo, returnPath,
466
484
  listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
485
+ isFlagged,
486
+ reputation,
467
487
  };
468
488
  }
469
489
  /** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
@@ -506,6 +526,158 @@ export class MailxService {
506
526
  }
507
527
  return { ok: resp.ok, status: resp.status, statusText: resp.statusText };
508
528
  }
529
+ // ── External edit in Microsoft Word ──
530
+ /** Per-session map: editId → temp file path + watcher cleanup.
531
+ * Lives in memory only — cleared when the user closes compose or sends. */
532
+ wordEdits = new Map();
533
+ /** Hand the current compose body off to Microsoft Word for editing. Writes
534
+ * the HTML to `~/.mailx/external-edit/<editId>.html`, opens it via the
535
+ * default OS handler (Word on Windows when .html is associated; otherwise
536
+ * the user's chosen editor for HTML), and starts an fs.watch that emits
537
+ * `wordEditUpdated` when Word saves. The compose UI listens for that
538
+ * event and reloads the editor.
539
+ *
540
+ * Windows-only by current default — on Mac/Linux there's no equivalent
541
+ * reliable round-trip. The compose toolbar should hide the button on
542
+ * non-win32 platforms. */
543
+ async openInWord(editId, html) {
544
+ const dir = path.join(getConfigDir(), "external-edit");
545
+ fs.mkdirSync(dir, { recursive: true });
546
+ const filePath = path.join(dir, `${editId}.html`);
547
+ // Wrap in a minimal HTML doc so Word picks up encoding + treats the
548
+ // body content as the document. Word imports the <body> contents and
549
+ // converts them to its own model; saving HTML preserves enough of
550
+ // the structure for re-import (paragraphs, links, basic formatting).
551
+ const wrapped = `<!doctype html>
552
+ <html><head><meta charset="utf-8"><title>mailx draft</title></head>
553
+ <body>${html || "<p></p>"}</body></html>
554
+ `;
555
+ fs.writeFileSync(filePath, wrapped, "utf-8");
556
+ // Stop any existing watcher for this edit (re-open re-arms cleanly).
557
+ this.wordEdits.get(editId)?.stop();
558
+ // Try Word explicitly first; on failure (Word not installed, exec not
559
+ // in PATH) fall back to the OS default handler so the user still gets
560
+ // *some* editor. Report which one ran so the UI can say "Opening in
561
+ // Word…" vs "Opening in default editor…".
562
+ const { spawnSync, spawn } = await import("node:child_process");
563
+ const tryLaunch = (cmd, args) => {
564
+ try {
565
+ const r = spawnSync(cmd, args, { stdio: "ignore", windowsHide: true });
566
+ return r.status === 0 && !r.error;
567
+ }
568
+ catch {
569
+ return false;
570
+ }
571
+ };
572
+ // Editor preference: settings.externalEditor in `~/.mailx/config.jsonc`
573
+ // can be "word" | "libreoffice" | "auto" (default). Auto means try
574
+ // Word first, then LibreOffice, then OS default — gives Word users the
575
+ // expected experience while still working when Word isn't installed.
576
+ // LibreOffice tends to round-trip email-shaped HTML cleaner than
577
+ // Word, so users on either platform may want to flip it via the
578
+ // config editor.
579
+ const settings = loadSettings() || {};
580
+ const pref = (settings.externalEditor || "auto");
581
+ const tryWord = () => {
582
+ if (process.platform === "win32")
583
+ return tryLaunch("cmd", ["/c", "start", "", "/B", "winword.exe", filePath]);
584
+ if (process.platform === "darwin")
585
+ return tryLaunch("open", ["-a", "Microsoft Word", filePath]);
586
+ return false; // no MS Word on Linux
587
+ };
588
+ const tryLibreOffice = () => {
589
+ if (process.platform === "win32")
590
+ return tryLaunch("cmd", ["/c", "start", "", "/B", "soffice.exe", "--writer", filePath]);
591
+ if (process.platform === "darwin")
592
+ return tryLaunch("open", ["-a", "LibreOffice", filePath]);
593
+ return tryLaunch("soffice", ["--writer", filePath]) || tryLaunch("libreoffice", ["--writer", filePath]);
594
+ };
595
+ const tryDefault = () => {
596
+ if (process.platform === "win32")
597
+ return tryLaunch("cmd", ["/c", "start", "", filePath]);
598
+ if (process.platform === "darwin")
599
+ return tryLaunch("open", [filePath]);
600
+ return tryLaunch("xdg-open", [filePath]);
601
+ };
602
+ let opener = "none";
603
+ const order = pref === "libreoffice"
604
+ ? [["libreoffice", tryLibreOffice], ["word", tryWord], ["default", tryDefault]]
605
+ : pref === "word"
606
+ ? [["word", tryWord], ["default", tryDefault]]
607
+ : [["word", tryWord], ["libreoffice", tryLibreOffice], ["default", tryDefault]]; // auto
608
+ for (const [name, fn] of order) {
609
+ if (fn()) {
610
+ opener = name;
611
+ break;
612
+ }
613
+ }
614
+ if (opener === "none") {
615
+ console.error(` [word-edit] no editor found on this platform — file written to ${filePath}`);
616
+ }
617
+ else {
618
+ console.log(` [word-edit] opened ${filePath} via ${opener}`);
619
+ }
620
+ // Watch for save events. fs.watch on Windows fires multiple events
621
+ // per save (rename + change for atomic replacement); debounce so the
622
+ // UI only reloads once per save. Watch the directory rather than the
623
+ // file directly because Word writes via temp-file rename, which can
624
+ // invalidate a file-level watch.
625
+ let debounce = null;
626
+ let lastSize = -1;
627
+ const watcher = fs.watch(dir, (eventType, name) => {
628
+ if (name !== `${editId}.html`)
629
+ return;
630
+ if (debounce)
631
+ clearTimeout(debounce);
632
+ debounce = setTimeout(() => {
633
+ let stat;
634
+ try {
635
+ stat = fs.statSync(filePath);
636
+ }
637
+ catch {
638
+ return;
639
+ }
640
+ // Skip duplicate events with the same size — Word fires several
641
+ // change notifications per save and we only want one reload.
642
+ if (stat.size === lastSize)
643
+ return;
644
+ lastSize = stat.size;
645
+ try {
646
+ const updatedHtml = fs.readFileSync(filePath, "utf-8");
647
+ // Strip the wrapper to extract just the body content.
648
+ const bodyMatch = updatedHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
649
+ const inner = bodyMatch ? bodyMatch[1] : updatedHtml;
650
+ this.imapManager.emit("wordEditUpdated", { editId, html: inner });
651
+ }
652
+ catch (e) {
653
+ console.error(` [word-edit] read after save failed: ${e.message}`);
654
+ }
655
+ }, 300);
656
+ });
657
+ const stop = () => {
658
+ try {
659
+ watcher.close();
660
+ }
661
+ catch { /* */ }
662
+ if (debounce)
663
+ clearTimeout(debounce);
664
+ };
665
+ this.wordEdits.set(editId, { path: filePath, stop });
666
+ return { ok: opener !== "none", path: filePath, opener };
667
+ }
668
+ /** End external editing. Stops the watcher, removes the temp file.
669
+ * Caller is the compose UI when the user closes the window or sends. */
670
+ async closeWordEdit(editId) {
671
+ const entry = this.wordEdits.get(editId);
672
+ if (!entry)
673
+ return;
674
+ entry.stop();
675
+ this.wordEdits.delete(editId);
676
+ try {
677
+ fs.unlinkSync(entry.path);
678
+ }
679
+ catch { /* file already gone — fine */ }
680
+ }
509
681
  async updateFlags(accountId, uid, flags) {
510
682
  const envelope = this.db.getMessageByUid(accountId, uid);
511
683
  await this.imapManager.updateFlagsLocal(accountId, uid, envelope?.folderId || 0, flags);
@@ -526,6 +698,102 @@ export class MailxService {
526
698
  await saveAllowlist(list);
527
699
  console.log(` [allow] Added ${type}: ${value}`);
528
700
  }
701
+ /** Domain-reputation cache. Lookups are fast (~50ms each, three in
702
+ * parallel) but we still don't want to redo them on every render of
703
+ * the same sender's mail. Five-minute TTL — long enough that scrolling
704
+ * a folder fans out one query set, short enough that a newly-listed
705
+ * domain surfaces within minutes. */
706
+ reputationCache = new Map();
707
+ static REPUTATION_TTL_MS = 5 * 60_000;
708
+ static REPUTATION_TIMEOUT_MS = 500;
709
+ /** Check a domain against three free no-key DNS blocklists in parallel:
710
+ *
711
+ * Spamhaus DBL — `<d>.dbl.spamhaus.org` spam/phish/malware
712
+ * SURBL multi — `<d>.multi.surbl.org` mixed (ph/mw/abuse)
713
+ * URIBL multi — `<d>.multi.uribl.com` black/grey/red lists
714
+ *
715
+ * Each lookup is bounded at 500 ms; missing/slow services are treated
716
+ * as "unknown" (don't poison the cache). Returns the aggregate plus
717
+ * the per-service detail so the UI can show "N of 3 services flag
718
+ * this domain" with the contributing source list.
719
+ *
720
+ * Privacy: each query leaks the bare domain to that DNSBL's
721
+ * infrastructure plus the user's local resolver. Opt-in via Settings.
722
+ *
723
+ * No API keys, free for personal use across all three services. */
724
+ async checkDomainReputation(domain) {
725
+ domain = (domain || "").toLowerCase().trim();
726
+ if (!domain)
727
+ return null;
728
+ const cached = this.reputationCache.get(domain);
729
+ if (cached && cached.expiresAt > Date.now())
730
+ return cached.result;
731
+ const probe = async (service, host, mapVerdict) => {
732
+ try {
733
+ const lookup = dns.resolve4(`${domain}.${host}`);
734
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("dnsbl-timeout")), MailxService.REPUTATION_TIMEOUT_MS));
735
+ const records = await Promise.race([lookup, timeout]);
736
+ const last = records[0]?.split(".").pop() || "";
737
+ return { service, flagged: true, verdict: mapVerdict(last) };
738
+ }
739
+ catch (e) {
740
+ const code = e?.code || "";
741
+ if (code === "ENOTFOUND" || code === "ENODATA") {
742
+ return { service, flagged: false, verdict: "clean" };
743
+ }
744
+ return null; // timeout / network — unknown
745
+ }
746
+ };
747
+ const dblVerdict = (last) => last === "2" ? "spam" :
748
+ last === "4" ? "phishing" :
749
+ last === "5" ? "malware" :
750
+ last === "6" ? "botnet" :
751
+ "listed";
752
+ // SURBL/URIBL encode multiple list memberships in a bitfield; the
753
+ // distinction matters less to the end user than "how many sources
754
+ // agree", so we keep a generic "listed" verdict for both.
755
+ const generic = (_last) => "listed";
756
+ const sources = await Promise.all([
757
+ probe("Spamhaus DBL", "dbl.spamhaus.org", dblVerdict),
758
+ probe("SURBL", "multi.surbl.org", generic),
759
+ probe("URIBL", "multi.uribl.com", generic),
760
+ ]);
761
+ const known = sources.filter((s) => s !== null);
762
+ const flagged = known.filter(s => s.flagged);
763
+ const result = {
764
+ flagged: flagged.length > 0,
765
+ listedCount: flagged.length,
766
+ checkedCount: known.length,
767
+ sources: flagged,
768
+ // Pick the most specific verdict if Spamhaus contributed (since
769
+ // DBL distinguishes phishing/malware/etc); otherwise generic.
770
+ verdict: flagged.find(s => s.service === "Spamhaus DBL")?.verdict || flagged[0]?.verdict || "clean",
771
+ service: flagged.map(s => s.service).join(", ") || "Spamhaus DBL / SURBL / URIBL",
772
+ };
773
+ this.reputationCache.set(domain, { result, expiresAt: Date.now() + MailxService.REPUTATION_TTL_MS });
774
+ return result;
775
+ }
776
+ /** Mark a sender or domain as suspect. Surfaced in the remote-content
777
+ * banner as a red warning on subsequent messages. Toggle: calling with
778
+ * the same value removes it. Returns the new state for UI feedback. */
779
+ async flagSenderOrDomain(type, value) {
780
+ const list = loadAllowlist();
781
+ const key = type === "sender" ? "flaggedSenders" : "flaggedDomains";
782
+ if (!Array.isArray(list[key]))
783
+ list[key] = [];
784
+ const lower = (value || "").toLowerCase();
785
+ const idx = list[key].findIndex((v) => (v || "").toLowerCase() === lower);
786
+ if (idx >= 0) {
787
+ list[key].splice(idx, 1);
788
+ await saveAllowlist(list);
789
+ console.log(` [flag] Removed ${type}: ${value}`);
790
+ return { flagged: false };
791
+ }
792
+ list[key].push(value);
793
+ await saveAllowlist(list);
794
+ console.log(` [flag] Added ${type}: ${value}`);
795
+ return { flagged: true };
796
+ }
529
797
  // ── Search ──
530
798
  async search(q, page = 1, pageSize = 50, scope = "all", accountId, folderId) {
531
799
  q = (q || "").trim();
@@ -1454,7 +1722,7 @@ export class MailxService {
1454
1722
  // ── Folder management ──
1455
1723
  async createFolder(accountId, parentPath, name) {
1456
1724
  const fullPath = parentPath ? `${parentPath}.${name}` : name;
1457
- const client = this.imapManager.createPublicClient(accountId);
1725
+ const client = await this.imapManager.createPublicClient(accountId);
1458
1726
  try {
1459
1727
  await client.createmailbox(fullPath);
1460
1728
  await this.imapManager.syncFolders(accountId, client);
@@ -1474,7 +1742,7 @@ export class MailxService {
1474
1742
  const parts = folder.path.split(folder.delimiter || ".");
1475
1743
  parts[parts.length - 1] = newName;
1476
1744
  const newPath = parts.join(folder.delimiter || ".");
1477
- const client = this.imapManager.createPublicClient(accountId);
1745
+ const client = await this.imapManager.createPublicClient(accountId);
1478
1746
  try {
1479
1747
  if (client.renameMailbox) {
1480
1748
  await client.renameMailbox(folder.path, newPath);
@@ -1498,7 +1766,7 @@ export class MailxService {
1498
1766
  const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
1499
1767
  if (!folder)
1500
1768
  throw new Error("Folder not found");
1501
- const client = this.imapManager.createPublicClient(accountId);
1769
+ const client = await this.imapManager.createPublicClient(accountId);
1502
1770
  try {
1503
1771
  try {
1504
1772
  if (client.deleteMailbox) {
@@ -1551,7 +1819,7 @@ export class MailxService {
1551
1819
  this.imapManager.emit?.("folderCountsChanged", accountId, {});
1552
1820
  }
1553
1821
  catch { /* non-fatal */ }
1554
- const client = this.imapManager.createPublicClient(accountId);
1822
+ const client = await this.imapManager.createPublicClient(accountId);
1555
1823
  try {
1556
1824
  const uids = await client.getUids(folder.path);
1557
1825
  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
  }