@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
|
@@ -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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
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
|
|
990
|
-
|
|
991
|
-
|
|
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,36 @@ 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
|
|
1046
|
-
//
|
|
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);
|
|
1248
|
+
// Notify subscribers so e.g. the reconciler can cancel any
|
|
1249
|
+
// pending deferred-delete for the original (folder, uid) —
|
|
1250
|
+
// otherwise the reconcile-delete grace timer fires for a
|
|
1251
|
+
// row that's been rebound elsewhere, and the user sees the
|
|
1252
|
+
// moved message vanish 30 minutes after the move.
|
|
1253
|
+
if (this._onMoveDetected) {
|
|
1254
|
+
try {
|
|
1255
|
+
this._onMoveDetected({
|
|
1256
|
+
accountId: msg.accountId,
|
|
1257
|
+
messageId: msg.messageId,
|
|
1258
|
+
fromFolderId: moved.folder_id,
|
|
1259
|
+
fromUid: moved.uid,
|
|
1260
|
+
toFolderId: msg.folderId,
|
|
1261
|
+
toUid: msg.uid,
|
|
1262
|
+
rowId: moved.id,
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
catch { /* listener errors must not break sync */ }
|
|
1266
|
+
}
|
|
1048
1267
|
return moved.id;
|
|
1049
1268
|
}
|
|
1050
1269
|
}
|
|
@@ -1067,6 +1286,11 @@ export class MailxDB {
|
|
|
1067
1286
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1068
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);
|
|
1069
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);
|
|
1070
1294
|
// Index for full-text search
|
|
1071
1295
|
try {
|
|
1072
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);
|
|
@@ -1074,41 +1298,47 @@ export class MailxDB {
|
|
|
1074
1298
|
catch { /* FTS insert may fail on rebuild, non-fatal */ }
|
|
1075
1299
|
return rowId;
|
|
1076
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. */
|
|
1077
1307
|
getMessages(query) {
|
|
1078
1308
|
const page = query.page || 1;
|
|
1079
1309
|
const pageSize = query.pageSize || 50;
|
|
1080
1310
|
const offset = (page - 1) * pageSize;
|
|
1081
1311
|
const sort = query.sort || "date";
|
|
1082
1312
|
const sortDir = query.sortDir || "desc";
|
|
1083
|
-
const sortCol = sort === "from" ? "from_name" : sort === "subject" ? "subject" : "date";
|
|
1084
|
-
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 = ?";
|
|
1085
1315
|
const params = [query.accountId, query.folderId];
|
|
1086
1316
|
if (query.search) {
|
|
1087
|
-
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 ?)";
|
|
1088
1318
|
const term = `%${query.search}%`;
|
|
1089
1319
|
params.push(term, term, term);
|
|
1090
1320
|
}
|
|
1091
1321
|
if (query.flaggedOnly) {
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
|
|
1104
|
-
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,
|
|
1105
1334
|
EXISTS(
|
|
1106
1335
|
SELECT 1 FROM sync_actions sa
|
|
1107
|
-
WHERE sa.account_id = m.account_id AND sa.uid =
|
|
1108
|
-
)
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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);
|
|
1112
1342
|
const items = rows.map(r => ({
|
|
1113
1343
|
id: r.id,
|
|
1114
1344
|
accountId: r.account_id,
|
|
@@ -1142,14 +1372,19 @@ export class MailxDB {
|
|
|
1142
1372
|
return { items: [], total: 0, page, pageSize };
|
|
1143
1373
|
const placeholders = inboxRows.map(() => "?").join(",");
|
|
1144
1374
|
const folderIds = inboxRows.map((r) => r.id);
|
|
1145
|
-
const total = this.db.prepare(`SELECT COUNT(*) as cnt
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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})
|
|
1153
1388
|
ORDER BY m.date DESC LIMIT ? OFFSET ?`).all(...folderIds, pageSize, offset);
|
|
1154
1389
|
const items = rows.map(r => ({
|
|
1155
1390
|
id: r.id,
|
|
@@ -1206,10 +1441,24 @@ export class MailxDB {
|
|
|
1206
1441
|
providerId: r.provider_id || undefined,
|
|
1207
1442
|
};
|
|
1208
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. */
|
|
1209
1450
|
getMessageByUid(accountId, uid, folderId) {
|
|
1210
1451
|
const sql = folderId != null
|
|
1211
|
-
?
|
|
1212
|
-
|
|
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`;
|
|
1213
1462
|
const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
|
|
1214
1463
|
const r = this.db.prepare(sql).get(...params);
|
|
1215
1464
|
if (!r)
|
|
@@ -1264,21 +1513,38 @@ export class MailxDB {
|
|
|
1264
1513
|
// Size 0 / NULL fall to the end so they don't masquerade as small.
|
|
1265
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);
|
|
1266
1515
|
}
|
|
1267
|
-
|
|
1268
|
-
|
|
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);
|
|
1269
1524
|
return r?.maxUid || 0;
|
|
1270
1525
|
}
|
|
1271
|
-
|
|
1272
|
-
|
|
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);
|
|
1273
1534
|
return r?.minDate || 0;
|
|
1274
1535
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
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);
|
|
1277
1540
|
return r?.cnt || 0;
|
|
1278
1541
|
}
|
|
1279
|
-
/**
|
|
1280
|
-
|
|
1281
|
-
|
|
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);
|
|
1282
1548
|
return rows.map(r => r.uid);
|
|
1283
1549
|
}
|
|
1284
1550
|
/** Delete a message by account + UID. Reason is propagated into the
|
|
@@ -1386,6 +1652,16 @@ export class MailxDB {
|
|
|
1386
1652
|
}
|
|
1387
1653
|
catch { /* ignore */ }
|
|
1388
1654
|
}
|
|
1655
|
+
/** Fired when upsertMessage detects a server-side move (Message-ID match
|
|
1656
|
+
* in another folder rebinds the existing row to the destination). The
|
|
1657
|
+
* reconciler can listen and cancel any pending deferred-delete for the
|
|
1658
|
+
* original (folder, uid), so the move propagates without the source
|
|
1659
|
+
* folder's reconcile-delete firing 30 minutes later for a row that's
|
|
1660
|
+
* been rebound elsewhere. */
|
|
1661
|
+
_onMoveDetected;
|
|
1662
|
+
setOnMoveDetected(cb) {
|
|
1663
|
+
this._onMoveDetected = cb;
|
|
1664
|
+
}
|
|
1389
1665
|
/** Seed `discovered`-tier contacts from every address that appears in
|
|
1390
1666
|
* any cached message — From / To / Cc / Bcc across all folders. One row
|
|
1391
1667
|
* per email; first non-empty name observed wins. Sent-folder rows skip
|