Email sending
Your app sends email for sign-up codes, password reset codes and security alerts, such as "a passkey was added to your account". On your computer, nothing leaves your machine: every email goes to a local inbox called Mailpit. In production, the app sends real email through a provider: Resend or any SMTP server.
| Where | Where email goes | What you set |
|---|---|---|
| Your computer | Mailpit, at http://127.0.0.1:8025 | Nothing |
| Your computer, trying the real provider | The provider | MAIL_DELIVERY=provider, plus the provider's values |
| Production | The provider, always | The provider's values |
What Mailpit is#
Mailpit is a small program that pretends to be an email server. Your app hands it emails exactly as it would hand them to a real provider, and Mailpit keeps them in an inbox you open in your browser, instead of delivering them.
- Why you need it: to sign up on your own app you need the 6-digit code from the verification email. Mailpit shows it to you without a real email account, and you can't email a real person by mistake.
- Where it comes from:
aps devstarts it in Docker from your app'scompose.yaml, next to PostgreSQL (imageaxllent/mailpit). - Where to see it: http://127.0.0.1:8025. The app sends to it on port 1025.
- If you removed it: in development, emails would fail to send and stay queued for retries; nobody could finish sign-up locally.
- In production: never. The app refuses to start with
MAIL_DELIVERY=mailpitwhenAPP_ENV=production.
No key is needed for Mailpit. It has no password and only listens on your own computer (127.0.0.1).
Option 1: Resend (recommended)#
Resend is an email service with a simple API. You need one value from it: an API key.
| Variable | Secret? | Looks like | Where it comes from |
|---|---|---|---|
RESEND_API_KEY |
Yes | re_123abc… |
Copied from the Resend dashboard, shown once |
-
Create an account
Go to resend.com and sign up. The free plan is enough to start.
-
Add your sending domain
In the dashboard, open Domains → Add Domain. Enter the domain your emails come from, such as
example.com(or a subdomain such asmail.example.com, which keeps your main domain's reputation separate). Choose the region closest to your users. -
Add the DNS records
Resend shows a few records: SPF and DKIM, which prove to inboxes that Resend may send for your domain, and an MX record for bounces. Open the website where you bought your domain (your DNS host, such as Cloudflare, Namecheap or Route 53) and add each record exactly as shown: type, name and value.
Back in Resend, click Verify DNS Records. It can take from a few minutes to a few hours. Wait for Verified.
-
Create the API key
Open API Keys → Create API Key. Name it after the environment, such as
acme-api production. Set Permission to Sending access and Domain to the domain from step 2, so the key can only send, and only from that domain. Click Add. -
Copy the key
The key is shown once. Copy it straight into your secret store (or
.envto test locally). If you lose it, delete the key in Resend and create another.terminalRESEND_API_KEY=re_123abc…
New Full apps already use Resend. If you switched to SMTP earlier, switch back with aps add mail --provider resend.
Option 2: SMTP#
SMTP is the standard way to send email, supported by every provider: Amazon SES, Postmark, Mailgun, Google Workspace or your own mail server. Switch your app to it once:
aps add mail --provider smtp
aps asks for each value, hides the password as you type it, and saves secrets only in .env. Run it inside your app, with no uncommitted changes. It changes internal/app/infra_mail.go, the email block of .env.example, apistock.yaml and go.mod; commit the result.
| Variable | Required? | Secret? | Example | What it is |
|---|---|---|---|---|
SMTP_HOST |
Yes, when sending real email | No | smtp.postmarkapp.com |
Your provider's SMTP server name |
SMTP_PORT |
No (default 587) |
No | 587 |
The server's port |
SMTP_TLS |
No (default starttls) |
No | starttls |
How the connection is encrypted: starttls for 587 and 2525, tls for 465, none only for a server on your own machine |
SMTP_USERNAME |
Depends on the provider | No | apikey |
The SMTP login |
SMTP_PASSWORD |
Yes, with a username | Yes | The SMTP password or token |
Where each provider shows these values:
| Provider | SMTP_HOST |
Username and password |
|---|---|---|
| Amazon SES | email-smtp.<region>.amazonaws.com, such as email-smtp.eu-west-1.amazonaws.com |
SES console → SMTP settings → Create SMTP credentials. These are special SMTP credentials, not your AWS access keys |
| Postmark | smtp.postmarkapp.com |
Your server → API Tokens → the Server API token, used as both username and password |
| Mailgun | smtp.mailgun.org, or smtp.eu.mailgun.org in the EU |
Sending → Domain settings → SMTP credentials |
| Google Workspace or Gmail | smtp.gmail.com |
Your address, and an app password from your Google Account's security settings |
Whatever the provider, verify your sending domain there first, as with Resend.
Set the sender address#
The sender's name and address aren't environment variables: they're runtime settings, which an administrator changes while the app runs.
| Setting | Default | Change it to |
|---|---|---|
mail.from_email |
no-reply@example.com |
An address on your verified domain, such as hello@example.com |
mail.from_name |
Your app's name | What people see as the sender, such as Acme |
mail.reply_to |
Empty | Where replies go, such as support@example.com |
Sign in as an administrator (how), keep the token in $TOKEN, then:
curl -X PUT http://127.0.0.1:8080/ops/settings/mail.from_email \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"value": "hello@example.com", "version": 0, "reason": "our domain is verified"}'
version is 0 the first time. If the setting was changed before, read its current version with GET /ops/settings/mail.from_email first: the app refuses a stale version, so two administrators can't overwrite each other by accident.
Check it works#
-
See what the app will use:
terminalcurl http://127.0.0.1:8080/ops/mail -H "Authorization: Bearer $TOKEN"json{"provider": "resend", "delivery": "mailpit", "details": {"api_key": "missing"}, "from_name": "acme-api", "from_email": "no-reply@example.com"}deliveryismailpitorprovider.detailssays whether the key or SMTP login is set, never its value. -
Send yourself a test email:
terminalcurl -X POST http://127.0.0.1:8080/ops/mail/test \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"to": "you@example.com"}'The answer is
202with{"status": "queued", …}: the email is queued, and a background job delivers it within seconds. In development, open http://127.0.0.1:8025 to see it. -
If it doesn't arrive, look at the delivery job:
terminalcurl "http://127.0.0.1:8080/ops/jobs/runs?kind=apistock.mail.send" -H "Authorization: Bearer $TOKEN"
If something goes wrong#
| What you see | What it means | Fix |
|---|---|---|
The app won't start: RESEND_API_KEY is required to send email with Resend |
Delivery is provider (always in production) and the key is empty |
Set RESEND_API_KEY, or in development leave MAIL_DELIVERY empty |
The app won't start: MAIL_DELIVERY=mailpit is for development |
Production can't use Mailpit | Remove MAIL_DELIVERY or set it to provider |
| Nothing in Mailpit | Mailpit isn't running, or the app sends to another port | docker compose ps should show mailpit as healthy; MAILPIT_SMTP_ADDR must match MAILPIT_SMTP_PORT |
Job run cancelled with 403 … domain is not verified |
Resend refuses your sender address | Finish domain verification, or set mail.from_email to an address on a verified domain |
Job run retrying with 401 … check RESEND_API_KEY |
Wrong or deleted key | Put the right key in the environment and restart; waiting retries then succeed |
Job run retrying with authenticate … check the SMTP username and password |
Wrong SMTP login | Fix SMTP_USERNAME and SMTP_PASSWORD, restart |
Job run cancelled with 550 |
The SMTP server refused the sender or recipient | Check the address and your provider's sending rules |
| People who signed in with Apple and hid their email get nothing | Apple only relays from registered senders | Register your domain with Apple (Apple sign-in, step 6) |
Temporary failures retry up to 8 times, and each email is sent at most once even when retried. Permanent refusals stop straight away, so you see the reason instead of waiting for retries.