@step-func-emailer/handlers 0.2.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.
Files changed (136) hide show
  1. package/dist/handlers/__tests__/bounce-handler.test.d.ts +2 -0
  2. package/dist/handlers/__tests__/bounce-handler.test.d.ts.map +1 -0
  3. package/dist/handlers/__tests__/bounce-handler.test.js +126 -0
  4. package/dist/handlers/__tests__/bounce-handler.test.js.map +1 -0
  5. package/dist/handlers/__tests__/check-condition.test.d.ts +2 -0
  6. package/dist/handlers/__tests__/check-condition.test.d.ts.map +1 -0
  7. package/dist/handlers/__tests__/check-condition.test.js +140 -0
  8. package/dist/handlers/__tests__/check-condition.test.js.map +1 -0
  9. package/dist/handlers/__tests__/engagement-handler.test.d.ts +2 -0
  10. package/dist/handlers/__tests__/engagement-handler.test.d.ts.map +1 -0
  11. package/dist/handlers/__tests__/engagement-handler.test.js +139 -0
  12. package/dist/handlers/__tests__/engagement-handler.test.js.map +1 -0
  13. package/dist/handlers/__tests__/send-email.test.d.ts +2 -0
  14. package/dist/handlers/__tests__/send-email.test.d.ts.map +1 -0
  15. package/dist/handlers/__tests__/send-email.test.js +205 -0
  16. package/dist/handlers/__tests__/send-email.test.js.map +1 -0
  17. package/dist/handlers/__tests__/unsubscribe.test.d.ts +2 -0
  18. package/dist/handlers/__tests__/unsubscribe.test.d.ts.map +1 -0
  19. package/dist/handlers/__tests__/unsubscribe.test.js +79 -0
  20. package/dist/handlers/__tests__/unsubscribe.test.js.map +1 -0
  21. package/dist/handlers/bounce-handler.d.ts +3 -0
  22. package/dist/handlers/bounce-handler.d.ts.map +1 -0
  23. package/dist/handlers/bounce-handler.js +55 -0
  24. package/dist/handlers/bounce-handler.js.map +1 -0
  25. package/dist/handlers/check-condition.d.ts +3 -0
  26. package/dist/handlers/check-condition.d.ts.map +1 -0
  27. package/dist/handlers/check-condition.js +74 -0
  28. package/dist/handlers/check-condition.js.map +1 -0
  29. package/dist/handlers/engagement-handler.d.ts +3 -0
  30. package/dist/handlers/engagement-handler.d.ts.map +1 -0
  31. package/dist/handlers/engagement-handler.js +98 -0
  32. package/dist/handlers/engagement-handler.js.map +1 -0
  33. package/dist/handlers/send-email.d.ts +5 -0
  34. package/dist/handlers/send-email.d.ts.map +1 -0
  35. package/dist/handlers/send-email.js +137 -0
  36. package/dist/handlers/send-email.js.map +1 -0
  37. package/dist/handlers/unsubscribe.d.ts +5 -0
  38. package/dist/handlers/unsubscribe.d.ts.map +1 -0
  39. package/dist/handlers/unsubscribe.js +53 -0
  40. package/dist/handlers/unsubscribe.js.map +1 -0
  41. package/dist/index.d.ts +8 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +50 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lib/__tests__/display-names.test.d.ts +2 -0
  46. package/dist/lib/__tests__/display-names.test.d.ts.map +1 -0
  47. package/dist/lib/__tests__/display-names.test.js +49 -0
  48. package/dist/lib/__tests__/display-names.test.js.map +1 -0
  49. package/dist/lib/__tests__/dynamo-client.test.d.ts +2 -0
  50. package/dist/lib/__tests__/dynamo-client.test.d.ts.map +1 -0
  51. package/dist/lib/__tests__/dynamo-client.test.js +229 -0
  52. package/dist/lib/__tests__/dynamo-client.test.js.map +1 -0
  53. package/dist/lib/__tests__/execution-stopper.test.d.ts +2 -0
  54. package/dist/lib/__tests__/execution-stopper.test.d.ts.map +1 -0
  55. package/dist/lib/__tests__/execution-stopper.test.js +56 -0
  56. package/dist/lib/__tests__/execution-stopper.test.js.map +1 -0
  57. package/dist/lib/__tests__/ses-sender.test.d.ts +2 -0
  58. package/dist/lib/__tests__/ses-sender.test.d.ts.map +1 -0
  59. package/dist/lib/__tests__/ses-sender.test.js +66 -0
  60. package/dist/lib/__tests__/ses-sender.test.js.map +1 -0
  61. package/dist/lib/__tests__/ssm-config.test.d.ts +2 -0
  62. package/dist/lib/__tests__/ssm-config.test.d.ts.map +1 -0
  63. package/dist/lib/__tests__/ssm-config.test.js +73 -0
  64. package/dist/lib/__tests__/ssm-config.test.js.map +1 -0
  65. package/dist/lib/__tests__/template-renderer.test.d.ts +2 -0
  66. package/dist/lib/__tests__/template-renderer.test.d.ts.map +1 -0
  67. package/dist/lib/__tests__/template-renderer.test.js +79 -0
  68. package/dist/lib/__tests__/template-renderer.test.js.map +1 -0
  69. package/dist/lib/__tests__/unsubscribe-token.test.d.ts +2 -0
  70. package/dist/lib/__tests__/unsubscribe-token.test.d.ts.map +1 -0
  71. package/dist/lib/__tests__/unsubscribe-token.test.js +74 -0
  72. package/dist/lib/__tests__/unsubscribe-token.test.js.map +1 -0
  73. package/dist/lib/display-names.d.ts +5 -0
  74. package/dist/lib/display-names.d.ts.map +1 -0
  75. package/dist/lib/display-names.js +46 -0
  76. package/dist/lib/display-names.js.map +1 -0
  77. package/dist/lib/dynamo-client.d.ts +13 -0
  78. package/dist/lib/dynamo-client.d.ts.map +1 -0
  79. package/dist/lib/dynamo-client.js +211 -0
  80. package/dist/lib/dynamo-client.js.map +1 -0
  81. package/dist/lib/execution-stopper.d.ts +2 -0
  82. package/dist/lib/execution-stopper.d.ts.map +1 -0
  83. package/dist/lib/execution-stopper.js +38 -0
  84. package/dist/lib/execution-stopper.js.map +1 -0
  85. package/dist/lib/logger.d.ts +8 -0
  86. package/dist/lib/logger.d.ts.map +1 -0
  87. package/dist/lib/logger.js +13 -0
  88. package/dist/lib/logger.js.map +1 -0
  89. package/dist/lib/ses-sender.d.ts +13 -0
  90. package/dist/lib/ses-sender.d.ts.map +1 -0
  91. package/dist/lib/ses-sender.js +86 -0
  92. package/dist/lib/ses-sender.js.map +1 -0
  93. package/dist/lib/ses-suppression.d.ts +2 -0
  94. package/dist/lib/ses-suppression.d.ts.map +1 -0
  95. package/dist/lib/ses-suppression.js +24 -0
  96. package/dist/lib/ses-suppression.js.map +1 -0
  97. package/dist/lib/ssm-config.d.ts +14 -0
  98. package/dist/lib/ssm-config.d.ts.map +1 -0
  99. package/dist/lib/ssm-config.js +54 -0
  100. package/dist/lib/ssm-config.js.map +1 -0
  101. package/dist/lib/template-renderer.d.ts +9 -0
  102. package/dist/lib/template-renderer.d.ts.map +1 -0
  103. package/dist/lib/template-renderer.js +36 -0
  104. package/dist/lib/template-renderer.js.map +1 -0
  105. package/dist/lib/unsubscribe-token.d.ts +13 -0
  106. package/dist/lib/unsubscribe-token.d.ts.map +1 -0
  107. package/dist/lib/unsubscribe-token.js +37 -0
  108. package/dist/lib/unsubscribe-token.js.map +1 -0
  109. package/package.json +41 -0
  110. package/src/handlers/__tests__/bounce-handler.test.ts +173 -0
  111. package/src/handlers/__tests__/check-condition.test.ts +172 -0
  112. package/src/handlers/__tests__/engagement-handler.test.ts +161 -0
  113. package/src/handlers/__tests__/send-email.test.ts +279 -0
  114. package/src/handlers/__tests__/unsubscribe.test.ts +111 -0
  115. package/src/handlers/bounce-handler.ts +91 -0
  116. package/src/handlers/check-condition.ts +77 -0
  117. package/src/handlers/engagement-handler.ts +169 -0
  118. package/src/handlers/send-email.ts +184 -0
  119. package/src/handlers/unsubscribe.ts +70 -0
  120. package/src/index.ts +10 -0
  121. package/src/lib/__tests__/display-names.test.ts +52 -0
  122. package/src/lib/__tests__/dynamo-client.test.ts +346 -0
  123. package/src/lib/__tests__/execution-stopper.test.ts +68 -0
  124. package/src/lib/__tests__/ses-sender.test.ts +85 -0
  125. package/src/lib/__tests__/ssm-config.test.ts +85 -0
  126. package/src/lib/__tests__/template-renderer.test.ts +96 -0
  127. package/src/lib/__tests__/unsubscribe-token.test.ts +79 -0
  128. package/src/lib/display-names.ts +54 -0
  129. package/src/lib/dynamo-client.ts +301 -0
  130. package/src/lib/execution-stopper.ts +40 -0
  131. package/src/lib/logger.ts +10 -0
  132. package/src/lib/ses-sender.ts +102 -0
  133. package/src/lib/ses-suppression.ts +30 -0
  134. package/src/lib/ssm-config.ts +81 -0
  135. package/src/lib/template-renderer.ts +52 -0
  136. package/src/lib/unsubscribe-token.ts +53 -0
@@ -0,0 +1,169 @@
1
+ import type { SNSEvent } from "aws-lambda";
2
+ import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
3
+ import { marshall } from "@aws-sdk/util-dynamodb";
4
+ import { subscriberPK, eventSK, EVENT_TTL_DAYS } from "@step-func-emailer/shared";
5
+ import type { EmailEventType } from "@step-func-emailer/shared";
6
+ import { resolveConfig } from "../lib/ssm-config.js";
7
+ import { createLogger } from "../lib/logger.js";
8
+
9
+ const logger = createLogger("engagement-handler");
10
+ const dynamo = new DynamoDBClient({});
11
+
12
+ interface SESMailHeader {
13
+ name: string;
14
+ value: string;
15
+ }
16
+
17
+ interface SESMail {
18
+ messageId: string;
19
+ destination: string[];
20
+ headers: SESMailHeader[];
21
+ }
22
+
23
+ interface SESDeliveryNotification {
24
+ eventType: "Delivery";
25
+ mail: SESMail;
26
+ delivery: { timestamp: string };
27
+ }
28
+
29
+ interface SESOpenNotification {
30
+ eventType: "Open";
31
+ mail: SESMail;
32
+ open: { timestamp: string; userAgent?: string };
33
+ }
34
+
35
+ interface SESClickNotification {
36
+ eventType: "Click";
37
+ mail: SESMail;
38
+ click: { timestamp: string; link: string; userAgent?: string };
39
+ }
40
+
41
+ interface SESBounceNotification {
42
+ eventType: "Bounce";
43
+ mail: SESMail;
44
+ bounce: {
45
+ bounceType: string;
46
+ bouncedRecipients: Array<{ emailAddress: string }>;
47
+ timestamp: string;
48
+ };
49
+ }
50
+
51
+ interface SESComplaintNotification {
52
+ eventType: "Complaint";
53
+ mail: SESMail;
54
+ complaint: {
55
+ complainedRecipients: Array<{ emailAddress: string }>;
56
+ timestamp: string;
57
+ };
58
+ }
59
+
60
+ type SESEventNotification =
61
+ | SESDeliveryNotification
62
+ | SESOpenNotification
63
+ | SESClickNotification
64
+ | SESBounceNotification
65
+ | SESComplaintNotification;
66
+
67
+ function getHeader(headers: SESMailHeader[], name: string): string {
68
+ return headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? "";
69
+ }
70
+
71
+ function extractRecipients(notification: SESEventNotification): string[] {
72
+ if (notification.eventType === "Bounce") {
73
+ return notification.bounce.bouncedRecipients.map((r) => r.emailAddress);
74
+ }
75
+ if (notification.eventType === "Complaint") {
76
+ return notification.complaint.complainedRecipients.map((r) => r.emailAddress);
77
+ }
78
+ return notification.mail.destination;
79
+ }
80
+
81
+ function extractTimestamp(notification: SESEventNotification): string {
82
+ switch (notification.eventType) {
83
+ case "Delivery":
84
+ return notification.delivery.timestamp;
85
+ case "Open":
86
+ return notification.open.timestamp;
87
+ case "Click":
88
+ return notification.click.timestamp;
89
+ case "Bounce":
90
+ return notification.bounce.timestamp;
91
+ case "Complaint":
92
+ return notification.complaint.timestamp;
93
+ }
94
+ }
95
+
96
+ const EVENT_TYPE_MAP: Record<string, EmailEventType> = {
97
+ Delivery: "delivery",
98
+ Open: "open",
99
+ Click: "click",
100
+ Bounce: "bounce",
101
+ Complaint: "complaint",
102
+ };
103
+
104
+ export const handler = async (event: SNSEvent): Promise<void> => {
105
+ logger.info("EngagementHandler invoked", { recordCount: event.Records.length });
106
+ const config = await resolveConfig();
107
+
108
+ for (const record of event.Records) {
109
+ const notification = JSON.parse(record.Sns.Message) as SESEventNotification;
110
+ const eventType = EVENT_TYPE_MAP[notification.eventType];
111
+ if (!eventType) {
112
+ logger.debug("Skipping unknown event type", { eventType: notification.eventType });
113
+ continue;
114
+ }
115
+
116
+ const timestamp = extractTimestamp(notification);
117
+ const recipients = extractRecipients(notification);
118
+ const subject = getHeader(notification.mail.headers, "Subject");
119
+ const templateKey = getHeader(notification.mail.headers, "X-Template-Key");
120
+ const sequenceId = getHeader(notification.mail.headers, "X-Sequence-Id") || "fire_and_forget";
121
+
122
+ const linkUrl = notification.eventType === "Click" ? notification.click.link : undefined;
123
+ const userAgent =
124
+ notification.eventType === "Open"
125
+ ? notification.open.userAgent
126
+ : notification.eventType === "Click"
127
+ ? notification.click.userAgent
128
+ : undefined;
129
+
130
+ logger.info("Processing engagement event", {
131
+ eventType,
132
+ templateKey,
133
+ sequenceId,
134
+ sesMessageId: notification.mail.messageId,
135
+ recipientCount: recipients.length,
136
+ ...(linkUrl ? { linkUrl } : {}),
137
+ });
138
+
139
+ const now = new Date();
140
+ const ttl = Math.floor(now.getTime() / 1000) + EVENT_TTL_DAYS * 86400;
141
+
142
+ for (const email of recipients) {
143
+ logger.debug("Writing engagement event", { email, eventType, templateKey });
144
+ await dynamo.send(
145
+ new PutItemCommand({
146
+ TableName: config.eventsTableName,
147
+ Item: marshall(
148
+ {
149
+ PK: subscriberPK(email),
150
+ SK: eventSK(timestamp, eventType),
151
+ eventType,
152
+ templateKey,
153
+ sequenceId,
154
+ subject,
155
+ ...(linkUrl ? { linkUrl } : {}),
156
+ ...(userAgent ? { userAgent } : {}),
157
+ sesMessageId: notification.mail.messageId,
158
+ timestamp,
159
+ ttl,
160
+ },
161
+ { removeUndefinedValues: true },
162
+ ),
163
+ }),
164
+ );
165
+ }
166
+ }
167
+
168
+ logger.info("EngagementHandler complete");
169
+ };
@@ -0,0 +1,184 @@
1
+ import { SFNClient, StopExecutionCommand } from "@aws-sdk/client-sfn";
2
+ import type { SendEmailInput, RegisterOutput, SendOutput } from "@step-func-emailer/shared";
3
+ import { resolveConfig } from "../lib/ssm-config.js";
4
+ import {
5
+ getSubscriberProfile,
6
+ upsertSubscriberProfile,
7
+ extractAttributes,
8
+ getExecution,
9
+ putExecution,
10
+ deleteExecution,
11
+ writeSendLog,
12
+ } from "../lib/dynamo-client.js";
13
+ import { renderTemplate } from "../lib/template-renderer.js";
14
+ import { sendEmail } from "../lib/ses-sender.js";
15
+ import { generateToken } from "../lib/unsubscribe-token.js";
16
+ import { loadDisplayNames, resolveDisplayNames } from "../lib/display-names.js";
17
+ import { createLogger } from "../lib/logger.js";
18
+
19
+ const logger = createLogger("send-email");
20
+ const sfn = new SFNClient({});
21
+
22
+ export const handler = async (
23
+ event: SendEmailInput,
24
+ ): Promise<RegisterOutput | SendOutput | { completed: true }> => {
25
+ logger.info("SendEmail invoked", { action: event.action, email: event.subscriber.email });
26
+ const config = await resolveConfig();
27
+
28
+ switch (event.action) {
29
+ case "register":
30
+ return handleRegister(event, config);
31
+ case "send":
32
+ return handleSend(event, config, event.sequenceId ?? "unknown");
33
+ case "fire_and_forget":
34
+ logger.info("Fire and forget", {
35
+ email: event.subscriber.email,
36
+ templateKey: event.templateKey,
37
+ });
38
+ await upsertSubscriberProfile(config.tableName, event.subscriber);
39
+ return handleSend(
40
+ {
41
+ action: "send",
42
+ templateKey: event.templateKey,
43
+ subject: event.subject,
44
+ subscriber: event.subscriber,
45
+ },
46
+ config,
47
+ "fire_and_forget",
48
+ );
49
+ case "complete":
50
+ logger.info("Completing sequence", {
51
+ email: event.subscriber.email,
52
+ sequenceId: event.sequenceId,
53
+ });
54
+ await deleteExecution(config.tableName, event.subscriber.email, event.sequenceId);
55
+ return { completed: true };
56
+ }
57
+ };
58
+
59
+ async function handleRegister(
60
+ event: Extract<SendEmailInput, { action: "register" }>,
61
+ config: Awaited<ReturnType<typeof resolveConfig>>,
62
+ ): Promise<RegisterOutput> {
63
+ logger.info("Registering subscriber for sequence", {
64
+ email: event.subscriber.email,
65
+ sequenceId: event.sequenceId,
66
+ executionArn: event.executionArn,
67
+ });
68
+
69
+ await upsertSubscriberProfile(config.tableName, event.subscriber);
70
+
71
+ // Check for existing execution and stop it
72
+ const existing = await getExecution(config.tableName, event.subscriber.email, event.sequenceId);
73
+ if (existing) {
74
+ logger.warn("Stopping existing execution before registering new one", {
75
+ email: event.subscriber.email,
76
+ sequenceId: event.sequenceId,
77
+ oldArn: existing.executionArn,
78
+ newArn: event.executionArn,
79
+ });
80
+ try {
81
+ await sfn.send(
82
+ new StopExecutionCommand({
83
+ executionArn: existing.executionArn,
84
+ cause: "Replaced by new execution",
85
+ }),
86
+ );
87
+ } catch {
88
+ // Execution may already be stopped
89
+ }
90
+ await deleteExecution(config.tableName, event.subscriber.email, event.sequenceId);
91
+ }
92
+
93
+ await putExecution(
94
+ config.tableName,
95
+ event.subscriber.email,
96
+ event.sequenceId,
97
+ event.executionArn,
98
+ );
99
+
100
+ logger.info("Registration complete", {
101
+ email: event.subscriber.email,
102
+ sequenceId: event.sequenceId,
103
+ });
104
+ return { registered: true };
105
+ }
106
+
107
+ async function handleSend(
108
+ event: Extract<SendEmailInput, { action: "send" }>,
109
+ config: Awaited<ReturnType<typeof resolveConfig>>,
110
+ sequenceId: string = "unknown",
111
+ ): Promise<SendOutput> {
112
+ logger.info("Processing send", {
113
+ email: event.subscriber.email,
114
+ templateKey: event.templateKey,
115
+ subject: event.subject,
116
+ sequenceId,
117
+ });
118
+
119
+ const profile = await getSubscriberProfile(config.tableName, event.subscriber.email);
120
+
121
+ // Pre-send checks
122
+ if (profile?.unsubscribed) {
123
+ logger.info("Skipping send — subscriber unsubscribed", { email: event.subscriber.email });
124
+ return { sent: false, reason: "unsubscribed" };
125
+ }
126
+ if (profile?.suppressed) {
127
+ logger.info("Skipping send — subscriber suppressed", { email: event.subscriber.email });
128
+ return { sent: false, reason: "suppressed" };
129
+ }
130
+
131
+ // Load display names and build context
132
+ const displayNameMap = await loadDisplayNames(config.templateBucket);
133
+ const attributes =
134
+ (profile ? extractAttributes(profile) : null) ?? event.subscriber.attributes ?? {};
135
+ const displayNames = resolveDisplayNames(displayNameMap, attributes as Record<string, unknown>);
136
+
137
+ const unsubscribeUrl = `${config.unsubscribeBaseUrl}?token=${generateToken(event.subscriber.email, config.unsubscribeSecret)}`;
138
+
139
+ const context = {
140
+ email: event.subscriber.email,
141
+ firstName: event.subscriber.firstName,
142
+ ...attributes,
143
+ ...displayNames,
144
+ unsubscribeUrl,
145
+ currentYear: new Date().getFullYear(),
146
+ };
147
+
148
+ const htmlBody = await renderTemplate(
149
+ config.templateBucket,
150
+ event.templateKey,
151
+ context as Parameters<typeof renderTemplate>[2],
152
+ );
153
+
154
+ const fromAddress = config.defaultFromName
155
+ ? `${config.defaultFromName} <${config.defaultFromEmail}>`
156
+ : config.defaultFromEmail;
157
+
158
+ const messageId = await sendEmail({
159
+ from: fromAddress,
160
+ to: event.subscriber.email,
161
+ subject: event.subject,
162
+ htmlBody,
163
+ configurationSetName: config.sesConfigSet,
164
+ unsubscribeUrl,
165
+ replyToAddress: config.replyToEmail || undefined,
166
+ templateKey: event.templateKey,
167
+ sequenceId,
168
+ });
169
+
170
+ await writeSendLog(config.tableName, event.subscriber.email, {
171
+ templateKey: event.templateKey,
172
+ sequenceId,
173
+ subject: event.subject,
174
+ sesMessageId: messageId,
175
+ });
176
+
177
+ logger.info("Send complete", {
178
+ email: event.subscriber.email,
179
+ templateKey: event.templateKey,
180
+ messageId,
181
+ sequenceId,
182
+ });
183
+ return { sent: true, messageId };
184
+ }
@@ -0,0 +1,70 @@
1
+ import type { APIGatewayProxyResultV2 } from "aws-lambda";
2
+ import { resolveConfig } from "../lib/ssm-config.js";
3
+ import { validateToken } from "../lib/unsubscribe-token.js";
4
+ import { setProfileFlag } from "../lib/dynamo-client.js";
5
+ import { stopAllExecutions } from "../lib/execution-stopper.js";
6
+ import { addToSuppressionList } from "../lib/ses-suppression.js";
7
+ import { createLogger } from "../lib/logger.js";
8
+
9
+ const logger = createLogger("unsubscribe");
10
+
11
+ const HTML_HEADERS = {
12
+ "Content-Type": "text/html; charset=utf-8",
13
+ };
14
+
15
+ function htmlPage(title: string, body: string): string {
16
+ return `<!DOCTYPE html>
17
+ <html lang="en">
18
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
19
+ <style>body{font-family:-apple-system,system-ui,sans-serif;max-width:480px;margin:60px auto;padding:0 20px;text-align:center;color:#333}h1{font-size:24px}p{font-size:16px;line-height:1.5;color:#666}</style>
20
+ </head><body>${body}</body></html>`;
21
+ }
22
+
23
+ export const handler = async (event: {
24
+ queryStringParameters?: Record<string, string | undefined>;
25
+ }): Promise<APIGatewayProxyResultV2> => {
26
+ const token = event.queryStringParameters?.token;
27
+ if (!token) {
28
+ logger.warn("Unsubscribe request with no token");
29
+ return {
30
+ statusCode: 400,
31
+ headers: HTML_HEADERS,
32
+ body: htmlPage(
33
+ "Invalid Request",
34
+ "<h1>Invalid Request</h1><p>No unsubscribe token provided.</p>",
35
+ ),
36
+ };
37
+ }
38
+
39
+ const config = await resolveConfig();
40
+ const result = validateToken(token, config.unsubscribeSecret);
41
+
42
+ if (!result.valid) {
43
+ logger.warn("Invalid unsubscribe token", { reason: result.reason });
44
+ return {
45
+ statusCode: 400,
46
+ headers: HTML_HEADERS,
47
+ body: htmlPage(
48
+ "Invalid Link",
49
+ `<h1>Invalid Link</h1><p>This unsubscribe link is ${result.reason === "token expired" ? "expired" : "invalid"}. Please contact support if you need help.</p>`,
50
+ ),
51
+ };
52
+ }
53
+
54
+ logger.info("Processing unsubscribe", { email: result.email });
55
+
56
+ await setProfileFlag(config.tableName, result.email, "unsubscribed");
57
+ await stopAllExecutions(config.tableName, result.email);
58
+ await addToSuppressionList(result.email, "COMPLAINT");
59
+
60
+ logger.info("Unsubscribe complete", { email: result.email });
61
+
62
+ return {
63
+ statusCode: 200,
64
+ headers: HTML_HEADERS,
65
+ body: htmlPage(
66
+ "Unsubscribed",
67
+ "<h1>You've been unsubscribed</h1><p>You won't receive any more emails from us. If this was a mistake, please contact support.</p>",
68
+ ),
69
+ };
70
+ };
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ import * as path from "node:path";
2
+
3
+ export { handler as sendEmailHandler } from "./handlers/send-email.js";
4
+ export { handler as checkConditionHandler } from "./handlers/check-condition.js";
5
+ export { handler as unsubscribeHandler } from "./handlers/unsubscribe.js";
6
+ export { handler as bounceHandlerHandler } from "./handlers/bounce-handler.js";
7
+ export { handler as engagementHandlerHandler } from "./handlers/engagement-handler.js";
8
+
9
+ /** Absolute path to the handlers TypeScript source directory. Used by CDK constructs. */
10
+ export const HANDLERS_SRC_DIR = path.join(__dirname, "../src");
@@ -0,0 +1,52 @@
1
+ import { resolveDisplayNames } from "../display-names.js";
2
+
3
+ describe("resolveDisplayNames", () => {
4
+ const displayNameMap = {
5
+ platformName: {
6
+ web: "Web App",
7
+ ios: "iPhone App",
8
+ android: "Android App",
9
+ },
10
+ roleName: {
11
+ admin: "Administrator",
12
+ user: "Standard User",
13
+ },
14
+ };
15
+
16
+ it("resolves known values to display names", () => {
17
+ const result = resolveDisplayNames(displayNameMap, {
18
+ platform: "web",
19
+ role: "admin",
20
+ });
21
+ expect(result).toEqual({
22
+ platformName: "Web App",
23
+ roleName: "Administrator",
24
+ });
25
+ });
26
+
27
+ it("falls back to raw value if no mapping exists", () => {
28
+ const result = resolveDisplayNames(displayNameMap, {
29
+ platform: "desktop",
30
+ });
31
+ expect(result).toEqual({ platformName: "desktop" });
32
+ });
33
+
34
+ it("skips non-string attribute values", () => {
35
+ const result = resolveDisplayNames(displayNameMap, {
36
+ platform: 123,
37
+ });
38
+ expect(result).toEqual({});
39
+ });
40
+
41
+ it("returns empty object when no attributes match", () => {
42
+ const result = resolveDisplayNames(displayNameMap, {
43
+ unrelatedField: "value",
44
+ });
45
+ expect(result).toEqual({});
46
+ });
47
+
48
+ it("handles empty inputs", () => {
49
+ expect(resolveDisplayNames({}, {})).toEqual({});
50
+ expect(resolveDisplayNames(displayNameMap, {})).toEqual({});
51
+ });
52
+ });