Add optional inbound relay (backup-MX) behind INBOUND_RELAY_ENABLE.
test / test (push) Waiting to run
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+16
-6
@@ -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"
|
||||
@@ -19,10 +20,12 @@ import (
|
||||
// mailStack is the panel's domain and application services plus the on-disk
|
||||
// mail-path adapters they write through.
|
||||
type mailStack struct {
|
||||
Domains *domain.Service
|
||||
Apps *app.Service
|
||||
pf *postfix.Postfix
|
||||
odk *domain.OpenDKIM
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 {
|
||||
@@ -281,11 +314,13 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
|
||||
|
||||
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
||||
drillDown := map[string]bool{
|
||||
"user_form.html": true,
|
||||
"user_delete.html": true,
|
||||
"domain_detail.html": true,
|
||||
"domain_delete.html": true,
|
||||
"delivery.html": true,
|
||||
"user_form.html": true,
|
||||
"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) {
|
||||
if !drillDown[name] {
|
||||
|
||||
+29
-18
@@ -18,9 +18,10 @@ var assetsFS embed.FS
|
||||
|
||||
// Engine holds parsed page and fragment templates.
|
||||
type Engine struct {
|
||||
pages map[string]*template.Template
|
||||
fragments map[string]*template.Template
|
||||
version string
|
||||
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
|
||||
@@ -31,21 +32,24 @@ type Engine struct {
|
||||
// encryption fields on the two secret downloads) list that partial the same
|
||||
// way.
|
||||
var pageFiles = map[string][]string{
|
||||
"setup": {"templates/setup.html"},
|
||||
"login": {"templates/login.html"},
|
||||
"dashboard": {"templates/dashboard.html"},
|
||||
"settings": {"templates/settings.html"},
|
||||
"users": {"templates/users.html"},
|
||||
"user_form": {"templates/user_form.html"},
|
||||
"user_delete": {"templates/user_delete.html"},
|
||||
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
|
||||
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
|
||||
"domain_delete": {"templates/domain_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"},
|
||||
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
||||
"status": {"templates/status.html", "templates/status_body.html"},
|
||||
"setup": {"templates/setup.html"},
|
||||
"login": {"templates/login.html"},
|
||||
"dashboard": {"templates/dashboard.html"},
|
||||
"settings": {"templates/settings.html"},
|
||||
"users": {"templates/users.html"},
|
||||
"user_form": {"templates/user_form.html"},
|
||||
"user_delete": {"templates/user_delete.html"},
|
||||
"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"},
|
||||
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
||||
"status": {"templates/status.html", "templates/status_body.html"},
|
||||
}
|
||||
|
||||
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ services:
|
||||
ports: !override
|
||||
- "20465:465"
|
||||
- "20587:587"
|
||||
- "20025:25"
|
||||
- "127.0.0.1:20080:8080"
|
||||
dns:
|
||||
- 10.77.0.10
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user