@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.
- package/client/app.js +13 -5
- package/client/app.js.map +1 -1
- package/client/app.ts +12 -4
- package/client/components/folder-tree.js +11 -0
- package/client/components/folder-tree.js.map +1 -1
- package/client/components/folder-tree.ts +8 -0
- package/client/components/message-list.js +6 -3
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +6 -3
- package/client/components/message-viewer.js +58 -5
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +58 -5
- package/client/index.html +1 -0
- package/client/lib/api-client.js +2 -2
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +2 -2
- package/client/lib/mailxapi.js +2 -2
- package/client/styles/components.css +13 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +29 -13
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +29 -13
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/index.d.ts +1 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +5 -3
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +5 -3
- package/packages/mailx-service/jsonrpc.js +1 -1
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +1 -1
- package/packages/mailx-service/local-store.d.ts +1 -1
- package/packages/mailx-service/local-store.d.ts.map +1 -1
- package/packages/mailx-service/local-store.js +2 -2
- package/packages/mailx-service/local-store.js.map +1 -1
- package/packages/mailx-service/local-store.ts +2 -2
- package/packages/mailx-store/db.d.ts +65 -6
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +311 -53
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +340 -54
- 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,17 @@ export class MailxDB {
|
|
|
1042
1234
|
const moved = this.db.prepare("SELECT id, folder_id, uid FROM messages WHERE account_id = ? AND message_id = ? LIMIT 1").get(msg.accountId, msg.messageId);
|
|
1043
1235
|
if (moved) {
|
|
1044
1236
|
console.log(` [move-detect] ${msg.accountId} ${msg.messageId}: rebinding row ${moved.id} (folder ${moved.folder_id}/uid ${moved.uid} → folder ${msg.folderId}/uid ${msg.uid})`);
|
|
1045
|
-
// Update folder_id + uid
|
|
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);
|
|
1048
1248
|
// Notify subscribers so e.g. the reconciler can cancel any
|
|
1049
1249
|
// pending deferred-delete for the original (folder, uid) —
|
|
1050
1250
|
// otherwise the reconcile-delete grace timer fires for a
|
|
@@ -1086,6 +1286,11 @@ export class MailxDB {
|
|
|
1086
1286
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1087
1287
|
`).run(msg.accountId, msg.folderId, msg.uid, uuid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now(), msg.providerId || null);
|
|
1088
1288
|
const rowId = Number(result.lastInsertRowid);
|
|
1289
|
+
// Record the (folder, uid) membership for the new row. New schema
|
|
1290
|
+
// source of truth — old folder_id/uid columns above are kept in
|
|
1291
|
+
// sync during the additive migration but reads will move to JOIN
|
|
1292
|
+
// with message_folders.
|
|
1293
|
+
this.upsertMessageFolder(rowId, msg.folderId, msg.uid);
|
|
1089
1294
|
// Index for full-text search
|
|
1090
1295
|
try {
|
|
1091
1296
|
this.db.prepare("INSERT INTO messages_fts (rowid, subject, from_name, from_address, to_text, cc_text, body_text) VALUES (?, ?, ?, ?, ?, ?, ?)").run(rowId, msg.subject, msg.from.name, msg.from.address, toText, ccText, msg.preview);
|
|
@@ -1093,41 +1298,47 @@ export class MailxDB {
|
|
|
1093
1298
|
catch { /* FTS insert may fail on rebuild, non-fatal */ }
|
|
1094
1299
|
return rowId;
|
|
1095
1300
|
}
|
|
1301
|
+
/** List view: messages currently in (account, folder).
|
|
1302
|
+
* Joins through `message_folders` so the UID + folder location come
|
|
1303
|
+
* from membership rows, not from the legacy messages.folder_id /
|
|
1304
|
+
* messages.uid columns. After a server-side move (Sieve filter,
|
|
1305
|
+
* webmail action, etc.), the membership row is the truth — the
|
|
1306
|
+
* legacy columns are frozen at first-sight values. */
|
|
1096
1307
|
getMessages(query) {
|
|
1097
1308
|
const page = query.page || 1;
|
|
1098
1309
|
const pageSize = query.pageSize || 50;
|
|
1099
1310
|
const offset = (page - 1) * pageSize;
|
|
1100
1311
|
const sort = query.sort || "date";
|
|
1101
1312
|
const sortDir = query.sortDir || "desc";
|
|
1102
|
-
const sortCol = sort === "from" ? "from_name" : sort === "subject" ? "subject" : "date";
|
|
1103
|
-
let where = "account_id = ? AND folder_id = ?";
|
|
1313
|
+
const sortCol = sort === "from" ? "m.from_name" : sort === "subject" ? "m.subject" : "m.date";
|
|
1314
|
+
let where = "m.account_id = ? AND mf.folder_id = ?";
|
|
1104
1315
|
const params = [query.accountId, query.folderId];
|
|
1105
1316
|
if (query.search) {
|
|
1106
|
-
where += " AND (subject LIKE ? OR from_name LIKE ? OR from_address LIKE ?)";
|
|
1317
|
+
where += " AND (m.subject LIKE ? OR m.from_name LIKE ? OR m.from_address LIKE ?)";
|
|
1107
1318
|
const term = `%${query.search}%`;
|
|
1108
1319
|
params.push(term, term, term);
|
|
1109
1320
|
}
|
|
1110
1321
|
if (query.flaggedOnly) {
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
|
|
1123
|
-
const rows = this.db.prepare(`SELECT m.*, (
|
|
1322
|
+
where += " AND m.flags_json LIKE '%\\\\Flagged%'";
|
|
1323
|
+
}
|
|
1324
|
+
const total = this.db.prepare(`SELECT COUNT(*) as cnt
|
|
1325
|
+
FROM messages m
|
|
1326
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1327
|
+
WHERE ${where}`).get(...params).cnt;
|
|
1328
|
+
// pending = user has a queued local action against (account, uid).
|
|
1329
|
+
// uid here is the membership uid (mf.uid), not m.uid — sync_actions
|
|
1330
|
+
// were also keyed against the at-action-time uid, so this match
|
|
1331
|
+
// remains stable across server-side moves of the SAME message
|
|
1332
|
+
// (the action gets re-targeted to the new uid by the reconciler).
|
|
1333
|
+
const rows = this.db.prepare(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id,
|
|
1124
1334
|
EXISTS(
|
|
1125
1335
|
SELECT 1 FROM sync_actions sa
|
|
1126
|
-
WHERE sa.account_id = m.account_id AND sa.uid =
|
|
1127
|
-
)
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1336
|
+
WHERE sa.account_id = m.account_id AND sa.uid = mf.uid
|
|
1337
|
+
) AS pending
|
|
1338
|
+
FROM messages m
|
|
1339
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1340
|
+
WHERE ${where}
|
|
1341
|
+
ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
1131
1342
|
const items = rows.map(r => ({
|
|
1132
1343
|
id: r.id,
|
|
1133
1344
|
accountId: r.account_id,
|
|
@@ -1161,14 +1372,19 @@ export class MailxDB {
|
|
|
1161
1372
|
return { items: [], total: 0, page, pageSize };
|
|
1162
1373
|
const placeholders = inboxRows.map(() => "?").join(",");
|
|
1163
1374
|
const folderIds = inboxRows.map((r) => r.id);
|
|
1164
|
-
const total = this.db.prepare(`SELECT COUNT(*) as cnt
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1375
|
+
const total = this.db.prepare(`SELECT COUNT(*) as cnt
|
|
1376
|
+
FROM message_folders mf
|
|
1377
|
+
WHERE mf.folder_id IN (${placeholders})`).get(...folderIds).cnt;
|
|
1378
|
+
const rows = this.db.prepare(`SELECT m.*, mf.uid AS uid, mf.folder_id AS folder_id,
|
|
1379
|
+
EXISTS(
|
|
1380
|
+
SELECT 1 FROM sync_actions sa
|
|
1381
|
+
WHERE sa.account_id = m.account_id AND sa.uid = mf.uid
|
|
1382
|
+
) AS pending,
|
|
1383
|
+
(SELECT COUNT(DISTINCT account_id) FROM messages m2
|
|
1384
|
+
WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
|
|
1385
|
+
FROM messages m
|
|
1386
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1387
|
+
WHERE mf.folder_id IN (${placeholders})
|
|
1172
1388
|
ORDER BY m.date DESC LIMIT ? OFFSET ?`).all(...folderIds, pageSize, offset);
|
|
1173
1389
|
const items = rows.map(r => ({
|
|
1174
1390
|
id: r.id,
|
|
@@ -1225,10 +1441,24 @@ export class MailxDB {
|
|
|
1225
1441
|
providerId: r.provider_id || undefined,
|
|
1226
1442
|
};
|
|
1227
1443
|
}
|
|
1444
|
+
/** Look up a message by (account, uid) and optionally folder.
|
|
1445
|
+
* Joins through `message_folders` so the message is found at its
|
|
1446
|
+
* CURRENT location, not whatever messages.folder_id was at first
|
|
1447
|
+
* sight. After server-side moves, that's the difference between
|
|
1448
|
+
* hitting the message in `_Spam` (correct) and hitting nothing
|
|
1449
|
+
* because the legacy messages.folder_id still points at INBOX. */
|
|
1228
1450
|
getMessageByUid(accountId, uid, folderId) {
|
|
1229
1451
|
const sql = folderId != null
|
|
1230
|
-
?
|
|
1231
|
-
|
|
1452
|
+
? `SELECT m.*, mf.uid AS uid
|
|
1453
|
+
FROM messages m
|
|
1454
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1455
|
+
WHERE m.account_id = ? AND mf.uid = ? AND mf.folder_id = ?
|
|
1456
|
+
LIMIT 1`
|
|
1457
|
+
: `SELECT m.*, mf.uid AS uid
|
|
1458
|
+
FROM messages m
|
|
1459
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1460
|
+
WHERE m.account_id = ? AND mf.uid = ?
|
|
1461
|
+
LIMIT 1`;
|
|
1232
1462
|
const params = folderId != null ? [accountId, uid, folderId] : [accountId, uid];
|
|
1233
1463
|
const r = this.db.prepare(sql).get(...params);
|
|
1234
1464
|
if (!r)
|
|
@@ -1283,21 +1513,38 @@ export class MailxDB {
|
|
|
1283
1513
|
// Size 0 / NULL fall to the end so they don't masquerade as small.
|
|
1284
1514
|
return this.db.prepare("SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path = '') ORDER BY (size IS NULL OR size = 0), size ASC, date DESC LIMIT ?").all(accountId, limit);
|
|
1285
1515
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1516
|
+
/** Highest server UID we've seen in this folder. After the
|
|
1517
|
+
* message_folders refactor, this reads from the membership table
|
|
1518
|
+
* rather than messages.uid — a server-side move from this folder
|
|
1519
|
+
* drops the membership row, so the high-water mark correctly
|
|
1520
|
+
* decreases when the message left. account_id is implicit
|
|
1521
|
+
* (folder_id is account-scoped). */
|
|
1522
|
+
getHighestUid(_accountId, folderId) {
|
|
1523
|
+
const r = this.db.prepare("SELECT MAX(uid) as maxUid FROM message_folders WHERE folder_id = ?").get(folderId);
|
|
1288
1524
|
return r?.maxUid || 0;
|
|
1289
1525
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
1526
|
+
/** Oldest message date in this folder. Joins through message_folders
|
|
1527
|
+
* so a message that LEFT this folder via server-side move stops
|
|
1528
|
+
* influencing the date window for incremental backfill. */
|
|
1529
|
+
getOldestDate(_accountId, folderId) {
|
|
1530
|
+
const r = this.db.prepare(`SELECT MIN(m.date) as minDate
|
|
1531
|
+
FROM messages m
|
|
1532
|
+
JOIN message_folders mf ON mf.message_row_id = m.id
|
|
1533
|
+
WHERE mf.folder_id = ?`).get(folderId);
|
|
1292
1534
|
return r?.minDate || 0;
|
|
1293
1535
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1536
|
+
/** Number of messages currently IN this folder (server-side membership,
|
|
1537
|
+
* not whatever messages.folder_id happens to say after a move). */
|
|
1538
|
+
getMessageCount(_accountId, folderId) {
|
|
1539
|
+
const r = this.db.prepare("SELECT count(*) as cnt FROM message_folders WHERE folder_id = ?").get(folderId);
|
|
1296
1540
|
return r?.cnt || 0;
|
|
1297
1541
|
}
|
|
1298
|
-
/**
|
|
1299
|
-
|
|
1300
|
-
|
|
1542
|
+
/** All UIDs the server has confirmed in this folder. Used by
|
|
1543
|
+
* reconcile to diff against the server's UID list — anything in
|
|
1544
|
+
* here but NOT on the server gets its membership dropped (which
|
|
1545
|
+
* may then GC the messages row if it has no other folders). */
|
|
1546
|
+
getUidsForFolder(_accountId, folderId) {
|
|
1547
|
+
const rows = this.db.prepare("SELECT uid FROM message_folders WHERE folder_id = ?").all(folderId);
|
|
1301
1548
|
return rows.map(r => r.uid);
|
|
1302
1549
|
}
|
|
1303
1550
|
/** Delete a message by account + UID. Reason is propagated into the
|
|
@@ -1804,7 +2051,7 @@ export class MailxDB {
|
|
|
1804
2051
|
}
|
|
1805
2052
|
// ── Search ──
|
|
1806
2053
|
/** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
|
|
1807
|
-
searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
|
|
2054
|
+
searchMessages(query, page = 1, pageSize = 50, accountId, folderId, includeTrashSpam = false) {
|
|
1808
2055
|
query = (query || "").trim();
|
|
1809
2056
|
// Parse qualifiers (C45: extended set — date:, has:, is:, folder:).
|
|
1810
2057
|
let ftsQuery = "";
|
|
@@ -1932,11 +2179,22 @@ export class MailxDB {
|
|
|
1932
2179
|
scopeWhere = " AND m.account_id = ?";
|
|
1933
2180
|
scopeParams.push(accountId);
|
|
1934
2181
|
}
|
|
2182
|
+
// Global / account-wide search excludes trash + junk unless the
|
|
2183
|
+
// user opted in. A user typing "invoice" in the search box almost
|
|
2184
|
+
// never wants the deleted/spam copy that's already filtered out
|
|
2185
|
+
// of every other view. When the user IS explicitly scoped to
|
|
2186
|
+
// trash/junk via folderId, this guard is skipped.
|
|
2187
|
+
if (!includeTrashSpam && !folderId) {
|
|
2188
|
+
scopeWhere += " AND (f.special_use IS NULL OR f.special_use NOT IN ('trash','junk'))";
|
|
2189
|
+
}
|
|
1935
2190
|
if (extraWhere.length > 0) {
|
|
1936
2191
|
scopeWhere += " AND " + extraWhere.join(" AND ");
|
|
1937
2192
|
scopeParams.push(...extraParams);
|
|
1938
2193
|
}
|
|
1939
|
-
const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages m
|
|
2194
|
+
const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages m
|
|
2195
|
+
JOIN messages_fts fts ON m.id = fts.rowid
|
|
2196
|
+
LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
|
|
2197
|
+
WHERE messages_fts MATCH ?${scopeWhere}`).get(ftsQuery, ...scopeParams);
|
|
1940
2198
|
const total = countRow?.cnt || 0;
|
|
1941
2199
|
const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name FROM messages m
|
|
1942
2200
|
JOIN messages_fts fts ON m.id = fts.rowid
|