@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,355 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import {
4
+ seedMailbox,
5
+ uniqueMailboxName,
6
+ } from "./test-helpers/isolated-mailbox.js";
7
+ import {
8
+ createMailfuzzConnection,
9
+ withMailfuzzConnection,
10
+ } from "./test-helpers/mailfuzz-connection.js";
11
+
12
+ describe(
13
+ "Mailbox listing (Dovecot)",
14
+ {
15
+ skip: !process.env.RUN_E2E_TESTS,
16
+ },
17
+ () => {
18
+ test("lists mailboxes and INBOX exists", async () => {
19
+ await withMailfuzzConnection(async (connection) => {
20
+ const mailboxes = await connection.listMailboxes();
21
+
22
+ assert.ok(mailboxes.length > 0, "Should have at least one mailbox");
23
+
24
+ const inbox = mailboxes.find((m) => m.fullPath === "INBOX");
25
+ assert.ok(inbox, "INBOX should exist in mailbox list");
26
+ assert.equal(inbox.name, "INBOX");
27
+ });
28
+ });
29
+
30
+ test("returns mailbox status with uidValidity and uidNext", async () => {
31
+ await withMailfuzzConnection(async (connection) => {
32
+ const status = await connection.getMailboxStatus("INBOX");
33
+
34
+ assert.ok(status.uidValidity > 0, "uidValidity should be positive");
35
+ assert.ok(status.uidNext > 0, "uidNext should be positive");
36
+ assert.ok(status.messages > 0, "INBOX should have messages");
37
+ });
38
+ });
39
+ },
40
+ );
41
+
42
+ describe(
43
+ "Message fetch (Dovecot)",
44
+ {
45
+ skip: !process.env.RUN_E2E_TESTS,
46
+ },
47
+ () => {
48
+ test("fetches messages with ENVELOPE data", async () => {
49
+ await withMailfuzzConnection(async (connection) => {
50
+ const boxStatus = await connection.openBox("INBOX", true);
51
+ assert.ok(boxStatus.messages.total > 0, "INBOX should have messages");
52
+
53
+ const uids = await connection.search(["ALL"]);
54
+ assert.ok(uids.length > 0, "Should find messages");
55
+
56
+ const fetchUids = uids.slice(0, 5);
57
+ const messages = await connection.fetchMessages(fetchUids);
58
+
59
+ assert.equal(
60
+ messages.length,
61
+ fetchUids.length,
62
+ "Should fetch all requested messages",
63
+ );
64
+
65
+ for (const msg of messages) {
66
+ assert.ok(msg.uid > 0, "Message should have a UID");
67
+ assert.ok(msg.envelope, "Message should have envelope data");
68
+ assert.ok(msg.envelope.subject, "Envelope should have a subject");
69
+ assert.ok(msg.envelope.messageId, "Envelope should have a messageId");
70
+ assert.ok(msg.envelope.date, "Envelope should have a date");
71
+ assert.ok(
72
+ msg.envelope.from.length > 0,
73
+ "Envelope should have from addresses",
74
+ );
75
+ assert.ok(
76
+ msg.envelope.to.length > 0,
77
+ "Envelope should have to addresses",
78
+ );
79
+
80
+ for (const addr of msg.envelope.from) {
81
+ assert.ok(addr.mailbox, "From address should have mailbox");
82
+ assert.ok(addr.host, "From address should have host");
83
+ }
84
+ }
85
+ });
86
+ });
87
+
88
+ test("fetched message count matches status", async () => {
89
+ // Seed a mailbox this test owns so the SELECT-vs-SEARCH invariant holds
90
+ // regardless of what other e2e files do in parallel — the TOCTOU race
91
+ // that #501 papered over with --test-concurrency=1 is removed by
92
+ // isolation, not serialisation (#508).
93
+ const mailbox = uniqueMailboxName("E2E_count");
94
+ await withMailfuzzConnection(async (connection) => {
95
+ await seedMailbox(connection, mailbox, 3);
96
+ try {
97
+ const boxStatus = await connection.openBox(mailbox, true);
98
+ const uids = await connection.search(["ALL"]);
99
+
100
+ assert.equal(
101
+ uids.length,
102
+ boxStatus.messages.total,
103
+ "Search ALL count should match SELECT messages count",
104
+ );
105
+ } finally {
106
+ await connection.closeBox().catch(() => {});
107
+ await connection.deleteMailbox(mailbox).catch(() => {});
108
+ }
109
+ });
110
+ });
111
+ },
112
+ );
113
+
114
+ describe(
115
+ "Message bodies (Dovecot)",
116
+ {
117
+ skip: !process.env.RUN_E2E_TESTS,
118
+ },
119
+ () => {
120
+ test("fetches message body with text content", async () => {
121
+ await withMailfuzzConnection(async (connection) => {
122
+ await connection.openBox("INBOX", true);
123
+ const uids = await connection.search(["ALL"]);
124
+ assert.ok(uids.length > 0, "Should have messages to fetch body for");
125
+
126
+ const body = await connection.fetchMessageBody(uids[0]);
127
+ assert.ok(body.length > 0, "Body should not be empty");
128
+
129
+ const bodyText = body.toString("utf-8");
130
+ assert.ok(bodyText.length > 0, "Body text should not be empty");
131
+ });
132
+ });
133
+ },
134
+ );
135
+
136
+ describe(
137
+ "Flag operations (Dovecot)",
138
+ {
139
+ skip: !process.env.RUN_E2E_TESTS,
140
+ // These three subtests all mutate uids[0] of the same seeded mailbox, so
141
+ // they must run sequentially relative to each other. node:test already
142
+ // serialises subtests within a describe; concurrency: 1 pins that intent.
143
+ // Cross-file isolation (a dedicated seeded mailbox, not the shared INBOX)
144
+ // is what makes the whole suite safe to run in parallel — see #508.
145
+ concurrency: 1,
146
+ },
147
+ () => {
148
+ let targetUid: number;
149
+ const mailbox = uniqueMailboxName("E2E_flags");
150
+
151
+ before(async () => {
152
+ await withMailfuzzConnection(async (connection) => {
153
+ await seedMailbox(connection, mailbox, 3);
154
+ });
155
+ });
156
+
157
+ after(async () => {
158
+ const connection = createMailfuzzConnection();
159
+ await connection.connect();
160
+ await connection.deleteMailbox(mailbox).catch(() => {});
161
+ if (connection.isConnected) {
162
+ await connection.disconnect();
163
+ }
164
+ });
165
+
166
+ test("sets \\Seen flag and verifies it persists", async () => {
167
+ await withMailfuzzConnection(async (connection) => {
168
+ await connection.openBox(mailbox, false);
169
+ const uids = await connection.search(["ALL"]);
170
+ assert.ok(uids.length > 0, "Should have messages");
171
+ targetUid = uids[0];
172
+
173
+ await connection.removeFlags([targetUid], ["\\Seen"]);
174
+ await connection.addFlags([targetUid], ["\\Seen"]);
175
+
176
+ const messages = await connection.fetchMessages([targetUid]);
177
+ assert.equal(messages.length, 1);
178
+ assert.ok(
179
+ messages[0].flags.includes("\\Seen"),
180
+ "Message should have \\Seen flag",
181
+ );
182
+ });
183
+ });
184
+
185
+ test("clears \\Seen flag and verifies it is cleared", async () => {
186
+ await withMailfuzzConnection(async (connection) => {
187
+ await connection.openBox(mailbox, false);
188
+ const uids = await connection.search(["ALL"]);
189
+ targetUid = uids[0];
190
+
191
+ await connection.addFlags([targetUid], ["\\Seen"]);
192
+ await connection.removeFlags([targetUid], ["\\Seen"]);
193
+
194
+ const messages = await connection.fetchMessages([targetUid]);
195
+ assert.equal(messages.length, 1);
196
+ assert.ok(
197
+ !messages[0].flags.includes("\\Seen"),
198
+ "Message should not have \\Seen flag after removal",
199
+ );
200
+ });
201
+ });
202
+
203
+ test("sets custom keyword flag", async () => {
204
+ await withMailfuzzConnection(async (connection) => {
205
+ await connection.openBox(mailbox, false);
206
+ const uids = await connection.search(["ALL"]);
207
+ targetUid = uids[0];
208
+
209
+ await connection.addFlags([targetUid], ["$label1"]);
210
+
211
+ const messages = await connection.fetchMessages([targetUid]);
212
+ assert.equal(messages.length, 1);
213
+ assert.ok(
214
+ messages[0].flags.includes("$label1"),
215
+ "Message should have custom keyword $label1",
216
+ );
217
+
218
+ await connection.removeFlags([targetUid], ["$label1"]);
219
+ });
220
+ });
221
+ },
222
+ );
223
+
224
+ describe(
225
+ "Message operations (Dovecot)",
226
+ {
227
+ skip: !process.env.RUN_E2E_TESTS,
228
+ },
229
+ () => {
230
+ const testFolder = uniqueMailboxName("E2E_TestCopy");
231
+ // Copy/move source their messages from a mailbox this file owns rather than
232
+ // the shared INBOX, so nothing here appends to or mutates INBOX (#508).
233
+ const sourceFolder = uniqueMailboxName("E2E_TestSource");
234
+
235
+ before(async () => {
236
+ await withMailfuzzConnection(async (connection) => {
237
+ await seedMailbox(connection, sourceFolder, 3);
238
+ });
239
+ });
240
+
241
+ test("copy returns COPYUID response with uidMap entries", async () => {
242
+ await withMailfuzzConnection(async (connection) => {
243
+ await connection.createMailbox(testFolder);
244
+
245
+ await connection.openBox(sourceFolder, false);
246
+ const uids = await connection.search(["ALL"]);
247
+ assert.ok(uids.length > 0, "Should have messages to copy");
248
+
249
+ const result = await connection.copyMessages([uids[0]], testFolder);
250
+
251
+ assert.equal(result.destination, testFolder);
252
+ assert.ok(
253
+ result.uidMap.size > 0,
254
+ "COPYUID response should have uidMap entries",
255
+ );
256
+ assert.ok(
257
+ result.uidMap.has(uids[0]),
258
+ "uidMap should contain the source UID",
259
+ );
260
+ });
261
+ });
262
+
263
+ test("move returns COPYUID response with uidMap entries", async () => {
264
+ const moveFolder = uniqueMailboxName("E2E_TestMove");
265
+
266
+ await withMailfuzzConnection(async (connection) => {
267
+ await connection.createMailbox(moveFolder);
268
+
269
+ const appendResult = await connection.append(
270
+ sourceFolder,
271
+ [
272
+ "From: test@example.com",
273
+ "To: vmail@localhost",
274
+ "Subject: Move test message",
275
+ `Date: ${new Date().toUTCString()}`,
276
+ `Message-ID: <move-test-${Date.now()}@test.example.com>`,
277
+ "",
278
+ "Body for move test",
279
+ ].join("\r\n"),
280
+ );
281
+
282
+ await connection.openBox(sourceFolder, false);
283
+ const result = await connection.moveMessages(
284
+ [appendResult.uid],
285
+ moveFolder,
286
+ );
287
+
288
+ assert.equal(result.destination, moveFolder);
289
+ assert.ok(
290
+ result.uidMap.size > 0,
291
+ "COPYUID response should have uidMap entries",
292
+ );
293
+
294
+ await connection.closeBox();
295
+ await connection.deleteMailbox(moveFolder);
296
+ });
297
+ });
298
+
299
+ after(async () => {
300
+ const connection = createMailfuzzConnection();
301
+ await connection.connect();
302
+ await connection.deleteMailbox(testFolder).catch(() => {});
303
+ await connection.deleteMailbox(sourceFolder).catch(() => {});
304
+ if (connection.isConnected) {
305
+ await connection.disconnect();
306
+ }
307
+ });
308
+ },
309
+ );
310
+
311
+ describe("Mailbox CRUD (Dovecot)", { skip: !process.env.RUN_E2E_TESTS }, () => {
312
+ const crudFolder = `CrudTest_${Date.now()}`;
313
+ const renamedFolder = `CrudRenamed_${Date.now()}`;
314
+
315
+ test("creates a new mailbox", async () => {
316
+ await withMailfuzzConnection(async (connection) => {
317
+ const result = await connection.createMailbox(crudFolder);
318
+ assert.ok(result.created, "Mailbox should be created");
319
+
320
+ const mailboxes = await connection.listMailboxes();
321
+ const found = mailboxes.find((m) => m.fullPath === crudFolder);
322
+ assert.ok(found, `Mailbox ${crudFolder} should exist after creation`);
323
+ });
324
+ });
325
+
326
+ test("renames a mailbox", async () => {
327
+ await withMailfuzzConnection(async (connection) => {
328
+ const result = await connection.renameMailbox(crudFolder, renamedFolder);
329
+ assert.ok(result.newPath, "Should return new path");
330
+
331
+ const mailboxes = await connection.listMailboxes();
332
+ const oldFound = mailboxes.find((m) => m.fullPath === crudFolder);
333
+ assert.ok(
334
+ !oldFound,
335
+ `Old mailbox ${crudFolder} should not exist after rename`,
336
+ );
337
+
338
+ const newFound = mailboxes.find((m) => m.fullPath === renamedFolder);
339
+ assert.ok(newFound, `Renamed mailbox ${renamedFolder} should exist`);
340
+ });
341
+ });
342
+
343
+ test("deletes a mailbox", async () => {
344
+ await withMailfuzzConnection(async (connection) => {
345
+ await connection.deleteMailbox(renamedFolder);
346
+
347
+ const mailboxes = await connection.listMailboxes();
348
+ const found = mailboxes.find((m) => m.fullPath === renamedFolder);
349
+ assert.ok(
350
+ !found,
351
+ `Mailbox ${renamedFolder} should not exist after deletion`,
352
+ );
353
+ });
354
+ });
355
+ });
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Integration tests for ImapFlowConnection using mokapi.
3
+ *
4
+ * These tests require mokapi to be running:
5
+ * npm run start:mokapi
6
+ *
7
+ * Run with:
8
+ * npm run test:integ -w packages/mailbox-service
9
+ *
10
+ * Note: mokapi has some limitations:
11
+ * - Doesn't return COPYUID responses (uidMap is empty)
12
+ * - Doesn't return ENVELOPE data
13
+ * - Closes connection after APPEND
14
+ * - Requires mailbox close/reopen to see changes
15
+ */
16
+
17
+ import assert from "node:assert";
18
+ import { describe, test } from "node:test";
19
+ import { ImapFlowConnection } from "./imapflow-connection.js";
20
+
21
+ const MOKAPI_CONFIG = {
22
+ host: "localhost",
23
+ port: 143,
24
+ user: "alice@mokapi.io",
25
+ credentials: { kind: "password" as const, password: "alice123" },
26
+ tls: false,
27
+ };
28
+
29
+ /**
30
+ * Helper to create a connection.
31
+ */
32
+ const createConnection = (): ImapFlowConnection => {
33
+ return new ImapFlowConnection(MOKAPI_CONFIG);
34
+ };
35
+
36
+ /**
37
+ * Helper to run a test with a managed connection.
38
+ * Creates a connection, runs the test, and ensures cleanup.
39
+ */
40
+ const withConnection = async (
41
+ fn: (connection: ImapFlowConnection) => Promise<void>,
42
+ ): Promise<void> => {
43
+ const connection = createConnection();
44
+ await connection.connect();
45
+ await fn(connection).finally(() => {
46
+ if (connection.isConnected) {
47
+ return connection.disconnect();
48
+ }
49
+ });
50
+ };
51
+
52
+ /**
53
+ * Helper to append a test message to a mailbox and find its UID.
54
+ * Works around mokapi closing connection after APPEND by using
55
+ * a separate connection for seeding.
56
+ *
57
+ * @returns The UID of the appended message
58
+ */
59
+ const seedTestMessage = async (
60
+ mailbox: string,
61
+ subject: string,
62
+ ): Promise<number> => {
63
+ const seedConn = createConnection();
64
+ await seedConn.connect();
65
+
66
+ const message = [
67
+ "From: test@example.com",
68
+ "To: alice@mokapi.io",
69
+ `Subject: ${subject}`,
70
+ `Date: ${new Date().toUTCString()}`,
71
+ `Message-ID: <${Date.now()}-${Math.random()}@test.example.com>`,
72
+ "",
73
+ `Test message body for ${subject}`,
74
+ ].join("\r\n");
75
+
76
+ // Append the message (connection may close after this in mokapi)
77
+ await seedConn.append(mailbox, message);
78
+
79
+ // mokapi closes connection after APPEND, so reconnect to find the UID
80
+ const findConn = createConnection();
81
+ await findConn.connect();
82
+ await findConn.openBox(mailbox, true);
83
+ const uids = await findConn.search(["ALL"]);
84
+
85
+ // Find the message by fetching headers (mokapi doesn't support ENVELOPE)
86
+ // biome-ignore lint/suspicious/noExplicitAny: Accessing private client for test purposes
87
+ const client = (findConn as any).client;
88
+ let foundUid: number | undefined;
89
+
90
+ for await (const msg of client.fetch(
91
+ uids.slice(-10).join(","),
92
+ { uid: true, headers: ["subject"] },
93
+ { uid: true },
94
+ )) {
95
+ const headerText = msg.headers?.toString() || "";
96
+ const subjectMatch = headerText.match(/^subject:\s*(.+)$/im);
97
+ if (subjectMatch && subjectMatch[1].trim() === subject) {
98
+ foundUid = msg.uid;
99
+ break;
100
+ }
101
+ }
102
+
103
+ await findConn.disconnect();
104
+
105
+ if (!foundUid) {
106
+ throw new Error(`Could not find seeded message with subject: ${subject}`);
107
+ }
108
+
109
+ return foundUid;
110
+ };
111
+
112
+ /**
113
+ * Helper to count messages in a mailbox using a fresh connection.
114
+ * This works around mokapi's caching by using a new connection.
115
+ */
116
+ const countMessagesInMailbox = async (mailbox: string): Promise<number> => {
117
+ const conn = createConnection();
118
+ await conn.connect();
119
+ await conn.openBox(mailbox, true);
120
+ const uids = await conn.search(["ALL"]);
121
+ await conn.disconnect();
122
+ return uids.length;
123
+ };
124
+
125
+ /**
126
+ * Helper to get all UIDs in a mailbox using a fresh connection.
127
+ */
128
+ const getMailboxUids = async (mailbox: string): Promise<number[]> => {
129
+ const conn = createConnection();
130
+ await conn.connect();
131
+ await conn.openBox(mailbox, true);
132
+ const uids = await conn.search(["ALL"]);
133
+ await conn.disconnect();
134
+ return uids;
135
+ };
136
+
137
+ describe(
138
+ "ImapFlowConnection integration tests",
139
+ {
140
+ skip: !process.env.RUN_INTEG_TESTS,
141
+ },
142
+ () => {
143
+ describe("moveMessages", () => {
144
+ test("moves a message to another mailbox", async () => {
145
+ // Seed the test message first
146
+ const subject = `Move test ${Date.now()}`;
147
+ const uid = await seedTestMessage("INBOX", subject);
148
+
149
+ // Count messages in Work before
150
+ const workCountBefore = await countMessagesInMailbox("Work");
151
+
152
+ await withConnection(async (connection) => {
153
+ // Open INBOX
154
+ await connection.openBox("INBOX", false);
155
+
156
+ // Move to Work folder
157
+ const result = await connection.moveMessages([uid], "Work");
158
+
159
+ // mokapi doesn't return uidMap, but should return destination
160
+ assert.equal(
161
+ result.destination,
162
+ "Work",
163
+ "Destination should be Work",
164
+ );
165
+ });
166
+
167
+ // Verify message moved using fresh connections (mokapi caching workaround)
168
+ const inboxUids = await getMailboxUids("INBOX");
169
+ assert.ok(
170
+ !inboxUids.includes(uid),
171
+ "Original UID should not be in INBOX",
172
+ );
173
+
174
+ const workCountAfter = await countMessagesInMailbox("Work");
175
+ assert.ok(
176
+ workCountAfter > workCountBefore,
177
+ `Work folder should have more messages (before: ${workCountBefore}, after: ${workCountAfter})`,
178
+ );
179
+ });
180
+
181
+ test("returns empty uidMap when no UIDs provided", async () => {
182
+ await withConnection(async (connection) => {
183
+ await connection.openBox("INBOX", false);
184
+
185
+ const result = await connection.moveMessages([], "Work");
186
+
187
+ assert.equal(result.destination, "Work");
188
+ assert.equal(result.uidMap.size, 0);
189
+ });
190
+ });
191
+ });
192
+
193
+ describe("copyMessages", () => {
194
+ test("copies a message to another mailbox", async () => {
195
+ // Seed the test message first
196
+ const subject = `Copy test ${Date.now()}`;
197
+ const uid = await seedTestMessage("INBOX", subject);
198
+
199
+ // Count messages in Archive before
200
+ const archiveCountBefore = await countMessagesInMailbox("Archive");
201
+
202
+ await withConnection(async (connection) => {
203
+ // Open INBOX
204
+ await connection.openBox("INBOX", false);
205
+
206
+ // Copy to Archive folder
207
+ const result = await connection.copyMessages([uid], "Archive");
208
+
209
+ assert.equal(
210
+ result.destination,
211
+ "Archive",
212
+ "Destination should be Archive",
213
+ );
214
+ });
215
+
216
+ // Verify original message is still in INBOX
217
+ const inboxUids = await getMailboxUids("INBOX");
218
+ assert.ok(
219
+ inboxUids.includes(uid),
220
+ "Original UID should still be in INBOX",
221
+ );
222
+
223
+ // Verify copy is in Archive (count increased)
224
+ const archiveCountAfter = await countMessagesInMailbox("Archive");
225
+ assert.ok(
226
+ archiveCountAfter > archiveCountBefore,
227
+ `Archive folder should have more messages (before: ${archiveCountBefore}, after: ${archiveCountAfter})`,
228
+ );
229
+
230
+ // Cleanup: delete the original from INBOX
231
+ await withConnection(async (connection) => {
232
+ await connection.openBox("INBOX", false);
233
+ await connection.deleteMessages([uid]);
234
+ });
235
+ });
236
+
237
+ test("returns empty uidMap when no UIDs provided", async () => {
238
+ await withConnection(async (connection) => {
239
+ await connection.openBox("INBOX", false);
240
+
241
+ const result = await connection.copyMessages([], "Archive");
242
+
243
+ assert.equal(result.destination, "Archive");
244
+ assert.equal(result.uidMap.size, 0);
245
+ });
246
+ });
247
+ });
248
+
249
+ describe("deleteMessages", () => {
250
+ test("permanently deletes a message", async () => {
251
+ // Seed the test message first
252
+ const subject = `Delete test ${Date.now()}`;
253
+ const uid = await seedTestMessage("INBOX", subject);
254
+
255
+ // Verify message exists
256
+ let uids = await getMailboxUids("INBOX");
257
+ assert.ok(uids.includes(uid), "Message should exist before delete");
258
+
259
+ await withConnection(async (connection) => {
260
+ // Open INBOX
261
+ await connection.openBox("INBOX", false);
262
+
263
+ // Delete the message
264
+ const deleted = await connection.deleteMessages([uid]);
265
+ // mokapi may return true instead of count
266
+ assert.ok(deleted, "Should report message deleted");
267
+
268
+ // mokapi requires mailbox close for delete to persist
269
+ await connection.closeBox();
270
+
271
+ // Verify within same connection
272
+ await connection.openBox("INBOX", true);
273
+ uids = await connection.search(["ALL"]);
274
+ assert.ok(
275
+ !uids.includes(uid),
276
+ "Message should not exist after delete",
277
+ );
278
+ });
279
+ });
280
+
281
+ test("returns 0 when no UIDs provided", async () => {
282
+ await withConnection(async (connection) => {
283
+ await connection.openBox("INBOX", false);
284
+
285
+ const deleted = await connection.deleteMessages([]);
286
+ assert.equal(deleted, 0);
287
+ });
288
+ });
289
+ });
290
+
291
+ describe("move to Trash workflow", () => {
292
+ test("moves message to Trash folder", async () => {
293
+ // Seed the test message first
294
+ const subject = `Trash test ${Date.now()}`;
295
+ const uid = await seedTestMessage("INBOX", subject);
296
+
297
+ // Count messages in Trash before
298
+ const trashCountBefore = await countMessagesInMailbox("Trash");
299
+
300
+ await withConnection(async (connection) => {
301
+ // Open INBOX
302
+ await connection.openBox("INBOX", false);
303
+
304
+ // Move to Trash
305
+ const result = await connection.moveMessages([uid], "Trash");
306
+
307
+ assert.equal(
308
+ result.destination,
309
+ "Trash",
310
+ "Destination should be Trash",
311
+ );
312
+ });
313
+
314
+ // Verify message is no longer in INBOX
315
+ const inboxUids = await getMailboxUids("INBOX");
316
+ assert.ok(!inboxUids.includes(uid), "Message should not be in INBOX");
317
+
318
+ // Verify message is in Trash (count increased)
319
+ const trashCountAfter = await countMessagesInMailbox("Trash");
320
+ assert.ok(
321
+ trashCountAfter > trashCountBefore,
322
+ `Trash folder should have more messages (before: ${trashCountBefore}, after: ${trashCountAfter})`,
323
+ );
324
+ });
325
+ });
326
+ },
327
+ );