@bobfrankston/mailx 1.0.353 → 1.0.360

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
@@ -5,7 +5,7 @@
5
5
  import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced } from "./components/folder-tree.js";
6
6
  import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages, markBodiesCached } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
8
- import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent } from "./lib/api-client.js";
8
+ import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage } from "./lib/api-client.js";
9
9
  import * as messageState from "./lib/message-state.js";
10
10
  // ── New message badge (favicon + title) ──
11
11
  let baseTitle = "mailx";
@@ -1144,6 +1144,64 @@ if (ftFilterInput) {
1144
1144
  }
1145
1145
  // ── Open links from email body in system browser ──
1146
1146
  window.addEventListener("message", (e) => {
1147
+ // Relay traces from iframes (compose) to Node via our working bridge.
1148
+ // The iframe calls logClientEvent which tries its own bridge first; if
1149
+ // that path is broken it also posts here as backup. Tag gets a `via-relay`
1150
+ // suffix when the iframe couldn't reach its own bridge — that alone
1151
+ // diagnoses whether the iframe bridge works.
1152
+ if (e.data?.type === "mailx-trace" && typeof e.data.tag === "string") {
1153
+ const relayTag = e.data.bridged ? e.data.tag : `${e.data.tag} (via-relay)`;
1154
+ logClientEvent(relayTag, e.data.data);
1155
+ return;
1156
+ }
1157
+ // Compose-send relay: iframe posts the send request here because its own
1158
+ // bridge call to sendMessage was failing to reach Node. This window's
1159
+ // bridge is proven (getAccounts / getOutboxStatus run every few seconds
1160
+ // with no failures), so we do the IPC from here and post the result back
1161
+ // to the iframe via its source. `e.source` is the iframe's window; use it
1162
+ // so targeting works even if the iframe moves in the DOM.
1163
+ if (e.data?.type === "mailx-compose-send" && e.data.id && e.data.body) {
1164
+ const src = e.source;
1165
+ const id = e.data.id;
1166
+ logClientEvent("relay-compose-send-received", { id });
1167
+ (async () => {
1168
+ try {
1169
+ await apiSendMessage(e.data.body);
1170
+ logClientEvent("relay-compose-send-ok", { id });
1171
+ src?.postMessage({ type: "mailx-compose-send-result", id, ok: true }, "*");
1172
+ }
1173
+ catch (err) {
1174
+ const msg = err?.message || String(err);
1175
+ logClientEvent("relay-compose-send-error", { id, error: msg });
1176
+ src?.postMessage({ type: "mailx-compose-send-result", id, ok: false, error: msg }, "*");
1177
+ }
1178
+ })();
1179
+ return;
1180
+ }
1181
+ // Generic IPC relay: the iframe's api-client routes every IPC call through
1182
+ // postMessage when it's running in a child frame. Same reason as the
1183
+ // compose-send relay — sendMessage wasn't the only method the iframe's
1184
+ // bridge dropped; saveDraft hit the same wall ("Draft save failed: mailxapi
1185
+ // timeout"). This handler invokes the named method on THIS window's
1186
+ // mailxapi and posts the result back to the iframe.
1187
+ if (e.data?.type === "mailx-ipc" && e.data.id && e.data.method) {
1188
+ const src = e.source;
1189
+ const { id, method, args } = e.data;
1190
+ const bridge = window.mailxapi;
1191
+ const fn = bridge?.[method];
1192
+ if (typeof fn !== "function") {
1193
+ src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: `parent bridge has no method "${method}"` }, "*");
1194
+ return;
1195
+ }
1196
+ try {
1197
+ const result = fn.apply(bridge, args || []);
1198
+ Promise.resolve(result).then((value) => src?.postMessage({ type: "mailx-ipc-result", id, ok: true, result: value }, "*"), (err) => src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: err?.message || String(err) }, "*"));
1199
+ }
1200
+ catch (err) {
1201
+ src?.postMessage({ type: "mailx-ipc-result", id, ok: false, error: err?.message || String(err) }, "*");
1202
+ }
1203
+ return;
1204
+ }
1147
1205
  if (e.data?.type === "openLink" && e.data.url) {
1148
1206
  window.open(e.data.url, "_blank", "noopener,noreferrer");
1149
1207
  }
@@ -75,6 +75,14 @@ body {
75
75
  padding: var(--gap-xs) 0;
76
76
  }
77
77
 
78
+ /* HTML `hidden` attribute is display:none by UA default, but the `display:
79
+ flex` above wins specificity-wise and the row stays visible. Restore the
80
+ expected hide behavior — the Cc/Bcc toggle buttons flip `hidden` to
81
+ reveal/conceal the rows, which is pointless if CSS forces them on. */
82
+ .compose-field[hidden] {
83
+ display: none;
84
+ }
85
+
78
86
  .compose-field label {
79
87
  flex: 0 0 60px;
80
88
  font-size: var(--font-size-sm);
@@ -4,7 +4,7 @@
4
4
  * Receives init data via window.opener.postMessage or URL params.
5
5
  */
6
6
  import { createEditor } from "./editor.js";
7
- import { getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft, logClientEvent } from "../lib/api-client.js";
7
+ import { getSettings, getAccounts, searchContacts, saveDraft as apiSaveDraft, deleteDraft, logClientEvent } from "../lib/api-client.js";
8
8
  // Very first line the iframe runs — if this doesn't reach Node, the iframe
9
9
  // itself isn't loading or the bridge is completely broken.
10
10
  logClientEvent("compose-module-loaded", { href: location.href, version: window.mailxVersion || "?" });
@@ -621,26 +621,46 @@ document.getElementById("btn-send")?.addEventListener("click", () => {
621
621
  if (statusEl)
622
622
  statusEl.textContent = "";
623
623
  const sendStart = Date.now();
624
- let ipcPromise;
625
624
  logClientEvent("compose-send-pre-ipc");
626
- try {
627
- ipcPromise = sendMessage(body);
628
- logClientEvent("compose-send-ipc-invoked", { promiseType: typeof ipcPromise, isThenable: !!(ipcPromise && typeof ipcPromise.then === "function") });
629
- }
630
- catch (e) {
631
- const msg = e?.message || String(e);
632
- logClientEvent("compose-send-sync-throw", { error: msg });
633
- console.error(`[compose] Send threw synchronously: ${msg}`);
634
- if (sendBtn) {
635
- sendBtn.disabled = false;
636
- sendBtn.textContent = "Send";
625
+ // Parent-window relay for send. Empirical observation: Android (direct
626
+ // in-process SMTP) sends reliably; desktop (iframe → parent.mailxapi →
627
+ // msger Node service) has sendMessage IPCs failing to reach Node
628
+ // for reasons still unknown (iframe bridge behaves differently from the
629
+ // top frame in msger's WebView2 for this specific call). Meanwhile the
630
+ // parent window's bridge is proven — getAccounts / getOutboxStatus run
631
+ // through it every few seconds with no failures.
632
+ //
633
+ // Fix: the iframe doesn't touch the bridge at all for send. It posts
634
+ // a request to the parent, and the parent calls the real sendMessage
635
+ // from its own frame. Parent posts the result back. This bypasses
636
+ // whatever is wrong with iframe-scoped IPC.
637
+ const ipcPromise = new Promise((resolve, reject) => {
638
+ const reqId = `send-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
639
+ const timer = setTimeout(() => {
640
+ window.removeEventListener("message", onMsg);
641
+ reject(new Error("parent-relay send timeout (120s)"));
642
+ }, 120000);
643
+ const onMsg = (ev) => {
644
+ if (!ev.data || ev.data.type !== "mailx-compose-send-result" || ev.data.id !== reqId)
645
+ return;
646
+ clearTimeout(timer);
647
+ window.removeEventListener("message", onMsg);
648
+ if (ev.data.ok)
649
+ resolve();
650
+ else
651
+ reject(new Error(ev.data.error || "unknown"));
652
+ };
653
+ window.addEventListener("message", onMsg);
654
+ try {
655
+ parent.postMessage({ type: "mailx-compose-send", id: reqId, body }, "*");
656
+ logClientEvent("compose-send-ipc-invoked", { via: "parent-relay", reqId });
637
657
  }
638
- if (statusEl)
639
- statusEl.textContent = `Send failed: ${msg}`;
640
- else
641
- alert(`Send failed: ${msg}`);
642
- return;
643
- }
658
+ catch (e) {
659
+ clearTimeout(timer);
660
+ window.removeEventListener("message", onMsg);
661
+ reject(e);
662
+ }
663
+ });
644
664
  Promise.resolve(ipcPromise)
645
665
  .then(() => {
646
666
  logClientEvent("compose-send-ipc-resolved", { ms: Date.now() - sendStart });
@@ -220,10 +220,24 @@ function createQuillEditor(container) {
220
220
  document.querySelectorAll(".ql-toolbar button, .ql-toolbar select, .ql-toolbar .ql-picker-label").forEach(el => el.setAttribute("tabindex", "-1"));
221
221
  // Native spell-check: WebView2 / Chromium underlines misspellings and the
222
222
  // right-click menu offers "Add to dictionary". Quill clears spellcheck on
223
- // its root by default, so we turn it back on explicitly.
224
- q.root.setAttribute("spellcheck", "true");
225
- q.root.setAttribute("autocorrect", "on");
226
- q.root.setAttribute("autocapitalize", "on");
223
+ // its root by default AND may re-clear it asynchronously as part of its
224
+ // setup (user-reported "spell-check broken again" with the one-shot set).
225
+ // Set once now, again after the frame flushes so post-init clears can't
226
+ // win, and install a MutationObserver to re-assert if anything later
227
+ // strips the attribute. Cheap — fires at most on each clear.
228
+ const applySpellcheck = () => {
229
+ if (q.root.getAttribute("spellcheck") !== "true")
230
+ q.root.setAttribute("spellcheck", "true");
231
+ if (q.root.getAttribute("autocorrect") !== "on")
232
+ q.root.setAttribute("autocorrect", "on");
233
+ if (q.root.getAttribute("autocapitalize") !== "on")
234
+ q.root.setAttribute("autocapitalize", "on");
235
+ };
236
+ applySpellcheck();
237
+ requestAnimationFrame(applySpellcheck);
238
+ setTimeout(applySpellcheck, 100);
239
+ const spellObs = new MutationObserver(() => applySpellcheck());
240
+ spellObs.observe(q.root, { attributes: true, attributeFilter: ["spellcheck", "autocorrect", "autocapitalize"] });
227
241
  // Toolbar link button: open our modal instead of Quill's built-in URL prompt.
228
242
  const toolbar = q.getModule("toolbar");
229
243
  toolbar?.addHandler("link", function () {
@@ -288,17 +302,30 @@ function createQuillEditor(container) {
288
302
  }
289
303
  catch { /* fall through to native menu */ }
290
304
  });
305
+ // IMPORTANT: register on the capture phase AND use stopImmediatePropagation
306
+ // when we handle the paste ourselves. Quill 2.x's clipboard module attaches
307
+ // its own listener on the same root element; preventDefault only stops the
308
+ // browser's default contenteditable insertion, NOT Quill's parallel listener.
309
+ // Without stopImmediatePropagation the two handlers fire independently and
310
+ // both insert — user sees the URL twice. Capture phase guarantees we run
311
+ // before Quill so stopImmediatePropagation actually blocks it.
291
312
  q.root.addEventListener("paste", (e) => {
292
313
  const cb = e.clipboardData;
293
314
  if (!cb)
294
315
  return;
316
+ // Helper: call when we've handled the paste ourselves. Stops Quill's
317
+ // own listener from also processing the same event.
318
+ const consume = () => {
319
+ e.preventDefault();
320
+ e.stopImmediatePropagation();
321
+ };
295
322
  // Q3: image-on-clipboard → inline as data: URL.
296
323
  for (const item of Array.from(cb.items)) {
297
324
  if (item.kind === "file" && item.type.startsWith("image/")) {
298
325
  const file = item.getAsFile();
299
326
  if (!file)
300
327
  continue;
301
- e.preventDefault();
328
+ consume();
302
329
  const reader = new FileReader();
303
330
  reader.onload = () => {
304
331
  const dataUrl = String(reader.result || "");
@@ -337,7 +364,7 @@ function createQuillEditor(container) {
337
364
  const href = a.getAttribute("href") || "";
338
365
  const text = (a.textContent || "").trim();
339
366
  if (href && text) {
340
- e.preventDefault();
367
+ consume();
341
368
  const range = q.getSelection(true);
342
369
  if (!range)
343
370
  return;
@@ -350,12 +377,35 @@ function createQuillEditor(container) {
350
377
  return;
351
378
  }
352
379
  }
380
+ // Single text-node wrapping the URL — common when copying from
381
+ // browser address bar (Chrome ships text/html as
382
+ // `<meta><span>URL</span>` alongside text/plain). Fall through
383
+ // to the plain-URL path below instead of letting Quill insert
384
+ // the bare URL text AND our handler insert it linked — which
385
+ // is exactly the double-paste the user reported.
386
+ const textOnly = (root.textContent || "").trim();
387
+ if (textOnly && looksLikeUrl(textOnly) && !root.querySelector("a")) {
388
+ consume();
389
+ const range = q.getSelection(true);
390
+ if (!range)
391
+ return;
392
+ const url = normalizeUrl(textOnly);
393
+ if (range.length > 0) {
394
+ q.formatText(range.index, range.length, "link", url);
395
+ q.setSelection(range.index + range.length, 0);
396
+ }
397
+ else {
398
+ q.insertText(range.index, textOnly, { link: url });
399
+ q.setSelection(range.index + textOnly.length, 0);
400
+ }
401
+ return;
402
+ }
353
403
  }
354
404
  catch { /* fall through to Quill default */ }
355
- return; // Quill handles richer HTML clipboard
405
+ return; // Quill handles richer HTML clipboard (no consume → Quill runs)
356
406
  }
357
407
  if (plain && looksLikeUrl(plain)) {
358
- e.preventDefault();
408
+ consume();
359
409
  const range = q.getSelection(true);
360
410
  if (!range)
361
411
  return;
@@ -370,7 +420,7 @@ function createQuillEditor(container) {
370
420
  q.setSelection(range.index + plain.trim().length, 0);
371
421
  }
372
422
  }
373
- });
423
+ }, true); // capture=true — run before Quill's own paste listener
374
424
  // Hover preview: show the target URL in a floating tooltip when the
375
425
  // pointer is over a link. Built on top of native mouseover/mouseout
376
426
  // rather than Quill's ql-tooltip (which is keyboard-triggered).
@@ -12,7 +12,76 @@ function getIpc() {
12
12
  return window.parent.mailxapi;
13
13
  return null;
14
14
  }
15
+ /** Build a proxy bridge that forwards every method call to the parent window
16
+ * via postMessage. Used when the compose iframe's own attempt to reach
17
+ * msger's IPC silently drops messages — empirically, `sendMessage` and
18
+ * `saveDraft` both hit this (user-visible: "Sending…" spinner forever;
19
+ * "Draft save failed: mailxapi timeout"). The main window's bridge is
20
+ * provably fine, so the iframe routes through it. */
21
+ function buildRelayBridge() {
22
+ const pending = new Map();
23
+ window.addEventListener("message", (ev) => {
24
+ if (!ev.data || ev.data.type !== "mailx-ipc-result" || !ev.data.id)
25
+ return;
26
+ const entry = pending.get(ev.data.id);
27
+ if (!entry)
28
+ return;
29
+ pending.delete(ev.data.id);
30
+ clearTimeout(entry.timer);
31
+ if (ev.data.ok)
32
+ entry.resolve(ev.data.result);
33
+ else
34
+ entry.reject(new Error(ev.data.error || "parent-relay ipc error"));
35
+ });
36
+ const call = (method, args) => {
37
+ const id = `ipc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
38
+ return new Promise((resolve, reject) => {
39
+ const timer = setTimeout(() => {
40
+ pending.delete(id);
41
+ reject(new Error(`parent-relay timeout: ${method}`));
42
+ }, 120000);
43
+ pending.set(id, { resolve, reject, timer });
44
+ try {
45
+ window.parent.postMessage({ type: "mailx-ipc", id, method, args }, "*");
46
+ }
47
+ catch (e) {
48
+ clearTimeout(timer);
49
+ pending.delete(id);
50
+ reject(e);
51
+ }
52
+ });
53
+ };
54
+ // Proxy: any property access returns a function that forwards to parent.
55
+ // `isApp` / `platform` / other non-function reads return sensible defaults
56
+ // so existing getIpc-style checks still work.
57
+ return new Proxy({}, {
58
+ get(_t, prop) {
59
+ if (prop === "isApp")
60
+ return true;
61
+ if (prop === "platform")
62
+ return window.parent?.mailxapi?.platform || "webview2";
63
+ if (prop === "onEvent") {
64
+ // Event subscription can't be relayed simply — iframes that need
65
+ // events are rare. Fall back to direct parent bridge for onEvent
66
+ // since the subscription path doesn't hit the broken send path.
67
+ return (handler) => window.parent?.mailxapi?.onEvent?.(handler);
68
+ }
69
+ return (...args) => call(prop, args);
70
+ }
71
+ });
72
+ }
73
+ let cachedRelayBridge = null;
15
74
  function ipc() {
75
+ // Direct bridge is fine for the top window (main mailx app). The iframe
76
+ // (compose) can't trust its own bridge resolution because msger-routed
77
+ // sendMessage / saveDraft IPCs disappear without trace. So when we're in
78
+ // a child frame with a parent bridge, go through the parent.
79
+ const inIframe = window.parent && window.parent !== window;
80
+ if (inIframe && window.parent?.mailxapi?.isApp) {
81
+ if (!cachedRelayBridge)
82
+ cachedRelayBridge = buildRelayBridge();
83
+ return cachedRelayBridge;
84
+ }
16
85
  const bridge = getIpc();
17
86
  if (!bridge)
18
87
  throw new Error("IPC bridge not available");
@@ -130,18 +199,32 @@ export function emptyFolder(accountId, folderId) {
130
199
  return ipc().emptyFolder?.(accountId, folderId);
131
200
  }
132
201
  /** Ship a named event to the Node log as `[client] <tag> <data>`. Fire and
133
- * forget — never awaits, never throws, never blocks the caller. If the IPC
134
- * bridge is unavailable, the call is a no-op so tracing calls scattered
135
- * through the UI don't become failure points themselves. */
202
+ * forget — never awaits, never throws, never blocks the caller. Tries two
203
+ * paths so a broken primary channel can't swallow the trace:
204
+ * 1. Direct bridge call (self / opener / parent mailxapi).
205
+ * 2. parent.postMessage fallback — the main window listens and relays.
206
+ * The fallback matters because the whole point of tracing is to diagnose
207
+ * a broken iframe bridge; a single-path tracer that goes through that same
208
+ * bridge is useless in exactly the case we need it. */
136
209
  export function logClientEvent(tag, data) {
210
+ let delivered = false;
137
211
  try {
138
212
  const bridge = typeof globalThis.mailxapi !== "undefined" && globalThis.mailxapi?.isApp ? globalThis.mailxapi
139
213
  : window.opener?.mailxapi?.isApp ? window.opener.mailxapi
140
214
  : window.parent?.mailxapi?.isApp ? window.parent.mailxapi
141
215
  : null;
142
- bridge?.logClientEvent?.(tag, data);
216
+ if (bridge?.logClientEvent) {
217
+ bridge.logClientEvent(tag, data);
218
+ delivered = true;
219
+ }
143
220
  }
144
221
  catch { /* never throw from tracing */ }
222
+ try {
223
+ if (window.parent && window.parent !== window) {
224
+ window.parent.postMessage({ type: "mailx-trace", tag, data, bridged: delivered }, "*");
225
+ }
226
+ }
227
+ catch { /* */ }
145
228
  }
146
229
  export function sendMessage(body) {
147
230
  return ipc().sendMessage?.(body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.353",
3
+ "version": "1.0.360",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -102,7 +102,7 @@ export class MailxService {
102
102
  for (const cfg of cfgs) {
103
103
  const a = dbAccounts.find(d => d.id === cfg.id);
104
104
  if (a)
105
- ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, identityDomains: cfg.identityDomains || [], spam: cfg.spam || "" });
105
+ ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, identityDomains: cfg.identityDomains || [] });
106
106
  }
107
107
  // Append any DB accounts not in settings
108
108
  for (const a of dbAccounts) {
@@ -307,43 +307,36 @@ const PROVIDERS = {
307
307
  label: "Gmail",
308
308
  imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
309
309
  smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
310
- spam: "SPAM", // Gmail labels, mailx tree shows as "SPAM"
311
310
  },
312
311
  "googlemail.com": {
313
312
  label: "Gmail",
314
313
  imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
315
314
  smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
316
- spam: "SPAM",
317
315
  },
318
316
  "outlook.com": {
319
317
  label: "Outlook",
320
318
  imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
321
319
  smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
322
- spam: "Junk Email",
323
320
  },
324
321
  "hotmail.com": {
325
322
  label: "Hotmail",
326
323
  imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
327
324
  smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
328
- spam: "Junk Email",
329
325
  },
330
326
  "yahoo.com": {
331
327
  label: "Yahoo",
332
328
  imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "password" },
333
329
  smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "password" },
334
- spam: "Bulk Mail",
335
330
  },
336
331
  "aol.com": {
337
332
  label: "AOL",
338
333
  imap: { host: "imap.aol.com", port: 993, tls: true, auth: "password" },
339
334
  smtp: { host: "smtp.aol.com", port: 587, tls: true, auth: "password" },
340
- spam: "Bulk Mail",
341
335
  },
342
336
  "icloud.com": {
343
337
  label: "iCloud",
344
338
  imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "password" },
345
339
  smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" },
346
- spam: "Junk",
347
340
  },
348
341
  };
349
342
  /** Fill in provider defaults for an account based on email domain */
@@ -388,13 +381,10 @@ function normalizeAccount(acct, globalName) {
388
381
  relayDomains: acct.relayDomains,
389
382
  deliveredToPrefix: acct.deliveredToPrefix,
390
383
  identityDomains: acct.identityDomains,
391
- // Spam folder: explicit account config wins; otherwise fall back to
392
- // the provider default (e.g. Gmail ships with built-in SPAM; Outlook
393
- // with "Junk Email"). Before 2026-04-21 this field was dropped by
394
- // normalizeAccount entirely silent regression even for accounts
395
- // that had it configured. `acct.spam` first so a user-set value on
396
- // a recognized provider still overrides the default.
397
- spam: acct.spam !== undefined ? acct.spam : provider?.spam,
384
+ // `spam` passthrough retired 2026-04-22 markAsSpamMessages now finds
385
+ // the junk folder via `specialUse === "junk"` on the DB folder record
386
+ // (populated by mailx-imap from iflow's getSpecialFolders()). Authoritative
387
+ // per-server info beats per-domain guesses.
398
388
  // `signature` is on AccountConfig in mailx-types but the workspace
399
389
  // build order sometimes leaves a stale .d.ts for type-check; using
400
390
  // `as any` is the minimum-blast-radius way to add the field without