@bobfrankston/mailx-store 0.1.10 → 0.1.12
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/db.d.ts +17 -2
- package/db.js +94 -12
- package/package.json +3 -3
package/db.d.ts
CHANGED
|
@@ -148,6 +148,20 @@ export declare class MailxDB {
|
|
|
148
148
|
updateLastSync(accountId: string, timestamp: number): void;
|
|
149
149
|
upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number;
|
|
150
150
|
getFolders(accountId: string): Folder[];
|
|
151
|
+
/** Append a row to the audit_log table. Every destructive operation on
|
|
152
|
+
* the messages table writes here so "where did the rows go?" has an
|
|
153
|
+
* authoritative answer. Cheap insert, indexed by ts and (account, folder). */
|
|
154
|
+
audit(entry: {
|
|
155
|
+
kind: "delete-msg" | "delete-bulk" | "migration" | "manual" | string;
|
|
156
|
+
accountId?: string;
|
|
157
|
+
folderId?: number;
|
|
158
|
+
uid?: number;
|
|
159
|
+
count?: number;
|
|
160
|
+
messageId?: string;
|
|
161
|
+
subject?: string;
|
|
162
|
+
reason?: string;
|
|
163
|
+
source?: string;
|
|
164
|
+
}): void;
|
|
151
165
|
deleteFolder(folderId: number): void;
|
|
152
166
|
markFolderRead(folderId: number): void;
|
|
153
167
|
deleteAllMessages(accountId: string, folderId: number): void;
|
|
@@ -204,8 +218,9 @@ export declare class MailxDB {
|
|
|
204
218
|
getMessageCount(accountId: string, folderId: number): number;
|
|
205
219
|
/** Get all UIDs for a folder */
|
|
206
220
|
getUidsForFolder(accountId: string, folderId: number): number[];
|
|
207
|
-
/** Delete a message by account + UID
|
|
208
|
-
|
|
221
|
+
/** Delete a message by account + UID. Reason is propagated into the
|
|
222
|
+
* audit_log so the DB carries an authoritative trail of every removal. */
|
|
223
|
+
deleteMessage(accountId: string, uid: number, reason?: string, source?: string): void;
|
|
209
224
|
/** Recalculate folder total/unread counts from actual messages */
|
|
210
225
|
recalcFolderCounts(folderId: number): void;
|
|
211
226
|
/** Bulk insert within a transaction for sync performance */
|
package/db.js
CHANGED
|
@@ -285,6 +285,35 @@ const SCHEMA = `
|
|
|
285
285
|
updated_at INTEGER NOT NULL,
|
|
286
286
|
PRIMARY KEY(scope, key)
|
|
287
287
|
);
|
|
288
|
+
|
|
289
|
+
-- Audit trail of every destructive DB operation. Writes ONLY — the row
|
|
290
|
+
-- inserted here records the deletion the caller is about to (or just
|
|
291
|
+
-- did) commit on the messages table. Lets the user retroactively answer
|
|
292
|
+
-- "where did my Sent rows go?" — every deletion has a row with
|
|
293
|
+
-- kind/reason/folder/uid + caller stack hint. Trivial cost (rows are
|
|
294
|
+
-- tiny), no retention policy yet — kept indefinitely.
|
|
295
|
+
--
|
|
296
|
+
-- kind values:
|
|
297
|
+
-- delete-msg single message removed (sync_actions drain, body
|
|
298
|
+
-- fetch reported isNotFound, reconcile)
|
|
299
|
+
-- delete-bulk bulk delete (reconcile pruning, emptyFolder, etc.)
|
|
300
|
+
-- migration schema migrations that touch messages
|
|
301
|
+
-- manual direct user "rebuild" / "wipe folder"
|
|
302
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
303
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
304
|
+
ts INTEGER NOT NULL,
|
|
305
|
+
kind TEXT NOT NULL,
|
|
306
|
+
account_id TEXT,
|
|
307
|
+
folder_id INTEGER,
|
|
308
|
+
uid INTEGER,
|
|
309
|
+
count INTEGER,
|
|
310
|
+
message_id TEXT,
|
|
311
|
+
subject TEXT,
|
|
312
|
+
reason TEXT,
|
|
313
|
+
source TEXT
|
|
314
|
+
);
|
|
315
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts);
|
|
316
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_folder ON audit_log(account_id, folder_id);
|
|
288
317
|
`;
|
|
289
318
|
export class MailxDB {
|
|
290
319
|
db;
|
|
@@ -334,6 +363,27 @@ export class MailxDB {
|
|
|
334
363
|
// this column landed. One UPDATE + an id roundtrip per row — cheap
|
|
335
364
|
// at our row counts, runs once per DB upgrade.
|
|
336
365
|
this.backfillUuids();
|
|
366
|
+
// One-shot cleanup: the retired insertOptimisticSentRow path wrote
|
|
367
|
+
// synthetic-negative-UID rows into Sent. Those rows are stale (the
|
|
368
|
+
// real server-synced row eventually appears with a positive UID),
|
|
369
|
+
// they pollute MAX(uid) (which broke Sent sync entirely), and the
|
|
370
|
+
// mechanism is gone. Drop them. Bodies on disk are orphaned; the
|
|
371
|
+
// body-store has no GC today but a one-time leak is fine.
|
|
372
|
+
try {
|
|
373
|
+
const purgedFlag = this.getKv("schema", "drop_synthetic_uids_v1");
|
|
374
|
+
if (!purgedFlag) {
|
|
375
|
+
const r = this.db.prepare("DELETE FROM messages WHERE uid < 0").run();
|
|
376
|
+
const count = r.changes || 0;
|
|
377
|
+
if (count > 0) {
|
|
378
|
+
console.log(` [migration] dropped ${count} synthetic-UID rows from messages`);
|
|
379
|
+
this.audit({ kind: "migration", count, reason: "drop_synthetic_uids_v1 — purge negative-UID optimistic-Sent rows", source: "MailxDB constructor" });
|
|
380
|
+
}
|
|
381
|
+
this.setKv("schema", "drop_synthetic_uids_v1", "1");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
console.error(` [migration] synthetic-UID cleanup failed: ${e?.message || e}`);
|
|
386
|
+
}
|
|
337
387
|
// One-shot contacts table reset: the contacts schema's UNIQUE constraint
|
|
338
388
|
// was widened from `(email)` to `(source, email, name)` so the same
|
|
339
389
|
// address can carry multiple distinct (name, source) entries — Bob's
|
|
@@ -911,8 +961,24 @@ export class MailxDB {
|
|
|
911
961
|
}
|
|
912
962
|
return folders;
|
|
913
963
|
}
|
|
964
|
+
/** Append a row to the audit_log table. Every destructive operation on
|
|
965
|
+
* the messages table writes here so "where did the rows go?" has an
|
|
966
|
+
* authoritative answer. Cheap insert, indexed by ts and (account, folder). */
|
|
967
|
+
audit(entry) {
|
|
968
|
+
try {
|
|
969
|
+
this.db.prepare(`INSERT INTO audit_log (ts, kind, account_id, folder_id, uid, count, message_id, subject, reason, source)
|
|
970
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(Date.now(), entry.kind, entry.accountId ?? null, entry.folderId ?? null, entry.uid ?? null, entry.count ?? null, entry.messageId ?? null, entry.subject ?? null, entry.reason ?? null, entry.source ?? null);
|
|
971
|
+
}
|
|
972
|
+
catch (e) {
|
|
973
|
+
// Audit must never break the operation it's auditing.
|
|
974
|
+
console.error(` [audit] failed: ${e?.message || e}`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
914
977
|
deleteFolder(folderId) {
|
|
915
|
-
this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
|
|
978
|
+
const r = this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
|
|
979
|
+
const count = r.changes || 0;
|
|
980
|
+
if (count > 0)
|
|
981
|
+
this.audit({ kind: "delete-bulk", folderId, count, reason: "folder deleted", source: "deleteFolder" });
|
|
916
982
|
this.db.prepare("DELETE FROM folders WHERE id = ?").run(folderId);
|
|
917
983
|
}
|
|
918
984
|
markFolderRead(folderId) {
|
|
@@ -920,7 +986,10 @@ export class MailxDB {
|
|
|
920
986
|
this.recalcFolderCounts(folderId);
|
|
921
987
|
}
|
|
922
988
|
deleteAllMessages(accountId, folderId) {
|
|
923
|
-
this.db.prepare("DELETE FROM messages WHERE account_id = ? AND folder_id = ?").run(accountId, folderId);
|
|
989
|
+
const r = this.db.prepare("DELETE FROM messages WHERE account_id = ? AND folder_id = ?").run(accountId, folderId);
|
|
990
|
+
const count = r.changes || 0;
|
|
991
|
+
if (count > 0)
|
|
992
|
+
this.audit({ kind: "delete-bulk", accountId, folderId, count, reason: "deleteAllMessages (emptyFolder)", source: "deleteAllMessages" });
|
|
924
993
|
this.recalcFolderCounts(folderId);
|
|
925
994
|
}
|
|
926
995
|
updateFolderCounts(folderId, total, unread) {
|
|
@@ -1029,15 +1098,14 @@ export class MailxDB {
|
|
|
1029
1098
|
// LEFT JOIN sync_actions so each row carries a `pending` flag —
|
|
1030
1099
|
// true when the user has a queued local action (move/flag/delete)
|
|
1031
1100
|
// not yet acknowledged by the server. UI renders these in pink so
|
|
1032
|
-
// local-only state is visible (Slice C of S1).
|
|
1033
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
// before the real APPENDUID comes back from the server).
|
|
1101
|
+
// local-only state is visible (Slice C of S1). The optimistic-Sent
|
|
1102
|
+
// negative-UID convention was retired — Sent now reflects only what
|
|
1103
|
+
// the server has; pending sends live in the Outbox view.
|
|
1036
1104
|
const rows = this.db.prepare(`SELECT m.*, (
|
|
1037
1105
|
EXISTS(
|
|
1038
1106
|
SELECT 1 FROM sync_actions sa
|
|
1039
1107
|
WHERE sa.account_id = m.account_id AND sa.uid = m.uid
|
|
1040
|
-
)
|
|
1108
|
+
)
|
|
1041
1109
|
) AS pending
|
|
1042
1110
|
FROM messages m WHERE ${where.replace(/\b(account_id|folder_id|uid|date|subject|from_name|from_address|flags_json)\b/g, "m.$1")}
|
|
1043
1111
|
ORDER BY m.${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
@@ -1213,11 +1281,25 @@ export class MailxDB {
|
|
|
1213
1281
|
const rows = this.db.prepare("SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?").all(accountId, folderId);
|
|
1214
1282
|
return rows.map(r => r.uid);
|
|
1215
1283
|
}
|
|
1216
|
-
/** Delete a message by account + UID
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1284
|
+
/** Delete a message by account + UID. Reason is propagated into the
|
|
1285
|
+
* audit_log so the DB carries an authoritative trail of every removal. */
|
|
1286
|
+
deleteMessage(accountId, uid, reason, source) {
|
|
1287
|
+
// Get folderId + message_id + subject before deleting so the audit
|
|
1288
|
+
// row carries enough context to identify what was removed.
|
|
1289
|
+
const msg = this.db.prepare("SELECT folder_id, message_id, subject FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
|
|
1290
|
+
const r = this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ?").run(accountId, uid);
|
|
1291
|
+
if (r.changes && msg) {
|
|
1292
|
+
this.audit({
|
|
1293
|
+
kind: "delete-msg",
|
|
1294
|
+
accountId,
|
|
1295
|
+
folderId: msg.folder_id,
|
|
1296
|
+
uid,
|
|
1297
|
+
messageId: msg.message_id || undefined,
|
|
1298
|
+
subject: msg.subject || undefined,
|
|
1299
|
+
reason: reason || "deleteMessage (no reason)",
|
|
1300
|
+
source: source || "db.deleteMessage",
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1221
1303
|
// Refresh folder counts
|
|
1222
1304
|
if (msg)
|
|
1223
1305
|
this.recalcFolderCounts(msg.folder_id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/mailx-types": "^0.1.10",
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.13"
|
|
14
14
|
},
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
".transformedSnapshot": {
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@bobfrankston/mailx-types": "^0.1.10",
|
|
29
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
29
|
+
"@bobfrankston/mailx-settings": "^0.1.13"
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
}
|