Skip to content
Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

How AstroBaaS sends email: choosing a transport, the environment variables, Reply-To, form and order notifications, editing the wording, newsletters, and testing mail after a deploy. It is for operators setting up or debugging a mail channel.

Every message AstroBaaS sends goes through one transport configured from the environment. That covers password resets, magic links, contact-form notifications, order confirmations and newsletter batches. Moving to another mail server means editing .env and restarting; no code changes. The operator reference is docs/EMAIL.md.

Transports

Transport How to select it Use for
Console EMAIL_TRANSPORT=console (the default) Local development. Messages are printed to the server log and nothing is sent.
SMTP EMAIL_TRANSPORT=smtp Any submission server: your host's, your own, or a provider's SMTP relay.
Webhook EMAIL_TRANSPORT=webhook POSTs each message as JSON to a URL you run, which can bridge to any provider.
SMTP2GO Activate the bundled smtp2go plugin SMTP2GO's HTTP API. Useful where outbound port 587 is blocked.

With the default console transport, password resets, magic links and 2FA recovery end up in the server log. The deep health check warns about this.

If EMAIL_TRANSPORT is set to smtp or webhook but the configuration is incomplete, AstroBaaS logs why at startup and falls back to console. The same reason appears in /api/health/deep.

SMTP

EMAIL_TRANSPORT=smtp
SMTP_HOST=mail.example.com
SMTP_PORT=587                      # 587 = STARTTLS; or 465 with SMTP_SECURE=1
SMTP_USER=noreply@example.com
SMTP_PASS='the-password'           # always single-quoted; set on the server, never committed
EMAIL_FROM="Example <noreply@example.com>"
EMAIL_REPLY_TO=info@example.com    # where replies go, if EMAIL_FROM is unmonitored
Variable Required Default Notes
EMAIL_TRANSPORT Yes console smtp, webhook or console.
SMTP_HOST Yes none The submission server.
EMAIL_FROM Yes none Must be an address the account may send as.
SMTP_PORT No none 587 for STARTTLS, 465 with SMTP_SECURE=1.
SMTP_SECURE No off Implicit TLS (port 465).
SMTP_USER / SMTP_PASS No none If you set a user you need a password too.
EMAIL_REPLY_TO No none Reply-To on every message, for any transport.
SMTP_TIMEOUT_MS No 10000 Each network wait. One attempt is capped at 3× this.
SMTP_DATA_TIMEOUT_MS No 600000 Wait for the reply after the message is sent (1 s to 30 min).
SMTP_RETRIES No 2 0–2, transient failures only.
SMTP_HELO_NAME No Machine name or EMAIL_FROM domain The name given in EHLO. Never SMTP_HOST.
SMTP_ALLOW_SELF_SIGNED No off Only for a relay on your own network.
EMAIL_CAMPAIGNS No on 0 refuses newsletter campaigns.

The password

SMTP_PASS is read from the environment only. It is never stored in the database, never logged and never returned by an endpoint. Always single-quote it in the env file. Unquoted, systemd and Node's env-file parser disagree, and each one damages some passwords (a # or \ is enough). The symptom is a puzzling 535. A password containing a single quote cannot be written this way, so choose another.

What is retried

  • Retries are on by default. A 4xx, a dropped connection or a timeout before the message is sent is retried after about 2 s, then about 6 s.
  • A 5xx, a failed login (535) and a certificate that does not verify are never retried.
  • If the whole message was sent but no reply came, the outcome is unknown and the message is not sent again. It shows as "outcome unknown" in the log, on Admin → Background jobs (/admin/operations) and in the health check. Check the inbox before resending.

Sends from a request are handed off, so the response never waits on the mail server. Scheduler mail (reminders, back-in-stock notices, newsletter batches) waits at most one minute for the final reply.

Webhook transport

EMAIL_TRANSPORT=webhook
EMAIL_WEBHOOK_URL=https://mail-bridge.example.com/send
EMAIL_WEBHOOK_SECRET=...           # optional

Each message is POSTed as JSON (to, subject, text, html, Reply-To and headers). With a secret set, the request carries X-AstroBaaS-Signature: sha256=<HMAC-SHA256 of the body>. Any non-2xx response counts as a failed send.

SMTP2GO plugin

SMTP2GO_API_KEY=api-XXXXXXXXXXXX
SMTP2GO_SENDER="My Shop <no-reply@example.com>"

Activate the smtp2go plugin in Admin → Plugins. If a variable is missing, activation logs what is needed and leaves the previous transport in place. Deactivating it returns to whatever EMAIL_TRANSPORT says. The key is sent in a header, never in the request body. A 200 response that reports failed recipients is treated as a failure.

Reply-To

  • EMAIL_REPLY_TO goes on every message, whatever the transport. Set it whenever EMAIL_FROM is a no-reply box.
  • Form notifications use the submitter's address instead. Pressing Reply answers the person who wrote. A submitted value that is not a usable address is dropped, and the default is used instead.
  • If EMAIL_REPLY_TO itself is unusable, mail still goes out without a Reply-To. The startup log and /api/health/deep both say so. The reasoning is that losing a password reset is worse than losing the Reply-To.

Form submission notifications

The built-in contact form and every content type with writable: 'public' send the same notification to the same recipient.

The recipient is the first usable address among these settings:

  1. contact_notify_email: Admin → Settings → "Send form submissions to"
  2. contact_email: the address shown on the public contact page
  3. admin_email: the operator's address

If none is set, nothing is sent. Submissions are still stored in the admin inbox (Messages).

The notification is plain text only, because a submission is text from a stranger. Each field is cut to 500 characters; the full text is in the admin.

Order emails

Email Recipient Default
Order confirmation Buyer On (order_confirmation_enabled). At most five per recipient per hour.
Unpaid-order reminder Buyer Sent once, before an unpaid order is cancelled.
Shipped notice Buyer Sent the first time tracking is recorded.
Back in stock Subscriber Sent once, when a sold-out product returns.
Sale notification Staff Off until sale_notify_enabled is on and sale_notify_recipients is set (up to 10).

A mail failure never fails a checkout. See Commerce.

Email wording (templates)

Admin → Email wording (admin-only, /admin/email-templates) lets you change the subject and body of each email without a deploy:

Template id When it is sent
order_confirmation To the customer, as soon as an order is placed.
stock_back Once, to somebody who asked to be told when a sold-out product returned.
order_recovery Once, to a customer whose order is still unpaid, before it is cancelled.
order_shipped To the customer, the first time tracking is recorded.
password_reset When somebody asks to reset their password.
magic_link When somebody signs in with a link instead of a password.
newsletter_confirm To a new subscriber, to confirm the subscription.
submission_notice To the site owner, when somebody sends a form.

The rules:

  • Templates control wording, not delivery. Who gets the email, when, and whether it is sent at all stay in code.
  • Each template has required placeholders (a password reset needs its link). A body missing one is refused when you save it.
  • If a stored template later fails validation, the built-in default is sent instead.
  • Templates are plain text. There is no HTML editing.
  • For the order confirmation, only the subject is templatable; the body is fixed.

The same data is available at /api/email-templates.

Newsletter and campaigns

  • Double opt-in. POST /api/newsletter stores nothing. It sends a signed confirmation link, and the address is added only when the person clicks it.
  • Campaigns are written and sent from Admin → Messages ("Send a newsletter"), or via /api/newsletter/campaigns.
  • Campaigns go out in batches of 25 per scheduler tick and pick up where they left off after a restart, so a newsletter is never sent twice.
  • Every campaign message carries List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click. Unsubscribing goes through /api/newsletter/unsubscribe.
  • This is not a full email service. There is no bounce handling, no complaint feedback loop and no suppression list. Sending a large list through your own mail server can hurt delivery of your order confirmations. For more than a few hundred addresses, use a dedicated provider. Subscriber events are sent as webhooks so you can sync one.
  • Transactional-only mailboxes. Set EMAIL_CAMPAIGNS=0 (or false, no, off) when the mailbox must not carry bulk mail. Pressing Send is then refused with the reason, and a queued campaign waits without marking anyone as failed. Password resets and order mail are not affected.

Test the mail channel after every deploy

Do not skip this step. In a checkout:

npm run mail:test -- --env-file=/var/www/<site>/shared/.env you@example.com

On a built release (no scripts/ directory), use the precompiled copy:

node dist/mail-test.mjs you@example.com
docker compose exec app node dist/mail-test.mjs you@example.com

To test with exactly the environment the systemd service sees:

sudo systemd-run --pipe --wait --quiet --uid=www-data --gid=www-data \
  -p EnvironmentFile=/var/www/<site>/shared/.env \
  -p WorkingDirectory=/var/www/<site>/current \
  /usr/bin/node dist/mail-test.mjs you@example.com

The test sends one message through the app's own mail code. It prints the configuration (the password only as "set" or "not set"), the SMTP conversation with the AUTH line masked, and the server's final reply. It exits 0 when the server accepts the message, so a deploy script can check it.

  • A variable already exported in your shell wins over --env-file.
  • A 250 means the server accepted the message, not that it arrived. Check the inbox and spam folder.
  • Afterwards, /api/health/deep reports the channel under email_channel: the transport, Reply-To, whether campaigns are on, and the last send.

Reading failures

The email log is on Admin → Background jobs (/admin/operations). It keeps each server reply, so the queue id is there when a customer says a message never arrived. The server log has one line per message and never includes the subject, body or credentials.

You see It means
mail is not being sent: EMAIL_TRANSPORT=smtp but … The configuration was rejected before connecting. The message names the variable.
SMTP authentication failed: 535 Wrong user or password, or an unquoted SMTP_PASS.
SMTP MAIL FROM failed: 553 / 550 … not owned by user EMAIL_FROM is not an address this account may send as.
SMTP RCPT TO failed: 550 The recipient was refused.
4xx … (after 3 attempts) A temporary refusal outlasted the retries, often a rate limit.
self-signed certificate / CERT_HAS_EXPIRED Fix the certificate, or trust an internal CA with NODE_EXTRA_CA_CERTS.
Refusing to send credentials over an unencrypted connection No STARTTLS on this port. Use 465 with SMTP_SECURE=1.
SMTP EHLO failed: 5xx Set SMTP_HELO_NAME to this machine's public host name.
SMTP outcome unknown The message may have been delivered and was not resent.

See also

Clone this wiki locally