@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.
- package/dist/handlers/__tests__/bounce-handler.test.d.ts +2 -0
- package/dist/handlers/__tests__/bounce-handler.test.d.ts.map +1 -0
- package/dist/handlers/__tests__/bounce-handler.test.js +126 -0
- package/dist/handlers/__tests__/bounce-handler.test.js.map +1 -0
- package/dist/handlers/__tests__/check-condition.test.d.ts +2 -0
- package/dist/handlers/__tests__/check-condition.test.d.ts.map +1 -0
- package/dist/handlers/__tests__/check-condition.test.js +140 -0
- package/dist/handlers/__tests__/check-condition.test.js.map +1 -0
- package/dist/handlers/__tests__/engagement-handler.test.d.ts +2 -0
- package/dist/handlers/__tests__/engagement-handler.test.d.ts.map +1 -0
- package/dist/handlers/__tests__/engagement-handler.test.js +139 -0
- package/dist/handlers/__tests__/engagement-handler.test.js.map +1 -0
- package/dist/handlers/__tests__/send-email.test.d.ts +2 -0
- package/dist/handlers/__tests__/send-email.test.d.ts.map +1 -0
- package/dist/handlers/__tests__/send-email.test.js +205 -0
- package/dist/handlers/__tests__/send-email.test.js.map +1 -0
- package/dist/handlers/__tests__/unsubscribe.test.d.ts +2 -0
- package/dist/handlers/__tests__/unsubscribe.test.d.ts.map +1 -0
- package/dist/handlers/__tests__/unsubscribe.test.js +79 -0
- package/dist/handlers/__tests__/unsubscribe.test.js.map +1 -0
- package/dist/handlers/bounce-handler.d.ts +3 -0
- package/dist/handlers/bounce-handler.d.ts.map +1 -0
- package/dist/handlers/bounce-handler.js +55 -0
- package/dist/handlers/bounce-handler.js.map +1 -0
- package/dist/handlers/check-condition.d.ts +3 -0
- package/dist/handlers/check-condition.d.ts.map +1 -0
- package/dist/handlers/check-condition.js +74 -0
- package/dist/handlers/check-condition.js.map +1 -0
- package/dist/handlers/engagement-handler.d.ts +3 -0
- package/dist/handlers/engagement-handler.d.ts.map +1 -0
- package/dist/handlers/engagement-handler.js +98 -0
- package/dist/handlers/engagement-handler.js.map +1 -0
- package/dist/handlers/send-email.d.ts +5 -0
- package/dist/handlers/send-email.d.ts.map +1 -0
- package/dist/handlers/send-email.js +137 -0
- package/dist/handlers/send-email.js.map +1 -0
- package/dist/handlers/unsubscribe.d.ts +5 -0
- package/dist/handlers/unsubscribe.d.ts.map +1 -0
- package/dist/handlers/unsubscribe.js +53 -0
- package/dist/handlers/unsubscribe.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/__tests__/display-names.test.d.ts +2 -0
- package/dist/lib/__tests__/display-names.test.d.ts.map +1 -0
- package/dist/lib/__tests__/display-names.test.js +49 -0
- package/dist/lib/__tests__/display-names.test.js.map +1 -0
- package/dist/lib/__tests__/dynamo-client.test.d.ts +2 -0
- package/dist/lib/__tests__/dynamo-client.test.d.ts.map +1 -0
- package/dist/lib/__tests__/dynamo-client.test.js +229 -0
- package/dist/lib/__tests__/dynamo-client.test.js.map +1 -0
- package/dist/lib/__tests__/execution-stopper.test.d.ts +2 -0
- package/dist/lib/__tests__/execution-stopper.test.d.ts.map +1 -0
- package/dist/lib/__tests__/execution-stopper.test.js +56 -0
- package/dist/lib/__tests__/execution-stopper.test.js.map +1 -0
- package/dist/lib/__tests__/ses-sender.test.d.ts +2 -0
- package/dist/lib/__tests__/ses-sender.test.d.ts.map +1 -0
- package/dist/lib/__tests__/ses-sender.test.js +66 -0
- package/dist/lib/__tests__/ses-sender.test.js.map +1 -0
- package/dist/lib/__tests__/ssm-config.test.d.ts +2 -0
- package/dist/lib/__tests__/ssm-config.test.d.ts.map +1 -0
- package/dist/lib/__tests__/ssm-config.test.js +73 -0
- package/dist/lib/__tests__/ssm-config.test.js.map +1 -0
- package/dist/lib/__tests__/template-renderer.test.d.ts +2 -0
- package/dist/lib/__tests__/template-renderer.test.d.ts.map +1 -0
- package/dist/lib/__tests__/template-renderer.test.js +79 -0
- package/dist/lib/__tests__/template-renderer.test.js.map +1 -0
- package/dist/lib/__tests__/unsubscribe-token.test.d.ts +2 -0
- package/dist/lib/__tests__/unsubscribe-token.test.d.ts.map +1 -0
- package/dist/lib/__tests__/unsubscribe-token.test.js +74 -0
- package/dist/lib/__tests__/unsubscribe-token.test.js.map +1 -0
- package/dist/lib/display-names.d.ts +5 -0
- package/dist/lib/display-names.d.ts.map +1 -0
- package/dist/lib/display-names.js +46 -0
- package/dist/lib/display-names.js.map +1 -0
- package/dist/lib/dynamo-client.d.ts +13 -0
- package/dist/lib/dynamo-client.d.ts.map +1 -0
- package/dist/lib/dynamo-client.js +211 -0
- package/dist/lib/dynamo-client.js.map +1 -0
- package/dist/lib/execution-stopper.d.ts +2 -0
- package/dist/lib/execution-stopper.d.ts.map +1 -0
- package/dist/lib/execution-stopper.js +38 -0
- package/dist/lib/execution-stopper.js.map +1 -0
- package/dist/lib/logger.d.ts +8 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/logger.js +13 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/ses-sender.d.ts +13 -0
- package/dist/lib/ses-sender.d.ts.map +1 -0
- package/dist/lib/ses-sender.js +86 -0
- package/dist/lib/ses-sender.js.map +1 -0
- package/dist/lib/ses-suppression.d.ts +2 -0
- package/dist/lib/ses-suppression.d.ts.map +1 -0
- package/dist/lib/ses-suppression.js +24 -0
- package/dist/lib/ses-suppression.js.map +1 -0
- package/dist/lib/ssm-config.d.ts +14 -0
- package/dist/lib/ssm-config.d.ts.map +1 -0
- package/dist/lib/ssm-config.js +54 -0
- package/dist/lib/ssm-config.js.map +1 -0
- package/dist/lib/template-renderer.d.ts +9 -0
- package/dist/lib/template-renderer.d.ts.map +1 -0
- package/dist/lib/template-renderer.js +36 -0
- package/dist/lib/template-renderer.js.map +1 -0
- package/dist/lib/unsubscribe-token.d.ts +13 -0
- package/dist/lib/unsubscribe-token.d.ts.map +1 -0
- package/dist/lib/unsubscribe-token.js +37 -0
- package/dist/lib/unsubscribe-token.js.map +1 -0
- package/package.json +41 -0
- package/src/handlers/__tests__/bounce-handler.test.ts +173 -0
- package/src/handlers/__tests__/check-condition.test.ts +172 -0
- package/src/handlers/__tests__/engagement-handler.test.ts +161 -0
- package/src/handlers/__tests__/send-email.test.ts +279 -0
- package/src/handlers/__tests__/unsubscribe.test.ts +111 -0
- package/src/handlers/bounce-handler.ts +91 -0
- package/src/handlers/check-condition.ts +77 -0
- package/src/handlers/engagement-handler.ts +169 -0
- package/src/handlers/send-email.ts +184 -0
- package/src/handlers/unsubscribe.ts +70 -0
- package/src/index.ts +10 -0
- package/src/lib/__tests__/display-names.test.ts +52 -0
- package/src/lib/__tests__/dynamo-client.test.ts +346 -0
- package/src/lib/__tests__/execution-stopper.test.ts +68 -0
- package/src/lib/__tests__/ses-sender.test.ts +85 -0
- package/src/lib/__tests__/ssm-config.test.ts +85 -0
- package/src/lib/__tests__/template-renderer.test.ts +96 -0
- package/src/lib/__tests__/unsubscribe-token.test.ts +79 -0
- package/src/lib/display-names.ts +54 -0
- package/src/lib/dynamo-client.ts +301 -0
- package/src/lib/execution-stopper.ts +40 -0
- package/src/lib/logger.ts +10 -0
- package/src/lib/ses-sender.ts +102 -0
- package/src/lib/ses-suppression.ts +30 -0
- package/src/lib/ssm-config.ts +81 -0
- package/src/lib/template-renderer.ts +52 -0
- package/src/lib/unsubscribe-token.ts +53 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { generateToken, validateToken } from "../unsubscribe-token.js";
|
|
2
|
+
|
|
3
|
+
const SECRET = "test-secret-key";
|
|
4
|
+
|
|
5
|
+
describe("unsubscribe-token", () => {
|
|
6
|
+
describe("generateToken", () => {
|
|
7
|
+
it("returns a base64url-encoded string", () => {
|
|
8
|
+
const token = generateToken("user@example.com", SECRET);
|
|
9
|
+
expect(token).toBeTruthy();
|
|
10
|
+
// base64url chars only
|
|
11
|
+
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("produces different tokens for different emails", () => {
|
|
15
|
+
const t1 = generateToken("a@example.com", SECRET);
|
|
16
|
+
const t2 = generateToken("b@example.com", SECRET);
|
|
17
|
+
expect(t1).not.toBe(t2);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("produces different tokens for different secrets", () => {
|
|
21
|
+
const t1 = generateToken("user@example.com", "secret-1");
|
|
22
|
+
const t2 = generateToken("user@example.com", "secret-2");
|
|
23
|
+
expect(t1).not.toBe(t2);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("validateToken", () => {
|
|
28
|
+
it("validates a freshly generated token", () => {
|
|
29
|
+
const token = generateToken("user@example.com", SECRET);
|
|
30
|
+
const result = validateToken(token, SECRET);
|
|
31
|
+
expect(result.valid).toBe(true);
|
|
32
|
+
if (result.valid) {
|
|
33
|
+
expect(result.email).toBe("user@example.com");
|
|
34
|
+
expect(result.sendTimestamp).toBeTruthy();
|
|
35
|
+
expect(result.expiryTimestamp).toBeTruthy();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects token with wrong secret", () => {
|
|
40
|
+
const token = generateToken("user@example.com", SECRET);
|
|
41
|
+
const result = validateToken(token, "wrong-secret");
|
|
42
|
+
expect(result.valid).toBe(false);
|
|
43
|
+
if (!result.valid) {
|
|
44
|
+
expect(result.reason).toBe("invalid signature");
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects malformed token", () => {
|
|
49
|
+
const result = validateToken("not-a-valid-token", SECRET);
|
|
50
|
+
expect(result.valid).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects expired token", () => {
|
|
54
|
+
// Manually craft an expired token
|
|
55
|
+
const { createHmac } = require("node:crypto");
|
|
56
|
+
const email = "user@example.com";
|
|
57
|
+
const sendTimestamp = "2020-01-01T00:00:00.000Z";
|
|
58
|
+
const expiryTimestamp = "2020-04-01T00:00:00.000Z"; // expired
|
|
59
|
+
const payload = `${email}|${sendTimestamp}|${expiryTimestamp}`;
|
|
60
|
+
const signature = createHmac("sha256", SECRET).update(payload).digest("hex");
|
|
61
|
+
const token = Buffer.from(`${payload}|${signature}`).toString("base64url");
|
|
62
|
+
|
|
63
|
+
const result = validateToken(token, SECRET);
|
|
64
|
+
expect(result.valid).toBe(false);
|
|
65
|
+
if (!result.valid) {
|
|
66
|
+
expect(result.reason).toBe("token expired");
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects token with wrong part count", () => {
|
|
71
|
+
const token = Buffer.from("a|b|c|d|e").toString("base64url");
|
|
72
|
+
const result = validateToken(token, SECRET);
|
|
73
|
+
expect(result.valid).toBe(false);
|
|
74
|
+
if (!result.valid) {
|
|
75
|
+
expect(result.reason).toBe("malformed token");
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import { createLogger } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
const logger = createLogger("display-names");
|
|
5
|
+
const s3 = new S3Client({});
|
|
6
|
+
|
|
7
|
+
type DisplayNameMap = Record<string, Record<string, string>>;
|
|
8
|
+
|
|
9
|
+
let cachedMap: { data: DisplayNameMap; fetchedAt: number } | null = null;
|
|
10
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export async function loadDisplayNames(bucket: string): Promise<DisplayNameMap> {
|
|
13
|
+
if (cachedMap && Date.now() - cachedMap.fetchedAt < CACHE_TTL_MS) {
|
|
14
|
+
logger.debug("Display names cache hit");
|
|
15
|
+
return cachedMap.data;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
logger.debug("Loading display names from S3", { bucket });
|
|
20
|
+
const result = await s3.send(
|
|
21
|
+
new GetObjectCommand({
|
|
22
|
+
Bucket: bucket,
|
|
23
|
+
Key: "display-names.json",
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
const body = (await result.Body?.transformToString()) ?? "{}";
|
|
27
|
+
const data = JSON.parse(body) as DisplayNameMap;
|
|
28
|
+
cachedMap = { data, fetchedAt: Date.now() };
|
|
29
|
+
logger.debug("Display names loaded", { mappingCount: Object.keys(data).length });
|
|
30
|
+
return data;
|
|
31
|
+
} catch {
|
|
32
|
+
logger.debug("Display names file not found, using empty map");
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveDisplayNames(
|
|
38
|
+
displayNameMap: DisplayNameMap,
|
|
39
|
+
attributes: Record<string, unknown>,
|
|
40
|
+
): Record<string, string> {
|
|
41
|
+
const resolved: Record<string, string> = {};
|
|
42
|
+
|
|
43
|
+
for (const [displayKey, valueMap] of Object.entries(displayNameMap)) {
|
|
44
|
+
// Convention: displayKey "platformName" maps from attribute "platform"
|
|
45
|
+
// The source attribute key is derived by removing "Name" suffix
|
|
46
|
+
const sourceKey = displayKey.replace(/Name$/, "");
|
|
47
|
+
const rawValue = attributes[sourceKey];
|
|
48
|
+
if (typeof rawValue === "string") {
|
|
49
|
+
resolved[displayKey] = valueMap[rawValue] ?? rawValue;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return resolved;
|
|
54
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DynamoDBClient,
|
|
3
|
+
GetItemCommand,
|
|
4
|
+
PutItemCommand,
|
|
5
|
+
UpdateItemCommand,
|
|
6
|
+
DeleteItemCommand,
|
|
7
|
+
QueryCommand,
|
|
8
|
+
} from "@aws-sdk/client-dynamodb";
|
|
9
|
+
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
|
|
10
|
+
import {
|
|
11
|
+
subscriberPK,
|
|
12
|
+
PROFILE_SK,
|
|
13
|
+
executionSK,
|
|
14
|
+
sentSK,
|
|
15
|
+
SUPPRESSION_SK,
|
|
16
|
+
EXEC_SK_PREFIX,
|
|
17
|
+
SENT_SK_PREFIX,
|
|
18
|
+
SEND_LOG_TTL_DAYS,
|
|
19
|
+
} from "@step-func-emailer/shared";
|
|
20
|
+
import type {
|
|
21
|
+
Subscriber,
|
|
22
|
+
SubscriberProfile,
|
|
23
|
+
ActiveExecution,
|
|
24
|
+
SendLog,
|
|
25
|
+
} from "@step-func-emailer/shared";
|
|
26
|
+
import { createLogger } from "./logger.js";
|
|
27
|
+
|
|
28
|
+
const logger = createLogger("dynamo-client");
|
|
29
|
+
const dynamo = new DynamoDBClient({});
|
|
30
|
+
|
|
31
|
+
// ── Subscriber profile ──────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export async function getSubscriberProfile(
|
|
34
|
+
tableName: string,
|
|
35
|
+
email: string,
|
|
36
|
+
): Promise<SubscriberProfile | null> {
|
|
37
|
+
logger.debug("Getting subscriber profile", { email });
|
|
38
|
+
const result = await dynamo.send(
|
|
39
|
+
new GetItemCommand({
|
|
40
|
+
TableName: tableName,
|
|
41
|
+
Key: marshall({ PK: subscriberPK(email), SK: PROFILE_SK }),
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
const profile = result.Item ? (unmarshall(result.Item) as SubscriberProfile) : null;
|
|
45
|
+
logger.debug("Subscriber profile result", {
|
|
46
|
+
email,
|
|
47
|
+
found: !!profile,
|
|
48
|
+
unsubscribed: profile?.unsubscribed,
|
|
49
|
+
suppressed: profile?.suppressed,
|
|
50
|
+
});
|
|
51
|
+
return profile;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const SYSTEM_KEYS = new Set([
|
|
55
|
+
"PK",
|
|
56
|
+
"SK",
|
|
57
|
+
"email",
|
|
58
|
+
"firstName",
|
|
59
|
+
"unsubscribed",
|
|
60
|
+
"suppressed",
|
|
61
|
+
"createdAt",
|
|
62
|
+
"updatedAt",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
export function extractAttributes(profile: Record<string, unknown>): Record<string, unknown> {
|
|
66
|
+
const attrs: Record<string, unknown> = {};
|
|
67
|
+
for (const [key, value] of Object.entries(profile)) {
|
|
68
|
+
if (!SYSTEM_KEYS.has(key)) {
|
|
69
|
+
attrs[key] = value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return attrs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function upsertSubscriberProfile(
|
|
76
|
+
tableName: string,
|
|
77
|
+
subscriber: Subscriber,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
const pk = subscriberPK(subscriber.email);
|
|
81
|
+
const rawAttrs = subscriber.attributes ?? {};
|
|
82
|
+
// Filter out system keys to avoid duplicate paths in the UpdateExpression
|
|
83
|
+
const attrs: Record<string, unknown> = {};
|
|
84
|
+
for (const [key, value] of Object.entries(rawAttrs)) {
|
|
85
|
+
if (!SYSTEM_KEYS.has(key)) {
|
|
86
|
+
attrs[key] = value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
logger.info("Upserting subscriber profile", {
|
|
91
|
+
email: subscriber.email,
|
|
92
|
+
firstName: subscriber.firstName,
|
|
93
|
+
attributeCount: Object.keys(attrs).length,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Build SET expressions — never overwrite unsubscribed or suppressed
|
|
97
|
+
const expressionParts = ["email = :email", "firstName = :firstName", "updatedAt = :updatedAt"];
|
|
98
|
+
const expressionValues: Record<string, unknown> = {
|
|
99
|
+
":email": subscriber.email,
|
|
100
|
+
":firstName": subscriber.firstName,
|
|
101
|
+
":updatedAt": now,
|
|
102
|
+
":createdAt": now,
|
|
103
|
+
":defaultFalse": false,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const expressionNames: Record<string, string> = {};
|
|
107
|
+
|
|
108
|
+
// Write each attribute as a top-level column
|
|
109
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
110
|
+
expressionParts.push(`#attr_${key} = :attr_${key}`);
|
|
111
|
+
expressionNames[`#attr_${key}`] = key;
|
|
112
|
+
expressionValues[`:attr_${key}`] = value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await dynamo.send(
|
|
116
|
+
new UpdateItemCommand({
|
|
117
|
+
TableName: tableName,
|
|
118
|
+
Key: marshall({ PK: pk, SK: PROFILE_SK }),
|
|
119
|
+
UpdateExpression: `SET ${expressionParts.join(", ")}, createdAt = if_not_exists(createdAt, :createdAt), unsubscribed = if_not_exists(unsubscribed, :defaultFalse), suppressed = if_not_exists(suppressed, :defaultFalse)`,
|
|
120
|
+
ExpressionAttributeValues: marshall(expressionValues),
|
|
121
|
+
...(Object.keys(expressionNames).length > 0
|
|
122
|
+
? { ExpressionAttributeNames: expressionNames }
|
|
123
|
+
: {}),
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Active executions ───────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export async function getExecution(
|
|
131
|
+
tableName: string,
|
|
132
|
+
email: string,
|
|
133
|
+
sequenceId: string,
|
|
134
|
+
): Promise<ActiveExecution | null> {
|
|
135
|
+
logger.debug("Getting execution", { email, sequenceId });
|
|
136
|
+
const result = await dynamo.send(
|
|
137
|
+
new GetItemCommand({
|
|
138
|
+
TableName: tableName,
|
|
139
|
+
Key: marshall({
|
|
140
|
+
PK: subscriberPK(email),
|
|
141
|
+
SK: executionSK(sequenceId),
|
|
142
|
+
}),
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
return result.Item ? (unmarshall(result.Item) as ActiveExecution) : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function putExecution(
|
|
149
|
+
tableName: string,
|
|
150
|
+
email: string,
|
|
151
|
+
sequenceId: string,
|
|
152
|
+
executionArn: string,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
logger.info("Storing execution", { email, sequenceId, executionArn });
|
|
155
|
+
await dynamo.send(
|
|
156
|
+
new PutItemCommand({
|
|
157
|
+
TableName: tableName,
|
|
158
|
+
Item: marshall({
|
|
159
|
+
PK: subscriberPK(email),
|
|
160
|
+
SK: executionSK(sequenceId),
|
|
161
|
+
executionArn,
|
|
162
|
+
sequenceId,
|
|
163
|
+
startedAt: new Date().toISOString(),
|
|
164
|
+
}),
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function deleteExecution(
|
|
170
|
+
tableName: string,
|
|
171
|
+
email: string,
|
|
172
|
+
sequenceId: string,
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
logger.info("Deleting execution", { email, sequenceId });
|
|
175
|
+
await dynamo.send(
|
|
176
|
+
new DeleteItemCommand({
|
|
177
|
+
TableName: tableName,
|
|
178
|
+
Key: marshall({
|
|
179
|
+
PK: subscriberPK(email),
|
|
180
|
+
SK: executionSK(sequenceId),
|
|
181
|
+
}),
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function getAllExecutions(
|
|
187
|
+
tableName: string,
|
|
188
|
+
email: string,
|
|
189
|
+
): Promise<ActiveExecution[]> {
|
|
190
|
+
logger.debug("Querying all executions", { email });
|
|
191
|
+
const result = await dynamo.send(
|
|
192
|
+
new QueryCommand({
|
|
193
|
+
TableName: tableName,
|
|
194
|
+
KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
|
|
195
|
+
ExpressionAttributeValues: marshall({
|
|
196
|
+
":pk": subscriberPK(email),
|
|
197
|
+
":prefix": EXEC_SK_PREFIX,
|
|
198
|
+
}),
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
const executions = (result.Items ?? []).map((item) => unmarshall(item) as ActiveExecution);
|
|
202
|
+
logger.debug("Found executions", { email, count: executions.length });
|
|
203
|
+
return executions;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Send log ────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export async function writeSendLog(
|
|
209
|
+
tableName: string,
|
|
210
|
+
email: string,
|
|
211
|
+
log: Omit<SendLog, "PK" | "SK" | "ttl">,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
const now = new Date();
|
|
214
|
+
const ttl = Math.floor(now.getTime() / 1000) + SEND_LOG_TTL_DAYS * 86400;
|
|
215
|
+
logger.info("Writing send log", {
|
|
216
|
+
email,
|
|
217
|
+
templateKey: log.templateKey,
|
|
218
|
+
sequenceId: log.sequenceId,
|
|
219
|
+
sesMessageId: log.sesMessageId,
|
|
220
|
+
});
|
|
221
|
+
await dynamo.send(
|
|
222
|
+
new PutItemCommand({
|
|
223
|
+
TableName: tableName,
|
|
224
|
+
Item: marshall({
|
|
225
|
+
PK: subscriberPK(email),
|
|
226
|
+
SK: sentSK(now.toISOString()),
|
|
227
|
+
...log,
|
|
228
|
+
ttl,
|
|
229
|
+
}),
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function hasBeenSent(
|
|
235
|
+
tableName: string,
|
|
236
|
+
email: string,
|
|
237
|
+
templateKey: string,
|
|
238
|
+
): Promise<boolean> {
|
|
239
|
+
logger.debug("Checking if template has been sent", { email, templateKey });
|
|
240
|
+
const result = await dynamo.send(
|
|
241
|
+
new QueryCommand({
|
|
242
|
+
TableName: tableName,
|
|
243
|
+
KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
|
|
244
|
+
FilterExpression: "templateKey = :templateKey",
|
|
245
|
+
ExpressionAttributeValues: marshall({
|
|
246
|
+
":pk": subscriberPK(email),
|
|
247
|
+
":prefix": SENT_SK_PREFIX,
|
|
248
|
+
":templateKey": templateKey,
|
|
249
|
+
}),
|
|
250
|
+
Limit: 1,
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
const sent = (result.Count ?? 0) > 0;
|
|
254
|
+
logger.debug("Has been sent result", { email, templateKey, sent });
|
|
255
|
+
return sent;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Suppression ─────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export async function writeSuppression(
|
|
261
|
+
tableName: string,
|
|
262
|
+
email: string,
|
|
263
|
+
reason: "bounce" | "complaint",
|
|
264
|
+
bounceType: string | undefined,
|
|
265
|
+
sesNotificationId: string,
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
logger.warn("Writing suppression record", { email, reason, bounceType, sesNotificationId });
|
|
268
|
+
await dynamo.send(
|
|
269
|
+
new PutItemCommand({
|
|
270
|
+
TableName: tableName,
|
|
271
|
+
Item: marshall({
|
|
272
|
+
PK: subscriberPK(email),
|
|
273
|
+
SK: SUPPRESSION_SK,
|
|
274
|
+
reason,
|
|
275
|
+
...(bounceType ? { bounceType } : {}),
|
|
276
|
+
sesNotificationId,
|
|
277
|
+
recordedAt: new Date().toISOString(),
|
|
278
|
+
}),
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function setProfileFlag(
|
|
284
|
+
tableName: string,
|
|
285
|
+
email: string,
|
|
286
|
+
flag: "unsubscribed" | "suppressed",
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
logger.warn("Setting profile flag", { email, flag });
|
|
289
|
+
await dynamo.send(
|
|
290
|
+
new UpdateItemCommand({
|
|
291
|
+
TableName: tableName,
|
|
292
|
+
Key: marshall({ PK: subscriberPK(email), SK: PROFILE_SK }),
|
|
293
|
+
UpdateExpression: `SET #flag = :val, updatedAt = :now`,
|
|
294
|
+
ExpressionAttributeNames: { "#flag": flag },
|
|
295
|
+
ExpressionAttributeValues: marshall({
|
|
296
|
+
":val": true,
|
|
297
|
+
":now": new Date().toISOString(),
|
|
298
|
+
}),
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { SFNClient, StopExecutionCommand } from "@aws-sdk/client-sfn";
|
|
2
|
+
import { getAllExecutions, deleteExecution } from "./dynamo-client.js";
|
|
3
|
+
import { createLogger } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
const logger = createLogger("execution-stopper");
|
|
6
|
+
const sfn = new SFNClient({});
|
|
7
|
+
|
|
8
|
+
export async function stopAllExecutions(tableName: string, email: string): Promise<void> {
|
|
9
|
+
const executions = await getAllExecutions(tableName, email);
|
|
10
|
+
logger.info("Stopping all executions for subscriber", {
|
|
11
|
+
email,
|
|
12
|
+
executionCount: executions.length,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
await Promise.all(
|
|
16
|
+
executions.map(async (exec) => {
|
|
17
|
+
try {
|
|
18
|
+
await sfn.send(
|
|
19
|
+
new StopExecutionCommand({
|
|
20
|
+
executionArn: exec.executionArn,
|
|
21
|
+
cause: "Subscriber unsubscribed or suppressed",
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
logger.info("Stopped execution", {
|
|
25
|
+
email,
|
|
26
|
+
sequenceId: exec.sequenceId,
|
|
27
|
+
executionArn: exec.executionArn,
|
|
28
|
+
});
|
|
29
|
+
} catch (err) {
|
|
30
|
+
logger.warn("Failed to stop execution (may already be stopped)", {
|
|
31
|
+
email,
|
|
32
|
+
sequenceId: exec.sequenceId,
|
|
33
|
+
executionArn: exec.executionArn,
|
|
34
|
+
error: err instanceof Error ? err.message : String(err),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
await deleteExecution(tableName, email, exec.sequenceId);
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Logger } from "@aws-lambda-powertools/logger";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a Logger instance scoped to a service name.
|
|
5
|
+
* LOG_LEVEL is controlled via environment variable (default: INFO).
|
|
6
|
+
* Set LOG_LEVEL=DEBUG for verbose tracing.
|
|
7
|
+
*/
|
|
8
|
+
export function createLogger(serviceName: string): Logger {
|
|
9
|
+
return new Logger({ serviceName });
|
|
10
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
|
|
2
|
+
import { createLogger } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
const logger = createLogger("ses-sender");
|
|
5
|
+
const ses = new SESv2Client({});
|
|
6
|
+
|
|
7
|
+
export interface SendEmailParams {
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
subject: string;
|
|
11
|
+
htmlBody: string;
|
|
12
|
+
configurationSetName: string;
|
|
13
|
+
unsubscribeUrl: string;
|
|
14
|
+
replyToAddress?: string;
|
|
15
|
+
templateKey: string;
|
|
16
|
+
sequenceId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Strip HTML to plain text for the text/plain MIME part.
|
|
21
|
+
* Handles common email HTML patterns without requiring an external dependency.
|
|
22
|
+
*/
|
|
23
|
+
function htmlToPlainText(html: string): string {
|
|
24
|
+
return html
|
|
25
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
26
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
27
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
28
|
+
.replace(/<\/p>/gi, "\n\n")
|
|
29
|
+
.replace(/<\/div>/gi, "\n")
|
|
30
|
+
.replace(/<\/tr>/gi, "\n")
|
|
31
|
+
.replace(/<\/li>/gi, "\n")
|
|
32
|
+
.replace(/<li[^>]*>/gi, " - ")
|
|
33
|
+
.replace(/<a[^>]+href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, "$2 ($1)")
|
|
34
|
+
.replace(/<[^>]+>/g, "")
|
|
35
|
+
.replace(/ /gi, " ")
|
|
36
|
+
.replace(/&/gi, "&")
|
|
37
|
+
.replace(/</gi, "<")
|
|
38
|
+
.replace(/>/gi, ">")
|
|
39
|
+
.replace(/"/gi, '"')
|
|
40
|
+
.replace(/'/gi, "'")
|
|
41
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function sendEmail(params: SendEmailParams): Promise<string> {
|
|
46
|
+
logger.info("Sending email via SES", {
|
|
47
|
+
to: params.to,
|
|
48
|
+
subject: params.subject,
|
|
49
|
+
templateKey: params.templateKey,
|
|
50
|
+
sequenceId: params.sequenceId,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const textBody = htmlToPlainText(params.htmlBody);
|
|
54
|
+
|
|
55
|
+
const result = await ses.send(
|
|
56
|
+
new SendEmailCommand({
|
|
57
|
+
FromEmailAddress: params.from,
|
|
58
|
+
Destination: { ToAddresses: [params.to] },
|
|
59
|
+
...(params.replyToAddress ? { ReplyToAddresses: [params.replyToAddress] } : {}),
|
|
60
|
+
Content: {
|
|
61
|
+
Simple: {
|
|
62
|
+
Subject: { Data: params.subject, Charset: "UTF-8" },
|
|
63
|
+
Body: {
|
|
64
|
+
Html: { Data: params.htmlBody, Charset: "UTF-8" },
|
|
65
|
+
Text: { Data: textBody, Charset: "UTF-8" },
|
|
66
|
+
},
|
|
67
|
+
Headers: [
|
|
68
|
+
{
|
|
69
|
+
Name: "List-Unsubscribe",
|
|
70
|
+
Value: `<${params.unsubscribeUrl}>`,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
Name: "List-Unsubscribe-Post",
|
|
74
|
+
Value: "List-Unsubscribe=One-Click",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
Name: "X-Template-Key",
|
|
78
|
+
Value: params.templateKey,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
Name: "X-Sequence-Id",
|
|
82
|
+
Value: params.sequenceId,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
ConfigurationSetName: params.configurationSetName,
|
|
88
|
+
EmailTags: [
|
|
89
|
+
{ Name: "templateKey", Value: params.templateKey.replace(/\//g, "--") },
|
|
90
|
+
{ Name: "sequenceId", Value: params.sequenceId },
|
|
91
|
+
],
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const messageId = result.MessageId ?? "unknown";
|
|
96
|
+
logger.info("Email sent successfully", {
|
|
97
|
+
messageId,
|
|
98
|
+
to: params.to,
|
|
99
|
+
templateKey: params.templateKey,
|
|
100
|
+
});
|
|
101
|
+
return messageId;
|
|
102
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SESv2Client,
|
|
3
|
+
PutSuppressedDestinationCommand,
|
|
4
|
+
type SuppressionListReason,
|
|
5
|
+
} from "@aws-sdk/client-sesv2";
|
|
6
|
+
import { createLogger } from "./logger.js";
|
|
7
|
+
|
|
8
|
+
const logger = createLogger("ses-suppression");
|
|
9
|
+
const ses = new SESv2Client({});
|
|
10
|
+
|
|
11
|
+
export async function addToSuppressionList(
|
|
12
|
+
email: string,
|
|
13
|
+
reason: "BOUNCE" | "COMPLAINT",
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
try {
|
|
16
|
+
await ses.send(
|
|
17
|
+
new PutSuppressedDestinationCommand({
|
|
18
|
+
EmailAddress: email,
|
|
19
|
+
Reason: reason as SuppressionListReason,
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
logger.info("Added to SES suppression list", { email, reason });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
logger.warn("Failed to add to SES suppression list", {
|
|
25
|
+
email,
|
|
26
|
+
reason,
|
|
27
|
+
error: error instanceof Error ? error.message : String(error),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
|
|
2
|
+
import { createLogger } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
const logger = createLogger("ssm-config");
|
|
5
|
+
const ssm = new SSMClient({});
|
|
6
|
+
const cache = new Map<string, { value: string; fetchedAt: number }>();
|
|
7
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
8
|
+
|
|
9
|
+
export async function getParameter(name: string): Promise<string> {
|
|
10
|
+
const cached = cache.get(name);
|
|
11
|
+
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
12
|
+
logger.debug("SSM cache hit", { parameter: name });
|
|
13
|
+
return cached.value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
logger.debug("Fetching SSM parameter", { parameter: name });
|
|
17
|
+
const result = await ssm.send(new GetParameterCommand({ Name: name, WithDecryption: true }));
|
|
18
|
+
|
|
19
|
+
const value = result.Parameter?.Value;
|
|
20
|
+
if (!value) {
|
|
21
|
+
logger.error("SSM parameter not found", { parameter: name });
|
|
22
|
+
throw new Error(`SSM parameter not found: ${name}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
cache.set(name, { value, fetchedAt: Date.now() });
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ResolvedConfig {
|
|
30
|
+
tableName: string;
|
|
31
|
+
eventsTableName: string;
|
|
32
|
+
templateBucket: string;
|
|
33
|
+
defaultFromEmail: string;
|
|
34
|
+
defaultFromName: string;
|
|
35
|
+
replyToEmail: string;
|
|
36
|
+
sesConfigSet: string;
|
|
37
|
+
unsubscribeBaseUrl: string;
|
|
38
|
+
unsubscribeSecret: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SSM_PREFIX = process.env.SSM_PREFIX ?? "/step-func-emailer";
|
|
42
|
+
|
|
43
|
+
export async function resolveConfig(): Promise<ResolvedConfig> {
|
|
44
|
+
logger.debug("Resolving config", { prefix: SSM_PREFIX });
|
|
45
|
+
|
|
46
|
+
const [
|
|
47
|
+
tableName,
|
|
48
|
+
eventsTableName,
|
|
49
|
+
templateBucket,
|
|
50
|
+
defaultFromEmail,
|
|
51
|
+
defaultFromName,
|
|
52
|
+
replyToEmail,
|
|
53
|
+
sesConfigSet,
|
|
54
|
+
unsubscribeBaseUrl,
|
|
55
|
+
unsubscribeSecret,
|
|
56
|
+
] = await Promise.all([
|
|
57
|
+
getParameter(`${SSM_PREFIX}/table-name`),
|
|
58
|
+
getParameter(`${SSM_PREFIX}/events-table-name`),
|
|
59
|
+
getParameter(`${SSM_PREFIX}/template-bucket`),
|
|
60
|
+
getParameter(`${SSM_PREFIX}/default-from-email`),
|
|
61
|
+
getParameter(`${SSM_PREFIX}/default-from-name`),
|
|
62
|
+
getParameter(`${SSM_PREFIX}/reply-to-email`).catch(() => ""),
|
|
63
|
+
getParameter(`${SSM_PREFIX}/ses-config-set`),
|
|
64
|
+
getParameter(`${SSM_PREFIX}/unsubscribe-base-url`),
|
|
65
|
+
getParameter(`${SSM_PREFIX}/unsubscribe-secret`),
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
logger.debug("Config resolved", { tableName, templateBucket });
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
tableName,
|
|
72
|
+
eventsTableName,
|
|
73
|
+
templateBucket,
|
|
74
|
+
defaultFromEmail,
|
|
75
|
+
defaultFromName,
|
|
76
|
+
replyToEmail,
|
|
77
|
+
sesConfigSet,
|
|
78
|
+
unsubscribeBaseUrl,
|
|
79
|
+
unsubscribeSecret,
|
|
80
|
+
};
|
|
81
|
+
}
|