@bobfrankston/mailx 1.0.360 → 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 +1 -1
- package/packages/mailx-imap/index.d.ts +4 -0
- package/packages/mailx-imap/index.js +47 -48
- package/packages/mailx-service/index.js +14 -0
- package/packages/mailx-store/db.d.ts +14 -0
- package/packages/mailx-store/db.js +62 -0
- package/packages/mailx-store/file-store.d.ts +27 -9
- package/packages/mailx-store/file-store.js +58 -17
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. */
|
|
@@ -689,6 +689,14 @@ export class ImapManager extends EventEmitter {
|
|
|
689
689
|
}
|
|
690
690
|
if (msg.uid <= highestUid)
|
|
691
691
|
continue; // already have it
|
|
692
|
+
// Tombstone check: if the user locally deleted this Message-ID,
|
|
693
|
+
// don't re-import it. Server-side EXPUNGE may lag, or reconcile
|
|
694
|
+
// may find the message in an old list snapshot. Without this,
|
|
695
|
+
// deleted messages reappear on the next sync pass.
|
|
696
|
+
if (msg.messageId && this.db.hasTombstone(accountId, msg.messageId)) {
|
|
697
|
+
console.log(` [tombstone] ${accountId}: skipping ${msg.messageId} (locally deleted)`);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
692
700
|
const source = msg.source || "";
|
|
693
701
|
let bodyPath = "";
|
|
694
702
|
let preview = "";
|
|
@@ -931,8 +939,9 @@ export class ImapManager extends EventEmitter {
|
|
|
931
939
|
const localUids = this.db.getUidsForFolder(accountId, folderId);
|
|
932
940
|
for (const uid of localUids) {
|
|
933
941
|
if (!serverUids.has(uid)) {
|
|
942
|
+
// Read body_path BEFORE deleting the row, then unlink.
|
|
943
|
+
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
934
944
|
this.db.deleteMessage(accountId, uid);
|
|
935
|
-
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
936
945
|
deletedCount++;
|
|
937
946
|
}
|
|
938
947
|
}
|
|
@@ -1265,8 +1274,8 @@ export class ImapManager extends EventEmitter {
|
|
|
1265
1274
|
}
|
|
1266
1275
|
else {
|
|
1267
1276
|
for (const uid of toDelete) {
|
|
1277
|
+
this.unlinkBodyFile(accountId, uid, folder.id).catch(() => { });
|
|
1268
1278
|
this.db.deleteMessage(accountId, uid);
|
|
1269
|
-
this.bodyStore.deleteMessage(accountId, folder.id, uid).catch(() => { });
|
|
1270
1279
|
}
|
|
1271
1280
|
if (toDelete.length > 0)
|
|
1272
1281
|
console.log(` [api] ${accountId}/${folder.path}: ${toDelete.length} deleted`);
|
|
@@ -1581,6 +1590,17 @@ export class ImapManager extends EventEmitter {
|
|
|
1581
1590
|
this.syncIntervals.set("prefetch", prefetchInterval);
|
|
1582
1591
|
console.log(` [periodic] body prefetch every 60s (independent of sync)`);
|
|
1583
1592
|
}
|
|
1593
|
+
// Tombstone prune: age out local-delete records older than 30 days.
|
|
1594
|
+
// Runs hourly — cheap (one indexed DELETE).
|
|
1595
|
+
const TOMBSTONE_RETENTION_DAYS = 30;
|
|
1596
|
+
const pruneTombstones = () => {
|
|
1597
|
+
const cutoff = Date.now() - TOMBSTONE_RETENTION_DAYS * 86400_000;
|
|
1598
|
+
const n = this.db.pruneTombstones(cutoff);
|
|
1599
|
+
if (n > 0)
|
|
1600
|
+
console.log(` [tombstones] pruned ${n} older than ${TOMBSTONE_RETENTION_DAYS} days`);
|
|
1601
|
+
};
|
|
1602
|
+
setTimeout(pruneTombstones, 30_000); // first run after startup settles
|
|
1603
|
+
this.syncIntervals.set("tombstone-prune", setInterval(pruneTombstones, 3600_000));
|
|
1584
1604
|
// Full sync (all folders + IDLE restart) at configured interval
|
|
1585
1605
|
const fullInterval = setInterval(async () => {
|
|
1586
1606
|
console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
|
|
@@ -1641,6 +1661,18 @@ export class ImapManager extends EventEmitter {
|
|
|
1641
1661
|
* The persistent fetchClient can only handle one command at a time (IMAP protocol limitation). */
|
|
1642
1662
|
fetchQueues = new Map();
|
|
1643
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
|
+
}
|
|
1644
1676
|
enqueueFetch(accountId, fn) {
|
|
1645
1677
|
const prev = this.fetchQueues.get(accountId) || Promise.resolve();
|
|
1646
1678
|
const next = prev.then(fn, fn); // run fn after previous completes (regardless of success/failure)
|
|
@@ -1651,48 +1683,15 @@ export class ImapManager extends EventEmitter {
|
|
|
1651
1683
|
/** Fetch a single message body on demand, caching in the store.
|
|
1652
1684
|
* Uses its own fresh connection — never blocked by background prefetch. */
|
|
1653
1685
|
async fetchMessageBody(accountId, folderId, uid) {
|
|
1654
|
-
// Already cached?
|
|
1655
|
-
//
|
|
1656
|
-
//
|
|
1657
|
-
//
|
|
1658
|
-
//
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
// different message — the on-disk .eml becomes stale but hasMessage()
|
|
1664
|
-
// still returns true. User-reported: "Peter Hoddie letter comingled
|
|
1665
|
-
// with a much older letter." Check fixes it regardless of root cause.
|
|
1666
|
-
const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
|
|
1667
|
-
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1668
|
-
const expectedId = envelope?.messageId || "";
|
|
1669
|
-
if (expectedId) {
|
|
1670
|
-
// Scan headers only — Message-ID should land in the first few KB.
|
|
1671
|
-
const head = cached.subarray(0, Math.min(cached.length, 16 * 1024)).toString("utf-8");
|
|
1672
|
-
const m = head.match(/^Message-ID:\s*<([^>\r\n]+)>/im);
|
|
1673
|
-
const cachedId = m ? `<${m[1]}>` : "";
|
|
1674
|
-
if (cachedId && expectedId && cachedId !== expectedId) {
|
|
1675
|
-
console.error(` [body] COMINGLING DETECTED ${accountId}/${folderId}/${uid}: expected ${expectedId}, cached ${cachedId} — dropping cache, re-fetching`);
|
|
1676
|
-
try {
|
|
1677
|
-
await this.bodyStore.deleteMessage(accountId, folderId, uid);
|
|
1678
|
-
}
|
|
1679
|
-
catch { /* */ }
|
|
1680
|
-
// fall through to re-fetch path
|
|
1681
|
-
}
|
|
1682
|
-
else {
|
|
1683
|
-
const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
|
|
1684
|
-
if (existingPath)
|
|
1685
|
-
this.db.updateBodyPath(accountId, uid, existingPath);
|
|
1686
|
-
return cached;
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
else {
|
|
1690
|
-
// No messageId on the DB row (shouldn't happen but be permissive).
|
|
1691
|
-
const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
|
|
1692
|
-
if (existingPath)
|
|
1693
|
-
this.db.updateBodyPath(accountId, uid, existingPath);
|
|
1694
|
-
return cached;
|
|
1695
|
-
}
|
|
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);
|
|
1696
1695
|
}
|
|
1697
1696
|
if (!this.configs.has(accountId))
|
|
1698
1697
|
return null;
|
|
@@ -1948,8 +1947,8 @@ export class ImapManager extends EventEmitter {
|
|
|
1948
1947
|
if (received.has(uid))
|
|
1949
1948
|
continue;
|
|
1950
1949
|
try {
|
|
1950
|
+
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
1951
1951
|
this.db.deleteMessage(accountId, uid);
|
|
1952
|
-
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
1953
1952
|
counters.deleted++;
|
|
1954
1953
|
madeProgress = true;
|
|
1955
1954
|
}
|
|
@@ -2032,8 +2031,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2032
2031
|
if (received.has(uid))
|
|
2033
2032
|
continue;
|
|
2034
2033
|
try {
|
|
2034
|
+
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
2035
2035
|
this.db.deleteMessage(accountId, uid);
|
|
2036
|
-
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
2037
2036
|
counters.deleted++;
|
|
2038
2037
|
madeProgress = true;
|
|
2039
2038
|
}
|
|
@@ -2077,8 +2076,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2077
2076
|
const trash = this.findFolder(accountId, "trash");
|
|
2078
2077
|
// Local first — remove all from DB immediately
|
|
2079
2078
|
for (const msg of messages) {
|
|
2079
|
+
this.unlinkBodyFile(accountId, msg.uid, msg.folderId).catch(() => { });
|
|
2080
2080
|
this.db.deleteMessage(accountId, msg.uid);
|
|
2081
|
-
this.bodyStore.deleteMessage(accountId, msg.folderId, msg.uid).catch(() => { });
|
|
2082
2081
|
}
|
|
2083
2082
|
console.log(` Deleted ${messages.length} messages locally`);
|
|
2084
2083
|
// Queue IMAP actions
|
|
@@ -2130,8 +2129,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2130
2129
|
async trashMessage(accountId, folderId, uid) {
|
|
2131
2130
|
const trash = this.findFolder(accountId, "trash");
|
|
2132
2131
|
// Local first — remove from DB immediately
|
|
2132
|
+
this.unlinkBodyFile(accountId, uid, folderId).catch(() => { });
|
|
2133
2133
|
this.db.deleteMessage(accountId, uid);
|
|
2134
|
-
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
2135
2134
|
// Queue IMAP action + log the resolution so "I deleted a message and
|
|
2136
2135
|
// now it's in neither trash nor deleted" is diagnosable from the log.
|
|
2137
2136
|
if (trash && trash.id !== folderId) {
|
|
@@ -646,6 +646,11 @@ export class MailxService {
|
|
|
646
646
|
const envelope = this.db.getMessageByUid(accountId, uid);
|
|
647
647
|
if (!envelope)
|
|
648
648
|
throw new Error("Message not found");
|
|
649
|
+
// Tombstone the Message-ID so a subsequent sync pass can't resurrect
|
|
650
|
+
// the row if the server's EXPUNGE hasn't propagated yet. `undelete`
|
|
651
|
+
// removes the tombstone.
|
|
652
|
+
if (envelope.messageId)
|
|
653
|
+
this.db.addTombstone(accountId, envelope.messageId, envelope.subject || "");
|
|
649
654
|
await this.imapManager.trashMessage(accountId, envelope.folderId, envelope.uid);
|
|
650
655
|
}
|
|
651
656
|
async deleteMessages(accountId, uids) {
|
|
@@ -653,6 +658,9 @@ export class MailxService {
|
|
|
653
658
|
const env = this.db.getMessageByUid(accountId, uid);
|
|
654
659
|
if (!env)
|
|
655
660
|
return null;
|
|
661
|
+
// Tombstone each — same reason as single-delete above.
|
|
662
|
+
if (env.messageId)
|
|
663
|
+
this.db.addTombstone(accountId, env.messageId, env.subject || "");
|
|
656
664
|
return { uid: env.uid, folderId: env.folderId };
|
|
657
665
|
}).filter(m => m !== null);
|
|
658
666
|
await this.imapManager.trashMessages(accountId, messages);
|
|
@@ -711,6 +719,12 @@ export class MailxService {
|
|
|
711
719
|
return { targetFolderId: target.id, moved: uids.length };
|
|
712
720
|
}
|
|
713
721
|
async undeleteMessage(accountId, uid, folderId) {
|
|
722
|
+
// Clear the tombstone first so a subsequent sync can re-import if
|
|
723
|
+
// the server still has the row. Messages with no Message-ID just
|
|
724
|
+
// didn't get a tombstone — this is a no-op for them.
|
|
725
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
726
|
+
if (envelope?.messageId)
|
|
727
|
+
this.db.removeTombstone(accountId, envelope.messageId);
|
|
714
728
|
await this.imapManager.undeleteMessage(accountId, uid, folderId);
|
|
715
729
|
}
|
|
716
730
|
async deleteOnServer(accountId, folderPath, uid) {
|
|
@@ -12,6 +12,20 @@ export declare class MailxDB {
|
|
|
12
12
|
hasSentMessage(messageId: string): boolean;
|
|
13
13
|
/** Record a successfully sent message so future attempts are skipped. */
|
|
14
14
|
recordSent(messageId: string, accountId: string, subject: string, recipients: string[]): void;
|
|
15
|
+
/** Mark a Message-ID as locally-deleted for an account. No-op if messageId
|
|
16
|
+
* is empty (e.g. provider stripped the header) — without a stable id we
|
|
17
|
+
* can't check against future sync results anyway. */
|
|
18
|
+
addTombstone(accountId: string, messageId: string, subject?: string): void;
|
|
19
|
+
/** Is this Message-ID tombstoned for this account? */
|
|
20
|
+
hasTombstone(accountId: string, messageId: string): boolean;
|
|
21
|
+
/** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
|
|
22
|
+
* re-imports the message as normal. Also lets the user recover from a
|
|
23
|
+
* mistaken local delete. */
|
|
24
|
+
removeTombstone(accountId: string, messageId: string): void;
|
|
25
|
+
/** Age-out tombstones older than the given cutoff. Keeps the table from
|
|
26
|
+
* growing unboundedly. Default retention is 30 days; caller passes the
|
|
27
|
+
* actual cutoff in ms since epoch. */
|
|
28
|
+
pruneTombstones(olderThanMs: number): number;
|
|
15
29
|
/** Idempotently add a column to a table if it's missing. */
|
|
16
30
|
private addColumnIfMissing;
|
|
17
31
|
/** Compute a thread id for an incoming message. Strategy:
|
|
@@ -126,6 +126,21 @@ const SCHEMA = `
|
|
|
126
126
|
last_error TEXT,
|
|
127
127
|
UNIQUE(account_id, action, uid, folder_id)
|
|
128
128
|
);
|
|
129
|
+
|
|
130
|
+
-- Tombstones: messages the user deleted locally. Sync checks this table
|
|
131
|
+
-- before inserting a new row so a server-side delete that hasn't yet
|
|
132
|
+
-- propagated (or a stale server listing during the EXPUNGE race) can't
|
|
133
|
+
-- resurrect a message the user already removed. Keyed by Message-ID
|
|
134
|
+
-- because that's the only identifier stable across UID renumbers,
|
|
135
|
+
-- UIDVALIDITY bumps, and cross-folder moves.
|
|
136
|
+
CREATE TABLE IF NOT EXISTS tombstones (
|
|
137
|
+
account_id TEXT NOT NULL,
|
|
138
|
+
message_id TEXT NOT NULL,
|
|
139
|
+
deleted_at INTEGER NOT NULL,
|
|
140
|
+
subject TEXT DEFAULT '',
|
|
141
|
+
PRIMARY KEY (account_id, message_id)
|
|
142
|
+
);
|
|
143
|
+
CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
|
|
129
144
|
`;
|
|
130
145
|
export class MailxDB {
|
|
131
146
|
db;
|
|
@@ -172,6 +187,53 @@ export class MailxDB {
|
|
|
172
187
|
console.error(` [sent_log] failed to record ${messageId}: ${e.message}`);
|
|
173
188
|
}
|
|
174
189
|
}
|
|
190
|
+
// ── Tombstones (local-delete record so server echo can't resurrect) ──
|
|
191
|
+
/** Mark a Message-ID as locally-deleted for an account. No-op if messageId
|
|
192
|
+
* is empty (e.g. provider stripped the header) — without a stable id we
|
|
193
|
+
* can't check against future sync results anyway. */
|
|
194
|
+
addTombstone(accountId, messageId, subject = "") {
|
|
195
|
+
if (!messageId)
|
|
196
|
+
return;
|
|
197
|
+
try {
|
|
198
|
+
this.db.prepare("INSERT INTO tombstones (account_id, message_id, deleted_at, subject) VALUES (?, ?, ?, ?) ON CONFLICT(account_id, message_id) DO UPDATE SET deleted_at = excluded.deleted_at").run(accountId, messageId, Date.now(), subject || "");
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
console.error(` [tombstones] failed to record ${messageId}: ${e.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Is this Message-ID tombstoned for this account? */
|
|
205
|
+
hasTombstone(accountId, messageId) {
|
|
206
|
+
if (!messageId)
|
|
207
|
+
return false;
|
|
208
|
+
const row = this.db.prepare("SELECT 1 FROM tombstones WHERE account_id = ? AND message_id = ? LIMIT 1").get(accountId, messageId);
|
|
209
|
+
return !!row;
|
|
210
|
+
}
|
|
211
|
+
/** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
|
|
212
|
+
* re-imports the message as normal. Also lets the user recover from a
|
|
213
|
+
* mistaken local delete. */
|
|
214
|
+
removeTombstone(accountId, messageId) {
|
|
215
|
+
if (!messageId)
|
|
216
|
+
return;
|
|
217
|
+
try {
|
|
218
|
+
this.db.prepare("DELETE FROM tombstones WHERE account_id = ? AND message_id = ?").run(accountId, messageId);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
console.error(` [tombstones] failed to remove ${messageId}: ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Age-out tombstones older than the given cutoff. Keeps the table from
|
|
225
|
+
* growing unboundedly. Default retention is 30 days; caller passes the
|
|
226
|
+
* actual cutoff in ms since epoch. */
|
|
227
|
+
pruneTombstones(olderThanMs) {
|
|
228
|
+
try {
|
|
229
|
+
const res = this.db.prepare("DELETE FROM tombstones WHERE deleted_at < ?").run(olderThanMs);
|
|
230
|
+
return Number(res.changes || 0);
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
console.error(` [tombstones] prune failed: ${e.message}`);
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
175
237
|
/** Idempotently add a column to a table if it's missing. */
|
|
176
238
|
addColumnIfMissing(table, column, sqlType) {
|
|
177
239
|
try {
|
|
@@ -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
|