Skip to content

Update lambda stack#3

Open
gerhc wants to merge 2 commits intomasterfrom
update-lambda-stack
Open

Update lambda stack#3
gerhc wants to merge 2 commits intomasterfrom
update-lambda-stack

Conversation

@gerhc
Copy link
Copy Markdown
Member

@gerhc gerhc commented Apr 14, 2026

Lambda Mailer — Modernization + Spam Fix

Context

The lambda-mailer repo is ~10 years old and was running on unmaintained tooling
(Zappa, Flask 1, Python 3.8). Bots were also bypassing the frontend entirely and
POSTing spam directly to the Lambda endpoint, since no server-side authentication
existed.

This PR modernizes the stack and adds a shared secret to block direct bot traffic.


What changed

Stack modernization

Before After
Python 3.8 (EOL) 3.12
Framework Flask 1 FastAPI + Mangum
Deployment Zappa (unmaintained) AWS SAM
Package manager Poetry uv
Linting Black Ruff
Tests None pytest + moto (14 tests)
  • api.py — rewritten with FastAPI, Pydantic request validation, CORS middleware,
    type hints, f-strings. Same behavior and honeypot logic as before.
  • handler.py — new Lambda entry point using Mangum (ASGI → Lambda adapter).
  • template.yaml — SAM template replacing zappa_settings.json. Defines Lambda,
    API Gateway, and a scoped IAM role (SES send only, no wildcards).
  • samconfig.toml — deploy config for dev and prod environments.
  • Makefile — shortcuts for deploy-dev, deploy-prod, test, lint, logs-dev,
    logs-prod. Reads secrets from .env automatically.
  • requirements.txt — added for SAM's Python builder to bundle dependencies.
  • .env.example — documents required secrets (never committed).

Spam fix — shared secret header

Bots were hitting the Lambda URL directly, bypassing reCAPTCHA and all frontend
validation. The fix is a shared secret (FORM_SHARED_SECRET) that the Next.js
server sends in every legitimate request via X-Form-Secret. Requests without
the correct header are rejected with 403.

Bot → POST Lambda directly → 403 (no secret) Browser → Next.js /api/contact → POST Lambda (with secret) → 200

The secret is never exposed to the browser.


Deployment

Two separate stacks are created — existing Zappa infrastructure is untouched:

Environment Stack
Dev lambda-mailer-v2-dev
Prod lambda-mailer-v2-prod
# One-time: generate secret and add to .env
openssl rand -hex 32  # → paste into .env as FORM_SHARED_SECRET

make deploy-dev
make deploy-prod

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Testing

make test   # 14 unit tests (validation, honeypot, CORS, secret auth)

Manual smoke test against the deployed endpoint:

# Should return {"status":"ok"}
curl -X POST <ApiUrl> \
  -H "Content-Type: application/json" \
  -H "X-Form-Secret: $(grep FORM_SHARED_SECRET .env | cut -d= -f2)" \
  -d @message.json

# Should return 403
curl -X POST <ApiUrl> -H "Content-Type: application/json" -d @message.json

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Required env vars (Lambda)

┌──────────────────────┬──────────────────────────────────────────────┐
│ Variable             │ Description                                  │
├──────────────────────┼──────────────────────────────────────────────┤
│ FROM_EMAIL           │ Verified SES sender address                  │
├──────────────────────┼──────────────────────────────────────────────┤
│ DESTINATION_EMAIL    │ Address to receive submissions               │
├──────────────────────┼──────────────────────────────────────────────┤
│ FORM_SHARED_SECRET   │ Shared secret (from .env, never committed)   │
├──────────────────────┼──────────────────────────────────────────────┤
│ ```                  │                                              │
└──────────────────────┴──────────────────────────────────────────────┘

gerhc added 2 commits April 10, 2026 16:14
┌───────────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────┐
│ File                          │ Action                                                                                 │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ pyproject.toml                │ Rewritten — Poetry→uv, Python 3.12, FastAPI+Mangum+boto3, ruff/mypy/pytest dev deps    │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ api.py                        │ Rewritten — Flask→FastAPI, Pydantic validation, CORS middleware, type hints, f-strings │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ handler.py                    │ New — Mangum Lambda entry point                                                        │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ template.yaml                 │ New — AWS SAM template (Lambda + API Gateway + scoped IAM)                             │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ samconfig.toml                │ New — SAM deploy config for dev/prod                                                   │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ tests/                        │ New — 14 pytest tests (validation, honeypot, CORS, errors)                             │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ README.md                     │ Updated — uv/SAM setup, deploy, test instructions                                      │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ .gitignore                    │ Modernized — added .aws-sam/, .ruff_cache/, .mypy_cache/                               │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ zappa_settings.json           │ Removed                                                                                │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ lambda-mailer-policy-*.json   │ Removed (IAM now inline in SAM template, scoped to SES identity)                       │
├───────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────┤
│ poetry.lock                   │ Removed (replaced by uv.lock)                                                          │
└───────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────┘
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Modernizes the legacy Lambda mailer by migrating from Flask/Zappa/Python 3.8 to FastAPI/Mangum/AWS SAM on Python 3.12, and adds a shared-secret header check to prevent direct-to-Lambda spam.

Changes:

  • Replaced Flask app with FastAPI + Pydantic validation and added X-Form-Secret auth gate.
  • Switched deployment tooling from Zappa to AWS SAM (SAM template + samconfig + Makefile).
  • Added pytest+moto test suite and updated packaging/tooling (uv/Ruff/Mypy/Hatch).

Reviewed changes

Copilot reviewed 13 out of 18 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
api.py Rewritten as FastAPI app; adds shared-secret auth, validation, CORS, SES sending logic.
handler.py New Mangum Lambda entrypoint for ASGI app.
template.yaml New SAM template defining Lambda, API Gateway events, env vars, and SES IAM policy.
samconfig.toml New SAM deploy configuration for dev/prod stacks.
Makefile Adds build/deploy/test/lint/logs shortcuts and injects secret from .env.
requirements.txt Adds deps for SAM Python builder.
pyproject.toml Migrates from Poetry to PEP 621 + Hatch, configures Ruff/Mypy/Pytest, declares deps.
tests/test_api.py Adds async tests for secret auth, validation, honeypot, CORS, and methods.
tests/conftest.py Sets required env vars for tests/moto and defines AWS credentials fixture.
tests/__init__.py Initializes test package.
README.md Updates setup/deploy/dev instructions for uv + SAM + secret header behavior.
.env.example Documents FORM_SHARED_SECRET for local/dev use.
.gitignore Updates ignored artifacts for uv/ruff/mypy/SAM workflows.
zappa_settings.json Removed legacy Zappa config.
poetry.lock Removed Poetry lockfile.
lambda-mailer-policy-dev.json Removed legacy IAM policy file (replaced by SAM template).
lambda-mailer-policy-prod.json Removed legacy IAM policy file (replaced by SAM template).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread api.py
if not re.match(EMAIL_REGEXP, data["email"]):
raise InvalidUsage('The "email" field needs to be a valid email address.')
@app.post("/", dependencies=[Depends(verify_secret)])
async def send_email(form: ContactForm) -> JSONResponse:
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route is declared async but calls boto3's synchronous ses.send_email, which will block the event loop. Consider making the endpoint a regular def (so FastAPI runs it in a threadpool) or explicitly offloading the SES call to a threadpool.

Suggested change
async def send_email(form: ContactForm) -> JSONResponse:
def send_email(form: ContactForm) -> JSONResponse:

Copilot uses AI. Check for mistakes.
Comment thread api.py
Comment on lines +77 to +79
# Honeypot: if "_important" is present, a bot likely submitted the form
if "_important" in extra_fields:
return JSONResponse(content={"status": "ok"}, status_code=200)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The honeypot path returns 200 but the tests don’t assert that no SES call is made. Add a test that verifies ses.send_email is not invoked when _important is present (e.g., by monkeypatching ses.send_email with a spy) so the anti-spam behavior is actually enforced.

Copilot uses AI. Check for mistakes.
Comment thread template.yaml
Comment on lines +5 to +11
Parameters:
Stage:
Type: String
Default: dev
AllowedValues:
- dev
- prod
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stage is defined as a parameter but not referenced anywhere in the template. This can confuse deployments (especially since samconfig.toml/Makefile pass it). Either remove it or wire it into resource naming/config (e.g., API stage name, log group names, or outputs).

Copilot uses AI. Check for mistakes.
Comment thread pyproject.toml
Comment on lines +15 to +24
[project.optional-dependencies]
dev = [
"pytest>=8,<9",
"moto[ses]>=5,<6",
"httpx>=0.28,<1",
"ruff>=0.9,<1",
"mypy>=1.13,<2",
"boto3-stubs[ses]>=1.35,<2",
"ipdb>=0.13,<1",
]
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two separate dev dependency mechanisms defined ([project.optional-dependencies].dev and [dependency-groups].dev). This can easily drift and makes uv sync --all-extras vs uv sync --group dev ambiguous. Consider consolidating into one approach and aligning the README install instructions accordingly.

Copilot uses AI. Check for mistakes.
Comment thread api.py
Comment on lines +38 to +39
if not FORM_SHARED_SECRET or x_form_secret != FORM_SHARED_SECRET:
raise HTTPException(status_code=403, detail="Forbidden")
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shared-secret comparison should use a constant-time check (e.g., secrets.compare_digest) to avoid timing leaks and to follow common secret-handling practices. Also consider normalizing the header value (e.g., stripping surrounding whitespace) before comparison if the client may include it.

Copilot uses AI. Check for mistakes.
Comment thread api.py
Comment on lines +50 to +55
@field_validator("name", "email", "message")
@classmethod
def must_not_be_blank(cls, v: str, info: object) -> str:
if not v.strip():
raise ValueError(f'The "{info.field_name}" field cannot be empty or spaces.') # type: ignore[union-attr]
return v
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validator uses info: object plus a type: ignore to access info.field_name, which undermines the strict mypy config. Use Pydantic's ValidationInfo type (and its supported API) for the info parameter so this stays type-safe without ignores.

Copilot uses AI. Check for mistakes.
Comment thread api.py
Comment on lines +81 to +93
other_fields_html = "\n".join(
f"<strong>{field}</strong>: {form.model_dump()[field]}<br>"
for field in sorted(extra_fields)
)

subject = "Tryolabs contact form message from {} ({})".format(
data["name"], data["email"]
)
message = """<strong>name</strong>: {}<br>
<strong>email</strong>: {}<br>
{}
subject = f"Tryolabs contact form message from {form.name} ({form.email})"
message_html = f"""<strong>name</strong>: {form.name}<br>
<strong>email</strong>: {form.email}<br>
{other_fields_html}
<p>
{}
{form.message.replace(chr(10), "<br>")}
</p>
""".format(
data["name"],
data["email"],
str_other_fields,
data["message"].replace("\n", "<br>"),
)
"""
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User-provided fields are interpolated directly into an HTML email body (other_fields_html / message_html) without escaping. This allows HTML injection in the generated email (e.g., <img>/links). Consider HTML-escaping all user-provided values before inserting them into the HTML body.

Copilot uses AI. Check for mistakes.
Comment thread README.md
Comment on lines +67 to +71
Or directly with uvicorn:

```bash
uv run uvicorn api:app --reload
```
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README suggests running uv run uvicorn api:app --reload, but uvicorn isn’t declared in the project’s dependencies/dev dependencies. Either add uvicorn (and any needed extras) to the dev dependency set, or update the docs to use an installed runner.

Copilot uses AI. Check for mistakes.
Comment thread pyproject.toml
[dependency-groups]
dev = [
"anyio>=4.13.0",
"pytest-anyio>=0.0.0",
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The [dependency-groups].dev section includes pytest-anyio>=0.0.0, which is unusual and may be unnecessary since AnyIO already provides a pytest plugin when installed. Consider removing it (or pinning to a real minimum version you rely on) to avoid resolver surprises.

Suggested change
"pytest-anyio>=0.0.0",

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants