@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,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.generateToken = generateToken;
4
+ exports.validateToken = validateToken;
5
+ const node_crypto_1 = require("node:crypto");
6
+ const EXPIRY_DAYS = 90;
7
+ function generateToken(email, secret) {
8
+ const sendTimestamp = new Date().toISOString();
9
+ const expiry = new Date(Date.now() + EXPIRY_DAYS * 24 * 3600 * 1000).toISOString();
10
+ const payload = `${email}|${sendTimestamp}|${expiry}`;
11
+ const signature = (0, node_crypto_1.createHmac)("sha256", secret).update(payload).digest("hex");
12
+ const token = Buffer.from(`${payload}|${signature}`).toString("base64url");
13
+ return token;
14
+ }
15
+ function validateToken(token, secret) {
16
+ try {
17
+ const decoded = Buffer.from(token, "base64url").toString("utf8");
18
+ const parts = decoded.split("|");
19
+ if (parts.length !== 4) {
20
+ return { valid: false, reason: "malformed token" };
21
+ }
22
+ const [email, sendTimestamp, expiryTimestamp, signature] = parts;
23
+ const payload = `${email}|${sendTimestamp}|${expiryTimestamp}`;
24
+ const expectedSignature = (0, node_crypto_1.createHmac)("sha256", secret).update(payload).digest("hex");
25
+ if (signature !== expectedSignature) {
26
+ return { valid: false, reason: "invalid signature" };
27
+ }
28
+ if (new Date(expiryTimestamp) < new Date()) {
29
+ return { valid: false, reason: "token expired" };
30
+ }
31
+ return { valid: true, email, sendTimestamp, expiryTimestamp };
32
+ }
33
+ catch {
34
+ return { valid: false, reason: "invalid token format" };
35
+ }
36
+ }
37
+ //# sourceMappingURL=unsubscribe-token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unsubscribe-token.js","sourceRoot":"","sources":["../../src/lib/unsubscribe-token.ts"],"names":[],"mappings":";;AAIA,sCASC;AAcD,sCAyBC;AApDD,6CAAyC;AAEzC,MAAM,WAAW,GAAG,EAAE,CAAC;AAEvB,SAAgB,aAAa,CAAC,KAAa,EAAE,MAAc;IACzD,MAAM,aAAa,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC/C,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,WAAW,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC;IAEnF,MAAM,OAAO,GAAG,GAAG,KAAK,IAAI,aAAa,IAAI,MAAM,EAAE,CAAC;IACtD,MAAM,SAAS,GAAG,IAAA,wBAAU,EAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAE7E,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,OAAO,IAAI,SAAS,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;IAC3E,OAAO,KAAK,CAAC;AACf,CAAC;AAcD,SAAgB,aAAa,CAAC,KAAa,EAAE,MAAc;IACzD,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACjE,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACjC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC;QACrD,CAAC;QAED,MAAM,CAAC,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,SAAS,CAAC,GAAG,KAAK,CAAC;QAEjE,MAAM,OAAO,GAAG,GAAG,KAAK,IAAI,aAAa,IAAI,eAAe,EAAE,CAAC;QAC/D,MAAM,iBAAiB,GAAG,IAAA,wBAAU,EAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAErF,IAAI,SAAS,KAAK,iBAAiB,EAAE,CAAC;YACpC,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,mBAAmB,EAAE,CAAC;QACvD,CAAC;QAED,IAAI,IAAI,IAAI,CAAC,eAAe,CAAC,GAAG,IAAI,IAAI,EAAE,EAAE,CAAC;YAC3C,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;QACnD,CAAC;QAED,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,CAAC;IAChE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,sBAAsB,EAAE,CAAC;IAC1D,CAAC;AACH,CAAC"}
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@step-func-emailer/handlers",
3
+ "version": "0.2.0",
4
+ "description": "Lambda handlers for step-func-emailer",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "src"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/mdwt/step-func-emailer.git",
19
+ "directory": "packages/handlers"
20
+ },
21
+ "dependencies": {
22
+ "@aws-lambda-powertools/logger": "^2.31.0",
23
+ "@aws-sdk/client-dynamodb": "^3.1009.0",
24
+ "@aws-sdk/client-s3": "^3.1009.0",
25
+ "@aws-sdk/client-sesv2": "^3.1009.0",
26
+ "@aws-sdk/client-sfn": "^3.1009.0",
27
+ "@aws-sdk/client-ssm": "^3.1009.0",
28
+ "@aws-sdk/util-dynamodb": "^3.996.2",
29
+ "liquidjs": "^10.25.0",
30
+ "@step-func-emailer/shared": "0.2.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/aws-lambda": "^8.10.161"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc",
37
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
38
+ "test": "vitest run",
39
+ "lint": "eslint src"
40
+ }
41
+ }
@@ -0,0 +1,173 @@
1
+ import { vi } from "vitest";
2
+ import type { SNSEvent } from "aws-lambda";
3
+
4
+ const mockResolveConfig = vi.fn();
5
+ const mockWriteSuppression = 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/dynamo-client.js", () => ({
15
+ writeSuppression: (...args: unknown[]) => mockWriteSuppression(...args),
16
+ setProfileFlag: (...args: unknown[]) => mockSetProfileFlag(...args),
17
+ }));
18
+
19
+ vi.mock("../../lib/execution-stopper.js", () => ({
20
+ stopAllExecutions: (...args: unknown[]) => mockStopAllExecutions(...args),
21
+ }));
22
+
23
+ vi.mock("../../lib/ses-suppression.js", () => ({
24
+ addToSuppressionList: (...args: unknown[]) => mockAddToSuppressionList(...args),
25
+ }));
26
+
27
+ const { handler } = await import("../bounce-handler.js");
28
+
29
+ const CONFIG = { tableName: "TestTable" };
30
+
31
+ function snsEvent(message: unknown): SNSEvent {
32
+ return {
33
+ Records: [
34
+ {
35
+ Sns: { Message: JSON.stringify(message) },
36
+ },
37
+ ],
38
+ } as SNSEvent;
39
+ }
40
+
41
+ beforeEach(() => {
42
+ mockResolveConfig.mockReset().mockResolvedValue(CONFIG);
43
+ mockWriteSuppression.mockReset().mockResolvedValue(undefined);
44
+ mockSetProfileFlag.mockReset().mockResolvedValue(undefined);
45
+ mockStopAllExecutions.mockReset().mockResolvedValue(undefined);
46
+ mockAddToSuppressionList.mockReset().mockResolvedValue(undefined);
47
+ });
48
+
49
+ describe("bounce-handler", () => {
50
+ it("suppresses subscriber on permanent bounce", async () => {
51
+ const event = snsEvent({
52
+ notificationType: "Bounce",
53
+ bounce: {
54
+ bounceType: "Permanent",
55
+ bouncedRecipients: [{ emailAddress: "bounced@example.com" }],
56
+ feedbackId: "feedback-1",
57
+ },
58
+ });
59
+
60
+ await handler(event);
61
+
62
+ expect(mockWriteSuppression).toHaveBeenCalledWith(
63
+ "TestTable",
64
+ "bounced@example.com",
65
+ "bounce",
66
+ "Permanent",
67
+ "feedback-1",
68
+ );
69
+ expect(mockSetProfileFlag).toHaveBeenCalledWith(
70
+ "TestTable",
71
+ "bounced@example.com",
72
+ "suppressed",
73
+ );
74
+ expect(mockStopAllExecutions).toHaveBeenCalledWith("TestTable", "bounced@example.com");
75
+ expect(mockAddToSuppressionList).toHaveBeenCalledWith("bounced@example.com", "BOUNCE");
76
+ });
77
+
78
+ it("ignores transient bounces", async () => {
79
+ const event = snsEvent({
80
+ notificationType: "Bounce",
81
+ bounce: {
82
+ bounceType: "Transient",
83
+ bouncedRecipients: [{ emailAddress: "user@example.com" }],
84
+ feedbackId: "feedback-2",
85
+ },
86
+ });
87
+
88
+ await handler(event);
89
+
90
+ expect(mockWriteSuppression).not.toHaveBeenCalled();
91
+ expect(mockSetProfileFlag).not.toHaveBeenCalled();
92
+ expect(mockStopAllExecutions).not.toHaveBeenCalled();
93
+ expect(mockAddToSuppressionList).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("suppresses subscriber on complaint", async () => {
97
+ const event = snsEvent({
98
+ notificationType: "Complaint",
99
+ complaint: {
100
+ complainedRecipients: [{ emailAddress: "complainer@example.com" }],
101
+ feedbackId: "feedback-3",
102
+ },
103
+ });
104
+
105
+ await handler(event);
106
+
107
+ expect(mockWriteSuppression).toHaveBeenCalledWith(
108
+ "TestTable",
109
+ "complainer@example.com",
110
+ "complaint",
111
+ undefined,
112
+ "feedback-3",
113
+ );
114
+ expect(mockSetProfileFlag).toHaveBeenCalledWith(
115
+ "TestTable",
116
+ "complainer@example.com",
117
+ "suppressed",
118
+ );
119
+ expect(mockStopAllExecutions).toHaveBeenCalledWith("TestTable", "complainer@example.com");
120
+ expect(mockAddToSuppressionList).toHaveBeenCalledWith("complainer@example.com", "COMPLAINT");
121
+ });
122
+
123
+ it("handles multiple recipients in a bounce", async () => {
124
+ const event = snsEvent({
125
+ notificationType: "Bounce",
126
+ bounce: {
127
+ bounceType: "Permanent",
128
+ bouncedRecipients: [{ emailAddress: "a@example.com" }, { emailAddress: "b@example.com" }],
129
+ feedbackId: "feedback-4",
130
+ },
131
+ });
132
+
133
+ await handler(event);
134
+
135
+ expect(mockWriteSuppression).toHaveBeenCalledTimes(2);
136
+ expect(mockSetProfileFlag).toHaveBeenCalledTimes(2);
137
+ expect(mockStopAllExecutions).toHaveBeenCalledTimes(2);
138
+ });
139
+
140
+ it("handles multiple SNS records", async () => {
141
+ const event: SNSEvent = {
142
+ Records: [
143
+ {
144
+ Sns: {
145
+ Message: JSON.stringify({
146
+ notificationType: "Bounce",
147
+ bounce: {
148
+ bounceType: "Permanent",
149
+ bouncedRecipients: [{ emailAddress: "a@example.com" }],
150
+ feedbackId: "f1",
151
+ },
152
+ }),
153
+ },
154
+ },
155
+ {
156
+ Sns: {
157
+ Message: JSON.stringify({
158
+ notificationType: "Complaint",
159
+ complaint: {
160
+ complainedRecipients: [{ emailAddress: "b@example.com" }],
161
+ feedbackId: "f2",
162
+ },
163
+ }),
164
+ },
165
+ },
166
+ ],
167
+ } as SNSEvent;
168
+
169
+ await handler(event);
170
+
171
+ expect(mockWriteSuppression).toHaveBeenCalledTimes(2);
172
+ });
173
+ });
@@ -0,0 +1,172 @@
1
+ import { vi } from "vitest";
2
+
3
+ const mockResolveConfig = vi.fn();
4
+ const mockGetSubscriberProfile = vi.fn();
5
+ const mockHasBeenSent = vi.fn();
6
+
7
+ vi.mock("../../lib/ssm-config.js", () => ({
8
+ resolveConfig: () => mockResolveConfig(),
9
+ }));
10
+
11
+ vi.mock("../../lib/dynamo-client.js", () => ({
12
+ getSubscriberProfile: (...args: unknown[]) => mockGetSubscriberProfile(...args),
13
+ hasBeenSent: (...args: unknown[]) => mockHasBeenSent(...args),
14
+ }));
15
+
16
+ const { handler } = await import("../check-condition.js");
17
+
18
+ const CONFIG = { tableName: "TestTable" };
19
+
20
+ beforeEach(() => {
21
+ mockResolveConfig.mockReset().mockResolvedValue(CONFIG);
22
+ mockGetSubscriberProfile.mockReset();
23
+ mockHasBeenSent.mockReset();
24
+ });
25
+
26
+ describe("check-condition handler", () => {
27
+ describe("subscriber_field_exists", () => {
28
+ it("returns true when field exists and has a value", async () => {
29
+ mockGetSubscriberProfile.mockResolvedValueOnce({
30
+ platform: "web",
31
+ });
32
+
33
+ const result = await handler({
34
+ check: "subscriber_field_exists",
35
+ field: "platform",
36
+ subscriber: { email: "user@example.com" },
37
+ });
38
+
39
+ expect(result).toEqual({ result: true });
40
+ });
41
+
42
+ it("returns false when field is empty string", async () => {
43
+ mockGetSubscriberProfile.mockResolvedValueOnce({
44
+ platform: "",
45
+ });
46
+
47
+ const result = await handler({
48
+ check: "subscriber_field_exists",
49
+ field: "platform",
50
+ subscriber: { email: "user@example.com" },
51
+ });
52
+
53
+ expect(result).toEqual({ result: false });
54
+ });
55
+
56
+ it("returns false when field is undefined", async () => {
57
+ mockGetSubscriberProfile.mockResolvedValueOnce({});
58
+
59
+ const result = await handler({
60
+ check: "subscriber_field_exists",
61
+ field: "platform",
62
+ subscriber: { email: "user@example.com" },
63
+ });
64
+
65
+ expect(result).toEqual({ result: false });
66
+ });
67
+
68
+ it("returns false when profile does not exist", async () => {
69
+ mockGetSubscriberProfile.mockResolvedValueOnce(null);
70
+
71
+ const result = await handler({
72
+ check: "subscriber_field_exists",
73
+ field: "platform",
74
+ subscriber: { email: "user@example.com" },
75
+ });
76
+
77
+ expect(result).toEqual({ result: false });
78
+ });
79
+ });
80
+
81
+ describe("subscriber_field_equals", () => {
82
+ it("returns true when field matches value", async () => {
83
+ mockGetSubscriberProfile.mockResolvedValueOnce({
84
+ plan: "pro",
85
+ });
86
+
87
+ const result = await handler({
88
+ check: "subscriber_field_equals",
89
+ field: "plan",
90
+ value: "pro",
91
+ subscriber: { email: "user@example.com" },
92
+ });
93
+
94
+ expect(result).toEqual({ result: true });
95
+ });
96
+
97
+ it("returns false when field does not match", async () => {
98
+ mockGetSubscriberProfile.mockResolvedValueOnce({
99
+ plan: "free",
100
+ });
101
+
102
+ const result = await handler({
103
+ check: "subscriber_field_equals",
104
+ field: "plan",
105
+ value: "pro",
106
+ subscriber: { email: "user@example.com" },
107
+ });
108
+
109
+ expect(result).toEqual({ result: false });
110
+ });
111
+
112
+ it("returns false when profile is null", async () => {
113
+ mockGetSubscriberProfile.mockResolvedValueOnce(null);
114
+
115
+ const result = await handler({
116
+ check: "subscriber_field_equals",
117
+ field: "plan",
118
+ value: "pro",
119
+ subscriber: { email: "user@example.com" },
120
+ });
121
+
122
+ expect(result).toEqual({ result: false });
123
+ });
124
+ });
125
+
126
+ describe("has_been_sent", () => {
127
+ it("returns true when template has been sent", async () => {
128
+ mockHasBeenSent.mockResolvedValueOnce(true);
129
+
130
+ const result = await handler({
131
+ check: "has_been_sent",
132
+ templateKey: "onboarding/welcome",
133
+ subscriber: { email: "user@example.com" },
134
+ });
135
+
136
+ expect(result).toEqual({ result: true });
137
+ });
138
+
139
+ it("returns false when template has not been sent", async () => {
140
+ mockHasBeenSent.mockResolvedValueOnce(false);
141
+
142
+ const result = await handler({
143
+ check: "has_been_sent",
144
+ templateKey: "onboarding/welcome",
145
+ subscriber: { email: "user@example.com" },
146
+ });
147
+
148
+ expect(result).toEqual({ result: false });
149
+ });
150
+
151
+ it("returns false when templateKey is missing", async () => {
152
+ const result = await handler({
153
+ check: "has_been_sent",
154
+ subscriber: { email: "user@example.com" },
155
+ } as any);
156
+
157
+ expect(result).toEqual({ result: false });
158
+ expect(mockHasBeenSent).not.toHaveBeenCalled();
159
+ });
160
+ });
161
+
162
+ describe("unknown check", () => {
163
+ it("returns false for unknown check type", async () => {
164
+ const result = await handler({
165
+ check: "unknown_check" as any,
166
+ subscriber: { email: "user@example.com" },
167
+ });
168
+
169
+ expect(result).toEqual({ result: false });
170
+ });
171
+ });
172
+ });
@@ -0,0 +1,161 @@
1
+ import { vi } from "vitest";
2
+ import type { SNSEvent } from "aws-lambda";
3
+
4
+ const mockResolveConfig = vi.fn();
5
+ const mockSend = vi.fn();
6
+
7
+ vi.mock("../../lib/ssm-config.js", () => ({
8
+ resolveConfig: () => mockResolveConfig(),
9
+ }));
10
+
11
+ vi.mock("@aws-sdk/client-dynamodb", () => ({
12
+ DynamoDBClient: class {
13
+ send = mockSend;
14
+ },
15
+ PutItemCommand: class {
16
+ input: unknown;
17
+ constructor(input: unknown) {
18
+ this.input = input;
19
+ }
20
+ },
21
+ }));
22
+
23
+ // Re-export real marshall/unmarshall
24
+ vi.mock("@aws-sdk/util-dynamodb", async () => {
25
+ const actual = await vi.importActual("@aws-sdk/util-dynamodb");
26
+ return actual;
27
+ });
28
+
29
+ const { handler } = await import("../engagement-handler.js");
30
+
31
+ const CONFIG = {
32
+ tableName: "TestTable",
33
+ eventsTableName: "EventsTable",
34
+ };
35
+
36
+ function snsEvent(message: unknown): SNSEvent {
37
+ return {
38
+ Records: [{ Sns: { Message: JSON.stringify(message) } }],
39
+ } as SNSEvent;
40
+ }
41
+
42
+ const baseHeaders = [
43
+ { name: "Subject", value: "Welcome!" },
44
+ { name: "X-Template-Key", value: "onboarding/welcome" },
45
+ { name: "X-Sequence-Id", value: "onboarding" },
46
+ ];
47
+
48
+ beforeEach(() => {
49
+ mockResolveConfig.mockReset().mockResolvedValue(CONFIG);
50
+ mockSend.mockReset().mockResolvedValue({});
51
+ });
52
+
53
+ describe("engagement-handler", () => {
54
+ it("writes delivery event to events table", async () => {
55
+ const event = snsEvent({
56
+ eventType: "Delivery",
57
+ mail: {
58
+ messageId: "msg-1",
59
+ destination: ["user@example.com"],
60
+ headers: baseHeaders,
61
+ },
62
+ delivery: { timestamp: "2026-01-15T10:00:00.000Z" },
63
+ });
64
+
65
+ await handler(event);
66
+
67
+ expect(mockSend).toHaveBeenCalledOnce();
68
+ const cmd = mockSend.mock.calls[0][0];
69
+ expect(cmd.input.TableName).toBe("EventsTable");
70
+ });
71
+
72
+ it("writes click event with link URL", async () => {
73
+ const event = snsEvent({
74
+ eventType: "Click",
75
+ mail: {
76
+ messageId: "msg-2",
77
+ destination: ["user@example.com"],
78
+ headers: baseHeaders,
79
+ },
80
+ click: {
81
+ timestamp: "2026-01-15T10:00:00.000Z",
82
+ link: "https://example.com/promo",
83
+ userAgent: "Mozilla/5.0",
84
+ },
85
+ });
86
+
87
+ await handler(event);
88
+
89
+ expect(mockSend).toHaveBeenCalledOnce();
90
+ });
91
+
92
+ it("writes open event with user agent", async () => {
93
+ const event = snsEvent({
94
+ eventType: "Open",
95
+ mail: {
96
+ messageId: "msg-3",
97
+ destination: ["user@example.com"],
98
+ headers: baseHeaders,
99
+ },
100
+ open: {
101
+ timestamp: "2026-01-15T10:00:00.000Z",
102
+ userAgent: "Apple Mail",
103
+ },
104
+ });
105
+
106
+ await handler(event);
107
+
108
+ expect(mockSend).toHaveBeenCalledOnce();
109
+ });
110
+
111
+ it("handles bounce event with multiple recipients", async () => {
112
+ const event = snsEvent({
113
+ eventType: "Bounce",
114
+ mail: {
115
+ messageId: "msg-4",
116
+ destination: ["a@example.com"],
117
+ headers: baseHeaders,
118
+ },
119
+ bounce: {
120
+ bounceType: "Permanent",
121
+ bouncedRecipients: [{ emailAddress: "a@example.com" }, { emailAddress: "b@example.com" }],
122
+ timestamp: "2026-01-15T10:00:00.000Z",
123
+ },
124
+ });
125
+
126
+ await handler(event);
127
+
128
+ expect(mockSend).toHaveBeenCalledTimes(2);
129
+ });
130
+
131
+ it("defaults sequenceId to fire_and_forget when header missing", async () => {
132
+ const event = snsEvent({
133
+ eventType: "Delivery",
134
+ mail: {
135
+ messageId: "msg-5",
136
+ destination: ["user@example.com"],
137
+ headers: [{ name: "Subject", value: "Hello" }],
138
+ },
139
+ delivery: { timestamp: "2026-01-15T10:00:00.000Z" },
140
+ });
141
+
142
+ await handler(event);
143
+
144
+ expect(mockSend).toHaveBeenCalledOnce();
145
+ });
146
+
147
+ it("skips unknown event types", async () => {
148
+ const event = snsEvent({
149
+ eventType: "UnknownType",
150
+ mail: {
151
+ messageId: "msg-6",
152
+ destination: ["user@example.com"],
153
+ headers: baseHeaders,
154
+ },
155
+ });
156
+
157
+ await handler(event);
158
+
159
+ expect(mockSend).not.toHaveBeenCalled();
160
+ });
161
+ });