apistockdocs
v0.4 GitHub apistock.dev
Guides/Build

Email

How a Full preset app sends email: choosing Resend or SMTP, where each setting lives, development with Mailpit, sending from code and fixing delivery problems. Implemented in examples/full-single. Decisions: ADR-0025, ADR-0037.

How email flows#

output
module code ─► mailer ─────────────► job queue ─► mail worker ─► Mailpit (development)
               fills the sender from   (retries,                  or Resend / SMTP
               mail.* settings          idempotent)
  • Code calls mailer.Send(ctx, mail.Message{...}). The message is validated and stored as a job, so the request doesn't wait for the provider.
  • The mail worker delivers it. Temporary failures (rate limits, outages) retry up to 8 times with the same idempotency key, so nobody gets the email twice. Permanent refusals (an unverified domain, an invalid address) stop at once and show in the job run.

Choose a provider#

Run this inside the app:

terminal
aps add mail
output
┃ How should the app send email?
┃ Switch any time by running aps add mail again. In development, email always lands in Mailpit.
┃ > Resend: an email API, the quickest to set up (recommended)
┃   SMTP: Amazon SES, Postmark, Mailgun, Google Workspace or your own server

aps then asks only what that provider needs, shows a summary to confirm, and prints the steps that remain. Secrets you type are hidden and saved only in .env, which git ignores.

Want Command
Resend, asked step by step aps add mail
Resend, no questions aps add mail --provider resend --yes (add the key to .env yourself)
SMTP with flags aps add mail --smtp-host smtp.postmarkapp.com --smtp-port 587 --smtp-username <token> (asks for the password)
See what would change aps add mail --provider smtp --dry-run
Switch provider Run aps add mail again and pick the other one

All flags are in the CLI guide.

Where each setting lives#

Setting Where Change it
Resend API key .env: RESEND_API_KEY Edit .env, restart
SMTP server and login .env: SMTP_HOST, SMTP_PORT, SMTP_TLS, SMTP_USERNAME, SMTP_PASSWORD Edit .env, restart
Mailpit or real email .env: MAIL_DELIVERY (mailpit or provider; empty means Mailpit in development, provider in production) Edit .env, restart
Sender name Runtime setting mail.from_name (default: the app name) PUT /ops/settings/mail.from_name, live
Sender address Runtime setting mail.from_email (default: no-reply@example.com) PUT /ops/settings/mail.from_email, live
Reply-to address Runtime setting mail.reply_to (default: none) PUT /ops/settings/mail.reply_to, live

Secrets are never runtime settings, and the sender is never an environment variable.

Set up Resend#

  1. Create an API key at resend.com/api-keys ("Sending access" is enough).
  2. Put it in .env, or paste it when aps add mail asks:
    terminal
    RESEND_API_KEY=re_…
    
  3. Verify the domain you send from at resend.com/domains.
  4. Set the sender (next section) to an address on that domain.

Set up SMTP#

Provider SMTP_HOST SMTP_PORT / SMTP_TLS Username and password
Amazon SES email-smtp.<region>.amazonaws.com 587 / starttls SES SMTP credentials (not your AWS keys)
Postmark smtp.postmarkapp.com 587 / starttls The server API token, as both
Mailgun smtp.mailgun.org (smtp.eu.mailgun.org in the EU) 587 / starttls The domain's SMTP login and password
Google Workspace or Gmail smtp.gmail.com 587 / starttls Your address and an app password
Your own server its host name 465 / tls, or 587 / starttls as configured

SMTP_TLS=none is only for servers on your own machine; aps refuses to send a password unencrypted to another host.

Set the sender#

Start the app, sign in as an account with platform_admin and keep the token in $TOKEN (authentication guide), then change the sender through the admin API. It applies to the next email on every instance, without a restart:

terminal
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@yourdomain.com","version":0}'

curl -X PUT http://127.0.0.1:8080/ops/settings/mail.from_name \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"value":"Your App","version":0}'

Send version from GET /ops/settings/mail.from_email when the setting was changed before. In production, the app logs a warning at startup while the sender is still no-reply@example.com.

Send a test email#

terminal
curl http://127.0.0.1:8080/ops/mail -H "Authorization: Bearer $TOKEN"
json
{"provider": "resend", "delivery": "mailpit", "details": {"api_key": "missing"}, "from_name": "Your App", "from_email": "hello@yourdomain.com"}
terminal
curl -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"}'

It returns 202 once the email is queued. See the delivery in GET /ops/jobs/runs?kind=apistock.mail.send.

Development: Mailpit#

docker compose up -d --wait starts Mailpit next to PostgreSQL. With MAIL_DELIVERY empty, every email the app sends, whatever the provider, lands in the inbox at http://127.0.0.1:8025, and nobody real is emailed.

To try the real provider while developing, set MAIL_DELIVERY=provider in .env with its credentials, and restart.

Send email from code#

Modules receive the mailer (a mail.Sender) as a dependency. Leave From empty to use the mail.* settings:

go
err := mailer.Send(ctx, mail.Message{
	To:      []mail.Address{{Email: user.Email}},
	Subject: "Verify your email",
	Text:    "Your code is " + code,
	HTML:    "<p>Your code is <strong>" + code + "</strong></p>",
	Tags:    map[string]string{"category": "verification"},
})
  • Send returns once the email is queued; a validation error (no recipient, a line break in the subject) returns at once.
  • Set IdempotencyKey (for example "welcome-" + user.ID) when the same email could be queued twice.
  • Tags reach Resend (letters, digits, _ and -); SMTP ignores them.
  • Inside a transaction, enqueue through the jobs client's InsertTx so the email is sent only if the transaction commits (background jobs).

Troubleshooting#

Symptom Where to look Fix
App won't start: RESEND_API_KEY is required Startup error Add the key to .env, or leave MAIL_DELIVERY empty in development
App won't start: SMTP_HOST is required Startup error Add the SMTP variables to .env
Nothing in Mailpit docker compose ps; MAILPIT_SMTP_ADDR Start Mailpit; check the address matches compose.yaml
Run cancelled with 403 … domain is not verified GET /ops/jobs/runs?kind=apistock.mail.send Verify the domain in Resend, or change mail.from_email
Run retrying with 401 … check RESEND_API_KEY Job runs Fix the key in .env and restart; pending retries then succeed
Run cancelled with 550 Job runs The SMTP server refused the sender or recipient; check the address and your provider's sending rules
Run retrying with authenticate … check the SMTP username and password Job runs Fix SMTP_USERNAME / SMTP_PASSWORD and restart

Retried and cancelled runs keep their error messages; message contents and recipients are never shown by the ops APIs.

Environment reference#

Variable Provider Default Notes
MAIL_DELIVERY both mailpit (development), provider (production) mailpit is refused in production
MAILPIT_SMTP_ADDR both 127.0.0.1:1025 Mailpit's SMTP address
MAILPIT_SMTP_PORT, MAILPIT_WEB_PORT both 1025, 8025 Host ports in compose.yaml
RESEND_API_KEY Resend none Required when delivery is provider
SMTP_HOST SMTP none Required when delivery is provider
SMTP_PORT SMTP 587
SMTP_TLS SMTP starttls starttls, tls or none
SMTP_USERNAME, SMTP_PASSWORD SMTP none The password is required with a username
esc
↑↓ move↵ openesc close