apistockdocs
v0.4 GitHub apistock.dev
Guides/Set up sign-in

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 dev starts it in Docker from your app's compose.yaml, next to PostgreSQL (image axllent/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=mailpit when APP_ENV=production.
Note

No key is needed for Mailpit. It has no password and only listens on your own computer (127.0.0.1).

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
  1. Create an account

    Go to resend.com and sign up. The free plan is enough to start.

  2. Add your sending domain

    In the dashboard, open DomainsAdd Domain. Enter the domain your emails come from, such as example.com (or a subdomain such as mail.example.com, which keeps your main domain's reputation separate). Choose the region closest to your users.

  3. 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.

  4. Create the API key

    Open API KeysCreate 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.

  5. Copy the key

    The key is shown once. Copy it straight into your secret store (or .env to test locally). If you lose it, delete the key in Resend and create another.

    terminal
    RESEND_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:

terminal
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 settingsCreate 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 SendingDomain settingsSMTP 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:

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@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#

  1. See what the app will use:

    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": "acme-api", "from_email": "no-reply@example.com"}
    

    delivery is mailpit or provider. details says whether the key or SMTP login is set, never its value.

  2. Send yourself a test email:

    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"}'
    

    The answer is 202 with {"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.

  3. If it doesn't arrive, look at the delivery job:

    terminal
    curl "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.

esc
↑↓ move↵ openesc close