@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" + (isFlagged ? " mv-remote-banner-flagged" : "");
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">&#x25B8;</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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.438",
3
+ "version": "1.0.440",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -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. */