@remit/mailbox-service 0.0.73 → 0.0.75

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.73",
3
+ "version": "0.0.75",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -8,7 +8,7 @@ import type {
8
8
  IThreadMessageRepository,
9
9
  } from "@remit/data-ports";
10
10
  import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
11
- import { trashRole } from "./test-helpers/folder-roles.js";
11
+ import { NO_JUNK_ROLES, trashRole } from "./test-helpers/folder-roles.js";
12
12
 
13
13
  const ACCOUNT = "acc-1";
14
14
  const ACCOUNT_CONFIG = "cfg-1";
@@ -57,6 +57,7 @@ const buildWorld = (trashExists: boolean) => {
57
57
  const mailboxSpecialUseService = {
58
58
  findTrashMailbox: async () => trash,
59
59
  resolveTrashRole: async () => trashRole(trash),
60
+ resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
60
61
  } as unknown as IMailboxSpecialUseRepository;
61
62
 
62
63
  const addressService = {
@@ -15,6 +15,7 @@ import {
15
15
  StaleTrashAppointmentError,
16
16
  UnconfirmedTrashMailboxError,
17
17
  } from "./message-move.js";
18
+ import { NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
18
19
 
19
20
  const ACCOUNT = "acc-1";
20
21
  const ACCOUNT_CONFIG = "cfg-1";
@@ -80,6 +81,7 @@ const buildWorld = (
80
81
 
81
82
  const mailboxSpecialUseService = {
82
83
  resolveTrashRole: async () => trashResolution,
84
+ resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
83
85
  } as unknown as IMailboxSpecialUseRepository;
84
86
 
85
87
  const addressService = {
@@ -15,7 +15,11 @@ import { MailboxCursorState } from "@remit/domain-enums";
15
15
  import type { ManagedConnectionFactory } from "./connection-factory.js";
16
16
  import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
17
17
  import { MessageSyncService } from "./message-sync.js";
18
- import { noFolderRoles, trashRole } from "./test-helpers/folder-roles.js";
18
+ import {
19
+ NO_JUNK_ROLES,
20
+ noFolderRoles,
21
+ trashRole,
22
+ } from "./test-helpers/folder-roles.js";
19
23
  import type { IImapConnection, ImapMessage } from "./types.js";
20
24
 
21
25
  const stubAddressService = (): IAddressRepository =>
@@ -176,6 +180,7 @@ const buildWorld = () => {
176
180
  const mailboxSpecialUseService = {
177
181
  findTrashMailbox: async () => null,
178
182
  resolveTrashRole: async () => trashRole(null),
183
+ resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
179
184
  } as unknown as IMailboxSpecialUseRepository;
180
185
 
181
186
  const config: MessageMoveConfig = {
@@ -22,6 +22,7 @@ import {
22
22
  StaleTrashAppointmentError,
23
23
  UnconfirmedTrashMailboxError,
24
24
  } from "./message-move.js";
25
+ import { NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
25
26
 
26
27
  const ACCOUNT = "acc-1";
27
28
  const ACCOUNT_CONFIG = "cfg-1";
@@ -100,6 +101,7 @@ const buildWorld = (
100
101
  } as unknown as IAddressRepository,
101
102
  mailboxSpecialUseService: {
102
103
  resolveTrashRole: async () => trashResolution,
104
+ resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
103
105
  } as unknown as IMailboxSpecialUseRepository,
104
106
  threadMessageService,
105
107
  sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
@@ -8,7 +8,7 @@ import type {
8
8
  IThreadMessageRepository,
9
9
  } from "@remit/data-ports";
10
10
  import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
11
- import { trashRole } from "./test-helpers/folder-roles.js";
11
+ import { NO_JUNK_ROLES, trashRole } from "./test-helpers/folder-roles.js";
12
12
 
13
13
  const stubAddressService = (): IAddressRepository =>
14
14
  ({
@@ -68,6 +68,7 @@ const buildWorld = () => {
68
68
  const mailboxSpecialUseService = {
69
69
  findTrashMailbox: async () => null,
70
70
  resolveTrashRole: async () => trashRole(null),
71
+ resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
71
72
  } as unknown as IMailboxSpecialUseRepository;
72
73
 
73
74
  const config: MessageMoveConfig = {
@@ -395,6 +395,12 @@ export class MessageMoveService {
395
395
 
396
396
  // Process move to trash
397
397
  if (trashMailbox && moveToTrashMessages.length > 0) {
398
+ // Every message in the batch shares the config, so its Junk and Trash
399
+ // folders resolve once rather than once per message.
400
+ const configJunkRoles =
401
+ await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
402
+ accountConfigId,
403
+ );
398
404
  for (const { messageId, message, sourceMailbox } of moveToTrashMessages) {
399
405
  // Update local state optimistically
400
406
  await this.messageService.updateForMove(messageId, {
@@ -413,7 +419,10 @@ export class MessageMoveService {
413
419
  true,
414
420
  );
415
421
 
416
- await this.addressService.reconcileJunkOnlyForMessage(messageId);
422
+ await this.addressService.reconcileJunkOnlyForMessage(
423
+ messageId,
424
+ configJunkRoles,
425
+ );
417
426
 
418
427
  events.push({
419
428
  type: "MESSAGE_DELETE",
@@ -548,7 +557,12 @@ export class MessageMoveService {
548
557
  isMovingToTrash,
549
558
  );
550
559
 
551
- await this.addressService.reconcileJunkOnlyForMessage(messageId);
560
+ await this.addressService.reconcileJunkOnlyForMessage(
561
+ messageId,
562
+ await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
563
+ accountConfigId,
564
+ ),
565
+ );
552
566
 
553
567
  // If moving FROM Trash, clear isDeleted
554
568
  if (isMovingFromTrash) {
@@ -19,7 +19,7 @@ import {
19
19
  addressSightingIn,
20
20
  MessageSyncService,
21
21
  } from "./message-sync.js";
22
- import { folderRoles } from "./test-helpers/folder-roles.js";
22
+ import { folderRoles, NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
23
23
  import type { ImapEnvelope, ImapMessage } from "./types.js";
24
24
 
25
25
  const stub = <T>(): T => ({}) as T;
@@ -141,6 +141,7 @@ const save = async (
141
141
  ).saveMessage(mailbox, "acct-1", "cfg-1", msg, {
142
142
  junkMailboxId: role === "Junk" ? mailbox.mailboxId : null,
143
143
  trashMailboxId: role === "Trash" ? mailbox.mailboxId : null,
144
+ configJunkRoles: NO_JUNK_ROLES,
144
145
  });
145
146
 
146
147
  return saved;
@@ -155,6 +156,7 @@ describe("what a mailbox says about the addresses on its messages", () => {
155
156
  const ROLES = (junk: string | null, trash: string | null) => ({
156
157
  junkMailboxId: junk,
157
158
  trashMailboxId: trash,
159
+ configJunkRoles: NO_JUNK_ROLES,
158
160
  });
159
161
 
160
162
  it("reads the folder the account appointed, not the folder’s name", () => {
@@ -13,6 +13,7 @@ import type {
13
13
  ThreadMessageItem,
14
14
  } from "@remit/data-ports";
15
15
  import { storedDisplayName } from "@remit/data-ports/display-name";
16
+ import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
16
17
  import {
17
18
  deriveAddressId,
18
19
  deriveBodyPartId,
@@ -92,6 +93,14 @@ export type AddressSighting = "junk" | "discarded" | "correspondent";
92
93
  export interface AccountFolderRoles {
93
94
  readonly junkMailboxId: string | null;
94
95
  readonly trashMailboxId: string | null;
96
+ /**
97
+ * The same two roles across every account under the config, for the
98
+ * reconcile that follows a save into Junk. The address book is keyed by
99
+ * config, so a predicate over an address weighs sightings from all of them —
100
+ * resolving only this account withholds a sender on one and restores it on
101
+ * the next.
102
+ */
103
+ readonly configJunkRoles: JunkRoleMailboxes;
95
104
  }
96
105
 
97
106
  /**
@@ -462,7 +471,7 @@ export class MessageSyncService {
462
471
  // which catches its own error and reports a `failed` outcome instead of
463
472
  // rejecting. So one bad message can no longer abort the whole batch (the
464
473
  // poison pill that previously froze the mailbox, #817).
465
- const roles = await this.folderRolesFor(accountId);
474
+ const roles = await this.folderRolesFor(accountId, accountConfigId);
466
475
  const outcomes = await pMap(
467
476
  applicable,
468
477
  (msg) =>
@@ -697,7 +706,7 @@ export class MessageSyncService {
697
706
  const newMessages =
698
707
  newUids.length > 0 ? await this.fetchMessageBatch(newUids) : [];
699
708
  const applicable = newMessages.filter((msg) => msg.envelope !== undefined);
700
- const roles = await this.folderRolesFor(accountId);
709
+ const roles = await this.folderRolesFor(accountId, accountConfigId);
701
710
  const outcomes = await pMap(
702
711
  applicable,
703
712
  (msg) =>
@@ -925,7 +934,7 @@ export class MessageSyncService {
925
934
  );
926
935
  }
927
936
 
928
- const roles = await this.folderRolesFor(accountId);
937
+ const roles = await this.folderRolesFor(accountId, accountConfigId);
929
938
  const outcomes = await pMap(
930
939
  applicable,
931
940
  (msg) =>
@@ -1280,14 +1289,19 @@ export class MessageSyncService {
1280
1289
  * batch rather than per message: the lookup reads an appointment row and
1281
1290
  * the account’s mailbox list, and every message in a batch shares both.
1282
1291
  */
1283
- private async folderRolesFor(accountId: string): Promise<AccountFolderRoles> {
1284
- const [junk, trash] = await Promise.all([
1292
+ private async folderRolesFor(
1293
+ accountId: string,
1294
+ accountConfigId: string,
1295
+ ): Promise<AccountFolderRoles> {
1296
+ const [junk, trash, configJunkRoles] = await Promise.all([
1285
1297
  this.mailboxSpecialUseService.findJunkMailbox(accountId),
1286
1298
  this.mailboxSpecialUseService.findTrashMailbox(accountId),
1299
+ this.mailboxSpecialUseService.resolveJunkRolesForConfig(accountConfigId),
1287
1300
  ]);
1288
1301
  return {
1289
1302
  junkMailboxId: junk?.mailboxId ?? null,
1290
1303
  trashMailboxId: trash?.mailboxId ?? null,
1304
+ configJunkRoles,
1291
1305
  };
1292
1306
  }
1293
1307
 
@@ -1432,7 +1446,10 @@ export class MessageSyncService {
1432
1446
  owned = created || item.mailboxId === mailboxId;
1433
1447
 
1434
1448
  if (sighting === "junk") {
1435
- await repos.address.reconcileJunkOnlyForMessage(messageId);
1449
+ await repos.address.reconcileJunkOnlyForMessage(
1450
+ messageId,
1451
+ roles.configJunkRoles,
1452
+ );
1436
1453
  }
1437
1454
 
1438
1455
  await this.createThreadForMessage(
@@ -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;
@@ -20,6 +20,7 @@ import type {
20
20
  UpdateMessageMoveInput,
21
21
  } from "@remit/data-ports";
22
22
  import { PlacementMoveService } from "./placement-move.js";
23
+ import { noFolderRoles } from "./test-helpers/folder-roles.js";
23
24
 
24
25
  const ACCOUNT_ID = "acc-pm";
25
26
  const ACCOUNT_CONFIG_ID = "cfg-pm";
@@ -111,6 +112,7 @@ const buildHarness = (): Harness => {
111
112
  threadMessageService,
112
113
  markerService,
113
114
  addressService,
115
+ mailboxSpecialUseService: noFolderRoles,
114
116
  sqsQueueUrl: "https://sqs.eu-west-1.amazonaws.com/000/message-mgmt",
115
117
  moveSettleTimeoutMs: 200,
116
118
  moveSettlePollMs: 10,
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
3
3
  import type {
4
4
  IAddressRepository,
5
+ IMailboxSpecialUseRepository,
5
6
  IMessagePlacementMoveRepository,
6
7
  IMessageRepository,
7
8
  IThreadMessageRepository,
@@ -44,6 +45,12 @@ export interface PlacementMoveConfig {
44
45
  threadMessageService: IThreadMessageRepository;
45
46
  markerService: IMessagePlacementMoveRepository;
46
47
  addressService: IAddressRepository;
48
+ /**
49
+ * Required, not optional: this mover carries automatic spam placement, and
50
+ * one built without the folder-role seam would silently leave the sender of
51
+ * every auto-filed spam suggestible in the compose picker.
52
+ */
53
+ mailboxSpecialUseService: IMailboxSpecialUseRepository;
47
54
  sqsQueueUrl: string;
48
55
  sqsEndpoint?: string;
49
56
  logger?: PlacementMoveLogger;
@@ -87,6 +94,7 @@ export class PlacementMoveService {
87
94
  private threadMessageService: IThreadMessageRepository;
88
95
  private markerService: IMessagePlacementMoveRepository;
89
96
  private addressService: IAddressRepository;
97
+ private mailboxSpecialUseService: IMailboxSpecialUseRepository;
90
98
  private sqs: SQSClient;
91
99
  private queueUrl: string;
92
100
  private log: PlacementMoveLogger;
@@ -98,6 +106,7 @@ export class PlacementMoveService {
98
106
  this.threadMessageService = config.threadMessageService;
99
107
  this.markerService = config.markerService;
100
108
  this.addressService = config.addressService;
109
+ this.mailboxSpecialUseService = config.mailboxSpecialUseService;
101
110
  this.queueUrl = config.sqsQueueUrl;
102
111
  this.log = config.logger ?? noopLogger;
103
112
  this.moveSettleTimeoutMs =
@@ -200,7 +209,12 @@ export class PlacementMoveService {
200
209
  originalUid: message.uid,
201
210
  });
202
211
 
203
- await this.addressService.reconcileJunkOnlyForMessage(messageId);
212
+ await this.addressService.reconcileJunkOnlyForMessage(
213
+ messageId,
214
+ await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
215
+ accountConfigId,
216
+ ),
217
+ );
204
218
 
205
219
  // The queue kick is a serious operational step, not a routine one — a
206
220
  // failure here MUST propagate (never swallowed): the marker stays
@@ -19,7 +19,7 @@ import {
19
19
  NoJunkMailboxError,
20
20
  SpamReportService,
21
21
  } from "./spam-report.js";
22
- import { trashRole } from "./test-helpers/folder-roles.js";
22
+ import { NO_JUNK_ROLES, trashRole } from "./test-helpers/folder-roles.js";
23
23
 
24
24
  const ACCOUNT = "acc-1";
25
25
  const ACCOUNT_CONFIG = "cfg-1";
@@ -235,6 +235,7 @@ const buildWorld = (
235
235
  findJunkMailbox: async () => junkMailbox,
236
236
  findTrashMailbox: async () => null,
237
237
  resolveTrashRole: async () => trashRole(null),
238
+ resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
238
239
  } as unknown as IMailboxSpecialUseRepository;
239
240
 
240
241
  const mailboxService = {
@@ -1,5 +1,8 @@
1
1
  import type { IMailboxSpecialUseRepository } from "@remit/data-ports";
2
- import type { RoleResolution } from "@remit/data-ports/folder-role";
2
+ import type {
3
+ JunkRoleMailboxes,
4
+ RoleResolution,
5
+ } from "@remit/data-ports/folder-role";
3
6
 
4
7
  type RoleMailbox = { mailboxId: string; fullPath: string };
5
8
 
@@ -24,6 +27,7 @@ export const noFolderRoles = {
24
27
  findJunkMailbox: async () => null,
25
28
  findTrashMailbox: async () => null,
26
29
  resolveTrashRole: async () => trashRole(null),
30
+ resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
27
31
  } as unknown as IMailboxSpecialUseRepository;
28
32
 
29
33
  /** A folder-role map naming the mailboxes that hold Junk and Trash. */
@@ -41,11 +45,22 @@ export const folderRoles = (roles: {
41
45
  : null,
42
46
  findTrashMailbox: async () => trash,
43
47
  resolveTrashRole: async () => trashRole(trash),
48
+ resolveJunkRolesForConfig: async () => ({
49
+ junkMailboxIds: roles.junkMailboxId ? [roles.junkMailboxId] : [],
50
+ trashMailboxIds: roles.trashMailboxId ? [roles.trashMailboxId] : [],
51
+ }),
44
52
  } as unknown as IMailboxSpecialUseRepository;
45
53
  };
46
54
 
55
+ /** An account whose config resolves no Junk and no Trash anywhere. */
56
+ export const NO_JUNK_ROLES: JunkRoleMailboxes = {
57
+ junkMailboxIds: [],
58
+ trashMailboxIds: [],
59
+ };
60
+
47
61
  /** The same account, as the resolved map `saveMessage` is handed. */
48
62
  export const NO_FOLDER_ROLES = {
49
63
  junkMailboxId: null,
50
64
  trashMailboxId: null,
65
+ configJunkRoles: NO_JUNK_ROLES,
51
66
  };