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.
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.
{ "message_id": "<ses-message-id>", "reason": "accepted", "request_id": "<uuid>" }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.
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.
| 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.
./mvnw quarkus:dev./mvnw verifyIntegration 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.
The native profile builds the image inside a builder container by default, so only
Docker is required (no local GraalVM):
./mvnw package -DnativeIf you have a local GraalVM with native-image installed and prefer to build on the host:
./mvnw package -Dnative -Dquarkus.native.container-build=falseThis produces target/function.zip (containing the bootstrap executable) for the
provided.al2023 custom Lambda runtime.
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 deployThe stack outputs the API base URL; send requests to {ApiUrl}v1/send.
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.
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.
- 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).
./infra/integration/integration-deploy.shFlags:
--build— force a fresh native build oftarget/function.zip.--no-build— never build; require an existing nativetarget/function.zip. The script verifies the artifact is a native build (containsbootstrap) 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 athttp://localhost:4566/_ministack/ses/messagesand Lambda logs via the CloudWatch Logs API againsthttp://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.
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.