@remit/backend 0.0.59 → 0.0.61

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.
@@ -0,0 +1,97 @@
1
+ import type {
2
+ MintOutboxAttachmentInput,
3
+ MintOutboxAttachmentResponse,
4
+ OutboxAttachmentRejection,
5
+ OutboxAttachmentResponse,
6
+ } from "@remit/api-openapi-types";
7
+ import { OutboxAttachmentRejectionReason } from "@remit/domain-enums";
8
+ import type {
9
+ OutboxAttachmentRejectionDetail,
10
+ OutboxAttachmentRejectionReasonValue,
11
+ } from "@remit/mailbox-service";
12
+ import type { APIGatewayProxyEvent } from "aws-lambda";
13
+ import type { Context } from "openapi-backend";
14
+ import { getAccountConfigIdFromEvent } from "../auth.js";
15
+ import { getClient } from "../service/data-client.js";
16
+
17
+ const TOO_LARGE: ReadonlySet<OutboxAttachmentRejectionReasonValue> = new Set([
18
+ OutboxAttachmentRejectionReason.FileTooLarge,
19
+ OutboxAttachmentRejectionReason.MessageTooLarge,
20
+ OutboxAttachmentRejectionReason.TooManyAttachments,
21
+ ]);
22
+
23
+ interface RejectionResponse {
24
+ statusCode: number;
25
+ body: OutboxAttachmentRejection;
26
+ }
27
+
28
+ const refuse = (
29
+ detail: OutboxAttachmentRejectionDetail,
30
+ ): RejectionResponse => ({
31
+ statusCode: TOO_LARGE.has(detail.reason) ? 413 : 400,
32
+ body: {
33
+ reason: detail.reason,
34
+ message: detail.message,
35
+ limitBytes: detail.limitBytes,
36
+ usedBytes: detail.usedBytes,
37
+ },
38
+ });
39
+
40
+ /**
41
+ * Reserve room on a draft and hand back somewhere to put the bytes. Small JSON
42
+ * in, small JSON out — the file itself never passes through here, which is what
43
+ * lets the upload live on its own fleet and keeps this operation under any
44
+ * payload ceiling the REST tier has.
45
+ */
46
+ export const mintOutboxAttachment = async (
47
+ context: Context,
48
+ ...args: unknown[]
49
+ ): Promise<MintOutboxAttachmentResponse | RejectionResponse> => {
50
+ const event = args[0] as APIGatewayProxyEvent;
51
+ const accountConfigId = getAccountConfigIdFromEvent(event);
52
+ const { outboxMessageId } = context.request.params as {
53
+ outboxMessageId: string;
54
+ };
55
+ const input = context.request.requestBody as MintOutboxAttachmentInput;
56
+
57
+ const client = await getClient();
58
+ const result = await client.outboxAttachment.mint({
59
+ accountConfigId,
60
+ outboxMessageId,
61
+ filename: input.filename,
62
+ contentType: input.contentType,
63
+ sizeBytes: input.sizeBytes,
64
+ });
65
+
66
+ if (result.outcome === "Rejected") return refuse(result.rejection);
67
+
68
+ return result.reservation;
69
+ };
70
+
71
+ /**
72
+ * Confirm the upload landed. The size in the response is read back from
73
+ * storage — on a deployment where the browser PUT straight to block storage
74
+ * this call is the first the API hears of the bytes, and the client's word for
75
+ * how many there are is not evidence.
76
+ */
77
+ export const completeOutboxAttachment = async (
78
+ context: Context,
79
+ ...args: unknown[]
80
+ ): Promise<OutboxAttachmentResponse | RejectionResponse> => {
81
+ const event = args[0] as APIGatewayProxyEvent;
82
+ const accountConfigId = getAccountConfigIdFromEvent(event);
83
+ const { outboxMessageId, outboxAttachmentId } = context.request.params as {
84
+ outboxMessageId: string;
85
+ outboxAttachmentId: string;
86
+ };
87
+ const client = await getClient();
88
+ const result = await client.outboxAttachment.complete({
89
+ accountConfigId,
90
+ outboxMessageId,
91
+ outboxAttachmentId,
92
+ });
93
+
94
+ if (result.outcome === "Rejected") return refuse(result.rejection);
95
+
96
+ return result.attachment;
97
+ };
@@ -17,15 +17,25 @@ import assert from "node:assert/strict";
17
17
  import { afterEach, describe, it } from "node:test";
18
18
  import type { SQSClient } from "@aws-sdk/client-sqs";
19
19
  import type {
20
+ CreateOutboxAttachmentInput,
20
21
  CreateOutboxMessageInput,
21
22
  IAccountRepository,
23
+ IOutboxAttachmentRepository,
22
24
  IOutboxMessageRepository,
25
+ OutboxAttachmentItem,
23
26
  OutboxMessageItem,
24
27
  UpdateOutboxMessageInput,
25
28
  } from "@remit/data-ports";
26
29
  import { NotFoundError } from "@remit/data-ports/errors";
27
30
  import { OutboxMessageStatus } from "@remit/domain-enums";
28
- import { OutboxQueueService } from "@remit/mailbox-service";
31
+ import {
32
+ OutboxAttachmentService,
33
+ OutboxQueueService,
34
+ } from "@remit/mailbox-service";
35
+ import {
36
+ createMockStorageService,
37
+ type StorageService,
38
+ } from "@remit/storage-service";
29
39
  import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
30
40
  import type { Context } from "openapi-backend";
31
41
  import { deriveAccountConfigId } from "../auth.js";
@@ -33,6 +43,7 @@ import { handleError } from "../error.js";
33
43
  import { formatResponse } from "../response.js";
34
44
  import {
35
45
  _resetForTest,
46
+ getClient,
36
47
  type RemitClient,
37
48
  setClient,
38
49
  } from "../service/data-client.js";
@@ -138,13 +149,83 @@ const accountRepository = {
138
149
  }),
139
150
  } as unknown as IAccountRepository;
140
151
 
152
+ let installedStorage: StorageService | null = null;
153
+ let attachmentRows = new Map<string, OutboxAttachmentItem>();
154
+
155
+ /** Rows only — this suite asserts what a discard takes, not how bytes move. */
156
+ const createAttachmentRepository = (
157
+ rows: Map<string, OutboxAttachmentItem>,
158
+ ): IOutboxAttachmentRepository =>
159
+ ({
160
+ reserve: async (input: CreateOutboxAttachmentInput) => {
161
+ const item = {
162
+ ...input,
163
+ state: "Pending",
164
+ createdAt: 0,
165
+ updatedAt: 0,
166
+ } as OutboxAttachmentItem;
167
+ rows.set(item.outboxAttachmentId, item);
168
+ return { outcome: "Reserved", item };
169
+ },
170
+ listByOutboxMessage: async (
171
+ _accountConfigId: string,
172
+ outboxMessageId: string,
173
+ ) =>
174
+ [...rows.values()].filter(
175
+ (row) => row.outboxMessageId === outboxMessageId,
176
+ ),
177
+ deleteByOutboxMessage: async (
178
+ _accountConfigId: string,
179
+ outboxMessageId: string,
180
+ ) => {
181
+ for (const row of [...rows.values()]) {
182
+ if (row.outboxMessageId === outboxMessageId) {
183
+ rows.delete(row.outboxAttachmentId);
184
+ }
185
+ }
186
+ },
187
+ deleteLapsedReservations: async (
188
+ accountConfigId: string,
189
+ outboxMessageId: string,
190
+ nowSeconds: number,
191
+ ) => {
192
+ const gone: string[] = [];
193
+ for (const row of [...rows.values()]) {
194
+ if (
195
+ row.accountConfigId === accountConfigId &&
196
+ row.outboxMessageId === outboxMessageId &&
197
+ row.state === "Pending" &&
198
+ row.reservationExpiresAt < nowSeconds
199
+ ) {
200
+ rows.delete(row.outboxAttachmentId);
201
+ gone.push(row.outboxAttachmentId);
202
+ }
203
+ }
204
+ return gone;
205
+ },
206
+ deleteMany: async (_accountConfigId: string, ids: string[]) => {
207
+ for (const id of ids) rows.delete(id);
208
+ },
209
+ }) as unknown as IOutboxAttachmentRepository;
210
+
141
211
  const installClient = (): void => {
142
212
  const outboxMessage = createInMemoryOutboxRepository();
213
+ const storage = createMockStorageService();
214
+ attachmentRows = new Map<string, OutboxAttachmentItem>();
215
+ const outboxAttachmentService = new OutboxAttachmentService({
216
+ outboxMessageService: outboxMessage,
217
+ outboxAttachmentService: createAttachmentRepository(attachmentRows),
218
+ storage,
219
+ });
220
+ installedStorage = storage;
143
221
  setClient({
144
222
  outboxMessage,
223
+ storage,
145
224
  account: accountRepository,
225
+ outboxAttachment: outboxAttachmentService,
146
226
  outboxQueue: new OutboxQueueService({
147
227
  outboxMessageService: outboxMessage,
228
+ outboxAttachmentService,
148
229
  accountService: accountRepository,
149
230
  sqsSmtpQueueUrl: "http://localhost:9324/queue/outbox-test",
150
231
  sqsClient: acceptingSqsClient(),
@@ -292,3 +373,168 @@ describe("an outbox entry that has left draft (#604)", () => {
292
373
  assert.equal(body.subject, "still editing");
293
374
  });
294
375
  });
376
+
377
+ describe("discarding a draft that carries files (#679)", () => {
378
+ it("takes the stored bytes with it, leaving nothing behind", async () => {
379
+ installClient();
380
+ const storage = installedStorage;
381
+ assert.ok(storage);
382
+
383
+ const draft = await createDraft(
384
+ requestContext({}),
385
+ authorizedEvent({
386
+ accountId: ACCOUNT_ID,
387
+ toAddresses: ["recipient@example.com"],
388
+ }),
389
+ );
390
+ const outboxMessageId = String(draft.outboxMessageId);
391
+
392
+ const client = await getClient();
393
+ const minted = await client.outboxAttachment.mint({
394
+ accountConfigId: ACCOUNT_CONFIG_ID,
395
+ outboxMessageId,
396
+ filename: "receipt.pdf",
397
+ contentType: "application/pdf",
398
+ sizeBytes: 16,
399
+ });
400
+ assert.equal(minted.outcome, "Minted");
401
+ // A mint reserves in the ledger; nothing is uploaded yet, so what a discard
402
+ // has to take with it is the reservation, not an object.
403
+ // A mint writes a row; nothing is uploaded yet, so what a discard has to
404
+ // take with it is the row, not an object.
405
+ assert.equal(attachmentRows.size, 1);
406
+
407
+ const response = await respond(() =>
408
+ deleteDraft(
409
+ requestContext({ params: { outboxMessageId } }),
410
+ authorizedEvent(),
411
+ ),
412
+ );
413
+
414
+ assert.equal(response.statusCode, 204);
415
+ // Nothing else references these objects, so a row that goes without them
416
+ // leaves bytes no sweep collects.
417
+ assert.deepEqual(
418
+ await storage.listOutboxAttachments(
419
+ ACCOUNT_CONFIG_ID,
420
+ ACCOUNT_ID,
421
+ outboxMessageId,
422
+ ),
423
+ [],
424
+ );
425
+ assert.equal(attachmentRows.size, 0);
426
+ });
427
+ });
428
+
429
+ describe("attachmentIds on a draft update (#679)", () => {
430
+ const seed = async (): Promise<{
431
+ outboxMessageId: string;
432
+ ids: string[];
433
+ }> => {
434
+ const draft = await createDraft(
435
+ requestContext({}),
436
+ authorizedEvent({
437
+ accountId: ACCOUNT_ID,
438
+ toAddresses: ["recipient@example.com"],
439
+ }),
440
+ );
441
+ const outboxMessageId = String(draft.outboxMessageId);
442
+ const client = await getClient();
443
+ const ids: string[] = [];
444
+ for (const filename of ["one.txt", "two.txt"]) {
445
+ const minted = await client.outboxAttachment.mint({
446
+ accountConfigId: ACCOUNT_CONFIG_ID,
447
+ outboxMessageId,
448
+ filename,
449
+ contentType: "text/plain",
450
+ sizeBytes: 8,
451
+ });
452
+ assert.equal(minted.outcome, "Minted");
453
+ if (minted.outcome !== "Minted") throw new Error("unreachable");
454
+ ids.push(minted.reservation.outboxAttachmentId);
455
+ }
456
+ return { outboxMessageId, ids };
457
+ };
458
+
459
+ const patch = (outboxMessageId: string, requestBody: unknown) =>
460
+ respond(() =>
461
+ updateDraft(
462
+ requestContext({ params: { outboxMessageId }, requestBody }),
463
+ authorizedEvent(),
464
+ ),
465
+ );
466
+
467
+ it("absent leaves the files alone — a subject-only save keeps them", async () => {
468
+ installClient();
469
+ const { outboxMessageId } = await seed();
470
+
471
+ const response = await patch(outboxMessageId, { subject: "still typing" });
472
+
473
+ assert.equal(response.statusCode, 200);
474
+ const body = JSON.parse(response.body) as { attachments: unknown[] };
475
+ assert.equal(body.attachments.length, 2);
476
+ assert.equal(attachmentRows.size, 2);
477
+ });
478
+
479
+ it("present and empty removes every file", async () => {
480
+ installClient();
481
+ const { outboxMessageId } = await seed();
482
+
483
+ const response = await patch(outboxMessageId, { attachmentIds: [] });
484
+
485
+ assert.equal(response.statusCode, 200);
486
+ assert.deepEqual(
487
+ (JSON.parse(response.body) as { attachments: unknown[] }).attachments,
488
+ [],
489
+ );
490
+ assert.equal(attachmentRows.size, 0);
491
+ });
492
+
493
+ it("present with ids keeps those and removes the rest, bytes included", async () => {
494
+ installClient();
495
+ const { outboxMessageId, ids } = await seed();
496
+ const storage = installedStorage;
497
+ assert.ok(storage);
498
+ await storage.storeOutboxAttachment({
499
+ accountConfigId: ACCOUNT_CONFIG_ID,
500
+ accountId: ACCOUNT_ID,
501
+ outboxMessageId,
502
+ outboxAttachmentId: ids[1],
503
+ content: Buffer.from("dropped"),
504
+ });
505
+
506
+ const response = await patch(outboxMessageId, {
507
+ attachmentIds: [ids[0]],
508
+ });
509
+
510
+ assert.equal(response.statusCode, 200);
511
+ const body = JSON.parse(response.body) as {
512
+ attachments: { outboxAttachmentId: string }[];
513
+ };
514
+ assert.deepEqual(
515
+ body.attachments.map((item) => item.outboxAttachmentId),
516
+ [ids[0]],
517
+ );
518
+ assert.equal(
519
+ await storage.statOutboxAttachment(
520
+ ACCOUNT_CONFIG_ID,
521
+ ACCOUNT_ID,
522
+ outboxMessageId,
523
+ ids[1],
524
+ ),
525
+ null,
526
+ );
527
+ });
528
+
529
+ it("an unknown id is a no-op, not a reason to drop the known ones", async () => {
530
+ installClient();
531
+ const { outboxMessageId, ids } = await seed();
532
+
533
+ const response = await patch(outboxMessageId, {
534
+ attachmentIds: [...ids, "never-existed"],
535
+ });
536
+
537
+ assert.equal(response.statusCode, 200);
538
+ assert.equal(attachmentRows.size, 2);
539
+ });
540
+ });
@@ -3,7 +3,10 @@ import type {
3
3
  OutboxMessageResponse,
4
4
  UpdateOutboxMessageInput,
5
5
  } from "@remit/api-openapi-types";
6
- import type { OutboxMessageItem } from "@remit/data-ports";
6
+ import type {
7
+ OutboxAttachmentItem,
8
+ OutboxMessageItem,
9
+ } from "@remit/data-ports";
7
10
  import { ForbiddenError } from "@remit/data-ports/errors";
8
11
  import type { APIGatewayProxyEvent } from "aws-lambda";
9
12
  import type { Context } from "openapi-backend";
@@ -11,13 +14,31 @@ import { getAccountConfigIdFromEvent } from "../auth.js";
11
14
  import { getClient } from "../service/data-client.js";
12
15
  import type {
13
16
  OperationHandler,
17
+ OutboxAttachmentOperationIds,
14
18
  OutboxDetailOperationIds,
15
19
  OutboxOperationIds,
16
20
  } from "../types.js";
21
+ import {
22
+ completeOutboxAttachment,
23
+ mintOutboxAttachment,
24
+ } from "./outbox-attachment.js";
25
+
26
+ const toAttachmentResponse = (
27
+ item: OutboxAttachmentItem,
28
+ ): OutboxMessageResponse["attachments"][number] => ({
29
+ outboxAttachmentId: item.outboxAttachmentId,
30
+ outboxMessageId: item.outboxMessageId,
31
+ filename: item.filename,
32
+ contentType: item.contentType,
33
+ sizeBytes: item.sizeBytes,
34
+ state: item.state,
35
+ });
17
36
 
18
37
  const toOutboxMessageResponse = (
19
38
  item: OutboxMessageItem,
39
+ attachments: OutboxAttachmentItem[] = [],
20
40
  ): OutboxMessageResponse => ({
41
+ attachments: attachments.map(toAttachmentResponse),
21
42
  outboxMessageId: item.outboxMessageId,
22
43
  accountId: item.accountId,
23
44
  fromAddress: item.fromAddress,
@@ -118,7 +139,7 @@ export const OutboxOperations: Record<
118
139
  });
119
140
 
120
141
  return {
121
- items: result.items.map(toOutboxMessageResponse),
142
+ items: result.items.map((item) => toOutboxMessageResponse(item)),
122
143
  continuationToken: result.continuationToken,
123
144
  };
124
145
  },
@@ -145,7 +166,10 @@ export const OutboxDetailOperations: Record<
145
166
  accountConfigId,
146
167
  outboxMessageId,
147
168
  );
148
- return toOutboxMessageResponse(outbox);
169
+ return toOutboxMessageResponse(
170
+ outbox,
171
+ await client.outboxAttachment.listFor(accountConfigId, outboxMessageId),
172
+ );
149
173
  },
150
174
 
151
175
  OutboxDetailOperations_updateOutboxMessage: async (
@@ -176,7 +200,23 @@ export const OutboxDetailOperations: Record<
176
200
  references: input.references,
177
201
  },
178
202
  );
179
- return toOutboxMessageResponse(updated);
203
+
204
+ // Absent means "leave the files alone" — a body that only carries a subject
205
+ // must not strip them. Present states exactly what the draft keeps, so an
206
+ // empty list is a real instruction to remove them all.
207
+ if (input.attachmentIds !== undefined) {
208
+ await client.outboxAttachment.retainOnly(
209
+ accountConfigId,
210
+ updated.accountId,
211
+ outboxMessageId,
212
+ input.attachmentIds,
213
+ );
214
+ }
215
+
216
+ return toOutboxMessageResponse(
217
+ updated,
218
+ await client.outboxAttachment.listFor(accountConfigId, outboxMessageId),
219
+ );
180
220
  },
181
221
 
182
222
  OutboxDetailOperations_deleteOutboxMessage: async (
@@ -215,4 +255,13 @@ export const OutboxDetailOperations: Record<
215
255
  );
216
256
  return toOutboxMessageResponse(sent);
217
257
  },
258
+
259
+ OutboxDetailOperations_mintOutboxAttachment: mintOutboxAttachment,
260
+ };
261
+
262
+ export const OutboxAttachmentOperations: Record<
263
+ OutboxAttachmentOperationIds,
264
+ OperationHandler<OutboxAttachmentOperationIds>
265
+ > = {
266
+ OutboxAttachmentOperations_completeOutboxAttachment: completeOutboxAttachment,
218
267
  };
@@ -22,6 +22,7 @@ import {
22
22
  MessagePlacementMoveRepo,
23
23
  messageDataSchema,
24
24
  OrganizeJobRequestRepo,
25
+ OutboxAttachmentRepo,
25
26
  OutboxMessageRepo,
26
27
  QuarantineRepo,
27
28
  } from "@remit/drizzle-service";
@@ -59,6 +60,7 @@ export const buildSqliteClient = async (): Promise<RemitClient> => {
59
60
  message: new DrizzleMessageRepository(messageDataDb),
60
61
  messageFlag: new DrizzleMessageFlagRepository(messageDataDb),
61
62
  outboxMessage: new OutboxMessageRepo(genericDb),
63
+ outboxAttachment: new OutboxAttachmentRepo(genericDb),
62
64
  threadMessage: new DrizzleThreadMessageRepository(genericDb),
63
65
  envelope: new DrizzleEnvelopeRepository(messageDataDb),
64
66
  accountExportRequest: new AccountExportRequestRepo(genericDb),
@@ -18,6 +18,7 @@ import type {
18
18
  IMessagePlacementMoveRepository,
19
19
  IMessageRepository,
20
20
  IOrganizeJobRequestRepository,
21
+ IOutboxAttachmentRepository,
21
22
  IOutboxMessageRepository,
22
23
  IQuarantineRepository,
23
24
  IThreadMessageRepository,
@@ -33,6 +34,7 @@ import {
33
34
  type IImapConnection,
34
35
  MailboxQueueService,
35
36
  MessageMoveService,
37
+ OutboxAttachmentService,
36
38
  OutboxQueueService,
37
39
  PlacementMoveService,
38
40
  SpamReportService,
@@ -144,6 +146,7 @@ export interface RemitClient {
144
146
  flagQueue: FlagQueueService;
145
147
  mailboxQueue: MailboxQueueService;
146
148
  messageMove: MessageMoveService;
149
+ outboxAttachment: OutboxAttachmentService;
147
150
  outboxQueue: OutboxQueueService;
148
151
 
149
152
  // Report-spam / not-spam (block-and-move, unified from the old separate
@@ -166,6 +169,7 @@ export interface RemitClientRepositories {
166
169
  message: IMessageRepository;
167
170
  messageFlag: IMessageFlagRepository;
168
171
  outboxMessage: IOutboxMessageRepository;
172
+ outboxAttachment: IOutboxAttachmentRepository;
169
173
  threadMessage: IThreadMessageRepository;
170
174
  envelope: IEnvelopeRepository;
171
175
  accountExportRequest: IAccountExportRequestRepository;
@@ -356,6 +360,15 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
356
360
  logger,
357
361
  });
358
362
 
363
+ // One instance, shared with the queue service below: the per-draft chain that
364
+ // makes the attachment cap safe under concurrency lives on it, and a discard
365
+ // racing an upload has to take the same lock the upload does.
366
+ const outboxAttachmentService = new OutboxAttachmentService({
367
+ outboxMessageService: repositories.outboxMessage,
368
+ outboxAttachmentService: repositories.outboxAttachment,
369
+ storage,
370
+ });
371
+
359
372
  const bodySync = new BodySyncService(
360
373
  repositories.message,
361
374
  storage,
@@ -408,8 +421,10 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
408
421
  logger,
409
422
  }),
410
423
  messageMove: messageMoveService,
424
+ outboxAttachment: outboxAttachmentService,
411
425
  outboxQueue: new OutboxQueueService({
412
426
  outboxMessageService: repositories.outboxMessage,
427
+ outboxAttachmentService,
413
428
  accountService: repositories.account,
414
429
  sqsSmtpQueueUrl,
415
430
  logger,
package/src/types.ts CHANGED
@@ -63,6 +63,8 @@ export type OperationIds =
63
63
  | "OutboxDetailOperations_updateOutboxMessage"
64
64
  | "OutboxDetailOperations_deleteOutboxMessage"
65
65
  | "OutboxDetailOperations_sendOutboxMessage"
66
+ | "OutboxDetailOperations_mintOutboxAttachment"
67
+ | "OutboxAttachmentOperations_completeOutboxAttachment"
66
68
  | "AddressOperations_searchAddresses"
67
69
  | "AddressDetailOperations_updateAddress";
68
70
 
@@ -159,6 +161,11 @@ export type OutboxDetailOperationIds = MatchPrefix<
159
161
  OperationIds
160
162
  >;
161
163
 
164
+ export type OutboxAttachmentOperationIds = MatchPrefix<
165
+ "OutboxAttachmentOperations_",
166
+ OperationIds
167
+ >;
168
+
162
169
  export type AddressOperationIds = MatchPrefix<
163
170
  "AddressOperations_",
164
171
  OperationIds