@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
@@ -303,6 +303,31 @@ const SCHEMA = `
303
303
  );
304
304
  CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts);
305
305
  CREATE INDEX IF NOT EXISTS idx_audit_log_folder ON audit_log(account_id, folder_id);
306
+
307
+ -- Schema refactor: messages identity vs folder location.
308
+ -- Before: a messages row was identified by (account, folder, uid).
309
+ -- Server-side moves required deleting the old row and inserting a
310
+ -- new one (losing UUID, body cache, flags), or a fragile move-detect
311
+ -- rebind. A message in multiple Gmail labels needed multiple rows
312
+ -- with the same Message-ID.
313
+ -- After: a messages row is one logical email (identity = uuid;
314
+ -- semantic key = account + message_id). Folder membership lives in
315
+ -- message_folders. A move is delete-one-membership + add-another.
316
+ -- Body, UUID, flags, custom annotations all stay put. Multi-folder
317
+ -- messages just have multiple membership rows.
318
+ -- During the additive migration, messages.folder_id / messages.uid
319
+ -- stay populated as primary location so existing reads keep working.
320
+ -- Reads switch to JOIN with message_folders step by step.
321
+ CREATE TABLE IF NOT EXISTS message_folders (
322
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
323
+ message_row_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
324
+ folder_id INTEGER NOT NULL,
325
+ uid INTEGER NOT NULL,
326
+ last_seen_at INTEGER NOT NULL,
327
+ UNIQUE(folder_id, uid)
328
+ );
329
+ CREATE INDEX IF NOT EXISTS idx_message_folders_msg ON message_folders(message_row_id);
330
+ CREATE INDEX IF NOT EXISTS idx_message_folders_folder_uid ON message_folders(folder_id, uid);
306
331
  `;
307
332
 
308
333
  export class MailxDB {
@@ -374,6 +399,31 @@ export class MailxDB {
374
399
  console.error(` [migration] synthetic-UID cleanup failed: ${e?.message || e}`);
375
400
  }
376
401
 
402
+ // One-shot: backfill message_folders from existing messages rows.
403
+ // After this runs, every existing (account, folder, uid) row in
404
+ // messages has a corresponding membership row in message_folders.
405
+ // New writes (upsertMessage) will populate both during the
406
+ // additive migration. UNIQUE(folder_id, uid) on message_folders
407
+ // means INSERT OR IGNORE — the migration is idempotent if rerun.
408
+ try {
409
+ const flag = this.getKv("schema", "message_folders_v1");
410
+ if (!flag) {
411
+ const now = Date.now();
412
+ const r = this.db.prepare(
413
+ `INSERT OR IGNORE INTO message_folders (message_row_id, folder_id, uid, last_seen_at)
414
+ SELECT id, folder_id, uid, ? FROM messages`
415
+ ).run(now);
416
+ const count = (r as any).changes || 0;
417
+ if (count > 0) {
418
+ console.log(` [migration] message_folders_v1: backfilled ${count} membership rows from messages`);
419
+ this.audit({ kind: "migration", count, reason: "message_folders_v1 — populate join table from existing messages.folder_id/uid", source: "MailxDB constructor" });
420
+ }
421
+ this.setKv("schema", "message_folders_v1", "1");
422
+ }
423
+ } catch (e: any) {
424
+ console.error(` [migration] message_folders backfill failed: ${e?.message || e}`);
425
+ }
426
+
377
427
  // One-shot contacts table reset: the contacts schema's UNIQUE constraint
378
428
  // was widened from `(email)` to `(source, email, name)` so the same
379
429
  // address can carry multiple distinct (name, source) entries — Bob's
@@ -1044,26 +1094,70 @@ export class MailxDB {
1044
1094
  }
1045
1095
 
1046
1096
  deleteFolder(folderId: number): void {
1047
- const r = this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
1048
- const count = (r as any).changes || 0;
1049
- if (count > 0) this.audit({ kind: "delete-bulk", folderId, count, reason: "folder deleted", source: "deleteFolder" });
1097
+ // Drop memberships in this folder first, then GC any messages
1098
+ // rows that are now orphaned (no remaining memberships). A
1099
+ // multi-folder message (e.g. Gmail label = INBOX + Important)
1100
+ // keeps its body intact when only one of its folders is deleted.
1101
+ const orphans = this.gcMembershipsAndCollectOrphans(folderId);
1102
+ if (orphans.count > 0) this.audit({ kind: "delete-bulk", folderId, count: orphans.count, reason: "folder deleted", source: "deleteFolder" });
1050
1103
  this.db.prepare("DELETE FROM folders WHERE id = ?").run(folderId);
1051
1104
  }
1052
1105
 
1053
1106
  markFolderRead(folderId: number): void {
1107
+ // Mark seen for messages currently in this folder (membership-scoped,
1108
+ // not legacy folder_id) so a message that lives in multiple folders
1109
+ // becomes seen everywhere — which matches IMAP semantics: \Seen is
1110
+ // a per-message flag, not per-folder. (When the day comes that we
1111
+ // want truly per-folder read-state, this needs `flag_overrides`.)
1054
1112
  this.db.prepare(
1055
- `UPDATE messages SET flags_json = REPLACE(flags_json, '[]', '["\\\\Seen"]') WHERE folder_id = ? AND flags_json NOT LIKE '%\\\\Seen%'`
1113
+ `UPDATE messages SET flags_json = REPLACE(flags_json, '[]', '["\\\\Seen"]')
1114
+ WHERE id IN (SELECT message_row_id FROM message_folders WHERE folder_id = ?)
1115
+ AND flags_json NOT LIKE '%\\\\Seen%'`
1056
1116
  ).run(folderId);
1057
1117
  this.recalcFolderCounts(folderId);
1058
1118
  }
1059
1119
 
1060
1120
  deleteAllMessages(accountId: string, folderId: number): void {
1061
- const r = this.db.prepare("DELETE FROM messages WHERE account_id = ? AND folder_id = ?").run(accountId, folderId);
1062
- const count = (r as any).changes || 0;
1063
- if (count > 0) this.audit({ kind: "delete-bulk", accountId, folderId, count, reason: "deleteAllMessages (emptyFolder)", source: "deleteAllMessages" });
1121
+ const orphans = this.gcMembershipsAndCollectOrphans(folderId, accountId);
1122
+ if (orphans.count > 0) this.audit({ kind: "delete-bulk", accountId, folderId, count: orphans.count, reason: "deleteAllMessages (emptyFolder)", source: "deleteAllMessages" });
1064
1123
  this.recalcFolderCounts(folderId);
1065
1124
  }
1066
1125
 
1126
+ /** Drop every `message_folders` row in `folderId` (optionally scoped to an
1127
+ * account) and GC any `messages` rows that no longer have ANY memberships.
1128
+ * Returns count of memberships removed (so callers can audit "I deleted N
1129
+ * things"). The messages-row delete cascades to FTS / sync_actions /
1130
+ * message_folders (CASCADE) so this is the safe single op for "clear
1131
+ * this folder" without nuking multi-folder messages. */
1132
+ private gcMembershipsAndCollectOrphans(folderId: number, accountId?: string): { count: number } {
1133
+ // Find membership rows we're about to delete
1134
+ const membershipQ = accountId
1135
+ ? `SELECT mf.id, mf.message_row_id FROM message_folders mf
1136
+ JOIN messages m ON m.id = mf.message_row_id
1137
+ WHERE mf.folder_id = ? AND m.account_id = ?`
1138
+ : `SELECT mf.id, mf.message_row_id FROM message_folders mf WHERE mf.folder_id = ?`;
1139
+ const memberships = (accountId
1140
+ ? this.db.prepare(membershipQ).all(folderId, accountId)
1141
+ : this.db.prepare(membershipQ).all(folderId)) as { id: number; message_row_id: number }[];
1142
+ if (memberships.length === 0) return { count: 0 };
1143
+
1144
+ // Drop them
1145
+ const delMemb = this.db.prepare("DELETE FROM message_folders WHERE id = ?");
1146
+ for (const m of memberships) delMemb.run(m.id);
1147
+
1148
+ // GC any messages rows that now have zero remaining memberships.
1149
+ // Use a Set so a multi-membership row only gets one orphan check.
1150
+ const touched = Array.from(new Set(memberships.map(m => m.message_row_id)));
1151
+ const isOrphan = this.db.prepare("SELECT COUNT(*) AS c FROM message_folders WHERE message_row_id = ?");
1152
+ const delMsg = this.db.prepare("DELETE FROM messages WHERE id = ?");
1153
+ for (const rowId of touched) {
1154
+ if (((isOrphan.get(rowId) as any)?.c | 0) === 0) {
1155
+ delMsg.run(rowId);
1156
+ }
1157
+ }
1158
+ return { count: memberships.length };
1159
+ }
1160
+
1067
1161
  updateFolderCounts(folderId: number, total: number, unread: number): void {
1068
1162
  this.db.prepare(
1069
1163
  "UPDATE folders SET total_count = ?, unread_count = ? WHERE id = ?"
@@ -1085,6 +1179,126 @@ export class MailxDB {
1085
1179
 
1086
1180
  // ── Messages ──
1087
1181
 
1182
+ /** Record that a message row currently lives at (folder, uid). Idempotent
1183
+ * via UNIQUE(folder_id, uid) — re-inserting the same membership just
1184
+ * refreshes last_seen_at. A message in N Gmail labels has N rows.
1185
+ * A server-side move is "delete one membership row, insert another";
1186
+ * the messages row is untouched. Called from upsertMessage on every
1187
+ * insert / move-detect / existing-row upsert during the additive
1188
+ * migration. Reconcile is the only path that DELETES from this table
1189
+ * (when the server stops listing a UID in a folder). */
1190
+ upsertMessageFolder(messageRowId: number, folderId: number, uid: number): void {
1191
+ const now = Date.now();
1192
+ // INSERT OR REPLACE on (folder_id, uid) — if some other message_row_id
1193
+ // somehow had this slot, replace it. In practice this happens after
1194
+ // an EXPUNGE+reinsert: a UID gets reused by the server for a different
1195
+ // message. The new message wins; the old's membership entry goes away.
1196
+ this.db.prepare(
1197
+ `INSERT INTO message_folders (message_row_id, folder_id, uid, last_seen_at)
1198
+ VALUES (?, ?, ?, ?)
1199
+ ON CONFLICT(folder_id, uid) DO UPDATE SET
1200
+ message_row_id = excluded.message_row_id,
1201
+ last_seen_at = excluded.last_seen_at`
1202
+ ).run(messageRowId, folderId, uid, now);
1203
+ }
1204
+
1205
+ /** Drop a membership row when reconcile finds the UID is no longer
1206
+ * on the server in this folder. The messages row itself is NOT
1207
+ * touched — it may still exist in other folders, or the deferred-
1208
+ * cleanup pass (later) handles orphan messages with no memberships. */
1209
+ removeMessageFolder(folderId: number, uid: number): boolean {
1210
+ const r = this.db.prepare(
1211
+ "DELETE FROM message_folders WHERE folder_id = ? AND uid = ?"
1212
+ ).run(folderId, uid);
1213
+ return ((r as any).changes || 0) > 0;
1214
+ }
1215
+
1216
+ /** Reconcile-driven membership drop. Called when the server's UID list
1217
+ * for a folder no longer contains a UID we have locally. Drops the
1218
+ * membership row; if the messages row has no other memberships AND
1219
+ * no other folder claims it via the legacy folder_id column, deletes
1220
+ * the messages row + unlinks the body file. Returns true if any
1221
+ * cleanup happened (so the caller can skip the legacy delete path).
1222
+ *
1223
+ * During the additive migration we ALSO have to handle the legacy
1224
+ * case where the row's folder_id matches but no message_folders row
1225
+ * was migrated for it (race during initial population, etc.) — we
1226
+ * fall back to the messages.folder_id check to clean up consistently. */
1227
+ dropFolderMembership(accountId: string, folderId: number, uid: number, folderPath?: string): boolean {
1228
+ // First find the messages row this membership pointed at.
1229
+ const mf = this.db.prepare(
1230
+ "SELECT message_row_id FROM message_folders WHERE folder_id = ? AND uid = ?"
1231
+ ).get(folderId, uid) as { message_row_id: number } | undefined;
1232
+
1233
+ let rowId: number | null = null;
1234
+ if (mf) {
1235
+ // Drop the membership.
1236
+ this.db.prepare(
1237
+ "DELETE FROM message_folders WHERE folder_id = ? AND uid = ?"
1238
+ ).run(folderId, uid);
1239
+ rowId = mf.message_row_id;
1240
+ } else {
1241
+ // Legacy fallback: the row exists in messages.folder_id/uid but
1242
+ // not in message_folders (pre-migration data, or a row that
1243
+ // somehow skipped the dual-write). Use the legacy lookup.
1244
+ const legacy = this.db.prepare(
1245
+ "SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?"
1246
+ ).get(accountId, folderId, uid) as { id: number } | undefined;
1247
+ rowId = legacy?.id ?? null;
1248
+ }
1249
+ if (rowId === null) {
1250
+ console.log(` [reconcile-skip] ${accountId} folder=${folderId} uid=${uid}: no row to drop (already gone or moved)`);
1251
+ return false;
1252
+ }
1253
+
1254
+ // Check if any other memberships exist for this row.
1255
+ const remaining = this.db.prepare(
1256
+ "SELECT COUNT(*) AS cnt FROM message_folders WHERE message_row_id = ?"
1257
+ ).get(rowId) as { cnt: number };
1258
+
1259
+ if (remaining.cnt > 0) {
1260
+ // Message lives in another folder still — preserve everything.
1261
+ // The messages.folder_id column may still point at the dropped
1262
+ // location; sync the primary location to one of the remaining
1263
+ // memberships so legacy reads stay consistent.
1264
+ const next = this.db.prepare(
1265
+ "SELECT folder_id, uid FROM message_folders WHERE message_row_id = ? ORDER BY id LIMIT 1"
1266
+ ).get(rowId) as { folder_id: number; uid: number } | undefined;
1267
+ if (next) {
1268
+ this.db.prepare(
1269
+ "UPDATE messages SET folder_id = ?, uid = ? WHERE id = ?"
1270
+ ).run(next.folder_id, next.uid, rowId);
1271
+ }
1272
+ console.log(` [reconcile-membership] ${accountId} ${folderPath || folderId}/${uid}: dropped — message still in ${remaining.cnt} other folder(s)`);
1273
+ this.audit({
1274
+ kind: "delete-membership",
1275
+ accountId, folderId, uid,
1276
+ reason: `reconcile dropped folder membership; ${remaining.cnt} other location(s) remain`,
1277
+ source: `dropFolderMembership (${folderPath || ""})`,
1278
+ });
1279
+ return true;
1280
+ }
1281
+
1282
+ // No other memberships — message is truly gone. Delete the row.
1283
+ // Capture context for audit before delete.
1284
+ const env = this.db.prepare(
1285
+ "SELECT message_id, subject, body_path FROM messages WHERE id = ?"
1286
+ ).get(rowId) as { message_id: string; subject: string; body_path: string } | undefined;
1287
+ this.db.prepare("DELETE FROM messages WHERE id = ?").run(rowId);
1288
+ if (env) {
1289
+ this.audit({
1290
+ kind: "delete-msg",
1291
+ accountId, folderId, uid,
1292
+ messageId: env.message_id || undefined,
1293
+ subject: env.subject || undefined,
1294
+ reason: "reconcile: server missing this UID after grace, no other folder memberships",
1295
+ source: `dropFolderMembership (${folderPath || ""})`,
1296
+ });
1297
+ console.log(` [reconcile-delete] ${accountId} ${folderPath || folderId}/${uid} msgid=${env.message_id || "?"} (no other memberships) — body ${env.body_path || "(none)"}`);
1298
+ }
1299
+ return true;
1300
+ }
1301
+
1088
1302
  upsertMessage(msg: {
1089
1303
  accountId: string;
1090
1304
  folderId: number;
@@ -1130,6 +1344,11 @@ export class MailxDB {
1130
1344
  WHERE id = ?
1131
1345
  `).run(JSON.stringify(msg.flags), Date.now(), existing.id);
1132
1346
  }
1347
+ // Refresh membership last_seen_at — server confirmed this UID
1348
+ // is still in this folder. No-op if migration already populated
1349
+ // it; defensive INSERT-OR-UPDATE handles the race where the row
1350
+ // was created without a corresponding membership.
1351
+ this.upsertMessageFolder(existing.id, msg.folderId, msg.uid);
1133
1352
  return existing.id;
1134
1353
  }
1135
1354
 
@@ -1147,11 +1366,19 @@ export class MailxDB {
1147
1366
  ).get(msg.accountId, msg.messageId) as { id: number; folder_id: number; uid: number } | undefined;
1148
1367
  if (moved) {
1149
1368
  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})`);
1150
- // Update folder_id + uid; preserve uuid, body_path, flags
1151
- // (server flags will catch up on the next full sync).
1369
+ // Update folder_id + uid on the messages row (additive
1370
+ // migration: keeps the "primary location" columns valid for
1371
+ // existing reads). The new schema's source of truth is
1372
+ // message_folders — see below.
1152
1373
  this.db.prepare(
1153
1374
  "UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?"
1154
1375
  ).run(msg.folderId, msg.uid, Date.now(), moved.id);
1376
+ // Add the new membership row. The OLD membership (source
1377
+ // folder + old uid) stays — reconcile in the source folder
1378
+ // is what eventually drops it (when the server stops
1379
+ // listing the UID there). For Gmail multi-label messages,
1380
+ // both memberships persist legitimately.
1381
+ this.upsertMessageFolder(moved.id, msg.folderId, msg.uid);
1155
1382
  // Notify subscribers so e.g. the reconciler can cancel any
1156
1383
  // pending deferred-delete for the original (folder, uid) —
1157
1384
  // otherwise the reconcile-delete grace timer fires for a
@@ -1205,6 +1432,12 @@ export class MailxDB {
1205
1432
 
1206
1433
  const rowId = Number(result.lastInsertRowid);
1207
1434
 
1435
+ // Record the (folder, uid) membership for the new row. New schema
1436
+ // source of truth — old folder_id/uid columns above are kept in
1437
+ // sync during the additive migration but reads will move to JOIN
1438
+ // with message_folders.
1439
+ this.upsertMessageFolder(rowId, msg.folderId, msg.uid);
1440
+
1208
1441
  // Index for full-text search
1209
1442
  try {
1210
1443
  this.db.prepare(
@@ -1215,6 +1448,12 @@ export class MailxDB {
1215
1448
  return rowId;
1216
1449
  }
1217
1450
 
1451
+ /** List view: messages currently in (account, folder).
1452
+ * Joins through `message_folders` so the UID + folder location come
1453
+ * from membership rows, not from the legacy messages.folder_id /
1454
+ * messages.uid columns. After a server-side move (Sieve filter,
1455
+ * webmail action, etc.), the membership row is the truth — the
1456
+ * legacy columns are frozen at first-sight values. */
1218
1457
  getMessages(query: MessageQuery): PagedResult<MessageEnvelope> {
1219
1458
  const page = query.page || 1;
1220
1459
  const pageSize = query.pageSize || 50;
@@ -1222,43 +1461,43 @@ export class MailxDB {
1222
1461
  const sort = query.sort || "date";
1223
1462
  const sortDir = query.sortDir || "desc";
1224
1463
 
1225
- const sortCol = sort === "from" ? "from_name" : sort === "subject" ? "subject" : "date";
1464
+ const sortCol = sort === "from" ? "m.from_name" : sort === "subject" ? "m.subject" : "m.date";
1226
1465
 
1227
- let where = "account_id = ? AND folder_id = ?";
1466
+ let where = "m.account_id = ? AND mf.folder_id = ?";
1228
1467
  const params: (string | number)[] = [query.accountId, query.folderId];
1229
1468
 
1230
1469
  if (query.search) {
1231
- where += " AND (subject LIKE ? OR from_name LIKE ? OR from_address LIKE ?)";
1470
+ where += " AND (m.subject LIKE ? OR m.from_name LIKE ? OR m.from_address LIKE ?)";
1232
1471
  const term = `%${query.search}%`;
1233
1472
  params.push(term, term, term);
1234
1473
  }
1235
1474
 
1236
1475
  if (query.flaggedOnly) {
1237
- // flags_json is a JSON array like ["\\Seen","\\Flagged"]. A plain
1238
- // LIKE on the serialized form is sufficient to find rows with the
1239
- // \Flagged flag without decoding every row.
1240
- where += " AND flags_json LIKE '%\\\\Flagged%'";
1476
+ where += " AND m.flags_json LIKE '%\\\\Flagged%'";
1241
1477
  }
1242
1478
 
1243
1479
  const total = (this.db.prepare(
1244
- `SELECT COUNT(*) as cnt FROM messages WHERE ${where}`
1480
+ `SELECT COUNT(*) as cnt
1481
+ FROM messages m
1482
+ JOIN message_folders mf ON mf.message_row_id = m.id
1483
+ WHERE ${where}`
1245
1484
  ).get(...params) as any).cnt;
1246
1485
 
1247
- // LEFT JOIN sync_actions so each row carries a `pending` flag
1248
- // true when the user has a queued local action (move/flag/delete)
1249
- // not yet acknowledged by the server. UI renders these in pink so
1250
- // local-only state is visible (Slice C of S1). The optimistic-Sent
1251
- // negative-UID convention was retired Sent now reflects only what
1252
- // the server has; pending sends live in the Outbox view.
1486
+ // pending = user has a queued local action against (account, uid).
1487
+ // uid here is the membership uid (mf.uid), not m.uid sync_actions
1488
+ // were also keyed against the at-action-time uid, so this match
1489
+ // remains stable across server-side moves of the SAME message
1490
+ // (the action gets re-targeted to the new uid by the reconciler).
1253
1491
  const rows = this.db.prepare(
1254
- `SELECT m.*, (
1492
+ `SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id,
1255
1493
  EXISTS(
1256
1494
  SELECT 1 FROM sync_actions sa
1257
- WHERE sa.account_id = m.account_id AND sa.uid = m.uid
1258
- )
1259
- ) AS pending
1260
- FROM messages m WHERE ${where.replace(/\b(account_id|folder_id|uid|date|subject|from_name|from_address|flags_json)\b/g, "m.$1")}
1261
- ORDER BY m.${sortCol} ${sortDir} LIMIT ? OFFSET ?`
1495
+ WHERE sa.account_id = m.account_id AND sa.uid = mf.uid
1496
+ ) AS pending
1497
+ FROM messages m
1498
+ JOIN message_folders mf ON mf.message_row_id = m.id
1499
+ WHERE ${where}
1500
+ ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`
1262
1501
  ).all(...params, pageSize, offset) as any[];
1263
1502
 
1264
1503
  const items: MessageEnvelope[] = rows.map(r => ({
@@ -1300,17 +1539,22 @@ export class MailxDB {
1300
1539
  const folderIds = inboxRows.map((r: any) => r.id);
1301
1540
 
1302
1541
  const total = (this.db.prepare(
1303
- `SELECT COUNT(*) as cnt FROM messages WHERE folder_id IN (${placeholders})`
1542
+ `SELECT COUNT(*) as cnt
1543
+ FROM message_folders mf
1544
+ WHERE mf.folder_id IN (${placeholders})`
1304
1545
  ).get(...folderIds) as any).cnt;
1305
1546
 
1306
1547
  const rows = this.db.prepare(
1307
- `SELECT m.*, EXISTS(
1308
- SELECT 1 FROM sync_actions sa
1309
- WHERE sa.account_id = m.account_id AND sa.uid = m.uid
1310
- ) AS pending,
1311
- (SELECT COUNT(DISTINCT account_id) FROM messages m2
1312
- WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
1313
- FROM messages m WHERE m.folder_id IN (${placeholders})
1548
+ `SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id,
1549
+ EXISTS(
1550
+ SELECT 1 FROM sync_actions sa
1551
+ WHERE sa.account_id = m.account_id AND sa.uid = mf.uid
1552
+ ) AS pending,
1553
+ (SELECT COUNT(DISTINCT account_id) FROM messages m2
1554
+ WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
1555
+ FROM messages m
1556
+ JOIN message_folders mf ON mf.message_row_id = m.id
1557
+ WHERE mf.folder_id IN (${placeholders})
1314
1558
  ORDER BY m.date DESC LIMIT ? OFFSET ?`
1315
1559
  ).all(...folderIds, pageSize, offset) as any[];
1316
1560
 
@@ -1372,10 +1616,24 @@ export class MailxDB {
1372
1616
  } as MessageEnvelope;
1373
1617
  }
1374
1618
 
1619
+ /** Look up a message by (account, uid) and optionally folder.
1620
+ * Joins through `message_folders` so the message is found at its
1621
+ * CURRENT location, not whatever messages.folder_id was at first
1622
+ * sight. After server-side moves, that's the difference between
1623
+ * hitting the message in `_Spam` (correct) and hitting nothing
1624
+ * because the legacy messages.folder_id still points at INBOX. */
1375
1625
  getMessageByUid(accountId: string, uid: number, folderId?: number): MessageEnvelope {
1376
1626
  const sql = folderId != null
1377
- ? "SELECT * FROM messages WHERE account_id = ? AND uid = ? AND folder_id = ?"
1378
- : "SELECT * FROM messages WHERE account_id = ? AND uid = ?";
1627
+ ? `SELECT m.*, mf.uid AS uid
1628
+ FROM messages m
1629
+ JOIN message_folders mf ON mf.message_row_id = m.id
1630
+ WHERE m.account_id = ? AND mf.uid = ? AND mf.folder_id = ?
1631
+ LIMIT 1`
1632
+ : `SELECT m.*, mf.uid AS uid
1633
+ FROM messages m
1634
+ JOIN message_folders mf ON mf.message_row_id = m.id
1635
+ WHERE m.account_id = ? AND mf.uid = ?
1636
+ LIMIT 1`;
1379
1637
  const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
1380
1638
  const r = this.db.prepare(sql).get(...params) as any;
1381
1639
  if (!r) return null as any;
@@ -1448,32 +1706,49 @@ export class MailxDB {
1448
1706
  ).all(accountId, limit) as any[];
1449
1707
  }
1450
1708
 
1451
- getHighestUid(accountId: string, folderId: number): number {
1709
+ /** Highest server UID we've seen in this folder. After the
1710
+ * message_folders refactor, this reads from the membership table
1711
+ * rather than messages.uid — a server-side move from this folder
1712
+ * drops the membership row, so the high-water mark correctly
1713
+ * decreases when the message left. account_id is implicit
1714
+ * (folder_id is account-scoped). */
1715
+ getHighestUid(_accountId: string, folderId: number): number {
1452
1716
  const r = this.db.prepare(
1453
- "SELECT MAX(uid) as maxUid FROM messages WHERE account_id = ? AND folder_id = ?"
1454
- ).get(accountId, folderId) as any;
1717
+ "SELECT MAX(uid) as maxUid FROM message_folders WHERE folder_id = ?"
1718
+ ).get(folderId) as any;
1455
1719
  return r?.maxUid || 0;
1456
1720
  }
1457
1721
 
1458
- getOldestDate(accountId: string, folderId: number): number {
1722
+ /** Oldest message date in this folder. Joins through message_folders
1723
+ * so a message that LEFT this folder via server-side move stops
1724
+ * influencing the date window for incremental backfill. */
1725
+ getOldestDate(_accountId: string, folderId: number): number {
1459
1726
  const r = this.db.prepare(
1460
- "SELECT MIN(date) as minDate FROM messages WHERE account_id = ? AND folder_id = ?"
1461
- ).get(accountId, folderId) as any;
1727
+ `SELECT MIN(m.date) as minDate
1728
+ FROM messages m
1729
+ JOIN message_folders mf ON mf.message_row_id = m.id
1730
+ WHERE mf.folder_id = ?`
1731
+ ).get(folderId) as any;
1462
1732
  return r?.minDate || 0;
1463
1733
  }
1464
1734
 
1465
- getMessageCount(accountId: string, folderId: number): number {
1735
+ /** Number of messages currently IN this folder (server-side membership,
1736
+ * not whatever messages.folder_id happens to say after a move). */
1737
+ getMessageCount(_accountId: string, folderId: number): number {
1466
1738
  const r = this.db.prepare(
1467
- "SELECT count(*) as cnt FROM messages WHERE account_id = ? AND folder_id = ?"
1468
- ).get(accountId, folderId) as any;
1739
+ "SELECT count(*) as cnt FROM message_folders WHERE folder_id = ?"
1740
+ ).get(folderId) as any;
1469
1741
  return r?.cnt || 0;
1470
1742
  }
1471
1743
 
1472
- /** Get all UIDs for a folder */
1473
- getUidsForFolder(accountId: string, folderId: number): number[] {
1744
+ /** All UIDs the server has confirmed in this folder. Used by
1745
+ * reconcile to diff against the server's UID list — anything in
1746
+ * here but NOT on the server gets its membership dropped (which
1747
+ * may then GC the messages row if it has no other folders). */
1748
+ getUidsForFolder(_accountId: string, folderId: number): number[] {
1474
1749
  const rows = this.db.prepare(
1475
- "SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?"
1476
- ).all(accountId, folderId) as any[];
1750
+ "SELECT uid FROM message_folders WHERE folder_id = ?"
1751
+ ).all(folderId) as any[];
1477
1752
  return rows.map(r => r.uid);
1478
1753
  }
1479
1754
 
@@ -2011,7 +2286,7 @@ export class MailxDB {
2011
2286
  // ── Search ──
2012
2287
 
2013
2288
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
2014
- searchMessages(query: string, page = 1, pageSize = 50, accountId?: string, folderId?: number): PagedResult<MessageEnvelope> {
2289
+ searchMessages(query: string, page = 1, pageSize = 50, accountId?: string, folderId?: number, includeTrashSpam = false): PagedResult<MessageEnvelope> {
2015
2290
  query = (query || "").trim();
2016
2291
  // Parse qualifiers (C45: extended set — date:, has:, is:, folder:).
2017
2292
  let ftsQuery = "";
@@ -2111,13 +2386,24 @@ export class MailxDB {
2111
2386
  scopeWhere = " AND m.account_id = ?";
2112
2387
  scopeParams.push(accountId);
2113
2388
  }
2389
+ // Global / account-wide search excludes trash + junk unless the
2390
+ // user opted in. A user typing "invoice" in the search box almost
2391
+ // never wants the deleted/spam copy that's already filtered out
2392
+ // of every other view. When the user IS explicitly scoped to
2393
+ // trash/junk via folderId, this guard is skipped.
2394
+ if (!includeTrashSpam && !folderId) {
2395
+ scopeWhere += " AND (f.special_use IS NULL OR f.special_use NOT IN ('trash','junk'))";
2396
+ }
2114
2397
  if (extraWhere.length > 0) {
2115
2398
  scopeWhere += " AND " + extraWhere.join(" AND ");
2116
2399
  scopeParams.push(...extraParams);
2117
2400
  }
2118
2401
 
2119
2402
  const countRow = this.db.prepare(
2120
- `SELECT COUNT(*) as cnt FROM messages m JOIN messages_fts fts ON m.id = fts.rowid WHERE messages_fts MATCH ?${scopeWhere}`
2403
+ `SELECT COUNT(*) as cnt FROM messages m
2404
+ JOIN messages_fts fts ON m.id = fts.rowid
2405
+ LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
2406
+ WHERE messages_fts MATCH ?${scopeWhere}`
2121
2407
  ).get(ftsQuery, ...scopeParams) as any;
2122
2408
  const total = countRow?.cnt || 0;
2123
2409
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",