Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

serverless-mail-send

A REST API for sending email, compatible with the Proofpoint Secure Email Relay (SER) email-submission send operation, backed by AWS SES.

The service is built with Quarkus, compiled to a GraalVM native executable, and deployed to AWS Lambda behind API Gateway using AWS CDK (Java). Tests emulate AWS using ministack.

API

POST /v1/send

Accepts a Proofpoint SER-compatible JSON body and sends the message via AWS SES.

{
  "messageId": "optional-message-id",
  "subject": "Hello",
  "from": { "email": "sender@example.com", "name": "Sender" },
  "headers": { "from": { "email": "noreply@example.com", "name": "No Reply" } },
  "content": [
    { "body": "Plain text body", "type": "text/plain" },
    { "body": "<p>HTML body</p>", "type": "text/html" }
  ],
  "tos": [ { "email": "to@example.com", "name": "To" } ],
  "cc": [ { "email": "cc@example.com" } ],
  "bcc": [ { "email": "bcc@example.com" } ],
  "replyTos": [ { "email": "reply@example.com" } ],
  "attachments": [
    {
      "content": "<base64-encoded-bytes>",
      "disposition": "attachment",
      "type": "text/plain",
      "filename": "note.txt"
    }
  ]
}

Required fields: subject, from, headers.from, content (at least one part), tos (at least one), and replyTos (may be an empty list). cc, bcc, attachments and messageId are optional. Inline attachments use "disposition": "inline" together with an id that is referenced as the MIME Content-ID.

Success — 200

{ "message_id": "<ses-message-id>", "reason": "accepted", "request_id": "<uuid>" }

Errors

Proofpoint SER-style body { "reason": ..., "request_id": ... } with status codes 400 (validation / malformed body), 413 (payload too large), 429, and 500.

Note: Authentication is handled at the API Gateway layer (API key, IAM, or a custom authorizer); the application itself does not implement the Proofpoint token endpoint.

API documentation (OpenAPI / Swagger UI)

The service exposes its own generated API documentation:

Path Description
/q/openapi OpenAPI 3.1 document (YAML; append ?format=json for JSON).
/q/swagger-ui/ Interactive Swagger UI.

These are served by the application itself, so they are reachable through the same API Gateway as /v1/send (e.g. <api-base>/q/swagger-ui/). Swagger UI is bundled into the native build via quarkus.swagger-ui.always-include=true, and it loads the spec from a relative URL (quarkus.swagger-ui.urls."default"=../openapi) so it keeps working behind the API Gateway stage prefix — an absolute /q/openapi would 404 there. For the same reason, a quarkus.swagger-ui.on-complete hook rewrites the topbar logo/title links (which Quarkus emits with an absolute /q/swagger-ui href) to a relative href at runtime. The OpenAPI document also declares a relative server (mp.openapi.servers=..) so that Swagger UI "Try it out" sends requests to <api-base>/v1/send (resolved against the spec location, including the stage prefix) instead of the origin root.

Configuration

Property Env var Default Description
quarkus.ses.aws.region AWS_REGION ap-southeast-2 SES region
quarkus.ses.endpoint-override SES_ENDPOINT_OVERRIDE (none) Override the SES endpoint (e.g. ministack)
quarkus.ses.aws.credentials.type SES_CREDENTIALS_TYPE default AWS credentials provider
mail.max-message-bytes MAIL_MAX_MESSAGE_BYTES 10485760 Max request body size before 413

SES requires verified sender identities; production sending also requires moving the account out of the SES sandbox.

Running in dev mode

./mvnw quarkus:dev

Testing

./mvnw verify

Integration tests start a ministack container via Testcontainers, point the SES client at it, send a message through POST /v1/send, and assert the captured email through ministack's /_ministack/ses/messages inspection endpoint. Docker is required.

Building the native Lambda package

The native profile builds the image inside a builder container by default, so only Docker is required (no local GraalVM):

./mvnw package -Dnative

If you have a local GraalVM with native-image installed and prefer to build on the host:

./mvnw package -Dnative -Dquarkus.native.container-build=false

This produces target/function.zip (containing the bootstrap executable) for the provided.al2023 custom Lambda runtime.

Deploying with CDK

The CDK app lives in infra/ and provisions the Lambda, API Gateway, IAM SES permissions and a log group. Build the native package first so target/function.zip exists. The API Gateway is configured with binaryMediaTypes=["*/*"] so that binary responses Quarkus base64-encodes (e.g. the Swagger UI logo.png/favicon) are decoded correctly; text responses (JSON, JS, CSS, the OpenAPI document) are passed through unchanged.

# 1. Build the Lambda package
./mvnw package -Dnative

# 2. Synthesize / deploy the stack
cd infra
npx aws-cdk synth
npx aws-cdk deploy

The stack outputs the API base URL; send requests to {ApiUrl}v1/send.

CDK context parameters

MailSendStack accepts optional context parameters (passed with -c key=value). When omitted, the production path is used unchanged. They exist mainly for the local integration environment, which points the deployed Lambda at the ministack emulator:

Context key Maps to env var Purpose
sesEndpointOverride SES_ENDPOINT_OVERRIDE Point the SES client at an emulator (e.g. ministack)
sesCredentialsType SES_CREDENTIALS_TYPE Credentials provider type (default, static)
lambdaArchitecture (Lambda architecture) x86_64 (default) or arm64

AWS_REGION and the AWS credential variables are reserved by the Lambda runtime and are injected automatically (by AWS or ministack), so they are not set via context.

Integration environment (deploy to ministack)

infra/integration/ provides a full, self-contained integration environment that builds the native Lambda, stands up ministack (a local AWS emulator), deploys the CDK stack into it, and runs an end-to-end smoke test: it invokes the deployed API Gateway URL with POST /v1/send and asserts the email is captured by ministack's SES.

Prerequisites

  • Docker (ministack runs the native Lambda via the Docker Lambda runtime), with the Docker socket at /var/run/docker.sock.
  • A JDK 25 (e.g. via SDKMAN) and Node.js (the CDK CLI runs through npx).

Run it locally

./infra/integration/integration-deploy.sh

Flags:

  • --build — force a fresh native build of target/function.zip.
  • --no-build — never build; require an existing native target/function.zip. The script verifies the artifact is a native build (contains bootstrap) and fails clearly if it is a JVM build (e.g. left over from ./mvnw verify).
  • --keep — leave ministack running (and skip teardown) for debugging. While it is up you can inspect captured email at http://localhost:4566/_ministack/ses/messages and Lambda logs via the CloudWatch Logs API against http://localhost:4566.

The script auto-detects the host architecture and builds/deploys a matching Lambda (arm64 on Apple Silicon, x86_64 on Intel/CI) so the native binary runs without slow qemu emulation. On a first run the Lambda cold start (and the provided.al2023 runtime image pull) can take a while, which the smoke test allows for with retries.

The same script runs in CI as the integration job (see .github/workflows/ci.yml), reusing the native package built by the native job.

Local development environment

infra/integration/local-env.sh reuses the same mechanism as the integration test (both share infra/integration/lib.sh: native build, ministack via docker-compose, and the CDK deploy with SES pointed at the emulator). Unlike the integration test, it is long-running — it leaves ministack and the deployed stack up so you can iterate, and exposes convenience subcommands instead of a one-shot smoke test plus teardown.

./infra/integration/local-env.sh up        # build (if needed) + start ministack + deploy
./infra/integration/local-env.sh send       # POST a sample /v1/send request
./infra/integration/local-env.sh ses        # show emails captured by ministack SES
./infra/integration/local-env.sh down       # stop ministack and remove its volumes
Command Description
up [--build|--no-build] Build native (if needed) + start ministack + deploy the stack.
deploy [--build|--no-build] Redeploy the stack to an already-running ministack.
redeploy Force a fresh native build and redeploy.
url [PATH] Print the API Gateway invoke URL (default path v1/send).
send [FILE] POST a /v1/send request (built-in sample, or JSON from FILE/stdin).
ses Show emails captured by ministack SES.
reset Reset ministack state (clears captured SES messages, etc.).
logs [args…] Tail ministack container logs (args pass through to docker compose logs).
status Report ministack health and whether the stack is deployed.
down Stop ministack and remove its volumes.

After editing application code, rebuild and redeploy with local-env.sh redeploy (or deploy --build). The build/deploy flags behave exactly as in the integration script, including the native-vs-JVM function.zip validation.

Related guides

About

Generated by code.quarkus.io

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages