@wraps.dev/cli 3.14.0 → 3.16.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 +87 -63
- package/dist/cli.js.map +1 -1
- package/dist/lambda/agent-enforcer/.bundled +1 -1
- package/dist/lambda/event-processor/.bundled +1 -1
- package/dist/lambda/event-processor/cloudformation-parity.test.ts +230 -0
- package/dist/lambda/event-processor/index.js +7 -7
- package/dist/lambda/event-processor/index.test.ts +223 -0
- package/dist/lambda/event-processor/index.ts +117 -1
- package/dist/lambda/inbound-processor/.bundled +1 -1
- package/dist/lambda/sms-event-processor/.bundled +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DynamoDBClient,
|
|
3
|
+
PutItemCommand,
|
|
4
|
+
UpdateItemCommand,
|
|
5
|
+
} from "@aws-sdk/client-dynamodb";
|
|
6
|
+
import type { Context } from "aws-lambda";
|
|
7
|
+
import { mockClient } from "aws-sdk-client-mock";
|
|
8
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
9
|
+
import { handler } from "./index.ts";
|
|
10
|
+
|
|
11
|
+
const ddbMock = mockClient(DynamoDBClient);
|
|
12
|
+
|
|
13
|
+
const MAIL = {
|
|
14
|
+
messageId: "ses-msg-1",
|
|
15
|
+
timestamp: "2026-09-01T00:00:00.000Z",
|
|
16
|
+
source: "a@wraps.dev",
|
|
17
|
+
destination: ["X@Example.com", "other@example.com"],
|
|
18
|
+
commonHeaders: { subject: "hi" },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const BOUNCE_TS = "2026-09-01T00:00:05.000Z";
|
|
22
|
+
|
|
23
|
+
function bounceDetail(
|
|
24
|
+
overrides: Record<string, unknown> = {},
|
|
25
|
+
bounceOverrides: Record<string, unknown> = {}
|
|
26
|
+
) {
|
|
27
|
+
return {
|
|
28
|
+
eventType: "Bounce",
|
|
29
|
+
mail: MAIL,
|
|
30
|
+
bounce: {
|
|
31
|
+
bounceType: "Permanent",
|
|
32
|
+
bounceSubType: "General",
|
|
33
|
+
timestamp: BOUNCE_TS,
|
|
34
|
+
feedbackId: "fb-1",
|
|
35
|
+
bouncedRecipients: [{ emailAddress: "X@Example.com " }],
|
|
36
|
+
...bounceOverrides,
|
|
37
|
+
},
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sqsEvent(...details: unknown[]) {
|
|
43
|
+
return {
|
|
44
|
+
Records: details.map((detail, i) => ({
|
|
45
|
+
messageId: `sqs-${i + 1}`,
|
|
46
|
+
body: JSON.stringify({ detail }),
|
|
47
|
+
})),
|
|
48
|
+
} as never;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const context = { awsRequestId: "req-1" } as Context;
|
|
52
|
+
|
|
53
|
+
const updates = () => ddbMock.commandCalls(UpdateItemCommand);
|
|
54
|
+
const puts = () => ddbMock.commandCalls(PutItemCommand);
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
ddbMock.reset();
|
|
58
|
+
process.env.TABLE_NAME = "wraps-email-history";
|
|
59
|
+
process.env.AWS_ACCOUNT_ID = "123456789012";
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("event-processor suppression ledger", () => {
|
|
63
|
+
it("writes one ledger item for a permanent bounce, normalised and recipient-scoped", async () => {
|
|
64
|
+
await handler(sqsEvent(bounceDetail()), context);
|
|
65
|
+
|
|
66
|
+
expect(updates()).toHaveLength(1);
|
|
67
|
+
const input = updates()[0].args[0].input;
|
|
68
|
+
expect(input.Key).toEqual({
|
|
69
|
+
messageId: { S: "SUPPRESSION#x@example.com" },
|
|
70
|
+
sentAt: { N: "0" },
|
|
71
|
+
});
|
|
72
|
+
expect(input.ExpressionAttributeValues?.[":r"]).toEqual({ S: "bounce" });
|
|
73
|
+
expect(input.ExpressionAttributeValues?.[":d"]).toEqual({ S: "General" });
|
|
74
|
+
expect(input.ExpressionAttributeValues?.[":sa"]).toEqual({
|
|
75
|
+
N: String(Date.parse(BOUNCE_TS)),
|
|
76
|
+
});
|
|
77
|
+
// other@example.com was in mail.destination but not in bouncedRecipients
|
|
78
|
+
expect(JSON.stringify(input)).not.toContain("other@example.com");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps ledger items out of the GSI and TTL, and preserves first-seen", async () => {
|
|
82
|
+
await handler(sqsEvent(bounceDetail()), context);
|
|
83
|
+
|
|
84
|
+
const input = updates()[0].args[0].input;
|
|
85
|
+
const serialised = JSON.stringify(input);
|
|
86
|
+
expect(serialised).not.toContain("accountId");
|
|
87
|
+
expect(serialised).not.toContain("expiresAt");
|
|
88
|
+
expect(input.UpdateExpression).toContain("if_not_exists(#fsa, :sa)");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("skips the ledger for a transient bounce but still stores the event", async () => {
|
|
92
|
+
await handler(
|
|
93
|
+
sqsEvent(bounceDetail({}, { bounceType: "Transient" })),
|
|
94
|
+
context
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(updates()).toHaveLength(0);
|
|
98
|
+
expect(puts()).toHaveLength(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("records OnAccountSuppressionList as a bounce and keeps the Suppressed event row", async () => {
|
|
102
|
+
await handler(
|
|
103
|
+
sqsEvent(
|
|
104
|
+
bounceDetail(
|
|
105
|
+
{},
|
|
106
|
+
{
|
|
107
|
+
bounceType: "Permanent",
|
|
108
|
+
bounceSubType: "OnAccountSuppressionList",
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
),
|
|
112
|
+
context
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(updates()).toHaveLength(1);
|
|
116
|
+
const values = updates()[0].args[0].input.ExpressionAttributeValues;
|
|
117
|
+
expect(values?.[":r"]).toEqual({ S: "bounce" });
|
|
118
|
+
expect(values?.[":d"]).toEqual({ S: "OnAccountSuppressionList" });
|
|
119
|
+
expect(puts()[0].args[0].input.Item?.eventType).toEqual({
|
|
120
|
+
S: "Suppressed",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("records EmailValidationSuppressed as validation", async () => {
|
|
125
|
+
await handler(
|
|
126
|
+
sqsEvent(
|
|
127
|
+
bounceDetail(
|
|
128
|
+
{},
|
|
129
|
+
{
|
|
130
|
+
bounceType: "Undetermined",
|
|
131
|
+
bounceSubType: "EmailValidationSuppressed",
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
),
|
|
135
|
+
context
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(updates()).toHaveLength(1);
|
|
139
|
+
expect(
|
|
140
|
+
updates()[0].args[0].input.ExpressionAttributeValues?.[":r"]
|
|
141
|
+
).toEqual({ S: "validation" });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("records a complaint with its feedback type", async () => {
|
|
145
|
+
await handler(
|
|
146
|
+
sqsEvent({
|
|
147
|
+
eventType: "Complaint",
|
|
148
|
+
mail: MAIL,
|
|
149
|
+
complaint: {
|
|
150
|
+
complainedRecipients: [{ emailAddress: "c@example.com" }],
|
|
151
|
+
complaintFeedbackType: "abuse",
|
|
152
|
+
timestamp: BOUNCE_TS,
|
|
153
|
+
feedbackId: "fb-2",
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
context
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(updates()).toHaveLength(1);
|
|
160
|
+
const values = updates()[0].args[0].input.ExpressionAttributeValues;
|
|
161
|
+
expect(values?.[":r"]).toEqual({ S: "complaint" });
|
|
162
|
+
expect(values?.[":d"]).toEqual({ S: "abuse" });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("writes nothing when the recipient list is missing, ignoring mail.destination", async () => {
|
|
166
|
+
await handler(
|
|
167
|
+
sqsEvent(bounceDetail({}, { bouncedRecipients: undefined })),
|
|
168
|
+
context
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(updates()).toHaveLength(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("contains a ledger failure so the rest of the batch still stores", async () => {
|
|
175
|
+
ddbMock.on(UpdateItemCommand).rejects(new Error("boom"));
|
|
176
|
+
|
|
177
|
+
const result = await handler(
|
|
178
|
+
sqsEvent(bounceDetail(), bounceDetail()),
|
|
179
|
+
context
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
expect(result).toMatchObject({ statusCode: 200 });
|
|
183
|
+
expect(puts()).toHaveLength(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("still records later recipients when an earlier ledger write fails", async () => {
|
|
187
|
+
ddbMock.on(UpdateItemCommand).rejectsOnce(new Error("boom")).resolves({});
|
|
188
|
+
|
|
189
|
+
await handler(
|
|
190
|
+
sqsEvent(
|
|
191
|
+
bounceDetail(
|
|
192
|
+
{},
|
|
193
|
+
{
|
|
194
|
+
bouncedRecipients: [
|
|
195
|
+
{ emailAddress: "first@example.com" },
|
|
196
|
+
{ emailAddress: "second@example.com" },
|
|
197
|
+
],
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
),
|
|
201
|
+
context
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(updates()).toHaveLength(2);
|
|
205
|
+
expect(updates()[1].args[0].input.Key?.messageId).toEqual({
|
|
206
|
+
S: "SUPPRESSION#second@example.com",
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("does not write a ledger item for a delivery event", async () => {
|
|
211
|
+
await handler(
|
|
212
|
+
sqsEvent({
|
|
213
|
+
eventType: "Delivery",
|
|
214
|
+
mail: MAIL,
|
|
215
|
+
delivery: { timestamp: BOUNCE_TS, recipients: ["x@example.com"] },
|
|
216
|
+
}),
|
|
217
|
+
context
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(updates()).toHaveLength(0);
|
|
221
|
+
expect(puts()).toHaveLength(1);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DynamoDBClient,
|
|
4
|
+
PutItemCommand,
|
|
5
|
+
UpdateItemCommand,
|
|
6
|
+
} from "@aws-sdk/client-dynamodb";
|
|
3
7
|
import { NodeHttpHandler } from "@smithy/node-http-handler";
|
|
4
8
|
import type { Context, SQSEvent } from "aws-lambda";
|
|
5
9
|
|
|
@@ -13,6 +17,54 @@ const awsDefaults = {
|
|
|
13
17
|
|
|
14
18
|
const dynamodb = new DynamoDBClient(awsDefaults);
|
|
15
19
|
|
|
20
|
+
type LedgerEntry = {
|
|
21
|
+
reason: "bounce" | "complaint" | "validation";
|
|
22
|
+
detail?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Which SES events write a suppression-history item, and with what reason. */
|
|
26
|
+
export function ledgerEntryFor(message: {
|
|
27
|
+
eventType?: string;
|
|
28
|
+
notificationType?: string;
|
|
29
|
+
bounce?: { bounceType?: string; bounceSubType?: string };
|
|
30
|
+
complaint?: { complaintFeedbackType?: string };
|
|
31
|
+
}): LedgerEntry | null {
|
|
32
|
+
const type = message.eventType || message.notificationType;
|
|
33
|
+
if (type === "Bounce" && message.bounce) {
|
|
34
|
+
const sub = message.bounce.bounceSubType;
|
|
35
|
+
if (sub === "EmailValidationSuppressed") {
|
|
36
|
+
return { reason: "validation", detail: sub };
|
|
37
|
+
}
|
|
38
|
+
if (sub === "Suppressed" || sub === "OnAccountSuppressionList") {
|
|
39
|
+
return { reason: "bounce", detail: sub };
|
|
40
|
+
}
|
|
41
|
+
if (message.bounce.bounceType === "Permanent") {
|
|
42
|
+
return { reason: "bounce", detail: sub };
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (type === "Complaint" && message.complaint) {
|
|
47
|
+
return {
|
|
48
|
+
reason: "complaint",
|
|
49
|
+
detail: message.complaint.complaintFeedbackType,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function recipientEmails(recipients: unknown): string[] {
|
|
56
|
+
if (!Array.isArray(recipients)) return [];
|
|
57
|
+
return recipients
|
|
58
|
+
.map((r) =>
|
|
59
|
+
r && typeof r === "object"
|
|
60
|
+
? (r as { emailAddress?: unknown }).emailAddress
|
|
61
|
+
: undefined
|
|
62
|
+
)
|
|
63
|
+
.filter((e): e is string => typeof e === "string")
|
|
64
|
+
.map((e) => e.toLowerCase().trim())
|
|
65
|
+
.filter((e) => e.length > 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
16
68
|
/**
|
|
17
69
|
* Lambda handler for processing SES events from SQS (via EventBridge)
|
|
18
70
|
* Stores all SES events in DynamoDB:
|
|
@@ -210,6 +262,70 @@ export async function handler(event: SQSEvent, context: Context) {
|
|
|
210
262
|
);
|
|
211
263
|
|
|
212
264
|
log("Stored event", { eventType, messageId });
|
|
265
|
+
|
|
266
|
+
const ledger = ledgerEntryFor(message);
|
|
267
|
+
if (ledger) {
|
|
268
|
+
const recipients = recipientEmails(
|
|
269
|
+
ledger.reason === "complaint"
|
|
270
|
+
? message.complaint?.complainedRecipients
|
|
271
|
+
: message.bounce?.bouncedRecipients
|
|
272
|
+
);
|
|
273
|
+
const feedbackId: string | undefined =
|
|
274
|
+
message.bounce?.feedbackId || message.complaint?.feedbackId;
|
|
275
|
+
if (recipients.length === 0) {
|
|
276
|
+
log("No recipients for suppression ledger", {
|
|
277
|
+
messageId,
|
|
278
|
+
reason: ledger.reason,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
for (const email of recipients) {
|
|
282
|
+
try {
|
|
283
|
+
await dynamodb.send(
|
|
284
|
+
new UpdateItemCommand({
|
|
285
|
+
TableName: tableName,
|
|
286
|
+
Key: {
|
|
287
|
+
messageId: { S: `SUPPRESSION#${email}` },
|
|
288
|
+
sentAt: { N: "0" },
|
|
289
|
+
},
|
|
290
|
+
UpdateExpression:
|
|
291
|
+
"SET #et = :et, #em = :em, #r = :r, #src = :src, #sa = :sa, " +
|
|
292
|
+
"#fsa = if_not_exists(#fsa, :sa)" +
|
|
293
|
+
(ledger.detail ? ", #d = :d" : "") +
|
|
294
|
+
(feedbackId ? ", #fid = :fid" : ""),
|
|
295
|
+
ExpressionAttributeNames: {
|
|
296
|
+
"#et": "eventType",
|
|
297
|
+
"#em": "email",
|
|
298
|
+
"#r": "reason",
|
|
299
|
+
"#src": "source",
|
|
300
|
+
"#sa": "suppressedAt",
|
|
301
|
+
"#fsa": "firstSuppressedAt",
|
|
302
|
+
...(ledger.detail ? { "#d": "detail" } : {}),
|
|
303
|
+
...(feedbackId ? { "#fid": "feedbackId" } : {}),
|
|
304
|
+
},
|
|
305
|
+
ExpressionAttributeValues: {
|
|
306
|
+
":et": { S: "Suppressed" },
|
|
307
|
+
":em": { S: email },
|
|
308
|
+
":r": { S: ledger.reason },
|
|
309
|
+
":src": { S: "ses_event" },
|
|
310
|
+
":sa": { N: eventTimestamp.toString() },
|
|
311
|
+
...(ledger.detail ? { ":d": { S: ledger.detail } } : {}),
|
|
312
|
+
...(feedbackId ? { ":fid": { S: feedbackId } } : {}),
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
log("Recorded suppression ledger entry", {
|
|
317
|
+
messageId,
|
|
318
|
+
reason: ledger.reason,
|
|
319
|
+
});
|
|
320
|
+
} catch (error) {
|
|
321
|
+
// Ledger write must never abort the SQS batch or skip other recipients.
|
|
322
|
+
logError("Error recording suppression ledger entry", error, {
|
|
323
|
+
messageId,
|
|
324
|
+
reason: ledger.reason,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
213
329
|
} catch (error) {
|
|
214
330
|
logError("Error processing record", error, {
|
|
215
331
|
sqsMessageId: record.messageId,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Built at: 2026-09-
|
|
1
|
+
Built at: 2026-09-30T17:20:07.414Z
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Built at: 2026-09-
|
|
1
|
+
Built at: 2026-09-30T17:20:07.323Z
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wraps.dev/cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.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",
|
|
@@ -105,8 +105,8 @@
|
|
|
105
105
|
"typescript": "^5.9.3",
|
|
106
106
|
"vitest": "4.1.9",
|
|
107
107
|
"@wraps.dev/email-check": "1.2.0",
|
|
108
|
-
"@wraps.dev/pulumi": "0.5.0",
|
|
109
108
|
"@wraps/core": "0.1.2",
|
|
109
|
+
"@wraps.dev/pulumi": "0.6.0",
|
|
110
110
|
"@wraps/db": "0.0.0",
|
|
111
111
|
"@wraps/template-render": "0.1.0"
|
|
112
112
|
},
|