Published11 min read

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.

Note

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, Proxmox 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 S3 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 URL into fetch() and move on—but keeping a third-party SaaS 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 SMTP 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 CloudFront 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:

  1. No Request Body Access: CloudFront Functions execute on headers, cookies, URIs, and query strings only—they cannot read an HTTP POST payload.
  2. Zero Network or AWS SDK Access: The cloudfront-js-2.0 sandbox 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/contact requires zero CORS OPTIONS preflight requests and zero third-party entries in Content-Security-Policy.
  • Locked Behind CloudFront OAC (AWS_IAM): Setting the Lambda Function URL auth type to AWS_IAM and attaching a CloudFront Origin Access Control (lambda SigV4 signing) means bots cannot bypass CloudFront to hammer the raw *.lambda-url endpoint 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:

3. Private Homelab Core (Zero Open WAN Ports)

2. Shared S3 Mail Buffer (ap-northeast-1)

1. Public Edge (AWS CloudFront + Lambda)

1. Focus Input -> GET /api/contact

2. Submit Form + PoW -> POST /api/contact

SigV4 OAC + Viewer Headers

3. Write-Only PutObject (.eml)

4. Outbound HTTPS 443 Pull

5. Local LMTP Ingest

Visitor Browser

AWS CloudFront Edge

Node.js Contact Lambda

home-lab-net-mail-relay (/contact/*.eml)

Existing S3 Mail Puller (LXC Container)

Stalwart Mail Server (Sieve + Spam Filter)

Roundcube & Mail Clients (Reply-To = Visitor)

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 RFC 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:

  1. Zero New Homelab Daemons: Inside an LXC container on Proxmox, the exact same S3 pull worker that retrieves inbound @home-lab.net emails picks up /contact/*.eml, hands it to Stalwart over LMTP, and deletes the S3 object.
  2. Native One-Click Replies: Because From: uses an internal address while Reply-To: holds the visitor’s email, hitting Reply in Roundcube or Apple Mail addresses the reply straight to the visitor.
  3. Write-Only IAM Isolation: The contact Lambda’s IAM role only needs one single action—s3:PutObject scoped to the contact/* and ratelimit/* prefixes. Even if the function had a bug, it cannot ListBucket or GetObject on 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 WAF 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-Policy exceptions, 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:

  1. Lazy Fetch on Focus (0ms PageSpeed 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).
  2. Stateless HMAC Challenge: Lambda generates a random salt, the current timestamp, and a target puzzle hash (~50,000 SHA-256 iterations), signing HMAC-SHA256(SECRET, salt + timestamp + viewerIp) so Lambda never has to store challenge state in DynamoDB or Redis.
  3. 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.
  4. Sub-Millisecond Verification on POST: Lambda checks the HMAC signature, verifies the timestamp is at least 2.5 seconds old (confirming human typing cadence) and under 15 minutes old (blocking token replay), and runs one single SHA-256 hash 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:

  1. Single-MicroVM In-Memory Guard (ReservedConcurrentExecutions = 1): A contact Lambda finishes in ~40ms, so pinning concurrency to 1 still 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-level Map in RAM 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 API calls on rejected traffic.
  2. Cold-Start-Proof S3 Atomic Locks (IfNoneMatch: "*"): To persist per-IP cooldowns across Lambda cold starts without adding DynamoDB or s3:GetObject permissions, 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 _gotcha input in ContactForm.astro stays in place: if populated, Lambda returns a decoy 200 OK and discards the request without touching S3, so simple scrapers move on instead of probing further.
  • Requests missing Sec-Fetch-Site: same-origin or an allowed Origin header (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 SEO link-spam straight to Junk and neatly tagging legitimate messages by site ([stuurman.com] vs. [home-lab.net]).


4. Architectural Comparison

DimensionFormspree (Current)Lambda + ses:SendEmailPlanned Lambda → S3 .eml → Stalwart
Monthly CostFree up to 50 msgs/mo ($10+/mo after)$0.00 (Free Tier / pennies)$0.00 (Free Tier / pennies)
Third-Party Data StorageSubmissions stored in external SaaSAWS onlyEncrypted S3 buffer → Local ZFS
SES Sender Reputation RiskNoneHigh if form is spam-floodedZero (never touches outbound SMTP)
Bot & Rate LimitingSaaS honeypot / 50-msg capNeeds Turnstile, DynamoDB, or ~$6/mo WAFWebCrypto PoW + S3 IfNoneMatch lock + Stalwart
PageSpeed / Third-Party JSClean until CAPTCHA addedDegrades if CAPTCHA widget loaded100 / 100 (0 KB third-party JS)
Homelab Offline ResilienceHeld in Formspree / SES bounceBounces if inbound pipeline downQueues 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 (Terraform): Deploy the Node.js 22 /api/contact Lambda in ap-northeast-1 (ReservedConcurrentExecutions = 1, write-only s3:PutObject on contact/* and ratelimit/*, plus a 1-day expiry rule on ratelimit/*) behind CloudFront with AWS_IAM Origin Access Control.
  • Phase 2 — Stateless WebCrypto PoW & Rate Limiter: Implement the GET /api/contact HMAC challenge, the in-RAM global/IP cap + S3 IfNoneMatch: "*" lock in Lambda, and the lazy focusin crypto.subtle solver in ContactForm.astro.
  • Phase 3 — Stalwart Sieve Filing: Add a Sieve rule in Stalwart matching X-Contact-Form: true to label messages by X-Contact-Site while running normal URL/spam scoring.
  • Phase 4 — Retire Formspree & Enable Contact Endpoints: Swap stuurman.com’s Formspree endpoint over to /api/contact and wire up home-lab.net’s /contact/ endpoint (while keeping external endpoint URLs supported as a configurable option in the-navigator starter template).
Ed Stuurman

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.

Emails are delivered directly to ed@stuurman.com.