@bobfrankston/rmfmail 1.2.324 → 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.
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;
@@ -12119,7 +12119,14 @@ onWsEvent((event) => {
12119
12119
  }, 8e3);
12120
12120
  }
12121
12121
  if (event.filename && /accounts\.jsonc/i.test(String(event.filename))) {
12122
- showRestartForConfigBanner();
12122
+ if (event.restartNeeded) {
12123
+ showRestartForConfigBanner();
12124
+ } else if (event.detail && statusSync) {
12125
+ statusSync.textContent = String(event.detail);
12126
+ setTimeout(() => {
12127
+ if (statusSync.textContent === String(event.detail)) statusSync.textContent = "";
12128
+ }, 8e3);
12129
+ }
12123
12130
  }
12124
12131
  break;
12125
12132
  case "cloudError":