June 5th - Blog Subscription Service - Part 4: The Lambda Code
This post covers both Lambdas in the system: the subscribe Lambda that handles subscribe, confirm, and unsubscribe flows, and the poller that runs daily to email confirmed subscribers when a new post goes out.
Subscribe Lambda
API Gateway routes all three endpoints to the same Lambda function. The handler reads the route from the event and dispatches:
def handler(event, context):
route = event.get("routeKey", "")
if route == "POST /subscribe":
return handle_subscribe(event)
elif route == "GET /confirm":
return handle_confirm(event)
elif route == "GET /unsubscribe":
return handle_unsubscribe(event)
return {"statusCode": 404, "body": json.dumps({"message": "Not found."})}
Example code: subscribe/index.py
Subscribe: POST /subscribe
The subscribe flow:
- Parse the email from the request body
- Validate format (regex) and length (254-char cap per RFC 5321)
- Validate the Cloudflare Turnstile token (more on this below)
- Generate a UUID token
- Write to DynamoDB with
confirmed = false - Send a confirmation email via SES with a link containing the token
Duplicate signups return the same generic success response as a new signup. The initial code Claude generated returned {"message": "Already subscribed."} for duplicates, but that's visible in browser dev tools even when the frontend hides it, which leaks subscriber status to anyone who pokes the API directly. Fixed by returning an identical response either way.
Turnstile
The subscribe endpoint is public, so it needs abuse protection beyond rate limiting. Cloudflare Turnstile (managed mode) is added to the subscribe form. The widget runs in the browser and produces a token that the Lambda verifies with Cloudflare's siteverify API before doing anything else.
The Turnstile secret is stored in SSM Parameter Store as a SecureString. It's fetched outside the Lambda handler so it's reused across warm invocations. SSM standard parameters have no per-call cost, but the fetch still adds latency on cold starts, so keeping it out of the hot path matters.
Confirm: GET /confirm
The confirm link in the email points to mydomain.com/confirm?token=...&email=..., a Docusaurus page that calls the API on load and renders a success or error message. (Claude's initial implementation pointed directly to the API endpoint, which just dumped raw JSON at the user.)
The Lambda confirm flow:
- Extract
tokenandemailfrom query string parameters - Validate UUID format on the token before touching DynamoDB
- Look up the subscriber by email
- Verify the stored token matches
- Update
confirmed = truein DynamoDB
Token and email are URL-encoded in the confirmation link to handle special characters safely.
Unsubscribe: GET /unsubscribe
The unsubscribe link in every email footer works the same way: GET /unsubscribe?token=...&email=.... The Lambda validates the token, then deletes the item from DynamoDB.
A DynamoDB Gotcha
token is a DynamoDB reserved word. Using it directly in a ConditionExpression throws a ValidationException at runtime, not at deploy time and not in local testing, unless you're hitting real DynamoDB. The fix is to use ExpressionAttributeNames:
response = table.update_item(
Key={"email": email},
ConditionExpression="attribute_exists(email) AND #tok = :token",
ExpressionAttributeNames={"#tok": "token"},
ExpressionAttributeValues={":token": token, ":confirmed": True},
UpdateExpression="SET confirmed = :confirmed",
)
This one only showed up after the first real confirmation attempt.
SES Sandbox
On the first test send, SES returned MessageRejected. In sandbox mode, both the sender AND recipient address must be verified in the console: it's not enough to have a verified sending domain. Either verify the recipient manually or request production access. For a personal blog newsletter with double opt-in, production access was approved the same day within a few hours.
Cost
At small scale, the subscribe Lambda costs essentially nothing:
- Lambda - invocations are on-demand and well within free tier
- DynamoDB - a handful of reads and writes per subscriber action
- SES - $0.10 per 1,000 emails; confirmation emails are one per signup
- SSM - standard parameters are free; the Turnstile secret fetch adds no cost
- Cloudflare Turnstile - free
Poller Lambda
The poller is a second Lambda, fired by EventBridge on a daily cron. It fetches the RSS feed, checks whether anything is new, emails every confirmed subscriber if so, and updates a cursor.
def handler(event, context):
last_seen = get_last_seen()
new_posts = fetch_new_posts(last_seen)
if not new_posts:
return
subscribers = get_confirmed_subscribers()
for post in new_posts:
send_to_all(post, subscribers)
update_last_seen(post["published"])
Example code: poller/index.py
Fetching the RSS Feed
The feed is fetched with urllib.request.urlopen with a 10-second timeout. No third-party dependencies: the feed is straightforward XML and xml.etree.ElementTree handles it fine.
Error handling covers the two likely failure modes:
try:
with urlopen(RSS_URL, timeout=10) as response:
xml_content = response.read()
except URLError as e:
logger.error("Failed to fetch RSS feed: %s", e)
raise
ET.ParseError is also caught separately in case the feed is reachable but returns malformed XML. In both cases the Lambda raises so EventBridge can log the failure. Silent swallowing would make it hard to notice the poller has stopped working.
Diffing Against last_seen
last_seen is stored as an ISO 8601 timestamp in DynamoDB, in a dedicated item with a fixed partition key (SYSTEM#last_seen). It's separate from subscriber records: same table, different key prefix.
On first run, last_seen doesn't exist yet. The poller treats a missing value as "never run" and emails all posts in the feed. In practice this means you should set last_seen manually (or let the first run email everything) before subscribing real users.
The diff is a simple comparison: post["published"] > last_seen. Posts are sorted oldest-first so emails go out in chronological order if multiple new posts exist on the same day.
Querying Confirmed Subscribers
The poller uses the confirmed GSI to query only confirmed subscribers, avoiding a full table scan:
response = table.query(
IndexName="confirmed-index",
KeyConditionExpression=Key("confirmed").eq(True),
)
The query handles LastEvaluatedKey pagination so it works correctly as the subscriber list grows:
kwargs = {"IndexName": "confirmed-index", "KeyConditionExpression": Key("confirmed").eq(True)}
while True:
response = table.query(**kwargs)
subscribers.extend(response["Items"])
last_key = response.get("LastEvaluatedKey")
if not last_key:
break
kwargs["ExclusiveStartKey"] = last_key
Sending the Email
Each confirmed subscriber gets a plain-text and HTML email with the post title, a short excerpt from the RSS description, a link to the post, and an unsubscribe link in the footer. No tracking pixels, no open rates, no click tracking.
The unsubscribe link is constructed from the subscriber's stored token:
https://mydomain.com/unsubscribe?email=...&token=...
Both email and token are URL-encoded. The HTML email body escapes RSS content (title, description, link) before embedding, as RSS feeds occasionally contain characters that would break an HTML template.
SES failure is caught per-subscriber. If a send fails, the error is logged and last_seen is not advanced for that post, so the next run will retry. This means failed sends can result in duplicate emails if the failure was transient, but it's preferable to silently skipping subscribers.
Idempotency
If EventBridge fires the Lambda twice in a day (rare but possible), the second run will find last_seen already advanced and send nothing. That's the correct behavior.
last_seen only advances after a successful send batch. If the Lambda errors mid-run, it will retry the whole batch on the next invocation. At the scale of a personal blog newsletter, occasional duplicate emails are acceptable. The alternative (distributed transactions across SES and DynamoDB) isn't worth it.
Cost
At small scale, the poller costs essentially nothing:
- Lambda - one invocation per day, well within free tier
- DynamoDB - a handful of reads and writes per day
- EventBridge - first 14 million events/month are free
- SES - $0.10 per 1,000 emails; at 100 subscribers that's $0.01/day if posting daily
Hardening
The items below were either caught during review of Claude's generated code or checked for based on architecture:
No SQL injection surface (both Lambdas): DynamoDB has no query language that accepts raw strings. All operations use the AWS SDK's structured API: keys, update expressions, and condition expressions are parameterized via ExpressionAttributeValues, so user input is always passed as a typed value and never interpolated into an expression. There is nothing to inject into.
Input validation (subscribe Lambda): email format checked with a regex, hard-capped at 254 characters. UUID format checked on tokens before any DynamoDB call. This avoids wasting a DynamoDB read on obviously invalid input.
Domain typo detection (subscribe Lambda): a lookup table of ~40 common misspellings across Gmail, Yahoo, Hotmail, Outlook, and iCloud is checked before anything else. If the submitted domain matches a known typo, the endpoint returns a 400 with a "Did you mean user@gmail.com?" message instead of silently accepting an address that will never receive the confirmation email. This is purely a UX call: a valid-format address that happens to be a fat-finger will pass regex validation just fine, so the only way to catch it is an explicit list.
URL encoding (both Lambdas): email and token are URL-encoded in confirmation and unsubscribe links via urllib.parse.quote. Without this, special characters in email addresses (valid per RFC) would silently corrupt the links.
HTML escaping (both Lambdas): confirmation URLs embedded in HTML email bodies are escaped before insertion. RSS content in the poller (title, description, link) is escaped before going into the HTML template.
No email enumeration (subscribe Lambda): subscribe returns the same response whether the address is new or already exists.
Token design (subscribe Lambda): tokens are UUIDs generated at subscribe time, stored in DynamoDB alongside the subscriber record, and validated on confirm and unsubscribe. They're not guessable, not reused, and not derived from the email address.
SES failure handling (both Lambdas): Claude's initial code silently returned 200 if SES threw an exception. Both Lambdas now catch, log, and return 500.
Reserved concurrency: not applied. AWS reserved concurrency caps and reserves at the same time, there's no way to just limit without also reserving capacity. The account's total concurrency limit is exactly 10, which is the minimum AWS requires to remain unreserved across the account, so setting any reserved concurrency at all fails with a 400. API Gateway throttling is the blast radius protection here instead.
source_account on Lambda permissions (both Lambdas): API Gateway and EventBridge are shared AWS services. Without a source_account condition on the Lambda invoke permission, the permission effectively says "any account's API Gateway can invoke this function." Adding source_account = your_account_id scopes it to your account only, preventing a confused deputy attack where a malicious actor with your Lambda ARN configures their own API Gateway to invoke it.
Hardcoded domain (poller Lambda): the initial code suggested by Claude hardcoded the API domain directly in the Lambda. Fixed by reading API_DOMAIN from an environment variable, which Terraform sets from a variable. Keeps the code deployable to a different domain without touching the source.
Real-World Observation: Junk Filtering
SPF, DKIM, and DMARC all passed on the first live send (Terraform configures all three). Despite that, Thunderbird flagged the confirmation email as junk (found this thanks to a brave volunteer). A brand new sending domain with zero history has no reputation signal for mail clients to work with, so some will err on the side of caution regardless of authentication results. Not much to do about this except send mail consistently over time and let the reputation build.
An improvement I made over Claude's initial code: adding a List-Unsubscribe header to all outbound emails. It gives mail clients a machine-readable unsubscribe path and is a strong signal that you're a legitimate sender. SES's send_email API doesn't support custom headers, so this required switching to send_raw_email and constructing the MIME message manually using Python's stdlib email.mime classes. More code, but no new dependencies. The List-Unsubscribe-Post header enables one-click unsubscribe (RFC 8058), which major mail clients now expect.
Wrapping Up
That's the full system: four posts covering a weekend project that now actually runs in production. The architecture isn't novel, but the details in each layer are where the interesting decisions live: OIDC over static keys, GSI over scan, double opt-in over server-side trust, Turnstile over WAF.
I refuse to hide the fact that I leveraged Claude to scaffold out the starting code, but there are critical aspects to my workflow that I adhere to when using any AI tool(sounds like a good blog post topic for the future). The issues flagged in this post - email enumeration, raw JSON confirmation responses, silent SES failure, missing List-Unsubscribe, are a good illustration of what that collaboration actually looks like in practice. The generated code was a solid starting point, but knowing enough about the problem to spot where it fell short mattered just as much as the speed of getting there.
- Part 1: The Why and the What
- Part 2: OIDC Authentication and the Terraform CI Pipeline
- Part 3: Terraform Infrastructure Deep Dive
This is part of the Blog Subscription Service series.

Keep the coffee flowing