@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,279 @@
1
+ import { vi } from "vitest";
2
+
3
+ const mockResolveConfig = vi.fn();
4
+ const mockGetSubscriberProfile = vi.fn();
5
+ const mockUpsertSubscriberProfile = vi.fn();
6
+ const mockGetExecution = vi.fn();
7
+ const mockPutExecution = vi.fn();
8
+ const mockDeleteExecution = vi.fn();
9
+ const mockWriteSendLog = vi.fn();
10
+ const mockRenderTemplate = vi.fn();
11
+ const mockSendEmail = vi.fn();
12
+ const mockGenerateToken = vi.fn();
13
+ const mockLoadDisplayNames = vi.fn();
14
+ const mockResolveDisplayNames = vi.fn();
15
+
16
+ const mockSfnSend = vi.fn();
17
+ vi.mock("@aws-sdk/client-sfn", () => ({
18
+ SFNClient: class {
19
+ send = mockSfnSend;
20
+ },
21
+ StopExecutionCommand: class {
22
+ input: unknown;
23
+ constructor(input: unknown) {
24
+ this.input = input;
25
+ }
26
+ },
27
+ }));
28
+
29
+ vi.mock("../../lib/ssm-config.js", () => ({
30
+ resolveConfig: () => mockResolveConfig(),
31
+ }));
32
+
33
+ vi.mock("../../lib/dynamo-client.js", () => ({
34
+ getSubscriberProfile: (...args: unknown[]) => mockGetSubscriberProfile(...args),
35
+ upsertSubscriberProfile: (...args: unknown[]) => mockUpsertSubscriberProfile(...args),
36
+ extractAttributes: (profile: Record<string, unknown>) => {
37
+ const SYSTEM_KEYS = new Set([
38
+ "PK",
39
+ "SK",
40
+ "email",
41
+ "firstName",
42
+ "unsubscribed",
43
+ "suppressed",
44
+ "createdAt",
45
+ "updatedAt",
46
+ ]);
47
+ const attrs: Record<string, unknown> = {};
48
+ for (const [key, value] of Object.entries(profile)) {
49
+ if (!SYSTEM_KEYS.has(key)) attrs[key] = value;
50
+ }
51
+ return attrs;
52
+ },
53
+ getExecution: (...args: unknown[]) => mockGetExecution(...args),
54
+ putExecution: (...args: unknown[]) => mockPutExecution(...args),
55
+ deleteExecution: (...args: unknown[]) => mockDeleteExecution(...args),
56
+ writeSendLog: (...args: unknown[]) => mockWriteSendLog(...args),
57
+ }));
58
+
59
+ vi.mock("../../lib/template-renderer.js", () => ({
60
+ renderTemplate: (...args: unknown[]) => mockRenderTemplate(...args),
61
+ }));
62
+
63
+ vi.mock("../../lib/ses-sender.js", () => ({
64
+ sendEmail: (...args: unknown[]) => mockSendEmail(...args),
65
+ }));
66
+
67
+ vi.mock("../../lib/unsubscribe-token.js", () => ({
68
+ generateToken: (...args: unknown[]) => mockGenerateToken(...args),
69
+ }));
70
+
71
+ vi.mock("../../lib/display-names.js", () => ({
72
+ loadDisplayNames: (...args: unknown[]) => mockLoadDisplayNames(...args),
73
+ resolveDisplayNames: (...args: unknown[]) => mockResolveDisplayNames(...args),
74
+ }));
75
+
76
+ const { handler } = await import("../send-email.js");
77
+
78
+ const CONFIG = {
79
+ tableName: "TestTable",
80
+ eventsTableName: "EventsTable",
81
+ templateBucket: "my-bucket",
82
+ defaultFromEmail: "noreply@example.com",
83
+ defaultFromName: "Example",
84
+ sesConfigSet: "my-config-set",
85
+ unsubscribeBaseUrl: "https://unsub.example.com",
86
+ unsubscribeSecret: "test-secret",
87
+ };
88
+
89
+ beforeEach(() => {
90
+ mockResolveConfig.mockReset().mockResolvedValue(CONFIG);
91
+ mockGetSubscriberProfile.mockReset();
92
+ mockUpsertSubscriberProfile.mockReset().mockResolvedValue(undefined);
93
+ mockGetExecution.mockReset();
94
+ mockPutExecution.mockReset().mockResolvedValue(undefined);
95
+ mockDeleteExecution.mockReset().mockResolvedValue(undefined);
96
+ mockWriteSendLog.mockReset().mockResolvedValue(undefined);
97
+ mockRenderTemplate.mockReset().mockResolvedValue("<h1>Hello</h1>");
98
+ mockSendEmail.mockReset().mockResolvedValue("msg-123");
99
+ mockGenerateToken.mockReset().mockReturnValue("token-abc");
100
+ mockLoadDisplayNames.mockReset().mockResolvedValue({});
101
+ mockResolveDisplayNames.mockReset().mockReturnValue({});
102
+ mockSfnSend.mockReset();
103
+ });
104
+
105
+ describe("send-email handler", () => {
106
+ describe("register action", () => {
107
+ const registerEvent = {
108
+ action: "register" as const,
109
+ sequenceId: "onboarding",
110
+ subscriber: { email: "user@example.com", firstName: "Jane" },
111
+ executionArn: "arn:aws:states:us-east-1:123:execution:new",
112
+ };
113
+
114
+ it("upserts profile and stores execution", async () => {
115
+ mockGetExecution.mockResolvedValueOnce(null);
116
+
117
+ const result = await handler(registerEvent);
118
+
119
+ expect(result).toEqual({ registered: true });
120
+ expect(mockUpsertSubscriberProfile).toHaveBeenCalledWith(
121
+ "TestTable",
122
+ registerEvent.subscriber,
123
+ );
124
+ expect(mockPutExecution).toHaveBeenCalledWith(
125
+ "TestTable",
126
+ "user@example.com",
127
+ "onboarding",
128
+ registerEvent.executionArn,
129
+ );
130
+ });
131
+
132
+ it("stops existing execution before registering new one", async () => {
133
+ mockGetExecution.mockResolvedValueOnce({
134
+ executionArn: "arn:aws:states:us-east-1:123:execution:old",
135
+ sequenceId: "onboarding",
136
+ });
137
+ mockSfnSend.mockResolvedValueOnce({});
138
+
139
+ const result = await handler(registerEvent);
140
+
141
+ expect(result).toEqual({ registered: true });
142
+ expect(mockSfnSend).toHaveBeenCalledOnce();
143
+ expect(mockDeleteExecution).toHaveBeenCalledWith(
144
+ "TestTable",
145
+ "user@example.com",
146
+ "onboarding",
147
+ );
148
+ expect(mockPutExecution).toHaveBeenCalled();
149
+ });
150
+
151
+ it("handles already-stopped execution gracefully", async () => {
152
+ mockGetExecution.mockResolvedValueOnce({
153
+ executionArn: "arn:old",
154
+ sequenceId: "onboarding",
155
+ });
156
+ mockSfnSend.mockRejectedValueOnce(new Error("ExecutionDoesNotExist"));
157
+
158
+ const result = await handler(registerEvent);
159
+
160
+ expect(result).toEqual({ registered: true });
161
+ expect(mockDeleteExecution).toHaveBeenCalled();
162
+ });
163
+ });
164
+
165
+ describe("send action", () => {
166
+ const sendEvent = {
167
+ action: "send" as const,
168
+ templateKey: "onboarding/welcome",
169
+ subject: "Welcome!",
170
+ subscriber: { email: "user@example.com", firstName: "Jane" },
171
+ };
172
+
173
+ it("sends email successfully", async () => {
174
+ mockGetSubscriberProfile.mockResolvedValueOnce({
175
+ email: "user@example.com",
176
+ firstName: "Jane",
177
+ unsubscribed: false,
178
+ suppressed: false,
179
+ });
180
+
181
+ const result = await handler(sendEvent);
182
+
183
+ expect(result).toEqual({ sent: true, messageId: "msg-123" });
184
+ expect(mockRenderTemplate).toHaveBeenCalled();
185
+ expect(mockSendEmail).toHaveBeenCalled();
186
+ expect(mockWriteSendLog).toHaveBeenCalled();
187
+ });
188
+
189
+ it("skips send when subscriber is unsubscribed", async () => {
190
+ mockGetSubscriberProfile.mockResolvedValueOnce({
191
+ email: "user@example.com",
192
+ firstName: "Jane",
193
+ unsubscribed: true,
194
+ suppressed: false,
195
+ });
196
+
197
+ const result = await handler(sendEvent);
198
+
199
+ expect(result).toEqual({ sent: false, reason: "unsubscribed" });
200
+ expect(mockSendEmail).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it("skips send when subscriber is suppressed", async () => {
204
+ mockGetSubscriberProfile.mockResolvedValueOnce({
205
+ email: "user@example.com",
206
+ firstName: "Jane",
207
+ unsubscribed: false,
208
+ suppressed: true,
209
+ });
210
+
211
+ const result = await handler(sendEvent);
212
+
213
+ expect(result).toEqual({ sent: false, reason: "suppressed" });
214
+ expect(mockSendEmail).not.toHaveBeenCalled();
215
+ });
216
+
217
+ it("sends when profile does not exist yet", async () => {
218
+ mockGetSubscriberProfile.mockResolvedValueOnce(null);
219
+
220
+ const result = await handler(sendEvent);
221
+
222
+ expect(result).toEqual({ sent: true, messageId: "msg-123" });
223
+ });
224
+
225
+ it("formats from address with display name", async () => {
226
+ mockGetSubscriberProfile.mockResolvedValueOnce(null);
227
+
228
+ await handler(sendEvent);
229
+
230
+ const sendCall = mockSendEmail.mock.calls[0][0];
231
+ expect(sendCall.from).toBe("Example <noreply@example.com>");
232
+ });
233
+
234
+ it("generates unsubscribe URL with token", async () => {
235
+ mockGetSubscriberProfile.mockResolvedValueOnce(null);
236
+
237
+ await handler(sendEvent);
238
+
239
+ expect(mockGenerateToken).toHaveBeenCalledWith("user@example.com", "test-secret");
240
+ const sendCall = mockSendEmail.mock.calls[0][0];
241
+ expect(sendCall.unsubscribeUrl).toBe("https://unsub.example.com?token=token-abc");
242
+ });
243
+ });
244
+
245
+ describe("fire_and_forget action", () => {
246
+ it("upserts profile then sends email", async () => {
247
+ mockGetSubscriberProfile.mockResolvedValueOnce(null);
248
+
249
+ const result = await handler({
250
+ action: "fire_and_forget",
251
+ templateKey: "promo/sale",
252
+ subject: "Sale!",
253
+ subscriber: { email: "user@example.com", firstName: "Jane" },
254
+ });
255
+
256
+ expect(result).toEqual({ sent: true, messageId: "msg-123" });
257
+ expect(mockUpsertSubscriberProfile).toHaveBeenCalled();
258
+ expect(mockSendEmail).toHaveBeenCalled();
259
+ });
260
+ });
261
+
262
+ describe("complete action", () => {
263
+ it("deletes execution and returns completed", async () => {
264
+ const result = await handler({
265
+ action: "complete",
266
+ sequenceId: "onboarding",
267
+ subscriber: { email: "user@example.com" },
268
+ executionArn: "arn:aws:states:us-east-1:123:execution:abc",
269
+ });
270
+
271
+ expect(result).toEqual({ completed: true });
272
+ expect(mockDeleteExecution).toHaveBeenCalledWith(
273
+ "TestTable",
274
+ "user@example.com",
275
+ "onboarding",
276
+ );
277
+ });
278
+ });
279
+ });
@@ -0,0 +1,111 @@
1
+ import { vi } from "vitest";
2
+ import type { APIGatewayProxyStructuredResultV2 } from "aws-lambda";
3
+
4
+ const mockResolveConfig = vi.fn();
5
+ const mockValidateToken = vi.fn();
6
+ const mockSetProfileFlag = vi.fn();
7
+ const mockStopAllExecutions = vi.fn();
8
+ const mockAddToSuppressionList = vi.fn();
9
+
10
+ vi.mock("../../lib/ssm-config.js", () => ({
11
+ resolveConfig: () => mockResolveConfig(),
12
+ }));
13
+
14
+ vi.mock("../../lib/unsubscribe-token.js", () => ({
15
+ validateToken: (...args: unknown[]) => mockValidateToken(...args),
16
+ }));
17
+
18
+ vi.mock("../../lib/dynamo-client.js", () => ({
19
+ setProfileFlag: (...args: unknown[]) => mockSetProfileFlag(...args),
20
+ }));
21
+
22
+ vi.mock("../../lib/execution-stopper.js", () => ({
23
+ stopAllExecutions: (...args: unknown[]) => mockStopAllExecutions(...args),
24
+ }));
25
+
26
+ vi.mock("../../lib/ses-suppression.js", () => ({
27
+ addToSuppressionList: (...args: unknown[]) => mockAddToSuppressionList(...args),
28
+ }));
29
+
30
+ const { handler } = await import("../unsubscribe.js");
31
+
32
+ const CONFIG = {
33
+ tableName: "TestTable",
34
+ unsubscribeSecret: "test-secret",
35
+ };
36
+
37
+ beforeEach(() => {
38
+ mockResolveConfig.mockReset().mockResolvedValue(CONFIG);
39
+ mockValidateToken.mockReset();
40
+ mockSetProfileFlag.mockReset().mockResolvedValue(undefined);
41
+ mockStopAllExecutions.mockReset().mockResolvedValue(undefined);
42
+ mockAddToSuppressionList.mockReset().mockResolvedValue(undefined);
43
+ });
44
+
45
+ describe("unsubscribe handler", () => {
46
+ it("returns 400 when no token is provided", async () => {
47
+ const result = (await handler({
48
+ queryStringParameters: {},
49
+ })) as APIGatewayProxyStructuredResultV2;
50
+
51
+ expect(result.statusCode).toBe(400);
52
+ expect(result.body).toContain("No unsubscribe token provided");
53
+ });
54
+
55
+ it("returns 400 when queryStringParameters is undefined", async () => {
56
+ const result = (await handler({})) as APIGatewayProxyStructuredResultV2;
57
+
58
+ expect(result.statusCode).toBe(400);
59
+ });
60
+
61
+ it("returns 400 for invalid token", async () => {
62
+ mockValidateToken.mockReturnValueOnce({
63
+ valid: false,
64
+ reason: "invalid signature",
65
+ });
66
+
67
+ const result = (await handler({
68
+ queryStringParameters: { token: "bad-token" },
69
+ })) as APIGatewayProxyStructuredResultV2;
70
+
71
+ expect(result.statusCode).toBe(400);
72
+ expect(result.body).toContain("invalid");
73
+ });
74
+
75
+ it("returns 400 for expired token", async () => {
76
+ mockValidateToken.mockReturnValueOnce({
77
+ valid: false,
78
+ reason: "token expired",
79
+ });
80
+
81
+ const result = (await handler({
82
+ queryStringParameters: { token: "expired-token" },
83
+ })) as APIGatewayProxyStructuredResultV2;
84
+
85
+ expect(result.statusCode).toBe(400);
86
+ expect(result.body).toContain("expired");
87
+ });
88
+
89
+ it("unsubscribes and stops executions for valid token", async () => {
90
+ mockValidateToken.mockReturnValueOnce({
91
+ valid: true,
92
+ email: "user@example.com",
93
+ sendTimestamp: "2026-01-01T00:00:00.000Z",
94
+ expiryTimestamp: "2026-04-01T00:00:00.000Z",
95
+ });
96
+
97
+ const result = (await handler({
98
+ queryStringParameters: { token: "valid-token" },
99
+ })) as APIGatewayProxyStructuredResultV2;
100
+
101
+ expect(result.statusCode).toBe(200);
102
+ expect(result.body).toContain("unsubscribed");
103
+ expect(mockSetProfileFlag).toHaveBeenCalledWith(
104
+ "TestTable",
105
+ "user@example.com",
106
+ "unsubscribed",
107
+ );
108
+ expect(mockStopAllExecutions).toHaveBeenCalledWith("TestTable", "user@example.com");
109
+ expect(mockAddToSuppressionList).toHaveBeenCalledWith("user@example.com", "COMPLAINT");
110
+ });
111
+ });
@@ -0,0 +1,91 @@
1
+ import type { SNSEvent } from "aws-lambda";
2
+ import { resolveConfig } from "../lib/ssm-config.js";
3
+ import { writeSuppression, setProfileFlag } from "../lib/dynamo-client.js";
4
+ import { stopAllExecutions } from "../lib/execution-stopper.js";
5
+ import { addToSuppressionList } from "../lib/ses-suppression.js";
6
+ import { createLogger } from "../lib/logger.js";
7
+
8
+ const logger = createLogger("bounce-handler");
9
+
10
+ interface SESBounceNotification {
11
+ notificationType: "Bounce";
12
+ bounce: {
13
+ bounceType: "Permanent" | "Transient";
14
+ bouncedRecipients: Array<{ emailAddress: string }>;
15
+ feedbackId: string;
16
+ };
17
+ }
18
+
19
+ interface SESComplaintNotification {
20
+ notificationType: "Complaint";
21
+ complaint: {
22
+ complainedRecipients: Array<{ emailAddress: string }>;
23
+ feedbackId: string;
24
+ };
25
+ }
26
+
27
+ type SESNotification = SESBounceNotification | SESComplaintNotification;
28
+
29
+ export const handler = async (event: SNSEvent): Promise<void> => {
30
+ logger.info("BounceHandler invoked", { recordCount: event.Records.length });
31
+ const config = await resolveConfig();
32
+
33
+ for (const record of event.Records) {
34
+ const notification = JSON.parse(record.Sns.Message) as SESNotification;
35
+
36
+ if (notification.notificationType === "Bounce") {
37
+ if (notification.bounce.bounceType === "Transient") {
38
+ logger.debug("Ignoring transient bounce", {
39
+ feedbackId: notification.bounce.feedbackId,
40
+ recipients: notification.bounce.bouncedRecipients.map((r) => r.emailAddress),
41
+ });
42
+ continue;
43
+ }
44
+
45
+ logger.warn("Processing permanent bounce", {
46
+ bounceType: notification.bounce.bounceType,
47
+ feedbackId: notification.bounce.feedbackId,
48
+ recipientCount: notification.bounce.bouncedRecipients.length,
49
+ });
50
+
51
+ for (const recipient of notification.bounce.bouncedRecipients) {
52
+ const email = recipient.emailAddress;
53
+ logger.warn("Suppressing bounced subscriber", { email });
54
+ await writeSuppression(
55
+ config.tableName,
56
+ email,
57
+ "bounce",
58
+ notification.bounce.bounceType,
59
+ notification.bounce.feedbackId,
60
+ );
61
+ await setProfileFlag(config.tableName, email, "suppressed");
62
+ await stopAllExecutions(config.tableName, email);
63
+ await addToSuppressionList(email, "BOUNCE");
64
+ }
65
+ }
66
+
67
+ if (notification.notificationType === "Complaint") {
68
+ logger.warn("Processing complaint", {
69
+ feedbackId: notification.complaint.feedbackId,
70
+ recipientCount: notification.complaint.complainedRecipients.length,
71
+ });
72
+
73
+ for (const recipient of notification.complaint.complainedRecipients) {
74
+ const email = recipient.emailAddress;
75
+ logger.warn("Suppressing complained subscriber", { email });
76
+ await writeSuppression(
77
+ config.tableName,
78
+ email,
79
+ "complaint",
80
+ undefined,
81
+ notification.complaint.feedbackId,
82
+ );
83
+ await setProfileFlag(config.tableName, email, "suppressed");
84
+ await stopAllExecutions(config.tableName, email);
85
+ await addToSuppressionList(email, "COMPLAINT");
86
+ }
87
+ }
88
+ }
89
+
90
+ logger.info("BounceHandler complete");
91
+ };
@@ -0,0 +1,77 @@
1
+ import type { CheckConditionInput, CheckConditionOutput } from "@step-func-emailer/shared";
2
+ import { resolveConfig } from "../lib/ssm-config.js";
3
+ import { getSubscriberProfile, hasBeenSent } from "../lib/dynamo-client.js";
4
+ import { createLogger } from "../lib/logger.js";
5
+
6
+ const logger = createLogger("check-condition");
7
+
8
+ export const handler = async (event: CheckConditionInput): Promise<CheckConditionOutput> => {
9
+ logger.info("CheckCondition invoked", {
10
+ check: event.check,
11
+ email: event.subscriber.email,
12
+ field: event.field,
13
+ value: event.value,
14
+ templateKey: event.templateKey,
15
+ });
16
+
17
+ const config = await resolveConfig();
18
+
19
+ switch (event.check) {
20
+ case "subscriber_field_exists": {
21
+ const profile = await getSubscriberProfile(config.tableName, event.subscriber.email);
22
+ if (!profile || !event.field) {
23
+ logger.debug("Field exists check — no profile or no field", {
24
+ profileFound: !!profile,
25
+ field: event.field,
26
+ });
27
+ return { result: false };
28
+ }
29
+ const value = profile[event.field];
30
+ const result = value !== undefined && value !== null && value !== "";
31
+ logger.info("Field exists check result", {
32
+ email: event.subscriber.email,
33
+ field: event.field,
34
+ result,
35
+ });
36
+ return { result };
37
+ }
38
+
39
+ case "subscriber_field_equals": {
40
+ const profile = await getSubscriberProfile(config.tableName, event.subscriber.email);
41
+ if (!profile || !event.field) {
42
+ logger.debug("Field equals check — no profile or no field", {
43
+ profileFound: !!profile,
44
+ field: event.field,
45
+ });
46
+ return { result: false };
47
+ }
48
+ const result = profile[event.field] === event.value;
49
+ logger.info("Field equals check result", {
50
+ email: event.subscriber.email,
51
+ field: event.field,
52
+ expectedValue: event.value,
53
+ actualValue: profile[event.field],
54
+ result,
55
+ });
56
+ return { result };
57
+ }
58
+
59
+ case "has_been_sent": {
60
+ if (!event.templateKey) {
61
+ logger.debug("Has been sent check — no templateKey provided");
62
+ return { result: false };
63
+ }
64
+ const sent = await hasBeenSent(config.tableName, event.subscriber.email, event.templateKey);
65
+ logger.info("Has been sent check result", {
66
+ email: event.subscriber.email,
67
+ templateKey: event.templateKey,
68
+ result: sent,
69
+ });
70
+ return { result: sent };
71
+ }
72
+
73
+ default:
74
+ logger.warn("Unknown check type", { check: event.check });
75
+ return { result: false };
76
+ }
77
+ };