Why a centralized relay?
Our previous guides — Proxmox Email via Graph/SES and DNF5 Automatic Email via Graph/SES — show how to configure individual servers to send notifications directly through a cloud API. That works well when you only have a few systems to manage.
But on a site with dozens of servers, switches, UPS devices, storage appliances, and other infrastructure — each needing to send alerts — manually deploying and maintaining API credentials on every system becomes impractical. Credentials expire, and you would need to rotate them everywhere.
The solution is a centralized API relay. Stand up a single dedicated VM or container running Postfix. Point every internal system at it using standard SMTP (the default for nearly every appliance and daemon). This relay accepts unauthenticated mail from your LAN, then delivers it to the outside world exclusively through secure HTTPS API calls — never over traditional SMTP ports that may be blocked by your ISP or firewall.
Internal systems (standard SMTP)
→ Centralized Postfix relay (your LAN)
→ Python script (this guide)
→ Microsoft Graph API or Amazon SES (HTTPS, port 443)
→ Recipient mailboxThis relay handles outbound notification mail only — it is not a full mail server. It does not receive inbound internet email, host mailboxes, or handle DKIM/SPF for arbitrary domains. It is a one-way bridge from internal SMTP to cloud API.
Architecture overview
The relay uses four files, all kept together in /opt/postfix-relay/:
| File | Purpose |
|---|---|
postfix-api-relay | The Python script — logic and API integration |
relay-config.json | Routing rules — approved senders, admin email, notification settings |
api-secrets.json | API credentials — locked to 0600 permissions |
postfix-api-relay.log | Timestamped activity and error log — created automatically by the script |
This separation means routing changes (adding an approved sender, changing the admin address) do not require touching the secrets file, and credential rotation does not require editing the configuration.
1. Prerequisites
This guide assumes you have a Linux system (RHEL/Fedora, Debian/Ubuntu, or similar) with Postfix installed at its default settings. Most minimal server installations include Postfix, or it can be installed with your package manager. All commands assume you are logged in as a non-root user with sudo access.
You also need credentials for your chosen cloud provider. If you have not set those up yet, see our Microsoft Graph App Registration guide.
Install Python dependencies
The Graph API provider uses only Python 3 standard library modules — no additional packages are needed. The SES provider requires boto3. On RHEL/Fedora, also install SELinux policy tools for managing the relay's security context:
# RHEL / Fedora
sudo dnf install -y python3-boto3 policycoreutils-python-utils
# Debian / Ubuntu
sudo apt install -y python3-boto32. Create the service account and secrets file
Postfix's pipe daemon refuses to execute commands as root, so a dedicated service account is required. All relay files — the script, config, secrets, and logs — live together in /opt/postfix-relay/ owned by this account.
sudo useradd -r -s /usr/sbin/nologin -d /opt/postfix-relay postfix-relay
sudo mkdir -p /opt/postfix-relay
sudo chown postfix-relay:postfix-relay /opt/postfix-relayThe secrets file stores your API credentials. The field names are generic so the same structure works for either provider — dual comments above each value indicate what it means for Graph vs. SES. Set API_PROVIDER to graph or ses, then fill in the remaining fields for your chosen provider:
sudo tee /opt/postfix-relay/api-secrets.json > /dev/null <<'EOF'
{
"API_PROVIDER": "graph",
"_comment_graph": "Microsoft Entra (Azure AD) Tenant ID",
"_comment_ses": "AWS Region for SES (e.g. us-east-1)",
"API_TENANT_OR_REGION": "YOUR_TENANT_ID_OR_REGION",
"_comment_id_graph": "App Registration Client ID",
"_comment_id_ses": "IAM Access Key ID",
"API_ID": "YOUR_CLIENT_OR_ACCESS_KEY_ID",
"_comment_secret_graph": "App Registration Client Secret",
"_comment_secret_ses": "IAM Secret Access Key",
"API_SECRET": "YOUR_SECRET",
"_comment_expires": "Expiration date of the secret or access key (YYYY-MM-DD)",
"API_EXPIRATION_DATE": "2028-04-05"
}
EOFLock down permissions
sudo chown postfix-relay:postfix-relay /opt/postfix-relay/api-secrets.json
sudo chmod 600 /opt/postfix-relay/api-secrets.jsonThe script enforces 0600 permissions and will refuse to run if the secrets file is accessible to group or world.
3. Create the configuration file
The configuration file controls routing behavior — which sender addresses are approved, who receives admin alerts, and where to log errors. This file does not contain secrets and is safe to manage openly.
sudo tee /opt/postfix-relay/relay-config.json > /dev/null <<'EOF'
{
"ADMIN_NOTIFICATION_EMAIL": "admin@domain.com",
"NOTIFY_ON_OVERRIDE": true,
"_comment_silent": "Senders that are always rewritten but never trigger an admin notification",
"SILENT_OVERRIDE_ADDRESSES": [
"device@domain.com"
],
"LOG_FILE_PATH": "/opt/postfix-relay/postfix-api-relay.log",
"RATE_LIMIT_PER_MINUTE": 28,
"VALID_FROM_ADDRESSES": [
"alerts@domain.com",
"network@domain.com"
],
"_comment_trusted": "IPs and domains work together: a device must match both an IP and a domain to relay",
"TRUSTED_DEVICE_IPS": [
"10.1.1.X"
],
"TRUSTED_DEVICE_DOMAINS": [
"domain.com"
],
"DEFAULT_FROM_ADDRESS": "alerts@domain.com"
}
EOF| Field | Description |
|---|---|
ADMIN_NOTIFICATION_EMAIL | (Required) The admin who receives expiry warnings, permanent delivery failure alerts, and sender override notices. Transient errors (503s, timeouts) are handled automatically by Postfix retry and do not generate admin emails. |
NOTIFY_ON_OVERRIDE | If true, the admin receives an email whenever a message arrives from an unapproved sender address and is automatically rerouted. Set to false to silently override without notification. |
SILENT_OVERRIDE_ADDRESSES | An optional array of sender addresses that are always rewritten to DEFAULT_FROM_ADDRESS but never trigger an admin notification, even when NOTIFY_ON_OVERRIDE is true. Use this for known system accounts (e.g. device@domain.com for a system account) where the rewrite is expected and the notification would only create noise. |
LOG_FILE_PATH | Local log file for all activity and errors. The script creates the file automatically if it does not exist, but pre-creating it with correct ownership is recommended (see step 4). |
VALID_FROM_ADDRESSES | An array of approved sender addresses. If an incoming message matches one of these, it is sent as-is. Otherwise it is sent using the default address. |
TRUSTED_DEVICE_IPS / TRUSTED_DEVICE_DOMAINS | Arrays used to configure Device Overrides. If a sender is not in the VALID_FROM_ADDRESSES list, but the email originates from an IP in TRUSTED_DEVICE_IPS and the sender domain matches a domain in TRUSTED_DEVICE_DOMAINS, it will be permitted to relay. This is useful for devices like copiers that scan to email using arbitrary sender addresses. |
DEFAULT_FROM_ADDRESS | (Required) The fallback sender address used when the original sender is not in the approved list. Must be a valid mailbox in your Graph tenant or a verified identity in SES. |
RATE_LIMIT_PER_MINUTE | Maximum number of API calls allowed per 60-second sliding window. Defaults to 28 for Graph (safely under the M365 30/min limit). For SES, the script auto-detects your account's rate from the MaxSendRate quota if this field is omitted. Set explicitly to override auto-detection, or to 0 to disable rate limiting entirely. |
4. Install the relay script
Download the postfix-api-relay Python script into /opt/postfix-relay/:
sudo curl -fsSL -o /opt/postfix-relay/postfix-api-relay https://meikakuconsulting.com/guides/postfix-relay/postfix-api-relay
sudo chmod 755 /opt/postfix-relay/postfix-api-relay
sudo chown postfix-relay:postfix-relay /opt/postfix-relay/postfix-api-relay
# Pre-create the log file with correct ownership
sudo touch /opt/postfix-relay/postfix-api-relay.log
sudo chown postfix-relay:postfix-relay /opt/postfix-relay/postfix-api-relay.logThe script will create the log file automatically if it does not exist, but pre-creating it here ensures it starts with the correct ownership.
Verify the script compiles
sudo python3 -m py_compile /opt/postfix-relay/postfix-api-relayIf this command produces no output, the script is syntactically valid.
5. Configure Postfix
Two Postfix files need to be modified: main.cf (general settings) and master.cf (service definitions). These changes tell Postfix to accept mail from your LAN and route all outbound mail through the relay script instead of trying to deliver via traditional SMTP.
These commands permanently modify your existing Postfix configuration. Each postconf -e call overwrites the matching parameter in /etc/postfix/main.cf — or adds it if it does not yet exist.
If this is not a brand-new Postfix installation, you must review your current main.cf before proceeding. Parameters such as mynetworks and inet_interfaces are very commonly customized, and overwriting them may disrupt mail flow. Take a backup first:
sudo cp /etc/postfix/main.cf /etc/postfix/main.cf.bak.$(date +%Y%m%d)Then review your current values before running any of the commands below: sudo postconf mynetworks inet_interfaces
main.cf — Accept LAN traffic and set the default transport
Replace 192.168.1.0/24 with your actual internal network CIDR. You can list multiple networks separated by commas.
# Allow your internal network to relay through this server
sudo postconf -e "mynetworks = 127.0.0.0/8, 192.168.1.0/24"
# Route ALL outbound mail through our API relay transport.
# The ":api-relay" suffix is a literal next-hop sentinel (NOT a
# hostname) that forces every recipient — regardless of domain — to
# share the same delivery next-hop, so Postfix bundles them together.
sudo postconf -e "default_transport = apirelay:api-relay"
# Bind Postfix to all interfaces so LAN clients can connect
sudo postconf -e "inet_interfaces = all"
# Bundle ALL recipients into a single pipe invocation (critical for CC/BCC)
sudo postconf -e "apirelay_destination_recipient_limit = 0"Security: Because this Postfix instance accepts unauthenticated mail from mynetworks, it must only be reachable by trusted systems. Use firewall rules or place it on a dedicated management VLAN. Do not expose it to the public internet.
main.cf — Tune retry backoff timing
Three Postfix parameters control how quickly deferred messages are retried:
queue_run_delay— how often the queue manager wakes up to scan for messages that are eligible to retry (default 300 s).minimal_backoff_time— minimum time a message must wait before it becomes eligible for its first retry (default 300 s).maximal_backoff_time— ceiling on the exponential backoff applied to messages that have been deferred multiple times (default 4000 s — over an hour).
All three must be tuned together. Setting the backoff times alone only makes messages eligible to retry sooner — if queue_run_delay is still at its 300 s default, Postfix will not actually attempt them until the next scan, which can be nearly five minutes away.
There is also a subtle race to avoid: if minimal_backoff_time is set equal to or close to queue_run_delay, the queue runner can fire a few seconds before deferred messages become eligible. It finds nothing ready, skips them, and the messages must wait a full extra cycle — producing delays nearly double what you configured. Set the backoff times comfortably below queue_run_delay so deferred messages are always ready before the next scan:
sudo postconf -e "queue_run_delay = 62s"
sudo postconf -e "minimal_backoff_time = 30s"
sudo postconf -e "maximal_backoff_time = 30s"These settings affect all deferred mail on this Postfix instance, not only rate-limited messages. On a dedicated notification relay that is the sole purpose of the system, this is the right trade-off — every deferral is either a transient API error or a rate-limit hit, and a fast retry is always appropriate for both. If you are adapting this configuration to a general-purpose mail server, consider the implications before lowering these values this far.
To drain the deferred queue immediately without waiting for the next scheduled retry run: sudo postqueue -f
master.cf — Define the API relay transport
Add the following line to the end of /etc/postfix/master.cf. This defines a Postfix service named apirelay that pipes every message through the Python script, passing the envelope sender, all recipients, and the client IP as arguments:
sudo tee -a /etc/postfix/master.cf > /dev/null <<'EOF'
# API mail relay transport — routes outbound mail through Graph or SES API
apirelay unix - n n - 1 pipe
flags=Rq user=postfix-relay argv=/opt/postfix-relay/postfix-api-relay ${sender} ${queue_id} ${recipient} ${client_address}
EOFTwo settings work together to deliver every send as a single API call:
- The
:api-relaysuffix ondefault_transportis a literal next-hop sentinel (not a hostname). It forces every recipient, regardless of domain, to share the same next-hop. Without it, Postfix uses each recipient's domain as its own next-hop and schedules one pipe invocation per domain. apirelay_destination_recipient_limit = 0sets no per-next-hop cap, so the whole shared-next-hop batch is handed to the script at once.
Combined with maxproc=1 on the pipe service, the script receives the full envelope recipient list in one invocation and makes exactly one API call per message.
flags=Rq has two roles. R prepends a Return-Path: header with the envelope sender. q quotes whitespace in addresses, preventing malformed argument splitting.
${queue_id} passes the Postfix queue ID to the script as a fixed second argument. The script logs it on every PROCESSING and DELIVERED entry, making it possible to correlate the relay log with the Postfix journal (e.g. to identify which messages were deferred by the rate limiter vs. delivered).
With _destination_recipient_limit = 0, ${recipient} expands to one argument per envelope recipient (Postfix 2.4+). The script reads the MIME To and Cc headers from the message body and classifies each envelope recipient as To, CC, or BCC accordingly.
user=postfix-relay runs the script as the dedicated service account created in step 2, which owns the secrets file and the working directory.
Restart Postfix
sudo systemctl restart postfixSELinux policy (Fedora / RHEL)
On systems with SELinux enforcing, the Postfix pipe daemon runs in a confined domain (postfix_pipe_t) that requires explicit permission to create and write files in /opt/postfix-relay/. Without the correct policy, the relay script fails with PermissionError(13, 'Permission denied')when writing the rate-limit state or log files.
Do not rely on audit2allow alone. The base SELinux policy contains dontaudit rules that suppress certain denials from the audit log. Specifically, the write permission on both directories and files for postfix_pipe_t accessing postfix_spool_t is silently blocked but never logged, so audit2allow never generates the necessary rule. The script continues to fail even after installing an audit2allow-generated module.
Install the complete policy using the pre-built type enforcement file below, which includes all required permissions: write on directories and files, open and read for state file access, and the process inheritance permissions (noatsecure, rlimitinh, siginh) that Postfix master requires when spawning pipe, smtpd, and cleanup worker processes.
# Create a permanent home for the policy source
sudo mkdir -p /opt/postfix-relay/selinux
# Write the complete type enforcement policy
sudo tee /opt/postfix-relay/selinux/postfix-relay.te > /dev/null << 'EOF'
module postfix-relay 1.2;
require {
type postfix_master_t;
type postfix_spool_t;
type postfix_pipe_t;
type postfix_cleanup_t;
type postfix_pickup_t;
type postfix_smtpd_t;
type logrotate_t;
class dir { add_name remove_name write };
class file { create unlink write open read getattr rename };
class process { noatsecure rlimitinh siginh };
}
# Allow Postfix master to spawn worker processes with full inheritance
allow postfix_master_t postfix_cleanup_t:process { noatsecure rlimitinh siginh };
allow postfix_master_t postfix_pickup_t:process { noatsecure rlimitinh siginh };
allow postfix_master_t postfix_pipe_t:process { noatsecure rlimitinh siginh };
allow postfix_master_t postfix_smtpd_t:process { noatsecure rlimitinh siginh };
# Allow the relay script to create and update its runtime files
# (the rate-limit state file and the activity log)
allow postfix_pipe_t postfix_spool_t:dir { add_name remove_name write };
allow postfix_pipe_t postfix_spool_t:file { create unlink write open read getattr };
# Allow logrotate to rotate the relay log file
# (rename the old file, create the new one, compress older rotations)
allow logrotate_t postfix_spool_t:file { getattr open read rename unlink create write };
allow logrotate_t postfix_spool_t:dir { add_name remove_name write };
EOF
# Build and install the module
cd /opt/postfix-relay/selinux
sudo checkmodule -M -m -o postfix-relay.mod postfix-relay.te
sudo semodule_package -o postfix-relay.pp -m postfix-relay.mod
sudo semodule -i postfix-relay.pp
sudo semodule -B
sudo chown -R postfix-relay:postfix-relay /opt/postfix-relay/selinuxSet permanent file context labels
The SELinux policy grants postfix_pipe_t write access to files labeled postfix_spool_t, and relies on the relay script being executable as bin_t. By default, /opt/postfix-relay/ inherits usr_t from /opt/. Without explicit file context rules, any future restorecon — triggered automatically by SELinux package upgrades — will reset the labels and break the relay. Register the correct contexts permanently:
# Label the directory and all state/log files as postfix_spool_t
sudo semanage fcontext -a -t postfix_spool_t "/opt/postfix-relay(/.*)?"
# Override the script itself to bin_t so Postfix pipe can execute it
sudo semanage fcontext -a -t bin_t "/opt/postfix-relay/postfix-api-relay"
# Apply the labels
sudo restorecon -Rv /opt/postfix-relay/The two rules work together: the first labels everything under /opt/postfix-relay/ as postfix_spool_t (matching what the policy allows the pipe daemon to write), and the second overrides just the script back to bin_t so the pipe daemon can execute it. More-specific rules take precedence over less-specific ones, so the script override wins.
Debian and Ubuntu do not use SELinux by default — this step applies only to Fedora, RHEL, AlmaLinux, Rocky Linux, and similar distributions. Proceed to step 6 to confirm delivery works correctly in enforcing mode.
6. Rate limiting
Microsoft 365 enforces a rate limit of 30 messages per minute per mailbox via the Graph API. On a busy network where dozens of systems may generate alerts simultaneously, it is easy to exceed this limit.
The relay script includes a sliding-window rate limiter that enforces this cap automatically. Here is how it works:
| Step | What happens |
|---|---|
| 1 | Postfix spawns one instance of the script per queued message (envelope). All recipients — To, CC, and BCC — are passed together in that single invocation. One API call is made regardless of recipient count. |
| 2 | Before making the API call, the script acquires an exclusive lock on a state file (.rate-limit-state) and counts how many API calls were made in the last 60 seconds. |
| 3a | Under the limit: the script proceeds normally, makes the API call, and records the timestamp. |
| 3b | At the limit: the script exits with EX_TEMPFAIL (exit code 75). This tells Postfix to re-queue the message and retry it later automatically. |
Because Postfix handles all retry logic — including backoff timing and queue persistence — the script never needs to sleep or block. Messages are not lost; they simply wait in the Postfix queue until the rate window has capacity. You can view deferred messages at any time with postqueue -p.
The default limit is 28 per minute — two below the Microsoft cap — to leave headroom for override notifications and credential expiry alerts that the script sends in addition to the primary message. You can adjust or disable this in /opt/postfix-relay/relay-config.json:
// Set to 0 to disable rate limiting entirely
"RATE_LIMIT_PER_MINUTE": 28Amazon SES: SES rate limits vary by account and region (sandbox accounts are limited to 1/sec; production accounts typically get 10–14/sec or more). When using SES, the script automatically queries your account's MaxSendRate via the get_send_quota API and uses that as the limit — no manual configuration needed. If the query fails, it falls back to the default of 28/min. You can always override this by setting RATE_LIMIT_PER_MINUTE explicitly.
7. Test
Send a test email through the relay from the local system:
sendmail -f alerts@domain.com admin@domain.com << 'EOF'
Subject: [RELAY TEST] Basic Test
To: admin@domain.com
Test message from the API relay.
EOFCheck the relay log for confirmation:
tail -20 /opt/postfix-relay/postfix-api-relay.logTest CC and BCC delivery
This is the most important functional test. It verifies that Postfix delivers all envelope recipients in a single pipe invocation and that the script correctly classifies them as To, CC, or BCC based on the MIME headers.
Replace to@external.com with an address you control and bcc@domain.com with an internal address. Use sendmail (not swaks or similar tools) — it goes through the real Postfix pipeline and exercises the correct SELinux domain transitions.
sendmail -f alerts@domain.com to@external.com bcc@domain.com << 'EOF'
From: alerts@domain.com
To: to@external.com
Subject: [RELAY TEST] BCC Classification
MIME-Version: 1.0
Content-Type: text/plain
BCC classification test — safe to delete.
EOFBoth addresses are in the SMTP envelope, but only to@external.com appears in the MIME To: header. The relay classifies bcc@domain.com as BCC because it is absent from all MIME headers. Check the relay log:
tail -10 /opt/postfix-relay/postfix-api-relay.logA successful delivery shows a single PROCESSING line with both recipients, followed by CLASSIFIED and DELIVERED:
[...] PROCESSING: sender='alerts@domain.com' recipients=['to@external.com', 'bcc@domain.com'] client_ip=''
[...] CLASSIFIED: to=['to@external.com'] cc=[] bcc=['bcc@domain.com'] (mime_to=['to@external.com'] mime_cc=[])
[...] DELIVERED: from='alerts@domain.com' to=['to@external.com'] cc=[] bcc=['bcc@domain.com'] subject='[RELAY TEST] BCC Classification'Strict visible-recipient policy. If no envelope recipient appears in the MIME To: or Cc: header, the relay refuses to deliver the message and Postfix bounces it to the envelope sender. This catches broken senders — cron jobs or notification scripts that pipe to sendmail without writing a To: header, and alias configurations that rewrite the envelope without updating the visible header. The admin is alerted so the source can be fixed. The log line is REJECTED (no visible recipient) followed by the envelope and MIME header dump.
If you see separate PROCESSING entries for each recipient (one per domain), the apirelay_destination_recipient_limit = 0 setting is missing from main.cf — see the troubleshooting section below.
Test from another system on your LAN
From any other server or appliance on your network, configure it to send SMTP mail to the relay system's IP address on port 25. For a quick test from another Linux box:
echo "Test from a remote system" | sendmail -S relay-hostname:25 -f alerts@domain.com admin@domain.comTest sender override
Send from an address that is not in your VALID_FROM_ADDRESSES list to verify the override logic:
echo "This should trigger an override" | sendmail -f unknown-device@local admin@domain.comThe email should arrive at your admin address sent from the default address instead of the unknown sender. If NOTIFY_ON_OVERRIDE is enabled, a separate notification email will also be delivered to the admin identifying the original sender.
8. Point your internal systems at the relay
On each server, switch, UPS, or appliance, configure SMTP to point at this relay. The exact setting varies by device, but the values are always:
| Setting | Value |
|---|---|
| SMTP Server / Relay Host | IP address or hostname of this relay system |
| SMTP Port | 25 |
| Authentication | None |
| TLS / SSL | None (internal LAN only) |
For example, on a Proxmox host you would set the relay host under Datacenter → Notifications → SMTP. On a network switch, it is usually under the SNMP/email alert settings. The key point is that every system simply sends standard SMTP to this one relay — no API keys, no OAuth tokens, no expiring credentials on any of them.
9. Error handling and logging
The relay script classifies every API failure as either transient or permanent and tells Postfix how to handle it:
| Scenario | Classification | Postfix behavior |
|---|---|---|
| HTTP 429 (rate limited), 500, 502, 503, 504 | Transient | Script exits with EX_TEMPFAIL (75). Postfix re-queues the message and retries automatically. |
| Network timeout, DNS failure, connection refused | Transient | Same as above — re-queued for retry. |
| HTTP 400 (bad request), 401 (unauthorized), 403 (forbidden), 404 (mailbox not found) | Permanent | Script exits with code 1. Postfix bounces the message back to the sender. Retrying would not help. |
| Credentials approaching expiration (within 30 days) | — | A dedicated expiry alert is sent to the admin. Unlike the single-server scripts, the warning is not prepended to outgoing messages — on a shared relay, arbitrary recipients should not see credential details. |
| Unapproved sender address | — | Message is delivered using the default address. If NOTIFY_ON_OVERRIDE is enabled, the admin is notified separately. |
In all failure cases, the API error response (including HTTP status code and body) is written to two locations:
| Location | How to check |
|---|---|
| Relay log file | Contains timestamped entries with the provider, sender, recipient, and error message. Permanent failures also include the full Python traceback for debugging; transient failures log a concise summary only. Path is configurable via LOG_FILE_PATH in /opt/postfix-relay/relay-config.json.tail -20f /opt/postfix-relay/postfix-api-relay.log |
| Postfix mail log | Postfix captures the script's stderr output, which includes the error summary and whether the message was deferred or bounced.journalctl -u postfix -n 20 -f (RHEL/Fedora)tail -20f /var/log/mail.log (Debian/Ubuntu) |
The -f flag means “follow” — the command will print the last several lines and then stay open, displaying new entries as they appear in real time. Press Ctrl+C to stop.
For permanent failures, the script also attempts to send an email alert to the admin with the failure type clearly labeled in the subject line. Transient failures are not emailed — they are self-healing via Postfix retry and the log entry is sufficient. If the admin alert itself fails, that secondary failure is appended to the relay log file and the script exits — no retry loop is created.
10. Log rotation
The relay log file will grow indefinitely if left unmanaged. Most Linux distributions ship with logrotate installed by default — add a configuration file to rotate the relay log automatically:
sudo tee /etc/logrotate.d/postfix-api-relay > /dev/null <<'EOF'
/opt/postfix-relay/postfix-api-relay.log {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
create 0644 postfix-relay postfix-relay
su postfix-relay postfix-relay
nodateext
}
EOF| Directive | Effect |
|---|---|
weekly | Rotate the log once per week. |
rotate 4 | Keep four weeks of history before deleting the oldest. |
compress / delaycompress | Gzip old logs, but skip compressing the most recently rotated file so it remains easy to grep. |
missingok | Do not error if the log file does not exist yet. |
notifempty | Skip rotation if the file is empty. |
create 0644 postfix-relay postfix-relay | After rotation, create a new log file with the correct ownership so the script can continue writing. |
su postfix-relay postfix-relay | Run the rotation as the relay user instead of root. The newly created log file is then owned correctly from the moment it exists — no chown step is required, so the new file is always writable by the relay script even if some distro-specific quirk prevents create from changing ownership after the fact. |
nodateext | Override the global dateext directive that some distributions (Fedora, RHEL) ship in /etc/logrotate.conf. With dateext, rotated files are named postfix-api-relay.log-YYYYMMDD instead of postfix-api-relay.log.1. Forcing the numeric suffix keeps downstream tools (log shippers, parsers, ingest scripts) able to find the most recent rotation by its conventional name. |
No postrotate signal is needed. The relay script opens and closes the log file on every invocation — it does not hold the file open like a long-running daemon. Logrotate can safely rename the old file and create a new one between invocations.
Verify the configuration parses correctly with a dry run:
sudo logrotate -d /etc/logrotate.d/postfix-api-relayThe -d flag is debug mode — it shows what logrotate would do without actually rotating anything.
Troubleshooting
| Symptom | Likely Cause |
|---|---|
Config file not found | relay-config.json does not exist in /opt/postfix-relay/. Create it per step 3. |
Secrets file not found | /opt/postfix-relay/api-secrets.json does not exist. Create it per step 2. |
permissions are 0o644, expected 0o600 | Run chmod 600 /opt/postfix-relay/api-secrets.json to fix. |
API_PROVIDER must be 'graph' or 'ses' | The API_PROVIDER field is missing or has a typo in the secrets file. |
ADMIN_NOTIFICATION_EMAIL is required | Add the ADMIN_NOTIFICATION_EMAIL field to relay-config.json. This field is mandatory. |
Failed to obtain Graph token | Check API_TENANT_OR_REGION, API_ID, and API_SECRET. Verify the app has Mail.Send permission with admin consent. |
Graph sendMail failed | The DEFAULT_FROM_ADDRESS mailbox may not exist or the app is not authorized to send as that mailbox. |
boto3 is required for the SES provider | Run dnf install python3-boto3 or apt install python3-boto3. |
SES send_email failed | Verify IAM credentials, region, and that the sender address is verified in SES. |
| Remote system cannot connect to the relay | Check that inet_interfaces = all is set in main.cf and that your firewall allows port 25 from the internal network. |
| Mail queues up but never delivers | Run postqueue -p to view the queue. Check the relay log file for errors. Verify the apirelay transport is correctly defined in master.cf. |
rate limit reached (28/28 per minute), deferring | The relay is throttling correctly. Messages are deferred in the Postfix queue and will be retried automatically. If you see this frequently, your environment may be generating more alerts than the provider allows. Check RATE_LIMIT_PER_MINUTE in the config file. |
| Burst senders see the queue drain slowly even after the rate window clears | Two things must align for fast retries. First, queue_run_delay controls how often Postfix scans the deferred queue — its default of 300 s means messages can sit nearly five minutes between attempts regardless of backoff. Second, if minimal_backoff_time is set equal to or close to queue_run_delay, the queue runner can fire just before deferred messages become eligible, miss them, and add a full extra cycle — producing delays nearly double what you configured. Set backoff below the scan interval: queue_run_delay = 62s, minimal_backoff_time = 30s, maximal_backoff_time = 30s. Then reload Postfix. sudo postqueue -f forces an immediate queue run for manual drain. |
| BCC or CC recipients each receive a separate standalone email | Postfix is splitting delivery per domain instead of bundling all recipients into one pipe call. Verify apirelay_destination_recipient_limit = 0 is set in main.cf (run postconf apirelay_destination_recipient_limit to check). Reload Postfix with sudo postfix reload after making changes. |