@remit/mailbox-service 0.0.1
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/README.md +147 -0
- package/package.json +65 -0
- package/src/account-credentials.ts +128 -0
- package/src/adversarial-mime.e2e.test.ts +311 -0
- package/src/attribute-mapper.test.ts +78 -0
- package/src/attribute-mapper.ts +147 -0
- package/src/body-part-mapper.property.test.ts +354 -0
- package/src/body-part-mapper.ts +360 -0
- package/src/body-sync-queue.test.ts +124 -0
- package/src/body-sync-queue.ts +131 -0
- package/src/body-sync-terminal.test.ts +265 -0
- package/src/body-sync-terminal.ts +172 -0
- package/src/body-sync.ts +1202 -0
- package/src/connection-factory.ts +102 -0
- package/src/connection-test.ts +208 -0
- package/src/credentials.test.ts +181 -0
- package/src/filters/match.test.ts +181 -0
- package/src/filters/match.ts +143 -0
- package/src/filters/pipeline.ts +243 -0
- package/src/flag-push-terminal.test.ts +156 -0
- package/src/flag-push-terminal.ts +106 -0
- package/src/flag-push.test.ts +408 -0
- package/src/flag-push.ts +198 -0
- package/src/flag-queue.ts +500 -0
- package/src/heuristics/classifyByHeaders.test.ts +578 -0
- package/src/heuristics/classifyByHeaders.ts +369 -0
- package/src/heuristics/classifyPlacement.ts +96 -0
- package/src/heuristics/socialDomains.ts +23 -0
- package/src/heuristics/transactionalDomains.ts +26 -0
- package/src/imapflow-connect-retry.test.ts +108 -0
- package/src/imapflow-connection.e2e.test.ts +355 -0
- package/src/imapflow-connection.integ.test.ts +327 -0
- package/src/imapflow-connection.test.ts +161 -0
- package/src/imapflow-connection.ts +1374 -0
- package/src/index.ts +214 -0
- package/src/mailbox-cursor-rebuild.test.ts +171 -0
- package/src/mailbox-cursor-rebuild.ts +146 -0
- package/src/mailbox-cursor.test.ts +249 -0
- package/src/mailbox-cursor.ts +177 -0
- package/src/mailbox-management.test.ts +103 -0
- package/src/mailbox-management.ts +235 -0
- package/src/mailbox-queue.ts +258 -0
- package/src/mailbox-sync.test.ts +153 -0
- package/src/mailbox-sync.ts +590 -0
- package/src/message-move.ts +825 -0
- package/src/message-parser.ts +26 -0
- package/src/message-sync.ts +1021 -0
- package/src/mime-walker.test.ts +311 -0
- package/src/mime-walker.ts +305 -0
- package/src/outbox-queue.ts +294 -0
- package/src/pass-through-unit-of-work.ts +17 -0
- package/src/placement-move-terminal.test.ts +179 -0
- package/src/placement-move-terminal.ts +104 -0
- package/src/placement-move.ts +224 -0
- package/src/snippet.test.ts +213 -0
- package/src/snippet.ts +152 -0
- package/src/stale-message-reconcile.test.ts +82 -0
- package/src/stale-message-reconcile.ts +55 -0
- package/src/test-helpers/isolated-mailbox.ts +44 -0
- package/src/test-helpers/mailfuzz-connection.ts +27 -0
- package/src/test-helpers/mime-tree-generator.ts +461 -0
- package/src/text/normalizer.test.ts +223 -0
- package/src/text/normalizer.ts +140 -0
- package/src/types/mailparser-augment.d.ts +15 -0
- package/src/types/natural-porter-stemmer.d.ts +50 -0
- package/src/types/stopword.d.ts +69 -0
- package/src/types.ts +349 -0
- package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
- package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
- package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
- package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
- package/test/fixtures/mime/02-flat-text-html.eml +10 -0
- package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
- package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
- package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
- package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
- package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
- package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
- package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
- package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
- package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
- package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
- package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
- package/test/fixtures/mime/10-calendar-invite.eml +33 -0
- package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
- package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
- package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
- package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
- package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
- package/test/fixtures/mime/12-base64-binary.eml +20 -0
- package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
- package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
- package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
- package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
- package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
- package/test/fixtures/mime/15-empty-text-part.eml +18 -0
- package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
- package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
- package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
- package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it, mock } from "node:test";
|
|
3
|
+
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import { MailboxCursorState } from "@remit/domain-enums";
|
|
5
|
+
import {
|
|
6
|
+
guardConnectionCursor,
|
|
7
|
+
guardMailboxCursor,
|
|
8
|
+
isCursorRebuildNeeded,
|
|
9
|
+
MailboxCursorPausedError,
|
|
10
|
+
} from "./mailbox-cursor.js";
|
|
11
|
+
import type { IImapConnection, ImapBoxStatus } from "./types.js";
|
|
12
|
+
|
|
13
|
+
const mailboxId = "mbx-1";
|
|
14
|
+
const accountId = "acc-1";
|
|
15
|
+
|
|
16
|
+
const baseMailbox: Pick<MailboxItem, "mailboxId" | "uidValidity"> & {
|
|
17
|
+
cursorState: MailboxItem["cursorState"] | undefined;
|
|
18
|
+
} = {
|
|
19
|
+
mailboxId,
|
|
20
|
+
uidValidity: 100,
|
|
21
|
+
// A pre-migration row genuinely lacks this attribute at runtime despite the
|
|
22
|
+
// type saying it's total — see mailbox-cursor.ts for why `undefined` is
|
|
23
|
+
// accepted here.
|
|
24
|
+
cursorState: undefined,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("guardMailboxCursor", () => {
|
|
28
|
+
it("is ok and writes nothing when the served UIDVALIDITY matches the stored value", async () => {
|
|
29
|
+
const update = mock.fn(async () => ({}) as MailboxItem);
|
|
30
|
+
const mailboxService = {
|
|
31
|
+
update,
|
|
32
|
+
} as unknown as Pick<IMailboxRepository, "update">;
|
|
33
|
+
|
|
34
|
+
const result = await guardMailboxCursor(
|
|
35
|
+
{ mailboxService },
|
|
36
|
+
accountId,
|
|
37
|
+
baseMailbox,
|
|
38
|
+
100,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
assert.deepEqual(result, { ok: true });
|
|
42
|
+
assert.equal(update.mock.calls.length, 0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("treats an absent cursorState as normal", async () => {
|
|
46
|
+
const update = mock.fn(async () => ({}) as MailboxItem);
|
|
47
|
+
const mailboxService = { update } as unknown as Pick<
|
|
48
|
+
IMailboxRepository,
|
|
49
|
+
"update"
|
|
50
|
+
>;
|
|
51
|
+
|
|
52
|
+
const result = await guardMailboxCursor(
|
|
53
|
+
{ mailboxService },
|
|
54
|
+
accountId,
|
|
55
|
+
{ ...baseMailbox, cursorState: undefined },
|
|
56
|
+
100,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
assert.deepEqual(result, { ok: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("trips cursor_invalid and persists it when the served UIDVALIDITY disagrees", async () => {
|
|
63
|
+
const update = mock.fn(async () => ({}) as MailboxItem);
|
|
64
|
+
const mailboxService = { update } as unknown as Pick<
|
|
65
|
+
IMailboxRepository,
|
|
66
|
+
"update"
|
|
67
|
+
>;
|
|
68
|
+
|
|
69
|
+
const result = await guardMailboxCursor(
|
|
70
|
+
{ mailboxService },
|
|
71
|
+
accountId,
|
|
72
|
+
{ ...baseMailbox, uidValidity: 100 },
|
|
73
|
+
200,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(result, { ok: false, state: "cursor_invalid" });
|
|
77
|
+
assert.equal(update.mock.calls.length, 1);
|
|
78
|
+
assert.deepEqual(update.mock.calls[0].arguments, [
|
|
79
|
+
accountId,
|
|
80
|
+
mailboxId,
|
|
81
|
+
{ cursorState: MailboxCursorState.cursor_invalid },
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("short-circuits without a write when already cursor_invalid, even if UIDVALIDITY now matches", async () => {
|
|
86
|
+
const update = mock.fn(async () => ({}) as MailboxItem);
|
|
87
|
+
const mailboxService = { update } as unknown as Pick<
|
|
88
|
+
IMailboxRepository,
|
|
89
|
+
"update"
|
|
90
|
+
>;
|
|
91
|
+
|
|
92
|
+
const result = await guardMailboxCursor(
|
|
93
|
+
{ mailboxService },
|
|
94
|
+
accountId,
|
|
95
|
+
{ ...baseMailbox, cursorState: MailboxCursorState.cursor_invalid },
|
|
96
|
+
100,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
assert.deepEqual(result, { ok: false, state: "cursor_invalid" });
|
|
100
|
+
assert.equal(
|
|
101
|
+
update.mock.calls.length,
|
|
102
|
+
0,
|
|
103
|
+
"paused mailboxes must not be re-tripped on every call (frugal — invariant 6)",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("short-circuits without a write when rebuilding", async () => {
|
|
108
|
+
const update = mock.fn(async () => ({}) as MailboxItem);
|
|
109
|
+
const mailboxService = { update } as unknown as Pick<
|
|
110
|
+
IMailboxRepository,
|
|
111
|
+
"update"
|
|
112
|
+
>;
|
|
113
|
+
|
|
114
|
+
const result = await guardMailboxCursor(
|
|
115
|
+
{ mailboxService },
|
|
116
|
+
accountId,
|
|
117
|
+
{ ...baseMailbox, cursorState: MailboxCursorState.rebuilding },
|
|
118
|
+
999,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
assert.deepEqual(result, { ok: false, state: "rebuilding" });
|
|
122
|
+
assert.equal(update.mock.calls.length, 0);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("guardConnectionCursor", () => {
|
|
127
|
+
const fakeBoxStatus = (uidvalidity: number): ImapBoxStatus =>
|
|
128
|
+
({
|
|
129
|
+
uidvalidity,
|
|
130
|
+
}) as unknown as ImapBoxStatus;
|
|
131
|
+
|
|
132
|
+
it("delegates to the real openBox and returns normally when normal and matching", async () => {
|
|
133
|
+
const openBox = mock.fn(async () => fakeBoxStatus(100));
|
|
134
|
+
const connection = { openBox } as unknown as IImapConnection;
|
|
135
|
+
const update = mock.fn(async () => ({}) as MailboxItem);
|
|
136
|
+
const mailboxService = { update } as unknown as Pick<
|
|
137
|
+
IMailboxRepository,
|
|
138
|
+
"update"
|
|
139
|
+
>;
|
|
140
|
+
|
|
141
|
+
const guarded = guardConnectionCursor(
|
|
142
|
+
connection,
|
|
143
|
+
{ mailboxService },
|
|
144
|
+
accountId,
|
|
145
|
+
{ ...baseMailbox, uidValidity: 100 },
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const result = await guarded.openBox("INBOX", false);
|
|
149
|
+
|
|
150
|
+
assert.deepEqual(result, fakeBoxStatus(100));
|
|
151
|
+
assert.equal(openBox.mock.calls.length, 1);
|
|
152
|
+
assert.equal(update.mock.calls.length, 0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("throws MailboxCursorPausedError and never calls the real openBox when already paused", async () => {
|
|
156
|
+
const openBox = mock.fn(async () => fakeBoxStatus(100));
|
|
157
|
+
const connection = { openBox } as unknown as IImapConnection;
|
|
158
|
+
const mailboxService = {
|
|
159
|
+
update: mock.fn(async () => ({}) as MailboxItem),
|
|
160
|
+
} as unknown as Pick<IMailboxRepository, "update">;
|
|
161
|
+
|
|
162
|
+
const guarded = guardConnectionCursor(
|
|
163
|
+
connection,
|
|
164
|
+
{ mailboxService },
|
|
165
|
+
accountId,
|
|
166
|
+
{ ...baseMailbox, cursorState: MailboxCursorState.cursor_invalid },
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
await assert.rejects(
|
|
170
|
+
() => guarded.openBox("INBOX"),
|
|
171
|
+
(err: unknown) =>
|
|
172
|
+
err instanceof MailboxCursorPausedError &&
|
|
173
|
+
err.state === "cursor_invalid",
|
|
174
|
+
);
|
|
175
|
+
assert.equal(
|
|
176
|
+
openBox.mock.calls.length,
|
|
177
|
+
0,
|
|
178
|
+
"a mailbox already known paused must not touch the network (frugal — invariant 6)",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("trips and throws MailboxCursorPausedError when the served UIDVALIDITY disagrees", async () => {
|
|
183
|
+
const openBox = mock.fn(async () => fakeBoxStatus(200));
|
|
184
|
+
const connection = { openBox } as unknown as IImapConnection;
|
|
185
|
+
const update = mock.fn(async () => ({}) as MailboxItem);
|
|
186
|
+
const mailboxService = { update } as unknown as Pick<
|
|
187
|
+
IMailboxRepository,
|
|
188
|
+
"update"
|
|
189
|
+
>;
|
|
190
|
+
|
|
191
|
+
const guarded = guardConnectionCursor(
|
|
192
|
+
connection,
|
|
193
|
+
{ mailboxService },
|
|
194
|
+
accountId,
|
|
195
|
+
{ ...baseMailbox, uidValidity: 100 },
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await assert.rejects(
|
|
199
|
+
() => guarded.openBox("INBOX"),
|
|
200
|
+
(err: unknown) =>
|
|
201
|
+
err instanceof MailboxCursorPausedError &&
|
|
202
|
+
err.state === "cursor_invalid",
|
|
203
|
+
);
|
|
204
|
+
assert.equal(
|
|
205
|
+
openBox.mock.calls.length,
|
|
206
|
+
1,
|
|
207
|
+
"the real openBox must run once to learn the served UIDVALIDITY",
|
|
208
|
+
);
|
|
209
|
+
assert.equal(
|
|
210
|
+
update.mock.calls.length,
|
|
211
|
+
1,
|
|
212
|
+
"the mismatch must trip the mailbox",
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("passes other methods through untouched (e.g. fetchMessages)", async () => {
|
|
217
|
+
const fetchMessages = mock.fn(async () => []);
|
|
218
|
+
const connection = {
|
|
219
|
+
openBox: async () => fakeBoxStatus(100),
|
|
220
|
+
fetchMessages,
|
|
221
|
+
} as unknown as IImapConnection;
|
|
222
|
+
|
|
223
|
+
const guarded = guardConnectionCursor(
|
|
224
|
+
connection,
|
|
225
|
+
{ mailboxService: { update: async () => ({}) as MailboxItem } },
|
|
226
|
+
accountId,
|
|
227
|
+
{ ...baseMailbox, uidValidity: 100 },
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
await guarded.fetchMessages([1, 2, 3]);
|
|
231
|
+
assert.equal(fetchMessages.mock.calls.length, 1);
|
|
232
|
+
assert.deepEqual(fetchMessages.mock.calls[0].arguments, [[1, 2, 3]]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("isCursorRebuildNeeded", () => {
|
|
237
|
+
it("is false for undefined and normal", () => {
|
|
238
|
+
assert.equal(isCursorRebuildNeeded(undefined), false);
|
|
239
|
+
assert.equal(isCursorRebuildNeeded(MailboxCursorState.normal), false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("is true for cursor_invalid and rebuilding", () => {
|
|
243
|
+
assert.equal(
|
|
244
|
+
isCursorRebuildNeeded(MailboxCursorState.cursor_invalid),
|
|
245
|
+
true,
|
|
246
|
+
);
|
|
247
|
+
assert.equal(isCursorRebuildNeeded(MailboxCursorState.rebuilding), true);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
2
|
+
import { MailboxCursorState } from "@remit/domain-enums";
|
|
3
|
+
import type { IImapConnection, ImapBoxStatus } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detection + trip for the UIDVALIDITY cursor-integrity state machine (issue
|
|
7
|
+
* #1272, epic #1281 invariant 5): `normal -> cursor_invalid -> rebuilding ->
|
|
8
|
+
* normal`.
|
|
9
|
+
*
|
|
10
|
+
* A UIDVALIDITY bump invalidates every stored UID on a mailbox's axis
|
|
11
|
+
* (RFC 9051 Section 2.3.1.1) — `lastSyncUid`, `highWaterMarkUid`, and every
|
|
12
|
+
* per-message UID become meaningless the instant the server reports a
|
|
13
|
+
* different value than what is stored. The state machine is persisted (not a
|
|
14
|
+
* lock) because `MailboxLockService` only serializes one event type at a
|
|
15
|
+
* time (`(mailboxId, eventName)` is its primary key) — it does not stop a
|
|
16
|
+
* flag push from racing a sync round on the same mailbox. Every worker path
|
|
17
|
+
* that SELECTs a mailbox calls {@link guardMailboxCursor} with the served
|
|
18
|
+
* UIDVALIDITY right after opening the box, before issuing any command that
|
|
19
|
+
* references a stored UID.
|
|
20
|
+
*/
|
|
21
|
+
export type MailboxCursorCheck =
|
|
22
|
+
| { ok: true }
|
|
23
|
+
| {
|
|
24
|
+
ok: false;
|
|
25
|
+
/** The state the mailbox is (now) in — always non-`normal` here. */
|
|
26
|
+
state: "cursor_invalid" | "rebuilding";
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface MailboxCursorGuardDeps {
|
|
30
|
+
mailboxService: Pick<IMailboxRepository, "update">;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `cursorState` is total per RFC 032 (defaults to `normal`) — but that default
|
|
35
|
+
* only applies to rows written after this field existed. A row persisted
|
|
36
|
+
* before this migration (DynamoDB attribute never written / Postgres column
|
|
37
|
+
* genuinely NULL) reads back with the attribute absent despite the type
|
|
38
|
+
* saying otherwise, so every consumer here treats `undefined` the same as
|
|
39
|
+
* `normal` defensively rather than trusting the type.
|
|
40
|
+
*/
|
|
41
|
+
type MailboxCursorStateOrLegacyAbsent = MailboxItem["cursorState"] | undefined;
|
|
42
|
+
|
|
43
|
+
const isNormal = (state: MailboxCursorStateOrLegacyAbsent): boolean =>
|
|
44
|
+
state === undefined || state === MailboxCursorState.normal;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compare a mailbox's stored UIDVALIDITY against what the server just served
|
|
48
|
+
* (from an `openBox`/`STATUS` response) and act:
|
|
49
|
+
*
|
|
50
|
+
* - Already `cursor_invalid` or `rebuilding` — outbound IMAP is already
|
|
51
|
+
* paused for this mailbox; short-circuit without a write (frugal — epic
|
|
52
|
+
* #1281 invariant 6) and report why.
|
|
53
|
+
* - `normal` but the served value disagrees with the stored one — trip the
|
|
54
|
+
* mailbox to `cursor_invalid` (the only write this function performs) and
|
|
55
|
+
* report the pause. This is the detection edge: whichever worker path
|
|
56
|
+
* notices the mismatch first is the one that flips the switch.
|
|
57
|
+
* - `normal` and the values agree — proceed, nothing to do.
|
|
58
|
+
*
|
|
59
|
+
* Callers must treat a `{ ok: false }` result as an expected, routine pause
|
|
60
|
+
* (epic #1281 invariant 3), not a fault: log/metric and skip the outbound
|
|
61
|
+
* operation for this round rather than throwing.
|
|
62
|
+
*/
|
|
63
|
+
export const guardMailboxCursor = async (
|
|
64
|
+
deps: MailboxCursorGuardDeps,
|
|
65
|
+
accountId: string,
|
|
66
|
+
mailbox: Pick<MailboxItem, "mailboxId" | "uidValidity"> & {
|
|
67
|
+
cursorState: MailboxCursorStateOrLegacyAbsent;
|
|
68
|
+
},
|
|
69
|
+
servedUidValidity: number,
|
|
70
|
+
): Promise<MailboxCursorCheck> => {
|
|
71
|
+
if (!isNormal(mailbox.cursorState)) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
state:
|
|
75
|
+
mailbox.cursorState === MailboxCursorState.rebuilding
|
|
76
|
+
? "rebuilding"
|
|
77
|
+
: "cursor_invalid",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (mailbox.uidValidity !== servedUidValidity) {
|
|
82
|
+
await deps.mailboxService.update(accountId, mailbox.mailboxId, {
|
|
83
|
+
cursorState: MailboxCursorState.cursor_invalid,
|
|
84
|
+
});
|
|
85
|
+
return { ok: false, state: "cursor_invalid" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { ok: true };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** True when a mailbox row is in either non-`normal` cursor state. */
|
|
92
|
+
export const isCursorRebuildNeeded = (
|
|
93
|
+
cursorState: MailboxCursorStateOrLegacyAbsent,
|
|
94
|
+
): boolean =>
|
|
95
|
+
cursorState === MailboxCursorState.cursor_invalid ||
|
|
96
|
+
cursorState === MailboxCursorState.rebuilding;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Thrown by the `openBox` override on a {@link guardConnectionCursor}-wrapped
|
|
100
|
+
* connection when the mailbox cursor is (or just became) non-`normal`. Every
|
|
101
|
+
* caller must treat this as the routine, expected pause described on {@link
|
|
102
|
+
* guardMailboxCursor} — catch it around the outbound operation and skip
|
|
103
|
+
* (ack/log/return), never let it surface as an infrastructure fault.
|
|
104
|
+
*/
|
|
105
|
+
export class MailboxCursorPausedError extends Error {
|
|
106
|
+
constructor(readonly state: "cursor_invalid" | "rebuilding") {
|
|
107
|
+
super(`Mailbox cursor is ${state}`);
|
|
108
|
+
this.name = "MailboxCursorPausedError";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Wrap a live `IImapConnection` so its `openBox` is the single, structural
|
|
114
|
+
* choke point for UIDVALIDITY cursor guarding (epic #1281 invariants 3 & 5).
|
|
115
|
+
*
|
|
116
|
+
* Every outbound IMAP operation that touches a stored UID requires an
|
|
117
|
+
* `openBox`/SELECT first — that is an IMAP protocol requirement, not a
|
|
118
|
+
* convention — so gating there means a caller cannot reach `fetchMessages`,
|
|
119
|
+
* `addFlags`, `moveMessages`, `fetchMessageBody`, etc. against a paused
|
|
120
|
+
* mailbox's stale axis without going through the same wrapped `openBox` this
|
|
121
|
+
* function defines. A handler that forgets to call `guardMailboxCursor`
|
|
122
|
+
* manually can no longer skip the check by omission; wiring a connection
|
|
123
|
+
* through here makes that structural instead of per-callsite.
|
|
124
|
+
*
|
|
125
|
+
* - Already `cursor_invalid`/`rebuilding` — throws {@link
|
|
126
|
+
* MailboxCursorPausedError} immediately, without calling the real
|
|
127
|
+
* `openBox` (frugal — invariant 6, no network round-trip for a mailbox
|
|
128
|
+
* already known paused).
|
|
129
|
+
* - `normal` but the served UIDVALIDITY disagrees — trips the mailbox (via
|
|
130
|
+
* {@link guardMailboxCursor}) and throws the same error.
|
|
131
|
+
* - `normal` and it matches — delegates to the real `openBox` and returns
|
|
132
|
+
* normally.
|
|
133
|
+
*
|
|
134
|
+
* `mailbox` is a snapshot taken once by the caller (they already need it for
|
|
135
|
+
* `mailboxPath`) — not re-fetched per call, so this adds no extra read.
|
|
136
|
+
*/
|
|
137
|
+
export const guardConnectionCursor = (
|
|
138
|
+
connection: IImapConnection,
|
|
139
|
+
deps: MailboxCursorGuardDeps,
|
|
140
|
+
accountId: string,
|
|
141
|
+
mailbox: Pick<MailboxItem, "mailboxId" | "uidValidity"> & {
|
|
142
|
+
cursorState: MailboxCursorStateOrLegacyAbsent;
|
|
143
|
+
},
|
|
144
|
+
): IImapConnection => {
|
|
145
|
+
const guardedOpenBox = async (
|
|
146
|
+
mailboxPath: string,
|
|
147
|
+
readOnly?: boolean,
|
|
148
|
+
): Promise<ImapBoxStatus> => {
|
|
149
|
+
if (!isNormal(mailbox.cursorState)) {
|
|
150
|
+
throw new MailboxCursorPausedError(
|
|
151
|
+
mailbox.cursorState === MailboxCursorState.rebuilding
|
|
152
|
+
? "rebuilding"
|
|
153
|
+
: "cursor_invalid",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const boxStatus = await connection.openBox(mailboxPath, readOnly);
|
|
158
|
+
const check = await guardMailboxCursor(
|
|
159
|
+
deps,
|
|
160
|
+
accountId,
|
|
161
|
+
mailbox,
|
|
162
|
+
boxStatus.uidvalidity,
|
|
163
|
+
);
|
|
164
|
+
if (!check.ok) {
|
|
165
|
+
throw new MailboxCursorPausedError(check.state);
|
|
166
|
+
}
|
|
167
|
+
return boxStatus;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return new Proxy(connection, {
|
|
171
|
+
get(target, prop, receiver) {
|
|
172
|
+
if (prop === "openBox") return guardedOpenBox;
|
|
173
|
+
const value = Reflect.get(target, prop, receiver);
|
|
174
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
parseMailboxPath,
|
|
5
|
+
validateMailboxOperation,
|
|
6
|
+
validateMailboxPath,
|
|
7
|
+
} from "./mailbox-management.js";
|
|
8
|
+
|
|
9
|
+
describe("parseMailboxPath", () => {
|
|
10
|
+
it("parses simple path", () => {
|
|
11
|
+
const result = parseMailboxPath("INBOX");
|
|
12
|
+
assert.strictEqual(result.name, "INBOX");
|
|
13
|
+
assert.strictEqual(result.parent, null);
|
|
14
|
+
assert.strictEqual(result.depth, 1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("parses nested path with default delimiter", () => {
|
|
18
|
+
const result = parseMailboxPath("Work/Projects/ClientA");
|
|
19
|
+
assert.strictEqual(result.name, "ClientA");
|
|
20
|
+
assert.strictEqual(result.parent, "Work/Projects");
|
|
21
|
+
assert.strictEqual(result.depth, 3);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("parses nested path with custom delimiter", () => {
|
|
25
|
+
const result = parseMailboxPath("Work.Projects.ClientA", ".");
|
|
26
|
+
assert.strictEqual(result.name, "ClientA");
|
|
27
|
+
assert.strictEqual(result.parent, "Work.Projects");
|
|
28
|
+
assert.strictEqual(result.depth, 3);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles two-level path", () => {
|
|
32
|
+
const result = parseMailboxPath("Personal/Archive");
|
|
33
|
+
assert.strictEqual(result.name, "Archive");
|
|
34
|
+
assert.strictEqual(result.parent, "Personal");
|
|
35
|
+
assert.strictEqual(result.depth, 2);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("validateMailboxPath", () => {
|
|
40
|
+
it("throws on empty path", () => {
|
|
41
|
+
assert.throws(() => validateMailboxPath(""), {
|
|
42
|
+
message: "Mailbox path cannot be empty",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("throws on whitespace-only path", () => {
|
|
47
|
+
assert.throws(() => validateMailboxPath(" "), {
|
|
48
|
+
message: "Mailbox path cannot be empty",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("throws on double delimiters", () => {
|
|
53
|
+
assert.throws(() => validateMailboxPath("Work//Projects"), {
|
|
54
|
+
message: "Mailbox path cannot contain empty hierarchy levels",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("throws on leading delimiter", () => {
|
|
59
|
+
assert.throws(() => validateMailboxPath("/Work/Projects"), {
|
|
60
|
+
message: "Mailbox path cannot start or end with hierarchy delimiter",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("throws on trailing delimiter", () => {
|
|
65
|
+
assert.throws(() => validateMailboxPath("Work/Projects/"), {
|
|
66
|
+
message: "Mailbox path cannot start or end with hierarchy delimiter",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("accepts valid simple path", () => {
|
|
71
|
+
assert.doesNotThrow(() => validateMailboxPath("INBOX"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("accepts valid nested path", () => {
|
|
75
|
+
assert.doesNotThrow(() => validateMailboxPath("Work/Projects/ClientA"));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("validateMailboxOperation", () => {
|
|
80
|
+
it("throws when deleting INBOX", () => {
|
|
81
|
+
assert.throws(() => validateMailboxOperation("delete", "INBOX"), {
|
|
82
|
+
message: "Cannot delete INBOX",
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("throws when deleting INBOX (case insensitive)", () => {
|
|
87
|
+
assert.throws(() => validateMailboxOperation("delete", "inbox"), {
|
|
88
|
+
message: "Cannot delete INBOX",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("allows deleting other mailboxes", () => {
|
|
93
|
+
assert.doesNotThrow(() => validateMailboxOperation("delete", "Archive"));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("allows renaming INBOX", () => {
|
|
97
|
+
assert.doesNotThrow(() => validateMailboxOperation("rename", "INBOX"));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("allows renaming other mailboxes", () => {
|
|
101
|
+
assert.doesNotThrow(() => validateMailboxOperation("rename", "Archive"));
|
|
102
|
+
});
|
|
103
|
+
});
|