RE:NODE

App hosting11 min read

Transactional email from your app: SMTP done properly

Send password resets and receipts from Node, Python, Django or PHP over SMTP submission, with credentials kept safe, a queue in front and bounces handled.

0 readers

An application should send its email the same way a person's mail client does: authenticated SMTP submission to a real mail server, on port 587 with STARTTLS or 465 with TLS from the start, using a dedicated sending account whose credentials live in environment variables. It should send from a background job rather than inside the web request, put a domain you control in the From: line with DKIM signing for that domain, and do something with bounces rather than ignoring them. None of that is complicated. Skipping any of it is how password-reset emails end up in spam, how a slow mail server makes your sign-up page time out, and how an SMTP password committed to a repository becomes someone else's spam operation.

This post covers the protocol choices, code for Node, Python, Django and PHP, queueing, the headers worth setting, and what bounces are telling you.

Transactional mail is a different job from marketing mail#

Transactional mail is triggered by something the recipient did: a sign-up confirmation, a password reset, a receipt, an alert they subscribed to. It is expected, usually urgent, and goes to one person at a time. Marketing mail is sent to a list because the sender decided to.

The distinction matters for three reasons:

  • Reputation is shared. If a newsletter draws complaints, receiving servers start treating the sending domain and IP with suspicion, and the password resets sent from the same place suffer with it. Larger senders separate the two streams - different sending addresses, often a different subdomain such as mail.example.com for marketing - so one cannot drag the other down.
  • The rules differ. Gmail and Yahoo require bulk senders of marketing mail to offer one-click unsubscribe. A password reset does not need an unsubscribe link and should not have one.
  • The tooling differs. Transactional mail is a few lines of code and a mail server. Bulk mail needs list management, unsubscribe handling, suppression lists and throttling, which is why people buy it as a service.

Everything below is about the transactional kind.

Submission, not port 25#

There are two ways mail moves over SMTP, and an application should use only one of them.

PortNameWho uses itAuthentication
25SMTP relayMail server to mail serverNone, checked by SPF, DKIM, reputation
587SubmissionClients and apps to their own serverRequired, after STARTTLS
465Submission over TLSClients and apps to their own serverRequired, inside TLS

Port 25 is how a mail server delivers to the recipient's mail server. If your application tried to deliver directly that way, it would need to look up every recipient's MX, queue and retry when servers are busy, handle greylisting, and send from an IP with reverse DNS and a reputation - in other words, it would need to be a mail server. Most hosting networks block outbound 25 from application servers anyway, because that is how compromised servers send spam. Port 25 and outbound mail blocks explains the blocking in detail.

Submission is the other path. The application authenticates to its own mail server - your server, or a provider's - and hands over the message. The mail server takes on queueing, retries, DKIM signing and delivery. 465 and 587 are equally fine; the only rule is that the TLS setting in your code must match the port's mode, or the connection hangs until it times out.

A dedicated sending account#

Create a mailbox or SMTP credential that only the application uses, such as app@example.com or noreply@example.com. Not a person's mailbox, and not an administrator account.

  • Credentials go in environment variables, never in the repository. A leaked SMTP password is used to send spam within hours of being pushed to a public repository, and the first you hear of it is your domain on a blocklist. Environment variables and secrets covers where they should live.
  • The `From:` address must be one the account may use. Most mail servers refuse, or should refuse, to let an authenticated account send as an address it does not own. If the application sends as orders@example.com, give that address to the sending account as an alias.
  • Use `Reply-To:` for replies. A noreply@ address is fine for the From:, but if users might reply to a receipt, set Reply-To: to a monitored support address rather than letting their replies vanish.
  • Rotate the password when someone with access leaves, and immediately if you suspect it leaked. A dedicated account means rotating it breaks one thing you control.

Sending from code#

All four examples read the same variables, so the configuration is the same everywhere:

.env
SMTP_HOST=mail.example.comSMTP_PORT=465SMTP_SECURE=trueSMTP_USER=app@example.comSMTP_PASS=a-long-random-passwordMAIL_FROM="Example <app@example.com>"

SMTP_SECURE is a separate variable rather than inferred from the port number, because the port your server listens on may not be the standard one. true means implicit TLS (the 465 mode), false means connect in plain text and upgrade with STARTTLS (the 587 mode).

Node.js with Nodemailer:

javascript
import nodemailer from "nodemailer";const transport = nodemailer.createTransport({  host: process.env.SMTP_HOST,  port: Number(process.env.SMTP_PORT),  secure: process.env.SMTP_SECURE === "true",   // false = STARTTLS  requireTLS: true,                              // refuse to send without TLS  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },});await transport.sendMail({  from: process.env.MAIL_FROM,  to: user.email,  subject: "Reset your password",  text: `Use this link within an hour: ${link}`,  html: `<p>Use <a href="${link}">this link</a> within an hour.</p>`,});

Create the transport once at start-up and reuse it. transport.verify() at boot is a cheap way to find a wrong password before the first user does.

Python with the standard library:

python
import os, smtplib, sslfrom email.message import EmailMessagefrom email.utils import make_msgid, formatdatemsg = EmailMessage()msg["From"] = os.environ["MAIL_FROM"]msg["To"] = to_addressmsg["Subject"] = "Reset your password"msg["Date"] = formatdate(localtime=True)msg["Message-ID"] = make_msgid(domain="example.com")msg.set_content(f"Use this link within an hour: {link}")ctx = ssl.create_default_context()host, port = os.environ["SMTP_HOST"], int(os.environ["SMTP_PORT"])if os.environ.get("SMTP_SECURE") == "true":    server = smtplib.SMTP_SSL(host, port, context=ctx, timeout=15)else:    server = smtplib.SMTP(host, port, timeout=15)    server.starttls(context=ctx)with server:    server.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])    server.send_message(msg)

Django reads its settings, so the variables map across directly:

settings.py
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"EMAIL_HOST = os.environ["SMTP_HOST"]EMAIL_PORT = int(os.environ["SMTP_PORT"])EMAIL_USE_SSL = os.environ.get("SMTP_SECURE") == "true"   # implicit TLSEMAIL_USE_TLS = not EMAIL_USE_SSL                          # STARTTLSEMAIL_HOST_USER = os.environ["SMTP_USER"]EMAIL_HOST_PASSWORD = os.environ["SMTP_PASS"]EMAIL_TIMEOUT = 15DEFAULT_FROM_EMAIL = os.environ["MAIL_FROM"]SERVER_EMAIL = DEFAULT_FROM_EMAIL

EMAIL_USE_SSL and EMAIL_USE_TLS are mutually exclusive; setting both is an error. SERVER_EMAIL is the sender for error reports to ADMINS, and leaving it at the default root@localhost is a common reason those reports never arrive.

PHP with Symfony Mailer (which Laravel also uses underneath) takes a DSN: smtps:// for implicit TLS, smtp:// for a connection that upgrades with STARTTLS:

env
MAILER_DSN=smtps://app%40example.com:a-long-random-password@mail.example.com:465

The @ in the username is URL-encoded as %40, and so must be any special character in the password. In Laravel, the same settings live in MAIL_MAILER=smtp, MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD and MAIL_FROM_ADDRESS; the name of the variable that selects the TLS mode has changed between Laravel versions, so check config/mail.php in your own project.

Send from a queue, not the request#

An SMTP conversation takes anything from a hundred milliseconds to many seconds, and a mail server that is restarting or slow makes it take until your timeout. If the web request waits for it, a sign-up page hangs, a user clicks again, and two accounts or two orders appear.

Put the message on a queue in the request and let a worker send it. The request returns at once, the worker retries on failure with a delay, and a mail server outage becomes a delay in mail rather than an outage of your application. On Node that is BullMQ; on Python, Celery or RQ; in Laravel, its queue system; in Django, Celery or a database-backed task runner. Background jobs on a small server covers the options, including a queue in PostgreSQL that needs no extra service, and job queues with Valkey covers the Redis-protocol queues.

Two rules for the worker:

  1. Retry temporary failures, not permanent ones. An SMTP reply starting with 4 (421, 451) means try later. One starting with 5 (550, 553) means no, and retrying will not change the answer.
  2. Make sending idempotent. If the worker crashes after the server accepted the message but before the job was marked done, the job runs again. Store a "sent" marker against the event - the password-reset token, the order number - and check it before sending.

Headers worth setting#

Mail libraries set the minimum. A few more headers make your mail behave better:

  • `Message-ID` and `Date`: required in practice; their absence costs spam score. Most libraries and mail servers add them, but check.
  • `Auto-Submitted: auto-generated` (RFC 3834): tells the recipient's server that the message was sent by a program, so vacation auto-responders do not reply to it.
  • A plain-text part alongside HTML. Mail with only HTML scores worse and is less accessible.
  • `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`: for notification digests and anything marketing-shaped, not for password resets.

Keep templates plain. A transactional message that looks like a newsletter - a large hero image, tracking pixels, a dozen links - is filtered like one.

Bounces and the envelope sender#

Every message has an envelope sender, set in the MAIL FROM command and recorded as Return-Path:. When delivery fails after your server accepted the message - the mailbox does not exist, the recipient's server rejected it - the bounce goes to that address. If it points at an unmonitored noreply@ mailbox, or one that does not exist, you never learn that half your sign-ups typed their address wrong.

What to do with them:

  • Hard bounces (5xx: user unknown, domain does not exist): stop sending to that address. Mark it on the user record and ask the user to correct it next time they log in. Repeatedly sending to dead addresses is one of the clearest signals of a sender who does not maintain its list.
  • Soft bounces (4xx, mailbox full, temporarily unavailable): your mail server retries these itself for days before giving up. Only after the final failure is it a real bounce.
  • Where they go: a mailbox the application reads (over IMAP, from the worker) or one a person checks. Applications that send a lot use VERP - an envelope sender per recipient, such as bounces+anna=gmail.com@example.com - so the bounce identifies the address without parsing the message, which is fiddly.

Your mail server also needs to be trusted when it delivers. SPF, DKIM and DMARC for the sending domain are covered in SPF, DKIM and DMARC explained; without them, careful code still lands in spam.

Your own mail server or a sending provider#

For low-volume transactional mail - a few hundred or a few thousand messages a day from one application - your own mail server is perfectly adequate, once its DNS is in order and its IP has a little history. Providers earn their price at volume, for marketing mail, and when you want delivery analytics without building them.

On RE:NODE, the Mail Server line runs Stalwart, and an application on a Node, Python or C# plan can submit to it like any mail client: the server's address and the submission port shown in the panel, a dedicated account created in Stalwart's web admin, and the credentials as environment variables on the application's Startup tab. You add the MX, SPF, DKIM and DMARC records for the domain. If a recipient network refuses mail from your server's address on port 25, Stalwart can hand outbound mail to a relay set in its web admin, and your application code does not change at all - SMTP relays for outgoing mail covers when that is worth doing.

Testing before users do#

swaks - the Swiss Army Knife for SMTP - sends a test message with exactly the settings your application uses, and prints the whole conversation:

bash
$ swaks --to you@example.net --from app@example.com \    --server mail.example.com:587 --tls \    --auth LOGIN --auth-user app@example.com --auth-password 'secret'

Use --tls-on-connect instead of --tls for an implicit-TLS port. A 235 reply means authentication worked; a 250 after the data means the server accepted the message. Then open the delivered copy and read its headers: Authentication-Results should show SPF, DKIM and DMARC all passing for your domain. Repeat the test to a Gmail and an Outlook address, because they are where most of your users are.

FAQ#

Can my app send email without a mail server?

Not reliably. Delivering directly on port 25 means implementing a mail server's job inside your application - MX lookups, queueing, retries, greylisting - from an IP without mail reputation, and most hosting networks block that port outbound anyway. Submit to a mail server, yours or a provider's, and let it deliver.

Why does sending hang until it times out?

The TLS mode does not match the port. Implicit TLS (secure: true, SMTP_SSL, EMAIL_USE_SSL, smtps://) on a STARTTLS port waits for a handshake the server never starts, and the reverse waits for a greeting the server never sends. Match the setting to the listener.

Should password resets come from a noreply address?

They can, but set Reply-To: to a monitored address so users who reply reach a person. Make sure the envelope sender points somewhere that receives bounces, or you will never learn which addresses are mistyped.

Not on purely transactional messages such as receipts or password resets. Notification digests, product updates and anything a user could reasonably not want should have one, including the one-click List-Unsubscribe headers.

How many emails can I send from my own mail server?

There is no fixed number on the server side; the limits come from receivers. A new server should start small and grow steadily, because large providers throttle and spam-folder sudden volume from an address they do not know. Transactional volumes for a typical application are well within what a self-hosted server handles.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000