AWS SES (email)
Outbound and inbound email go through Amazon SES (SESv2 API). You need an AWS account and the SES service enabled in one region. You also need IAM-role credentials (recommended for EC2/ECS/EKS deployments) or a long-lived access key.
1. Credentials
The Python SDK uses the standard boto3 credential chain. If you run on AWS infrastructure with an attached IAM role, leave the keys empty in .env. Otherwise, set:
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIA…
AWS_SECRET_ACCESS_KEY=…Minimal IAM policy (one statement is sufficient for the v1 surface):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:CreateEmailIdentity",
"ses:GetEmailIdentity",
"ses:DeleteEmailIdentity"
],
"Resource": "*"
}
]
}2. Sandbox vs production
New SES accounts start in the sandbox: 200 messages/day, 1 message/second, and you can send only to verified addresses. When you are ready to send to arbitrary recipients, request production access from SES → Account dashboard → Request production access.
3. Why mail.hail.so (a subdomain), not hail.so
Always send transactional mail from a dedicated subdomain, not from your apex domain. There are two reasons:
- Reputation isolation. If a bounce spike or a spam-report cluster damages the sender reputation, only the subdomain takes the damage. Your apex domain (website, marketing email if any) stays clean.
- Standard practice. Postmark uses
mtasv.net, and SendGrid usessendgrid.net. Resend has tenants verify their own subdomains. Do not mix transactional volume with brand traffic on the apex domain — it is a known risk.
The recommended subdomain for the Hail public cloud is mail.hail.so. Self-hosters: select a subdomain that you own, for example mail.<your-domain>.
4. Set up the SES identity
This is operator setup, done once per deployment. Hail does not configure SES for the parent HAIL_MAIL_BASE_DOMAIN. That identity must exist, and be verified, before the first POST /emails. The operator also configures the MAIL FROM subdomain. Custom tenant domains follow a separate, fully automated flow: Hail calls CreateEmailIdentity and configures their MAIL FROM (refer to §7).
- Open AWS Console → SES → Verified identities → Create identity → Domain. Enter the bare subdomain (
mail.hail.so). Enable DKIM (the default; keep the bit-length at 2048). - SES returns three CNAMEs of the form
<token>._domainkey.mail.hail.so → <token>.dkim.amazonses.com. Publish all three at your DNS provider. Wait until SES sets the status to Verified (usually less than 1 hour). - Configure a custom MAIL FROM domain — this step is mandatory for production deliverability (refer to §5 below). On the identity detail page, select Edit MAIL FROM and set the value to
bounces.mail.hail.so. SES returns one MX record and one TXT record. Publish both at DNS:bounces.mail.hail.so MX 10 feedback-smtp.us-east-1.amazonses.com bounces.mail.hail.so TXT "v=spf1 include:amazonses.com ~all" - Wait until the MAIL FROM domain status is Success.
This MAIL FROM subdomain —
bounces.mail.hail.soon the operator parent — is operator-managed: you configure it once, manually, in the steps above. Custom tenant domains are different: Hail configures their MAIL FROM automatically.POST /email-domains(kind=custom) callsPutEmailIdentityMailFromAttributesforsend.<domain>and returns the MX + SPF records to publish together with the DKIM CNAMEs.POST /email-domains/{id}/verifythen re-polls both the DKIM status and the MAIL FROM status. Refer to §7.
5. DMARC alignment (required for inbox delivery)
Without an aligned MAIL FROM, SES uses <random>@amazonses.com as the Return-Path. SPF then authenticates against amazonses.com, not against your domain. This breaks DMARC alignment, and the DMARC policies of recipients push your mail to spam.
With bounces.mail.hail.so as MAIL FROM:
- SPF authenticates against
bounces.mail.hail.so→ aligns with the From-domainmail.hail.so. - DKIM signs with
mail.hail.so→ aligns. - Both aligned → DMARC
pass→ inbox.
Publish a DMARC record. Start at p=none (monitor-only). Increase the policy after some weeks of clean reports:
_dmarc.mail.hail.so TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@hail.so; adkim=s; aspf=s"Field summary: p=none reports but does not block (start here). quarantine moves unaligned mail to spam. reject drops it. adkim=s and aspf=s require strict alignment between the DKIM/SPF authentication domain and the From domain.
TODO(dmarc-ratchet): Hail's public
mail.hail.sois currently atp=none. After 30 or more days of clean DMARC aggregate reports (no unauthenticated mail inrua=feeds), step the policy top=quarantine. Monitor for 30 more days, then move top=reject. Self-hosters must follow the same staged rollout on their own subdomain.
6. Configure Hail
HAIL_MAIL_BASE_DOMAIN=mail.hail.so
# Single-variable form (self-hosters running a SINGLE org):
HAIL_MAIL_FROM=admin+selfhost@mail.hail.so
# Multi-tenant: leave HAIL_MAIL_FROM unset. The org prefix is derived
# per-org from the organization id; set only the default user prefix:
# HAIL_MAIL_DEFAULT_USER_PREFIX=admin
AWS_REGION=us-east-1Hail-mail addresses always have the shape <user>+<org>@<HAIL_MAIL_BASE_DOMAIN> — for example alice+acme@mail.hail.so. Hail validates both <user> and <org> against ^[a-z0-9]([a-z0-9-]{0,18}[a-z0-9])?$ (1–20 characters, lowercase alphanumeric plus hyphens, no leading or trailing hyphen).
Precedence at send time (highest wins):
- User prefix: explicit
local_prefix_user→HAIL_MAIL_FROM(user part) →HAIL_MAIL_DEFAULT_USER_PREFIX. - Org prefix: explicit
local_prefix_org→HAIL_MAIL_FROM(org part, single-tenant) → derived per-org from the organization id. The org prefix is never a deploy-wide constant — that constant would make every org collide on one address.
Self-host vs managed
Self-hosters: there is no console, so the env vars are the configuration. Set them in .env once, then restart. POST /emails then works without a prior POST /email-domains — the server auto-mints a hail-mail row from the env defaults on the first send.
Managed cloud: the website provisions the hail-mail row of each org at signup. It calls POST /email-domains with prefixes derived from the org slug and the user identity. Org admins then change the visible address through the console, which writes via PATCH /email-domains/{id}. The env vars provide deploy-time defaults but rarely surface to tenants directly.
7. Custom (tenant) domains
Tenants can register their own DNS-controlled domain:
curl -X POST $HAIL_API_URL/email-domains \
-H "Authorization: Bearer $HAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"kind":"custom","domain":"acme.com"}'The response returns the full DNS record set to publish:
- three DKIM
_domainkeyCNAMEs —<token>._domainkey.acme.com → <token>.dkim.amazonses.com; - a MAIL FROM MX + SPF TXT on
send.acme.com— Hail configures the custom MAIL FROM automatically, so there is no AWS-console step for the tenant:send.acme.com MX 10 feedback-smtp.<region>.amazonses.com send.acme.com TXT "v=spf1 include:amazonses.com ~all"
After the tenant publishes all records, the tenant calls POST /email-domains/{id}/verify to re-poll SES for both the DKIM status and the MAIL FROM status.
hail email domain register --kind custom --domain acme.com
# → prints the DKIM CNAMEs + the send.acme.com MAIL FROM records in a copy-pastable table
hail email domain verify <id>
# → re-polls SES; flips the row to verified once the records are liveInbound on a custom domain: after the row is
verified, enable inbound (forward_toand/or a webhook) to receive mail. Matching is by identity, so each receiving domain yields its own inbound row + webhook. Receiving still relies on the operator's region-wide SES receipt rule (§10).
8. Send
hail email send --to alice@example.com --subject "hi" --body "hello"Or via HTTP:
curl -X POST $HAIL_API_URL/emails \
-H "Authorization: Bearer $HAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["alice@example.com"],
"subject": "hi from Hail",
"body_text": "this just works"
}'The from field is optional. Resolution order:
- Explicit
from— must match averifiedemail_domain row owned by the caller's org. - The first verified org-owned domain (ordered by
created_at, so the default sender stays stable when you add more domains). - The auto-minted hail-mail row, if
HAIL_MAIL_BASE_DOMAINand the prefixes are configured.
If none of these resolve, the call returns 503 with instructions to register a domain.
8a. Attachments
Upload a file once and attach it to as many sends as you want. The file size limit is 10MB per upload. SES caps the total message size (including all attachments) at its 10MB raw-message limit.
Upload a file
curl -s -X POST $HAIL_API_URL/email-attachments \
-H "Authorization: Bearer $HAIL_API_KEY" \
-F "file=@invoice.pdf" | jq -r .id
# → "3fa85f64-5717-4562-b3fc-2c963f66afa6"The response is a JSON object with an id field (UUID). Store this id to reference the attachment in sends.
Attach to a send
Pass attachment_ids (a list of UUIDs) in the POST /emails payload:
curl -X POST $HAIL_API_URL/emails \
-H "Authorization: Bearer $HAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["alice@example.com"],
"subject": "Invoice",
"body_text": "See attached.",
"recipient_consent": true,
"attachment_ids": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]
}'You can attach the same uploaded file to multiple sends without a new upload. CLI shortcut — upload and attach in one step:
hail email send --to alice@example.com --subject "Invoice" --body "See attached." --attach invoice.pdfLifecycle
Hail garbage-collects unused uploads (not attached to any send) 24 hours after upload. After you attach the file to a send, Hail retains it indefinitely. You can reuse it across as many messages as you want.
9. What v1 does not do
Skip these until later milestones. This list names them so that you do not use SES features that are not wired yet:
- Templates — the API takes raw
body_text/body_html. SES templates are a v2 request. - Cloud-agnostic inbound — the SMTP listener is stubbed at
docs/setup/smtp-inbound.md; inbound currently runs on AWS only.
10. Inbound email
To receive mail at <user>+<org>@<HAIL_MAIL_BASE_DOMAIN>, you need four things:
- An MX record on
mail.hail.sothat points at SES inbound. - An S3 bucket that SES can write raw MIME into.
- A SES Receipt Rule that writes the object and invokes a Lambda.
- A small Lambda that signs the SES event and POSTs it to Hail.
A Terragrunt wrapper at infra/terragrunt.hcl automates provisioning
around the bare Terraform module in infra/terraform/. The wrapper
configures an S3-backed remote state with a DynamoDB lock table. It
pulls every input from the repo's .env — no parallel tfvars file.
10.1 Terragrunt apply
# .env must contain at minimum: AWS_PROFILE, AWS_REGION,
# HAIL_TERRAFORM_STATE_BUCKET, HAIL_TERRAFORM_LOCK_TABLE, HAIL_API_URL,
# HAIL_INBOUND_HMAC_SECRET (generate: openssl rand -hex 32),
# HAIL_MAIL_BASE_DOMAIN.
cd infra
terragrunt init # Terragrunt sources .env automatically via
terragrunt plan # `run_cmd`; no manual export step.
terragrunt applyDo a one-time bootstrap per AWS account before the first terragrunt init:
the state bucket + lock table do not auto-create. Refer to the comment block
at the top of infra/terragrunt.hcl for
the AWS CLI one-liners. Refer to docs/operations.md →
"Inbound email rollout → Stage 4" for the full sequence.
Outputs:
inbound_mx_record— publish at DNS forHAIL_MAIL_BASE_DOMAIN.inbound_bucket— this is${HAIL_MAIL_NAME_PREFIX}-mail. SetHAIL_MAIL_NAME_PREFIXin the API.envto match the Terraformname_prefixvar. The bucket name is not settable directly — there is noHAIL_MAIL_BUCKETvar.activate_command— theaws sesv2 set-active-receipt-rule-set ...to run once.
The bare Terraform module at infra/terraform/ is provider-vanilla. If
you prefer to skip Terragrunt, it still works with
terraform apply -var=....
10.2 Activate the receipt rule set (manual)
SES has one active receipt rule set per region per AWS account. The module creates the rule set but does not activate it. If an account already has another rule set active, activation is destructive.
- Greenfield AWS account: run the
activate_commandoutput verbatim. - Account with existing rules: import the existing rule set into Terraform state and merge Hail's rule into it. As an alternative, skip the module's rule resource and add Hail's rule manually via the AWS console.
10.3 Publish the MX record
At your DNS provider, publish what the Terraform output prints, for example:
mail.hail.so MX 10 inbound-smtp.us-east-1.amazonaws.com10.4 Configure Hail
In the API service .env:
HAIL_INBOUND_ENABLED=true
HAIL_MAIL_NAME_PREFIX=hail-inbound-prod # matches Terraform `name_prefix`; bucket = ${prefix}-mail
HAIL_INBOUND_HMAC_SECRET=<same as Terraform var>Restart api. Send a test mail to a hail-mail address and confirm:
curl "$HAIL_API_URL/emails?direction=inbound" \
-H "Authorization: Bearer $HAIL_API_KEY"Or, with the Python SDK:
emails = await client.emails.list(direction="inbound")If the API is down beyond the Lambda's async retries, failed deliveries land in
the <name_prefix>-ingest-dlq SQS queue (ingest_dlq_url terraform output). To
replay, re-drive each message's body at POST /internal/ses-events. The raw
MIME is still in S3.
Delivery & engagement events
Outbound sends carry the SES configuration set named by
HAIL_SES_CONFIGURATION_SET (Terraform default: hail-events). SES
publishes Delivery / Bounce / Complaint / Reject / DeliveryDelay / Open /
Click events to SNS. The ingest Lambda relays them to
POST /internal/ses-events, which records them in email_events,
advances emails.status, and fans out webhooks.
Check a single email's timeline:
hail email events <email-id>Account-level stats:
hail email stats --from 2026-06-01T00:00:00Z --bucket dayNotes:
- Open/Click tracking rewrites links through the default SES tracking domain. Hail does not yet support a custom tracking domain.
- Open counts are approximate (mail clients that proxy images inflate them).
- Hail acknowledges and drops events for mail sent outside Hail from the
same SES account (
status: unmatchedin the API log). - Hail re-sends forwarded inbound mail (refer to §10.5) as normal outbound.
It carries the config set and writes a synthetic
sentevent, so it counts in/emails/statslike any other send.
10.5 Forwarding and webhooks
Tenants configure routing per email_domains row:
# Forward every inbound on the org's hail-mail address to a real inbox
curl -X PATCH $HAIL_API_URL/email-domains/$DOMAIN_ID \
-H "Authorization: Bearer $HAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"inbound_enabled":true,"forward_to":["team@acme.com"]}'
# Or POST inbound events to a webhook URL — the response carries the secret once
curl -X PATCH $HAIL_API_URL/email-domains/$DOMAIN_ID \
-H "Authorization: Bearer $HAIL_API_KEY" \
-d '{"inbound_enabled":true,"webhook_url":"https://hooks.acme.com/hail"}'For org-wide multi-event delivery (firehose pattern):
curl -X POST $HAIL_API_URL/webhooks \
-H "Authorization: Bearer $HAIL_API_KEY" \
-d '{"target_url":"https://hooks.acme.com/all","event_types":["email.received","email.bounced","email.complained"]}'10.6 Webhook secrets at rest
Hail encrypts webhook signing secrets at rest with a deployment-scoped
Fernet key
(core/hailhq/core/secret_cipher.py).
The worker decrypts on each delivery, so deliveries survive API restarts and
work across multi-process deployments. If HAIL_WEBHOOK_SECRET_KEY is unset,
webhook creation returns 500. Generate and set the key before you enable
webhooks:
# Generate a key (run once; store in .env, never commit). Run inside the
# project venv so the `cryptography` package is on PYTHONPATH.
uv run --directory core python -c "from hailhq.core.secret_cipher import generate_key; print(generate_key())"
# In .env:
HAIL_WEBHOOK_SECRET_KEY=<output above>Reference
- SES sending limits
- SESv2 API
- DKIM in SES
- Custom MAIL FROM
- DMARC overview
- OpenAPI:
openapi/openapi.yaml→/emails,/email-domains,/webhookstags - Code paths:
api/hailhq/api/routes/emails.py,api/hailhq/api/routes/email_domains.py,api/hailhq/api/routes/webhooks.py,core/hailhq/core/providers/email/ses.py - Inbound infra:
infra/terraform/,infra/ses-ingest-lambda/ - Design spec:
docs/superpowers/specs/2026-06-06-inbound-email-design.md
LiveKit Cloud
LiveKit Cloud supplies the media (SIP bridge + WebRTC) in v1. A self-hosted SFU is a later milestone.
MCP clients
Hail exposes MCP as a remote server. Hail Cloud uses OAuth. Paste the URL into your client. Click Allow in the browser. Your agent then gets the call/sms/mail tools. There are no keys to manage and no installation.