@bobfrankston/rmfmail 1.1.170 → 1.1.177

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 (53) hide show
  1. package/bin/mailx.js +140 -4
  2. package/bin/mailx.js.map +1 -1
  3. package/bin/mailx.ts +127 -5
  4. package/client/android-bootstrap.bundle.js +4 -3
  5. package/client/android-bootstrap.bundle.js.map +2 -2
  6. package/client/app.bundle.js +91 -6
  7. package/client/app.bundle.js.map +2 -2
  8. package/client/app.js +39 -0
  9. package/client/app.js.map +1 -1
  10. package/client/app.ts +33 -0
  11. package/client/components/message-list.js +114 -7
  12. package/client/components/message-list.js.map +1 -1
  13. package/client/components/message-list.ts +113 -6
  14. package/client/compose/compose.bundle.js +2068 -1775
  15. package/client/compose/compose.bundle.js.map +4 -4
  16. package/client/compose/editor.js +85 -0
  17. package/client/compose/editor.js.map +1 -1
  18. package/client/compose/editor.ts +79 -0
  19. package/client/compose/spellcheck-core.js +253 -0
  20. package/client/compose/spellcheck-core.js.map +1 -0
  21. package/client/compose/spellcheck-core.ts +242 -0
  22. package/package.json +5 -5
  23. package/packages/mailx-core/index.js +1 -1
  24. package/packages/mailx-core/index.js.map +1 -1
  25. package/packages/mailx-core/index.ts +1 -1
  26. package/packages/mailx-imap/index.d.ts.map +1 -1
  27. package/packages/mailx-imap/index.js +15 -1
  28. package/packages/mailx-imap/index.js.map +1 -1
  29. package/packages/mailx-imap/index.ts +9 -1
  30. package/packages/mailx-imap/package-lock.json +2 -2
  31. package/packages/mailx-imap/package.json +1 -1
  32. package/packages/mailx-store/db.d.ts +9 -1
  33. package/packages/mailx-store/db.d.ts.map +1 -1
  34. package/packages/mailx-store/db.js +15 -2
  35. package/packages/mailx-store/db.js.map +1 -1
  36. package/packages/mailx-store/db.ts +18 -4
  37. package/packages/mailx-store/package.json +1 -1
  38. package/packages/mailx-store/store.js +1 -1
  39. package/packages/mailx-store/store.js.map +1 -1
  40. package/packages/mailx-store/store.ts +1 -1
  41. package/packages/mailx-store-web/android-bootstrap.js +1 -1
  42. package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
  43. package/packages/mailx-store-web/android-bootstrap.ts +1 -1
  44. package/packages/mailx-store-web/db.d.ts +2 -1
  45. package/packages/mailx-store-web/db.d.ts.map +1 -1
  46. package/packages/mailx-store-web/db.js +3 -2
  47. package/packages/mailx-store-web/db.js.map +1 -1
  48. package/packages/mailx-store-web/db.ts +4 -3
  49. package/packages/mailx-store-web/package.json +1 -1
  50. package/packages/mailx-store-web/sync-manager.js +1 -1
  51. package/packages/mailx-store-web/sync-manager.js.map +1 -1
  52. package/packages/mailx-store-web/sync-manager.ts +1 -1
  53. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-46236 → node_modules.npmglobalize-stash-50992}/.package-lock.json +0 -0
package/bin/mailx.ts CHANGED
@@ -399,7 +399,7 @@ function pidIsMailx(pid: number): boolean {
399
399
  // on an old UI with no indication that the install has been upgraded.
400
400
  // Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
401
401
  // the internal --daemon respawn.
402
- const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "recover", "import", "log", "reauth"];
402
+ const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "recover", "fix-flags", "import", "log", "reauth"];
403
403
  const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
404
404
  // `--another` opts out of the replace-on-launch sweep so the user can run
405
405
  // multiple rmfmail instances side by side (testing, parallel sessions, etc.).
@@ -473,7 +473,7 @@ const recoverMode = hasFlag("recover");
473
473
  const importMode = hasFlag("import");
474
474
 
475
475
  // Validate arguments
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"];
476
+ const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "recover", "fix-flags", "log", "import", "email", "mail", "daemon", "reauth", "mailto", "register-mailto", "unregister-mailto", "allow-elevated", "another", "server", "no-browser", "debug-server", "send", "account"];
477
477
  for (const arg of args) {
478
478
  // Strip a leading -/-- and any `=value` suffix before checking.
479
479
  const flag = arg.replace(/^--?/, "").split("=")[0];
@@ -500,6 +500,10 @@ for (const arg of args) {
500
500
  " (same 200-newest cap; fixes corrupt fields)\n" +
501
501
  " -recover REBUILD DB index from .eml files on disk\n" +
502
502
  " (no network; restores everything you had)\n" +
503
+ " -fix-flags Reconcile local \\Flagged stars against the\n" +
504
+ " IMAP server's actual SEARCH FLAGGED truth\n" +
505
+ " (one-shot cleanup for the pre-1.1.172 UID-\n" +
506
+ " collision bug)\n" +
503
507
  " -reauth Clear cached OAuth tokens; re-consent on next start\n" +
504
508
  " -log Print log file path and exit\n" +
505
509
  " -email <addr> First-time setup with this email (skips prompt)\n" +
@@ -761,12 +765,28 @@ if (recoverMode) {
761
765
 
762
766
  console.log("Recovering rmfmail index from .eml files on disk...");
763
767
  console.log(` Store: ${storePath}`);
764
- console.log(" No network access; existing DB rows preserved.");
768
+ console.log(" Local-only — no IMAP/network calls. Existing DB rows preserved.");
765
769
 
766
770
  const { DatabaseSync } = await import("node:sqlite");
767
771
  const db = new DatabaseSync(dbPath);
768
772
  db.exec("PRAGMA journal_mode = WAL");
769
773
 
774
+ // BACKFILL existing rows that are missing a message_folders membership.
775
+ // The unified inbox view + per-folder list view both JOIN through
776
+ // message_folders, so a messages row with no membership is INVISIBLE
777
+ // in the UI even though it's in the DB. This catches the pre-1.1.174
778
+ // -recover runs that only populated messages — Bob's symptom was 8304
779
+ // INBOX rows in the folder badge but an empty list view (2026-05-27).
780
+ const backfilled = db.prepare(`
781
+ INSERT OR IGNORE INTO message_folders (message_row_id, folder_id, uid, last_seen_at)
782
+ SELECT m.id, m.folder_id, m.uid, m.cached_at
783
+ FROM messages m
784
+ WHERE NOT EXISTS (SELECT 1 FROM message_folders mf WHERE mf.message_row_id = m.id)
785
+ `).run();
786
+ if ((backfilled as any).changes > 0) {
787
+ console.log(` [backfill] inserted ${(backfilled as any).changes} missing message_folders rows`);
788
+ }
789
+
770
790
  // Build the Message-ID set already in the DB so we can skip duplicates.
771
791
  const seenStmt = db.prepare("SELECT message_id FROM messages WHERE account_id = ? AND message_id IS NOT NULL AND message_id != ''");
772
792
  // INBOX folder per account (target for recovered rows). If no INBOX row
@@ -784,6 +804,12 @@ if (recoverMode) {
784
804
  has_attachments, preview, body_path, cached_at)
785
805
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
786
806
  `);
807
+ // Insert a membership row for every messages row we create so the
808
+ // list views (which JOIN through message_folders) can see them.
809
+ const insertMembership = db.prepare(`
810
+ INSERT OR IGNORE INTO message_folders (message_row_id, folder_id, uid, last_seen_at)
811
+ VALUES (?, ?, ?, ?)
812
+ `);
787
813
 
788
814
  // Walk every account directory under storePath.
789
815
  const accountDirs = fs.readdirSync(storePath, { withFileTypes: true })
@@ -861,10 +887,11 @@ if (recoverMode) {
861
887
  const ccList = parseAddrs(ccRaw);
862
888
  const stat = fs.statSync(p);
863
889
  const rel = path.relative(storePath, p).replace(/\\/g, "/");
864
- insertMsg.run(
890
+ const thisSynthUid = synthUid--;
891
+ const result = insertMsg.run(
865
892
  acct,
866
893
  inboxFolderId,
867
- synthUid--,
894
+ thisSynthUid,
868
895
  messageId,
869
896
  date,
870
897
  subject,
@@ -879,6 +906,12 @@ if (recoverMode) {
879
906
  rel, // body_path (relative to storePath)
880
907
  Date.now(), // cached_at
881
908
  );
909
+ // Membership row — list views JOIN through this, without
910
+ // it the recovered row is invisible in the UI.
911
+ const newRowId = Number((result as any).lastInsertRowid);
912
+ if (newRowId > 0) {
913
+ insertMembership.run(newRowId, inboxFolderId, thisSynthUid, Date.now());
914
+ }
882
915
  seen.add(messageId);
883
916
  indexed++;
884
917
  if (indexed % 500 === 0) console.log(` [${acct}] ${indexed} indexed (${scanned} scanned)`);
@@ -905,6 +938,95 @@ if (recoverMode) {
905
938
  process.exit(0);
906
939
  }
907
940
 
941
+ // Fix flags: strip every \Flagged that exists ONLY in the local DB but NOT
942
+ // on the IMAP server, per folder. The pre-1.1.172 updateMessageFlags had a
943
+ // UID-without-folder bug that propagated a flag set on one folder's UID to
944
+ // every same-numeric-UID row across the account — leaving stars on hundreds
945
+ // of letters the user never starred. This is the one-shot cleanup that
946
+ // reconciles every local flag against the server's truth.
947
+ //
948
+ // What it does: per IMAP-account folder, runs `UID SEARCH FLAGGED` on the
949
+ // server, takes that as authoritative, and clears \Flagged from every local
950
+ // row whose UID isn't in the server's set.
951
+ //
952
+ // What it does NOT touch: \Seen, \Draft, or any custom flags — only the
953
+ // star (\Flagged). That keeps the cleanup scope tight to the symptom the
954
+ // user reported.
955
+ if (hasFlag("fix-flags")) {
956
+ const { getConfigDir } = await import("@bobfrankston/mailx-settings");
957
+ const { loadSettings } = await import("@bobfrankston/mailx-settings");
958
+ const { NativeImapClient } = await import("@bobfrankston/iflow-direct");
959
+ const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
960
+ const dbDir = getConfigDir();
961
+ const dbPath = path.join(dbDir, "mailx.db");
962
+ if (!fs.existsSync(dbPath)) { console.error("No database found."); process.exit(1); }
963
+ const settings = loadSettings();
964
+ const { DatabaseSync } = await import("node:sqlite");
965
+ const db = new DatabaseSync(dbPath);
966
+ db.exec("PRAGMA journal_mode = WAL");
967
+ console.log("Fixing spurious \\Flagged stars...");
968
+ console.log(" Authoritative source: each folder's server-side UID SEARCH FLAGGED.");
969
+ console.log(" Only \\Flagged is touched; \\Seen and others are left alone.");
970
+ let totalCleared = 0;
971
+ let totalFolders = 0;
972
+ for (const acct of settings.accounts) {
973
+ const imap: any = (acct as any).imap;
974
+ if (!imap) { console.log(` [skip] ${acct.id}: no IMAP config (likely Gmail-API)`); continue; }
975
+ if (!imap.password) { console.log(` [skip] ${acct.id}: OAuth account — fix-flags only supports password-auth IMAP for now`); continue; }
976
+ let client: any;
977
+ try {
978
+ client = new (NativeImapClient as any)({
979
+ server: imap.host, port: imap.port || 993,
980
+ user: imap.user || acct.email, password: imap.password,
981
+ useTls: imap.tls !== false,
982
+ }, () => new (NodeTcpTransport as any)());
983
+ await client.connect();
984
+ const folderRows = db.prepare("SELECT id, path FROM folders WHERE account_id = ?").all(acct.id) as { id: number; path: string }[];
985
+ for (const folder of folderRows) {
986
+ try {
987
+ await client.select(folder.path);
988
+ const serverFlaggedUids: number[] = await client.search("FLAGGED");
989
+ const serverSet = new Set<number>(serverFlaggedUids);
990
+ // Pull every local row in this folder that has \Flagged
991
+ const stmt = db.prepare(
992
+ "SELECT uid, flags_json FROM messages WHERE account_id = ? AND folder_id = ? AND flags_json LIKE ?"
993
+ );
994
+ const upd = db.prepare(
995
+ "UPDATE messages SET flags_json = ? WHERE account_id = ? AND folder_id = ? AND uid = ?"
996
+ );
997
+ let cleared = 0;
998
+ for (const r of stmt.iterate(acct.id, folder.id, "%\\\\Flagged%") as any) {
999
+ const uid = r.uid;
1000
+ if (serverSet.has(uid)) continue;
1001
+ let flags: string[] = [];
1002
+ try { flags = JSON.parse(r.flags_json); } catch { /* */ }
1003
+ const without = flags.filter(f => f !== "\\Flagged");
1004
+ if (without.length !== flags.length) {
1005
+ upd.run(JSON.stringify(without), acct.id, folder.id, uid);
1006
+ cleared++;
1007
+ }
1008
+ }
1009
+ if (cleared > 0) {
1010
+ console.log(` [${acct.id}] ${folder.path}: cleared ${cleared} spurious \\Flagged (server has ${serverFlaggedUids.length} actual)`);
1011
+ totalCleared += cleared;
1012
+ }
1013
+ totalFolders++;
1014
+ } catch (e: any) {
1015
+ console.error(` [${acct.id}] ${folder.path}: ${e?.message || e}`);
1016
+ }
1017
+ }
1018
+ } catch (e: any) {
1019
+ console.error(` [${acct.id}] connect failed: ${e?.message || e}`);
1020
+ } finally {
1021
+ try { if (client) await client.logout(); } catch { /* */ }
1022
+ }
1023
+ }
1024
+ db.close();
1025
+ console.log(`Done. Cleared ${totalCleared} spurious \\Flagged rows across ${totalFolders} folders.`);
1026
+ console.log(" Run 'rmfmail' to start the daemon.");
1027
+ process.exit(0);
1028
+ }
1029
+
908
1030
  // Import accounts from a local file into GDrive
909
1031
  if (importMode) {
910
1032
  const importPath = args.find(a => !a.startsWith("-"));
@@ -5209,8 +5209,9 @@ var WebMailxDB = class {
5209
5209
  const r = this.get("SELECT provider_id FROM messages WHERE account_id = ? AND uid = ?", [accountId, uid]);
5210
5210
  return r?.provider_id || "";
5211
5211
  }
5212
- updateMessageFlags(accountId, uid, flags) {
5213
- this.run("UPDATE messages SET flags_json = ? WHERE account_id = ? AND uid = ?", [JSON.stringify(flags), accountId, uid]);
5212
+ /** folderId required — see main mailx-store/db.ts for why. */
5213
+ updateMessageFlags(accountId, folderId, uid, flags) {
5214
+ this.run("UPDATE messages SET flags_json = ? WHERE account_id = ? AND folder_id = ? AND uid = ?", [JSON.stringify(flags), accountId, folderId, uid]);
5214
5215
  }
5215
5216
  updateBodyPath(accountId, uid, bodyPath) {
5216
5217
  this.run("UPDATE messages SET body_path = ? WHERE account_id = ? AND uid = ?", [bodyPath, accountId, uid]);
@@ -9995,7 +9996,7 @@ var AndroidSyncManager = class {
9995
9996
  return raw;
9996
9997
  }
9997
9998
  async updateFlagsLocal(accountId, uid, folderId, flags) {
9998
- this.db.updateMessageFlags(accountId, uid, flags);
9999
+ this.db.updateMessageFlags(accountId, folderId, uid, flags);
9999
10000
  this.db.recalcFolderCounts(folderId);
10000
10001
  this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
10001
10002
  emitEvent({ type: "folderCountsChanged", accountId, counts: {} });