For a long time, every alert in my homelab left the same way: Beszel and the APC UPS card both sent through smtp.gmail.com with a Gmail app password, using addresses like beszel@king-ppap.net. Inbound mail for the domain goes through Cloudflare Email Routing into Gmail, and Gmail's "Send mail as" covered the outbound side.
Then the alerts stopped arriving. Instead I got this:
550 5.7.26 Unauthenticated email from king-ppap.net is not accepted due to domain's DMARC policy.
Why Gmail SMTP Stopped Working
king-ppap.net has a DMARC record with p=reject. To pass DMARC, a message needs SPF or DKIM to pass for the domain in the From: header.
Mail relayed through a personal Gmail account gets neither:
- SPF is checked against the envelope sender, which Gmail rewrites to
@gmail.com. It passes, but for the wrong domain. Gmail's own message details showmailed-by: gmail.com. - DKIM is signed
d=gmail.com. A free Gmail account can't sign for your own domain.
So DMARC fails, and p=reject does exactly what it says. Adding Google to the domain's SPF record doesn't help either, because SPF is never evaluated against king-ppap.net in the first place.
The fix is to send through something that signs DKIM as king-ppap.net.
Picking a Relay
| Option | Free? | Notes |
|---|---|---|
| Cloudflare Email Sending | No | Can't be enabled without Workers Paid ($5/mo) |
| Brevo | 300/day | Marketing-first platform, one account-wide SMTP key |
| Resend | 100/day, 3,000/mo | API keys can be scoped to one domain, send-only |
For homelab alerts, 100 a day is plenty, and domain-scoped keys mean each device gets its own revocable credential. I went with Resend.
Setting Up Resend
When adding the domain I picked the Tokyo region and turned off click and open tracking. Click tracking rewrites every link through a redirect subdomain, which breaks the direct URLs in alert mails. Open tracking is a hidden pixel I don't need.
Resend supports Cloudflare's Domain Connect, so the DNS records went in with one Authorize click:
| Type | Name | Purpose |
|---|---|---|
| TXT | resend._domainkey | DKIM public key |
| CNAME | send | Return-path for bounces |
| CNAME | rsend | Regional sending subdomain |
| CNAME | links | Tracking (unused with tracking off) |
The root MX, SPF and DMARC records don't change. Inbound mail still goes through Cloudflare Email Routing.
The SMTP settings are the same for every app:
Host: smtp.resend.com Port: 465 (implicit TLS) or 587 (STARTTLS) Username: resend Password: <API key>
The first Beszel test mail went Resend → Cloudflare Email Routing → Gmail, and the headers came back clean:
dkim=pass header.i=@king-ppap.net header.s=resend arc=pass (i=1 spf=pass spfdomain=rsend.king-ppap.net dkim=pass dkdomain=king-ppap.net) dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=king-ppap.net
The forwarding hop is where DKIM earns its keep. Cloudflare's forward rewrites the envelope, so SPF alignment is gone by the time Gmail sees the message. The DKIM signature survives the forward, and DMARC still passes.
The UPS Card That Couldn't Connect
The APC Network Management Card 2 was a different story. Pointed at Resend, it got as far as 220 Ready to start TLS and then failed. On port 465 with implicit TLS, it didn't even get a server response.
openssl showed why:
openssl s_client -starttls smtp -connect smtp.resend.com:587 -tls1_2 \ -cipher ECDHE-RSA-AES128-GCM-SHA256 # ssl/tls alert handshake failure openssl s_client -starttls smtp -connect smtp.resend.com:587 \ | openssl x509 -noout -text | grep "Public Key Algorithm" # Public Key Algorithm: id-ecPublicKey
Resend's SMTP endpoint only offers an ECDSA certificate, and every RSA cipher suite is refused. The UPS card's TLS stack evidently can't do ECDSA, while smtp.gmail.com still offers RSA, which is why it had worked with Gmail all along.
Firmware can't fix that from my side, so the card needed something in the middle.
A Local Relay — Locked Down
A Pi CM5 already runs Docker on the LAN, so it got a small Postfix container (boky/postfix). The UPS talks to the relay with an RSA certificate it understands, and the relay talks to Resend with modern TLS.
The obvious risk: an SMTP relay on the LAN holding a valid API key is exactly what a compromised device would love to find. So the relay is locked down in layers.
# compose.yaml
services:
smtp-relay:
image: boky/postfix:latest
restart: unless-stopped
ports:
- "587:587"
env_file: .env # RELAYHOST_PASSWORD, SMTPD_SASL_USERS
environment:
RELAYHOST: "[smtp.resend.com]:587"
RELAYHOST_USERNAME: resend
ALLOWED_SENDER_DOMAINS: king-ppap.net
POSTFIX_smtp_tls_security_level: encrypt
volumes:
- ./certs:/certs:ro
- ./init:/docker-init.d:ro
The image generates its own Postfix config at startup, and any script in /docker-init.d/ runs afterwards. That gives the lockdown script the final say:
#!/bin/sh
# init/10-lockdown.sh
postconf -e \
"mynetworks = 127.0.0.0/8" \
"smtpd_tls_cert_file = /certs/relay.crt" \
"smtpd_tls_key_file = /certs/relay.key" \
"smtpd_tls_security_level = encrypt" \
"smtpd_tls_auth_only = yes" \
"smtpd_tls_mandatory_protocols = >=TLSv1.2" \
"smtpd_delay_reject = no" \
"smtpd_client_restrictions = check_client_access inline:{ <ups-ip>=OK, 127.0.0.1=OK }, reject" \
"smtpd_relay_restrictions = permit_sasl_authenticated, reject" \
"smtpd_sender_login_maps = inline:{ ups-network@king-ppap.net=ups-network@king-ppap.net }" \
"smtpd_sender_restrictions = reject_sender_login_mismatch, check_sender_access inline:{ ups-network@king-ppap.net=OK }, reject" \
"smtpd_recipient_restrictions = check_recipient_access inline:{ <my-inbox>=OK }, reject" \
"smtpd_client_message_rate_limit = 20" \
"smtpd_client_auth_rate_limit = 10" \
"anvil_rate_time_unit = 3600s" \
"smtpd_client_event_limit_exceptions ="
Each line closes one path for abuse:
| Layer | Effect |
|---|---|
Client allowlist + smtpd_delay_reject = no | Any IP other than the UPS is rejected as soon as it connects |
smtpd_tls_security_level = encrypt + smtpd_tls_auth_only | Login is never offered in cleartext |
| SASL auth required to relay | A random 32-character password per device |
reject_sender_login_mismatch | A login can only send as its own address |
| Recipient allowlist | Mail can only go to my own inboxes, never to arbitrary targets |
| Rate limits | 20 messages and 10 auth attempts per hour |
Two details are easy to miss:
- The certificate is a self-signed RSA key, generated once with
openssl req -x509 -newkey rsa:2048. The card has "Require CA Root Certificate" turned off, and RSA is the whole reason the relay exists. smtpd_client_event_limit_exceptionsdefaults to$mynetworks, so rate limits silently don't apply to trusted clients. Setting it to empty makes them apply to everyone.
I tested every path before wiring up the UPS:
no TLS → AUTH not offered wrong password → 535 authentication failed wrong sender → 553 Sender address rejected: not owned by user wrong recipient → 554 Recipient address rejected: Access denied other LAN host → 554 Client host rejected: Access denied (at connect) UPS, correct auth → 250 Ok → relayed to Resend, status=sent
The worst case is now an attacker who has both spoofed the UPS's IP and stolen its password. Even then, they can only send a few alerts an hour to my own inbox.
The UPS settings ended up as <cm5-ip>:587, authentication on, SSL/TLS Always (STARTTLS), and a static DHCP lease on both boxes so the IP allowlist stays valid.
Where Everything Sends Now
| Sender | Route | Status |
|---|---|---|
| Beszel | Directly to smtp.resend.com | ✅ |
| APC UPS card | Locked-down CM5 relay → Resend | ✅ |
Gmail "Send mail as" (ponpawit@kpp.sh) | smtp.resend.com, port 465 SSL, key scoped to kpp.sh | ✅ |
Takeaways
p=rejectbreaks Gmail "Send mail as" for your domain. Gmail's envelope and DKIM both saygmail.com, so nothing aligns.- DKIM is what survives forwarding. With Cloudflare Email Routing in the path, SPF alignment is gone by the time mail reaches Gmail. DKIM carries it.
- Check the certificate type when an old device can't connect. An ECDSA-only endpoint looks like a generic "SMTP configuration error" on embedded firmware.
- Treat a LAN relay as an internet-facing service. It holds a key that can send as your domain, so lock it down accordingly.