@remit/mailbox-service 0.0.103 → 0.0.105

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.103",
3
+ "version": "0.0.105",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -25,6 +25,10 @@
25
25
  "types": "./src/account-credentials.ts",
26
26
  "default": "./src/account-credentials.ts"
27
27
  },
28
+ "./outbox-attachment-content": {
29
+ "types": "./src/outbox-attachment-content.ts",
30
+ "default": "./src/outbox-attachment-content.ts"
31
+ },
28
32
  "./types": {
29
33
  "types": "./src/types.ts",
30
34
  "default": "./src/types.ts"
package/src/index.ts CHANGED
@@ -219,6 +219,8 @@ export {
219
219
  FolderRenameSettleError,
220
220
  isMailboxAbsentUpstream,
221
221
  isMailboxPresentUpstream,
222
+ isMailboxRefusedUpstream,
223
+ upstreamFailureReason,
222
224
  } from "./mailbox-upstream.js";
223
225
  export {
224
226
  denormalizeMessageCategory,
@@ -227,7 +227,7 @@ describe("MailboxManagementService.failDelete", () => {
227
227
  ]);
228
228
  const service = new MailboxManagementService(repo);
229
229
 
230
- await service.failDelete("acc-1", "mbx-1");
230
+ await service.failDelete("acc-1", "mbx-1", "Permission denied");
231
231
 
232
232
  assert.equal(byId.get("mbx-1")?.syncStatus, MailboxSyncStatus.failed);
233
233
  assert.equal(byId.get("mbx-1")?.fullPath, "Receipts");
@@ -239,7 +239,7 @@ describe("MailboxManagementService.failDelete", () => {
239
239
  const service = new MailboxManagementService(repo);
240
240
 
241
241
  await assert.rejects(
242
- service.failDelete("acc-1", "mbx-1"),
242
+ service.failDelete("acc-1", "mbx-1", "Permission denied"),
243
243
  (error: unknown) => (error as Error).name === "NotFoundError",
244
244
  );
245
245
  });
@@ -598,6 +598,7 @@ export class MailboxManagementService {
598
598
  mailboxId: string,
599
599
  oldPath: string,
600
600
  newPath: string,
601
+ reason: string,
601
602
  ): Promise<void> => {
602
603
  const root = await this.mailboxService
603
604
  .get(accountId, mailboxId)
@@ -643,6 +644,7 @@ export class MailboxManagementService {
643
644
  from: [MailboxSyncStatus.pending],
644
645
  wherePendingPath: row.pendingPath,
645
646
  to: MailboxSyncStatus.failed,
647
+ set: { syncFailureReason: reason },
646
648
  },
647
649
  );
648
650
  this.log.info(
@@ -811,10 +813,15 @@ export class MailboxManagementService {
811
813
  * target is written, which is how the client tells a failed delete from a
812
814
  * failed rename.
813
815
  */
814
- failDelete = async (accountId: string, mailboxId: string): Promise<void> => {
816
+ failDelete = async (
817
+ accountId: string,
818
+ mailboxId: string,
819
+ reason: string,
820
+ ): Promise<void> => {
815
821
  const failed = await this.mailboxService.transition(accountId, mailboxId, {
816
822
  from: [MailboxSyncStatus.deleting],
817
823
  to: MailboxSyncStatus.failed,
824
+ set: { syncFailureReason: reason },
818
825
  });
819
826
  if (!failed) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
820
827
  this.log.info(
@@ -361,7 +361,13 @@ describe("MailboxManagementService.syncRename — rows it must not claim", () =>
361
361
  ]);
362
362
  const service = new MailboxManagementService(repo);
363
363
 
364
- await service.failRename("acc-1", "mbx-parent", "Work", "Projects");
364
+ await service.failRename(
365
+ "acc-1",
366
+ "mbx-parent",
367
+ "Work",
368
+ "Projects",
369
+ "Permission denied",
370
+ );
365
371
 
366
372
  assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
367
373
  assert.equal(rowOf("mbx-other")?.syncStatus, MailboxSyncStatus.pending);
@@ -572,7 +578,13 @@ describe("MailboxManagementService.failRename", () => {
572
578
  ]);
573
579
  const service = new MailboxManagementService(repo);
574
580
 
575
- await service.failRename("acc-1", "mbx-parent", "Work", "Projects");
581
+ await service.failRename(
582
+ "acc-1",
583
+ "mbx-parent",
584
+ "Work",
585
+ "Projects",
586
+ "Permission denied",
587
+ );
576
588
 
577
589
  for (const [id, path, target] of [
578
590
  ["mbx-parent", "Work", "Projects"],
@@ -593,7 +605,13 @@ describe("MailboxManagementService.failRename", () => {
593
605
  const service = new MailboxManagementService(repo);
594
606
 
595
607
  await assert.rejects(
596
- service.failRename("acc-1", "mbx-parent", "Work", "Projects"),
608
+ service.failRename(
609
+ "acc-1",
610
+ "mbx-parent",
611
+ "Work",
612
+ "Projects",
613
+ "Permission denied",
614
+ ),
597
615
  (error: unknown) => (error as Error).name === "NotFoundError",
598
616
  );
599
617
  });
@@ -606,7 +624,13 @@ describe("MailboxManagementService.failRename", () => {
606
624
  const service = new MailboxManagementService(repo);
607
625
 
608
626
  await assert.doesNotReject(
609
- service.failRename("acc-1", "mbx-parent", "Work", "Projects"),
627
+ service.failRename(
628
+ "acc-1",
629
+ "mbx-parent",
630
+ "Work",
631
+ "Projects",
632
+ "Permission denied",
633
+ ),
610
634
  );
611
635
  });
612
636
 
@@ -627,7 +651,13 @@ describe("MailboxManagementService.failRename", () => {
627
651
  /not ready/,
628
652
  );
629
653
 
630
- await service.failRename("acc-1", "mbx-parent", "Work", "Projects");
654
+ await service.failRename(
655
+ "acc-1",
656
+ "mbx-parent",
657
+ "Work",
658
+ "Projects",
659
+ "Permission denied",
660
+ );
631
661
  assert.equal(rowOf("mbx-parent")?.syncStatus, MailboxSyncStatus.failed);
632
662
  assert.equal(rowOf("mbx-parent")?.fullPath, "Work");
633
663
  });
@@ -36,6 +36,18 @@ export const isMailboxPresentUpstream = (error: unknown): boolean => {
36
36
  return /already exists/i.test(saidByServer(error));
37
37
  };
38
38
 
39
+ const PERMANENT_REFUSALS = new Set(["NOPERM", "CANNOT"]);
40
+
41
+ export const isMailboxRefusedUpstream = (error: unknown): boolean =>
42
+ error instanceof Error &&
43
+ PERMANENT_REFUSALS.has(stringField(error, "serverResponseCode"));
44
+
45
+ export const upstreamFailureReason = (error: unknown): string => {
46
+ const serverText = stringField(error, "responseText").trim();
47
+ if (serverText.length > 0) return serverText;
48
+ return error instanceof Error ? error.message : String(error);
49
+ };
50
+
39
51
  /**
40
52
  * Something after the server RENAME failed — the local settle, not the
41
53
  * mutation. The rename landed, so this is never a refused rename: marking the
@@ -0,0 +1,145 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { OutboxAttachmentItem } from "@remit/data-ports";
4
+ import { createMockStorageService } from "@remit/storage-service";
5
+ import {
6
+ loadOutboxAttachmentContents,
7
+ OutboxAttachmentUnavailableError,
8
+ } from "./outbox-attachment-content.js";
9
+
10
+ const OWNER = {
11
+ accountConfigId: "cfg-1",
12
+ accountId: "acc-1",
13
+ outboxMessageId: "ob-1",
14
+ };
15
+ const NOW = 1_000;
16
+
17
+ const row = (
18
+ outboxAttachmentId: string,
19
+ overrides: Partial<OutboxAttachmentItem> = {},
20
+ ): OutboxAttachmentItem => ({
21
+ outboxAttachmentId,
22
+ outboxMessageId: OWNER.outboxMessageId,
23
+ accountId: OWNER.accountId,
24
+ accountConfigId: OWNER.accountConfigId,
25
+ filename: `${outboxAttachmentId}.txt`,
26
+ contentType: "text/plain",
27
+ sizeBytes: 5,
28
+ state: "Stored",
29
+ storageKey: `key/${outboxAttachmentId}`,
30
+ reservationExpiresAt: 0,
31
+ createdAt: 0,
32
+ updatedAt: 0,
33
+ ...overrides,
34
+ });
35
+
36
+ const setup = async (rows: OutboxAttachmentItem[], stored: string[]) => {
37
+ const storage = createMockStorageService();
38
+ for (const outboxAttachmentId of stored) {
39
+ await storage.storeOutboxAttachment({
40
+ ...OWNER,
41
+ outboxAttachmentId,
42
+ content: Buffer.from(`bytes of ${outboxAttachmentId}`),
43
+ });
44
+ }
45
+ return {
46
+ attachments: { listByOutboxMessage: async () => rows },
47
+ storage,
48
+ };
49
+ };
50
+
51
+ describe("loadOutboxAttachmentContents", () => {
52
+ it("returns every stored file with its bytes, in the order it was attached", async () => {
53
+ const deps = await setup(
54
+ [row("second", { createdAt: 20 }), row("first", { createdAt: 10 })],
55
+ ["first", "second"],
56
+ );
57
+
58
+ const contents = await loadOutboxAttachmentContents(deps, OWNER, NOW);
59
+
60
+ assert.deepEqual(
61
+ contents.map((item) => [
62
+ item.filename,
63
+ item.contentType,
64
+ item.content.toString(),
65
+ ]),
66
+ [
67
+ ["first.txt", "text/plain", "bytes of first"],
68
+ ["second.txt", "text/plain", "bytes of second"],
69
+ ],
70
+ );
71
+ });
72
+
73
+ it("leaves out a reservation that lapsed without its bytes", async () => {
74
+ const deps = await setup(
75
+ [
76
+ row("kept"),
77
+ row("lapsed", { state: "Pending", reservationExpiresAt: NOW - 1 }),
78
+ ],
79
+ ["kept"],
80
+ );
81
+
82
+ const contents = await loadOutboxAttachmentContents(deps, OWNER, NOW);
83
+
84
+ assert.deepEqual(
85
+ contents.map((item) => item.filename),
86
+ ["kept.txt"],
87
+ );
88
+ });
89
+
90
+ it("refuses to build a message while an upload is still open", async () => {
91
+ const deps = await setup(
92
+ [row("open", { state: "Pending", reservationExpiresAt: NOW + 60 })],
93
+ [],
94
+ );
95
+
96
+ await assert.rejects(
97
+ () => loadOutboxAttachmentContents(deps, OWNER, NOW),
98
+ (error: unknown) => {
99
+ assert.ok(error instanceof OutboxAttachmentUnavailableError);
100
+ assert.equal(error.filename, "open.txt");
101
+ assert.match(error.message, /never finished uploading/);
102
+ return true;
103
+ },
104
+ );
105
+ });
106
+
107
+ it("refuses to build a message whose stored file has no bytes behind it", async () => {
108
+ const deps = await setup([row("gone")], []);
109
+
110
+ await assert.rejects(
111
+ () => loadOutboxAttachmentContents(deps, OWNER, NOW),
112
+ (error: unknown) => {
113
+ assert.ok(error instanceof OutboxAttachmentUnavailableError);
114
+ assert.equal(
115
+ error.message,
116
+ '"gone.txt" could not be read: nothing is stored at key/gone',
117
+ );
118
+ return true;
119
+ },
120
+ );
121
+ });
122
+
123
+ it("names the file when storage itself fails", async () => {
124
+ const deps = {
125
+ attachments: { listByOutboxMessage: async () => [row("payroll")] },
126
+ storage: {
127
+ retrieveOutboxAttachment: async (): Promise<Buffer | null> => {
128
+ throw new Error("S3 is down");
129
+ },
130
+ },
131
+ };
132
+
133
+ await assert.rejects(
134
+ () => loadOutboxAttachmentContents(deps, OWNER, NOW),
135
+ (error: unknown) => {
136
+ assert.ok(error instanceof OutboxAttachmentUnavailableError);
137
+ assert.equal(
138
+ error.message,
139
+ '"payroll.txt" could not be read: storage failed (S3 is down)',
140
+ );
141
+ return true;
142
+ },
143
+ );
144
+ });
145
+ });
@@ -0,0 +1,99 @@
1
+ import type { IOutboxAttachmentRepository } from "@remit/data-ports";
2
+ import { holdsRoom } from "@remit/data-ports";
3
+ import type { StorageService } from "@remit/storage-service";
4
+
5
+ export interface OutboxAttachmentContent {
6
+ filename: string;
7
+ contentType: string;
8
+ content: Buffer;
9
+ }
10
+
11
+ /**
12
+ * A file on a message that cannot be put into it. Carries the file's name so
13
+ * whoever settles the message can tell the sender which file to fix.
14
+ */
15
+ export class OutboxAttachmentUnavailableError extends Error {
16
+ constructor(
17
+ readonly filename: string,
18
+ readonly detail: string,
19
+ options?: { cause: unknown },
20
+ ) {
21
+ super(`"${filename}" could not be read: ${detail}`, options);
22
+ this.name = "OutboxAttachmentUnavailableError";
23
+ }
24
+ }
25
+
26
+ const describeCause = (error: unknown): string =>
27
+ error instanceof Error ? error.message : String(error);
28
+
29
+ export interface OutboxAttachmentContentDeps {
30
+ attachments: Pick<IOutboxAttachmentRepository, "listByOutboxMessage">;
31
+ storage: Pick<StorageService, "retrieveOutboxAttachment">;
32
+ }
33
+
34
+ export interface OutboxAttachmentOwner {
35
+ accountConfigId: string;
36
+ accountId: string;
37
+ outboxMessageId: string;
38
+ }
39
+
40
+ /**
41
+ * Every file a message goes out with, bytes included, in the order they were
42
+ * attached. The send op refuses a draft with an upload still open, so a live
43
+ * `Pending` row here, or a `Stored` row with nothing behind it, is a message
44
+ * that cannot be built as the sender saw it — and sending it without the file
45
+ * would be worse than not sending it.
46
+ */
47
+ export const loadOutboxAttachmentContents = async (
48
+ deps: OutboxAttachmentContentDeps,
49
+ owner: OutboxAttachmentOwner,
50
+ nowSeconds: number,
51
+ ): Promise<OutboxAttachmentContent[]> => {
52
+ const held = await deps.attachments.listByOutboxMessage(
53
+ owner.accountConfigId,
54
+ owner.outboxMessageId,
55
+ );
56
+ const live = held
57
+ .filter((item) => holdsRoom(item, nowSeconds))
58
+ .sort((a, b) => a.createdAt - b.createdAt);
59
+
60
+ const unfinished = live.find((item) => item.state !== "Stored");
61
+ if (unfinished) {
62
+ throw new OutboxAttachmentUnavailableError(
63
+ unfinished.filename,
64
+ `attachment ${unfinished.outboxAttachmentId} never finished uploading`,
65
+ );
66
+ }
67
+
68
+ return Promise.all(
69
+ live.map(async (item) => {
70
+ const content = await Promise.resolve()
71
+ .then(() =>
72
+ deps.storage.retrieveOutboxAttachment(
73
+ owner.accountConfigId,
74
+ owner.accountId,
75
+ owner.outboxMessageId,
76
+ item.outboxAttachmentId,
77
+ ),
78
+ )
79
+ .catch((error: unknown) => {
80
+ throw new OutboxAttachmentUnavailableError(
81
+ item.filename,
82
+ `storage failed (${describeCause(error)})`,
83
+ { cause: error },
84
+ );
85
+ });
86
+ if (content === null) {
87
+ throw new OutboxAttachmentUnavailableError(
88
+ item.filename,
89
+ `nothing is stored at ${item.storageKey}`,
90
+ );
91
+ }
92
+ return {
93
+ filename: item.filename,
94
+ contentType: item.contentType,
95
+ content,
96
+ };
97
+ }),
98
+ );
99
+ };
@@ -13,6 +13,10 @@ import {
13
13
  buildOutboxAttachmentKey,
14
14
  UPLOAD_URL_TTL_SECONDS,
15
15
  } from "@remit/storage-service";
16
+ import {
17
+ loadOutboxAttachmentContents,
18
+ type OutboxAttachmentContent,
19
+ } from "./outbox-attachment-content.js";
16
20
  import {
17
21
  normalizeAttachmentContentType,
18
22
  sanitizeAttachmentFilename,
@@ -407,6 +411,34 @@ export class OutboxAttachmentService {
407
411
  return held.filter((item) => holdsRoom(item, nowSeconds));
408
412
  };
409
413
 
414
+ /**
415
+ * The files a message goes out with, bytes included. The Sent copy is built
416
+ * from the same rows the wire copy was, so the two carry the same parts.
417
+ */
418
+ contentsFor = (
419
+ accountConfigId: string,
420
+ accountId: string,
421
+ outboxMessageId: string,
422
+ ): Promise<OutboxAttachmentContent[]> =>
423
+ loadOutboxAttachmentContents(
424
+ { attachments: this.attachments, storage: this.storage },
425
+ { accountConfigId, accountId, outboxMessageId },
426
+ this.now(),
427
+ );
428
+
429
+ /**
430
+ * The file that stops a send, if any: one still uploading, or one whose
431
+ * upload never finished. A message must not leave without a file its sender
432
+ * can see in the composer.
433
+ */
434
+ unfinishedUpload = async (
435
+ accountConfigId: string,
436
+ outboxMessageId: string,
437
+ ): Promise<OutboxAttachmentItem | undefined> => {
438
+ const live = await this.listFor(accountConfigId, outboxMessageId);
439
+ return live.find((item) => item.state !== "Stored");
440
+ };
441
+
410
442
  /**
411
443
  * Drop the draft's lapsed reservations and answer with the ids that are still
412
444
  * good. The sweep runs this before it decides what to collect: a lapsed row
@@ -122,7 +122,9 @@ const createHarness = (
122
122
 
123
123
  harness.service = new OutboxQueueService({
124
124
  outboxMessageService,
125
- outboxAttachmentService: {} as unknown as OutboxAttachmentService,
125
+ outboxAttachmentService: {
126
+ unfinishedUpload: async () => undefined,
127
+ } as unknown as OutboxAttachmentService,
126
128
  accountService: {} as unknown as IAccountRepository,
127
129
  sqsSmtpQueueUrl: "http://localhost/queue",
128
130
  sqsClient: {
@@ -87,6 +87,7 @@ const createHarness = (stored: OutboxMessageItem): Harness => {
87
87
  discardAll: async (_configId: string, _accountId: string, id: string) => {
88
88
  harness.discarded.push(id);
89
89
  },
90
+ unfinishedUpload: async () => undefined,
90
91
  } as unknown as OutboxAttachmentService,
91
92
  accountService: {} as unknown as IAccountRepository,
92
93
  sqsSmtpQueueUrl: "http://localhost/queue",
@@ -15,6 +15,7 @@ import { describe, it } from "node:test";
15
15
  import type {
16
16
  IAccountRepository,
17
17
  IOutboxMessageRepository,
18
+ OutboxAttachmentItem,
18
19
  OutboxMessageItem,
19
20
  } from "@remit/data-ports";
20
21
  import { BadRequestError } from "@remit/data-ports/errors";
@@ -50,7 +51,12 @@ interface Harness {
50
51
  statusWrites: string[];
51
52
  }
52
53
 
53
- const createHarness = (stored: OutboxMessageItem): Harness => {
54
+ const createHarness = (
55
+ stored: OutboxMessageItem,
56
+ attachments: OutboxAttachmentItem[] = [],
57
+ lateAttachments: OutboxAttachmentItem[] = [],
58
+ ): Harness => {
59
+ let unfinishedChecks = 0;
54
60
  const harness: Harness = {
55
61
  service: undefined as unknown as OutboxQueueService,
56
62
  enqueued: [],
@@ -85,7 +91,13 @@ const createHarness = (stored: OutboxMessageItem): Harness => {
85
91
 
86
92
  harness.service = new OutboxQueueService({
87
93
  outboxMessageService,
88
- outboxAttachmentService: {} as unknown as OutboxAttachmentService,
94
+ outboxAttachmentService: {
95
+ unfinishedUpload: async () => {
96
+ unfinishedChecks += 1;
97
+ const held = unfinishedChecks === 1 ? attachments : lateAttachments;
98
+ return held.find((item) => item.state !== "Stored");
99
+ },
100
+ } as unknown as OutboxAttachmentService,
89
101
  accountService: {} as unknown as IAccountRepository,
90
102
  sqsSmtpQueueUrl: "http://localhost/queue",
91
103
  sqsClient: {
@@ -99,6 +111,21 @@ const createHarness = (stored: OutboxMessageItem): Harness => {
99
111
  return harness;
100
112
  };
101
113
 
114
+ const pendingFile: OutboxAttachmentItem = {
115
+ outboxAttachmentId: "att-late",
116
+ outboxMessageId: OUTBOX_MESSAGE_ID,
117
+ accountId: ACCOUNT_ID,
118
+ accountConfigId: ACCOUNT_CONFIG_ID,
119
+ filename: "report.pdf",
120
+ contentType: "application/pdf",
121
+ sizeBytes: 10,
122
+ state: "Pending",
123
+ storageKey: "k",
124
+ reservationExpiresAt: Number.MAX_SAFE_INTEGER,
125
+ createdAt: 0,
126
+ updatedAt: 0,
127
+ };
128
+
102
129
  const sendInput = (overrides: Record<string, unknown>) => ({
103
130
  accountId: ACCOUNT_ID,
104
131
  accountConfigId: ACCOUNT_CONFIG_ID,
@@ -126,6 +153,56 @@ describe("OutboxQueueService and a message with nowhere to go", () => {
126
153
  assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
127
154
  });
128
155
 
156
+ it("puts the draft back when a reservation lands between the check and the queue", async () => {
157
+ const harness = createHarness(draft({}), [], [pendingFile]);
158
+
159
+ await assert.rejects(
160
+ () => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
161
+ (error: unknown) => {
162
+ assert.ok(error instanceof BadRequestError);
163
+ assert.match(error.message, /"report\.pdf" has not finished uploading/);
164
+ return true;
165
+ },
166
+ );
167
+
168
+ assert.deepEqual(harness.statusWrites, [
169
+ OutboxMessageStatus.queued,
170
+ OutboxMessageStatus.draft,
171
+ ]);
172
+ assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
173
+ });
174
+
175
+ it("refuses to queue a draft with a file still uploading, and names the file", async () => {
176
+ const harness = createHarness(draft({}), [
177
+ {
178
+ outboxAttachmentId: "att-1",
179
+ outboxMessageId: OUTBOX_MESSAGE_ID,
180
+ accountId: ACCOUNT_ID,
181
+ accountConfigId: ACCOUNT_CONFIG_ID,
182
+ filename: "report.pdf",
183
+ contentType: "application/pdf",
184
+ sizeBytes: 10,
185
+ state: "Pending",
186
+ storageKey: "k",
187
+ reservationExpiresAt: Number.MAX_SAFE_INTEGER,
188
+ createdAt: 0,
189
+ updatedAt: 0,
190
+ },
191
+ ]);
192
+
193
+ await assert.rejects(
194
+ () => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
195
+ (error: unknown) => {
196
+ assert.ok(error instanceof BadRequestError);
197
+ assert.match(error.message, /"report\.pdf" has not finished uploading/);
198
+ return true;
199
+ },
200
+ );
201
+
202
+ assert.deepEqual(harness.statusWrites, [], "it stayed a draft");
203
+ assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
204
+ });
205
+
129
206
  it("queues a draft addressed only in Bcc — that envelope is real", async () => {
130
207
  const harness = createHarness(
131
208
  draft({ toAddresses: [], bccAddresses: ["them@example.com"] }),
@@ -269,6 +269,16 @@ export class OutboxQueueService {
269
269
  throw new BadRequestError(NO_RECIPIENT_MESSAGE);
270
270
  }
271
271
 
272
+ const unfinished = await this.outboxAttachmentService.unfinishedUpload(
273
+ accountConfigId,
274
+ outboxMessageId,
275
+ );
276
+ if (unfinished) {
277
+ throw new BadRequestError(
278
+ `"${unfinished.filename}" has not finished uploading. Wait for it, or remove it, and send again.`,
279
+ );
280
+ }
281
+
272
282
  // Conditional on the status this send was decided against, so two presses —
273
283
  // or a press racing the worker — produce one queued row and one conflict
274
284
  // rather than two events for the same message.
@@ -280,6 +290,25 @@ export class OutboxQueueService {
280
290
  );
281
291
  if (!updated) throw new ConflictError(MOVED_WHILE_SENDING_MESSAGE);
282
292
 
293
+ // A reservation made from another tab between the check above and the
294
+ // move to `queued` got past it; one made after the move is refused, so a
295
+ // second look now is the last word.
296
+ const lateUpload = await this.outboxAttachmentService.unfinishedUpload(
297
+ accountConfigId,
298
+ outboxMessageId,
299
+ );
300
+ if (lateUpload) {
301
+ await this.outboxMessageService.updateIfStatus(
302
+ accountConfigId,
303
+ outboxMessageId,
304
+ OutboxMessageStatus.queued,
305
+ { status: existing.status },
306
+ );
307
+ throw new BadRequestError(
308
+ `"${lateUpload.filename}" has not finished uploading. Wait for it, or remove it, and send again.`,
309
+ );
310
+ }
311
+
283
312
  // `queued` is a dead end for a row the queue never accepted: `send` takes
284
313
  // draft, failed and blocked, `deleteDraft` those three plus unfiled, so a
285
314
  // row parked at `queued` by a failed enqueue is neither sendable nor