@remit/mailbox-service 0.0.102 → 0.0.104

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.
@@ -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