@remit/mailbox-service 0.0.111 → 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 +156 -235
  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 +5 -4
  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/adversarial-mime.e2e.test.ts +0 -307
  74. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  75. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  76. package/src/body-sync-category-denormalize.test.ts +0 -352
  77. package/src/body-sync-category-override.test.ts +0 -501
  78. package/src/body-sync-category-writeonce.test.ts +0 -234
  79. package/src/body-sync-filter.test.ts +0 -237
  80. package/src/body-sync-message-gone.test.ts +0 -139
  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
@@ -1,134 +1,97 @@
1
- /**
2
- * Unit tests for ImapFlowConnection.connect() retry semantics.
3
- *
4
- * Verifies the retry policy without touching a real IMAP server:
5
- * - Network errors (e.g. ECONNREFUSED) are retried with backoff before the
6
- * classified MailConnectionError("network") is finally thrown.
7
- * - Auth errors are thrown immediately and NEVER retried.
8
- *
9
- * The private `attemptConnect` and `sleep` instance methods are stubbed so the
10
- * test exercises the loop logic directly and runs instantly.
11
- */
12
-
13
1
  import assert from "node:assert/strict";
14
- import { describe, it, mock } from "node:test";
2
+ import { createServer, type Server, type Socket } from "node:net";
3
+ import { after, describe, it } from "node:test";
15
4
  import { ImapFlowConnection } from "./imapflow-connection.js";
16
5
  import { MailConnectionError } from "./types.js";
17
6
 
18
- const baseConfig = {
19
- host: "localhost",
20
- port: 143,
21
- user: "alice@example.com",
22
- credentials: { kind: "password" as const, password: "s3cr3t" },
23
- tls: false,
24
- };
25
-
26
- type WithPrivates = {
27
- attemptConnect: () => Promise<void>;
28
- sleep: (ms: number) => Promise<void>;
29
- };
30
-
31
- const makeErrnoError = (code: string): Error => {
32
- const err = new Error(`connect ${code}`);
33
- return Object.assign(err, { code });
34
- };
7
+ const servers: Server[] = [];
8
+ const sockets: Socket[] = [];
35
9
 
36
- describe("ImapFlowConnection.connect() retry policy", () => {
37
- it("retries network errors (ECONNREFUSED) 3x with backoff, then throws classified network error", async () => {
38
- const connection = new ImapFlowConnection(baseConfig);
39
- const priv = connection as unknown as WithPrivates;
10
+ after(() => {
11
+ for (const socket of sockets) socket.destroy();
12
+ for (const server of servers) server.close();
13
+ });
40
14
 
41
- // Always fail with a network-level error.
42
- const attempt = mock.method(priv, "attemptConnect", async () => {
43
- throw makeErrnoError("ECONNREFUSED");
15
+ const listen = (server: Server): Promise<number> =>
16
+ new Promise((resolve) => {
17
+ servers.push(server);
18
+ server.listen(0, "127.0.0.1", () => {
19
+ const address = server.address();
20
+ resolve(typeof address === "object" && address ? address.port : 0);
44
21
  });
45
- // Stub sleep so the backoff does not actually delay the test.
46
- mock.method(priv, "sleep", async () => {});
47
-
48
- await assert.rejects(
49
- () => connection.connect(),
50
- (error: unknown) => {
51
- assert.ok(
52
- error instanceof MailConnectionError,
53
- "final error should be a MailConnectionError",
54
- );
55
- assert.equal(error.kind, "network");
56
- // The host:port endpoint must appear in the message so a DNS /
57
- // connect failure is self-describing in the logs (issue #835).
58
- assert.match(error.message, /localhost:143/);
59
- return true;
60
- },
61
- );
62
-
63
- // 3 attempts total (the retry loop runs maxRetries times).
64
- assert.equal(attempt.mock.callCount(), 3);
65
22
  });
66
23
 
67
- it("succeeds on a later attempt when a transient network error clears", async () => {
68
- const connection = new ImapFlowConnection(baseConfig);
69
- const priv = connection as unknown as WithPrivates;
70
-
71
- let calls = 0;
72
- const attempt = mock.method(priv, "attemptConnect", async () => {
73
- calls += 1;
74
- if (calls < 2) {
75
- throw makeErrnoError("ECONNRESET");
24
+ const refusingLogin = async (refusal: string) => {
25
+ const state = { connections: 0 };
26
+ const server = createServer((socket) => {
27
+ state.connections++;
28
+ sockets.push(socket);
29
+ socket.write("* OK [CAPABILITY IMAP4rev1] ready\r\n");
30
+ let buffered = "";
31
+ socket.on("data", (chunk) => {
32
+ buffered += chunk.toString();
33
+ let end = buffered.indexOf("\r\n");
34
+ while (end !== -1) {
35
+ const line = buffered.slice(0, end);
36
+ buffered = buffered.slice(end + 2);
37
+ const [tag, command] = line.split(" ");
38
+ const verb = command?.toUpperCase();
39
+ if (verb === "CAPABILITY") {
40
+ socket.write(`* CAPABILITY IMAP4rev1\r\n${tag} OK done\r\n`);
41
+ } else if (verb === "LOGIN") {
42
+ socket.write(`${tag} NO [AUTHENTICATIONFAILED] ${refusal}\r\n`);
43
+ } else if (verb === "LOGOUT") {
44
+ socket.end(`* BYE\r\n${tag} OK bye\r\n`);
45
+ } else {
46
+ socket.write(`${tag} BAD unknown\r\n`);
47
+ }
48
+ end = buffered.indexOf("\r\n");
76
49
  }
77
- // Second attempt succeeds.
78
50
  });
79
- mock.method(priv, "sleep", async () => {});
80
-
81
- await connection.connect();
82
-
83
- assert.equal(attempt.mock.callCount(), 2);
51
+ socket.on("error", () => {});
84
52
  });
53
+ return { port: await listen(server), state };
54
+ };
85
55
 
86
- it("does NOT retry auth errors — throws immediately on attempt 1", async () => {
87
- const connection = new ImapFlowConnection(baseConfig);
88
- const priv = connection as unknown as WithPrivates;
89
-
90
- const attempt = mock.method(priv, "attemptConnect", async () => {
91
- throw new Error("Invalid credentials");
92
- });
93
- const sleep = mock.method(priv, "sleep", async () => {});
56
+ const closedPort = async (): Promise<number> => {
57
+ const server = createServer();
58
+ const port = await listen(server);
59
+ await new Promise((resolve) => server.close(resolve));
60
+ return port;
61
+ };
94
62
 
95
- await assert.rejects(
96
- () => connection.connect(),
97
- (error: unknown) => {
98
- assert.ok(error instanceof MailConnectionError);
99
- assert.equal(error.kind, "auth");
100
- return true;
101
- },
102
- );
63
+ const connectTo = (port: number) =>
64
+ new ImapFlowConnection({
65
+ host: "127.0.0.1",
66
+ port,
67
+ user: "alice@example.com",
68
+ credentials: { kind: "password", password: "s3cr3t" },
69
+ tls: false,
70
+ }).connect();
103
71
 
104
- // Only one attempt, no backoff sleep.
105
- assert.equal(attempt.mock.callCount(), 1);
106
- assert.equal(sleep.mock.callCount(), 0);
72
+ describe("ImapFlowConnection.connect() retry policy", () => {
73
+ it("retries a refused connection, then names the endpoint in a network error", async () => {
74
+ const port = await closedPort();
75
+
76
+ await assert.rejects(connectTo(port), (error: unknown) => {
77
+ assert.ok(error instanceof MailConnectionError);
78
+ assert.equal(error.kind, "network");
79
+ assert.match(error.message, new RegExp(`127\\.0\\.0\\.1:${port}`));
80
+ return true;
81
+ });
107
82
  });
108
83
 
109
- it("carries the server's own refusal, which is not always a re-auth", async () => {
110
- const connection = new ImapFlowConnection(baseConfig);
111
- const priv = connection as unknown as WithPrivates;
84
+ it("does not retry a refused login and carries the server's own words", async () => {
85
+ const { port, state } = await refusingLogin(
86
+ "LOGIN failed. SmtpClientAuthentication is disabled for the Tenant.",
87
+ );
112
88
 
113
- // imapflow's shape for a tagged NO: the message is its own, the server's
114
- // words are on `responseText`. Signing in again fixes nothing here — the
115
- // tenant has IMAP switched off — so the text has to reach the user.
116
- mock.method(priv, "attemptConnect", async () => {
117
- throw Object.assign(new Error("Command failed"), {
118
- authenticationFailed: true,
119
- responseText:
120
- "LOGIN failed. User is authenticated but not connected. SmtpClientAuthentication is disabled for the Tenant.",
121
- });
89
+ await assert.rejects(connectTo(port), (error: unknown) => {
90
+ assert.ok(error instanceof MailConnectionError);
91
+ assert.equal(error.kind, "auth");
92
+ assert.match(error.message, /SmtpClientAuthentication is disabled/);
93
+ return true;
122
94
  });
123
-
124
- await assert.rejects(
125
- () => connection.connect(),
126
- (error: unknown) => {
127
- assert.ok(error instanceof MailConnectionError);
128
- assert.equal(error.kind, "auth");
129
- assert.match(error.message, /SmtpClientAuthentication is disabled/);
130
- return true;
131
- },
132
- );
95
+ assert.equal(state.connections, 1);
133
96
  });
134
97
  });
@@ -467,3 +467,37 @@ describe("ImapFlowConnection message envelopes (issue #72)", () => {
467
467
  assert.equal(message?.envelope?.from[0]?.mailbox, "sender");
468
468
  });
469
469
  });
470
+
471
+ describe("ImapFlowConnection.fetchMessageBodies — a body the server no longer has", () => {
472
+ const fetchBodies = async (
473
+ rows: Array<Record<string, unknown>>,
474
+ ): Promise<number[]> => {
475
+ const connection = buildConnectionWithClient({
476
+ mailbox: fakeMailbox("INBOX", 1),
477
+ mailboxOpen: async (path: string) => fakeMailbox(path, 1),
478
+ fetch: () =>
479
+ (async function* () {
480
+ for (const row of rows) yield row;
481
+ })(),
482
+ });
483
+ Object.assign(connection as unknown as Record<string, unknown>, {
484
+ currentMailbox: "INBOX",
485
+ });
486
+ const uids: number[] = [];
487
+ for await (const { uid, source } of connection.fetchMessageBodies([1, 2])) {
488
+ source.resume();
489
+ uids.push(uid);
490
+ }
491
+ return uids;
492
+ };
493
+
494
+ it("leaves out a row whose source is false instead of failing the batch", async () => {
495
+ assert.deepStrictEqual(
496
+ await fetchBodies([
497
+ { uid: 1, source: false },
498
+ { uid: 2, source: Buffer.from("Subject: x\r\n\r\nhi") },
499
+ ]),
500
+ [2],
501
+ );
502
+ });
503
+ });
@@ -805,10 +805,11 @@ export class ImapFlowConnection {
805
805
  try {
806
806
  for await (const msg of fetchIterator) {
807
807
  // imapflow can yield a row with an undefined uid or no source on
808
- // back-to-back FETCH calls; skip it rather than crash the batch — the
809
- // caller treats any UID it never sees as failed and re-enqueues it.
810
- // See #408.
811
- if (msg.uid == null || msg.source == null) {
808
+ // back-to-back FETCH calls, and Dovecot answers `source: false` for a
809
+ // message another client expunged; skip it rather than crash the batch
810
+ // — the caller treats any UID it never sees as failed and re-enqueues
811
+ // it. See #408.
812
+ if (msg.uid == null || !msg.source) {
812
813
  continue;
813
814
  }
814
815
 
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export {
27
27
  export {
28
28
  type BodySyncFolder,
29
29
  type BodySyncLogger,
30
+ type BodySyncRoundFolder,
30
31
  BodySyncService,
31
32
  type ConnectionGetter,
32
33
  extractSnippet,
@@ -81,16 +82,18 @@ export {
81
82
  testSmtpConnection,
82
83
  } from "./connection-test.js";
83
84
  export {
84
- type AnchorDriftDeps,
85
- type AnchorEmbedder,
86
- refreshAnchorForEmbedder,
87
- } from "./filters/anchor-drift.js";
85
+ type CreatedFilter,
86
+ createFilter,
87
+ type FilterPage,
88
+ type FilterPatch,
89
+ listFilters,
90
+ type NewFilter,
91
+ resolveFilterUpdate,
92
+ updateFilter,
93
+ } from "./filter-operations.js";
88
94
  export { extractListId, normalizeListId } from "./filters/list-id.js";
89
95
  export {
90
- buildMatchText,
91
96
  clauseMatches,
92
- cosineSimilarity,
93
- DEFAULT_SEMANTIC_MATCH_THRESHOLD,
94
97
  type FilterMessage,
95
98
  type LiteralClauseNarrowing,
96
99
  literalClausesMatch,
@@ -103,7 +106,6 @@ export {
103
106
  type FilterDecision,
104
107
  type FilterLogger,
105
108
  FilterPipeline,
106
- type MessageEmbedder,
107
109
  } from "./filters/pipeline.js";
108
110
  export {
109
111
  type FlagPushConfig,
@@ -126,6 +128,17 @@ export {
126
128
  type UpdateFlagsInput,
127
129
  type UpdateFlagsResult,
128
130
  } from "./flag-queue.js";
131
+ export {
132
+ type CreateFolderDeps,
133
+ changeFolderPath,
134
+ createFolder,
135
+ type FolderPathChange,
136
+ type FolderPathDeps,
137
+ type FolderPathOutcome,
138
+ type NewFolder,
139
+ type NewFolderOutcome,
140
+ ownedFolder,
141
+ } from "./folder-operations.js";
129
142
  export {
130
143
  type FolderPlacementLookup,
131
144
  resolveAccountFolderPlacements,
@@ -188,6 +201,7 @@ export {
188
201
  type CursorRebuildSnapshot,
189
202
  matchCursorRebuild,
190
203
  } from "./mailbox-cursor-rebuild.js";
204
+ export { folderInFlight } from "./mailbox-intent.js";
191
205
  export {
192
206
  type CreateMailboxInput,
193
207
  type DeleteMailboxInput,
@@ -244,12 +258,27 @@ export {
244
258
  parseMessageContent,
245
259
  } from "./message-parser.js";
246
260
  export { isMessageGoneFromOpenMailbox } from "./message-presence.js";
261
+ export {
262
+ type BodyPartMaterializer,
263
+ type BodySyncCue,
264
+ type MaterializeLogger,
265
+ type MaterializeResult,
266
+ materializeBodyParts,
267
+ } from "./message-read.js";
247
268
  export {
248
269
  type ImapConnectionFactory,
249
270
  MessageSyncService,
250
271
  type SyncedMessage,
251
272
  type SyncMessagesResult,
252
273
  } from "./message-sync.js";
274
+ export {
275
+ type OrganizeJobDeps,
276
+ type OrganizeJobLogger,
277
+ type OrganizeJobOwner,
278
+ type OrganizeJobPredicate,
279
+ type OrganizeJobRequest,
280
+ queueOrganizeJob,
281
+ } from "./organize-job.js";
253
282
  export {
254
283
  type CompleteOutboxAttachmentInput,
255
284
  type CompleteOutboxAttachmentOutcome,
@@ -275,6 +304,13 @@ export {
275
304
  OutboxQueueService,
276
305
  type UpdateDraftInput,
277
306
  } from "./outbox-queue.js";
307
+ export {
308
+ folderInAccount,
309
+ type MessageOwnershipClient,
310
+ ownedAccount,
311
+ ownedMessages,
312
+ settledFolder,
313
+ } from "./ownership.js";
278
314
  export {
279
315
  type PlacementMoveConfig,
280
316
  type PlacementMoveLogger,
@@ -293,6 +329,7 @@ export {
293
329
  isPlacementUnsettled,
294
330
  type PlacementBinding,
295
331
  placementBindingOf,
332
+ uidMailboxIdOf,
296
333
  } from "./placement-settled.js";
297
334
  export {
298
335
  type QuarantineContext,
@@ -304,6 +341,15 @@ export {
304
341
  resolveMailboxRole,
305
342
  shapeFromMessageData,
306
343
  } from "./quarantine.js";
344
+ export {
345
+ type AccessMode,
346
+ accept,
347
+ invalid,
348
+ type Refusable,
349
+ type Refusal,
350
+ type Refused,
351
+ refuse,
352
+ } from "./refusal.js";
307
353
  export {
308
354
  type RestoreSourcePlacementDeps,
309
355
  type RestoreSourcePlacementInput,
@@ -325,10 +371,11 @@ export {
325
371
  SpamReportService,
326
372
  } from "./spam-report.js";
327
373
  export {
328
- reconcileStaleMessage,
329
- type StaleMessageReconcileDeps,
330
- type StaleMessageReconcileResult,
331
- } from "./stale-message-reconcile.js";
374
+ type ServerUids,
375
+ type SyncWarningContext,
376
+ type SyncWarningRound,
377
+ SyncWarningService,
378
+ } from "./sync-warning.js";
332
379
  export {
333
380
  buildThreadMessageMoveRevert,
334
381
  buildThreadMessageTrashUpdate,