@bobfrankston/rmfmail 1.2.324 → 1.2.326

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.
@@ -18,7 +18,7 @@
18
18
 
19
19
  import * as fs from "node:fs";
20
20
  import * as path from "node:path";
21
- import { parse as parseJsonc } from "jsonc-parser";
21
+ import { parse as parseJsonc, printParseErrorCode, type ParseError } from "jsonc-parser";
22
22
  import type { MailxSettings, AccountConfig, AutocompleteSettings, AiKeys, ReminderState } from "@bobfrankston/mailx-types";
23
23
  import { REMINDER_STATE_FILE, normalizeReminderState, mergeReminderStates, reminderStatesEqual } from "@bobfrankston/mailx-types";
24
24
  import { getCloudProvider, gDriveFindOrCreateFolder, gDriveValidateCachedFolder, type CloudProvider } from "./cloud.js";
@@ -312,11 +312,22 @@ export async function cloudRead(filename: string): Promise<string | null> {
312
312
  if (wroteAt >= startedAt) {
313
313
  console.log(` [cloud] ${filename} changed locally during the read — keeping the local copy`);
314
314
  } else {
315
- fs.mkdirSync(LOCAL_DIR, { recursive: true });
315
+ // 2026-09-13 17:20 EDT — Claude Code (Fable 5.1), at Bob's direction.
316
+ // Was an unconditional plain writeFileSync on EVERY cloud read —
317
+ // and pollCloud reads every 3 min — so accounts.jsonc was rewritten
318
+ // all day even when identical (the log showed "fs.watch fired but
319
+ // content unchanged" every 3 minutes). A kernel crash at 16:36
320
+ // today caught one of those rewrites and NTFS left the file
321
+ // zero-filled; the daemon then booted with 0 accounts. Now: skip
322
+ // when identical, and write crash-safe (tmp + fsync + rename).
316
323
  stashPrev(filename, content);
317
- fs.writeFileSync(path.join(LOCAL_DIR, filename), content);
324
+ writeTextIfChanged(path.join(LOCAL_DIR, filename), content);
318
325
  }
319
- } catch { /* ignore cache write failure */ }
326
+ } catch (e: any) {
327
+ // The cloud content is still returned to the caller; only the local
328
+ // offline cache is stale, and the next read retries the write.
329
+ console.error(` [cloud] ${filename}: local cache write failed: ${e?.message || e}`);
330
+ }
320
331
  }
321
332
  // Don't set error for missing files — they may not exist yet (e.g., clients.jsonc on first run)
322
333
  return content;
@@ -488,19 +499,31 @@ function readJsonc(filePath: string): any {
488
499
  if (!fs.existsSync(actual)) return null;
489
500
  }
490
501
  try {
491
- return parseJsonc(fs.readFileSync(actual, "utf-8").replace(/\r/g, ""));
502
+ // 2026-09-13 17:20 EDT — Claude Code (Fable 5.1), at Bob's direction.
503
+ // jsonc-parser's parse() never throws: on garbage it returns undefined
504
+ // and only reports through the errors array. Without the array, the
505
+ // crash-zeroed accounts.jsonc (1932 NUL bytes) read as "no accounts"
506
+ // with not one log line — the daemon started with zero accounts and
507
+ // the only symptom was the "restart to apply" banner 14 s later.
508
+ const text = fs.readFileSync(actual, "utf-8").replace(/\r/g, "");
509
+ const errors: ParseError[] = [];
510
+ const data = parseJsonc(text, errors);
511
+ if (errors.length > 0) {
512
+ const first = errors[0];
513
+ console.error(`Failed to parse ${actual}: ${printParseErrorCode(first.error)} at offset ${first.offset} (${errors.length} error(s), ${text.length} chars)`);
514
+ return null;
515
+ }
516
+ return data;
492
517
  } catch (e: any) {
493
518
  console.error(`Failed to read ${actual}: ${e.message}`);
494
519
  return null;
495
520
  }
496
521
  }
497
522
 
523
+ // 2026-09-13 17:20 EDT — Claude Code (Fable 5.1): one write mechanism; the
524
+ // fsync lives in atomicWriteText (see its note).
498
525
  function atomicWrite(filePath: string, data: any): void {
499
- const dir = path.dirname(filePath);
500
- fs.mkdirSync(dir, { recursive: true });
501
- const tmp = filePath + ".tmp";
502
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
503
- fs.renameSync(tmp, filePath);
526
+ atomicWriteText(filePath, JSON.stringify(data, null, 2));
504
527
  }
505
528
 
506
529
  /** Read a config file: shared first, local fallback, merge local overrides */
@@ -850,8 +873,9 @@ export function loadAccounts(): AccountConfig[] {
850
873
  const norm = (s: string): string =>
851
874
  s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
852
875
  if (norm(sharedContent) !== norm(localContent)) {
853
- fs.mkdirSync(LOCAL_DIR, { recursive: true });
854
- fs.writeFileSync(localPath, sharedContent);
876
+ // 2026-09-13 17:20 EDT — Claude Code (Fable 5.1): crash-safe
877
+ // write (tmp + fsync + rename); see atomicWriteText.
878
+ atomicWriteText(localPath, sharedContent);
855
879
  }
856
880
  } catch { /* ignore */ }
857
881
  }
@@ -1317,14 +1341,41 @@ function buildDictCsv(words: string[]): string {
1317
1341
  return "# rmfmail user dictionary — one word per line\n" + sorted.join("\n") + "\n";
1318
1342
  }
1319
1343
 
1320
- function atomicWriteText(filePath: string, text: string): void {
1344
+ /** Crash-safe text write: tmp file → fsync → rename over the target.
1345
+ *
1346
+ * 2026-09-13 17:20 EDT — Claude Code (Fable 5.1), at Bob's direction.
1347
+ * Added the fsync and exported this for every local-cache writer. Kernel-Power
1348
+ * 41 crash at 16:36 today left `~/.rmfmail/accounts.jsonc` as 1932 NUL bytes:
1349
+ * NTFS journals metadata (the new size) but not data, so a file rewritten
1350
+ * moments before a hard crash comes back zero-filled. A rename alone does not
1351
+ * close that window — the tmp file's data can be unflushed too — hence the
1352
+ * fsync before the rename. The daemon then booted with 0 accounts. */
1353
+ export function atomicWriteText(filePath: string, text: string): void {
1321
1354
  const dir = path.dirname(filePath);
1322
1355
  fs.mkdirSync(dir, { recursive: true });
1323
1356
  const tmp = filePath + ".tmp";
1324
- fs.writeFileSync(tmp, text);
1357
+ const fd = fs.openSync(tmp, "w");
1358
+ try {
1359
+ fs.writeSync(fd, text);
1360
+ fs.fsyncSync(fd);
1361
+ } finally {
1362
+ fs.closeSync(fd);
1363
+ }
1325
1364
  fs.renameSync(tmp, filePath);
1326
1365
  }
1327
1366
 
1367
+ /** Write `text` to `filePath` only when it differs from what is already there.
1368
+ * Returns true when a write happened. Identical content is skipped so a
1369
+ * periodic cloud poll doesn't keep the file in a perpetual just-written
1370
+ * window (see atomicWriteText) and doesn't fire fs.watch for nothing. */
1371
+ export function writeTextIfChanged(filePath: string, text: string): boolean {
1372
+ let existing: string = null;
1373
+ try { existing = fs.readFileSync(filePath, "utf-8"); } catch { existing = null; /* missing or unreadable → write it */ }
1374
+ if (existing === text) return false;
1375
+ atomicWriteText(filePath, text);
1376
+ return true;
1377
+ }
1378
+
1328
1379
  /** Load user-added dictionary words. Mirrored to GDrive so "Add to dictionary"
1329
1380
  * on one machine appears on every machine. Returns a deduped string array.
1330
1381
  * Tries the cloud copy first (so a fresh machine sees prior words), then
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-settings",
3
- "version": "0.1.91",
3
+ "version": "0.1.94",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.126",
3
+ "version": "0.1.127",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/.commitmsg DELETED
@@ -1,24 +0,0 @@
1
- A proved, trusted sender vouches for its own links; the trust button appears whenever trust would clear the banner
2
-
3
- Bob 2026-09-11, a Toast receipt: "Another message to flag as safe because
4
- I trust toasttakeout.com. There should be a trust button."
5
-
6
- The mail is dmarc=pass toasttab.com, toasttab.com is on his list, so the
7
- server's 19.9 spam verdict was already silent — what remained was the
8
- redirector warning: every link runs through toasttakeout.page.link
9
- (Firebase's per-app redirector) to toasttakeout.com. Two organisations by
10
- hostname, nobody signed for page.link, the sender is neither, so rules 1
11
- to 4 all missed, and the finding's own text said "only the sender could
12
- vouch for the link". Rule 5: when the proved sender is one the reader
13
- trusts, it has. Proof stays mandatory — trust without the server's pass
14
- clears nothing (tested), so a forger typing a trusted domain into From
15
- gets no vouching.
16
-
17
- The banner's "Stop warning about" buttons used to appear only when the
18
- server's verdict was the single finding. They now appear whenever every
19
- finding is one trust clears (the verdict, the redirector warning) and the
20
- sender is proved — the Toast receipt carried both and so had no button
21
- on the one message that needed it. A proved-but-untrusted sender's
22
- redirector warning now names the action; an unproved one says plainly
23
- that nothing proves who the sender is. mailx-types 0.1.99. Verified on
24
- the live receipt: two cautions before, nothing after.