@bobfrankston/rmfmail 1.0.565 → 1.0.570

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 (44) hide show
  1. package/client/app.js +13 -5
  2. package/client/app.js.map +1 -1
  3. package/client/app.ts +12 -4
  4. package/client/components/folder-tree.js +11 -0
  5. package/client/components/folder-tree.js.map +1 -1
  6. package/client/components/folder-tree.ts +8 -0
  7. package/client/components/message-list.js +6 -3
  8. package/client/components/message-list.js.map +1 -1
  9. package/client/components/message-list.ts +6 -3
  10. package/client/components/message-viewer.js +58 -5
  11. package/client/components/message-viewer.js.map +1 -1
  12. package/client/components/message-viewer.ts +58 -5
  13. package/client/index.html +1 -0
  14. package/client/lib/api-client.js +2 -2
  15. package/client/lib/api-client.js.map +1 -1
  16. package/client/lib/api-client.ts +2 -2
  17. package/client/lib/mailxapi.js +2 -2
  18. package/client/styles/components.css +13 -0
  19. package/package.json +3 -3
  20. package/packages/mailx-imap/index.d.ts.map +1 -1
  21. package/packages/mailx-imap/index.js +29 -13
  22. package/packages/mailx-imap/index.js.map +1 -1
  23. package/packages/mailx-imap/index.ts +29 -13
  24. package/packages/mailx-imap/package-lock.json +2 -2
  25. package/packages/mailx-imap/package.json +1 -1
  26. package/packages/mailx-service/index.d.ts +1 -1
  27. package/packages/mailx-service/index.d.ts.map +1 -1
  28. package/packages/mailx-service/index.js +5 -3
  29. package/packages/mailx-service/index.js.map +1 -1
  30. package/packages/mailx-service/index.ts +5 -3
  31. package/packages/mailx-service/jsonrpc.js +1 -1
  32. package/packages/mailx-service/jsonrpc.js.map +1 -1
  33. package/packages/mailx-service/jsonrpc.ts +1 -1
  34. package/packages/mailx-service/local-store.d.ts +1 -1
  35. package/packages/mailx-service/local-store.d.ts.map +1 -1
  36. package/packages/mailx-service/local-store.js +2 -2
  37. package/packages/mailx-service/local-store.js.map +1 -1
  38. package/packages/mailx-service/local-store.ts +2 -2
  39. package/packages/mailx-store/db.d.ts +65 -6
  40. package/packages/mailx-store/db.d.ts.map +1 -1
  41. package/packages/mailx-store/db.js +311 -53
  42. package/packages/mailx-store/db.js.map +1 -1
  43. package/packages/mailx-store/db.ts +340 -54
  44. package/packages/mailx-store/package.json +1 -1
@@ -314,6 +314,31 @@ const SCHEMA = `
314
314
  );
315
315
  CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts);
316
316
  CREATE INDEX IF NOT EXISTS idx_audit_log_folder ON audit_log(account_id, folder_id);
317
+
318
+ -- Schema refactor: messages identity vs folder location.
319
+ -- Before: a messages row was identified by (account, folder, uid).
320
+ -- Server-side moves required deleting the old row and inserting a
321
+ -- new one (losing UUID, body cache, flags), or a fragile move-detect
322
+ -- rebind. A message in multiple Gmail labels needed multiple rows
323
+ -- with the same Message-ID.
324
+ -- After: a messages row is one logical email (identity = uuid;
325
+ -- semantic key = account + message_id). Folder membership lives in
326
+ -- message_folders. A move is delete-one-membership + add-another.
327
+ -- Body, UUID, flags, custom annotations all stay put. Multi-folder
328
+ -- messages just have multiple membership rows.
329
+ -- During the additive migration, messages.folder_id / messages.uid
330
+ -- stay populated as primary location so existing reads keep working.
331
+ -- Reads switch to JOIN with message_folders step by step.
332
+ CREATE TABLE IF NOT EXISTS message_folders (
333
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
334
+ message_row_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
335
+ folder_id INTEGER NOT NULL,
336
+ uid INTEGER NOT NULL,
337
+ last_seen_at INTEGER NOT NULL,
338
+ UNIQUE(folder_id, uid)
339
+ );
340
+ CREATE INDEX IF NOT EXISTS idx_message_folders_msg ON message_folders(message_row_id);
341
+ CREATE INDEX IF NOT EXISTS idx_message_folders_folder_uid ON message_folders(folder_id, uid);
317
342
  `;
318
343
  export class MailxDB {
319
344
  db;
@@ -384,6 +409,29 @@ export class MailxDB {
384
409
  catch (e) {
385
410
  console.error(` [migration] synthetic-UID cleanup failed: ${e?.message || e}`);
386
411
  }
412
+ // One-shot: backfill message_folders from existing messages rows.
413
+ // After this runs, every existing (account, folder, uid) row in
414
+ // messages has a corresponding membership row in message_folders.
415
+ // New writes (upsertMessage) will populate both during the
416
+ // additive migration. UNIQUE(folder_id, uid) on message_folders
417
+ // means INSERT OR IGNORE — the migration is idempotent if rerun.
418
+ try {
419
+ const flag = this.getKv("schema", "message_folders_v1");
420
+ if (!flag) {
421
+ const now = Date.now();
422
+ const r = this.db.prepare(`INSERT OR IGNORE INTO message_folders (message_row_id, folder_id, uid, last_seen_at)
423
+ SELECT id, folder_id, uid, ? FROM messages`).run(now);
424
+ const count = r.changes || 0;
425
+ if (count > 0) {
426
+ console.log(` [migration] message_folders_v1: backfilled ${count} membership rows from messages`);
427
+ this.audit({ kind: "migration", count, reason: "message_folders_v1 — populate join table from existing messages.folder_id/uid", source: "MailxDB constructor" });
428
+ }
429
+ this.setKv("schema", "message_folders_v1", "1");
430
+ }
431
+ }
432
+ catch (e) {
433
+ console.error(` [migration] message_folders backfill failed: ${e?.message || e}`);
434
+ }
387
435
  // One-shot contacts table reset: the contacts schema's UNIQUE constraint
388
436
  // was widened from `(email)` to `(source, email, name)` so the same
389
437
  // address can carry multiple distinct (name, source) entries — Bob's
@@ -975,23 +1023,66 @@ export class MailxDB {
975
1023
  }
976
1024
  }
977
1025
  deleteFolder(folderId) {
978
- const r = this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
979
- const count = r.changes || 0;
980
- if (count > 0)
981
- this.audit({ kind: "delete-bulk", folderId, count, reason: "folder deleted", source: "deleteFolder" });
1026
+ // Drop memberships in this folder first, then GC any messages
1027
+ // rows that are now orphaned (no remaining memberships). A
1028
+ // multi-folder message (e.g. Gmail label = INBOX + Important)
1029
+ // keeps its body intact when only one of its folders is deleted.
1030
+ const orphans = this.gcMembershipsAndCollectOrphans(folderId);
1031
+ if (orphans.count > 0)
1032
+ this.audit({ kind: "delete-bulk", folderId, count: orphans.count, reason: "folder deleted", source: "deleteFolder" });
982
1033
  this.db.prepare("DELETE FROM folders WHERE id = ?").run(folderId);
983
1034
  }
984
1035
  markFolderRead(folderId) {
985
- this.db.prepare(`UPDATE messages SET flags_json = REPLACE(flags_json, '[]', '["\\\\Seen"]') WHERE folder_id = ? AND flags_json NOT LIKE '%\\\\Seen%'`).run(folderId);
1036
+ // Mark seen for messages currently in this folder (membership-scoped,
1037
+ // not legacy folder_id) so a message that lives in multiple folders
1038
+ // becomes seen everywhere — which matches IMAP semantics: \Seen is
1039
+ // a per-message flag, not per-folder. (When the day comes that we
1040
+ // want truly per-folder read-state, this needs `flag_overrides`.)
1041
+ this.db.prepare(`UPDATE messages SET flags_json = REPLACE(flags_json, '[]', '["\\\\Seen"]')
1042
+ WHERE id IN (SELECT message_row_id FROM message_folders WHERE folder_id = ?)
1043
+ AND flags_json NOT LIKE '%\\\\Seen%'`).run(folderId);
986
1044
  this.recalcFolderCounts(folderId);
987
1045
  }
988
1046
  deleteAllMessages(accountId, folderId) {
989
- const r = this.db.prepare("DELETE FROM messages WHERE account_id = ? AND folder_id = ?").run(accountId, folderId);
990
- const count = r.changes || 0;
991
- if (count > 0)
992
- this.audit({ kind: "delete-bulk", accountId, folderId, count, reason: "deleteAllMessages (emptyFolder)", source: "deleteAllMessages" });
1047
+ const orphans = this.gcMembershipsAndCollectOrphans(folderId, accountId);
1048
+ if (orphans.count > 0)
1049
+ this.audit({ kind: "delete-bulk", accountId, folderId, count: orphans.count, reason: "deleteAllMessages (emptyFolder)", source: "deleteAllMessages" });
993
1050
  this.recalcFolderCounts(folderId);
994
1051
  }
1052
+ /** Drop every `message_folders` row in `folderId` (optionally scoped to an
1053
+ * account) and GC any `messages` rows that no longer have ANY memberships.
1054
+ * Returns count of memberships removed (so callers can audit "I deleted N
1055
+ * things"). The messages-row delete cascades to FTS / sync_actions /
1056
+ * message_folders (CASCADE) so this is the safe single op for "clear
1057
+ * this folder" without nuking multi-folder messages. */
1058
+ gcMembershipsAndCollectOrphans(folderId, accountId) {
1059
+ // Find membership rows we're about to delete
1060
+ const membershipQ = accountId
1061
+ ? `SELECT mf.id, mf.message_row_id FROM message_folders mf
1062
+ JOIN messages m ON m.id = mf.message_row_id
1063
+ WHERE mf.folder_id = ? AND m.account_id = ?`
1064
+ : `SELECT mf.id, mf.message_row_id FROM message_folders mf WHERE mf.folder_id = ?`;
1065
+ const memberships = (accountId
1066
+ ? this.db.prepare(membershipQ).all(folderId, accountId)
1067
+ : this.db.prepare(membershipQ).all(folderId));
1068
+ if (memberships.length === 0)
1069
+ return { count: 0 };
1070
+ // Drop them
1071
+ const delMemb = this.db.prepare("DELETE FROM message_folders WHERE id = ?");
1072
+ for (const m of memberships)
1073
+ delMemb.run(m.id);
1074
+ // GC any messages rows that now have zero remaining memberships.
1075
+ // Use a Set so a multi-membership row only gets one orphan check.
1076
+ const touched = Array.from(new Set(memberships.map(m => m.message_row_id)));
1077
+ const isOrphan = this.db.prepare("SELECT COUNT(*) AS c FROM message_folders WHERE message_row_id = ?");
1078
+ const delMsg = this.db.prepare("DELETE FROM messages WHERE id = ?");
1079
+ for (const rowId of touched) {
1080
+ if ((isOrphan.get(rowId)?.c | 0) === 0) {
1081
+ delMsg.run(rowId);
1082
+ }
1083
+ }
1084
+ return { count: memberships.length };
1085
+ }
995
1086
  updateFolderCounts(folderId, total, unread) {
996
1087
  this.db.prepare("UPDATE folders SET total_count = ?, unread_count = ? WHERE id = ?").run(total, unread, folderId);
997
1088
  }
@@ -1003,6 +1094,102 @@ export class MailxDB {
1003
1094
  return row || { uidvalidity: 0, highestModseq: "0" };
1004
1095
  }
1005
1096
  // ── Messages ──
1097
+ /** Record that a message row currently lives at (folder, uid). Idempotent
1098
+ * via UNIQUE(folder_id, uid) — re-inserting the same membership just
1099
+ * refreshes last_seen_at. A message in N Gmail labels has N rows.
1100
+ * A server-side move is "delete one membership row, insert another";
1101
+ * the messages row is untouched. Called from upsertMessage on every
1102
+ * insert / move-detect / existing-row upsert during the additive
1103
+ * migration. Reconcile is the only path that DELETES from this table
1104
+ * (when the server stops listing a UID in a folder). */
1105
+ upsertMessageFolder(messageRowId, folderId, uid) {
1106
+ const now = Date.now();
1107
+ // INSERT OR REPLACE on (folder_id, uid) — if some other message_row_id
1108
+ // somehow had this slot, replace it. In practice this happens after
1109
+ // an EXPUNGE+reinsert: a UID gets reused by the server for a different
1110
+ // message. The new message wins; the old's membership entry goes away.
1111
+ this.db.prepare(`INSERT INTO message_folders (message_row_id, folder_id, uid, last_seen_at)
1112
+ VALUES (?, ?, ?, ?)
1113
+ ON CONFLICT(folder_id, uid) DO UPDATE SET
1114
+ message_row_id = excluded.message_row_id,
1115
+ last_seen_at = excluded.last_seen_at`).run(messageRowId, folderId, uid, now);
1116
+ }
1117
+ /** Drop a membership row when reconcile finds the UID is no longer
1118
+ * on the server in this folder. The messages row itself is NOT
1119
+ * touched — it may still exist in other folders, or the deferred-
1120
+ * cleanup pass (later) handles orphan messages with no memberships. */
1121
+ removeMessageFolder(folderId, uid) {
1122
+ const r = this.db.prepare("DELETE FROM message_folders WHERE folder_id = ? AND uid = ?").run(folderId, uid);
1123
+ return (r.changes || 0) > 0;
1124
+ }
1125
+ /** Reconcile-driven membership drop. Called when the server's UID list
1126
+ * for a folder no longer contains a UID we have locally. Drops the
1127
+ * membership row; if the messages row has no other memberships AND
1128
+ * no other folder claims it via the legacy folder_id column, deletes
1129
+ * the messages row + unlinks the body file. Returns true if any
1130
+ * cleanup happened (so the caller can skip the legacy delete path).
1131
+ *
1132
+ * During the additive migration we ALSO have to handle the legacy
1133
+ * case where the row's folder_id matches but no message_folders row
1134
+ * was migrated for it (race during initial population, etc.) — we
1135
+ * fall back to the messages.folder_id check to clean up consistently. */
1136
+ dropFolderMembership(accountId, folderId, uid, folderPath) {
1137
+ // First find the messages row this membership pointed at.
1138
+ const mf = this.db.prepare("SELECT message_row_id FROM message_folders WHERE folder_id = ? AND uid = ?").get(folderId, uid);
1139
+ let rowId = null;
1140
+ if (mf) {
1141
+ // Drop the membership.
1142
+ this.db.prepare("DELETE FROM message_folders WHERE folder_id = ? AND uid = ?").run(folderId, uid);
1143
+ rowId = mf.message_row_id;
1144
+ }
1145
+ else {
1146
+ // Legacy fallback: the row exists in messages.folder_id/uid but
1147
+ // not in message_folders (pre-migration data, or a row that
1148
+ // somehow skipped the dual-write). Use the legacy lookup.
1149
+ const legacy = this.db.prepare("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(accountId, folderId, uid);
1150
+ rowId = legacy?.id ?? null;
1151
+ }
1152
+ if (rowId === null) {
1153
+ console.log(` [reconcile-skip] ${accountId} folder=${folderId} uid=${uid}: no row to drop (already gone or moved)`);
1154
+ return false;
1155
+ }
1156
+ // Check if any other memberships exist for this row.
1157
+ const remaining = this.db.prepare("SELECT COUNT(*) AS cnt FROM message_folders WHERE message_row_id = ?").get(rowId);
1158
+ if (remaining.cnt > 0) {
1159
+ // Message lives in another folder still — preserve everything.
1160
+ // The messages.folder_id column may still point at the dropped
1161
+ // location; sync the primary location to one of the remaining
1162
+ // memberships so legacy reads stay consistent.
1163
+ const next = this.db.prepare("SELECT folder_id, uid FROM message_folders WHERE message_row_id = ? ORDER BY id LIMIT 1").get(rowId);
1164
+ if (next) {
1165
+ this.db.prepare("UPDATE messages SET folder_id = ?, uid = ? WHERE id = ?").run(next.folder_id, next.uid, rowId);
1166
+ }
1167
+ console.log(` [reconcile-membership] ${accountId} ${folderPath || folderId}/${uid}: dropped — message still in ${remaining.cnt} other folder(s)`);
1168
+ this.audit({
1169
+ kind: "delete-membership",
1170
+ accountId, folderId, uid,
1171
+ reason: `reconcile dropped folder membership; ${remaining.cnt} other location(s) remain`,
1172
+ source: `dropFolderMembership (${folderPath || ""})`,
1173
+ });
1174
+ return true;
1175
+ }
1176
+ // No other memberships — message is truly gone. Delete the row.
1177
+ // Capture context for audit before delete.
1178
+ const env = this.db.prepare("SELECT message_id, subject, body_path FROM messages WHERE id = ?").get(rowId);
1179
+ this.db.prepare("DELETE FROM messages WHERE id = ?").run(rowId);
1180
+ if (env) {
1181
+ this.audit({
1182
+ kind: "delete-msg",
1183
+ accountId, folderId, uid,
1184
+ messageId: env.message_id || undefined,
1185
+ subject: env.subject || undefined,
1186
+ reason: "reconcile: server missing this UID after grace, no other folder memberships",
1187
+ source: `dropFolderMembership (${folderPath || ""})`,
1188
+ });
1189
+ console.log(` [reconcile-delete] ${accountId} ${folderPath || folderId}/${uid} msgid=${env.message_id || "?"} (no other memberships) — body ${env.body_path || "(none)"}`);
1190
+ }
1191
+ return true;
1192
+ }
1006
1193
  upsertMessage(msg) {
1007
1194
  const existing = this.db.prepare("SELECT id, provider_id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(msg.accountId, msg.folderId, msg.uid);
1008
1195
  if (existing) {
@@ -1028,6 +1215,11 @@ export class MailxDB {
1028
1215
  WHERE id = ?
1029
1216
  `).run(JSON.stringify(msg.flags), Date.now(), existing.id);
1030
1217
  }
1218
+ // Refresh membership last_seen_at — server confirmed this UID
1219
+ // is still in this folder. No-op if migration already populated
1220
+ // it; defensive INSERT-OR-UPDATE handles the race where the row
1221
+ // was created without a corresponding membership.
1222
+ this.upsertMessageFolder(existing.id, msg.folderId, msg.uid);
1031
1223
  return existing.id;
1032
1224
  }
1033
1225
  // Move-detection: if this Message-ID already exists for this account
@@ -1042,9 +1234,17 @@ export class MailxDB {
1042
1234
  const moved = this.db.prepare("SELECT id, folder_id, uid FROM messages WHERE account_id = ? AND message_id = ? LIMIT 1").get(msg.accountId, msg.messageId);
1043
1235
  if (moved) {
1044
1236
  console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: rebinding row ${moved.id} (folder ${moved.folder_id}/uid ${moved.uid} → folder ${msg.folderId}/uid ${msg.uid})`);
1045
- // Update folder_id + uid; preserve uuid, body_path, flags
1046
- // (server flags will catch up on the next full sync).
1237
+ // Update folder_id + uid on the messages row (additive
1238
+ // migration: keeps the "primary location" columns valid for
1239
+ // existing reads). The new schema's source of truth is
1240
+ // message_folders — see below.
1047
1241
  this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
1242
+ // Add the new membership row. The OLD membership (source
1243
+ // folder + old uid) stays — reconcile in the source folder
1244
+ // is what eventually drops it (when the server stops
1245
+ // listing the UID there). For Gmail multi-label messages,
1246
+ // both memberships persist legitimately.
1247
+ this.upsertMessageFolder(moved.id, msg.folderId, msg.uid);
1048
1248
  // Notify subscribers so e.g. the reconciler can cancel any
1049
1249
  // pending deferred-delete for the original (folder, uid) —
1050
1250
  // otherwise the reconcile-delete grace timer fires for a
@@ -1086,6 +1286,11 @@ export class MailxDB {
1086
1286
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1087
1287
  `).run(msg.accountId, msg.folderId, msg.uid, uuid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now(), msg.providerId || null);
1088
1288
  const rowId = Number(result.lastInsertRowid);
1289
+ // Record the (folder, uid) membership for the new row. New schema
1290
+ // source of truth — old folder_id/uid columns above are kept in
1291
+ // sync during the additive migration but reads will move to JOIN
1292
+ // with message_folders.
1293
+ this.upsertMessageFolder(rowId, msg.folderId, msg.uid);
1089
1294
  // Index for full-text search
1090
1295
  try {
1091
1296
  this.db.prepare("INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)").run(rowId, msg.subject, msg.from.name, msg.from.address, toText, ccText, msg.preview);
@@ -1093,41 +1298,47 @@ export class MailxDB {
1093
1298
  catch { /* FTS insert may fail on rebuild, non-fatal */ }
1094
1299
  return rowId;
1095
1300
  }
1301
+ /** List view: messages currently in (account, folder).
1302
+ * Joins through `message_folders` so the UID + folder location come
1303
+ * from membership rows, not from the legacy messages.folder_id /
1304
+ * messages.uid columns. After a server-side move (Sieve filter,
1305
+ * webmail action, etc.), the membership row is the truth — the
1306
+ * legacy columns are frozen at first-sight values. */
1096
1307
  getMessages(query) {
1097
1308
  const page = query.page || 1;
1098
1309
  const pageSize = query.pageSize || 50;
1099
1310
  const offset = (page - 1) * pageSize;
1100
1311
  const sort = query.sort || "date";
1101
1312
  const sortDir = query.sortDir || "desc";
1102
- const sortCol = sort === "from" ? "from_name" : sort === "subject" ? "subject" : "date";
1103
- let where = "account_id = ? AND folder_id = ?";
1313
+ const sortCol = sort === "from" ? "m.from_name" : sort === "subject" ? "m.subject" : "m.date";
1314
+ let where = "m.account_id = ? AND mf.folder_id = ?";
1104
1315
  const params = [query.accountId, query.folderId];
1105
1316
  if (query.search) {
1106
- where += " AND (subject LIKE ? OR from_name LIKE ? OR from_address LIKE ?)";
1317
+ where += " AND (m.subject LIKE ? OR m.from_name LIKE ? OR m.from_address LIKE ?)";
1107
1318
  const term = `%${query.search}%`;
1108
1319
  params.push(term, term, term);
1109
1320
  }
1110
1321
  if (query.flaggedOnly) {
1111
- // flags_json is a JSON array like ["\\Seen","\\Flagged"]. A plain
1112
- // LIKE on the serialized form is sufficient to find rows with the
1113
- // \Flagged flag without decoding every row.
1114
- where += " AND flags_json LIKE '%\\\\Flagged%'";
1115
- }
1116
- const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`).get(...params).cnt;
1117
- // LEFT JOIN sync_actions so each row carries a `pending` flag
1118
- // true when the user has a queued local action (move/flag/delete)
1119
- // not yet acknowledged by the server. UI renders these in pink so
1120
- // local-only state is visible (Slice C of S1). The optimistic-Sent
1121
- // negative-UID convention was retired Sent now reflects only what
1122
- // the server has; pending sends live in the Outbox view.
1123
- const rows = this.db.prepare(`SELECT m.*, (
1322
+ where += " AND m.flags_json LIKE '%\\\\Flagged%'";
1323
+ }
1324
+ const total = this.db.prepare(`SELECT COUNT(*) as cnt
1325
+ FROM messages m
1326
+ JOIN message_folders mf ON mf.message_row_id = m.id
1327
+ WHERE ${where}`).get(...params).cnt;
1328
+ // pending = user has a queued local action against (account, uid).
1329
+ // uid here is the membership uid (mf.uid), not m.uid sync_actions
1330
+ // were also keyed against the at-action-time uid, so this match
1331
+ // remains stable across server-side moves of the SAME message
1332
+ // (the action gets re-targeted to the new uid by the reconciler).
1333
+ const rows = this.db.prepare(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id,
1124
1334
  EXISTS(
1125
1335
  SELECT 1 FROM sync_actions sa
1126
- WHERE sa.account_id = m.account_id AND sa.uid = m.uid
1127
- )
1128
- ) AS pending
1129
- FROM messages m WHERE ${where.replace(/\b(account_id|folder_id|uid|date|subject|from_name|from_address|flags_json)\b/g, "m.$1")}
1130
- ORDER BY m.${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
1336
+ WHERE sa.account_id = m.account_id AND sa.uid = mf.uid
1337
+ ) AS pending
1338
+ FROM messages m
1339
+ JOIN message_folders mf ON mf.message_row_id = m.id
1340
+ WHERE ${where}
1341
+ ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
1131
1342
  const items = rows.map(r => ({
1132
1343
  id: r.id,
1133
1344
  accountId: r.account_id,
@@ -1161,14 +1372,19 @@ export class MailxDB {
1161
1372
  return { items: [], total: 0, page, pageSize };
1162
1373
  const placeholders = inboxRows.map(() => "?").join(",");
1163
1374
  const folderIds = inboxRows.map((r) => r.id);
1164
- const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages WHERE folder_id IN (${placeholders})`).get(...folderIds).cnt;
1165
- const rows = this.db.prepare(`SELECT m.*, EXISTS(
1166
- SELECT 1 FROM sync_actions sa
1167
- WHERE sa.account_id = m.account_id AND sa.uid = m.uid
1168
- ) AS pending,
1169
- (SELECT COUNT(DISTINCT account_id) FROM messages m2
1170
- WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
1171
- FROM messages m WHERE m.folder_id IN (${placeholders})
1375
+ const total = this.db.prepare(`SELECT COUNT(*) as cnt
1376
+ FROM message_folders mf
1377
+ WHERE mf.folder_id IN (${placeholders})`).get(...folderIds).cnt;
1378
+ const rows = this.db.prepare(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id,
1379
+ EXISTS(
1380
+ SELECT 1 FROM sync_actions sa
1381
+ WHERE sa.account_id = m.account_id AND sa.uid = mf.uid
1382
+ ) AS pending,
1383
+ (SELECT COUNT(DISTINCT account_id) FROM messages m2
1384
+ WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
1385
+ FROM messages m
1386
+ JOIN message_folders mf ON mf.message_row_id = m.id
1387
+ WHERE mf.folder_id IN (${placeholders})
1172
1388
  ORDER BY m.date DESC LIMIT ? OFFSET ?`).all(...folderIds, pageSize, offset);
1173
1389
  const items = rows.map(r => ({
1174
1390
  id: r.id,
@@ -1225,10 +1441,24 @@ export class MailxDB {
1225
1441
  providerId: r.provider_id || undefined,
1226
1442
  };
1227
1443
  }
1444
+ /** Look up a message by (account, uid) and optionally folder.
1445
+ * Joins through `message_folders` so the message is found at its
1446
+ * CURRENT location, not whatever messages.folder_id was at first
1447
+ * sight. After server-side moves, that's the difference between
1448
+ * hitting the message in `_Spam` (correct) and hitting nothing
1449
+ * because the legacy messages.folder_id still points at INBOX. */
1228
1450
  getMessageByUid(accountId, uid, folderId) {
1229
1451
  const sql = folderId != null
1230
- ? "SELECT * FROM messages WHERE account_id = ? AND uid = ? AND folder_id = ?"
1231
- : "SELECT * FROM messages WHERE account_id = ? AND uid = ?";
1452
+ ? `SELECT m.*, mf.uid AS uid
1453
+ FROM messages m
1454
+ JOIN message_folders mf ON mf.message_row_id = m.id
1455
+ WHERE m.account_id = ? AND mf.uid = ? AND mf.folder_id = ?
1456
+ LIMIT 1`
1457
+ : `SELECT m.*, mf.uid AS uid
1458
+ FROM messages m
1459
+ JOIN message_folders mf ON mf.message_row_id = m.id
1460
+ WHERE m.account_id = ? AND mf.uid = ?
1461
+ LIMIT 1`;
1232
1462
  const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
1233
1463
  const r = this.db.prepare(sql).get(...params);
1234
1464
  if (!r)
@@ -1283,21 +1513,38 @@ export class MailxDB {
1283
1513
  // Size 0 / NULL fall to the end so they don't masquerade as small.
1284
1514
  return this.db.prepare("SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path = '') ORDER BY (size IS NULL OR size = 0), size ASC, date DESC LIMIT ?").all(accountId, limit);
1285
1515
  }
1286
- getHighestUid(accountId, folderId) {
1287
- const r = this.db.prepare("SELECT MAX(uid) as maxUid FROM messages WHERE account_id = ? AND folder_id = ?").get(accountId, folderId);
1516
+ /** Highest server UID we've seen in this folder. After the
1517
+ * message_folders refactor, this reads from the membership table
1518
+ * rather than messages.uid — a server-side move from this folder
1519
+ * drops the membership row, so the high-water mark correctly
1520
+ * decreases when the message left. account_id is implicit
1521
+ * (folder_id is account-scoped). */
1522
+ getHighestUid(_accountId, folderId) {
1523
+ const r = this.db.prepare("SELECT MAX(uid) as maxUid FROM message_folders WHERE folder_id = ?").get(folderId);
1288
1524
  return r?.maxUid || 0;
1289
1525
  }
1290
- getOldestDate(accountId, folderId) {
1291
- const r = this.db.prepare("SELECT MIN(date) as minDate FROM messages WHERE account_id = ? AND folder_id = ?").get(accountId, folderId);
1526
+ /** Oldest message date in this folder. Joins through message_folders
1527
+ * so a message that LEFT this folder via server-side move stops
1528
+ * influencing the date window for incremental backfill. */
1529
+ getOldestDate(_accountId, folderId) {
1530
+ const r = this.db.prepare(`SELECT MIN(m.date) as minDate
1531
+ FROM messages m
1532
+ JOIN message_folders mf ON mf.message_row_id = m.id
1533
+ WHERE mf.folder_id = ?`).get(folderId);
1292
1534
  return r?.minDate || 0;
1293
1535
  }
1294
- getMessageCount(accountId, folderId) {
1295
- const r = this.db.prepare("SELECT count(*) as cnt FROM messages WHERE account_id = ? AND folder_id = ?").get(accountId, folderId);
1536
+ /** Number of messages currently IN this folder (server-side membership,
1537
+ * not whatever messages.folder_id happens to say after a move). */
1538
+ getMessageCount(_accountId, folderId) {
1539
+ const r = this.db.prepare("SELECT count(*) as cnt FROM message_folders WHERE folder_id = ?").get(folderId);
1296
1540
  return r?.cnt || 0;
1297
1541
  }
1298
- /** Get all UIDs for a folder */
1299
- getUidsForFolder(accountId, folderId) {
1300
- const rows = this.db.prepare("SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?").all(accountId, folderId);
1542
+ /** All UIDs the server has confirmed in this folder. Used by
1543
+ * reconcile to diff against the server's UID list — anything in
1544
+ * here but NOT on the server gets its membership dropped (which
1545
+ * may then GC the messages row if it has no other folders). */
1546
+ getUidsForFolder(_accountId, folderId) {
1547
+ const rows = this.db.prepare("SELECT uid FROM message_folders WHERE folder_id = ?").all(folderId);
1301
1548
  return rows.map(r => r.uid);
1302
1549
  }
1303
1550
  /** Delete a message by account + UID. Reason is propagated into the
@@ -1804,7 +2051,7 @@ export class MailxDB {
1804
2051
  }
1805
2052
  // ── Search ──
1806
2053
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
1807
- searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
2054
+ searchMessages(query, page = 1, pageSize = 50, accountId, folderId, includeTrashSpam = false) {
1808
2055
  query = (query || "").trim();
1809
2056
  // Parse qualifiers (C45: extended set — date:, has:, is:, folder:).
1810
2057
  let ftsQuery = "";
@@ -1932,11 +2179,22 @@ export class MailxDB {
1932
2179
  scopeWhere = " AND m.account_id = ?";
1933
2180
  scopeParams.push(accountId);
1934
2181
  }
2182
+ // Global / account-wide search excludes trash + junk unless the
2183
+ // user opted in. A user typing "invoice" in the search box almost
2184
+ // never wants the deleted/spam copy that's already filtered out
2185
+ // of every other view. When the user IS explicitly scoped to
2186
+ // trash/junk via folderId, this guard is skipped.
2187
+ if (!includeTrashSpam && !folderId) {
2188
+ scopeWhere += " AND (f.special_use IS NULL OR f.special_use NOT IN ('trash','junk'))";
2189
+ }
1935
2190
  if (extraWhere.length > 0) {
1936
2191
  scopeWhere += " AND " + extraWhere.join(" AND ");
1937
2192
  scopeParams.push(...extraParams);
1938
2193
  }
1939
- const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages m JOIN messages_fts fts ON m.id = fts.rowid WHERE messages_fts MATCH ?${scopeWhere}`).get(ftsQuery, ...scopeParams);
2194
+ const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages m
2195
+ JOIN messages_fts fts ON m.id = fts.rowid
2196
+ LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
2197
+ WHERE messages_fts MATCH ?${scopeWhere}`).get(ftsQuery, ...scopeParams);
1940
2198
  const total = countRow?.cnt || 0;
1941
2199
  const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name FROM messages m
1942
2200
  JOIN messages_fts fts ON m.id = fts.rowid