@bobfrankston/rmfmail 1.2.323 → 1.2.325

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.
Files changed (40) hide show
  1. package/.commitmsg +17 -15
  2. package/.llm/config-cache-crash.md +26 -0
  3. package/.llm/trusted-lists.md +1 -0
  4. package/bin/mailx.js +96 -25
  5. package/bin/mailx.js.map +1 -1
  6. package/bin/mailx.ts +105 -16
  7. package/client/android-bootstrap.bundle.js +5 -2
  8. package/client/android-bootstrap.bundle.js.map +2 -2
  9. package/client/app.bundle.js +11 -3
  10. package/client/app.bundle.js.map +2 -2
  11. package/client/app.js +15 -1
  12. package/client/app.js.map +1 -1
  13. package/client/app.ts +13 -1
  14. package/client/components/message-viewer.js +12 -2
  15. package/client/components/message-viewer.js.map +1 -1
  16. package/client/components/message-viewer.ts +13 -3
  17. package/docs/allowlist.md +1 -0
  18. package/npmchanges.md +47 -0
  19. package/package.json +5 -5
  20. package/packages/mailx-imap/index.d.ts.map +1 -1
  21. package/packages/mailx-imap/index.js +7 -2
  22. package/packages/mailx-imap/index.js.map +1 -1
  23. package/packages/mailx-imap/index.ts +7 -2
  24. package/packages/mailx-imap/package-lock.json +2 -2
  25. package/packages/mailx-imap/package.json +1 -1
  26. package/packages/mailx-settings/docs/allowlist.md +1 -0
  27. package/packages/mailx-settings/index.d.ts +15 -0
  28. package/packages/mailx-settings/index.d.ts.map +1 -1
  29. package/packages/mailx-settings/index.js +71 -14
  30. package/packages/mailx-settings/index.js.map +1 -1
  31. package/packages/mailx-settings/index.ts +65 -14
  32. package/packages/mailx-settings/package.json +1 -1
  33. package/packages/mailx-store/package.json +1 -1
  34. package/packages/mailx-store-web/package.json +1 -1
  35. package/packages/mailx-types/package.json +1 -1
  36. package/packages/mailx-types/trust.d.ts +4 -0
  37. package/packages/mailx-types/trust.d.ts.map +1 -1
  38. package/packages/mailx-types/trust.js +17 -1
  39. package/packages/mailx-types/trust.js.map +1 -1
  40. package/packages/mailx-types/trust.ts +20 -1
package/bin/mailx.ts CHANGED
@@ -2025,7 +2025,7 @@ async function main(): Promise<void> {
2025
2025
  { ImapManager },
2026
2026
  { MailxService, spawnSyncWorker },
2027
2027
  { dispatch, setDebugEvalSink },
2028
- { loadSettings, loadAccountsAsync, loadAllowlistAsync, getConfigDir, getStorageInfo, getStorePath },
2028
+ { loadSettings, loadAccounts, loadAccountsAsync, loadAllowlistAsync, getConfigDir, getStorageInfo, getStorePath },
2029
2029
  { NodeTcpTransport },
2030
2030
  { FileMessageStore },
2031
2031
  ] = await Promise.all([
@@ -2064,20 +2064,24 @@ async function main(): Promise<void> {
2064
2064
  //
2065
2065
  // The local accounts.jsonc cache (loaded synchronously above) has the
2066
2066
  // last-known state; that's what every account+folder+message UI read
2067
- // depends on. Cloud refresh's only job is "another device added an
2068
- // account" fire-and-forget, log + warn if it diverges, the user can
2069
- // restart for it to take effect. Was a band-aid hidden in a sync
2070
- // path; per feedback_no_bandaids, fix the real cause.
2071
- void (async () => {
2072
- try {
2073
- const cloudAccounts = await loadAccountsAsync();
2074
- if (cloudAccounts.length > 0 && cloudAccounts.length !== settings.accounts.length) {
2075
- console.log(` [cloud] accounts diverged: cache=${settings.accounts.length} cloud=${cloudAccounts.length} restart to apply`);
2076
- }
2077
- } catch (e: any) {
2078
- console.error(` [cloud] background account refresh failed: ${e?.message || e}`);
2079
- }
2080
- })();
2067
+ // depends on. The cloud refresh runs in the background; its result is
2068
+ // applied once the account machinery exists (see the continuation after
2069
+ // watchConfigFiles() below).
2070
+ //
2071
+ // 2026-09-13 17:20 EDT — Claude Code (Fable 5.1), at Bob's direction.
2072
+ // This used to log "accounts diverged — restart to apply" and stop. A
2073
+ // kernel crash at 16:36 today zero-filled the local accounts.jsonc, the
2074
+ // daemon booted with 0 accounts, and the cloud copy (4 accounts) sat
2075
+ // unused behind a banner. Now the promise is kept and the divergence is
2076
+ // reconciled in-process through the same configChanged path an on-disk
2077
+ // edit takes (reconcileAccountsFromDisk) — one mechanism, two triggers.
2078
+ // Typed off the loader: mailx-types is not a dependency of this package.
2079
+ type AccountList = Awaited<ReturnType<typeof loadAccountsAsync>>;
2080
+ const cloudAccountsRefresh = loadAccountsAsync().catch((e: any): AccountList => {
2081
+ // The boot snapshot stays in force; the 3-minute cloud poll retries.
2082
+ console.error(` [cloud] background account refresh failed: ${e?.message || e}`);
2083
+ return [];
2084
+ });
2081
2085
 
2082
2086
  const db = new MailxDB(getConfigDir());
2083
2087
  _tick("DB opened");
@@ -2870,8 +2874,78 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
2870
2874
  imapManager.on("accountError", (accountId: string, error: string, hint: string, isOAuth: boolean) => {
2871
2875
  handle.send({ _event: "accountError", type: "accountError", accountId, error, hint, isOAuth });
2872
2876
  });
2877
+ /** Bring the running daemon in line with accounts.jsonc as it is on disk.
2878
+ * New enabled accounts are added live (addAccount + inbox check + sync +
2879
+ * IDLE). Anything that can't be applied to a live account — one removed,
2880
+ * disabled, or with changed settings — is reported with restartNeeded so
2881
+ * the client shows the Restart banner only when a restart really is the
2882
+ * fix. Idempotent: addAccount ignores ids it already has, and the boot
2883
+ * snapshot (settings.accounts) is updated in place.
2884
+ *
2885
+ * 2026-09-13 17:20 EDT — Claude Code (Fable 5.1), at Bob's direction.
2886
+ * Before this every accounts.jsonc change, including the daemon's own
2887
+ * repair of a crash-zeroed cache from the cloud copy, produced only
2888
+ * "restart to apply". */
2889
+ async function reconcileAccountsFromDisk(): Promise<{ restartNeeded: boolean; detail: string }> {
2890
+ const onDisk = loadAccounts();
2891
+ if (onDisk.length === 0) {
2892
+ // A file that reads as empty is a broken file, not a request to
2893
+ // drop every account (readJsonc has already logged the parse error).
2894
+ return { restartNeeded: false, detail: "accounts.jsonc has no accounts — keeping the running set" };
2895
+ }
2896
+ const snapshot = new Map(settings.accounts.map(a => [a.id, a]));
2897
+ const diskIds = new Set(onDisk.map(a => a.id));
2898
+ const added = onDisk.filter(a => a.enabled && !snapshot.has(a.id));
2899
+ const removed = settings.accounts.filter(a => !diskIds.has(a.id)).map(a => a.id);
2900
+ const changed = onDisk
2901
+ .filter(a => snapshot.has(a.id) && JSON.stringify(snapshot.get(a.id)) !== JSON.stringify(a))
2902
+ .map(a => a.id);
2903
+ if (added.length === 0 && removed.length === 0 && changed.length === 0) {
2904
+ return { restartNeeded: false, detail: "accounts.jsonc updated — no account changes" };
2905
+ }
2906
+ const applied: string[] = [];
2907
+ for (const account of added) {
2908
+ try {
2909
+ await imapManager.addAccount(account);
2910
+ settings.accounts.push(account);
2911
+ applied.push(account.label || account.name || account.id);
2912
+ console.log(` Account: ${account.label || account.name} (${account.id}) — added from accounts.jsonc`);
2913
+ imapManager.quickInboxCheckAccount(account.id).catch(e =>
2914
+ console.error(` [startup-check] ${account.id}: ${e?.message || e}`));
2915
+ } catch (e: any) {
2916
+ console.error(` Failed: ${account.id}: ${e.message}`);
2917
+ }
2918
+ }
2919
+ if (applied.length > 0) {
2920
+ // syncAll is a no-op while a sync is already running; the periodic
2921
+ // sweep picks the new account up then. startWatching is per-config
2922
+ // and idempotent, so IDLE starts for the new account either way.
2923
+ imapManager.syncAll()
2924
+ .then(() => imapManager.startWatching())
2925
+ .catch(e => console.error(` Sync error: ${e.message}`));
2926
+ }
2927
+ const parts: string[] = [];
2928
+ if (applied.length) parts.push(`added ${applied.join(", ")}`);
2929
+ if (removed.length) parts.push(`removed ${removed.join(", ")}`);
2930
+ if (changed.length) parts.push(`changed ${changed.join(", ")}`);
2931
+ const restartNeeded = removed.length > 0 || changed.length > 0;
2932
+ const detail = `accounts.jsonc: ${parts.join("; ")}${restartNeeded ? " — restart to apply" : ""}`;
2933
+ console.log(` [accounts] ${detail}`);
2934
+ return { restartNeeded, detail };
2935
+ }
2873
2936
  imapManager.on("configChanged", (filename: string) => {
2874
- handle.send({ _event: "configChanged", type: "configChanged", filename });
2937
+ if (filename !== "accounts.jsonc") {
2938
+ handle.send({ _event: "configChanged", type: "configChanged", filename });
2939
+ return;
2940
+ }
2941
+ reconcileAccountsFromDisk()
2942
+ .then(r => handle.send({ _event: "configChanged", type: "configChanged", filename, restartNeeded: r.restartNeeded, detail: r.detail }))
2943
+ .catch((e: any) => {
2944
+ // Reconcile failed: fall back to the old behaviour — the user can
2945
+ // always restart — and say why in the log.
2946
+ console.error(` [accounts] reconcile failed: ${e?.message || e}`);
2947
+ handle.send({ _event: "configChanged", type: "configChanged", filename, restartNeeded: true, detail: `accounts.jsonc changed — restart to apply (${e?.message || e})` });
2948
+ });
2875
2949
  });
2876
2950
  imapManager.on("outboxStatus", (status: any) => {
2877
2951
  handle.send({ _event: "outboxStatus", type: "outboxStatus", ...status });
@@ -3231,6 +3305,21 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
3231
3305
  imapManager.startSentSweep();
3232
3306
  imapManager.watchConfigFiles();
3233
3307
 
3308
+ // 2026-09-13 17:20 EDT — Claude Code (Fable 5.1), at Bob's direction.
3309
+ // Apply the background cloud refresh started before the DB was even open.
3310
+ // cloudRead has already written the cloud copy into the local cache; if
3311
+ // it differs from the boot snapshot, run it through the same configChanged
3312
+ // path an on-disk edit takes (MailxService drops its accounts cache, then
3313
+ // reconcileAccountsFromDisk adds new accounts live and tells the client).
3314
+ // Emitting here rather than relying on fs.watch closes the race where the
3315
+ // cloud read lands before watchConfigFiles() registered the watcher.
3316
+ void cloudAccountsRefresh.then((cloudAccounts) => {
3317
+ if (cloudAccounts.length === 0) return;
3318
+ if (JSON.stringify(cloudAccounts) === JSON.stringify(settings.accounts)) return;
3319
+ console.log(` [cloud] accounts.jsonc differs from the boot snapshot (cache=${settings.accounts.length} cloud=${cloudAccounts.length}) — reconciling`);
3320
+ imapManager.emit("configChanged", "accounts.jsonc");
3321
+ });
3322
+
3234
3323
  // Deploy app-owned reference docs (.md per JSONC schema) to the cloud
3235
3324
  // folder so users see them next to the .jsonc they document. Versioned
3236
3325
  // by app version — only redeploys when the app updates. Fire-and-forget;
@@ -2589,6 +2589,8 @@ function provedSenderDomain(input) {
2589
2589
  function redirectorLink(input) {
2590
2590
  const bodyHtml = input.bodyHtml || "";
2591
2591
  const senderDomain = provedSenderDomain(input);
2592
+ if (senderDomain && input.senderTrusted)
2593
+ return null;
2592
2594
  const intermediary = provedIntermediary(input);
2593
2595
  const signers = dkimSigners(input);
2594
2596
  for (const m of bodyHtml.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
@@ -2618,13 +2620,14 @@ function redirectorLink(input) {
2618
2620
  if (senderDomain && sameOrg(target.hostname, senderDomain))
2619
2621
  continue;
2620
2622
  const signer = signers.find((s) => sameOrg(s, url.hostname));
2621
- const how = signer ? ` ${signer} signed this message; "Trust ${signer} as an intermediary" stops this warning for links through ${signer} in mail it signs.` : ` Nobody signed this message for ${url.hostname}, so there is no intermediary to trust; only the sender could vouch for the link.`;
2623
+ const how = signer ? ` ${signer} signed this message; "Trust ${signer} as an intermediary" stops this warning for links through ${signer} in mail it signs.` : senderDomain ? ` Nobody signed this message for ${url.hostname}, so there is no intermediary to trust; ${senderDomain} proved it sent this, and "Stop warning about *@${senderDomain}" lets it vouch for its own links.` : ` Nobody signed this message for ${url.hostname}, so there is no intermediary to trust; only the sender could vouch for the link, and nothing proves who the sender is.`;
2622
2624
  return {
2623
2625
  id: "redirector-link",
2624
2626
  severity: "caution",
2625
2627
  text: `A link hides where it goes: it passes through ${url.hostname} and ends at ${target.hostname}.`,
2626
2628
  detail: `${url.hostname} -> ${target.hostname}.${how}`,
2627
- ...signer ? { via: signer } : {}
2629
+ ...signer ? { via: signer } : {},
2630
+ ...senderDomain && !signer ? { sender: senderDomain } : {}
2628
2631
  };
2629
2632
  }
2630
2633
  }