@wraps.dev/cli 3.17.3 → 3.17.5

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.
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import {
3
3
  EventBridgeClient,
4
4
  PutEventsCommand,
@@ -38,6 +38,205 @@ const INBOUND_EVENT_SOURCE =
38
38
  /** Max HTML size in EventBridge detail (200KB) */
39
39
  const MAX_HTML_SIZE = 200 * 1024;
40
40
 
41
+ const EMAIL_RECEIVED_DETAIL_TYPE = "email.received";
42
+
43
+ /**
44
+ * EventBridge PutEvents size limits. AWS raised the limit from 256 KB to
45
+ * 1 MB on 2026-01-29 ("the total request size … must be less than 1 MB
46
+ * (1,048,576 bytes)"), except in the Regions listed below, which keep the
47
+ * old 256 KB limit. GovCloud was not part of that announcement.
48
+ * https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-putevents.html#eb-putevent-size
49
+ * https://aws.amazon.com/about-aws/whats-new/2026/01/amazon-eventbridge-increases-event-payload-size-256-kb-1-mb
50
+ */
51
+ const EVENTBRIDGE_LIMIT_BYTES = 1024 * 1024;
52
+ const EVENTBRIDGE_LEGACY_LIMIT_BYTES = 256 * 1024;
53
+ const LEGACY_LIMIT_REGIONS = new Set([
54
+ "ap-southeast-5", // Asia Pacific (Malaysia)
55
+ "ap-southeast-6", // Asia Pacific (New Zealand)
56
+ "ap-southeast-7", // Asia Pacific (Thailand)
57
+ "ap-east-2", // Asia Pacific (Taipei)
58
+ "mx-central-1", // Mexico (Central)
59
+ ]);
60
+ /** Headroom below the hard limit, so the entry is strictly smaller. */
61
+ const EVENT_SIZE_MARGIN_BYTES = 1024;
62
+
63
+ /** Longest subject kept in the minimal fallback detail. */
64
+ const MAX_FALLBACK_SUBJECT_LENGTH = 1000;
65
+
66
+ export function maxEventEntryBytes(
67
+ region = process.env.AWS_REGION ?? ""
68
+ ): number {
69
+ const limit =
70
+ LEGACY_LIMIT_REGIONS.has(region) || region.startsWith("us-gov-")
71
+ ? EVENTBRIDGE_LEGACY_LIMIT_BYTES
72
+ : EVENTBRIDGE_LIMIT_BYTES;
73
+ return limit - EVENT_SIZE_MARGIN_BYTES;
74
+ }
75
+
76
+ /**
77
+ * Size of a PutEvents entry as EventBridge counts it: the UTF-8 bytes of
78
+ * Source, DetailType and Detail (no Time or Resources are sent).
79
+ */
80
+ export function putEventsEntrySize(entry: {
81
+ Source: string;
82
+ DetailType: string;
83
+ Detail: string;
84
+ }): number {
85
+ return (
86
+ Buffer.byteLength(entry.Source, "utf8") +
87
+ Buffer.byteLength(entry.DetailType, "utf8") +
88
+ Buffer.byteLength(entry.Detail, "utf8")
89
+ );
90
+ }
91
+
92
+ type TrimmableEmail = {
93
+ emailId: string;
94
+ messageId: string;
95
+ receivingDomain: string | null;
96
+ from: unknown;
97
+ to: unknown[];
98
+ cc: unknown[];
99
+ subject: string;
100
+ date: string;
101
+ html: string | null;
102
+ htmlTruncated: boolean;
103
+ text: string | null;
104
+ headers: Record<string, unknown>;
105
+ attachments: unknown[];
106
+ spamVerdict: unknown;
107
+ virusVerdict: unknown;
108
+ rawS3Key: string;
109
+ receivedAt: string;
110
+ replyToken: unknown;
111
+ autoReply: boolean;
112
+ };
113
+
114
+ /**
115
+ * Build the `email.received` Detail string so the entry fits under `maxBytes`.
116
+ *
117
+ * If the full parsed email fits, it is sent unchanged. Otherwise the large
118
+ * fields are dropped one at a time until it fits:
119
+ * 1. html, text and headers, largest first
120
+ * (html → null with htmlTruncated: true, text → null, headers → {})
121
+ * 2. attachments → [] (attachmentCount keeps the number)
122
+ * 3. to/cc → [] (toCount/ccCount kept), subject cut to 1000 characters
123
+ * 4. last resort ("envelope"): only emailId, rawS3Key, parsedS3Key,
124
+ * receivedAt, replyToken and autoReply, which are all bounded
125
+ * Ties keep the html, text, headers order; fields that are already empty
126
+ * are skipped. A trimmed detail carries
127
+ * `truncated: true`, `truncatedFields`, and `parsedS3Key`, the key of the
128
+ * full parsed JSON in the same bucket as `rawS3Key`.
129
+ */
130
+ export function buildEventDetail(
131
+ email: TrimmableEmail,
132
+ parsedS3Key: string,
133
+ source: string,
134
+ maxBytes: number
135
+ ): { detail: string; truncatedFields: string[]; fullBytes: number } {
136
+ const sizeOf = (candidate: string) =>
137
+ putEventsEntrySize({
138
+ Source: source,
139
+ DetailType: EMAIL_RECEIVED_DETAIL_TYPE,
140
+ Detail: candidate,
141
+ });
142
+
143
+ const full = JSON.stringify(email);
144
+ const fullBytes = sizeOf(full);
145
+ if (fullBytes <= maxBytes) {
146
+ return { detail: full, truncatedFields: [], fullBytes };
147
+ }
148
+
149
+ const truncatedFields: string[] = [];
150
+ const trimmed: Record<string, unknown> = {
151
+ ...email,
152
+ truncated: true,
153
+ truncatedFields,
154
+ parsedS3Key,
155
+ };
156
+ const bodySteps: [string, number, () => void][] = [
157
+ [
158
+ "html",
159
+ Buffer.byteLength(JSON.stringify(email.html), "utf8"),
160
+ () => {
161
+ trimmed.html = null;
162
+ trimmed.htmlTruncated = true;
163
+ },
164
+ ],
165
+ [
166
+ "text",
167
+ Buffer.byteLength(JSON.stringify(email.text), "utf8"),
168
+ () => {
169
+ trimmed.text = null;
170
+ },
171
+ ],
172
+ [
173
+ "headers",
174
+ Buffer.byteLength(JSON.stringify(email.headers), "utf8"),
175
+ () => {
176
+ trimmed.headers = {};
177
+ },
178
+ ],
179
+ ];
180
+ const present: Record<string, boolean> = {
181
+ html: email.html !== null,
182
+ text: email.text !== null,
183
+ headers: Object.keys(email.headers).length > 0,
184
+ };
185
+ // Stable sort: equal sizes keep the html, text, headers order. Fields
186
+ // that are already empty are skipped so they are not reported as dropped.
187
+ bodySteps.sort((a, b) => b[1] - a[1]);
188
+ const steps: [string, () => void][] = [
189
+ ...bodySteps
190
+ .filter(([field]) => present[field])
191
+ .map(([field, , drop]): [string, () => void] => [field, drop]),
192
+ [
193
+ "attachments",
194
+ () => {
195
+ trimmed.attachmentCount = email.attachments.length;
196
+ trimmed.attachments = [];
197
+ },
198
+ ],
199
+ [
200
+ "recipients",
201
+ () => {
202
+ trimmed.toCount = email.to.length;
203
+ trimmed.ccCount = email.cc.length;
204
+ trimmed.to = [];
205
+ trimmed.cc = [];
206
+ trimmed.subject = email.subject.slice(0, MAX_FALLBACK_SUBJECT_LENGTH);
207
+ },
208
+ ],
209
+ ];
210
+
211
+ let detail = JSON.stringify(trimmed);
212
+ for (const [field, drop] of steps) {
213
+ if (sizeOf(detail) <= maxBytes) {
214
+ break;
215
+ }
216
+ drop();
217
+ truncatedFields.push(field);
218
+ detail = JSON.stringify(trimmed);
219
+ }
220
+
221
+ // Sender-controlled fields that are left (from.name, messageId, verdict
222
+ // headers) are unbounded. If they alone are too big, send an envelope of
223
+ // bounded fields only, so the event can never exceed the limit.
224
+ if (sizeOf(detail) > maxBytes) {
225
+ truncatedFields.push("envelope");
226
+ detail = JSON.stringify({
227
+ emailId: email.emailId,
228
+ rawS3Key: email.rawS3Key,
229
+ parsedS3Key,
230
+ receivedAt: email.receivedAt,
231
+ replyToken: email.replyToken,
232
+ autoReply: email.autoReply,
233
+ truncated: true,
234
+ truncatedFields,
235
+ });
236
+ }
237
+ return { detail, truncatedFields, fullBytes };
238
+ }
239
+
41
240
  type CachedSecret = {
42
241
  kid: number;
43
242
  current: Buffer;
@@ -217,7 +416,9 @@ function detectAutoReply(headers: Record<string, unknown>): boolean {
217
416
  * 3. Parse MIME → extract headers, body, attachments
218
417
  * 4. Store attachments → S3 (attachments/{emailId}/{attId}-{filename})
219
418
  * 5. Store parsed JSON → S3 (parsed/{emailId}.json)
220
- * 6. Put event → EventBridge (source: "wraps.inbound", detail-type: "email.received")
419
+ * 6. Put event → EventBridge (source: "wraps.inbound", detail-type: "email.received").
420
+ * If the full parsed email does not fit the PutEvents size limit, the
421
+ * detail is trimmed (see buildEventDetail) and points at parsed/{emailId}.json.
221
422
  */
222
423
  export async function handler(event: S3Event, context: Context) {
223
424
  const requestId = context.awsRequestId;
@@ -249,6 +450,10 @@ export async function handler(event: S3Event, context: Context) {
249
450
  for (const record of event.Records) {
250
451
  const s3Key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
251
452
  const bucket = record.s3.bucket.name;
453
+ // S3 event time is identical across retries; new Date() would differ per attempt.
454
+ const receivedAt = record.eventTime
455
+ ? new Date(record.eventTime).toISOString()
456
+ : new Date().toISOString();
252
457
 
253
458
  try {
254
459
  log("Processing raw email", { bucket, key: s3Key });
@@ -265,8 +470,9 @@ export async function handler(event: S3Event, context: Context) {
265
470
  // 2. Parse MIME
266
471
  const parsed = await simpleParser(rawBody);
267
472
 
268
- // Generate email ID
269
- const emailId = `inb_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
473
+ // Deterministic per raw object: a Lambda retry re-processes the same S3 key,
474
+ // so it must produce the same id (parsed JSON, attachments, event all key off it).
475
+ const emailId = `inb_${createHash("sha256").update(s3Key).digest("hex").slice(0, 16)}`;
270
476
 
271
477
  // 3. Extract headers as key-value pairs
272
478
  // mailparser returns most headers as strings, but some (list, received,
@@ -461,7 +667,7 @@ export async function handler(event: S3Event, context: Context) {
461
667
  to: toAddresses,
462
668
  cc: ccAddresses,
463
669
  subject: parsed.subject || "",
464
- date: parsed.date?.toISOString() || new Date().toISOString(),
670
+ date: parsed.date?.toISOString() || receivedAt,
465
671
  html,
466
672
  htmlTruncated,
467
673
  text: parsed.text || null,
@@ -470,16 +676,17 @@ export async function handler(event: S3Event, context: Context) {
470
676
  spamVerdict,
471
677
  virusVerdict,
472
678
  rawS3Key: s3Key,
473
- receivedAt: new Date().toISOString(),
679
+ receivedAt,
474
680
  replyToken,
475
681
  autoReply,
476
682
  };
477
683
 
478
684
  // 7. Store parsed JSON at parsed/{emailId}.json
685
+ const parsedS3Key = `parsed/${emailId}.json`;
479
686
  await s3.send(
480
687
  new PutObjectCommand({
481
688
  Bucket: BUCKET_NAME,
482
- Key: `parsed/${emailId}.json`,
689
+ Key: parsedS3Key,
483
690
  Body: JSON.stringify(parsedEmail),
484
691
  ContentType: "application/json",
485
692
  })
@@ -490,19 +697,50 @@ export async function handler(event: S3Event, context: Context) {
490
697
  attachmentCount: attachments.length,
491
698
  });
492
699
 
493
- // 8. Put event to EventBridge
494
- await eventbridge.send(
700
+ // 8. Put event to EventBridge, trimmed to fit the PutEvents size limit
701
+ const maxBytes = maxEventEntryBytes();
702
+ const { detail, truncatedFields, fullBytes } = buildEventDetail(
703
+ parsedEmail,
704
+ parsedS3Key,
705
+ INBOUND_EVENT_SOURCE,
706
+ maxBytes
707
+ );
708
+ if (truncatedFields.length > 0) {
709
+ log("Trimmed email.received detail to fit EventBridge limit", {
710
+ emailId,
711
+ fullBytes,
712
+ trimmedBytes: putEventsEntrySize({
713
+ Source: INBOUND_EVENT_SOURCE,
714
+ DetailType: EMAIL_RECEIVED_DETAIL_TYPE,
715
+ Detail: detail,
716
+ }),
717
+ maxBytes,
718
+ truncatedFields,
719
+ parsedS3Key,
720
+ });
721
+ }
722
+
723
+ const putResult = await eventbridge.send(
495
724
  new PutEventsCommand({
496
725
  Entries: [
497
726
  {
498
727
  Source: INBOUND_EVENT_SOURCE,
499
- DetailType: "email.received",
500
- Detail: JSON.stringify(parsedEmail),
728
+ DetailType: EMAIL_RECEIVED_DETAIL_TYPE,
729
+ Detail: detail,
501
730
  },
502
731
  ],
503
732
  })
504
733
  );
505
734
 
735
+ // PutEvents reports per-entry failures in a 200 response, so a
736
+ // failed entry must be surfaced explicitly to trigger a retry.
737
+ if ((putResult.FailedEntryCount ?? 0) > 0) {
738
+ const failed = (putResult.Entries ?? []).find((e) => e.ErrorCode);
739
+ throw new Error(
740
+ `EventBridge PutEvents failed for ${emailId}: ${failed?.ErrorCode ?? "UnknownError"} ${failed?.ErrorMessage ?? ""}`.trim()
741
+ );
742
+ }
743
+
506
744
  log("Processed inbound", {
507
745
  emailId,
508
746
  sendingDomain: replyRecipient?.sendingDomain ?? null,
@@ -1 +1 @@
1
- Built at: 2026-10-12T05:04:39.515Z
1
+ Built at: 2026-10-27T16:59:11.639Z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wraps.dev/cli",
3
- "version": "3.17.3",
3
+ "version": "3.17.5",
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",
@@ -46,32 +46,32 @@
46
46
  "author": "Wraps",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@aws-sdk/client-acm": "3.1074.0",
50
- "@aws-sdk/client-cloudformation": "3.1074.0",
51
- "@aws-sdk/client-cloudfront": "3.1074.0",
52
- "@aws-sdk/client-cloudwatch": "3.1074.0",
53
- "@aws-sdk/client-cloudwatch-logs": "3.1074.0",
54
- "@aws-sdk/client-dynamodb": "3.1074.0",
55
- "@aws-sdk/client-eventbridge": "3.1074.0",
56
- "@aws-sdk/client-iam": "3.1074.0",
57
- "@aws-sdk/client-lambda": "3.1074.0",
58
- "@aws-sdk/client-mailmanager": "3.1074.0",
59
- "@aws-sdk/client-pinpoint-sms-voice-v2": "3.1074.0",
60
- "@aws-sdk/client-route-53": "3.1074.0",
61
- "@aws-sdk/client-s3": "3.1074.0",
62
- "@aws-sdk/client-ses": "3.1074.0",
63
- "@aws-sdk/client-sesv2": "3.1091.0",
64
- "@aws-sdk/client-sns": "3.1074.0",
65
- "@aws-sdk/client-sqs": "3.1074.0",
66
- "@aws-sdk/client-ssm": "3.1074.0",
67
- "@aws-sdk/client-sts": "3.1074.0",
68
- "@aws-sdk/s3-request-presigner": "3.1074.0",
69
- "@aws-sdk/util-dynamodb": "3.996.5",
49
+ "@aws-sdk/client-acm": "3.1152.0",
50
+ "@aws-sdk/client-cloudformation": "3.1152.0",
51
+ "@aws-sdk/client-cloudfront": "3.1152.0",
52
+ "@aws-sdk/client-cloudwatch": "3.1152.0",
53
+ "@aws-sdk/client-cloudwatch-logs": "3.1152.0",
54
+ "@aws-sdk/client-dynamodb": "3.1152.0",
55
+ "@aws-sdk/client-eventbridge": "3.1152.0",
56
+ "@aws-sdk/client-iam": "3.1152.0",
57
+ "@aws-sdk/client-lambda": "3.1152.0",
58
+ "@aws-sdk/client-mailmanager": "3.1152.0",
59
+ "@aws-sdk/client-pinpoint-sms-voice-v2": "3.1152.0",
60
+ "@aws-sdk/client-route-53": "3.1152.0",
61
+ "@aws-sdk/client-s3": "3.1152.0",
62
+ "@aws-sdk/client-ses": "3.1152.0",
63
+ "@aws-sdk/client-sesv2": "3.1152.0",
64
+ "@aws-sdk/client-sns": "3.1152.0",
65
+ "@aws-sdk/client-sqs": "3.1152.0",
66
+ "@aws-sdk/client-ssm": "3.1152.0",
67
+ "@aws-sdk/client-sts": "3.1152.0",
68
+ "@aws-sdk/s3-request-presigner": "3.1152.0",
69
+ "@aws-sdk/util-dynamodb": "3.996.9",
70
70
  "@clack/prompts": "^0.11.0",
71
71
  "@pulumi/aws": "^7.39.0",
72
72
  "@pulumi/pulumi": "^3.254.0",
73
73
  "@react-email/render": "2.1.0",
74
- "better-auth": "1.7.5",
74
+ "better-auth": "1.7.11",
75
75
  "conf": "^15.1.0",
76
76
  "cosmiconfig": "^9.0.2",
77
77
  "drizzle-orm": "^0.45.2",
@@ -99,13 +99,13 @@
99
99
  "@vitest/coverage-v8": "4.1.11",
100
100
  "aws-sdk-client-mock": "4.1.0",
101
101
  "aws-sdk-client-mock-vitest": "7.0.1",
102
- "handlebars": "4.7.9",
102
+ "handlebars": "4.7.10",
103
103
  "tsup": "^8.5.1",
104
104
  "tsx": "4.20.6",
105
105
  "typescript": "^5.9.3",
106
106
  "vitest": "4.1.11",
107
+ "@wraps.dev/email-check": "1.3.0",
107
108
  "@wraps.dev/pulumi": "0.6.0",
108
- "@wraps.dev/email-check": "1.2.0",
109
109
  "@wraps/core": "0.1.2",
110
110
  "@wraps/db": "0.0.0",
111
111
  "@wraps/template-render": "0.1.0"