@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,344 +1,276 @@
1
- /**
2
- * The two ways an outbox row used to become unusable without ever leaving.
3
- *
4
- * `createAndSend` wrote the row at `queued` and then enqueued. A throw from the
5
- * enqueue left it there, and `queued` is accepted by neither `send` nor
6
- * `deleteDraft` — the message could be neither sent nor discarded (#936).
7
- *
8
- * A send that failed settled at `failed`, and `updateDraft` refused anything
9
- * but `draft`. Retry re-queued the same envelope and Edit took a 409 on the
10
- * flush that precedes the send, so a message refused for a bad address had no
11
- * way back to sent short of retyping it (#933).
12
- *
13
- * Where a stranded row settles is the load-bearing part. Not `draft`: a throw
14
- * from `SendMessage` says the response was lost, not that the broker refused
15
- * the event, and `draft` is inside the SMTP worker's send fence — a row put
16
- * there is sendable both by the event that landed anyway and by the user
17
- * pressing Send. `failed` is outside that fence and, since #933, is a row the
18
- * user can still edit and send.
19
- */
20
-
21
1
  import assert from "node:assert/strict";
22
- import { describe, it } from "node:test";
23
- import type {
24
- IAccountRepository,
25
- IOutboxMessageRepository,
26
- OutboxMessageItem,
27
- UpdateOutboxMessageInput,
28
- } from "@remit/data-ports";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type { AccountItem, OutboxMessageItem } from "@remit/data-ports";
29
4
  import { ConflictError } from "@remit/data-ports/errors";
30
5
  import { OutboxMessageStatus } from "@remit/domain-enums";
31
- import type { OutboxAttachmentService } from "./outbox-attachment.js";
6
+ import { OutboxAttachmentService } from "./outbox-attachment.js";
32
7
  import { OutboxQueueService } from "./outbox-queue.js";
8
+ import {
9
+ beforeNext,
10
+ captureQueue,
11
+ openStore,
12
+ type QueueCapture,
13
+ type QueuedEvent,
14
+ type Store,
15
+ seedAccount,
16
+ } from "./test-helpers/test-store.js";
17
+
18
+ let store: Store;
19
+ let account: AccountItem;
20
+ let capture: QueueCapture;
21
+ let enqueued: QueuedEvent[];
22
+ let enqueueFails: Error | undefined;
23
+ let whileEnqueueing: () => Promise<unknown>;
24
+
25
+ beforeEach(async () => {
26
+ store = await openStore();
27
+ account = await seedAccount(store);
28
+ enqueueFails = undefined;
29
+ whileEnqueueing = async () => {};
30
+ capture = captureQueue();
31
+ capture.answer(async () => {
32
+ await whileEnqueueing();
33
+ if (enqueueFails) throw enqueueFails;
34
+ return {};
35
+ });
36
+ enqueued = capture.sent;
37
+ });
38
+
39
+ afterEach(async () => {
40
+ capture.restore();
41
+ await store.close();
42
+ });
33
43
 
34
- const ACCOUNT_CONFIG_ID = "cfg-1";
35
- const ACCOUNT_ID = "acc-1";
36
- const OUTBOX_MESSAGE_ID = "ob-1";
44
+ const queue = () =>
45
+ new OutboxQueueService({
46
+ outboxMessageService: store.outboxMessage,
47
+ outboxAttachmentService: new OutboxAttachmentService({
48
+ outboxMessageService: store.outboxMessage,
49
+ outboxAttachmentService: store.outboxAttachment,
50
+ storage: store.storage(),
51
+ }),
52
+ accountService: store.account,
53
+ sqsSmtpQueueUrl: "http://localhost/queue",
54
+ });
37
55
 
38
- const row = (overrides: Partial<OutboxMessageItem>): OutboxMessageItem =>
39
- ({
40
- outboxMessageId: OUTBOX_MESSAGE_ID,
41
- accountId: ACCOUNT_ID,
42
- accountConfigId: ACCOUNT_CONFIG_ID,
56
+ const message = (status: OutboxMessageItem["status"]) =>
57
+ store.outboxMessage.create({
58
+ accountId: account.accountId,
59
+ accountConfigId: account.accountConfigId,
43
60
  fromAddress: "me@example.com",
44
61
  toAddresses: ["typo@exmaple.com"],
45
- ccAddresses: [],
46
- bccAddresses: [],
47
- references: [],
48
62
  messageIdValue: "<m1@example.com>",
49
63
  subject: "Invoice",
50
64
  textBody: "Attached.",
51
- status: OutboxMessageStatus.draft,
52
- createdAt: 0,
53
- updatedAt: 0,
54
- ...overrides,
55
- }) as OutboxMessageItem;
56
-
57
- interface ConditionalWrite {
58
- expected: OutboxMessageItem["status"];
59
- input: UpdateOutboxMessageInput;
60
- applied: boolean;
61
- }
62
-
63
- interface Harness {
64
- service: OutboxQueueService;
65
- enqueued: string[];
66
- createdStatuses: string[];
67
- writes: ConditionalWrite[];
68
- /** What the row holds now, after every write this harness applied. */
69
- status: () => OutboxMessageItem["status"];
70
- }
71
-
72
- interface HarnessOptions {
73
- enqueueFails?: Error;
74
- settleFails?: Error;
75
- /** Moves the row the instant the service reads it, as a racing writer would. */
76
- movesOnRead?: OutboxMessageItem["status"];
77
- }
78
-
79
- const createHarness = (
80
- stored: OutboxMessageItem,
81
- options: HarnessOptions = {},
82
- ): Harness => {
83
- let current = stored;
84
-
85
- const harness: Harness = {
86
- service: undefined as unknown as OutboxQueueService,
87
- enqueued: [],
88
- createdStatuses: [],
89
- writes: [],
90
- status: () => current.status,
91
- };
92
-
93
- const outboxMessageService = {
94
- get: async () => {
95
- const seen = current;
96
- if (options.movesOnRead) {
97
- current = row({ ...current, status: options.movesOnRead });
98
- }
99
- return seen;
100
- },
101
- create: async (input: Record<string, unknown>) => {
102
- current = row(input as Partial<OutboxMessageItem>);
103
- harness.createdStatuses.push(current.status);
104
- return current;
105
- },
106
- updateIfStatus: async (
107
- _configId: string,
108
- _id: string,
109
- expected: OutboxMessageItem["status"],
110
- input: UpdateOutboxMessageInput,
111
- ) => {
112
- if (options.settleFails && input.status === OutboxMessageStatus.failed) {
113
- throw options.settleFails;
114
- }
115
- const applied = current.status === expected;
116
- harness.writes.push({ expected, input, applied });
117
- if (!applied) return null;
118
- current = row({ ...current, ...input });
119
- return current;
120
- },
121
- } as unknown as IOutboxMessageRepository;
122
-
123
- harness.service = new OutboxQueueService({
124
- outboxMessageService,
125
- outboxAttachmentService: {
126
- unfinishedUpload: async () => undefined,
127
- } as unknown as OutboxAttachmentService,
128
- accountService: {
129
- get: async () => ({ syncedServices: ["Mail"] }),
130
- } as unknown as IAccountRepository,
131
- sqsSmtpQueueUrl: "http://localhost/queue",
132
- sqsClient: {
133
- send: async (command: { input: { MessageBody: string } }) => {
134
- if (options.enqueueFails) throw options.enqueueFails;
135
- harness.enqueued.push(command.input.MessageBody);
136
- return {};
137
- },
138
- } as never,
65
+ status,
139
66
  });
140
67
 
141
- return harness;
142
- };
143
-
144
- const createInput = {
145
- accountId: ACCOUNT_ID,
146
- accountConfigId: ACCOUNT_CONFIG_ID,
68
+ const createInput = () => ({
69
+ accountId: account.accountId,
70
+ accountConfigId: account.accountConfigId,
147
71
  fromAddress: "me@example.com",
148
72
  toAddresses: ["them@example.com"],
149
73
  subject: "Invoice",
150
74
  textBody: "Attached.",
151
- };
75
+ });
152
76
 
153
- const applied = (harness: Harness): UpdateOutboxMessageInput[] =>
154
- harness.writes.filter((write) => write.applied).map((write) => write.input);
77
+ const current = (outboxMessageId: string) =>
78
+ store.outboxMessage.get(account.accountConfigId, outboxMessageId);
155
79
 
156
- describe("an enqueue that reports a failure", () => {
157
- it("settles the new row of createAndSend at `failed`, never at `queued`", async () => {
158
- const harness = createHarness(row({}), {
159
- enqueueFails: new Error("SQS unavailable"),
160
- });
80
+ const all = async () =>
81
+ (await store.outboxMessage.listByAccount(account.accountId)).items;
82
+
83
+ const isConflict = (error: unknown) => {
84
+ assert.ok(error instanceof ConflictError);
85
+ assert.equal(error.statusCode, 409);
86
+ return true;
87
+ };
88
+
89
+ describe("an enqueue that fails", () => {
90
+ it("settles the new row of createAndSend at failed, saying why, and still reports the failure", async () => {
91
+ enqueueFails = new Error("SQS unavailable");
161
92
 
162
93
  await assert.rejects(
163
- () => harness.service.createAndSend(createInput),
94
+ queue().createAndSend(createInput()),
164
95
  /SQS unavailable/,
165
- "the caller still hears the failure",
166
96
  );
167
97
 
168
- assert.deepEqual(harness.createdStatuses, [OutboxMessageStatus.queued]);
169
- assert.equal(harness.status(), OutboxMessageStatus.failed);
98
+ const [row] = await all();
99
+ assert.equal(row.status, OutboxMessageStatus.failed);
100
+ assert.match(
101
+ String(row.lastError),
102
+ /could not be handed to the outgoing queue/,
103
+ );
170
104
  });
171
105
 
172
- it("settles at `failed` rather than `draft` — the event may have landed", async () => {
173
- // `draft` is inside the worker's send fence, so a row put back there is
174
- // sent by the landed event and sendable again by hand: two copies of one
175
- // message. This is the assertion that keeps it out.
176
- const harness = createHarness(row({}), {
177
- enqueueFails: new Error("SQS unavailable"),
178
- });
106
+ it("takes a sent draft to failed, never back to draft", async () => {
107
+ const draft = await message(OutboxMessageStatus.draft);
108
+ enqueueFails = new Error("SQS unavailable");
179
109
 
180
- await assert.rejects(() => harness.service.createAndSend(createInput));
110
+ await assert.rejects(
111
+ queue().send(account.accountConfigId, draft.outboxMessageId),
112
+ );
181
113
 
182
- assert.notEqual(harness.status(), OutboxMessageStatus.draft);
183
- assert.match(
184
- String(applied(harness).at(-1)?.lastError),
185
- /could not be handed to the outgoing queue/,
186
- "and it says why, where the Outbox shows it",
114
+ assert.equal(
115
+ (await current(draft.outboxMessageId)).status,
116
+ OutboxMessageStatus.failed,
187
117
  );
188
118
  });
189
119
 
190
- it("takes a draft that was sent to `failed`, not back to `draft`", async () => {
191
- const harness = createHarness(row({}), {
192
- enqueueFails: new Error("SQS unavailable"),
193
- });
120
+ it("returns a blocked row to blocked", async () => {
121
+ const blocked = await message(OutboxMessageStatus.blocked);
122
+ enqueueFails = new Error("SQS unavailable");
194
123
 
195
- await assert.rejects(() =>
196
- harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
124
+ await assert.rejects(
125
+ queue().send(account.accountConfigId, blocked.outboxMessageId),
197
126
  );
198
127
 
199
- assert.equal(harness.status(), OutboxMessageStatus.failed);
128
+ assert.equal(
129
+ (await current(blocked.outboxMessageId)).status,
130
+ OutboxMessageStatus.blocked,
131
+ );
200
132
  });
201
133
 
202
- it("returns a `blocked` row to `blocked` — outside the fence, and the reason stands", async () => {
203
- const harness = createHarness(
204
- row({ status: OutboxMessageStatus.blocked }),
205
- {
206
- enqueueFails: new Error("SQS unavailable"),
207
- },
208
- );
134
+ it("leaves a row the worker already took alone", async () => {
135
+ const draft = await message(OutboxMessageStatus.draft);
136
+ enqueueFails = new Error("SQS timed out after the event landed");
137
+ whileEnqueueing = () =>
138
+ store.outboxMessage.updateStatus(
139
+ account.accountConfigId,
140
+ draft.outboxMessageId,
141
+ OutboxMessageStatus.sending,
142
+ );
209
143
 
210
- await assert.rejects(() =>
211
- harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
144
+ await assert.rejects(
145
+ queue().send(account.accountConfigId, draft.outboxMessageId),
212
146
  );
213
147
 
214
- assert.equal(harness.status(), OutboxMessageStatus.blocked);
148
+ assert.equal(
149
+ (await current(draft.outboxMessageId)).status,
150
+ OutboxMessageStatus.sending,
151
+ );
215
152
  });
216
153
 
217
- it("only settles a row still at `queued` — the worker may already hold it", async () => {
218
- const harness = createHarness(row({}), {
219
- enqueueFails: new Error("SQS unavailable"),
220
- });
221
-
222
- await assert.rejects(() => harness.service.createAndSend(createInput));
154
+ it("keeps the row queued when the enqueue succeeds", async () => {
155
+ await queue().createAndSend(createInput());
223
156
 
224
- const settle = harness.writes.at(-1);
225
- assert.equal(settle?.expected, OutboxMessageStatus.queued);
157
+ const [row] = await all();
158
+ assert.equal(row.status, OutboxMessageStatus.queued);
159
+ assert.equal(enqueued.length, 1);
226
160
  });
161
+ });
227
162
 
228
- it("still reports the enqueue failure when the settle write also fails", async () => {
229
- // The settle is the repair, not the failure. Replacing the enqueue error
230
- // with the repair's would name the wrong cause and hide the row's state.
231
- const harness = createHarness(row({}), {
232
- enqueueFails: new Error("SQS unavailable"),
233
- settleFails: new Error("database unreachable"),
234
- });
163
+ describe("a row the worker settled", () => {
164
+ it("is sendable again from failed, and discardable", async () => {
165
+ const failed = await message(OutboxMessageStatus.failed);
235
166
 
236
- await assert.rejects(
237
- () => harness.service.createAndSend(createInput),
238
- /SQS unavailable/,
167
+ await queue().send(account.accountConfigId, failed.outboxMessageId);
168
+ assert.equal(
169
+ (await current(failed.outboxMessageId)).status,
170
+ OutboxMessageStatus.queued,
239
171
  );
172
+
173
+ const other = await message(OutboxMessageStatus.failed);
174
+ await queue().deleteDraft(account.accountConfigId, other.outboxMessageId);
175
+ await assert.rejects(current(other.outboxMessageId));
240
176
  });
241
177
 
242
- it("keeps the row at `queued` when the enqueue succeeds", async () => {
243
- const harness = createHarness(row({}));
178
+ it("is discardable but never sendable from unfiled", async () => {
179
+ const unfiled = await message(OutboxMessageStatus.unfiled);
244
180
 
245
- await harness.service.createAndSend(createInput);
181
+ await assert.rejects(
182
+ queue().send(account.accountConfigId, unfiled.outboxMessageId),
183
+ /cannot be sent again/,
184
+ );
185
+ assert.deepEqual(enqueued, []);
246
186
 
247
- assert.equal(harness.enqueued.length, 1);
248
- assert.deepEqual(harness.writes, [], "nothing walked the row back");
187
+ await queue().deleteDraft(account.accountConfigId, unfiled.outboxMessageId);
188
+ await assert.rejects(current(unfiled.outboxMessageId));
249
189
  });
250
190
  });
251
191
 
252
192
  describe("correcting a message that failed to send", () => {
253
- it("accepts the edit and returns the row to `draft`", async () => {
254
- const harness = createHarness(row({ status: OutboxMessageStatus.failed }));
193
+ it("accepts the edit, keeps the content and returns the row to draft", async () => {
194
+ const failed = await message(OutboxMessageStatus.failed);
255
195
 
256
- const updated = await harness.service.updateDraft(
257
- ACCOUNT_CONFIG_ID,
258
- OUTBOX_MESSAGE_ID,
196
+ const updated = await queue().updateDraft(
197
+ account.accountConfigId,
198
+ failed.outboxMessageId,
259
199
  { toAddresses: ["them@example.com"] },
260
200
  );
261
201
 
262
- assert.deepEqual(applied(harness), [
263
- {
264
- status: OutboxMessageStatus.draft,
265
- toAddresses: ["them@example.com"],
266
- },
267
- ]);
268
202
  assert.equal(updated.status, OutboxMessageStatus.draft);
269
- assert.equal(updated.subject, "Invoice", "its content came with it");
203
+ assert.deepEqual(updated.toAddresses, ["them@example.com"]);
204
+ assert.equal(updated.subject, "Invoice");
270
205
  });
271
206
 
272
207
  it("sends the corrected message", async () => {
273
- const harness = createHarness(row({ status: OutboxMessageStatus.failed }));
208
+ const failed = await message(OutboxMessageStatus.failed);
274
209
 
275
- await harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
210
+ await queue().updateDraft(account.accountConfigId, failed.outboxMessageId, {
276
211
  toAddresses: ["them@example.com"],
277
212
  });
278
- await harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
279
-
280
- assert.equal(harness.status(), OutboxMessageStatus.queued);
281
- assert.equal(harness.enqueued.length, 1);
282
- });
283
-
284
- it("accepts the edit on a `blocked` row too — Send already does", async () => {
285
- const harness = createHarness(row({ status: OutboxMessageStatus.blocked }));
286
-
287
- await harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
288
- subject: "Invoice, corrected",
289
- });
213
+ await queue().send(account.accountConfigId, failed.outboxMessageId);
290
214
 
291
- assert.equal(applied(harness).at(0)?.status, OutboxMessageStatus.draft);
215
+ assert.equal(
216
+ (await current(failed.outboxMessageId)).status,
217
+ OutboxMessageStatus.queued,
218
+ );
219
+ assert.equal(enqueued.length, 1);
292
220
  });
293
221
 
294
- it("writes no status on a row that is already a draft", async () => {
295
- const harness = createHarness(row({}));
222
+ it("accepts the edit on a blocked row too", async () => {
223
+ const blocked = await message(OutboxMessageStatus.blocked);
296
224
 
297
- await harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
298
- subject: "Invoice, corrected",
299
- });
225
+ const updated = await queue().updateDraft(
226
+ account.accountConfigId,
227
+ blocked.outboxMessageId,
228
+ { subject: "Invoice, corrected" },
229
+ );
300
230
 
301
- assert.deepEqual(applied(harness), [{ subject: "Invoice, corrected" }]);
231
+ assert.equal(updated.status, OutboxMessageStatus.draft);
302
232
  });
303
233
  });
304
234
 
305
235
  describe("a message that moves while the request is deciding", () => {
306
- it("refuses the edit rather than pulling a queued row back to `draft`", async () => {
307
- // The send this loses to has an event on the wire, and `draft` is inside
308
- // the fence that event has to pass.
309
- const harness = createHarness(row({ status: OutboxMessageStatus.failed }), {
310
- movesOnRead: OutboxMessageStatus.queued,
311
- });
236
+ it("refuses the edit rather than pulling a queued row back to draft", async () => {
237
+ const failed = await message(OutboxMessageStatus.failed);
238
+ beforeNext(store.outboxMessage, "updateIfStatus", () =>
239
+ store.outboxMessage.updateStatus(
240
+ account.accountConfigId,
241
+ failed.outboxMessageId,
242
+ OutboxMessageStatus.queued,
243
+ ),
244
+ );
312
245
 
313
246
  await assert.rejects(
314
- () =>
315
- harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
316
- toAddresses: ["them@example.com"],
317
- }),
318
- (error: unknown) => {
319
- assert.ok(error instanceof ConflictError);
320
- assert.equal(error.statusCode, 409);
321
- return true;
322
- },
247
+ queue().updateDraft(account.accountConfigId, failed.outboxMessageId, {
248
+ toAddresses: ["them@example.com"],
249
+ }),
250
+ isConflict,
323
251
  );
324
252
 
325
- assert.equal(harness.status(), OutboxMessageStatus.queued);
253
+ assert.equal(
254
+ (await current(failed.outboxMessageId)).status,
255
+ OutboxMessageStatus.queued,
256
+ );
326
257
  });
327
258
 
328
259
  it("refuses the second of two sends racing the same row", async () => {
329
- const harness = createHarness(row({}), {
330
- movesOnRead: OutboxMessageStatus.sending,
331
- });
260
+ const draft = await message(OutboxMessageStatus.draft);
261
+ beforeNext(store.outboxMessage, "updateIfStatus", () =>
262
+ store.outboxMessage.updateStatus(
263
+ account.accountConfigId,
264
+ draft.outboxMessageId,
265
+ OutboxMessageStatus.sending,
266
+ ),
267
+ );
332
268
 
333
269
  await assert.rejects(
334
- () => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
335
- (error: unknown) => {
336
- assert.ok(error instanceof ConflictError);
337
- return true;
338
- },
270
+ queue().send(account.accountConfigId, draft.outboxMessageId),
271
+ isConflict,
339
272
  );
340
-
341
- assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
273
+ assert.deepEqual(enqueued, []);
342
274
  });
343
275
  });
344
276
 
@@ -349,22 +281,19 @@ describe("a message that is not the user's to edit", () => {
349
281
  OutboxMessageStatus.sent,
350
282
  OutboxMessageStatus.unfiled,
351
283
  ]) {
352
- it(`refuses the edit of a \`${status}\` row`, async () => {
353
- const harness = createHarness(row({ status }));
284
+ it(`refuses the edit of a ${status} row`, async () => {
285
+ const row = await message(status);
354
286
 
355
287
  await assert.rejects(
356
- () =>
357
- harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
358
- toAddresses: ["them@example.com"],
359
- }),
360
- (error: unknown) => {
361
- assert.ok(error instanceof ConflictError);
362
- assert.equal(error.statusCode, 409);
363
- return true;
364
- },
288
+ queue().updateDraft(account.accountConfigId, row.outboxMessageId, {
289
+ toAddresses: ["them@example.com"],
290
+ }),
291
+ isConflict,
365
292
  );
366
293
 
367
- assert.deepEqual(harness.writes, []);
294
+ assert.deepEqual((await current(row.outboxMessageId)).toAddresses, [
295
+ "typo@exmaple.com",
296
+ ]);
368
297
  });
369
298
  }
370
299
  });
@@ -289,3 +289,27 @@ describe("OutboxQueueService and an account with mail off", () => {
289
289
  assert.equal(harness.enqueued.length, 1);
290
290
  });
291
291
  });
292
+
293
+ describe("OutboxQueueService and a reply's threading headers", () => {
294
+ it("stores the parent's ids bare however they were bracketed", async () => {
295
+ const { service } = createHarness(draft({}));
296
+
297
+ const outbox = await service.createDraft(
298
+ sendInput({
299
+ toAddresses: ["them@example.com"],
300
+ inReplyTo: "<parent@example.com>",
301
+ references: [
302
+ "<<root@example.com>>",
303
+ "parent@example.com",
304
+ "<parent@example.com>",
305
+ ],
306
+ }),
307
+ );
308
+
309
+ assert.equal(outbox.inReplyTo, "parent@example.com");
310
+ assert.deepEqual(outbox.references, [
311
+ "root@example.com",
312
+ "parent@example.com",
313
+ ]);
314
+ });
315
+ });
@@ -10,6 +10,7 @@ import {
10
10
  ConflictError,
11
11
  MailSyncOffError,
12
12
  } from "@remit/data-ports/errors";
13
+ import { bareMessageId } from "@remit/data-ports/id";
13
14
  import { AccountService, OutboxMessageStatus } from "@remit/domain-enums";
14
15
  import { createQueueProducer } from "@remit/sqs-client/producer";
15
16
  import type { OutboxAttachmentService } from "./outbox-attachment.js";
@@ -69,6 +70,27 @@ export interface UpdateDraftInput {
69
70
  references?: string[];
70
71
  }
71
72
 
73
+ const MAX_REFERENCES = 21;
74
+
75
+ const capReferences = (ids: string[]): string[] =>
76
+ ids.length <= MAX_REFERENCES
77
+ ? ids
78
+ : [ids[0], ...ids.slice(ids.length - (MAX_REFERENCES - 1))];
79
+
80
+ const bareThreadingHeaders = (input: {
81
+ inReplyTo?: string;
82
+ references?: string[];
83
+ }): { inReplyTo?: string; references?: string[] } => ({
84
+ ...(input.inReplyTo !== undefined && {
85
+ inReplyTo: bareMessageId(input.inReplyTo),
86
+ }),
87
+ ...(input.references !== undefined && {
88
+ references: capReferences([
89
+ ...new Set(input.references.map(bareMessageId).filter((id) => id !== "")),
90
+ ]),
91
+ }),
92
+ });
93
+
72
94
  /**
73
95
  * Nobody to send to. `@minItems(1)` on the create input is the only thing that
74
96
  * has ever stood between a zero-recipient message and nodemailer, which refuses
@@ -184,8 +206,7 @@ export class OutboxQueueService {
184
206
  subject: input.subject,
185
207
  textBody: input.textBody,
186
208
  htmlBody: input.htmlBody,
187
- inReplyTo: input.inReplyTo,
188
- references: input.references,
209
+ ...bareThreadingHeaders(input),
189
210
  messageIdValue,
190
211
  status: OutboxMessageStatus.draft,
191
212
  });
@@ -244,10 +265,7 @@ export class OutboxQueueService {
244
265
  ...(input.subject !== undefined && { subject: input.subject }),
245
266
  ...(input.textBody !== undefined && { textBody: input.textBody }),
246
267
  ...(input.htmlBody !== undefined && { htmlBody: input.htmlBody }),
247
- ...(input.inReplyTo !== undefined && { inReplyTo: input.inReplyTo }),
248
- ...(input.references !== undefined && {
249
- references: input.references,
250
- }),
268
+ ...bareThreadingHeaders(input),
251
269
  },
252
270
  );
253
271
  if (!updated) throw new ConflictError(MOVED_WHILE_EDITING_MESSAGE);
@@ -369,8 +387,7 @@ export class OutboxQueueService {
369
387
  subject: input.subject,
370
388
  textBody: input.textBody,
371
389
  htmlBody: input.htmlBody,
372
- inReplyTo: input.inReplyTo,
373
- references: input.references,
390
+ ...bareThreadingHeaders(input),
374
391
  messageIdValue,
375
392
  status: OutboxMessageStatus.queued,
376
393
  });