Add optional inbound relay (backup-MX) behind INBOUND_RELAY_ENABLE.
test / test (push) Waiting to run

Port 25 accepts only configured domains and listed recipients, then forwards to an upstream; the outbound path is unchanged when the flag is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 23:17:30 +03:00
parent 6218540211
commit 0d98d92642
49 changed files with 2495 additions and 86 deletions
+27
View File
@@ -5,6 +5,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased]
Optional inbound relay (backup-MX / forwarder) on port 25, off by default.
Mail is accepted only for domains configured in the panel and forwarded to
an upstream; there are still no mailboxes. Security review of the inbound
path is pending (Fable).
### Added
- `INBOUND_RELAY_ENABLE` (default `false`): when true, Postfix listens on
port 25 without SASL, only for `relay_domains` + known recipients, and
forwards via `transport_maps`. Empty upstream host is omitted from the
maps so mail is never accepted with nowhere to send it.
- Panel **Inbound** pages (global administrator): domains, upstream host/port
and TLS, recipient list or any-at-domain, MX check against
`SELFPOST_HOSTNAME`.
- Optional `INBOUND_ANTISPAM_MILTER` on the inbound listener only, plus
[deploy/antispam/docker-compose.antispam.yml](deploy/antispam/docker-compose.antispam.yml).
Default milter action is fail-open (`accept`).
- Inbound rate limit (`INBOUND_RATE_LIMIT_MESSAGES_PER_IP`, default 20) and
`INBOUND_MESSAGE_SIZE_LIMIT` (default 25 MiB) on smtpd port 25.
### Changed
- Compose publishes host port 25 even when the flag is off (nothing listens
until it is on), the same pattern as 587 / `SUBMISSION_ENABLE`.
- Full-backup restore Resync also rebuilds inbound maps when the flag is on.
Single-domain export/import is still sending domains only.
## [1.3.1] - 2026-08-17
Retry policy in the panel, persistent Postfix queue, and self-contained
+16 -3
View File
@@ -10,7 +10,9 @@ Docker image. Postfix, OpenDKIM, and a small Go panel run together under
then point your apps at the SMTP endpoint.
SelfPost sends mail straight to the internet from **your own IP**, with per-domain
DKIM signing. It is **outbound only** — no inbound mail, mailboxes, or webmail.
DKIM signing. It is outbound by default — no mailboxes or webmail. An optional
inbound relay (backup-MX / forwarder on port 25) can be turned on; it forwards
to an upstream, it does not store mail.
**For:** operators who run their own VPS or home server and want a simple relay
they control, without a third-party SMTP provider.
@@ -21,6 +23,7 @@ send log and DNS checks in the panel, encrypted backups.
## Features
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
- Optional inbound relay on port 25 (backup-MX / forwarder; off by default)
- Web panel — domains, applications, deliveries, mail queue, system log, backup
- Multi-domain relay — each SASL application is bound to one sending domain
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
@@ -78,6 +81,15 @@ For every domain you add in the panel:
See [Domain-level DNS](docs/guide.md#domain-level-dns-spf-dkim-dmarc) in the operator guide.
### Per inbound domain (optional)
Only if you turn on inbound relay (`INBOUND_RELAY_ENABLE=true`):
- [ ] MX record pointing at `SELFPOST_HOSTNAME` (keep any existing primary MX
if this host is backup-MX)
See [Inbound relay](docs/guide.md#inbound-relay) in the operator guide.
## Quick start
> **First boot — create the admin account.** On a fresh container SelfPost prints
@@ -136,8 +148,9 @@ DNS — lives in the operator guide's [Full
deployment](docs/guide.md#full-deployment) section, with proxy-specific
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
The compose file always publishes **465** and **587**; Postfix listens on 587
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
The compose file always publishes **465**, **587**, and **25**; Postfix listens
on 587 only when `SUBMISSION_ENABLE=true`, and on 25 only when
`INBOUND_RELAY_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
pinned image tag deliberately when upgrading, never `:latest` ([why](docs/guide.md#fixed-image-tag)). Optional
variables (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
variables](docs/guide.md#environment-variables).
+4 -4
View File
@@ -107,10 +107,10 @@ COPY --chmod=0755 build/crashexit.py /usr/local/bin/crashexit.py
COPY --chmod=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
# plus the panel on 8080. Outbound delivery dials remote MXs on 25 as a client,
# which needs no inbound listener or EXPOSE.
EXPOSE 8080 465 587
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional),
# inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080.
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
EXPOSE 8080 465 587 25
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
+7
View File
@@ -103,6 +103,13 @@ chmod 2750 /data/sasl
# writes sender_login_maps; Postfix owns the on-disk queue tree under queue/.
mkdir -p /data/postfix/queue
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
# Inbound relay maps (written by the panel when INBOUND_RELAY_ENABLE=true).
# Empty files keep postfix check happy if the flag is on before any domain exists.
for f in relay_domains transport relay_recipients tls_policy; do
[ -e "/data/postfix/$f" ] || : > "/data/postfix/$f"
chown panel:selfpost "/data/postfix/$f"
chmod 0640 "/data/postfix/$f"
done
chown panel:selfpost /data/postfix
chmod 2750 /data/postfix
chown panel:selfpost /data/postfix/sender_login_maps
+69
View File
@@ -66,6 +66,20 @@ SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
# Optional inbound relay (backup-MX / forwarder). Off by default: port 25 does
# not accept mail, Postfix inbound maps are not referenced, and the panel UI is
# absent. When true, smtp inet on 25 accepts only relay_domains + known
# recipients (docs/plans/inbound-relay.md).
INBOUND_RELAY_ENABLE="${INBOUND_RELAY_ENABLE:-false}"
INBOUND_ANTISPAM_MILTER="${INBOUND_ANTISPAM_MILTER:-}"
INBOUND_ANTISPAM_MILTER_ACTION="${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
INBOUND_RATE_LIMIT_MESSAGES_PER_IP="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
INBOUND_MESSAGE_SIZE_LIMIT="${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
RELAY_DOMAINS_MAP="${POSTFIX_RELAY_DOMAINS:-/data/postfix/relay_domains}"
TRANSPORT_MAP="${POSTFIX_TRANSPORT_MAPS:-/data/postfix/transport}"
RELAY_RECIPIENTS_MAP="${POSTFIX_RELAY_RECIPIENTS:-/data/postfix/relay_recipients}"
TLS_POLICY_MAP="${POSTFIX_TLS_POLICY_MAPS:-/data/postfix/tls_policy}"
# Delivery log, written by postlogd and read by the panel's log-tailer. It lives
# under the persistent /data (not the ephemeral /var/log) so the delivery lines
# for messages still marked "queued" survive a container recreate — without
@@ -197,6 +211,61 @@ else
postconf -MX "submission/inet" 2>/dev/null || true
fi
# --- inbound smtpd on port 25 (optional backup-MX / forwarder) ---------------
# Debian's stock master.cf enables smtp/inet. When the flag is off, remove that
# listener so port 25 is not an inbound smtpd (outbound delivery uses smtp/unix).
# When on: no SASL, no OpenDKIM, accept only relay_domains + listed recipients.
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
for f in "$RELAY_DOMAINS_MAP" "$TRANSPORT_MAP" "$RELAY_RECIPIENTS_MAP" "$TLS_POLICY_MAP"; do
[ -e "$f" ] || : > "$f"
done
postconf -e \
"relay_domains=texthash:${RELAY_DOMAINS_MAP}" \
"transport_maps=texthash:${TRANSPORT_MAP}" \
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}" \
"smtpd_reject_unlisted_recipient=yes"
INBOUND_MILTERS=""
if [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
case "${INBOUND_ANTISPAM_MILTER}" in
inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;;
*)
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
exit 1
;;
esac
case "${INBOUND_ANTISPAM_MILTER_ACTION}" in
accept|tempfail) ;;
*)
echo "FATAL: INBOUND_ANTISPAM_MILTER_ACTION must be accept or tempfail, got: ${INBOUND_ANTISPAM_MILTER_ACTION}" >&2
exit 1
;;
esac
INBOUND_MILTERS="{ ${INBOUND_ANTISPAM_MILTER}, default_action=${INBOUND_ANTISPAM_MILTER_ACTION} }"
fi
postconf -M "smtp/inet=smtp inet n - n - - smtpd"
postconf -P \
"smtp/inet/smtpd_sasl_auth_enable=no" \
"smtp/inet/smtpd_tls_auth_only=no" \
"smtp/inet/smtpd_sender_login_maps=" \
"smtp/inet/smtpd_sender_restrictions=" \
"smtp/inet/smtpd_client_restrictions=" \
"smtp/inet/smtpd_relay_restrictions=reject_unauth_destination" \
"smtp/inet/smtpd_recipient_restrictions=reject_unauth_destination, reject_unlisted_recipient" \
"smtp/inet/smtpd_milters=${INBOUND_MILTERS}" \
"smtp/inet/smtpd_client_message_rate_limit=${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}" \
"smtp/inet/message_size_limit=${INBOUND_MESSAGE_SIZE_LIMIT}"
else
postconf -MX "smtp/inet" 2>/dev/null || true
postconf -e \
"relay_domains=" \
"transport_maps=" \
"relay_recipient_maps=" \
"smtp_tls_policy_maps="
fi
# Disable chroot for every service (spec 5 p.2). Debian ships the smtp delivery
# agent and others chrooted to /var/spool/postfix, where they cannot read
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail
+19
View File
@@ -15,6 +15,11 @@ var documentedPublic = []string{
"PANEL_SESSION_IDLE_DAYS",
"SELFPOST_DNS_RESOLVERS",
"TRUSTED_PROXY_CIDR",
"INBOUND_RELAY_ENABLE",
"INBOUND_ANTISPAM_MILTER",
"INBOUND_ANTISPAM_MILTER_ACTION",
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
"INBOUND_MESSAGE_SIZE_LIMIT",
}
// documentedInternal matches architecture.md § Configuration "Internal env vars".
@@ -34,6 +39,10 @@ var documentedInternal = []string{
"POSTFIX_DIR",
"POSTFIX_SENDER_LOGIN_MAPS",
"POSTFIX_QUEUE_DIR",
"POSTFIX_RELAY_DOMAINS",
"POSTFIX_TRANSPORT_MAPS",
"POSTFIX_RELAY_RECIPIENTS",
"POSTFIX_TLS_POLICY_MAPS",
"SELFPOST_DEPLOY_ROOT",
"MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT",
@@ -73,6 +82,7 @@ var loadConfigKeys = []string{
"SASL_REALM",
"POSTFIX_DIR",
"SELFPOST_DEPLOY_ROOT",
"INBOUND_RELAY_ENABLE",
}
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
@@ -90,6 +100,15 @@ var buildScriptKeys = []string{
"POSTFIX_QUEUE_DIR",
"SASL_DB_PATH",
"SUBMISSION_ENABLE",
"INBOUND_RELAY_ENABLE",
"INBOUND_ANTISPAM_MILTER",
"INBOUND_ANTISPAM_MILTER_ACTION",
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
"INBOUND_MESSAGE_SIZE_LIMIT",
"POSTFIX_RELAY_DOMAINS",
"POSTFIX_TRANSPORT_MAPS",
"POSTFIX_RELAY_RECIPIENTS",
"POSTFIX_TLS_POLICY_MAPS",
"MILTER_CONNECT_TIMEOUT",
"MILTER_COMMAND_TIMEOUT",
"MILTER_CONTENT_TIMEOUT",
+12 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/buildinfo"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web"
@@ -21,8 +22,10 @@ import (
type mailStack struct {
Domains *domain.Service
Apps *app.Service
Inbound *inbound.Service
pf *postfix.Postfix
odk *domain.OpenDKIM
inboundEnabled bool
}
func newMailStack(cfg config, st *store.Store) *mailStack {
@@ -30,7 +33,8 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
odk := domain.NewOpenDKIM(cfg.opendkimDir)
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk}
inb := inbound.NewService(st, pf)
return &mailStack{Domains: domains, Apps: apps, Inbound: inb, pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled}
}
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
@@ -42,6 +46,11 @@ func (m *mailStack) Resync() error {
if err := m.Apps.Resync(); err != nil {
return fmt.Errorf("postfix resync: %w", err)
}
if m.inboundEnabled {
if err := m.Inbound.Resync(); err != nil {
return fmt.Errorf("inbound maps resync: %w", err)
}
}
return nil
}
@@ -70,7 +79,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
// effective config, including a manual override; the panel keeps this
// snapshot for the process lifetime (architecture.md).
retryPolicy := postfix.LoadRetryPolicy()
return web.New(st, ms.Domains, ms.Apps, web.Config{
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, web.Config{
Hostname: cfg.hostname,
CookieSecure: cfg.cookieSecure,
SubmissionEnabled: cfg.submissionEnabled,
@@ -88,6 +97,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
RetryPolicy: retryPolicy,
InboundEnabled: cfg.inboundEnabled,
}, cfg.setupTokenPath)
}
+4
View File
@@ -79,6 +79,8 @@ type config struct {
saslRealm string
postfixDir string
deployRoot string
inboundEnabled bool
}
func loadConfig() config {
@@ -140,6 +142,8 @@ func loadConfig() config {
saslRealm: saslRealm(),
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true",
}
}
+15
View File
@@ -11,6 +11,21 @@ SELFPOST_HOSTNAME=mail.example.com
# primary 465/smtps listener.
SUBMISSION_ENABLE=false
# Optional inbound relay (backup-MX / forwarder). When true, Postfix accepts
# mail on port 25 only for domains configured in the panel and forwards them
# to the upstream you set. Off by default — the outbound path is unchanged.
INBOUND_RELAY_ENABLE=false
# Optional milter on the inbound listener only (not 465/587). Empty = off.
# Example with deploy/antispam/docker-compose.antispam.yml:
# INBOUND_ANTISPAM_MILTER=inet:antispam:11332
# INBOUND_ANTISPAM_MILTER_ACTION=accept
# Coarse per-client-IP limit and message size on inbound smtpd (port 25).
# INBOUND_RATE_LIMIT_MESSAGES_PER_IP=20
# INBOUND_MESSAGE_SIZE_LIMIT=26214400
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
RATE_LIMIT_MESSAGES_PER_IP=100
RATE_LIMIT_WINDOW_SECONDS=3600
@@ -0,0 +1,29 @@
# Optional anti-spam sidecar for inbound relay (docs/plans/inbound-relay.md).
#
# SelfPost does not ship or start an anti-spam engine. This fragment brings up
# rspamd next to SelfPost and points the inbound smtpd milter at it. Merge it
# only when you want filtering on port 25:
#
# docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
#
# Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client
# IP, HELO and PTR — unlike the upstream, which only sees SelfPost's address.
#
# Pin the rspamd tag the same way the SelfPost image is pinned; bump it
# deliberately. Default milter action is fail-open (accept) so a down sidecar
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
# defer instead.
services:
selfpost:
environment:
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-true}"
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-inet:antispam:11332}"
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
depends_on:
- antispam
antispam:
image: rspamd/rspamd:3.11
restart: unless-stopped
# No host ports: only SelfPost talks to the milter on the compose network.
+1
View File
@@ -23,6 +23,7 @@ services:
ports: !override
- "465:465"
- "587:587"
- "25:25"
environment:
# Caddy names certificate files after the hostname, not
# fullchain.pem/privkey.pem like certbot — override the base file's
+9 -1
View File
@@ -33,6 +33,11 @@ services:
# Set to true to also publish RFC 6409 submission (587/STARTTLS)
# alongside the primary 465/smtps listener (spec 5).
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
INBOUND_ANTISPAM_MILTER: "${INBOUND_ANTISPAM_MILTER:-}"
INBOUND_ANTISPAM_MILTER_ACTION: "${INBOUND_ANTISPAM_MILTER_ACTION:-accept}"
INBOUND_RATE_LIMIT_MESSAGES_PER_IP: "${INBOUND_RATE_LIMIT_MESSAGES_PER_IP:-20}"
INBOUND_MESSAGE_SIZE_LIMIT: "${INBOUND_MESSAGE_SIZE_LIMIT:-26214400}"
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app
# limits (level 2) are configured later from the panel itself.
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
@@ -76,6 +81,9 @@ services:
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
# when the variable is true — see docs/guide.md "Published ports".
- "587:587"
# 25 is mapped even when INBOUND_RELAY_ENABLE=false; Postfix listens only
# when the variable is true.
- "25:25"
- "127.0.0.1:8080:8080"
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as
# root very briefly to fix /data ownership and normalise permissions
@@ -85,7 +93,7 @@ services:
# self-healing. What IS applied: no privilege escalation past what the
# image already grants, and every Linux capability dropped except the
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
# - NET_BIND_SERVICE — bind 465/587 (and 25 outbound) below 1024;
# - NET_BIND_SERVICE — bind 465/587 and inbound 25 below 1024;
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
# - FOWNER — entrypoint then chmods those now panel-owned /data
# dirs/files while still root (owner-check bypass);
+1
View File
@@ -23,6 +23,7 @@ services:
ports: !override
- "465:465"
- "587:587"
- "25:25"
# No host publish for 8080 here: nginx reaches it over the compose
# network at selfpost:8080 instead (see nginx.conf.example).
volumes: !override
@@ -15,6 +15,7 @@ services:
ports: !override
- "465:465"
- "587:587"
- "25:25"
volumes: !override
- ./data:/data
- .:/selfpost-deploy:ro
+38 -4
View File
@@ -53,13 +53,31 @@ Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
├─► OpenDKIM milter (sign, tempfail on failure)
├─► journal-milter (send log + L2 rate limits, fail-open)
└─► outbound MX delivery (port 25 client)
Internet ──► Postfix smtp inet :25 (only when INBOUND_RELAY_ENABLE=true)
├─► optional antispam milter (inbound only)
└─► smtp:[upstream]:port (transport_maps; no local delivery)
```
The inbound listener is **absent** when the flag is off (`postconf -MX smtp/inet`
removes Debian's stock smtpd). Outbound delivery still uses the `smtp unix`
client; it is not the same service.
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
- **465/smtps** — implicit TLS, SASL required; primary listener.
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
`smtpd_tls_security_level=encrypt`.
- **25/smtp inet** — only when `INBOUND_RELAY_ENABLE=true`. No SASL, no
OpenDKIM, no journal-milter. Accepts only `relay_domains` +
`relay_recipient_maps` (`reject_unauth_destination`,
`reject_unlisted_recipient`). Maps under `/data/postfix/`
(`relay_domains`, `transport`, `relay_recipients`, `tls_policy`), written
atomically by [internal/postfix/inbound.go](../internal/postfix/inbound.go).
Domains with an empty upstream host are omitted from the maps. Optional
`INBOUND_ANTISPAM_MILTER` on this listener only; default
`milter_default_action` is fail-open (`accept`).
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
@@ -186,6 +204,7 @@ below is a summary — HTMX fragment endpoints
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
| `/settings` | Username/password for any user; DMARC report default is **global** only |
| `/users`, `/users/*` | **Global.** Panel user CRUD |
| `/inbound`, `/inbound/{id}`, `/inbound/{id}/*` | **Global.** Inbound relay domains. Registered only when `INBOUND_RELAY_ENABLE=true`; otherwise 404. |
HTMX polling refreshes monitoring fragments (5 s while the operator is active on
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
@@ -251,6 +270,7 @@ flowchart TB
subgraph services ["Services — multi-store operations + rollback"]
domainSvc["internal/domain"]
appSvc["internal/app"]
inboundSvc["internal/inbound"]
end
subgraph persistence ["Persistence"]
store["internal/store — SQLite, embedded migrations"]
@@ -272,16 +292,19 @@ flowchart TB
web --> store
web --> domainSvc
web --> appSvc
web --> inboundSvc
web --> backupPkg
web --> dnscheck
web --> health
web --> secretfile
domainSvc --> store
appSvc --> store
inboundSvc --> store
milterPkg --> store
logtail --> store
domainSvc --> postfix
appSvc --> postfix
inboundSvc --> postfix
```
The three roles inside the `panel` process (HTTP server, journal-milter,
@@ -295,11 +318,15 @@ single-connection trade-off that follows from it.
| Path | Contents |
|---|---|
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset |
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset, inbound relay domains |
| `setup-token` | First-run setup token file |
| `opendkim/` | DKIM keys + tables |
| `sasl/sasldb2` | Application SASL credentials |
| `postfix/sender_login_maps` | Login → From binding |
| `postfix/relay_domains` | Inbound domains accepted on port 25 |
| `postfix/transport` | Inbound next-hop `smtp:[host]:port` |
| `postfix/relay_recipients` | Inbound recipient allow-list or `@domain` catch-all |
| `postfix/tls_policy` | TLS policy for inbound next hops |
| `postfix/queue/` | Postfix transit mail (deferred/active); survives container recreate |
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
| `manifest.json` | Backup version stamp (consumed on restore) |
@@ -317,9 +344,10 @@ under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
version check on restore. Requires the project directory mounted read-only at
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
tables and Postfix's sender map are re-derived from SQLite and both daemons are
reloaded, so drift between the extracted archive and the database is healed
before mail flows (same step as `POST /reload` on demand). Stopped-container
tables, Postfix's sender map, and (when `INBOUND_RELAY_ENABLE=true`) inbound
relay maps are re-derived from SQLite and both daemons are reloaded, so drift
between the extracted archive and the database is healed before mail flows
(same step as `POST /reload` on demand). Stopped-container
`tar` of `./data` alone remains possible for state-only copies (see guide).
**Optional encryption** of the two secret-bearing downloads
@@ -364,6 +392,12 @@ unsupported rather than as a missing doc:
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
desyncs the map Postfix reads from the file the panel maintains),
`POSTFIX_RELAY_DOMAINS` (`/data/postfix/relay_domains`),
`POSTFIX_TRANSPORT_MAPS` (`/data/postfix/transport`),
`POSTFIX_RELAY_RECIPIENTS` (`/data/postfix/relay_recipients`),
`POSTFIX_TLS_POLICY_MAPS` (`/data/postfix/tls_policy`) — same desync if
overridden without matching the panel writer in
[internal/postfix/inbound.go](../internal/postfix/inbound.go),
`POSTFIX_QUEUE_DIR` (`/data/postfix/queue` — set in `build/postfix-config.sh`),
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` — operator project directory for
full backups; mount `.:/selfpost-deploy:ro` in compose).
+74 -13
View File
@@ -32,6 +32,7 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
- [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop)
- [Full backup and restore](#full-backup-and-restore)
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
- [Inbound relay](#inbound-relay)
- [Domain administration](#domain-administration)
- [Domains page](#domains-page)
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
@@ -44,10 +45,13 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
### Ports
`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465
(smtps) is always active. Port **587** is published even when
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless,
but it can look like an open port in external scans.
`deploy/docker-compose.yml` maps **465**, **587**, and **25** to the host. Port
465 (smtps) is always active. Port **587** is published even when
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true`. Port
**25** is published even when `INBOUND_RELAY_ENABLE=false`; Postfix does not
accept inbound mail until you set it to `true` (see [Inbound
relay](#inbound-relay)). Harmless extra publishes can look like open ports in
external scans.
### Local trial
@@ -185,6 +189,11 @@ expected to set; defaults match the code exactly.
|---|---|---|---|
| `SELFPOST_HOSTNAME` | Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. | *(required)* | `.env` |
| `SUBMISSION_ENABLE` | When `true`, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. | `false` | `.env` |
| `INBOUND_RELAY_ENABLE` | When `true`, accept mail on port 25 for domains configured under *Inbound* in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See [Inbound relay](#inbound-relay). | `false` | `.env` |
| `INBOUND_ANTISPAM_MILTER` | Optional milter on the inbound listener only (not 465/587). Empty = off. Format `inet:host:port` or `unix:/path`. Example with [deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml): `inet:antispam:11332`. | *(empty)* | `.env` |
| `INBOUND_ANTISPAM_MILTER_ACTION` | What Postfix does if that milter is down: `accept` (fail-open) or `tempfail` (defer). | `accept` | `.env` |
| `INBOUND_RATE_LIMIT_MESSAGES_PER_IP` | Coarse per-client-IP cap on inbound smtpd (`smtpd_client_message_rate_limit`). Uses the same window as `RATE_LIMIT_WINDOW_SECONDS`. | `20` | `.env` |
| `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). | `26214400` (25 MiB) | `.env` |
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
| `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.env` |
@@ -313,8 +322,9 @@ against the PTR record the internet publishes for this server's IP
(forward-confirmed reverse DNS) — see
[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
Postfix sender map from the database — use it if daemons drifted from what
the panel shows after manual edits under `/data`.
Postfix sender map from the database (and inbound relay maps when
`INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the panel
shows after manual edits under `/data`.
### Mail queue and System log
@@ -346,12 +356,13 @@ are separate and are not changed here.
There are two roles:
- **Global administrator** — full access to every page and every domain,
including Users, Backup, Status, Mail queue, and System log.
including Users, Backup, Status, Mail queue, System log, and Inbound (when
the inbound relay flag is on).
- **Domain-admin** — scoped to one or more domains assigned by a global
administrator. Sees only those domains' pages, applications, and
Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
`/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not
reachable (404). A domain-admin can *export* the
`/status`, `/mail-queue`, `/system-log`, `/inbound`, and `POST /reload` are
not reachable (404). A domain-admin can *export* the
domains assigned to them — see
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain).
@@ -443,9 +454,10 @@ which tag to use. On the first successful start after restore, `data/manifest.js
from the archive is **deleted** — it guards only that one boot, so a later
in-place upgrade is not blocked. On that same first boot the panel also runs
one **Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from
SQLite and both daemons are reloaded, healing any drift between the extracted
files and the database (the Status page's *Reload configuration* button runs
the same step on demand). This is why the compose file pins a fixed tag rather
SQLite (and inbound relay maps when `INBOUND_RELAY_ENABLE=true`) and both
daemons are reloaded, healing any drift between the extracted files and the
database (the Status page's *Reload configuration* button runs the same step on
demand). This is why the compose file pins a fixed tag rather
than `:latest`: without a known version, there'd be no way to tell which image
restoring a given backup actually requires (see [Fixed image
tag](#fixed-image-tag)).
@@ -579,6 +591,53 @@ docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > back
With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
### Inbound relay
Optional backup-MX / forwarder: Postfix accepts mail on port **25** for
domains you list under *Inbound* and hands each message to the upstream host
you configure. It is **not** mailboxes, IMAP, or webmail — SelfPost never
stores the message locally.
**Off by default.** Set `INBOUND_RELAY_ENABLE=true` in `.env` and recreate the
container. Until then there is no `smtp inet` listener, no Inbound item in
the nav, and `/inbound` is 404. Outbound 465/587 is unchanged.
**Panel** (`/inbound`, global administrator only): add a domain, set the
upstream host/port and TLS to that hop (opportunistic / required / off), and
choose recipients — an allow-list, or any address at that domain. A domain
with an empty upstream is kept in the database but is **not** published into
Postfix maps, so mail is never accepted with nowhere to send it.
**DNS.** Unlike sending domains, an inbound domain needs an **MX** record that
points at `SELFPOST_HOSTNAME`. The domain page shows the value to publish
(`10 <hostname>.`) and a check that succeeds when *any* MX host matches this
server — other MX targets (a primary mail server) are fine; this is how
backup-MX is meant to work. Use *Re-check* after publishing.
**Not an open relay.** The inbound smtpd offers no SASL. It accepts only
domains in `relay_domains` and only listed recipients (`relay_recipient_maps`);
everything else is `reject_unauth_destination` / `reject_unlisted_recipient`.
Prefer an explicit recipient list so unknown addresses are refused at RCPT
and never generate a bounce (backscatter).
**Anti-spam.** SelfPost does not ship a filter. To attach one, set
`INBOUND_ANTISPAM_MILTER` (inbound listener only) and merge
[deploy/antispam/docker-compose.antispam.yml](../deploy/antispam/docker-compose.antispam.yml)
the same way as the nginx/Caddy fragments:
```sh
docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d
```
The milter sees the real client IP, HELO and PTR — unlike the upstream, which
only sees SelfPost. Default action is fail-open (`accept`) so a down sidecar
does not block backup-MX; set `INBOUND_ANTISPAM_MILTER_ACTION=tempfail` to
defer instead.
Inbound configuration lives in SQLite and `/data/postfix/` map files, so it
is included in a [full backup](#full-backup-and-restore). Single-domain
export/import is sending domains only.
## Domain administration
### Domains page
@@ -595,7 +654,9 @@ domain](#exporting-and-importing-a-single-domain).
### Domain-level DNS (SPF, DKIM, DMARC)
For *every* sending domain you add in the panel:
For *every* sending domain you add in the panel (outbound). An inbound
forwarding domain is a different object — it needs an MX, not these TXT
records; see [Inbound relay](#inbound-relay).
- **SPF** — a TXT record on the domain authorizing this server to send on its
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
+14 -14
View File
@@ -175,18 +175,18 @@ Target version cut: **`1.4.0`** (MINOR). One commit per step; see
[development.md](../development.md) § Plan checklists. UI reference:
[panel-ui inbound mockups](../assets/panel-ui/inbound.html).
- [ ] Migration: inbound domain / recipient / transport tables under `/data`**Opus**
- [ ] `INBOUND_RELAY_ENABLE` (default false) in entrypoint + `postfix-config.sh`**Opus**
- [ ] `master.cf`: inbound `smtp inet` on 25; separate from 465/587 — **Opus**
- [ ] Generate `relay_domains`, `transport_maps`, `relay_recipient_maps` (injection-safe) — **Opus**
- [ ] `smtpd_relay_restrictions` / recipient maps — no open relay, no backscatter — **Opus**
- [ ] `internal/store` CRUD + validation (domain, host, port) — **Opus**
- [ ] Panel: list, add, domain detail, recipients, danger zone (mockups) — **Sonnet**
- [ ] Rate limit + `message_size_limit` on inbound smtpd — **Opus**
- [ ] Optional `INBOUND_ANTISPAM_MILTER` + compose fragment — **Opus**
- [ ] DNS MX copy in README/guide; `.env.example`**Sonnet**
- [ ] Backup/export inbound config (per plan optional flag) — **Opus**
- [ ] Unit + handler tests; image build and container smoke — **Opus**
- [ ] [guide.md](../guide.md) and [security.md](../security.md) — **Sonnet**
- [x] Migration: inbound domain / recipient / transport tables under `/data`**Opus**
- [x] `INBOUND_RELAY_ENABLE` (default false) in entrypoint + `postfix-config.sh`**Opus**
- [x] `master.cf`: inbound `smtp inet` on 25; separate from 465/587 — **Opus**
- [x] Generate `relay_domains`, `transport_maps`, `relay_recipient_maps` (injection-safe) — **Opus**
- [x] `smtpd_relay_restrictions` / recipient maps — no open relay, no backscatter — **Opus**
- [x] `internal/store` CRUD + validation (domain, host, port) — **Opus**
- [x] Panel: list, add, domain detail, recipients, danger zone (mockups) — **Sonnet**
- [x] Rate limit + `message_size_limit` on inbound smtpd — **Opus**
- [x] Optional `INBOUND_ANTISPAM_MILTER` + compose fragment — **Opus**
- [x] DNS MX copy in README/guide; `.env.example`**Sonnet**
- [x] Backup/export inbound config (per plan optional flag) — **Opus**
- [x] Unit + handler tests; image build and container smoke — **Opus**
- [x] [guide.md](../guide.md) and [security.md](../security.md) — **Sonnet**
- [ ] Security review inbound path — **Fable**
- [ ] `go vet`, `go test`, e2e if applicable — **Haiku**
- [x] `go vet`, `go test`, e2e if applicable — **Haiku**
+9 -8
View File
@@ -50,18 +50,19 @@ Explicitly excluded to prevent scope creep:
- Organisations / tenancy beyond global + domain-admin roles; managing
**multiple sending domains** under one global administrator is in scope (see
below)
- Inbound antispam/antivirus (rspamd, ClamAV, etc.)
- Inbound antispam/antivirus engines (rspamd, ClamAV, etc.) — SelfPost may
expose a milter hook; it does not ship or start a filter
- A custom MTA — Postfix is used as-is
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
The **domain-admin** role ships in the current line (global administrator plus
domain administrators with assigned domains). The optional **inbound relay** is
the main agreed **1.x+** extension still on the
[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major
remains possible pending implementation. Items marked *candidate* in the
roadmap require explicit approval before coding (including **send-log retention
in Settings**, **30-day send statistics**, and **auto level-2 rate limits**
see linked plans there).
domain administrators with assigned domains). The optional **inbound relay**
(backup-MX / forwarder on port 25) is an agreed 1.x extension — off by default
behind `INBOUND_RELAY_ENABLE`; it is relay/forward, not IMAP/webmail. Items
marked *candidate* in the
[roadmap](roadmap.md) require explicit approval before coding (including
**send-log retention in Settings**, **30-day send statistics**, and **auto
level-2 rate limits** — see linked plans there).
---
+1 -1
View File
@@ -28,7 +28,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Progress | Plan |
|---|---|---|---|---|
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | 0/15 | [plans/inbound-relay.md](plans/inbound-relay.md) |
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | 14/15 | [plans/inbound-relay.md](plans/inbound-relay.md) |
| send-log-retention | Send-log retention days in panel Settings | candidate | 0/8 | [plans/send-log-retention.md](plans/send-log-retention.md) |
| domain-stats-auto-ratelimit | 30-day send stats + auto level-2 rate limit | candidate | 0/11 | [plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md) |
| contributing | `CONTRIBUTING.md` | candidate | — | — |
+13 -2
View File
@@ -10,7 +10,9 @@ login in the `saslpasswd2` argv
2026-08-13 full-tree review against this document — send-log authorization for
domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed
session creation, and application-delete ordering: no findings, and nothing
needed adding to the accepted risks. (2) **Accepted risks**
needed adding to the accepted risks. The inbound-relay path (port 25, maps,
panel) is described under Mail path below and is **not yet** in that review
history — it awaits a separate Fable pass before 1.4.0. (2) **Accepted risks**
deliberate departures beyond the mandatory, recorded so the decision is not
lost.
@@ -77,8 +79,17 @@ The panel is exposed to the internet — the items below are **not optional**.
### Mail path (security-relevant)
- **Not an open relay** — SASL only; `reject_unauth_destination`;
- **Not an open relay** — SASL only on 465/587; `reject_unauth_destination`;
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
- **Inbound relay (optional, `INBOUND_RELAY_ENABLE`)** — port 25 is not an
open relay either: SASL is off; `smtpd_relay_restrictions` /
`smtpd_recipient_restrictions` are `reject_unauth_destination` and
`reject_unlisted_recipient`; maps list only configured domains and
recipients. Domains with no upstream host are omitted from the maps so mail
is never accepted with nowhere to send it. Prefer recipient mode `list` to
refuse unknown addresses at RCPT (no backscatter). OpenDKIM is not attached
on inbound. An optional antispam milter is inbound-only; default action is
fail-open (`accept`).
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
`encrypt`).
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
+71
View File
@@ -13,6 +13,7 @@ package dnscheck
import (
"context"
"errors"
"fmt"
"net"
"strings"
"sync"
@@ -98,6 +99,7 @@ type Checker struct {
mu sync.Mutex
servers map[string]cached[Server]
domains map[string]cached[Domain]
inbound map[string]cached[Result]
}
type cached[T any] struct {
@@ -120,6 +122,7 @@ func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
domainTTL: domTTL,
servers: make(map[string]cached[Server]),
domains: make(map[string]cached[Domain]),
inbound: make(map[string]cached[Result]),
}
}
@@ -172,9 +175,77 @@ func (c *Checker) Domain(q Query, force bool) Domain {
func (c *Checker) Forget(domainName string) {
c.mu.Lock()
delete(c.domains, domainName)
delete(c.inbound, domainName)
c.mu.Unlock()
}
// InboundMX reports whether any MX for name points at this server's hostname
// (the inbound-relay check). Other MX values are the domain's own primaries
// and are not an error. force skips the cache.
func (c *Checker) InboundMX(name, hostname string, force bool) Result {
key := name + "\x00" + hostname
if !force {
c.mu.Lock()
entry, ok := c.inbound[key]
c.mu.Unlock()
if ok && time.Now().Before(entry.expires) {
return entry.value
}
}
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
defer cancel()
result := c.checkInboundMX(ctx, name, hostname)
c.mu.Lock()
c.inbound[key] = cached[Result]{value: result, expires: time.Now().Add(c.domainTTL)}
c.mu.Unlock()
return result
}
func (c *Checker) checkInboundMX(ctx context.Context, name, hostname string) Result {
mxs, err := c.resolver.LookupMX(ctx, name)
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname),
}
}
return lookupFailed("the MX record", err)
}
if len(mxs) == 0 {
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname),
}
}
want := normalizeName(hostname)
var records []string
matched := false
for _, mx := range mxs {
host := normalizeName(mx.Host)
records = append(records, fmt.Sprintf("%d %s.", mx.Pref, host))
if host == want {
matched = true
}
}
if matched {
return Result{
Status: health.StatusOK,
Detail: fmt.Sprintf("An MX points at %s (this server). Other MX values are the domain's own primaries — they are not an error.", hostname),
Records: records,
}
}
return Result{
Status: health.StatusError,
Detail: fmt.Sprintf("No MX points at %s (this server). Publish the record below, or wait for DNS to propagate and Re-check.", hostname),
Records: records,
}
}
// checkDomain runs the three record checks concurrently: they are independent,
// and in series three timeouts would stack up into a page that looks hung.
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
+34
View File
@@ -374,3 +374,37 @@ func TestReportAuth(t *testing.T) {
t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample())
}
}
func TestInboundMXPointsAtServer(t *testing.T) {
f := &fakeResolver{
mx: map[string][]*net.MX{
"lists.example.com": {
{Host: "mail.primary.example.net.", Pref: 10},
{Host: "mail.example.org.", Pref: 20},
},
},
}
got := newTestChecker(f).InboundMX("lists.example.com", "mail.example.org", false)
if got.Status != health.StatusOK {
t.Fatalf("status = %q (%s)", got.Status, got.Detail)
}
}
func TestInboundMXMissingThisServer(t *testing.T) {
f := &fakeResolver{
mx: map[string][]*net.MX{
"backup.example.net": {{Host: "mail.primary.example.net.", Pref: 10}},
},
}
got := newTestChecker(f).InboundMX("backup.example.net", "mail.example.org", false)
if got.Status != health.StatusError {
t.Fatalf("status = %q, want error", got.Status)
}
}
func TestInboundMXAbsent(t *testing.T) {
got := newTestChecker(&fakeResolver{}).InboundMX("none.example", "mail.example.org", false)
if got.Status != health.StatusError {
t.Fatalf("status = %q, want error", got.Status)
}
}
+156
View File
@@ -0,0 +1,156 @@
// Package inbound owns backup-MX / relay-forwarder domains: the SQLite
// registry and the Postfix lookup tables (relay_domains, transport_maps,
// relay_recipient_maps, smtp_tls_policy_maps). It does not listen on port 25
// itself — postfix-config.sh does that when INBOUND_RELAY_ENABLE is true.
package inbound
import (
"fmt"
"strings"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// Maps is the slice of the Postfix manager the inbound service needs.
type Maps interface {
RebuildInboundMaps(routes []postfix.InboundRoute) error
}
// Service coordinates inbound-domain state across SQLite and the Postfix maps.
type Service struct {
store *store.Store
maps Maps
}
// NewService builds the inbound service over the shared store and Postfix maps.
func NewService(st *store.Store, maps Maps) *Service {
return &Service{store: st, maps: maps}
}
// List returns every inbound domain.
func (s *Service) List() ([]store.InboundDomain, error) {
return s.store.ListInboundDomains()
}
// Get returns one inbound domain by id.
func (s *Service) Get(id int64) (store.InboundDomain, error) {
return s.store.GetInboundDomain(id)
}
// Add validates the domain name, inserts it, and rebuilds the maps.
func (s *Service) Add(name string) (store.InboundDomain, error) {
name = normalizeDomain(name)
if err := checkDomain(name); err != nil {
return store.InboundDomain{}, err
}
d, err := s.store.AddInboundDomain(name)
if err != nil {
return store.InboundDomain{}, err
}
if err := s.Resync(); err != nil {
_ = s.store.DeleteInboundDomain(d.ID)
return store.InboundDomain{}, err
}
return d, nil
}
// SetTransport validates and saves the upstream, then rebuilds the maps.
func (s *Service) SetTransport(id int64, host, portRaw, tlsMode string) error {
if _, err := s.store.GetInboundDomain(id); err != nil {
return err
}
host = normalizeHost(host)
if err := checkHost(host); err != nil {
return err
}
port, err := parsePort(portRaw)
if err != nil {
return err
}
if err := checkTLSMode(tlsMode); err != nil {
return err
}
if err := s.store.UpdateInboundTransport(id, host, port, tlsMode); err != nil {
return err
}
return s.Resync()
}
// SetRecipients validates the mode and, in list mode, every address, then
// rebuilds the maps.
func (s *Service) SetRecipients(id int64, mode string, rawAddresses []string) error {
d, err := s.store.GetInboundDomain(id)
if err != nil {
return err
}
if err := checkRecipientMode(mode); err != nil {
return err
}
var addrs []string
if mode == store.RecipientModeList {
addrs, err = parseRecipientAddresses(rawAddresses, d.Name)
if err != nil {
return err
}
}
if err := s.store.UpdateInboundRecipients(id, mode, addrs); err != nil {
return err
}
return s.Resync()
}
// Delete removes the domain and rebuilds the maps.
func (s *Service) Delete(id int64) error {
if err := s.store.DeleteInboundDomain(id); err != nil {
return err
}
return s.Resync()
}
// Resync rebuilds the inbound Postfix maps from SQLite.
func (s *Service) Resync() error {
list, err := s.store.ListInboundDomains()
if err != nil {
return err
}
routes := make([]postfix.InboundRoute, 0, len(list))
for _, d := range list {
full, err := s.store.GetInboundDomain(d.ID)
if err != nil {
return err
}
routes = append(routes, postfix.InboundRoute{
Domain: full.Name,
Host: full.Host,
Port: full.Port,
TLSMode: full.TLSMode,
RecipientMode: full.RecipientMode,
Recipients: full.Recipients,
})
}
return s.maps.RebuildInboundMaps(routes)
}
func parseRecipientAddresses(raw []string, domain string) ([]string, error) {
seen := make(map[string]bool)
out := make([]string, 0, len(raw))
for _, r := range raw {
addr := strings.ToLower(strings.TrimSpace(r))
if addr == "" {
continue
}
if err := checkMailbox(addr, domain); err != nil {
return nil, err
}
if seen[addr] {
continue
}
seen[addr] = true
out = append(out, addr)
}
if len(out) == 0 {
return nil, fmt.Errorf("listed-recipients mode requires at least one address")
}
return out, nil
}
+111
View File
@@ -0,0 +1,111 @@
package inbound
import (
"errors"
"path/filepath"
"testing"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
type fakeMaps struct {
routes []postfix.InboundRoute
err error
rebuild int
}
func (f *fakeMaps) RebuildInboundMaps(routes []postfix.InboundRoute) error {
f.rebuild++
f.routes = append([]postfix.InboundRoute(nil), routes...)
return f.err
}
func testService(t *testing.T) (*Service, *store.Store, *fakeMaps) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
m := &fakeMaps{}
return NewService(st, m), st, m
}
func TestAddAndSetTransport(t *testing.T) {
s, _, m := testService(t)
d, err := s.Add("Lists.Example.COM")
if err != nil {
t.Fatal(err)
}
if d.Name != "lists.example.com" {
t.Fatalf("name = %q", d.Name)
}
if m.rebuild != 1 {
t.Fatalf("rebuild after add = %d", m.rebuild)
}
// Empty host is omitted from maps.
if len(m.routes) != 1 || m.routes[0].Host != "" {
t.Fatalf("routes after add: %+v", m.routes)
}
if err := s.SetTransport(d.ID, "10.0.0.8", "25", store.TLSModeEncrypt); err != nil {
t.Fatal(err)
}
if m.routes[0].Host != "10.0.0.8" || m.routes[0].TLSMode != store.TLSModeEncrypt {
t.Fatalf("routes after transport: %+v", m.routes[0])
}
}
func TestSetRecipientsValidatesDomain(t *testing.T) {
s, _, _ := testService(t)
d, err := s.Add("lists.example.com")
if err != nil {
t.Fatal(err)
}
err = s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@other.com"})
if err == nil {
t.Fatal("expected foreign-domain error")
}
err = s.SetRecipients(d.ID, store.RecipientModeList, nil)
if err == nil {
t.Fatal("expected empty-list error")
}
if err := s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@lists.example.com"}); err != nil {
t.Fatal(err)
}
}
func TestRejectsUnsafeHost(t *testing.T) {
s, _, _ := testService(t)
d, err := s.Add("lists.example.com")
if err != nil {
t.Fatal(err)
}
if err := s.SetTransport(d.ID, "10.0.0.8; rm", "25", store.TLSModeMay); err == nil {
t.Fatal("expected unsafe host to be rejected")
}
}
func TestDeleteResyncs(t *testing.T) {
s, _, m := testService(t)
d, err := s.Add("lists.example.com")
if err != nil {
t.Fatal(err)
}
if err := s.Delete(d.ID); err != nil {
t.Fatal(err)
}
if !errors.Is(mustGet(t, s, d.ID), store.ErrInboundDomainNotFound) {
t.Fatal("domain still present")
}
if len(m.routes) != 0 {
t.Fatalf("maps after delete: %+v", m.routes)
}
}
func mustGet(t *testing.T, s *Service, id int64) error {
t.Helper()
_, err := s.Get(id)
return err
}
+131
View File
@@ -0,0 +1,131 @@
package inbound
import (
"fmt"
"net"
"strconv"
"strings"
)
func normalizeDomain(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func normalizeHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
return host
}
func checkDomain(name string) error {
if name == "" {
return fmt.Errorf("domain is required")
}
if len(name) > 253 {
return fmt.Errorf("domain must be at most 253 characters")
}
labels := strings.Split(name, ".")
if len(labels) < 2 {
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
}
for _, label := range labels {
if err := checkLabel(label); err != nil {
return err
}
}
return nil
}
func checkHost(host string) error {
if host == "" {
return fmt.Errorf("host is required")
}
if len(host) > 253 {
return fmt.Errorf("host must be at most 253 characters")
}
if ip := net.ParseIP(host); ip != nil {
return nil
}
for _, label := range strings.Split(host, ".") {
if err := checkLabel(label); err != nil {
return fmt.Errorf("host is invalid: %w", err)
}
}
return nil
}
func checkLabel(label string) error {
if len(label) == 0 {
return fmt.Errorf("must not contain an empty label")
}
if len(label) > 63 {
return fmt.Errorf("each label must be at most 63 characters")
}
if label[0] == '-' || label[len(label)-1] == '-' {
return fmt.Errorf("labels must not start or end with '-'")
}
for i := 0; i < len(label); i++ {
c := label[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '-' {
return fmt.Errorf("may contain only lower-case letters, digits, '.' and '-'")
}
}
return nil
}
func parsePort(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, fmt.Errorf("port is required")
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 65535 {
return 0, fmt.Errorf("port must be between 1 and 65535")
}
return n, nil
}
func checkTLSMode(mode string) error {
switch mode {
case "may", "encrypt", "none":
return nil
default:
return fmt.Errorf("invalid TLS mode")
}
}
func checkRecipientMode(mode string) error {
switch mode {
case "list", "any":
return nil
default:
return fmt.Errorf("invalid recipient mode")
}
}
func checkMailbox(addr, domain string) error {
at := strings.LastIndexByte(addr, '@')
if at <= 0 || at >= len(addr)-1 {
return fmt.Errorf("%q is not a valid email address", addr)
}
local, host := addr[:at], addr[at+1:]
if host != domain {
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
}
if local == "" || local[0] == '.' || local[len(local)-1] == '.' {
return fmt.Errorf("%q: invalid local part", addr)
}
for i := 0; i < len(local); i++ {
c := local[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
return fmt.Errorf("%q: local part contains invalid characters", addr)
}
}
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package postfix
import (
"fmt"
"net"
"path/filepath"
"sort"
"strconv"
"strings"
)
// InboundRoute is one inbound domain's Postfix map material: the relay
// domain, the next-hop transport, the TLS policy for that hop, and the
// recipient list (or a domain catch-all).
type InboundRoute struct {
Domain string
Host string
Port int
TLSMode string
RecipientMode string
Recipients []string
}
func (p *Postfix) inboundMapPaths() (relayDomains, transport, recipients, tlsPolicy string) {
dir := filepath.Dir(p.senderLoginMapsPath)
return filepath.Join(dir, "relay_domains"),
filepath.Join(dir, "transport"),
filepath.Join(dir, "relay_recipients"),
filepath.Join(dir, "tls_policy")
}
// RebuildInboundMaps regenerates the inbound relay lookup tables from the full
// set of configured routes and reloads Postfix. Domains with an empty host are
// omitted so mail is never accepted with nowhere to send it. Full regeneration
// keeps the files a pure function of the registry (security.md).
func (p *Postfix) RebuildInboundMaps(routes []InboundRoute) error {
relay, transport, recipients, tlsPolicy, err := renderInboundMaps(routes)
if err != nil {
return err
}
rd, tr, rc, tl := p.inboundMapPaths()
if err := writeFileAtomic(rd, relay, 0o640); err != nil {
return err
}
if err := writeFileAtomic(tr, transport, 0o640); err != nil {
return err
}
if err := writeFileAtomic(rc, recipients, 0o640); err != nil {
return err
}
if err := writeFileAtomic(tl, tlsPolicy, 0o640); err != nil {
return err
}
return p.reload()
}
func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tlsPolicy []byte, err error) {
sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
var relayB, transportB, recipB, tlsB strings.Builder
for _, r := range routes {
if strings.TrimSpace(r.Host) == "" {
continue
}
if err := assertInboundRouteSafe(r); err != nil {
return nil, nil, nil, nil, err
}
nexthop := inboundNexthop(r.Host, r.Port)
fmt.Fprintf(&relayB, "%s OK\n", r.Domain)
fmt.Fprintf(&transportB, "%s smtp:%s\n", r.Domain, nexthop)
fmt.Fprintf(&tlsB, "%s %s\n", nexthop, r.TLSMode)
switch r.RecipientMode {
case "any":
fmt.Fprintf(&recipB, "@%s OK\n", r.Domain)
default:
addrs := append([]string(nil), r.Recipients...)
sort.Strings(addrs)
for _, addr := range addrs {
fmt.Fprintf(&recipB, "%s OK\n", addr)
}
}
}
return []byte(relayB.String()), []byte(transportB.String()), []byte(recipB.String()), []byte(tlsB.String()), nil
}
// inboundNexthop is the Postfix next-hop [host]:port form that disables MX
// lookup for the explicit upstream.
func inboundNexthop(host string, port int) string {
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
return "[" + host + "]:" + strconv.Itoa(port)
}
return "[" + host + "]:" + strconv.Itoa(port)
}
func assertInboundRouteSafe(r InboundRoute) error {
if err := assertMapToken(r.Domain, "domain"); err != nil {
return err
}
if err := assertMapToken(r.Host, "host"); err != nil {
return err
}
if r.Port < 1 || r.Port > 65535 {
return fmt.Errorf("postfix: invalid inbound port %d", r.Port)
}
switch r.TLSMode {
case "may", "encrypt", "none":
default:
return fmt.Errorf("postfix: invalid tls mode %q", r.TLSMode)
}
if r.RecipientMode != "list" && r.RecipientMode != "any" {
return fmt.Errorf("postfix: invalid recipient mode %q", r.RecipientMode)
}
for _, addr := range r.Recipients {
if err := assertMapToken(addr, "recipient"); err != nil {
return err
}
}
return nil
}
// assertMapToken rejects values that could break out of a texthash line.
func assertMapToken(v, what string) error {
if v == "" {
return fmt.Errorf("postfix: empty %s", what)
}
if strings.ContainsAny(v, " \t\r\n,\\") {
return fmt.Errorf("postfix: unsafe character in %s %q", what, v)
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package postfix
import (
"os"
"path/filepath"
"testing"
)
func TestRenderInboundMaps(t *testing.T) {
routes := []InboundRoute{
{
Domain: "zeta.example", Host: "192.0.2.20", Port: 25,
TLSMode: "none", RecipientMode: "any",
},
{
Domain: "lists.example.com", Host: "10.0.0.8", Port: 25,
TLSMode: "encrypt", RecipientMode: "list",
Recipients: []string{"staff@lists.example.com", "abuse@lists.example.com"},
},
{
Domain: "pending.example", Host: "", Port: 25,
TLSMode: "may", RecipientMode: "list",
},
}
relay, transport, recipients, tlsPolicy, err := renderInboundMaps(routes)
if err != nil {
t.Fatal(err)
}
wantRelay := "lists.example.com OK\nzeta.example OK\n"
if string(relay) != wantRelay {
t.Errorf("relay_domains =\n%q\nwant\n%q", relay, wantRelay)
}
wantTransport := "lists.example.com smtp:[10.0.0.8]:25\nzeta.example smtp:[192.0.2.20]:25\n"
if string(transport) != wantTransport {
t.Errorf("transport =\n%q\nwant\n%q", transport, wantTransport)
}
wantRecipients := "abuse@lists.example.com OK\nstaff@lists.example.com OK\n@zeta.example OK\n"
if string(recipients) != wantRecipients {
t.Errorf("relay_recipients =\n%q\nwant\n%q", recipients, wantRecipients)
}
wantTLS := "[10.0.0.8]:25 encrypt\n[192.0.2.20]:25 none\n"
if string(tlsPolicy) != wantTLS {
t.Errorf("tls_policy =\n%q\nwant\n%q", tlsPolicy, wantTLS)
}
}
func TestRenderInboundMapsIPv6(t *testing.T) {
routes := []InboundRoute{{
Domain: "v6.example", Host: "2001:db8::1", Port: 25,
TLSMode: "may", RecipientMode: "any",
}}
_, transport, _, tlsPolicy, err := renderInboundMaps(routes)
if err != nil {
t.Fatal(err)
}
if string(transport) != "v6.example smtp:[2001:db8::1]:25\n" {
t.Errorf("transport = %q", transport)
}
if string(tlsPolicy) != "[2001:db8::1]:25 may\n" {
t.Errorf("tls_policy = %q", tlsPolicy)
}
}
func TestRenderInboundMapsRejectsInjection(t *testing.T) {
bad := []InboundRoute{
{Domain: "ex ample.com", Host: "10.0.0.1", Port: 25, TLSMode: "may", RecipientMode: "any"},
{Domain: "example.com", Host: "10.0.0.1\nrelay", Port: 25, TLSMode: "may", RecipientMode: "any"},
{Domain: "example.com", Host: "10.0.0.1", Port: 25, TLSMode: "evil", RecipientMode: "any"},
{Domain: "example.com", Host: "10.0.0.1", Port: 25, TLSMode: "may", RecipientMode: "list",
Recipients: []string{"a@example.com OK\nb@evil.com"}},
}
for i, r := range bad {
if _, _, _, _, err := renderInboundMaps([]InboundRoute{r}); err == nil {
t.Errorf("case %d: expected injection rejection", i)
}
}
}
func TestRebuildInboundMapsWritesAndReloads(t *testing.T) {
p, reloads := newTestPostfix(t)
err := p.RebuildInboundMaps([]InboundRoute{{
Domain: "lists.example.com", Host: "10.0.0.8", Port: 25,
TLSMode: "encrypt", RecipientMode: "any",
}})
if err != nil {
t.Fatal(err)
}
if *reloads != 1 {
t.Errorf("reload called %d times, want 1", *reloads)
}
rd, _, _, _ := p.inboundMapPaths()
data, err := os.ReadFile(rd)
if err != nil {
t.Fatal(err)
}
if string(data) != "lists.example.com OK\n" {
t.Errorf("relay_domains file = %q", data)
}
if filepath.Base(rd) != "relay_domains" {
t.Errorf("unexpected path %s", rd)
}
}
func TestRebuildInboundMapsEmptyOmitsPending(t *testing.T) {
p, _ := newTestPostfix(t)
if err := p.RebuildInboundMaps(nil); err != nil {
t.Fatal(err)
}
rd, tr, rc, tl := p.inboundMapPaths()
for _, path := range []string{rd, tr, rc, tl} {
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if len(data) != 0 {
t.Errorf("%s not empty: %q", path, data)
}
}
}
+249
View File
@@ -0,0 +1,249 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// Recipient modes for an inbound domain. Kept in sync with the CHECK constraint.
const (
RecipientModeList = "list" // only explicitly listed addresses
RecipientModeAny = "any" // any address at the domain
)
// TLS modes for the hand-off to the upstream. Values are Postfix
// smtp_tls_policy_maps levels: may (opportunistic), encrypt (required), none.
const (
TLSModeMay = "may"
TLSModeEncrypt = "encrypt"
TLSModeNone = "none"
)
// ErrInboundDomainExists is returned when the inbound domain is already configured.
var ErrInboundDomainExists = errors.New("inbound domain already exists")
// ErrInboundDomainNotFound is returned when an inbound domain id/name does not exist.
var ErrInboundDomainNotFound = errors.New("inbound domain not found")
// InboundDomain is a backup-MX / forwarder domain. Host may be empty until the
// operator saves an upstream; map generation skips those rows so mail is never
// accepted with nowhere to send it. RecipientCount is populated by listing
// queries; Recipients is populated by Get.
type InboundDomain struct {
ID int64
Name string
RecipientMode string
Host string
Port int
TLSMode string
CreatedAt time.Time
RecipientCount int
Recipients []string
}
// AddInboundDomain inserts a new inbound domain with a default transport
// (empty host, port 25, opportunistic TLS) and listed-recipients mode. The
// caller must have validated name (security.md).
func (s *Store) AddInboundDomain(name string) (InboundDomain, error) {
now := time.Now().UTC()
tx, err := s.db.Begin()
if err != nil {
return InboundDomain{}, fmt.Errorf("begin add inbound domain: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec(
"INSERT INTO inbound_domains (name, recipient_mode, created_at) VALUES (?, ?, ?)",
name, RecipientModeList, now.Format(time.RFC3339),
)
if err != nil {
if isUniqueViolation(err) {
return InboundDomain{}, ErrInboundDomainExists
}
return InboundDomain{}, fmt.Errorf("insert inbound domain: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return InboundDomain{}, fmt.Errorf("inbound domain id: %w", err)
}
if _, err := tx.Exec(
"INSERT INTO inbound_transports (inbound_domain_id, host, port, tls_mode) VALUES (?, '', 25, ?)",
id, TLSModeMay,
); err != nil {
return InboundDomain{}, fmt.Errorf("insert inbound transport: %w", err)
}
if err := tx.Commit(); err != nil {
return InboundDomain{}, fmt.Errorf("commit add inbound domain: %w", err)
}
return InboundDomain{
ID: id,
Name: name,
RecipientMode: RecipientModeList,
Port: 25,
TLSMode: TLSModeMay,
CreatedAt: now,
}, nil
}
// ListInboundDomains returns every inbound domain with its transport and
// recipient count, ordered by name.
func (s *Store) ListInboundDomains() ([]InboundDomain, error) {
rows, err := s.db.Query(`
SELECT d.id, d.name, d.recipient_mode, d.created_at,
t.host, t.port, t.tls_mode,
(SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id)
FROM inbound_domains d
INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id
ORDER BY d.name`)
if err != nil {
return nil, fmt.Errorf("list inbound domains: %w", err)
}
defer rows.Close()
var out []InboundDomain
for rows.Next() {
d, err := scanInboundDomain(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// GetInboundDomain returns one inbound domain with its recipient list, or
// ErrInboundDomainNotFound.
func (s *Store) GetInboundDomain(id int64) (InboundDomain, error) {
row := s.db.QueryRow(`
SELECT d.id, d.name, d.recipient_mode, d.created_at,
t.host, t.port, t.tls_mode,
(SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id)
FROM inbound_domains d
INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id
WHERE d.id = ?`, id)
d, err := scanInboundDomain(row)
if errors.Is(err, sql.ErrNoRows) {
return InboundDomain{}, ErrInboundDomainNotFound
}
if err != nil {
return InboundDomain{}, err
}
addrs, err := s.listInboundRecipients(id)
if err != nil {
return InboundDomain{}, err
}
d.Recipients = addrs
return d, nil
}
// UpdateInboundTransport sets the upstream host, port and TLS mode.
func (s *Store) UpdateInboundTransport(id int64, host string, port int, tlsMode string) error {
res, err := s.db.Exec(
"UPDATE inbound_transports SET host = ?, port = ?, tls_mode = ? WHERE inbound_domain_id = ?",
host, port, tlsMode, id,
)
if err != nil {
return fmt.Errorf("update inbound transport: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update inbound transport rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
return nil
}
// UpdateInboundRecipients replaces the recipient mode and, in list mode, the
// address list. In any mode the stored list is cleared.
func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin update inbound recipients: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec("UPDATE inbound_domains SET recipient_mode = ? WHERE id = ?", mode, id)
if err != nil {
return fmt.Errorf("update inbound recipient mode: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update inbound recipient mode rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
if _, err := tx.Exec("DELETE FROM inbound_recipients WHERE inbound_domain_id = ?", id); err != nil {
return fmt.Errorf("clear inbound recipients: %w", err)
}
if mode == RecipientModeList {
for _, addr := range addresses {
if _, err := tx.Exec(
"INSERT INTO inbound_recipients (inbound_domain_id, address) VALUES (?, ?)",
id, addr,
); err != nil {
return fmt.Errorf("insert inbound recipient: %w", err)
}
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit update inbound recipients: %w", err)
}
return nil
}
// DeleteInboundDomain removes an inbound domain and its transport/recipients
// (ON DELETE CASCADE). Returns ErrInboundDomainNotFound if no such row existed.
func (s *Store) DeleteInboundDomain(id int64) error {
res, err := s.db.Exec("DELETE FROM inbound_domains WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete inbound domain: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete inbound domain rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
return nil
}
func (s *Store) listInboundRecipients(id int64) ([]string, error) {
rows, err := s.db.Query(
"SELECT address FROM inbound_recipients WHERE inbound_domain_id = ? ORDER BY address",
id,
)
if err != nil {
return nil, fmt.Errorf("list inbound recipients: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var addr string
if err := rows.Scan(&addr); err != nil {
return nil, err
}
out = append(out, addr)
}
return out, rows.Err()
}
func scanInboundDomain(r scanRow) (InboundDomain, error) {
var (
d InboundDomain
createdAt string
)
if err := r.Scan(
&d.ID, &d.Name, &d.RecipientMode, &createdAt,
&d.Host, &d.Port, &d.TLSMode, &d.RecipientCount,
); err != nil {
return InboundDomain{}, err
}
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return d, nil
}
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"errors"
"testing"
)
func TestInboundDomainCRUD(t *testing.T) {
st := openTestStore(t)
d, err := st.AddInboundDomain("lists.example.com")
if err != nil {
t.Fatalf("AddInboundDomain: %v", err)
}
if d.ID == 0 || d.Name != "lists.example.com" || d.RecipientMode != RecipientModeList {
t.Fatalf("unexpected domain: %+v", d)
}
if d.Port != 25 || d.TLSMode != TLSModeMay || d.Host != "" {
t.Fatalf("unexpected default transport: %+v", d)
}
if _, err := st.AddInboundDomain("lists.example.com"); !errors.Is(err, ErrInboundDomainExists) {
t.Fatalf("duplicate error = %v, want ErrInboundDomainExists", err)
}
if err := st.UpdateInboundTransport(d.ID, "10.0.0.8", 25, TLSModeEncrypt); err != nil {
t.Fatalf("UpdateInboundTransport: %v", err)
}
addrs := []string{"staff@lists.example.com", "postmaster@lists.example.com"}
if err := st.UpdateInboundRecipients(d.ID, RecipientModeList, addrs); err != nil {
t.Fatalf("UpdateInboundRecipients: %v", err)
}
got, err := st.GetInboundDomain(d.ID)
if err != nil {
t.Fatalf("GetInboundDomain: %v", err)
}
if got.Host != "10.0.0.8" || got.TLSMode != TLSModeEncrypt || got.RecipientCount != 2 {
t.Fatalf("get after update: %+v", got)
}
if len(got.Recipients) != 2 || got.Recipients[0] != "postmaster@lists.example.com" {
t.Fatalf("recipients not sorted: %v", got.Recipients)
}
list, err := st.ListInboundDomains()
if err != nil {
t.Fatalf("ListInboundDomains: %v", err)
}
if len(list) != 1 || list[0].RecipientCount != 2 {
t.Fatalf("list: %+v", list)
}
if err := st.UpdateInboundRecipients(d.ID, RecipientModeAny, nil); err != nil {
t.Fatalf("switch to any: %v", err)
}
got, err = st.GetInboundDomain(d.ID)
if err != nil {
t.Fatal(err)
}
if got.RecipientMode != RecipientModeAny || got.RecipientCount != 0 || len(got.Recipients) != 0 {
t.Fatalf("any mode should clear the list: %+v", got)
}
if err := st.DeleteInboundDomain(d.ID); err != nil {
t.Fatalf("DeleteInboundDomain: %v", err)
}
assertCount(t, st, "inbound_domains", 0)
assertCount(t, st, "inbound_transports", 0)
assertCount(t, st, "inbound_recipients", 0)
if _, err := st.GetInboundDomain(d.ID); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("Get after delete = %v, want ErrInboundDomainNotFound", err)
}
}
func TestInboundDomainNotFound(t *testing.T) {
st := openTestStore(t)
if _, err := st.GetInboundDomain(99); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("GetInboundDomain(missing) = %v", err)
}
if err := st.UpdateInboundTransport(99, "10.0.0.1", 25, TLSModeNone); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("UpdateInboundTransport(missing) = %v", err)
}
if err := st.DeleteInboundDomain(99); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("DeleteInboundDomain(missing) = %v", err)
}
}
func TestInboundDeleteCascadesRecipients(t *testing.T) {
st := openTestStore(t)
d, err := st.AddInboundDomain("backup.example.net")
if err != nil {
t.Fatal(err)
}
if err := st.UpdateInboundRecipients(d.ID, RecipientModeList, []string{"a@backup.example.net"}); err != nil {
t.Fatal(err)
}
if err := st.DeleteInboundDomain(d.ID); err != nil {
t.Fatal(err)
}
assertCount(t, st, "inbound_recipients", 0)
}
@@ -0,0 +1,26 @@
-- Optional inbound relay (backup-MX / forwarder). Separate from sending
-- domains: these rows exist even when INBOUND_RELAY_ENABLE is false, but the
-- listener, Postfix maps and panel UI are generated only when that flag is on.
CREATE TABLE inbound_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')),
created_at TEXT NOT NULL
);
-- One upstream per inbound domain (host:port + TLS policy for the hand-off).
CREATE TABLE inbound_transports (
inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE,
host TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none'))
);
-- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'.
CREATE TABLE inbound_recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
address TEXT NOT NULL,
UNIQUE (inbound_domain_id, address)
);
+3 -3
View File
@@ -1,8 +1,8 @@
// Package store owns the SelfPost SQLite database: the single file under /data
// that persists panel users (global administrators and domain-admins), sending
// domains and applications, the send log and rate-limit settings
// (architecture.md § Persistence). It exposes typed queries so the rest of the
// panel never builds SQL by hand.
// domains and applications, optional inbound-relay domains, the send log and
// rate-limit settings (architecture.md § Persistence). It exposes typed queries
// so the rest of the panel never builds SQL by hand.
package store
import (
+9
View File
@@ -49,6 +49,15 @@ var globalOnlyRoutes = []route{
{"GET", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}},
{"POST", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}},
{"POST", "/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil},
{"GET", "/inbound", func(h *Handlers) http.HandlerFunc { return h.HandleInboundList }, nil},
{"POST", "/inbound", func(h *Handlers) http.HandlerFunc { return h.HandleAddInbound }, nil},
{"GET", "/inbound/1", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDetail }, map[string]string{"id": "1"}},
{"POST", "/inbound/1/dns-recheck", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDNSRecheck }, map[string]string{"id": "1"}},
{"POST", "/inbound/1/upstream", func(h *Handlers) http.HandlerFunc { return h.HandleInboundTransport }, map[string]string{"id": "1"}},
{"POST", "/inbound/1/recipients", func(h *Handlers) http.HandlerFunc { return h.HandleInboundRecipients }, map[string]string{"id": "1"}},
{"GET", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDeleteConfirm }, map[string]string{"id": "1"}},
{"POST", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDelete }, map[string]string{"id": "1"}},
}
// A domain administrator has an account on the panel, so authentication is not
+7
View File
@@ -8,6 +8,7 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
@@ -34,6 +35,9 @@ type Config struct {
// when the HTTP role starts (architecture.md). The Mail queue card and
// delivery history read it from here; they never call postconf.
RetryPolicy postfix.RetryPolicy
// InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and
// routes exist only when this is true.
InboundEnabled bool
}
// Handlers holds dependencies for authenticated panel routes.
@@ -41,6 +45,7 @@ type Handlers struct {
store *store.Store
domains *domain.Service
apps *app.Service
inbound *inbound.Service
cfg Config
view *view.Engine
dns *dnscheck.Checker
@@ -53,6 +58,7 @@ func New(
st *store.Store,
domains *domain.Service,
apps *app.Service,
inboundSvc *inbound.Service,
cfg Config,
v *view.Engine,
dns *dnscheck.Checker,
@@ -63,6 +69,7 @@ func New(
store: st,
domains: domains,
apps: apps,
inbound: inboundSvc,
cfg: cfg,
view: v,
dns: dns,
@@ -172,6 +172,13 @@ func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
if h.cfg.InboundEnabled && h.inbound != nil {
if err := h.inbound.Resync(); err != nil {
logf("panel: manual reload (inbound): %v", err)
http.Error(w, "reload failed", http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
}
+319
View File
@@ -0,0 +1,319 @@
package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate"
)
type inboundRow struct {
store.InboundDomain
DNS health.Status
Upstream string
TLSLabel string
RcptLabel string
}
func (h *Handlers) requireInbound(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
if !h.cfg.InboundEnabled || h.inbound == nil {
http.NotFound(w, r)
return auth.Principal{}, false
}
return h.requireGlobal(w, r)
}
func (h *Handlers) lookupInbound(w http.ResponseWriter, r *http.Request) (store.InboundDomain, bool) {
if h.inbound == nil {
http.NotFound(w, r)
return store.InboundDomain{}, false
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return store.InboundDomain{}, false
}
d, err := h.inbound.Get(id)
if err != nil {
if errors.Is(err, store.ErrInboundDomainNotFound) {
http.NotFound(w, r)
return store.InboundDomain{}, false
}
logf("panel: get inbound domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return store.InboundDomain{}, false
}
return d, true
}
// HandleInboundList is the inbound-relay domain list (global administrators only).
func (h *Handlers) HandleInboundList(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
h.renderInboundList(w, r, http.StatusOK, "", "")
}
func (h *Handlers) renderInboundList(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
list, err := h.inbound.List()
if err != nil {
logf("panel: inbound list: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — inbound"
data["Active"] = "inbound"
data["Domains"] = h.inboundRows(list)
data["Error"] = formErr
data["FormName"] = formName
if r.URL.Query().Get("deleted") != "" {
data["Flash"] = "Inbound domain deleted."
}
h.view.Render(w, status, "inbound", data)
}
func (h *Handlers) inboundRows(domains []store.InboundDomain) []inboundRow {
rows := make([]inboundRow, len(domains))
var wg sync.WaitGroup
for i, d := range domains {
rows[i] = inboundRow{
InboundDomain: d,
DNS: health.StatusUnknown,
Upstream: inboundUpstream(d),
TLSLabel: tlsLabel(d.TLSMode),
RcptLabel: rcptLabel(d),
}
wg.Add(1)
go func() {
defer wg.Done()
if h.dns != nil && h.cfg.Hostname != "" {
rows[i].DNS = h.dns.InboundMX(d.Name, h.cfg.Hostname, false).Status
}
}()
}
wg.Wait()
return rows
}
func inboundUpstream(d store.InboundDomain) string {
if d.Host == "" {
return "—"
}
return fmt.Sprintf("%s:%d", d.Host, d.Port)
}
func tlsLabel(mode string) string {
switch mode {
case store.TLSModeEncrypt:
return "required"
case store.TLSModeNone:
return "off"
default:
return "opportunistic"
}
}
func tlsStatusClass(mode string) string {
if mode == store.TLSModeEncrypt {
return "ok"
}
return "unknown"
}
func rcptLabel(d store.InboundDomain) string {
if d.RecipientMode == store.RecipientModeAny {
return "any"
}
n := d.RecipientCount
if n == 1 {
return "1 listed"
}
return fmt.Sprintf("%d listed", n)
}
// HandleAddInbound validates the name, creates the inbound domain, and
// redirects to its page.
func (h *Handlers) HandleAddInbound(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderInboundList(w, r, http.StatusBadRequest, "Invalid form submission.", "")
return
}
raw := r.PostFormValue("name")
name := validate.NormalizeDomain(raw)
if err := validate.Domain(name); err != nil {
h.renderInboundList(w, r, http.StatusBadRequest, err.Error(), raw)
return
}
d, err := h.inbound.Add(name)
if err != nil {
if errors.Is(err, store.ErrInboundDomainExists) {
h.renderInboundList(w, r, http.StatusConflict, "That inbound domain is already configured.", raw)
return
}
logf("panel: add inbound domain %q: %v", name, err)
h.renderInboundList(w, r, http.StatusInternalServerError,
"Could not add the domain. Please check the logs and try again.", raw)
return
}
http.Redirect(w, r, fmt.Sprintf("/inbound/%d", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundDetail(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
h.renderInboundDetail(w, r, http.StatusOK, d, inboundDetailView{})
}
type inboundDetailView struct {
FormErr string
TransportErr string
RecipientErr string
}
func (h *Handlers) renderInboundDetail(w http.ResponseWriter, r *http.Request, status int, d store.InboundDomain, extra inboundDetailView) {
mx := dnscheck.Result{Status: health.StatusUnknown}
if h.dns != nil && h.cfg.Hostname != "" {
mx = h.dns.InboundMX(d.Name, h.cfg.Hostname, false)
}
data := h.pageBase(r)
data["Title"] = "SelfPost — " + d.Name
data["Active"] = "inbound"
data["Domain"] = d
data["MX"] = mx
data["MXValue"] = "10 " + strings.TrimSuffix(h.cfg.Hostname, ".") + "."
data["Hostname"] = h.cfg.Hostname
data["TLSLabel"] = tlsLabel(d.TLSMode)
data["TLSClass"] = tlsStatusClass(d.TLSMode)
data["RecipientText"] = strings.Join(d.Recipients, "\n")
data["Flash"] = inboundFlash(r)
data["FormErr"] = extra.FormErr
data["TransportErr"] = extra.TransportErr
data["RecipientErr"] = extra.RecipientErr
h.view.Render(w, status, "inbound_domain", data)
}
func inboundFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("saved") != "":
return "Upstream saved."
case r.URL.Query().Get("recipients") != "":
return "Recipients saved."
case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked."
default:
return ""
}
}
func (h *Handlers) HandleInboundDNSRecheck(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
if h.dns != nil && h.cfg.Hostname != "" {
h.dns.InboundMX(d.Name, h.cfg.Hostname, true)
}
http.Redirect(w, r, fmt.Sprintf("/inbound/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: "Invalid form submission."})
return
}
host := r.PostFormValue("host")
port := r.PostFormValue("port")
tlsMode := r.PostFormValue("tls_mode")
if err := h.inbound.SetTransport(d.ID, host, port, tlsMode); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: err.Error()})
return
}
http.Redirect(w, r, fmt.Sprintf("/inbound/%d?saved=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundRecipients(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: "Invalid form submission."})
return
}
mode := r.PostFormValue("recipient_mode")
addrs := splitAddresses(r.PostFormValue("addresses"))
if err := h.inbound.SetRecipients(d.ID, mode, addrs); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: err.Error()})
return
}
http.Redirect(w, r, fmt.Sprintf("/inbound/%d?recipients=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — delete " + d.Name
data["Active"] = "inbound"
data["Domain"] = d
data["Upstream"] = inboundUpstream(d)
h.view.Render(w, http.StatusOK, "inbound_delete", data)
}
func (h *Handlers) HandleInboundDelete(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
if h.dns != nil {
h.dns.Forget(d.Name)
}
if err := h.inbound.Delete(d.ID); err != nil {
if errors.Is(err, store.ErrInboundDomainNotFound) {
http.NotFound(w, r)
return
}
logf("panel: delete inbound domain %d: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/inbound?deleted=1", http.StatusSeeOther)
}
@@ -0,0 +1,160 @@
package handlers
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
type recordingMaps struct {
n int
}
func (r *recordingMaps) RebuildInboundMaps(_ []postfix.InboundRoute) error {
r.n++
return nil
}
func inboundHandlers(t *testing.T) (*Handlers, *store.Store) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
v := mustView(t)
v.SetInboundEnabled(true)
h := &Handlers{
store: st,
inbound: inbound.NewService(st, &recordingMaps{}),
view: v,
cfg: Config{Version: "test", InboundEnabled: true, Hostname: "mail.example.org"},
}
return h, st
}
func inboundCall(h *Handlers, method, target string, form url.Values, p auth.Principal) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
var req *http.Request
if form != nil {
req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req = httptest.NewRequest(method, target, nil)
}
req = auth.RequestWithPrincipal(req, p)
if rest, ok := strings.CutPrefix(req.URL.Path, "/inbound/"); ok {
id, _, _ := strings.Cut(rest, "/")
if id != "" && id != "delete" {
req.SetPathValue("id", id)
}
}
switch {
case method == http.MethodGet && target == "/inbound":
h.HandleInboundList(rec, req)
case method == http.MethodPost && target == "/inbound":
h.HandleAddInbound(rec, req)
case strings.HasSuffix(target, "/delete") && method == http.MethodGet:
h.HandleInboundDeleteConfirm(rec, req)
case strings.HasSuffix(target, "/delete") && method == http.MethodPost:
h.HandleInboundDelete(rec, req)
case strings.HasSuffix(target, "/upstream"):
h.HandleInboundTransport(rec, req)
case strings.HasSuffix(target, "/recipients"):
h.HandleInboundRecipients(rec, req)
default:
h.HandleInboundDetail(rec, req)
}
return rec
}
func TestInboundListAndAdd(t *testing.T) {
h, _ := inboundHandlers(t)
rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
if rec.Code != http.StatusOK {
t.Fatalf("list = %d\n%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Add inbound domain") {
t.Fatal("list missing add form")
}
rec = inboundCall(h, http.MethodPost, "/inbound", url.Values{"name": {"lists.example.com"}}, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("add = %d %s", rec.Code, rec.Body.String())
}
rec = inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
if !strings.Contains(rec.Body.String(), "lists.example.com") {
t.Fatalf("list missing domain:\n%s", rec.Body.String())
}
}
func TestInboundDisabledIs404(t *testing.T) {
h, _ := inboundHandlers(t)
h.cfg.InboundEnabled = false
rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
if rec.Code != http.StatusNotFound {
t.Fatalf("disabled inbound = %d, want 404", rec.Code)
}
}
func TestInboundTransportAndRecipients(t *testing.T) {
h, st := inboundHandlers(t)
d, err := st.AddInboundDomain("lists.example.com")
if err != nil {
t.Fatal(err)
}
id := itoa(d.ID)
rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/upstream", url.Values{
"host": {"10.0.0.8"}, "port": {"25"}, "tls_mode": {"encrypt"},
}, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("upstream = %d %s", rec.Code, rec.Body.String())
}
rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{
"recipient_mode": {"list"},
"addresses": {"staff@lists.example.com\nabuse@other.com"},
}, globalPrincipal)
if rec.Code != http.StatusBadRequest {
t.Fatalf("foreign recipient = %d, want 400", rec.Code)
}
rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{
"recipient_mode": {"list"},
"addresses": {"staff@lists.example.com"},
}, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("recipients = %d %s", rec.Code, rec.Body.String())
}
rec = inboundCall(h, http.MethodGet, "/inbound/"+id, nil, globalPrincipal)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "10.0.0.8") {
t.Fatalf("detail =\n%s", rec.Body.String())
}
}
func TestInboundDelete(t *testing.T) {
h, st := inboundHandlers(t)
d, err := st.AddInboundDomain("lists.example.com")
if err != nil {
t.Fatal(err)
}
id := itoa(d.ID)
rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/delete", nil, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("delete = %d %s", rec.Code, rec.Body.String())
}
if _, err := st.GetInboundDomain(d.ID); err == nil {
t.Fatal("domain still present")
}
}
@@ -10,6 +10,7 @@ import (
"testing"
"time"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
@@ -347,7 +348,12 @@ func serverWithTwoDomains(t *testing.T) (*Handlers, map[string]store.Domain) {
domains[d.name] = dom
}
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, domains
return &Handlers{
store: st,
inbound: inbound.NewService(st, &recordingMaps{}),
view: mustView(t),
cfg: Config{Version: "test", InboundEnabled: true},
}, domains
}
var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
+103
View File
@@ -3,6 +3,8 @@ package validate
import (
"fmt"
"net"
"strconv"
"strings"
"unicode"
)
@@ -146,3 +148,104 @@ func Email(addr string) error {
}
return nil
}
const maxHostLen = 253
// NormalizeHost trims, lower-cases, and strips wrapping IPv6 brackets so the
// stored value is a bare hostname or IP, safe to wrap again when writing maps.
func NormalizeHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
return host
}
// Host enforces a whitelist for an upstream hostname or IP (security.md): a
// dotted domain, a single DNS label (LAN names), or an IPv4/IPv6 address.
func Host(host string) error {
if host == "" {
return fmt.Errorf("host is required")
}
if len(host) > maxHostLen {
return fmt.Errorf("host must be at most %d characters", maxHostLen)
}
if ip := net.ParseIP(host); ip != nil {
return nil
}
labels := strings.Split(host, ".")
for _, label := range labels {
if err := domainLabel(label); err != nil {
return fmt.Errorf("host is invalid: %w", err)
}
}
return nil
}
// Port checks a TCP port number parsed from form input.
func Port(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, fmt.Errorf("port is required")
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 65535 {
return 0, fmt.Errorf("port must be between 1 and 65535")
}
return n, nil
}
// TLSMode checks a Postfix smtp_tls_policy_maps level.
func TLSMode(mode string) error {
switch mode {
case "may", "encrypt", "none":
return nil
default:
return fmt.Errorf("invalid TLS mode")
}
}
// RecipientMode checks an inbound-domain recipient policy.
func RecipientMode(mode string) error {
switch mode {
case "list", "any":
return nil
default:
return fmt.Errorf("invalid recipient mode")
}
}
// MailboxInDomain checks that addr is a conservative mailbox on domain
// (security.md). domain must already be normalised.
func MailboxInDomain(addr, domain string) error {
at := strings.LastIndexByte(addr, '@')
if at <= 0 || at >= len(addr)-1 {
return fmt.Errorf("%q is not a valid email address", addr)
}
local, host := addr[:at], addr[at+1:]
if host != domain {
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
}
if err := mailboxLocalPart(local); err != nil {
return fmt.Errorf("%q: %w", addr, err)
}
return nil
}
func mailboxLocalPart(local string) error {
if local == "" {
return fmt.Errorf("missing the part before '@'")
}
if local[0] == '.' || local[len(local)-1] == '.' {
return fmt.Errorf("local part must not start or end with '.'")
}
for i := 0; i < len(local); i++ {
c := local[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
return fmt.Errorf("local part may contain only lower-case letters, digits, '.', '-', '_' and '+'")
}
}
return nil
}
+66
View File
@@ -65,3 +65,69 @@ func TestValidateDomainLongLabelRejected(t *testing.T) {
t.Error("expected error for over-long label")
}
}
func TestHostValid(t *testing.T) {
valid := []string{
"10.0.0.8",
"192.0.2.20",
"2001:db8::1",
"mail.internal.example",
"mx1",
"mail-1.lan",
}
for _, h := range valid {
if err := Host(h); err != nil {
t.Errorf("Host(%q) unexpected error: %v", h, err)
}
}
}
func TestHostInvalid(t *testing.T) {
invalid := []string{
"",
"exa mple",
"host/name",
"host;rm",
"-bad",
"bad-",
"host\nname",
}
for _, h := range invalid {
if err := Host(h); err == nil {
t.Errorf("Host(%q) = nil, want error", h)
}
}
}
func TestNormalizeHostStripsIPv6Brackets(t *testing.T) {
if got := NormalizeHost(" [2001:DB8::1] "); got != "2001:db8::1" {
t.Errorf("NormalizeHost IPv6 = %q", got)
}
if got := NormalizeHost("Mail.Example.COM"); got != "mail.example.com" {
t.Errorf("NormalizeHost hostname = %q", got)
}
}
func TestPort(t *testing.T) {
n, err := Port("25")
if err != nil || n != 25 {
t.Fatalf("Port(25) = %d, %v", n, err)
}
for _, raw := range []string{"", "0", "65536", "abc", "-1"} {
if _, err := Port(raw); err == nil {
t.Errorf("Port(%q) = nil, want error", raw)
}
}
}
func TestMailboxInDomain(t *testing.T) {
if err := MailboxInDomain("staff@lists.example.com", "lists.example.com"); err != nil {
t.Fatal(err)
}
if err := MailboxInDomain("staff@other.com", "lists.example.com"); err == nil {
t.Fatal("expected domain mismatch error")
}
if err := MailboxInDomain("bad addr@lists.example.com", "lists.example.com"); err == nil {
t.Fatal("expected local-part error")
}
}
+50
View File
@@ -0,0 +1,50 @@
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>Inbound</h1>
<p class="muted">Backup-MX / forwarder. Accepts on port 25 only for listed
domains. Recipients are either an allow-list or any address at that domain.
Off by default in Compose.</p>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card">
<h2>Forwarding</h2>
<form method="post" action="/inbound">
<label for="name">Add inbound domain</label>
<div class="input-row">
<input id="name" name="name" type="text" placeholder="lists.example.com"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormName}}" autofocus required>
<button type="submit">Add domain</button>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
</form>
{{if .Domains}}
<table>
<thead>
<tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS</th><th></th></tr>
</thead>
<tbody>
{{range .Domains}}
<tr>
<td><a href="/inbound/{{.ID}}">{{.Name}}</a></td>
<td><a class="st st-{{.DNS}}" href="/inbound/{{.ID}}">{{.DNS}}</a></td>
<td class="muted">{{.Upstream}}</td>
<td>{{.RcptLabel}}</td>
<td><span class="st st-{{if eq .TLSMode "encrypt"}}ok{{else}}unknown{{end}}">{{.TLSLabel}}</span></td>
<td class="actions"><a class="danger" href="/inbound/{{.ID}}/delete">Delete</a></td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">The <em>DNS</em> badge is the MX check: at least one MX must
point at this server. Results are cached for a few minutes; open a domain for
the lookup and a <em>Re-check</em> button.</p>
{{else}}
<p class="muted">No inbound domains yet. Add one above, then set the upstream
on its page. Mail is not accepted until an upstream host is saved.</p>
{{end}}
</div>
{{end}}
@@ -0,0 +1,20 @@
{{define "content"}}
<h1>Delete {{.Domain.Name}}</h1>
{{template "back_link" (back (printf "/inbound/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to stop accepting inbound mail for <strong>{{.Domain.Name}}</strong>. This will:</p>
<ul>
<li>remove it from <code>relay_domains</code> and the recipient map;</li>
<li>stop forwarding{{if ne .Upstream "—"}} to <strong>{{.Upstream}}</strong>{{end}};</li>
<li>leave outbound sending domains untouched.</li>
</ul>
<p class="muted">This cannot be undone from a backup of inbound maps alone
unless you restore one. Remove the MX if you do not plan to re-add the domain.</p>
<form method="post" action="/inbound/{{.Domain.ID}}/delete">
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
</form>
</div>
{{end}}
@@ -0,0 +1,118 @@
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>{{.Domain.Name}}</h1>
{{template "back_link" (back "/inbound" "All inbound domains")}}
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<div class="card" id="dns-status">
<h2>DNS status <span class="st st-{{.MX.Status}}">{{.MX.Status}}</span></h2>
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing.
Unlike outbound, inbound needs an MX pointing at this server.</p>
<label>MX <span class="st st-{{.MX.Status}}">{{.MX.Status}}</span></label>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<span class="code">{{.Domain.Name}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
{{if .MX.Records}}
<span class="code">{{range .MX.Records}}{{.}}
{{end}}</span>
{{else}}
<span class="code muted">No MX records found.</span>
{{end}}
{{if eq .MX.Status "ok"}}
<p class="muted">{{.MX.Detail}}</p>
{{else}}
<p class="error">{{.MX.Detail}}</p>
{{end}}
<form method="post" action="/inbound/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
</div>
<div class="split">
<div class="card">
<h2>Upstream</h2>
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
<form method="post" action="/inbound/{{.Domain.ID}}/upstream">
<label for="host">Host</label>
<input id="host" name="host" type="text" value="{{.Domain.Host}}"
autocomplete="off" autocapitalize="none" spellcheck="false" required>
<label for="port">Port</label>
<input id="port" name="port" type="text" inputmode="numeric" value="{{.Domain.Port}}" required>
<label for="tls_mode">TLS to upstream</label>
<select id="tls_mode" name="tls_mode">
<option value="may" {{if eq .Domain.TLSMode "may"}}selected{{end}}>Opportunistic</option>
<option value="encrypt" {{if eq .Domain.TLSMode "encrypt"}}selected{{end}}>Required</option>
<option value="none" {{if eq .Domain.TLSMode "none"}}selected{{end}}>Off</option>
</select>
{{if .TransportErr}}<p class="error">{{.TransportErr}}</p>{{end}}
<button type="submit">Save upstream</button>
</form>
</div>
<div class="card">
<h2>MX record to publish</h2>
<p class="muted">Add this MX so the internet delivers here. Keep any existing
primary MX if this is backup-MX.</p>
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.Domain.Name}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">MX</span>
</div>
</div>
<label>Value</label>
<div class="code-row">
<span class="code">{{.MXValue}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
</div>
<div class="split">
<div class="card">
<h2>Valid recipients</h2>
<p class="muted">Who this domain accepts on port 25. Same idea as an
application's address mode: a list, or any address at the domain.</p>
<form method="post" action="/inbound/{{.Domain.ID}}/recipients">
<label for="recipient_mode">Who to accept</label>
<select id="recipient_mode" name="recipient_mode" data-list-mode="list">
<option value="list" {{if eq .Domain.RecipientMode "list"}}selected{{end}}>Listed addresses only</option>
<option value="any" {{if eq .Domain.RecipientMode "any"}}selected{{end}}>Any recipient at this domain</option>
</select>
<div data-addresses>
<label for="addresses">Addresses (one per line or comma-separated)</label>
<textarea id="addresses" name="addresses" rows="6">{{.RecipientText}}</textarea>
<p class="muted">Unknown recipients are rejected at RCPT so this relay
does not generate backscatter.</p>
</div>
<p class="muted">Every address at this domain is accepted and forwarded when
“any recipient” is selected. Prefer a list unless the upstream rejects
unknowns — otherwise this relay may generate backscatter.</p>
{{if .RecipientErr}}<p class="error">{{.RecipientErr}}</p>{{end}}
<button type="submit">Save recipients</button>
</form>
</div>
<div class="card">
<h2>Danger zone</h2>
<p class="muted">Stops accepting mail for this domain. Does not touch
outbound sending domains.</p>
<p><a class="danger" href="/inbound/{{.Domain.ID}}/delete">Delete inbound domain</a></p>
</div>
</div>
{{end}}
+4
View File
@@ -70,6 +70,9 @@
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
{{end}}
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
{{if and .IsGlobal .InboundEnabled}}
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}}
{{end}}
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
{{if .IsGlobal}}
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
@@ -112,6 +115,7 @@
templates so the nav above stays one readable line per page. */}}
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
{{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}}
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
+36 -1
View File
@@ -178,6 +178,35 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
if strings.Index(out, "Status") > strings.Index(out, "Domains") {
t.Errorf("Status is not the first navigation entry:\n%s", out)
}
if strings.Contains(out, `href="/inbound"`) || strings.Contains(out, "Inbound") {
t.Errorf("Inbound nav is shown while InboundEnabled is unset:\n%s", out)
}
}
func TestNavShowsInboundWhenEnabled(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
engine.SetInboundEnabled(true)
var buf bytes.Buffer
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
"IsGlobal": true,
"InboundEnabled": true,
}); err != nil {
t.Fatalf("execute nav: %v", err)
}
out := buf.String()
if !strings.Contains(out, `href="/inbound"`) || !strings.Contains(out, "Inbound") {
t.Errorf("Inbound nav is missing while InboundEnabled is true:\n%s", out)
}
dom := strings.Index(out, `href="/domains"`)
inb := strings.Index(out, `href="/inbound"`)
if dom < 0 || inb < 0 || inb < dom {
t.Errorf("Inbound should follow Domains:\n%s", out)
}
}
// Whether a page takes the whole column or the reading measure is declared by
@@ -190,7 +219,11 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
if err != nil {
t.Fatalf("New: %v", err)
}
wide := map[string]bool{"settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "status": true, "system_log": true, "domain_detail": true}
wide := map[string]bool{
"settings": true, "deliveries": true, "delivery": true, "mail_queue": true,
"status": true, "system_log": true, "domain_detail": true,
"inbound": true, "inbound_domain": true,
}
for name, page := range engine.Pages() {
var buf bytes.Buffer
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
@@ -285,6 +318,8 @@ func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
"user_delete.html": true,
"domain_detail.html": true,
"domain_delete.html": true,
"inbound_domain.html": true,
"inbound_delete.html": true,
"delivery.html": true,
}
forEachTemplate(t, func(name, body string) {
+11
View File
@@ -21,6 +21,7 @@ type Engine struct {
pages map[string]*template.Template
fragments map[string]*template.Template
version string
inboundEnabled bool
}
// pageFiles maps a logical page name to its template files. Every page
@@ -41,6 +42,9 @@ var pageFiles = map[string][]string{
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
"domain_delete": {"templates/domain_delete.html"},
"inbound": {"templates/inbound.html"},
"inbound_domain": {"templates/inbound_domain.html"},
"inbound_delete": {"templates/inbound_delete.html"},
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"delivery": {"templates/delivery.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
@@ -82,6 +86,12 @@ func New(version string) (*Engine, error) {
return e, nil
}
// SetInboundEnabled controls whether the Inbound nav item is shown. The
// listener and routes are gated the same way (INBOUND_RELAY_ENABLE).
func (e *Engine) SetInboundEnabled(v bool) {
e.inboundEnabled = v
}
// templateFuncs supplies helpers shared across page templates.
func templateFuncs() template.FuncMap {
return template.FuncMap{
@@ -124,6 +134,7 @@ func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any
m["Version"] = e.version
m["Copyright"] = legal.CopyrightLine
m["SourceURL"] = legal.SourceURL
m["InboundEnabled"] = e.inboundEnabled
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
+18 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/legal"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
@@ -82,6 +83,8 @@ type Config struct {
// when the HTTP role starts. Handlers read the cache; they never call
// postconf (architecture.md).
RetryPolicy postfix.RetryPolicy
// InboundEnabled mirrors INBOUND_RELAY_ENABLE.
InboundEnabled bool
}
// Server is the panel HTTP application.
@@ -96,18 +99,19 @@ type Server struct {
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
// apps owns application SASL accounts and the Postfix sender map
// (architecture.md § Mail path).
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) {
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, cfg Config, setupTokenPath string) (*Server, error) {
v, err := view.New(cfg.Version)
if err != nil {
return nil, err
}
v.SetInboundEnabled(cfg.InboundEnabled)
a := auth.New(st, auth.Config{
CookieSecure: cfg.CookieSecure,
Hostname: cfg.Hostname,
SessionIdleDays: cfg.SessionIdleDays,
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
}, v, setupTokenPath)
h := handlers.New(st, domains, apps, handlers.Config{
h := handlers.New(st, domains, apps, inboundSvc, handlers.Config{
Hostname: cfg.Hostname,
SubmissionEnabled: cfg.SubmissionEnabled,
MailLogPath: cfg.MailLogPath,
@@ -121,6 +125,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
RetryPolicy: cfg.RetryPolicy,
InboundEnabled: cfg.InboundEnabled,
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
return &Server{cfg: cfg, auth: a, handlers: h}, nil
}
@@ -167,6 +172,17 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
authed.HandleFunc("POST /reload", h.HandleReload)
if s.cfg.InboundEnabled {
authed.HandleFunc("GET /inbound", h.HandleInboundList)
authed.HandleFunc("POST /inbound", h.HandleAddInbound)
authed.HandleFunc("GET /inbound/{id}", h.HandleInboundDetail)
authed.HandleFunc("POST /inbound/{id}/dns-recheck", h.HandleInboundDNSRecheck)
authed.HandleFunc("POST /inbound/{id}/upstream", h.HandleInboundTransport)
authed.HandleFunc("POST /inbound/{id}/recipients", h.HandleInboundRecipients)
authed.HandleFunc("GET /inbound/{id}/delete", h.HandleInboundDeleteConfirm)
authed.HandleFunc("POST /inbound/{id}/delete", h.HandleInboundDelete)
}
authed.HandleFunc("/settings", h.HandleSettings)
authed.HandleFunc("/account", redirectSettings)
+1
View File
@@ -47,6 +47,7 @@ services:
ports: !override
- "20465:465"
- "20587:587"
- "20025:25"
- "127.0.0.1:20080:8080"
dns:
- 10.77.0.10
+23
View File
@@ -105,6 +105,9 @@ func TestE2E(t *testing.T) {
if err := checkSupervisorProcesses(h); err != nil {
t.Fatal(err)
}
if err := checkInboundRelayOff(h); err != nil {
t.Fatal(err)
}
if err := checkLogrotateConfigMode(h); err != nil {
t.Fatal(err)
}
@@ -134,6 +137,26 @@ func TestE2E(t *testing.T) {
sc.panel = p
})
run("inbound_ui_absent_when_disabled", func(t *testing.T) {
resp, body, err := sc.panel.get("/status")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
if strings.Contains(body, `href="/inbound"`) {
t.Fatal("status page shows Inbound nav while INBOUND_RELAY_ENABLE is off")
}
resp, _, err = sc.panel.get("/inbound")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 404 {
t.Fatalf("GET /inbound = %d, want 404 with inbound relay off", resp.StatusCode)
}
})
run("add_domain_and_publish_dkim", func(t *testing.T) {
id, err := sc.panel.addDomain(senderDomain)
if err != nil {
+15
View File
@@ -62,3 +62,18 @@ func parseSupervisorStatus(out string) map[string]string {
}
return states
}
// checkInboundRelayOff asserts the default image does not accept mail on
// port 25: smtp/inet is absent from master.cf (Debian's stock listener is
// removed when INBOUND_RELAY_ENABLE is not true).
func checkInboundRelayOff(s *stack) error {
out, err := s.execIn("selfpost", "postconf", "-M", "smtp/inet")
combined := out
if err != nil {
combined += err.Error()
}
if strings.Contains(combined, "smtpd") && !strings.Contains(combined, "warning:") && !strings.Contains(combined, "fatal:") {
return fmt.Errorf("inbound smtp/inet is present while INBOUND_RELAY_ENABLE is off:\n%s", out)
}
return nil
}