@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.
- package/bin/mailx.js +5 -0
- package/client/app.js +43 -0
- package/client/components/message-viewer.js +50 -3
- package/client/compose/compose.html +1 -0
- package/client/compose/compose.js +49 -1
- package/client/index.html +6 -0
- package/client/lib/api-client.js +9 -0
- package/client/lib/mailxapi.js +9 -0
- package/client/styles/components.css +23 -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 +64 -0
- package/packages/mailx-service/index.js +272 -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
|
@@ -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();
|
|
@@ -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
|
}
|