@bobfrankston/mailx 1.0.360 → 1.0.362

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.360",
3
+ "version": "1.0.362",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -177,6 +177,10 @@ export declare class ImapManager extends EventEmitter {
177
177
  * The persistent fetchClient can only handle one command at a time (IMAP protocol limitation). */
178
178
  private fetchQueues;
179
179
  /** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
180
+ /** Unlink the on-disk body file for a message by reading its `body_path`
181
+ * from the DB. Safe to call either before or after `db.deleteMessage`
182
+ * — read body_path first, store it, then unlink whenever. */
183
+ private unlinkBodyFile;
180
184
  private enqueueFetch;
181
185
  /** Fetch a single message body on demand, caching in the store.
182
186
  * Uses its own fresh connection — never blocked by background prefetch. */
@@ -689,6 +689,14 @@ export class ImapManager extends EventEmitter {
689
689
  }
690
690
  if (msg.uid <= highestUid)
691
691
  continue; // already have it
692
+ // Tombstone check: if the user locally deleted this Message-ID,
693
+ // don't re-import it. Server-side EXPUNGE may lag, or reconcile
694
+ // may find the message in an old list snapshot. Without this,
695
+ // deleted messages reappear on the next sync pass.
696
+ if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
697
+ console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted)`);
698
+ continue;
699
+ }
692
700
  const source = msg.source || "";
693
701
  let bodyPath = "";
694
702
  let preview = "";
@@ -931,8 +939,9 @@ export class ImapManager extends EventEmitter {
931
939
  const localUids = this.db.getUidsForFolder(accountId, folderId);
932
940
  for (const uid of localUids) {
933
941
  if (!serverUids.has(uid)) {
942
+ // Read body_path BEFORE deleting the row, then unlink.
943
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
934
944
  this.db.deleteMessage(accountId, uid);
935
- this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
936
945
  deletedCount++;
937
946
  }
938
947
  }
@@ -1265,8 +1274,8 @@ export class ImapManager extends EventEmitter {
1265
1274
  }
1266
1275
  else {
1267
1276
  for (const uid of toDelete) {
1277
+ this.unlinkBodyFile(accountId, uid, folder.id).catch(() => { });
1268
1278
  this.db.deleteMessage(accountId, uid);
1269
- this.bodyStore.deleteMessage(accountId, folder.id, uid).catch(() => { });
1270
1279
  }
1271
1280
  if (toDelete.length > 0)
1272
1281
  console.log(` [api] ${accountId}/${folder.path}: ${toDelete.length} deleted`);
@@ -1581,6 +1590,17 @@ export class ImapManager extends EventEmitter {
1581
1590
  this.syncIntervals.set("prefetch", prefetchInterval);
1582
1591
  console.log(` [periodic] body prefetch every 60s (independent of sync)`);
1583
1592
  }
1593
+ // Tombstone prune: age out local-delete records older than 30 days.
1594
+ // Runs hourly — cheap (one indexed DELETE).
1595
+ const TOMBSTONE_RETENTION_DAYS = 30;
1596
+ const pruneTombstones = () => {
1597
+ const cutoff = Date.now() - TOMBSTONE_RETENTION_DAYS * 86400_000;
1598
+ const n = this.db.pruneTombstones(cutoff);
1599
+ if (n > 0)
1600
+ console.log(` [tombstones] pruned ${n} older than ${TOMBSTONE_RETENTION_DAYS} days`);
1601
+ };
1602
+ setTimeout(pruneTombstones, 30_000); // first run after startup settles
1603
+ this.syncIntervals.set("tombstone-prune", setInterval(pruneTombstones, 3600_000));
1584
1604
  // Full sync (all folders + IDLE restart) at configured interval
1585
1605
  const fullInterval = setInterval(async () => {
1586
1606
  console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
@@ -1641,6 +1661,18 @@ export class ImapManager extends EventEmitter {
1641
1661
  * The persistent fetchClient can only handle one command at a time (IMAP protocol limitation). */
1642
1662
  fetchQueues = new Map();
1643
1663
  /** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
1664
+ /** Unlink the on-disk body file for a message by reading its `body_path`
1665
+ * from the DB. Safe to call either before or after `db.deleteMessage`
1666
+ * — read body_path first, store it, then unlink whenever. */
1667
+ async unlinkBodyFile(accountId, uid, folderId) {
1668
+ try {
1669
+ const row = this.db.getMessageByUid(accountId, uid, folderId);
1670
+ const p = row?.bodyPath;
1671
+ if (p)
1672
+ await this.bodyStore.unlinkByPath(p);
1673
+ }
1674
+ catch { /* row already gone / file already gone — both fine */ }
1675
+ }
1644
1676
  enqueueFetch(accountId, fn) {
1645
1677
  const prev = this.fetchQueues.get(accountId) || Promise.resolve();
1646
1678
  const next = prev.then(fn, fn); // run fn after previous completes (regardless of success/failure)
@@ -1651,48 +1683,15 @@ export class ImapManager extends EventEmitter {
1651
1683
  /** Fetch a single message body on demand, caching in the store.
1652
1684
  * Uses its own fresh connection — never blocked by background prefetch. */
1653
1685
  async fetchMessageBody(accountId, folderId, uid) {
1654
- // Already cached? If the file is on disk but body_path wasn't written to
1655
- // the DB (e.g. from an interrupted earlier run), the prefetch loop would
1656
- // otherwise keep returning the same missing rows forever once saw
1657
- // "gmail: 17266796 bodies cached" in the logs, which is the counter
1658
- // spinning on the same 100 rows.
1659
- if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
1660
- // COMINGLING GUARD: verify the cached body's Message-ID matches the
1661
- // DB row's messageId. If UIDVALIDITY changed server-side (mailbox
1662
- // recreated, server quirk) the same integer UID can point at a
1663
- // different message — the on-disk .eml becomes stale but hasMessage()
1664
- // still returns true. User-reported: "Peter Hoddie letter comingled
1665
- // with a much older letter." Check fixes it regardless of root cause.
1666
- const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
1667
- const envelope = this.db.getMessageByUid(accountId, uid, folderId);
1668
- const expectedId = envelope?.messageId || "";
1669
- if (expectedId) {
1670
- // Scan headers only — Message-ID should land in the first few KB.
1671
- const head = cached.subarray(0, Math.min(cached.length, 16 * 1024)).toString("utf-8");
1672
- const m = head.match(/^Message-ID:\s*<([^>\r\n]+)>/im);
1673
- const cachedId = m ? `<${m[1]}>` : "";
1674
- if (cachedId && expectedId && cachedId !== expectedId) {
1675
- console.error(` [body] COMINGLING DETECTED ${accountId}/${folderId}/${uid}: expected ${expectedId}, cached ${cachedId} — dropping cache, re-fetching`);
1676
- try {
1677
- await this.bodyStore.deleteMessage(accountId, folderId, uid);
1678
- }
1679
- catch { /* */ }
1680
- // fall through to re-fetch path
1681
- }
1682
- else {
1683
- const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
1684
- if (existingPath)
1685
- this.db.updateBodyPath(accountId, uid, existingPath);
1686
- return cached;
1687
- }
1688
- }
1689
- else {
1690
- // No messageId on the DB row (shouldn't happen but be permissive).
1691
- const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
1692
- if (existingPath)
1693
- this.db.updateBodyPath(accountId, uid, existingPath);
1694
- return cached;
1695
- }
1686
+ // Already cached? Read the DB row's `body_path` and check the file
1687
+ // exists there. No more `(folderId, uid)` path reconstruction that
1688
+ // was the source of the S49 comingling bug (UID reuse + folder move
1689
+ // pointing two messages at one file). `body_path` is the sole
1690
+ // authority on where a given message's body lives on disk.
1691
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
1692
+ const storedPath = envelope?.bodyPath || "";
1693
+ if (storedPath && await this.bodyStore.hasByPath(storedPath)) {
1694
+ return this.bodyStore.readByPath(storedPath);
1696
1695
  }
1697
1696
  if (!this.configs.has(accountId))
1698
1697
  return null;
@@ -1948,8 +1947,8 @@ export class ImapManager extends EventEmitter {
1948
1947
  if (received.has(uid))
1949
1948
  continue;
1950
1949
  try {
1950
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
1951
1951
  this.db.deleteMessage(accountId, uid);
1952
- this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
1953
1952
  counters.deleted++;
1954
1953
  madeProgress = true;
1955
1954
  }
@@ -2032,8 +2031,8 @@ export class ImapManager extends EventEmitter {
2032
2031
  if (received.has(uid))
2033
2032
  continue;
2034
2033
  try {
2034
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2035
2035
  this.db.deleteMessage(accountId, uid);
2036
- this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
2037
2036
  counters.deleted++;
2038
2037
  madeProgress = true;
2039
2038
  }
@@ -2077,8 +2076,8 @@ export class ImapManager extends EventEmitter {
2077
2076
  const trash = this.findFolder(accountId, "trash");
2078
2077
  // Local first — remove all from DB immediately
2079
2078
  for (const msg of messages) {
2079
+ this.unlinkBodyFile(accountId, msg.uid, msg.folderId).catch(() => { });
2080
2080
  this.db.deleteMessage(accountId, msg.uid);
2081
- this.bodyStore.deleteMessage(accountId, msg.folderId, msg.uid).catch(() => { });
2082
2081
  }
2083
2082
  console.log(` Deleted ${messages.length} messages locally`);
2084
2083
  // Queue IMAP actions
@@ -2130,8 +2129,8 @@ export class ImapManager extends EventEmitter {
2130
2129
  async trashMessage(accountId, folderId, uid) {
2131
2130
  const trash = this.findFolder(accountId, "trash");
2132
2131
  // Local first — remove from DB immediately
2132
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2133
2133
  this.db.deleteMessage(accountId, uid);
2134
- this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
2135
2134
  // Queue IMAP action + log the resolution so "I deleted a message and
2136
2135
  // now it's in neither trash nor deleted" is diagnosable from the log.
2137
2136
  if (trash && trash.id !== folderId) {
@@ -646,6 +646,11 @@ export class MailxService {
646
646
  const envelope = this.db.getMessageByUid(accountId, uid);
647
647
  if (!envelope)
648
648
  throw new Error("Message not found");
649
+ // Tombstone the Message-ID so a subsequent sync pass can't resurrect
650
+ // the row if the server's EXPUNGE hasn't propagated yet. `undelete`
651
+ // removes the tombstone.
652
+ if (envelope.messageId)
653
+ this.db.addTombstone(accountId, envelope.messageId, envelope.subject || "");
649
654
  await this.imapManager.trashMessage(accountId, envelope.folderId, envelope.uid);
650
655
  }
651
656
  async deleteMessages(accountId, uids) {
@@ -653,6 +658,9 @@ export class MailxService {
653
658
  const env = this.db.getMessageByUid(accountId, uid);
654
659
  if (!env)
655
660
  return null;
661
+ // Tombstone each — same reason as single-delete above.
662
+ if (env.messageId)
663
+ this.db.addTombstone(accountId, env.messageId, env.subject || "");
656
664
  return { uid: env.uid, folderId: env.folderId };
657
665
  }).filter(m => m !== null);
658
666
  await this.imapManager.trashMessages(accountId, messages);
@@ -711,6 +719,12 @@ export class MailxService {
711
719
  return { targetFolderId: target.id, moved: uids.length };
712
720
  }
713
721
  async undeleteMessage(accountId, uid, folderId) {
722
+ // Clear the tombstone first so a subsequent sync can re-import if
723
+ // the server still has the row. Messages with no Message-ID just
724
+ // didn't get a tombstone — this is a no-op for them.
725
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
726
+ if (envelope?.messageId)
727
+ this.db.removeTombstone(accountId, envelope.messageId);
714
728
  await this.imapManager.undeleteMessage(accountId, uid, folderId);
715
729
  }
716
730
  async deleteOnServer(accountId, folderPath, uid) {
@@ -12,6 +12,20 @@ export declare class MailxDB {
12
12
  hasSentMessage(messageId: string): boolean;
13
13
  /** Record a successfully sent message so future attempts are skipped. */
14
14
  recordSent(messageId: string, accountId: string, subject: string, recipients: string[]): void;
15
+ /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
16
+ * is empty (e.g. provider stripped the header) — without a stable id we
17
+ * can't check against future sync results anyway. */
18
+ addTombstone(accountId: string, messageId: string, subject?: string): void;
19
+ /** Is this Message-ID tombstoned for this account? */
20
+ hasTombstone(accountId: string, messageId: string): boolean;
21
+ /** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
22
+ * re-imports the message as normal. Also lets the user recover from a
23
+ * mistaken local delete. */
24
+ removeTombstone(accountId: string, messageId: string): void;
25
+ /** Age-out tombstones older than the given cutoff. Keeps the table from
26
+ * growing unboundedly. Default retention is 30 days; caller passes the
27
+ * actual cutoff in ms since epoch. */
28
+ pruneTombstones(olderThanMs: number): number;
15
29
  /** Idempotently add a column to a table if it's missing. */
16
30
  private addColumnIfMissing;
17
31
  /** Compute a thread id for an incoming message. Strategy:
@@ -126,6 +126,21 @@ const SCHEMA = `
126
126
  last_error TEXT,
127
127
  UNIQUE(account_id, action, uid, folder_id)
128
128
  );
129
+
130
+ -- Tombstones: messages the user deleted locally. Sync checks this table
131
+ -- before inserting a new row so a server-side delete that hasn't yet
132
+ -- propagated (or a stale server listing during the EXPUNGE race) can't
133
+ -- resurrect a message the user already removed. Keyed by Message-ID
134
+ -- because that's the only identifier stable across UID renumbers,
135
+ -- UIDVALIDITY bumps, and cross-folder moves.
136
+ CREATE TABLE IF NOT EXISTS tombstones (
137
+ account_id TEXT NOT NULL,
138
+ message_id TEXT NOT NULL,
139
+ deleted_at INTEGER NOT NULL,
140
+ subject TEXT DEFAULT '',
141
+ PRIMARY KEY (account_id, message_id)
142
+ );
143
+ CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
129
144
  `;
130
145
  export class MailxDB {
131
146
  db;
@@ -172,6 +187,53 @@ export class MailxDB {
172
187
  console.error(` [sent_log] failed to record ${messageId}: ${e.message}`);
173
188
  }
174
189
  }
190
+ // ── Tombstones (local-delete record so server echo can't resurrect) ──
191
+ /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
192
+ * is empty (e.g. provider stripped the header) — without a stable id we
193
+ * can't check against future sync results anyway. */
194
+ addTombstone(accountId, messageId, subject = "") {
195
+ if (!messageId)
196
+ return;
197
+ try {
198
+ this.db.prepare("INSERT INTO tombstones (account_id, message_id, deleted_at, subject) VALUES (?, ?, ?, ?) ON CONFLICT(account_id, message_id) DO UPDATE SET deleted_at = excluded.deleted_at").run(accountId, messageId, Date.now(), subject || "");
199
+ }
200
+ catch (e) {
201
+ console.error(` [tombstones] failed to record ${messageId}: ${e.message}`);
202
+ }
203
+ }
204
+ /** Is this Message-ID tombstoned for this account? */
205
+ hasTombstone(accountId, messageId) {
206
+ if (!messageId)
207
+ return false;
208
+ const row = this.db.prepare("SELECT 1 FROM tombstones WHERE account_id = ? AND message_id = ? LIMIT 1").get(accountId, messageId);
209
+ return !!row;
210
+ }
211
+ /** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
212
+ * re-imports the message as normal. Also lets the user recover from a
213
+ * mistaken local delete. */
214
+ removeTombstone(accountId, messageId) {
215
+ if (!messageId)
216
+ return;
217
+ try {
218
+ this.db.prepare("DELETE FROM tombstones WHERE account_id = ? AND message_id = ?").run(accountId, messageId);
219
+ }
220
+ catch (e) {
221
+ console.error(` [tombstones] failed to remove ${messageId}: ${e.message}`);
222
+ }
223
+ }
224
+ /** Age-out tombstones older than the given cutoff. Keeps the table from
225
+ * growing unboundedly. Default retention is 30 days; caller passes the
226
+ * actual cutoff in ms since epoch. */
227
+ pruneTombstones(olderThanMs) {
228
+ try {
229
+ const res = this.db.prepare("DELETE FROM tombstones WHERE deleted_at < ?").run(olderThanMs);
230
+ return Number(res.changes || 0);
231
+ }
232
+ catch (e) {
233
+ console.error(` [tombstones] prune failed: ${e.message}`);
234
+ return 0;
235
+ }
236
+ }
175
237
  /** Idempotently add a column to a table if it's missing. */
176
238
  addColumnIfMissing(table, column, sqlType) {
177
239
  try {
@@ -1,18 +1,36 @@
1
1
  /**
2
2
  * File-per-message body storage backend.
3
- * Messages stored as: {basePath}/{accountId}/{folderId}/{uid}.eml
4
- * Folder IDs are numeric (from SQLite), not names.
3
+ *
4
+ * Disk layout: {basePath}/{accountId}/<xx>/<uuid>.eml
5
+ * Filename is an opaque UUID. Two-char prefix dir for filesystem fan-out.
6
+ *
7
+ * CRITICAL: the on-disk filename carries NO semantic meaning — not folder
8
+ * id, not UID, not Message-ID. A new UUID is minted on every `putMessage`.
9
+ * Moves, UID renumbers, UIDVALIDITY bumps cannot shadow a body because no
10
+ * filename is ever reused. DB's `body_path` column is the sole authority
11
+ * on where a given message's body lives.
5
12
  */
6
13
  import type { MessageStore } from "@bobfrankston/mailx-types";
7
14
  export declare class FileMessageStore implements MessageStore {
8
15
  private basePath;
9
16
  constructor(basePath: string);
10
- private messagePath;
11
- /** Public lookup of the on-disk path without touching the file. */
12
- getMessagePath(accountId: string, folderId: number, uid: number): string;
13
- putMessage(accountId: string, folderId: number, uid: number, raw: Buffer): Promise<string>;
14
- getMessage(accountId: string, folderId: number, uid: number): Promise<Buffer>;
15
- deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;
16
- hasMessage(accountId: string, folderId: number, uid: number): Promise<boolean>;
17
+ /** Fresh opaque path per call. No inputs from the caller affect the name. */
18
+ private newMessagePath;
19
+ /** Verify a given path resolves inside this store's basePath — refuses
20
+ * any value that doesn't (cheap directory-traversal guard). */
21
+ private inStore;
22
+ /** Write a new body. Always a fresh UUID path. Caller MUST persist the
23
+ * returned path in the DB (`body_path`) and use it for all reads. The
24
+ * (folderId, uid) args are kept for interface compatibility; they do
25
+ * NOT affect the filename. */
26
+ putMessage(accountId: string, _folderId: number, _uid: number, raw: Buffer): Promise<string>;
27
+ /** Read by absolute path (DB `body_path`). The primary read API. */
28
+ readByPath(fullPath: string): Promise<Buffer>;
29
+ hasByPath(fullPath: string): Promise<boolean>;
30
+ unlinkByPath(fullPath: string): Promise<void>;
31
+ getMessagePath(_accountId: string, _folderId: number, _uid: number): string;
32
+ getMessage(_accountId: string, _folderId: number, _uid: number): Promise<Buffer>;
33
+ hasMessage(_accountId: string, _folderId: number, _uid: number): Promise<boolean>;
34
+ deleteMessage(_accountId: string, _folderId: number, _uid: number): Promise<void>;
17
35
  }
18
36
  //# sourceMappingURL=file-store.d.ts.map
@@ -1,39 +1,80 @@
1
1
  /**
2
2
  * File-per-message body storage backend.
3
- * Messages stored as: {basePath}/{accountId}/{folderId}/{uid}.eml
4
- * Folder IDs are numeric (from SQLite), not names.
3
+ *
4
+ * Disk layout: {basePath}/{accountId}/<xx>/<uuid>.eml
5
+ * Filename is an opaque UUID. Two-char prefix dir for filesystem fan-out.
6
+ *
7
+ * CRITICAL: the on-disk filename carries NO semantic meaning — not folder
8
+ * id, not UID, not Message-ID. A new UUID is minted on every `putMessage`.
9
+ * Moves, UID renumbers, UIDVALIDITY bumps cannot shadow a body because no
10
+ * filename is ever reused. DB's `body_path` column is the sole authority
11
+ * on where a given message's body lives.
5
12
  */
6
13
  import * as fs from "node:fs";
7
14
  import * as path from "node:path";
15
+ import { randomUUID } from "node:crypto";
8
16
  export class FileMessageStore {
9
17
  basePath;
10
18
  constructor(basePath) {
11
19
  this.basePath = basePath;
12
20
  fs.mkdirSync(basePath, { recursive: true });
13
21
  }
14
- messagePath(accountId, folderId, uid) {
15
- return path.join(this.basePath, accountId, String(folderId), `${uid}.eml`);
22
+ /** Fresh opaque path per call. No inputs from the caller affect the name. */
23
+ newMessagePath(accountId) {
24
+ const uuid = randomUUID().replace(/-/g, "");
25
+ const prefix = uuid.slice(0, 2);
26
+ return path.join(this.basePath, accountId, prefix, `${uuid}.eml`);
16
27
  }
17
- /** Public lookup of the on-disk path without touching the file. */
18
- getMessagePath(accountId, folderId, uid) {
19
- return this.messagePath(accountId, folderId, uid);
28
+ /** Verify a given path resolves inside this store's basePath refuses
29
+ * any value that doesn't (cheap directory-traversal guard). */
30
+ inStore(fullPath) {
31
+ if (!fullPath)
32
+ return false;
33
+ const rel = path.relative(path.resolve(this.basePath), path.resolve(fullPath));
34
+ return !rel.startsWith("..") && !path.isAbsolute(rel);
20
35
  }
21
- async putMessage(accountId, folderId, uid, raw) {
22
- const filePath = this.messagePath(accountId, folderId, uid);
36
+ /** Write a new body. Always a fresh UUID path. Caller MUST persist the
37
+ * returned path in the DB (`body_path`) and use it for all reads. The
38
+ * (folderId, uid) args are kept for interface compatibility; they do
39
+ * NOT affect the filename. */
40
+ async putMessage(accountId, _folderId, _uid, raw) {
41
+ const filePath = this.newMessagePath(accountId);
23
42
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
24
43
  fs.writeFileSync(filePath, raw);
25
44
  return filePath;
26
45
  }
27
- async getMessage(accountId, folderId, uid) {
28
- return fs.readFileSync(this.messagePath(accountId, folderId, uid));
46
+ /** Read by absolute path (DB `body_path`). The primary read API. */
47
+ async readByPath(fullPath) {
48
+ if (!this.inStore(fullPath))
49
+ throw new Error(`refusing to read outside store: ${fullPath}`);
50
+ return fs.readFileSync(fullPath);
29
51
  }
30
- async deleteMessage(accountId, folderId, uid) {
31
- const filePath = this.messagePath(accountId, folderId, uid);
32
- if (fs.existsSync(filePath))
33
- fs.unlinkSync(filePath);
52
+ async hasByPath(fullPath) {
53
+ if (!this.inStore(fullPath))
54
+ return false;
55
+ return fs.existsSync(fullPath);
34
56
  }
35
- async hasMessage(accountId, folderId, uid) {
36
- return fs.existsSync(this.messagePath(accountId, folderId, uid));
57
+ async unlinkByPath(fullPath) {
58
+ if (!this.inStore(fullPath))
59
+ return;
60
+ if (fs.existsSync(fullPath))
61
+ fs.unlinkSync(fullPath);
62
+ }
63
+ // MessageStore interface compatibility (unused once all callers migrate to
64
+ // path-based reads). These used to compose {folderId}/{uid}.eml and they
65
+ // would resurrect the comingling bug if restored. Kept as throwing stubs
66
+ // so any accidental caller surfaces loudly rather than silently misbehave.
67
+ getMessagePath(_accountId, _folderId, _uid) {
68
+ throw new Error("FileMessageStore.getMessagePath is retired — read body_path from DB");
69
+ }
70
+ async getMessage(_accountId, _folderId, _uid) {
71
+ throw new Error("FileMessageStore.getMessage(folder,uid) is retired — use readByPath(body_path)");
72
+ }
73
+ async hasMessage(_accountId, _folderId, _uid) {
74
+ throw new Error("FileMessageStore.hasMessage(folder,uid) is retired — use hasByPath(body_path)");
75
+ }
76
+ async deleteMessage(_accountId, _folderId, _uid) {
77
+ throw new Error("FileMessageStore.deleteMessage(folder,uid) is retired — use unlinkByPath(body_path)");
37
78
  }
38
79
  }
39
80
  //# sourceMappingURL=file-store.js.map