@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 +1 -1
- package/src/derive/enrichThreadRows.test.ts +29 -0
- package/src/derive/enrichThreadRows.ts +8 -2
- package/src/handlers/message.test.ts +88 -0
- package/src/handlers/message.ts +127 -1
- package/src/handlers/outbox.test.ts +294 -0
- package/src/index.ts +12 -9
- package/src/request-context.ts +4 -0
- package/src/response.test.ts +24 -1
- package/src/response.ts +17 -1
- package/src/service/create-remit-client.ts +25 -8
- package/src/service/data-client.test.ts +1 -0
- package/src/types.ts +2 -0
package/package.json
CHANGED
|
@@ -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
|
|
105
|
-
* (
|
|
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
|
+
});
|
package/src/handlers/message.ts
CHANGED
|
@@ -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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
);
|
package/src/request-context.ts
CHANGED
|
@@ -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 [];
|
package/src/response.test.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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:
|
|
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
|
};
|
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"
|