@remit/backend 0.0.55 → 0.0.57

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/backend",
3
- "version": "0.0.55",
3
+ "version": "0.0.57",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -166,6 +166,35 @@ describe("enrichThreadRows — labels", () => {
166
166
  });
167
167
  });
168
168
 
169
+ describe("enrichThreadRows — spamReport", () => {
170
+ test("projects spamReport straight from the Message row", async () => {
171
+ const rows = [threadRow("tm-1", "msg-1")];
172
+ const client: EnrichClient = {
173
+ message: {
174
+ get: async () =>
175
+ [
176
+ {
177
+ messageId: "msg-1",
178
+ spamReport: { reportedAt: 1_700_000_000_000 },
179
+ },
180
+ ] as unknown as MessageItem[],
181
+ },
182
+ address: { getAddress: async () => [] },
183
+ messageLabel: { listByMessageIds: async () => [] },
184
+ label: { listByAccountConfig: async () => [] },
185
+ };
186
+
187
+ const [result] = await enrichThreadRows(rows, client, "acc-1");
188
+ assert.deepEqual(result?.spamReport, { reportedAt: 1_700_000_000_000 });
189
+ });
190
+
191
+ test("omits spamReport when the Message has never been reported", async () => {
192
+ const rows = [threadRow("tm-1", "msg-1")];
193
+ const [result] = await enrichThreadRows(rows, buildClient([], []), "acc-1");
194
+ assert.equal(result?.spamReport, undefined);
195
+ });
196
+ });
197
+
169
198
  describe("enrichThreadRows — muted", () => {
170
199
  const SET_AT = 1_700_000_000_000;
171
200
 
@@ -101,8 +101,9 @@ export const planBatchFetch = (rows: ThreadMessageItem[]): BatchPlan => {
101
101
 
102
102
  /**
103
103
  * Enrich a page of ThreadMessage rows with `senderTrust` and `muted` (both
104
- * derived from the From Address's flags map), `authenticity` and `autoMoved`
105
- * (both projected from the Message row, see `deriveAutoMoved`).
104
+ * derived from the From Address's flags map), `authenticity`, `autoMoved` and
105
+ * `spamReport` (all projected straight from the Message row no ThreadMessage
106
+ * column of their own, see `deriveAutoMoved`).
106
107
  *
107
108
  * `category` is not enriched: it is denormalized onto the ThreadMessage row
108
109
  * (shared with `Message.category`'s write-once value, see body-sync.ts) and
@@ -170,6 +171,9 @@ export const enrichThreadRows = async (
170
171
  const autoMovedByMessageId = new Map(
171
172
  messages.map((m) => [m.messageId, deriveAutoMoved(m)]),
172
173
  );
174
+ const spamReportByMessageId = new Map(
175
+ messages.map((m) => [m.messageId, m.spamReport]),
176
+ );
173
177
  const trustByAddressId = new Map(
174
178
  addresses.map((a) => [a.addressId, deriveSenderTrust(a.flags)]),
175
179
  );
@@ -181,6 +185,7 @@ export const enrichThreadRows = async (
181
185
  const base = toResponse(row);
182
186
  const authenticity = authenticityByMessageId.get(row.messageId);
183
187
  const autoMoved = autoMovedByMessageId.get(row.messageId);
188
+ const spamReport = spamReportByMessageId.get(row.messageId);
184
189
  const addressId = plan.addressIdByRow.get(row.threadMessageId);
185
190
  const senderTrust = addressId
186
191
  ? (trustByAddressId.get(addressId) ?? SenderTrust.Unknown)
@@ -194,6 +199,7 @@ export const enrichThreadRows = async (
194
199
  ...(authenticity !== undefined ? { authenticity } : {}),
195
200
  ...(autoMoved !== undefined ? { autoMoved } : {}),
196
201
  ...(labels !== undefined ? { labels } : {}),
202
+ ...(spamReport !== undefined ? { spamReport } : {}),
197
203
  senderTrust,
198
204
  muted,
199
205
  };
@@ -0,0 +1,88 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { MoveNotSettledError } from "@remit/mailbox-service";
4
+ import { GENERIC_FAILURE_REASON, settleSpamReportBulk } from "./message.js";
5
+
6
+ describe("settleSpamReportBulk", () => {
7
+ it("aggregates successes and failures, surfacing the one allowlisted reason verbatim", async () => {
8
+ const outcome = await settleSpamReportBulk(
9
+ ["msg-1", "msg-2", "msg-3"],
10
+ async (messageId) => {
11
+ if (messageId === "msg-2") {
12
+ throw new MoveNotSettledError(messageId);
13
+ }
14
+ },
15
+ );
16
+
17
+ assert.equal(outcome.successCount, 2);
18
+ assert.equal(outcome.failureCount, 1);
19
+ assert.deepEqual(outcome.failures, [
20
+ {
21
+ messageId: "msg-2",
22
+ reason:
23
+ "Message msg-2's move to Junk has not settled yet; try again in a moment.",
24
+ },
25
+ ]);
26
+ });
27
+
28
+ it("omits failures entirely when every message succeeds", async () => {
29
+ const outcome = await settleSpamReportBulk(
30
+ ["msg-1", "msg-2"],
31
+ async () => {},
32
+ );
33
+
34
+ assert.equal(outcome.successCount, 2);
35
+ assert.equal(outcome.failureCount, 0);
36
+ assert.equal(outcome.failures, undefined);
37
+ });
38
+
39
+ // The response is user-facing (the field is documented as shown as-is) and
40
+ // this is a 200, not an error path guarded by error.ts's own flattening —
41
+ // so this is the one place a raw internal message could otherwise leak: an
42
+ // account id in "No Junk mailbox found for account <id>", a message id in
43
+ // "has no From address to act on", or an AWS SDK message naming a queue
44
+ // URL or ECONNREFUSED host:port on an SQS failure.
45
+ it("never puts an arbitrary error's raw text in the response — only the allowlisted reason ships", async () => {
46
+ const outcome = await settleSpamReportBulk(
47
+ ["msg-leaky-error", "msg-leaky-string", "msg-settled-ok"],
48
+ async (messageId) => {
49
+ if (messageId === "msg-leaky-error") {
50
+ throw new Error(
51
+ "No Junk mailbox found for account acc-super-secret-internal-id",
52
+ );
53
+ }
54
+ if (messageId === "msg-leaky-string") {
55
+ return Promise.reject("ECONNREFUSED sqs.us-east-1.amazonaws.com:443");
56
+ }
57
+ },
58
+ );
59
+
60
+ assert.equal(outcome.failureCount, 2);
61
+ const reasons = outcome.failures?.map((f) => f.reason) ?? [];
62
+ assert.deepEqual(reasons, [GENERIC_FAILURE_REASON, GENERIC_FAILURE_REASON]);
63
+
64
+ const leaked = reasons.some(
65
+ (reason) =>
66
+ reason.includes("acc-super-secret-internal-id") ||
67
+ reason.includes("ECONNREFUSED") ||
68
+ reason.includes("amazonaws.com"),
69
+ );
70
+ assert.equal(leaked, false, "no internal detail may reach the response");
71
+ });
72
+
73
+ it("runs every message concurrently — total time is one wait, not N waits", async () => {
74
+ const WAIT_MS = 60;
75
+ const start = Date.now();
76
+
77
+ await settleSpamReportBulk(
78
+ Array.from({ length: 10 }, (_, i) => `msg-${i}`),
79
+ () => new Promise((resolve) => setTimeout(resolve, WAIT_MS)),
80
+ );
81
+
82
+ const elapsed = Date.now() - start;
83
+ assert.ok(
84
+ elapsed < WAIT_MS * 5,
85
+ `expected roughly one wait (~${WAIT_MS}ms), took ${elapsed}ms — looks sequential`,
86
+ );
87
+ });
88
+ });
@@ -23,6 +23,7 @@ import {
23
23
  isCursorRebuildNeeded,
24
24
  isMessageBodySyncBroken,
25
25
  MailboxCursorPausedError,
26
+ MoveNotSettledError,
26
27
  } from "@remit/mailbox-service";
27
28
  import {
28
29
  isStorageNotFoundError as isStorageNotFoundErrorFromService,
@@ -30,7 +31,7 @@ import {
30
31
  } from "@remit/storage-service";
31
32
  import type { APIGatewayProxyEvent } from "aws-lambda";
32
33
  import type { Context } from "openapi-backend";
33
- import { getAccountConfigIdFromEvent } from "../auth.js";
34
+ import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
34
35
  import { deriveAutoMoved } from "../derive/autoMoved.js";
35
36
  import {
36
37
  type ContentSigner,
@@ -326,6 +327,74 @@ export const materializeBodyParts = async (
326
327
  }
327
328
  };
328
329
 
330
+ export interface SpamReportBulkFailure {
331
+ messageId: string;
332
+ reason: string;
333
+ }
334
+
335
+ export interface SpamReportBulkOutcome {
336
+ successCount: number;
337
+ failureCount: number;
338
+ failures?: SpamReportBulkFailure[];
339
+ }
340
+
341
+ /**
342
+ * The one designed, allowlisted failure reason returned to the client
343
+ * verbatim — everything else is flattened to `GENERIC_FAILURE_REASON`, same
344
+ * policy as `error.ts`'s "Internal server error" flattening for an unhandled
345
+ * error. Without this, an arbitrary thrown error's raw text — an internal
346
+ * "No Junk mailbox found for account <id>", an AWS SDK message naming a
347
+ * queue URL or `ECONNREFUSED host:port` — would land straight in a 200
348
+ * response body, since `SpamReportBulkResult.reason` is documented as shown
349
+ * to the user as-is.
350
+ */
351
+ export const GENERIC_FAILURE_REASON =
352
+ "This message could not be processed. Please try again.";
353
+
354
+ const failureReason = (reason: unknown): string =>
355
+ reason instanceof MoveNotSettledError
356
+ ? reason.message
357
+ : GENERIC_FAILURE_REASON;
358
+
359
+ /**
360
+ * Drive one report-spam/not-spam operation per message, concurrently. Each
361
+ * message's own wait for its move to settle (SpamReportService's R2 wait) is
362
+ * bounded on its own, but running the batch sequentially would let those
363
+ * waits accumulate across the whole request — 50 messages could each wait
364
+ * seconds, serializing into minutes and dying at the proxy. Running them
365
+ * concurrently instead bounds the whole request by a single message's wait,
366
+ * regardless of batch size.
367
+ */
368
+ export const settleSpamReportBulk = async (
369
+ messageIds: string[],
370
+ run: (messageId: string) => Promise<void>,
371
+ ): Promise<SpamReportBulkOutcome> => {
372
+ const results = await Promise.allSettled(
373
+ messageIds.map((messageId) => run(messageId)),
374
+ );
375
+
376
+ let successCount = 0;
377
+ const failures: SpamReportBulkFailure[] = [];
378
+ results.forEach((result, index) => {
379
+ if (result.status === "fulfilled") {
380
+ successCount += 1;
381
+ return;
382
+ }
383
+ const messageId = messageIds[index];
384
+ failures.push({ messageId, reason: failureReason(result.reason) });
385
+ logger.error(
386
+ { messageId, error: result.reason },
387
+ "spam-report bulk operation failed for one message",
388
+ );
389
+ });
390
+
391
+ return {
392
+ successCount,
393
+ failureCount: failures.length,
394
+ ...(failures.length > 0 ? { failures } : {}),
395
+ };
396
+ };
397
+
329
398
  export const MessageOperations: Record<
330
399
  MessageOperationIds,
331
400
  OperationHandler<MessageOperationIds>
@@ -379,6 +448,7 @@ export const MessageOperations: Record<
379
448
  authenticity: message.authenticity,
380
449
  ...(autoMoved ? { autoMoved } : {}),
381
450
  ...(labels.length > 0 ? { labels } : {}),
451
+ ...(message.spamReport ? { spamReport: message.spamReport } : {}),
382
452
  };
383
453
 
384
454
  // Batch-fetch the resolved Address rows so each EnvelopeAddressResponse can
@@ -947,4 +1017,60 @@ export const MessageBulkOperations: Record<
947
1017
  failureCount: 0,
948
1018
  };
949
1019
  },
1020
+
1021
+ MessageBulkOperations_reportSpam: async (context, ...args: unknown[]) => {
1022
+ const event = args[0] as APIGatewayProxyEvent;
1023
+ const accountConfigId = getAccountConfigIdFromEvent(event);
1024
+ const { messageIds: requestedIds } = context.request.requestBody as {
1025
+ messageIds: string[];
1026
+ };
1027
+ const messageIds = [...new Set(requestedIds)];
1028
+
1029
+ if (messageIds.length === 0) {
1030
+ return { successCount: 0, failureCount: 0 };
1031
+ }
1032
+
1033
+ const client = await getClient();
1034
+ const accountId = await assertMessagesOwned(
1035
+ client,
1036
+ messageIds,
1037
+ accountConfigId,
1038
+ "act",
1039
+ );
1040
+ const setBy = getSubFromEvent(event) ?? accountConfigId;
1041
+
1042
+ return settleSpamReportBulk(messageIds, (messageId) =>
1043
+ client.spamReport.reportSpam({
1044
+ accountConfigId,
1045
+ accountId,
1046
+ messageId,
1047
+ setBy,
1048
+ }),
1049
+ );
1050
+ },
1051
+
1052
+ MessageBulkOperations_notSpam: async (context, ...args: unknown[]) => {
1053
+ const event = args[0] as APIGatewayProxyEvent;
1054
+ const accountConfigId = getAccountConfigIdFromEvent(event);
1055
+ const { messageIds: requestedIds } = context.request.requestBody as {
1056
+ messageIds: string[];
1057
+ };
1058
+ const messageIds = [...new Set(requestedIds)];
1059
+
1060
+ if (messageIds.length === 0) {
1061
+ return { successCount: 0, failureCount: 0 };
1062
+ }
1063
+
1064
+ const client = await getClient();
1065
+ const accountId = await assertMessagesOwned(
1066
+ client,
1067
+ messageIds,
1068
+ accountConfigId,
1069
+ "act",
1070
+ );
1071
+
1072
+ return settleSpamReportBulk(messageIds, (messageId) =>
1073
+ client.spamReport.notSpam({ accountConfigId, accountId, messageId }),
1074
+ );
1075
+ },
950
1076
  };
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Issue #604: composing a reply, sending it, and then letting one more autosave
3
+ * land 500'd the browser with "Internal server error" two seconds after a send
4
+ * that had actually succeeded.
5
+ *
6
+ * A PATCH against an entry that is no longer a draft is a foreseeable race, not
7
+ * a fault: the draft editor debounces its writes, so the last one can be in
8
+ * flight while the send flips the status. The designed answer is 409 — the
9
+ * entry is immutable now, and saying so truthfully is what lets a client stop.
10
+ *
11
+ * Driven through the real handlers, the real OutboxQueueService and the real
12
+ * error funnel, so what is asserted is the status code the browser receives.
13
+ * Only the queue and the store are stood in for.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { afterEach, describe, it } from "node:test";
18
+ import type { SQSClient } from "@aws-sdk/client-sqs";
19
+ import type {
20
+ CreateOutboxMessageInput,
21
+ IAccountRepository,
22
+ IOutboxMessageRepository,
23
+ OutboxMessageItem,
24
+ UpdateOutboxMessageInput,
25
+ } from "@remit/data-ports";
26
+ import { NotFoundError } from "@remit/data-ports/errors";
27
+ import { OutboxMessageStatus } from "@remit/domain-enums";
28
+ import { OutboxQueueService } from "@remit/mailbox-service";
29
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
30
+ import type { Context } from "openapi-backend";
31
+ import { deriveAccountConfigId } from "../auth.js";
32
+ import { handleError } from "../error.js";
33
+ import { formatResponse } from "../response.js";
34
+ import {
35
+ _resetForTest,
36
+ type RemitClient,
37
+ setClient,
38
+ } from "../service/data-client.js";
39
+ import { OutboxDetailOperations, OutboxOperations } from "./outbox.js";
40
+
41
+ const SUB = "cognito-sub-604";
42
+ const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
43
+ const ACCOUNT_ID = "acc-604";
44
+ const ACCOUNT_EMAIL = "sender@example.com";
45
+
46
+ const createInMemoryOutboxRepository = (): IOutboxMessageRepository => {
47
+ const rows = new Map<string, OutboxMessageItem>();
48
+ let sequence = 0;
49
+
50
+ const mustGet = (outboxMessageId: string): OutboxMessageItem => {
51
+ const row = rows.get(outboxMessageId);
52
+ if (!row) throw new NotFoundError(`No outbox message ${outboxMessageId}`);
53
+ return row;
54
+ };
55
+
56
+ const repository = {
57
+ create: async (
58
+ input: CreateOutboxMessageInput,
59
+ ): Promise<OutboxMessageItem> => {
60
+ sequence += 1;
61
+ const now = Date.now();
62
+ const row: OutboxMessageItem = {
63
+ ...input,
64
+ ccAddresses: input.ccAddresses ?? [],
65
+ bccAddresses: input.bccAddresses ?? [],
66
+ references: input.references ?? [],
67
+ outboxMessageId: `outbox-${sequence}`,
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ };
71
+ rows.set(row.outboxMessageId, row);
72
+ return row;
73
+ },
74
+ get: async (
75
+ _accountConfigId: string,
76
+ outboxMessageId: string | string[],
77
+ ) =>
78
+ Array.isArray(outboxMessageId)
79
+ ? outboxMessageId.map(mustGet)
80
+ : mustGet(outboxMessageId),
81
+ update: async (
82
+ _accountConfigId: string,
83
+ outboxMessageId: string,
84
+ input: UpdateOutboxMessageInput,
85
+ ): Promise<OutboxMessageItem> => {
86
+ const row = {
87
+ ...mustGet(outboxMessageId),
88
+ ...input,
89
+ updatedAt: Date.now(),
90
+ };
91
+ rows.set(outboxMessageId, row);
92
+ return row;
93
+ },
94
+ updateStatus: async (
95
+ accountConfigId: string,
96
+ outboxMessageId: string,
97
+ status: OutboxMessageItem["status"],
98
+ ) => repository.update(accountConfigId, outboxMessageId, { status }),
99
+ markSent: async (
100
+ accountConfigId: string,
101
+ outboxMessageId: string,
102
+ fields: { sentAt: number; smtpMessageId?: string },
103
+ ) =>
104
+ repository.update(accountConfigId, outboxMessageId, {
105
+ ...fields,
106
+ status: OutboxMessageStatus.sent,
107
+ }),
108
+ delete: async (_accountConfigId: string, outboxMessageId: string) => {
109
+ rows.delete(outboxMessageId);
110
+ },
111
+ deleteMany: async (
112
+ _accountConfigId: string,
113
+ outboxMessageIds: string[],
114
+ ) => {
115
+ for (const id of outboxMessageIds) rows.delete(id);
116
+ },
117
+ listByAccount: async () => ({
118
+ items: [...rows.values()],
119
+ continuationToken: null,
120
+ }),
121
+ listQueued: async () =>
122
+ [...rows.values()].filter(
123
+ (row) => row.status === OutboxMessageStatus.queued,
124
+ ),
125
+ } as unknown as IOutboxMessageRepository;
126
+
127
+ return repository;
128
+ };
129
+
130
+ const acceptingSqsClient = (): SQSClient =>
131
+ ({ send: async () => ({}) }) as unknown as SQSClient;
132
+
133
+ const accountRepository = {
134
+ get: async () => ({
135
+ accountId: ACCOUNT_ID,
136
+ accountConfigId: ACCOUNT_CONFIG_ID,
137
+ email: ACCOUNT_EMAIL,
138
+ }),
139
+ } as unknown as IAccountRepository;
140
+
141
+ const installClient = (): void => {
142
+ const outboxMessage = createInMemoryOutboxRepository();
143
+ setClient({
144
+ outboxMessage,
145
+ account: accountRepository,
146
+ outboxQueue: new OutboxQueueService({
147
+ outboxMessageService: outboxMessage,
148
+ accountService: accountRepository,
149
+ sqsSmtpQueueUrl: "http://localhost:9324/queue/outbox-test",
150
+ sqsClient: acceptingSqsClient(),
151
+ }),
152
+ } as unknown as RemitClient);
153
+ };
154
+
155
+ const authorizedEvent = (body?: unknown): APIGatewayProxyEvent =>
156
+ ({
157
+ body: body === undefined ? null : JSON.stringify(body),
158
+ requestContext: { authorizer: { claims: { sub: SUB } } },
159
+ }) as unknown as APIGatewayProxyEvent;
160
+
161
+ const requestContext = (request: {
162
+ params?: Record<string, string>;
163
+ requestBody?: unknown;
164
+ }): Context => ({ request }) as unknown as Context;
165
+
166
+ type Handler = (
167
+ context: Context,
168
+ event: APIGatewayProxyEvent,
169
+ ) => Promise<Record<string, unknown>>;
170
+
171
+ const createDraft =
172
+ OutboxOperations.OutboxOperations_createOutboxMessage as Handler;
173
+ const sendMessage =
174
+ OutboxDetailOperations.OutboxDetailOperations_sendOutboxMessage as Handler;
175
+ const updateDraft =
176
+ OutboxDetailOperations.OutboxDetailOperations_updateOutboxMessage as Handler;
177
+ const deleteDraft =
178
+ OutboxDetailOperations.OutboxDetailOperations_deleteOutboxMessage as Handler;
179
+
180
+ type Outcome =
181
+ | { readonly ok: true; readonly body: Record<string, unknown> }
182
+ | { readonly ok: false; readonly error: unknown };
183
+
184
+ /** The response the browser would receive, error funnel included. */
185
+ const respond = async (
186
+ run: () => Promise<Record<string, unknown>>,
187
+ ): Promise<APIGatewayProxyResult> => {
188
+ const outcome: Outcome = await run().then(
189
+ (body) => ({ ok: true, body }) as const,
190
+ (error: unknown) => ({ ok: false, error }) as const,
191
+ );
192
+ if (!outcome.ok) return handleError(outcome.error);
193
+ return formatResponse(outcome.body);
194
+ };
195
+
196
+ const sentOutboxMessageId = async (): Promise<string> => {
197
+ const draft = await createDraft(
198
+ requestContext({}),
199
+ authorizedEvent({
200
+ accountId: ACCOUNT_ID,
201
+ toAddresses: ["recipient@example.com"],
202
+ subject: "Re: the thing",
203
+ textBody: "on it",
204
+ }),
205
+ );
206
+ const outboxMessageId = draft.outboxMessageId;
207
+ assert.equal(typeof outboxMessageId, "string");
208
+
209
+ const sent = await sendMessage(
210
+ requestContext({ params: { outboxMessageId: String(outboxMessageId) } }),
211
+ authorizedEvent(),
212
+ );
213
+ assert.notEqual(sent.status, "draft");
214
+
215
+ return String(outboxMessageId);
216
+ };
217
+
218
+ afterEach(() => {
219
+ _resetForTest();
220
+ });
221
+
222
+ describe("an outbox entry that has left draft (#604)", () => {
223
+ it("answers a late autosave PATCH with 409, never a 500", async () => {
224
+ installClient();
225
+ const outboxMessageId = await sentOutboxMessageId();
226
+
227
+ const response = await respond(() =>
228
+ updateDraft(
229
+ requestContext({
230
+ params: { outboxMessageId },
231
+ requestBody: { subject: "Re: the thing", textBody: "on it!" },
232
+ }),
233
+ authorizedEvent(),
234
+ ),
235
+ );
236
+
237
+ assert.equal(response.statusCode, 409);
238
+ const body = JSON.parse(response.body) as { message?: string };
239
+ assert.match(String(body.message), /can no longer be edited/);
240
+ });
241
+
242
+ it("refuses a second send with 409, never a 500", async () => {
243
+ installClient();
244
+ const outboxMessageId = await sentOutboxMessageId();
245
+
246
+ const response = await respond(() =>
247
+ sendMessage(
248
+ requestContext({ params: { outboxMessageId } }),
249
+ authorizedEvent(),
250
+ ),
251
+ );
252
+
253
+ assert.equal(response.statusCode, 409);
254
+ });
255
+
256
+ it("refuses a discard with 409, never a 500", async () => {
257
+ installClient();
258
+ const outboxMessageId = await sentOutboxMessageId();
259
+
260
+ const response = await respond(() =>
261
+ deleteDraft(
262
+ requestContext({ params: { outboxMessageId } }),
263
+ authorizedEvent(),
264
+ ),
265
+ );
266
+
267
+ assert.equal(response.statusCode, 409);
268
+ });
269
+
270
+ it("still accepts an autosave PATCH while the entry is a draft", async () => {
271
+ installClient();
272
+ const draft = await createDraft(
273
+ requestContext({}),
274
+ authorizedEvent({
275
+ accountId: ACCOUNT_ID,
276
+ toAddresses: ["recipient@example.com"],
277
+ }),
278
+ );
279
+
280
+ const response = await respond(() =>
281
+ updateDraft(
282
+ requestContext({
283
+ params: { outboxMessageId: String(draft.outboxMessageId) },
284
+ requestBody: { subject: "still editing" },
285
+ }),
286
+ authorizedEvent(),
287
+ ),
288
+ );
289
+
290
+ assert.equal(response.statusCode, 200);
291
+ const body = JSON.parse(response.body) as { subject?: string };
292
+ assert.equal(body.subject, "still editing");
293
+ });
294
+ });
package/src/index.ts CHANGED
@@ -160,15 +160,18 @@ const rawHandler = async (event: APIGatewayProxyEvent, context: Context) =>
160
160
 
161
161
  const origin = readOriginHeader(event.headers);
162
162
 
163
- if (usesBetterAuthJwt()) {
164
- const denied = await authenticateSelfHostRequest(event);
165
- if (denied) return denied;
166
- }
167
-
168
- return runWithRequestContext({ origin }, () =>
169
- api
170
- .handleRequest(normalizeRequest(event), event, context)
171
- .catch(handleError),
163
+ return runWithRequestContext(
164
+ { origin, correlationId: context.awsRequestId },
165
+ async () => {
166
+ if (usesBetterAuthJwt()) {
167
+ const denied = await authenticateSelfHostRequest(event);
168
+ if (denied) return denied;
169
+ }
170
+
171
+ return api
172
+ .handleRequest(normalizeRequest(event), event, context)
173
+ .catch(handleError);
174
+ },
172
175
  );
173
176
  },
174
177
  );
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
 
3
3
  interface RequestContext {
4
4
  origin?: string;
5
+ correlationId?: string;
5
6
  }
6
7
 
7
8
  const storage = new AsyncLocalStorage<RequestContext>();
@@ -12,6 +13,9 @@ export const runWithRequestContext = <T>(ctx: RequestContext, fn: () => T): T =>
12
13
  export const getRequestOrigin = (): string | undefined =>
13
14
  storage.getStore()?.origin;
14
15
 
16
+ export const getRequestCorrelationId = (): string | undefined =>
17
+ storage.getStore()?.correlationId;
18
+
15
19
  const parseAllowedOrigins = (): readonly string[] => {
16
20
  const raw = process.env.CORS_ALLOWED_ORIGINS;
17
21
  if (!raw) return [];
@@ -1,7 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { afterEach, beforeEach, describe, it } from "node:test";
3
3
  import type { Context as OpenAPIContext } from "openapi-backend";
4
- import { postResponseHandler } from "./response.js";
4
+ import { runWithRequestContext } from "./request-context.js";
5
+ import { formatResponse, postResponseHandler } from "./response.js";
5
6
 
6
7
  type ValidateResponseFn = (
7
8
  response: unknown,
@@ -105,3 +106,25 @@ describe("postResponseHandler validation gating", () => {
105
106
  assert.equal(result.statusCode, 200);
106
107
  });
107
108
  });
109
+
110
+ // A bug report quotes the correlation id off the failing response. Without the
111
+ // header it reads "(none)" and there is nothing to grep the server logs for.
112
+ describe("the correlation id travels back on the response", () => {
113
+ it("carries the request's id, and exposes the header to the browser", () => {
114
+ const result = runWithRequestContext({ correlationId: "req-604" }, () =>
115
+ formatResponse({ message: "Conflict" }, 409),
116
+ );
117
+
118
+ assert.equal(result.headers?.["x-correlation-id"], "req-604");
119
+ assert.match(
120
+ String(result.headers?.["Access-Control-Expose-Headers"]),
121
+ /x-correlation-id/,
122
+ );
123
+ });
124
+
125
+ it("omits the header when the request carried no id", () => {
126
+ const result = formatResponse({ message: "Conflict" }, 409);
127
+
128
+ assert.equal(result.headers?.["x-correlation-id"], undefined);
129
+ });
130
+ });
package/src/response.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { logger } from "@remit/logger-lambda";
2
2
  import type { APIGatewayProxyResult } from "aws-lambda";
3
3
  import type { Context as OpenAPIContext } from "openapi-backend";
4
- import { getRequestOrigin, resolveAllowedOrigin } from "./request-context.js";
4
+ import {
5
+ getRequestCorrelationId,
6
+ getRequestOrigin,
7
+ resolveAllowedOrigin,
8
+ } from "./request-context.js";
5
9
 
6
10
  export const formatResponse = (
7
11
  body: Record<string, unknown>,
@@ -30,11 +34,23 @@ export const formatResponse = (
30
34
  corsHeaders["Access-Control-Allow-Credentials"] = "true";
31
35
  }
32
36
 
37
+ // The id the request's log lines are already tagged with. Returning it is
38
+ // what makes a bug report's "correlation id" resolve to a server-side line;
39
+ // the browser can only read a non-safelisted header when it is exposed.
40
+ const correlationId = getRequestCorrelationId();
41
+ const correlationHeaders: Record<string, string> = correlationId
42
+ ? {
43
+ "x-correlation-id": correlationId,
44
+ "Access-Control-Expose-Headers": "x-correlation-id",
45
+ }
46
+ : {};
47
+
33
48
  return {
34
49
  statusCode: statusCode,
35
50
  headers: {
36
51
  "Content-Type": "application/json",
37
52
  ...corsHeaders,
53
+ ...correlationHeaders,
38
54
  },
39
55
  body: JSON.stringify(body),
40
56
  };
@@ -35,6 +35,7 @@ import {
35
35
  MessageMoveService,
36
36
  OutboxQueueService,
37
37
  PlacementMoveService,
38
+ SpamReportService,
38
39
  } from "@remit/mailbox-service";
39
40
  import { createSearchService, type SearchService } from "@remit/search-service";
40
41
  import {
@@ -145,6 +146,11 @@ export interface RemitClient {
145
146
  messageMove: MessageMoveService;
146
147
  outboxQueue: OutboxQueueService;
147
148
 
149
+ // Report-spam / not-spam (block-and-move, unified from the old separate
150
+ // Block + Mark spam actions). Composed over `messageMove` and the same
151
+ // `flagPushService` instance `flagQueue` shares below.
152
+ spamReport: SpamReportService;
153
+
148
154
  // Helper to create IMAP connection scope from accountId
149
155
  createConnectionScope: (accountId: string) => Promise<ConnectionScope>;
150
156
  }
@@ -341,6 +347,15 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
341
347
  logger,
342
348
  });
343
349
 
350
+ const messageMoveService = new MessageMoveService({
351
+ messageService: repositories.message,
352
+ mailboxService: repositories.mailbox,
353
+ mailboxSpecialUseService: repositories.mailboxSpecialUse,
354
+ threadMessageService: repositories.threadMessage,
355
+ sqsQueueUrl,
356
+ logger,
357
+ });
358
+
344
359
  const bodySync = new BodySyncService(
345
360
  repositories.message,
346
361
  storage,
@@ -392,20 +407,22 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
392
407
  sqsQueueUrl,
393
408
  logger,
394
409
  }),
395
- messageMove: new MessageMoveService({
396
- messageService: repositories.message,
397
- mailboxService: repositories.mailbox,
398
- mailboxSpecialUseService: repositories.mailboxSpecialUse,
399
- threadMessageService: repositories.threadMessage,
400
- sqsQueueUrl,
401
- logger,
402
- }),
410
+ messageMove: messageMoveService,
403
411
  outboxQueue: new OutboxQueueService({
404
412
  outboxMessageService: repositories.outboxMessage,
405
413
  accountService: repositories.account,
406
414
  sqsSmtpQueueUrl,
407
415
  logger,
408
416
  }),
417
+ spamReport: new SpamReportService({
418
+ messageService: repositories.message,
419
+ addressService: repositories.address,
420
+ accountService: repositories.account,
421
+ mailboxSpecialUseService: repositories.mailboxSpecialUse,
422
+ messageMoveService,
423
+ flagPushService,
424
+ logger,
425
+ }),
409
426
 
410
427
  createConnectionScope: buildConnectionScope(repositories.account, secrets),
411
428
  };
@@ -32,6 +32,7 @@ const REQUIRED_KEYS: ReadonlyArray<keyof RemitClient> = [
32
32
  "mailboxQueue",
33
33
  "messageMove",
34
34
  "outboxQueue",
35
+ "spamReport",
35
36
  "createConnectionScope",
36
37
  ] as const;
37
38
 
package/src/types.ts CHANGED
@@ -54,6 +54,8 @@ export type OperationIds =
54
54
  | "MessageBulkOperations_updateFlags"
55
55
  | "MessageBulkOperations_copyMessages"
56
56
  | "MessageBulkOperations_updateMessageLabels"
57
+ | "MessageBulkOperations_reportSpam"
58
+ | "MessageBulkOperations_notSpam"
57
59
  | "TrashOperations_emptyTrash"
58
60
  | "OutboxOperations_createOutboxMessage"
59
61
  | "OutboxOperations_listOutboxMessages"