The problem
Two problems that turn out to be one.
The site needed a contact form, and every convenient option is a third-party script — a form service, a CAPTCHA — which means a new origin in a content-security policy I’ve kept at default-src 'self' throughout this whole portfolio. Weakening it for a contact form would undo a decision I’d defended everywhere else.
And the site advertised a mailto: address on the domain. While building this I checked the DNS and found the domain had no MX records at all — not misconfigured, simply absent. That address had never been able to receive mail. Every message anyone sent there was lost silently, with no bounce that would reach me. The site had been advertising a dead address for months.
The architecture
POST /api/contact → Lambda → SES → my inbox, riding the /api/* CloudFront behavior that already exists. Same-origin, so the page posts to its own domain and the CSP is untouched.
Alongside it, the DNS that makes mail from this domain verifiable: SPF (which servers may send), DKIM (a signature proving the message wasn’t altered), and DMARC (what a receiver should do when those disagree with the visible From:, and where to report it).
Key decisions & trade-offs
SPF alignment is the part that’s easy to get silently wrong. SPF passes against the envelope sender, which for a default SES setup is an amazonses.com address. So a naive configuration passes SPF — and still fails DMARC, because the domain that passed isn’t the domain in From:. You’d be relying entirely on DKIM without realising it, and you’d only find out when deliverability quietly degraded. The fix is a custom MAIL FROM domain (mail.andruxa.dev), which moves the envelope sender under the From: domain so both mechanisms align. It’s set to REJECT_MESSAGE on MX failure rather than falling back to amazonses.com, because a silent fallback would break alignment invisibly.
p=quarantine, not p=none. Every DMARC guide starts at p=none, and that’s correct advice for a domain with existing mail flows you might break. This domain had none — no MX, no TXT, nothing — so there was no legacy sender to discover. Publishing p=none would have been cargo-culting a migration step for a migration that wasn’t happening. It’s not p=reject only because the aggregate reports are the evidence for that step, and it’s worth reading a few weeks of them first.
Staying in the SES sandbox, deliberately. I’d assumed this project was gated on a production-access support case with days of lead time. It isn’t: the sandbox restricts recipients, and a contact form only ever mails one address — mine. Verify that address and it works. Leaving sandbox would raise limits nobody needs while widening a bug’s blast radius from “can email me” to “can email strangers”. The 200/day cap is a free guardrail.
No CAPTCHA. The spam defence is a honeypot field real users can’t see, a timing check (a form submitted within seconds of rendering wasn’t typed by a person), a per-sender rate limit, and server-side size validation. Individually weak; together they stop the automated traffic that makes an unprotected form unusable. All four cost nothing and add no origin. Spam rejections deliberately return the same success response a real submission gets — telling a bot which check caught it is free tuning information.
The sender’s address goes in Reply-To, never From. Sending as the submitter’s domain would fail SPF and DMARC for them — precisely the abuse the rest of this project exists to prevent. It would also teach receivers that mail from my domain lies about who sent it.
The rate-limit table stores a hash, not an address. It only needs to answer “has this sender been seen recently”, which doesn’t require knowing who they are. A one-way hash with a one-hour TTL answers it exactly as well, and DynamoDB enforces the retention rather than my remembering to prune.
The bug that took four deploys
The first real submission failed with AccessDeniedException, and the handler logged only the error code — which is unactionable, because it names nothing. After adding the full AWS message, the answer was immediate and surprising:
not authorized to perform `ses:SendEmail' on resource
`arn:aws:ses:<region>:<account>:identity/<the recipient address>'
The denied resource was the recipient’s identity. In sandbox mode SES authorizes against the destination as well as the sender, so a policy granting send permission on the sending domain and the configuration set — everything the sender side needs — still denies every message. Nothing on the sender side hints at it.
Along the way I also removed a ses:FromAddress condition that looked like good least-privilege practice and was in fact a guaranteed denial: the SESv2 SendEmail API doesn’t populate that condition key, so StringEquals on it can never match and the statement becomes an implicit deny. aws iam simulate-principal-policy confirmed it — “allowed” with the key supplied, “implicitDeny” without. A condition that silently denies everything is worse than no condition, so it’s gone, with the reasoning written into the policy.
Cost breakdown
| Component | Monthly cost |
|---|---|
| SES sending ($0.10 per 1,000) | ~$0.00 |
| SES inbound for DMARC reports ($0.10 per 1,000) | ~$0.00 |
| Lambda + API Gateway (existing API, new route) | $0.00 |
| DynamoDB rate limiting (on-demand, TTL) | ~$0.01 |
| S3 for aggregate reports (90-day expiry) | ~$0.01 |
| Route 53 records in the existing zone | $0.00 |
| Total | ≈ $0.02/mo |
What I’d do differently at scale
The DMARC reports land in S3 as raw gzipped XML, which is honest but not useful — reading them means downloading and parsing by hand. The natural next step is a parser Lambda producing a small aggregate, the same shape as the analytics pipeline: who is sending as this domain, how much of it passes alignment, and whether anything is being spoofed. That data is also the evidence for moving p=quarantine to p=reject.
At real volume the honeypot-and-timing approach stops being enough, and the answer isn’t a CAPTCHA — it’s reputation scoring on the sending IP and content heuristics off the request path, so a legitimate sender is never asked to prove they’re human.
Links
- The artifact: andruxa.dev/contact
- Repo: github.com/andruxap/resume-website