@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.
Files changed (124) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/src/account-credentials.ts +128 -0
  4. package/src/adversarial-mime.e2e.test.ts +311 -0
  5. package/src/attribute-mapper.test.ts +78 -0
  6. package/src/attribute-mapper.ts +147 -0
  7. package/src/body-part-mapper.property.test.ts +354 -0
  8. package/src/body-part-mapper.ts +360 -0
  9. package/src/body-sync-queue.test.ts +124 -0
  10. package/src/body-sync-queue.ts +131 -0
  11. package/src/body-sync-terminal.test.ts +265 -0
  12. package/src/body-sync-terminal.ts +172 -0
  13. package/src/body-sync.ts +1202 -0
  14. package/src/connection-factory.ts +102 -0
  15. package/src/connection-test.ts +208 -0
  16. package/src/credentials.test.ts +181 -0
  17. package/src/filters/match.test.ts +181 -0
  18. package/src/filters/match.ts +143 -0
  19. package/src/filters/pipeline.ts +243 -0
  20. package/src/flag-push-terminal.test.ts +156 -0
  21. package/src/flag-push-terminal.ts +106 -0
  22. package/src/flag-push.test.ts +408 -0
  23. package/src/flag-push.ts +198 -0
  24. package/src/flag-queue.ts +500 -0
  25. package/src/heuristics/classifyByHeaders.test.ts +578 -0
  26. package/src/heuristics/classifyByHeaders.ts +369 -0
  27. package/src/heuristics/classifyPlacement.ts +96 -0
  28. package/src/heuristics/socialDomains.ts +23 -0
  29. package/src/heuristics/transactionalDomains.ts +26 -0
  30. package/src/imapflow-connect-retry.test.ts +108 -0
  31. package/src/imapflow-connection.e2e.test.ts +355 -0
  32. package/src/imapflow-connection.integ.test.ts +327 -0
  33. package/src/imapflow-connection.test.ts +161 -0
  34. package/src/imapflow-connection.ts +1374 -0
  35. package/src/index.ts +214 -0
  36. package/src/mailbox-cursor-rebuild.test.ts +171 -0
  37. package/src/mailbox-cursor-rebuild.ts +146 -0
  38. package/src/mailbox-cursor.test.ts +249 -0
  39. package/src/mailbox-cursor.ts +177 -0
  40. package/src/mailbox-management.test.ts +103 -0
  41. package/src/mailbox-management.ts +235 -0
  42. package/src/mailbox-queue.ts +258 -0
  43. package/src/mailbox-sync.test.ts +153 -0
  44. package/src/mailbox-sync.ts +590 -0
  45. package/src/message-move.ts +825 -0
  46. package/src/message-parser.ts +26 -0
  47. package/src/message-sync.ts +1021 -0
  48. package/src/mime-walker.test.ts +311 -0
  49. package/src/mime-walker.ts +305 -0
  50. package/src/outbox-queue.ts +294 -0
  51. package/src/pass-through-unit-of-work.ts +17 -0
  52. package/src/placement-move-terminal.test.ts +179 -0
  53. package/src/placement-move-terminal.ts +104 -0
  54. package/src/placement-move.ts +224 -0
  55. package/src/snippet.test.ts +213 -0
  56. package/src/snippet.ts +152 -0
  57. package/src/stale-message-reconcile.test.ts +82 -0
  58. package/src/stale-message-reconcile.ts +55 -0
  59. package/src/test-helpers/isolated-mailbox.ts +44 -0
  60. package/src/test-helpers/mailfuzz-connection.ts +27 -0
  61. package/src/test-helpers/mime-tree-generator.ts +461 -0
  62. package/src/text/normalizer.test.ts +223 -0
  63. package/src/text/normalizer.ts +140 -0
  64. package/src/types/mailparser-augment.d.ts +15 -0
  65. package/src/types/natural-porter-stemmer.d.ts +50 -0
  66. package/src/types/stopword.d.ts +69 -0
  67. package/src/types.ts +349 -0
  68. package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
  69. package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
  70. package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
  71. package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
  72. package/test/fixtures/mime/02-flat-text-html.eml +10 -0
  73. package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
  74. package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
  75. package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
  76. package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
  77. package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
  78. package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
  79. package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
  80. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
  81. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
  82. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
  83. package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
  84. package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
  85. package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
  86. package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
  87. package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
  88. package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
  89. package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
  90. package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
  91. package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
  92. package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
  93. package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
  94. package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
  95. package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
  96. package/test/fixtures/mime/10-calendar-invite.eml +33 -0
  97. package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
  98. package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
  99. package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
  100. package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
  101. package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
  102. package/test/fixtures/mime/12-base64-binary.eml +20 -0
  103. package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
  104. package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
  105. package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
  106. package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
  107. package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
  108. package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
  109. package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
  110. package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
  111. package/test/fixtures/mime/15-empty-text-part.eml +18 -0
  112. package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
  113. package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
  114. package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
  115. package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
  124. 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
+ });