@remit/mailbox-service 0.0.110 → 0.0.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +226 -200
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +19 -15
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/types.ts +1 -1
  74. package/src/adversarial-mime.e2e.test.ts +0 -307
  75. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  76. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  77. package/src/body-sync-category-denormalize.test.ts +0 -352
  78. package/src/body-sync-category-override.test.ts +0 -501
  79. package/src/body-sync-category-writeonce.test.ts +0 -234
  80. package/src/body-sync-filter.test.ts +0 -237
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -1,419 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type {
4
- IAddressRepository,
5
- IEnvelopeRepository,
6
- IMailboxRepository,
7
- IMessageRepository,
8
- IThreadMessageRepository,
9
- MailboxItem,
10
- MessageItem,
11
- ThreadMessageItem,
12
- UpdateThreadMessageInput,
13
- } from "@remit/data-ports";
14
- import { deriveMessageId } from "@remit/data-ports/id";
15
- import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
16
- import type { ManagedConnectionFactory } from "./connection-factory.js";
17
- import { type DepartureVerdicts, placementKey } from "./external-move.js";
18
- import { type AccountFolderRoles, MessageSyncService } from "./message-sync.js";
19
- import { folderRoles, NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
20
- import type { ImapEnvelope, ImapMessage } from "./types.js";
21
-
22
- const stub = <T>(): T => ({}) as T;
23
-
24
- const envelope: ImapEnvelope = {
25
- date: new Date(0).toISOString(),
26
- messageId: "<root@example.com>",
27
- subject: "Subject",
28
- from: [{ name: "Pharma Deals", mailbox: "sales", host: "pharma.example" }],
29
- sender: [],
30
- replyTo: [],
31
- to: [{ name: "", mailbox: "victim", host: "ischen.nl" }],
32
- cc: [],
33
- bcc: [],
34
- inReplyTo: "",
35
- };
36
-
37
- const mailboxAt = (fullPath: string, specialUse?: string[]): MailboxItem =>
38
- ({
39
- mailboxId: `mbx-${fullPath}`,
40
- fullPath,
41
- hierarchyDelimiter: "/",
42
- ...(specialUse ? { specialUse } : {}),
43
- }) as MailboxItem;
44
-
45
- const INBOX = mailboxAt("INBOX");
46
- const JUNK = mailboxAt("INBOX/Rubbish");
47
- const TRASH = mailboxAt("Trash");
48
- const ALL_MAIL = mailboxAt("[Gmail]/All Mail", ["All"]);
49
-
50
- /**
51
- * `pending` is what an ordinary inbound row carries: `upsertWithStatus` is
52
- * called without a `syncStatus` and the repository defaults to it, and nothing
53
- * on the sync path ever promotes it (#1096). The fixture states the reachable
54
- * state, not the one the gate used to demand.
55
- */
56
- const storedIn = (
57
- mailbox: MailboxItem,
58
- overrides: Partial<MessageItem> = {},
59
- ): MessageItem =>
60
- ({
61
- mailboxId: mailbox.mailboxId,
62
- uid: 7,
63
- status: MessageStatus.active,
64
- syncStatus: MessageSyncStatus.pending,
65
- ...overrides,
66
- }) as MessageItem;
67
-
68
- const threadRowIn = (mailbox: MailboxItem): ThreadMessageItem =>
69
- ({
70
- threadMessageId: "tm-1",
71
- accountConfigId: "cfg-1",
72
- mailboxId: mailbox.mailboxId,
73
- uid: 7,
74
- sentDate: 0,
75
- isRead: false,
76
- isDeleted: mailbox === TRASH,
77
- hasStars: false,
78
- hasAttachment: false,
79
- }) as ThreadMessageItem;
80
-
81
- interface Observed {
82
- repointedTo: Array<{ mailboxId: string; uid: number }>;
83
- threadUpdates: UpdateThreadMessageInput[];
84
- reconciled: string[];
85
- owned: boolean;
86
- unresolved: boolean;
87
- }
88
-
89
- /**
90
- * What `confirmDepartures` had answered about the stored placement by the time
91
- * the batch was saved (#1146).
92
- *
93
- * - `departed` — the source folder no longer holds it: a move another client made.
94
- * - `held` — the source folder still holds it: the labelled message, in two
95
- * folders at once.
96
- * - `none` — no verdict at all: the source could not be asked this round.
97
- * - `stale` — a verdict, but for a placement this row no longer has, because it
98
- * moved between the probe and the save.
99
- */
100
- type Verdict = "departed" | "held" | "none" | "stale";
101
-
102
- /**
103
- * Sync one message out of `sighting`, against a database that already holds it
104
- * under `stored`. The account's Junk folder is `JUNK` and its Trash is `TRASH`,
105
- * so the sighting's own role follows from which mailbox it is.
106
- */
107
- const sync = async (
108
- sighting: MailboxItem,
109
- stored: MessageItem,
110
- threadRow: ThreadMessageItem | null = threadRowIn(INBOX),
111
- copiesHere: MessageItem[] = [],
112
- verdict: Verdict = "departed",
113
- ): Promise<Observed> => {
114
- const observed: Observed = {
115
- repointedTo: [],
116
- threadUpdates: [],
117
- reconciled: [],
118
- owned: false,
119
- unresolved: false,
120
- };
121
-
122
- const messageService = {
123
- upsertWithStatus: async () => ({ item: stored, created: false }),
124
- get: async () => copiesHere,
125
- updateUid: async (_id: string, uid: number, mailboxId: string) => {
126
- observed.repointedTo.push({ mailboxId, uid });
127
- return stored;
128
- },
129
- } as unknown as IMessageRepository;
130
-
131
- const threadMessageService = {
132
- findByMessageId: async () => threadRow,
133
- update: async (
134
- _config: string,
135
- _id: string,
136
- input: UpdateThreadMessageInput,
137
- ) => {
138
- observed.threadUpdates.push(input);
139
- return threadRow as ThreadMessageItem;
140
- },
141
- create: async (input: unknown) => input as ThreadMessageItem,
142
- } as unknown as IThreadMessageRepository;
143
-
144
- const envelopeService = {
145
- upsertEnvelope: async () => {},
146
- upsertBodyParts: async () => {},
147
- } as unknown as IEnvelopeRepository;
148
-
149
- const addressService = {
150
- upsertCorrespondentAddress: async () => {},
151
- upsertJunkAddress: async () => {},
152
- upsertAddress: async () => {},
153
- upsertEnvelopeAddress: async () => {},
154
- reconcileJunkOnlyForMessage: async (messageId: string) => {
155
- observed.reconciled.push(messageId);
156
- },
157
- } as unknown as IAddressRepository;
158
-
159
- const service = new MessageSyncService(
160
- stub<ManagedConnectionFactory>(),
161
- stub<IMailboxRepository>(),
162
- folderRoles({
163
- junkMailboxId: JUNK.mailboxId,
164
- trashMailboxId: TRASH.mailboxId,
165
- }),
166
- messageService,
167
- envelopeService,
168
- addressService,
169
- threadMessageService,
170
- );
171
-
172
- const msg = {
173
- uid: 42,
174
- seq: 1,
175
- size: 100,
176
- internalDate: new Date(0),
177
- flags: [],
178
- envelope,
179
- } as unknown as ImapMessage;
180
-
181
- const answeredFor = placementKey(
182
- deriveMessageId("acct-1", envelope.messageId),
183
- stored.mailboxId,
184
- verdict === "stale" ? stored.uid + 1 : stored.uid,
185
- );
186
- const departures: DepartureVerdicts = {
187
- departed:
188
- verdict === "held" || verdict === "none"
189
- ? new Set()
190
- : new Set([answeredFor]),
191
- settled: verdict === "none" ? new Set() : new Set([answeredFor]),
192
- };
193
-
194
- const result = (await (
195
- service as unknown as {
196
- saveMessage: (
197
- mailbox: MailboxItem,
198
- accountId: string,
199
- accountConfigId: string,
200
- msg: ImapMessage,
201
- roles: AccountFolderRoles,
202
- departures: DepartureVerdicts,
203
- ) => Promise<{ owned: boolean; unresolvedSighting: boolean }>;
204
- }
205
- ).saveMessage(
206
- sighting,
207
- "acct-1",
208
- "cfg-1",
209
- msg,
210
- {
211
- junkMailboxId: JUNK.mailboxId,
212
- trashMailboxId: TRASH.mailboxId,
213
- configJunkRoles: NO_JUNK_ROLES,
214
- },
215
- departures,
216
- )) as { owned: boolean; unresolvedSighting: boolean };
217
-
218
- observed.owned = result.owned;
219
- observed.unresolved = result.unresolvedSighting;
220
- return observed;
221
- };
222
-
223
- describe("which folder a message the database already holds lives in", () => {
224
- it("follows the last move when another client files it into Junk", async () => {
225
- const observed = await sync(JUNK, storedIn(INBOX));
226
-
227
- assert.deepEqual(observed.repointedTo, [
228
- { mailboxId: JUNK.mailboxId, uid: 42 },
229
- ]);
230
- });
231
-
232
- it("follows the last move back when another client rescues it", async () => {
233
- const observed = await sync(INBOX, storedIn(JUNK), threadRowIn(JUNK));
234
-
235
- assert.deepEqual(observed.repointedTo, [
236
- { mailboxId: INBOX.mailboxId, uid: 42 },
237
- ]);
238
- });
239
-
240
- it("takes the UID of the folder that issued it, never the old one", async () => {
241
- const observed = await sync(JUNK, storedIn(INBOX, { uid: 7 }));
242
-
243
- assert.equal(observed.repointedTo[0].uid, 42);
244
- });
245
-
246
- it("leaves a row this folder already owns alone", async () => {
247
- const observed = await sync(INBOX, storedIn(INBOX));
248
-
249
- assert.deepEqual(observed.repointedTo, []);
250
- assert.equal(observed.owned, true);
251
- });
252
-
253
- it("holds a row whose own move has not settled", async () => {
254
- const observed = await sync(
255
- INBOX,
256
- storedIn(JUNK, {
257
- status: MessageStatus.moving,
258
- syncStatus: MessageSyncStatus.pending,
259
- }),
260
- );
261
-
262
- assert.deepEqual(observed.repointedTo, []);
263
- assert.equal(observed.owned, false);
264
- });
265
-
266
- /**
267
- * The reported bug (#1146): a message that legitimately sits in two real
268
- * folders — a Gmail user label, a Sieve `fileinto` beside a `keep`, a Sent
269
- * copy a list echoes back — was re-pointed out of the Inbox by whichever
270
- * folder the round enumerated last.
271
- */
272
- it("leaves a labelled message in the folder that still holds it", async () => {
273
- const observed = await sync(
274
- mailboxAt("Receipts"),
275
- storedIn(INBOX),
276
- threadRowIn(INBOX),
277
- [],
278
- "held",
279
- );
280
-
281
- assert.deepEqual(observed.repointedTo, []);
282
- assert.deepEqual(observed.threadUpdates, []);
283
- assert.equal(observed.owned, false);
284
- });
285
-
286
- /**
287
- * A decided sighting is decided for good: the message is in two folders and
288
- * asking again next round would get the same answer. Only an UNDECIDED one
289
- * comes back, so a settled decline must not hold the watermark.
290
- */
291
- it("finishes with a labelled message rather than holding the watermark for it", async () => {
292
- const observed = await sync(
293
- mailboxAt("Receipts"),
294
- storedIn(INBOX),
295
- threadRowIn(INBOX),
296
- [],
297
- "held",
298
- );
299
-
300
- assert.equal(observed.unresolved, false);
301
- });
302
-
303
- it("holds the watermark when the source folder could not be asked", async () => {
304
- const observed = await sync(
305
- JUNK,
306
- storedIn(INBOX),
307
- threadRowIn(INBOX),
308
- [],
309
- "none",
310
- );
311
-
312
- assert.deepEqual(observed.repointedTo, []);
313
- assert.equal(observed.unresolved, true);
314
- });
315
-
316
- /**
317
- * The probe reads the row outside the transaction that acts on it, so a user
318
- * move landing in between leaves a verdict about a placement this row no
319
- * longer has. Spending it would re-point the row against an answer reached
320
- * for somewhere else.
321
- */
322
- it("spends no verdict reached for a placement the row has since left", async () => {
323
- const observed = await sync(
324
- JUNK,
325
- storedIn(INBOX),
326
- threadRowIn(INBOX),
327
- [],
328
- "stale",
329
- );
330
-
331
- assert.deepEqual(observed.repointedTo, []);
332
- assert.equal(observed.unresolved, true);
333
- });
334
-
335
- it("holds the watermark for nothing when the row is already ours", async () => {
336
- const observed = await sync(
337
- INBOX,
338
- storedIn(INBOX),
339
- threadRowIn(INBOX),
340
- [],
341
- "none",
342
- );
343
-
344
- assert.equal(observed.unresolved, false);
345
- });
346
-
347
- it("declines a sighting in a folder that copies every message", async () => {
348
- const observed = await sync(ALL_MAIL, storedIn(INBOX));
349
-
350
- assert.deepEqual(observed.repointedTo, []);
351
- assert.equal(observed.owned, false);
352
- });
353
-
354
- it("declines a sighting of a copy the user put in this folder", async () => {
355
- const observed = await sync(JUNK, storedIn(INBOX), threadRowIn(INBOX), [
356
- storedIn(JUNK),
357
- ]);
358
-
359
- assert.deepEqual(observed.repointedTo, []);
360
- assert.deepEqual(observed.threadUpdates, []);
361
- });
362
-
363
- it("hands the re-pointed message to this folder's body sync", async () => {
364
- const observed = await sync(JUNK, storedIn(INBOX));
365
-
366
- assert.equal(observed.owned, true);
367
- });
368
- });
369
-
370
- describe("what a re-pointed message does to the listing that renders it", () => {
371
- it("moves the row the listing and the folder counts read", async () => {
372
- const observed = await sync(JUNK, storedIn(INBOX));
373
-
374
- assert.deepEqual(observed.threadUpdates, [
375
- { mailboxId: JUNK.mailboxId, uid: 42, isDeleted: false },
376
- ]);
377
- });
378
-
379
- it("marks the row deleted when the move was into Trash", async () => {
380
- const observed = await sync(TRASH, storedIn(INBOX));
381
-
382
- assert.equal(observed.threadUpdates[0].isDeleted, true);
383
- });
384
-
385
- it("clears the deleted mark when the move was out of Trash", async () => {
386
- const observed = await sync(INBOX, storedIn(TRASH), threadRowIn(TRASH));
387
-
388
- assert.equal(observed.threadUpdates[0].isDeleted, false);
389
- });
390
-
391
- it("writes nothing when no row renders the message yet", async () => {
392
- const observed = await sync(JUNK, storedIn(INBOX), null);
393
-
394
- assert.deepEqual(observed.threadUpdates, []);
395
- assert.deepEqual(observed.repointedTo, [
396
- { mailboxId: JUNK.mailboxId, uid: 42 },
397
- ]);
398
- });
399
- });
400
-
401
- describe("what a re-pointed message does to its sender's standing", () => {
402
- it("re-asks the question when the move was into Junk", async () => {
403
- const observed = await sync(JUNK, storedIn(INBOX));
404
-
405
- assert.equal(observed.reconciled.length, 1);
406
- });
407
-
408
- it("re-asks it again when the move was back out of Junk", async () => {
409
- const observed = await sync(INBOX, storedIn(JUNK), threadRowIn(JUNK));
410
-
411
- assert.equal(observed.reconciled.length, 1);
412
- });
413
-
414
- it("asks nothing when the pointer did not move", async () => {
415
- const observed = await sync(INBOX, storedIn(INBOX));
416
-
417
- assert.deepEqual(observed.reconciled, []);
418
- });
419
- });
@@ -1,140 +0,0 @@
1
- /**
2
- * A row the SMTP worker settled has to be a row the user can still act on.
3
- *
4
- * `sending` is not: `send` takes draft, failed and blocked, and `deleteDraft`
5
- * those three plus `unfiled`, so a send that never reached the server left a
6
- * row that 409s on both buttons forever (#951). The worker now settles that
7
- * row instead of dead-lettering it, and these are the two moves the settled
8
- * status has to accept for the settle to be worth anything.
9
- */
10
-
11
- import assert from "node:assert/strict";
12
- import { describe, it } from "node:test";
13
- import type {
14
- IAccountRepository,
15
- IOutboxMessageRepository,
16
- OutboxMessageItem,
17
- } from "@remit/data-ports";
18
- import { OutboxMessageStatus } from "@remit/domain-enums";
19
- import type { OutboxAttachmentService } from "./outbox-attachment.js";
20
- import { OutboxQueueService } from "./outbox-queue.js";
21
-
22
- const ACCOUNT_CONFIG_ID = "cfg-1";
23
- const ACCOUNT_ID = "acc-1";
24
- const OUTBOX_MESSAGE_ID = "ob-1";
25
-
26
- const row = (status: OutboxMessageItem["status"]): OutboxMessageItem =>
27
- ({
28
- outboxMessageId: OUTBOX_MESSAGE_ID,
29
- accountId: ACCOUNT_ID,
30
- accountConfigId: ACCOUNT_CONFIG_ID,
31
- fromAddress: "me@example.com",
32
- toAddresses: ["them@example.com"],
33
- ccAddresses: [],
34
- bccAddresses: [],
35
- references: [],
36
- messageIdValue: "<m1@example.com>",
37
- lastError: "SMTP connection failed: ECONNREFUSED",
38
- status,
39
- createdAt: 0,
40
- updatedAt: 0,
41
- }) as unknown as OutboxMessageItem;
42
-
43
- interface Harness {
44
- service: OutboxQueueService;
45
- enqueued: string[];
46
- statusWrites: string[];
47
- deleted: string[];
48
- discarded: string[];
49
- }
50
-
51
- const createHarness = (stored: OutboxMessageItem): Harness => {
52
- const harness: Harness = {
53
- service: undefined as unknown as OutboxQueueService,
54
- enqueued: [],
55
- statusWrites: [],
56
- deleted: [],
57
- discarded: [],
58
- };
59
-
60
- const outboxMessageService = {
61
- get: async () => stored,
62
- updateStatus: async (
63
- _configId: string,
64
- _id: string,
65
- status: OutboxMessageItem["status"],
66
- ) => {
67
- harness.statusWrites.push(status);
68
- return { ...stored, status };
69
- },
70
- updateIfStatus: async (
71
- _configId: string,
72
- _id: string,
73
- _expected: OutboxMessageItem["status"],
74
- input: { status?: OutboxMessageItem["status"] },
75
- ) => {
76
- if (input.status) harness.statusWrites.push(input.status);
77
- return { ...stored, ...input };
78
- },
79
- delete: async (_configId: string, id: string) => {
80
- harness.deleted.push(id);
81
- },
82
- } as unknown as IOutboxMessageRepository;
83
-
84
- harness.service = new OutboxQueueService({
85
- outboxMessageService,
86
- outboxAttachmentService: {
87
- discardAll: async (_configId: string, _accountId: string, id: string) => {
88
- harness.discarded.push(id);
89
- },
90
- unfinishedUpload: async () => undefined,
91
- } as unknown as OutboxAttachmentService,
92
- accountService: {
93
- get: async () => ({ syncedServices: ["Mail"] }),
94
- } as unknown as IAccountRepository,
95
- sqsSmtpQueueUrl: "http://localhost/queue",
96
- sqsClient: {
97
- send: async (command: { input: { MessageBody: string } }) => {
98
- harness.enqueued.push(command.input.MessageBody);
99
- return {};
100
- },
101
- } as never,
102
- });
103
-
104
- return harness;
105
- };
106
-
107
- describe("a row the worker settled at `failed`", () => {
108
- it("is sendable again — Retry is the whole point of settling there", async () => {
109
- const harness = createHarness(row(OutboxMessageStatus.failed));
110
-
111
- await harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
112
-
113
- assert.deepEqual(harness.statusWrites, [OutboxMessageStatus.queued]);
114
- assert.equal(harness.enqueued.length, 1);
115
- });
116
-
117
- it("is discardable — the other way out of the Outbox", async () => {
118
- const harness = createHarness(row(OutboxMessageStatus.failed));
119
-
120
- await harness.service.deleteDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
121
-
122
- assert.deepEqual(harness.deleted, [OUTBOX_MESSAGE_ID]);
123
- assert.deepEqual(harness.discarded, [OUTBOX_MESSAGE_ID]);
124
- });
125
- });
126
-
127
- describe("a row the worker settled at `unfiled`", () => {
128
- it("is discardable but never sendable — the server may already hold it", async () => {
129
- const harness = createHarness(row(OutboxMessageStatus.unfiled));
130
-
131
- await assert.rejects(
132
- () => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
133
- /cannot be sent again/,
134
- );
135
- assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
136
-
137
- await harness.service.deleteDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
138
- assert.deepEqual(harness.deleted, [OUTBOX_MESSAGE_ID]);
139
- });
140
- });
@@ -1,17 +0,0 @@
1
- import type { IUnitOfWork, UnitOfWorkRepositories } from "@remit/data-ports";
2
-
3
- /**
4
- * Runs the write set against fixed repositories with no surrounding
5
- * transaction: the writes are not atomic. For a backend that has no
6
- * cross-entity transaction this matches its own guarantees; the SQLite path
7
- * injects a real transactional unit of work instead.
8
- */
9
- export class PassThroughUnitOfWork implements IUnitOfWork {
10
- constructor(private repos: UnitOfWorkRepositories) {}
11
-
12
- transaction<T>(
13
- fn: (repos: UnitOfWorkRepositories) => Promise<T>,
14
- ): Promise<T> {
15
- return fn(this.repos);
16
- }
17
- }