@bobfrankston/mailx-store 0.1.2 → 0.1.5

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 (5) hide show
  1. package/db.d.ts +250 -4
  2. package/db.js +1392 -57
  3. package/file-store.d.ts +27 -13
  4. package/file-store.js +60 -39
  5. package/package.json +10 -9
package/file-store.d.ts CHANGED
@@ -1,22 +1,36 @@
1
1
  /**
2
2
  * File-per-message body storage backend.
3
- * Messages stored as: {basePath}/{accountId}/{folderName}/{uid}.eml
4
- * Legacy: also checks {basePath}/{accountId}/{folderId}/{uid}.eml
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
- private folderNames;
10
16
  constructor(basePath: string);
11
- /** Register folder ID path mapping for human-readable directory names */
12
- registerFolder(folderId: number, folderPath: string): void;
13
- private folderDir;
14
- private messagePath;
15
- /** Check legacy path (numeric folder ID) */
16
- private legacyPath;
17
- putMessage(accountId: string, folderId: number, uid: number, raw: Buffer): Promise<string>;
18
- getMessage(accountId: string, folderId: number, uid: number): Promise<Buffer>;
19
- deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;
20
- 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>;
21
35
  }
22
36
  //# sourceMappingURL=file-store.d.ts.map
package/file-store.js CHANGED
@@ -1,59 +1,80 @@
1
1
  /**
2
2
  * File-per-message body storage backend.
3
- * Messages stored as: {basePath}/{accountId}/{folderName}/{uid}.eml
4
- * Legacy: also checks {basePath}/{accountId}/{folderId}/{uid}.eml
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";
8
- /** Sanitize folder path for use as directory name — replace delimiters with _ */
9
- function sanitizeFolderName(folderPath) {
10
- return folderPath.replace(/[\/\\.:]/g, "_");
11
- }
15
+ import { randomUUID } from "node:crypto";
12
16
  export class FileMessageStore {
13
17
  basePath;
14
- folderNames = new Map();
15
18
  constructor(basePath) {
16
19
  this.basePath = basePath;
17
20
  fs.mkdirSync(basePath, { recursive: true });
18
21
  }
19
- /** Register folder ID path mapping for human-readable directory names */
20
- registerFolder(folderId, folderPath) {
21
- this.folderNames.set(folderId, sanitizeFolderName(folderPath));
22
- }
23
- folderDir(accountId, folderId) {
24
- const name = this.folderNames.get(folderId) || String(folderId);
25
- return path.join(this.basePath, accountId, name);
26
- }
27
- messagePath(accountId, folderId, uid) {
28
- return path.join(this.folderDir(accountId, 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`);
29
27
  }
30
- /** Check legacy path (numeric folder ID) */
31
- legacyPath(accountId, folderId, uid) {
32
- return path.join(this.basePath, accountId, String(folderId), `${uid}.eml`);
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);
33
35
  }
34
- async putMessage(accountId, folderId, uid, raw) {
35
- 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);
36
42
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
37
43
  fs.writeFileSync(filePath, raw);
38
44
  return filePath;
39
45
  }
40
- async getMessage(accountId, folderId, uid) {
41
- const filePath = this.messagePath(accountId, folderId, uid);
42
- if (fs.existsSync(filePath))
43
- return fs.readFileSync(filePath);
44
- // Fallback to legacy path
45
- const legacy = this.legacyPath(accountId, folderId, uid);
46
- return fs.readFileSync(legacy);
47
- }
48
- async deleteMessage(accountId, folderId, uid) {
49
- for (const p of [this.messagePath(accountId, folderId, uid), this.legacyPath(accountId, folderId, uid)]) {
50
- if (fs.existsSync(p))
51
- fs.unlinkSync(p);
52
- }
53
- }
54
- async hasMessage(accountId, folderId, uid) {
55
- return fs.existsSync(this.messagePath(accountId, folderId, uid)) ||
56
- fs.existsSync(this.legacyPath(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);
51
+ }
52
+ async hasByPath(fullPath) {
53
+ if (!this.inStore(fullPath))
54
+ return false;
55
+ return fs.existsSync(fullPath);
56
+ }
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)");
57
78
  }
58
79
  }
59
80
  //# sourceMappingURL=file-store.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,12 +9,8 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.3",
13
- "@bobfrankston/mailx-settings": "^0.1.2",
14
- "better-sqlite3": "^11.7.0"
15
- },
16
- "devDependencies": {
17
- "@types/better-sqlite3": "^7.6.12"
12
+ "@bobfrankston/mailx-types": "^0.1.5",
13
+ "@bobfrankston/mailx-settings": "^0.1.6"
18
14
  },
19
15
  "repository": {
20
16
  "type": "git",
@@ -22,7 +18,12 @@
22
18
  },
23
19
  ".dependencies": {
24
20
  "@bobfrankston/mailx-types": "file:../mailx-types",
25
- "@bobfrankston/mailx-settings": "file:../mailx-settings",
26
- "better-sqlite3": "^11.7.0"
21
+ "@bobfrankston/mailx-settings": "file:../mailx-settings"
22
+ },
23
+ ".transformedSnapshot": {
24
+ "dependencies": {
25
+ "@bobfrankston/mailx-types": "^0.1.5",
26
+ "@bobfrankston/mailx-settings": "^0.1.6"
27
+ }
27
28
  }
28
29
  }