@wraps.dev/cli 2.18.14 → 2.19.1
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/dist/cli.js +2515 -1363
- package/dist/cli.js.map +1 -1
- package/dist/lambda/event-processor/.bundled +1 -1
- package/dist/lambda/inbound-processor/.bundled +1 -1
- package/dist/lambda/inbound-processor/index.js +44 -44
- package/dist/lambda/inbound-processor/index.test.ts +576 -0
- package/dist/lambda/inbound-processor/index.ts +264 -11
- package/dist/lambda/sms-event-processor/.bundled +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EventBridgeClient,
|
|
3
|
+
PutEventsCommand,
|
|
4
|
+
} from "@aws-sdk/client-eventbridge";
|
|
5
|
+
import {
|
|
6
|
+
GetObjectCommand,
|
|
7
|
+
PutObjectCommand,
|
|
8
|
+
S3Client,
|
|
9
|
+
} from "@aws-sdk/client-s3";
|
|
10
|
+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
11
|
+
import type { Context, S3Event } from "aws-lambda";
|
|
12
|
+
import { mockClient } from "aws-sdk-client-mock";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { encodeReplyToken } from "../../src/reply-token.js";
|
|
15
|
+
|
|
16
|
+
const s3Mock = mockClient(S3Client);
|
|
17
|
+
const ebMock = mockClient(EventBridgeClient);
|
|
18
|
+
const ssmMock = mockClient(SSMClient);
|
|
19
|
+
|
|
20
|
+
const PARSED_BUCKET = "parsed-bucket";
|
|
21
|
+
|
|
22
|
+
type EmailFixture = {
|
|
23
|
+
to?: Array<{ address: string }>;
|
|
24
|
+
cc?: Array<{ address: string }>;
|
|
25
|
+
from?: { address: string; name?: string };
|
|
26
|
+
headers?: [string, unknown][];
|
|
27
|
+
subject?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const parsedState: { queue: EmailFixture[] } = { queue: [] };
|
|
31
|
+
|
|
32
|
+
function parseFromFixture(fx: EmailFixture) {
|
|
33
|
+
const headers = new Map<string, unknown>(fx.headers ?? []);
|
|
34
|
+
const toValue = (fx.to ?? []).map((a) => ({ address: a.address, name: "" }));
|
|
35
|
+
const ccValue = (fx.cc ?? []).map((a) => ({ address: a.address, name: "" }));
|
|
36
|
+
const fromValue = fx.from
|
|
37
|
+
? [{ address: fx.from.address, name: fx.from.name ?? "" }]
|
|
38
|
+
: [];
|
|
39
|
+
return {
|
|
40
|
+
messageId: "<msg@example.com>",
|
|
41
|
+
subject: fx.subject ?? "",
|
|
42
|
+
from: fx.from ? { value: fromValue } : undefined,
|
|
43
|
+
to:
|
|
44
|
+
toValue.length > 0
|
|
45
|
+
? { value: toValue, text: toValue.map((t) => t.address).join(", ") }
|
|
46
|
+
: undefined,
|
|
47
|
+
cc:
|
|
48
|
+
ccValue.length > 0
|
|
49
|
+
? { value: ccValue, text: ccValue.map((t) => t.address).join(", ") }
|
|
50
|
+
: undefined,
|
|
51
|
+
html: "<p>hi</p>",
|
|
52
|
+
text: "hi",
|
|
53
|
+
headers,
|
|
54
|
+
attachments: [],
|
|
55
|
+
date: new Date("2026-01-01T00:00:00Z"),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
vi.mock("mailparser", () => ({
|
|
60
|
+
simpleParser: vi.fn(async () => {
|
|
61
|
+
const fx = parsedState.queue.shift() ?? {};
|
|
62
|
+
return parseFromFixture(fx);
|
|
63
|
+
}),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
function singleS3Event(key = "raw/abc"): S3Event {
|
|
67
|
+
return {
|
|
68
|
+
Records: [{ s3: { bucket: { name: "raw-bucket" }, object: { key } } }],
|
|
69
|
+
} as unknown as S3Event;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function multiS3Event(keys: string[]): S3Event {
|
|
73
|
+
return {
|
|
74
|
+
Records: keys.map((key) => ({
|
|
75
|
+
s3: { bucket: { name: "raw-bucket" }, object: { key } },
|
|
76
|
+
})),
|
|
77
|
+
} as unknown as S3Event;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeCtx(): Context {
|
|
81
|
+
return { awsRequestId: "req-1" } as unknown as Context;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function capturedPut(idx = 0): Promise<{
|
|
85
|
+
source?: string;
|
|
86
|
+
detail: Record<string, unknown>;
|
|
87
|
+
}> {
|
|
88
|
+
const calls = ebMock.commandCalls(PutEventsCommand);
|
|
89
|
+
if (calls.length <= idx) {
|
|
90
|
+
throw new Error(`no EventBridge PutEvents call at idx ${idx}`);
|
|
91
|
+
}
|
|
92
|
+
const entry = calls[idx].args[0].input.Entries?.[0];
|
|
93
|
+
return {
|
|
94
|
+
source: entry?.Source,
|
|
95
|
+
detail: JSON.parse(entry?.Detail ?? "{}") as Record<string, unknown>,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runHandler(event: S3Event = singleS3Event()) {
|
|
100
|
+
const { handler } = await import("./index.ts");
|
|
101
|
+
await handler(event, makeCtx());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
s3Mock.reset();
|
|
106
|
+
ebMock.reset();
|
|
107
|
+
ssmMock.reset();
|
|
108
|
+
parsedState.queue = [];
|
|
109
|
+
|
|
110
|
+
s3Mock.on(GetObjectCommand).callsFake(() => ({
|
|
111
|
+
Body: {
|
|
112
|
+
transformToString: async () => "From: a@example.com\r\n\r\nhello",
|
|
113
|
+
},
|
|
114
|
+
}));
|
|
115
|
+
s3Mock.on(PutObjectCommand).resolves({});
|
|
116
|
+
ebMock.on(PutEventsCommand).resolves({ FailedEntryCount: 0, Entries: [] });
|
|
117
|
+
|
|
118
|
+
process.env.BUCKET_NAME = PARSED_BUCKET;
|
|
119
|
+
// biome-ignore lint/performance/noDelete: process.env coerces `= undefined` to the string "undefined" (truthy). Actual key removal is required so `Boolean(process.env.REPLY_SECRET_PARAMETER_PREFIX)` is false in the feature-disabled tests.
|
|
120
|
+
delete process.env.REPLY_SECRET_PARAMETER_PREFIX;
|
|
121
|
+
|
|
122
|
+
// Fresh module — re-initializes the module-scope domainSecretCache per test.
|
|
123
|
+
vi.resetModules();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
// biome-ignore lint/performance/noDelete: see above — string "undefined" is truthy.
|
|
128
|
+
delete process.env.REPLY_SECRET_PARAMETER_PREFIX;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("inbound-processor — non-reply recipient", () => {
|
|
132
|
+
it("emits replyToken: null and autoReply: false for a non r.mail.* recipient", async () => {
|
|
133
|
+
parsedState.queue = [
|
|
134
|
+
{
|
|
135
|
+
from: { address: "sender@external.com" },
|
|
136
|
+
to: [{ address: "inbox@support.foo.com" }],
|
|
137
|
+
headers: [],
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
await runHandler();
|
|
142
|
+
|
|
143
|
+
const ev = await capturedPut();
|
|
144
|
+
expect(ev.source).toBe("wraps.inbound");
|
|
145
|
+
expect(ev.detail.replyToken).toBeNull();
|
|
146
|
+
expect(ev.detail.autoReply).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("inbound-processor — reply-threading branch", () => {
|
|
151
|
+
const secret = Buffer.alloc(32, 0x5a);
|
|
152
|
+
const prefix = "/wraps/email/reply-secret/";
|
|
153
|
+
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
process.env.REPLY_SECRET_PARAMETER_PREFIX = prefix;
|
|
156
|
+
ssmMock.on(GetParameterCommand).resolves({
|
|
157
|
+
Parameter: {
|
|
158
|
+
Value: JSON.stringify({ kid: 1, current: secret.toString("base64") }),
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("emits status: valid with conversationId/sendId when token verifies", async () => {
|
|
164
|
+
const convId = Buffer.alloc(8, 0x01);
|
|
165
|
+
const sendId = Buffer.alloc(8, 0x02);
|
|
166
|
+
const token = encodeReplyToken({
|
|
167
|
+
kid: 1,
|
|
168
|
+
convId,
|
|
169
|
+
sendId,
|
|
170
|
+
exp: 0,
|
|
171
|
+
secret,
|
|
172
|
+
});
|
|
173
|
+
parsedState.queue = [
|
|
174
|
+
{
|
|
175
|
+
from: { address: "sender@external.com" },
|
|
176
|
+
to: [{ address: `${token}@r.mail.support.foo.com` }],
|
|
177
|
+
headers: [],
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
await runHandler();
|
|
182
|
+
|
|
183
|
+
const ev = await capturedPut();
|
|
184
|
+
const rt = ev.detail.replyToken as {
|
|
185
|
+
status: string;
|
|
186
|
+
conversationId: string;
|
|
187
|
+
sendId: string;
|
|
188
|
+
};
|
|
189
|
+
expect(rt.status).toBe("valid");
|
|
190
|
+
expect(typeof rt.conversationId).toBe("string");
|
|
191
|
+
expect(typeof rt.sendId).toBe("string");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("emits status: invalid-signature when HMAC does not match; does not leak ids", async () => {
|
|
195
|
+
const token = encodeReplyToken({
|
|
196
|
+
kid: 1,
|
|
197
|
+
convId: Buffer.alloc(8, 0x11),
|
|
198
|
+
sendId: Buffer.alloc(8, 0x22),
|
|
199
|
+
exp: 0,
|
|
200
|
+
secret: Buffer.alloc(32, 0xee),
|
|
201
|
+
});
|
|
202
|
+
parsedState.queue = [
|
|
203
|
+
{
|
|
204
|
+
from: { address: "sender@external.com" },
|
|
205
|
+
to: [{ address: `${token}@r.mail.support.foo.com` }],
|
|
206
|
+
headers: [],
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
await runHandler();
|
|
211
|
+
|
|
212
|
+
const ev = await capturedPut();
|
|
213
|
+
const rt = ev.detail.replyToken as {
|
|
214
|
+
status: string;
|
|
215
|
+
conversationId: unknown;
|
|
216
|
+
sendId: unknown;
|
|
217
|
+
};
|
|
218
|
+
expect(rt.status).toBe("invalid-signature");
|
|
219
|
+
expect(rt.conversationId).toBeNull();
|
|
220
|
+
expect(rt.sendId).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("prefers X-Original-To header over To: for recipient derivation", async () => {
|
|
224
|
+
const token = encodeReplyToken({
|
|
225
|
+
kid: 1,
|
|
226
|
+
convId: Buffer.alloc(8, 0x33),
|
|
227
|
+
sendId: Buffer.alloc(8, 0x44),
|
|
228
|
+
exp: 0,
|
|
229
|
+
secret,
|
|
230
|
+
});
|
|
231
|
+
parsedState.queue = [
|
|
232
|
+
{
|
|
233
|
+
from: { address: "sender@external.com" },
|
|
234
|
+
to: [{ address: "not-a-reply@support.foo.com" }],
|
|
235
|
+
headers: [["x-original-to", `${token}@r.mail.support.foo.com`]],
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
await runHandler();
|
|
240
|
+
|
|
241
|
+
const ev = await capturedPut();
|
|
242
|
+
const rt = ev.detail.replyToken as { status: string };
|
|
243
|
+
expect(rt.status).toBe("valid");
|
|
244
|
+
expect(ev.detail.receivingDomain).toBe("r.mail.support.foo.com");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("sets autoReply: true for Auto-Submitted: auto-replied headers", async () => {
|
|
248
|
+
parsedState.queue = [
|
|
249
|
+
{
|
|
250
|
+
from: { address: "vacation@external.com" },
|
|
251
|
+
to: [{ address: "inbox@support.foo.com" }],
|
|
252
|
+
headers: [["auto-submitted", "auto-replied"]],
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
await runHandler();
|
|
257
|
+
|
|
258
|
+
const ev = await capturedPut();
|
|
259
|
+
expect(ev.detail.autoReply).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("reuses the per-domain cache: two records for the same domain → one SSM fetch", async () => {
|
|
263
|
+
const token = encodeReplyToken({
|
|
264
|
+
kid: 1,
|
|
265
|
+
convId: Buffer.alloc(8, 0x55),
|
|
266
|
+
sendId: Buffer.alloc(8, 0x66),
|
|
267
|
+
exp: 0,
|
|
268
|
+
secret,
|
|
269
|
+
});
|
|
270
|
+
parsedState.queue = [
|
|
271
|
+
{
|
|
272
|
+
from: { address: "sender@external.com" },
|
|
273
|
+
to: [{ address: `${token}@r.mail.support.foo.com` }],
|
|
274
|
+
headers: [],
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
from: { address: "sender@external.com" },
|
|
278
|
+
to: [{ address: `${token}@r.mail.support.foo.com` }],
|
|
279
|
+
headers: [],
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
await runHandler(multiS3Event(["raw/a", "raw/b"]));
|
|
284
|
+
|
|
285
|
+
const getCalls = ssmMock.commandCalls(GetParameterCommand);
|
|
286
|
+
expect(getCalls.length).toBe(1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("per-domain cache: different sending domains → separate SSM fetches", async () => {
|
|
290
|
+
const token1 = encodeReplyToken({
|
|
291
|
+
kid: 1,
|
|
292
|
+
convId: Buffer.alloc(8, 0xa1),
|
|
293
|
+
sendId: Buffer.alloc(8, 0xa2),
|
|
294
|
+
exp: 0,
|
|
295
|
+
secret,
|
|
296
|
+
});
|
|
297
|
+
const token2 = encodeReplyToken({
|
|
298
|
+
kid: 1,
|
|
299
|
+
convId: Buffer.alloc(8, 0xb1),
|
|
300
|
+
sendId: Buffer.alloc(8, 0xb2),
|
|
301
|
+
exp: 0,
|
|
302
|
+
secret,
|
|
303
|
+
});
|
|
304
|
+
parsedState.queue = [
|
|
305
|
+
{
|
|
306
|
+
from: { address: "sender@external.com" },
|
|
307
|
+
to: [{ address: `${token1}@r.mail.support.foo.com` }],
|
|
308
|
+
headers: [],
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
from: { address: "sender@external.com" },
|
|
312
|
+
to: [{ address: `${token2}@r.mail.sales.foo.com` }],
|
|
313
|
+
headers: [],
|
|
314
|
+
},
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
await runHandler(multiS3Event(["raw/a", "raw/b"]));
|
|
318
|
+
|
|
319
|
+
const getCalls = ssmMock.commandCalls(GetParameterCommand);
|
|
320
|
+
expect(getCalls.length).toBe(2);
|
|
321
|
+
const names = getCalls.map((c) => c.args[0].input.Name).sort();
|
|
322
|
+
expect(names).toEqual([
|
|
323
|
+
`${prefix}sales.foo.com`,
|
|
324
|
+
`${prefix}support.foo.com`,
|
|
325
|
+
]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("emits status: unknown-domain when SSM value is malformed JSON", async () => {
|
|
329
|
+
const token = encodeReplyToken({
|
|
330
|
+
kid: 1,
|
|
331
|
+
convId: Buffer.alloc(8, 0x01),
|
|
332
|
+
sendId: Buffer.alloc(8, 0x02),
|
|
333
|
+
exp: 0,
|
|
334
|
+
secret,
|
|
335
|
+
});
|
|
336
|
+
parsedState.queue = [
|
|
337
|
+
{
|
|
338
|
+
from: { address: "sender@external.com" },
|
|
339
|
+
to: [{ address: `${token}@r.mail.support.foo.com` }],
|
|
340
|
+
headers: [],
|
|
341
|
+
},
|
|
342
|
+
];
|
|
343
|
+
ssmMock.reset();
|
|
344
|
+
ssmMock.on(GetParameterCommand).resolves({
|
|
345
|
+
Parameter: { Value: "not-valid-json{" },
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await runHandler();
|
|
349
|
+
|
|
350
|
+
const ev = await capturedPut();
|
|
351
|
+
const rt = ev.detail.replyToken as { status: string };
|
|
352
|
+
expect(rt.status).toBe("unknown-domain");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("emits status: unknown-domain when SSM value is missing required fields", async () => {
|
|
356
|
+
const token = encodeReplyToken({
|
|
357
|
+
kid: 1,
|
|
358
|
+
convId: Buffer.alloc(8, 0x11),
|
|
359
|
+
sendId: Buffer.alloc(8, 0x22),
|
|
360
|
+
exp: 0,
|
|
361
|
+
secret,
|
|
362
|
+
});
|
|
363
|
+
parsedState.queue = [
|
|
364
|
+
{
|
|
365
|
+
from: { address: "sender@external.com" },
|
|
366
|
+
to: [{ address: `${token}@r.mail.support.foo.com` }],
|
|
367
|
+
headers: [],
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
ssmMock.reset();
|
|
371
|
+
// Missing `kid` and `current` — e.g. an operator mis-edited the param.
|
|
372
|
+
ssmMock.on(GetParameterCommand).resolves({
|
|
373
|
+
Parameter: { Value: JSON.stringify({ foo: "bar" }) },
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await runHandler();
|
|
377
|
+
|
|
378
|
+
const ev = await capturedPut();
|
|
379
|
+
const rt = ev.detail.replyToken as { status: string };
|
|
380
|
+
expect(rt.status).toBe("unknown-domain");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("emits status: unknown-domain when SSM returns ParameterNotFound", async () => {
|
|
384
|
+
const token = encodeReplyToken({
|
|
385
|
+
kid: 1,
|
|
386
|
+
convId: Buffer.alloc(8, 0xcc),
|
|
387
|
+
sendId: Buffer.alloc(8, 0xdd),
|
|
388
|
+
exp: 0,
|
|
389
|
+
secret,
|
|
390
|
+
});
|
|
391
|
+
parsedState.queue = [
|
|
392
|
+
{
|
|
393
|
+
from: { address: "sender@external.com" },
|
|
394
|
+
to: [{ address: `${token}@r.mail.unknown.foo.com` }],
|
|
395
|
+
headers: [],
|
|
396
|
+
},
|
|
397
|
+
];
|
|
398
|
+
ssmMock.reset();
|
|
399
|
+
class ParameterNotFound extends Error {
|
|
400
|
+
override name = "ParameterNotFound";
|
|
401
|
+
}
|
|
402
|
+
ssmMock.on(GetParameterCommand).rejects(new ParameterNotFound("nope"));
|
|
403
|
+
|
|
404
|
+
await runHandler();
|
|
405
|
+
|
|
406
|
+
const ev = await capturedPut();
|
|
407
|
+
const rt = ev.detail.replyToken as { status: string };
|
|
408
|
+
expect(rt.status).toBe("unknown-domain");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("emits status: unknown-domain for malformed sending domain; never calls SSM", async () => {
|
|
412
|
+
// Recipient host contains a slash — stripping `r.mail.` leaves a domain
|
|
413
|
+
// that would produce a path-traversal SSM parameter name. The handler
|
|
414
|
+
// must reject it without issuing a GetParameterCommand (which would fail
|
|
415
|
+
// with ValidationException and trigger retry/DLQ).
|
|
416
|
+
const token = encodeReplyToken({
|
|
417
|
+
kid: 1,
|
|
418
|
+
convId: Buffer.alloc(8, 0xab),
|
|
419
|
+
sendId: Buffer.alloc(8, 0xcd),
|
|
420
|
+
exp: 0,
|
|
421
|
+
secret,
|
|
422
|
+
});
|
|
423
|
+
parsedState.queue = [
|
|
424
|
+
{
|
|
425
|
+
from: { address: "sender@external.com" },
|
|
426
|
+
to: [{ address: `${token}@r.mail.foo%2Fbar.com` }],
|
|
427
|
+
headers: [],
|
|
428
|
+
},
|
|
429
|
+
];
|
|
430
|
+
|
|
431
|
+
await runHandler();
|
|
432
|
+
|
|
433
|
+
const getCalls = ssmMock.commandCalls(GetParameterCommand);
|
|
434
|
+
expect(getCalls.length).toBe(0);
|
|
435
|
+
const ev = await capturedPut();
|
|
436
|
+
const rt = ev.detail.replyToken as { status: string };
|
|
437
|
+
expect(rt.status).toBe("unknown-domain");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("emits status: unknown-domain for path-traversal sending domain", async () => {
|
|
441
|
+
const token = encodeReplyToken({
|
|
442
|
+
kid: 1,
|
|
443
|
+
convId: Buffer.alloc(8, 0x11),
|
|
444
|
+
sendId: Buffer.alloc(8, 0x22),
|
|
445
|
+
exp: 0,
|
|
446
|
+
secret,
|
|
447
|
+
});
|
|
448
|
+
parsedState.queue = [
|
|
449
|
+
{
|
|
450
|
+
from: { address: "sender@external.com" },
|
|
451
|
+
to: [{ address: `${token}@r.mail...foo.com` }],
|
|
452
|
+
headers: [],
|
|
453
|
+
},
|
|
454
|
+
];
|
|
455
|
+
|
|
456
|
+
await runHandler();
|
|
457
|
+
|
|
458
|
+
const getCalls = ssmMock.commandCalls(GetParameterCommand);
|
|
459
|
+
expect(getCalls.length).toBe(0);
|
|
460
|
+
const ev = await capturedPut();
|
|
461
|
+
const rt = ev.detail.replyToken as { status: string };
|
|
462
|
+
expect(rt.status).toBe("unknown-domain");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("verifies tokens signed by non-sequential previous kid when SSM stores previousKid explicitly", async () => {
|
|
466
|
+
// SSM blob uses non-sequential kids (5 current, 3 previous) — could occur
|
|
467
|
+
// if an operator hand-edits the parameter. buildSecretsMap must honor the
|
|
468
|
+
// explicit previousKid rather than assuming `kid - 1`.
|
|
469
|
+
const currentSecret = Buffer.alloc(32, 0xaa);
|
|
470
|
+
const previousSecret = Buffer.alloc(32, 0xbb);
|
|
471
|
+
ssmMock.reset();
|
|
472
|
+
ssmMock.on(GetParameterCommand).resolves({
|
|
473
|
+
Parameter: {
|
|
474
|
+
Value: JSON.stringify({
|
|
475
|
+
kid: 5,
|
|
476
|
+
current: currentSecret.toString("base64"),
|
|
477
|
+
previous: previousSecret.toString("base64"),
|
|
478
|
+
previousKid: 3,
|
|
479
|
+
}),
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Token signed with the previous key (kid=3), which skipped kid=4.
|
|
484
|
+
const token = encodeReplyToken({
|
|
485
|
+
kid: 3,
|
|
486
|
+
convId: Buffer.alloc(8, 0x33),
|
|
487
|
+
sendId: Buffer.alloc(8, 0x44),
|
|
488
|
+
exp: 0,
|
|
489
|
+
secret: previousSecret,
|
|
490
|
+
});
|
|
491
|
+
parsedState.queue = [
|
|
492
|
+
{
|
|
493
|
+
from: { address: "sender@external.com" },
|
|
494
|
+
to: [{ address: `${token}@r.mail.support.foo.com` }],
|
|
495
|
+
headers: [],
|
|
496
|
+
},
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
await runHandler();
|
|
500
|
+
|
|
501
|
+
const ev = await capturedPut();
|
|
502
|
+
const rt = ev.detail.replyToken as { status: string };
|
|
503
|
+
expect(rt.status).toBe("valid");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("normalizes mixed-case recipient domains to lowercase for SSM lookup", async () => {
|
|
507
|
+
// SSM params are stored at lowercase (created by CLI), but SES/mailparser
|
|
508
|
+
// may preserve case in the recipient address. The Lambda must lowercase
|
|
509
|
+
// the sending domain so `@r.mail.SUPPORT.FOO.com` finds
|
|
510
|
+
// `/wraps/email/reply-secret/support.foo.com`.
|
|
511
|
+
const token = encodeReplyToken({
|
|
512
|
+
kid: 1,
|
|
513
|
+
convId: Buffer.alloc(8, 0x77),
|
|
514
|
+
sendId: Buffer.alloc(8, 0x88),
|
|
515
|
+
exp: 0,
|
|
516
|
+
secret,
|
|
517
|
+
});
|
|
518
|
+
parsedState.queue = [
|
|
519
|
+
{
|
|
520
|
+
from: { address: "sender@external.com" },
|
|
521
|
+
to: [{ address: `${token}@r.mail.SUPPORT.FOO.com` }],
|
|
522
|
+
headers: [],
|
|
523
|
+
},
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
await runHandler();
|
|
527
|
+
|
|
528
|
+
const getCalls = ssmMock.commandCalls(GetParameterCommand);
|
|
529
|
+
expect(getCalls.length).toBe(1);
|
|
530
|
+
expect(getCalls[0].args[0].input.Name).toBe(`${prefix}support.foo.com`);
|
|
531
|
+
const ev = await capturedPut();
|
|
532
|
+
const rt = ev.detail.replyToken as { status: string };
|
|
533
|
+
expect(rt.status).toBe("valid");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("strips the r.mail. prefix to compute SSM parameter name", async () => {
|
|
537
|
+
const token = encodeReplyToken({
|
|
538
|
+
kid: 1,
|
|
539
|
+
convId: Buffer.alloc(8, 0x77),
|
|
540
|
+
sendId: Buffer.alloc(8, 0x88),
|
|
541
|
+
exp: 0,
|
|
542
|
+
secret,
|
|
543
|
+
});
|
|
544
|
+
parsedState.queue = [
|
|
545
|
+
{
|
|
546
|
+
from: { address: "sender@external.com" },
|
|
547
|
+
to: [{ address: `${token}@r.mail.support.foo.com` }],
|
|
548
|
+
headers: [],
|
|
549
|
+
},
|
|
550
|
+
];
|
|
551
|
+
|
|
552
|
+
await runHandler();
|
|
553
|
+
|
|
554
|
+
const getCalls = ssmMock.commandCalls(GetParameterCommand);
|
|
555
|
+
expect(getCalls.length).toBe(1);
|
|
556
|
+
expect(getCalls[0].args[0].input.Name).toBe(`${prefix}support.foo.com`);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
describe("inbound-processor — feature-disabled path", () => {
|
|
561
|
+
it("never calls SSM and always emits replyToken: null when prefix env is unset", async () => {
|
|
562
|
+
parsedState.queue = [
|
|
563
|
+
{
|
|
564
|
+
from: { address: "sender@external.com" },
|
|
565
|
+
to: [{ address: "something@r.mail.support.foo.com" }],
|
|
566
|
+
headers: [],
|
|
567
|
+
},
|
|
568
|
+
];
|
|
569
|
+
|
|
570
|
+
await runHandler();
|
|
571
|
+
|
|
572
|
+
const ev = await capturedPut();
|
|
573
|
+
expect(ev.detail.replyToken).toBeNull();
|
|
574
|
+
expect(ssmMock.commandCalls(GetParameterCommand).length).toBe(0);
|
|
575
|
+
});
|
|
576
|
+
});
|