@bobfrankston/rmfmail 1.2.187 → 1.2.190

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 (33) hide show
  1. package/TODO.md +10 -7
  2. package/client/android-bootstrap.bundle.js +66 -47
  3. package/client/android-bootstrap.bundle.js.map +2 -2
  4. package/client/app.bundle.js +22 -0
  5. package/client/app.bundle.js.map +3 -3
  6. package/client/components/calendar-sidebar.js +12 -1
  7. package/client/components/calendar-sidebar.js.map +1 -1
  8. package/client/components/calendar-sidebar.ts +8 -1
  9. package/client/components/message-viewer.js +19 -0
  10. package/client/components/message-viewer.js.map +1 -1
  11. package/client/components/message-viewer.ts +17 -0
  12. package/client/compose/compose.bundle.js +4 -2
  13. package/client/compose/compose.bundle.js.map +2 -2
  14. package/client/compose/editor.js +11 -2
  15. package/client/compose/editor.js.map +1 -1
  16. package/client/compose/editor.ts +11 -2
  17. package/client/package.json +1 -1
  18. package/docs/TODO-prereorg-snapshot-2026-07-15.md +1130 -0
  19. package/package.json +5 -5
  20. package/packages/mailx-imap/package-lock.json +2 -2
  21. package/packages/mailx-imap/package.json +1 -1
  22. package/packages/mailx-store/db.d.ts +6 -2
  23. package/packages/mailx-store/db.d.ts.map +1 -1
  24. package/packages/mailx-store/db.js +46 -16
  25. package/packages/mailx-store/db.js.map +1 -1
  26. package/packages/mailx-store/db.ts +53 -24
  27. package/packages/mailx-store/package.json +1 -1
  28. package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -1
  29. package/packages/mailx-store-web/android-bootstrap.js +84 -50
  30. package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
  31. package/packages/mailx-store-web/android-bootstrap.ts +86 -51
  32. package/packages/mailx-store-web/package.json +1 -1
  33. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-35072 → node_modules.npmglobalize-stash-12376}/.package-lock.json +0 -0
@@ -1759,8 +1759,12 @@ export class MailxDB {
1759
1759
  * During the additive migration we ALSO have to handle the legacy
1760
1760
  * case where the row's folder_id matches but no message_folders row
1761
1761
  * was migrated for it (race during initial population, etc.) — we
1762
- * fall back to the messages.folder_id check to clean up consistently. */
1763
- dropFolderMembership(accountId: string, folderId: number, uid: number, folderPath?: string): boolean {
1762
+ * fall back to the messages.folder_id check to clean up consistently.
1763
+ *
1764
+ * `reason`/`source` override the audit text — deleteMessage routes user
1765
+ * deletes through here, and "reconcile: server missing this UID" would
1766
+ * be a lie in that audit trail. */
1767
+ dropFolderMembership(accountId: string, folderId: number, uid: number, folderPath?: string, reason?: string, source?: string): boolean {
1764
1768
  // First find the messages row this membership pointed at.
1765
1769
  const mf = this.db.prepare(
1766
1770
  "SELECT message_row_id FROM message_folders WHERE folder_id = ? AND uid = ?"
@@ -1809,8 +1813,8 @@ export class MailxDB {
1809
1813
  this.audit({
1810
1814
  kind: "delete-membership",
1811
1815
  accountId, folderId, uid,
1812
- reason: `reconcile dropped folder membership; ${remaining.cnt} other location(s) remain`,
1813
- source: `dropFolderMembership (${folderPath || ""})`,
1816
+ reason: reason || `reconcile dropped folder membership; ${remaining.cnt} other location(s) remain`,
1817
+ source: source || `dropFolderMembership (${folderPath || ""})`,
1814
1818
  });
1815
1819
  return true;
1816
1820
  }
@@ -1827,8 +1831,8 @@ export class MailxDB {
1827
1831
  accountId, folderId, uid,
1828
1832
  messageId: env.message_id || undefined,
1829
1833
  subject: env.subject || undefined,
1830
- reason: "reconcile: server missing this UID after grace, no other folder memberships",
1831
- source: `dropFolderMembership (${folderPath || ""})`,
1834
+ reason: reason || "reconcile: server missing this UID after grace, no other folder memberships",
1835
+ source: source || `dropFolderMembership (${folderPath || ""})`,
1832
1836
  });
1833
1837
  console.log(` [reconcile-delete] ${accountId} ${folderPath || folderId}/${uid} msgid=${env.message_id || "?"} (no other memberships) — body ${env.body_path || "(none)"}`);
1834
1838
  }
@@ -2505,13 +2509,19 @@ export class MailxDB {
2505
2509
  * hitting the message in `_Spam` (correct) and hitting nothing
2506
2510
  * because the legacy messages.folder_id still points at INBOX. */
2507
2511
  getMessageByUid(accountId: string, uid: number, folderId?: number): MessageEnvelope {
2512
+ // Alias mf.folder_id over m.folder_id too — the envelope must carry
2513
+ // the identity of the membership that MATCHED, not the legacy primary
2514
+ // location, or a caller doing delete/move/flag with the returned
2515
+ // folderId operates on a different folder than the one it looked up
2516
+ // (the two drift: local moves keep the original uid, re-binds update
2517
+ // one table but not the other).
2508
2518
  const sql = folderId != null
2509
- ? `SELECT m.*, mf.uid AS uid
2519
+ ? `SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
2510
2520
  FROM messages m
2511
2521
  JOIN message_folders mf ON mf.message_row_id = m.id
2512
2522
  WHERE m.account_id = ? AND mf.uid = ? AND mf.folder_id = ?
2513
2523
  LIMIT 1`
2514
- : `SELECT m.*, mf.uid AS uid
2524
+ : `SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id
2515
2525
  FROM messages m
2516
2526
  JOIN message_folders mf ON mf.message_row_id = m.id
2517
2527
  WHERE m.account_id = ? AND mf.uid = ?
@@ -3114,22 +3124,39 @@ export class MailxDB {
3114
3124
  * Reason is propagated into the audit_log so the DB carries an
3115
3125
  * authoritative trail of every removal. */
3116
3126
  deleteMessage(accountId: string, folderId: number | null, uid: number, reason?: string, source?: string): void {
3117
- // Get message_id + subject before deleting so the audit row carries
3118
- // enough context to identify what was removed.
3119
- const msg = folderId != null
3120
- ? this.db.prepare(
3121
- "SELECT folder_id, message_id, subject FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?"
3122
- ).get(accountId, folderId, uid) as any
3123
- : this.db.prepare(
3124
- "SELECT folder_id, message_id, subject FROM messages WHERE account_id = ? AND uid = ?"
3125
- ).get(accountId, uid) as any;
3126
- const r = folderId != null
3127
- ? this.db.prepare(
3128
- "DELETE FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?"
3129
- ).run(accountId, folderId, uid)
3130
- : this.db.prepare(
3131
- "DELETE FROM messages WHERE account_id = ? AND uid = ?"
3132
- ).run(accountId, uid);
3127
+ if (folderId != null) {
3128
+ // Resolve through message_folders the SAME identity that
3129
+ // getMessageByUid and every list query use. The legacy
3130
+ // messages.folder_id/uid columns can drift from the membership
3131
+ // rows (local moves keep the original uid, server re-binds update
3132
+ // one table but not the other), and a delete keyed only on the
3133
+ // legacy columns silently matches ZERO rows on a drifted message:
3134
+ // the membership keeps it visible in the UI and it becomes
3135
+ // undeletable forever (Eleanor 2026-07-29: ElkinWs uid 125482 in
3136
+ // Trash "refuses to delete" — every attempt no-op'd here while
3137
+ // the sync action reported success). dropFolderMembership drops
3138
+ // the membership, orphan-cleans the messages row, and falls back
3139
+ // to the legacy columns for pre-migration rows.
3140
+ const cleaned = this.dropFolderMembership(
3141
+ accountId, folderId, uid, undefined,
3142
+ reason || "deleteMessage (no reason)",
3143
+ source || "db.deleteMessage",
3144
+ );
3145
+ if (!cleaned) {
3146
+ // A delete that deletes nothing is the "refuses to delete"
3147
+ // symptom — never let it pass silently.
3148
+ console.error(` [delete-miss] ${accountId} folder=${folderId} uid=${uid}: no membership or legacy row matched — nothing deleted (source=${source || "db.deleteMessage"})`);
3149
+ }
3150
+ this.recalcFolderCounts(folderId);
3151
+ return;
3152
+ }
3153
+ // No folderId (legacy callers): fall back to the messages-table key.
3154
+ const msg = this.db.prepare(
3155
+ "SELECT id, folder_id, message_id, subject FROM messages WHERE account_id = ? AND uid = ?"
3156
+ ).get(accountId, uid) as any;
3157
+ const r = this.db.prepare(
3158
+ "DELETE FROM messages WHERE account_id = ? AND uid = ?"
3159
+ ).run(accountId, uid);
3133
3160
  if ((r as any).changes && msg) {
3134
3161
  this.audit({
3135
3162
  kind: "delete-msg",
@@ -3141,6 +3168,8 @@ export class MailxDB {
3141
3168
  reason: reason || "deleteMessage (no reason)",
3142
3169
  source: source || "db.deleteMessage",
3143
3170
  });
3171
+ } else if (!(r as any).changes) {
3172
+ console.error(` [delete-miss] ${accountId} uid=${uid} (no folderId): no messages row matched — nothing deleted (source=${source || "db.deleteMessage"})`);
3144
3173
  }
3145
3174
  // Refresh folder counts
3146
3175
  if (msg) this.recalcFolderCounts(msg.folder_id);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1 +1 @@
1
- {"version":3,"file":"android-bootstrap.d.ts","sourceRoot":"","sources":["android-bootstrap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAizCH,wBAAsB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAgOjD;AAED,wBAAsB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAIhD"}
1
+ {"version":3,"file":"android-bootstrap.d.ts","sourceRoot":"","sources":["android-bootstrap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAo0CH,wBAAgB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAc3C;AAoOD,wBAAsB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAIhD"}
@@ -1020,59 +1020,78 @@ async function refreshAccessToken(refreshToken) {
1020
1020
  }
1021
1021
  // ── Token provider (browser OAuth, same as desktop) ──
1022
1022
  function createNativeTokenProvider(email) {
1023
- return async () => {
1024
- // Check cached token first
1025
- const cached = await getCachedToken(email);
1026
- if (cached?.access_token) {
1027
- const expiresAt = cached.expires_at || 0;
1028
- const bufferMs = 5 * 60 * 1000; // 5 min buffer
1029
- if (Date.now() < expiresAt - bufferMs) {
1030
- return cached.access_token;
1023
+ return () => {
1024
+ // C158: single-flight per email. Startup fires several concurrent
1025
+ // token requests (GDrive reconcile, contacts sync, syncAll) — with
1026
+ // no cached token each one launched its OWN browser-consent intent
1027
+ // (three "Starting OAuth flow" in one boot, 2026-07-22 logit trail).
1028
+ // Concurrent callers now share one resolution. Keyed on a window
1029
+ // global, not a module local, because the double-init bug that
1030
+ // exposed this also loads the module twice — two module instances
1031
+ // must still share the guard.
1032
+ const w = window;
1033
+ const inflight = w.__rmfOAuthInflight ||= new Map();
1034
+ const key = canonEmail(email);
1035
+ const existing = inflight.get(key);
1036
+ if (existing)
1037
+ return existing;
1038
+ const p = fetchTokenForEmail(email).finally(() => inflight.delete(key));
1039
+ inflight.set(key, p);
1040
+ return p;
1041
+ };
1042
+ }
1043
+ async function fetchTokenForEmail(email) {
1044
+ // Check cached token first
1045
+ const cached = await getCachedToken(email);
1046
+ if (cached?.access_token) {
1047
+ const expiresAt = cached.expires_at || 0;
1048
+ const bufferMs = 5 * 60 * 1000; // 5 min buffer
1049
+ if (Date.now() < expiresAt - bufferMs) {
1050
+ return cached.access_token;
1051
+ }
1052
+ // Try refresh
1053
+ if (cached.refresh_token) {
1054
+ try {
1055
+ console.log(`[oauth] Refreshing token for ${email}`);
1056
+ const refreshed = await refreshAccessToken(cached.refresh_token);
1057
+ const token = {
1058
+ access_token: refreshed.access_token,
1059
+ refresh_token: cached.refresh_token,
1060
+ expires_at: Date.now() + refreshed.expires_in * 1000,
1061
+ };
1062
+ await setCachedToken(email, token);
1063
+ return token.access_token;
1031
1064
  }
1032
- // Try refresh
1033
- if (cached.refresh_token) {
1034
- try {
1035
- console.log(`[oauth] Refreshing token for ${email}`);
1036
- const refreshed = await refreshAccessToken(cached.refresh_token);
1037
- const token = {
1038
- access_token: refreshed.access_token,
1039
- refresh_token: cached.refresh_token,
1040
- expires_at: Date.now() + refreshed.expires_in * 1000,
1041
- };
1042
- await setCachedToken(email, token);
1043
- return token.access_token;
1044
- }
1045
- catch (e) {
1046
- console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`);
1047
- }
1065
+ catch (e) {
1066
+ console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`);
1048
1067
  }
1049
1068
  }
1050
- // No valid token — start browser OAuth flow
1051
- const bridge = window._nativeBridge;
1052
- if (!bridge?.app?.startOAuth) {
1053
- throw new Error("No native OAuth bridge");
1054
- }
1055
- const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({
1056
- client_id: OAUTH_CLIENT.clientId,
1057
- redirect_uri: OAUTH_CLIENT.redirectUri,
1058
- response_type: "code",
1059
- scope: OAUTH_SCOPES,
1060
- access_type: "offline",
1061
- prompt: "consent",
1062
- login_hint: email,
1063
- }).toString();
1064
- console.log(`[oauth] Starting browser consent for ${email}`);
1065
- const code = await bridge.app.startOAuth(authUrl);
1066
- const tokens = await exchangeCodeForTokens(code);
1067
- const token = {
1068
- access_token: tokens.access_token,
1069
- refresh_token: tokens.refresh_token,
1070
- expires_at: Date.now() + tokens.expires_in * 1000,
1071
- };
1072
- await setCachedToken(email, token);
1073
- console.log(`[oauth] Token obtained for ${email}`);
1074
- return token.access_token;
1069
+ }
1070
+ // No valid token — start browser OAuth flow
1071
+ const bridge = window._nativeBridge;
1072
+ if (!bridge?.app?.startOAuth) {
1073
+ throw new Error("No native OAuth bridge");
1074
+ }
1075
+ const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({
1076
+ client_id: OAUTH_CLIENT.clientId,
1077
+ redirect_uri: OAUTH_CLIENT.redirectUri,
1078
+ response_type: "code",
1079
+ scope: OAUTH_SCOPES,
1080
+ access_type: "offline",
1081
+ prompt: "consent",
1082
+ login_hint: email,
1083
+ }).toString();
1084
+ console.log(`[oauth] Starting browser consent for ${email}`);
1085
+ const code = await bridge.app.startOAuth(authUrl);
1086
+ const tokens = await exchangeCodeForTokens(code);
1087
+ const token = {
1088
+ access_token: tokens.access_token,
1089
+ refresh_token: tokens.refresh_token,
1090
+ expires_at: Date.now() + tokens.expires_in * 1000,
1075
1091
  };
1092
+ await setCachedToken(email, token);
1093
+ console.log(`[oauth] Token obtained for ${email}`);
1094
+ return token.access_token;
1076
1095
  }
1077
1096
  // ── GDrive folder lookup ──
1078
1097
  async function registerDeviceInGDrive(tokenProvider, folderId, accountIds) {
@@ -1318,7 +1337,22 @@ async function waitForNativeBridge(timeoutMs = 5000) {
1318
1337
  check();
1319
1338
  });
1320
1339
  }
1321
- export async function initAndroid() {
1340
+ export function initAndroid() {
1341
+ // C158: idempotency guard. The 2026-07-22 fold/unfold logit trail showed
1342
+ // ONE WebView reload executing the boot module TWICE — duplicate "bridge
1343
+ // installed", duplicate GDrive lookups, tripled OAuth launches. The
1344
+ // guard lives on window (not a module local) so it holds even when the
1345
+ // module itself is instantiated twice (bundle + package-path specifiers
1346
+ // resolve to distinct module instances).
1347
+ const w = window;
1348
+ if (w.__rmfInitAndroid) {
1349
+ console.warn("[android] initAndroid called again — duplicate suppressed (C158)");
1350
+ vlog("C158: duplicate initAndroid call suppressed");
1351
+ return w.__rmfInitAndroid;
1352
+ }
1353
+ return w.__rmfInitAndroid = initAndroidOnce();
1354
+ }
1355
+ async function initAndroidOnce() {
1322
1356
  console.log("[android] Initializing mailx (main-thread mode)...");
1323
1357
  // Main-thread path: async I/O (fetch, TCP bridge) doesn't block the UI,
1324
1358
  // and only sql.js is CPU-bound enough to maybe warrant a Worker later.