@remit/mailbox-service 0.0.72 → 0.0.74

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.72",
3
+ "version": "0.0.74",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -13,6 +13,7 @@ import {
13
13
  MessageMoveService,
14
14
  NoTrashMailboxError,
15
15
  StaleTrashAppointmentError,
16
+ UnconfirmedTrashMailboxError,
16
17
  } from "./message-move.js";
17
18
 
18
19
  const ACCOUNT = "acc-1";
@@ -200,14 +201,34 @@ describe("delete only expunges when it was asked to", () => {
200
201
  );
201
202
  });
202
203
 
203
- it("expunges a message already inside a Trash that resolves by name", async () => {
204
- // D4a: the rows were selected and the dialog named the consequence, so
205
- // consent is per message. Empty Trash has no such consent and refuses.
206
- const { service, events } = buildWorld(
204
+ it("refuses to expunge a message already inside a Trash that only resolves by name", async () => {
205
+ // #876: a message already sitting in the guessed folder used to fall
206
+ // straight through to an expunge, on the same name guess Empty Trash
207
+ // refuses to act on. Deleting it is exactly as unrecoverable, so it
208
+ // demands the same confirmed evidence — nobody, neither the user nor the
209
+ // server, ever said this folder is Trash.
210
+ const { service, patches, events, message } = buildWorld(
207
211
  { kind: "proposed", mailbox: { mailboxId: TRASH, fullPath: "Trash" } },
208
212
  TRASH,
209
213
  );
210
214
 
215
+ await assert.rejects(
216
+ () => service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
217
+ (error: unknown) =>
218
+ error instanceof UnconfirmedTrashMailboxError &&
219
+ error.statusCode === 409 &&
220
+ error.publicApiError?.details?.reason === "unconfirmed" &&
221
+ error.publicApiError?.details?.accountId === ACCOUNT,
222
+ );
223
+
224
+ assert.deepEqual(events, []);
225
+ assert.deepEqual(patches, []);
226
+ assert.equal(message.mailboxId, TRASH);
227
+ });
228
+
229
+ it("expunges a message already inside a confirmed Trash", async () => {
230
+ const { service, events } = buildWorld(flaggedTrash, TRASH);
231
+
211
232
  await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
212
233
 
213
234
  assert.deepEqual(
@@ -162,7 +162,8 @@ export class StaleTrashAppointmentError extends FolderRoleUnresolvedError {
162
162
 
163
163
  /**
164
164
  * A folder resolves as Trash by its name alone. Enough to move mail into,
165
- * never enough to expunge — so only the Empty Trash path raises it.
165
+ * never enough to expunge — raised by Empty Trash and by a delete that would
166
+ * expunge a message already sitting in that folder.
166
167
  */
167
168
  export class UnconfirmedTrashMailboxError extends FolderRoleUnresolvedError {
168
169
  name = "UnconfirmedTrashMailboxError";
@@ -307,12 +308,13 @@ export class MessageMoveService {
307
308
  const wantsPermanentDelete =
308
309
  options.permanent === true || options.toTrash === false;
309
310
 
310
- const trashGate = wantsPermanentDelete
311
+ const trashResolution = wantsPermanentDelete
311
312
  ? null
312
- : trashMailboxAt(
313
- await this.mailboxSpecialUseService.resolveTrashRole(accountId),
314
- "resolved",
315
- );
313
+ : await this.mailboxSpecialUseService.resolveTrashRole(accountId);
314
+
315
+ const trashGate = trashResolution
316
+ ? trashMailboxAt(trashResolution, "resolved")
317
+ : null;
316
318
 
317
319
  if (trashGate && !trashGate.allowed) {
318
320
  this.log.error(
@@ -324,6 +326,34 @@ export class MessageMoveService {
324
326
 
325
327
  const trashMailbox = trashGate ? trashGate.mailbox : null;
326
328
 
329
+ // A message already sitting in the folder resolved as Trash deletes by
330
+ // expunging it, not by moving it — the same unrecoverable act Empty
331
+ // Trash performs, so it demands the same confirmed evidence. The name
332
+ // guess ("resolved") is enough to file mail into; it is never enough to
333
+ // destroy mail that is already there. Checked once, before any local
334
+ // write, so a batch that touches such a message is refused whole rather
335
+ // than partially expunged.
336
+ if (!wantsPermanentDelete && trashResolution && trashMailbox) {
337
+ const deletesInPlace = messages.some(
338
+ (message) => message.mailboxId === trashMailbox.mailboxId,
339
+ );
340
+
341
+ if (deletesInPlace) {
342
+ const confirmedTrashGate = trashMailboxAt(trashResolution, "confirmed");
343
+ if (!confirmedTrashGate.allowed) {
344
+ this.log.error(
345
+ {
346
+ accountId,
347
+ messageCount: messages.length,
348
+ reason: confirmedTrashGate.reason,
349
+ },
350
+ "Refused to delete: this account's Trash is not confirmed",
351
+ );
352
+ throw trashRefusal(confirmedTrashGate.reason, accountId);
353
+ }
354
+ }
355
+ }
356
+
327
357
  // Group messages by operation type
328
358
  const moveToTrashMessages: Array<{
329
359
  messageId: string;
@@ -7,10 +7,7 @@ import type {
7
7
  import { holdsRoom } from "@remit/data-ports";
8
8
  import { ConflictError } from "@remit/data-ports/errors";
9
9
  import { base36uuid } from "@remit/data-ports/id";
10
- import {
11
- OutboxAttachmentRejectionReason,
12
- OutboxMessageStatus,
13
- } from "@remit/domain-enums";
10
+ import { OutboxAttachmentRejectionReason } from "@remit/domain-enums";
14
11
  import type { StorageService } from "@remit/storage-service";
15
12
  import {
16
13
  buildOutboxAttachmentKey,
@@ -20,6 +17,7 @@ import {
20
17
  normalizeAttachmentContentType,
21
18
  sanitizeAttachmentFilename,
22
19
  } from "./outbox-attachment-filename.js";
20
+ import { isOpenForWork } from "./outbox-status.js";
23
21
 
24
22
  /**
25
23
  * 25 MB is what most receiving servers accept, and base64 inflates it to roughly
@@ -111,9 +109,14 @@ export class OutboxAttachmentService {
111
109
  * Resolve a draft the caller is entitled to act on.
112
110
  *
113
111
  * Mode "act": the caller has named the draft, so a foreign one is denied with
114
- * 403 rather than feigned as a 404. An entry that has left draft is a
115
- * conflict. Both abort the request — only the file itself comes back as a
116
- * result the composer can render next to the row it refused.
112
+ * 403 rather than feigned as a 404. An entry the user can no longer work on
113
+ * is a conflict. Both abort the request — only the file itself comes back as
114
+ * a result the composer can render next to the row it refused.
115
+ *
116
+ * Same predicate the composer's other two writes use. A `failed` message is
117
+ * editable and sendable again (#933), and an attachment is part of what the
118
+ * correction may be — a message refused for the wrong file would otherwise
119
+ * still be stuck.
117
120
  */
118
121
  private getWritableDraft = async (
119
122
  accountConfigId: string,
@@ -125,7 +128,7 @@ export class OutboxAttachmentService {
125
128
  "act",
126
129
  );
127
130
 
128
- if (outbox.status !== OutboxMessageStatus.draft) {
131
+ if (!isOpenForWork(outbox.status)) {
129
132
  throw new ConflictError(
130
133
  `This message is already ${outbox.status} and can no longer take an attachment. Start a new message to change it.`,
131
134
  );
@@ -0,0 +1,366 @@
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
+ 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";
29
+ import { ConflictError } from "@remit/data-ports/errors";
30
+ import { OutboxMessageStatus } from "@remit/domain-enums";
31
+ import type { OutboxAttachmentService } from "./outbox-attachment.js";
32
+ import { OutboxQueueService } from "./outbox-queue.js";
33
+
34
+ const ACCOUNT_CONFIG_ID = "cfg-1";
35
+ const ACCOUNT_ID = "acc-1";
36
+ const OUTBOX_MESSAGE_ID = "ob-1";
37
+
38
+ const row = (overrides: Partial<OutboxMessageItem>): OutboxMessageItem =>
39
+ ({
40
+ outboxMessageId: OUTBOX_MESSAGE_ID,
41
+ accountId: ACCOUNT_ID,
42
+ accountConfigId: ACCOUNT_CONFIG_ID,
43
+ fromAddress: "me@example.com",
44
+ toAddresses: ["typo@exmaple.com"],
45
+ ccAddresses: [],
46
+ bccAddresses: [],
47
+ references: [],
48
+ messageIdValue: "<m1@example.com>",
49
+ subject: "Invoice",
50
+ 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: {} as unknown as OutboxAttachmentService,
126
+ accountService: {} as unknown as IAccountRepository,
127
+ sqsSmtpQueueUrl: "http://localhost/queue",
128
+ sqsClient: {
129
+ send: async (command: { input: { MessageBody: string } }) => {
130
+ if (options.enqueueFails) throw options.enqueueFails;
131
+ harness.enqueued.push(command.input.MessageBody);
132
+ return {};
133
+ },
134
+ } as never,
135
+ });
136
+
137
+ return harness;
138
+ };
139
+
140
+ const createInput = {
141
+ accountId: ACCOUNT_ID,
142
+ accountConfigId: ACCOUNT_CONFIG_ID,
143
+ fromAddress: "me@example.com",
144
+ toAddresses: ["them@example.com"],
145
+ subject: "Invoice",
146
+ textBody: "Attached.",
147
+ };
148
+
149
+ const applied = (harness: Harness): UpdateOutboxMessageInput[] =>
150
+ harness.writes.filter((write) => write.applied).map((write) => write.input);
151
+
152
+ describe("an enqueue that reports a failure", () => {
153
+ it("settles the new row of createAndSend at `failed`, never at `queued`", async () => {
154
+ const harness = createHarness(row({}), {
155
+ enqueueFails: new Error("SQS unavailable"),
156
+ });
157
+
158
+ await assert.rejects(
159
+ () => harness.service.createAndSend(createInput),
160
+ /SQS unavailable/,
161
+ "the caller still hears the failure",
162
+ );
163
+
164
+ assert.deepEqual(harness.createdStatuses, [OutboxMessageStatus.queued]);
165
+ assert.equal(harness.status(), OutboxMessageStatus.failed);
166
+ });
167
+
168
+ it("settles at `failed` rather than `draft` — the event may have landed", async () => {
169
+ // `draft` is inside the worker's send fence, so a row put back there is
170
+ // sent by the landed event and sendable again by hand: two copies of one
171
+ // message. This is the assertion that keeps it out.
172
+ const harness = createHarness(row({}), {
173
+ enqueueFails: new Error("SQS unavailable"),
174
+ });
175
+
176
+ await assert.rejects(() => harness.service.createAndSend(createInput));
177
+
178
+ assert.notEqual(harness.status(), OutboxMessageStatus.draft);
179
+ assert.match(
180
+ String(applied(harness).at(-1)?.lastError),
181
+ /could not be handed to the outgoing queue/,
182
+ "and it says why, where the Outbox shows it",
183
+ );
184
+ });
185
+
186
+ it("takes a draft that was sent to `failed`, not back to `draft`", async () => {
187
+ const harness = createHarness(row({}), {
188
+ enqueueFails: new Error("SQS unavailable"),
189
+ });
190
+
191
+ await assert.rejects(() =>
192
+ harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
193
+ );
194
+
195
+ assert.equal(harness.status(), OutboxMessageStatus.failed);
196
+ });
197
+
198
+ it("returns a `blocked` row to `blocked` — outside the fence, and the reason stands", async () => {
199
+ const harness = createHarness(
200
+ row({ status: OutboxMessageStatus.blocked }),
201
+ {
202
+ enqueueFails: new Error("SQS unavailable"),
203
+ },
204
+ );
205
+
206
+ await assert.rejects(() =>
207
+ harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
208
+ );
209
+
210
+ assert.equal(harness.status(), OutboxMessageStatus.blocked);
211
+ });
212
+
213
+ it("only settles a row still at `queued` — the worker may already hold it", async () => {
214
+ const harness = createHarness(row({}), {
215
+ enqueueFails: new Error("SQS unavailable"),
216
+ });
217
+
218
+ await assert.rejects(() => harness.service.createAndSend(createInput));
219
+
220
+ const settle = harness.writes.at(-1);
221
+ assert.equal(settle?.expected, OutboxMessageStatus.queued);
222
+ });
223
+
224
+ it("still reports the enqueue failure when the settle write also fails", async () => {
225
+ // The settle is the repair, not the failure. Replacing the enqueue error
226
+ // with the repair's would name the wrong cause and hide the row's state.
227
+ const harness = createHarness(row({}), {
228
+ enqueueFails: new Error("SQS unavailable"),
229
+ settleFails: new Error("database unreachable"),
230
+ });
231
+
232
+ await assert.rejects(
233
+ () => harness.service.createAndSend(createInput),
234
+ /SQS unavailable/,
235
+ );
236
+ });
237
+
238
+ it("keeps the row at `queued` when the enqueue succeeds", async () => {
239
+ const harness = createHarness(row({}));
240
+
241
+ await harness.service.createAndSend(createInput);
242
+
243
+ assert.equal(harness.enqueued.length, 1);
244
+ assert.deepEqual(harness.writes, [], "nothing walked the row back");
245
+ });
246
+ });
247
+
248
+ describe("correcting a message that failed to send", () => {
249
+ it("accepts the edit and returns the row to `draft`", async () => {
250
+ const harness = createHarness(row({ status: OutboxMessageStatus.failed }));
251
+
252
+ const updated = await harness.service.updateDraft(
253
+ ACCOUNT_CONFIG_ID,
254
+ OUTBOX_MESSAGE_ID,
255
+ { toAddresses: ["them@example.com"] },
256
+ );
257
+
258
+ assert.deepEqual(applied(harness), [
259
+ {
260
+ status: OutboxMessageStatus.draft,
261
+ toAddresses: ["them@example.com"],
262
+ },
263
+ ]);
264
+ assert.equal(updated.status, OutboxMessageStatus.draft);
265
+ assert.equal(updated.subject, "Invoice", "its content came with it");
266
+ });
267
+
268
+ it("sends the corrected message", async () => {
269
+ const harness = createHarness(row({ status: OutboxMessageStatus.failed }));
270
+
271
+ await harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
272
+ toAddresses: ["them@example.com"],
273
+ });
274
+ await harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
275
+
276
+ assert.equal(harness.status(), OutboxMessageStatus.queued);
277
+ assert.equal(harness.enqueued.length, 1);
278
+ });
279
+
280
+ it("accepts the edit on a `blocked` row too — Send already does", async () => {
281
+ const harness = createHarness(row({ status: OutboxMessageStatus.blocked }));
282
+
283
+ await harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
284
+ subject: "Invoice, corrected",
285
+ });
286
+
287
+ assert.equal(applied(harness).at(0)?.status, OutboxMessageStatus.draft);
288
+ });
289
+
290
+ it("writes no status on a row that is already a draft", async () => {
291
+ const harness = createHarness(row({}));
292
+
293
+ await harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
294
+ subject: "Invoice, corrected",
295
+ });
296
+
297
+ assert.deepEqual(applied(harness), [{ subject: "Invoice, corrected" }]);
298
+ });
299
+ });
300
+
301
+ describe("a message that moves while the request is deciding", () => {
302
+ it("refuses the edit rather than pulling a queued row back to `draft`", async () => {
303
+ // The send this loses to has an event on the wire, and `draft` is inside
304
+ // the fence that event has to pass.
305
+ const harness = createHarness(row({ status: OutboxMessageStatus.failed }), {
306
+ movesOnRead: OutboxMessageStatus.queued,
307
+ });
308
+
309
+ await assert.rejects(
310
+ () =>
311
+ harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
312
+ toAddresses: ["them@example.com"],
313
+ }),
314
+ (error: unknown) => {
315
+ assert.ok(error instanceof ConflictError);
316
+ assert.equal(error.statusCode, 409);
317
+ return true;
318
+ },
319
+ );
320
+
321
+ assert.equal(harness.status(), OutboxMessageStatus.queued);
322
+ });
323
+
324
+ it("refuses the second of two sends racing the same row", async () => {
325
+ const harness = createHarness(row({}), {
326
+ movesOnRead: OutboxMessageStatus.sending,
327
+ });
328
+
329
+ await assert.rejects(
330
+ () => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
331
+ (error: unknown) => {
332
+ assert.ok(error instanceof ConflictError);
333
+ return true;
334
+ },
335
+ );
336
+
337
+ assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
338
+ });
339
+ });
340
+
341
+ describe("a message that is not the user's to edit", () => {
342
+ for (const status of [
343
+ OutboxMessageStatus.queued,
344
+ OutboxMessageStatus.sending,
345
+ OutboxMessageStatus.sent,
346
+ OutboxMessageStatus.unfiled,
347
+ ]) {
348
+ it(`refuses the edit of a \`${status}\` row`, async () => {
349
+ const harness = createHarness(row({ status }));
350
+
351
+ await assert.rejects(
352
+ () =>
353
+ harness.service.updateDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID, {
354
+ toAddresses: ["them@example.com"],
355
+ }),
356
+ (error: unknown) => {
357
+ assert.ok(error instanceof ConflictError);
358
+ assert.equal(error.statusCode, 409);
359
+ return true;
360
+ },
361
+ );
362
+
363
+ assert.deepEqual(harness.writes, []);
364
+ });
365
+ }
366
+ });
@@ -67,6 +67,15 @@ const createHarness = (stored: OutboxMessageItem): Harness => {
67
67
  harness.statusWrites.push(status);
68
68
  return { ...stored, status };
69
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
+ },
70
79
  delete: async (_configId: string, id: string) => {
71
80
  harness.deleted.push(id);
72
81
  },
@@ -72,6 +72,15 @@ const createHarness = (stored: OutboxMessageItem): Harness => {
72
72
  harness.statusWrites.push(status);
73
73
  return draft({ status });
74
74
  },
75
+ updateIfStatus: async (
76
+ _configId: string,
77
+ _id: string,
78
+ _expected: OutboxMessageItem["status"],
79
+ input: { status?: OutboxMessageItem["status"] },
80
+ ) => {
81
+ if (input.status) harness.statusWrites.push(input.status);
82
+ return draft(input as Partial<OutboxMessageItem>);
83
+ },
75
84
  } as unknown as IOutboxMessageRepository;
76
85
 
77
86
  harness.service = new OutboxQueueService({
@@ -9,6 +9,7 @@ import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
9
9
  import { OutboxMessageStatus } from "@remit/domain-enums";
10
10
  import { createQueueProducer } from "@remit/sqs-client/producer";
11
11
  import type { OutboxAttachmentService } from "./outbox-attachment.js";
12
+ import { isOpenForWork } from "./outbox-status.js";
12
13
 
13
14
  interface SendMessageEvent {
14
15
  type: "SEND_MESSAGE";
@@ -87,6 +88,38 @@ const hasNowhereToGo = (message: {
87
88
  const NO_RECIPIENT_MESSAGE =
88
89
  "This message has nobody to send to. Add a recipient before sending it.";
89
90
 
91
+ const ENQUEUE_FAILED_MESSAGE =
92
+ "This message could not be handed to the outgoing queue, so it was not sent. Send it again.";
93
+
94
+ const MOVED_WHILE_EDITING_MESSAGE =
95
+ "This message started sending while it was being edited, so the change was not saved. Open the Outbox to see where it stands.";
96
+
97
+ const MOVED_WHILE_SENDING_MESSAGE =
98
+ "This message already left the queue and cannot be sent again. Open the Outbox to see where it stands.";
99
+
100
+ /**
101
+ * Where a row goes when the enqueue reports a failure — never `draft`, and
102
+ * never back to `draft` where it came from.
103
+ *
104
+ * A throw from `SendMessage` does not prove the broker refused the event. This
105
+ * queue is not FIFO on the send path and carries no deduplication id, so a
106
+ * timeout or a lost response leaves an event that may still be delivered. And
107
+ * `draft` is inside the worker's send fence (`SENDABLE_STATUSES`, smtp-worker
108
+ * `send-message-core.ts`): a row put back there is sendable by the landed event
109
+ * and by the user pressing Send, which is two copies of one message. `failed`
110
+ * is outside that fence, so the landed event is dropped on arrival, and a
111
+ * `failed` row is editable and sendable by hand (#933) — the recovery survives.
112
+ *
113
+ * `blocked` is outside the fence too and names a cause the enqueue did not
114
+ * change, so a row that came from there goes back to it.
115
+ */
116
+ const settledStatusFor = (
117
+ priorStatus: OutboxMessageItem["status"],
118
+ ): OutboxMessageItem["status"] =>
119
+ priorStatus === OutboxMessageStatus.blocked
120
+ ? OutboxMessageStatus.blocked
121
+ : OutboxMessageStatus.failed;
122
+
90
123
  const generateMessageId = (domain: string): string => {
91
124
  const timestamp = Date.now();
92
125
  const random = randomUUID().replace(/-/g, "").slice(0, 16);
@@ -168,16 +201,30 @@ export class OutboxQueueService {
168
201
  outboxMessageId,
169
202
  "act",
170
203
  );
171
- if (existing.status !== OutboxMessageStatus.draft) {
204
+ if (!isOpenForWork(existing.status)) {
172
205
  throw new ConflictError(
173
206
  `This message is already ${existing.status} and can no longer be edited as a draft. Start a new message to change it.`,
174
207
  );
175
208
  }
176
209
 
177
- const updated = await this.outboxMessageService.update(
210
+ // Editing a settled failure returns the row to `draft`: it is no longer the
211
+ // message that failed, and the Outbox renders a `failed` row with its
212
+ // `lastError` — a failure reported against text that never went out. The
213
+ // same row moves to Drafts carrying its content, recipients and
214
+ // attachments, so there is no copy to reconcile.
215
+ //
216
+ // Conditional on the status this decision was read from. A concurrent send
217
+ // can move the row to `queued` between the two, and an unconditional write
218
+ // would pull it back to `draft` with its event already on the wire — the
219
+ // worker's fence takes `draft`, so that row goes out and stays sendable.
220
+ const updated = await this.outboxMessageService.updateIfStatus(
178
221
  accountConfigId,
179
222
  outboxMessageId,
223
+ existing.status,
180
224
  {
225
+ ...(existing.status !== OutboxMessageStatus.draft && {
226
+ status: OutboxMessageStatus.draft,
227
+ }),
181
228
  ...(input.toAddresses !== undefined && {
182
229
  toAddresses: input.toAddresses,
183
230
  }),
@@ -196,6 +243,7 @@ export class OutboxQueueService {
196
243
  }),
197
244
  },
198
245
  );
246
+ if (!updated) throw new ConflictError(MOVED_WHILE_EDITING_MESSAGE);
199
247
 
200
248
  this.log.info({ outboxMessageId }, "Updated draft outbox message");
201
249
 
@@ -211,11 +259,7 @@ export class OutboxQueueService {
211
259
  outboxMessageId,
212
260
  "act",
213
261
  );
214
- if (
215
- existing.status !== OutboxMessageStatus.draft &&
216
- existing.status !== OutboxMessageStatus.failed &&
217
- existing.status !== OutboxMessageStatus.blocked
218
- ) {
262
+ if (!isOpenForWork(existing.status)) {
219
263
  throw new ConflictError(
220
264
  `This message is already ${existing.status} and cannot be sent again. Open the Outbox to see where it stands.`,
221
265
  );
@@ -225,20 +269,25 @@ export class OutboxQueueService {
225
269
  throw new BadRequestError(NO_RECIPIENT_MESSAGE);
226
270
  }
227
271
 
228
- const updated = await this.outboxMessageService.updateStatus(
272
+ // Conditional on the status this send was decided against, so two presses —
273
+ // or a press racing the worker — produce one queued row and one conflict
274
+ // rather than two events for the same message.
275
+ const updated = await this.outboxMessageService.updateIfStatus(
229
276
  accountConfigId,
230
277
  outboxMessageId,
231
- OutboxMessageStatus.queued,
278
+ existing.status,
279
+ { status: OutboxMessageStatus.queued },
232
280
  );
281
+ if (!updated) throw new ConflictError(MOVED_WHILE_SENDING_MESSAGE);
233
282
 
234
283
  // `queued` is a dead end for a row the queue never accepted: `send` takes
235
284
  // draft, failed and blocked, `deleteDraft` those three plus unfiled, so a
236
285
  // row parked at `queued` by a failed enqueue is neither sendable nor
237
- // discardable (#845.8). Put it back where it came from and let the enqueue
238
- // failure surface — the row stays reachable, the caller still hears no.
286
+ // discardable (#845.8). Settle it and let the enqueue failure surface —
287
+ // the row stays reachable, the caller still hears no.
239
288
  await this.enqueueSend(existing.accountId, outboxMessageId).catch(
240
289
  async (error: unknown) => {
241
- await this.outboxMessageService.updateStatus(
290
+ await this.settleUnqueued(
242
291
  accountConfigId,
243
292
  outboxMessageId,
244
293
  existing.status,
@@ -282,7 +331,21 @@ export class OutboxQueueService {
282
331
  status: OutboxMessageStatus.queued,
283
332
  });
284
333
 
285
- await this.enqueueSend(input.accountId, outbox.outboxMessageId);
334
+ // Same dead end as in `send`: a row parked at `queued` by an enqueue that
335
+ // threw is neither sendable nor discardable (#845.8, #931, #936). This row
336
+ // is new, so there is no prior status — it settles at `failed`, carrying
337
+ // the reason, which keeps the composed text editable and sendable while
338
+ // staying outside the fence a landed event has to pass.
339
+ await this.enqueueSend(input.accountId, outbox.outboxMessageId).catch(
340
+ async (error: unknown) => {
341
+ await this.settleUnqueued(
342
+ input.accountConfigId,
343
+ outbox.outboxMessageId,
344
+ OutboxMessageStatus.failed,
345
+ );
346
+ throw error;
347
+ },
348
+ );
286
349
 
287
350
  this.log.info(
288
351
  { outboxMessageId: outbox.outboxMessageId, accountId: input.accountId },
@@ -330,6 +393,45 @@ export class OutboxQueueService {
330
393
  this.log.info({ outboxMessageId }, "Deleted outbox message");
331
394
  };
332
395
 
396
+ /**
397
+ * Take a row back out of `queued` after the enqueue reported a failure.
398
+ *
399
+ * Conditional on `queued`, because the event may have landed regardless — an
400
+ * error from `SendMessage` says the response was lost, not that the broker
401
+ * refused it. If the worker already has the row, it holds the newer truth and
402
+ * this write does nothing.
403
+ *
404
+ * Never rejects. The enqueue failure is what went wrong and what the caller
405
+ * has to hear; a settle that also fails would replace it with the wrong
406
+ * cause, so it is logged and the row is left where #936 found it — which the
407
+ * caller's error at least names.
408
+ */
409
+ private settleUnqueued = async (
410
+ accountConfigId: string,
411
+ outboxMessageId: string,
412
+ priorStatus: OutboxMessageItem["status"],
413
+ ): Promise<void> => {
414
+ const status = settledStatusFor(priorStatus);
415
+ await this.outboxMessageService
416
+ .updateIfStatus(
417
+ accountConfigId,
418
+ outboxMessageId,
419
+ OutboxMessageStatus.queued,
420
+ {
421
+ status,
422
+ ...(status === OutboxMessageStatus.failed && {
423
+ lastError: ENQUEUE_FAILED_MESSAGE,
424
+ }),
425
+ },
426
+ )
427
+ .catch((settleError: unknown) => {
428
+ this.log.error(
429
+ { outboxMessageId, settleError: String(settleError) },
430
+ "Could not settle an outbox message the queue refused",
431
+ );
432
+ });
433
+ };
434
+
333
435
  private enqueueSend = async (
334
436
  accountId: string,
335
437
  outboxMessageId: string,
@@ -0,0 +1,21 @@
1
+ import type { OutboxMessageItem } from "@remit/data-ports";
2
+ import { OutboxMessageStatus } from "@remit/domain-enums";
3
+
4
+ /**
5
+ * Whether the message is still the user's to work on: nothing of it is on the
6
+ * wire, so Send, Edit and its attachments all apply.
7
+ *
8
+ * One predicate for all three because they answer the same question, and
9
+ * splitting them is what dead-ended a refused message. Sending took `failed`
10
+ * while editing took only `draft`, so a message the server turned away for a
11
+ * bad address could be re-sent unchanged or deleted, and nothing else — Retry
12
+ * queued the same envelope, and Edit took a 409 on the flush that precedes the
13
+ * send (#933).
14
+ *
15
+ * Everything absent is in the worker's hands or settled: `queued` and `sending`
16
+ * are mid-flight, `sent` and `unfiled` were delivered.
17
+ */
18
+ export const isOpenForWork = (status: OutboxMessageItem["status"]): boolean =>
19
+ status === OutboxMessageStatus.draft ||
20
+ status === OutboxMessageStatus.failed ||
21
+ status === OutboxMessageStatus.blocked;