@wraps.dev/cli 2.6.0 → 2.7.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.
@@ -0,0 +1,234 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ EventBridgeClient,
4
+ PutEventsCommand,
5
+ } from "@aws-sdk/client-eventbridge";
6
+ import {
7
+ GetObjectCommand,
8
+ PutObjectCommand,
9
+ S3Client,
10
+ } from "@aws-sdk/client-s3";
11
+ import type { Context, S3Event } from "aws-lambda";
12
+ import { simpleParser } from "mailparser";
13
+
14
+ const s3 = new S3Client({});
15
+ const eventbridge = new EventBridgeClient({});
16
+
17
+ const BUCKET_NAME = process.env.BUCKET_NAME!;
18
+ const INBOUND_EVENT_SOURCE =
19
+ process.env.INBOUND_EVENT_SOURCE || "wraps.inbound";
20
+
21
+ /** Max HTML size in EventBridge detail (200KB) */
22
+ const MAX_HTML_SIZE = 200 * 1024;
23
+
24
+ /**
25
+ * Lambda handler for processing inbound emails from S3 (via SES Receipt Rule)
26
+ *
27
+ * Flow:
28
+ * 1. SES receives email → stores raw MIME in S3 (raw/{messageId})
29
+ * 2. S3 notification triggers this Lambda
30
+ * 3. Parse MIME → extract headers, body, attachments
31
+ * 4. Store attachments → S3 (attachments/{emailId}/{attId}-{filename})
32
+ * 5. Store parsed JSON → S3 (parsed/{emailId}.json)
33
+ * 6. Put event → EventBridge (source: "wraps.inbound", detail-type: "email.received")
34
+ */
35
+ export async function handler(event: S3Event, context: Context) {
36
+ const requestId = context.awsRequestId;
37
+ const batchId = randomUUID().slice(0, 8);
38
+
39
+ const log = (msg: string, data?: Record<string, unknown>) => {
40
+ console.log(JSON.stringify({ requestId, batchId, msg, ...data }));
41
+ };
42
+ const logError = (
43
+ msg: string,
44
+ error: unknown,
45
+ data?: Record<string, unknown>
46
+ ) => {
47
+ console.error(
48
+ JSON.stringify({
49
+ requestId,
50
+ batchId,
51
+ msg,
52
+ error: String(error),
53
+ ...data,
54
+ })
55
+ );
56
+ };
57
+
58
+ log("Processing inbound email batch", {
59
+ recordCount: event.Records.length,
60
+ });
61
+
62
+ for (const record of event.Records) {
63
+ const s3Key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
64
+ const bucket = record.s3.bucket.name;
65
+
66
+ try {
67
+ log("Processing raw email", { bucket, key: s3Key });
68
+
69
+ // 1. Read raw MIME from S3
70
+ const rawResponse = await s3.send(
71
+ new GetObjectCommand({ Bucket: bucket, Key: s3Key })
72
+ );
73
+ const rawBody = await rawResponse.Body!.transformToString();
74
+
75
+ // 2. Parse MIME
76
+ const parsed = await simpleParser(rawBody);
77
+
78
+ // Generate email ID
79
+ const emailId = `inb_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
80
+
81
+ // 3. Extract headers as lowercase key-value pairs
82
+ const headers: Record<string, string> = {};
83
+ if (parsed.headers) {
84
+ for (const [key, value] of parsed.headers) {
85
+ const headerValue =
86
+ typeof value === "object" && value !== null && "text" in value
87
+ ? (value as { text: string }).text
88
+ : String(value);
89
+ if (headers[key]) {
90
+ headers[key] = `${headers[key]}, ${headerValue}`;
91
+ } else {
92
+ headers[key] = headerValue;
93
+ }
94
+ }
95
+ }
96
+
97
+ // 4. Extract SES spam/virus verdicts from headers
98
+ const spamVerdict = headers["x-ses-spam-verdict"] || null;
99
+ const virusVerdict = headers["x-ses-virus-verdict"] || null;
100
+
101
+ // 5. Upload attachments to S3
102
+ const attachments: Array<{
103
+ id: string;
104
+ filename: string;
105
+ contentType: string;
106
+ size: number;
107
+ s3Key: string;
108
+ contentDisposition: string;
109
+ cid: string | null;
110
+ }> = [];
111
+
112
+ if (parsed.attachments && parsed.attachments.length > 0) {
113
+ for (let i = 0; i < parsed.attachments.length; i++) {
114
+ const att = parsed.attachments[i];
115
+ const attId = `att_${i}`;
116
+ const safeFilename = (att.filename || `attachment_${i}`).replace(
117
+ /[^a-zA-Z0-9._-]/g,
118
+ "_"
119
+ );
120
+ const attKey = `attachments/${emailId}/${attId}-${safeFilename}`;
121
+
122
+ await s3.send(
123
+ new PutObjectCommand({
124
+ Bucket: BUCKET_NAME,
125
+ Key: attKey,
126
+ Body: att.content,
127
+ ContentType: att.contentType || "application/octet-stream",
128
+ })
129
+ );
130
+
131
+ attachments.push({
132
+ id: attId,
133
+ filename: att.filename || `attachment_${i}`,
134
+ contentType: att.contentType || "application/octet-stream",
135
+ size: att.size,
136
+ s3Key: attKey,
137
+ contentDisposition: att.contentDisposition || "attachment",
138
+ cid: att.cid || null,
139
+ });
140
+ }
141
+ }
142
+
143
+ // 6. Build parsed email JSON
144
+ let html = parsed.html || null;
145
+ let htmlTruncated = false;
146
+
147
+ if (html && html.length > MAX_HTML_SIZE) {
148
+ html = html.slice(0, MAX_HTML_SIZE);
149
+ htmlTruncated = true;
150
+ }
151
+
152
+ const toAddresses = parsed.to
153
+ ? (Array.isArray(parsed.to) ? parsed.to : [parsed.to])
154
+ .flatMap((addr) => ("value" in addr ? addr.value : [addr]))
155
+ .map((a) => ({
156
+ address: a.address || "",
157
+ name: a.name || "",
158
+ }))
159
+ : [];
160
+
161
+ const fromAddress = parsed.from?.value?.[0]
162
+ ? {
163
+ address: parsed.from.value[0].address || "",
164
+ name: parsed.from.value[0].name || "",
165
+ }
166
+ : { address: "", name: "" };
167
+
168
+ const parsedEmail = {
169
+ emailId,
170
+ messageId: parsed.messageId || s3Key.replace("raw/", ""),
171
+ from: fromAddress,
172
+ to: toAddresses,
173
+ cc: parsed.cc
174
+ ? (Array.isArray(parsed.cc) ? parsed.cc : [parsed.cc])
175
+ .flatMap((addr) => ("value" in addr ? addr.value : [addr]))
176
+ .map((a) => ({
177
+ address: a.address || "",
178
+ name: a.name || "",
179
+ }))
180
+ : [],
181
+ subject: parsed.subject || "",
182
+ date: parsed.date?.toISOString() || new Date().toISOString(),
183
+ html,
184
+ htmlTruncated,
185
+ text: parsed.text || null,
186
+ headers,
187
+ attachments,
188
+ spamVerdict,
189
+ virusVerdict,
190
+ rawS3Key: s3Key,
191
+ receivedAt: new Date().toISOString(),
192
+ };
193
+
194
+ // 7. Store parsed JSON at parsed/{emailId}.json
195
+ await s3.send(
196
+ new PutObjectCommand({
197
+ Bucket: BUCKET_NAME,
198
+ Key: `parsed/${emailId}.json`,
199
+ Body: JSON.stringify(parsedEmail),
200
+ ContentType: "application/json",
201
+ })
202
+ );
203
+
204
+ log("Stored parsed email", {
205
+ emailId,
206
+ attachmentCount: attachments.length,
207
+ });
208
+
209
+ // 8. Put event to EventBridge
210
+ await eventbridge.send(
211
+ new PutEventsCommand({
212
+ Entries: [
213
+ {
214
+ Source: INBOUND_EVENT_SOURCE,
215
+ DetailType: "email.received",
216
+ Detail: JSON.stringify(parsedEmail),
217
+ },
218
+ ],
219
+ })
220
+ );
221
+
222
+ log("Published EventBridge event", { emailId });
223
+ } catch (error) {
224
+ logError("Error processing inbound email", error, { s3Key });
225
+ // Re-throw to trigger Lambda retry / DLQ
226
+ throw error;
227
+ }
228
+ }
229
+
230
+ return {
231
+ statusCode: 200,
232
+ body: JSON.stringify({ message: "Inbound emails processed" }),
233
+ };
234
+ }
@@ -1 +1 @@
1
- Built at: 2026-02-02T19:20:11.804Z
1
+ Built at: 2026-02-03T04:33:00.757Z
@@ -1 +1,2 @@
1
+ import { createRequire } from 'module'; const require = createRequire(import.meta.url);
1
2
  import{DynamoDBClient as N,PutItemCommand as E}from"@aws-sdk/client-dynamodb";var u=new N({});async function v(r){console.log("Processing SMS event from SQS:",JSON.stringify(r,null,2));let a=process.env.TABLE_NAME;if(!a)throw new Error("TABLE_NAME environment variable not set");let i=Number.parseInt(process.env.RETENTION_DAYS||"90",10);for(let m of r.Records)try{let e=JSON.parse(m.body),t=e.eventType||e.messageStatus||"UNKNOWN",n=e.messageId,d=e.destinationPhoneNumber,c=e.originationPhoneNumber,o=e.messageBody||"",T=e.isoCountryCode||"US",S=e.messageType||"TRANSACTIONAL",p=e.eventTimestamp?new Date(e.eventTimestamp).getTime():Date.now();console.log("Processing SMS event:",{messageId:n,eventType:t,destinationNumber:d,originationNumber:c});let s={isoCountryCode:T,messageType:S};t==="TEXT_DELIVERED"||t==="TEXT_SUCCESSFUL"?s={...s,deliveryTimestamp:e.deliveryTimestamp,carrierName:e.carrierName,providerResponse:e.providerResponse}:t==="TEXT_FAILED"||t==="TEXT_INVALID"||t==="TEXT_CARRIER_UNREACHABLE"||t==="TEXT_BLOCKED"?s={...s,failureReason:e.failureReason||e.statusMessage,failureCode:e.failureCode||e.statusCode,providerResponse:e.providerResponse}:t==="TEXT_QUEUED"||t==="TEXT_SENT"?s={...s,queuedTimestamp:e.queuedTimestamp,sentTimestamp:e.sentTimestamp}:t==="TEXT_TTL_EXPIRED"&&(s={...s,expirationTimestamp:e.expirationTimestamp,ttlSeconds:e.ttlSeconds});let l=o?Math.ceil(o.length/160):1,g=i>0?Date.now()+i*24*60*60*1e3:Date.now()+365*24*60*60*1e3;await u.send(new E({TableName:a,Item:{messageId:{S:n},sentAt:{N:p.toString()},accountId:{S:process.env.AWS_ACCOUNT_ID||"unknown"},destinationNumber:{S:d||""},originationNumber:{S:c||""},messageBody:{S:o},eventType:{S:t},segments:{N:l.toString()},eventData:{S:JSON.stringify(e)},additionalData:{S:JSON.stringify(s)},createdAt:{N:Date.now().toString()},expiresAt:{N:g.toString()}}})),console.log(`Stored ${t} event for message ${n}`,s)}catch(e){console.error("Error processing record:",e),console.error("Record:",JSON.stringify(m,null,2))}return{statusCode:200,body:JSON.stringify({message:"SMS events processed successfully"})}}export{v as handler};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wraps.dev/cli",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "CLI for deploying Wraps email infrastructure to your AWS account",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
@@ -78,13 +78,12 @@
78
78
  "@vitest/coverage-v8": "4.0.7",
79
79
  "aws-sdk-client-mock": "4.1.0",
80
80
  "aws-sdk-client-mock-vitest": "7.0.1",
81
- "eslint": "^8.56.0",
82
81
  "tsup": "^8.0.1",
83
82
  "tsx": "4.20.6",
84
83
  "typescript": "^5.9.3",
85
84
  "vitest": "^4.0.7",
86
- "@wraps/email-check": "1.0.0",
87
- "@wraps/core": "0.1.0"
85
+ "@wraps/core": "0.1.0",
86
+ "@wraps/email-check": "1.0.0"
88
87
  },
89
88
  "engines": {
90
89
  "node": ">=20.0.0"
@@ -98,7 +97,6 @@
98
97
  "test:watch": "vitest --watch",
99
98
  "test:ui": "vitest --ui",
100
99
  "test:coverage": "vitest run --coverage",
101
- "typecheck": "tsc --noEmit",
102
- "lint": "eslint src"
100
+ "typecheck": "tsc --noEmit"
103
101
  }
104
102
  }