@bobfrankston/mailx 1.0.361 → 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.361",
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. */
@@ -939,8 +939,9 @@ export class ImapManager extends EventEmitter {
939
939
  const localUids = this.db.getUidsForFolder(accountId, folderId);
940
940
  for (const uid of localUids) {
941
941
  if (!serverUids.has(uid)) {
942
+ // Read body_path BEFORE deleting the row, then unlink.
943
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
942
944
  this.db.deleteMessage(accountId, uid);
943
- this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
944
945
  deletedCount++;
945
946
  }
946
947
  }
@@ -1273,8 +1274,8 @@ export class ImapManager extends EventEmitter {
1273
1274
  }
1274
1275
  else {
1275
1276
  for (const uid of toDelete) {
1277
+ this.unlinkBodyFile(accountId, uid, folder.id).catch(() => { });
1276
1278
  this.db.deleteMessage(accountId, uid);
1277
- this.bodyStore.deleteMessage(accountId, folder.id, uid).catch(() => { });
1278
1279
  }
1279
1280
  if (toDelete.length > 0)
1280
1281
  console.log(` [api] ${accountId}/${folder.path}: ${toDelete.length} deleted`);
@@ -1660,6 +1661,18 @@ export class ImapManager extends EventEmitter {
1660
1661
  * The persistent fetchClient can only handle one command at a time (IMAP protocol limitation). */
1661
1662
  fetchQueues = new Map();
1662
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
+ }
1663
1676
  enqueueFetch(accountId, fn) {
1664
1677
  const prev = this.fetchQueues.get(accountId) || Promise.resolve();
1665
1678
  const next = prev.then(fn, fn); // run fn after previous completes (regardless of success/failure)
@@ -1670,48 +1683,15 @@ export class ImapManager extends EventEmitter {
1670
1683
  /** Fetch a single message body on demand, caching in the store.
1671
1684
  * Uses its own fresh connection — never blocked by background prefetch. */
1672
1685
  async fetchMessageBody(accountId, folderId, uid) {
1673
- // Already cached? If the file is on disk but body_path wasn't written to
1674
- // the DB (e.g. from an interrupted earlier run), the prefetch loop would
1675
- // otherwise keep returning the same missing rows forever once saw
1676
- // "gmail: 17266796 bodies cached" in the logs, which is the counter
1677
- // spinning on the same 100 rows.
1678
- if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
1679
- // COMINGLING GUARD: verify the cached body's Message-ID matches the
1680
- // DB row's messageId. If UIDVALIDITY changed server-side (mailbox
1681
- // recreated, server quirk) the same integer UID can point at a
1682
- // different message — the on-disk .eml becomes stale but hasMessage()
1683
- // still returns true. User-reported: "Peter Hoddie letter comingled
1684
- // with a much older letter." Check fixes it regardless of root cause.
1685
- const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
1686
- const envelope = this.db.getMessageByUid(accountId, uid, folderId);
1687
- const expectedId = envelope?.messageId || "";
1688
- if (expectedId) {
1689
- // Scan headers only — Message-ID should land in the first few KB.
1690
- const head = cached.subarray(0, Math.min(cached.length, 16 * 1024)).toString("utf-8");
1691
- const m = head.match(/^Message-ID:\s*<([^>\r\n]+)>/im);
1692
- const cachedId = m ? `<${m[1]}>` : "";
1693
- if (cachedId && expectedId && cachedId !== expectedId) {
1694
- console.error(` [body] COMINGLING DETECTED ${accountId}/${folderId}/${uid}: expected ${expectedId}, cached ${cachedId} — dropping cache, re-fetching`);
1695
- try {
1696
- await this.bodyStore.deleteMessage(accountId, folderId, uid);
1697
- }
1698
- catch { /* */ }
1699
- // fall through to re-fetch path
1700
- }
1701
- else {
1702
- const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
1703
- if (existingPath)
1704
- this.db.updateBodyPath(accountId, uid, existingPath);
1705
- return cached;
1706
- }
1707
- }
1708
- else {
1709
- // No messageId on the DB row (shouldn't happen but be permissive).
1710
- const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
1711
- if (existingPath)
1712
- this.db.updateBodyPath(accountId, uid, existingPath);
1713
- return cached;
1714
- }
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);
1715
1695
  }
1716
1696
  if (!this.configs.has(accountId))
1717
1697
  return null;
@@ -1967,8 +1947,8 @@ export class ImapManager extends EventEmitter {
1967
1947
  if (received.has(uid))
1968
1948
  continue;
1969
1949
  try {
1950
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
1970
1951
  this.db.deleteMessage(accountId, uid);
1971
- this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
1972
1952
  counters.deleted++;
1973
1953
  madeProgress = true;
1974
1954
  }
@@ -2051,8 +2031,8 @@ export class ImapManager extends EventEmitter {
2051
2031
  if (received.has(uid))
2052
2032
  continue;
2053
2033
  try {
2034
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2054
2035
  this.db.deleteMessage(accountId, uid);
2055
- this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
2056
2036
  counters.deleted++;
2057
2037
  madeProgress = true;
2058
2038
  }
@@ -2096,8 +2076,8 @@ export class ImapManager extends EventEmitter {
2096
2076
  const trash = this.findFolder(accountId, "trash");
2097
2077
  // Local first — remove all from DB immediately
2098
2078
  for (const msg of messages) {
2079
+ this.unlinkBodyFile(accountId, msg.uid, msg.folderId).catch(() => { });
2099
2080
  this.db.deleteMessage(accountId, msg.uid);
2100
- this.bodyStore.deleteMessage(accountId, msg.folderId, msg.uid).catch(() => { });
2101
2081
  }
2102
2082
  console.log(` Deleted ${messages.length} messages locally`);
2103
2083
  // Queue IMAP actions
@@ -2149,8 +2129,8 @@ export class ImapManager extends EventEmitter {
2149
2129
  async trashMessage(accountId, folderId, uid) {
2150
2130
  const trash = this.findFolder(accountId, "trash");
2151
2131
  // Local first — remove from DB immediately
2132
+ this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
2152
2133
  this.db.deleteMessage(accountId, uid);
2153
- this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
2154
2134
  // Queue IMAP action + log the resolution so "I deleted a message and
2155
2135
  // now it's in neither trash nor deleted" is diagnosable from the log.
2156
2136
  if (trash && trash.id !== folderId) {
@@ -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