@remit/mailbox-service 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -125,4 +125,4 @@ export const encryptRefreshToken = async (
125
125
  return JSON.stringify(serializeEncryptedPayload(payload));
126
126
  };
127
127
 
128
- export { RefreshTokenError, ConnectionState };
128
+ export { ConnectionState, RefreshTokenError };
@@ -151,161 +151,157 @@ const bodyText = (parsed: ParsedMail): string => {
151
151
  return `${text}\n${html}`;
152
152
  };
153
153
 
154
- describe(
155
- "Adversarial MIME corpus (Dovecot) — #402",
156
- { skip: !process.env.RUN_E2E_TESTS },
157
- () => {
158
- // Own mailbox instead of the shared INBOX so appending fixtures never
159
- // collides with another e2e file's count/flag assertions (#508).
160
- const inbox = uniqueMailboxName("E2E_adversarial");
154
+ describe("Adversarial MIME corpus (Dovecot) — #402", {
155
+ skip: !process.env.RUN_E2E_TESTS,
156
+ }, () => {
157
+ // Own mailbox instead of the shared INBOX so appending fixtures never
158
+ // collides with another e2e file's count/flag assertions (#508).
159
+ const inbox = uniqueMailboxName("E2E_adversarial");
161
160
 
162
- before(async () => {
163
- await withMailfuzzConnection(async (connection) => {
164
- await connection.createMailbox(inbox);
165
- });
161
+ before(async () => {
162
+ await withMailfuzzConnection(async (connection) => {
163
+ await connection.createMailbox(inbox);
166
164
  });
165
+ });
167
166
 
168
- after(async () => {
169
- const connection = createMailfuzzConnection();
170
- await connection.connect();
171
- await connection.deleteMailbox(inbox).catch(() => {});
172
- if (connection.isConnected) {
173
- await connection.disconnect();
174
- }
175
- });
167
+ after(async () => {
168
+ const connection = createMailfuzzConnection();
169
+ await connection.connect();
170
+ await connection.deleteMailbox(inbox).catch(() => {});
171
+ if (connection.isConnected) {
172
+ await connection.disconnect();
173
+ }
174
+ });
176
175
 
177
- // Sanity: every .eml on disk has an expectation entry, and every
178
- // expectation entry points at an existing file. Catches a fixture being
179
- // added without an assertion (or vice versa).
180
- test("every fixture has an expectation entry", () => {
181
- const onDisk = new Set(listEmlFixtures());
182
- const expected = new Set(Object.keys(EXPECTATIONS));
183
- for (const name of onDisk) {
184
- assert.ok(
185
- expected.has(name),
186
- `Fixture ${name} has no expectation entry in EXPECTATIONS`,
187
- );
188
- }
189
- for (const name of expected) {
176
+ // Sanity: every .eml on disk has an expectation entry, and every
177
+ // expectation entry points at an existing file. Catches a fixture being
178
+ // added without an assertion (or vice versa).
179
+ test("every fixture has an expectation entry", () => {
180
+ const onDisk = new Set(listEmlFixtures());
181
+ const expected = new Set(Object.keys(EXPECTATIONS));
182
+ for (const name of onDisk) {
183
+ assert.ok(
184
+ expected.has(name),
185
+ `Fixture ${name} has no expectation entry in EXPECTATIONS`,
186
+ );
187
+ }
188
+ for (const name of expected) {
189
+ assert.ok(
190
+ onDisk.has(name),
191
+ `Expectation ${name} has no .eml file on disk`,
192
+ );
193
+ }
194
+ // Pin the count: #402 calls for 8 adversarial shapes.
195
+ assert.equal(
196
+ onDisk.size,
197
+ 8,
198
+ "Expected exactly 8 adversarial MIME fixtures",
199
+ );
200
+ });
201
+
202
+ for (const name of Object.keys(EXPECTATIONS).sort()) {
203
+ const expectation = EXPECTATIONS[name];
204
+ if (!expectation) continue;
205
+
206
+ test(`fixture ${name} round-trips through IMAP and parses`, async () => {
207
+ const raw = loadFixtureRaw(name);
208
+
209
+ await withMailfuzzConnection(async (connection) => {
210
+ // APPEND the fixture into this file's own mailbox. Mailfuzz uses
211
+ // Dovecot which returns a proper UID on APPEND (mokapi does not —
212
+ // see outbox-roundtrip.e2e.test.ts and the RFC 025 notes).
213
+ const appendResult = await connection.append(inbox, raw);
190
214
  assert.ok(
191
- onDisk.has(name),
192
- `Expectation ${name} has no .eml file on disk`,
215
+ appendResult.uid > 0,
216
+ `APPEND should return a UID for ${name}`,
193
217
  );
194
- }
195
- // Pin the count: #402 calls for 8 adversarial shapes.
196
- assert.equal(
197
- onDisk.size,
198
- 8,
199
- "Expected exactly 8 adversarial MIME fixtures",
200
- );
201
- });
202
218
 
203
- for (const name of Object.keys(EXPECTATIONS).sort()) {
204
- const expectation = EXPECTATIONS[name];
205
- if (!expectation) continue;
219
+ await connection.openBox(inbox, true);
206
220
 
207
- test(`fixture ${name} round-trips through IMAP and parses`, async () => {
208
- const raw = loadFixtureRaw(name);
221
+ // Fetch the message we just appended back. fetchMessageBody
222
+ // returns the raw RFC822 source; mailparser handles the rest
223
+ // (multipart walking, base64/qp decoding, charset folding).
224
+ const rfc822 = await connection.fetchMessageBody(appendResult.uid);
225
+ assert.ok(
226
+ rfc822.length > 0,
227
+ `fetchMessageBody should return bytes for ${name}`,
228
+ );
209
229
 
210
- await withMailfuzzConnection(async (connection) => {
211
- // APPEND the fixture into this file's own mailbox. Mailfuzz uses
212
- // Dovecot which returns a proper UID on APPEND (mokapi does not —
213
- // see outbox-roundtrip.e2e.test.ts and the RFC 025 notes).
214
- const appendResult = await connection.append(inbox, raw);
230
+ // BODYSTRUCTURE re-fetch: exercises the IMAP mapper path at the
231
+ // integration boundary (the exact regression class from #394).
232
+ // Skipped for fixtures with synthetic shapes that don't produce
233
+ // meaningful BODYSTRUCTURE (see skipBodyStructure flag + #408).
234
+ // The guard in fetchMessages skips rows with undefined uid/internalDate
235
+ // so back-to-back FETCH is safe on Dovecot (see imapflow-connection.ts).
236
+ if (!expectation.skipBodyStructure) {
237
+ const refetched = await connection.fetchMessages([appendResult.uid]);
215
238
  assert.ok(
216
- appendResult.uid > 0,
217
- `APPEND should return a UID for ${name}`,
239
+ refetched.length > 0,
240
+ `BODYSTRUCTURE re-fetch should return a message for ${name}`,
218
241
  );
219
-
220
- await connection.openBox(inbox, true);
221
-
222
- // Fetch the message we just appended back. fetchMessageBody
223
- // returns the raw RFC822 source; mailparser handles the rest
224
- // (multipart walking, base64/qp decoding, charset folding).
225
- const rfc822 = await connection.fetchMessageBody(appendResult.uid);
226
242
  assert.ok(
227
- rfc822.length > 0,
228
- `fetchMessageBody should return bytes for ${name}`,
243
+ refetched[0]?.bodyStructure != null,
244
+ `BODYSTRUCTURE should be present for ${name}`,
229
245
  );
246
+ }
230
247
 
231
- // BODYSTRUCTURE re-fetch: exercises the IMAP mapper path at the
232
- // integration boundary (the exact regression class from #394).
233
- // Skipped for fixtures with synthetic shapes that don't produce
234
- // meaningful BODYSTRUCTURE (see skipBodyStructure flag + #408).
235
- // The guard in fetchMessages skips rows with undefined uid/internalDate
236
- // so back-to-back FETCH is safe on Dovecot (see imapflow-connection.ts).
237
- if (!expectation.skipBodyStructure) {
238
- const refetched = await connection.fetchMessages([
239
- appendResult.uid,
240
- ]);
241
- assert.ok(
242
- refetched.length > 0,
243
- `BODYSTRUCTURE re-fetch should return a message for ${name}`,
244
- );
245
- assert.ok(
246
- refetched[0]?.bodyStructure != null,
247
- `BODYSTRUCTURE should be present for ${name}`,
248
- );
249
- }
248
+ const parsed = await simpleParser(rfc822);
250
249
 
251
- const parsed = await simpleParser(rfc822);
250
+ // Subject round-trips (proves we fetched the right message and
251
+ // any encoded-word headers decoded correctly).
252
+ assert.ok(
253
+ parsed.subject,
254
+ `Parsed subject should be present for ${name}`,
255
+ );
256
+ assert.ok(
257
+ parsed.subject?.includes(expectation.subjectIncludes),
258
+ `Subject "${parsed.subject}" should include "${expectation.subjectIncludes}" for ${name}`,
259
+ );
252
260
 
253
- // Subject round-trips (proves we fetched the right message and
254
- // any encoded-word headers decoded correctly).
261
+ // Body renders at least one of text/html is non-empty. This
262
+ // is the headline "body renders" assertion from #402.
263
+ const text = parsed.text ?? "";
264
+ const html = typeof parsed.html === "string" ? parsed.html : "";
265
+ assert.ok(
266
+ text.trim().length > 0 || html.trim().length > 0,
267
+ `Body should render (text or html non-empty) for ${name}`,
268
+ );
269
+
270
+ if (expectation.bodyContains) {
271
+ const haystack = bodyText(parsed).toLowerCase();
272
+ const needle = expectation.bodyContains.toLowerCase();
255
273
  assert.ok(
256
- parsed.subject,
257
- `Parsed subject should be present for ${name}`,
274
+ haystack.includes(needle),
275
+ `Body should contain "${expectation.bodyContains}" for ${name}`,
276
+ );
277
+ }
278
+
279
+ // Attachments are listable — every expected attachment appears
280
+ // in parsed.attachments with a matching filename and non-zero
281
+ // content length. This is the "attachments listable" half of
282
+ // the #402 contract.
283
+ const attachments = parsed.attachments ?? [];
284
+ for (const expectedAtt of expectation.attachments) {
285
+ const found = attachments.find(
286
+ (a) => a.filename === expectedAtt.filename,
258
287
  );
259
288
  assert.ok(
260
- parsed.subject?.includes(expectation.subjectIncludes),
261
- `Subject "${parsed.subject}" should include "${expectation.subjectIncludes}" for ${name}`,
289
+ found,
290
+ `Attachment "${expectedAtt.filename}" should be listable for ${name}; got [${attachments.map((a) => a.filename).join(", ")}]`,
262
291
  );
263
-
264
- // Body renders — at least one of text/html is non-empty. This
265
- // is the headline "body renders" assertion from #402.
266
- const text = parsed.text ?? "";
267
- const html = typeof parsed.html === "string" ? parsed.html : "";
268
292
  assert.ok(
269
- text.trim().length > 0 || html.trim().length > 0,
270
- `Body should render (text or html non-empty) for ${name}`,
293
+ (found.size ?? 0) > 0,
294
+ `Attachment "${expectedAtt.filename}" should have non-zero size for ${name}`,
271
295
  );
272
-
273
- if (expectation.bodyContains) {
274
- const haystack = bodyText(parsed).toLowerCase();
275
- const needle = expectation.bodyContains.toLowerCase();
276
- assert.ok(
277
- haystack.includes(needle),
278
- `Body should contain "${expectation.bodyContains}" for ${name}`,
279
- );
280
- }
281
-
282
- // Attachments are listable — every expected attachment appears
283
- // in parsed.attachments with a matching filename and non-zero
284
- // content length. This is the "attachments listable" half of
285
- // the #402 contract.
286
- const attachments = parsed.attachments ?? [];
287
- for (const expectedAtt of expectation.attachments) {
288
- const found = attachments.find(
289
- (a) => a.filename === expectedAtt.filename,
290
- );
291
- assert.ok(
292
- found,
293
- `Attachment "${expectedAtt.filename}" should be listable for ${name}; got [${attachments.map((a) => a.filename).join(", ")}]`,
296
+ if (expectedAtt.contentType) {
297
+ assert.equal(
298
+ found.contentType,
299
+ expectedAtt.contentType,
300
+ `Attachment "${expectedAtt.filename}" should have contentType "${expectedAtt.contentType}" for ${name}`,
294
301
  );
295
- assert.ok(
296
- (found.size ?? 0) > 0,
297
- `Attachment "${expectedAtt.filename}" should have non-zero size for ${name}`,
298
- );
299
- if (expectedAtt.contentType) {
300
- assert.equal(
301
- found.contentType,
302
- expectedAtt.contentType,
303
- `Attachment "${expectedAtt.filename}" should have contentType "${expectedAtt.contentType}" for ${name}`,
304
- );
305
- }
306
302
  }
307
- });
303
+ }
308
304
  });
309
- }
310
- },
311
- );
305
+ });
306
+ }
307
+ });
@@ -9,304 +9,284 @@ import {
9
9
  withMailfuzzConnection,
10
10
  } from "./test-helpers/mailfuzz-connection.js";
11
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
- });
12
+ describe("Mailbox listing (Dovecot)", {
13
+ skip: !process.env.RUN_E2E_TESTS,
14
+ }, () => {
15
+ test("lists mailboxes and INBOX exists", async () => {
16
+ await withMailfuzzConnection(async (connection) => {
17
+ const mailboxes = await connection.listMailboxes();
18
+
19
+ assert.ok(mailboxes.length > 0, "Should have at least one mailbox");
20
+
21
+ const inbox = mailboxes.find((m) => m.fullPath === "INBOX");
22
+ assert.ok(inbox, "INBOX should exist in mailbox list");
23
+ assert.equal(inbox.name, "INBOX");
28
24
  });
25
+ });
29
26
 
30
- test("returns mailbox status with uidValidity and uidNext", async () => {
31
- await withMailfuzzConnection(async (connection) => {
32
- const status = await connection.getMailboxStatus("INBOX");
27
+ test("returns mailbox status with uidValidity and uidNext", async () => {
28
+ await withMailfuzzConnection(async (connection) => {
29
+ const status = await connection.getMailboxStatus("INBOX");
33
30
 
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
- });
31
+ assert.ok(status.uidValidity > 0, "uidValidity should be positive");
32
+ assert.ok(status.uidNext > 0, "uidNext should be positive");
33
+ assert.ok(status.messages > 0, "INBOX should have messages");
38
34
  });
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");
35
+ });
36
+ });
52
37
 
53
- const uids = await connection.search(["ALL"]);
54
- assert.ok(uids.length > 0, "Should find messages");
38
+ describe("Message fetch (Dovecot)", {
39
+ skip: !process.env.RUN_E2E_TESTS,
40
+ }, () => {
41
+ test("fetches messages with ENVELOPE data", async () => {
42
+ await withMailfuzzConnection(async (connection) => {
43
+ const boxStatus = await connection.openBox("INBOX", true);
44
+ assert.ok(boxStatus.messages.total > 0, "INBOX should have messages");
55
45
 
56
- const fetchUids = uids.slice(0, 5);
57
- const messages = await connection.fetchMessages(fetchUids);
46
+ const uids = await connection.search(["ALL"]);
47
+ assert.ok(uids.length > 0, "Should find messages");
58
48
 
59
- assert.equal(
60
- messages.length,
61
- fetchUids.length,
62
- "Should fetch all requested messages",
49
+ const fetchUids = uids.slice(0, 5);
50
+ const messages = await connection.fetchMessages(fetchUids);
51
+
52
+ assert.equal(
53
+ messages.length,
54
+ fetchUids.length,
55
+ "Should fetch all requested messages",
56
+ );
57
+
58
+ for (const msg of messages) {
59
+ assert.ok(msg.uid > 0, "Message should have a UID");
60
+ assert.ok(msg.envelope, "Message should have envelope data");
61
+ assert.ok(msg.envelope.subject, "Envelope should have a subject");
62
+ assert.ok(msg.envelope.messageId, "Envelope should have a messageId");
63
+ assert.ok(msg.envelope.date, "Envelope should have a date");
64
+ assert.ok(
65
+ msg.envelope.from.length > 0,
66
+ "Envelope should have from addresses",
67
+ );
68
+ assert.ok(
69
+ msg.envelope.to.length > 0,
70
+ "Envelope should have to addresses",
63
71
  );
64
72
 
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
- }
73
+ for (const addr of msg.envelope.from) {
74
+ assert.ok(addr.mailbox, "From address should have mailbox");
75
+ assert.ok(addr.host, "From address should have host");
84
76
  }
85
- });
77
+ }
86
78
  });
79
+ });
87
80
 
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);
81
+ test("fetched message count matches status", async () => {
82
+ // Seed a mailbox this test owns so the SELECT-vs-SEARCH invariant holds
83
+ // regardless of what other e2e files do in parallel — the TOCTOU race
84
+ // that #501 papered over with --test-concurrency=1 is removed by
85
+ // isolation, not serialisation (#508).
86
+ const mailbox = uniqueMailboxName("E2E_count");
87
+ await withMailfuzzConnection(async (connection) => {
88
+ await seedMailbox(connection, mailbox, 3);
89
+ try {
90
+ const boxStatus = await connection.openBox(mailbox, true);
123
91
  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
92
 
129
- const bodyText = body.toString("utf-8");
130
- assert.ok(bodyText.length > 0, "Body text should not be empty");
131
- });
93
+ assert.equal(
94
+ uids.length,
95
+ boxStatus.messages.total,
96
+ "Search ALL count should match SELECT messages count",
97
+ );
98
+ } finally {
99
+ await connection.closeBox().catch(() => {});
100
+ await connection.deleteMailbox(mailbox).catch(() => {});
101
+ }
132
102
  });
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
- });
103
+ });
104
+ });
105
+
106
+ describe("Message bodies (Dovecot)", {
107
+ skip: !process.env.RUN_E2E_TESTS,
108
+ }, () => {
109
+ test("fetches message body with text content", async () => {
110
+ await withMailfuzzConnection(async (connection) => {
111
+ await connection.openBox("INBOX", true);
112
+ const uids = await connection.search(["ALL"]);
113
+ assert.ok(uids.length > 0, "Should have messages to fetch body for");
114
+
115
+ const body = await connection.fetchMessageBody(uids[0]);
116
+ assert.ok(body.length > 0, "Body should not be empty");
117
+
118
+ const bodyText = body.toString("utf-8");
119
+ assert.ok(bodyText.length > 0, "Body text should not be empty");
155
120
  });
121
+ });
122
+ });
156
123
 
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
- }
124
+ describe("Flag operations (Dovecot)", {
125
+ skip: !process.env.RUN_E2E_TESTS,
126
+ // These three subtests all mutate uids[0] of the same seeded mailbox, so
127
+ // they must run sequentially relative to each other. node:test already
128
+ // serialises subtests within a describe; concurrency: 1 pins that intent.
129
+ // Cross-file isolation (a dedicated seeded mailbox, not the shared INBOX)
130
+ // is what makes the whole suite safe to run in parallel — see #508.
131
+ concurrency: 1,
132
+ }, () => {
133
+ let targetUid: number;
134
+ const mailbox = uniqueMailboxName("E2E_flags");
135
+
136
+ before(async () => {
137
+ await withMailfuzzConnection(async (connection) => {
138
+ await seedMailbox(connection, mailbox, 3);
164
139
  });
140
+ });
165
141
 
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];
142
+ after(async () => {
143
+ const connection = createMailfuzzConnection();
144
+ await connection.connect();
145
+ await connection.deleteMailbox(mailbox).catch(() => {});
146
+ if (connection.isConnected) {
147
+ await connection.disconnect();
148
+ }
149
+ });
172
150
 
173
- await connection.removeFlags([targetUid], ["\\Seen"]);
174
- await connection.addFlags([targetUid], ["\\Seen"]);
151
+ test("sets \\Seen flag and verifies it persists", async () => {
152
+ await withMailfuzzConnection(async (connection) => {
153
+ await connection.openBox(mailbox, false);
154
+ const uids = await connection.search(["ALL"]);
155
+ assert.ok(uids.length > 0, "Should have messages");
156
+ targetUid = uids[0];
175
157
 
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
- });
158
+ await connection.removeFlags([targetUid], ["\\Seen"]);
159
+ await connection.addFlags([targetUid], ["\\Seen"]);
160
+
161
+ const messages = await connection.fetchMessages([targetUid]);
162
+ assert.equal(messages.length, 1);
163
+ assert.ok(
164
+ messages[0].flags.includes("\\Seen"),
165
+ "Message should have \\Seen flag",
166
+ );
183
167
  });
168
+ });
184
169
 
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];
170
+ test("clears \\Seen flag and verifies it is cleared", async () => {
171
+ await withMailfuzzConnection(async (connection) => {
172
+ await connection.openBox(mailbox, false);
173
+ const uids = await connection.search(["ALL"]);
174
+ targetUid = uids[0];
190
175
 
191
- await connection.addFlags([targetUid], ["\\Seen"]);
192
- await connection.removeFlags([targetUid], ["\\Seen"]);
176
+ await connection.addFlags([targetUid], ["\\Seen"]);
177
+ await connection.removeFlags([targetUid], ["\\Seen"]);
193
178
 
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
- });
179
+ const messages = await connection.fetchMessages([targetUid]);
180
+ assert.equal(messages.length, 1);
181
+ assert.ok(
182
+ !messages[0].flags.includes("\\Seen"),
183
+ "Message should not have \\Seen flag after removal",
184
+ );
201
185
  });
186
+ });
202
187
 
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];
188
+ test("sets custom keyword flag", async () => {
189
+ await withMailfuzzConnection(async (connection) => {
190
+ await connection.openBox(mailbox, false);
191
+ const uids = await connection.search(["ALL"]);
192
+ targetUid = uids[0];
208
193
 
209
- await connection.addFlags([targetUid], ["$label1"]);
194
+ await connection.addFlags([targetUid], ["$label1"]);
210
195
 
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
- );
196
+ const messages = await connection.fetchMessages([targetUid]);
197
+ assert.equal(messages.length, 1);
198
+ assert.ok(
199
+ messages[0].flags.includes("$label1"),
200
+ "Message should have custom keyword $label1",
201
+ );
217
202
 
218
- await connection.removeFlags([targetUid], ["$label1"]);
219
- });
203
+ await connection.removeFlags([targetUid], ["$label1"]);
220
204
  });
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
- });
205
+ });
206
+ });
207
+
208
+ describe("Message operations (Dovecot)", {
209
+ skip: !process.env.RUN_E2E_TESTS,
210
+ }, () => {
211
+ const testFolder = uniqueMailboxName("E2E_TestCopy");
212
+ // Copy/move source their messages from a mailbox this file owns rather than
213
+ // the shared INBOX, so nothing here appends to or mutates INBOX (#508).
214
+ const sourceFolder = uniqueMailboxName("E2E_TestSource");
215
+
216
+ before(async () => {
217
+ await withMailfuzzConnection(async (connection) => {
218
+ await seedMailbox(connection, sourceFolder, 3);
239
219
  });
220
+ });
240
221
 
241
- test("copy returns COPYUID response with uidMap entries", async () => {
242
- await withMailfuzzConnection(async (connection) => {
243
- await connection.createMailbox(testFolder);
222
+ test("copy returns COPYUID response with uidMap entries", async () => {
223
+ await withMailfuzzConnection(async (connection) => {
224
+ await connection.createMailbox(testFolder);
244
225
 
245
- await connection.openBox(sourceFolder, false);
246
- const uids = await connection.search(["ALL"]);
247
- assert.ok(uids.length > 0, "Should have messages to copy");
226
+ await connection.openBox(sourceFolder, false);
227
+ const uids = await connection.search(["ALL"]);
228
+ assert.ok(uids.length > 0, "Should have messages to copy");
248
229
 
249
- const result = await connection.copyMessages([uids[0]], testFolder);
230
+ const result = await connection.copyMessages([uids[0]], testFolder);
250
231
 
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
- });
232
+ assert.equal(result.destination, testFolder);
233
+ assert.ok(
234
+ result.uidMap.size > 0,
235
+ "COPYUID response should have uidMap entries",
236
+ );
237
+ assert.ok(
238
+ result.uidMap.has(uids[0]),
239
+ "uidMap should contain the source UID",
240
+ );
261
241
  });
242
+ });
262
243
 
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
- );
244
+ test("move returns COPYUID response with uidMap entries", async () => {
245
+ const moveFolder = uniqueMailboxName("E2E_TestMove");
281
246
 
282
- await connection.openBox(sourceFolder, false);
283
- const result = await connection.moveMessages(
284
- [appendResult.uid],
285
- moveFolder,
286
- );
247
+ await withMailfuzzConnection(async (connection) => {
248
+ await connection.createMailbox(moveFolder);
249
+
250
+ const appendResult = await connection.append(
251
+ sourceFolder,
252
+ [
253
+ "From: test@example.com",
254
+ "To: vmail@localhost",
255
+ "Subject: Move test message",
256
+ `Date: ${new Date().toUTCString()}`,
257
+ `Message-ID: <move-test-${Date.now()}@test.example.com>`,
258
+ "",
259
+ "Body for move test",
260
+ ].join("\r\n"),
261
+ );
287
262
 
288
- assert.equal(result.destination, moveFolder);
289
- assert.ok(
290
- result.uidMap.size > 0,
291
- "COPYUID response should have uidMap entries",
292
- );
263
+ await connection.openBox(sourceFolder, false);
264
+ const result = await connection.moveMessages(
265
+ [appendResult.uid],
266
+ moveFolder,
267
+ );
293
268
 
294
- await connection.closeBox();
295
- await connection.deleteMailbox(moveFolder);
296
- });
297
- });
269
+ assert.equal(result.destination, moveFolder);
270
+ assert.ok(
271
+ result.uidMap.size > 0,
272
+ "COPYUID response should have uidMap entries",
273
+ );
298
274
 
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
- }
275
+ await connection.closeBox();
276
+ await connection.deleteMailbox(moveFolder);
307
277
  });
308
- },
309
- );
278
+ });
279
+
280
+ after(async () => {
281
+ const connection = createMailfuzzConnection();
282
+ await connection.connect();
283
+ await connection.deleteMailbox(testFolder).catch(() => {});
284
+ await connection.deleteMailbox(sourceFolder).catch(() => {});
285
+ if (connection.isConnected) {
286
+ await connection.disconnect();
287
+ }
288
+ });
289
+ });
310
290
 
311
291
  describe("Mailbox CRUD (Dovecot)", { skip: !process.env.RUN_E2E_TESTS }, () => {
312
292
  const crudFolder = `CrudTest_${Date.now()}`;
@@ -134,194 +134,183 @@ const getMailboxUids = async (mailbox: string): Promise<number[]> => {
134
134
  return uids;
135
135
  };
136
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
- );
137
+ describe("ImapFlowConnection integration tests", {
138
+ skip: !process.env.RUN_INTEG_TESTS,
139
+ }, () => {
140
+ describe("moveMessages", () => {
141
+ test("moves a message to another mailbox", async () => {
142
+ // Seed the test message first
143
+ const subject = `Move test ${Date.now()}`;
144
+ const uid = await seedTestMessage("INBOX", subject);
145
+
146
+ // Count messages in Work before
147
+ const workCountBefore = await countMessagesInMailbox("Work");
148
+
149
+ await withConnection(async (connection) => {
150
+ // Open INBOX
151
+ await connection.openBox("INBOX", false);
152
+
153
+ // Move to Work folder
154
+ const result = await connection.moveMessages([uid], "Work");
155
+
156
+ // mokapi doesn't return uidMap, but should return destination
157
+ assert.equal(result.destination, "Work", "Destination should be Work");
179
158
  });
180
159
 
181
- test("returns empty uidMap when no UIDs provided", async () => {
182
- await withConnection(async (connection) => {
183
- await connection.openBox("INBOX", false);
160
+ // Verify message moved using fresh connections (mokapi caching workaround)
161
+ const inboxUids = await getMailboxUids("INBOX");
162
+ assert.ok(
163
+ !inboxUids.includes(uid),
164
+ "Original UID should not be in INBOX",
165
+ );
166
+
167
+ const workCountAfter = await countMessagesInMailbox("Work");
168
+ assert.ok(
169
+ workCountAfter > workCountBefore,
170
+ `Work folder should have more messages (before: ${workCountBefore}, after: ${workCountAfter})`,
171
+ );
172
+ });
184
173
 
185
- const result = await connection.moveMessages([], "Work");
174
+ test("returns empty uidMap when no UIDs provided", async () => {
175
+ await withConnection(async (connection) => {
176
+ await connection.openBox("INBOX", false);
186
177
 
187
- assert.equal(result.destination, "Work");
188
- assert.equal(result.uidMap.size, 0);
189
- });
178
+ const result = await connection.moveMessages([], "Work");
179
+
180
+ assert.equal(result.destination, "Work");
181
+ assert.equal(result.uidMap.size, 0);
190
182
  });
191
183
  });
184
+ });
192
185
 
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
- );
186
+ describe("copyMessages", () => {
187
+ test("copies a message to another mailbox", async () => {
188
+ // Seed the test message first
189
+ const subject = `Copy test ${Date.now()}`;
190
+ const uid = await seedTestMessage("INBOX", subject);
222
191
 
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})`,
192
+ // Count messages in Archive before
193
+ const archiveCountBefore = await countMessagesInMailbox("Archive");
194
+
195
+ await withConnection(async (connection) => {
196
+ // Open INBOX
197
+ await connection.openBox("INBOX", false);
198
+
199
+ // Copy to Archive folder
200
+ const result = await connection.copyMessages([uid], "Archive");
201
+
202
+ assert.equal(
203
+ result.destination,
204
+ "Archive",
205
+ "Destination should be Archive",
228
206
  );
207
+ });
229
208
 
230
- // Cleanup: delete the original from INBOX
231
- await withConnection(async (connection) => {
232
- await connection.openBox("INBOX", false);
233
- await connection.deleteMessages([uid]);
234
- });
209
+ // Verify original message is still in INBOX
210
+ const inboxUids = await getMailboxUids("INBOX");
211
+ assert.ok(
212
+ inboxUids.includes(uid),
213
+ "Original UID should still be in INBOX",
214
+ );
215
+
216
+ // Verify copy is in Archive (count increased)
217
+ const archiveCountAfter = await countMessagesInMailbox("Archive");
218
+ assert.ok(
219
+ archiveCountAfter > archiveCountBefore,
220
+ `Archive folder should have more messages (before: ${archiveCountBefore}, after: ${archiveCountAfter})`,
221
+ );
222
+
223
+ // Cleanup: delete the original from INBOX
224
+ await withConnection(async (connection) => {
225
+ await connection.openBox("INBOX", false);
226
+ await connection.deleteMessages([uid]);
235
227
  });
228
+ });
236
229
 
237
- test("returns empty uidMap when no UIDs provided", async () => {
238
- await withConnection(async (connection) => {
239
- await connection.openBox("INBOX", false);
230
+ test("returns empty uidMap when no UIDs provided", async () => {
231
+ await withConnection(async (connection) => {
232
+ await connection.openBox("INBOX", false);
240
233
 
241
- const result = await connection.copyMessages([], "Archive");
234
+ const result = await connection.copyMessages([], "Archive");
242
235
 
243
- assert.equal(result.destination, "Archive");
244
- assert.equal(result.uidMap.size, 0);
245
- });
236
+ assert.equal(result.destination, "Archive");
237
+ assert.equal(result.uidMap.size, 0);
246
238
  });
247
239
  });
240
+ });
241
+
242
+ describe("deleteMessages", () => {
243
+ test("permanently deletes a message", async () => {
244
+ // Seed the test message first
245
+ const subject = `Delete test ${Date.now()}`;
246
+ const uid = await seedTestMessage("INBOX", subject);
248
247
 
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
- });
248
+ // Verify message exists
249
+ let uids = await getMailboxUids("INBOX");
250
+ assert.ok(uids.includes(uid), "Message should exist before delete");
251
+
252
+ await withConnection(async (connection) => {
253
+ // Open INBOX
254
+ await connection.openBox("INBOX", false);
255
+
256
+ // Delete the message
257
+ const deleted = await connection.deleteMessages([uid]);
258
+ // mokapi may return true instead of count
259
+ assert.ok(deleted, "Should report message deleted");
260
+
261
+ // mokapi requires mailbox close for delete to persist
262
+ await connection.closeBox();
263
+
264
+ // Verify within same connection
265
+ await connection.openBox("INBOX", true);
266
+ uids = await connection.search(["ALL"]);
267
+ assert.ok(!uids.includes(uid), "Message should not exist after delete");
279
268
  });
269
+ });
280
270
 
281
- test("returns 0 when no UIDs provided", async () => {
282
- await withConnection(async (connection) => {
283
- await connection.openBox("INBOX", false);
271
+ test("returns 0 when no UIDs provided", async () => {
272
+ await withConnection(async (connection) => {
273
+ await connection.openBox("INBOX", false);
284
274
 
285
- const deleted = await connection.deleteMessages([]);
286
- assert.equal(deleted, 0);
287
- });
275
+ const deleted = await connection.deleteMessages([]);
276
+ assert.equal(deleted, 0);
288
277
  });
289
278
  });
279
+ });
280
+
281
+ describe("move to Trash workflow", () => {
282
+ test("moves message to Trash folder", async () => {
283
+ // Seed the test message first
284
+ const subject = `Trash test ${Date.now()}`;
285
+ const uid = await seedTestMessage("INBOX", subject);
290
286
 
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})`,
287
+ // Count messages in Trash before
288
+ const trashCountBefore = await countMessagesInMailbox("Trash");
289
+
290
+ await withConnection(async (connection) => {
291
+ // Open INBOX
292
+ await connection.openBox("INBOX", false);
293
+
294
+ // Move to Trash
295
+ const result = await connection.moveMessages([uid], "Trash");
296
+
297
+ assert.equal(
298
+ result.destination,
299
+ "Trash",
300
+ "Destination should be Trash",
323
301
  );
324
302
  });
303
+
304
+ // Verify message is no longer in INBOX
305
+ const inboxUids = await getMailboxUids("INBOX");
306
+ assert.ok(!inboxUids.includes(uid), "Message should not be in INBOX");
307
+
308
+ // Verify message is in Trash (count increased)
309
+ const trashCountAfter = await countMessagesInMailbox("Trash");
310
+ assert.ok(
311
+ trashCountAfter > trashCountBefore,
312
+ `Trash folder should have more messages (before: ${trashCountBefore}, after: ${trashCountAfter})`,
313
+ );
325
314
  });
326
- },
327
- );
315
+ });
316
+ });
@@ -1,7 +1,4 @@
1
- import type {
2
- IUnitOfWork,
3
- UnitOfWorkRepositories,
4
- } from "@remit/data-ports";
1
+ import type { IUnitOfWork, UnitOfWorkRepositories } from "@remit/data-ports";
5
2
 
6
3
  /**
7
4
  * Runs the write set against fixed repositories with no surrounding