@remit/mailbox-service 0.0.110 → 0.0.112

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 (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +226 -200
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +19 -15
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/types.ts +1 -1
  74. package/src/adversarial-mime.e2e.test.ts +0 -307
  75. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  76. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  77. package/src/body-sync-category-denormalize.test.ts +0 -352
  78. package/src/body-sync-category-override.test.ts +0 -501
  79. package/src/body-sync-category-writeonce.test.ts +0 -234
  80. package/src/body-sync-filter.test.ts +0 -237
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -0,0 +1,273 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type { AccountItem, MailboxItem } from "@remit/data-ports";
4
+ import { MailboxSyncStatus } from "@remit/domain-enums";
5
+ import {
6
+ changeFolderPath,
7
+ createFolder,
8
+ type FolderPathOutcome,
9
+ } from "./folder-operations.js";
10
+ import { MailboxQueueService } from "./mailbox-queue.js";
11
+ import { folderInAccount } from "./ownership.js";
12
+ import type { Refusable } from "./refusal.js";
13
+ import {
14
+ captureQueue,
15
+ openStore,
16
+ type QueueCapture,
17
+ type Store,
18
+ seedAccount,
19
+ seedMailbox,
20
+ } from "./test-helpers/test-store.js";
21
+
22
+ let store: Store;
23
+ let capture: QueueCapture;
24
+ let account: AccountItem;
25
+ let mailboxQueue: MailboxQueueService;
26
+
27
+ beforeEach(async () => {
28
+ capture = captureQueue();
29
+ store = await openStore();
30
+ account = await seedAccount(store);
31
+ mailboxQueue = new MailboxQueueService({
32
+ mailboxService: store.mailbox,
33
+ sqsQueueUrl: "http://localhost:4566/000000000000/mailbox",
34
+ });
35
+ });
36
+
37
+ afterEach(async () => {
38
+ capture.restore();
39
+ await store.close();
40
+ });
41
+
42
+ const rename = (
43
+ mailbox: MailboxItem,
44
+ fullPath: string,
45
+ ): Promise<Refusable<FolderPathOutcome>> =>
46
+ changeFolderPath(
47
+ { account: store.account, mailbox: store.mailbox, mailboxQueue },
48
+ account.accountConfigId,
49
+ {
50
+ accountId: mailbox.accountId,
51
+ mailboxId: mailbox.mailboxId,
52
+ fullPath,
53
+ },
54
+ );
55
+
56
+ const refusalOf = <T>(outcome: Refusable<T>) => {
57
+ assert.equal(outcome.ok, false, "expected a refusal");
58
+ return outcome.ok ? undefined : outcome.refusal;
59
+ };
60
+
61
+ const invalid = (message: string) => ({ kind: "Invalid", message });
62
+
63
+ describe("changeFolderPath refuses a rename no retry could satisfy", () => {
64
+ it("keeps INBOX where it is", async () => {
65
+ const inbox = await seedMailbox(store, account, "INBOX");
66
+
67
+ assert.deepEqual(
68
+ refusalOf(await rename(inbox, "Old inbox")),
69
+ invalid("The inbox can't be renamed."),
70
+ );
71
+ });
72
+
73
+ it("keeps the INBOX name for the inbox", async () => {
74
+ const folder = await seedMailbox(store, account, "Receipts");
75
+
76
+ assert.deepEqual(
77
+ refusalOf(await rename(folder, "inbox")),
78
+ invalid("“INBOX” is reserved for the inbox."),
79
+ );
80
+ });
81
+
82
+ it("needs a name", async () => {
83
+ const folder = await seedMailbox(store, account, "Receipts");
84
+
85
+ assert.deepEqual(
86
+ refusalOf(await rename(folder, " ")),
87
+ invalid("A folder needs a name."),
88
+ );
89
+ });
90
+
91
+ it("keeps a system folder's conventional name free", async () => {
92
+ const folder = await seedMailbox(store, account, "Receipts");
93
+
94
+ assert.deepEqual(
95
+ refusalOf(await rename(folder, "Projects/Trash")),
96
+ invalid("“Trash” is reserved for a system folder. Pick another name."),
97
+ );
98
+ });
99
+
100
+ it("refuses a path another folder holds", async () => {
101
+ await seedMailbox(store, account, "Archive 2024");
102
+ const folder = await seedMailbox(store, account, "Receipts");
103
+
104
+ assert.deepEqual(
105
+ refusalOf(await rename(folder, "Archive 2024")),
106
+ invalid("A folder named “Archive 2024” is already there."),
107
+ );
108
+ });
109
+
110
+ it("refuses a path a pending rename is heading for", async () => {
111
+ const first = await seedMailbox(store, account, "Bills");
112
+ const second = await seedMailbox(store, account, "Receipts");
113
+ assert.equal((await rename(first, "Paperwork")).ok, true);
114
+
115
+ assert.deepEqual(
116
+ refusalOf(await rename(second, "Paperwork")),
117
+ invalid("“Bills” is already being renamed to “Paperwork”."),
118
+ );
119
+ });
120
+ });
121
+
122
+ describe("changeFolderPath records a pending rename the mail server settles", () => {
123
+ it("keeps the confirmed path, records the target and queues the rename", async () => {
124
+ const folder = await seedMailbox(store, account, "Receipts");
125
+
126
+ const outcome = await rename(folder, "Paperwork");
127
+
128
+ assert.equal(outcome.ok, true);
129
+ const row = await store.mailbox.get(account.accountId, folder.mailboxId);
130
+ assert.equal(row.fullPath, "Receipts");
131
+ assert.equal(row.pendingPath, "Paperwork");
132
+ assert.equal(row.syncStatus, MailboxSyncStatus.pending);
133
+ assert.deepEqual(
134
+ capture.sent.map((event) => event.body.type),
135
+ ["MAILBOX_RENAME"],
136
+ );
137
+ });
138
+
139
+ it("dismisses a failed rename when asked for the path the folder has", async () => {
140
+ const folder = await seedMailbox(store, account, "Receipts");
141
+ await store.mailbox.transition(account.accountId, folder.mailboxId, {
142
+ from: [MailboxSyncStatus.synced],
143
+ to: MailboxSyncStatus.failed,
144
+ set: { pendingPath: "Paperwork" },
145
+ });
146
+
147
+ const outcome = await rename(folder, "Receipts");
148
+
149
+ assert.equal(outcome.ok, true);
150
+ const row = await store.mailbox.get(account.accountId, folder.mailboxId);
151
+ assert.equal(row.syncStatus, MailboxSyncStatus.synced);
152
+ assert.equal(row.pendingPath, undefined);
153
+ assert.deepEqual(capture.sent, []);
154
+ });
155
+
156
+ it("returns the recorded intent when asked for the target it is already heading for", async () => {
157
+ const folder = await seedMailbox(store, account, "Receipts");
158
+ assert.equal((await rename(folder, "Paperwork")).ok, true);
159
+
160
+ const again = await rename(folder, "Paperwork");
161
+
162
+ assert.equal(again.ok && again.value.outcome, "AlreadyRecorded");
163
+ assert.equal(again.ok && again.value.mailbox.pendingPath, "Paperwork");
164
+ assert.equal(capture.sent.length, 1);
165
+ });
166
+
167
+ it("refuses a new target while another change is in flight", async () => {
168
+ const folder = await seedMailbox(store, account, "Receipts");
169
+ assert.equal((await rename(folder, "Paperwork")).ok, true);
170
+
171
+ assert.deepEqual(refusalOf(await rename(folder, "Bills")), {
172
+ kind: "Conflict",
173
+ message: `A rename is in progress for "Receipts".`,
174
+ });
175
+ assert.equal(capture.sent.length, 1);
176
+ });
177
+ });
178
+
179
+ describe("createFolder", () => {
180
+ it("records a pending folder and queues its create", async () => {
181
+ const outcome = await createFolder(
182
+ { account: store.account, mailbox: store.mailbox, mailboxQueue },
183
+ account.accountConfigId,
184
+ {
185
+ accountId: account.accountId,
186
+ namespaceType: "personal",
187
+ fullPath: "Receipts",
188
+ },
189
+ );
190
+
191
+ assert.equal(outcome.ok, true);
192
+ assert.equal(outcome.ok && outcome.value.outcome, "Created");
193
+ assert.equal(outcome.ok && outcome.value.mailbox.syncStatus, "pending");
194
+ assert.deepEqual(
195
+ capture.sent.map((event) => event.body.type),
196
+ ["MAILBOX_CREATE"],
197
+ );
198
+ });
199
+
200
+ it("returns the folder already at that path", async () => {
201
+ const existing = await seedMailbox(store, account, "Receipts");
202
+
203
+ const outcome = await createFolder(
204
+ { account: store.account, mailbox: store.mailbox, mailboxQueue },
205
+ account.accountConfigId,
206
+ {
207
+ accountId: account.accountId,
208
+ namespaceType: "personal",
209
+ fullPath: "Receipts",
210
+ },
211
+ );
212
+
213
+ assert.equal(outcome.ok && outcome.value.outcome, "Existing");
214
+ assert.equal(
215
+ outcome.ok && outcome.value.mailbox.mailboxId,
216
+ existing.mailboxId,
217
+ );
218
+ assert.deepEqual(capture.sent, []);
219
+ });
220
+ });
221
+
222
+ describe("createFolder for another tenant", () => {
223
+ it("refuses a caller who does not own the account, recording nothing", async () => {
224
+ const outcome = await createFolder(
225
+ { account: store.account, mailbox: store.mailbox, mailboxQueue },
226
+ "cfg-x",
227
+ {
228
+ accountId: account.accountId,
229
+ namespaceType: "personal",
230
+ fullPath: "Receipts",
231
+ },
232
+ );
233
+
234
+ assert.deepEqual(refusalOf(outcome), {
235
+ kind: "Forbidden",
236
+ message: `Account ${account.accountId} not in account config`,
237
+ });
238
+ assert.deepEqual(capture.sent, []);
239
+ });
240
+ });
241
+
242
+ describe("changeFolderPath for another tenant", () => {
243
+ it("refuses a caller who does not own the account, recording nothing", async () => {
244
+ const folder = await seedMailbox(store, account, "Receipts");
245
+ const outcome = await changeFolderPath(
246
+ { account: store.account, mailbox: store.mailbox, mailboxQueue },
247
+ "cfg-x",
248
+ {
249
+ accountId: account.accountId,
250
+ mailboxId: folder.mailboxId,
251
+ fullPath: "Paperwork",
252
+ },
253
+ );
254
+
255
+ assert.deepEqual(refusalOf(outcome), {
256
+ kind: "Forbidden",
257
+ message: `Account ${account.accountId} not in account config`,
258
+ });
259
+ assert.deepEqual(capture.sent, []);
260
+ });
261
+ });
262
+
263
+ describe("folderInAccount", () => {
264
+ it("refuses another account's folder as not found on a read", async () => {
265
+ const other = await seedAccount(store, { accountConfigId: "cfg-other" });
266
+ const theirs = await seedMailbox(store, other, "Receipts");
267
+
268
+ assert.deepEqual(
269
+ refusalOf(folderInAccount(theirs, account.accountId, "read")),
270
+ { kind: "NotFound", message: `Mailbox not found: ${theirs.mailboxId}` },
271
+ );
272
+ });
273
+ });
@@ -0,0 +1,217 @@
1
+ import type {
2
+ AccountItem,
3
+ IAccountRepository,
4
+ IMailboxRepository,
5
+ MailboxItem,
6
+ } from "@remit/data-ports";
7
+ import { isReservedFolderName } from "@remit/data-ports/folder-role";
8
+ import { mailboxLeafName } from "@remit/data-ports/mailbox-name";
9
+ import { MailboxSyncStatus } from "@remit/domain-enums";
10
+ import { carriesIntent, folderInFlight } from "./mailbox-intent.js";
11
+ import type { MailboxQueueService } from "./mailbox-queue.js";
12
+ import { folderInAccount, ownedAccount } from "./ownership.js";
13
+ import { accept, invalid, type Refusable, refuse } from "./refusal.js";
14
+
15
+ export interface FolderPathDeps {
16
+ account: { get(accountId: string): Promise<AccountItem> };
17
+ mailbox: {
18
+ get(accountId: string, mailboxId: string): Promise<MailboxItem>;
19
+ listAllByAccount(accountId: string): Promise<MailboxItem[]>;
20
+ };
21
+ mailboxQueue: Pick<
22
+ MailboxQueueService,
23
+ "renameMailbox" | "dismissMailboxIntent"
24
+ >;
25
+ }
26
+
27
+ export interface CreateFolderDeps {
28
+ account: Pick<IAccountRepository, "get">;
29
+ mailbox: Pick<IMailboxRepository, "findByPath">;
30
+ mailboxQueue: Pick<MailboxQueueService, "createMailbox">;
31
+ }
32
+
33
+ export interface NewFolder {
34
+ accountId: string;
35
+ namespaceType: MailboxItem["namespaceType"];
36
+ fullPath: string;
37
+ }
38
+
39
+ // AlreadyRecorded: the folder is already heading for the requested path, so
40
+ // nothing new was recorded or queued.
41
+ export interface FolderPathOutcome {
42
+ outcome: "Recorded" | "AlreadyRecorded";
43
+ mailbox: MailboxItem;
44
+ }
45
+
46
+ export interface NewFolderOutcome {
47
+ outcome: "Created" | "Existing";
48
+ mailbox: MailboxItem;
49
+ }
50
+
51
+ export interface FolderPathChange {
52
+ accountId: string;
53
+ mailboxId: string;
54
+ fullPath: string;
55
+ }
56
+
57
+ const renameTargetRefusal = async (
58
+ deps: Pick<FolderPathDeps, "mailbox">,
59
+ accountId: string,
60
+ before: MailboxItem,
61
+ target: string,
62
+ ): Promise<Refusable<string>> => {
63
+ // Renaming INBOX leaves an empty INBOX behind, and INBOX is the one name
64
+ // RFC 3501 reserves.
65
+ if (before.fullPath.toUpperCase() === "INBOX") {
66
+ return invalid("The inbox can't be renamed.");
67
+ }
68
+ if (target.trim().length === 0) {
69
+ return invalid("A folder needs a name.");
70
+ }
71
+ if (target.toUpperCase() === "INBOX") {
72
+ return invalid("“INBOX” is reserved for the inbox.");
73
+ }
74
+
75
+ // The mailbox sweep reads an unflagged folder under a role's conventional
76
+ // name as a duplicate and reaps it, with its mail, once another folder holds
77
+ // the flag.
78
+ const leaf = mailboxLeafName({
79
+ fullPath: target,
80
+ hierarchyDelimiter: before.hierarchyDelimiter,
81
+ });
82
+ if (isReservedFolderName(leaf)) {
83
+ return invalid(
84
+ `“${leaf}” is reserved for a system folder. Pick another name.`,
85
+ );
86
+ }
87
+
88
+ // A path a pending rename is heading for is as taken as one a folder sits
89
+ // at. A failed rename keeps its target only so the client can offer a
90
+ // retry; nothing is coming for it, so it claims nothing.
91
+ const folders = await deps.mailbox.listAllByAccount(accountId);
92
+ for (const folder of folders) {
93
+ if (folder.mailboxId === before.mailboxId) continue;
94
+ if (folder.fullPath === target) {
95
+ return invalid(`A folder named “${target}” is already there.`);
96
+ }
97
+ if (
98
+ folder.syncStatus === MailboxSyncStatus.pending &&
99
+ folder.pendingPath === target
100
+ ) {
101
+ return invalid(
102
+ `“${folder.fullPath}” is already being renamed to “${target}”.`,
103
+ );
104
+ }
105
+ }
106
+ return accept(target);
107
+ };
108
+
109
+ // A fullPath equal to the confirmed one is not a rename: it dismisses a failed
110
+ // intent, the one route out of `failed` that enqueues nothing. The row keeps
111
+ // the path the server holds until the rename settles.
112
+ export const ownedFolder = async (
113
+ deps: Pick<FolderPathDeps, "account" | "mailbox">,
114
+ accountConfigId: string,
115
+ accountId: string,
116
+ mailboxId: string,
117
+ ): Promise<Refusable<MailboxItem>> => {
118
+ const owned = ownedAccount(
119
+ await deps.account.get(accountId),
120
+ accountConfigId,
121
+ "act",
122
+ );
123
+ if (!owned.ok) return owned;
124
+ return folderInAccount(
125
+ await deps.mailbox.get(accountId, mailboxId),
126
+ accountId,
127
+ "act",
128
+ );
129
+ };
130
+
131
+ export const changeFolderPath = async (
132
+ deps: FolderPathDeps,
133
+ accountConfigId: string,
134
+ change: FolderPathChange,
135
+ ): Promise<Refusable<FolderPathOutcome>> => {
136
+ const { accountId, mailboxId, fullPath } = change;
137
+ const folder = await ownedFolder(deps, accountConfigId, accountId, mailboxId);
138
+ if (!folder.ok) return folder;
139
+ const before = folder.value;
140
+ if (fullPath === before.fullPath) {
141
+ return accept({
142
+ outcome: "Recorded",
143
+ mailbox: await deps.mailboxQueue.dismissMailboxIntent(
144
+ mailboxId,
145
+ accountId,
146
+ ),
147
+ });
148
+ }
149
+
150
+ const target = await renameTargetRefusal(deps, accountId, before, fullPath);
151
+ if (!target.ok) return target;
152
+
153
+ if (!carriesIntent(before)) {
154
+ if (
155
+ before.syncStatus === MailboxSyncStatus.pending &&
156
+ before.pendingPath === fullPath
157
+ ) {
158
+ return accept({ outcome: "AlreadyRecorded", mailbox: before });
159
+ }
160
+ return refuse({ kind: "Conflict", message: folderInFlight(before) });
161
+ }
162
+
163
+ return accept({
164
+ outcome: "Recorded",
165
+ mailbox: await deps.mailboxQueue.renameMailbox(
166
+ mailboxId,
167
+ fullPath,
168
+ accountId,
169
+ ),
170
+ });
171
+ };
172
+
173
+ export const createFolder = async (
174
+ deps: CreateFolderDeps,
175
+ accountConfigId: string,
176
+ folder: NewFolder,
177
+ ): Promise<Refusable<NewFolderOutcome>> => {
178
+ const { accountId, namespaceType, fullPath } = folder;
179
+ const owned = ownedAccount(
180
+ await deps.account.get(accountId),
181
+ accountConfigId,
182
+ "act",
183
+ );
184
+ if (!owned.ok) return owned;
185
+
186
+ const created = await deps.mailboxQueue.createMailbox(
187
+ {
188
+ accountId,
189
+ namespaceType,
190
+ namespacePrefix: "",
191
+ hierarchyDelimiter: "/",
192
+ fullPath,
193
+ uidValidity: 0,
194
+ uidNext: 1,
195
+ highestModseq: "0",
196
+ messageCount: 0,
197
+ unseenCount: 0,
198
+ deletedCount: 0,
199
+ totalSize: 0,
200
+ lastSyncUid: 0,
201
+ highWaterMarkUid: 0,
202
+ lastMessageSyncAt: 0,
203
+ },
204
+ accountId,
205
+ true,
206
+ );
207
+ if (created.outcome === "PathTaken") {
208
+ const existing = await deps.mailbox.findByPath(accountId, fullPath);
209
+ if (!existing) {
210
+ throw new Error(
211
+ `No folder at "${fullPath}" after its create reported the path taken`,
212
+ );
213
+ }
214
+ return accept({ outcome: "Existing", mailbox: existing });
215
+ }
216
+ return accept({ outcome: "Created", mailbox: created.mailbox });
217
+ };
@@ -0,0 +1,122 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import {
7
+ mapMessagePartsToContent,
8
+ parsePartSource,
9
+ } from "./body-part-mapper.js";
10
+ import { type MimeNode, walkMimeStructure } from "./mime-walker.js";
11
+
12
+ const FIXTURE = readFileSync(
13
+ resolve(
14
+ dirname(fileURLToPath(import.meta.url)),
15
+ "../../e2e/fixtures/mime-adversarial/04-forwarded-rfc822-with-attachments.eml",
16
+ ),
17
+ "utf8",
18
+ ).replace(/\r?\n/g, "\r\n");
19
+
20
+ const ATTACHED = 'Content-Disposition: attachment; filename="forwarded.eml"';
21
+ const INLINE = 'Content-Disposition: inline; filename="forwarded.eml"';
22
+
23
+ /** Fixture 04 as imapflow hands it over: the forwarded message's root reuses part "2". */
24
+ const structure = (disposition: string): MimeNode => ({
25
+ type: "multipart/mixed",
26
+ childNodes: [
27
+ { part: "1", type: "text/plain", encoding: "7bit", size: 115 },
28
+ {
29
+ part: "2",
30
+ type: "message/rfc822",
31
+ encoding: "7bit",
32
+ size: 638,
33
+ disposition,
34
+ dispositionParameters: { filename: "forwarded.eml" },
35
+ childNodes: [
36
+ {
37
+ part: "2",
38
+ type: "multipart/mixed",
39
+ childNodes: [
40
+ { part: "2.1", type: "text/plain", encoding: "7bit", size: 40 },
41
+ {
42
+ part: "2.2",
43
+ type: "application/pdf",
44
+ encoding: "base64",
45
+ size: 44,
46
+ disposition: "attachment",
47
+ dispositionParameters: { filename: "original.pdf" },
48
+ },
49
+ ],
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ });
55
+
56
+ const forwardedSource = (): string => {
57
+ const start = FIXTURE.indexOf("From: carol@example.com");
58
+ const end = FIXTURE.indexOf("\r\n--=_adv04_outer_=--");
59
+ return FIXTURE.slice(start, end);
60
+ };
61
+
62
+ const storedContent = async (
63
+ source: string,
64
+ disposition: string,
65
+ ): Promise<Map<string, string>> => {
66
+ const pairs = await mapMessagePartsToContent(
67
+ walkMimeStructure(structure(disposition)),
68
+ await parsePartSource(Buffer.from(source)),
69
+ );
70
+ return new Map(
71
+ pairs.map((pair) => [pair.partPath, pair.content.toString("utf8")]),
72
+ );
73
+ };
74
+
75
+ describe("a forwarded message/rfc822 part", () => {
76
+ it("is stored as its own part, apart from the message it carries", () => {
77
+ const records = walkMimeStructure(structure("attachment"));
78
+
79
+ assert.deepEqual(
80
+ records.map((record) => [
81
+ record.partPath,
82
+ record.parentPartPath,
83
+ `${record.mediaType}/${record.mediaSubtype}`,
84
+ ]),
85
+ [
86
+ ["0", null, "MULTIPART/mixed"],
87
+ ["1", "0", "TEXT/plain"],
88
+ ["2", "0", "MESSAGE/rfc822"],
89
+ ["2.0", "2", "MULTIPART/mixed"],
90
+ ["2.1", "2.0", "TEXT/plain"],
91
+ ["2.2", "2.0", "APPLICATION/pdf"],
92
+ ],
93
+ );
94
+ const forwarded = records.find((record) => record.partPath === "2");
95
+ assert.equal(forwarded?.isMultipart, false);
96
+ assert.equal(forwarded?.dispositionFilename, "forwarded.eml");
97
+ });
98
+
99
+ for (const [disposition, header] of [
100
+ ["attachment", ATTACHED],
101
+ ["inline", INLINE],
102
+ ] as const) {
103
+ it(`stores the raw forwarded message and its own parts' bytes when it is ${disposition}`, async () => {
104
+ const content = await storedContent(
105
+ FIXTURE.replace(ATTACHED, header),
106
+ disposition,
107
+ );
108
+
109
+ assert.deepEqual([...content.keys()], ["1", "2", "2.1", "2.2"]);
110
+ assert.match(content.get("1") ?? "", /Forwarding the message below/);
111
+ assert.equal(
112
+ (content.get("2") ?? "").trimEnd(),
113
+ forwardedSource().trimEnd(),
114
+ );
115
+ assert.equal(
116
+ (content.get("2.1") ?? "").trimEnd(),
117
+ "Original message body before forwarding.",
118
+ );
119
+ assert.equal(content.get("2.2"), "%PDF-1.4\n%adv 04 inner pdf\n%EOF\n");
120
+ });
121
+ }
122
+ });