Your Own SMTP Server¶
The most common notification endpoint isn't a service with a dashboard and an API key — it's the mail infrastructure you already run: Postfix on the database host, an Exim or Sendmail relay in the server room, the appliance your organisation routes all outbound mail through. Nothing about pg_relay prefers a hosted service; the smtp transport speaks the protocol itself, and a LAN relay is if anything the easy case. This chapter gives you everything needed to point a profile at your own server, from the two-line localhost case up to an authenticated DMZ relay.
(pg_relay's own documentation covers the transport's wire behaviour in its SMTP chapter; this page is the profile-building companion. The full field reference also lives in this book's SMTP Endpoints.)
The simplest case: a relay that trusts your network¶
An internal relay that accepts mail from known hosts needs neither credentials nor, on a network you already trust, TLS:
SELECT pgrelay_notifier.create_profile(
p_profile_name => 'internal_relay',
p_transport => 'smtp',
p_profile => '{
"host": "mailrelay.internal.example",
"port": 25,
"security": "none",
"auth": "none",
"from": "postgres-alerts@internal.example"
}'::jsonb,
p_channel => 'notifications'
);
Three things to know about this shape:
auth: "none"is explicit and legitimate. IP-based relaying (Postfixmynetworks, Exim host lists) is the normal arrangement for internal relays. The host that must be allow-listed is the Processor host — the machine running the pg_relay binary — not the PostgreSQL server, if they differ.security: "none"is for networks you trust. The Processor never sends credentials over an unencrypted connection anyway (localhost excepted, below), so withauth: "none"the exposure is message content on your own LAN — your call. If the relay offers STARTTLS, prefer"security": "starttls"even internally; it costs nothing.fromshould be a sender your relay is willing to relay. Most relay configurations restrict envelope senders; pick an address in a domain the relay considers its own, or you'll collect5xxrejections that have nothing to do with pg_relay.
Postfix on localhost¶
If Postfix (or any MTA) runs on the Processor host itself:
{"host": "localhost", "port": 25, "security": "none", "auth": "none",
"from": "postgres-alerts@db01.internal.example"}
Localhost is the one place the Processor relaxes its TLS discipline: it will authenticate over a plain-text connection to localhost only, because the traffic never crosses a wire. Everywhere else, login and OAuth2 authentication are refused outright on a connection that hasn't negotiated TLS — a guarantee you don't have to configure, it's just how the transport behaves.
An authenticated relay¶
For a relay that requires SASL credentials — a submission port on your own MTA, a smarthost your provider runs:
SELECT pgrelay_notifier.create_profile(
p_profile_name => 'smarthost',
p_transport => 'smtp',
p_profile => '{
"host": "smtp.internal.example",
"port": 587,
"security": "starttls",
"auth": "plain",
"username": "pgrelay",
"password": "_env:SMARTHOST_PASSWORD",
"from": "postgres-alerts@example.com",
"timeout_seconds": 30
}'::jsonb,
p_channel => 'notifications'
);
The password is an _env: reference, never a literal — create_profile() rejects a literal outright. The variable lives in the Processor's environment file on its host; rotating the password is an edit there, not a database change. Port-and-security pairings are the conventional ones: 587 + starttls (submission), 465 + tls (implicit TLS), 25 + none or starttls (relay). auth: "login" exists for servers that only advertise AUTH LOGIN.
Prove it works before wiring up producers¶
The profile machinery is testable end to end with one throwaway send:
-- 1. Dry-run the profile JSON first: zero rows means valid.
SELECT * FROM pgrelay_notifier.validate_profile('smtp',
'{"host": "mailrelay.internal.example", "port": 25,
"security": "none", "auth": "none", "from": "postgres-alerts@internal.example"}'::jsonb);
-- 2. Send one message with the debug trace on.
SELECT pgrelay_notifier.send('internal_relay', ARRAY['you@example.com'],
'pg_relay smoke test', 'If you can read this, the relay profile works.',
p_debug => true);
-- 3. Watch the attempt as it happens.
SELECT * FROM pgrelay_notifier.get_status(<the returned id>);
SELECT * FROM pgrelay_notifier.get_trace(<the returned id>);
The debug trace is written outside the delivery transaction, so a hanging connection is visible while it hangs — claimed and fetched present, sent absent — which is exactly the signature of a firewall silently dropping packets to the SMTP port.
Troubleshooting the usual suspects¶
| Symptom | Usual cause |
|---|---|
Status retry, detail names a dial failure or timeout |
The Processor host can't reach host:port — firewall, wrong port, or the relay only listens on specific interfaces. Test from that host: nc -vz mailrelay.internal.example 25. Remember it's the Processor's network position that matters, not the database server's. |
Status retry after ~30 s each attempt |
Connection accepted but the conversation stalls — greeting delays (Postfix postscreen), or a middlebox eating STARTTLS. Raise timeout_seconds only after finding out why it's slow. |
Status failed, detail quotes a 5xx with "Relay access denied" |
The relay doesn't trust the Processor host's IP and you're not authenticating. Add the host to mynetworks (or equivalent), or switch to an authenticated submission port. |
Status failed, detail quotes a 5xx naming the sender |
Sender restrictions on the relay — adjust from to a domain the relay accepts. |
Status failed, detail names an unset environment variable |
The _env: variable isn't in the Processor's environment. Set it in the Processor's environment file and restart the binary — it reads its environment at start. |
| TLS errors against an internal relay | The relay's certificate isn't trusted by the Processor host (internal CA). Install the CA certificate into the host's system trust store — the Processor uses it. |
Transient failures (4xx, timeouts, dial failures) retry automatically on the channel's retry policy; permanent ones (5xx) fail immediately with the server's reply in status_detail — the relay's own words are almost always the fastest diagnosis.
Two properties you get for free¶
Deterministic Message-IDs. Every send carries a Message-ID derived from the notification's primary key, so if a crash forces a redelivery of a message the relay had already accepted, receivers that de-duplicate on Message-ID show it once. Your own relay's logs will also show the retry as the same message — useful when auditing.
The queue is your outage buffer. If the relay goes down, sends fail transiently and wait in the retry chain; nothing is lost, and delivery resumes when the relay does. A planned relay outage needs no coordination with pg_relay at all — though pausing the profile keeps the retry counters quiet if you prefer.
Next: Using an Unlisted Provider — the same self-reliance, applied to services these pages don't name.