Skip to content

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 (Postfix mynetworks, 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 with auth: "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.
  • from should 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 collect 5xx rejections 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.