@bobfrankston/mailx-store 0.1.11 → 0.1.13
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 +33 -2
- package/db.js +102 -9
- package/package.json +1 -1
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 */
|
|
@@ -238,6 +253,22 @@ export declare class MailxDB {
|
|
|
238
253
|
private _onContactsChanged?;
|
|
239
254
|
setOnContactsChanged(cb: () => void): void;
|
|
240
255
|
private notifyContactsChanged;
|
|
256
|
+
/** Fired when upsertMessage detects a server-side move (Message-ID match
|
|
257
|
+
* in another folder rebinds the existing row to the destination). The
|
|
258
|
+
* reconciler can listen and cancel any pending deferred-delete for the
|
|
259
|
+
* original (folder, uid), so the move propagates without the source
|
|
260
|
+
* folder's reconcile-delete firing 30 minutes later for a row that's
|
|
261
|
+
* been rebound elsewhere. */
|
|
262
|
+
private _onMoveDetected?;
|
|
263
|
+
setOnMoveDetected(cb: (info: {
|
|
264
|
+
accountId: string;
|
|
265
|
+
messageId: string;
|
|
266
|
+
fromFolderId: number;
|
|
267
|
+
fromUid: number;
|
|
268
|
+
toFolderId: number;
|
|
269
|
+
toUid: number;
|
|
270
|
+
rowId: number;
|
|
271
|
+
}) => void): void;
|
|
241
272
|
/** Seed `discovered`-tier contacts from every address that appears in
|
|
242
273
|
* any cached message — From / To / Cc / Bcc across all folders. One row
|
|
243
274
|
* per email; first non-empty name observed wins. Sent-folder rows skip
|
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;
|
|
@@ -344,8 +373,10 @@ export class MailxDB {
|
|
|
344
373
|
const purgedFlag = this.getKv("schema", "drop_synthetic_uids_v1");
|
|
345
374
|
if (!purgedFlag) {
|
|
346
375
|
const r = this.db.prepare("DELETE FROM messages WHERE uid < 0").run();
|
|
347
|
-
|
|
348
|
-
|
|
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" });
|
|
349
380
|
}
|
|
350
381
|
this.setKv("schema", "drop_synthetic_uids_v1", "1");
|
|
351
382
|
}
|
|
@@ -930,8 +961,24 @@ export class MailxDB {
|
|
|
930
961
|
}
|
|
931
962
|
return folders;
|
|
932
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
|
+
}
|
|
933
977
|
deleteFolder(folderId) {
|
|
934
|
-
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" });
|
|
935
982
|
this.db.prepare("DELETE FROM folders WHERE id = ?").run(folderId);
|
|
936
983
|
}
|
|
937
984
|
markFolderRead(folderId) {
|
|
@@ -939,7 +986,10 @@ export class MailxDB {
|
|
|
939
986
|
this.recalcFolderCounts(folderId);
|
|
940
987
|
}
|
|
941
988
|
deleteAllMessages(accountId, folderId) {
|
|
942
|
-
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" });
|
|
943
993
|
this.recalcFolderCounts(folderId);
|
|
944
994
|
}
|
|
945
995
|
updateFolderCounts(folderId, total, unread) {
|
|
@@ -995,6 +1045,25 @@ export class MailxDB {
|
|
|
995
1045
|
// Update folder_id + uid; preserve uuid, body_path, flags
|
|
996
1046
|
// (server flags will catch up on the next full sync).
|
|
997
1047
|
this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
|
|
1048
|
+
// Notify subscribers so e.g. the reconciler can cancel any
|
|
1049
|
+
// pending deferred-delete for the original (folder, uid) —
|
|
1050
|
+
// otherwise the reconcile-delete grace timer fires for a
|
|
1051
|
+
// row that's been rebound elsewhere, and the user sees the
|
|
1052
|
+
// moved message vanish 30 minutes after the move.
|
|
1053
|
+
if (this._onMoveDetected) {
|
|
1054
|
+
try {
|
|
1055
|
+
this._onMoveDetected({
|
|
1056
|
+
accountId: msg.accountId,
|
|
1057
|
+
messageId: msg.messageId,
|
|
1058
|
+
fromFolderId: moved.folder_id,
|
|
1059
|
+
fromUid: moved.uid,
|
|
1060
|
+
toFolderId: msg.folderId,
|
|
1061
|
+
toUid: msg.uid,
|
|
1062
|
+
rowId: moved.id,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
catch { /* listener errors must not break sync */ }
|
|
1066
|
+
}
|
|
998
1067
|
return moved.id;
|
|
999
1068
|
}
|
|
1000
1069
|
}
|
|
@@ -1231,11 +1300,25 @@ export class MailxDB {
|
|
|
1231
1300
|
const rows = this.db.prepare("SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?").all(accountId, folderId);
|
|
1232
1301
|
return rows.map(r => r.uid);
|
|
1233
1302
|
}
|
|
1234
|
-
/** Delete a message by account + UID
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1303
|
+
/** Delete a message by account + UID. Reason is propagated into the
|
|
1304
|
+
* audit_log so the DB carries an authoritative trail of every removal. */
|
|
1305
|
+
deleteMessage(accountId, uid, reason, source) {
|
|
1306
|
+
// Get folderId + message_id + subject before deleting so the audit
|
|
1307
|
+
// row carries enough context to identify what was removed.
|
|
1308
|
+
const msg = this.db.prepare("SELECT folder_id, message_id, subject FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
|
|
1309
|
+
const r = this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ?").run(accountId, uid);
|
|
1310
|
+
if (r.changes && msg) {
|
|
1311
|
+
this.audit({
|
|
1312
|
+
kind: "delete-msg",
|
|
1313
|
+
accountId,
|
|
1314
|
+
folderId: msg.folder_id,
|
|
1315
|
+
uid,
|
|
1316
|
+
messageId: msg.message_id || undefined,
|
|
1317
|
+
subject: msg.subject || undefined,
|
|
1318
|
+
reason: reason || "deleteMessage (no reason)",
|
|
1319
|
+
source: source || "db.deleteMessage",
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1239
1322
|
// Refresh folder counts
|
|
1240
1323
|
if (msg)
|
|
1241
1324
|
this.recalcFolderCounts(msg.folder_id);
|
|
@@ -1322,6 +1405,16 @@ export class MailxDB {
|
|
|
1322
1405
|
}
|
|
1323
1406
|
catch { /* ignore */ }
|
|
1324
1407
|
}
|
|
1408
|
+
/** Fired when upsertMessage detects a server-side move (Message-ID match
|
|
1409
|
+
* in another folder rebinds the existing row to the destination). The
|
|
1410
|
+
* reconciler can listen and cancel any pending deferred-delete for the
|
|
1411
|
+
* original (folder, uid), so the move propagates without the source
|
|
1412
|
+
* folder's reconcile-delete firing 30 minutes later for a row that's
|
|
1413
|
+
* been rebound elsewhere. */
|
|
1414
|
+
_onMoveDetected;
|
|
1415
|
+
setOnMoveDetected(cb) {
|
|
1416
|
+
this._onMoveDetected = cb;
|
|
1417
|
+
}
|
|
1325
1418
|
/** Seed `discovered`-tier contacts from every address that appears in
|
|
1326
1419
|
* any cached message — From / To / Cc / Bcc across all folders. One row
|
|
1327
1420
|
* per email; first non-empty name observed wins. Sent-folder rows skip
|