@remit/backend 0.0.56 → 0.0.58

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.56",
3
+ "version": "0.0.58",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -50,7 +50,7 @@
50
50
  "@types/swagger-ui-express": "^4.1.8",
51
51
  "better-sqlite3": "^12.11.1",
52
52
  "electrodb": "*",
53
- "esbuild": "^0.27.7",
53
+ "esbuild": "^0.28.1",
54
54
  "express": "^5.1.0",
55
55
  "swagger-ui-express": "^5.0.1",
56
56
  "tsx": "*"
@@ -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
  };
@@ -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"