@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.
Files changed (3) hide show
  1. package/db.d.ts +17 -2
  2. package/db.js +94 -12
  3. 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
- deleteMessage(accountId: string, uid: number): void;
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). Negative UIDs also
1033
- // count as pending: that's the convention for optimistic local
1034
- // inserts (e.g. Sent rows written the moment the user hits Send,
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
- ) OR m.uid < 0
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
- deleteMessage(accountId, uid) {
1218
- // Get folderId before deleting so we can update counts
1219
- const msg = this.db.prepare("SELECT folder_id FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
1220
- this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ?").run(accountId, uid);
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.10",
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.12"
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.12"
29
+ "@bobfrankston/mailx-settings": "^0.1.13"
30
30
  }
31
31
  }
32
32
  }