@wraps.dev/cli 2.18.13 → 2.19.0
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 +2464 -1354
- 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 +6 -5
|
@@ -8,9 +8,16 @@ import {
|
|
|
8
8
|
PutObjectCommand,
|
|
9
9
|
S3Client,
|
|
10
10
|
} from "@aws-sdk/client-s3";
|
|
11
|
+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
11
12
|
import { NodeHttpHandler } from "@smithy/node-http-handler";
|
|
12
13
|
import type { Context, S3Event } from "aws-lambda";
|
|
13
14
|
import { simpleParser } from "mailparser";
|
|
15
|
+
import {
|
|
16
|
+
type DecodedReplyToken,
|
|
17
|
+
decodeReplyToken,
|
|
18
|
+
type ReplyTokenStatus,
|
|
19
|
+
verifyReplyToken,
|
|
20
|
+
} from "../../src/reply-token.js";
|
|
14
21
|
|
|
15
22
|
const awsDefaults = {
|
|
16
23
|
requestHandler: new NodeHttpHandler({
|
|
@@ -22,6 +29,7 @@ const awsDefaults = {
|
|
|
22
29
|
|
|
23
30
|
const s3 = new S3Client(awsDefaults);
|
|
24
31
|
const eventbridge = new EventBridgeClient(awsDefaults);
|
|
32
|
+
const ssm = new SSMClient(awsDefaults);
|
|
25
33
|
|
|
26
34
|
const BUCKET_NAME = process.env.BUCKET_NAME!;
|
|
27
35
|
const INBOUND_EVENT_SOURCE =
|
|
@@ -30,6 +38,176 @@ const INBOUND_EVENT_SOURCE =
|
|
|
30
38
|
/** Max HTML size in EventBridge detail (200KB) */
|
|
31
39
|
const MAX_HTML_SIZE = 200 * 1024;
|
|
32
40
|
|
|
41
|
+
type CachedSecret = {
|
|
42
|
+
kid: number;
|
|
43
|
+
current: Buffer;
|
|
44
|
+
previous?: Buffer;
|
|
45
|
+
/** kid of the `previous` secret. If absent, falls back to `kid - 1`. */
|
|
46
|
+
previousKid?: number;
|
|
47
|
+
fetchedAt: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const SECRET_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
51
|
+
const domainSecretCache = new Map<string, CachedSecret>();
|
|
52
|
+
|
|
53
|
+
// Well-formed DNS label per RFC 1035: lowercase a-z, 0-9, hyphen (not at
|
|
54
|
+
// start/end), 2+ labels separated by dots. Prevents SSM path injection via
|
|
55
|
+
// malformed recipient hostnames.
|
|
56
|
+
const DOMAIN_REGEX =
|
|
57
|
+
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
58
|
+
|
|
59
|
+
function isValidDomain(domain: string): boolean {
|
|
60
|
+
if (!domain || domain.length > 253 || domain.includes("..")) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return DOMAIN_REGEX.test(domain.toLowerCase());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ReplyTokenEvent =
|
|
67
|
+
| {
|
|
68
|
+
conversationId: string;
|
|
69
|
+
sendId: string;
|
|
70
|
+
status: "valid";
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
conversationId: null;
|
|
74
|
+
sendId: null;
|
|
75
|
+
status: Exclude<ReplyTokenStatus, "valid">;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
async function getReplySecretForDomain(
|
|
79
|
+
domain: string
|
|
80
|
+
): Promise<CachedSecret | null> {
|
|
81
|
+
const prefix = process.env.REPLY_SECRET_PARAMETER_PREFIX;
|
|
82
|
+
if (!prefix) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!isValidDomain(domain)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const cached = domainSecretCache.get(domain);
|
|
90
|
+
if (cached && now - cached.fetchedAt < SECRET_CACHE_TTL_MS) {
|
|
91
|
+
return cached;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const res = await ssm.send(
|
|
95
|
+
new GetParameterCommand({
|
|
96
|
+
Name: prefix + domain,
|
|
97
|
+
WithDecryption: true,
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
const value = res.Parameter?.Value;
|
|
101
|
+
if (!value) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
let parsed: {
|
|
105
|
+
kid: number;
|
|
106
|
+
current: string;
|
|
107
|
+
previous?: string;
|
|
108
|
+
previousKid?: number;
|
|
109
|
+
};
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(value);
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (
|
|
116
|
+
!parsed ||
|
|
117
|
+
typeof parsed.kid !== "number" ||
|
|
118
|
+
typeof parsed.current !== "string"
|
|
119
|
+
) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const entry: CachedSecret = {
|
|
123
|
+
kid: parsed.kid,
|
|
124
|
+
current: Buffer.from(parsed.current, "base64"),
|
|
125
|
+
previous:
|
|
126
|
+
typeof parsed.previous === "string"
|
|
127
|
+
? Buffer.from(parsed.previous, "base64")
|
|
128
|
+
: undefined,
|
|
129
|
+
previousKid:
|
|
130
|
+
typeof parsed.previousKid === "number" ? parsed.previousKid : undefined,
|
|
131
|
+
fetchedAt: now,
|
|
132
|
+
};
|
|
133
|
+
domainSecretCache.set(domain, entry);
|
|
134
|
+
return entry;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const name = (error as { name?: string }).name;
|
|
137
|
+
if (name === "ParameterNotFound") {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type RecipientAddr = { address: string };
|
|
145
|
+
|
|
146
|
+
function findReplyRecipient(
|
|
147
|
+
headers: Record<string, unknown>,
|
|
148
|
+
toAddresses: RecipientAddr[],
|
|
149
|
+
ccAddresses: RecipientAddr[]
|
|
150
|
+
): { address: string; replyDomain: string; sendingDomain: string } | null {
|
|
151
|
+
const xOriginalTo =
|
|
152
|
+
typeof headers["x-original-to"] === "string"
|
|
153
|
+
? (headers["x-original-to"] as string).trim()
|
|
154
|
+
: null;
|
|
155
|
+
const candidates: string[] = xOriginalTo
|
|
156
|
+
? [xOriginalTo]
|
|
157
|
+
: [
|
|
158
|
+
...toAddresses.map((a) => a.address),
|
|
159
|
+
...ccAddresses.map((a) => a.address),
|
|
160
|
+
];
|
|
161
|
+
for (const addr of candidates) {
|
|
162
|
+
if (!addr) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const at = addr.lastIndexOf("@");
|
|
166
|
+
if (at < 1) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const host = addr.slice(at + 1).toLowerCase();
|
|
170
|
+
if (host.startsWith("r.mail.")) {
|
|
171
|
+
return {
|
|
172
|
+
address: addr,
|
|
173
|
+
replyDomain: host,
|
|
174
|
+
sendingDomain: host.slice("r.mail.".length),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildSecretsMap(cached: CachedSecret): Record<number, Buffer> {
|
|
182
|
+
const out: Record<number, Buffer> = { [cached.kid]: cached.current };
|
|
183
|
+
if (cached.previous) {
|
|
184
|
+
const prevKid = cached.previousKid ?? cached.kid - 1;
|
|
185
|
+
out[prevKid] = cached.previous;
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function detectAutoReply(headers: Record<string, unknown>): boolean {
|
|
191
|
+
const autoSubmitted =
|
|
192
|
+
typeof headers["auto-submitted"] === "string"
|
|
193
|
+
? (headers["auto-submitted"] as string).toLowerCase()
|
|
194
|
+
: "";
|
|
195
|
+
if (autoSubmitted && autoSubmitted !== "no") {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
const precedence =
|
|
199
|
+
typeof headers.precedence === "string"
|
|
200
|
+
? (headers.precedence as string).toLowerCase()
|
|
201
|
+
: "";
|
|
202
|
+
if (precedence === "bulk" || precedence === "auto_reply") {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if ("x-autoreply" in headers || "x-autorespond" in headers) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
33
211
|
/**
|
|
34
212
|
* Lambda handler for processing inbound emails from S3 (via SES Receipt Rule)
|
|
35
213
|
*
|
|
@@ -191,6 +369,15 @@ export async function handler(event: S3Event, context: Context) {
|
|
|
191
369
|
}))
|
|
192
370
|
: [];
|
|
193
371
|
|
|
372
|
+
const ccAddresses = parsed.cc
|
|
373
|
+
? (Array.isArray(parsed.cc) ? parsed.cc : [parsed.cc])
|
|
374
|
+
.flatMap((addr) => ("value" in addr ? addr.value : [addr]))
|
|
375
|
+
.map((a) => ({
|
|
376
|
+
address: a.address || "",
|
|
377
|
+
name: a.name || "",
|
|
378
|
+
}))
|
|
379
|
+
: [];
|
|
380
|
+
|
|
194
381
|
const fromAddress = parsed.from?.value?.[0]
|
|
195
382
|
? {
|
|
196
383
|
address: parsed.from.value[0].address || "",
|
|
@@ -198,8 +385,73 @@ export async function handler(event: S3Event, context: Context) {
|
|
|
198
385
|
}
|
|
199
386
|
: { address: "", name: "" };
|
|
200
387
|
|
|
201
|
-
//
|
|
202
|
-
|
|
388
|
+
// Prefer X-Original-To for recipient derivation (SES envelope),
|
|
389
|
+
// then fall back to scanning To + CC for r.mail.* recipients.
|
|
390
|
+
const replyRecipient = findReplyRecipient(
|
|
391
|
+
headers,
|
|
392
|
+
toAddresses,
|
|
393
|
+
ccAddresses
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const receivingDomain =
|
|
397
|
+
replyRecipient?.replyDomain ||
|
|
398
|
+
toAddresses[0]?.address?.split("@")[1] ||
|
|
399
|
+
null;
|
|
400
|
+
|
|
401
|
+
let replyToken: ReplyTokenEvent | null = null;
|
|
402
|
+
let cacheHit = false;
|
|
403
|
+
const replyThreadingEnabled = Boolean(
|
|
404
|
+
process.env.REPLY_SECRET_PARAMETER_PREFIX
|
|
405
|
+
);
|
|
406
|
+
if (replyRecipient && replyThreadingEnabled) {
|
|
407
|
+
const localPart = replyRecipient.address.slice(
|
|
408
|
+
0,
|
|
409
|
+
replyRecipient.address.lastIndexOf("@")
|
|
410
|
+
);
|
|
411
|
+
const decoded: DecodedReplyToken | null = decodeReplyToken(localPart);
|
|
412
|
+
if (decoded) {
|
|
413
|
+
const beforeCache = domainSecretCache.get(
|
|
414
|
+
replyRecipient.sendingDomain
|
|
415
|
+
);
|
|
416
|
+
const cached = await getReplySecretForDomain(
|
|
417
|
+
replyRecipient.sendingDomain
|
|
418
|
+
);
|
|
419
|
+
cacheHit = beforeCache !== undefined && cached !== null;
|
|
420
|
+
if (cached) {
|
|
421
|
+
const verified = verifyReplyToken(
|
|
422
|
+
decoded,
|
|
423
|
+
buildSecretsMap(cached),
|
|
424
|
+
Math.floor(Date.now() / 1000)
|
|
425
|
+
);
|
|
426
|
+
replyToken =
|
|
427
|
+
verified.status === "valid"
|
|
428
|
+
? {
|
|
429
|
+
status: "valid",
|
|
430
|
+
conversationId: verified.conversationId,
|
|
431
|
+
sendId: verified.sendId,
|
|
432
|
+
}
|
|
433
|
+
: {
|
|
434
|
+
status: verified.status,
|
|
435
|
+
conversationId: null,
|
|
436
|
+
sendId: null,
|
|
437
|
+
};
|
|
438
|
+
} else {
|
|
439
|
+
replyToken = {
|
|
440
|
+
status: "unknown-domain",
|
|
441
|
+
conversationId: null,
|
|
442
|
+
sendId: null,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
replyToken = {
|
|
447
|
+
status: "malformed",
|
|
448
|
+
conversationId: null,
|
|
449
|
+
sendId: null,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const autoReply = detectAutoReply(headers);
|
|
203
455
|
|
|
204
456
|
const parsedEmail = {
|
|
205
457
|
emailId,
|
|
@@ -207,14 +459,7 @@ export async function handler(event: S3Event, context: Context) {
|
|
|
207
459
|
receivingDomain,
|
|
208
460
|
from: fromAddress,
|
|
209
461
|
to: toAddresses,
|
|
210
|
-
cc:
|
|
211
|
-
? (Array.isArray(parsed.cc) ? parsed.cc : [parsed.cc])
|
|
212
|
-
.flatMap((addr) => ("value" in addr ? addr.value : [addr]))
|
|
213
|
-
.map((a) => ({
|
|
214
|
-
address: a.address || "",
|
|
215
|
-
name: a.name || "",
|
|
216
|
-
}))
|
|
217
|
-
: [],
|
|
462
|
+
cc: ccAddresses,
|
|
218
463
|
subject: parsed.subject || "",
|
|
219
464
|
date: parsed.date?.toISOString() || new Date().toISOString(),
|
|
220
465
|
html,
|
|
@@ -226,6 +471,8 @@ export async function handler(event: S3Event, context: Context) {
|
|
|
226
471
|
virusVerdict,
|
|
227
472
|
rawS3Key: s3Key,
|
|
228
473
|
receivedAt: new Date().toISOString(),
|
|
474
|
+
replyToken,
|
|
475
|
+
autoReply,
|
|
229
476
|
};
|
|
230
477
|
|
|
231
478
|
// 7. Store parsed JSON at parsed/{emailId}.json
|
|
@@ -256,7 +503,13 @@ export async function handler(event: S3Event, context: Context) {
|
|
|
256
503
|
})
|
|
257
504
|
);
|
|
258
505
|
|
|
259
|
-
log("
|
|
506
|
+
log("Processed inbound", {
|
|
507
|
+
emailId,
|
|
508
|
+
sendingDomain: replyRecipient?.sendingDomain ?? null,
|
|
509
|
+
replyTokenStatus: replyToken?.status ?? null,
|
|
510
|
+
autoReply,
|
|
511
|
+
cacheHit,
|
|
512
|
+
});
|
|
260
513
|
} catch (error) {
|
|
261
514
|
logError("Error processing inbound email", error, { s3Key });
|
|
262
515
|
// Re-throw to trigger Lambda retry / DLQ
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Built at: 2026-04-
|
|
1
|
+
Built at: 2026-04-17T16:23:21.591Z
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wraps.dev/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "Deploy email, SMS, and CDN infrastructure to your AWS account with one command. Sets up SES with DKIM/SPF/DMARC, event processing, and history automatically.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cli.js",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"@aws-sdk/client-sesv2": "3.1030.0",
|
|
62
62
|
"@aws-sdk/client-sns": "3.1030.0",
|
|
63
63
|
"@aws-sdk/client-sqs": "3.1030.0",
|
|
64
|
+
"@aws-sdk/client-ssm": "3.1030.0",
|
|
64
65
|
"@aws-sdk/client-sts": "3.1030.0",
|
|
65
66
|
"@aws-sdk/s3-request-presigner": "3.1030.0",
|
|
66
67
|
"@aws-sdk/util-dynamodb": "3.996.2",
|
|
@@ -87,16 +88,16 @@
|
|
|
87
88
|
"devDependencies": {
|
|
88
89
|
"@types/express": "^5.0.0",
|
|
89
90
|
"@types/mailparser": "3.4.6",
|
|
90
|
-
"@types/node": "
|
|
91
|
+
"@types/node": "24.10.0",
|
|
91
92
|
"@types/react": "19.2.4",
|
|
92
93
|
"@types/uuid": "^10.0.0",
|
|
93
|
-
"@vitest/coverage-v8": "4.0.
|
|
94
|
+
"@vitest/coverage-v8": "4.0.18",
|
|
94
95
|
"aws-sdk-client-mock": "4.1.0",
|
|
95
96
|
"aws-sdk-client-mock-vitest": "7.0.1",
|
|
96
|
-
"tsup": "^8.
|
|
97
|
+
"tsup": "^8.5.1",
|
|
97
98
|
"tsx": "4.20.6",
|
|
98
99
|
"typescript": "^5.9.3",
|
|
99
|
-
"vitest": "
|
|
100
|
+
"vitest": "4.0.18",
|
|
100
101
|
"@wraps/core": "0.1.2",
|
|
101
102
|
"@wraps/email-check": "1.0.0"
|
|
102
103
|
},
|