@wraps.dev/cli 2.18.14 → 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.
@@ -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
- // Derive receiving domain from first to: address
202
- const receivingDomain = toAddresses[0]?.address?.split("@")[1] || null;
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: parsed.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("Published EventBridge event", { emailId });
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-16T17:20:24.078Z
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.18.14",
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",