@bobfrankston/mailx 1.0.437 → 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
  }