@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
|
@@ -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?
|
|
1674
|
-
//
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
1677
|
-
//
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
52
|
+
async hasByPath(fullPath) {
|
|
53
|
+
if (!this.inStore(fullPath))
|
|
54
|
+
return false;
|
|
55
|
+
return fs.existsSync(fullPath);
|
|
34
56
|
}
|
|
35
|
-
async
|
|
36
|
-
|
|
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
|