Conversation
┌───────────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────┐ │ 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) │ └───────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────┘
There was a problem hiding this comment.
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-Secretauth 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.
| 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: |
There was a problem hiding this comment.
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.
| async def send_email(form: ContactForm) -> JSONResponse: | |
| def send_email(form: ContactForm) -> JSONResponse: |
| # Honeypot: if "_important" is present, a bot likely submitted the form | ||
| if "_important" in extra_fields: | ||
| return JSONResponse(content={"status": "ok"}, status_code=200) |
There was a problem hiding this comment.
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.
| Parameters: | ||
| Stage: | ||
| Type: String | ||
| Default: dev | ||
| AllowedValues: | ||
| - dev | ||
| - prod |
There was a problem hiding this comment.
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).
| [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", | ||
| ] |
There was a problem hiding this comment.
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.
| if not FORM_SHARED_SECRET or x_form_secret != FORM_SHARED_SECRET: | ||
| raise HTTPException(status_code=403, detail="Forbidden") |
There was a problem hiding this comment.
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.
| @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 |
There was a problem hiding this comment.
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.
| 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>"), | ||
| ) | ||
| """ |
There was a problem hiding this comment.
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.
| Or directly with uvicorn: | ||
|
|
||
| ```bash | ||
| uv run uvicorn api:app --reload | ||
| ``` |
There was a problem hiding this comment.
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.
| [dependency-groups] | ||
| dev = [ | ||
| "anyio>=4.13.0", | ||
| "pytest-anyio>=0.0.0", |
There was a problem hiding this comment.
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.
| "pytest-anyio>=0.0.0", |
Lambda Mailer — Modernization + Spam Fix
Context
The
lambda-mailerrepo 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
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 replacingzappa_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 fordeploy-dev,deploy-prod,test,lint,logs-dev,logs-prod. Reads secrets from.envautomatically.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.jsserver sends in every legitimate request via
X-Form-Secret. Requests withoutthe 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:
lambda-mailer-v2-devlambda-mailer-v2-prod