@remit/mailbox-service 0.0.103 → 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.
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.104",
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"
@@ -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