@bobfrankston/mailx-store 0.1.3 → 0.1.7

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 +282 -4
  2. package/db.js +1513 -57
  3. package/file-store.d.ts +45 -13
  4. package/file-store.js +108 -43
  5. package/package.json +13 -9
package/file-store.d.ts CHANGED
@@ -1,22 +1,54 @@
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, returned RELATIVE to `basePath`. The DB
18
+ * stores this relative form so the body store can be relocated (or
19
+ * the user folder renamed — e.g. `.mailx → .rmfmail`) without the
20
+ * body_path entries breaking. */
21
+ private newRelativePath;
22
+ /** Resolve a stored path. Accepts either a relative path (post-2026-05
23
+ * format) or an absolute path (legacy entries until next-launch
24
+ * migration rewrites them). Either way, refuse anything outside the
25
+ * store as a directory-traversal guard. */
26
+ private resolveStored;
27
+ /** Write a new body. Returns a path RELATIVE to basePath; caller stores
28
+ * it in `body_path`. The (folderId, uid) args are kept for interface
29
+ * compatibility; they do NOT affect the filename. */
30
+ putMessage(accountId: string, _folderId: number, _uid: number, raw: Buffer): Promise<string>;
31
+ /** Read by stored path (relative or absolute). */
32
+ readByPath(stored: string): Promise<Buffer>;
33
+ hasByPath(stored: string): Promise<boolean>;
34
+ unlinkByPath(stored: string): Promise<void>;
35
+ /** One-shot migration: rewrite absolute body_path values that point at
36
+ * this store's basePath into relative form. Called once from db.ts on
37
+ * startup (gated by a kv flag); silently skipped after first run.
38
+ * Returns the count of rewritten paths.
39
+ *
40
+ * Bodies whose absolute path is OUTSIDE basePath (the legacy
41
+ * `~/.mailx/mailxstore/...` entries on a machine where basePath is
42
+ * now `~/.rmfmail/mailxstore`) are left alone — the migration in
43
+ * mailx-settings already moves the files; this rewrite is purely
44
+ * about getting body_path to a relative shape. */
45
+ rewriteAbsoluteToRelative(rows: Array<{
46
+ id: number;
47
+ body_path: string;
48
+ }>, update: (id: number, newPath: string) => void): number;
49
+ getMessagePath(_accountId: string, _folderId: number, _uid: number): string;
50
+ getMessage(_accountId: string, _folderId: number, _uid: number): Promise<Buffer>;
51
+ hasMessage(_accountId: string, _folderId: number, _uid: number): Promise<boolean>;
52
+ deleteMessage(_accountId: string, _folderId: number, _uid: number): Promise<void>;
21
53
  }
22
54
  //# sourceMappingURL=file-store.d.ts.map
package/file-store.js CHANGED
@@ -1,59 +1,124 @@
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`);
29
- }
30
- /** Check legacy path (numeric folder ID) */
31
- legacyPath(accountId, folderId, uid) {
32
- return path.join(this.basePath, accountId, String(folderId), `${uid}.eml`);
33
- }
34
- async putMessage(accountId, folderId, uid, raw) {
35
- const filePath = this.messagePath(accountId, folderId, uid);
36
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
37
- fs.writeFileSync(filePath, raw);
38
- return filePath;
39
- }
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);
22
+ /** Fresh opaque path per call, returned RELATIVE to `basePath`. The DB
23
+ * stores this relative form so the body store can be relocated (or
24
+ * the user folder renamed — e.g. `.mailx → .rmfmail`) without the
25
+ * body_path entries breaking. */
26
+ newRelativePath(accountId) {
27
+ const uuid = randomUUID().replace(/-/g, "");
28
+ const prefix = uuid.slice(0, 2);
29
+ return path.join(accountId, prefix, `${uuid}.eml`);
30
+ }
31
+ /** Resolve a stored path. Accepts either a relative path (post-2026-05
32
+ * format) or an absolute path (legacy entries until next-launch
33
+ * migration rewrites them). Either way, refuse anything outside the
34
+ * store as a directory-traversal guard. */
35
+ resolveStored(p) {
36
+ if (!p)
37
+ return "";
38
+ const abs = path.isAbsolute(p) ? p : path.resolve(this.basePath, p);
39
+ const rel = path.relative(path.resolve(this.basePath), abs);
40
+ if (rel.startsWith("..") || path.isAbsolute(rel))
41
+ return "";
42
+ return abs;
43
+ }
44
+ /** Write a new body. Returns a path RELATIVE to basePath; caller stores
45
+ * it in `body_path`. The (folderId, uid) args are kept for interface
46
+ * compatibility; they do NOT affect the filename. */
47
+ async putMessage(accountId, _folderId, _uid, raw) {
48
+ const rel = this.newRelativePath(accountId);
49
+ const abs = path.join(this.basePath, rel);
50
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
51
+ fs.writeFileSync(abs, raw);
52
+ return rel;
53
+ }
54
+ /** Read by stored path (relative or absolute). */
55
+ async readByPath(stored) {
56
+ const abs = this.resolveStored(stored);
57
+ if (!abs)
58
+ throw new Error(`refusing to read outside store: ${stored}`);
59
+ return fs.readFileSync(abs);
60
+ }
61
+ async hasByPath(stored) {
62
+ const abs = this.resolveStored(stored);
63
+ return !!abs && fs.existsSync(abs);
64
+ }
65
+ async unlinkByPath(stored) {
66
+ const abs = this.resolveStored(stored);
67
+ if (!abs)
68
+ return;
69
+ if (fs.existsSync(abs))
70
+ fs.unlinkSync(abs);
71
+ }
72
+ /** One-shot migration: rewrite absolute body_path values that point at
73
+ * this store's basePath into relative form. Called once from db.ts on
74
+ * startup (gated by a kv flag); silently skipped after first run.
75
+ * Returns the count of rewritten paths.
76
+ *
77
+ * Bodies whose absolute path is OUTSIDE basePath (the legacy
78
+ * `~/.mailx/mailxstore/...` entries on a machine where basePath is
79
+ * now `~/.rmfmail/mailxstore`) are left alone — the migration in
80
+ * mailx-settings already moves the files; this rewrite is purely
81
+ * about getting body_path to a relative shape. */
82
+ rewriteAbsoluteToRelative(rows, update) {
83
+ let n = 0;
84
+ const baseAbs = path.resolve(this.basePath);
85
+ for (const r of rows) {
86
+ if (!r.body_path || !path.isAbsolute(r.body_path))
87
+ continue;
88
+ const rel = path.relative(baseAbs, path.resolve(r.body_path));
89
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
90
+ // Outside this store — try mapping `.mailx → .rmfmail` if the
91
+ // legacy dir was the source. Then re-test.
92
+ const remapped = r.body_path.replace(/[\\/]\.mailx[\\/]mailxstore[\\/]/i, `${path.sep}.rmfmail${path.sep}mailxstore${path.sep}`);
93
+ if (remapped !== r.body_path) {
94
+ const rel2 = path.relative(baseAbs, path.resolve(remapped));
95
+ if (!rel2.startsWith("..") && !path.isAbsolute(rel2)) {
96
+ update(r.id, rel2.replace(/\\/g, "/"));
97
+ n++;
98
+ }
99
+ }
100
+ continue;
101
+ }
102
+ update(r.id, rel.replace(/\\/g, "/"));
103
+ n++;
52
104
  }
105
+ return n;
106
+ }
107
+ // MessageStore interface compatibility (unused once all callers migrate to
108
+ // path-based reads). These used to compose {folderId}/{uid}.eml and they
109
+ // would resurrect the comingling bug if restored. Kept as throwing stubs
110
+ // so any accidental caller surfaces loudly rather than silently misbehave.
111
+ getMessagePath(_accountId, _folderId, _uid) {
112
+ throw new Error("FileMessageStore.getMessagePath is retired — read body_path from DB");
113
+ }
114
+ async getMessage(_accountId, _folderId, _uid) {
115
+ throw new Error("FileMessageStore.getMessage(folder,uid) is retired — use readByPath(body_path)");
116
+ }
117
+ async hasMessage(_accountId, _folderId, _uid) {
118
+ throw new Error("FileMessageStore.hasMessage(folder,uid) is retired — use hasByPath(body_path)");
53
119
  }
54
- async hasMessage(accountId, folderId, uid) {
55
- return fs.existsSync(this.messagePath(accountId, folderId, uid)) ||
56
- fs.existsSync(this.legacyPath(accountId, folderId, uid));
120
+ async deleteMessage(_accountId, _folderId, _uid) {
121
+ throw new Error("FileMessageStore.deleteMessage(folder,uid) is retired — use unlinkByPath(body_path)");
57
122
  }
58
123
  }
59
124
  //# 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.3",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,20 +9,24 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.3",
13
- "@bobfrankston/mailx-settings": "^0.1.4",
14
- "better-sqlite3": "^11.7.0"
15
- },
16
- "devDependencies": {
17
- "@types/better-sqlite3": "^7.6.12"
12
+ "@bobfrankston/mailx-types": "^0.1.6",
13
+ "@bobfrankston/mailx-settings": "^0.1.7"
18
14
  },
19
15
  "repository": {
20
16
  "type": "git",
21
17
  "url": "https://github.com/BobFrankston/mailx-store.git"
22
18
  },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
23
22
  ".dependencies": {
24
23
  "@bobfrankston/mailx-types": "file:../mailx-types",
25
- "@bobfrankston/mailx-settings": "file:../mailx-settings",
26
- "better-sqlite3": "^11.7.0"
24
+ "@bobfrankston/mailx-settings": "file:../mailx-settings"
25
+ },
26
+ ".transformedSnapshot": {
27
+ "dependencies": {
28
+ "@bobfrankston/mailx-types": "^0.1.6",
29
+ "@bobfrankston/mailx-settings": "^0.1.7"
30
+ }
27
31
  }
28
32
  }