@bobfrankston/mailx 1.0.438 → 1.0.440
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();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Message viewer component -- displays full message in sandboxed iframe.
|
|
3
3
|
* Subscribes to message-state: clears when selected becomes null.
|
|
4
4
|
*/
|
|
5
|
-
import { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick } from "../lib/api-client.js";
|
|
5
|
+
import { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick, addPreferredContact } from "../lib/api-client.js";
|
|
6
6
|
import { showContextMenu } from "./context-menu.js";
|
|
7
7
|
import * as state from "../lib/message-state.js";
|
|
8
8
|
/** Currently displayed message (for reply/forward) */
|
|
@@ -348,6 +348,28 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
348
348
|
await showAddContactDialog(name, addr.address);
|
|
349
349
|
},
|
|
350
350
|
});
|
|
351
|
+
// "Add to preferred" — separate path: writes to
|
|
352
|
+
// contacts.jsonc#preferred[] with an optional source tag.
|
|
353
|
+
// Distinct from "Add to contacts" which goes into the DB +
|
|
354
|
+
// pushes to Google. Preferred entries rank higher in
|
|
355
|
+
// autocomplete and survive Google sync's churn.
|
|
356
|
+
items.push({
|
|
357
|
+
label: `Add to preferred: ${addr.address}`,
|
|
358
|
+
action: async () => {
|
|
359
|
+
const tag = prompt("Tag (e.g. work, family, vendor) — leave blank for default:", "");
|
|
360
|
+
if (tag === null)
|
|
361
|
+
return; // user cancelled
|
|
362
|
+
try {
|
|
363
|
+
await addPreferredContact({ name, email: addr.address, source: tag.trim() || undefined });
|
|
364
|
+
const status = document.getElementById("status-sync");
|
|
365
|
+
if (status)
|
|
366
|
+
status.textContent = `Added to preferred: ${addr.address}${tag ? ` [${tag}]` : ""}`;
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
alert(`Couldn't add to preferred: ${e?.message || e}`);
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
});
|
|
351
373
|
items.push({ label: "", action: () => { }, separator: true });
|
|
352
374
|
}
|
|
353
375
|
items.push({ label: "Reply", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })) });
|
|
@@ -548,12 +570,22 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
548
570
|
const toAddr = msg.to?.[0]?.address || "";
|
|
549
571
|
const returnPath = msg.returnPath || "";
|
|
550
572
|
const isFlagged = !!msg.isFlagged;
|
|
573
|
+
const reputation = msg.reputation;
|
|
574
|
+
const reputationFlagged = !!(reputation && reputation.flagged);
|
|
575
|
+
const reputationText = reputationFlagged
|
|
576
|
+
? `⚠ ${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(", "))})`
|
|
577
|
+
: "";
|
|
551
578
|
const banner = document.createElement("div");
|
|
552
|
-
banner.className = "mv-remote-banner"
|
|
579
|
+
banner.className = "mv-remote-banner"
|
|
580
|
+
+ (isFlagged ? " mv-remote-banner-flagged" : "")
|
|
581
|
+
+ (reputationFlagged ? " mv-remote-banner-reputation" : "");
|
|
553
582
|
banner.innerHTML =
|
|
554
583
|
(isFlagged
|
|
555
584
|
? `<div class="mv-rb-flagged">⚠ FLAGGED: this sender or domain is on your flagged list</div>`
|
|
556
585
|
: "") +
|
|
586
|
+
(reputationFlagged
|
|
587
|
+
? `<div class="mv-rb-reputation">${reputationText}</div>`
|
|
588
|
+
: "") +
|
|
557
589
|
`<div class="mv-rb-summary">` +
|
|
558
590
|
`<span class="mv-rb-toggle">▸</span>` +
|
|
559
591
|
`<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 three free DNS blocklists in parallel: Spamhaus DBL, SURBL, URIBL. The banner shows N-of-3 services flagging the domain. Each query leaks the bare domain to that DNSBL's DNS. Off by default."><input type="checkbox" id="opt-check-reputation"> Check sender reputation (DNSBLs)</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>
|
|
@@ -183,6 +184,7 @@
|
|
|
183
184
|
</header>
|
|
184
185
|
<div class="cal-side-actions">
|
|
185
186
|
<button class="cal-side-new" id="cal-side-new" title="New event">+ New event</button>
|
|
187
|
+
<button class="cal-side-new" id="cal-side-refresh-events" title="Refresh events from Google">↻</button>
|
|
186
188
|
<label class="cal-side-opt" title="Include expanded instances of recurring events">
|
|
187
189
|
<input type="checkbox" id="cal-side-show-recurring" checked> Show recurring
|
|
188
190
|
</label>
|
|
@@ -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. */
|