@bobfrankston/rmfmail 1.1.163 → 1.1.169

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 (48) hide show
  1. package/bin/mailx.js +182 -6
  2. package/bin/mailx.js.map +1 -1
  3. package/bin/mailx.ts +185 -6
  4. package/client/android-bootstrap.bundle.js +28 -9
  5. package/client/android-bootstrap.bundle.js.map +2 -2
  6. package/client/app.bundle.js +16 -10
  7. package/client/app.bundle.js.map +2 -2
  8. package/client/components/message-viewer.js +21 -12
  9. package/client/components/message-viewer.js.map +1 -1
  10. package/client/components/message-viewer.ts +18 -12
  11. package/package.json +7 -7
  12. package/packages/mailx-core/index.js +1 -1
  13. package/packages/mailx-core/index.js.map +1 -1
  14. package/packages/mailx-core/index.ts +1 -1
  15. package/packages/mailx-imap/index.d.ts.map +1 -1
  16. package/packages/mailx-imap/index.js +60 -8
  17. package/packages/mailx-imap/index.js.map +1 -1
  18. package/packages/mailx-imap/index.ts +52 -8
  19. package/packages/mailx-imap/package-lock.json +2 -2
  20. package/packages/mailx-imap/package.json +1 -1
  21. package/packages/mailx-service/index.js +1 -1
  22. package/packages/mailx-service/index.js.map +1 -1
  23. package/packages/mailx-service/index.ts +1 -1
  24. package/packages/mailx-service/reconciler.js +1 -1
  25. package/packages/mailx-service/reconciler.js.map +1 -1
  26. package/packages/mailx-service/reconciler.ts +1 -1
  27. package/packages/mailx-store/db.d.ts +25 -4
  28. package/packages/mailx-store/db.d.ts.map +1 -1
  29. package/packages/mailx-store/db.js +36 -9
  30. package/packages/mailx-store/db.js.map +1 -1
  31. package/packages/mailx-store/db.ts +48 -15
  32. package/packages/mailx-store/package.json +1 -1
  33. package/packages/mailx-store/store.js +2 -2
  34. package/packages/mailx-store/store.js.map +1 -1
  35. package/packages/mailx-store/store.ts +2 -2
  36. package/packages/mailx-store-web/android-bootstrap.js +2 -2
  37. package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
  38. package/packages/mailx-store-web/android-bootstrap.ts +2 -2
  39. package/packages/mailx-store-web/db.d.ts +5 -1
  40. package/packages/mailx-store-web/db.d.ts.map +1 -1
  41. package/packages/mailx-store-web/db.js +7 -5
  42. package/packages/mailx-store-web/db.js.map +1 -1
  43. package/packages/mailx-store-web/db.ts +7 -4
  44. package/packages/mailx-store-web/package.json +1 -1
  45. package/packages/mailx-store-web/sync-manager.js +3 -3
  46. package/packages/mailx-store-web/sync-manager.js.map +1 -1
  47. package/packages/mailx-store-web/sync-manager.ts +3 -3
  48. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-51500 → node_modules.npmglobalize-stash-61952}/.package-lock.json +0 -0
package/bin/mailx.ts CHANGED
@@ -20,8 +20,12 @@
20
20
  * rmfmail -setup Interactive first-time account setup (CLI)
21
21
  * rmfmail -add Add another account (CLI)
22
22
  * rmfmail -test Test IMAP/SMTP connectivity for all accounts
23
- * rmfmail -rebuild Wipe local cache, re-sync everything from IMAP
24
- * rmfmail -repair Re-sync metadata (fix corrupt subjects), keeps .eml files
23
+ * rmfmail -rebuild WIPE local DB + .eml files, then re-sync from IMAP
24
+ * (slow; first-sync caps at 200 newest per folder)
25
+ * rmfmail -repair DELETE DB rows, KEEP .eml files, re-sync from IMAP
26
+ * (subject/encoding fixes; same 200-newest cap)
27
+ * rmfmail -recover DELETE bobma DB rows, REBUILD index from .eml files
28
+ * on disk (no network; recovers everything you had)
25
29
  * rmfmail -reauth Clear cached OAuth tokens; next start re-consents
26
30
  * (use when new Google scopes have been added)
27
31
  * rmfmail -log Print log file path and exit
@@ -395,7 +399,7 @@ function pidIsMailx(pid: number): boolean {
395
399
  // on an old UI with no indication that the install has been upgraded.
396
400
  // Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
397
401
  // the internal --daemon respawn.
398
- const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log", "reauth"];
402
+ const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "recover", "import", "log", "reauth"];
399
403
  const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
400
404
  // `--another` opts out of the replace-on-launch sweep so the user can run
401
405
  // multiple rmfmail instances side by side (testing, parallel sessions, etc.).
@@ -465,10 +469,11 @@ const addMode = hasFlag("add");
465
469
  const testMode = hasFlag("test");
466
470
  const rebuildMode = hasFlag("rebuild");
467
471
  const repairMode = hasFlag("repair");
472
+ const recoverMode = hasFlag("recover");
468
473
  const importMode = hasFlag("import");
469
474
 
470
475
  // Validate arguments
471
- const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "email", "mail", "daemon", "reauth", "mailto", "register-mailto", "unregister-mailto", "allow-elevated", "another", "server", "no-browser", "debug-server", "send", "account"];
476
+ const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "recover", "log", "import", "email", "mail", "daemon", "reauth", "mailto", "register-mailto", "unregister-mailto", "allow-elevated", "another", "server", "no-browser", "debug-server", "send", "account"];
472
477
  for (const arg of args) {
473
478
  // Strip a leading -/-- and any `=value` suffix before checking.
474
479
  const flag = arg.replace(/^--?/, "").split("=")[0];
@@ -489,8 +494,12 @@ for (const arg of args) {
489
494
  " -setup Interactive first-time account setup (CLI)\n" +
490
495
  " -add Add another account (CLI)\n" +
491
496
  " -test Test IMAP/SMTP connectivity for all accounts\n" +
492
- " -rebuild Wipe local cache, re-sync everything\n" +
493
- " -repair Re-sync metadata (keeps .eml files)\n" +
497
+ " -rebuild WIPE DB + .eml files, re-sync from IMAP\n" +
498
+ " (cap 200 newest per folder on first-sync)\n" +
499
+ " -repair DELETE DB rows, KEEP .eml files, re-sync\n" +
500
+ " (same 200-newest cap; fixes corrupt fields)\n" +
501
+ " -recover REBUILD DB index from .eml files on disk\n" +
502
+ " (no network; restores everything you had)\n" +
494
503
  " -reauth Clear cached OAuth tokens; re-consent on next start\n" +
495
504
  " -log Print log file path and exit\n" +
496
505
  " -email <addr> First-time setup with this email (skips prompt)\n" +
@@ -726,6 +735,176 @@ if (repairMode) {
726
735
  process.exit(0);
727
736
  }
728
737
 
738
+ // Recover: walk .eml files on disk and rebuild DB index from them. No network.
739
+ // Use case: data loss event wiped DB rows but mailxstore .eml files survived
740
+ // (2026-05-27 UID-collision DELETE bug). Each .eml is parsed for Message-ID +
741
+ // envelope fields and inserted as a placeholder row with a negative UID in
742
+ // INBOX so the user can read/search them immediately. Subsequent IMAP sync
743
+ // rebinds these placeholders to real (folder, uid) when the server confirms
744
+ // each message — at which point the synthetic-UID row is replaced or merged
745
+ // by the existing move-detect-via-Message-ID logic.
746
+ //
747
+ // Skips messages whose Message-ID is already indexed (idempotent re-run).
748
+ if (recoverMode) {
749
+ const { getConfigDir, getStorePath } = await import("@bobfrankston/mailx-settings");
750
+ const dbDir = getConfigDir();
751
+ const storePath = getStorePath();
752
+ const dbPath = path.join(dbDir, "mailx.db");
753
+ if (!fs.existsSync(dbPath)) {
754
+ console.error("No database found. Run 'rmfmail -setup' first.");
755
+ process.exit(1);
756
+ }
757
+ if (!fs.existsSync(storePath)) {
758
+ console.error(`No mailxstore directory found at ${storePath}.`);
759
+ process.exit(1);
760
+ }
761
+
762
+ console.log("Recovering rmfmail index from .eml files on disk...");
763
+ console.log(` Store: ${storePath}`);
764
+ console.log(" No network access; existing DB rows preserved.");
765
+
766
+ const { DatabaseSync } = await import("node:sqlite");
767
+ const db = new DatabaseSync(dbPath);
768
+ db.exec("PRAGMA journal_mode = WAL");
769
+
770
+ // Build the Message-ID set already in the DB so we can skip duplicates.
771
+ const seenStmt = db.prepare("SELECT message_id FROM messages WHERE account_id = ? AND message_id IS NOT NULL AND message_id != ''");
772
+ // INBOX folder per account (target for recovered rows). If no INBOX row
773
+ // exists for an account we skip that account entirely — we have nowhere
774
+ // to put the recovered messages and creating a folder row from a CLI is
775
+ // out of scope here.
776
+ const inboxStmt = db.prepare("SELECT id FROM folders WHERE account_id = ? AND (LOWER(special_use) = 'inbox' OR LOWER(path) = 'inbox') LIMIT 1");
777
+ // Synthetic UID allocator: start well below the smallest real IMAP UID
778
+ // and decrement, so they're distinct from existing real or synthetic UIDs.
779
+ let synthUid = -2_000_000;
780
+ const insertMsg = db.prepare(`
781
+ INSERT INTO messages
782
+ (account_id, folder_id, uid, message_id, date, subject,
783
+ from_address, from_name, to_json, cc_json, flags_json, size,
784
+ has_attachments, preview, body_path, cached_at)
785
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
786
+ `);
787
+
788
+ // Walk every account directory under storePath.
789
+ const accountDirs = fs.readdirSync(storePath, { withFileTypes: true })
790
+ .filter(d => d.isDirectory())
791
+ .map(d => d.name);
792
+ let totalScanned = 0;
793
+ let totalIndexed = 0;
794
+ let totalSkipped = 0;
795
+ for (const acct of accountDirs) {
796
+ const inboxRow = inboxStmt.get(acct) as any;
797
+ if (!inboxRow) {
798
+ console.log(` [skip] ${acct}: no INBOX folder row in DB — skipping ${fs.readdirSync(path.join(storePath, acct)).length} entries`);
799
+ continue;
800
+ }
801
+ const inboxFolderId: number = inboxRow.id;
802
+ const seen = new Set<string>();
803
+ for (const r of seenStmt.iterate(acct) as any) {
804
+ if (r.message_id) seen.add(String(r.message_id));
805
+ }
806
+ console.log(` [${acct}] INBOX folder_id=${inboxFolderId}; already indexed: ${seen.size}`);
807
+
808
+ const acctRoot = path.join(storePath, acct);
809
+ let scanned = 0;
810
+ let indexed = 0;
811
+ let skipped = 0;
812
+
813
+ // Iterate two-level layout: acct/<2hex>/<uuid>.eml. Also tolerate
814
+ // flat or three-level layouts that older builds might've used.
815
+ const walkDir = (dir: string): void => {
816
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
817
+ const p = path.join(dir, entry.name);
818
+ if (entry.isDirectory()) { walkDir(p); continue; }
819
+ if (!entry.isFile() || !entry.name.endsWith(".eml")) continue;
820
+ scanned++;
821
+ try {
822
+ // Read only the headers — first ~8 KB is plenty. Faster
823
+ // than slurping a 100 KB body when we only need fields.
824
+ const fd = fs.openSync(p, "r");
825
+ const buf = Buffer.alloc(8192);
826
+ const n = fs.readSync(fd, buf, 0, 8192, 0);
827
+ fs.closeSync(fd);
828
+ const head = buf.slice(0, n).toString("utf-8");
829
+ // Header section ends at the first blank line. Stop there
830
+ // so a `Subject:` inside the body can't shadow the real one.
831
+ const headerEnd = head.search(/\r?\n\r?\n/);
832
+ const hdrText = headerEnd >= 0 ? head.slice(0, headerEnd) : head;
833
+ const getHdr = (name: string): string => {
834
+ const re = new RegExp(`^${name}:[ \\t]*(.*(?:\\r?\\n[ \\t]+.*)*)`, "im");
835
+ const m = hdrText.match(re);
836
+ return m ? m[1].replace(/\r?\n[ \t]+/g, " ").trim() : "";
837
+ };
838
+ const messageId = (getHdr("Message-ID") || getHdr("Message-Id")).replace(/^<|>$/g, "");
839
+ if (!messageId) { skipped++; continue; }
840
+ if (seen.has(messageId) || seen.has(`<${messageId}>`)) { skipped++; continue; }
841
+ const subject = getHdr("Subject");
842
+ const dateStr = getHdr("Date");
843
+ const fromRaw = getHdr("From");
844
+ const toRaw = getHdr("To");
845
+ const ccRaw = getHdr("Cc");
846
+ const date = dateStr ? (Date.parse(dateStr) || 0) : 0;
847
+ // Crude address split — good enough for index reconstruction;
848
+ // the daemon's normal upsert will overwrite with parsed data
849
+ // once IMAP rebinds. We just need something to display.
850
+ const parseAddrs = (raw: string): { name: string; address: string }[] => {
851
+ if (!raw) return [];
852
+ return raw.split(",").map(s => {
853
+ const m = s.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>\s*$/) || s.match(/^\s*([^\s<>"]+@[^\s<>"]+)\s*$/);
854
+ if (!m) return { name: "", address: s.trim() };
855
+ return m[2] ? { name: m[1].trim(), address: m[2].trim() } : { name: "", address: m[1].trim() };
856
+ });
857
+ };
858
+ const fromAddrs = parseAddrs(fromRaw);
859
+ const from = fromAddrs[0] || { name: "", address: "" };
860
+ const toList = parseAddrs(toRaw);
861
+ const ccList = parseAddrs(ccRaw);
862
+ const stat = fs.statSync(p);
863
+ const rel = path.relative(storePath, p).replace(/\\/g, "/");
864
+ insertMsg.run(
865
+ acct,
866
+ inboxFolderId,
867
+ synthUid--,
868
+ messageId,
869
+ date,
870
+ subject,
871
+ from.address,
872
+ from.name,
873
+ JSON.stringify(toList),
874
+ JSON.stringify(ccList),
875
+ "[]", // flags_json
876
+ stat.size, // size
877
+ 0, // has_attachments — leave as 0; daemon refreshes on first view
878
+ "", // preview — empty, daemon will fill on first view
879
+ rel, // body_path (relative to storePath)
880
+ Date.now(), // cached_at
881
+ );
882
+ seen.add(messageId);
883
+ indexed++;
884
+ if (indexed % 500 === 0) console.log(` [${acct}] ${indexed} indexed (${scanned} scanned)`);
885
+ } catch (e: any) {
886
+ skipped++;
887
+ if (skipped < 5) console.error(` [${acct}] parse error on ${p}: ${e?.message || e}`);
888
+ }
889
+ }
890
+ };
891
+ walkDir(acctRoot);
892
+ // Refresh folder counts so the INBOX row shows the new total.
893
+ const cnt = (db.prepare("SELECT COUNT(*) as c FROM messages WHERE folder_id = ?").get(inboxFolderId) as any).c;
894
+ db.prepare("UPDATE folders SET total_count = ? WHERE id = ?").run(cnt, inboxFolderId);
895
+ console.log(` [${acct}] done — scanned ${scanned}, indexed ${indexed}, skipped ${skipped}. INBOX total now ${cnt}.`);
896
+ totalScanned += scanned;
897
+ totalIndexed += indexed;
898
+ totalSkipped += skipped;
899
+ }
900
+ db.close();
901
+ console.log(`Recovery complete: indexed ${totalIndexed} of ${totalScanned} .eml files (${totalSkipped} skipped).`);
902
+ console.log(" Recovered rows have synthetic negative UIDs and live in INBOX.");
903
+ console.log(" Run 'rmfmail' to start the daemon — normal IMAP sync will rebind");
904
+ console.log(" these placeholders to real (folder, uid) tuples by Message-ID.");
905
+ process.exit(0);
906
+ }
907
+
729
908
  // Import accounts from a local file into GDrive
730
909
  if (importMode) {
731
910
  const importPath = args.find(a => !a.startsWith("-"));
@@ -5233,11 +5233,13 @@ var WebMailxDB = class {
5233
5233
  getUidsForFolder(accountId, folderId) {
5234
5234
  return this.all("SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]).map((r) => r.uid);
5235
5235
  }
5236
- deleteMessage(accountId, uid) {
5237
- const msg = this.get("SELECT folder_id FROM messages WHERE account_id = ? AND uid = ?", [accountId, uid]);
5238
- this.run("DELETE FROM messages WHERE account_id = ? AND uid = ?", [accountId, uid]);
5239
- if (msg)
5240
- this.recalcFolderCounts(msg.folder_id);
5236
+ /** folderId REQUIRED. See main mailx-store/db.ts for why: IMAP UIDs are
5237
+ * per-folder, so DELETE WHERE (account, uid) would wipe the same UID
5238
+ * out of every folder. The pre-2026-05-27 implementation took only
5239
+ * (accountId, uid) — that was the 70k-row data-loss bug. */
5240
+ deleteMessage(accountId, folderId, uid) {
5241
+ this.run("DELETE FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?", [accountId, folderId, uid]);
5242
+ this.recalcFolderCounts(folderId);
5241
5243
  }
5242
5244
  recalcFolderCounts(folderId) {
5243
5245
  const counts = this.get(`SELECT COUNT(*) as total,
@@ -7564,8 +7566,19 @@ var NativeImapClient = class {
7564
7566
  }
7565
7567
  });
7566
7568
  this.transport.onError((err) => {
7569
+ const errAny = err;
7570
+ const parts = [];
7571
+ if (errAny?.message)
7572
+ parts.push(String(errAny.message));
7573
+ if (errAny?.code)
7574
+ parts.push(`code=${errAny.code}`);
7575
+ if (errAny?.errno !== void 0)
7576
+ parts.push(`errno=${errAny.errno}`);
7577
+ if (errAny?.syscall)
7578
+ parts.push(`syscall=${errAny.syscall}`);
7579
+ const desc = parts.length > 0 ? parts.join(" ") : `<no message> ${typeof err} ${err?.constructor?.name || ""}`.trim();
7567
7580
  if (this.verbose)
7568
- console.error(` [imap] Transport error: ${err.message}`);
7581
+ console.error(` [imap] Transport error: ${desc}`);
7569
7582
  this._connected = false;
7570
7583
  this.idleTag = null;
7571
7584
  this.idleCallback = null;
@@ -7577,7 +7590,13 @@ var NativeImapClient = class {
7577
7590
  if (this.pendingCommand) {
7578
7591
  const { reject } = this.pendingCommand;
7579
7592
  this.pendingCommand = null;
7580
- reject(err);
7593
+ if (err instanceof Error && !err.message) {
7594
+ const wrapped = new Error(desc);
7595
+ wrapped.cause = err;
7596
+ reject(wrapped);
7597
+ } else {
7598
+ reject(err);
7599
+ }
7581
7600
  }
7582
7601
  });
7583
7602
  await this.transport.connect(this.config.server, this.config.port, useTls, this.config.server);
@@ -9870,7 +9889,7 @@ var AndroidSyncManager = class {
9870
9889
  console.log(`[sync] ${folder.path}: reconcile refused \u2014 would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%)`);
9871
9890
  } else {
9872
9891
  for (const uid of toDelete) {
9873
- this.db.deleteMessage(accountId, uid);
9892
+ this.db.deleteMessage(accountId, folderId, uid);
9874
9893
  this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => {
9875
9894
  });
9876
9895
  }
@@ -9982,7 +10001,7 @@ var AndroidSyncManager = class {
9982
10001
  emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
9983
10002
  }
9984
10003
  async trashMessage(accountId, folderId, uid) {
9985
- this.db.deleteMessage(accountId, uid);
10004
+ this.db.deleteMessage(accountId, folderId, uid);
9986
10005
  this.db.queueSyncAction(accountId, "trash", uid, folderId);
9987
10006
  emitEvent({ type: "messageDeleted", accountId, folderId, uid });
9988
10007
  emitEvent({ type: "folderCountsChanged", accountId, counts: {} });