@bobfrankston/mailx 1.0.438 → 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/client/app.js
CHANGED
|
@@ -2873,6 +2873,21 @@ optAiProofread?.addEventListener("change", () => {
|
|
|
2873
2873
|
}
|
|
2874
2874
|
catch { /* */ }
|
|
2875
2875
|
});
|
|
2876
|
+
// Sender reputation check (Spamhaus DBL). Stored at top-level settings so
|
|
2877
|
+
// the service can read it cheaply without going through autocomplete config.
|
|
2878
|
+
// Off by default — enabling it leaks read-recipient domains to Spamhaus's
|
|
2879
|
+
// DNS infra, which the user should opt into knowingly.
|
|
2880
|
+
const optCheckReputation = document.getElementById("opt-check-reputation");
|
|
2881
|
+
getSettings().then((s) => {
|
|
2882
|
+
if (optCheckReputation)
|
|
2883
|
+
optCheckReputation.checked = !!s.checkDomainReputation;
|
|
2884
|
+
}).catch(() => { });
|
|
2885
|
+
optCheckReputation?.addEventListener("change", () => {
|
|
2886
|
+
getSettings().then((settings) => {
|
|
2887
|
+
settings.checkDomainReputation = !!optCheckReputation.checked;
|
|
2888
|
+
saveSettings(settings);
|
|
2889
|
+
}).catch(() => { });
|
|
2890
|
+
});
|
|
2876
2891
|
const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
2877
2892
|
// Wait for server ready signal, then fetch version
|
|
2878
2893
|
const versionPromise = getVersion();
|
|
@@ -548,12 +548,22 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
548
548
|
const toAddr = msg.to?.[0]?.address || "";
|
|
549
549
|
const returnPath = msg.returnPath || "";
|
|
550
550
|
const isFlagged = !!msg.isFlagged;
|
|
551
|
+
const reputation = msg.reputation;
|
|
552
|
+
const reputationFlagged = !!(reputation && reputation.flagged);
|
|
553
|
+
const reputationText = reputationFlagged
|
|
554
|
+
? `⚠ ${reputation.listedCount} of ${reputation.checkedCount} reputation services flag <strong>${escapeText(senderDomain)}</strong> as <strong>${escapeText(reputation.verdict)}</strong> (${escapeText(reputation.sources.map(s => s.service).join(", "))})`
|
|
555
|
+
: "";
|
|
551
556
|
const banner = document.createElement("div");
|
|
552
|
-
banner.className = "mv-remote-banner"
|
|
557
|
+
banner.className = "mv-remote-banner"
|
|
558
|
+
+ (isFlagged ? " mv-remote-banner-flagged" : "")
|
|
559
|
+
+ (reputationFlagged ? " mv-remote-banner-reputation" : "");
|
|
553
560
|
banner.innerHTML =
|
|
554
561
|
(isFlagged
|
|
555
562
|
? `<div class="mv-rb-flagged">⚠ FLAGGED: this sender or domain is on your flagged list</div>`
|
|
556
563
|
: "") +
|
|
564
|
+
(reputationFlagged
|
|
565
|
+
? `<div class="mv-rb-reputation">${reputationText}</div>`
|
|
566
|
+
: "") +
|
|
557
567
|
`<div class="mv-rb-summary">` +
|
|
558
568
|
`<span class="mv-rb-toggle">▸</span>` +
|
|
559
569
|
`<span>Remote content blocked</span>` +
|
package/client/index.html
CHANGED
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
<label class="tb-menu-item" title="Ghost-text completions while composing — Ollama / Claude / OpenAI back-end, off by default"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
|
|
56
56
|
<label class="tb-menu-item" title="Right-click in message body → Translate"><input type="checkbox" id="opt-ai-translate"> AI translate (off by default)</label>
|
|
57
57
|
<label class="tb-menu-item" title="Right-click in compose editor → Proofread (when wired)"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
|
|
58
|
+
<label class="tb-menu-item" title="When opening a message with remote content, look up the sender's domain in Spamhaus DBL. Domain leaks to Spamhaus's DNS. Off by default."><input type="checkbox" id="opt-check-reputation"> Check sender reputation (Spamhaus DBL)</label>
|
|
58
59
|
<hr class="tb-menu-sep">
|
|
59
60
|
<button class="tb-menu-item" id="btn-edit-jsonc" title="Edit accounts.jsonc / allowlist.jsonc">Edit config files...</button>
|
|
60
61
|
<button class="tb-menu-item" id="btn-open-mailx-dir" title="Open ~/.mailx in file explorer">Open mailx folder...</button>
|
|
@@ -1536,6 +1536,18 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
|
|
|
1536
1536
|
.mv-remote-banner-flagged { box-shadow: inset 0 0 0 2px oklch(0.55 0.22 25); }
|
|
1537
1537
|
.mv-rb-flag-btn { background: oklch(0.42 0.20 25); }
|
|
1538
1538
|
|
|
1539
|
+
/* External reputation hit (Spamhaus DBL etc) — orange strip, distinct from
|
|
1540
|
+
the user's manual red flag. Renders below the user-flag strip when both
|
|
1541
|
+
are present so the precedence is visible. */
|
|
1542
|
+
.mv-rb-reputation {
|
|
1543
|
+
background: oklch(0.55 0.18 50);
|
|
1544
|
+
color: white;
|
|
1545
|
+
padding: var(--gap-xs) var(--gap-md);
|
|
1546
|
+
font-weight: 600;
|
|
1547
|
+
letter-spacing: 0.02em;
|
|
1548
|
+
}
|
|
1549
|
+
.mv-remote-banner-reputation { box-shadow: inset 0 0 0 2px oklch(0.65 0.20 50); }
|
|
1550
|
+
|
|
1539
1551
|
.mv-rb-summary {
|
|
1540
1552
|
display: flex;
|
|
1541
1553
|
align-items: center;
|
package/package.json
CHANGED
|
@@ -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;
|
|
@@ -82,6 +94,30 @@ export declare class MailxService {
|
|
|
82
94
|
closeWordEdit(editId: string): Promise<void>;
|
|
83
95
|
updateFlags(accountId: string, uid: number, flags: string[]): Promise<void>;
|
|
84
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>;
|
|
85
121
|
/** Mark a sender or domain as suspect. Surfaced in the remote-content
|
|
86
122
|
* banner as a red warning on subsequent messages. Toggle: calling with
|
|
87
123
|
* the same value removes it. Returns the new state for UI feedback. */
|
|
@@ -311,4 +347,5 @@ export declare class MailxService {
|
|
|
311
347
|
* default false. Returns empty text + reason when disabled or on error. */
|
|
312
348
|
aiTransform(req: AiTransformRequest): Promise<AiTransformResponse>;
|
|
313
349
|
}
|
|
350
|
+
export {};
|
|
314
351
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -468,11 +468,22 @@ export class MailxService {
|
|
|
468
468
|
const senderDomain = senderAddr.split("@")[1] || "";
|
|
469
469
|
const isFlagged = !!((allowList.flaggedSenders || []).some((s) => (s || "").toLowerCase() === senderAddr) ||
|
|
470
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
|
+
}
|
|
471
481
|
return {
|
|
472
482
|
...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
|
|
473
483
|
attachments, emlPath, deliveredTo, returnPath,
|
|
474
484
|
listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
|
|
475
485
|
isFlagged,
|
|
486
|
+
reputation,
|
|
476
487
|
};
|
|
477
488
|
}
|
|
478
489
|
/** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
|
|
@@ -687,6 +698,81 @@ export class MailxService {
|
|
|
687
698
|
await saveAllowlist(list);
|
|
688
699
|
console.log(` [allow] Added ${type}: ${value}`);
|
|
689
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
|
+
}
|
|
690
776
|
/** Mark a sender or domain as suspect. Surfaced in the remote-content
|
|
691
777
|
* banner as a red warning on subsequent messages. Toggle: calling with
|
|
692
778
|
* the same value removes it. Returns the new state for UI feedback. */
|