@bobfrankston/rmfmail 1.0.564 → 1.0.567
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/message-list.js +19 -2
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +17 -2
- package/package.json +5 -5
- package/packages/mailx-imap/index.d.ts +17 -0
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +109 -56
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +110 -50
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-store/db.d.ts +80 -5
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +327 -51
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +356 -52
- 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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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"]')
|
|
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
|
|
1062
|
-
|
|
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,37 @@ 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
|
|
1151
|
-
//
|
|
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);
|
|
1382
|
+
// Notify subscribers so e.g. the reconciler can cancel any
|
|
1383
|
+
// pending deferred-delete for the original (folder, uid) —
|
|
1384
|
+
// otherwise the reconcile-delete grace timer fires for a
|
|
1385
|
+
// row that's been rebound elsewhere, and the user sees the
|
|
1386
|
+
// moved message vanish 30 minutes after the move.
|
|
1387
|
+
if (this._onMoveDetected) {
|
|
1388
|
+
try {
|
|
1389
|
+
this._onMoveDetected({
|
|
1390
|
+
accountId: msg.accountId,
|
|
1391
|
+
messageId: msg.messageId,
|
|
1392
|
+
fromFolderId: moved.folder_id,
|
|
1393
|
+
fromUid: moved.uid,
|
|
1394
|
+
toFolderId: msg.folderId,
|
|
1395
|
+
toUid: msg.uid,
|
|
1396
|
+
rowId: moved.id,
|
|
1397
|
+
});
|
|
1398
|
+
} catch { /* listener errors must not break sync */ }
|
|
1399
|
+
}
|
|
1155
1400
|
return moved.id;
|
|
1156
1401
|
}
|
|
1157
1402
|
}
|
|
@@ -1187,6 +1432,12 @@ export class MailxDB {
|
|
|
1187
1432
|
|
|
1188
1433
|
const rowId = Number(result.lastInsertRowid);
|
|
1189
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
|
+
|
|
1190
1441
|
// Index for full-text search
|
|
1191
1442
|
try {
|
|
1192
1443
|
this.db.prepare(
|
|
@@ -1197,6 +1448,12 @@ export class MailxDB {
|
|
|
1197
1448
|
return rowId;
|
|
1198
1449
|
}
|
|
1199
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. */
|
|
1200
1457
|
getMessages(query: MessageQuery): PagedResult<MessageEnvelope> {
|
|
1201
1458
|
const page = query.page || 1;
|
|
1202
1459
|
const pageSize = query.pageSize || 50;
|
|
@@ -1204,43 +1461,43 @@ export class MailxDB {
|
|
|
1204
1461
|
const sort = query.sort || "date";
|
|
1205
1462
|
const sortDir = query.sortDir || "desc";
|
|
1206
1463
|
|
|
1207
|
-
const sortCol = sort === "from" ? "from_name" : sort === "subject" ? "subject" : "date";
|
|
1464
|
+
const sortCol = sort === "from" ? "m.from_name" : sort === "subject" ? "m.subject" : "m.date";
|
|
1208
1465
|
|
|
1209
|
-
let where = "account_id = ? AND folder_id = ?";
|
|
1466
|
+
let where = "m.account_id = ? AND mf.folder_id = ?";
|
|
1210
1467
|
const params: (string | number)[] = [query.accountId, query.folderId];
|
|
1211
1468
|
|
|
1212
1469
|
if (query.search) {
|
|
1213
|
-
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 ?)";
|
|
1214
1471
|
const term = `%${query.search}%`;
|
|
1215
1472
|
params.push(term, term, term);
|
|
1216
1473
|
}
|
|
1217
1474
|
|
|
1218
1475
|
if (query.flaggedOnly) {
|
|
1219
|
-
|
|
1220
|
-
// LIKE on the serialized form is sufficient to find rows with the
|
|
1221
|
-
// \Flagged flag without decoding every row.
|
|
1222
|
-
where += " AND flags_json LIKE '%\\\\Flagged%'";
|
|
1476
|
+
where += " AND m.flags_json LIKE '%\\\\Flagged%'";
|
|
1223
1477
|
}
|
|
1224
1478
|
|
|
1225
1479
|
const total = (this.db.prepare(
|
|
1226
|
-
`SELECT COUNT(*) as cnt
|
|
1480
|
+
`SELECT COUNT(*) as cnt
|
|
1481
|
+
FROM messages m
|
|
1482
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1483
|
+
WHERE ${where}`
|
|
1227
1484
|
).get(...params) as any).cnt;
|
|
1228
1485
|
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
// 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).
|
|
1235
1491
|
const rows = this.db.prepare(
|
|
1236
|
-
`SELECT m.*,
|
|
1492
|
+
`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id,
|
|
1237
1493
|
EXISTS(
|
|
1238
1494
|
SELECT 1 FROM sync_actions sa
|
|
1239
|
-
WHERE sa.account_id = m.account_id AND sa.uid =
|
|
1240
|
-
)
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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 ?`
|
|
1244
1501
|
).all(...params, pageSize, offset) as any[];
|
|
1245
1502
|
|
|
1246
1503
|
const items: MessageEnvelope[] = rows.map(r => ({
|
|
@@ -1282,17 +1539,22 @@ export class MailxDB {
|
|
|
1282
1539
|
const folderIds = inboxRows.map((r: any) => r.id);
|
|
1283
1540
|
|
|
1284
1541
|
const total = (this.db.prepare(
|
|
1285
|
-
`SELECT COUNT(*) as cnt
|
|
1542
|
+
`SELECT COUNT(*) as cnt
|
|
1543
|
+
FROM message_folders mf
|
|
1544
|
+
WHERE mf.folder_id IN (${placeholders})`
|
|
1286
1545
|
).get(...folderIds) as any).cnt;
|
|
1287
1546
|
|
|
1288
1547
|
const rows = this.db.prepare(
|
|
1289
|
-
`SELECT m.*,
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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})
|
|
1296
1558
|
ORDER BY m.date DESC LIMIT ? OFFSET ?`
|
|
1297
1559
|
).all(...folderIds, pageSize, offset) as any[];
|
|
1298
1560
|
|
|
@@ -1354,10 +1616,24 @@ export class MailxDB {
|
|
|
1354
1616
|
} as MessageEnvelope;
|
|
1355
1617
|
}
|
|
1356
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. */
|
|
1357
1625
|
getMessageByUid(accountId: string, uid: number, folderId?: number): MessageEnvelope {
|
|
1358
1626
|
const sql = folderId != null
|
|
1359
|
-
?
|
|
1360
|
-
|
|
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`;
|
|
1361
1637
|
const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
|
|
1362
1638
|
const r = this.db.prepare(sql).get(...params) as any;
|
|
1363
1639
|
if (!r) return null as any;
|
|
@@ -1430,32 +1706,49 @@ export class MailxDB {
|
|
|
1430
1706
|
).all(accountId, limit) as any[];
|
|
1431
1707
|
}
|
|
1432
1708
|
|
|
1433
|
-
|
|
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 {
|
|
1434
1716
|
const r = this.db.prepare(
|
|
1435
|
-
"SELECT MAX(uid) as maxUid FROM
|
|
1436
|
-
).get(
|
|
1717
|
+
"SELECT MAX(uid) as maxUid FROM message_folders WHERE folder_id = ?"
|
|
1718
|
+
).get(folderId) as any;
|
|
1437
1719
|
return r?.maxUid || 0;
|
|
1438
1720
|
}
|
|
1439
1721
|
|
|
1440
|
-
|
|
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 {
|
|
1441
1726
|
const r = this.db.prepare(
|
|
1442
|
-
|
|
1443
|
-
|
|
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;
|
|
1444
1732
|
return r?.minDate || 0;
|
|
1445
1733
|
}
|
|
1446
1734
|
|
|
1447
|
-
|
|
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 {
|
|
1448
1738
|
const r = this.db.prepare(
|
|
1449
|
-
"SELECT count(*) as cnt FROM
|
|
1450
|
-
).get(
|
|
1739
|
+
"SELECT count(*) as cnt FROM message_folders WHERE folder_id = ?"
|
|
1740
|
+
).get(folderId) as any;
|
|
1451
1741
|
return r?.cnt || 0;
|
|
1452
1742
|
}
|
|
1453
1743
|
|
|
1454
|
-
/**
|
|
1455
|
-
|
|
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[] {
|
|
1456
1749
|
const rows = this.db.prepare(
|
|
1457
|
-
"SELECT uid FROM
|
|
1458
|
-
).all(
|
|
1750
|
+
"SELECT uid FROM message_folders WHERE folder_id = ?"
|
|
1751
|
+
).all(folderId) as any[];
|
|
1459
1752
|
return rows.map(r => r.uid);
|
|
1460
1753
|
}
|
|
1461
1754
|
|
|
@@ -1575,6 +1868,17 @@ export class MailxDB {
|
|
|
1575
1868
|
try { this._onContactsChanged?.(); } catch { /* ignore */ }
|
|
1576
1869
|
}
|
|
1577
1870
|
|
|
1871
|
+
/** Fired when upsertMessage detects a server-side move (Message-ID match
|
|
1872
|
+
* in another folder rebinds the existing row to the destination). The
|
|
1873
|
+
* reconciler can listen and cancel any pending deferred-delete for the
|
|
1874
|
+
* original (folder, uid), so the move propagates without the source
|
|
1875
|
+
* folder's reconcile-delete firing 30 minutes later for a row that's
|
|
1876
|
+
* been rebound elsewhere. */
|
|
1877
|
+
private _onMoveDetected?: (info: { accountId: string; messageId: string; fromFolderId: number; fromUid: number; toFolderId: number; toUid: number; rowId: number }) => void;
|
|
1878
|
+
setOnMoveDetected(cb: (info: { accountId: string; messageId: string; fromFolderId: number; fromUid: number; toFolderId: number; toUid: number; rowId: number }) => void): void {
|
|
1879
|
+
this._onMoveDetected = cb;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1578
1882
|
/** Seed `discovered`-tier contacts from every address that appears in
|
|
1579
1883
|
* any cached message — From / To / Cc / Bcc across all folders. One row
|
|
1580
1884
|
* per email; first non-empty name observed wins. Sent-folder rows skip
|