@bobfrankston/rmfmail 1.0.503 → 1.0.504

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.
@@ -21,46 +21,91 @@ export class FileMessageStore implements MessageStore {
21
21
  fs.mkdirSync(basePath, { recursive: true });
22
22
  }
23
23
 
24
- /** Fresh opaque path per call. No inputs from the caller affect the name. */
25
- private newMessagePath(accountId: string): string {
24
+ /** Fresh opaque path per call, returned RELATIVE to `basePath`. The DB
25
+ * stores this relative form so the body store can be relocated (or
26
+ * the user folder renamed — e.g. `.mailx → .rmfmail`) without the
27
+ * body_path entries breaking. */
28
+ private newRelativePath(accountId: string): string {
26
29
  const uuid = randomUUID().replace(/-/g, "");
27
30
  const prefix = uuid.slice(0, 2);
28
- return path.join(this.basePath, accountId, prefix, `${uuid}.eml`);
31
+ return path.join(accountId, prefix, `${uuid}.eml`);
29
32
  }
30
33
 
31
- /** Verify a given path resolves inside this store's basePath — refuses
32
- * any value that doesn't (cheap directory-traversal guard). */
33
- private inStore(fullPath: string): boolean {
34
- if (!fullPath) return false;
35
- const rel = path.relative(path.resolve(this.basePath), path.resolve(fullPath));
36
- return !rel.startsWith("..") && !path.isAbsolute(rel);
34
+ /** Resolve a stored path. Accepts either a relative path (post-2026-05
35
+ * format) or an absolute path (legacy entries until next-launch
36
+ * migration rewrites them). Either way, refuse anything outside the
37
+ * store as a directory-traversal guard. */
38
+ private resolveStored(p: string): string {
39
+ if (!p) return "";
40
+ const abs = path.isAbsolute(p) ? p : path.resolve(this.basePath, p);
41
+ const rel = path.relative(path.resolve(this.basePath), abs);
42
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return "";
43
+ return abs;
37
44
  }
38
45
 
39
- /** Write a new body. Always a fresh UUID path. Caller MUST persist the
40
- * returned path in the DB (`body_path`) and use it for all reads. The
41
- * (folderId, uid) args are kept for interface compatibility; they do
42
- * NOT affect the filename. */
46
+ /** Write a new body. Returns a path RELATIVE to basePath; caller stores
47
+ * it in `body_path`. The (folderId, uid) args are kept for interface
48
+ * compatibility; they do NOT affect the filename. */
43
49
  async putMessage(accountId: string, _folderId: number, _uid: number, raw: Buffer): Promise<string> {
44
- const filePath = this.newMessagePath(accountId);
45
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
46
- fs.writeFileSync(filePath, raw);
47
- return filePath;
50
+ const rel = this.newRelativePath(accountId);
51
+ const abs = path.join(this.basePath, rel);
52
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
53
+ fs.writeFileSync(abs, raw);
54
+ return rel;
48
55
  }
49
56
 
50
- /** Read by absolute path (DB `body_path`). The primary read API. */
51
- async readByPath(fullPath: string): Promise<Buffer> {
52
- if (!this.inStore(fullPath)) throw new Error(`refusing to read outside store: ${fullPath}`);
53
- return fs.readFileSync(fullPath);
57
+ /** Read by stored path (relative or absolute). */
58
+ async readByPath(stored: string): Promise<Buffer> {
59
+ const abs = this.resolveStored(stored);
60
+ if (!abs) throw new Error(`refusing to read outside store: ${stored}`);
61
+ return fs.readFileSync(abs);
54
62
  }
55
63
 
56
- async hasByPath(fullPath: string): Promise<boolean> {
57
- if (!this.inStore(fullPath)) return false;
58
- return fs.existsSync(fullPath);
64
+ async hasByPath(stored: string): Promise<boolean> {
65
+ const abs = this.resolveStored(stored);
66
+ return !!abs && fs.existsSync(abs);
59
67
  }
60
68
 
61
- async unlinkByPath(fullPath: string): Promise<void> {
62
- if (!this.inStore(fullPath)) return;
63
- if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath);
69
+ async unlinkByPath(stored: string): Promise<void> {
70
+ const abs = this.resolveStored(stored);
71
+ if (!abs) return;
72
+ if (fs.existsSync(abs)) fs.unlinkSync(abs);
73
+ }
74
+
75
+ /** One-shot migration: rewrite absolute body_path values that point at
76
+ * this store's basePath into relative form. Called once from db.ts on
77
+ * startup (gated by a kv flag); silently skipped after first run.
78
+ * Returns the count of rewritten paths.
79
+ *
80
+ * Bodies whose absolute path is OUTSIDE basePath (the legacy
81
+ * `~/.mailx/mailxstore/...` entries on a machine where basePath is
82
+ * now `~/.rmfmail/mailxstore`) are left alone — the migration in
83
+ * mailx-settings already moves the files; this rewrite is purely
84
+ * about getting body_path to a relative shape. */
85
+ rewriteAbsoluteToRelative(rows: Array<{ id: number; body_path: string }>,
86
+ update: (id: number, newPath: string) => void): number {
87
+ let n = 0;
88
+ const baseAbs = path.resolve(this.basePath);
89
+ for (const r of rows) {
90
+ if (!r.body_path || !path.isAbsolute(r.body_path)) continue;
91
+ const rel = path.relative(baseAbs, path.resolve(r.body_path));
92
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
93
+ // Outside this store — try mapping `.mailx → .rmfmail` if the
94
+ // legacy dir was the source. Then re-test.
95
+ const remapped = r.body_path.replace(/[\\/]\.mailx[\\/]mailxstore[\\/]/i, `${path.sep}.rmfmail${path.sep}mailxstore${path.sep}`);
96
+ if (remapped !== r.body_path) {
97
+ const rel2 = path.relative(baseAbs, path.resolve(remapped));
98
+ if (!rel2.startsWith("..") && !path.isAbsolute(rel2)) {
99
+ update(r.id, rel2.replace(/\\/g, "/"));
100
+ n++;
101
+ }
102
+ }
103
+ continue;
104
+ }
105
+ update(r.id, rel.replace(/\\/g, "/"));
106
+ n++;
107
+ }
108
+ return n;
64
109
  }
65
110
 
66
111
  // MessageStore interface compatibility (unused once all callers migrate to