Retiring Formspree: A Planned Zero-Port Contact Pipeline with Lambda, S3 & Stalwart
How I plan to replace the last third-party SaaS integration on my static sites—using a CloudFront-fronted Lambda, stateless WebCrypto proof-of-work, and direct RFC 5322 .eml drops into the existing S3 mail relay.
Blueprint Status (Planned Target Architecture): Like the Zero-Port Homelab Mail and Zero-Port Umami Analytics blueprints, this article documents a planned concept designed for stuurman.com and HOME-LAB.net. Today, stuurman.com still uses Formspree (and HOME-LAB.net’s contact endpoint is waiting on this setup); this pipeline is queued for implementation once the UniFi core network, ProxmoxProxmoxProxmox Virtual EnvironmentVirtualizationAn open-source Debian-based enterprise virtualization platform tightly coupling KVM hypervisors, LXC containers, ZFS storage, and clustered management. VE server consolidation, and Stalwart mail relay are online.
Once you map out self-hosted email and first-party analytics around a zero-inbound-port S3S3Simple Storage ServiceCloudAmazon Web Services scalable object storage offering 99.999999999% (11 9s) of data durability with fine-grained IAM policies and versioning. store-and-forward boundary, the remaining external dependencies on a static site stand out pretty quickly.
Right now, the /contact/ page on stuurman.com posts submissions to Formspree. Formspree is great when spinning up a static Astro site—you drop an endpoint URLURLUniform Resource LocatorNetworkingA specific type of Uniform Resource Identifier (URI) that both identifies a web resource and specifies the network protocol (e.g. https://) and hostname needed to retrieve it. into fetch() and move on—but keeping a third-party SaaSSaaSSoftware as a ServiceCloudA cloud delivery model in which centrally hosted software applications are licensed on a subscription basis and accessed directly via web browsers or APIs. form processor (capped at 50 messages a month on the free tier and storing visitor messages in an external database) feels redundant once an encrypted S3 mail relay and a local Stalwart mail server are already part of the plan.
Even better: because the planned homelab mail stack already pulls raw .eml files out of S3 into Stalwart, a first-party contact form doesn’t even need SMTPSMTPSimple Mail Transfer ProtocolNetworkingThe foundational Internet standard communication protocol for relaying and delivering outbound electronic mail across Mail Transfer Agents (MTAs). or ses:SendEmail.
Here is the concept for retiring Formspree cleanly.
1. Choosing the Right Edge Primitive: CloudFront Function vs. Lambda
Since my static sites already run a CloudFrontCloudFrontAmazon CloudFrontCloudAWS global content delivery network (CDN) that securely delivers data, static assets, and APIs at low latency with edge caching and Origin Access Control. Function (cloudfront-js-2.0) on every request to handle naked-to-www 301 redirects and directory index rewrites, my first thought was: can a CloudFront Function handle /api/contact directly?
The short answer is no, for two hard runtime reasons:
- No Request Body Access: CloudFront Functions execute on headers, cookies, URIsURIUniform Resource IdentifierNetworkingA standard string of characters providing an unambiguous identifier for a specific resource on a computer network or the internet., and query strings only—they cannot read an HTTPHTTPHypertext Transfer ProtocolNetworkingThe foundational stateless request-response application protocol of the World Wide Web used by browsers and APIs to fetch hypermedia documents and exchange data.
POSTpayload. - Zero Network or AWSAWSAmazon Web ServicesCloudAmazon global cloud computing platform offering on-demand compute, object storage, content delivery, and managed infrastructure services across dozens of geographic regions. SDKSDKSoftware Development KitDevOpsA distributable collection of client libraries, TypeScript/API type definitions, documentation, and CLI tooling that enables developers to integrate with a platform or cloud service. Access: The
cloudfront-js-2.0sandbox runs in sub-millisecond isolation at the edge POP with no outbound network access, so it cannot write to S3 or call SES.
Lambda@Edge can make network calls, but it truncates request bodies at 40 KB, requires deploying in us-east-1, and replicates slowly across regions.
Instead, the clean fit—matching the planned /api/send Umami ingestion route—is a CloudFront cache behavior (/api/contact) backed by a regional Node.js Lambda Function URL in Tokyo (ap-northeast-1):
- 100% Same-Origin: Submitting to
https://www.stuurman.com/api/contactrequires zero CORSCORSCross-Origin Resource SharingSecurityAn HTTP-header based security mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources.OPTIONSpreflight requests and zero third-party entries inContent-Security-Policy. - Locked Behind CloudFront OACOACOrigin Access ControlSecurityAWS CloudFront security mechanism that signs viewer requests with SigV4 to restrict Amazon S3 bucket access strictly to authorized CloudFront distributions. (
AWS_IAM): Setting the Lambda Function URL auth type toAWS_IAMand attaching a CloudFront Origin Access Control (lambdaSigV4SigV4AWS Signature Version 4SecurityAWS standard cryptographic protocol that authenticates HTTP requests using HMAC-SHA256 signatures derived from AWS credentials and request parameters. signing) means bots cannot bypass CloudFront to hammer the raw*.lambda-urlendpoint directly.
2. The Shortcut: Drop .eml Straight Into S3 (Skip ses:SendEmail)
Most serverless contact-form tutorials wire AWS Lambda to ses:SendEmail. That works, but it has an ugly side effect on a public web form: if a spammer floods your contact endpoint with garbage payloads or spoofed addresses, any resulting bounces or complaints count directly against your domain’s Amazon SES sender reputation—putting your legitimate outbound mail deliverability at risk. And if you are only emailing yourself, sending an SMTP message out to the internet just so it can loop right back into your own SES inbound receipt rule is completely unnecessary.
With the SES + S3 mail relay already planned, we can cut out the entire SMTP middleman:
Synthesizing a Clean RFC 5322 .eml Payload
Once the Lambda function validates a form submission, it strips CR/LF characters from every header field (preventing MIME header injection), builds a standard RFCRFCRequest for CommentsArchitectureThe formal publication series authored by the IETF and Internet Society that documents technical specifications, protocols, and best current practices for the Internet. 5322 .eml text payload, and drops it into the /contact/ prefix of the existing mail relay bucket:
import crypto from "node:crypto";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: "ap-northeast-1" });
const MAIL_BUCKET = process.env.MAIL_RELAY_BUCKET; // e.g. "home-lab-net-mail-relay"
// Strip CR/LF and control characters so user input can never inject MIME headers
const cleanHeader = (val = "", max = 200) =>
String(val).replace(/[\r\n\x00-\x1f]+/g, " ").trim().slice(0, max);
export function buildContactEml({ site, name, email, subject, message, viewerIp, country }) {
const safeName = cleanHeader(name, 80).replace(/"/g, "'");
const safeEmail = cleanHeader(email, 120);
const safeSubject = cleanHeader(subject || `New message via ${site}`, 140);
const msgId = `<${crypto.randomUUID()}@${site}>`;
const dateHeader = new Date().toUTCString();
return [
`From: "${site} Contact Form" <noreply@stuurman.com>`,
`To: <ed@stuurman.com>`,
`Reply-To: "${safeName}" <${safeEmail}>`,
`Subject: [Contact] ${safeSubject}`,
`Date: ${dateHeader}`,
`Message-ID: ${msgId}`,
`MIME-Version: 1.0`,
`Content-Type: text/plain; charset=utf-8`,
`Content-Transfer-Encoding: 8bit`,
`X-Contact-Form: true`,
`X-Contact-Site: ${cleanHeader(site, 60)}`,
`X-Contact-Country: ${cleanHeader(country, 8)}`,
`X-Contact-IP: ${cleanHeader(viewerIp, 64)}`,
``,
String(message).slice(0, 10000),
``,
].join("\r\n");
}
Look at what happens downstream for free:
- Zero New Homelab Daemons: Inside an LXCLXCLinux ContainersVirtualizationOS-level virtualization technology providing lightweight, near-bare-metal isolated Linux user spaces using kernel cgroups and namespaces. container on Proxmox, the exact same S3 pull worker that retrieves inbound
@home-lab.netemails picks up/contact/*.eml, hands it to Stalwart over LMTP, and deletes the S3 object. - Native One-Click Replies: Because
From:uses an internal address whileReply-To:holds the visitor’s email, hitting Reply in Roundcube or Apple Mail addresses the reply straight to the visitor. - Write-Only IAMIAMIdentity & Access ManagementSecurityA security framework of role-based and attribute-based policies controlling which identities (users, services, or roles) can perform actions on cloud resources. Isolation: The contact Lambda’s IAM role only needs one single action—
s3:PutObjectscoped to thecontact/*andratelimit/*prefixes. Even if the function had a bug, it cannotListBucketorGetObjecton any email in the bucket:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "WriteOnlyContactAndRateLimitLocks",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": [
"arn:aws:s3:::home-lab-net-mail-relay/contact/*",
"arn:aws:s3:::home-lab-net-mail-relay/ratelimit/*"
]
}
]
}
3. Stopping Spam & Rate-Limiting Without Third-Party CAPTCHAs or $6/mo AWS WAF
Replacing Formspree raises two immediate questions: what stops bots from filling up S3 with junk, and how do you rate-limit submissions without a database?
Standard CAPTCHA and WAFWAFWeb Application FirewallSecurityA Layer 7 security appliance or edge service that inspects HTTP traffic to block SQL injection, cross-site scripting (XSS), bot scraping, and application-layer floods. options clash with a fast, near-$0, zero-third-party static site:
- Google reCAPTCHA & Cloudflare Turnstile: Pull in 50–150 KB of external JavaScript, require
Content-Security-Policyexceptions, send visitor browser telemetry to third-party networks, and drag down PageSpeed scores on/contact/. - AWS WAF Captcha / Rate-Based Rules on CloudFront: Charge a fixed ~$6/month base fee just to attach a WebACL and rule—wiping out the $0.00 serverless economics before receiving a single message.
Instead, the planned design stacks four zero-widget, zero-database layers:
Layer 1: Lazy Stateless Proof-of-Work (HMAC + WebCrypto SHA-256)
This follows the open Hashcash / ALTCHA pattern using ~35 lines of native browser WebCrypto (crypto.subtle) and zero server-side database:
- Lazy Fetch on Focus (
0msPageSpeed Impact): Nothing runs when/contact/loads. Only when a visitor clicks or tabs into a form field (focusin) does the browser request a lightweight challenge (GET /api/contact). - Stateless HMACHMACHash-based Message Authentication CodeSecurityA cryptographic construction that combines a cryptographic hash function (such as SHA-256) with a secret key to simultaneously verify data integrity and authenticity. Challenge: Lambda generates a random
salt, the currenttimestamp, and a target puzzle hash (~50,000SHA-256iterations), signingHMAC-SHA256(SECRET, salt + timestamp + viewerIp)so Lambda never has to store challenge state in DynamoDB or Redis. - Invisible Solve While Typing (~100ms): While the human spends 15–30 seconds typing their message, the browser finds the matching nonce in ~100ms in the background—no “select all motorcycles” grid, no checkbox, and zero friction for screen readers.
- Sub-Millisecond Verification on
POST: Lambda checks the HMAC signature, verifies the timestamp is at least2.5 secondsold (confirming human typing cadence) and under15 minutesold (blocking token replay), and runs one singleSHA-256hash to verify the proof-of-work.
For a real person sending one message, 100ms on their device while typing is imperceptible. For a bot trying to blast 50,000 automated requests, computing billions of hashes breaks the economics completely.
Layer 2: Zero-Database Rate Limiting (Concurrency = 1 + S3 IfNoneMatch: "*")
You don’t need Redis or DynamoDB to enforce strict per-IP and global rate limits:
- Single-MicroVM In-Memory Guard (
ReservedConcurrentExecutions = 1): A contact Lambda finishes in ~40ms, so pinning concurrency to1still handles 20+ requests per second while funneling every request through a single warm Node.js instance. Because only one instance is active at a time, a module-levelMapin RAMRAMRandom Access MemoryHardwareHigh-speed volatile physical memory providing near-instantaneous read and write access for applications and operating system processes running on the CPU. reliably enforces both a per-IP cooldown (1 submission per 10 minutes) and a global circuit breaker (e.g., max 10 submissions per hour across all IPs combined) with zero S3 APIAPIApplication Programming InterfaceDevOpsA formal contract of endpoints, protocols, and data schemas that enables distinct software services to exchange commands and state predictably. calls on rejected traffic. - Cold-Start-Proof S3 Atomic Locks (
IfNoneMatch: "*"): To persist per-IP cooldowns across Lambda cold starts without adding DynamoDB ors3:GetObjectpermissions, Lambda leverages S3’s native conditional writes:
const window10m = Math.floor(Date.now() / 600_000); // 10-minute bucket slot
const ipHash = crypto.createHmac("sha256", SECRET).update(viewerIp).digest("hex").slice(0, 16);
// Atomically claim this IP's 10-minute slot in S3 (0-byte lock object)
await s3.send(
new PutObjectCommand({
Bucket: MAIL_BUCKET,
Key: `ratelimit/${window10m}-${ipHash}.lock`,
Body: "",
IfNoneMatch: "*", // Throws 412 PreconditionFailed if this IP already submitted in this window
})
);
If the same IP submits twice within a 10-minute window, S3 atomically rejects the second write with 412 PreconditionFailed and Lambda returns 429 Too Many Requests without writing a .eml file. A 24-hour S3 lifecycle rule on ratelimit/* cleans up the 0-byte lock files automatically. (And if volumetric edge throttling before Lambda invocation is ever needed, placing an API Gateway HTTP API stage with rate = 0.5 req/s, burst = 2 between CloudFront and Lambda drops floods at the AWS edge for $0.00).
Layer 3: Silent Honeypot & Browser Metadata Checks
- The existing hidden
_gotchainput inContactForm.astrostays in place: if populated, Lambda returns a decoy200 OKand discards the request without touching S3, so simple scrapers move on instead of probing further. - Requests missing
Sec-Fetch-Site: same-originor an allowedOriginheader (https://www.stuurman.com,https://www.home-lab.net) are rejected immediately.
Layer 4: Stalwart’s Spam Engine & Sieve Rules as the Backstop
Because every submission enters Stalwart inside the homelab as a normal .eml message with X-Contact-Form: true, Stalwart’s built-in Bayesian/URL heuristics and Sieve filters act as a final safety net—routing obvious SEOSEOSearch Engine OptimizationArchitectureThe practice of structuring HTML metadata, site architecture, and content crawlability to increase organic visibility across search engine results. link-spam straight to Junk and neatly tagging legitimate messages by site ([stuurman.com] vs. [home-lab.net]).
4. Architectural Comparison
| Dimension | Formspree (Current) | Lambda + ses:SendEmail | Planned Lambda → S3 .eml → Stalwart |
|---|---|---|---|
| Monthly Cost | Free up to 50 msgs/mo ($10+/mo after) | $0.00 (Free Tier / pennies) | $0.00 (Free Tier / pennies) |
| Third-Party Data Storage | Submissions stored in external SaaS | AWS only | Encrypted S3 buffer → Local ZFSZFSZettabyte File SystemStorageAn enterprise combined file system and logical volume manager featuring copy-on-write, data integrity checksumming, snapshots, and software RAID. |
| SES Sender Reputation Risk | None | High if form is spam-flooded | Zero (never touches outbound SMTP) |
| Bot & Rate Limiting | SaaS honeypot / 50-msg cap | Needs Turnstile, DynamoDB, or ~$6/mo WAF | WebCrypto PoW + S3 IfNoneMatch lock + Stalwart |
| PageSpeed / Third-Party JS | Clean until CAPTCHA added | Degrades if CAPTCHA widget loaded | 100 / 100 (0 KB third-party JS) |
| Homelab Offline Resilience | Held in Formspree / SES bounce | Bounces if inbound pipeline down | Queues durably in S3 (7-day lifecycle) |
5. Planned Build Checklist
Once the UniFi core network, Proxmox VE consolidation, and Stalwart S3 mail relay are up and running, cutting over /contact/ is a compact weekend project:
- Phase 1 — Contact Lambda, OAC & S3 Lifecycle (TerraformTerraformHashiCorp TerraformDevOpsAn open-source Infrastructure as Code (IaC) tool that provisions, manages, and versions multi-cloud resources declaratively via HashiCorp Configuration Language.): Deploy the Node.js 22
/api/contactLambda inap-northeast-1(ReservedConcurrentExecutions = 1, write-onlys3:PutObjectoncontact/*andratelimit/*, plus a 1-day expiry rule onratelimit/*) behind CloudFront withAWS_IAMOrigin Access Control. - Phase 2 — Stateless WebCrypto PoW & Rate Limiter: Implement the
GET /api/contactHMAC challenge, the in-RAM global/IP cap + S3IfNoneMatch: "*"lock in Lambda, and the lazyfocusincrypto.subtlesolver inContactForm.astro. - Phase 3 — Stalwart Sieve Filing: Add a Sieve rule in Stalwart matching
X-Contact-Form: trueto label messages byX-Contact-Sitewhile running normal URL/spam scoring. - Phase 4 — Retire Formspree & Enable Contact Endpoints: Swap
stuurman.com’s Formspree endpoint over to/api/contactand wire uphome-lab.net’s/contact/endpoint (while keeping external endpoint URLs supported as a configurable option inthe-navigatorstarter template).

About the author
Ed Stuurman
Infrastructure and reliability engineer based in Japan. Passionate about resilient systems, bare-metal hardware, cloud automation with Terraform, and all things homelab.
Contact
Thoughts or questions on this article?
Found a bug, tested a similar setup, or want to discuss the details? Drop a message below.