@bobfrankston/rmfmail 1.1.166 → 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.
- package/bin/mailx.js +182 -6
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +185 -6
- package/client/android-bootstrap.bundle.js +9 -7
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/package.json +7 -7
- package/packages/mailx-core/index.js +1 -1
- package/packages/mailx-core/index.js.map +1 -1
- package/packages/mailx-core/index.ts +1 -1
- package/packages/mailx-imap/index.js +5 -5
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +5 -5
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/reconciler.js +1 -1
- package/packages/mailx-service/reconciler.js.map +1 -1
- package/packages/mailx-service/reconciler.ts +1 -1
- package/packages/mailx-store/db.d.ts +17 -3
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +25 -7
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +33 -11
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store/store.js +1 -1
- package/packages/mailx-store/store.js.map +1 -1
- package/packages/mailx-store/store.ts +1 -1
- package/packages/mailx-store-web/android-bootstrap.js +2 -2
- package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
- package/packages/mailx-store-web/android-bootstrap.ts +2 -2
- package/packages/mailx-store-web/db.d.ts +5 -1
- package/packages/mailx-store-web/db.d.ts.map +1 -1
- package/packages/mailx-store-web/db.js +7 -5
- package/packages/mailx-store-web/db.js.map +1 -1
- package/packages/mailx-store-web/db.ts +7 -4
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-store-web/sync-manager.js +3 -3
- package/packages/mailx-store-web/sync-manager.js.map +1 -1
- package/packages/mailx-store-web/sync-manager.ts +3 -3
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-56540 → 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
|
|
24
|
-
*
|
|
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
|
|
493
|
-
"
|
|
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
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
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,
|
|
@@ -9887,7 +9889,7 @@ var AndroidSyncManager = class {
|
|
|
9887
9889
|
console.log(`[sync] ${folder.path}: reconcile refused \u2014 would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%)`);
|
|
9888
9890
|
} else {
|
|
9889
9891
|
for (const uid of toDelete) {
|
|
9890
|
-
this.db.deleteMessage(accountId, uid);
|
|
9892
|
+
this.db.deleteMessage(accountId, folderId, uid);
|
|
9891
9893
|
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => {
|
|
9892
9894
|
});
|
|
9893
9895
|
}
|
|
@@ -9999,7 +10001,7 @@ var AndroidSyncManager = class {
|
|
|
9999
10001
|
emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
10000
10002
|
}
|
|
10001
10003
|
async trashMessage(accountId, folderId, uid) {
|
|
10002
|
-
this.db.deleteMessage(accountId, uid);
|
|
10004
|
+
this.db.deleteMessage(accountId, folderId, uid);
|
|
10003
10005
|
this.db.queueSyncAction(accountId, "trash", uid, folderId);
|
|
10004
10006
|
emitEvent({ type: "messageDeleted", accountId, folderId, uid });
|
|
10005
10007
|
emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|