Compare commits
20
Commits
v1.3.0
..
1e4623b6e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e4623b6e1 | ||
|
|
bb26e5022e | ||
|
|
b4a9b93cf2 | ||
|
|
a8ded7ecc8 | ||
|
|
33ee08f7b6 | ||
|
|
27aeadc71d | ||
|
|
c1ec4fbd79 | ||
|
|
8538b8d5f6 | ||
|
|
909145e5c8 | ||
|
|
d0bf4a4226 | ||
|
|
0d98d92642 | ||
|
|
6218540211 | ||
|
|
705239568b | ||
|
|
46191172d6 | ||
|
|
b99f60c1d6 | ||
|
|
41c3e6e896 | ||
|
|
c6a75ce775 | ||
|
|
f47cf39802 | ||
|
|
9edb39a8ee | ||
|
|
f517e8da41 |
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: Panel UI mockups use the design system primitives, not a reskin of live templates.
|
||||||
|
globs: docs/assets/panel-ui/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Panel UI system
|
||||||
|
|
||||||
|
Source of truth: [docs/assets/panel-ui/system.html](docs/assets/panel-ui/system.html) and `system.css`.
|
||||||
|
|
||||||
|
Do not copy `internal/web/view/templates` into mockups. Do not mark `<main>` as `ops`/`form` to pick width. Do not fix empty columns, Host/Type height, or split Save/Delete with one-off CSS.
|
||||||
|
|
||||||
|
Compose screens from `stack`, `pair`, `measure`, `fill`, `field-row`, `actions-row`:
|
||||||
|
- Two peer jobs → `pair` (one child shrinks to `measure`).
|
||||||
|
- Tables/logs/DNS → `fill` (nowrap + overflow-x on the card). Add-to-list is a `measure` row inside that card, not a second card above it.
|
||||||
|
- Confirm/login/user form → `measure`.
|
||||||
|
- Host ‖ Type and two equal inputs → `field-row` (shared grid row, not two `.code` paddings).
|
||||||
|
- Save + Delete → `actions-row` inside the card; never submit inside `<form>` and danger after `</form>`.
|
||||||
|
- Danger zone is the second column of the last `pair`, never a full-width `fill`.
|
||||||
|
- Card chrome is title + optional `?` + body + actions. Help is not sprinkled later.
|
||||||
|
- Phone: `pair` stacks; `field-row` does not; tables become lists.
|
||||||
|
- One screen is one HTML file. Shared chrome is `shell.js`. Do not put every screen in one hash-SPA.
|
||||||
+225
@@ -5,6 +5,231 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- [docs/plans/inbound-antispam-panel.md](docs/plans/inbound-antispam-panel.md) —
|
||||||
|
agreed plan for inbound antispam journal (inbound-journal milter + mail.log
|
||||||
|
rejects) and instance-wide allow/deny lists synced to rspamd maps; target
|
||||||
|
`1.10.0`; Composer → Opus → Fable workflow.
|
||||||
|
- [docs/plans/inbound-quarantine.md](docs/plans/inbound-quarantine.md) — candidate
|
||||||
|
plan for held-mail review/release (design TBD).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- [docs/roadmap.md](docs/roadmap.md) — `inbound-antispam-panel` (agreed, 1/12)
|
||||||
|
and `inbound-quarantine` (candidate) in the index.
|
||||||
|
- [docs/development.md](docs/development.md) — plan checklists may override
|
||||||
|
model routing with Composer → Opus → Fable.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- [docs/schema-migrations.md](docs/schema-migrations.md) — living reference for the
|
||||||
|
SQLite migration chain (`user_version` head, per-file history, legacy artefacts,
|
||||||
|
1.x rules, and planned 2.x squash gate).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- [docs/development.md](docs/development.md), [docs/roadmap.md](docs/roadmap.md),
|
||||||
|
and [docs/architecture.md](docs/architecture.md) link to the new file;
|
||||||
|
`schema-squash` defers gate thresholds to it instead of a stale v5 snapshot.
|
||||||
|
|
||||||
|
|
||||||
|
Application sending controls: client IP allow-list for authorization, and
|
||||||
|
level-2 rate limits that override the domain ceiling per application.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Per-application **client IP allow-list** on the domain page: when enabled,
|
||||||
|
only listed addresses may authenticate and submit as that application; when
|
||||||
|
off, any client IP is allowed. Form `POST /applications/{id}/authips`.
|
||||||
|
- SQLite migration `0009_application_auth_ips.sql` (moves legacy trusted IPs
|
||||||
|
from `rate_limits` into `applications.auth_allowed_ips`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Application level-2 rate limit **overrides** the domain limit for that login
|
||||||
|
— the ceiling may be higher or lower than the domain setting (still ≤ level
|
||||||
|
1). Rate limits are no longer tied to client IPs; enforcement is in the
|
||||||
|
journal-milter at `MAIL FROM`.
|
||||||
|
- Domain export/import carries `auth_ip_restrict` / `auth_allowed_ips`; legacy
|
||||||
|
`allowed_ips` on an application `rate_limit` in export JSON migrate on import.
|
||||||
|
- Panel help, Settings, and [guide.md](docs/guide.md) describe override semantics
|
||||||
|
and the IP allow-list boundary.
|
||||||
|
- [guide.md](docs/guide.md): full-backup section documents all four capture
|
||||||
|
methods (panel, `selfpost-backup`, stopped `tar` of the project directory,
|
||||||
|
stopped `tar` of `./data` only), warns against `tar` on a live container, and
|
||||||
|
compares downtime, archive contents, and restore behaviour in a table.
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-08-18
|
||||||
|
|
||||||
|
Built-in operator documentation in the panel: a CSS-only help drawer and Help
|
||||||
|
page so Status checks and domain controls are explained without leaving the
|
||||||
|
panel.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Help** navigation entry and `/help` index page.
|
||||||
|
- CSS-checkbox help drawer (no extra script): seeded topics for Status (Machine,
|
||||||
|
TLS, PTR, mail queue, inbound when enabled) and domain cards (DNS, records,
|
||||||
|
DMARC, connection, applications, settings, export).
|
||||||
|
- «?» entry points on Status and domain card heads open the matching drawer
|
||||||
|
topic.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- [guide.md](docs/guide.md) documents the boundary: in-panel help for card
|
||||||
|
meaning; the repository guide for installation, env vars, and operations.
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-08-18
|
||||||
|
|
||||||
|
Optional DMARC aggregate report ingest: accept `rua=` mail on port 25, parse
|
||||||
|
gzip/XML into SQLite, and show summaries in the panel.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Optional DMARC aggregate report ingest (`DMARC_REPORTS_ENABLE`): port 25
|
||||||
|
accepts only configured `rua=` addresses, gzip/XML is parsed into SQLite, and
|
||||||
|
the panel shows per-domain roll-ups and individual reports under *DMARC*.
|
||||||
|
- Per-domain **SelfPost hosted** `rua=` mode when ingest is enabled
|
||||||
|
(`dmarc-reports+<domain>@<hostname>`).
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-08-18
|
||||||
|
|
||||||
|
30-day sending statistics per domain and application, plus optional auto
|
||||||
|
level-2 rate limits derived from average send rate.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Domain page **Sending statistics** card: total messages, peak and average
|
||||||
|
msg/h over the last 30 days (or shorter when send-log retention is below
|
||||||
|
30 days). Per-application stats in the application list.
|
||||||
|
- Level-2 rate limit **Manual** / **Auto** mode on domain and application
|
||||||
|
forms. Auto sets `max_messages` from `ceil(avg × multiplier)` over the
|
||||||
|
level-1 window, capped at level 1; background recalc every six hours and
|
||||||
|
**Recalculate now** on the domain page.
|
||||||
|
- Domain export JSON now includes rate limits (`mode`, `auto_multiplier`,
|
||||||
|
ceilings, trusted IPs).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Auto mode uses the level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); manual
|
||||||
|
mode behaviour is unchanged.
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-08-18
|
||||||
|
|
||||||
|
Send-log retention is configurable from panel **Settings** (global
|
||||||
|
administrator); `SEND_LOG_RETENTION_DAYS` seeds the initial default only.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Panel **Settings** (global administrator): **Send log retention (days)** —
|
||||||
|
how long delivery journal rows on `/deliveries` are kept (7–365). Stored in
|
||||||
|
SQLite `settings`; `SEND_LOG_RETENTION_DAYS` seeds the initial default only.
|
||||||
|
The log-tailer re-reads the value every prune cycle (no restart).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Deliveries list and delivery detail pages show the configured retention
|
||||||
|
instead of a hardcoded ninety-day message.
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-08-17
|
||||||
|
|
||||||
|
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. A dedicated security review of
|
||||||
|
the inbound path is still open on the plan (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
|
||||||
|
full backups. Mail queue and delivery history show this Postfix's first
|
||||||
|
retry, backoff cap, and queue lifetime. Deferred mail survives container
|
||||||
|
recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`;
|
||||||
|
the archive layout breaks 1.3.0 flat backups (unpack those with
|
||||||
|
`tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a
|
||||||
|
tag bump; no schema migration.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Mail queue and a delivery's history show this Postfix's retry policy
|
||||||
|
(first delay, backoff cap, queue lifetime), read from `postconf -h` once
|
||||||
|
at panel start. A manual `postconf -e` override is visible after the next
|
||||||
|
panel restart. There is no attempt counter — Postfix retries on time.
|
||||||
|
|
||||||
|
- docs: **Plan checklists** in [development.md](docs/development.md) — format,
|
||||||
|
`Progress` column in [roadmap.md](docs/roadmap.md), per-step commit +
|
||||||
|
CHANGELOG, version cuts `1.3.1`…`1.8.0` per roadmap stage.
|
||||||
|
- docs: **Implementation checklists** with model routing in
|
||||||
|
[docs/plans/](docs/plans/) (queue-retries, inbound-relay, send-log-retention,
|
||||||
|
domain-stats-auto-ratelimit, dmarc-reports, panel-docs).
|
||||||
|
- docs: roadmap candidates **send-log-retention** (panel Settings for delivery
|
||||||
|
journal retention) and **domain-stats-auto-ratelimit** (30-day send stats per
|
||||||
|
domain/application, auto level-2 rate limit from avg × multiplier) — plans in
|
||||||
|
[docs/plans/](docs/plans/).
|
||||||
|
|
||||||
|
- Postfix queue under `/data/postfix/queue` — deferred and active mail survive
|
||||||
|
container recreate and are included in full backups.
|
||||||
|
- Full backup archives the whole operator project: `data/`, `docker-compose.yml`,
|
||||||
|
`.env`, and `certs/` (requires `.:/selfpost-deploy:ro` in compose). Restore
|
||||||
|
by unpacking into an empty project directory.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Breaking:** full-backup archive layout — paths are prefixed with `data/`;
|
||||||
|
deploy files sit at the archive root. Old flat archives restore with
|
||||||
|
`tar xzf backup.tar.gz -C ./data` as before.
|
||||||
|
- docs: operator guide, architecture, security, and backup UI updated for
|
||||||
|
self-contained backups and persistent queue.
|
||||||
|
|
||||||
|
- docs: HTML mockups for a full panel UI refresh
|
||||||
|
([docs/assets/panel-ui/](docs/assets/panel-ui/index.html)) — current screens,
|
||||||
|
agreed and candidate roadmap surfaces (queue-retries, inbound-relay,
|
||||||
|
dmarc-reports, panel-docs), a hybrid width (ops pages use the window, forms
|
||||||
|
keep a reading measure), and a 390px emergency layout. The stamp and brick
|
||||||
|
palette stay; extra nav icons are proposals only. Design artefact; the
|
||||||
|
running panel is unchanged.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- docs: panel UI mockups after review — Status restored to live readings
|
||||||
|
(queue line, PTR, machine/process tables, Configuration); inbound MX DNS
|
||||||
|
check, recipient mode (list or any), and Danger zone beside recipients;
|
||||||
|
Backup and Settings as two-column ops pages; Save and Delete user on one
|
||||||
|
row; help «?» on domain cards; Host/name ‖ Type height matched to the live
|
||||||
|
panel (`b0ebe06`).
|
||||||
|
|
||||||
|
- docs: panel UI design system and mockups rebuilt as separate pages
|
||||||
|
(`system.html`, `status.html`, `domain.html`, …), not one hash sheet.
|
||||||
|
Regions are `stack` / `pair` / `measure` / `fill`; Host ‖ Type is
|
||||||
|
`field-row`; Save + Delete is `actions-row`. Shared chrome is `shell.js`.
|
||||||
|
|
||||||
|
- docs: panel UI mockups — add-domain sits in the list card (Domains and
|
||||||
|
Inbound); DMARC candidate screens drill into a domain roll-up and a
|
||||||
|
parsed aggregate report (aligned vs third-party fail), not a hub-only
|
||||||
|
summary.
|
||||||
|
|
||||||
## [1.3.0] - 2026-08-14
|
## [1.3.0] - 2026-08-14
|
||||||
|
|
||||||
Security and quality after 1.2.5: domain-admin send-log authorization,
|
Security and quality after 1.2.5: domain-admin send-log authorization,
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ Docker image. Postfix, OpenDKIM, and a small Go panel run together under
|
|||||||
then point your apps at the SMTP endpoint.
|
then point your apps at the SMTP endpoint.
|
||||||
|
|
||||||
SelfPost sends mail straight to the internet from **your own IP**, with per-domain
|
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
|
**For:** operators who run their own VPS or home server and want a simple relay
|
||||||
they control, without a third-party SMTP provider.
|
they control, without a third-party SMTP provider.
|
||||||
@@ -21,6 +23,7 @@ send log and DNS checks in the panel, encrypted backups.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Outbound SMTP (465/smtps; optional 587 submission) with per-domain DKIM signing
|
- 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
|
- Web panel — domains, applications, deliveries, mail queue, system log, backup
|
||||||
- Multi-domain relay — each SASL application is bound to one sending domain
|
- Multi-domain relay — each SASL application is bound to one sending domain
|
||||||
- DNS status checks (PTR, SPF, DKIM, DMARC) with in-panel re-check
|
- 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.
|
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
|
## Quick start
|
||||||
|
|
||||||
> **First boot — create the admin account.** On a fresh container SelfPost prints
|
> **First boot — create the admin account.** On a fresh container SelfPost prints
|
||||||
@@ -96,7 +108,7 @@ docker run --rm -d --name selfpost-try \
|
|||||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||||
-e PANEL_COOKIE_SECURE=false \
|
-e PANEL_COOKIE_SECURE=false \
|
||||||
-v selfpost-try-data:/data \
|
-v selfpost-try-data:/data \
|
||||||
ghcr.io/mixeme/selfpost:1.3.0
|
ghcr.io/mixeme/selfpost:1.7.0
|
||||||
```
|
```
|
||||||
|
|
||||||
**Get the setup URL** (pick one):
|
**Get the setup URL** (pick one):
|
||||||
@@ -136,8 +148,9 @@ DNS — lives in the operator guide's [Full
|
|||||||
deployment](docs/guide.md#full-deployment) section, with proxy-specific
|
deployment](docs/guide.md#full-deployment) section, with proxy-specific
|
||||||
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
|
commands under [Reverse proxy](docs/guide.md#reverse-proxy-mandatory).
|
||||||
|
|
||||||
The compose file always publishes **465** and **587**; Postfix listens on 587
|
The compose file always publishes **465**, **587**, and **25**; Postfix listens
|
||||||
only when `SUBMISSION_ENABLE=true` (see [Ports](docs/guide.md#ports)). Bump the
|
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
|
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 (`TRUSTED_PROXY_CIDR`, rate limits, retention): see [Environment
|
||||||
variables](docs/guide.md#environment-variables).
|
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=0755 build/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
|
COPY --chmod=0644 build/supervisord.conf /etc/supervisor/supervisord.conf
|
||||||
|
|
||||||
# Published submission ports: 465 (smtps, primary) and 587 (submission, optional)
|
# 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,
|
# inbound SMTP on 25 when INBOUND_RELAY_ENABLE=true, plus the panel on 8080.
|
||||||
# which needs no inbound listener or EXPOSE.
|
# Outbound delivery dials remote MXs on 25 as a client even when inbound is off.
|
||||||
EXPOSE 8080 465 587
|
EXPOSE 8080 465 587 25
|
||||||
|
|
||||||
# Liveness probe: panel HTTP plus mail-path processes (opendkim, panel, postfix).
|
# 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.
|
# Does not verify TLS, DNS, or end-to-end delivery — see docs/guide.md Operations.
|
||||||
|
|||||||
+20
-6
@@ -69,7 +69,7 @@ chmod 755 /data
|
|||||||
# later phase deliberately hands to another service. /data/log is exempt: it is
|
# later phase deliberately hands to another service. /data/log is exempt: it is
|
||||||
# deliberately owned by postfix (postlogd writes the delivery log there) and is
|
# deliberately owned by postfix (postlogd writes the delivery log there) and is
|
||||||
# normalised on its own below.
|
# normalised on its own below.
|
||||||
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log -exec chown -R panel:panel {} +
|
find /data -mindepth 1 -maxdepth 1 ! -user panel ! -name log ! -name postfix -exec chown -R panel:panel {} +
|
||||||
|
|
||||||
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes
|
# DKIM key tree (spec 6, 9). The panel (user `panel`) generates keys and writes
|
||||||
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
|
# the OpenDKIM tables; OpenDKIM (user `opendkim`) must read them. Normalise the
|
||||||
@@ -99,13 +99,20 @@ chown -R panel:selfpost /data/sasl
|
|||||||
chmod 2750 /data/sasl
|
chmod 2750 /data/sasl
|
||||||
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
[ -e /data/sasl/sasldb2 ] && chmod 0640 /data/sasl/sasldb2
|
||||||
|
|
||||||
# Postfix sender_login_maps (spec 5.1). The panel writes it; Postfix reads it.
|
# Postfix state under /data (spec 5.1, architecture.md § Persistence). The panel
|
||||||
# Ensure the file exists (empty is fine) before Postfix starts so a reload that
|
# writes sender_login_maps; Postfix owns the on-disk queue tree under queue/.
|
||||||
# references it never fails on a missing file, and keep it group-readable.
|
mkdir -p /data/postfix/queue
|
||||||
mkdir -p /data/postfix
|
|
||||||
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
[ -e /data/postfix/sender_login_maps ] || : > /data/postfix/sender_login_maps
|
||||||
chown -R panel:selfpost /data/postfix
|
# 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
|
chmod 2750 /data/postfix
|
||||||
|
chown panel:selfpost /data/postfix/sender_login_maps
|
||||||
chmod 0640 /data/postfix/sender_login_maps
|
chmod 0640 /data/postfix/sender_login_maps
|
||||||
|
|
||||||
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
# Delivery log (architecture.md § Log tailer). postlogd writes it as user
|
||||||
@@ -144,4 +151,11 @@ chmod 2750 /run/opendkim /run/selfpost
|
|||||||
# start the same way the /data normalisation above is.
|
# start the same way the /data normalisation above is.
|
||||||
/usr/local/bin/postfix-config.sh
|
/usr/local/bin/postfix-config.sh
|
||||||
|
|
||||||
|
# Initialise the persistent queue tree on first start or after restore. postfix
|
||||||
|
# set-permissions reads queue_directory from main.cf (set by postfix-config.sh).
|
||||||
|
if [ ! -d /data/postfix/queue/active ]; then
|
||||||
|
postfix set-permissions
|
||||||
|
fi
|
||||||
|
chown -R postfix:postfix /data/postfix/queue
|
||||||
|
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||||
|
|||||||
+133
-1
@@ -57,12 +57,38 @@ JOURNAL_SOCK="${JOURNAL_MILTER_SOCKET:-/run/selfpost/journal.sock}"
|
|||||||
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so
|
# Persistent panel-managed sender map (spec 5.1); texthash needs no postmap, so
|
||||||
# the unprivileged panel can rewrite it and just ask for a reload.
|
# the unprivileged panel can rewrite it and just ask for a reload.
|
||||||
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
SENDER_LOGIN_MAPS="${POSTFIX_SENDER_LOGIN_MAPS:-/data/postfix/sender_login_maps}"
|
||||||
|
# Transit mail queue under /data so deferred/active messages survive container
|
||||||
|
# recreate (architecture.md § Persistence). Distinct from sender_login_maps.
|
||||||
|
QUEUE_DIR="${POSTFIX_QUEUE_DIR:-/data/postfix/queue}"
|
||||||
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
|
SASLDB_PATH="${SASL_DB_PATH:-/data/sasl/sasldb2}"
|
||||||
|
|
||||||
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only
|
# Optional submission service on 587 (spec 5 p.1: off by default, enabled only
|
||||||
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
|
# when a client library needs STARTTLS on 587 instead of implicit TLS on 465).
|
||||||
SUBMISSION_ENABLE="${SUBMISSION_ENABLE:-false}"
|
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}"
|
||||||
|
|
||||||
|
# Optional DMARC aggregate ingest (plans/dmarc-reports.md). Off by default:
|
||||||
|
# port 25 does not accept report mail and the panel ingest maps stay empty.
|
||||||
|
DMARC_REPORTS_ENABLE="${DMARC_REPORTS_ENABLE:-false}"
|
||||||
|
DMARC_RATE_LIMIT_MESSAGES_PER_IP="${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||||
|
DMARC_MESSAGE_SIZE_LIMIT="${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
|
||||||
|
DMARC_RECIPIENTS_MAP="${POSTFIX_DMARC_RECIPIENTS:-/data/postfix/dmarc_recipients}"
|
||||||
|
DMARC_TRANSPORT_MAP="${POSTFIX_DMARC_TRANSPORT:-/data/postfix/dmarc_transport}"
|
||||||
|
DMARC_RELAY_DOMAINS_MAP="${POSTFIX_DMARC_RELAY_DOMAINS:-/data/postfix/dmarc_relay_domains}"
|
||||||
|
|
||||||
# Delivery log, written by postlogd and read by the panel's log-tailer. It lives
|
# 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
|
# under the persistent /data (not the ephemeral /var/log) so the delivery lines
|
||||||
# for messages still marked "queued" survive a container recreate — without
|
# for messages still marked "queued" survive a container recreate — without
|
||||||
@@ -128,7 +154,8 @@ postconf -e \
|
|||||||
# resolves the full address first, then the "@domain" wildcard, so both address
|
# resolves the full address first, then the "@domain" wildcard, so both address
|
||||||
# modes work from the same map.
|
# modes work from the same map.
|
||||||
postconf -e \
|
postconf -e \
|
||||||
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}"
|
"smtpd_sender_login_maps=texthash:${SENDER_LOGIN_MAPS}" \
|
||||||
|
"queue_directory=${QUEUE_DIR}"
|
||||||
|
|
||||||
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
# Restrictions: authenticated clients only, no relay to foreign destinations,
|
||||||
# and every authenticated sender address must be owned by its login. NO
|
# and every authenticated sender address must be owned by its login. NO
|
||||||
@@ -193,6 +220,111 @@ else
|
|||||||
postconf -MX "submission/inet" 2>/dev/null || true
|
postconf -MX "submission/inet" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- inbound smtpd on port 25 (optional inbound relay and/or DMARC ingest) -----
|
||||||
|
# Debian's stock master.cf enables smtp/inet. When both flags are off, remove
|
||||||
|
# that listener so port 25 is not an inbound smtpd (outbound delivery uses
|
||||||
|
# smtp/unix). When either is on: no SASL, no OpenDKIM; relay accepts only
|
||||||
|
# configured domains/recipients; DMARC accepts only allow-listed report addresses.
|
||||||
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
|
RELAY_DOMAINS_SETTING=""
|
||||||
|
TRANSPORT_SETTING=""
|
||||||
|
RECIPIENT_RESTRICTIONS="reject"
|
||||||
|
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
|
||||||
|
RELAY_DOMAINS_SETTING="texthash:${RELAY_DOMAINS_MAP}"
|
||||||
|
TRANSPORT_SETTING="texthash:${TRANSPORT_MAP}"
|
||||||
|
RECIPIENT_RESTRICTIONS="reject_unauth_destination, reject_unlisted_recipient"
|
||||||
|
fi
|
||||||
|
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
|
for f in "$DMARC_RECIPIENTS_MAP" "$DMARC_TRANSPORT_MAP" "$DMARC_RELAY_DOMAINS_MAP"; do
|
||||||
|
[ -e "$f" ] || : > "$f"
|
||||||
|
done
|
||||||
|
postconf -M "dmarc-ingest/unix-pipe=dmarc-ingest unix - n n - - pipe"
|
||||||
|
postconf -P \
|
||||||
|
"dmarc-ingest/unix-pipe/flags=Rq" \
|
||||||
|
"dmarc-ingest/unix-pipe/user=panel" \
|
||||||
|
"dmarc-ingest/unix-pipe/argv=/usr/local/bin/panel -dmarc-ingest"
|
||||||
|
if [ -n "$RELAY_DOMAINS_SETTING" ]; then
|
||||||
|
RELAY_DOMAINS_SETTING="${RELAY_DOMAINS_SETTING} texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||||
|
TRANSPORT_SETTING="${TRANSPORT_SETTING} texthash:${DMARC_TRANSPORT_MAP}"
|
||||||
|
else
|
||||||
|
RELAY_DOMAINS_SETTING="texthash:${DMARC_RELAY_DOMAINS_MAP}"
|
||||||
|
TRANSPORT_SETTING="texthash:${DMARC_TRANSPORT_MAP}"
|
||||||
|
fi
|
||||||
|
RECIPIENT_RESTRICTIONS="check_recipient_access texthash:${DMARC_RECIPIENTS_MAP}, ${RECIPIENT_RESTRICTIONS}"
|
||||||
|
fi
|
||||||
|
postconf -e \
|
||||||
|
"relay_domains=${RELAY_DOMAINS_SETTING}" \
|
||||||
|
"transport_maps=${TRANSPORT_SETTING}" \
|
||||||
|
"smtpd_reject_unlisted_recipient=yes"
|
||||||
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ]; then
|
||||||
|
postconf -e \
|
||||||
|
"relay_recipient_maps=texthash:${RELAY_RECIPIENTS_MAP}" \
|
||||||
|
"smtp_tls_policy_maps=texthash:${TLS_POLICY_MAP}"
|
||||||
|
else
|
||||||
|
postconf -e \
|
||||||
|
"relay_recipient_maps=" \
|
||||||
|
"smtp_tls_policy_maps="
|
||||||
|
fi
|
||||||
|
|
||||||
|
INBOUND_MILTERS=""
|
||||||
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -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
|
||||||
|
|
||||||
|
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||||
|
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||||
|
if [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
|
PORT25_RATE="${DMARC_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||||
|
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
|
||||||
|
fi
|
||||||
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ "${DMARC_REPORTS_ENABLE}" = "true" ]; then
|
||||||
|
PORT25_RATE="${INBOUND_RATE_LIMIT_MESSAGES_PER_IP}"
|
||||||
|
if [ "${INBOUND_MESSAGE_SIZE_LIMIT}" -gt "${DMARC_MESSAGE_SIZE_LIMIT}" ]; then
|
||||||
|
PORT25_SIZE="${INBOUND_MESSAGE_SIZE_LIMIT}"
|
||||||
|
else
|
||||||
|
PORT25_SIZE="${DMARC_MESSAGE_SIZE_LIMIT}"
|
||||||
|
fi
|
||||||
|
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=${RECIPIENT_RESTRICTIONS}" \
|
||||||
|
"smtp/inet/smtpd_milters=${INBOUND_MILTERS}" \
|
||||||
|
"smtp/inet/smtpd_client_message_rate_limit=${PORT25_RATE}" \
|
||||||
|
"smtp/inet/message_size_limit=${PORT25_SIZE}"
|
||||||
|
else
|
||||||
|
postconf -MX "smtp/inet" 2>/dev/null || true
|
||||||
|
postconf -MX "dmarc-ingest/unix-pipe" 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
|
# 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
|
# 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
|
# /etc/resolv.conf — so outbound MX lookups fail with "Host not found" and mail
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/dmarc"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runDMARCIngestMode() error {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if !cfg.dmarcEnabled {
|
||||||
|
return fmt.Errorf("dmarc ingest invoked but DMARC_REPORTS_ENABLE is not true")
|
||||||
|
}
|
||||||
|
st, err := store.Open(cfg.dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
recipient := strings.ToLower(strings.TrimSpace(os.Getenv("RECIPIENT")))
|
||||||
|
if recipient == "" {
|
||||||
|
recipient = strings.ToLower(strings.TrimSpace(os.Getenv("ORIGINAL_RECIPIENT")))
|
||||||
|
}
|
||||||
|
if err := dmarc.IngestMessage(st, os.Stdin, recipient, time.Now().UTC()); err != nil {
|
||||||
|
if incrErr := st.IncrDMARCParseFailures(); incrErr != nil {
|
||||||
|
log.Printf("dmarc ingest: record failure: %v", incrErr)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDMARCIngestInvocation() bool {
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "-dmarc-ingest" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return filepath.Base(os.Args[0]) == "dmarc-ingest"
|
||||||
|
}
|
||||||
@@ -15,6 +15,14 @@ var documentedPublic = []string{
|
|||||||
"PANEL_SESSION_IDLE_DAYS",
|
"PANEL_SESSION_IDLE_DAYS",
|
||||||
"SELFPOST_DNS_RESOLVERS",
|
"SELFPOST_DNS_RESOLVERS",
|
||||||
"TRUSTED_PROXY_CIDR",
|
"TRUSTED_PROXY_CIDR",
|
||||||
|
"INBOUND_RELAY_ENABLE",
|
||||||
|
"INBOUND_ANTISPAM_MILTER",
|
||||||
|
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||||
|
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||||
|
"DMARC_REPORTS_ENABLE",
|
||||||
|
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||||
}
|
}
|
||||||
|
|
||||||
// documentedInternal matches architecture.md § Configuration "Internal env vars".
|
// documentedInternal matches architecture.md § Configuration "Internal env vars".
|
||||||
@@ -33,6 +41,15 @@ var documentedInternal = []string{
|
|||||||
"SASL_REALM",
|
"SASL_REALM",
|
||||||
"POSTFIX_DIR",
|
"POSTFIX_DIR",
|
||||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||||
|
"POSTFIX_QUEUE_DIR",
|
||||||
|
"POSTFIX_RELAY_DOMAINS",
|
||||||
|
"POSTFIX_TRANSPORT_MAPS",
|
||||||
|
"POSTFIX_RELAY_RECIPIENTS",
|
||||||
|
"POSTFIX_TLS_POLICY_MAPS",
|
||||||
|
"POSTFIX_DMARC_RECIPIENTS",
|
||||||
|
"POSTFIX_DMARC_TRANSPORT",
|
||||||
|
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||||
|
"SELFPOST_DEPLOY_ROOT",
|
||||||
"MILTER_CONNECT_TIMEOUT",
|
"MILTER_CONNECT_TIMEOUT",
|
||||||
"MILTER_COMMAND_TIMEOUT",
|
"MILTER_COMMAND_TIMEOUT",
|
||||||
"MILTER_CONTENT_TIMEOUT",
|
"MILTER_CONTENT_TIMEOUT",
|
||||||
@@ -70,6 +87,9 @@ var loadConfigKeys = []string{
|
|||||||
"SASL_DB_PATH",
|
"SASL_DB_PATH",
|
||||||
"SASL_REALM",
|
"SASL_REALM",
|
||||||
"POSTFIX_DIR",
|
"POSTFIX_DIR",
|
||||||
|
"SELFPOST_DEPLOY_ROOT",
|
||||||
|
"INBOUND_RELAY_ENABLE",
|
||||||
|
"DMARC_REPORTS_ENABLE",
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||||
@@ -84,8 +104,24 @@ var buildScriptKeys = []string{
|
|||||||
"JOURNAL_MILTER_SOCKET",
|
"JOURNAL_MILTER_SOCKET",
|
||||||
"MAIL_LOG",
|
"MAIL_LOG",
|
||||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||||
|
"POSTFIX_QUEUE_DIR",
|
||||||
"SASL_DB_PATH",
|
"SASL_DB_PATH",
|
||||||
"SUBMISSION_ENABLE",
|
"SUBMISSION_ENABLE",
|
||||||
|
"INBOUND_RELAY_ENABLE",
|
||||||
|
"INBOUND_ANTISPAM_MILTER",
|
||||||
|
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||||
|
"INBOUND_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"INBOUND_MESSAGE_SIZE_LIMIT",
|
||||||
|
"DMARC_REPORTS_ENABLE",
|
||||||
|
"DMARC_RATE_LIMIT_MESSAGES_PER_IP",
|
||||||
|
"DMARC_MESSAGE_SIZE_LIMIT",
|
||||||
|
"POSTFIX_RELAY_DOMAINS",
|
||||||
|
"POSTFIX_TRANSPORT_MAPS",
|
||||||
|
"POSTFIX_RELAY_RECIPIENTS",
|
||||||
|
"POSTFIX_TLS_POLICY_MAPS",
|
||||||
|
"POSTFIX_DMARC_RECIPIENTS",
|
||||||
|
"POSTFIX_DMARC_TRANSPORT",
|
||||||
|
"POSTFIX_DMARC_RELAY_DOMAINS",
|
||||||
"MILTER_CONNECT_TIMEOUT",
|
"MILTER_CONNECT_TIMEOUT",
|
||||||
"MILTER_COMMAND_TIMEOUT",
|
"MILTER_COMMAND_TIMEOUT",
|
||||||
"MILTER_CONTENT_TIMEOUT",
|
"MILTER_CONTENT_TIMEOUT",
|
||||||
|
|||||||
+36
-6
@@ -10,7 +10,9 @@ import (
|
|||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/app"
|
"github.com/mixeme/selfpost/internal/app"
|
||||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||||
|
"github.com/mixeme/selfpost/internal/dmarc"
|
||||||
"github.com/mixeme/selfpost/internal/domain"
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
|
"github.com/mixeme/selfpost/internal/inbound"
|
||||||
"github.com/mixeme/selfpost/internal/postfix"
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
"github.com/mixeme/selfpost/internal/web"
|
"github.com/mixeme/selfpost/internal/web"
|
||||||
@@ -19,10 +21,14 @@ import (
|
|||||||
// mailStack is the panel's domain and application services plus the on-disk
|
// mailStack is the panel's domain and application services plus the on-disk
|
||||||
// mail-path adapters they write through.
|
// mail-path adapters they write through.
|
||||||
type mailStack struct {
|
type mailStack struct {
|
||||||
Domains *domain.Service
|
Domains *domain.Service
|
||||||
Apps *app.Service
|
Apps *app.Service
|
||||||
pf *postfix.Postfix
|
Inbound *inbound.Service
|
||||||
odk *domain.OpenDKIM
|
DMARC *dmarc.Service
|
||||||
|
pf *postfix.Postfix
|
||||||
|
odk *domain.OpenDKIM
|
||||||
|
inboundEnabled bool
|
||||||
|
dmarcEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMailStack(cfg config, st *store.Store) *mailStack {
|
func newMailStack(cfg config, st *store.Store) *mailStack {
|
||||||
@@ -30,7 +36,12 @@ func newMailStack(cfg config, st *store.Store) *mailStack {
|
|||||||
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
odk := domain.NewOpenDKIM(cfg.opendkimDir)
|
||||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||||
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
domains := domain.NewService(st, odk, apps, cfg.dkimSelectorDef)
|
||||||
return &mailStack{Domains: domains, Apps: apps, pf: pf, odk: odk}
|
inb := inbound.NewService(st, pf)
|
||||||
|
dmarcSvc := dmarc.NewService(st, pf, cfg.hostname, cfg.dmarcEnabled)
|
||||||
|
return &mailStack{
|
||||||
|
Domains: domains, Apps: apps, Inbound: inb, DMARC: dmarcSvc,
|
||||||
|
pf: pf, odk: odk, inboundEnabled: cfg.inboundEnabled, dmarcEnabled: cfg.dmarcEnabled,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
|
// Resync rebuilds OpenDKIM's tables and Postfix's sender map from SQLite and
|
||||||
@@ -42,6 +53,16 @@ func (m *mailStack) Resync() error {
|
|||||||
if err := m.Apps.Resync(); err != nil {
|
if err := m.Apps.Resync(); err != nil {
|
||||||
return fmt.Errorf("postfix resync: %w", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.dmarcEnabled {
|
||||||
|
if err := m.DMARC.Resync(); err != nil {
|
||||||
|
return fmt.Errorf("dmarc maps resync: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +87,18 @@ func resyncAfterRestore(cfg config, st *store.Store, testNoReload bool) error {
|
|||||||
// the environment describes it, with nothing bound to a port yet.
|
// the environment describes it, with nothing bound to a port yet.
|
||||||
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||||
ms := newMailStack(cfg, st)
|
ms := newMailStack(cfg, st)
|
||||||
return web.New(st, ms.Domains, ms.Apps, web.Config{
|
// postfix-config.sh has already run (entrypoint). postconf -h is the
|
||||||
|
// 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, ms.Inbound, ms.DMARC, web.Config{
|
||||||
Hostname: cfg.hostname,
|
Hostname: cfg.hostname,
|
||||||
CookieSecure: cfg.cookieSecure,
|
CookieSecure: cfg.cookieSecure,
|
||||||
SubmissionEnabled: cfg.submissionEnabled,
|
SubmissionEnabled: cfg.submissionEnabled,
|
||||||
MailLogPath: cfg.mailLog,
|
MailLogPath: cfg.mailLog,
|
||||||
DataDir: cfg.dataDir,
|
DataDir: cfg.dataDir,
|
||||||
DBPath: cfg.dbPath,
|
DBPath: cfg.dbPath,
|
||||||
|
DeployRoot: cfg.deployRoot,
|
||||||
Version: buildinfo.Version,
|
Version: buildinfo.Version,
|
||||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||||
TLSCertFile: cfg.tlsCertFile,
|
TLSCertFile: cfg.tlsCertFile,
|
||||||
@@ -82,6 +108,10 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
|||||||
DNSResolvers: cfg.dnsResolvers,
|
DNSResolvers: cfg.dnsResolvers,
|
||||||
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
|
||||||
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||||
|
RetryPolicy: retryPolicy,
|
||||||
|
InboundEnabled: cfg.inboundEnabled,
|
||||||
|
DMARCEnabled: cfg.dmarcEnabled,
|
||||||
|
SendLogRetentionEnvDefault: cfg.retentionDays,
|
||||||
}, cfg.setupTokenPath)
|
}, cfg.setupTokenPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-2
@@ -28,6 +28,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if isDMARCIngestInvocation() {
|
||||||
|
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||||
|
log.SetPrefix("dmarc-ingest: ")
|
||||||
|
if err := runDMARCIngestMode(); err != nil {
|
||||||
|
log.Printf("ingest failed: %v", err)
|
||||||
|
os.Exit(75) // EX_TEMPFAIL — ask Postfix to defer
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
showVersion := flag.Bool("version", false, "print version and exit")
|
showVersion := flag.Bool("version", false, "print version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -78,6 +88,10 @@ type config struct {
|
|||||||
saslDBPath string
|
saslDBPath string
|
||||||
saslRealm string
|
saslRealm string
|
||||||
postfixDir string
|
postfixDir string
|
||||||
|
deployRoot string
|
||||||
|
|
||||||
|
inboundEnabled bool
|
||||||
|
dmarcEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() config {
|
func loadConfig() config {
|
||||||
@@ -138,6 +152,11 @@ func loadConfig() config {
|
|||||||
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
||||||
saslRealm: saslRealm(),
|
saslRealm: saslRealm(),
|
||||||
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
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",
|
||||||
|
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
|
||||||
|
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,10 +260,20 @@ func run() error {
|
|||||||
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
if err := resyncAfterRestore(cfg, st, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if cfg.dmarcEnabled {
|
||||||
|
ms := newMailStack(cfg, st)
|
||||||
|
ms.pf.SetReloadHook(func() error { return nil })
|
||||||
|
if err := ms.DMARC.Resync(); err != nil {
|
||||||
|
log.Printf("dmarc maps bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
|
||||||
|
return fmt.Errorf("bootstrap send-log retention: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errc := make(chan error, 3)
|
errc := make(chan error, 4)
|
||||||
|
|
||||||
roles := []struct {
|
roles := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -252,7 +281,20 @@ func run() error {
|
|||||||
}{
|
}{
|
||||||
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
||||||
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
||||||
{"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }},
|
{"log-tailer", func(ctx context.Context) error {
|
||||||
|
return logtail.Run(ctx, cfg.mailLog, st, func() int {
|
||||||
|
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("send-log retention: %v", err)
|
||||||
|
return cfg.retentionDays
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
})
|
||||||
|
}},
|
||||||
|
{"rate-limit-recalc", func(ctx context.Context) error {
|
||||||
|
runAutoRateLimitRecalc(ctx, cfg, st)
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range roles {
|
for _, r := range roles {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/logtail"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runAutoRateLimitRecalc recomputes auto-mode level-2 limits on the same
|
||||||
|
// interval as send-log retention pruning (6 hours).
|
||||||
|
func runAutoRateLimitRecalc(ctx context.Context, cfg config, st *store.Store) {
|
||||||
|
recalc := func() {
|
||||||
|
retention := cfg.retentionDays
|
||||||
|
if days, err := st.GetSendLogRetentionDays(cfg.retentionDays); err == nil {
|
||||||
|
retention = days
|
||||||
|
}
|
||||||
|
l1Max := cfg.rateLimitMessagesPerIP
|
||||||
|
if l1Max <= 0 {
|
||||||
|
l1Max = 100
|
||||||
|
}
|
||||||
|
l1Window := cfg.rateLimitWindowSeconds
|
||||||
|
if l1Window <= 0 {
|
||||||
|
l1Window = 3600
|
||||||
|
}
|
||||||
|
n, err := st.RecalcAllAutoRateLimits(retention, l1Max, l1Window)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("auto rate-limit recalc: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("auto rate-limit recalc: updated %d limit(s)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recalc()
|
||||||
|
t := time.NewTicker(logtail.RetentionInterval())
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
recalc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-26
@@ -22,7 +22,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Restoring a SelfPost backup is not a code path in the panel: the operator
|
// Restoring a SelfPost backup is not a code path in the panel: the operator
|
||||||
// extracts the archive into the /data bind mount and starts the image, and the
|
// extracts the archive into a project directory and starts the image, and the
|
||||||
// panel is expected to come up on it (architecture.md § Persistence). Nothing
|
// panel is expected to come up on it (architecture.md § Persistence). Nothing
|
||||||
// below stubs that story out — the archive is downloaded from a running panel
|
// below stubs that story out — the archive is downloaded from a running panel
|
||||||
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
|
// through /backup, unpacked the way `tar -xzf` unpacks it, and a second panel
|
||||||
@@ -37,9 +37,10 @@ const (
|
|||||||
|
|
||||||
// restored is the outcome of a full backup-and-restore round trip.
|
// restored is the outcome of a full backup-and-restore round trip.
|
||||||
type restored struct {
|
type restored struct {
|
||||||
panel http.Handler // panel booted on the restored data directory
|
panel http.Handler // panel booted on the restored data directory
|
||||||
dataDir string // the restored /data
|
deployRoot string // the restored project directory
|
||||||
session *http.Cookie // a session opened before the backup was taken
|
dataDir string // the restored /data
|
||||||
|
session *http.Cookie // a session opened before the backup was taken
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
|
// restoreFromOwnBackup runs the operator's path end to end: seed a panel that
|
||||||
@@ -50,7 +51,7 @@ type restored struct {
|
|||||||
func restoreFromOwnBackup(t *testing.T, password string) restored {
|
func restoreFromOwnBackup(t *testing.T, password string) restored {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
live := seedPanelData(t)
|
live := seedPanelProject(t)
|
||||||
panel := bootPanel(t, live)
|
panel := bootPanel(t, live)
|
||||||
session := signIn(t, panel)
|
session := signIn(t, panel)
|
||||||
archive := downloadBackup(t, panel, session, password)
|
archive := downloadBackup(t, panel, session, password)
|
||||||
@@ -58,7 +59,12 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
|
|||||||
target := t.TempDir()
|
target := t.TempDir()
|
||||||
extract(t, archive, target)
|
extract(t, archive, target)
|
||||||
|
|
||||||
return restored{panel: bootPanel(t, target), dataDir: target, session: session}
|
return restored{
|
||||||
|
panel: bootPanel(t, target),
|
||||||
|
deployRoot: target,
|
||||||
|
dataDir: filepath.Join(target, "data"),
|
||||||
|
session: session,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The panel has to come up on the restored directory and show the state that
|
// The panel has to come up on the restored directory and show the state that
|
||||||
@@ -91,6 +97,12 @@ func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
|||||||
t.Errorf("%s = %q, want %q", path, got, want)
|
t.Errorf("%s = %q, want %q", path, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{backup.ComposeFileName, backup.EnvFileName} {
|
||||||
|
if _, err := os.Stat(filepath.Join(r.deployRoot, name)); err != nil {
|
||||||
|
t.Errorf("the restored project directory has no %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The one-time setup link is closed by the presence of a panel user, and the
|
// The one-time setup link is closed by the presence of a panel user, and the
|
||||||
@@ -138,8 +150,9 @@ func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
|||||||
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
|
// A restore boot runs one Resync from SQLite. If the archive's Postfix map
|
||||||
// drifted from the database, that step puts it back before mail flows.
|
// drifted from the database, that step puts it back before mail flows.
|
||||||
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
||||||
dataDir := seedPanelData(t)
|
deployRoot := seedPanelProject(t)
|
||||||
cfg := panelConfig(t, dataDir)
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
|
cfg := panelConfig(t, deployRoot)
|
||||||
|
|
||||||
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
|
mapPath := filepath.Join(dataDir, "postfix", "sender_login_maps")
|
||||||
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
|
if err := os.WriteFile(mapPath, []byte("stale map\n"), 0o640); err != nil {
|
||||||
@@ -191,12 +204,14 @@ func TestResyncAfterRestoreHealsDriftedMaps(t *testing.T) {
|
|||||||
// stays put on a mismatch: the operator's next move is to start the image the
|
// stays put on a mismatch: the operator's next move is to start the image the
|
||||||
// backup names, and it has to be there when they do.
|
// backup names, and it has to be there when they do.
|
||||||
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
|
func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
|
||||||
live := seedPanelData(t)
|
deployRoot := seedPanelProject(t)
|
||||||
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
var archive bytes.Buffer
|
var archive bytes.Buffer
|
||||||
if err := backup.Create(&archive, backup.Params{
|
if err := backup.Create(&archive, backup.Params{
|
||||||
DataDir: live,
|
DataDir: dataDir,
|
||||||
DBPath: filepath.Join(live, "selfpost.db"),
|
DBPath: filepath.Join(dataDir, "selfpost.db"),
|
||||||
Version: "9.9.9",
|
Version: "9.9.9",
|
||||||
|
DeployRoot: deployRoot,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("create backup: %v", err)
|
t.Fatalf("create backup: %v", err)
|
||||||
}
|
}
|
||||||
@@ -218,13 +233,15 @@ func TestPanelRefusesADataDirectoryRestoredFromAnotherVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedPanelData builds the /data tree of a panel that has been in use: an
|
// seedPanelProject builds an operator project tree: data/ with a panel that has
|
||||||
// administrator, a sending domain with an application and one logged message,
|
// been in use, plus docker-compose.yml, .env, and certs/ for full backups.
|
||||||
// and the daemon state the mail path needs (a DKIM key, the SASL database and
|
func seedPanelProject(t *testing.T) string {
|
||||||
// Postfix's sender map).
|
|
||||||
func seedPanelData(t *testing.T) string {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dataDir := t.TempDir()
|
deployRoot := t.TempDir()
|
||||||
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -268,16 +285,33 @@ func seedPanelData(t *testing.T) string {
|
|||||||
t.Fatalf("write %s: %v", full, err)
|
t.Fatalf("write %s: %v", full, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dataDir
|
|
||||||
|
writeDeployFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||||
|
writeDeployFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.ru\n")
|
||||||
|
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
|
||||||
|
writeDeployFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
|
||||||
|
return deployRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
// panelConfig resolves the panel's own configuration for a data directory, so
|
func writeDeployFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// panelConfig resolves the panel's own configuration for a project directory, so
|
||||||
// the test finds the files where the running binary would look for them rather
|
// the test finds the files where the running binary would look for them rather
|
||||||
// than where it put them. Cookies are marked insecure for the same reason the
|
// than where it put them. Cookies are marked insecure for the same reason the
|
||||||
// e2e stand does it: the test client speaks plain HTTP.
|
// e2e stand does it: the test client speaks plain HTTP.
|
||||||
func panelConfig(t *testing.T, dataDir string) config {
|
func panelConfig(t *testing.T, deployRoot string) config {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
dataDir := filepath.Join(deployRoot, "data")
|
||||||
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||||
|
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
||||||
t.Setenv("PANEL_COOKIE_SECURE", "false")
|
t.Setenv("PANEL_COOKIE_SECURE", "false")
|
||||||
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
|
t.Setenv("SELFPOST_HOSTNAME", "mail.example.ru")
|
||||||
// MAIL_LOG's default is an absolute path, not one derived from the data
|
// MAIL_LOG's default is an absolute path, not one derived from the data
|
||||||
@@ -288,13 +322,13 @@ func panelConfig(t *testing.T, dataDir string) config {
|
|||||||
|
|
||||||
// bootPanel performs the startup sequence run() performs, in the same order,
|
// bootPanel performs the startup sequence run() performs, in the same order,
|
||||||
// and returns the panel's HTTP handler.
|
// and returns the panel's HTTP handler.
|
||||||
func bootPanel(t *testing.T, dataDir string) http.Handler {
|
func bootPanel(t *testing.T, deployRoot string) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cfg := panelConfig(t, dataDir)
|
cfg := panelConfig(t, deployRoot)
|
||||||
|
|
||||||
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
restored, err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("the panel refused to start on %s: %v", dataDir, err)
|
t.Fatalf("the panel refused to start on %s: %v", deployRoot, err)
|
||||||
}
|
}
|
||||||
if restored {
|
if restored {
|
||||||
if _, err := os.Stat(cfg.manifestPath); err == nil {
|
if _, err := os.Stat(cfg.manifestPath); err == nil {
|
||||||
@@ -380,8 +414,8 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
|
|||||||
return plain
|
return plain
|
||||||
}
|
}
|
||||||
|
|
||||||
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the /data
|
// extract unpacks a backup archive into dir, as `tar -xzf` does onto the project
|
||||||
// bind mount before the image is started.
|
// directory before the image is started.
|
||||||
func extract(t *testing.T, archive []byte, dir string) {
|
func extract(t *testing.T, archive []byte, dir string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
//
|
//
|
||||||
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||||
//
|
//
|
||||||
// Use -o to write to a file instead. The resulting archive contains DKIM private
|
// Use -o to write to a file instead. The resulting archive is self-contained:
|
||||||
// keys, the admin password hash and SASL credentials — treat it as a secret
|
// data/, docker-compose.yml, .env, and certs/ when present — treat it as a
|
||||||
// (architecture.md § Persistence).
|
// secret (architecture.md § Persistence).
|
||||||
//
|
//
|
||||||
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
|
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
|
||||||
// argument, which would show up in the process list) the archive is written as
|
// argument, which would show up in the process list) the archive is written as
|
||||||
@@ -87,9 +87,13 @@ func run(outPath, password string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := backup.Create(sink, backup.Params{
|
if err := backup.Create(sink, backup.Params{
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
DBPath: dbPath,
|
DBPath: dbPath,
|
||||||
Version: buildinfo.Version,
|
Version: buildinfo.Version,
|
||||||
|
DeployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||||
|
OnWarn: func(msg string) {
|
||||||
|
fmt.Fprintf(os.Stderr, "selfpost-backup: %s\n", msg)
|
||||||
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/backup"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedDataDir builds the minimum /data tree a backup can be taken from.
|
// seedProject builds the minimum operator project tree a backup can be taken from.
|
||||||
func seedDataDir(t *testing.T) string {
|
func seedProject(t *testing.T) (deployRoot, dataDir string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dataDir := t.TempDir()
|
deployRoot = t.TempDir()
|
||||||
|
dataDir = filepath.Join(deployRoot, "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir data: %v", err)
|
||||||
|
}
|
||||||
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open store: %v", err)
|
t.Fatalf("open store: %v", err)
|
||||||
@@ -26,16 +31,31 @@ func seedDataDir(t *testing.T) string {
|
|||||||
if err := st.Close(); err != nil {
|
if err := st.Close(); err != nil {
|
||||||
t.Fatalf("close store: %v", err)
|
t.Fatalf("close store: %v", err)
|
||||||
}
|
}
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "fullchain.pem"), "CERT")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, backup.CertsDirName, "privkey.pem"), "KEY")
|
||||||
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||||
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||||
return dataDir
|
t.Setenv("SELFPOST_DEPLOY_ROOT", deployRoot)
|
||||||
|
return deployRoot, dataDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An encrypted backup is only worth having if the container it came from can
|
// An encrypted backup is only worth having if the container it came from can
|
||||||
// hand it back as an ordinary archive during a restore, so the two halves of
|
// hand it back as an ordinary archive during a restore, so the two halves of
|
||||||
// the CLI are tested as the one round trip an operator actually performs.
|
// the CLI are tested as the one round trip an operator actually performs.
|
||||||
func TestEncryptedBackupRoundTrip(t *testing.T) {
|
func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||||
seedDataDir(t)
|
seedProject(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
encrypted := filepath.Join(dir, "backup.spbk")
|
encrypted := filepath.Join(dir, "backup.spbk")
|
||||||
plain := filepath.Join(dir, "backup.tar.gz")
|
plain := filepath.Join(dir, "backup.tar.gz")
|
||||||
@@ -81,7 +101,12 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
names[hdr.Name] = true
|
names[hdr.Name] = true
|
||||||
}
|
}
|
||||||
for _, want := range []string{"manifest.json", "selfpost.db"} {
|
for _, want := range []string{
|
||||||
|
backup.DataArchivePrefix + backup.ManifestName,
|
||||||
|
backup.DataArchivePrefix + "selfpost.db",
|
||||||
|
backup.ComposeFileName,
|
||||||
|
backup.EnvFileName,
|
||||||
|
} {
|
||||||
if !names[want] {
|
if !names[want] {
|
||||||
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
|
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
|
||||||
}
|
}
|
||||||
@@ -91,7 +116,7 @@ func TestEncryptedBackupRoundTrip(t *testing.T) {
|
|||||||
// Without a password the CLI keeps producing the plain archive that existing
|
// Without a password the CLI keeps producing the plain archive that existing
|
||||||
// backup scripts consume.
|
// backup scripts consume.
|
||||||
func TestUnencryptedBackupStaysPlain(t *testing.T) {
|
func TestUnencryptedBackupStaysPlain(t *testing.T) {
|
||||||
seedDataDir(t)
|
seedProject(t)
|
||||||
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||||
if err := run(out, ""); err != nil {
|
if err := run(out, ""); err != nil {
|
||||||
t.Fatalf("create backup: %v", err)
|
t.Fatalf("create backup: %v", err)
|
||||||
|
|||||||
@@ -11,6 +11,30 @@ SELFPOST_HOSTNAME=mail.example.com
|
|||||||
# primary 465/smtps listener.
|
# primary 465/smtps listener.
|
||||||
SUBMISSION_ENABLE=false
|
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 DMARC aggregate report ingest. When true, Postfix accepts mail on port
|
||||||
|
# 25 only for report addresses configured in the panel (global default and/or
|
||||||
|
# per-domain SelfPost-hosted addresses) and pipes them to the ingest worker.
|
||||||
|
DMARC_REPORTS_ENABLE=false
|
||||||
|
|
||||||
|
# Coarse per-client-IP limit and message size for DMARC ingest on port 25.
|
||||||
|
# DMARC_RATE_LIMIT_MESSAGES_PER_IP=20
|
||||||
|
# DMARC_MESSAGE_SIZE_LIMIT=5242880
|
||||||
|
|
||||||
|
# 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".
|
# Level-1 backstop rate limit (anvil) — see docs/guide.md "Rate limiting".
|
||||||
RATE_LIMIT_MESSAGES_PER_IP=100
|
RATE_LIMIT_MESSAGES_PER_IP=100
|
||||||
RATE_LIMIT_WINDOW_SECONDS=3600
|
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
|
ports: !override
|
||||||
- "465:465"
|
- "465:465"
|
||||||
- "587:587"
|
- "587:587"
|
||||||
|
- "25:25"
|
||||||
environment:
|
environment:
|
||||||
# Caddy names certificate files after the hostname, not
|
# Caddy names certificate files after the hostname, not
|
||||||
# fullchain.pem/privkey.pem like certbot — override the base file's
|
# fullchain.pem/privkey.pem like certbot — override the base file's
|
||||||
@@ -31,6 +32,7 @@ services:
|
|||||||
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
|
TLS_KEY_FILE: /etc/postfix/tls/<hostname>.key
|
||||||
volumes: !override
|
volumes: !override
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
- .:/selfpost-deploy:ro
|
||||||
# Replace <hostname> with your actual mail/panel hostname, matching
|
# Replace <hostname> with your actual mail/panel hostname, matching
|
||||||
# the Caddyfile below and SELFPOST_HOSTNAME.
|
# the Caddyfile below and SELFPOST_HOSTNAME.
|
||||||
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
|
- ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/<hostname>:/etc/postfix/tls:ro
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
selfpost:
|
selfpost:
|
||||||
image: ghcr.io/mixeme/selfpost:1.3.0
|
image: ghcr.io/mixeme/selfpost:1.9.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||||
@@ -33,6 +33,14 @@ services:
|
|||||||
# Set to true to also publish RFC 6409 submission (587/STARTTLS)
|
# Set to true to also publish RFC 6409 submission (587/STARTTLS)
|
||||||
# alongside the primary 465/smtps listener (spec 5).
|
# alongside the primary 465/smtps listener (spec 5).
|
||||||
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
SUBMISSION_ENABLE: "${SUBMISSION_ENABLE:-false}"
|
||||||
|
INBOUND_RELAY_ENABLE: "${INBOUND_RELAY_ENABLE:-false}"
|
||||||
|
DMARC_REPORTS_ENABLE: "${DMARC_REPORTS_ENABLE:-false}"
|
||||||
|
DMARC_RATE_LIMIT_MESSAGES_PER_IP: "${DMARC_RATE_LIMIT_MESSAGES_PER_IP:-20}"
|
||||||
|
DMARC_MESSAGE_SIZE_LIMIT: "${DMARC_MESSAGE_SIZE_LIMIT:-5242880}"
|
||||||
|
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
|
# Level-1 backstop rate limit (anvil, spec 5.5, 7.4); per-domain/app
|
||||||
# limits (level 2) are configured later from the panel itself.
|
# limits (level 2) are configured later from the panel itself.
|
||||||
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
RATE_LIMIT_MESSAGES_PER_IP: "${RATE_LIMIT_MESSAGES_PER_IP:-100}"
|
||||||
@@ -58,6 +66,9 @@ services:
|
|||||||
# kept) so the delivery lines that resolve a queued send-log row survive
|
# kept) so the delivery lines that resolve a queued send-log row survive
|
||||||
# a container recreate; it is diagnostics and is left out of backups.
|
# a container recreate; it is diagnostics and is left out of backups.
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
# Read-only project root for full backups: docker-compose.yml, .env, and
|
||||||
|
# certs/ are archived alongside /data (architecture.md § Persistence).
|
||||||
|
- .:/selfpost-deploy:ro
|
||||||
# Read-only: SelfPost only ever reads certificates, never manages them
|
# Read-only: SelfPost only ever reads certificates, never manages them
|
||||||
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot
|
# (spec 10 p.2). Point this at wherever your reverse-proxy/certbot
|
||||||
# writes PEM files, e.g. /etc/letsencrypt on the host.
|
# writes PEM files, e.g. /etc/letsencrypt on the host.
|
||||||
@@ -73,6 +84,9 @@ services:
|
|||||||
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
# 587 is mapped even when SUBMISSION_ENABLE=false; Postfix listens only
|
||||||
# when the variable is true — see docs/guide.md "Published ports".
|
# when the variable is true — see docs/guide.md "Published ports".
|
||||||
- "587:587"
|
- "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"
|
- "127.0.0.1:8080:8080"
|
||||||
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as
|
# Hardening (spec 10 p.6). SelfPost's entrypoint still needs to run as
|
||||||
# root very briefly to fix /data ownership and normalise permissions
|
# root very briefly to fix /data ownership and normalise permissions
|
||||||
@@ -82,7 +96,7 @@ services:
|
|||||||
# self-healing. What IS applied: no privilege escalation past what the
|
# self-healing. What IS applied: no privilege escalation past what the
|
||||||
# image already grants, and every Linux capability dropped except the
|
# image already grants, and every Linux capability dropped except the
|
||||||
# small set the root startup phase and Postfix/OpenDKIM genuinely need:
|
# 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`;
|
# - CHOWN — entrypoint re-owns /data (bind mount) to `panel`;
|
||||||
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
# - FOWNER — entrypoint then chmods those now panel-owned /data
|
||||||
# dirs/files while still root (owner-check bypass);
|
# dirs/files while still root (owner-check bypass);
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ services:
|
|||||||
ports: !override
|
ports: !override
|
||||||
- "465:465"
|
- "465:465"
|
||||||
- "587:587"
|
- "587:587"
|
||||||
|
- "25:25"
|
||||||
# No host publish for 8080 here: nginx reaches it over the compose
|
# No host publish for 8080 here: nginx reaches it over the compose
|
||||||
# network at selfpost:8080 instead (see nginx.conf.example).
|
# network at selfpost:8080 instead (see nginx.conf.example).
|
||||||
volumes: !override
|
volumes: !override
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
- .:/selfpost-deploy:ro
|
||||||
# Same host directory certbot below writes into — plain bind mount,
|
# Same host directory certbot below writes into — plain bind mount,
|
||||||
# no named volume, so the PEM files are as directly inspectable as in
|
# no named volume, so the PEM files are as directly inspectable as in
|
||||||
# the Apache scenario (spec 10.3).
|
# the Apache scenario (spec 10.3).
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ services:
|
|||||||
ports: !override
|
ports: !override
|
||||||
- "465:465"
|
- "465:465"
|
||||||
- "587:587"
|
- "587:587"
|
||||||
|
- "25:25"
|
||||||
volumes: !override
|
volumes: !override
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
- .:/selfpost-deploy:ro
|
||||||
# Populated by extract-cert.sh from traefik's acme.json — see below.
|
# Populated by extract-cert.sh from traefik's acme.json — see below.
|
||||||
- ./traefik/extracted-certs:/etc/postfix/tls:ro
|
- ./traefik/extracted-certs:/etc/postfix/tls:ro
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
+64
-14
@@ -53,13 +53,31 @@ Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
|
|||||||
├─► OpenDKIM milter (sign, tempfail on failure)
|
├─► OpenDKIM milter (sign, tempfail on failure)
|
||||||
├─► journal-milter (send log + L2 rate limits, fail-open)
|
├─► journal-milter (send log + L2 rate limits, fail-open)
|
||||||
└─► outbound MX delivery (port 25 client)
|
└─► 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))
|
### Postfix ([build/postfix-config.sh](../build/postfix-config.sh))
|
||||||
|
|
||||||
- **465/smtps** — implicit TLS, SASL required; primary listener.
|
- **465/smtps** — implicit TLS, SASL required; primary listener.
|
||||||
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
|
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
|
||||||
`smtpd_tls_security_level=encrypt`.
|
`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`;
|
- **No open relay** — `permit_sasl_authenticated`, `reject_unauth_destination`;
|
||||||
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
`smtpd_sender_login_maps` + `reject_sender_login_mismatch`.
|
||||||
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
- **Level-1 rate limit** — `smtpd_client_message_rate_limit` /
|
||||||
@@ -78,7 +96,14 @@ by the panel. Socket `/run/opendkim/opendkim.sock`.
|
|||||||
One process, three roles:
|
One process, three roles:
|
||||||
|
|
||||||
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
||||||
proxy only.
|
proxy only. On start it runs `postconf -h` once for the deferred-mail retry
|
||||||
|
parameters (`queue_run_delay`, `minimal_backoff_time`,
|
||||||
|
`maximal_backoff_time`, `maximal_queue_lifetime`, `bounce_queue_lifetime`,
|
||||||
|
`delay_warning_time`) and caches the snapshot on the handlers config. The
|
||||||
|
Mail queue card and a delivery's `deferred` / `bounced` history print those
|
||||||
|
numbers; they never call `postconf` per request. If `postconf` is missing,
|
||||||
|
the panel logs a warning and uses Postfix 3.x compiled-in defaults
|
||||||
|
(`300s` / `4000s` / `5d` / `0`) with a muted note on the card.
|
||||||
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
2. **journal-milter** — unix socket `JOURNAL_MILTER_SOCKET`; records From/To/
|
||||||
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
Subject/SASL user at DATA; enforces level-2 rate limits; **fail-open**
|
||||||
(`default_action=accept`) so milter failure does not stop mail. Domain
|
(`default_action=accept`) so milter failure does not stop mail. Domain
|
||||||
@@ -173,18 +198,21 @@ below is a summary — HTMX fragment endpoints
|
|||||||
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
|
| `/domains/{id}`, `/domains/{id}/*` | Assigned-domain detail for domain-admins; delete domain is **global** |
|
||||||
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
|
| `/domains/import` | **Global.** Domain import (`POST`; form on the Backup page) |
|
||||||
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
|
| `/deliveries`, `/deliveries/{id}` | Send log with filters; scoped to assigned domains for domain-admins |
|
||||||
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view |
|
| `/mail-queue`, `/mail-queue/*` | **Global.** Postfix queue view; retry-policy card on the page (not the HTMX fragment) |
|
||||||
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
|
| `/system-log`, `/system-log/*` | **Global.** `mail.log` tail |
|
||||||
| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
|
| `/reload` | **Global.** `POST` — reload OpenDKIM + Postfix maps |
|
||||||
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
|
| `/backup`, `/backup/*` | **Global.** Full backup download (page also hosts the import form) |
|
||||||
| `/settings` | Username/password for any user; DMARC report default is **global** only |
|
| `/settings` | Username/password for any user; DMARC report default is **global** only |
|
||||||
| `/users`, `/users/*` | **Global.** Panel user CRUD |
|
| `/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
|
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
|
the page, 30 s when the tab is visible but idle, none when hidden — scheduled in
|
||||||
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
|
`panel.js` via `data-poll`, not `hx-trigger="every …"`); polling does not extend
|
||||||
session idle timeout (only non-`HX-Request` GET and mutating requests count as
|
session idle timeout (only non-`HX-Request` GET and mutating requests count as
|
||||||
activity).
|
activity). The Mail queue retry-policy card is outside that fragment: it is the
|
||||||
|
start-up `postconf -h` snapshot (see [Panel binary](#panel-binary-cmdpanel)),
|
||||||
|
not a live re-read.
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
@@ -242,6 +270,7 @@ flowchart TB
|
|||||||
subgraph services ["Services — multi-store operations + rollback"]
|
subgraph services ["Services — multi-store operations + rollback"]
|
||||||
domainSvc["internal/domain"]
|
domainSvc["internal/domain"]
|
||||||
appSvc["internal/app"]
|
appSvc["internal/app"]
|
||||||
|
inboundSvc["internal/inbound"]
|
||||||
end
|
end
|
||||||
subgraph persistence ["Persistence"]
|
subgraph persistence ["Persistence"]
|
||||||
store["internal/store — SQLite, embedded migrations"]
|
store["internal/store — SQLite, embedded migrations"]
|
||||||
@@ -263,16 +292,19 @@ flowchart TB
|
|||||||
web --> store
|
web --> store
|
||||||
web --> domainSvc
|
web --> domainSvc
|
||||||
web --> appSvc
|
web --> appSvc
|
||||||
|
web --> inboundSvc
|
||||||
web --> backupPkg
|
web --> backupPkg
|
||||||
web --> dnscheck
|
web --> dnscheck
|
||||||
web --> health
|
web --> health
|
||||||
web --> secretfile
|
web --> secretfile
|
||||||
domainSvc --> store
|
domainSvc --> store
|
||||||
appSvc --> store
|
appSvc --> store
|
||||||
|
inboundSvc --> store
|
||||||
milterPkg --> store
|
milterPkg --> store
|
||||||
logtail --> store
|
logtail --> store
|
||||||
domainSvc --> postfix
|
domainSvc --> postfix
|
||||||
appSvc --> postfix
|
appSvc --> postfix
|
||||||
|
inboundSvc --> postfix
|
||||||
```
|
```
|
||||||
|
|
||||||
The three roles inside the `panel` process (HTTP server, journal-milter,
|
The three roles inside the `panel` process (HTTP server, journal-milter,
|
||||||
@@ -286,28 +318,37 @@ single-connection trade-off that follows from it.
|
|||||||
|
|
||||||
| Path | Contents |
|
| Path | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset |
|
| `selfpost.db` | SQLite panel state (domains, apps, users, sessions, send log, L2 limits, log-tailer offset, inbound relay, DMARC reports) — see [schema-migrations.md](schema-migrations.md) |
|
||||||
| `setup-token` | First-run setup token file |
|
| `setup-token` | First-run setup token file |
|
||||||
| `opendkim/` | DKIM keys + tables |
|
| `opendkim/` | DKIM keys + tables |
|
||||||
| `sasl/sasldb2` | Application SASL credentials |
|
| `sasl/sasldb2` | Application SASL credentials |
|
||||||
| `postfix/sender_login_maps` | Login → From binding |
|
| `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) |
|
| `log/mail.log` | Postfix delivery log + rotated copies (excluded from backups) |
|
||||||
| `manifest.json` | Backup version stamp (consumed on restore) |
|
| `manifest.json` | Backup version stamp (consumed on restore) |
|
||||||
|
|
||||||
Not in `/data`: TLS certificates (reverse-proxy mount), Postfix queue
|
Not in `/data`: TLS certificates for the panel (reverse-proxy mount) — though
|
||||||
(transit mail not migrated by design).
|
full backups also archive the operator's `./certs` PEM files when present.
|
||||||
|
|
||||||
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
|
**Rotation:** send-log retention `SEND_LOG_RETENTION_DAYS` (default 90);
|
||||||
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
`mail.log` via logrotate (14 rotated files, check every 6h, rename +
|
||||||
`postfix reload` in `postrotate` — see § Log tailer above).
|
`postfix reload` in `postrotate` — see § Log tailer above).
|
||||||
|
|
||||||
**Restore:** panel button or `selfpost-backup` CLI — SQLite snapshot + tar of
|
**Restore:** panel button or `selfpost-backup` CLI — self-contained archive:
|
||||||
`/data` tree, minus `log/`, the setup token and any `tls/`; version check on
|
`data/` (SQLite snapshot + tree minus `log/`, the setup token and any `tls/`
|
||||||
restore. On the first successful boot after restore, the panel runs one
|
under `/data`), `docker-compose.yml`, `.env`, and `certs/` when present;
|
||||||
**Resync** — OpenDKIM's tables and Postfix's sender map are re-derived from
|
version check on restore. Requires the project directory mounted read-only at
|
||||||
SQLite and both daemons are reloaded, so drift between the extracted archive
|
`SELFPOST_DEPLOY_ROOT` (`/selfpost-deploy` in the default compose file). On the
|
||||||
and the database is healed before mail flows (same step as `POST /reload` on
|
first successful boot after restore, the panel runs one **Resync** — OpenDKIM's
|
||||||
demand). Stopped-container `tar` of `./data` is safe (see guide).
|
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
|
**Optional encryption** of the two secret-bearing downloads
|
||||||
([internal/secretfile](../internal/secretfile/secretfile.go)): password →
|
([internal/secretfile](../internal/secretfile/secretfile.go)): password →
|
||||||
@@ -350,7 +391,16 @@ unsupported rather than as a missing doc:
|
|||||||
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
|
`POSTFIX_DIR` (`/data/postfix`), `POSTFIX_SENDER_LOGIN_MAPS`
|
||||||
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
|
(`/data/postfix/sender_login_maps` — read by Postfix config only; the panel
|
||||||
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
|
always writes `<POSTFIX_DIR>/sender_login_maps`, so overriding this env alone
|
||||||
desyncs the map Postfix reads from the file the panel maintains).
|
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).
|
||||||
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
- **Milter and Postfix startup:** `MILTER_CONNECT_TIMEOUT` (`15s`),
|
||||||
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
`MILTER_COMMAND_TIMEOUT` (`15s`), `MILTER_CONTENT_TIMEOUT` (`30s`),
|
||||||
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
`MILTER_WAIT_TIMEOUT` (`30` seconds).
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SelfPost — panel mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var map = {
|
||||||
|
login: "login.html",
|
||||||
|
setup: "setup.html",
|
||||||
|
status: "status.html",
|
||||||
|
domains: "domains.html",
|
||||||
|
domain: "domain.html",
|
||||||
|
"domain-delete": "domain-delete.html",
|
||||||
|
deliveries: "deliveries.html",
|
||||||
|
delivery: "delivery.html",
|
||||||
|
"mail-queue": "mail-queue.html",
|
||||||
|
"system-log": "system-log.html",
|
||||||
|
backup: "backup.html",
|
||||||
|
users: "users.html",
|
||||||
|
"user-form": "user-form.html",
|
||||||
|
"user-delete": "user-delete.html",
|
||||||
|
settings: "settings.html",
|
||||||
|
inbound: "inbound.html",
|
||||||
|
"inbound-domain": "inbound-domain.html",
|
||||||
|
"inbound-backup": "inbound-backup.html",
|
||||||
|
"inbound-delete": "inbound-delete.html",
|
||||||
|
dmarc: "dmarc.html",
|
||||||
|
"dmarc-domain": "dmarc-domain.html",
|
||||||
|
"dmarc-report": "dmarc-report.html",
|
||||||
|
"dmarc-report-fail": "dmarc-report-fail.html",
|
||||||
|
help: "help.html"
|
||||||
|
};
|
||||||
|
var h = location.hash.replace(/^#/, "");
|
||||||
|
location.replace((map[h] || "status.html") + location.search);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Макеты собраны отдельными страницами. Откройте <a href="status.html">Status</a> или <a href="index.html">оглавление</a>.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Backup & migration — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-backup" data-page="backup" data-nav="backup" data-title="Backup" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Backup & migration</h1>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Full backup</h2>
|
||||||
|
<p class="muted">Download a self-contained backup — <code>data/</code> (database, DKIM keys, credentials, Postfix queue), <code>docker-compose.yml</code>, <code>.env</code>, and <code>certs/</code>. Extract into an empty project directory on a new machine, adjust hostname if needed, and start the <strong>same SelfPost version</strong> before first boot. The reverse-proxy vhost is not included.</p>
|
||||||
|
<p class="muted"><strong>The backup file is a secret.</strong> Encrypting it is the simplest way to store it: the download is then a <code>.spbk</code> that only the password opens.</p>
|
||||||
|
<div class="encrypt">
|
||||||
|
<label class="check"><input type="checkbox" checked> <span>Encrypt with a password</span></label>
|
||||||
|
<div class="encrypt-fields">
|
||||||
|
<label>Password</label><input type="password" placeholder="at least 8 characters">
|
||||||
|
<label>Repeat password</label><input type="password">
|
||||||
|
<p class="muted">Keep this password: without it the file cannot be opened.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Download full backup</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Import a domain</h2>
|
||||||
|
<p class="muted">Move a single domain here from another SelfPost instance — plain <code>.json</code> or encrypted <code>.spde</code>. Its DKIM key and application passwords come across, so the published DNS record needs no change. The export file is a secret, like a full backup.</p>
|
||||||
|
<label>Domain export file</label>
|
||||||
|
<input type="file">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" autocomplete="off">
|
||||||
|
<p class="muted">Needed for a <code>.spde</code> file. Leave empty for plain <code>.json</code>.</p>
|
||||||
|
<div class="actions-row"><button type="button">Import domain</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Deliveries — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-deliveries" data-page="deliveries" data-nav="deliveries" data-title="Deliveries">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Deliveries</h1>
|
||||||
|
<form class="toolbar fill" action="#" onsubmit="return false">
|
||||||
|
<div class="field"><label>Domain</label>
|
||||||
|
<select><option>All domains</option><option>example.com</option><option>alerts.example.com</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Application</label>
|
||||||
|
<select><option>All applications</option><option>newsletter</option><option>billing</option></select>
|
||||||
|
</div>
|
||||||
|
<button type="button">Filter</button>
|
||||||
|
</form>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 20:14:02</td>
|
||||||
|
<td>billing@example.com</td>
|
||||||
|
<td>ada@example.net</td>
|
||||||
|
<td class="subject"><span>Invoice #4412</span></td>
|
||||||
|
<td class="status"><span class="st st-warn">deferred</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 20:11:40</td>
|
||||||
|
<td>news@example.com</td>
|
||||||
|
<td>list-bounces@example.net</td>
|
||||||
|
<td class="subject"><span>August digest</span></td>
|
||||||
|
<td class="status"><span class="st st-ok">delivered</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 19:02:11</td>
|
||||||
|
<td>alerts@alerts.example.com</td>
|
||||||
|
<td>noreply@blocked.example</td>
|
||||||
|
<td class="subject"><span>Disk 92% on web-3</span></td>
|
||||||
|
<td class="status"><span class="st st-error">bounced</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 18:44:09</td>
|
||||||
|
<td>news@example.com</td>
|
||||||
|
<td>sam@example.org</td>
|
||||||
|
<td class="subject"><span>August digest</span></td>
|
||||||
|
<td class="status"><span class="st st-ok">delivered</span></td>
|
||||||
|
<td class="actions"><a href="delivery.html">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<a href="delivery.html">
|
||||||
|
<span class="st st-warn">deferred</span><span class="when">20:14</span>
|
||||||
|
<span class="meta">billing@example.com → ada@example.net</span>
|
||||||
|
<span class="subj">Invoice #4412</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="delivery.html">
|
||||||
|
<span class="st st-ok">delivered</span><span class="when">20:11</span>
|
||||||
|
<span class="meta">news@example.com → list-bounces@example.net</span>
|
||||||
|
<span class="subj">August digest</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="delivery.html">
|
||||||
|
<span class="st st-error">bounced</span><span class="when">19:02</span>
|
||||||
|
<span class="meta">alerts@alerts.example.com → noreply@blocked.example</span>
|
||||||
|
<span class="subj">Disk 92% on web-3</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Page 1 of 4 · <a href="deliveries.html">Older →</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Invoice #4412 — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-delivery" data-page="delivery" data-nav="deliveries" data-title="Message">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1 class="subject">Invoice #4412</h1>
|
||||||
|
<p class="route">
|
||||||
|
<span class="addr">billing@example.com</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="addr">ada@example.net</span>
|
||||||
|
<span class="st st-warn">deferred</span>
|
||||||
|
</p>
|
||||||
|
<a class="back" href="deliveries.html">← Back to deliveries</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Message</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Application</span><span class="fact-value">billing</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Accepted</span><span class="fact-value">2026-08-15 20:14:02 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Status reported</span><span class="fact-value">2026-08-15 20:14:08 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Queue id</span><span class="fact-value mono">4C3A1E2F1A</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Journal id</span><span class="fact-value mono">1842</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>History</h2>
|
||||||
|
<ol class="timeline">
|
||||||
|
<li class="event lvl-ok">
|
||||||
|
<p class="event-time">2026-08-15 20:14:02 UTC</p>
|
||||||
|
<p class="event-title"><span class="st st-ok">accepted</span> Received by the relay</p>
|
||||||
|
<p class="event-detail muted">SASL login billing, queued as 4C3A1E2F1A.</p>
|
||||||
|
</li>
|
||||||
|
<li class="event lvl-warn">
|
||||||
|
<p class="event-time">2026-08-15 20:14:08 UTC</p>
|
||||||
|
<p class="event-title"><span class="st st-warn">deferred</span> Receiving MX asked to try later</p>
|
||||||
|
<p class="event-detail muted">Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.</p>
|
||||||
|
</li>
|
||||||
|
<li class="event pending">
|
||||||
|
<p class="event-time">not yet</p>
|
||||||
|
<p class="event-title"><span class="st st-unknown">delivery</span> Waiting on the next retry</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Delivery log</h2>
|
||||||
|
<table class="log desk-only">
|
||||||
|
<thead><tr><th>Time</th><th>Message</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40, sasl_username=billing</td></tr>
|
||||||
|
<tr><td class="time muted">20:14:02</td><td class="log-text">postfix/cleanup[224]: 4C3A1E2F1A: message-id=<4412@example.com></td></tr>
|
||||||
|
<tr><td class="time muted">20:14:08</td><td class="log-text">postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, status=deferred (450 4.2.1 mailbox busy)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="phone-only">
|
||||||
|
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:08</p>
|
||||||
|
<span class="code">status=deferred (450 4.2.1 mailbox busy)</span>
|
||||||
|
<p class="muted" style="margin:0.6rem 0 0.2rem">20:14:02</p>
|
||||||
|
<span class="code">client=203.0.113.40, sasl_username=billing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>example.com — DMARC — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc-domain" data-page="dmarc-domain" data-nav="dmarc" data-title="example.com DMARC">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>example.com <span class="tag future">candidate</span></h1>
|
||||||
|
<a class="back g-only" href="dmarc.html">← All DMARC reports</a>
|
||||||
|
<a class="back" href="domain.html">← Back to example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Last 7 days <span class="st st-ok">pass</span></h2>
|
||||||
|
<p>98% aligned. Tightening <code>p=</code> looks reasonable.</p>
|
||||||
|
<p class="muted"><a href="domain.html">Domain DNS and rua=</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Third-party senders</h2>
|
||||||
|
<p>google.com / 66.102.0.0/20 failed SPF and DKIM 6 times. Everyone else is this relay.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Reports</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 06:12</td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>14 Aug</td>
|
||||||
|
<td>412</td>
|
||||||
|
<td>6</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-14 06:08</td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>13 Aug</td>
|
||||||
|
<td>390</td>
|
||||||
|
<td>2</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">412 pass</span><span class="when">14 Aug</span>
|
||||||
|
<span class="meta">google.com · 6 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">390 pass</span><span class="when">13 Aug</span>
|
||||||
|
<span class="meta">google.com · 2 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Sources · last 7 days</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Source</th><th>Pass</th><th>Fail</th><th>Disposition</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="muted">203.0.113.10 (this relay)</td><td>802</td><td>0</td><td><span class="st st-ok">none</span></td></tr>
|
||||||
|
<tr><td class="muted">google.com / 66.102.0.0/20</td><td>0</td><td>8</td><td><span class="st st-warn">none</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="dmarc-report.html"><span>this relay</span><span class="st st-ok">802 pass</span></a></li>
|
||||||
|
<li><a href="dmarc-report.html"><span>google.com / 66.102.0.0/20</span><span class="st st-warn">8 fail</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>google.com report — alerts.example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc-report-fail" data-page="dmarc-report-fail" data-nav="dmarc" data-title="DMARC report">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1 class="subject">google.com · 14 Aug</h1>
|
||||||
|
<p class="route">
|
||||||
|
<span class="addr">alerts.example.com</span>
|
||||||
|
<span class="st st-ok">88 pass</span>
|
||||||
|
<span class="st st-error">19 fail</span>
|
||||||
|
</p>
|
||||||
|
<a class="back" href="dmarc.html">← All DMARC reports</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Report</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944098765432109</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 – 2026-08-15 00:00 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 04:40 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card attn">
|
||||||
|
<h2>Published policy</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">alerts.example.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="muted">A third-party source is not in SPF or DKIM. Do not tighten <code>p=</code> until that sender is gone or aligned.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Records</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">203.0.113.10 (this relay)</td>
|
||||||
|
<td>88</td>
|
||||||
|
<td><span class="st st-ok">none</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td>alerts.example.com</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">198.51.100.80 (unknown)</td>
|
||||||
|
<td>19</td>
|
||||||
|
<td><span class="st st-error">none</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td>alerts.example.com</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>this relay · 88</span><span class="st st-ok">pass</span>
|
||||||
|
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>unknown · 19</span><span class="st st-error">fail</span>
|
||||||
|
<span class="meta">198.51.100.80 · SPF fail · DKIM fail</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>google.com report — example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc-report" data-page="dmarc-report" data-nav="dmarc" data-title="DMARC report">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1 class="subject">google.com · 14 Aug</h1>
|
||||||
|
<p class="route">
|
||||||
|
<span class="addr">example.com</span>
|
||||||
|
<span class="st st-ok">412 pass</span>
|
||||||
|
<span class="st st-warn">6 fail</span>
|
||||||
|
</p>
|
||||||
|
<a class="back" href="dmarc-domain.html">← Back to example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Report</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Reporter</span><span class="fact-value">google.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Report id</span><span class="fact-value mono">3178944012345678901</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Window</span><span class="fact-value">2026-08-14 00:00 – 2026-08-15 00:00 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Received</span><span class="fact-value">2026-08-15 06:12 UTC</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Contact</span><span class="fact-value">noreply-dmarc-support@google.com</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Published policy</h2>
|
||||||
|
<div class="facts">
|
||||||
|
<div class="fact"><span class="fact-label">Domain</span><span class="fact-value">example.com</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">p / sp / pct</span><span class="fact-value mono">none / none / 100</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">adkim / aspf</span><span class="fact-value mono">r / r</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">rua</span><span class="fact-value">dmarc@mail.example.org</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="muted"><code>p=none</code> does not affect delivery. Failures here are forwarding through Google, not this relay.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Records</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Source</th><th>Count</th><th>Disposition</th><th>SPF</th><th>DKIM</th><th>Header from</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">203.0.113.10 (this relay)</td>
|
||||||
|
<td>412</td>
|
||||||
|
<td><span class="st st-ok">none</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td><span class="st st-ok">pass</span></td>
|
||||||
|
<td>example.com</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="muted">66.102.1.44 (google.com)</td>
|
||||||
|
<td>6</td>
|
||||||
|
<td><span class="st st-warn">none</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td><span class="st st-error">fail</span></td>
|
||||||
|
<td>example.com</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>this relay · 412</span><span class="st st-ok">pass</span>
|
||||||
|
<span class="meta">203.0.113.10 · SPF pass · DKIM pass</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="item">
|
||||||
|
<span>google.com · 6</span><span class="st st-error">fail</span>
|
||||||
|
<span class="meta">66.102.1.44 · SPF fail · DKIM fail</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Parsed from the aggregate XML. Forensic (<code>ruf=</code>) samples are not stored.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>DMARC reports — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-dmarc" data-page="dmarc" data-nav="dmarc" data-title="DMARC" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>DMARC reports <span class="tag future">candidate</span></h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Aggregate reports SelfPost accepted for <code>rua=</code>. Forensic (<code>ruf=</code>) is out of scope. Open a report for the parsed XML — not a dashboard.</p>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Ingest <span class="st st-ok">ok</span></h2>
|
||||||
|
<p>Last report 6 hours ago. Port 25 accepts only the configured report address.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>This week</h2>
|
||||||
|
<p>14 kept, 0 parse failures. Older summaries are pruned.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent reports</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 06:12</td>
|
||||||
|
<td><a href="dmarc-domain.html">example.com</a></td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>14 Aug</td>
|
||||||
|
<td>412</td>
|
||||||
|
<td>6</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-15 04:40</td>
|
||||||
|
<td>alerts.example.com</td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>14 Aug</td>
|
||||||
|
<td>88</td>
|
||||||
|
<td>19</td>
|
||||||
|
<td class="actions"><a href="dmarc-report-fail.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="time muted">2026-08-14 06:08</td>
|
||||||
|
<td><a href="dmarc-domain.html">example.com</a></td>
|
||||||
|
<td class="muted">google.com</td>
|
||||||
|
<td>13 Aug</td>
|
||||||
|
<td>390</td>
|
||||||
|
<td>2</td>
|
||||||
|
<td class="actions"><a href="dmarc-report.html">View</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">412 pass</span><span class="when">06:12</span>
|
||||||
|
<span class="meta">example.com · google.com · 14 Aug</span>
|
||||||
|
<span class="subj">6 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report-fail.html">
|
||||||
|
<span class="st st-error">19 fail</span><span class="when">04:40</span>
|
||||||
|
<span class="meta">alerts.example.com · google.com · 14 Aug</span>
|
||||||
|
<span class="subj">88 pass</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="dmarc-report.html">
|
||||||
|
<span class="st st-ok">390 pass</span><span class="when">14 Aug</span>
|
||||||
|
<span class="meta">example.com · google.com · 13 Aug</span>
|
||||||
|
<span class="subj">2 fail</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">Each row is one aggregate XML SelfPost parsed. Domain names open the roll-up for that sending domain.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Delete example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-domain-delete" data-page="domain-delete" data-nav="domains" data-title="Delete domain">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Delete example.com</h1>
|
||||||
|
<a class="back" href="domain.html">← Back to example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Confirm deletion</h2>
|
||||||
|
<p>You are about to delete <strong>example.com</strong>. This will:</p>
|
||||||
|
<ul>
|
||||||
|
<li>permanently delete its DKIM signing key;</li>
|
||||||
|
<li>delete <strong>all 2 bound applications</strong>, including their SASL credentials;</li>
|
||||||
|
<li>reload OpenDKIM so the domain is no longer signed.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">This cannot be undone.</p>
|
||||||
|
<div class="actions-row"><button type="button" class="danger">Delete example.com</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-domain" data-page="domain" data-nav="domains" data-title="example.com">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>example.com</h1>
|
||||||
|
<a class="back" href="domains.html">← All domains</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card credential" id="new-credential">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>New application password</h2>
|
||||||
|
<label class="help-link" for="help-password" title="Why this is shown once">?</label>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now.</p>
|
||||||
|
<label>Login</label>
|
||||||
|
<div class="code-row"><span class="code">newsletter</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<label>Password</label>
|
||||||
|
<div class="code-row"><span class="code">xK.9fQ2m-pL7wR</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="dns-status">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DNS status <span class="st st-ok">ok</span></h2>
|
||||||
|
<label class="help-link" for="help-dns" title="What these checks mean">?</label>
|
||||||
|
</div>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="pair">
|
||||||
|
<div>
|
||||||
|
<label>DKIM <span class="st st-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">mail._domainkey.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>SPF <span class="st st-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div>
|
||||||
|
<label>DMARC <span class="st st-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">_dmarc.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted">p=none; rua points at SelfPost ingest.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Report authorization</label>
|
||||||
|
<p class="muted">Not required (rua= is on a domain SelfPost accepts).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Re-check</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="dkim-spf">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DKIM and SPF records</h2>
|
||||||
|
<label class="help-link" for="help-records" title="What to publish">?</label>
|
||||||
|
</div>
|
||||||
|
<p class="check-col-title">DKIM</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">mail._domainkey.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">v=DKIM1; k=rsa; p=MIIBIjANBgkqh…</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<p class="check-col-title">SPF</p>
|
||||||
|
<div class="code-row"><span class="code">v=spf1 ip4:203.0.113.10 -all</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="dmarc-record">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DMARC record</h2>
|
||||||
|
<label class="help-link" for="help-dmarc" title="What this record does">?</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">_dmarc.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value <span class="muted">(from Settings)</span></label>
|
||||||
|
<div class="code-row"><span class="code">v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<p class="muted"><a href="dmarc-domain.html">Open DMARC reports</a> for this domain.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="connection">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Connection settings</h2>
|
||||||
|
<label class="help-link" for="help-connection" title="How to connect">?</label>
|
||||||
|
</div>
|
||||||
|
<label>Server</label>
|
||||||
|
<div class="code-row"><span class="code">mail.example.org</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<label>Port and encryption</label>
|
||||||
|
<span class="code">465 — SSL/TLS (implicit)
|
||||||
|
587 — STARTTLS (submission)</span>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="add-application">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Add an application</h2>
|
||||||
|
<label class="help-link" for="help-apps" title="What an application is">?</label>
|
||||||
|
</div>
|
||||||
|
<form action="#" onsubmit="return false">
|
||||||
|
<label>Login</label>
|
||||||
|
<input placeholder="prod-server" autocomplete="off">
|
||||||
|
<label>Address mode</label>
|
||||||
|
<select><option>Any address of the domain</option><option>Specific addresses (list)</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Create application</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="applications">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Applications</h2>
|
||||||
|
<label class="help-link" for="help-apps" title="Address mode and rate limits">?</label>
|
||||||
|
</div>
|
||||||
|
<ul class="apps">
|
||||||
|
<li class="app-item">
|
||||||
|
<p class="app-login">newsletter</p>
|
||||||
|
<p class="app-addr muted">Any address of the domain — *@example.com</p>
|
||||||
|
<div class="actions">
|
||||||
|
<input class="panel-toggle t-edit" id="edit-1" type="checkbox" checked>
|
||||||
|
<label class="toggle for-edit" for="edit-1">Edit <span class="st st-ok">limit</span></label>
|
||||||
|
<button type="button">New password</button>
|
||||||
|
<button type="button" class="danger">Delete</button>
|
||||||
|
<div class="panel panel-edit">
|
||||||
|
<div class="pair">
|
||||||
|
<div>
|
||||||
|
<p class="check-col-title">Address mode</p>
|
||||||
|
<select><option selected>Any address of the domain</option><option>Specific addresses (list)</option></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="check-col-title">Trusted-IP override <span class="st st-ok">active</span></p>
|
||||||
|
<textarea rows="1">203.0.113.40</textarea>
|
||||||
|
<div class="field-row equal">
|
||||||
|
<div class="field">
|
||||||
|
<label>Message limit</label>
|
||||||
|
<input value="80">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Window (seconds)</label>
|
||||||
|
<input value="60">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="app-item">
|
||||||
|
<p class="app-login">billing</p>
|
||||||
|
<p class="app-addr muted">Fixed list — invoices@example.com</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button">Edit</button>
|
||||||
|
<button type="button">New password</button>
|
||||||
|
<button type="button" class="danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="domain-settings-dmarc">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DMARC reports</h2>
|
||||||
|
<label class="help-link" for="help-domain-settings" title="Reports and rate limits">?</label>
|
||||||
|
</div>
|
||||||
|
<label>Aggregate reports (rua=)</label>
|
||||||
|
<select><option selected>Same as Settings (dmarc@mail.example.org)</option><option>No aggregate reports</option><option>Custom address</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Save DMARC report settings</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="domain-settings-limit">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Level-2 rate limit <span class="st st-ok">active</span></h2>
|
||||||
|
</div>
|
||||||
|
<div class="field-row equal">
|
||||||
|
<div class="field">
|
||||||
|
<label>Message limit</label>
|
||||||
|
<input value="40">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Window (seconds)</label>
|
||||||
|
<input value="60">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Save limit</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="export-domain">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Export domain</h2>
|
||||||
|
<label class="help-link" for="help-export" title="What the file contains">?</label>
|
||||||
|
</div>
|
||||||
|
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt as <code>.spde</code>.</p>
|
||||||
|
<div class="encrypt">
|
||||||
|
<label class="check"><input type="checkbox"> <span>Encrypt with a password</span></label>
|
||||||
|
<div class="encrypt-fields">
|
||||||
|
<label>Password</label><input type="password">
|
||||||
|
<label>Repeat password</label><input type="password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row"><button type="button">Export domain</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card g-only">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="domain-delete.html">Delete domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Domains — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-domains" data-page="domains" data-nav="domains" data-title="Domains">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head"><h1>Domains</h1></div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Domains</h2>
|
||||||
|
<form class="measure g-only" action="#" onsubmit="return false;">
|
||||||
|
<label for="dname">Add a sending domain</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="dname" placeholder="example.com" autocomplete="off">
|
||||||
|
<button type="button">Add domain</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain.html">example.com</a></td>
|
||||||
|
<td><a class="st st-ok" href="domain.html">ok</a></td>
|
||||||
|
<td class="muted">mail</td>
|
||||||
|
<td>2</td>
|
||||||
|
<td class="actions g-only"><a class="danger" href="domain-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain.html">alerts.example.com</a></td>
|
||||||
|
<td><a class="st st-warn" href="domain.html">warn</a></td>
|
||||||
|
<td class="muted">mail</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td class="actions g-only"><a class="danger" href="domain-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="domain.html"><span>example.com</span><span class="st st-ok">ok</span><span class="meta">2 apps · selector mail</span></a></li>
|
||||||
|
<li><a href="domain.html"><span>alerts.example.com</span><span class="st st-warn">warn</span><span class="meta">1 app · selector mail</span></a></li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">The DNS badge is the worst of DKIM, SPF and DMARC. Open a domain for details.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Help — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-help" data-page="help" data-nav="help" data-title="Help">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Help <span class="tag future">candidate</span></h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Short operator notes inside the panel — not a second copy of the full guide. Seeded from the Status explanations that do not belong on the cards (what a kernel counter is, why PTR is set at the host, what Reload does not touch). The cards themselves keep their readings, Detail columns, and the Configuration control.</p>
|
||||||
|
<div class="measure stack">
|
||||||
|
<div class="card">
|
||||||
|
<h2>On this panel</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="help.html">Machine</a> — kernel counters and the rate window</li>
|
||||||
|
<li><a href="help.html">TLS certificate</a> — port 465, reverse-proxy mount</li>
|
||||||
|
<li><a href="help.html">Hostname / reverse DNS</a> — forward-confirmed PTR at the hosting provider</li>
|
||||||
|
<li><a href="help.html">Mail queue retries</a> — time-based, no attempt budget</li>
|
||||||
|
<li><a href="help.html">Inbound</a> — not mailboxes; listed recipients or any address at the domain</li>
|
||||||
|
<li>Domain page — DNS, records, connection, applications, export (drawer from each card’s «?»)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="muted">The same texts open in the drawer from Status’s «?» — so a card can stay a reading, not a paragraph, without throwing the reading away.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Machine</h2>
|
||||||
|
<p>CPU and memory are the container’s own readings, not the host’s spare capacity. Network is a short window, not a daily total. High CPU with an empty queue usually means something else on the box — not SelfPost “being slow to send”.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>TLS certificate</h2>
|
||||||
|
<p>Port 465 presents the certificate the reverse proxy (or the image) mounted. The panel does not issue certificates. A warn here is “expires soon”; an error is “missing or unreadable”, and clients will refuse submission.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Hostname / reverse DNS</h2>
|
||||||
|
<p>Forward-confirmed reverse DNS: the A/AAAA for <code>SELFPOST_HOSTNAME</code> must reverse to that same name. PTR is set at the hosting provider, not in this panel. Receiving networks use this pair as a cheap reputation check.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>backup.example.net — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound-backup" data-page="inbound-backup" data-nav="inbound" data-title="backup.example.net" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>backup.example.net</h1>
|
||||||
|
<a class="back" href="inbound.html">← All inbound domains</a>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>DNS status <span class="st st-error">error</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-error">error</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">backup.example.net</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">10 mail.primary.example.net.</span>
|
||||||
|
<p class="error">No MX points at mail.example.org (this server). Publish the record below, or wait for DNS to propagate and Re-check.</p>
|
||||||
|
<div class="actions-row"><button type="button">Re-check</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Upstream</h2>
|
||||||
|
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
|
||||||
|
<label>Host</label><input value="192.0.2.20">
|
||||||
|
<label>Port</label><input value="25">
|
||||||
|
<label>TLS to upstream</label>
|
||||||
|
<select><option>Opportunistic</option><option>Required</option><option selected>Off</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Save upstream</button></div>
|
||||||
|
</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-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">backup.example.net</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">20 mail.example.org.</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card rcpt-mode">
|
||||||
|
<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>
|
||||||
|
<label>Who to accept</label>
|
||||||
|
<select>
|
||||||
|
<option value="list">Listed addresses only</option>
|
||||||
|
<option value="any" selected>Any recipient at this domain</option>
|
||||||
|
</select>
|
||||||
|
<div class="rcpt-list">
|
||||||
|
<label>Addresses (one per line or comma-separated)</label>
|
||||||
|
<textarea rows="6">postmaster@backup.example.net
|
||||||
|
abuse@backup.example.net</textarea>
|
||||||
|
<p class="muted">Unknown recipients are rejected at RCPT so this relay does not generate backscatter.</p>
|
||||||
|
</div>
|
||||||
|
<p class="rcpt-any muted">Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.</p>
|
||||||
|
<div class="actions-row"><button type="button">Save recipients</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
<p class="muted">Stops accepting mail for this domain. Does not touch outbound sending domains.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="inbound-delete.html">Delete inbound domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Delete lists.example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound-delete" data-page="inbound-delete" data-nav="inbound" data-title="Delete inbound" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Delete lists.example.com</h1>
|
||||||
|
<a class="back" href="inbound-domain.html">← Back to lists.example.com</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Confirm deletion</h2>
|
||||||
|
<p>You are about to stop accepting inbound mail for <strong>lists.example.com</strong>. This will:</p>
|
||||||
|
<ul>
|
||||||
|
<li>remove it from <code>relay_domains</code> and the recipient map;</li>
|
||||||
|
<li>stop forwarding to <strong>10.0.0.8:25</strong>;</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>
|
||||||
|
<div class="actions-row"><button type="button" class="danger">Delete lists.example.com</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>lists.example.com — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound-domain" data-page="inbound-domain" data-nav="inbound" data-title="lists.example.com" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>lists.example.com</h1>
|
||||||
|
<a class="back" href="inbound.html">← All inbound domains</a>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>DNS status <span class="st st-ok">ok</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-ok">ok</span></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">lists.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<span class="code">10 mail.example.org.
|
||||||
|
20 mail.primary.example.net.</span>
|
||||||
|
<p class="muted">An MX points at mail.example.org (this server). Other MX values are the domain’s own primaries — they are not an error.</p>
|
||||||
|
<div class="actions-row"><button type="button">Re-check</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Upstream</h2>
|
||||||
|
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
|
||||||
|
<label>Host</label><input value="10.0.0.8">
|
||||||
|
<label>Port</label><input value="25">
|
||||||
|
<label>TLS to upstream</label>
|
||||||
|
<select><option>Opportunistic</option><option selected>Required</option><option>Off</option></select>
|
||||||
|
<div class="actions-row"><button type="button">Save upstream</button></div>
|
||||||
|
</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-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">lists.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">10 mail.example.org.</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card rcpt-mode">
|
||||||
|
<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>
|
||||||
|
<label>Who to accept</label>
|
||||||
|
<select>
|
||||||
|
<option value="list" selected>Listed addresses only</option>
|
||||||
|
<option value="any">Any recipient at this domain</option>
|
||||||
|
</select>
|
||||||
|
<div class="rcpt-list">
|
||||||
|
<label>Addresses (one per line or comma-separated)</label>
|
||||||
|
<textarea rows="6">staff@lists.example.com
|
||||||
|
postmaster@lists.example.com
|
||||||
|
abuse@lists.example.com</textarea>
|
||||||
|
<p class="muted">Unknown recipients are rejected at RCPT so this relay does not generate backscatter.</p>
|
||||||
|
</div>
|
||||||
|
<p class="rcpt-any muted">Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.</p>
|
||||||
|
<div class="actions-row"><button type="button">Save recipients</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
<p class="muted">Stops accepting mail for this domain. Does not touch outbound sending domains.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="inbound-delete.html">Delete inbound domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Inbound — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-inbound" data-page="inbound" data-nav="inbound" data-title="Inbound" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Inbound <span class="tag">1.x</span></h1>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Forwarding</h2>
|
||||||
|
<form class="measure" action="#" onsubmit="return false">
|
||||||
|
<label for="iname">Add inbound domain</label>
|
||||||
|
<div class="input-row">
|
||||||
|
<input id="iname" placeholder="lists.example.com">
|
||||||
|
<button type="button">Add domain</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a href="inbound-domain.html">lists.example.com</a></td>
|
||||||
|
<td><a class="st st-ok" href="inbound-domain.html">ok</a></td>
|
||||||
|
<td class="muted">10.0.0.8:25</td>
|
||||||
|
<td>12 listed</td>
|
||||||
|
<td><span class="st st-ok">required</span></td>
|
||||||
|
<td class="actions"><a class="danger" href="inbound-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="inbound-backup.html">backup.example.net</a></td>
|
||||||
|
<td><a class="st st-error" href="inbound-backup.html">error</a></td>
|
||||||
|
<td class="muted">192.0.2.20:25</td>
|
||||||
|
<td>any</td>
|
||||||
|
<td><span class="st st-unknown">off</span></td>
|
||||||
|
<td class="actions"><a class="danger" href="inbound-delete.html">Delete</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="inbound-domain.html"><span>lists.example.com</span><span class="st st-ok">ok</span><span class="meta">12 listed · 10.0.0.8:25</span></a></li>
|
||||||
|
<li><a href="inbound-backup.html"><span>backup.example.net</span><span class="st st-error">error</span><span class="meta">any recipient · 192.0.2.20:25</span></a></li>
|
||||||
|
</ul>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SelfPost — макеты панели</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
</head>
|
||||||
|
<body class="doc">
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">макеты · не панель</p>
|
||||||
|
<h1>Self<b>Post</b> — полное обновление интерфейса</h1>
|
||||||
|
<p>Проект системы — как собирать экраны, чтобы не чинить пустые колонки, высоту полей и ряды кнопок по одному. Каждый экран — отдельная страница, не простыня с якорями. Знак и палитра из листа утверждения не меняются. HTML в <code>docs/assets/panel-ui/</code>, не вёрстка <code>internal/web</code>.</p>
|
||||||
|
<div class="cta-row">
|
||||||
|
<a class="cta" href="status.html">Открыть Status</a>
|
||||||
|
<a class="cta quiet" href="system.html">Система интерфейса</a>
|
||||||
|
<a class="cta quiet" href="status.html?view=phone">Аварийный телефон</a>
|
||||||
|
<a class="cta quiet" href="status.html?theme=dark">Status, тёмная тема</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Система, не рескин</h2>
|
||||||
|
<p>Первый click-through повторил сетку живой панели, поэтому те же сбои чинились по одному. <a href="system.html">Грамматика</a> задаёт регионы (<code>measure</code> / <code>pair</code> / <code>fill</code>), карточку со слотом справки, <code>field-row</code> (Host и Type одной высоты) и <code>actions-row</code>. Макеты собраны только из этого: один экран — один HTML-файл.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Ширина окна</h2>
|
||||||
|
<p>Сейчас оболочка центрируется, колонка контента упирается в <code>64rem</code>, формы ещё уже — <code>48rem</code>. На широком мониторе поля пустые. Растянуть поля на 100% окна нельзя — они становятся нечитаемыми. Две независимые карточки рядом — можно.</p>
|
||||||
|
<h3>Гибрид</h3>
|
||||||
|
<p>Навигация прижата влево. Ops-страницы (Status, Deliveries, очередь, лог, Backup, Settings, DMARC, inbound) занимают остаток до <strong>90rem (~1440px)</strong>. Backup — полный бэкап ‖ импорт. Settings — учётные данные ‖ DMARC (у domain-admin остаётся одна узкая карточка). Таблица Users — <code>fill</code>. Одиночные формы (подтверждения, форма пользователя) остаются <strong>42rem и выровнены влево</strong>. Login и setup — по-прежнему узкий центрированный блок.</p>
|
||||||
|
<div class="compare">
|
||||||
|
<div>
|
||||||
|
<div class="row">сейчас, окно ~1600px</div>
|
||||||
|
<div class="bar">
|
||||||
|
<i class="navc">nav</i>
|
||||||
|
<i class="col" style="width:42%">64rem</i>
|
||||||
|
<i class="empty">пусто</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="row">макет, ops-страница</div>
|
||||||
|
<div class="bar">
|
||||||
|
<i class="navc">nav</i>
|
||||||
|
<i class="fill">контент до 90rem</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="row">макет, форма</div>
|
||||||
|
<div class="bar">
|
||||||
|
<i class="navc">nav</i>
|
||||||
|
<i class="col" style="width:28%">42rem</i>
|
||||||
|
<i class="empty">поле не растягивает инпуты</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="note">На телефоне это не «узкий десктоп». Таблицы из шести колонок складываются в список. Навигация — выезжающая колонка на CSS-checkbox, без обязательного JavaScript (как панели Edit у приложений уже сейчас).</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Информационная архитектура</h2>
|
||||||
|
<div class="ia">
|
||||||
|
<div>
|
||||||
|
<h3>Global administrator</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Status</li>
|
||||||
|
<li>Domains → domain → delete</li>
|
||||||
|
<li>Deliveries → message</li>
|
||||||
|
<li>Mail queue</li>
|
||||||
|
<li>System log</li>
|
||||||
|
<li>Inbound <span class="tag">1.x</span> — скрыт, пока <code>INBOUND_RELAY_ENABLE</code> выключен</li>
|
||||||
|
<li>DMARC reports <span class="tag future">candidate</span></li>
|
||||||
|
<li>Backup, Users, Settings</li>
|
||||||
|
<li>Help <span class="tag future">candidate</span> — пункт навбара и drawer с карточек Status и страницы домена</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Domain-admin</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Domains (только назначенные) → domain → DMARC reports этого домена</li>
|
||||||
|
<li>Deliveries → message, включая пояснение retry</li>
|
||||||
|
<li>Settings — только учётные данные панели</li>
|
||||||
|
<li>Help drawer</li>
|
||||||
|
<li>Нет Status, очереди, system log, Backup, Users, inbound, глобального DMARC hub</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-top:1rem">В прототипе роль, inbound-флаг, ширина и тема переключаются в шапке. <code>contributing</code> и <code>schema-squash</code> экранов не имеют.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Знак и новые иконки</h2>
|
||||||
|
<p>Утверждённый stamp не заменяется. Для узкой шапки телефона предлагается уже существующий SP-значок (фавикон), а не компактный wordmark: на 390px высота важнее, чем прочитать «SelfPost» второй раз.</p>
|
||||||
|
<div class="mark-row">
|
||||||
|
<figure>
|
||||||
|
<img src="../selfpost-stamp.svg" width="220" height="100" alt="Полный знак">
|
||||||
|
<figcaption>эталон · login / setup</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<img src="../selfpost-stamp-compact.svg" width="160" height="73" alt="Компактный знак">
|
||||||
|
<figcaption>колонка навбара</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<img src="../selfpost-icon.svg" width="64" height="64" alt="SP">
|
||||||
|
<figcaption>телефон · шапка drawer</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
<h3>Иконки новых пунктов</h3>
|
||||||
|
<p class="muted">Тот же штрих 1.5, что у Status / Domains. Не пиктограммы-эмодзи и не заливка.</p>
|
||||||
|
<div class="icons-row">
|
||||||
|
<div class="icon-card">
|
||||||
|
<svg 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>
|
||||||
|
<span>Inbound</span>
|
||||||
|
</div>
|
||||||
|
<div class="icon-card">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></svg>
|
||||||
|
<span>DMARC</span>
|
||||||
|
</div>
|
||||||
|
<div class="icon-card">
|
||||||
|
<svg 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="M8 7.2V11.4"/><path d="M8 5.05v.01"/></svg>
|
||||||
|
<span>Help</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Экраны</h2>
|
||||||
|
<h3>Система</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="system.html">Грамматика и живые образцы</a>
|
||||||
|
</div>
|
||||||
|
<h3>Вход</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="login.html">Sign in</a>
|
||||||
|
<a href="setup.html">Create administrator</a>
|
||||||
|
</div>
|
||||||
|
<h3>Текущая панель</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="status.html">Status</a>
|
||||||
|
<a href="domains.html">Domains</a>
|
||||||
|
<a href="domain.html">Domain detail</a>
|
||||||
|
<a href="domain-delete.html">Delete domain</a>
|
||||||
|
<a href="deliveries.html">Deliveries</a>
|
||||||
|
<a href="delivery.html">Delivery (deferred + retries)</a>
|
||||||
|
<a href="mail-queue.html">Mail queue + retry policy</a>
|
||||||
|
<a href="system-log.html">System log</a>
|
||||||
|
<a href="backup.html">Backup</a>
|
||||||
|
<a href="users.html">Users</a>
|
||||||
|
<a href="user-form.html">Edit user</a>
|
||||||
|
<a href="user-delete.html">Delete user</a>
|
||||||
|
<a href="settings.html">Settings</a>
|
||||||
|
</div>
|
||||||
|
<h3>Roadmap</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="mail-queue.html">queue-retries — карточка на Mail queue</a>
|
||||||
|
<a href="delivery.html">queue-retries — history deferred</a>
|
||||||
|
<a href="inbound.html">Inbound list (колонка DNS = MX)</a>
|
||||||
|
<a href="inbound-domain.html">Inbound domain — DNS ok</a>
|
||||||
|
<a href="inbound-backup.html">Inbound domain — DNS error</a>
|
||||||
|
<a href="inbound-delete.html">Delete inbound domain</a>
|
||||||
|
<a href="status.html?inbound=0">Inbound выключен (пункта нет)</a>
|
||||||
|
<a href="dmarc.html">DMARC reports hub <span class="tag future">candidate</span></a>
|
||||||
|
<a href="dmarc-domain.html">DMARC — domain roll-up</a>
|
||||||
|
<a href="dmarc-report.html">DMARC report — aligned</a>
|
||||||
|
<a href="dmarc-report-fail.html">DMARC report — third-party fail</a>
|
||||||
|
<a href="help.html">Help <span class="tag future">candidate</span></a>
|
||||||
|
</div>
|
||||||
|
<h3>Аварийный телефон</h3>
|
||||||
|
<div class="screen-index">
|
||||||
|
<a href="status.html?view=phone">Status 390px — Overall, then queue/cert, then the rest</a>
|
||||||
|
<a href="mail-queue.html?view=phone">Mail queue 390px</a>
|
||||||
|
<a href="deliveries.html?view=phone">Deliveries 390px — список, не таблица</a>
|
||||||
|
<a href="delivery.html?view=phone">Delivery 390px</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="border-bottom:none">
|
||||||
|
<h2>Как смотреть</h2>
|
||||||
|
<p>Откройте любой экран из списка — это отдельная страница, не якорь в одном файле. Шапка прототипа — не часть панели: роль, Desktop/Phone, inbound, светлая/тёмная. Копирайт экранов английский, как в продукте. Drawer Help открывается с «?» на Status или с карточек домена; на карточках остаются показания и колонка Detail.</p>
|
||||||
|
<p class="muted">После утверждения макетов вёрстка <code>panel.css</code> и шаблонов — отдельная задача. CSP и progressive enhancement в этом HTML не воспроизводятся один в один: здесь допустимы вещи, которые в панели останутся в файле стилей.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-login" data-page="login" data-nav="login" data-title="Sign in" data-auth="1">
|
||||||
|
<main class="auth">
|
||||||
|
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<div class="card">
|
||||||
|
<form action="status.html">
|
||||||
|
<label for="u">Username</label>
|
||||||
|
<input id="u" name="username" autocomplete="username">
|
||||||
|
<label for="p">Password</label>
|
||||||
|
<input id="p" name="password" type="password" autocomplete="current-password">
|
||||||
|
<div class="actions-row"><button type="submit">Sign in</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="version" style="text-align:center">© Mixeme · <a href="#">License (AGPL-3.0)</a></p>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Mail queue — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-mail-queue" data-page="mail-queue" data-nav="queue" data-title="Mail queue" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Mail queue</h1>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>How delivery retries work</h2>
|
||||||
|
<p class="muted">This Postfix’s policy, read once at panel start. There is no maximum attempt count — only time.</p>
|
||||||
|
<div class="facts retry-facts">
|
||||||
|
<div class="fact"><span class="fact-label">First retry</span><span class="fact-value">5 minutes</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Later retries</span><span class="fact-value">doubling, cap 1 h 7 min</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Kept in queue</span><span class="fact-value">5 days</span></div>
|
||||||
|
<div class="fact"><span class="fact-label">Then</span><span class="fact-value">bounced</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Pending messages</h2>
|
||||||
|
<table class="desk-only">
|
||||||
|
<thead><tr><th>Queue id</th><th>Age</th><th>From</th><th>To</th><th>Size</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="mono">4C3A1E2F1A</td><td class="time muted">18 min</td><td>billing@example.com</td><td>ada@example.net</td><td>12 KiB</td></tr>
|
||||||
|
<tr><td class="mono">4C3A1E3010</td><td class="time muted">11 min</td><td>news@example.com</td><td>pat@slow.example</td><td>48 KiB</td></tr>
|
||||||
|
<tr><td class="mono">4C3A1E3102</td><td class="time muted">4 min</td><td>billing@example.com</td><td>ada@example.net</td><td>9 KiB</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<ul class="phone-list phone-only">
|
||||||
|
<li><a href="delivery.html"><span>4C3A1E2F1A</span><span class="when">18 min</span><span class="meta">billing@example.com → ada@example.net</span></a></li>
|
||||||
|
<li><a href="delivery.html"><span>4C3A1E3010</span><span class="when">11 min</span><span class="meta">news@example.com → pat@slow.example</span></a></li>
|
||||||
|
<li><a href="delivery.html"><span>4C3A1E3102</span><span class="when">4 min</span><span class="meta">billing@example.com → ada@example.net</span></a></li>
|
||||||
|
</ul>
|
||||||
|
<label>postqueue -p</label>
|
||||||
|
<span class="code">-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
|
||||||
|
4C3A1E2F1A* 12288 Sat Aug 15 20:14:02 billing@example.com
|
||||||
|
ada@example.net
|
||||||
|
4C3A1E3010 49152 Sat Aug 15 20:21:18 news@example.com
|
||||||
|
pat@slow.example
|
||||||
|
-- 3 Kbytes in 3 Requests.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
/* SelfPost panel UI mockups. Tokens match internal/web/view/static/panel.css.
|
||||||
|
This file is a design artifact, not the panel stylesheet. */
|
||||||
|
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@200;300;400;500;600;700&display=swap");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--font-sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
--bg: #f4f2ed; --fg: #12161c;
|
||||||
|
--muted: #6b7684;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--border: #dedcd7;
|
||||||
|
--control-border: #cbc8c1;
|
||||||
|
--input-bg: #fff;
|
||||||
|
--code-bg: #efede9;
|
||||||
|
--surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8;
|
||||||
|
--nav-active-bg: #ede4de;
|
||||||
|
--accent-fill: #7a3b2e; --accent-fill-hover: #632f25; --accent-text: #7a3b2e;
|
||||||
|
--on-accent: #fff;
|
||||||
|
--flash-bg: #edf6ef; --flash-border: #c2e0cc; --flash-fg: #2c6b43;
|
||||||
|
--credential-bg: #fcf6e4; --credential-border: #e0c874;
|
||||||
|
--danger-fill: #b42318; --danger-fill-hover: #912018;
|
||||||
|
--danger-bg: #fbedea; --danger-border: #efccc4; --danger-fg: #b42318; --danger-bg-hover: #f7dfda;
|
||||||
|
--st-ok-bg: #edf6ef; --st-ok-fg: #2c6b43; --st-ok-border: #c2e0cc;
|
||||||
|
--st-warn-bg: #fbf2e2; --st-warn-fg: #8a5510; --st-warn-border: #ebd5a6;
|
||||||
|
--st-error-bg: #fbedea; --st-error-fg: #b42318; --st-error-border: #efccc4;
|
||||||
|
--st-unknown-bg: #efede9; --st-unknown-fg: #6b7684; --st-unknown-border: #dedcd7;
|
||||||
|
--nav-w: 14rem;
|
||||||
|
--ops-max: 90rem;
|
||||||
|
--form-max: 42rem;
|
||||||
|
--auth-max: 24rem;
|
||||||
|
--gallery-h: 3.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-tokens {
|
||||||
|
--bg: #16181b; --fg: #e9e6e0;
|
||||||
|
--muted: #9aa1a9;
|
||||||
|
--card-bg: #1d2024;
|
||||||
|
--border: #2c2f34;
|
||||||
|
--control-border: #3a3e44;
|
||||||
|
--input-bg: #14161a;
|
||||||
|
--code-bg: #14161a;
|
||||||
|
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
|
||||||
|
--nav-active-bg: #2a1f1b;
|
||||||
|
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
|
||||||
|
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
|
||||||
|
--credential-bg: #26210d; --credential-border: #5e5013;
|
||||||
|
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
|
||||||
|
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
|
||||||
|
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
|
||||||
|
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
|
||||||
|
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
|
||||||
|
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
html:not(.force-light) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scrollbar-gutter: stable; }
|
||||||
|
body {
|
||||||
|
font: 400 15px/1.5 var(--font-sans);
|
||||||
|
margin: 0; background: var(--bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
|
|
||||||
|
/* —— Gallery chrome (index + prototype toolbar) —— */
|
||||||
|
.gallery {
|
||||||
|
position: sticky; top: 0; z-index: 40;
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 0.55rem 1rem;
|
||||||
|
min-height: var(--gallery-h);
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.gallery a { color: var(--accent-text); }
|
||||||
|
.gallery .brand-mini {
|
||||||
|
font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600;
|
||||||
|
letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.gallery .seg {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 0.2rem 0.35rem;
|
||||||
|
}
|
||||||
|
.gallery .seg > span { color: var(--muted); margin-right: 0.15rem; }
|
||||||
|
.gallery .seg label {
|
||||||
|
margin: 0; font-weight: 500; cursor: pointer;
|
||||||
|
padding: 0.2rem 0.55rem; border: 1px solid var(--control-border);
|
||||||
|
border-radius: 5px; background: var(--card-bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
#role-global:checked ~ .gallery label[for="role-global"],
|
||||||
|
#role-domain:checked ~ .gallery label[for="role-domain"],
|
||||||
|
#vp-desktop:checked ~ .gallery label[for="vp-desktop"],
|
||||||
|
#vp-phone:checked ~ .gallery label[for="vp-phone"],
|
||||||
|
#theme-light:checked ~ .gallery label[for="theme-light"],
|
||||||
|
#theme-dark:checked ~ .gallery label[for="theme-dark"] {
|
||||||
|
background: var(--nav-active-bg); color: var(--accent-text);
|
||||||
|
border-color: var(--accent-fill); font-weight: 600;
|
||||||
|
}
|
||||||
|
.gallery .check-lab {
|
||||||
|
display: flex; align-items: center; gap: 0.35rem; margin: 0; font-weight: 500; cursor: pointer;
|
||||||
|
}
|
||||||
|
.gallery .check-lab input { width: auto; margin: 0; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block; font-family: var(--font-mono); font-size: 0.68rem;
|
||||||
|
font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase;
|
||||||
|
padding: 0.12rem 0.4rem; border-radius: 4px;
|
||||||
|
background: var(--surface-bg); color: var(--muted); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tag.future { color: var(--st-warn-fg); border-color: var(--st-warn-border); background: var(--st-warn-bg); }
|
||||||
|
|
||||||
|
body:has(#theme-dark:checked) { background: #16181b; color: #e9e6e0; }
|
||||||
|
|
||||||
|
#theme-dark:checked ~ .gallery,
|
||||||
|
#theme-dark:checked ~ .app,
|
||||||
|
#theme-dark:checked ~ .help-drawer {
|
||||||
|
--bg: #16181b; --fg: #e9e6e0;
|
||||||
|
--muted: #9aa1a9;
|
||||||
|
--card-bg: #1d2024;
|
||||||
|
--border: #2c2f34;
|
||||||
|
--control-border: #3a3e44;
|
||||||
|
--input-bg: #14161a;
|
||||||
|
--code-bg: #14161a;
|
||||||
|
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
|
||||||
|
--nav-active-bg: #2a1f1b;
|
||||||
|
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
|
||||||
|
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
|
||||||
|
--credential-bg: #26210d; --credential-border: #5e5013;
|
||||||
|
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
|
||||||
|
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
|
||||||
|
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
|
||||||
|
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
|
||||||
|
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
|
||||||
|
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
|
||||||
|
background: var(--bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— Prototype shell: nav docked left, content uses remaining width —— */
|
||||||
|
.app {
|
||||||
|
display: flex; align-items: flex-start; min-height: calc(100vh - var(--gallery-h));
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.stage { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.nav {
|
||||||
|
position: sticky; top: var(--gallery-h); align-self: stretch;
|
||||||
|
flex: none; width: var(--nav-w);
|
||||||
|
display: flex; flex-direction: column; gap: 0.75rem;
|
||||||
|
padding: 1.1rem 0.85rem 1.4rem;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
max-height: calc(100vh - var(--gallery-h)); overflow-y: auto;
|
||||||
|
}
|
||||||
|
.nav .links, .nav .session { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
|
.nav .session { margin-top: auto; padding-top: 0.75rem; border-top: 1px solid var(--border); }
|
||||||
|
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
|
||||||
|
.nav .session-user { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.nav .brand { padding: 0; display: block; }
|
||||||
|
.nav .brand img { display: block; width: 100%; height: auto; }
|
||||||
|
.nav a, .nav [aria-current], .nav .current {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.6rem; border-radius: 5px; text-decoration: none;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.nav a:hover { background: var(--surface-bg); }
|
||||||
|
.nav [aria-current], .nav .current {
|
||||||
|
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
|
||||||
|
box-shadow: inset 2px 0 0 var(--accent-fill);
|
||||||
|
}
|
||||||
|
body.page-status .n-status,
|
||||||
|
body.page-domains .n-domains,
|
||||||
|
body.page-domain .n-domains,
|
||||||
|
body.page-domain-delete .n-domains,
|
||||||
|
body.page-deliveries .n-deliveries,
|
||||||
|
body.page-delivery .n-deliveries,
|
||||||
|
body.page-mail-queue .n-queue,
|
||||||
|
body.page-system-log .n-log,
|
||||||
|
body.page-inbound .n-inbound,
|
||||||
|
body.page-inbound-domain .n-inbound,
|
||||||
|
body.page-inbound-backup .n-inbound,
|
||||||
|
body.page-inbound-delete .n-inbound,
|
||||||
|
body.page-dmarc .n-dmarc,
|
||||||
|
body.page-dmarc-domain .n-dmarc,
|
||||||
|
body.page-dmarc-report .n-dmarc,
|
||||||
|
body.page-dmarc-report-fail .n-dmarc,
|
||||||
|
body.page-backup .n-backup,
|
||||||
|
body.page-users .n-users,
|
||||||
|
body.page-user-form .n-users,
|
||||||
|
body.page-user-delete .n-users,
|
||||||
|
body.page-help .n-help,
|
||||||
|
body.page-settings .n-settings {
|
||||||
|
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
|
||||||
|
box-shadow: inset 2px 0 0 var(--accent-fill);
|
||||||
|
}
|
||||||
|
.nav .icon { width: 1rem; height: 1rem; flex: none; }
|
||||||
|
.nav button, .nav .btn-ghost {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
margin: 0; padding: 0.35rem 0.6rem; font: inherit; font-size: 0.95rem; font-weight: 400;
|
||||||
|
color: var(--danger-fg); background: var(--danger-bg); border: 1px solid var(--danger-border);
|
||||||
|
border-radius: 5px; cursor: pointer; text-decoration: none; width: 100%;
|
||||||
|
}
|
||||||
|
.nav button:hover, .nav .btn-ghost:hover { background: var(--danger-bg-hover); }
|
||||||
|
|
||||||
|
.phone-bar { display: none; }
|
||||||
|
.phone-bar .icon { width: 1.1rem; height: 1.1rem; }
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||||
|
|
||||||
|
#feat-inbound:checked ~ .gallery label[for="feat-inbound"] {
|
||||||
|
background: var(--nav-active-bg); color: var(--accent-text);
|
||||||
|
border-color: var(--accent-fill);
|
||||||
|
}
|
||||||
|
.gallery label[for="feat-inbound"] {
|
||||||
|
margin: 0; font-weight: 500; cursor: pointer;
|
||||||
|
padding: 0.2rem 0.55rem; border: 1px solid var(--control-border);
|
||||||
|
border-radius: 5px; background: var(--card-bg); color: var(--fg);
|
||||||
|
}
|
||||||
|
#role-domain:checked ~ .gallery .g-only { display: none; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1 1 auto; min-width: 0; width: 100%;
|
||||||
|
padding: 1.5rem 1.5rem 2.5rem;
|
||||||
|
}
|
||||||
|
main.auth {
|
||||||
|
max-width: none; display: flex; flex-direction: column; align-items: center;
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
main.auth > * { width: 100%; max-width: var(--auth-max); margin-left: auto; margin-right: auto; }
|
||||||
|
main.stack > .page-head,
|
||||||
|
main.stack > h1 { margin-bottom: 0; }
|
||||||
|
main.stack > .back { margin: 0; }
|
||||||
|
main.stack > .route { margin-top: 0; margin-bottom: 0; }
|
||||||
|
main.stack > p.muted { margin-top: 0; margin-bottom: 0; }
|
||||||
|
main.stack > .toolbar { margin-bottom: 0; max-width: var(--ops-max); }
|
||||||
|
|
||||||
|
body.page-login .nav,
|
||||||
|
body.page-setup .nav,
|
||||||
|
body.page-login .phone-bar,
|
||||||
|
body.page-setup .phone-bar { display: none !important; }
|
||||||
|
body.page-login .app,
|
||||||
|
body.page-setup .app { display: block; }
|
||||||
|
|
||||||
|
#role-domain:checked ~ .app .g-only { display: none !important; }
|
||||||
|
#role-domain:checked ~ .app .pair:has(> .g-only) {
|
||||||
|
display: block;
|
||||||
|
max-width: var(--form-max);
|
||||||
|
}
|
||||||
|
#feat-inbound:not(:checked) ~ .app .in-only { display: none !important; }
|
||||||
|
#feat-inbound:checked ~ .app .in-off { display: none !important; }
|
||||||
|
|
||||||
|
h1 { font-size: 1.5rem; font-weight: 300; letter-spacing: -0.01em; margin: 0 0 1rem; }
|
||||||
|
h1.subject { overflow-wrap: anywhere; }
|
||||||
|
h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.4rem; }
|
||||||
|
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 1.25rem 1.4rem; margin: 0;
|
||||||
|
}
|
||||||
|
.card.narrow { max-width: var(--auth-max); }
|
||||||
|
.card + .card { margin-top: 1rem; }
|
||||||
|
.card.credential { border-color: var(--credential-border); background: var(--credential-bg); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; }
|
||||||
|
a { color: var(--accent-text); }
|
||||||
|
.back { display: block; margin: -0.4rem 0 1rem; }
|
||||||
|
.flash {
|
||||||
|
background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg);
|
||||||
|
padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.flash.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); }
|
||||||
|
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: var(--muted); }
|
||||||
|
.version a { color: inherit; }
|
||||||
|
|
||||||
|
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
|
||||||
|
label.check {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem; margin: 0.45rem 0 0; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
label.check input { width: auto; margin: 0; padding: 0; border: 0; background: none; }
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%; padding: 0.55rem 0.7rem; font-family: inherit; font-size: 1rem;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
|
||||||
|
}
|
||||||
|
textarea { resize: vertical; }
|
||||||
|
fieldset {
|
||||||
|
margin: 0.9rem 0 0; padding: 0.55rem 0.85rem 0.85rem;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px;
|
||||||
|
}
|
||||||
|
fieldset legend { padding: 0 0.25rem; font-weight: 600; }
|
||||||
|
fieldset > .muted { margin: 0.15rem 0 0.35rem; font-size: 0.85rem; font-weight: 400; }
|
||||||
|
|
||||||
|
button, a.btn, a.danger {
|
||||||
|
display: inline-block; margin-top: 1.1rem; padding: 0.6rem 1.1rem;
|
||||||
|
font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none;
|
||||||
|
color: var(--on-accent); background: var(--accent-fill); border: 0; border-radius: 5px; cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover, a.btn:hover { background: var(--accent-fill-hover); }
|
||||||
|
button.danger, a.danger { background: var(--danger-fill); color: var(--on-accent); }
|
||||||
|
button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
|
||||||
|
form.inline { display: inline; margin: 0; }
|
||||||
|
.form-actions {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
}
|
||||||
|
.form-actions > button, .form-actions > a.btn, .form-actions > a.danger { margin-top: 0; }
|
||||||
|
|
||||||
|
.st {
|
||||||
|
display: inline-block; padding: 0.14rem 0.45rem 0.28rem; border-radius: 4px;
|
||||||
|
font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; line-height: 1;
|
||||||
|
letter-spacing: 0.02em; vertical-align: middle; border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
|
||||||
|
.st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); }
|
||||||
|
.st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); }
|
||||||
|
.st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); }
|
||||||
|
a.st, a.st:hover { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td {
|
||||||
|
text-align: left; padding: 0.5rem 0.45rem; border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
|
||||||
|
}
|
||||||
|
td.actions { text-align: right; }
|
||||||
|
.metric { white-space: nowrap; }
|
||||||
|
.card:has(table) { overflow-x: auto; }
|
||||||
|
td.subject span {
|
||||||
|
display: block; max-width: 22rem;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
display: block; white-space: pre; overflow-x: auto; overflow-wrap: normal;
|
||||||
|
word-break: normal; font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 5px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0;
|
||||||
|
}
|
||||||
|
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||||
|
.code-row .code { flex: 1; min-width: 0; margin-top: 0; padding-top: 0.45rem; padding-bottom: 0.45rem; }
|
||||||
|
.input-row { display: flex; align-items: stretch; gap: 0.5rem; }
|
||||||
|
.input-row input { flex: 1; min-width: 0; }
|
||||||
|
.input-row button { margin-top: 0; flex: none; white-space: nowrap; }
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||||
|
gap: 1rem; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
h1 + .split, h1 + form > .split { margin-top: 0; }
|
||||||
|
.split > .card { min-width: 0; }
|
||||||
|
.split > .card + .card { margin-top: 0; }
|
||||||
|
.split + .card, .card + .split, form:has(.split) + .card { margin-top: 1rem; }
|
||||||
|
#settings form { width: 100%; max-width: none; }
|
||||||
|
#settings .split,
|
||||||
|
#backup > .split {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-cols {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
||||||
|
gap: 1rem 1.2rem; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.check-col { min-width: 0; }
|
||||||
|
.check-col-title { margin: 0.85rem 0 0.35rem; font-size: 1.05rem; font-weight: 600; }
|
||||||
|
.field-pair {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||||
|
gap: 0 1rem; margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-pair > div { min-width: 0; }
|
||||||
|
.field-pair label { margin-top: 0.45rem; }
|
||||||
|
/* Host / name beside Type — size the type column to that token rather than
|
||||||
|
giving it half the row. Vertical padding matches .code-row .code so Type is
|
||||||
|
the same height as Host when Copy sits beside it. */
|
||||||
|
.field-pair.host-type {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
.field-pair.host-type .code {
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-type { width: max-content; }
|
||||||
|
.field-type .code {
|
||||||
|
width: fit-content; min-width: 2.75rem; text-align: center; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
|
||||||
|
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 6px; background: var(--surface-bg); }
|
||||||
|
.fact-label {
|
||||||
|
display: block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted);
|
||||||
|
}
|
||||||
|
.fact-value { display: block; margin-top: 0.1rem; white-space: nowrap; overflow-x: auto; }
|
||||||
|
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
list-style: none; display: flex; flex-direction: column; gap: 1.1rem;
|
||||||
|
margin: 1rem 0 0; padding: 0.2rem 0 0.2rem 1.4rem; border-left: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.event { position: relative; min-width: 0; }
|
||||||
|
.event::before {
|
||||||
|
content: ""; position: absolute; left: -1.85rem; top: 0.3rem;
|
||||||
|
width: 0.65rem; height: 0.65rem; border-radius: 50%;
|
||||||
|
background: var(--card-bg); border: 2px solid var(--control-border);
|
||||||
|
}
|
||||||
|
.event.lvl-ok::before { border-color: var(--st-ok-fg); background: var(--st-ok-bg); }
|
||||||
|
.event.lvl-warn::before { border-color: var(--st-warn-fg); background: var(--st-warn-bg); }
|
||||||
|
.event.lvl-error::before { border-color: var(--st-error-fg); background: var(--st-error-bg); }
|
||||||
|
.event.pending { opacity: 0.7; }
|
||||||
|
.event.pending::before { border-style: dashed; }
|
||||||
|
.event-time { margin: 0; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
|
||||||
|
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
|
||||||
|
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.route { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: -0.5rem; margin-bottom: 1rem; }
|
||||||
|
.route .addr { font-family: var(--font-mono); font-size: 0.9rem; white-space: nowrap; overflow-x: auto; max-width: 100%; }
|
||||||
|
.route .arrow { color: var(--muted); }
|
||||||
|
|
||||||
|
table.log { margin-top: 1rem; }
|
||||||
|
table.log th:first-child, table.log td.time { width: 1%; }
|
||||||
|
table.log td.log-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem; white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.copy, .actions button, .actions > label.toggle, .actions a.danger {
|
||||||
|
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
|
||||||
|
border-radius: 5px; white-space: nowrap;
|
||||||
|
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
|
||||||
|
}
|
||||||
|
button.copy:hover, .actions button:hover, .actions > label.toggle:hover { background: var(--surface-bg-hover); }
|
||||||
|
.actions button.danger, .actions a.danger { color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border); }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
|
||||||
|
|
||||||
|
.apps { list-style: none; margin: 1rem 0 0; padding: 0; }
|
||||||
|
.app-item { padding: 0.9rem 0; border-top: 1px solid var(--border); }
|
||||||
|
.app-item:last-child { padding-bottom: 0; }
|
||||||
|
.app-login { margin: 0; font-family: var(--font-mono); font-weight: 600; }
|
||||||
|
.app-addr { margin: 0.15rem 0 0; white-space: nowrap; overflow-x: auto; }
|
||||||
|
.app-item .actions { margin-top: 0.7rem; }
|
||||||
|
.app-item .actions > .panel-toggle {
|
||||||
|
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
|
||||||
|
}
|
||||||
|
.app-item .actions > .panel { display: none; flex: 1 0 100%; }
|
||||||
|
.app-item .actions > .t-edit:checked ~ .panel-edit { display: block; }
|
||||||
|
.app-item .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
|
||||||
|
.app-item .actions > .panel button { margin-top: 0.9rem; }
|
||||||
|
|
||||||
|
.encrypt { margin-top: 1.2rem; }
|
||||||
|
.encrypt-fields {
|
||||||
|
display: none; margin-top: 0.8rem; margin-left: 1.6rem; padding-left: 0.9rem;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.encrypt:has(input[type="checkbox"]:checked) .encrypt-fields { display: block; }
|
||||||
|
.encrypt-fields label { margin-top: 0.7rem; }
|
||||||
|
|
||||||
|
.rcpt-list, .rcpt-any { display: none; }
|
||||||
|
.rcpt-mode:has(option[value="list"]:checked) .rcpt-list { display: block; }
|
||||||
|
.rcpt-mode:has(option[value="any"]:checked) .rcpt-any { display: block; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.6rem 1rem; align-items: end;
|
||||||
|
padding: 0.85rem 1rem; margin-bottom: 1rem;
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
}
|
||||||
|
.toolbar .field { flex: 1 1 10rem; min-width: 8rem; }
|
||||||
|
.toolbar label { margin-top: 0; font-size: 0.8rem; }
|
||||||
|
.toolbar button { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Status: hero + dense grid */
|
||||||
|
.status-hero {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.75rem 1.2rem;
|
||||||
|
padding: 1rem 1.25rem; margin-bottom: 1rem;
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
}
|
||||||
|
.status-hero.attn,
|
||||||
|
.card.attn { border-color: var(--st-warn-border); }
|
||||||
|
.status-hero h1 { margin: 0; }
|
||||||
|
.status-hero .lead { margin: 0; color: var(--fg); }
|
||||||
|
.status-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(17.5rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.status-grid .card { margin: 0; }
|
||||||
|
.status-grid .card.attn { border-color: var(--st-warn-border); }
|
||||||
|
.metric-row {
|
||||||
|
display: flex; justify-content: space-between; gap: 0.75rem; align-items: baseline;
|
||||||
|
padding: 0.35rem 0; border-bottom: 1px solid var(--border); font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.metric-row:last-child { border-bottom: 0; }
|
||||||
|
.metric-row .k { font-weight: 600; }
|
||||||
|
.metric-row .v { font-family: var(--font-mono); font-size: 0.85rem; }
|
||||||
|
meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4rem; }
|
||||||
|
.card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.card-head h2 { margin: 0; }
|
||||||
|
.help-link {
|
||||||
|
flex: none; width: 1.35rem; height: 1.35rem; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 50%; border: 1px solid var(--control-border); color: var(--muted);
|
||||||
|
text-decoration: none; font-family: var(--font-mono); font-size: 0.75rem; font-weight: 600;
|
||||||
|
background: var(--surface-bg); cursor: pointer; margin: 0;
|
||||||
|
}
|
||||||
|
.help-link:hover { color: var(--accent-text); border-color: var(--accent-fill); }
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 0.5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.page-head h1 { margin: 0; }
|
||||||
|
|
||||||
|
/* Compact retry policy */
|
||||||
|
.retry-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.5rem; margin-top: 0.7rem; }
|
||||||
|
|
||||||
|
.phone-list { display: none; list-style: none; margin: 0; padding: 0; }
|
||||||
|
.phone-list li { border-bottom: 1px solid var(--border); }
|
||||||
|
.phone-list a,
|
||||||
|
.phone-list .item {
|
||||||
|
display: grid; grid-template-columns: 1fr auto; gap: 0.15rem 0.7rem;
|
||||||
|
padding: 0.75rem 0.1rem; text-decoration: none; color: var(--fg);
|
||||||
|
}
|
||||||
|
.phone-list .when { font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
|
||||||
|
.phone-list .meta { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 0.82rem; white-space: nowrap; overflow-x: auto; }
|
||||||
|
.phone-list .subj { grid-column: 1 / -1; margin: 0; }
|
||||||
|
|
||||||
|
/* Help drawer — CSS checkbox, no script required */
|
||||||
|
.help-drawer {
|
||||||
|
display: none; position: fixed; top: var(--gallery-h); right: 0; bottom: 0;
|
||||||
|
width: min(26rem, 100vw); z-index: 30;
|
||||||
|
background: var(--card-bg); border-left: 1px solid var(--border);
|
||||||
|
padding: 1.25rem 1.35rem 2rem; overflow-y: auto;
|
||||||
|
}
|
||||||
|
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-drawer,
|
||||||
|
html:has(input[name="help"]:checked):not(:has(#help-off:checked)) .help-scrim { display: block; }
|
||||||
|
.help-pane { display: none; }
|
||||||
|
html:has(#help-index:checked) .help-pane-index,
|
||||||
|
html:has(#help-status:checked) .help-pane-status,
|
||||||
|
html:has(#help-password:checked) .help-pane-password,
|
||||||
|
html:has(#help-dns:checked) .help-pane-dns,
|
||||||
|
html:has(#help-records:checked) .help-pane-records,
|
||||||
|
html:has(#help-dmarc:checked) .help-pane-dmarc,
|
||||||
|
html:has(#help-connection:checked) .help-pane-connection,
|
||||||
|
html:has(#help-apps:checked) .help-pane-apps,
|
||||||
|
html:has(#help-domain-settings:checked) .help-pane-domain-settings,
|
||||||
|
html:has(#help-export:checked) .help-pane-export { display: block; }
|
||||||
|
.help-scrim {
|
||||||
|
display: none; position: fixed; inset: var(--gallery-h) 0 0 0; z-index: 25;
|
||||||
|
background: rgba(18, 22, 28, 0.28);
|
||||||
|
}
|
||||||
|
.help-drawer h2 { margin-top: 1.2rem; }
|
||||||
|
.help-drawer h2:first-of-type,
|
||||||
|
.help-pane h2 { margin-top: 0; }
|
||||||
|
.help-drawer .toc { list-style: none; margin: 0.7rem 0 0; padding: 0; }
|
||||||
|
.help-drawer .toc li { margin: 0.35rem 0 0; }
|
||||||
|
.help-drawer .toc label {
|
||||||
|
margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.help-drawer .more { margin-top: 1.2rem; font-size: 0.85rem; }
|
||||||
|
.help-drawer .more label {
|
||||||
|
display: inline; margin: 0; font-weight: 600; color: var(--accent-text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.help-close {
|
||||||
|
position: absolute; top: 0.8rem; right: 0.8rem; margin: 0; padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.8rem; font-weight: 600; background: var(--surface-bg); color: var(--fg);
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phone preview (gallery toggle) and real narrow windows */
|
||||||
|
.nav-burger { display: none; }
|
||||||
|
|
||||||
|
#vp-phone:checked ~ .app {
|
||||||
|
position: relative;
|
||||||
|
width: 390px; margin: 0.75rem auto 2rem; min-height: 760px;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; overflow: hidden;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app .nav {
|
||||||
|
position: absolute; top: 0; left: 0; bottom: 0;
|
||||||
|
height: auto; max-height: none;
|
||||||
|
width: min(16rem, 82%); z-index: 20;
|
||||||
|
transform: translateX(-110%);
|
||||||
|
border-right: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
#nav-open:checked ~ .app .nav { transform: translateX(0); }
|
||||||
|
#vp-phone:checked ~ .app .phone-bar {
|
||||||
|
display: flex; align-items: center; gap: 0.55rem;
|
||||||
|
margin: 0; padding: 0.65rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app .nav-burger {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 2.1rem; height: 2.1rem; margin: 0; padding: 0;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px;
|
||||||
|
background: var(--surface-bg); cursor: pointer;
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app .phone-mark { width: 1.7rem; height: 1.7rem; }
|
||||||
|
#vp-phone:checked ~ .app .phone-bar .grow { flex: 1; font-weight: 600; }
|
||||||
|
#vp-phone:checked ~ .app main { padding: 1.5rem 0.9rem 2rem; }
|
||||||
|
#vp-phone:checked ~ .app .desk-only { display: none !important; }
|
||||||
|
#vp-phone:checked ~ .app .phone-only { display: block; }
|
||||||
|
#vp-phone:checked ~ .app .phone-list { display: block; }
|
||||||
|
#vp-phone:checked ~ .app .status-grid { display: flex; flex-direction: column; }
|
||||||
|
#vp-phone:checked ~ .app .status-grid .attn { order: -1; }
|
||||||
|
#vp-phone:checked ~ .app .pair { grid-template-columns: 1fr; }
|
||||||
|
#vp-phone:checked ~ .app .split,
|
||||||
|
#vp-phone:checked ~ .app .check-cols,
|
||||||
|
#vp-phone:checked ~ .app .field-pair:not(.host-type),
|
||||||
|
#vp-phone:checked ~ .app .facts,
|
||||||
|
#vp-phone:checked ~ .app .retry-facts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
#vp-phone:checked ~ .app td.subject span { max-width: none; }
|
||||||
|
#vp-phone:checked ~ .help-drawer { width: 100vw; }
|
||||||
|
|
||||||
|
.phone-only { display: none; }
|
||||||
|
|
||||||
|
@media (max-width: 52rem) {
|
||||||
|
.app { min-height: calc(100vh - var(--gallery-h)); }
|
||||||
|
.nav {
|
||||||
|
position: fixed; top: var(--gallery-h); left: 0; bottom: 0;
|
||||||
|
z-index: 20; width: min(16rem, 84vw); max-height: none;
|
||||||
|
transform: translateX(-110%);
|
||||||
|
border-right: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
#nav-open:checked ~ .app .nav { transform: translateX(0); }
|
||||||
|
.phone-bar {
|
||||||
|
display: flex; align-items: center; gap: 0.55rem;
|
||||||
|
margin: 0; padding: 0.65rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border); background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.nav-burger {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 2.1rem; height: 2.1rem; margin: 0; padding: 0;
|
||||||
|
border: 1px solid var(--control-border); border-radius: 5px;
|
||||||
|
background: var(--surface-bg); cursor: pointer;
|
||||||
|
}
|
||||||
|
.phone-mark { width: 1.7rem; height: 1.7rem; }
|
||||||
|
.phone-bar .grow { flex: 1; font-weight: 600; }
|
||||||
|
main { padding: 1.5rem 0.9rem 2rem; }
|
||||||
|
.desk-only { display: none !important; }
|
||||||
|
.phone-only, .phone-list { display: block; }
|
||||||
|
.status-grid { display: flex; flex-direction: column; }
|
||||||
|
.status-grid .attn { order: -1; }
|
||||||
|
.split, .pair, #settings .split, #backup > .split,
|
||||||
|
.check-cols, .field-pair:not(.host-type), .facts, .retry-facts { grid-template-columns: 1fr; }
|
||||||
|
.help-drawer { width: 100vw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Index page */
|
||||||
|
.wrap { max-width: 58rem; margin: 0 auto; padding: 2.5rem 1.25rem 5rem; }
|
||||||
|
.wrap > header { border-bottom: 2px solid var(--accent-fill); padding-bottom: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; color: var(--accent-text); margin: 0 0 0.7rem;
|
||||||
|
}
|
||||||
|
.wrap h1 { font-size: clamp(1.7rem, 4vw, 2.4rem); font-weight: 200; }
|
||||||
|
.wrap h1 b { font-weight: 600; }
|
||||||
|
.wrap > header p { margin: 0; max-width: 62ch; color: var(--muted); }
|
||||||
|
.wrap section { border-bottom: 1px solid var(--border); padding: 2.2rem 0; }
|
||||||
|
.wrap h2 {
|
||||||
|
font-size: 0.8rem; font-weight: 500; margin: 0 0 1.1rem;
|
||||||
|
letter-spacing: 0.1em; text-transform: uppercase; font-family: var(--font-mono); color: var(--muted);
|
||||||
|
}
|
||||||
|
.wrap h3 { font-size: 1.05rem; font-weight: 600; margin: 1.2rem 0 0.4rem; }
|
||||||
|
.note {
|
||||||
|
background: #f1eae4; border-left: 2px solid var(--accent-fill);
|
||||||
|
padding: 0.9rem 1rem; font-size: 0.92rem; line-height: 1.6; max-width: 64ch; margin-top: 1rem;
|
||||||
|
}
|
||||||
|
#theme-dark:checked ~ .doc .note { background: #2a1f1b; }
|
||||||
|
.compare {
|
||||||
|
display: grid; grid-template-columns: 1fr; gap: 0.75rem; margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
.compare .row {
|
||||||
|
font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted);
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
display: flex; height: 2.1rem; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.bar i { display: flex; align-items: center; justify-content: center; font-style: normal; font-size: 0.7rem; }
|
||||||
|
.bar .navc { width: 14%; background: var(--surface-bg); color: var(--fg); border-right: 1px solid var(--border); }
|
||||||
|
.bar .col { background: var(--nav-active-bg); color: var(--accent-text); }
|
||||||
|
.bar .empty { flex: 1; background: var(--code-bg); color: var(--muted); }
|
||||||
|
.bar .fill { flex: 1; background: var(--st-ok-bg); color: var(--st-ok-fg); }
|
||||||
|
.ia { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
@media (max-width: 40rem) { .ia { grid-template-columns: 1fr; } }
|
||||||
|
.ia ul { margin: 0.3rem 0 0; padding-left: 1.1rem; }
|
||||||
|
.ia li { margin: 0.2rem 0; }
|
||||||
|
.screen-index { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0.4rem 1.5rem; }
|
||||||
|
.screen-index a { display: block; padding: 0.25rem 0; }
|
||||||
|
.icons-row { display: flex; flex-wrap: wrap; gap: 1.2rem; align-items: end; margin-top: 0.8rem; }
|
||||||
|
.icon-card {
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 1rem 1.1rem 0.85rem; text-align: center; min-width: 7.5rem;
|
||||||
|
}
|
||||||
|
.icon-card svg { width: 1.5rem; height: 1.5rem; display: block; margin: 0 auto 0.45rem; }
|
||||||
|
.icon-card span { display: block; font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); }
|
||||||
|
.mark-row { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: end; }
|
||||||
|
.mark-row figure { margin: 0; }
|
||||||
|
.mark-row figcaption { font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted); margin-top: 0.4rem; }
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
display: inline-block; margin-top: 1rem; padding: 0.65rem 1.2rem;
|
||||||
|
background: var(--accent-fill); color: var(--on-accent); text-decoration: none;
|
||||||
|
font-weight: 600; border-radius: 5px;
|
||||||
|
}
|
||||||
|
.cta:hover { background: var(--accent-fill-hover); color: var(--on-accent); }
|
||||||
|
.cta-row { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1rem; }
|
||||||
|
.cta.quiet {
|
||||||
|
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
|
||||||
|
}
|
||||||
|
.cta.quiet:hover { background: var(--surface-bg-hover); color: var(--accent-text); }
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Settings — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-settings" data-page="settings" data-nav="settings" data-title="Settings">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<form class="stack" action="#" onsubmit="return false">
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Panel credentials</h2>
|
||||||
|
<p class="muted">These are the credentials for this control panel only. Applications keep their own logins and passwords, which are not affected.</p>
|
||||||
|
<label>Username</label><input value="admin" autocomplete="username">
|
||||||
|
<label>Current password</label><input type="password" autocomplete="current-password">
|
||||||
|
<label>New password</label><input type="password" autocomplete="new-password">
|
||||||
|
<label>Confirm new password</label><input type="password">
|
||||||
|
<div class="actions-row"><button type="button">Save changes</button></div>
|
||||||
|
<p class="muted">Leave both new-password fields empty to change the username or DMARC address only. Changing the password signs out every other session; this one stays signed in.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card g-only">
|
||||||
|
<h2>DMARC aggregate reports</h2>
|
||||||
|
<p class="muted">Default <code>rua=</code> for every sending domain (overridable per domain). When ingest is on, this can be an address SelfPost accepts.</p>
|
||||||
|
<label>Default report address</label>
|
||||||
|
<input type="email" value="dmarc@mail.example.org">
|
||||||
|
<p class="muted">When <code>rua=</code> points at another domain, that hub must publish a report-authorisation record. <a href="dmarc.html">DMARC reports</a> in the panel.</p>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row"><span class="code">mail.example.org._report._dmarc.example.com</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Value</label>
|
||||||
|
<div class="code-row"><span class="code">v=DMARC1;</span><button type="button" class="copy">Copy</button></div>
|
||||||
|
<label>Report authorization DNS <span class="st st-ok">ok</span></label>
|
||||||
|
<p class="muted">Published at mail.example.org._report._dmarc.example.com — aggregate reports addressed to dmarc@mail.example.org are authorised.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="fill g-only">
|
||||||
|
<div class="card" id="rate-limits">
|
||||||
|
<h2>Sending rate limits</h2>
|
||||||
|
<p class="muted">Level 1 is set in Compose; restart the container to change it. Domain and application ceilings live on each domain’s page.</p>
|
||||||
|
<div class="check-cols">
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Level 1 — per client IP</p>
|
||||||
|
<span class="code">100 messages / 60 seconds</span>
|
||||||
|
<p class="muted"><code>RATE_LIMIT_MESSAGES_PER_IP</code> / <code>RATE_LIMIT_WINDOW_SECONDS</code>. Hard ceiling for every connecting IP; the panel cannot raise a domain or application limit above this.</p>
|
||||||
|
</div>
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Level 2 — domain</p>
|
||||||
|
<p class="muted">Optional ceiling for <em>all</em> senders on a domain. When unset, only level 1 applies. Must be ≤ level 1.</p>
|
||||||
|
</div>
|
||||||
|
<div class="check-col">
|
||||||
|
<p class="check-col-title">Level 2 — application</p>
|
||||||
|
<p class="muted">Optional override for trusted IPs: a ceiling strictly above the domain limit (still ≤ level 1). Those IPs skip the domain check; everyone else stays under the domain (or level 1).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Create administrator — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-setup" data-page="setup" data-nav="setup" data-title="Create administrator" data-auth="1">
|
||||||
|
<main class="auth">
|
||||||
|
<img class="mark" src="../selfpost-stamp.svg" width="330" height="150" alt="SelfPost">
|
||||||
|
<h1>Create administrator</h1>
|
||||||
|
<div class="card">
|
||||||
|
<p class="muted">This one-time link creates the single panel administrator. After you submit, the link stops working for good.</p>
|
||||||
|
<form action="status.html">
|
||||||
|
<label>Username</label>
|
||||||
|
<input autocomplete="username">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" autocomplete="new-password">
|
||||||
|
<label>Confirm password</label>
|
||||||
|
<input type="password" autocomplete="new-password">
|
||||||
|
<div class="actions-row"><button type="submit">Create administrator</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
/* Shared chrome for panel UI page mockups. Each screen is its own HTML file;
|
||||||
|
this script injects radios, gallery, nav, sprite, and the help drawer so
|
||||||
|
file:// viewing does not need a module fetch. */
|
||||||
|
(function () {
|
||||||
|
if (document.body.dataset.shell === "1") return;
|
||||||
|
document.body.dataset.shell = "1";
|
||||||
|
var main = document.querySelector("main");
|
||||||
|
if (!main) return;
|
||||||
|
|
||||||
|
var page = document.body.getAttribute("data-page") || "";
|
||||||
|
var navKey = document.body.getAttribute("data-nav") || page;
|
||||||
|
var title = document.body.getAttribute("data-title") || document.title;
|
||||||
|
var globalOnly = document.body.getAttribute("data-global-only") === "1";
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML("afterbegin",
|
||||||
|
'<input class="ctrl" type="radio" name="role" id="role-global" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="role" id="role-domain">' +
|
||||||
|
'<input class="ctrl" type="radio" name="vp" id="vp-desktop" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="vp" id="vp-phone">' +
|
||||||
|
'<input class="ctrl" type="radio" name="theme" id="theme-light" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="theme" id="theme-dark">' +
|
||||||
|
'<input class="ctrl" type="checkbox" id="feat-inbound" checked>' +
|
||||||
|
'<input class="ctrl" type="checkbox" id="nav-open">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-off" checked>' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-index">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-status">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-password">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-dns">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-records">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-dmarc">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-connection">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-apps">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-domain-settings">' +
|
||||||
|
'<input class="ctrl" type="radio" name="help" id="help-export">'
|
||||||
|
);
|
||||||
|
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
if (q.get("view") === "phone") document.getElementById("vp-phone").checked = true;
|
||||||
|
if (q.get("role") === "domain") document.getElementById("role-domain").checked = true;
|
||||||
|
if (q.get("theme") === "dark") document.getElementById("theme-dark").checked = true;
|
||||||
|
if (q.get("inbound") === "0") document.getElementById("feat-inbound").checked = false;
|
||||||
|
|
||||||
|
var gallery =
|
||||||
|
'<header class="gallery">' +
|
||||||
|
'<a class="brand-mini" href="index.html">Макеты</a>' +
|
||||||
|
'<div class="seg"><span>Роль</span>' +
|
||||||
|
'<label for="role-global">Global</label>' +
|
||||||
|
'<label for="role-domain">Domain-admin</label></div>' +
|
||||||
|
'<div class="seg"><span>Ширина</span>' +
|
||||||
|
'<label for="vp-desktop">Desktop</label>' +
|
||||||
|
'<label for="vp-phone">Phone</label></div>' +
|
||||||
|
'<div class="seg"><span>Тема</span>' +
|
||||||
|
'<label for="theme-light">Light</label>' +
|
||||||
|
'<label for="theme-dark">Dark</label></div>' +
|
||||||
|
'<label class="g-only" for="feat-inbound">Inbound</label>' +
|
||||||
|
'<a href="index.html">Оглавление</a>' +
|
||||||
|
'<a href="system.html">Система</a>' +
|
||||||
|
"</header>";
|
||||||
|
|
||||||
|
var sprite =
|
||||||
|
'<svg class="sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
||||||
|
'<symbol id="i-status" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></symbol>' +
|
||||||
|
'<symbol id="i-domains" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>' +
|
||||||
|
'<symbol id="i-deliveries" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>' +
|
||||||
|
'<symbol id="i-queue" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>' +
|
||||||
|
'<symbol id="i-log" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>' +
|
||||||
|
'<symbol id="i-inbound" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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"/></symbol>' +
|
||||||
|
'<symbol id="i-dmarc" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 1.85 2.75 3.7v4.2c0 3.15 2.15 5.2 5.25 6.25 3.1-1.05 5.25-3.1 5.25-6.25V3.7Z"/><path d="M5.4 8.05 7.15 9.8 10.7 6.2"/></symbol>' +
|
||||||
|
'<symbol id="i-backup" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></symbol>' +
|
||||||
|
'<symbol id="i-users" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></symbol>' +
|
||||||
|
'<symbol id="i-help" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.25"/><path d="M8 7.2V11.4"/><path d="M8 5.05v.01"/></symbol>' +
|
||||||
|
'<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol>' +
|
||||||
|
'<symbol id="i-account" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></symbol>' +
|
||||||
|
'<symbol id="i-out" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></symbol>' +
|
||||||
|
'<symbol id="i-menu" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"/></symbol>' +
|
||||||
|
"</svg>";
|
||||||
|
|
||||||
|
function icon(id) {
|
||||||
|
return '<svg class="icon"><use href="#' + id + '"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var navHtml =
|
||||||
|
'<nav class="nav">' +
|
||||||
|
'<a class="brand" href="status.html"><img src="../selfpost-stamp-compact.svg" width="220" height="100" alt="SelfPost"></a>' +
|
||||||
|
'<div class="links">' +
|
||||||
|
'<a class="n-status g-only" href="status.html">' + icon("i-status") + "Status</a>" +
|
||||||
|
'<a class="n-domains" href="domains.html">' + icon("i-domains") + "Domains</a>" +
|
||||||
|
'<a class="n-deliveries" href="deliveries.html">' + icon("i-deliveries") + "Deliveries</a>" +
|
||||||
|
'<a class="n-queue g-only" href="mail-queue.html">' + icon("i-queue") + "Mail queue</a>" +
|
||||||
|
'<a class="n-log g-only" href="system-log.html">' + icon("i-log") + "System log</a>" +
|
||||||
|
'<a class="n-inbound g-only in-only" href="inbound.html">' + icon("i-inbound") + "Inbound</a>" +
|
||||||
|
'<a class="n-dmarc g-only" href="dmarc.html">' + icon("i-dmarc") + 'DMARC <span class="tag future">1.x</span></a>' +
|
||||||
|
'<a class="n-backup g-only" href="backup.html">' + icon("i-backup") + "Backup</a>" +
|
||||||
|
'<a class="n-users g-only" href="users.html">' + icon("i-users") + "Users</a>" +
|
||||||
|
'<a class="n-help" href="help.html">' + icon("i-help") + 'Help <span class="tag future">1.x</span></a>' +
|
||||||
|
"</div>" +
|
||||||
|
'<div class="session">' +
|
||||||
|
'<span class="session-user muted">' + icon("i-account") + "User: admin</span>" +
|
||||||
|
'<a class="n-settings" href="settings.html">' + icon("i-settings") + "Settings</a>" +
|
||||||
|
'<a class="btn-ghost" href="login.html">' + icon("i-out") + "Sign out</a>" +
|
||||||
|
"</div>" +
|
||||||
|
"</nav>";
|
||||||
|
|
||||||
|
var phone =
|
||||||
|
'<div class="phone-bar">' +
|
||||||
|
'<label class="nav-burger" for="nav-open" title="Menu">' + icon("i-menu") + "</label>" +
|
||||||
|
'<img class="phone-mark" src="../selfpost-icon.svg" width="28" height="28" alt="">' +
|
||||||
|
'<span class="grow phone-title"></span>' +
|
||||||
|
'<label class="help-link" for="help-index" title="Help">?</label>' +
|
||||||
|
"</div>";
|
||||||
|
|
||||||
|
var help =
|
||||||
|
'<label class="help-scrim" for="help-off"></label>' +
|
||||||
|
'<aside class="help-drawer">' +
|
||||||
|
'<label class="help-close" for="help-off">Close</label>' +
|
||||||
|
'<article class="help-pane help-pane-index">' +
|
||||||
|
"<h2>Help</h2>" +
|
||||||
|
"<p>Short notes for the card you opened — not a second copy of the guide.</p>" +
|
||||||
|
'<p class="muted">Status</p>' +
|
||||||
|
'<ul class="toc"><li><label for="help-status">Status checks</label></li></ul>' +
|
||||||
|
'<p class="muted">Domain</p>' +
|
||||||
|
'<ul class="toc">' +
|
||||||
|
'<li><label for="help-password">New application password</label></li>' +
|
||||||
|
'<li><label for="help-dns">DNS status</label></li>' +
|
||||||
|
'<li><label for="help-records">DKIM and SPF records</label></li>' +
|
||||||
|
'<li><label for="help-dmarc">DMARC record</label></li>' +
|
||||||
|
'<li><label for="help-connection">Connection settings</label></li>' +
|
||||||
|
'<li><label for="help-apps">Applications</label></li>' +
|
||||||
|
'<li><label for="help-domain-settings">Domain settings</label></li>' +
|
||||||
|
'<li><label for="help-export">Export domain</label></li>' +
|
||||||
|
"</ul></article>" +
|
||||||
|
'<article class="help-pane help-pane-status">' +
|
||||||
|
"<h2>Status checks</h2>" +
|
||||||
|
"<p>The cards keep the readings. This drawer is what used to sit under them as paragraphs.</p>" +
|
||||||
|
"<h2>Machine</h2>" +
|
||||||
|
"<p>CPU and memory are the container’s readings. Network is a short rate window. These numbers explain load; they do not replace the queue.</p>" +
|
||||||
|
"<h2>TLS certificate</h2>" +
|
||||||
|
"<p>Presented on port 465. Issued and renewed outside SelfPost. Warn = expires soon; error = missing, and submission will fail.</p>" +
|
||||||
|
"<h2>Hostname / reverse DNS</h2>" +
|
||||||
|
"<p>The hostname must forward to this IP and the PTR must come back to the same name. Set PTR at the provider.</p>" +
|
||||||
|
"<h2>Mail queue</h2>" +
|
||||||
|
"<p>Deferred mail is retried on a time schedule (first delay, doubling cap, queue lifetime). There is no “attempt 3 of N”.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-password">' +
|
||||||
|
"<h2>New application password</h2>" +
|
||||||
|
"<p>Shown <strong>once only</strong> and not stored. Copy it now — if it is lost, regenerate a new one. The previous password stops working immediately.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-dns">' +
|
||||||
|
"<h2>DNS status</h2>" +
|
||||||
|
"<p>The badge is the worst of DKIM, SPF and DMARC. Results are cached a few minutes — use <em>Re-check</em> after publishing.</p>" +
|
||||||
|
"<p>SPF is a shallow check: the literal address only, no <code>include:</code> or <code>redirect=</code>. Report authorization is required only when <code>rua=</code> points at a domain this server does not accept.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-records">' +
|
||||||
|
"<h2>DKIM and SPF records</h2>" +
|
||||||
|
"<p>DKIM is not a secret. The selector on this page is the one this server signs with. Merge the SPF example into an existing record if the domain already has one — do not publish a second TXT.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-dmarc">' +
|
||||||
|
"<h2>DMARC record</h2>" +
|
||||||
|
"<p><code>p=none</code> does not affect delivery. Tighten to <code>p=quarantine</code> then <code>p=reject</code> once reports look clean. The report address is set under Domain settings (or the Settings default).</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-connection">' +
|
||||||
|
"<h2>Connection settings</h2>" +
|
||||||
|
"<p>Same host for every domain. Authenticate with an application login from this page. Auth is required on every port. The password is shown once at create or regenerate.</p>" +
|
||||||
|
"<p>465 is implicit TLS; 587 is STARTTLS submission when that port is enabled.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-apps">' +
|
||||||
|
"<h2>Applications</h2>" +
|
||||||
|
"<p>SASL logins for this domain. Login is unique across domains; letters, digits, <code>.</code>, <code>-</code> and <code>_</code>. The password is shown once.</p>" +
|
||||||
|
"<p>Address mode is which From addresses this application may use: any address of the domain, or a fixed list. A trusted-IP override gives those clients a higher ceiling than the domain (still ≤ level 1) and skips the domain check; everyone else uses the domain limit if set, otherwise level 1.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-domain-settings">' +
|
||||||
|
"<h2>Domain settings</h2>" +
|
||||||
|
"<p>Aggregate reports (<code>rua=</code>) inherit the Settings default, or you override them per domain. Level 2 is an optional ceiling for all senders on this domain; it must be ≤ level 1. Application overrides live on each application.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
'<article class="help-pane help-pane-export">' +
|
||||||
|
"<h2>Export domain</h2>" +
|
||||||
|
"<p>The file is a secret: it carries the DKIM key and application passwords, so published DNS does not have to change on the other instance. Transfer it securely, or encrypt it as <code>.spde</code>.</p>" +
|
||||||
|
'<p class="more muted"><label for="help-index">All topics</label></p></article>' +
|
||||||
|
"</aside>";
|
||||||
|
|
||||||
|
var app = document.createElement("div");
|
||||||
|
app.className = "app";
|
||||||
|
app.innerHTML = navHtml + '<div class="stage">' + phone + "</div>";
|
||||||
|
main.parentNode.insertBefore(app, main);
|
||||||
|
app.insertAdjacentHTML("beforebegin", gallery + sprite);
|
||||||
|
app.querySelector(".stage").appendChild(main);
|
||||||
|
app.insertAdjacentHTML("afterend", help);
|
||||||
|
|
||||||
|
var cur = app.querySelector(".n-" + navKey);
|
||||||
|
if (cur) cur.setAttribute("aria-current", "page");
|
||||||
|
var grow = app.querySelector(".phone-title");
|
||||||
|
if (grow) grow.textContent = title;
|
||||||
|
|
||||||
|
var skip = { "index.html": 1, "system.html": 1, "app.html": 1 };
|
||||||
|
|
||||||
|
function qs() {
|
||||||
|
var p = new URLSearchParams();
|
||||||
|
if (document.getElementById("vp-phone").checked) p.set("view", "phone");
|
||||||
|
if (document.getElementById("role-domain").checked) p.set("role", "domain");
|
||||||
|
if (document.getElementById("theme-dark").checked) p.set("theme", "dark");
|
||||||
|
if (!document.getElementById("feat-inbound").checked) p.set("inbound", "0");
|
||||||
|
var s = p.toString();
|
||||||
|
return s ? "?" + s : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withQuery(href) {
|
||||||
|
if (!href) return href;
|
||||||
|
if (href.charAt(0) === "#" || /^(https?:|mailto:|javascript:)/i.test(href)) return href;
|
||||||
|
var hash = "";
|
||||||
|
var path = href;
|
||||||
|
var hashAt = href.indexOf("#");
|
||||||
|
if (hashAt >= 0) {
|
||||||
|
hash = href.slice(hashAt);
|
||||||
|
path = href.slice(0, hashAt);
|
||||||
|
}
|
||||||
|
var qAt = path.indexOf("?");
|
||||||
|
var file = qAt >= 0 ? path.slice(0, qAt) : path;
|
||||||
|
var extra = qAt >= 0 ? path.slice(qAt + 1) : "";
|
||||||
|
var base = file.split("/").pop();
|
||||||
|
if (!base || !/\.html$/i.test(base) || skip[base]) return href;
|
||||||
|
var p = new URLSearchParams(extra);
|
||||||
|
var curQs = new URLSearchParams(qs().replace(/^\?/, ""));
|
||||||
|
["view", "role", "theme", "inbound"].forEach(function (k) {
|
||||||
|
if (curQs.has(k)) p.set(k, curQs.get(k));
|
||||||
|
else p.delete(k);
|
||||||
|
});
|
||||||
|
var s = p.toString();
|
||||||
|
return base + (s ? "?" + s : "") + hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteLinks() {
|
||||||
|
document.querySelectorAll("a[href]").forEach(function (a) {
|
||||||
|
var raw = a.getAttribute("href");
|
||||||
|
if (!raw) return;
|
||||||
|
a.setAttribute("href", withQuery(raw));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandHref() {
|
||||||
|
var brand = document.querySelector(".nav .brand");
|
||||||
|
if (!brand) return;
|
||||||
|
brand.setAttribute("href", withQuery(
|
||||||
|
document.getElementById("role-domain").checked ? "domains.html" : "status.html"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function gate() {
|
||||||
|
brandHref();
|
||||||
|
if (!document.getElementById("role-domain").checked) return;
|
||||||
|
if (globalOnly) location.replace(withQuery("domains.html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUrl() {
|
||||||
|
if (history.replaceState) {
|
||||||
|
history.replaceState(null, "", location.pathname.split("/").pop() + qs() + location.hash);
|
||||||
|
}
|
||||||
|
rewriteLinks();
|
||||||
|
brandHref();
|
||||||
|
gate();
|
||||||
|
}
|
||||||
|
|
||||||
|
["role-global", "role-domain", "vp-desktop", "vp-phone", "theme-light", "theme-dark", "feat-inbound"].forEach(function (id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener("change", syncUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".nav a").forEach(function (a) {
|
||||||
|
a.addEventListener("click", function () {
|
||||||
|
var open = document.getElementById("nav-open");
|
||||||
|
if (open) open.checked = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
rewriteLinks();
|
||||||
|
gate();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Status — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-status" data-page="status" data-nav="status" data-title="Status" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Status</h1>
|
||||||
|
<label class="help-link" for="help-status" title="What these checks mean">?</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="overall">
|
||||||
|
<h2>Overall <span class="st st-warn">warn</span></h2>
|
||||||
|
<p class="muted">Running, with warnings below.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card attn" id="queue">
|
||||||
|
<h2>Mail queue <span class="st st-warn">warn</span></h2>
|
||||||
|
<p>3 Kbytes in 3 Requests.</p>
|
||||||
|
<div class="actions-row"><a class="btn" href="mail-queue.html">View queue</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="certificate">
|
||||||
|
<h2>TLS certificate <span class="st st-ok">ok</span></h2>
|
||||||
|
<label>Expires</label>
|
||||||
|
<span class="code">2026-11-02 12:00 UTC</span>
|
||||||
|
<p class="muted">Valid for another 78 day(s).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="sockets">
|
||||||
|
<h2>Milter sockets <span class="st st-ok">ok</span></h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>OpenDKIM</td><td><span class="st st-ok">ok</span></td><td class="muted">Listening</td></tr>
|
||||||
|
<tr><td>send-log</td><td><span class="st st-ok">ok</span></td><td class="muted">Listening</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="hostname">
|
||||||
|
<h2>Hostname and reverse DNS <span class="st st-ok">ok</span></h2>
|
||||||
|
<label>Server hostname</label>
|
||||||
|
<span class="code">mail.example.org</span>
|
||||||
|
<label>Forward and reverse lookup</label>
|
||||||
|
<span class="code">203.0.113.10 → mail.example.org</span>
|
||||||
|
<p class="muted">mail.example.org resolves to 203.0.113.10 and the reverse lookup points back at it.</p>
|
||||||
|
<div class="actions-row"><button type="button">Re-check DNS</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fill in-only" id="inbound-status">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Inbound <span class="st st-warn">warn</span></h2>
|
||||||
|
<p><code>INBOUND_RELAY_ENABLE</code> is on. Port 25 accepts mail for 2 domains and forwards it upstream — not to local mailboxes.</p>
|
||||||
|
<p class="muted">One domain has no MX pointing at this server. Recipients are a list or any address at the domain. Open Inbound for the list, MX checks, upstream, and recipient maps.</p>
|
||||||
|
<p><a href="inbound.html">Inbound domains</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card" id="machine">
|
||||||
|
<h2>Machine <span class="st st-ok">ok</span></h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="metric">CPU</td>
|
||||||
|
<td class="metric"><meter value="12" min="0" max="100" low="70" high="90" optimum="10">12%</meter> 12%</td>
|
||||||
|
<td class="muted">4 cores · 4 threads</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="metric">Memory</td>
|
||||||
|
<td class="metric"><meter value="41" min="0" max="100" low="70" high="90" optimum="10">41%</meter> 41%</td>
|
||||||
|
<td class="muted">1.6 GiB used of 4.0 GiB.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="metric">Network</td>
|
||||||
|
<td class="metric">↓ 2.0 KiB/s<br>↑ 1.0 KiB/s</td>
|
||||||
|
<td class="muted"><div>eth0: 1.0 MiB in, 512.0 KiB out</div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="processes">
|
||||||
|
<h2>Processes <span class="st st-ok">ok</span></h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>opendkim</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 21, uptime 3 days, 4:12:01</td></tr>
|
||||||
|
<tr><td>panel</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 18, uptime 3 days, 4:12:03</td></tr>
|
||||||
|
<tr><td>postfix</td><td><span class="st st-ok">RUNNING</span></td><td class="muted">pid 42, uptime 3 days, 4:11:58</td></tr>
|
||||||
|
<tr><td>postfix-reload</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
|
||||||
|
<tr><td>cert-reload</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
|
||||||
|
<tr><td>logrotate</td><td><span class="st st-ok">STOPPED</span></td><td class="muted">Not started</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card" id="configuration">
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
|
||||||
|
database and reloads both daemons. Use it if you edited the files by hand,
|
||||||
|
restored a backup, or the running configuration looks out of step with the
|
||||||
|
domain and application lists. It does not touch the mail queue or the TLS
|
||||||
|
certificate, and it is safe to run at any time.</p>
|
||||||
|
<div class="actions-row"><button type="button">Reload configuration</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="version">SelfPost 1.2.3 · © Mixeme · <a href="#">License (AGPL-3.0)</a></p>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>System log — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-system-log" data-page="system-log" data-nav="log" data-title="System log" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>System log</h1>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent log entries</h2>
|
||||||
|
<span class="code">Aug 15 20:14:08 mail postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, delays=0.2/0.1/0.4/5.3, dsn=4.2.1, status=deferred (450 4.2.1 mailbox busy)
|
||||||
|
Aug 15 20:14:02 mail postfix/qmgr[119]: 4C3A1E2F1A: from=<billing@example.com>, size=12288, nrcpt=1 (queue active)
|
||||||
|
Aug 15 20:14:02 mail postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40[203.0.113.40], sasl_method=PLAIN, sasl_username=billing
|
||||||
|
Aug 15 20:11:40 mail postfix/smtp[228]: 4B19D0AA01: to=<list-bounces@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=0.9, status=sent (250 2.0.0 Ok)
|
||||||
|
Aug 15 20:02:11 mail postfix/smtp[226]: 4B19C0BB12: to=<noreply@blocked.example>, status=bounced (host mx.blocked.example[203.0.113.99] said: 550 5.7.1 rejected)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* Panel UI system primitives.
|
||||||
|
Source of truth: system.html. Screens are separate HTML files composed from
|
||||||
|
these classes. Do not “fix” empty columns, Host/Type height, or split
|
||||||
|
actions by one-off rules — compose with these instead. */
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex; flex-direction: column; gap: 1rem;
|
||||||
|
width: 100%; min-width: 0;
|
||||||
|
}
|
||||||
|
.stack > .card,
|
||||||
|
.stack > .pair,
|
||||||
|
.stack > .measure,
|
||||||
|
.stack > .fill { margin-top: 0; }
|
||||||
|
.stack > .card + .card { margin-top: 0; }
|
||||||
|
|
||||||
|
.measure { width: 100%; max-width: var(--form-max); min-width: 0; }
|
||||||
|
|
||||||
|
.fill { width: 100%; max-width: var(--ops-max); min-width: 0; }
|
||||||
|
.fill > .card { margin-top: 0; }
|
||||||
|
.fill .card:has(table) { overflow-x: auto; }
|
||||||
|
.card > .measure { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Two peer jobs. One child → reading measure (domain-admin Settings).
|
||||||
|
Never a full-width lonely card on an ops page. */
|
||||||
|
.pair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%; max-width: var(--ops-max); min-width: 0;
|
||||||
|
}
|
||||||
|
.pair > * { min-width: 0; }
|
||||||
|
.card > .stack { min-width: 0; }
|
||||||
|
.pair > .card { margin-top: 0; }
|
||||||
|
.pair > .card + .card { margin-top: 0; }
|
||||||
|
.pair:has(> :only-child) {
|
||||||
|
display: block;
|
||||||
|
max-width: var(--form-max);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
}
|
||||||
|
.actions-row > button,
|
||||||
|
.actions-row > a.btn,
|
||||||
|
.actions-row > a.danger { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Two labelled controls on one row. Labels share row 1, values share row 2
|
||||||
|
so they are the same height by construction — not by matching padding. */
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
grid-template-rows: auto minmax(2.5rem, auto);
|
||||||
|
column-gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-row.equal { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
|
.field-row > .field { display: contents; }
|
||||||
|
.field-row > .field:first-child > * { grid-column: 1; }
|
||||||
|
.field-row > .field:last-child > * { grid-column: 2; }
|
||||||
|
.field-row > .field > label { grid-row: 1; margin-top: 0.45rem; }
|
||||||
|
.field-row > .field > :not(label) {
|
||||||
|
grid-row: 2; align-self: stretch; min-width: 0;
|
||||||
|
margin-top: 0.3rem; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.field-row > .field > .code,
|
||||||
|
.field-row > .field > .code-row {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.field-row > .field > .code {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-row > .field:last-child > .code {
|
||||||
|
width: fit-content; min-width: 2.75rem;
|
||||||
|
justify-content: center; text-align: center;
|
||||||
|
}
|
||||||
|
.field-row .code-row {
|
||||||
|
display: flex; align-items: stretch; gap: 0.5rem; height: 100%;
|
||||||
|
}
|
||||||
|
.field-row .code-row .code {
|
||||||
|
flex: 1; min-width: 0; margin-top: 0; height: auto;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding-top: 0.45rem; padding-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
.field-row .code-row .copy { margin-top: 0; align-self: stretch; }
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.card-head h2 { margin: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 52rem) {
|
||||||
|
.pair { grid-template-columns: 1fr; }
|
||||||
|
/* field-row stays two columns: Type is a token, not a second form. */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specimens on the system page */
|
||||||
|
.sys-wrap { max-width: 70rem; }
|
||||||
|
.sys-wrap > header p,
|
||||||
|
.sys-wrap section > p,
|
||||||
|
.sys-wrap li { max-width: 68ch; }
|
||||||
|
.sys-toc {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.35rem 1.1rem;
|
||||||
|
margin: 1rem 0 0; padding: 0; list-style: none;
|
||||||
|
}
|
||||||
|
.sys-toc a { font-size: 0.92rem; }
|
||||||
|
.specimen {
|
||||||
|
margin: 1rem 0 0; padding: 0.9rem 1rem 1.1rem;
|
||||||
|
border: 1px dashed var(--border); border-radius: 6px; background: var(--code-bg);
|
||||||
|
}
|
||||||
|
.specimen > figcaption {
|
||||||
|
font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase; color: var(--muted); margin: 0 0 0.7rem;
|
||||||
|
}
|
||||||
|
.specimen.bad {
|
||||||
|
border-color: var(--danger-border); background: var(--danger-bg);
|
||||||
|
}
|
||||||
|
.specimen.bad > figcaption { color: var(--danger-fg); }
|
||||||
|
.recipes {
|
||||||
|
width: 100%; margin-top: 0.8rem; font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.recipes th, .recipes td { white-space: normal; vertical-align: top; }
|
||||||
|
.recipes code { font-size: 0.82rem; }
|
||||||
|
.forbid { margin: 0.4rem 0 0; padding-left: 1.1rem; }
|
||||||
|
.forbid li { margin: 0.35rem 0; }
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SelfPost — система панели</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="doc">
|
||||||
|
<div class="wrap sys-wrap">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<p class="eyebrow">система · не рескин</p>
|
||||||
|
<h1>Self<b>Post</b> — грамматика интерфейса</h1>
|
||||||
|
<p>Это проект системы. Экраны складываются из регионов и контролов. Запрещённые состояния нельзя «починить паддингом» — их не из чего собрать. Знак, кирпич и IBM Plex не меняются. Живая панель (<code>internal/web</code>) пока не трогается.</p>
|
||||||
|
<div class="cta-row">
|
||||||
|
<a class="cta" href="#regions">Регионы</a>
|
||||||
|
<a class="cta quiet" href="#controls">Контролы</a>
|
||||||
|
<a class="cta quiet" href="#recipes">Экраны</a>
|
||||||
|
<a class="cta quiet" href="status.html">Макеты экранов</a>
|
||||||
|
</div>
|
||||||
|
<ul class="sys-toc">
|
||||||
|
<li><a href="#why">Зачем</a></li>
|
||||||
|
<li><a href="#regions">Регионы</a></li>
|
||||||
|
<li><a href="#card">Карточка</a></li>
|
||||||
|
<li><a href="#controls">Контролы</a></li>
|
||||||
|
<li><a href="#actions">Действия</a></li>
|
||||||
|
<li><a href="#forbid">Нельзя</a></li>
|
||||||
|
<li><a href="#recipes">Рецепты экранов</a></li>
|
||||||
|
<li><a href="#phone">Телефон</a></li>
|
||||||
|
<li><a href="#apply">Как внедрять</a></li>
|
||||||
|
</ul>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="why">
|
||||||
|
<h2>Зачем</h2>
|
||||||
|
<p>Первый макет скопировал грамматику живой панели: страница помечается <code>ops</code> или <code>form</code>, карточки стопкой, пара полей — два независимых <code>.code</code>, Save внутри <code><form></code>, Delete снаружи. Дальше каждая мелочь чинилась отдельно — как в <code>panel.css</code> годами. Новый интерфейс должен <strong>не давать собрать</strong> пустую колонку, разные высоты Host/Type и кнопки на двух строках.</p>
|
||||||
|
<p>Три оси, которые больше не выбираются «на глаз» у каждой страницы:</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Регион</strong> — как блок занимает ширину окна.</li>
|
||||||
|
<li><strong>Карточка</strong> — заголовок, справка, тело, действия.</li>
|
||||||
|
<li><strong>Контрол</strong> — одно поле или пара полей одной высоты.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="regions">
|
||||||
|
<h2>Регионы</h2>
|
||||||
|
<p>Страница — это <code>stack</code> регионов, не класс на <code><main></code>. Оболочка одна: навбар прижат влево, контент забирает остаток. Навбар не прыгает, когда меняется содержимое.</p>
|
||||||
|
<table class="recipes">
|
||||||
|
<thead><tr><th>Регион</th><th>Ширина</th><th>Когда</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>measure</code></td>
|
||||||
|
<td>до 42rem, влево</td>
|
||||||
|
<td>Одна читаемая форма: login, setup, подтверждение удаления, форма пользователя. Не «вся страница Settings».</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>pair</code></td>
|
||||||
|
<td>две колонки до 90rem</td>
|
||||||
|
<td>Две равноправные задачи на одном экране. Один ребёнок — сам сжимается в <code>measure</code> (domain-admin без DMARC).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>fill</code></td>
|
||||||
|
<td>до 90rem</td>
|
||||||
|
<td>Таблица, лог, DNS-статус, список приложений. Ячейки не переносят однострочные значения; карточка скроллится по X.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>stack</code></td>
|
||||||
|
<td>колонка с зазором</td>
|
||||||
|
<td>Вертикальный порядок регионов. Зазор даёт <code>gap</code>, не <code>.card + .card</code>.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<figure class="specimen bad">
|
||||||
|
<figcaption>Нельзя — страница-форма, две задачи стопкой</figcaption>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Full backup</h2>
|
||||||
|
<p class="muted">Карточка узкая. Справа пустое поле на 1600px.</p>
|
||||||
|
<button type="button">Download</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Import a domain</h2>
|
||||||
|
<p class="muted">Вторая задача под первой — та же пустота.</p>
|
||||||
|
<button type="button">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>Надо — pair</figcaption>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Full backup</h2>
|
||||||
|
<p class="muted">Секрет. Шифрование — внутри карточки, поля не растягиваются на 1440px.</p>
|
||||||
|
<div class="actions-row"><button type="button">Download full backup</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Import a domain</h2>
|
||||||
|
<p class="muted">Тот же экран, вторая задача. Не «ещё одна форма ниже».</p>
|
||||||
|
<div class="actions-row"><button type="button">Import domain</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="card">
|
||||||
|
<h2>Карточка</h2>
|
||||||
|
<p>Один хром: <code>card-head</code> (заголовок + слот «?»), тело, при необходимости <code>actions-row</code>. Справка — часть хрома, её не расставляют после того, как карточки уже собраны. Нет «?» — слот пустой, заголовки соседних карточек всё равно на одной линии.</p>
|
||||||
|
<p>Показания (очередь, PTR, CPU) остаются на карточке. В drawer уходит только то, чего на карточке быть не должно: что такое kernel counter, зачем PTR у провайдера, почему пароль показывают один раз.</p>
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>Хром карточки</figcaption>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>DNS status <span class="st st-ok">ok</span></h2>
|
||||||
|
<span class="help-link" title="What these checks mean">?</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Чтение и Re-check здесь. Абзац «зачем MX» — в справке.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Без «?»: действие очевидное. Карточка всё равно пара к форме, не на всю ширину.</p>
|
||||||
|
<div class="actions-row"><a class="danger" href="#forbid">Delete inbound domain</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="controls">
|
||||||
|
<h2>Контролы</h2>
|
||||||
|
<p>Одно поле — <code>field</code> (подпись + контроль). Два поля в ряд — <code>field-row</code>: подписи в первой сетке-строке, значения во второй. Высота значений общая, потому что это одна строка грида, а не два блока с подобранным padding. Баг «Type ниже Host» (<code>b0ebe06</code>) из этой разметки не собирается.</p>
|
||||||
|
<p><code>field-row</code> с узкой второй колонкой — Host / name ‖ Type. <code>field-row equal</code> — два равноправных инпута (лимит и окно).</p>
|
||||||
|
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>field-row — Host ‖ Type, с Copy и без</figcaption>
|
||||||
|
<div class="pair">
|
||||||
|
<div class="card">
|
||||||
|
<h2>With Copy</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">mail._domainkey.example.com</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">TXT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Lookup, no Copy</h2>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Host / name</label>
|
||||||
|
<span class="code">lists.example.com</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Type</label>
|
||||||
|
<span class="code">MX</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>field-row equal — лимит ‖ окно</figcaption>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Level-2 rate limit</h2>
|
||||||
|
<div class="field-row equal">
|
||||||
|
<div class="field">
|
||||||
|
<label>Message limit</label>
|
||||||
|
<input value="40">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Window (seconds)</label>
|
||||||
|
<input value="60">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="actions">
|
||||||
|
<h2>Действия</h2>
|
||||||
|
<p>Primary, secondary и danger — всегда <code>actions-row</code>. Для вёрстки неважно, POST это или переход на confirm: ряд один. Форма либо оборачивает всю карточку, либо кнопки несут <code>form=</code>. Нельзя оставить Submit внутри блочной формы, а Delete — следующим соседом: блок формы занимает строку целиком.</p>
|
||||||
|
<figure class="specimen">
|
||||||
|
<figcaption>Save и Delete в одном ряду</figcaption>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Edit user</h2>
|
||||||
|
<label>Username</label>
|
||||||
|
<input value="ops-alerts">
|
||||||
|
<div class="actions-row">
|
||||||
|
<button type="button">Save</button>
|
||||||
|
<a class="danger" href="#forbid">Delete user</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="forbid">
|
||||||
|
<h2>Нельзя</h2>
|
||||||
|
<p>Если хочется добавить правило «только на этой странице» — сначала проверить, какого региона не хватило.</p>
|
||||||
|
<ul class="forbid">
|
||||||
|
<li>Класс на <code>main</code> (<code>ops</code> / <code>form</code>) как способ выбрать ширину. Ширину выбирает регион.</li>
|
||||||
|
<li>Одиночная карточка на 90rem с двумя полями ввода. Это <code>measure</code> или <code>pair</code>.</li>
|
||||||
|
<li>Danger zone отдельным <code>fill</code>. Она вторая колонка последней пары (получатели ‖ удалить, экспорт ‖ удалить).</li>
|
||||||
|
<li>Два <code>.code</code> рядом с разным padding, чтобы «почти совпало». Только <code>field-row</code>.</li>
|
||||||
|
<li>Submit внутри <code><form></code>, danger-ссылка после <code></form></code>.</li>
|
||||||
|
<li>Перенос однострочного поля, адреса, статуса, hostname. <code>nowrap</code> + горизонтальный скролл карточки.</li>
|
||||||
|
<li>Status из одних бейджей без Detail / без строки очереди / без PTR.</li>
|
||||||
|
<li>Телефон как уменьшенный десктоп с шестиколоночной таблицей. Таблица → список; <code>pair</code> → одна колонка; <code>field-row</code> остаётся парой.</li>
|
||||||
|
<li>Копировать разметку <code>internal/web/view/templates</code> «как есть» в макет. Рецепт экрана — ниже, не шаблон Go.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="recipes">
|
||||||
|
<h2>Рецепты экранов</h2>
|
||||||
|
<p>Экраны — отдельные HTML-файлы рядом с этой спецификацией. Собираются только так:</p>
|
||||||
|
<table class="recipes">
|
||||||
|
<thead><tr><th>Экран</th><th>Стек регионов</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><a href="login.html">Login</a> / <a href="setup.html">setup</a></td>
|
||||||
|
<td>центр оболочки, <code>measure</code> 24rem, полный stamp</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="status.html">Status</a></td>
|
||||||
|
<td><code>stack</code>: overall → <code>pair</code> очередь‖TLS → <code>pair</code> milters‖PTR → inbound если есть → <code>pair</code> machine‖processes → configuration. На карточках остаются числа и Detail.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domains.html">Domains</a> / <a href="inbound.html">Inbound</a> / <a href="deliveries.html">Deliveries</a> / <a href="system-log.html">log</a> / <a href="mail-queue.html">queue</a></td>
|
||||||
|
<td><code>fill</code> таблица. Добавление домена — поле в той же карточке списка (<code>measure</code> внутри, не вторая карточка и не инпут на 90rem).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain.html">Domain</a></td>
|
||||||
|
<td><code>fill</code> DNS status → <code>pair</code> DKIM/SPF ‖ DMARC → <code>pair</code> connection ‖ add app → <code>fill</code> applications → <code>pair</code> domain settings (две половины) → <code>pair</code> export ‖ danger. «?» на рабочих карточках.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="inbound-domain.html">Inbound domain</a></td>
|
||||||
|
<td><code>fill</code> MX DNS → <code>pair</code> upstream ‖ MX to publish → <code>pair</code> recipients ‖ danger</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="dmarc.html">DMARC</a></td>
|
||||||
|
<td>hub: <code>pair</code> ingest ‖ this week, затем <code>fill</code> список отчётов. Домен: <a href="dmarc-domain.html">roll-up</a> (<code>pair</code> 7 days ‖ third-party, <code>fill</code> reports + sources). Один XML: <a href="dmarc-report.html">просмотр</a> — <code>pair</code> report ‖ policy, <code>fill</code> records. Не дашборд, не <code>ruf=</code>.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="backup.html">Backup</a></td>
|
||||||
|
<td><code>pair</code> full backup ‖ import</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="settings.html">Settings</a></td>
|
||||||
|
<td>global: <code>pair</code> credentials ‖ DMARC, затем <code>fill</code> rate limits. Domain-admin: один ребёнок в <code>pair</code> → сам <code>measure</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="users.html">Users</a></td>
|
||||||
|
<td><code>fill</code> таблица. <a href="user-form.html">Create/Edit</a> — <code>measure</code> + <code>actions-row</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="domain-delete.html">Confirm delete</a></td>
|
||||||
|
<td><code>measure</code> одна карточка</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="phone">
|
||||||
|
<h2>Телефон</h2>
|
||||||
|
<p>Аварийный доступ, не продукт. <code>pair</code> складывается в одну колонку. Таблица заменяется списком (как сейчас в прототипе). <code>field-row</code> не складывается: Type — токен. Навбар — выезжающая колонка на checkbox, без обязательного JS. Знак в шапке — SP-иконка, не второй wordmark.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="apply" style="border-bottom:none">
|
||||||
|
<h2>Как внедрять</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Новый экран или правка макета — только классы из <code>system.css</code> (<code>stack</code>, <code>pair</code>, <code>measure</code>, <code>fill</code>, <code>field-row</code>, <code>actions-row</code>).</li>
|
||||||
|
<li>Не добавлять исключения в <code>mock.css</code> «для этой страницы», если это ширина, высота пары полей или ряд кнопок.</li>
|
||||||
|
<li>Click-through — отдельные страницы (<code>status.html</code>, <code>domain.html</code>, …), не простыня с якорями. Оболочка общая: <code>shell.js</code>.</li>
|
||||||
|
<li>Вёрстка <code>internal/web</code> — отдельная задача после утверждения системы, не параллельный рескин шаблонов.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="muted">Классы живут в <code>docs/assets/panel-ui/system.css</code>. Этот файл — спецификация. Экраны — <a href="status.html">status.html</a> и соседние страницы; оглавление — <a href="index.html">index.html</a>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Delete ops-alerts — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-user-delete" data-page="user-delete" data-nav="users" data-title="Delete user" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Delete ops-alerts</h1>
|
||||||
|
<a class="back" href="user-form.html">← Back to ops-alerts</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Confirm deletion</h2>
|
||||||
|
<p>You are about to delete the panel user <strong>ops-alerts</strong>. A signed-in session for this user stops working immediately.</p>
|
||||||
|
<div class="actions-row"><button type="button" class="danger">Delete ops-alerts</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Edit user — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-user-form" data-page="user-form" data-nav="users" data-title="Edit user" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<div>
|
||||||
|
<h1>Edit user</h1>
|
||||||
|
<a class="back" href="users.html">← Back to users</a>
|
||||||
|
</div>
|
||||||
|
<div class="measure">
|
||||||
|
<div class="card">
|
||||||
|
<form action="#" onsubmit="return false">
|
||||||
|
<label>Username</label>
|
||||||
|
<input value="ops-alerts">
|
||||||
|
<label>Password (leave empty to keep)</label>
|
||||||
|
<input type="password">
|
||||||
|
<label>Role</label>
|
||||||
|
<select><option selected>Domain administrator</option><option>Global administrator</option></select>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Assigned domains</legend>
|
||||||
|
<p class="muted">Required for domain administrators.</p>
|
||||||
|
<label class="check"><input type="checkbox"> example.com</label>
|
||||||
|
<label class="check"><input type="checkbox" checked> alerts.example.com</label>
|
||||||
|
</fieldset>
|
||||||
|
<div class="actions-row">
|
||||||
|
<button type="button">Save</button>
|
||||||
|
<a class="danger" href="user-delete.html">Delete user</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Users — SelfPost mockups</title>
|
||||||
|
<link rel="icon" href="../selfpost-icon-16.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="mock.css">
|
||||||
|
<link rel="stylesheet" href="system.css">
|
||||||
|
</head>
|
||||||
|
<body class="page-users" data-page="users" data-nav="users" data-title="Users" data-global-only="1">
|
||||||
|
<main class="stack">
|
||||||
|
<h1>Users</h1>
|
||||||
|
<div class="fill">
|
||||||
|
<div class="card">
|
||||||
|
<p><a href="user-form.html">Create user</a></p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>admin</td><td>Global</td><td class="muted">All</td><td class="actions"><a href="user-form.html">Edit</a></td></tr>
|
||||||
|
<tr><td>ops-alerts</td><td>Domain admin</td><td class="muted">alerts.example.com</td><td class="actions"><a href="user-form.html">Edit</a></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="shell.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -64,6 +64,63 @@ obligation — not because the convention around AI authorship shifts.
|
|||||||
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial
|
Default rule: risk-critical → Opus; UI / docs / boilerplate → Sonnet; trivial
|
||||||
mechanics → Haiku. Reviewers must not be the author of the code under review.
|
mechanics → Haiku. Reviewers must not be the author of the code under review.
|
||||||
|
|
||||||
|
**Composer → Opus → Fable** (when a plan specifies it): **Composer** writes all
|
||||||
|
implementation for that item (code, tests, docs); **Opus** does the technical
|
||||||
|
review of the result; **Fable** does the security review. A plan's checklist
|
||||||
|
overrides the default routing table for that item only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan checklists
|
||||||
|
|
||||||
|
Open [roadmap.md](roadmap.md) items with a linked plan file carry an
|
||||||
|
**Implementation checklist** in [plans/](plans/). After a context reset, work
|
||||||
|
the **active** plan — not the roadmap prose — from the first unchecked line.
|
||||||
|
|
||||||
|
**Checklist format**
|
||||||
|
|
||||||
|
- `- [ ] Step description — **Model**` (model from the routing table above).
|
||||||
|
- Mark `[x]` only in the commit that actually finishes the step.
|
||||||
|
- `candidate` items: checklist may exist, but no code until the roadmap status
|
||||||
|
is agreed.
|
||||||
|
|
||||||
|
**Progress column** in the roadmap index (`0/N`) counts every checklist line in
|
||||||
|
that plan (including docs and `go vet` / `go test` steps). Update `N` when the
|
||||||
|
checklist changes; update the numerator when steps are checked off.
|
||||||
|
|
||||||
|
### Roadmap track: commit and version
|
||||||
|
|
||||||
|
For the current 1.x+ roadmap queue ([roadmap.md](roadmap.md) index), stricter
|
||||||
|
than the default «meaningful step» rule in [§ Commits and release build](#commits-and-release-build):
|
||||||
|
|
||||||
|
**After each checklist step**
|
||||||
|
|
||||||
|
1. Check off the step in the plan file; bump `Progress` in the roadmap index.
|
||||||
|
2. Append [CHANGELOG.md](../CHANGELOG.md) under `[Unreleased]` for that step.
|
||||||
|
3. Commit on `main` (push / tag / Release only on explicit request).
|
||||||
|
|
||||||
|
**After each roadmap stage** (all checklist steps done; «Done when» satisfied)
|
||||||
|
|
||||||
|
1. Version cut in one commit: rename `[Unreleased]` to `[X.Y.Z] - date`, open a
|
||||||
|
fresh `[Unreleased]`, bump the image pin in
|
||||||
|
[deploy/docker-compose.yml](../deploy/docker-compose.yml).
|
||||||
|
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
|
||||||
|
[§ Release image](#release-image)).
|
||||||
|
|
||||||
|
**Planned version cuts** (from pin `1.7.0`; adjust if semver changes mid-track):
|
||||||
|
|
||||||
|
| Stage | ID | Cut |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | queue-retries | `1.3.1` (PATCH) |
|
||||||
|
| 2 | inbound-relay | `1.4.0` |
|
||||||
|
| 3 | send-log-retention | `1.5.0` |
|
||||||
|
| 4 | domain-stats-auto-ratelimit | `1.6.0` |
|
||||||
|
| 5 | dmarc-reports | `1.7.0` |
|
||||||
|
| 6 | panel-docs | `1.8.0` |
|
||||||
|
|
||||||
|
Docs-only prep (checklists in plans, no product code) uses the same per-step
|
||||||
|
commit rule but **no** version cut until the next product stage ships.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Technology stack and tools
|
## Technology stack and tools
|
||||||
@@ -372,6 +429,7 @@ There is no `docs/archive/` directory.
|
|||||||
| As-built design | [architecture.md](architecture.md) |
|
| As-built design | [architecture.md](architecture.md) |
|
||||||
| Development process (this file) | [development.md](development.md) |
|
| Development process (this file) | [development.md](development.md) |
|
||||||
| Security requirements and accepted risks | [security.md](security.md) |
|
| Security requirements and accepted risks | [security.md](security.md) |
|
||||||
|
| SQLite schema and migrations | [schema-migrations.md](schema-migrations.md) |
|
||||||
| Roadmap (1.x+) | [roadmap.md](roadmap.md) |
|
| Roadmap (1.x+) | [roadmap.md](roadmap.md) |
|
||||||
| Active design plans | [plans/](plans/) |
|
| Active design plans | [plans/](plans/) |
|
||||||
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
| Release history | [CHANGELOG.md](../CHANGELOG.md) |
|
||||||
@@ -401,6 +459,8 @@ Out of scope for v1.x: `CONTRIBUTING.md`, man pages, a separate docs site
|
|||||||
fails on an undocumented `loadConfig` or build-script key.
|
fails on an undocumented `loadConfig` or build-script key.
|
||||||
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
|
3. **New gaps** go into [roadmap.md](roadmap.md) (or the active plan file), not
|
||||||
silent drive-by edits.
|
silent drive-by edits.
|
||||||
|
4. **Schema changes:** update [schema-migrations.md](schema-migrations.md) in the
|
||||||
|
same change as a new `internal/store/migrations/*.sql` file.
|
||||||
|
|
||||||
### Verifying docs against code
|
### Verifying docs against code
|
||||||
|
|
||||||
|
|||||||
+304
-83
@@ -31,7 +31,11 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
|
|||||||
- [Server-level DNS (PTR/rDNS)](#server-level-dns-ptrrdns)
|
- [Server-level DNS (PTR/rDNS)](#server-level-dns-ptrrdns)
|
||||||
- [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop)
|
- [Rate limiting — level 1 (IP backstop)](#rate-limiting--level-1-ip-backstop)
|
||||||
- [Full backup and restore](#full-backup-and-restore)
|
- [Full backup and restore](#full-backup-and-restore)
|
||||||
|
- [Taking a full backup](#taking-a-full-backup)
|
||||||
|
- [Restore](#restore)
|
||||||
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
|
- [Encrypting a backup or export](#encrypting-a-backup-or-export)
|
||||||
|
- [Inbound relay](#inbound-relay)
|
||||||
|
- [DMARC reports](#dmarc-reports)
|
||||||
- [Domain administration](#domain-administration)
|
- [Domain administration](#domain-administration)
|
||||||
- [Domains page](#domains-page)
|
- [Domains page](#domains-page)
|
||||||
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
|
- [Domain-level DNS (SPF, DKIM, DMARC)](#domain-level-dns-spf-dkim-dmarc)
|
||||||
@@ -44,10 +48,13 @@ domains hosted on that instance — DNS, deliveries, rate limits, applications).
|
|||||||
|
|
||||||
### Ports
|
### Ports
|
||||||
|
|
||||||
`deploy/docker-compose.yml` maps **465** and **587** to the host. Port 465
|
`deploy/docker-compose.yml` maps **465**, **587**, and **25** to the host. Port
|
||||||
(smtps) is always active. Port **587** is published even when
|
465 (smtps) is always active. Port **587** is published even when
|
||||||
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true` — harmless,
|
`SUBMISSION_ENABLE=false`; nothing listens until you set it to `true`. Port
|
||||||
but it can look like an open port in external scans.
|
**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
|
### Local trial
|
||||||
|
|
||||||
@@ -165,7 +172,7 @@ cat ./data/setup-token
|
|||||||
#### Fixed image tag
|
#### Fixed image tag
|
||||||
|
|
||||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||||
deliberately never `:latest`. The current pin is `1.3.0`. Intermediate
|
deliberately never `:latest`. The current pin is `1.7.0`. Intermediate
|
||||||
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
CHANGELOG sections (`0.2.0`…`0.6.0`) record development cuts from before that
|
||||||
image was published. Pinning matters because of the backup version check (see
|
image was published. Pinning matters because of the backup version check (see
|
||||||
[Full backup and restore](#full-backup-and-restore)): the panel binary's
|
[Full backup and restore](#full-backup-and-restore)): the panel binary's
|
||||||
@@ -185,9 +192,17 @@ 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` |
|
| `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` |
|
| `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` |
|
||||||
|
| `DMARC_REPORTS_ENABLE` | When `true`, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip/XML, and show summaries under *DMARC*. Off by default. See [DMARC reports](#dmarc-reports). | `false` | `.env` |
|
||||||
|
| `DMARC_RATE_LIMIT_MESSAGES_PER_IP` | Per-client-IP cap on port 25 when DMARC ingest is on (shared listener with inbound relay if both are enabled). | `20` | `.env` |
|
||||||
|
| `DMARC_MESSAGE_SIZE_LIMIT` | Maximum report message size in bytes when DMARC ingest is on. | `5242880` (5 MiB) | `.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_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` |
|
| `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` |
|
| `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows — the main driver of `/data` growth over time. After the first panel start, change retention on **Settings** (global administrator); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
|
||||||
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` |
|
||||||
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
|
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
|
||||||
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` |
|
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` |
|
||||||
@@ -298,6 +313,18 @@ Schedule `extract-cert.sh` (cron or a timer) alongside Traefik's renewals.
|
|||||||
After sign-in the panel opens on **Status** — the place to answer "is the
|
After sign-in the panel opens on **Status** — the place to answer "is the
|
||||||
service healthy and will mail be accepted?"
|
service healthy and will mail be accepted?"
|
||||||
|
|
||||||
|
### In-panel help
|
||||||
|
|
||||||
|
The panel carries short operator notes so day-to-day work does not require
|
||||||
|
opening this guide. **Help** in the navigation opens an index; each Status or
|
||||||
|
domain card's «?» opens the same text in a side drawer (CSS only, no extra
|
||||||
|
script). That content explains what a check or control *means* — kernel
|
||||||
|
counters, TLS on port 465, forward-confirmed PTR, DNS publish hints, rate
|
||||||
|
limits, and similar. It is not a second copy of this document: installation,
|
||||||
|
reverse-proxy setup, backup/restore, environment variables, and security
|
||||||
|
assumptions stay here in `docs/guide.md` (linked from the drawer and Help
|
||||||
|
page).
|
||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
|
||||||
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
|
`/status` shows supervised processes (Postfix, OpenDKIM, panel), TLS
|
||||||
@@ -313,13 +340,19 @@ against the PTR record the internet publishes for this server's IP
|
|||||||
(forward-confirmed reverse DNS) — see
|
(forward-confirmed reverse DNS) — see
|
||||||
[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
|
[Server-level DNS](#server-level-dns-ptrrdns); use *Re-check* after changing
|
||||||
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
|
DNS. The **Reload configuration** button re-applies OpenDKIM tables and the
|
||||||
Postfix sender map from the database — use it if daemons drifted from what
|
Postfix sender map from the database (and inbound relay maps when
|
||||||
the panel shows after manual edits under `/data`.
|
`INBOUND_RELAY_ENABLE=true`) — use it if daemons drifted from what the panel
|
||||||
|
shows after manual edits under `/data`.
|
||||||
|
|
||||||
### Mail queue and System log
|
### Mail queue and System log
|
||||||
|
|
||||||
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
|
- **Mail queue** (`/mail-queue`) — live view of messages Postfix is still
|
||||||
trying to deliver or deferring.
|
trying to deliver or deferring. A card at the top states this instance's
|
||||||
|
retry policy — first retry delay, later backoff cap, how long a message
|
||||||
|
stays in the queue — from `postconf -h`, read once when the panel starts.
|
||||||
|
A `postconf -e` override inside the container is visible after the next
|
||||||
|
panel (or container) restart. There is no maximum attempt count: Postfix
|
||||||
|
retries until the message is delivered or the queue lifetime runs out.
|
||||||
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
|
- **System log** (`/system-log`) — tail of `/data/log/mail.log` (Postfix and
|
||||||
related daemon lines). The log rotates daily (14 files kept) with a
|
related daemon lines). The log rotates daily (14 files kept) with a
|
||||||
`postfix reload` after each rotation; a background loop checks every six
|
`postfix reload` after each rotation; a background loop checks every six
|
||||||
@@ -332,8 +365,10 @@ the panel shows after manual edits under `/data`.
|
|||||||
`/settings` changes the signed-in user's username and/or password. **Global
|
`/settings` changes the signed-in user's username and/or password. **Global
|
||||||
administrators** also set the panel-wide default DMARC report address (`rua=`)
|
administrators** also set the panel-wide default DMARC report address (`rua=`)
|
||||||
offered when a domain doesn't set its own — see
|
offered when a domain doesn't set its own — see
|
||||||
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins
|
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc) — and how many days
|
||||||
are separate and are not changed here.
|
**Deliveries** rows are kept before the background sweep deletes them (7–365
|
||||||
|
days; takes effect on the next six-hour prune cycle without a container
|
||||||
|
restart). Application SASL logins are separate and are not changed here.
|
||||||
|
|
||||||
### Users
|
### Users
|
||||||
|
|
||||||
@@ -341,12 +376,13 @@ are separate and are not changed here.
|
|||||||
There are two roles:
|
There are two roles:
|
||||||
|
|
||||||
- **Global administrator** — full access to every page and every domain,
|
- **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
|
- **Domain-admin** — scoped to one or more domains assigned by a global
|
||||||
administrator. Sees only those domains' pages, applications, and
|
administrator. Sees only those domains' pages, applications, and
|
||||||
Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
|
Deliveries rows; cannot add or delete domains. `/users`, `/backup`,
|
||||||
`/status`, `/mail-queue`, `/system-log`, and `POST /reload` are not
|
`/status`, `/mail-queue`, `/system-log`, `/inbound`, and `POST /reload` are
|
||||||
reachable (404). A domain-admin can *export* the
|
not reachable (404). A domain-admin can *export* the
|
||||||
domains assigned to them — see
|
domains assigned to them — see
|
||||||
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain).
|
[Exporting and importing a single domain](#exporting-and-importing-a-single-domain).
|
||||||
|
|
||||||
@@ -417,27 +453,114 @@ journal-milter (level 2) is down. There is no per-IP bypass.
|
|||||||
|
|
||||||
### Full backup and restore
|
### Full backup and restore
|
||||||
|
|
||||||
**Full backup** (whole `/data` except `log/`: SQLite, all domains' DKIM keys,
|
**Full backup** is a self-contained project archive: `data/` (SQLite, all
|
||||||
all applications' SASL credentials, `manifest.json` with the version that
|
domains' DKIM keys, all applications' SASL credentials, the Postfix queue,
|
||||||
created it): panel button (*Backup* → *Full backup*), or from the host:
|
`manifest.json` with the version that created it), plus `docker-compose.yml`,
|
||||||
|
`.env`, and `certs/` from the operator directory next to `./data`. Delivery
|
||||||
|
logs under `data/log/` are excluded. The base compose file mounts the project
|
||||||
|
directory read-only at `/selfpost-deploy` so the panel and CLI can read those
|
||||||
|
deploy files — without that mount, *Full backup* refuses with an error.
|
||||||
|
|
||||||
|
#### Taking a full backup
|
||||||
|
|
||||||
|
There are four ways to capture a full backup. They differ in whether the
|
||||||
|
instance keeps running, what goes into the archive, and what happens on restore.
|
||||||
|
|
||||||
|
**1. Panel — while the container is running (no downtime).** *Backup* → *Full
|
||||||
|
backup* (optionally tick *Encrypt with a password*). Same archive as the CLI
|
||||||
|
below.
|
||||||
|
|
||||||
|
**2. `selfpost-backup` CLI — while the container is running (no downtime).**
|
||||||
|
The panel button and this command call the same code path: a consistent SQLite
|
||||||
|
snapshot via `VACUUM INTO`, then a gzip tar of `data/` (minus diagnostics) plus
|
||||||
|
deploy files.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
**Restore** means unpacking that archive into a fresh `/data` bind mount and
|
Use `-o /path/inside/container` to write inside the container instead of
|
||||||
starting a container of the **exact same image version** that created it —
|
stdout. Optional encryption: set `SELFPOST_BACKUP_PASSWORD` or pass
|
||||||
SelfPost refuses to start otherwise and tells you which tag to use. On the
|
`-password-file` — see [Encrypting a backup or
|
||||||
first successful start after restore, `manifest.json` from the archive is
|
export](#encrypting-a-backup-or-export).
|
||||||
**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** —
|
**3. `tar` of the whole project directory — with the service stopped.** Stop
|
||||||
OpenDKIM's tables and Postfix's sender map are re-derived from SQLite and both
|
the instance so nothing is writing to SQLite, then archive the operator
|
||||||
daemons are reloaded, healing any drift between the extracted files and the
|
directory (the folder that contains `docker-compose.yml`, `.env`, `data/`, and
|
||||||
database (the Status page's *Reload configuration* button runs the same step
|
usually `certs/`):
|
||||||
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
|
```sh
|
||||||
restoring a given backup actually requires (see [Fixed image
|
docker compose down
|
||||||
tag](#fixed-image-tag)).
|
tar czf ../selfpost-backup.tar.gz .
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Postfix delivery logs under `data/log/` are included and are often the bulk of
|
||||||
|
the archive; add `--exclude='./data/log'` if you only want state. Unlike the
|
||||||
|
panel/CLI backup, this archive has no `data/manifest.json`, so restore does not
|
||||||
|
run the version guard or the one-time post-restore *Resync* (see
|
||||||
|
[Restore](#restore) below) — a normal `docker compose up` is enough when the
|
||||||
|
files were captured cleanly while stopped.
|
||||||
|
|
||||||
|
**4. `tar` of `./data` only — with the service stopped.** Same as (3), but only
|
||||||
|
the data volume:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose down
|
||||||
|
tar czf ../selfpost-data.tar.gz ./data
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **not** self-contained: you must keep `docker-compose.yml`, `.env`,
|
||||||
|
and `certs/` separately (another backup, or unchanged on the same host).
|
||||||
|
Restore is `tar xzf selfpost-data.tar.gz` into an existing project directory,
|
||||||
|
not into an empty one. Also includes `data/log/` unless you exclude it.
|
||||||
|
|
||||||
|
**Do not `tar` while the container is running.** SQLite uses WAL mode; copying
|
||||||
|
or archiving `./data` (or the whole project directory) while the panel and
|
||||||
|
Postfix are writing can produce an inconsistent database. The panel and CLI
|
||||||
|
backup avoid this by snapshotting the database on a live instance; the `tar`
|
||||||
|
methods avoid it by stopping first.
|
||||||
|
|
||||||
|
| | Panel / `selfpost-backup` | `tar` project dir (stopped) | `tar` `./data` only (stopped) |
|
||||||
|
|--|---------------------------|------------------------------|-------------------------------|
|
||||||
|
| Downtime | None | Yes (`docker compose down`) | Yes |
|
||||||
|
| Self-contained archive | Yes | Yes | No — deploy files separate |
|
||||||
|
| SQLite consistency | `VACUUM INTO` snapshot | Safe when stopped | Safe when stopped |
|
||||||
|
| `data/log/` | Excluded | Included (optional exclude) | Included (optional exclude) |
|
||||||
|
| `data/manifest.json` | Written (version stamp) | Absent | Absent |
|
||||||
|
| Restore version check | Yes — must match image tag | No | No |
|
||||||
|
| Post-restore *Resync* | Yes, on first boot | No | No |
|
||||||
|
| Password encryption (`.spbk`) | Yes | No — encrypt the `.tar.gz` yourself if needed | No |
|
||||||
|
|
||||||
|
Prefer the panel or CLI when you cannot afford downtime. Prefer a stopped `tar`
|
||||||
|
of the whole project directory when you already plan maintenance and want a
|
||||||
|
plain archive without going through the container. Use `tar` of `./data` only
|
||||||
|
when deploy files are managed elsewhere and never change.
|
||||||
|
|
||||||
|
#### Restore
|
||||||
|
|
||||||
|
**Panel and CLI archives** (with `data/manifest.json`) restore by unpacking into
|
||||||
|
an **empty project directory** (not into `./data` alone) and starting a
|
||||||
|
container of the **exact same image version** that created the backup — SelfPost
|
||||||
|
refuses to start otherwise and tells you which tag to use. On the first
|
||||||
|
successful start after restore, `data/manifest.json` 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 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)).
|
||||||
|
|
||||||
|
**Stopped-`tar` archives** (methods 3 and 4 above) have no `manifest.json`, so
|
||||||
|
there is no version guard and no automatic post-restore *Resync*. Unpack a
|
||||||
|
self-contained project archive into an empty directory (or replace `./data` in
|
||||||
|
place for a data-only archive) and `docker compose up -d`. Use an image tag
|
||||||
|
compatible with the data on disk; when in doubt, match the tag that was running
|
||||||
|
when the archive was taken.
|
||||||
|
|
||||||
**Restoring in place** (same host — recovering from data loss, or rolling
|
**Restoring in place** (same host — recovering from data loss, or rolling
|
||||||
back after a bad change):
|
back after a bad change):
|
||||||
@@ -446,16 +569,16 @@ back after a bad change):
|
|||||||
# 1. Stop the instance being replaced
|
# 1. Stop the instance being replaced
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
# 2. Move the current /data aside rather than deleting it, start from empty
|
# 2. Move the current project aside rather than deleting it
|
||||||
mv ./data ./data.before-restore
|
mv . ../selfpost.before-restore
|
||||||
mkdir ./data
|
mkdir selfpost && cd selfpost
|
||||||
|
|
||||||
# 3. Unpack the backup into the fresh directory
|
# 3. Unpack the backup into the fresh directory
|
||||||
tar xzf selfpost-backup.tar.gz -C ./data
|
tar xzf ../selfpost-backup.tar.gz
|
||||||
|
|
||||||
# 4. docker-compose.yml must already pin the exact tag the backup was made
|
# 4. docker-compose.yml in the archive must pin the exact tag the backup was
|
||||||
# with — check the archive's manifest if unsure:
|
# made with — check if unsure:
|
||||||
tar xzf selfpost-backup.tar.gz -O manifest.json
|
tar xzf ../selfpost-backup.tar.gz -O data/manifest.json
|
||||||
|
|
||||||
# 5. Start it and watch the boot
|
# 5. Start it and watch the boot
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -466,35 +589,38 @@ A version mismatch at step 5 refuses to start and leaves `/data` untouched —
|
|||||||
the panel exits with a message naming the tag to use, e.g.:
|
the panel exits with a message naming the tag to use, e.g.:
|
||||||
|
|
||||||
```
|
```
|
||||||
backup: this backup was created by SelfPost 1.2.3 but this image is 1.3.0 — restore into the matching image (selfpost:1.2.3)
|
backup: this backup was created by SelfPost 1.2.3 but this image is 1.5.0 — restore into the matching image (selfpost:1.2.3)
|
||||||
```
|
```
|
||||||
|
|
||||||
Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up
|
Fix the tag in `docker-compose.yml`, `docker compose pull && docker compose up
|
||||||
-d` again — the manifest is still there because the failed boot never got to
|
-d` again — the manifest is still there because the failed boot never got to
|
||||||
delete it.
|
delete it.
|
||||||
|
|
||||||
**Moving to a different host** is the same five steps, just starting cold:
|
**Moving to a different host** is the same flow: create an empty project
|
||||||
bring the compose files and the correct pinned image tag to the new host
|
directory, unpack the backup there, edit `.env` (and `docker-compose.yml` if
|
||||||
(step 1 of [Full deployment](#full-deployment)), put the backup archive in
|
needed) for the new hostname or proxy, then `docker compose up -d`. The archive
|
||||||
place of step 3 above, then redo the reverse-proxy/TLS and DNS steps of a
|
carries `certs/` from the old host — re-issue certificates when the hostname or
|
||||||
[Full deployment](#full-deployment) — the PTR record and the certificate both
|
IP changes. Set up the reverse-proxy vhost separately (not in the backup).
|
||||||
belong to the old IP/host and have to be reissued for the new one; nothing in
|
|
||||||
the backup carries them.
|
|
||||||
|
|
||||||
**Restoring an encrypted (`.spbk`) backup** needs a running container to
|
**Restoring an encrypted (`.spbk`) backup** needs a running container to
|
||||||
decrypt it first — any container with the `selfpost-backup` CLI works; decryption
|
decrypt it first — any container with the `selfpost-backup` CLI works; decryption
|
||||||
does not read `/data` and performs no version check. Start one normally
|
does not read `/data` and performs no version check. Start one normally
|
||||||
(step 5, but on an empty `/data` you haven't unpacked yet), then:
|
(step 5, but on an empty project you have not unpacked yet), then:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz
|
docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
Stop it, wipe `/data` again, and continue from step 2 above with the
|
Stop it, wipe the project directory again, and continue from step 2 above with
|
||||||
resulting `.tar.gz` — see [Encrypting a backup or
|
the resulting `.tar.gz` — see [Encrypting a backup or
|
||||||
export](#encrypting-a-backup-or-export) for the decrypt command's password
|
export](#encrypting-a-backup-or-export) for the decrypt command's password
|
||||||
options.
|
options.
|
||||||
|
|
||||||
|
**Archives from older SelfPost versions** (flat layout: `manifest.json` and
|
||||||
|
`selfpost.db` at the archive root, no `data/` prefix, no deploy files) restore
|
||||||
|
with the previous procedure: `tar xzf backup.tar.gz -C ./data` into a project
|
||||||
|
that already has `docker-compose.yml` and `.env`.
|
||||||
|
|
||||||
Restoring an archive taken **before** a session row was removed can bring
|
Restoring an archive taken **before** a session row was removed can bring
|
||||||
that session back: session rows travel with the backup, and a browser that
|
that session back: session rows travel with the backup, and a browser that
|
||||||
still holds the matching cookie is signed in again on the next request if the
|
still holds the matching cookie is signed in again on the next request if the
|
||||||
@@ -504,25 +630,14 @@ on `/settings` deletes your other sessions, but a global administrator
|
|||||||
resetting another user's password on `/users` does not invalidate that user's
|
resetting another user's password on `/users` does not invalidate that user's
|
||||||
existing sessions.
|
existing sessions.
|
||||||
|
|
||||||
**Alternative: archive `./data` while stopped.** If the service can be taken
|
|
||||||
offline, `docker compose down` then `tar czf selfpost-data.tar.gz ./data` on
|
|
||||||
the host is safe — nothing is writing to SQLite. Unlike the panel/CLI backup
|
|
||||||
this sweeps in `./data/log/` too, which is Postfix's raw log and usually the
|
|
||||||
bulk of the archive; add `--exclude=./data/log` if you only want the state.
|
|
||||||
Do **not** tar `./data` while the container is running: the database uses
|
|
||||||
WAL mode and a naive copy can capture an inconsistent snapshot. The
|
|
||||||
panel/CLI backup remains preferable when you cannot afford downtime because
|
|
||||||
it takes a consistent SQLite snapshot via the Backup API on a live
|
|
||||||
container.
|
|
||||||
|
|
||||||
See also [Exporting and importing a single
|
See also [Exporting and importing a single
|
||||||
domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped
|
domain](#exporting-and-importing-a-single-domain) — a different, domain-scoped
|
||||||
operation that also lives on the *Backup* page (`/backup`).
|
operation that also lives on the *Backup* page (`/backup`).
|
||||||
|
|
||||||
Both a full backup and a domain export are **secrets** — they contain the
|
Both a full backup and a domain export are **secrets** — they contain the
|
||||||
admin password hash (full backup) or working application credentials (domain
|
admin password hash (full backup), TLS private keys and `.env` (full backup),
|
||||||
export) in the clear or in directly reversible form. Treat them like any
|
or working application credentials (domain export) in the clear or in
|
||||||
other credential material: restrict who can read them, don't email them
|
directly reversible form. Treat them like any other credential material: restrict who can read them, don't email them
|
||||||
around — and encrypt them, which SelfPost can do for you.
|
around — and encrypt them, which SelfPost can do for you.
|
||||||
|
|
||||||
#### Encrypting a backup or export
|
#### Encrypting a backup or export
|
||||||
@@ -565,6 +680,91 @@ 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.
|
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.
|
||||||
|
|
||||||
|
### DMARC reports
|
||||||
|
|
||||||
|
Optional ingest of DMARC **aggregate** reports (`rua=`): Postfix accepts mail on
|
||||||
|
port **25** only for addresses you configure, pipes each message to the panel
|
||||||
|
ingest worker, and stores parsed summaries in SQLite. Forensic reports (`ruf=`)
|
||||||
|
are not stored. This is separate from [Inbound relay](#inbound-relay) — no
|
||||||
|
backup-MX, no forwarding upstream.
|
||||||
|
|
||||||
|
**Off by default.** Set `DMARC_REPORTS_ENABLE=true` in `.env` and recreate the
|
||||||
|
container. Until then there is no report ingest, no *DMARC* item in the nav,
|
||||||
|
and `/dmarc` is 404. Outbound 465/587 is unchanged.
|
||||||
|
|
||||||
|
**Addresses.** In *Settings* (global administrator), set the default
|
||||||
|
`rua=` mailbox to an address on `SELFPOST_HOSTNAME`, e.g.
|
||||||
|
`dmarc-reports@mail.example.com`. Per domain you can choose **SelfPost hosted**
|
||||||
|
(`dmarc-reports+<domain>@<hostname>`) under *Domain settings → DMARC reports*.
|
||||||
|
Only those allow-listed addresses are accepted on port 25.
|
||||||
|
|
||||||
|
**DNS.** Publish the usual `_dmarc` TXT on each sending domain with
|
||||||
|
`rua=mailto:…` pointing at your hosted address. Receivers deliver to the
|
||||||
|
address domain — publish **MX** for `SELFPOST_HOSTNAME` (or the report
|
||||||
|
address domain if different) so reports reach this server. If a hub domain
|
||||||
|
authorises external destinations, publish `_report._dmarc` there too; the panel
|
||||||
|
checks it on the domain page.
|
||||||
|
|
||||||
|
**Panel.** *DMARC* (global administrator) lists recent reports and ingest
|
||||||
|
health. Open a domain's roll-up from the list or from *View DMARC reports* on
|
||||||
|
the domain page. Domain administrators see reports only for domains assigned to
|
||||||
|
them. Summaries are pruned (500 kept, 90 days max).
|
||||||
|
|
||||||
|
**Not an open relay.** The inbound smtpd offers no SASL. With DMARC ingest
|
||||||
|
alone, `check_recipient_access` permits only configured report addresses;
|
||||||
|
everything else is rejected. With inbound relay enabled too, both allow-lists
|
||||||
|
apply.
|
||||||
|
|
||||||
|
Parsed report data lives in SQLite and is included in a
|
||||||
|
[full backup](#full-backup-and-restore).
|
||||||
|
|
||||||
## Domain administration
|
## Domain administration
|
||||||
|
|
||||||
### Domains page
|
### Domains page
|
||||||
@@ -572,8 +772,8 @@ With no password set, the CLI keeps writing the plain `.tar.gz` it always has.
|
|||||||
`/domains` lists sending domains and hosts the add-domain form (**global
|
`/domains` lists sending domains and hosts the add-domain form (**global
|
||||||
administrator only**). Domain administrators see only domains assigned to
|
administrator only**). Domain administrators see only domains assigned to
|
||||||
them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL
|
them. Each row shows its DKIM TXT value, SPF/DMARC checks, and SASL
|
||||||
applications. Per-domain rate limits (level 2) and trusted-IP application
|
applications. Per-domain rate limits (level 2), per-application limits, and
|
||||||
overrides are configured here — see [Rate limiting —
|
optional client IP allow-lists are configured here — see [Rate limiting —
|
||||||
level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
|
level 2](#rate-limiting--level-2-domain-and-application). *Export domain*
|
||||||
writes a single-domain archive; *Import a domain* on the Backup page reads
|
writes a single-domain archive; *Import a domain* on the Backup page reads
|
||||||
one back in (**global administrator only**) — see [Exporting and importing a single
|
one back in (**global administrator only**) — see [Exporting and importing a single
|
||||||
@@ -581,17 +781,19 @@ domain](#exporting-and-importing-a-single-domain).
|
|||||||
|
|
||||||
### Domain-level DNS (SPF, DKIM, DMARC)
|
### 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
|
- **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).
|
behalf (e.g. `v=spf1 a mx ip4:<server IP> -all`, adjusted to your setup).
|
||||||
- **DKIM** — a TXT record with the exact value the panel shows on that
|
- **DKIM** — a TXT record with the exact value the panel shows on that
|
||||||
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
domain's page (`domain page → DKIM TXT record`), one selector per domain.
|
||||||
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
|
- **DMARC** — a `_dmarc` TXT record. The panel suggests `p=none` (monitoring
|
||||||
only, safe to publish immediately). On a send-only relay the sending domain
|
only, safe to publish immediately). Set `rua=` to receive aggregate reports:
|
||||||
often has no inbox, so `rua=` is optional — configure a default report address
|
with [DMARC reports](#dmarc-reports) enabled, use a SelfPost-hosted address
|
||||||
in *Settings* (see [Settings](#settings)) or per domain when you have a
|
from *Settings* or per-domain *Domain settings*; otherwise point `rua=` at a
|
||||||
mailbox that receives inbound mail elsewhere. If `rua=` points at another
|
mailbox elsewhere that receives inbound mail. If `rua=` points at another
|
||||||
domain, publish `_report._dmarc` on that hub domain too; the panel checks
|
domain, publish `_report._dmarc` on that hub domain too; the panel checks
|
||||||
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
|
it. Public mail hosts (Gmail, Outlook, …) cannot be used as external
|
||||||
report destinations.
|
report destinations.
|
||||||
@@ -635,12 +837,26 @@ returns a 4xx and the refusal is recorded in [Deliveries](#deliveries) as
|
|||||||
sending as that domain. When unset, only level 1 applies for non-privileged
|
sending as that domain. When unset, only level 1 applies for non-privileged
|
||||||
senders.
|
senders.
|
||||||
|
|
||||||
**Level 2 — application (trusted IPs)** — optional override on an
|
**Client IP allow-list (application)** — optional restriction on an
|
||||||
application: list one or more client IPs and a ceiling **strictly above**
|
application: when enabled, list one or more client IPs that may authenticate
|
||||||
the domain limit (still ≤ level 1). Connections from those IPs use the
|
and submit mail as that application. When disabled, any client IP is allowed.
|
||||||
application ceiling and skip the domain check. Other IPs stay under the
|
This is independent of rate limits.
|
||||||
domain limit (or level 1 alone). An application override without trusted
|
|
||||||
IPs is inactive.
|
**Level 2 — application** — optional override of the domain limit for one
|
||||||
|
application (≤ level 1). The ceiling may be **higher or lower** than the domain
|
||||||
|
limit. When active, it replaces the domain limit for that login; when unset,
|
||||||
|
the domain limit (or level 1 alone) applies.
|
||||||
|
|
||||||
|
**Manual and auto mode** — each domain and application limit can be
|
||||||
|
**Manual** (you set the ceiling and window) or **Auto**. Auto derives
|
||||||
|
`max_messages` from sending statistics: `ceil(average msg/h × multiplier)`
|
||||||
|
over the level-1 window (`RATE_LIMIT_WINDOW_SECONDS`), capped at level 1.
|
||||||
|
The panel shows 30-day statistics (total, peak and average msg/h) on the
|
||||||
|
domain page; level-1 refusals are not in the send log, so totals
|
||||||
|
under-count strict IP limits. When retention is below 30 days, statistics
|
||||||
|
use `min(30, retention)` days. With no traffic in the window, auto stays
|
||||||
|
inactive until messages are sent. Auto limits are recalculated every six
|
||||||
|
hours and on demand via **Recalculate now**.
|
||||||
|
|
||||||
**Level 2 is best-effort, not a guarantee.** It runs inside the
|
**Level 2 is best-effort, not a guarantee.** It runs inside the
|
||||||
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
|
journal-milter and is deliberately fail-open: if the rate-limit lookup hits
|
||||||
@@ -659,20 +875,25 @@ by a [level-2 rate limit](#rate-limiting--level-2-domain-and-application));
|
|||||||
*Details* opens that row's own page (`/deliveries/{id}`). That page carries
|
*Details* opens that row's own page (`/deliveries/{id}`). That page carries
|
||||||
the sending domain, the application it was submitted under, the Postfix
|
the sending domain, the application it was submitted under, the Postfix
|
||||||
queue id and the journal id, beside the message's history — when it was
|
queue id and the journal id, beside the message's history — when it was
|
||||||
accepted and what Postfix later reported for the recipient — and, under
|
accepted and what Postfix later reported for the recipient. A `deferred`
|
||||||
both, the `mail.log` lines for its queue id: the connection to the
|
or `bounced` row includes this Postfix's retry intervals (first delay,
|
||||||
|
backoff cap, queue lifetime), the same numbers Mail queue shows; domain
|
||||||
|
administrators see them here because they cannot open Mail queue. Under
|
||||||
|
both sit the `mail.log` lines for its queue id: the connection to the
|
||||||
receiving server, the server's reply, and the status that reply was filed
|
receiving server, the server's reply, and the status that reply was filed
|
||||||
as. Rows outlive `mail.log`, so an older message's lines may have rotated
|
as. Rows outlive `mail.log`, so an older message's lines may have rotated
|
||||||
away; the page says so. Retention is controlled by
|
away; the page says so. Retention is set on **Settings** (global
|
||||||
`SEND_LOG_RETENTION_DAYS`.
|
administrator); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial
|
||||||
|
default until it is changed there.
|
||||||
|
|
||||||
### Exporting and importing a single domain
|
### Exporting and importing a single domain
|
||||||
|
|
||||||
Domain page → *Export domain* to write the file, *Backup* → *Import a
|
Domain page → *Export domain* to write the file, *Backup* → *Import a
|
||||||
domain* to read it back in. This moves one domain — its DKIM key and its
|
domain* to read it back in. This moves one domain — its DKIM key, its
|
||||||
applications' **working** SASL passwords — to a different SelfPost instance
|
applications' **working** SASL passwords, and configured **rate limits**
|
||||||
without regenerating anything, so DNS (the DKIM TXT record) doesn't need to
|
(mode, ceilings, multipliers) and client IP allow-lists — to a different SelfPost
|
||||||
change. Unlike a full restore (see [Full backup and
|
instance without regenerating anything, so DNS (the DKIM TXT record)
|
||||||
|
doesn't need to change. Unlike a full restore (see [Full backup and
|
||||||
restore](#full-backup-and-restore)), this works across different
|
restore](#full-backup-and-restore)), this works across different
|
||||||
hostnames/instances. *Import* is global-administrator only; *export* is
|
hostnames/instances. *Import* is global-administrator only; *export* is
|
||||||
available to any user who can access the domain, **including a domain-admin**
|
available to any user who can access the domain, **including a domain-admin**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Plan: dmarc-reports
|
# Plan: dmarc-reports
|
||||||
|
|
||||||
**Status:** candidate
|
**Status:** done — shipped in `[1.7.0]` (2026-08-18); security review (Fable)
|
||||||
|
of the ingest path pending.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,3 +49,31 @@ remain a separate, opt-in feature that does not forward mail upstream.
|
|||||||
|
|
||||||
- Attack surface of accepting mail (mitigate: strict recipient allow-list).
|
- Attack surface of accepting mail (mitigate: strict recipient allow-list).
|
||||||
- Report volume and retention (mitigate: caps + pruning).
|
- Report volume and retention (mitigate: caps + pruning).
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
**Ingest path.** `DMARC_REPORTS_ENABLE=true` enables `smtp/inet` on 25 (shared
|
||||||
|
with inbound relay when both are on). Postfix `relay_domains` +
|
||||||
|
`transport_maps` route allow-listed recipients to a `dmarc-ingest` pipe
|
||||||
|
(`panel -dmarc-ingest`). `check_recipient_access` on `dmarc_recipients` is the
|
||||||
|
allow-list; no SASL, no local mailboxes.
|
||||||
|
|
||||||
|
**Schema.** Migration `0008_dmarc_reports.sql`: `dmarc_reports` (summary per
|
||||||
|
aggregate) + `dmarc_report_records` (per-source rows). Dedup on
|
||||||
|
`(reporter, report_id, domain)`.
|
||||||
|
|
||||||
|
**Retention.** Max 500 reports; drop older than 90 days; prune after each
|
||||||
|
ingest.
|
||||||
|
|
||||||
|
Target version cut: **`1.7.0`** (MINOR). One commit per step; code only after
|
||||||
|
roadmap status is **agreed**. Expand the sketch sections above before step 1
|
||||||
|
if still thin. See [development.md](../development.md) § Plan checklists.
|
||||||
|
|
||||||
|
- [x] Expand plan: ingest path, `dmarc_reports` schema, retention caps — **Sonnet**
|
||||||
|
- [x] Opt-in inbound SMTP for report addresses only (allow-list) — **Opus**
|
||||||
|
- [x] Worker: gzip/XML parse → SQLite — **Opus**
|
||||||
|
- [x] Panel: domain roll-up + parsed report (panel-ui mockups) — **Sonnet**
|
||||||
|
- [x] Tie-in `dmarc_report_email` / `domains.dmarc_rua` — **Sonnet**
|
||||||
|
- [x] Tests and [guide.md](../guide.md) — **Sonnet**
|
||||||
|
- [ ] Security review ingest path — **Fable**
|
||||||
|
- [x] `go vet`, `go test` on touched packages — **Haiku**
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# Plan: domain-stats-auto-ratelimit
|
||||||
|
|
||||||
|
**Status:** done — shipped in `[1.6.0]` (2026-08-18); security review (Fable) pending
|
||||||
|
**Date:** 2026-08-17
|
||||||
|
**Version:** `1.x` MINOR; migrations must stay compatible with `1.0.0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Give the operator **30-day sending statistics** per domain and per application
|
||||||
|
(total volume, peak and average rate), and an optional **auto** level-2 rate
|
||||||
|
limit that sets `max_messages` from the average rate (avg × multiplier) over the
|
||||||
|
level-1 window.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- Rolling **30-day** stats on each domain page (domain aggregate + per-app rows):
|
||||||
|
message count, peak msg/h, average msg/h.
|
||||||
|
- Level-2 rate limit mode **manual** (today) or **auto** for domain and
|
||||||
|
application scopes.
|
||||||
|
- Auto formula: `max_messages = ceil(avg_hourly × multiplier)`, window =
|
||||||
|
level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); capped at level 1.
|
||||||
|
- Background recalculation (e.g. every 6 h, alongside send-log prune); milter
|
||||||
|
reads stored `max_messages` / `window_seconds` only (no aggregates on the
|
||||||
|
hot path).
|
||||||
|
- Panel UI: stats card, manual/auto toggle, multiplier field, read-only computed
|
||||||
|
limit in auto mode, optional «Recalculate now».
|
||||||
|
- RBAC: domain-admin sees stats and may configure auto/manual for assigned
|
||||||
|
domains only; same authz as existing rate-limit handlers.
|
||||||
|
- Tests, [guide.md](../guide.md), [CHANGELOG.md](../../CHANGELOG.md); security
|
||||||
|
review (Fable) for rate-limit path changes.
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
|
||||||
|
- Changing level 1 (Postfix env) — auto only fills level 2 under the L1 cap.
|
||||||
|
- Automated IP warmup schedules ([guide.md](../guide.md) § IP warmup stays
|
||||||
|
operator-driven).
|
||||||
|
- Prometheus/Grafana, alerting, APIs.
|
||||||
|
- Counting level-1 refusals or `rejected` rows as sent volume.
|
||||||
|
- Per-client-IP analytics.
|
||||||
|
|
||||||
|
## Data source
|
||||||
|
|
||||||
|
All metrics come from SQLite `send_log`, same rules as
|
||||||
|
[`CountMessages`](../../internal/store/ratelimits.go):
|
||||||
|
|
||||||
|
- One message = one distinct `queue_id` (many recipients = one count).
|
||||||
|
- `status != rejected` (level-2 refusals never queued).
|
||||||
|
- Level-1 refusals are **not** in `send_log` — stats under-count refusals;
|
||||||
|
document in UI copy.
|
||||||
|
|
||||||
|
Retention today is env `SEND_LOG_RETENTION_DAYS` (default 90). Stats use the
|
||||||
|
last **30 days** of rows still present. If retention < 30 days (after
|
||||||
|
[send-log-retention](send-log-retention.md)), the stats window is
|
||||||
|
`min(30, retention)` with a warning.
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
| Metric | Definition |
|
||||||
|
|---|---|
|
||||||
|
| **total** | `COUNT(DISTINCT queue_id)` in the stats window |
|
||||||
|
| **peak rate** | maximum messages in any **hourly** bucket in that window (msg/h) |
|
||||||
|
| **avg rate** | `total / hours_in_window`, where `hours_in_window = min(720, age of domain/app in hours, retention hours)` |
|
||||||
|
|
||||||
|
Keys: `send_log.domain` (domain scope), `send_log.app_login` (application scope).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
milter["journal-milter"] --> sendLog["send_log"]
|
||||||
|
sendLog --> statsQuery["StatsQuery 30d"]
|
||||||
|
statsQuery --> domainPage["domain page stats card"]
|
||||||
|
statsQuery --> autoJob["auto recalc job"]
|
||||||
|
autoJob --> rateLimits["rate_limits"]
|
||||||
|
rateLimits --> milter
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **[`internal/store/stats.go`](../../internal/store/stats.go)** — `SendStats`
|
||||||
|
with `Total`, `PeakPerHour`, `AvgPerHour`; `DomainSendStats(name, since)` /
|
||||||
|
`AppSendStats(login, since)`.
|
||||||
|
2. Hourly buckets: `strftime('%Y-%m-%d %H', created_at)` + `GROUP BY`; subquery
|
||||||
|
for peak; total via distinct `queue_id`.
|
||||||
|
3. Indexes `idx_send_log_domain` and `idx_send_log_created_at` exist; add
|
||||||
|
composite `(domain, created_at)` only if profiling shows need.
|
||||||
|
4. **Auto recalc** — panel goroutine (same interval as send-log prune): for each
|
||||||
|
`rate_limits` row with `mode = auto`, recompute `max_messages`, set
|
||||||
|
`auto_updated_at`. Milter unchanged except reading new columns via existing
|
||||||
|
`RateLimit` lookup.
|
||||||
|
|
||||||
|
### Auto rate limit
|
||||||
|
|
||||||
|
Extend [`RateLimit`](../../internal/store/ratelimits.go):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type RateLimit struct {
|
||||||
|
// existing: Scope, RefID, AllowedIPs, MaxMessages, WindowSeconds
|
||||||
|
Mode string // "manual" | "auto"
|
||||||
|
AutoMultiplier float64 // default 2.5 when Mode == "auto"
|
||||||
|
AutoUpdatedAt time.Time
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Formula:**
|
||||||
|
|
||||||
|
```
|
||||||
|
avg_hourly = total_messages_in_window / hours_in_window
|
||||||
|
max_messages = ceil(avg_hourly * auto_multiplier)
|
||||||
|
window_seconds = L1 window (not editable in auto mode)
|
||||||
|
max_messages = min(max_messages, L1 max)
|
||||||
|
```
|
||||||
|
|
||||||
|
When `total == 0`: auto limit stays **inactive** (same as empty manual limit);
|
||||||
|
UI explains that traffic is required before auto can apply.
|
||||||
|
|
||||||
|
**Application overrides** ([`handlers_ratelimit.go`](../../internal/web/handlers/handlers_ratelimit.go)):
|
||||||
|
|
||||||
|
- Trusted IPs required.
|
||||||
|
- Auto app ceiling **strictly above** domain limit when domain limit is active.
|
||||||
|
- Ceiling ≤ L1.
|
||||||
|
|
||||||
|
**Fail-open:** store errors during recalc must not weaken enforcement of the
|
||||||
|
last successfully written limit; recalc failures are logged only.
|
||||||
|
|
||||||
|
### Migration (`0006_rate_limit_auto.sql`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE rate_limits ADD COLUMN mode TEXT NOT NULL DEFAULT 'manual'
|
||||||
|
CHECK (mode IN ('manual', 'auto'));
|
||||||
|
ALTER TABLE rate_limits ADD COLUMN auto_multiplier REAL;
|
||||||
|
ALTER TABLE rate_limits ADD COLUMN auto_updated_at TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing rows → `manual`.
|
||||||
|
|
||||||
|
### Domain export
|
||||||
|
|
||||||
|
Today rate limits are **not** exported. This plan adds them (including
|
||||||
|
`mode`, `auto_multiplier`) to domain transfer JSON — document as a boundary
|
||||||
|
change in [guide.md](../guide.md) § Export.
|
||||||
|
|
||||||
|
## Panel UI
|
||||||
|
|
||||||
|
- Domain page ([`domain_detail.html`](../../internal/web/view/templates/domain_detail.html)):
|
||||||
|
- **Sending statistics (30 days)** — total, peak msg/h, avg msg/h.
|
||||||
|
- Per-application stats in the app list.
|
||||||
|
- Rate limit: Manual / Auto, multiplier (e.g. 1.5–5.0, default 2.5), read-only
|
||||||
|
computed max/window in auto mode, «Recalculate now».
|
||||||
|
- Optional later: «30d» column on domain list (global admin only).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `internal/store/stats_test.go` — fixtures → total / peak / avg.
|
||||||
|
- `internal/store/ratelimits_test.go` — auto recalc, L1 cap, app > domain.
|
||||||
|
- Handler tests — auto form validation, multiplier bounds.
|
||||||
|
- Milter tests — enforced limit matches last recalculated values.
|
||||||
|
|
||||||
|
`go test` / `go vet` on touched packages.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Domain and app 30-day stats visible on the domain page; domain-admin scoping
|
||||||
|
enforced.
|
||||||
|
- Manual/auto toggle works for domain and app; auto recalc updates `rate_limits`
|
||||||
|
and milter enforces stored ceilings.
|
||||||
|
- Zero-traffic auto stays inactive with clear UI copy.
|
||||||
|
- [guide.md](../guide.md) and [CHANGELOG.md](../../CHANGELOG.md) updated;
|
||||||
|
security review passed.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Heavy aggregation on large `send_log` tables — mitigate with indexes or
|
||||||
|
nightly rollups (phase 2).
|
||||||
|
- Stats without level-1 visibility — mitigate with operator-facing caveat.
|
||||||
|
- Auto limit too tight after a spike — multiplier is operator-tuned; show peak
|
||||||
|
alongside avg in auto UI.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- [`send-log-retention`](send-log-retention.md) is a separate roadmap item but
|
||||||
|
should land before or in parallel so operators can set retention ≥ 30 days
|
||||||
|
from the panel.
|
||||||
|
|
||||||
|
**Version:** `1.x` MINOR.
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
Target version cut: **`1.6.0`** (MINOR). One commit per step; code only after
|
||||||
|
roadmap status is **agreed**. See [development.md](../development.md) § Plan
|
||||||
|
checklists.
|
||||||
|
|
||||||
|
- [x] Migration `0007_rate_limit_auto.sql` (`mode`, `auto_multiplier`, `auto_updated_at`) — **Opus**
|
||||||
|
- [x] `internal/store/stats.go`: total / peak / avg over 30 days — **Opus**
|
||||||
|
- [x] Auto recalc job (6h): `ceil(avg × multiplier)`, L1 cap, fail-open on error — **Opus**
|
||||||
|
- [x] Extend `RateLimit` + handler forms (manual/auto) — **Opus**
|
||||||
|
- [x] Domain page stats card + per-app stats (`domain_detail.html`) — **Sonnet**
|
||||||
|
- [x] Domain export JSON includes rate limits — **Opus**
|
||||||
|
- [x] Milter tests — enforced limit matches stored ceiling — **Opus**
|
||||||
|
- [x] Store and handler tests — **Sonnet**
|
||||||
|
- [x] [guide.md](../guide.md) — **Sonnet**
|
||||||
|
- [ ] Security review rate-limit path — **Fable** (pending post-release)
|
||||||
|
- [x] `go vet`, `go test` on touched packages — **Haiku**
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# Plan: inbound-antispam-panel
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Date:** 2026-08-19
|
||||||
|
**Version:** `1.10.0` MINOR (opt-in; no change to outbound-only or inbound-without-filter paths).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Give the operator a **panel view of inbound anti-spam decisions** and **editable
|
||||||
|
allow/deny lists** that tune filter behaviour — without SelfPost shipping or
|
||||||
|
starting an anti-spam engine ([product.md](../product.md) out-of-scope list
|
||||||
|
unchanged).
|
||||||
|
|
||||||
|
Typical journal row:
|
||||||
|
|
||||||
|
| When | From | To | Subject | Decision |
|
||||||
|
|------|------|----|---------|----------|
|
||||||
|
| 2026-08-19 09:14 | `spammer@evil.example` | `user@inbound.example` | Cheap pills | reject (rspamd: BAYES_SPAM +0.95) |
|
||||||
|
|
||||||
|
Lists let the operator correct false positives/negatives (e.g. always allow
|
||||||
|
`billing@vendor.example`, block `192.0.2.0/24`).
|
||||||
|
|
||||||
|
## Context (as-built)
|
||||||
|
|
||||||
|
- Inbound relay ships in `[1.4.0]` (`INBOUND_RELAY_ENABLE`); optional
|
||||||
|
`INBOUND_ANTISPAM_MILTER` attaches an **external** filter (documented rspamd
|
||||||
|
sidecar in [deploy/antispam/docker-compose.antispam.yml](../../deploy/antispam/docker-compose.antispam.yml)).
|
||||||
|
- The inbound smtpd has **no journal-milter** today — only outbound 465/587
|
||||||
|
writes `send_log` ([architecture.md](../architecture.md)).
|
||||||
|
- Inbound UI and handlers are **global-administrator only** today
|
||||||
|
(`requireInbound` → `requireGlobal`).
|
||||||
|
|
||||||
|
## Quarantine
|
||||||
|
|
||||||
|
**Not in this plan.** Held-mail review/release is tracked separately as roadmap
|
||||||
|
candidate [inbound-quarantine](inbound-quarantine.md). The journal may still
|
||||||
|
record a `quarantine` **decision label** when the external filter reports it,
|
||||||
|
without SelfPost storing the message.
|
||||||
|
|
||||||
|
**DMARC `p=quarantine`** remains unrelated (outbound policy for receivers).
|
||||||
|
|
||||||
|
## Decisions (closed 2026-08-19)
|
||||||
|
|
||||||
|
### Capture mechanism
|
||||||
|
|
||||||
|
**Inbound journal-milter in `panel` only** — no rspamd history API polling in v1.
|
||||||
|
A separate unix socket (`INBOUND_JOURNAL_MILTER_SOCKET`, default
|
||||||
|
`/run/selfpost/inbound-journal.sock`) on the inbound smtpd only; fail-open
|
||||||
|
(`default_action=accept`).
|
||||||
|
|
||||||
|
**Milter order on port 25** (when antispam is enabled):
|
||||||
|
|
||||||
|
```text
|
||||||
|
smtpd_milters = { antispam, inbound-journal }
|
||||||
|
```
|
||||||
|
|
||||||
|
Antispam runs first; inbound-journal runs at end-of-message on the **accept**
|
||||||
|
path and reads rspamd-added headers (`X-Rspamd-Action`, `X-Rspamd-Score`, symbol
|
||||||
|
summary when present).
|
||||||
|
|
||||||
|
**Antispam rejects** do not reach end-of-message on the journal milter. v1 also
|
||||||
|
tails `mail.log` for inbound `postfix/smtpd` **`milter-reject`** / **`reject`**
|
||||||
|
lines and inserts journal rows (client IP, envelope from/to when logged, reason
|
||||||
|
text, subject empty if rejected before DATA). DMARC-ingest pipe traffic is out
|
||||||
|
of scope for this tailer.
|
||||||
|
|
||||||
|
### Journal without `INBOUND_ANTISPAM_MILTER`
|
||||||
|
|
||||||
|
Journal stays **on** whenever `INBOUND_RELAY_ENABLE=true` (not tied to the
|
||||||
|
antispam hook). Without an external filter:
|
||||||
|
|
||||||
|
- **accept** rows — message relayed (engine `selfpost`, detail empty);
|
||||||
|
- **reject** / **tempfail** rows — from the mail.log tailer (Postfix policy,
|
||||||
|
size limit, unknown recipient, etc.).
|
||||||
|
|
||||||
|
Panel copy explains that rspamd symbols appear only when the antispam milter is
|
||||||
|
configured.
|
||||||
|
|
||||||
|
### RBAC
|
||||||
|
|
||||||
|
**Global administrator only** for journal and lists — same as inbound relay. No
|
||||||
|
domain-admin access in v1.
|
||||||
|
|
||||||
|
### Retention
|
||||||
|
|
||||||
|
Mirror send-log retention:
|
||||||
|
|
||||||
|
- Settings key `inbound_spam_log_retention_days` on `/settings` (global admin).
|
||||||
|
- Default **90** days; validation **7–365**; seed from env
|
||||||
|
`INBOUND_SPAM_LOG_RETENTION_DAYS` when the setting is missing.
|
||||||
|
- Prune every **6 hours** (same cadence as send-log retention).
|
||||||
|
- Hard cap **10 000** rows after age prune (drop oldest) — busier than DMARC
|
||||||
|
reports, lighter than unbounded growth.
|
||||||
|
|
||||||
|
### Allow/deny lists
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|-------|----------|
|
||||||
|
| Entry types | sender address, sender domain (`@domain` suffix), client IP / CIDR |
|
||||||
|
| Precedence | **deny overrides allow** |
|
||||||
|
| Scope | instance-wide (not per inbound domain in v1) |
|
||||||
|
| Where applied | **rspamd map files** under `/data/antispam/` (allow.map, deny.map); documented compose fragment mounts them into the sidecar |
|
||||||
|
| SelfPost pre-check | **out of v1** — lists live in rspamd only |
|
||||||
|
| Edit access | global administrator only |
|
||||||
|
| When inactive | lists editable in the panel but UI warns that sync applies only when `INBOUND_ANTISPAM_MILTER` is set; `postfix reload` / rspamd reload documented in guide |
|
||||||
|
|
||||||
|
List change → atomic map rewrite (same pattern as Postfix maps) → rspamd reload
|
||||||
|
via documented operator step or sidecar `SIGHUP` in the compose fragment.
|
||||||
|
|
||||||
|
### Activation
|
||||||
|
|
||||||
|
| Condition | Behaviour |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `INBOUND_RELAY_ENABLE=false` | no inbound journal milter, no UI, no tables written |
|
||||||
|
| inbound on, antispam empty | journal + UI; lists visible but marked inactive for filter sync |
|
||||||
|
| inbound on + antispam set | full journal (symbols on accept) + list sync |
|
||||||
|
|
||||||
|
No separate feature flag beyond inbound relay + existing antispam env vars.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
New nav item **Inbound spam** (or subsection under **Inbound**) — journal table
|
||||||
|
with filters (decision, domain, date range), list management on the same page or
|
||||||
|
a tab. Mockup: add `docs/assets/panel-ui/inbound_spam.html` in the panel step.
|
||||||
|
|
||||||
|
## Schema (migration `0010_inbound_spam_log.sql`)
|
||||||
|
|
||||||
|
**`inbound_spam_log`**
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | INTEGER PK | |
|
||||||
|
| `inbound_domain` | TEXT NOT NULL | recipient domain (from `relay_domains`) |
|
||||||
|
| `client_ip` | TEXT NOT NULL | |
|
||||||
|
| `from_addr` | TEXT NOT NULL | envelope from |
|
||||||
|
| `to_addr` | TEXT NOT NULL | envelope to (one row per recipient) |
|
||||||
|
| `subject` | TEXT NOT NULL | truncated with `mailhdr.SubjectMaxRunes` (200) |
|
||||||
|
| `decision` | TEXT NOT NULL | `accept`, `reject`, `tempfail`, `quarantine` |
|
||||||
|
| `engine` | TEXT NOT NULL | `rspamd`, `postfix`, `selfpost` |
|
||||||
|
| `detail` | TEXT NOT NULL | symbol/score summary or log-line reason; may be empty |
|
||||||
|
| `created_at` | TEXT NOT NULL | RFC3339 UTC |
|
||||||
|
|
||||||
|
Index on `(created_at)`, `(inbound_domain, created_at)`.
|
||||||
|
|
||||||
|
**`inbound_spam_list`**
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `id` | INTEGER PK | |
|
||||||
|
| `list_type` | TEXT NOT NULL | `allow` or `deny` |
|
||||||
|
| `entry_type` | TEXT NOT NULL | `address`, `domain`, `ip` |
|
||||||
|
| `value` | TEXT NOT NULL | normalized entry |
|
||||||
|
| `note` | TEXT NOT NULL | operator comment; default `''` |
|
||||||
|
| `created_at` | TEXT NOT NULL | |
|
||||||
|
|
||||||
|
Unique on `(list_type, entry_type, value)`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- Schema, store CRUD, retention loop, mail.log tailer extension for inbound
|
||||||
|
rejects.
|
||||||
|
- Inbound journal-milter (fail-open) + Postfix `postfix-config.sh` wiring.
|
||||||
|
- rspamd map sync under `/data/antispam/`.
|
||||||
|
- Panel journal + list UI (global admin).
|
||||||
|
- Full backup includes `/data/antispam/` and new SQLite tables.
|
||||||
|
- Tests; [guide.md](../guide.md); [architecture.md](../architecture.md).
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
|
||||||
|
- Shipping rspamd, ClamAV, or any filter binary inside the SelfPost image.
|
||||||
|
- Outbound spam filtering.
|
||||||
|
- [inbound-quarantine.md](inbound-quarantine.md) storage/release.
|
||||||
|
- rspamd rule editing, Bayes training UI, antivirus.
|
||||||
|
- MIME replay from the panel (metadata only).
|
||||||
|
- Per-domain lists; domain-admin RBAC.
|
||||||
|
- rspamd history API polling.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
internet["Internet :25"] --> inbound["Postfix inbound smtpd"]
|
||||||
|
inbound --> antispam["External antispam milter optional"]
|
||||||
|
antispam --> journalM["inbound-journal milter fail-open"]
|
||||||
|
journalM --> upstream["smtp upstream transport"]
|
||||||
|
journalM --> sqlite["inbound_spam_log"]
|
||||||
|
mailog["mail.log tailer"] --> sqlite
|
||||||
|
listsUI["Panel allow/deny lists"] --> maps["/data/antispam/*.map"]
|
||||||
|
maps --> antispam
|
||||||
|
sqlite --> journalUI["Panel inbound spam journal"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Same validation whitelists as inbound relay ([security.md](../security.md)).
|
||||||
|
- Journal rows contain PII — retention and global-admin-only access.
|
||||||
|
- Inbound journal milter fail-open must not block mail (backup-MX role).
|
||||||
|
- List map writes injection-safe; deny wins over allow.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- With inbound relay + antispam sidecar, panel shows accept rows with symbols
|
||||||
|
and reject rows from mail.log tailing.
|
||||||
|
- Operator can add/remove list entries; a test message reflects allow/deny.
|
||||||
|
- With antispam hook off, journal still shows Postfix-level decisions; list UI
|
||||||
|
shows inactive sync notice.
|
||||||
|
- `go vet`, `go test`, image build green; guide and architecture updated.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- rspamd header/symbol format drift — thin parser, pin sidecar tag in compose.
|
||||||
|
- Journal volume — retention + 10k cap.
|
||||||
|
- mail.log parse brittleness — unit tests on sample lines; inbound smtpd only.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- [inbound-relay.md](inbound-relay.md) (shipped).
|
||||||
|
|
||||||
|
## Model routing (this plan)
|
||||||
|
|
||||||
|
| Role | Model |
|
||||||
|
|------|-------|
|
||||||
|
| All implementation (code, tests, docs) | **Composer** |
|
||||||
|
| Technical review (reviewer ≠ author) | **Opus** |
|
||||||
|
| Security review | **Fable** |
|
||||||
|
|
||||||
|
Composer ships each step; Opus reviews the accumulated diff once implementation
|
||||||
|
steps are done (fix loop: Composer addresses Opus findings, Opus re-checks).
|
||||||
|
Fable runs after Opus sign-off.
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
Target version cut: **`1.10.0`** (MINOR). One commit per step;
|
||||||
|
[development.md](../development.md) § Plan checklists.
|
||||||
|
|
||||||
|
- [x] Agree journal fields, retention, and RBAC (this plan § Decisions) — **Composer**
|
||||||
|
- [ ] Migration `0010_inbound_spam_log.sql` — **Composer**
|
||||||
|
- [ ] Inbound journal-milter + Postfix wiring — **Composer**
|
||||||
|
- [ ] mail.log tailer: inbound reject rows — **Composer**
|
||||||
|
- [ ] List CRUD + validation + atomic rspamd map sync — **Composer**
|
||||||
|
- [ ] Panel: journal + lists UI (+ mockup) — **Composer**
|
||||||
|
- [ ] Backup includes `/data/antispam/` — **Composer**
|
||||||
|
- [ ] Unit + handler tests — **Composer**
|
||||||
|
- [ ] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer**
|
||||||
|
- [ ] `go vet`, `go test`, e2e if applicable — **Composer**
|
||||||
|
- [ ] Technical review of inbound antispam changes — **Opus**
|
||||||
|
- [ ] Security review inbound antispam path — **Fable**
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Plan: inbound-quarantine
|
||||||
|
|
||||||
|
**Status:** candidate — design TBD; needs explicit agreement before any code
|
||||||
|
**Version:** TBD (`1.x` MINOR if opt-in and backward-compatible; may warrant `2.x`
|
||||||
|
if it changes inbound storage or backup contract).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal (draft)
|
||||||
|
|
||||||
|
Let the operator **hold suspicious inbound mail** instead of only accept/reject
|
||||||
|
at the milter — review in the panel, then release to the configured upstream,
|
||||||
|
delete, or add sender to allow/deny lists.
|
||||||
|
|
||||||
|
This is a **product boundary question**, not a settled design. The item exists
|
||||||
|
so we can compare approaches before committing.
|
||||||
|
|
||||||
|
## Why it is separate from inbound-antispam-panel
|
||||||
|
|
||||||
|
[inbound-antispam-panel.md](inbound-antispam-panel.md) covers **metadata**
|
||||||
|
(journal + lists). Quarantine implies **storing message bodies** (or a durable
|
||||||
|
reference to them) under `/data`, retention, release workflow, and a wider
|
||||||
|
attack/backup surface — closer to "mini-mailbox" than pure relay.
|
||||||
|
|
||||||
|
## Open questions (to decide before a checklist)
|
||||||
|
|
||||||
|
1. **Where mail lives**
|
||||||
|
- SelfPost store under `/data/quarantine/` (panel owns lifecycle), or
|
||||||
|
- delegate to rspamd/Redis and panel only proxies release (less storage in
|
||||||
|
SelfPost, tighter rspamd coupling).
|
||||||
|
2. **What "release" means**
|
||||||
|
- inject into Postfix as a new delivery to the domain's upstream transport;
|
||||||
|
- or manual download only (no automatic forward).
|
||||||
|
3. **Retention and caps**
|
||||||
|
- max age, max total bytes, per-domain limits; interaction with backup size.
|
||||||
|
4. **RBAC**
|
||||||
|
- global admin only vs domain-admin for assigned inbound domains.
|
||||||
|
5. **Threat model**
|
||||||
|
- malware in stored MIME, path traversal on extract, quota DoS on busy MX.
|
||||||
|
6. **Product fit**
|
||||||
|
- does this violate [product.md](../product.md) "no mailboxes" if we only
|
||||||
|
hold spam suspects briefly? Explicit product decision required.
|
||||||
|
|
||||||
|
## Context (as-built)
|
||||||
|
|
||||||
|
- Inbound relay forwards or rejects; no local delivery ([inbound-relay.md](inbound-relay.md)).
|
||||||
|
- Optional `INBOUND_ANTISPAM_MILTER` — external rspamd may quarantine on its
|
||||||
|
own today, without panel integration.
|
||||||
|
- DMARC `p=quarantine` is unrelated (outbound policy for receivers).
|
||||||
|
|
||||||
|
## Likely out of scope (until revisited)
|
||||||
|
|
||||||
|
- IMAP/webmail for quarantined mail.
|
||||||
|
- End-user self-service quarantine (non-admin recipients).
|
||||||
|
- Outbound quarantine.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- [inbound-relay.md](inbound-relay.md) (shipped).
|
||||||
|
- Sensible to decide **after** or **alongside**
|
||||||
|
[inbound-antispam-panel.md](inbound-antispam-panel.md) (journal/lists), but
|
||||||
|
not blocked on it for design discussion.
|
||||||
|
|
||||||
|
## Done when (placeholder)
|
||||||
|
|
||||||
|
TBD once approach is chosen. Minimum bar would include: opt-in flag, panel
|
||||||
|
list/detail, release or discard action, retention, backup inclusion documented,
|
||||||
|
security review.
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
None yet — expand this plan after the open questions above are answered.
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
# Plan: inbound-relay (inbound relay)
|
# Plan: inbound-relay (inbound relay)
|
||||||
|
|
||||||
**Status:** agreed
|
**Status:** done — shipped in `[1.4.0]` (2026-08-17)
|
||||||
**Version:** target bump **1.x** MINOR; **`2.x` possible** — to be settled once
|
**Version:** 1.4.0 MINOR (flag off is compatible; not a 2.x break).
|
||||||
the implementation lands (do not fix a major in advance).
|
**Order:** after queue-retries `[1.3.1]`. One checklist step remains:
|
||||||
**Order:** the 2026-08-13 full-tree review follow-ups are closed (CHANGELOG
|
security review of the inbound path (Fable).
|
||||||
`[Unreleased]`), so this is next up as the largest remaining 1.x+ **feature**.
|
|
||||||
The domain-admin role
|
|
||||||
and `internal/web` split have shipped — see [CHANGELOG](../../CHANGELOG.md)
|
|
||||||
`[1.2.0]`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -169,3 +165,25 @@ the SelfPost image, brought up by the operator when the option is enabled.
|
|||||||
|
|
||||||
A finished outbound path (already implemented). Agreement obtained — see the
|
A finished outbound path (already implemented). Agreement obtained — see the
|
||||||
status above.
|
status above.
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
- [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**
|
||||||
|
- [x] `go vet`, `go test`, e2e if applicable — **Haiku**
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Plan: panel-docs (in-panel operator documentation)
|
||||||
|
|
||||||
|
**Status:** agreed
|
||||||
|
**Date:** 2026-08-17
|
||||||
|
**Version:** `1.x` MINOR; `candidate` until explicitly agreed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Built-in operator documentation in the panel — short pages or a help drawer that
|
||||||
|
explain what each Status check and other controls mean, without sending the
|
||||||
|
operator out to [guide.md](../guide.md).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- Help drawer or short help pages (CSS-checkbox pattern from
|
||||||
|
[panel-ui mockups](../assets/panel-ui/system.html)).
|
||||||
|
- Seed content: Status blurbs removed from cards for a denser layout — Machine
|
||||||
|
(kernel counters / rate window), TLS certificate (port 465, reverse-proxy
|
||||||
|
mount), Hostname / reverse DNS (FCrDNS, PTR at the hosting provider), and
|
||||||
|
similar notes for other surfaces as inline commentary is removed.
|
||||||
|
- «?» entry points on domain cards (mockups).
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
|
||||||
|
- A second copy of the full operator guide.
|
||||||
|
- Translation workflow beyond English (same as the rest of the panel).
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
An operator can open help from the panel for those topics; the removed Status
|
||||||
|
blurbs are preserved there (or equivalent); no requirement to read the git
|
||||||
|
tree for day-to-day meaning of a card.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
Copy ownership and keeping help in sync when checks change; not bloating every
|
||||||
|
page with a second column of prose.
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
Target version cut: **`1.8.0`** (MINOR). One commit per step; code only after
|
||||||
|
roadmap status is **agreed**. See [development.md](../development.md) § Plan
|
||||||
|
checklists.
|
||||||
|
|
||||||
|
- [x] Help drawer / pages shell (CSS checkbox pattern from mockups) — **Sonnet**
|
||||||
|
- [x] Seed Status blurbs (Machine, TLS, PTR, …) — **Sonnet**
|
||||||
|
- [x] «?» entry points on domain cards — **Sonnet**
|
||||||
|
- [x] [guide.md](../guide.md) boundary: in-panel help vs full guide — **Sonnet**
|
||||||
|
- [x] Template tests — **Sonnet**
|
||||||
|
- [x] `go vet`, `go test` on touched packages — **Haiku**
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Plan: queue-retries (Postfix retry policy in the panel)
|
# Plan: queue-retries (Postfix retry policy in the panel)
|
||||||
|
|
||||||
**Status:** agreed
|
**Status:** done — shipped in `[1.3.1]` (2026-08-17)
|
||||||
**Date:** 2026-08-13
|
**Date:** 2026-08-13
|
||||||
**Version:** patch; no schema, no configuration surface.
|
**Version:** patch; no schema, no configuration surface.
|
||||||
**Order:** small panel item; does not wait on inbound-relay.
|
**Order:** small panel item; does not wait on inbound-relay.
|
||||||
@@ -139,3 +139,18 @@ lifetime runs out.
|
|||||||
- Showing compiled-in fallbacks when `postconf` failed would mislead if the
|
- Showing compiled-in fallbacks when `postconf` failed would mislead if the
|
||||||
operator had overridden them — mitigate with the muted note on the card.
|
operator had overridden them — mitigate with the muted note on the card.
|
||||||
- Inventing a max-attempt count would be false; the copy must stay time-based.
|
- Inventing a max-attempt count would be false; the copy must stay time-based.
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
Target version cut: **`1.3.1`** (PATCH). One commit per step; see
|
||||||
|
[development.md](../development.md) § Plan checklists.
|
||||||
|
|
||||||
|
- [x] `internal/postfix`: parse Postfix time units (`5d`, `300s`, bare seconds) + tests — **Opus**
|
||||||
|
- [x] `internal/postfix`: one-shot `postconf -h` (six keys), fallback + warn — **Opus**
|
||||||
|
- [x] Load policy at HTTP start in `cmd/panel/httpserver.go`; cache on handlers config — **Opus**
|
||||||
|
- [x] Human-readable duration formatter (shared by Mail queue card and delivery history) — **Sonnet**
|
||||||
|
- [x] «How delivery retries work» card on `/mail-queue` (outside HTMX fragment) — **Sonnet**
|
||||||
|
- [x] `deliveryEvents(row, policy)` — intervals in deferred/bounced copy — **Sonnet**
|
||||||
|
- [x] Handler and template tests (`handlers_monitor_test.go`, `templates_test.go`) — **Sonnet**
|
||||||
|
- [x] [guide.md](../guide.md) and [architecture.md](../architecture.md) — **Sonnet**
|
||||||
|
- [x] `go vet`, `go test` on touched packages — **Haiku**
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Plan: send-log-retention
|
||||||
|
|
||||||
|
**Status:** done — shipped in `[1.5.0]` (2026-08-18)
|
||||||
|
**Date:** 2026-08-17
|
||||||
|
**Version:** `1.5.0` MINOR; no schema migration required (uses existing `settings` table).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let the **global administrator** change how long delivery journal rows
|
||||||
|
(`send_log`, `/deliveries`) are kept, from the panel — without editing `.env`.
|
||||||
|
|
||||||
|
## Context (as-built)
|
||||||
|
|
||||||
|
Retention **already exists**, but only via environment:
|
||||||
|
|
||||||
|
- `SEND_LOG_RETENTION_DAYS` (default **90**) in `.env` / Compose.
|
||||||
|
- [`cmd/panel/main.go`](../../cmd/panel/main.go) passes it to
|
||||||
|
[`logtail.Run`](../../internal/logtail/logtail.go).
|
||||||
|
- [`retentionLoop`](../../internal/logtail/logtail.go) prunes via
|
||||||
|
[`DeleteSendLogBefore`](../../internal/store/sendlog.go) every **6 hours**.
|
||||||
|
- No panel control; [`handlers_monitor.go`](../../internal/web/handlers/handlers_monitor.go)
|
||||||
|
hardcodes «ninety days» in copy.
|
||||||
|
- Migration `0001_init.sql` describes `settings` as the place for «retention
|
||||||
|
overrides», but no UI writes that key yet.
|
||||||
|
|
||||||
|
This plan moves the **effective** retention into SQLite `settings`, with env as
|
||||||
|
bootstrap only.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- Settings card on `/settings` (global administrator only): **Send log
|
||||||
|
retention (days)**.
|
||||||
|
- Key `send_log_retention_days` in [`settings`](../../internal/store/settings.go).
|
||||||
|
- Validation: integer range **7–365** (exact bounds fixed at implementation).
|
||||||
|
- On first use: if setting missing, seed from env
|
||||||
|
(`SEND_LOG_RETENTION_DAYS`, default 90) at panel start or first save.
|
||||||
|
- Log-tailer reads the setting **each prune cycle** (no container restart).
|
||||||
|
- Delivery pages and guide copy show the **current** retention, not a hardcoded
|
||||||
|
90.
|
||||||
|
- Tests; [guide.md](../guide.md); [CHANGELOG.md](../../CHANGELOG.md).
|
||||||
|
|
||||||
|
**Out:**
|
||||||
|
|
||||||
|
- Per-domain retention (instance-wide only).
|
||||||
|
- `mail.log` rotation (logrotate, 14 daily files — unchanged).
|
||||||
|
- Immediate prune on save when lowering retention (next 6 h cycle is enough;
|
||||||
|
optional «Prune now» not in v1).
|
||||||
|
- Domain-admin access to this setting.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
settingsPage["/settings form"] --> sqlite["settings.send_log_retention_days"]
|
||||||
|
env["SEND_LOG_RETENTION_DAYS bootstrap"] --> sqlite
|
||||||
|
sqlite --> retentionLoop["logtail retentionLoop"]
|
||||||
|
retentionLoop --> prune["DeleteSendLogBefore"]
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Read path** — `GetSendLogRetentionDays()`: settings value if valid, else env
|
||||||
|
default.
|
||||||
|
2. **Write path** — POST `/settings` (global admin): validate, `SetSetting`,
|
||||||
|
flash confirmation.
|
||||||
|
3. **Prune path** — change [`logtail.retentionLoop`](../../internal/logtail/logtail.go)
|
||||||
|
to accept `func() int` or `RetentionReader` that queries settings each cycle
|
||||||
|
(same 6 h ticker).
|
||||||
|
4. **Copy** — inject retention days into delivery list/detail templates and
|
||||||
|
remove hardcoded «ninety days».
|
||||||
|
|
||||||
|
`SEND_LOG_RETENTION_DAYS` remains documented in [guide.md](../guide.md) as the
|
||||||
|
**initial default** until changed in Settings.
|
||||||
|
|
||||||
|
## Relation to domain-stats-auto-ratelimit
|
||||||
|
|
||||||
|
[domain-stats-auto-ratelimit](domain-stats-auto-ratelimit.md) uses a **30-day**
|
||||||
|
stats window. Requires effective retention ≥ 30 for full accuracy. When
|
||||||
|
retention < 30:
|
||||||
|
|
||||||
|
- Stats UI shows a warning and uses `min(30, retention)` as the window, or
|
||||||
|
- Settings validation warns when saving a value below 30 while stats/auto are
|
||||||
|
enabled (pick one at implementation; document in guide).
|
||||||
|
|
||||||
|
Recommended roadmap order: **send-log-retention** before or parallel with
|
||||||
|
domain-stats-auto-ratelimit.
|
||||||
|
|
||||||
|
## Panel UI
|
||||||
|
|
||||||
|
New card on [`settings.html`](../../internal/web/view/templates/settings.html)
|
||||||
|
(global admin block, near rate limits or under a «Deliveries» heading):
|
||||||
|
|
||||||
|
- Number input: retention days (7–365).
|
||||||
|
- Muted copy: rows older than this are deleted from `/deliveries`; main driver
|
||||||
|
of `/data` growth; does not affect `mail.log` rotation.
|
||||||
|
|
||||||
|
Domain administrators keep the narrow credentials-only settings page.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Save/load setting; reject out-of-range values.
|
||||||
|
- `retentionLoop` uses updated value without process restart (mock reader).
|
||||||
|
- Bootstrap: empty settings → env default used for prune.
|
||||||
|
- Template/delivery copy reflects configured days.
|
||||||
|
|
||||||
|
`go test` / `go vet` on touched packages.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Global admin can set retention on `/settings`; value persists in SQLite.
|
||||||
|
- Prune uses the panel value on the next cycle; env remains bootstrap default.
|
||||||
|
- Guide documents panel vs env; CHANGELOG entry added.
|
||||||
|
- Hardcoded «ninety days» removed from delivery UI.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Operator lowers retention while bookmarking old delivery URLs — existing
|
||||||
|
behaviour; copy already notes pruned rows are gone.
|
||||||
|
- Settings change without restart — must be tested so log-tailer never keeps a
|
||||||
|
stale int from panel start only.
|
||||||
|
|
||||||
|
**Version:** `1.x` MINOR.
|
||||||
|
|
||||||
|
## Implementation checklist
|
||||||
|
|
||||||
|
Target version cut: **`1.5.0`** (MINOR). One commit per step; code only after
|
||||||
|
roadmap status is **agreed**. See [development.md](../development.md) § Plan
|
||||||
|
checklists.
|
||||||
|
|
||||||
|
- [x] `GetSendLogRetentionDays` / `SetSetting` key `send_log_retention_days` (7–365) — **Opus**
|
||||||
|
- [x] Bootstrap from `SEND_LOG_RETENTION_DAYS` when settings empty — **Opus**
|
||||||
|
- [x] `logtail.retentionLoop`: read setting each prune cycle — **Opus**
|
||||||
|
- [x] Settings card on `/settings` (`settings.html`) — **Sonnet**
|
||||||
|
- [x] Remove hardcoded «ninety days» in handlers and templates — **Sonnet**
|
||||||
|
- [x] Tests: save/load, range, loop without restart — **Sonnet**
|
||||||
|
- [x] [guide.md](../guide.md) — **Sonnet**
|
||||||
|
- [x] `go vet`, `go test` on touched packages — **Haiku**
|
||||||
+10
-6
@@ -50,16 +50,20 @@ Explicitly excluded to prevent scope creep:
|
|||||||
- Organisations / tenancy beyond global + domain-admin roles; managing
|
- Organisations / tenancy beyond global + domain-admin roles; managing
|
||||||
**multiple sending domains** under one global administrator is in scope (see
|
**multiple sending domains** under one global administrator is in scope (see
|
||||||
below)
|
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
|
- A custom MTA — Postfix is used as-is
|
||||||
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
- Dovecot or a full mail stack for SASL — Cyrus SASL (`sasldb2`) only
|
||||||
|
|
||||||
The **domain-admin** role ships in the current line (global administrator plus
|
The **domain-admin** role ships in the current line (global administrator plus
|
||||||
domain administrators with assigned domains). The optional **inbound relay** is
|
domain administrators with assigned domains). The optional **inbound relay**
|
||||||
the main agreed **1.x+** extension still on the
|
(backup-MX / forwarder on port 25) ships in `[1.4.0]`, off by default behind
|
||||||
[roadmap](roadmap.md) — it targets a 1.x MINOR bump by default; a 2.x major
|
`INBOUND_RELAY_ENABLE`; it is relay/forward, not IMAP/webmail. **Send-log
|
||||||
remains possible pending implementation. Items marked *candidate* in the
|
retention in Settings** ships in `[1.5.0]`. **30-day send statistics** and
|
||||||
roadmap require explicit approval before coding.
|
**auto level-2 rate limits** ship in `[1.6.0]`. **DMARC aggregate report
|
||||||
|
ingest** ships in `[1.7.0]`, off by default behind `DMARC_REPORTS_ENABLE`.
|
||||||
|
Items marked *candidate* in the
|
||||||
|
[roadmap](roadmap.md) require explicit approval before coding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+75
-106
@@ -26,66 +26,26 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
|
|
||||||
## Index
|
## Index
|
||||||
|
|
||||||
| ID | Topic | Status | Plan |
|
| ID | Topic | Status | Progress | Plan |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| queue-retries | Postfix retry policy in the panel (queue lifetime, backoff) | **agreed** | [plans/queue-retries.md](plans/queue-retries.md) |
|
| contributing | `CONTRIBUTING.md` | candidate | — | — |
|
||||||
| inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) |
|
| panel-docs | In-panel operator documentation | agreed | 6/6 | [plans/panel-docs.md](plans/panel-docs.md) |
|
||||||
| contributing | `CONTRIBUTING.md` | candidate | — |
|
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | agreed | 1/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
|
||||||
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
|
| inbound-quarantine | Inbound spam quarantine (hold / review / release) | candidate | — | [plans/inbound-quarantine.md](plans/inbound-quarantine.md) |
|
||||||
| panel-docs | In-panel operator documentation | candidate | — |
|
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
|
||||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — |
|
|
||||||
|
|
||||||
**Recommended order** (not binding): **queue-retries** is a small panel item
|
**Recommended order** (not binding): the next feature is **panel-docs** once
|
||||||
that can land first or in parallel; the next feature is **inbound-relay**.
|
agreed. dmarc-reports shipped in
|
||||||
The 2026-08-13 full-tree review follow-ups (send-log authorization,
|
[CHANGELOG.md](../CHANGELOG.md) `[1.7.0]` (security review of the ingest path
|
||||||
fail-closed paths, docs, GUI, tests, licence) are closed — history in
|
pending); domain-stats-auto-ratelimit in `[1.6.0]`; send-log-retention in
|
||||||
[CHANGELOG.md](../CHANGELOG.md) `[Unreleased]` and git. Candidates need
|
`[1.5.0]`; inbound-relay in `[1.4.0]`; queue-retries in `[1.3.1]`; the
|
||||||
|
2026-08-13 full-tree review follow-ups are in `[1.3.0]`. Candidates need
|
||||||
explicit agreement before they join the queue.
|
explicit agreement before they join the queue.
|
||||||
|
|
||||||
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
After a context reset, pick an item marked `agreed` or `in progress`, then work
|
||||||
the checklist in its linked plan.
|
the **Implementation checklist** in its linked plan. The `Progress` column above
|
||||||
|
is `done/total` checklist steps in that plan ([development.md](development.md)
|
||||||
---
|
§ Plan checklists).
|
||||||
|
|
||||||
## queue-retries
|
|
||||||
|
|
||||||
**Goal:** show on Mail queue and on a delivery's history how this Postfix
|
|
||||||
retries deferred mail — first delay, backoff cap, queue lifetime — reading
|
|
||||||
the effective config (`postconf -h`) once at panel start so a manual
|
|
||||||
override is visible.
|
|
||||||
|
|
||||||
**Boundary:** explanation only. Postfix stays as-is; no attempt counter, no
|
|
||||||
panel knobs for queue lifetime, no schema change. Domain administrators see
|
|
||||||
the intervals on `/deliveries/{id}` (they cannot open Mail queue).
|
|
||||||
|
|
||||||
**Done when:** see the criteria in
|
|
||||||
[plans/queue-retries.md](plans/queue-retries.md).
|
|
||||||
|
|
||||||
**Dependencies / risks:** `postconf` unavailable outside the container
|
|
||||||
(fallback + muted note). Copy must stay time-based — Postfix has no max
|
|
||||||
attempt count.
|
|
||||||
**Version:** patch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## inbound-relay
|
|
||||||
|
|
||||||
**Goal:** optional acceptance of mail on port 25 for explicitly configured
|
|
||||||
domains, forwarded to an upstream (backup-MX / relay-forwarder). Off by default
|
|
||||||
(`INBOUND_RELAY_ENABLE=false`); without the flag the outbound path is
|
|
||||||
unchanged.
|
|
||||||
|
|
||||||
**Boundary:** an extension of v1.0 — [product.md](product.md) excludes inbound
|
|
||||||
mail and mailboxes. This is relay/forward, not IMAP/POP3/webmail; an anti-spam
|
|
||||||
engine stays outside the image, only the attachment point is provided.
|
|
||||||
|
|
||||||
**Done when:** see the criteria in
|
|
||||||
[plans/inbound-relay.md](plans/inbound-relay.md).
|
|
||||||
|
|
||||||
**Dependencies / risks:** a finished outbound path; open relay and backscatter;
|
|
||||||
a wider attack surface (port 25 accepting mail).
|
|
||||||
**Version:** target bump `1.x`; `2.x` possible — to be settled once the
|
|
||||||
implementation lands.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -107,33 +67,6 @@ priority.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## dmarc-reports
|
|
||||||
|
|
||||||
**Goal:** SelfPost receives DMARC aggregate reports (RFC 7489) on SMTP,
|
|
||||||
parses the gzip/XML payloads, and shows pass/fail summaries in the panel — so
|
|
||||||
the operator does not need an external DMARC service or a separate mailbox
|
|
||||||
workflow.
|
|
||||||
|
|
||||||
**Boundary:** an extension of v1.0 — not IMAP/webmail and not a general
|
|
||||||
inbound relay. A dedicated inbound path for report messages only; forensic
|
|
||||||
reports (`ruf=`) out of scope for v1.
|
|
||||||
|
|
||||||
**Done when:** see [plans/dmarc-reports.md](plans/dmarc-reports.md).
|
|
||||||
|
|
||||||
**Dependencies / risks:** inbound SMTP in the image (may share infrastructure
|
|
||||||
with [inbound-relay](plans/inbound-relay.md) but must not require backup-MX);
|
|
||||||
storage and retention of parsed summaries; the `dmarc_report_email` setting
|
|
||||||
(migration `0005` moved it off the old `admin` table into `settings`) and
|
|
||||||
`domains.dmarc_rua` added in the DMARC template work must stay the source of
|
|
||||||
truth for `rua=` in DNS guidance.
|
|
||||||
|
|
||||||
**Order:** after the DMARC `rua=` settings ship; may follow or overlap with
|
|
||||||
inbound-relay depending on how port 25 acceptance is structured.
|
|
||||||
|
|
||||||
**Version:** `1.x` MINOR.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## panel-docs
|
## panel-docs
|
||||||
|
|
||||||
**Goal:** built-in operator documentation in the panel — short pages (or a
|
**Goal:** built-in operator documentation in the panel — short pages (or a
|
||||||
@@ -158,38 +91,74 @@ when checks change; not bloating every page with a second column of prose.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## inbound-antispam-panel
|
||||||
|
|
||||||
|
**Goal:** panel view of inbound anti-spam decisions (when, from, to, subject,
|
||||||
|
action) plus editable allow/deny lists that tune the external filter — without
|
||||||
|
shipping rspamd or another engine inside the image.
|
||||||
|
|
||||||
|
**Boundary:** metadata journal and list sync only; SelfPost does not run the
|
||||||
|
filter ([product.md](product.md)). Quarantine:
|
||||||
|
[inbound-quarantine](#inbound-quarantine). Design closed in
|
||||||
|
[plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) § Decisions.
|
||||||
|
|
||||||
|
**Done when:** with inbound relay enabled, the operator sees journal rows
|
||||||
|
(accepts via inbound-journal milter, rejects via mail.log tailer) and can
|
||||||
|
maintain instance-wide allow/deny lists synced to rspamd maps when the antispam
|
||||||
|
hook is set.
|
||||||
|
|
||||||
|
**Dependencies / risks:** inbound relay `[1.4.0]`; rspamd map/header coupling;
|
||||||
|
journal PII; global-admin-only RBAC.
|
||||||
|
|
||||||
|
**Version:** `1.10.0` MINOR; **agreed**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## inbound-quarantine
|
||||||
|
|
||||||
|
**Goal:** optional hold for suspicious inbound mail — review in the panel, then
|
||||||
|
release to upstream, discard, or tune lists. Design not settled.
|
||||||
|
|
||||||
|
**Boundary:** expands inbound from pure relay toward short-lived message
|
||||||
|
storage; may conflict with "no mailboxes" in [product.md](product.md) until
|
||||||
|
product explicitly agrees. Alternatives (rspamd-only quarantine vs SelfPost
|
||||||
|
store) are listed in [plans/inbound-quarantine.md](plans/inbound-quarantine.md).
|
||||||
|
|
||||||
|
**Done when:** TBD after open questions in the plan are answered.
|
||||||
|
|
||||||
|
**Dependencies / risks:** inbound relay `[1.4.0]`; storage/retention/backup
|
||||||
|
size; malware in held MIME; overlap with
|
||||||
|
[inbound-antispam-panel](#inbound-antispam-panel). No implementation checklist
|
||||||
|
yet.
|
||||||
|
|
||||||
|
**Version:** TBD; `candidate` until explicitly agreed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## schema-squash
|
## schema-squash
|
||||||
|
|
||||||
**Goal:** when 2.x is cut, stop shipping the 1.x migration files
|
**Goal:** when 2.x is cut, stop shipping the 1.x migration chain in the binary
|
||||||
(`0001_init.sql` … `0005_panel_users.sql`) in the binary and replace them with
|
and replace it with one baseline equal to the schema at the then-current head.
|
||||||
one baseline that is the schema as of `user_version = 5`. Fresh 2.x data
|
Fresh 2.x data directories no longer create-then-drop the historical `admin`
|
||||||
directories no longer create-then-drop the historical `admin` table.
|
table. Current chain, legacy notes, and gate thresholds:
|
||||||
|
[schema-migrations.md](schema-migrations.md) (update that file when migrations
|
||||||
|
ship; adjust the squash gate to its head at cut time).
|
||||||
|
|
||||||
**Boundary:** 1.x keeps the full chain so a 1.0.0 data directory still boots.
|
**Boundary:** 1.x keeps the full chain so a `1.0.0` data directory still boots.
|
||||||
Do not delete, rename, or reorder those files while MINOR compatibility with
|
Do not delete, rename, or reorder migration files while MINOR compatibility with
|
||||||
`1.0.0` holds. `migrate()` maps **file order** to `PRAGMA user_version` (`target
|
`1.0.0` holds. Git history keeps the old files either way; only the embedded
|
||||||
= i + 1`); dropping a file in 1.x would skip or mis-apply steps on existing
|
set in the 2.x image changes.
|
||||||
databases. Git history keeps the old files either way; only the embedded set
|
|
||||||
in the 2.x image changes.
|
|
||||||
|
|
||||||
**Upgrade gate (required with the squash):**
|
|
||||||
|
|
||||||
| `user_version` | 2.x behaviour |
|
|
||||||
|---|---|
|
|
||||||
| `0` (empty file) | Apply the baseline; set `user_version` to the new chain’s head |
|
|
||||||
| `>= 5` (fully migrated 1.x) | Skip; schema is already the baseline |
|
|
||||||
| `1`…`4` (mid-chain 1.x) | **Refuse to start** — boot the last 1.x once, then 2.x |
|
|
||||||
|
|
||||||
Restore remains a separate lock: the backup manifest version must match the
|
Restore remains a separate lock: the backup manifest version must match the
|
||||||
running binary ([architecture.md](architecture.md) § Persistence). It does not
|
running binary ([architecture.md](architecture.md) § Persistence). It does not
|
||||||
replace this gate.
|
replace the squash upgrade gate.
|
||||||
|
|
||||||
**Done when:** 2.x embeds a single baseline (plus any 2.x-only migrations after
|
**Done when:** 2.x embeds a single baseline (plus any 2.x-only migrations after
|
||||||
it); the gate above is tested; the operator guide says a 2.x image will not
|
it); the gate in [schema-migrations.md](schema-migrations.md) is tested; the
|
||||||
open an unfinished 1.x database.
|
operator guide says a 2.x image will not open an unfinished 1.x database.
|
||||||
|
|
||||||
**Dependencies / risks:** a decided 2.x cut (another breaking change, or an
|
**Dependencies / risks:** a decided 2.x cut (another breaking change, or an
|
||||||
explicit major). Squashing five short files is not a reason to cut 2.x on its
|
explicit major). Squashing the current short chain is not a reason to cut 2.x on
|
||||||
own. A missed gate leaves a `user_version = 3` database silently stuck.
|
its own. A missed gate leaves a mid-chain 1.x database silently stuck.
|
||||||
**Version:** `2.x` major only; not a 1.x item.
|
**Version:** `2.x` major only; not a 1.x item.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# SQLite schema and migrations
|
||||||
|
|
||||||
|
**What this file is.** A living reference for the embedded SQLite migration chain,
|
||||||
|
historical legacy that still runs on fresh 1.x installs, and the planned 2.x
|
||||||
|
schema squash. When you add or review a migration, update this file in the same
|
||||||
|
change.
|
||||||
|
|
||||||
|
**Source of truth in code:** `internal/store/migrations/*.sql`, applied by
|
||||||
|
`internal/store/store.go` (`migrate()`).
|
||||||
|
|
||||||
|
**Related:** [architecture.md](architecture.md) § Persistence;
|
||||||
|
[roadmap.md](roadmap.md) § schema-squash; [development.md](development.md) §
|
||||||
|
Documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current snapshot
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Chain head | `user_version = 9` |
|
||||||
|
| Files | `0001_init.sql` … `0009_application_auth_ips.sql` (9 files) |
|
||||||
|
| Database file | `/data/selfpost.db` (bind mount) |
|
||||||
|
| Compatibility | 1.x MINOR releases must boot a `1.0.0` data directory |
|
||||||
|
|
||||||
|
Last updated with release **1.9.0** (`0009_application_auth_ips`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How migrations run
|
||||||
|
|
||||||
|
1. Migrations are embedded at build time (`//go:embed migrations/*.sql`).
|
||||||
|
2. Filenames are sorted lexicographically; **file order = version number**.
|
||||||
|
3. `PRAGMA user_version` records progress: after file *i* (0-based), version is
|
||||||
|
*i + 1*.
|
||||||
|
4. Each pending migration runs in its own transaction, then bumps `user_version`.
|
||||||
|
5. There is no down-migration; fixes ship as a new `00NN_*.sql` file.
|
||||||
|
|
||||||
|
```text
|
||||||
|
0001_init.sql → user_version 1
|
||||||
|
0002_sessions.sql → user_version 2
|
||||||
|
…
|
||||||
|
0009_application_auth_ips.sql → user_version 9
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implication:** never delete, rename, or reorder migration files while 1.x must
|
||||||
|
stay compatible with `1.0.0`. Git history keeps old files; only a 2.x cut may
|
||||||
|
replace the embedded set (see [Planned 2.x squash](#planned-2x-squash)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration chain
|
||||||
|
|
||||||
|
| Ver | File | Shipped | Kind | Summary |
|
||||||
|
|-----|------|---------|------|---------|
|
||||||
|
| 1 | `0001_init.sql` | ≤ 1.0.0 | DDL | Core schema: `admin`, `settings`, `domains`, `applications`, `application_addresses`, `send_log`, `rate_limits` |
|
||||||
|
| 2 | `0002_sessions.sql` | ≤ 1.0.0 | DDL | `sessions` (token hash, sliding idle expiry) |
|
||||||
|
| 3 | `0003_logtail_state.sql` | 0.5.0 | DDL | `logtail_state` (log-tailer read offset + fingerprint) |
|
||||||
|
| 4 | `0004_dmarc_report_email.sql` | 1.1.0 | DDL | `admin.dmarc_report_email`, `domains.dmarc_rua` |
|
||||||
|
| 5 | `0005_panel_users.sql` | 1.2.0 | DDL + data | `users`, `user_domains`; migrate single `admin` row; copy profile `dmarc_report_email` to `settings`; **DROP `admin`** |
|
||||||
|
| 6 | `0006_inbound_relay.sql` | 1.4.0 | DDL | `inbound_domains`, `inbound_transports`, `inbound_recipients` |
|
||||||
|
| 7 | `0007_rate_limit_auto.sql` | 1.6.0 | DDL | `rate_limits.mode`, `auto_multiplier`, `auto_updated_at` |
|
||||||
|
| 8 | `0008_dmarc_reports.sql` | 1.7.0 | DDL | `dmarc_reports`, `dmarc_report_records` |
|
||||||
|
| 9 | `0009_application_auth_ips.sql` | 1.9.0 | DDL + data | `applications.auth_ip_restrict`, `auth_allowed_ips`; move legacy app `rate_limits.allowed_ips` into auth columns; clear those IPs on rate limits |
|
||||||
|
|
||||||
|
**Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct
|
||||||
|
for operators upgrading from older 1.x images.
|
||||||
|
|
||||||
|
Feature plans that introduced schema work: [inbound-relay](plans/inbound-relay.md),
|
||||||
|
[domain-stats-auto-ratelimit](plans/domain-stats-auto-ratelimit.md),
|
||||||
|
[dmarc-reports](plans/dmarc-reports.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema after head (v9)
|
||||||
|
|
||||||
|
Tables present in a fully migrated database:
|
||||||
|
|
||||||
|
| Table | Introduced | Role |
|
||||||
|
|-------|------------|------|
|
||||||
|
| `settings` | 0001 | Key/value panel settings (retention, profile flags) |
|
||||||
|
| `domains` | 0001 (+ `dmarc_rua` in 0004) | Sending domains |
|
||||||
|
| `applications` | 0001 (+ auth IP cols in 0009) | SASL applications per domain |
|
||||||
|
| `application_addresses` | 0001 | Explicit From addresses (`list` mode) |
|
||||||
|
| `send_log` | 0001 | Delivery journal |
|
||||||
|
| `rate_limits` | 0001 (+ auto cols in 0007) | Level-2 limits per domain/application |
|
||||||
|
| `sessions` | 0002 | Panel login sessions |
|
||||||
|
| `logtail_state` | 0003 | Log-tailer persistence |
|
||||||
|
| `users` | 0005 | Panel users (`global`, `domain_admin`) |
|
||||||
|
| `user_domains` | 0005 | Domain-admin assignments |
|
||||||
|
| `inbound_domains` | 0006 | Inbound relay domains |
|
||||||
|
| `inbound_transports` | 0006 | Upstream host/port/TLS per inbound domain |
|
||||||
|
| `inbound_recipients` | 0006 | Allow-list when `recipient_mode = list` |
|
||||||
|
| `dmarc_reports` | 0008 | Parsed aggregate report summaries |
|
||||||
|
| `dmarc_report_records` | 0008 | Per-source rows inside a report |
|
||||||
|
|
||||||
|
**Not present after v9:** `admin` (dropped in 0005).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legacy and fresh-install artefacts
|
||||||
|
|
||||||
|
These are intentional in 1.x; they are the main motivation for
|
||||||
|
[schema-squash](roadmap.md#schema-squash) at 2.x.
|
||||||
|
|
||||||
|
### `admin` table (0001 → 0005)
|
||||||
|
|
||||||
|
On a **new** 1.x data directory the chain still:
|
||||||
|
|
||||||
|
1. Creates `admin` (0001),
|
||||||
|
2. Adds `admin.dmarc_report_email` (0004),
|
||||||
|
3. Copies the row into `users` and drops `admin` (0005).
|
||||||
|
|
||||||
|
Functionally harmless; confusing when reading migrations or inferring schema from
|
||||||
|
code. A 2.x baseline should define `users` directly and omit `admin`.
|
||||||
|
|
||||||
|
### Application trusted IPs (0009)
|
||||||
|
|
||||||
|
Before 1.9.0, client IP restriction for an application lived in
|
||||||
|
`rate_limits.allowed_ips` (`scope = application`). Migration 0009 copies non-empty
|
||||||
|
values into `applications.auth_allowed_ips`, sets `auth_ip_restrict = 1`, and
|
||||||
|
clears `rate_limits.allowed_ips` for application scope. Level-2 limits no longer
|
||||||
|
carry IP bindings; auth and rate limiting are separate concerns.
|
||||||
|
|
||||||
|
### Profile DMARC email (0004 → 0005)
|
||||||
|
|
||||||
|
`0004` adds `admin.dmarc_report_email`. `0005` copies it into the migrated global
|
||||||
|
`users` row and into `settings` key `dmarc_report_email`. Per-domain overrides
|
||||||
|
remain on `domains.dmarc_rua`.
|
||||||
|
|
||||||
|
### Documentation references to migration numbers
|
||||||
|
|
||||||
|
Other docs cite migrations by number (e.g. architecture § sessions → `0002`).
|
||||||
|
After a 2.x squash, those references remain valid for **upgrade history** and
|
||||||
|
git; fresh 2.x installs only run the baseline plus post-2.x files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules for 1.x changes
|
||||||
|
|
||||||
|
1. **Add** the next `00NN_descriptive_name.sql`; do not edit shipped migrations.
|
||||||
|
2. **Bump this file:** snapshot table, chain row, schema table if needed.
|
||||||
|
3. **CHANGELOG** under the release that ships the migration.
|
||||||
|
4. **Operator impact:** if upgrade behaviour matters, note it in [guide.md](guide.md).
|
||||||
|
5. **Backup manifest** is separate: restore requires matching binary version
|
||||||
|
([architecture.md](architecture.md) § Persistence); it does not replace
|
||||||
|
running pending SQLite migrations.
|
||||||
|
|
||||||
|
### Checklist for a new migration
|
||||||
|
|
||||||
|
- [ ] File name is next integer, zero-padded four digits.
|
||||||
|
- [ ] SQL is idempotent in spirit (runs once per DB; guard with schema state, not
|
||||||
|
“IF NOT EXISTS” everywhere unless needed).
|
||||||
|
- [ ] Data migrations handle empty/partial state (e.g. no `admin` row on re-run is
|
||||||
|
impossible; mid-upgrade failure is recovered by re-running the same file
|
||||||
|
only if the transaction failed before `user_version` bump).
|
||||||
|
- [ ] Row added to [Migration chain](#migration-chain) and [Current snapshot](#current-snapshot).
|
||||||
|
- [ ] `go test ./...` (store and dependents).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planned 2.x squash
|
||||||
|
|
||||||
|
Tracked as [schema-squash](roadmap.md#schema-squash). **Not** a reason to cut 2.x
|
||||||
|
on its own — only bundled with another breaking change or an explicit major.
|
||||||
|
|
||||||
|
**Goal:** embed one baseline SQL file equal to the v9 schema (plus any later 1.x
|
||||||
|
migrations if 2.x is cut later), instead of the full 1.x chain. Fresh 2.x
|
||||||
|
`/data` directories skip create-then-drop `admin`.
|
||||||
|
|
||||||
|
**Upgrade gate (required when squash ships):**
|
||||||
|
|
||||||
|
| `user_version` | 2.x behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `0` (empty DB) | Apply baseline; set `user_version` to new chain head |
|
||||||
|
| `>= 9` (fully migrated 1.x) | Skip; schema already matches baseline |
|
||||||
|
| `1`…`8` (mid-chain 1.x) | **Refuse to start** — run the last 1.x image once, then 2.x |
|
||||||
|
|
||||||
|
Adjust the `>= N` and `1`…`N-1` thresholds to the chain head at cut time.
|
||||||
|
|
||||||
|
**Done when:** baseline embedded; gate tested; [guide.md](guide.md) states that
|
||||||
|
2.x will not open an unfinished 1.x database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restore vs migrate
|
||||||
|
|
||||||
|
| Mechanism | What it checks |
|
||||||
|
|-----------|----------------|
|
||||||
|
| SQLite `user_version` | Which embedded migrations have run on this `selfpost.db` |
|
||||||
|
| Backup `manifest.json` | Binary/image version after full restore |
|
||||||
|
|
||||||
|
An operator can have a matching manifest after restore but still need migrations
|
||||||
|
if they restored an older DB snapshot with a newer binary — normal `migrate()`
|
||||||
|
applies pending files. The 2.x gate adds a **refusal** for half-upgraded 1.x DBs
|
||||||
|
when the old chain is no longer embedded.
|
||||||
+18
-6
@@ -10,7 +10,9 @@ login in the `saslpasswd2` argv
|
|||||||
2026-08-13 full-tree review against this document — send-log authorization for
|
2026-08-13 full-tree review against this document — send-log authorization for
|
||||||
domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed
|
domain administrators, the atomic level-2 admit (`tryAdmit`), fail-closed
|
||||||
session creation, and application-delete ordering: no findings, and nothing
|
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
|
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||||
lost.
|
lost.
|
||||||
|
|
||||||
@@ -77,8 +79,17 @@ The panel is exposed to the internet — the items below are **not optional**.
|
|||||||
|
|
||||||
### Mail path (security-relevant)
|
### 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`.
|
`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
|
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
|
||||||
`encrypt`).
|
`encrypt`).
|
||||||
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
|
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
|
||||||
@@ -86,10 +97,11 @@ The panel is exposed to the internet — the items below are **not optional**.
|
|||||||
|
|
||||||
### Backup and domain export
|
### Backup and domain export
|
||||||
|
|
||||||
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, and the
|
- Both files are secrets: a full backup carries DKIM keys, `sasldb2`, the
|
||||||
administrator's password hash; a domain export carries the DKIM key and
|
administrator's password hash, `docker-compose.yml`, `.env`, and the TLS
|
||||||
**working** application passwords in the clear (otherwise a transfer without
|
private key from `certs/` when present; a domain export carries the DKIM key
|
||||||
recreating credentials would be impossible).
|
and **working** application passwords in the clear (otherwise a transfer
|
||||||
|
without recreating credentials would be impossible).
|
||||||
- Both downloads can be encrypted with a password (a checkbox on the form):
|
- Both downloads can be encrypted with a password (a checkbox on the form):
|
||||||
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
|
scrypt (N=2¹⁵, r=8, p=1) → AES-256-GCM, streamed in 64 KiB chunks, each
|
||||||
authenticated with the header, the chunk number, and an end-of-stream flag —
|
authenticated with the header, the chunk number, and an end-of-stream flag —
|
||||||
|
|||||||
+14
-11
@@ -199,17 +199,14 @@ func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
|
|||||||
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveRateLimit stores the application-level trusted-IP override (guide § Rate
|
// SaveRateLimit stores the application-level rate limit (guide § Rate
|
||||||
// limiting). The caller has validated the IPs and numbers (security.md); the
|
// limiting). The caller has validated the numbers (security.md); the milter
|
||||||
// milter reads the row live, so no reload is needed.
|
// reads the row live, so no reload is needed.
|
||||||
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
|
func (s *Service) SaveRateLimit(appID int64, rl store.RateLimit) error {
|
||||||
return s.store.SetRateLimit(store.RateLimit{
|
rl.Scope = store.RateLimitScopeApp
|
||||||
Scope: store.RateLimitScopeApp,
|
rl.RefID = appID
|
||||||
RefID: appID,
|
rl.AllowedIPs = nil
|
||||||
AllowedIPs: ips,
|
return s.store.SetRateLimit(rl)
|
||||||
MaxMessages: maxMessages,
|
|
||||||
WindowSeconds: windowSeconds,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearRateLimit removes the application-level rate limit (guide § Rate
|
// ClearRateLimit removes the application-level rate limit (guide § Rate
|
||||||
@@ -218,6 +215,12 @@ func (s *Service) ClearRateLimit(appID int64) error {
|
|||||||
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
|
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAuthIPs sets client IP restriction for an application (guide § Rate
|
||||||
|
// limiting — authorization). The caller has validated the IPs (security.md).
|
||||||
|
func (s *Service) UpdateAuthIPs(appID int64, restrict bool, ips []string) error {
|
||||||
|
return s.store.UpdateApplicationAuthIPs(appID, restrict, ips)
|
||||||
|
}
|
||||||
|
|
||||||
// PurgeDomainSASL removes the SASL accounts of every application bound to a
|
// PurgeDomainSASL removes the SASL accounts of every application bound to a
|
||||||
// domain. It must be called before the domain's registry rows are cascade-
|
// domain. It must be called before the domain's registry rows are cascade-
|
||||||
// deleted, while the logins are still known (product.md). The registry rows and
|
// deleted, while the logins are still known (product.md). The registry rows and
|
||||||
|
|||||||
+116
-46
@@ -1,15 +1,17 @@
|
|||||||
// Package backup implements SelfPost's full-server backup and the restore
|
// Package backup implements SelfPost's full-server backup and the restore
|
||||||
// version guard (architecture.md § Persistence). A full backup is a
|
// version guard (architecture.md § Persistence). A full backup is a
|
||||||
// gzip-compressed tar of the consolidated persistent state under /data — the
|
// gzip-compressed tar of the consolidated persistent state under /data — the
|
||||||
// SQLite database (as a consistent snapshot), the per-domain DKIM keys and the
|
// SQLite database (as a consistent snapshot), the per-domain DKIM keys, the
|
||||||
// SASL database — plus a manifest recording the SelfPost version that produced
|
// SASL database, and the Postfix queue — plus docker-compose.yml, .env, and
|
||||||
// it. TLS certificates (the reverse proxy's responsibility) and the Postfix
|
// certs/ from the operator's deploy directory, and a manifest recording the
|
||||||
// queue are deliberately excluded (architecture.md § Persistence).
|
// SelfPost version that produced it. Postfix delivery logs under log/ are
|
||||||
|
// excluded (diagnostics, not state).
|
||||||
//
|
//
|
||||||
// Restore is not a separate code path in the panel: a backup is extracted into
|
// Restore is not a separate code path in the panel: a backup is extracted into
|
||||||
// the /data bind mount before first start, and the archive already carries
|
// the operator's project directory (data/, docker-compose.yml, .env, certs/)
|
||||||
// everything the mail path needs — DKIM keys, sasldb2, and Postfix's sender
|
// before first start. The archive carries everything needed to bring the
|
||||||
// map — so the daemons can start on the extracted files alone. The
|
// instance back — DKIM keys, sasldb2, sender map, queue, and deploy files —
|
||||||
|
// so the operator only adjusts hostname or proxy settings on a new host. The
|
||||||
// restore-specific steps the panel runs are CheckRestore, which refuses to
|
// restore-specific steps the panel runs are CheckRestore, which refuses to
|
||||||
// boot if the manifest's version does not match the running binary so
|
// boot if the manifest's version does not match the running binary so
|
||||||
// schema/format skew between versions cannot silently corrupt state
|
// schema/format skew between versions cannot silently corrupt state
|
||||||
@@ -40,11 +42,21 @@ import (
|
|||||||
// FormatFull identifies a full-server backup manifest.
|
// FormatFull identifies a full-server backup manifest.
|
||||||
const FormatFull = "selfpost-full-backup"
|
const FormatFull = "selfpost-full-backup"
|
||||||
|
|
||||||
// ManifestName is the manifest's filename, both inside the archive and, after a
|
// ManifestName is the manifest's filename inside the data directory. After
|
||||||
// restore extraction, at the root of the data directory where CheckRestore
|
// restore extraction it lives at data/manifest.json under the project root;
|
||||||
// looks for it.
|
// CheckRestore reads it from the /data bind mount.
|
||||||
const ManifestName = "manifest.json"
|
const ManifestName = "manifest.json"
|
||||||
|
|
||||||
|
// DataArchivePrefix is the path prefix for every /data entry in the archive.
|
||||||
|
const DataArchivePrefix = "data/"
|
||||||
|
|
||||||
|
// ComposeFileName and EnvFileName are required deploy files at the archive root.
|
||||||
|
const (
|
||||||
|
ComposeFileName = "docker-compose.yml"
|
||||||
|
EnvFileName = ".env"
|
||||||
|
CertsDirName = "certs"
|
||||||
|
)
|
||||||
|
|
||||||
// Manifest is the small JSON document embedded in every backup archive. Its
|
// Manifest is the small JSON document embedded in every backup archive. Its
|
||||||
// Version is the single fact that makes restore safe: the panel refuses to
|
// Version is the single fact that makes restore safe: the panel refuses to
|
||||||
// boot a data directory whose manifest version does not match its own binary
|
// boot a data directory whose manifest version does not match its own binary
|
||||||
@@ -58,21 +70,23 @@ type Manifest struct {
|
|||||||
// Params configures a backup. DataDir is the consolidated state root (/data);
|
// Params configures a backup. DataDir is the consolidated state root (/data);
|
||||||
// DBPath is the live SQLite file within it, snapshotted consistently rather than
|
// DBPath is the live SQLite file within it, snapshotted consistently rather than
|
||||||
// copied byte-for-byte while it may be mid-write; Version is stamped into the
|
// copied byte-for-byte while it may be mid-write; Version is stamped into the
|
||||||
// manifest.
|
// manifest; DeployRoot is the host project directory mounted read-only (holds
|
||||||
|
// docker-compose.yml, .env, and optionally certs/). OnWarn is called for
|
||||||
|
// non-fatal issues such as a missing certs/ directory.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
DataDir string
|
DataDir string
|
||||||
DBPath string
|
DBPath string
|
||||||
Version string
|
Version string
|
||||||
|
DeployRoot string
|
||||||
|
OnWarn func(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// excludedFromArchive lists the data-directory entries a backup never carries.
|
// excludedFromArchive lists the data-directory entries a backup never carries.
|
||||||
// The live database files are replaced by a consistent VACUUM INTO snapshot
|
// The live database files are replaced by a consistent VACUUM INTO snapshot
|
||||||
// written under the canonical name; the setup token is transient bootstrap
|
// written under the canonical name; the setup token is transient bootstrap
|
||||||
// state; a stale manifest from a previous restore must not be re-captured (a
|
// state; a stale manifest from a previous restore must not be re-captured (a
|
||||||
// fresh one is written instead); a "tls" directory holds the reverse proxy's
|
// fresh one is written instead); a "tls" directory under /data is skipped when
|
||||||
// certificates, which are explicitly out of scope for a SelfPost backup
|
// an operator pointed TLS_CERT_FILE inside /data; and "log" is Postfix's raw
|
||||||
// (architecture.md § Persistence) — excluding it keeps that guarantee even when
|
|
||||||
// an operator points TLS_CERT_FILE inside /data; and "log" is Postfix's raw
|
|
||||||
// delivery log plus its fourteen rotated files, which is diagnostic output, not
|
// delivery log plus its fourteen rotated files, which is diagnostic output, not
|
||||||
// state to restore, and by far the largest thing under /data.
|
// state to restore, and by far the largest thing under /data.
|
||||||
var excludedFromArchive = map[string]bool{
|
var excludedFromArchive = map[string]bool{
|
||||||
@@ -86,16 +100,22 @@ var excludedFromArchive = map[string]bool{
|
|||||||
ManifestName: true,
|
ManifestName: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create writes a gzip-compressed tar backup to w. Archive entries are named
|
// Create writes a gzip-compressed tar backup to w. Archive layout:
|
||||||
// relative to DataDir, so extracting the archive into the /data bind mount
|
//
|
||||||
// reconstructs the state in place (architecture.md § Persistence). The SQLite
|
// data/manifest.json, data/selfpost.db, data/<rest of /data>
|
||||||
// database is added as a consistent snapshot under "selfpost.db"; everything
|
// docker-compose.yml, .env, certs/...
|
||||||
// else under DataDir is copied as-is except the entries in
|
//
|
||||||
// excludedFromArchive.
|
// Extract the archive into an empty project directory, then docker compose up.
|
||||||
func Create(w io.Writer, p Params) error {
|
func Create(w io.Writer, p Params) error {
|
||||||
if p.DataDir == "" || p.DBPath == "" {
|
if p.DataDir == "" || p.DBPath == "" {
|
||||||
return fmt.Errorf("backup: DataDir and DBPath are required")
|
return fmt.Errorf("backup: DataDir and DBPath are required")
|
||||||
}
|
}
|
||||||
|
if p.DeployRoot == "" {
|
||||||
|
return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)")
|
||||||
|
}
|
||||||
|
if err := validateDeployRoot(p.DeployRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
snapshot, cleanup, err := snapshotDB(p.DBPath)
|
snapshot, cleanup, err := snapshotDB(p.DBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,17 +135,21 @@ func Create(w io.Writer, p Params) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("backup: encode manifest: %w", err)
|
return fmt.Errorf("backup: encode manifest: %w", err)
|
||||||
}
|
}
|
||||||
if err := writeTarBytes(tw, ManifestName, 0o600, manifestJSON); err != nil {
|
if err := writeTarBytes(tw, DataArchivePrefix+ManifestName, 0o600, manifestJSON); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// The consistent SQLite snapshot, under the canonical filename the panel
|
// The consistent SQLite snapshot, under the canonical filename the panel
|
||||||
// opens on start (the live file and its WAL/SHM are excluded from the walk).
|
// opens on start (the live file and its WAL/SHM are excluded from the walk).
|
||||||
if err := writeTarFile(tw, "selfpost.db", 0o640, snapshot); err != nil {
|
if err := writeTarFile(tw, DataArchivePrefix+"selfpost.db", 0o640, snapshot); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := addTree(tw, p.DataDir); err != nil {
|
if err := addTree(tw, p.DataDir, DataArchivePrefix, excludedFromArchive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := addDeployFiles(tw, p.DeployRoot, p.OnWarn); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,33 +162,79 @@ func Create(w io.Writer, p Params) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addTree walks dataDir and adds every regular file (and directory, to preserve
|
// ValidateDeployRoot checks that the operator project directory is mounted and
|
||||||
// empty ones and modes) to tw under its path relative to dataDir, skipping the
|
// contains the files a full backup requires. Call before streaming a response.
|
||||||
// excluded entries. Non-regular, non-directory entries (symlinks, sockets) are
|
func ValidateDeployRoot(deployRoot string) error {
|
||||||
// skipped: /data holds none in normal operation, and copying them into a backup
|
if deployRoot == "" {
|
||||||
// would be meaningless or unsafe.
|
return fmt.Errorf("backup: DeployRoot is required (mount the project directory at SELFPOST_DEPLOY_ROOT)")
|
||||||
func addTree(tw *tar.Writer, dataDir string) error {
|
}
|
||||||
return filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, err error) error {
|
return validateDeployRoot(deployRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDeployRoot(deployRoot string) error {
|
||||||
|
for _, name := range []string{ComposeFileName, EnvFileName} {
|
||||||
|
path := filepath.Join(deployRoot, name)
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return fmt.Errorf("backup: deploy root %q is missing %s: %w", deployRoot, name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDeployFiles(tw *tar.Writer, deployRoot string, onWarn func(string)) error {
|
||||||
|
for _, name := range []string{ComposeFileName, EnvFileName} {
|
||||||
|
src := filepath.Join(deployRoot, name)
|
||||||
|
info, err := os.Stat(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup: stat deploy file %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("backup: deploy file %s is not a regular file", name)
|
||||||
|
}
|
||||||
|
if err := writeTarFile(tw, name, info.Mode().Perm(), src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
certsDir := filepath.Join(deployRoot, CertsDirName)
|
||||||
|
if _, err := os.Stat(certsDir); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
if onWarn != nil {
|
||||||
|
onWarn("certs/ not found in deploy root; backup will not include TLS material")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("backup: stat %s: %w", CertsDirName, err)
|
||||||
|
}
|
||||||
|
return addTree(tw, certsDir, CertsDirName+"/", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addTree walks root and adds every regular file (and directory, to preserve
|
||||||
|
// empty ones and modes) to tw under archivePrefix + path relative to root.
|
||||||
|
// When exclude is non-nil, top-level names relative to root are skipped.
|
||||||
|
// Non-regular, non-directory entries (symlinks, sockets) are skipped.
|
||||||
|
func addTree(tw *tar.Writer, root, archivePrefix string, exclude map[string]bool) error {
|
||||||
|
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rel, err := filepath.Rel(dataDir, path)
|
rel, err := filepath.Rel(root, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if rel == "." {
|
if rel == "." {
|
||||||
return nil // the data root itself is implicit
|
|
||||||
}
|
|
||||||
// filepath.Rel yields OS separators; tar names use forward slashes.
|
|
||||||
name := filepath.ToSlash(rel)
|
|
||||||
// Exclude by top-level name (the live DB, setup token and stale manifest
|
|
||||||
// all live at the data root).
|
|
||||||
if excludedFromArchive[name] {
|
|
||||||
if d.IsDir() {
|
|
||||||
return fs.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
name := archivePrefix + filepath.ToSlash(rel)
|
||||||
|
if exclude != nil {
|
||||||
|
top := strings.Split(filepath.ToSlash(rel), "/")[0]
|
||||||
|
if exclude[top] {
|
||||||
|
if d.IsDir() {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info, err := d.Info()
|
info, err := d.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -182,7 +252,7 @@ func addTree(tw *tar.Writer, dataDir string) error {
|
|||||||
case info.Mode().IsRegular():
|
case info.Mode().IsRegular():
|
||||||
return writeTarFile(tw, name, info.Mode().Perm(), path)
|
return writeTarFile(tw, name, info.Mode().Perm(), path)
|
||||||
default:
|
default:
|
||||||
return nil // skip symlinks/sockets/devices
|
return nil
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
|
// seedDataDir builds a realistic /data tree: a migrated SQLite database plus the
|
||||||
// DKIM key, SASL and transient files a backup must include or exclude.
|
// DKIM key, SASL and transient files a backup must include or exclude. It also
|
||||||
func seedDataDir(t *testing.T) (dataDir, dbPath string) {
|
// seeds a deploy root beside data/ with compose, .env, and certs/.
|
||||||
|
func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dataDir = t.TempDir()
|
deployRoot = t.TempDir()
|
||||||
|
dataDir = filepath.Join(deployRoot, "data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||||
|
t.Fatalf("mkdir data: %v", err)
|
||||||
|
}
|
||||||
dbPath = filepath.Join(dataDir, "selfpost.db")
|
dbPath = filepath.Join(dataDir, "selfpost.db")
|
||||||
|
|
||||||
st, err := store.Open(dbPath)
|
st, err := store.Open(dbPath)
|
||||||
@@ -35,6 +40,7 @@ func seedDataDir(t *testing.T) (dataDir, dbPath string) {
|
|||||||
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
|
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
|
||||||
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
|
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
|
||||||
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
|
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
|
||||||
|
writeFile(t, filepath.Join(dataDir, "postfix", "queue", "deferred", "sample"), "queue-file")
|
||||||
// Transient files that must NOT be archived.
|
// Transient files that must NOT be archived.
|
||||||
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
|
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
|
||||||
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
|
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
|
||||||
@@ -43,7 +49,11 @@ func seedDataDir(t *testing.T) (dataDir, dbPath string) {
|
|||||||
// state, and the bulkiest thing under /data.
|
// state, and the bulkiest thing under /data.
|
||||||
writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
|
writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
|
||||||
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
|
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
|
||||||
return dataDir, dbPath
|
writeFile(t, filepath.Join(deployRoot, ComposeFileName), "services:\n selfpost:\n image: test\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, EnvFileName), "SELFPOST_HOSTNAME=mail.example.com\n")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, CertsDirName, "fullchain.pem"), "CERT")
|
||||||
|
writeFile(t, filepath.Join(deployRoot, CertsDirName, "privkey.pem"), "KEY")
|
||||||
|
return dataDir, dbPath, deployRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeFile(t *testing.T, path, content string) {
|
func writeFile(t *testing.T, path, content string) {
|
||||||
@@ -87,21 +97,26 @@ func readArchive(t *testing.T, data []byte) map[string]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
||||||
dataDir, dbPath := seedDataDir(t)
|
dataDir, dbPath, deployRoot := seedDataDir(t)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3"}); err != nil {
|
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot}); err != nil {
|
||||||
t.Fatalf("Create: %v", err)
|
t.Fatalf("Create: %v", err)
|
||||||
}
|
}
|
||||||
files := readArchive(t, buf.Bytes())
|
files := readArchive(t, buf.Bytes())
|
||||||
|
|
||||||
// Present.
|
// Present.
|
||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
ManifestName,
|
DataArchivePrefix + ManifestName,
|
||||||
"selfpost.db",
|
DataArchivePrefix + "selfpost.db",
|
||||||
"opendkim/keys/example.com/selfpost.private",
|
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
|
||||||
"sasl/sasldb2",
|
DataArchivePrefix + "sasl/sasldb2",
|
||||||
"postfix/sender_login_maps",
|
DataArchivePrefix + "postfix/sender_login_maps",
|
||||||
|
DataArchivePrefix + "postfix/queue/deferred/sample",
|
||||||
|
ComposeFileName,
|
||||||
|
EnvFileName,
|
||||||
|
CertsDirName + "/fullchain.pem",
|
||||||
|
CertsDirName + "/privkey.pem",
|
||||||
} {
|
} {
|
||||||
if _, ok := files[name]; !ok {
|
if _, ok := files[name]; !ok {
|
||||||
t.Errorf("archive missing %s", name)
|
t.Errorf("archive missing %s", name)
|
||||||
@@ -109,8 +124,11 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Excluded.
|
// Excluded.
|
||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
"setup-token", "selfpost.db-wal", "selfpost.db-shm",
|
DataArchivePrefix + "setup-token",
|
||||||
"log/mail.log", "log/mail.log.1",
|
DataArchivePrefix + "selfpost.db-wal",
|
||||||
|
DataArchivePrefix + "selfpost.db-shm",
|
||||||
|
DataArchivePrefix + "log/mail.log",
|
||||||
|
DataArchivePrefix + "log/mail.log.1",
|
||||||
} {
|
} {
|
||||||
if _, ok := files[name]; ok {
|
if _, ok := files[name]; ok {
|
||||||
t.Errorf("archive should not contain %s", name)
|
t.Errorf("archive should not contain %s", name)
|
||||||
@@ -119,7 +137,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
|||||||
|
|
||||||
// Manifest is well-formed and carries the version.
|
// Manifest is well-formed and carries the version.
|
||||||
var m Manifest
|
var m Manifest
|
||||||
if err := json.Unmarshal([]byte(files[ManifestName]), &m); err != nil {
|
if err := json.Unmarshal([]byte(files[DataArchivePrefix+ManifestName]), &m); err != nil {
|
||||||
t.Fatalf("manifest json: %v", err)
|
t.Fatalf("manifest json: %v", err)
|
||||||
}
|
}
|
||||||
if m.Format != FormatFull || m.Version != "1.2.3" {
|
if m.Format != FormatFull || m.Version != "1.2.3" {
|
||||||
@@ -128,7 +146,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
|||||||
|
|
||||||
// The archived selfpost.db is a real, openable SQLite snapshot with our data.
|
// The archived selfpost.db is a real, openable SQLite snapshot with our data.
|
||||||
snapPath := filepath.Join(t.TempDir(), "restored.db")
|
snapPath := filepath.Join(t.TempDir(), "restored.db")
|
||||||
if err := os.WriteFile(snapPath, []byte(files["selfpost.db"]), 0o640); err != nil {
|
if err := os.WriteFile(snapPath, []byte(files[DataArchivePrefix+"selfpost.db"]), 0o640); err != nil {
|
||||||
t.Fatalf("write snapshot: %v", err)
|
t.Fatalf("write snapshot: %v", err)
|
||||||
}
|
}
|
||||||
st, err := store.Open(snapPath)
|
st, err := store.Open(snapPath)
|
||||||
@@ -145,6 +163,14 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateRequiresDeployRoot(t *testing.T) {
|
||||||
|
dataDir, dbPath, _ := seedDataDir(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := Create(&buf, Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0"}); err == nil {
|
||||||
|
t.Fatal("Create without DeployRoot succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeManifest(t *testing.T, dir, format, version string) string {
|
func writeManifest(t *testing.T, dir, format, version string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(dir, ManifestName)
|
path := filepath.Join(dir, ManifestName)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package dmarc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultLocalPart is the mailbox local-part for the panel-wide hosted address.
|
||||||
|
const DefaultLocalPart = "dmarc-reports"
|
||||||
|
|
||||||
|
// HostedReportAddress is the per-domain SelfPost-hosted rua= destination.
|
||||||
|
func HostedReportAddress(hostname, domain string) string {
|
||||||
|
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||||
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
return DefaultLocalPart + "+" + domain + "@" + hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultHostedReportAddress is the settings-level hosted rua= when ingest is on.
|
||||||
|
func DefaultHostedReportAddress(hostname string) string {
|
||||||
|
return DefaultLocalPart + "@" + strings.ToLower(strings.TrimSpace(hostname))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHostedOnHostname reports whether addr is delivered locally on hostname.
|
||||||
|
func IsHostedOnHostname(addr, hostname string) bool {
|
||||||
|
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||||
|
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||||
|
if hostname == "" || !strings.Contains(addr, "@") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
at := strings.LastIndex(addr, "@")
|
||||||
|
return addr[at+1:] == hostname
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package dmarc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net/mail"
|
||||||
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IngestMessage parses a raw RFC 5322 message from r and stores any DMARC
|
||||||
|
// aggregate attachment it finds.
|
||||||
|
func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt time.Time) error {
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(r, 12<<20))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read message: %w", err)
|
||||||
|
}
|
||||||
|
payload, err := extractAggregatePayload(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed, err := ParseAggregate(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rep := store.DMARCReport{
|
||||||
|
Domain: parsed.Domain,
|
||||||
|
Reporter: parsed.Reporter,
|
||||||
|
ReportID: parsed.ReportID,
|
||||||
|
PeriodBegin: parsed.PeriodBegin,
|
||||||
|
PeriodEnd: parsed.PeriodEnd,
|
||||||
|
ReceivedAt: receivedAt.UTC(),
|
||||||
|
ContactEmail: parsed.ContactEmail,
|
||||||
|
PolicyP: parsed.PolicyP,
|
||||||
|
PolicySP: parsed.PolicySP,
|
||||||
|
PolicyPct: parsed.PolicyPct,
|
||||||
|
PolicyADKIM: parsed.PolicyADKIM,
|
||||||
|
PolicyASPF: parsed.PolicyASPF,
|
||||||
|
PassCount: parsed.PassCount,
|
||||||
|
FailCount: parsed.FailCount,
|
||||||
|
Recipient: strings.ToLower(strings.TrimSpace(recipient)),
|
||||||
|
}
|
||||||
|
for _, rec := range parsed.Records {
|
||||||
|
rep.Records = append(rep.Records, store.DMARCReportRecord{
|
||||||
|
SourceIP: rec.SourceIP,
|
||||||
|
Count: rec.Count,
|
||||||
|
Disposition: rec.Disposition,
|
||||||
|
SPFResult: rec.SPFResult,
|
||||||
|
DKIMResult: rec.DKIMResult,
|
||||||
|
HeaderFrom: rec.HeaderFrom,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := st.InsertDMARCReport(rep); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return st.PruneDMARCReports()
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAggregatePayload(raw []byte) ([]byte, error) {
|
||||||
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse message: %w", err)
|
||||||
|
}
|
||||||
|
ct := msg.Header.Get("Content-Type")
|
||||||
|
if ct == "" {
|
||||||
|
return io.ReadAll(io.LimitReader(msg.Body, 8<<20))
|
||||||
|
}
|
||||||
|
mediaType, params, err := mime.ParseMediaType(ct)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("content-type: %w", err)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(mediaType, "multipart/") {
|
||||||
|
boundary := params["boundary"]
|
||||||
|
if boundary == "" {
|
||||||
|
return nil, fmt.Errorf("multipart without boundary")
|
||||||
|
}
|
||||||
|
mr := multipart.NewReader(msg.Body, boundary)
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("multipart: %w", err)
|
||||||
|
}
|
||||||
|
name := strings.ToLower(part.FileName())
|
||||||
|
if name == "" {
|
||||||
|
name = strings.ToLower(part.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if !looksLikeAggregate(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := readEncodedBody(textproto.MIMEHeader(part.Header), part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(data) > 0 {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no aggregate attachment found")
|
||||||
|
}
|
||||||
|
return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeAggregate(name string) bool {
|
||||||
|
name = strings.ToLower(name)
|
||||||
|
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) {
|
||||||
|
encoding := strings.ToLower(hdr.Get("Content-Transfer-Encoding"))
|
||||||
|
var body io.Reader = r
|
||||||
|
switch encoding {
|
||||||
|
case "base64":
|
||||||
|
body = base64.NewDecoder(base64.StdEncoding, r)
|
||||||
|
case "quoted-printable":
|
||||||
|
body = quotedprintable.NewReader(r)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(body, 8<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read part body: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package dmarc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParsedReport is the structured form of a DMARC aggregate XML payload.
|
||||||
|
type ParsedReport struct {
|
||||||
|
Reporter string
|
||||||
|
ReportID string
|
||||||
|
ContactEmail string
|
||||||
|
PeriodBegin time.Time
|
||||||
|
PeriodEnd time.Time
|
||||||
|
Domain string
|
||||||
|
PolicyP string
|
||||||
|
PolicySP string
|
||||||
|
PolicyPct int
|
||||||
|
PolicyADKIM string
|
||||||
|
PolicyASPF string
|
||||||
|
Records []ParsedRecord
|
||||||
|
PassCount int
|
||||||
|
FailCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedRecord is one <record> row from the aggregate XML.
|
||||||
|
type ParsedRecord struct {
|
||||||
|
SourceIP string
|
||||||
|
Count int
|
||||||
|
Disposition string
|
||||||
|
SPFResult string
|
||||||
|
DKIMResult string
|
||||||
|
HeaderFrom string
|
||||||
|
}
|
||||||
|
|
||||||
|
type feedbackXML struct {
|
||||||
|
XMLName xml.Name `xml:"feedback"`
|
||||||
|
Metadata struct {
|
||||||
|
OrgName string `xml:"org_name"`
|
||||||
|
Email string `xml:"email"`
|
||||||
|
ReportID string `xml:"report_id"`
|
||||||
|
DateRange struct {
|
||||||
|
Begin int64 `xml:"begin"`
|
||||||
|
End int64 `xml:"end"`
|
||||||
|
} `xml:"date_range"`
|
||||||
|
} `xml:"report_metadata"`
|
||||||
|
Policy struct {
|
||||||
|
Domain string `xml:"domain"`
|
||||||
|
P string `xml:"p"`
|
||||||
|
SP string `xml:"sp"`
|
||||||
|
Pct int `xml:"pct"`
|
||||||
|
ADKIM string `xml:"adkim"`
|
||||||
|
ASPF string `xml:"aspf"`
|
||||||
|
} `xml:"policy_published"`
|
||||||
|
Records []struct {
|
||||||
|
Row struct {
|
||||||
|
SourceIP string `xml:"source_ip"`
|
||||||
|
Count int `xml:"count"`
|
||||||
|
Policy struct {
|
||||||
|
Disposition string `xml:"disposition"`
|
||||||
|
DKIM string `xml:"dkim"`
|
||||||
|
SPF string `xml:"spf"`
|
||||||
|
} `xml:"policy_evaluated"`
|
||||||
|
} `xml:"row"`
|
||||||
|
Identifiers struct {
|
||||||
|
HeaderFrom string `xml:"header_from"`
|
||||||
|
} `xml:"identifiers"`
|
||||||
|
} `xml:"record"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAggregate decodes gzip-compressed or raw DMARC aggregate XML.
|
||||||
|
func ParseAggregate(raw []byte) (ParsedReport, error) {
|
||||||
|
data, err := maybeGunzip(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ParsedReport{}, err
|
||||||
|
}
|
||||||
|
var doc feedbackXML
|
||||||
|
if err := xml.Unmarshal(data, &doc); err != nil {
|
||||||
|
return ParsedReport{}, fmt.Errorf("dmarc xml: %w", err)
|
||||||
|
}
|
||||||
|
if doc.Metadata.ReportID == "" || doc.Policy.Domain == "" {
|
||||||
|
return ParsedReport{}, fmt.Errorf("dmarc xml: missing report_id or domain")
|
||||||
|
}
|
||||||
|
out := ParsedReport{
|
||||||
|
Reporter: strings.TrimSpace(doc.Metadata.OrgName),
|
||||||
|
ReportID: strings.TrimSpace(doc.Metadata.ReportID),
|
||||||
|
ContactEmail: strings.TrimSpace(doc.Metadata.Email),
|
||||||
|
PeriodBegin: time.Unix(doc.Metadata.DateRange.Begin, 0).UTC(),
|
||||||
|
PeriodEnd: time.Unix(doc.Metadata.DateRange.End, 0).UTC(),
|
||||||
|
Domain: strings.ToLower(strings.TrimSpace(doc.Policy.Domain)),
|
||||||
|
PolicyP: strings.TrimSpace(doc.Policy.P),
|
||||||
|
PolicySP: strings.TrimSpace(doc.Policy.SP),
|
||||||
|
PolicyPct: doc.Policy.Pct,
|
||||||
|
PolicyADKIM: strings.TrimSpace(doc.Policy.ADKIM),
|
||||||
|
PolicyASPF: strings.TrimSpace(doc.Policy.ASPF),
|
||||||
|
}
|
||||||
|
if out.PolicyPct == 0 {
|
||||||
|
out.PolicyPct = 100
|
||||||
|
}
|
||||||
|
for _, rec := range doc.Records {
|
||||||
|
row := ParsedRecord{
|
||||||
|
SourceIP: strings.TrimSpace(rec.Row.SourceIP),
|
||||||
|
Count: rec.Row.Count,
|
||||||
|
Disposition: strings.TrimSpace(rec.Row.Policy.Disposition),
|
||||||
|
SPFResult: strings.TrimSpace(rec.Row.Policy.SPF),
|
||||||
|
DKIMResult: strings.TrimSpace(rec.Row.Policy.DKIM),
|
||||||
|
HeaderFrom: strings.TrimSpace(rec.Identifiers.HeaderFrom),
|
||||||
|
}
|
||||||
|
if row.Count <= 0 {
|
||||||
|
row.Count = 1
|
||||||
|
}
|
||||||
|
if dmarcRecordPasses(row) {
|
||||||
|
out.PassCount += row.Count
|
||||||
|
} else {
|
||||||
|
out.FailCount += row.Count
|
||||||
|
}
|
||||||
|
out.Records = append(out.Records, row)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dmarcRecordPasses(r ParsedRecord) bool {
|
||||||
|
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
func maybeGunzip(raw []byte) ([]byte, error) {
|
||||||
|
if len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b {
|
||||||
|
zr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dmarc gzip: %w", err)
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(zr, 8<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dmarc gzip read: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TightenPolicyHint summarises whether raising p= looks reasonable.
|
||||||
|
func TightenPolicyHint(pass, fail int, sources []SourceHint) string {
|
||||||
|
total := pass + fail
|
||||||
|
if total == 0 {
|
||||||
|
return "No messages in the reporting window yet."
|
||||||
|
}
|
||||||
|
failPct := float64(fail) * 100 / float64(total)
|
||||||
|
if fail > 0 {
|
||||||
|
for _, s := range sources {
|
||||||
|
if s.FailCount > 0 && !s.ThisRelay {
|
||||||
|
return "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failPct <= 2 && pass > 0 {
|
||||||
|
return "Alignment looks strong. Tightening p= may be reasonable."
|
||||||
|
}
|
||||||
|
if fail > 0 {
|
||||||
|
return "Some failures remain. Review sources before tightening p=."
|
||||||
|
}
|
||||||
|
return "Alignment looks clean for this window."
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceHint is a panel-facing rollup row with relay detection.
|
||||||
|
type SourceHint struct {
|
||||||
|
SourceIP string
|
||||||
|
PassCount int
|
||||||
|
FailCount int
|
||||||
|
ThisRelay bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package dmarc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleXML = `<?xml version="1.0"?>
|
||||||
|
<feedback>
|
||||||
|
<report_metadata>
|
||||||
|
<org_name>google.com</org_name>
|
||||||
|
<email>noreply@google.com</email>
|
||||||
|
<report_id>12345</report_id>
|
||||||
|
<date_range><begin>1723593600</begin><end>1723680000</end></date_range>
|
||||||
|
</report_metadata>
|
||||||
|
<policy_published>
|
||||||
|
<domain>example.com</domain>
|
||||||
|
<p>none</p><sp>none</sp><pct>100</pct>
|
||||||
|
<adkim>r</adkim><aspf>r</aspf>
|
||||||
|
</policy_published>
|
||||||
|
<record>
|
||||||
|
<row>
|
||||||
|
<source_ip>203.0.113.10</source_ip>
|
||||||
|
<count>10</count>
|
||||||
|
<policy_evaluated>
|
||||||
|
<disposition>none</disposition>
|
||||||
|
<dkim>pass</dkim><spf>pass</spf>
|
||||||
|
</policy_evaluated>
|
||||||
|
</row>
|
||||||
|
<identifiers><header_from>example.com</header_from></identifiers>
|
||||||
|
</record>
|
||||||
|
<record>
|
||||||
|
<row>
|
||||||
|
<source_ip>198.51.100.1</source_ip>
|
||||||
|
<count>2</count>
|
||||||
|
<policy_evaluated>
|
||||||
|
<disposition>none</disposition>
|
||||||
|
<dkim>fail</dkim><spf>fail</spf>
|
||||||
|
</policy_evaluated>
|
||||||
|
</row>
|
||||||
|
<identifiers><header_from>example.com</header_from></identifiers>
|
||||||
|
</record>
|
||||||
|
</feedback>`
|
||||||
|
|
||||||
|
func TestParseAggregateGzip(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := gzip.NewWriter(&buf)
|
||||||
|
if _, err := zw.Write([]byte(sampleXML)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := ParseAggregate(buf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAggregate: %v", err)
|
||||||
|
}
|
||||||
|
if got.Domain != "example.com" || got.Reporter != "google.com" {
|
||||||
|
t.Fatalf("metadata = %+v", got)
|
||||||
|
}
|
||||||
|
if got.PassCount != 10 || got.FailCount != 2 {
|
||||||
|
t.Fatalf("counts = %d pass %d fail", got.PassCount, got.FailCount)
|
||||||
|
}
|
||||||
|
if !got.PeriodBegin.Equal(time.Unix(1723593600, 0).UTC()) {
|
||||||
|
t.Fatalf("period begin = %v", got.PeriodBegin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestMessageStoresReport(t *testing.T) {
|
||||||
|
st, err := store.Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
var gz bytes.Buffer
|
||||||
|
zw := gzip.NewWriter(&gz)
|
||||||
|
_, _ = zw.Write([]byte(sampleXML))
|
||||||
|
_ = zw.Close()
|
||||||
|
|
||||||
|
msg := bytes.NewBufferString(
|
||||||
|
"From: noreply@google.com\r\n" +
|
||||||
|
"To: dmarc-reports@mail.example.com\r\n" +
|
||||||
|
"Subject: Report\r\n" +
|
||||||
|
"Content-Type: application/gzip; name=\"report.xml.gz\"\r\n" +
|
||||||
|
"\r\n",
|
||||||
|
)
|
||||||
|
// append raw gzip for simple single-part test
|
||||||
|
msg.Write(gz.Bytes())
|
||||||
|
|
||||||
|
if err := IngestMessage(st, msg, "dmarc-reports@mail.example.com", time.Now().UTC()); err != nil {
|
||||||
|
t.Fatalf("IngestMessage: %v", err)
|
||||||
|
}
|
||||||
|
list, err := st.ListDMARCReports(nil, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("reports = %d", len(list))
|
||||||
|
}
|
||||||
|
if list[0].PassCount != 10 {
|
||||||
|
t.Fatalf("pass = %d", list[0].PassCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostedReportAddress(t *testing.T) {
|
||||||
|
got := HostedReportAddress("mail.example.com", "Example.COM")
|
||||||
|
want := "dmarc-reports+example.com@mail.example.com"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Package dmarc receives DMARC aggregate reports: Postfix pipes messages here,
|
||||||
|
// gzip/XML is parsed, and summaries land in SQLite for the panel.
|
||||||
|
package dmarc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
|
"github.com/mixeme/selfpost/internal/postfix"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Maps is the Postfix manager surface the DMARC service needs.
|
||||||
|
type Maps interface {
|
||||||
|
RebuildDMARCMaps(cfg postfix.DMARCMapsConfig) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service coordinates DMARC ingest allow-lists across SQLite and Postfix.
|
||||||
|
type Service struct {
|
||||||
|
store *store.Store
|
||||||
|
maps Maps
|
||||||
|
hostname string
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService builds the DMARC ingest service.
|
||||||
|
func NewService(st *store.Store, maps Maps, hostname string, enabled bool) *Service {
|
||||||
|
return &Service{store: st, maps: maps, hostname: strings.ToLower(strings.TrimSpace(hostname)), enabled: enabled}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether DMARC ingest is active in this deployment.
|
||||||
|
func (s *Service) Enabled() bool { return s.enabled }
|
||||||
|
|
||||||
|
// Resync rebuilds the Postfix allow-list and transport maps from SQLite.
|
||||||
|
func (s *Service) Resync() error {
|
||||||
|
if !s.enabled {
|
||||||
|
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{})
|
||||||
|
}
|
||||||
|
addrs, err := s.AllowedRecipients()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
domains := recipientDomains(addrs)
|
||||||
|
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{
|
||||||
|
Recipients: addrs,
|
||||||
|
Domains: domains,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedRecipients returns every address Postfix may accept for DMARC ingest.
|
||||||
|
func (s *Service) AllowedRecipients() ([]string, error) {
|
||||||
|
if !s.enabled || s.hostname == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
profile, err := s.store.GlobalDMARCReportEmail()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var out []string
|
||||||
|
add := func(addr string) {
|
||||||
|
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||||
|
if addr == "" || !IsHostedOnHostname(addr, s.hostname) || seen[addr] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[addr] = true
|
||||||
|
out = append(out, addr)
|
||||||
|
}
|
||||||
|
add(profile)
|
||||||
|
if profile == "" {
|
||||||
|
add(DefaultHostedReportAddress(s.hostname))
|
||||||
|
}
|
||||||
|
domains, err := s.store.ListDomains()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, d := range domains {
|
||||||
|
rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile)
|
||||||
|
if rua == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if IsHostedOnHostname(rua, s.hostname) {
|
||||||
|
add(rua)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if d.DMARCRua.Valid && d.DMARCRua.String == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hosted := HostedReportAddress(s.hostname, d.Name)
|
||||||
|
if strings.EqualFold(rua, hosted) {
|
||||||
|
add(rua)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recipientDomains(addrs []string) []string {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var out []string
|
||||||
|
for _, addr := range addrs {
|
||||||
|
d := dnscheck.EmailDomain(addr)
|
||||||
|
if d == "" || seen[d] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[d] = true
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostedSuggestion returns the address the panel should suggest for a domain.
|
||||||
|
func (s *Service) HostedSuggestion(domain string) string {
|
||||||
|
return HostedReportAddress(s.hostname, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultHostedSuggestion is the settings-level hosted address.
|
||||||
|
func (s *Service) DefaultHostedSuggestion() string {
|
||||||
|
return DefaultHostedReportAddress(s.hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateHostedAddress ensures addr is on this hostname before saving.
|
||||||
|
func (s *Service) ValidateHostedAddress(addr string) error {
|
||||||
|
if !IsHostedOnHostname(addr, s.hostname) {
|
||||||
|
return fmt.Errorf("hosted report address must be on %s", s.hostname)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ package dnscheck
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -98,6 +99,7 @@ type Checker struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
servers map[string]cached[Server]
|
servers map[string]cached[Server]
|
||||||
domains map[string]cached[Domain]
|
domains map[string]cached[Domain]
|
||||||
|
inbound map[string]cached[Result]
|
||||||
}
|
}
|
||||||
|
|
||||||
type cached[T any] struct {
|
type cached[T any] struct {
|
||||||
@@ -120,6 +122,7 @@ func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
|
|||||||
domainTTL: domTTL,
|
domainTTL: domTTL,
|
||||||
servers: make(map[string]cached[Server]),
|
servers: make(map[string]cached[Server]),
|
||||||
domains: make(map[string]cached[Domain]),
|
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) {
|
func (c *Checker) Forget(domainName string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
delete(c.domains, domainName)
|
delete(c.domains, domainName)
|
||||||
|
delete(c.inbound, domainName)
|
||||||
c.mu.Unlock()
|
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,
|
// checkDomain runs the three record checks concurrently: they are independent,
|
||||||
// and in series three timeouts would stack up into a page that looks hung.
|
// and in series three timeouts would stack up into a page that looks hung.
|
||||||
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
|
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())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -147,14 +147,11 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
|
|||||||
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
|
||||||
// numbers (security.md); the milter reads the row live, so no reload is needed.
|
// numbers (security.md); the milter reads the row live, so no reload is needed.
|
||||||
// Domain limits do not use an IP allowlist.
|
// Domain limits do not use an IP allowlist.
|
||||||
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
|
func (s *Service) SaveRateLimit(domainID int64, rl store.RateLimit) error {
|
||||||
return s.store.SetRateLimit(store.RateLimit{
|
rl.Scope = store.RateLimitScopeDomain
|
||||||
Scope: store.RateLimitScopeDomain,
|
rl.RefID = domainID
|
||||||
RefID: domainID,
|
rl.AllowedIPs = nil
|
||||||
AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel
|
return s.store.SetRateLimit(rl)
|
||||||
MaxMessages: maxMessages,
|
|
||||||
WindowSeconds: windowSeconds,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
|
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
|
||||||
|
|||||||
+98
-10
@@ -25,15 +25,28 @@ type DomainExport struct {
|
|||||||
DKIMSelector string `json:"dkim_selector"`
|
DKIMSelector string `json:"dkim_selector"`
|
||||||
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
|
||||||
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
|
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
|
||||||
|
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
|
||||||
Applications []AppExport `json:"applications"`
|
Applications []AppExport `json:"applications"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RateLimitExport is the transferable level-2 limit for a domain or application.
|
||||||
|
type RateLimitExport struct {
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
MaxMessages int `json:"max_messages,omitempty"`
|
||||||
|
WindowSeconds int `json:"window_seconds,omitempty"`
|
||||||
|
AutoMultiplier float64 `json:"auto_multiplier,omitempty"`
|
||||||
|
AllowedIPs []string `json:"allowed_ips,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// AppExport is one application within a DomainExport.
|
// AppExport is one application within a DomainExport.
|
||||||
type AppExport struct {
|
type AppExport struct {
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
AddressMode string `json:"address_mode"`
|
AddressMode string `json:"address_mode"`
|
||||||
Addresses []string `json:"addresses,omitempty"` // list mode only
|
Addresses []string `json:"addresses,omitempty"` // list mode only
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
AuthIPRestrict bool `json:"auth_ip_restrict,omitempty"`
|
||||||
|
AuthAllowedIPs []string `json:"auth_allowed_ips,omitempty"`
|
||||||
|
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export builds the transferable representation of a domain: its DKIM key, its
|
// Export builds the transferable representation of a domain: its DKIM key, its
|
||||||
@@ -65,17 +78,26 @@ func (s *Service) Export(id int64) (DomainExport, error) {
|
|||||||
s := d.DMARCRua.String
|
s := d.DMARCRua.String
|
||||||
exp.DMARCRua = &s
|
exp.DMARCRua = &s
|
||||||
}
|
}
|
||||||
|
if rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id); err == nil && ok {
|
||||||
|
exp.RateLimit = exportRateLimit(rl)
|
||||||
|
}
|
||||||
for _, a := range apps {
|
for _, a := range apps {
|
||||||
password, err := s.apps.Secret(a.Login)
|
password, err := s.apps.Secret(a.Login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
|
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
|
||||||
}
|
}
|
||||||
exp.Applications = append(exp.Applications, AppExport{
|
appExp := AppExport{
|
||||||
Login: a.Login,
|
Login: a.Login,
|
||||||
AddressMode: a.AddressMode,
|
AddressMode: a.AddressMode,
|
||||||
Addresses: a.Addresses,
|
Addresses: a.Addresses,
|
||||||
Password: password,
|
Password: password,
|
||||||
})
|
AuthIPRestrict: a.AuthIPRestrict,
|
||||||
|
AuthAllowedIPs: a.AuthAllowedIPs,
|
||||||
|
}
|
||||||
|
if rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeApp, a.ID); err == nil && ok {
|
||||||
|
appExp.RateLimit = exportRateLimit(rl)
|
||||||
|
}
|
||||||
|
exp.Applications = append(exp.Applications, appExp)
|
||||||
}
|
}
|
||||||
return exp, nil
|
return exp, nil
|
||||||
}
|
}
|
||||||
@@ -123,12 +145,45 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
|
|||||||
}
|
}
|
||||||
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua}
|
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua}
|
||||||
}
|
}
|
||||||
|
if exp.RateLimit != nil {
|
||||||
|
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil {
|
||||||
|
s.importRollback(d.ID)
|
||||||
|
return store.Domain{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, a := range exp.Applications {
|
for _, a := range exp.Applications {
|
||||||
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
|
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
|
||||||
s.importRollback(d.ID)
|
s.importRollback(d.ID)
|
||||||
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
|
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
|
||||||
}
|
}
|
||||||
|
restrict, ips := a.AuthIPRestrict, a.AuthAllowedIPs
|
||||||
|
// Legacy exports stored client IPs on the rate-limit row as trusted IPs.
|
||||||
|
if a.RateLimit != nil && len(a.RateLimit.AllowedIPs) > 0 {
|
||||||
|
restrict = true
|
||||||
|
ips = a.RateLimit.AllowedIPs
|
||||||
|
}
|
||||||
|
needAuthIPs := restrict || len(ips) > 0
|
||||||
|
needRateLimit := a.RateLimit != nil
|
||||||
|
if needAuthIPs || needRateLimit {
|
||||||
|
app, err := s.store.GetApplicationByLogin(a.Login)
|
||||||
|
if err != nil {
|
||||||
|
s.importRollback(d.ID)
|
||||||
|
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
|
||||||
|
}
|
||||||
|
if needAuthIPs {
|
||||||
|
if err := s.store.UpdateApplicationAuthIPs(app.ID, restrict, ips); err != nil {
|
||||||
|
s.importRollback(d.ID)
|
||||||
|
return store.Domain{}, fmt.Errorf("import auth IPs for %q: %w", a.Login, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if needRateLimit {
|
||||||
|
if err := s.importRateLimit(store.RateLimitScopeApp, app.ID, *a.RateLimit); err != nil {
|
||||||
|
s.importRollback(d.ID)
|
||||||
|
return store.Domain{}, fmt.Errorf("import rate limit for %q: %w", a.Login, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := s.apps.Resync(); err != nil {
|
if err := s.apps.Resync(); err != nil {
|
||||||
s.importRollback(d.ID)
|
s.importRollback(d.ID)
|
||||||
@@ -145,3 +200,36 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
|
|||||||
func (s *Service) importRollback(id int64) {
|
func (s *Service) importRollback(id int64) {
|
||||||
_ = s.Delete(id)
|
_ = s.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportRateLimit(rl store.RateLimit) *RateLimitExport {
|
||||||
|
mode := rl.Mode
|
||||||
|
if mode == "" {
|
||||||
|
mode = store.RateLimitModeManual
|
||||||
|
}
|
||||||
|
exp := &RateLimitExport{
|
||||||
|
Mode: mode,
|
||||||
|
MaxMessages: rl.MaxMessages,
|
||||||
|
WindowSeconds: rl.WindowSeconds,
|
||||||
|
AutoMultiplier: rl.AutoMultiplier,
|
||||||
|
}
|
||||||
|
return exp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) importRateLimit(scope string, refID int64, exp RateLimitExport) error {
|
||||||
|
mode := exp.Mode
|
||||||
|
if mode == "" {
|
||||||
|
mode = store.RateLimitModeManual
|
||||||
|
}
|
||||||
|
rl := store.RateLimit{
|
||||||
|
Scope: scope,
|
||||||
|
RefID: refID,
|
||||||
|
Mode: mode,
|
||||||
|
MaxMessages: exp.MaxMessages,
|
||||||
|
WindowSeconds: exp.WindowSeconds,
|
||||||
|
AutoMultiplier: exp.AutoMultiplier,
|
||||||
|
}
|
||||||
|
if mode == store.RateLimitModeManual && rl.MaxMessages <= 0 && rl.WindowSeconds <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.store.SetRateLimit(rl)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+21
-13
@@ -39,17 +39,20 @@ type StatusStore interface {
|
|||||||
// is a var so tests can shorten it.
|
// is a var so tests can shorten it.
|
||||||
var pollInterval = time.Second
|
var pollInterval = time.Second
|
||||||
|
|
||||||
|
// retentionInterval is how often the retention sweep runs (also once at
|
||||||
|
// startup). It is a var so tests can shorten it.
|
||||||
|
var retentionInterval = 6 * time.Hour
|
||||||
|
|
||||||
|
// RetentionInterval returns how often background send-log pruning runs.
|
||||||
|
func RetentionInterval() time.Duration {
|
||||||
|
return retentionInterval
|
||||||
|
}
|
||||||
|
|
||||||
// queueIDs lists the messages Postfix currently holds, for the reconcile sweep.
|
// queueIDs lists the messages Postfix currently holds, for the reconcile sweep.
|
||||||
// It is a var so tests can answer without a running Postfix.
|
// It is a var so tests can answer without a running Postfix.
|
||||||
var queueIDs = postfix.QueueIDs
|
var queueIDs = postfix.QueueIDs
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// retentionInterval is how often the retention sweep runs (also once at
|
|
||||||
// startup). The window itself is configurable; the cadence need not be.
|
|
||||||
retentionInterval = 6 * time.Hour
|
|
||||||
// defaultRetentionDays applies when the configured value is unset/invalid
|
|
||||||
// (guide § Environment variables: SEND_LOG_RETENTION_DAYS).
|
|
||||||
defaultRetentionDays = 90
|
|
||||||
// reconcileInterval is how often the sweep compares stuck rows against the
|
// reconcileInterval is how often the sweep compares stuck rows against the
|
||||||
// Postfix queue, and reconcileGrace how long a row is left alone first.
|
// Postfix queue, and reconcileGrace how long a row is left alone first.
|
||||||
// The grace covers the ordinary lag between the milter writing the row and
|
// The grace covers the ordinary lag between the milter writing the row and
|
||||||
@@ -59,6 +62,10 @@ const (
|
|||||||
reconcileGrace = 2 * time.Minute
|
reconcileGrace = 2 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RetentionDays returns the send-log retention window in days. The log-tailer
|
||||||
|
// calls it on every prune cycle so a panel change takes effect without restart.
|
||||||
|
type RetentionDays func() int
|
||||||
|
|
||||||
// deliveryRe matches a Postfix delivery line and captures queue-id, recipient
|
// deliveryRe matches a Postfix delivery line and captures queue-id, recipient
|
||||||
// and status, e.g.
|
// and status, e.g.
|
||||||
//
|
//
|
||||||
@@ -98,11 +105,11 @@ func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run follows path and updates send-log statuses until ctx is cancelled, while
|
// Run follows path and updates send-log statuses until ctx is cancelled, while
|
||||||
// a background sweep prunes rows older than retentionDays. Reading resumes at
|
// a background sweep prunes rows older than retention(). Reading resumes at
|
||||||
// the offset the previous run persisted, so a restart parses the delivery lines
|
// the offset the previous run persisted, so a restart parses the delivery lines
|
||||||
// written while the panel was down. It returns nil on a clean shutdown.
|
// written while the panel was down. It returns nil on a clean shutdown.
|
||||||
func Run(ctx context.Context, path string, st StatusStore, retentionDays int) error {
|
func Run(ctx context.Context, path string, st StatusStore, retention RetentionDays) error {
|
||||||
go retentionLoop(ctx, st, retentionDays)
|
go retentionLoop(ctx, st, retention)
|
||||||
|
|
||||||
// The reconcile sweep must not run against a backlog the tailer has not
|
// The reconcile sweep must not run against a backlog the tailer has not
|
||||||
// read yet: on a restart the log holds the very lines that resolve the rows
|
// read yet: on a restart the log holds the very lines that resolve the rows
|
||||||
@@ -192,11 +199,12 @@ func reconcile(st StatusStore, cutoff time.Time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// retentionLoop prunes expired send-log rows immediately and then periodically.
|
// retentionLoop prunes expired send-log rows immediately and then periodically.
|
||||||
func retentionLoop(ctx context.Context, st StatusStore, retentionDays int) {
|
func retentionLoop(ctx context.Context, st StatusStore, retention RetentionDays) {
|
||||||
if retentionDays <= 0 {
|
|
||||||
retentionDays = defaultRetentionDays
|
|
||||||
}
|
|
||||||
prune := func() {
|
prune := func() {
|
||||||
|
retentionDays := retention()
|
||||||
|
if retentionDays <= 0 {
|
||||||
|
retentionDays = store.SendLogRetentionDaysDefault
|
||||||
|
}
|
||||||
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)
|
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)
|
||||||
n, err := st.DeleteSendLogBefore(cutoff)
|
n, err := st.DeleteSendLogBefore(cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ func TestFollowTailsAndRotates(t *testing.T) {
|
|||||||
cs := &captureStore{}
|
cs := &captureStore{}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() { done <- Run(ctx, path, cs, 90) }()
|
go func() { done <- Run(ctx, path, cs, func() int { return 90 }) }()
|
||||||
|
|
||||||
// Give follow() time to open at EOF (it seeks to end immediately on start,
|
// Give follow() time to open at EOF (it seeks to end immediately on start,
|
||||||
// so the seed line above is ignored), then append a delivery line.
|
// so the seed line above is ignored), then append a delivery line.
|
||||||
@@ -295,7 +295,7 @@ func startRun(t *testing.T, path string, cs *captureStore) func() {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() { done <- Run(ctx, path, cs, 90) }()
|
go func() { done <- Run(ctx, path, cs, func() int { return 90 }) }()
|
||||||
// follow() opens and seeks on start; give it a moment before the caller
|
// follow() opens and seeks on start; give it a moment before the caller
|
||||||
// appends, so the append is not raced by the initial open.
|
// appends, so the append is not raced by the initial open.
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package logtail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type retentionProbeStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cutoff time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *retentionProbeStore) UpdateStatus(string, string, string) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *retentionProbeStore) ListQueuedOlderThan(time.Time) ([]store.QueuedDelivery, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *retentionProbeStore) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cutoff = cutoff
|
||||||
|
s.mu.Unlock()
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *retentionProbeStore) LogtailState(string) (store.LogtailState, bool, error) {
|
||||||
|
return store.LogtailState{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *retentionProbeStore) SaveLogtailState(string, store.LogtailState) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *retentionProbeStore) lastCutoff() time.Time {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.cutoff
|
||||||
|
}
|
||||||
|
|
||||||
|
// retentionLoop must re-read the configured window every cycle so a panel
|
||||||
|
// change takes effect without restarting the process.
|
||||||
|
func TestRetentionLoopUsesUpdatedValue(t *testing.T) {
|
||||||
|
old := retentionInterval
|
||||||
|
retentionInterval = 20 * time.Millisecond
|
||||||
|
t.Cleanup(func() { retentionInterval = old })
|
||||||
|
|
||||||
|
var days atomic.Int32
|
||||||
|
days.Store(30)
|
||||||
|
|
||||||
|
st := &retentionProbeStore{}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go retentionLoop(ctx, st, func() int { return int(days.Load()) })
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if age := time.Since(st.lastCutoff()); age < 31*24*time.Hour && age > 29*24*time.Hour {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if age := time.Since(st.lastCutoff()); age < 29*24*time.Hour || age > 31*24*time.Hour {
|
||||||
|
t.Fatalf("first prune cutoff age %v, want about 30 days", age)
|
||||||
|
}
|
||||||
|
|
||||||
|
days.Store(7)
|
||||||
|
deadline = time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if age := time.Since(st.lastCutoff()); age < 8*24*time.Hour && age > 6*24*time.Hour {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
age := time.Since(st.lastCutoff())
|
||||||
|
t.Fatalf("second prune cutoff age %v, want about 7 days", age)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package milter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authIPAllowed reports whether the authenticated application may submit from
|
||||||
|
// the connecting client IP. When the application has no IP restriction, or the
|
||||||
|
// client IP is not known, the check passes. Store errors are fail-open (see
|
||||||
|
// overLimit).
|
||||||
|
func (s *session) authIPAllowed() bool {
|
||||||
|
if s.login == "" || s.clientIP == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
a, err := s.rec.ApplicationByLogin(s.login)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrApplicationNotFound) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
log.Printf("journal-milter: auth IP lookup application %q: %v (fail-open)", s.login, err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a.AllowsAuthFromIP(s.clientIP) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
log.Printf("journal-milter: application %q refused from %s — client IP not allowed", s.login, s.clientIP)
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ type Store interface {
|
|||||||
InsertRejected(e store.SendLogEntry) error
|
InsertRejected(e store.SendLogEntry) error
|
||||||
RateLimit(scope, ref string) (store.RateLimit, bool, error)
|
RateLimit(scope, ref string) (store.RateLimit, bool, error)
|
||||||
CountMessages(scope, ref string, since time.Time) (int64, error)
|
CountMessages(scope, ref string, since time.Time) (int64, error)
|
||||||
|
ApplicationByLogin(login string) (store.Application, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// session accumulates the fields of one message as the milter callbacks fire.
|
// session accumulates the fields of one message as the milter callbacks fire.
|
||||||
@@ -84,6 +85,10 @@ func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, er
|
|||||||
s.login = macro(m, "auth_authen")
|
s.login = macro(m, "auth_authen")
|
||||||
s.rcpts = nil
|
s.rcpts = nil
|
||||||
s.subject = ""
|
s.subject = ""
|
||||||
|
if !s.authIPAllowed() {
|
||||||
|
s.recordRejected()
|
||||||
|
return milter.RespTempFail, nil
|
||||||
|
}
|
||||||
if s.overLimit() {
|
if s.overLimit() {
|
||||||
s.recordRejected()
|
s.recordRejected()
|
||||||
return milter.RespTempFail, nil
|
return milter.RespTempFail, nil
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ type fakeRecorder struct {
|
|||||||
fail bool
|
fail bool
|
||||||
|
|
||||||
// limits, keyed by "scope|ref", drive the level-2 rate-limit tests. counts
|
// limits, keyed by "scope|ref", drive the level-2 rate-limit tests. counts
|
||||||
// gives the recent-message count returned for a "scope|ref". lookupErr and
|
// gives the recent-message count returned for a "scope|ref". apps supplies
|
||||||
// countErr force the store errors that must fail open.
|
// application rows for client-IP authorization tests. lookupErr and countErr
|
||||||
|
// force the store errors that must fail open.
|
||||||
limits map[string]store.RateLimit
|
limits map[string]store.RateLimit
|
||||||
counts map[string]int64
|
counts map[string]int64
|
||||||
|
apps map[string]store.Application
|
||||||
lookupErr error
|
lookupErr error
|
||||||
countErr error
|
countErr error
|
||||||
|
|
||||||
@@ -75,6 +77,17 @@ func (f *fakeRecorder) CountMessages(scope, ref string, _ time.Time) (int64, err
|
|||||||
return f.counts[scope+"|"+ref], nil
|
return f.counts[scope+"|"+ref], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeRecorder) ApplicationByLogin(login string) (store.Application, error) {
|
||||||
|
if f.lookupErr != nil {
|
||||||
|
return store.Application{}, f.lookupErr
|
||||||
|
}
|
||||||
|
a, ok := f.apps[login]
|
||||||
|
if !ok {
|
||||||
|
return store.Application{}, store.ErrApplicationNotFound
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
func mods(kv map[string]string) *milter.Modifier {
|
func mods(kv map[string]string) *milter.Modifier {
|
||||||
return &milter.Modifier{Macros: kv}
|
return &milter.Modifier{Macros: kv}
|
||||||
}
|
}
|
||||||
@@ -211,8 +224,8 @@ func domainLimit() store.RateLimit {
|
|||||||
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
|
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
|
||||||
}
|
}
|
||||||
|
|
||||||
func appLimit(ips ...string) store.RateLimit {
|
func appLimit() store.RateLimit {
|
||||||
return store.RateLimit{AllowedIPs: ips, MaxMessages: 5, WindowSeconds: 3600}
|
return store.RateLimit{MaxMessages: 5, WindowSeconds: 3600}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mailFrom drives just the connect + MAIL FROM stages and returns the response,
|
// mailFrom drives just the connect + MAIL FROM stages and returns the response,
|
||||||
@@ -248,7 +261,7 @@ func TestRateLimitRefusesWhenDomainOverLimit(t *testing.T) {
|
|||||||
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
func TestRateLimitRefusesWhenAppOverLimit(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
|
store.RateLimitScopeApp + "|app1": appLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
|
counts: map[string]int64{store.RateLimitScopeApp + "|app1": 9}, // over max
|
||||||
}
|
}
|
||||||
@@ -286,13 +299,11 @@ func TestRateLimitDomainAppliesToAnyIP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitTrustedAppSkipsDomain(t *testing.T) {
|
func TestRateLimitAppSkipsDomainWhenActive(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
||||||
store.RateLimitScopeApp + "|app1": {
|
store.RateLimitScopeApp + "|app1": appLimit(),
|
||||||
AllowedIPs: []string{limitIP}, MaxMessages: 10, WindowSeconds: 3600,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
counts: map[string]int64{
|
counts: map[string]int64{
|
||||||
store.RateLimitScopeDomain + "|example.com": 5, // over domain
|
store.RateLimitScopeDomain + "|example.com": 5, // over domain
|
||||||
@@ -300,41 +311,51 @@ func TestRateLimitTrustedAppSkipsDomain(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||||
t.Fatalf("trusted app under its ceiling = %v, want Continue (domain skipped)", resp)
|
t.Fatalf("app under its ceiling = %v, want Continue (domain not checked)", resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitUnlistedIPHitsDomain(t *testing.T) {
|
func TestAuthIPRestrictBlocksUnlisted(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
apps: map[string]store.Application{
|
||||||
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
"app1": {
|
||||||
store.RateLimitScopeApp + "|app1": {
|
Login: "app1",
|
||||||
AllowedIPs: []string{"198.51.100.1"}, MaxMessages: 100, WindowSeconds: 3600,
|
AuthIPRestrict: true,
|
||||||
|
AuthAllowedIPs: []string{"198.51.100.1"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
counts: map[string]int64{
|
|
||||||
store.RateLimitScopeDomain + "|example.com": 1,
|
|
||||||
store.RateLimitScopeApp + "|app1": 0,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
||||||
t.Fatalf("unlisted IP under domain = %v, want TempFail", resp)
|
t.Fatalf("unlisted IP = %v, want TempFail", resp)
|
||||||
|
}
|
||||||
|
if len(rec.rejected) != 1 {
|
||||||
|
t.Fatalf("want one rejected row, got %+v", rec.rejected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitAppWithoutIPsDoesNotPrivilege(t *testing.T) {
|
func TestAuthIPRestrictAllowsListed(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
apps: map[string]store.Application{
|
||||||
store.RateLimitScopeDomain + "|example.com": {MaxMessages: 1, WindowSeconds: 3600},
|
"app1": {
|
||||||
store.RateLimitScopeApp + "|app1": {MaxMessages: 100, WindowSeconds: 3600}, // no IPs
|
Login: "app1",
|
||||||
},
|
AuthIPRestrict: true,
|
||||||
counts: map[string]int64{
|
AuthAllowedIPs: []string{limitIP},
|
||||||
store.RateLimitScopeDomain + "|example.com": 1,
|
},
|
||||||
store.RateLimitScopeApp + "|app1": 0,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespTempFail {
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||||
t.Fatalf("app without IPs must not skip domain = %v, want TempFail", resp)
|
t.Fatalf("listed IP = %v, want Continue", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthIPRestrictOffAllowsAnyIP(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{
|
||||||
|
apps: map[string]store.Application{
|
||||||
|
"app1": {Login: "app1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if resp := mailFrom(t, rec, limitIP, "a@example.com", "app1"); resp != milter.RespContinue {
|
||||||
|
t.Fatalf("restriction off = %v, want Continue", resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,7 +546,7 @@ func TestRefusalDoesNotLeaveDomainReservation(t *testing.T) {
|
|||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
store.RateLimitScopeApp + "|app1": appLimit(limitIP),
|
store.RateLimitScopeApp + "|app1": appLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{
|
counts: map[string]int64{
|
||||||
store.RateLimitScopeDomain + "|example.com": 0,
|
store.RateLimitScopeDomain + "|example.com": 0,
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import (
|
|||||||
// overLimit reports whether the message currently being received should be
|
// overLimit reports whether the message currently being received should be
|
||||||
// refused under a level-2 differentiated limit (guide § Rate limiting).
|
// refused under a level-2 differentiated limit (guide § Rate limiting).
|
||||||
//
|
//
|
||||||
// Trusted application IPs (app limit active and client IP listed) use only the
|
// When an application has an active limit, it overrides the domain limit for
|
||||||
// app ceiling and skip the domain check. Everyone else is under the domain
|
// that login (the ceiling may be higher or lower than the domain). Otherwise
|
||||||
// ceiling when one is configured; otherwise only level 1 applies.
|
// the domain ceiling applies when configured; if neither is set, only level 1
|
||||||
|
// applies.
|
||||||
//
|
//
|
||||||
// It is deliberately fail-open: any store error, or the absence of a usable
|
// It is deliberately fail-open: any store error, or the absence of a usable
|
||||||
// limit, is treated as "not over limit" so a malfunction of the level-2
|
// limit, is treated as "not over limit" so a malfunction of the level-2
|
||||||
@@ -33,7 +34,7 @@ func (s *session) overLimit() bool {
|
|||||||
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
|
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err)
|
log.Printf("journal-milter: rate-limit lookup application %q: %v (fail-open)", s.login, err)
|
||||||
} else if ok && rl.Active() && rl.AllowsIP(s.clientIP) {
|
} else if ok && rl.Active() {
|
||||||
return s.enforceLimit(store.RateLimitScopeApp, s.login, rl)
|
return s.enforceLimit(store.RateLimitScopeApp, s.login, rl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package postfix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DMARCMapsConfig is the allow-list material for DMARC aggregate ingest.
|
||||||
|
type DMARCMapsConfig struct {
|
||||||
|
Recipients []string
|
||||||
|
Domains []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postfix) dmarcMapPaths() (recipients, transport, relayDomains string) {
|
||||||
|
dir := filepath.Dir(p.senderLoginMapsPath)
|
||||||
|
return filepath.Join(dir, "dmarc_recipients"),
|
||||||
|
filepath.Join(dir, "dmarc_transport"),
|
||||||
|
filepath.Join(dir, "dmarc_relay_domains")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuildDMARCMaps regenerates DMARC ingest lookup tables. Empty config clears
|
||||||
|
// the files so port-25 ingest is off even if the listener stays up for inbound
|
||||||
|
// relay.
|
||||||
|
func (p *Postfix) RebuildDMARCMaps(cfg DMARCMapsConfig) error {
|
||||||
|
recip, transport, relay, err := renderDMARCMaps(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rc, tr, rd := p.dmarcMapPaths()
|
||||||
|
if err := writeFileAtomic(rc, recip, 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(tr, transport, 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(rd, relay, 0o640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderDMARCMaps(cfg DMARCMapsConfig) (recipients, transport, relayDomains []byte, err error) {
|
||||||
|
recipients = []byte{}
|
||||||
|
transport = []byte{}
|
||||||
|
relayDomains = []byte{}
|
||||||
|
if len(cfg.Recipients) == 0 {
|
||||||
|
return recipients, transport, relayDomains, nil
|
||||||
|
}
|
||||||
|
addrs := append([]string(nil), cfg.Recipients...)
|
||||||
|
sort.Strings(addrs)
|
||||||
|
var recipB, transportB strings.Builder
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if err := assertMapToken(addr, "recipient"); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&recipB, "%s OK\n", addr)
|
||||||
|
fmt.Fprintf(&transportB, "%s dmarc-ingest:\n", addr)
|
||||||
|
}
|
||||||
|
domains := append([]string(nil), cfg.Domains...)
|
||||||
|
sort.Strings(domains)
|
||||||
|
var relayB strings.Builder
|
||||||
|
for _, d := range domains {
|
||||||
|
if err := assertMapToken(d, "domain"); err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&relayB, "%s OK\n", d)
|
||||||
|
}
|
||||||
|
return []byte(recipB.String()), []byte(transportB.String()), []byte(relayB.String()), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package postfix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderDMARCMaps(t *testing.T) {
|
||||||
|
recip, transport, relay, err := renderDMARCMaps(DMARCMapsConfig{
|
||||||
|
Recipients: []string{"dmarc-reports@mail.example.com"},
|
||||||
|
Domains: []string{"mail.example.com"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(recip), "dmarc-reports@mail.example.com OK") {
|
||||||
|
t.Fatalf("recipients:\n%s", recip)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(transport), "dmarc-ingest:") {
|
||||||
|
t.Fatalf("transport:\n%s", transport)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(relay), "mail.example.com OK") {
|
||||||
|
t.Fatalf("relay:\n%s", relay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderDMARCMapsEmpty(t *testing.T) {
|
||||||
|
recip, transport, relay, err := renderDMARCMaps(DMARCMapsConfig{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(recip)+len(transport)+len(relay) != 0 {
|
||||||
|
t.Fatalf("expected empty maps")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package postfix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseDuration converts a Postfix time value to a duration. Units are those
|
||||||
|
// postconf prints and accepts: s, m, h, d, w. A bare number is seconds. Values
|
||||||
|
// may concatenate units (`1h7m`), matching Postfix's own conv_time.
|
||||||
|
func ParseDuration(s string) (time.Duration, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, fmt.Errorf("postfix: empty time value")
|
||||||
|
}
|
||||||
|
var total time.Duration
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
for i < len(s) && unicode.IsSpace(rune(s[i])) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i >= len(s) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if s[i] == '-' {
|
||||||
|
return 0, fmt.Errorf("postfix: negative time value %q", s)
|
||||||
|
}
|
||||||
|
if s[i] == '+' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
start := i
|
||||||
|
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == start {
|
||||||
|
return 0, fmt.Errorf("postfix: invalid time value %q", s)
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(s[start:i], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("postfix: invalid time value %q", s)
|
||||||
|
}
|
||||||
|
unit := byte('s')
|
||||||
|
if i < len(s) && isTimeUnit(s[i]) {
|
||||||
|
unit = s[i] | 0x20 // ASCII fold to lowercase
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
part, err := durationForUnit(n, unit)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
total += part
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTimeUnit(c byte) bool {
|
||||||
|
switch c | 0x20 {
|
||||||
|
case 's', 'm', 'h', 'd', 'w':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationForUnit(n int64, unit byte) (time.Duration, error) {
|
||||||
|
var unitDur time.Duration
|
||||||
|
switch unit {
|
||||||
|
case 's':
|
||||||
|
unitDur = time.Second
|
||||||
|
case 'm':
|
||||||
|
unitDur = time.Minute
|
||||||
|
case 'h':
|
||||||
|
unitDur = time.Hour
|
||||||
|
case 'd':
|
||||||
|
unitDur = 24 * time.Hour
|
||||||
|
case 'w':
|
||||||
|
unitDur = 7 * 24 * time.Hour
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("postfix: unknown time unit %q", string(unit))
|
||||||
|
}
|
||||||
|
return time.Duration(n) * unitDur, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDuration renders a Postfix interval the way the Mail queue card and
|
||||||
|
// delivery history share it, so the two cannot drift. Exact day/hour/minute/
|
||||||
|
// second values stay exact (`5 minutes`, `5 days`, `1 hour`); a remainder that
|
||||||
|
// is rounded to the nearest minute is marked `about` (`about 1 hour 7 minutes`
|
||||||
|
// for the stock 4000s backoff cap).
|
||||||
|
func FormatDuration(d time.Duration) string {
|
||||||
|
if d < 0 {
|
||||||
|
d = -d
|
||||||
|
}
|
||||||
|
sec := int64(d / time.Second)
|
||||||
|
if sec == 0 {
|
||||||
|
return "0 seconds"
|
||||||
|
}
|
||||||
|
|
||||||
|
days := sec / 86400
|
||||||
|
rem := sec % 86400
|
||||||
|
hours := rem / 3600
|
||||||
|
rem %= 3600
|
||||||
|
minutes := rem / 60
|
||||||
|
seconds := rem % 60
|
||||||
|
|
||||||
|
about := false
|
||||||
|
if days > 0 || hours > 0 {
|
||||||
|
if seconds >= 30 {
|
||||||
|
minutes++
|
||||||
|
about = true
|
||||||
|
} else if seconds > 0 {
|
||||||
|
about = true
|
||||||
|
}
|
||||||
|
seconds = 0
|
||||||
|
if minutes >= 60 {
|
||||||
|
hours++
|
||||||
|
minutes = 0
|
||||||
|
}
|
||||||
|
if hours >= 24 {
|
||||||
|
days++
|
||||||
|
hours = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]string, 0, 4)
|
||||||
|
if days > 0 {
|
||||||
|
parts = append(parts, counted(days, "day"))
|
||||||
|
}
|
||||||
|
if hours > 0 {
|
||||||
|
parts = append(parts, counted(hours, "hour"))
|
||||||
|
}
|
||||||
|
if minutes > 0 {
|
||||||
|
parts = append(parts, counted(minutes, "minute"))
|
||||||
|
}
|
||||||
|
if seconds > 0 {
|
||||||
|
parts = append(parts, counted(seconds, "second"))
|
||||||
|
}
|
||||||
|
s := strings.Join(parts, " ")
|
||||||
|
if about {
|
||||||
|
return "about " + s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func counted(n int64, unit string) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "1 " + unit
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(n, 10) + " " + unit + "s"
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package postfix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDuration(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{"5d", 5 * 24 * time.Hour},
|
||||||
|
{"300s", 300 * time.Second},
|
||||||
|
{"4000s", 4000 * time.Second},
|
||||||
|
{"1h", time.Hour},
|
||||||
|
{"0", 0},
|
||||||
|
{"0s", 0},
|
||||||
|
{"300", 300 * time.Second},
|
||||||
|
{" 300s ", 300 * time.Second},
|
||||||
|
{"1h7m", time.Hour + 7*time.Minute},
|
||||||
|
{"1w", 7 * 24 * time.Hour},
|
||||||
|
{"2m", 2 * time.Minute},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, err := ParseDuration(tc.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseDuration(%q): %v", tc.in, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("ParseDuration(%q) = %v, want %v", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDurationRejectsInvalid(t *testing.T) {
|
||||||
|
for _, in := range []string{"", "foo", "5x", "-300s", "s", "1h 5x"} {
|
||||||
|
if _, err := ParseDuration(in); err == nil {
|
||||||
|
t.Errorf("ParseDuration(%q) = nil, want error", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDuration(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in time.Duration
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{5 * 24 * time.Hour, "5 days"},
|
||||||
|
{300 * time.Second, "5 minutes"},
|
||||||
|
{4000 * time.Second, "about 1 hour 7 minutes"},
|
||||||
|
{time.Hour, "1 hour"},
|
||||||
|
{0, "0 seconds"},
|
||||||
|
{time.Second, "1 second"},
|
||||||
|
{2 * time.Minute, "2 minutes"},
|
||||||
|
{24 * time.Hour, "1 day"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := FormatDuration(tc.in); got != tc.want {
|
||||||
|
t.Errorf("FormatDuration(%v) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,135 @@
|
|||||||
|
package postfix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RetryPolicy is this Postfix's deferred-mail retry timings, as postconf
|
||||||
|
// reports them. The panel loads it once at HTTP start (architecture.md) and
|
||||||
|
// never re-reads it for a request.
|
||||||
|
type RetryPolicy struct {
|
||||||
|
QueueRunDelay time.Duration
|
||||||
|
MinimalBackoff time.Duration
|
||||||
|
MaximalBackoff time.Duration
|
||||||
|
MaximalQueueLifetime time.Duration
|
||||||
|
BounceQueueLifetime time.Duration
|
||||||
|
DelayWarningTime time.Duration
|
||||||
|
// FromDefaults is true when postconf could not be read and the compiled-in
|
||||||
|
// Postfix 3.x values were substituted. The Mail queue card shows a muted
|
||||||
|
// note in that case so an operator who overrode the parameters is not
|
||||||
|
// silently shown the stock numbers.
|
||||||
|
FromDefaults bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRetryPolicy is Postfix 3.x compiled-in values for the six parameters
|
||||||
|
// build/postfix-config.sh does not set. Used when postconf is missing (unit
|
||||||
|
// tests, a binary outside the container) so the panel still starts.
|
||||||
|
func DefaultRetryPolicy() RetryPolicy {
|
||||||
|
return RetryPolicy{
|
||||||
|
QueueRunDelay: 300 * time.Second,
|
||||||
|
MinimalBackoff: 300 * time.Second,
|
||||||
|
MaximalBackoff: 4000 * time.Second,
|
||||||
|
MaximalQueueLifetime: 5 * 24 * time.Hour,
|
||||||
|
BounceQueueLifetime: 5 * 24 * time.Hour,
|
||||||
|
DelayWarningTime: 0,
|
||||||
|
FromDefaults: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryConfKeys is the fixed argv tail for `postconf -h`. Order matches the
|
||||||
|
// fields of RetryPolicy. No user input is interpolated (security.md).
|
||||||
|
var retryConfKeys = []string{
|
||||||
|
"queue_run_delay",
|
||||||
|
"minimal_backoff_time",
|
||||||
|
"maximal_backoff_time",
|
||||||
|
"maximal_queue_lifetime",
|
||||||
|
"bounce_queue_lifetime",
|
||||||
|
"delay_warning_time",
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRetryConf runs `postconf -h` for the retry-policy keys. Tests replace it
|
||||||
|
// the same way logtail stubs queueIDs.
|
||||||
|
var readRetryConf = postconfRetryValues
|
||||||
|
|
||||||
|
func postconfRetryValues() ([]string, error) {
|
||||||
|
// Fixed argv, no user input — same pattern as Queue (security.md).
|
||||||
|
cmd := exec.Command("postconf", "-h",
|
||||||
|
"queue_run_delay",
|
||||||
|
"minimal_backoff_time",
|
||||||
|
"maximal_backoff_time",
|
||||||
|
"maximal_queue_lifetime",
|
||||||
|
"bounce_queue_lifetime",
|
||||||
|
"delay_warning_time",
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("postconf -h: %w: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
if len(lines) != len(retryConfKeys) {
|
||||||
|
return nil, fmt.Errorf("postconf -h: got %d values, want %d", len(lines), len(retryConfKeys))
|
||||||
|
}
|
||||||
|
for i := range lines {
|
||||||
|
lines[i] = strings.TrimSpace(lines[i])
|
||||||
|
}
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRetryPolicy reads the effective Postfix retry parameters. On any failure
|
||||||
|
// it logs a warning and returns DefaultRetryPolicy; the HTTP role must not
|
||||||
|
// refuse to start because postconf is absent.
|
||||||
|
func LoadRetryPolicy() RetryPolicy {
|
||||||
|
lines, err := readRetryConf()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("postfix: retry policy: %v; using compiled-in defaults", err)
|
||||||
|
return DefaultRetryPolicy()
|
||||||
|
}
|
||||||
|
p, err := parseRetryPolicy(lines)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("postfix: retry policy: %v; using compiled-in defaults", err)
|
||||||
|
return DefaultRetryPolicy()
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRetryPolicy(lines []string) (RetryPolicy, error) {
|
||||||
|
if len(lines) != len(retryConfKeys) {
|
||||||
|
return RetryPolicy{}, fmt.Errorf("got %d values, want %d", len(lines), len(retryConfKeys))
|
||||||
|
}
|
||||||
|
var durs [6]time.Duration
|
||||||
|
for i, line := range lines {
|
||||||
|
d, err := ParseDuration(line)
|
||||||
|
if err != nil {
|
||||||
|
return RetryPolicy{}, fmt.Errorf("%s: %w", retryConfKeys[i], err)
|
||||||
|
}
|
||||||
|
durs[i] = d
|
||||||
|
}
|
||||||
|
return RetryPolicy{
|
||||||
|
QueueRunDelay: durs[0],
|
||||||
|
MinimalBackoff: durs[1],
|
||||||
|
MaximalBackoff: durs[2],
|
||||||
|
MaximalQueueLifetime: durs[3],
|
||||||
|
BounceQueueLifetime: durs[4],
|
||||||
|
DelayWarningTime: durs[5],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FirstRetry is the human string for the first deferred retry (minimal
|
||||||
|
// backoff), shared by the Mail queue card and delivery history.
|
||||||
|
func (p RetryPolicy) FirstRetry() string {
|
||||||
|
return FormatDuration(p.MinimalBackoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackoffCap is the human string for maximal_backoff_time.
|
||||||
|
func (p RetryPolicy) BackoffCap() string {
|
||||||
|
return FormatDuration(p.MaximalBackoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueLifetime is the human string for maximal_queue_lifetime.
|
||||||
|
func (p RetryPolicy) QueueLifetime() string {
|
||||||
|
return FormatDuration(p.MaximalQueueLifetime)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package postfix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadRetryPolicyUsesPostconfValues(t *testing.T) {
|
||||||
|
old := readRetryConf
|
||||||
|
readRetryConf = func() ([]string, error) {
|
||||||
|
return []string{"300s", "300s", "4000s", "2d", "5d", "0"}, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { readRetryConf = old })
|
||||||
|
|
||||||
|
p := LoadRetryPolicy()
|
||||||
|
if p.FromDefaults {
|
||||||
|
t.Fatal("FromDefaults = true, want live postconf values")
|
||||||
|
}
|
||||||
|
if p.MinimalBackoff != 300*time.Second {
|
||||||
|
t.Errorf("MinimalBackoff = %v, want 300s", p.MinimalBackoff)
|
||||||
|
}
|
||||||
|
if p.MaximalBackoff != 4000*time.Second {
|
||||||
|
t.Errorf("MaximalBackoff = %v, want 4000s", p.MaximalBackoff)
|
||||||
|
}
|
||||||
|
if p.MaximalQueueLifetime != 2*24*time.Hour {
|
||||||
|
t.Errorf("MaximalQueueLifetime = %v, want 2d", p.MaximalQueueLifetime)
|
||||||
|
}
|
||||||
|
if p.BounceQueueLifetime != 5*24*time.Hour {
|
||||||
|
t.Errorf("BounceQueueLifetime = %v, want 5d", p.BounceQueueLifetime)
|
||||||
|
}
|
||||||
|
if p.DelayWarningTime != 0 {
|
||||||
|
t.Errorf("DelayWarningTime = %v, want 0", p.DelayWarningTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRetryPolicyFallsBackWhenPostconfFails(t *testing.T) {
|
||||||
|
old := readRetryConf
|
||||||
|
readRetryConf = func() ([]string, error) {
|
||||||
|
return nil, errors.New("exec: not found")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { readRetryConf = old })
|
||||||
|
|
||||||
|
p := LoadRetryPolicy()
|
||||||
|
want := DefaultRetryPolicy()
|
||||||
|
if !p.FromDefaults {
|
||||||
|
t.Error("FromDefaults = false, want true when postconf fails")
|
||||||
|
}
|
||||||
|
if p.QueueRunDelay != want.QueueRunDelay || p.MinimalBackoff != want.MinimalBackoff ||
|
||||||
|
p.MaximalBackoff != want.MaximalBackoff || p.MaximalQueueLifetime != want.MaximalQueueLifetime {
|
||||||
|
t.Errorf("fallback = %+v, want compiled-in defaults %+v", p, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRetryPolicyFallsBackOnUnparseableValues(t *testing.T) {
|
||||||
|
old := readRetryConf
|
||||||
|
readRetryConf = func() ([]string, error) {
|
||||||
|
return []string{"300s", "nope", "4000s", "5d", "5d", "0"}, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { readRetryConf = old })
|
||||||
|
|
||||||
|
p := LoadRetryPolicy()
|
||||||
|
if !p.FromDefaults {
|
||||||
|
t.Error("FromDefaults = false, want true when a value cannot be parsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRetryPolicyStock(t *testing.T) {
|
||||||
|
p, err := parseRetryPolicy([]string{"300s", "300s", "4000s", "5d", "5d", "0"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseRetryPolicy: %v", err)
|
||||||
|
}
|
||||||
|
if p.FromDefaults {
|
||||||
|
t.Error("parsed policy should not be marked FromDefaults")
|
||||||
|
}
|
||||||
|
if p.QueueRunDelay != 300*time.Second || p.MaximalQueueLifetime != 5*24*time.Hour {
|
||||||
|
t.Errorf("parsed = %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,12 +29,32 @@ const (
|
|||||||
// hashed — so it can be shown exactly once at creation/regeneration
|
// hashed — so it can be shown exactly once at creation/regeneration
|
||||||
// (security.md). Addresses is populated only in 'list' mode.
|
// (security.md). Addresses is populated only in 'list' mode.
|
||||||
type Application struct {
|
type Application struct {
|
||||||
ID int64
|
ID int64
|
||||||
DomainID int64
|
DomainID int64
|
||||||
Login string
|
Login string
|
||||||
AddressMode string
|
AddressMode string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Addresses []string
|
Addresses []string
|
||||||
|
AuthIPRestrict bool // when true, only AuthAllowedIPs may submit as this login
|
||||||
|
AuthAllowedIPs []string // client IPs permitted when AuthIPRestrict is set
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowsAuthFromIP reports whether a client at ip may submit mail authenticated
|
||||||
|
// as this application. When AuthIPRestrict is false, every IP is allowed.
|
||||||
|
func (a Application) AllowsAuthFromIP(ip string) bool {
|
||||||
|
if !a.AuthIPRestrict {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
c := net.ParseIP(ip)
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, allowed := range a.AuthAllowedIPs {
|
||||||
|
if p := net.ParseIP(allowed); p != nil && p.Equal(c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binding is one sender-address → login pair, as consumed by the
|
// Binding is one sender-address → login pair, as consumed by the
|
||||||
@@ -147,7 +169,7 @@ func normalizedList(mode string, addresses []string) []string {
|
|||||||
// ErrApplicationNotFound.
|
// ErrApplicationNotFound.
|
||||||
func (s *Store) GetApplication(id int64) (Application, error) {
|
func (s *Store) GetApplication(id int64) (Application, error) {
|
||||||
row := s.db.QueryRow(
|
row := s.db.QueryRow(
|
||||||
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE id = ?", id)
|
"SELECT id, domain_id, login, address_mode, created_at, auth_ip_restrict, auth_allowed_ips FROM applications WHERE id = ?", id)
|
||||||
a, err := scanApplication(row)
|
a, err := scanApplication(row)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Application{}, ErrApplicationNotFound
|
return Application{}, ErrApplicationNotFound
|
||||||
@@ -163,11 +185,35 @@ func (s *Store) GetApplication(id int64) (Application, error) {
|
|||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetApplicationByLogin returns one application by its globally unique SASL login.
|
||||||
|
func (s *Store) GetApplicationByLogin(login string) (Application, error) {
|
||||||
|
row := s.db.QueryRow(
|
||||||
|
"SELECT id, domain_id, login, address_mode, created_at, auth_ip_restrict, auth_allowed_ips FROM applications WHERE login = ?", login)
|
||||||
|
a, err := scanApplication(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Application{}, ErrApplicationNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Application{}, err
|
||||||
|
}
|
||||||
|
addrs, err := s.applicationAddresses(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Application{}, err
|
||||||
|
}
|
||||||
|
a.Addresses = addrs
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationByLogin is the milter-facing alias for GetApplicationByLogin.
|
||||||
|
func (s *Store) ApplicationByLogin(login string) (Application, error) {
|
||||||
|
return s.GetApplicationByLogin(login)
|
||||||
|
}
|
||||||
|
|
||||||
// ListApplicationsByDomain returns a domain's applications ordered by login,
|
// ListApplicationsByDomain returns a domain's applications ordered by login,
|
||||||
// each with its address list populated (product.md).
|
// each with its address list populated (product.md).
|
||||||
func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) {
|
func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE domain_id = ? ORDER BY login",
|
"SELECT id, domain_id, login, address_mode, created_at, auth_ip_restrict, auth_allowed_ips FROM applications WHERE domain_id = ? ORDER BY login",
|
||||||
domainID)
|
domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list applications: %w", err)
|
return nil, fmt.Errorf("list applications: %w", err)
|
||||||
@@ -272,6 +318,31 @@ func (s *Store) ListBindings() ([]Binding, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateApplicationAuthIPs sets whether client IP restriction is active for an
|
||||||
|
// application and, when active, the permitted client addresses. The caller has
|
||||||
|
// already validated the IPs (security.md). Returns ErrApplicationNotFound when
|
||||||
|
// the id does not exist.
|
||||||
|
func (s *Store) UpdateApplicationAuthIPs(id int64, restrict bool, ips []string) error {
|
||||||
|
if !restrict {
|
||||||
|
ips = nil
|
||||||
|
}
|
||||||
|
res, err := s.db.Exec(
|
||||||
|
"UPDATE applications SET auth_ip_restrict = ?, auth_allowed_ips = ? WHERE id = ?",
|
||||||
|
boolToInt(restrict), strings.Join(ips, ","), id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update application auth IPs: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update application auth IPs rows: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrApplicationNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteApplication removes an application and its addresses (via cascade),
|
// DeleteApplication removes an application and its addresses (via cascade),
|
||||||
// returning the deleted application so the caller can drop its sasldb2 entry
|
// returning the deleted application so the caller can drop its sasldb2 entry
|
||||||
// (product.md). Returns ErrApplicationNotFound if no such row existed.
|
// (product.md). Returns ErrApplicationNotFound if no such row existed.
|
||||||
@@ -317,10 +388,21 @@ func scanApplication(r scanRow) (Application, error) {
|
|||||||
var (
|
var (
|
||||||
a Application
|
a Application
|
||||||
createdAt string
|
createdAt string
|
||||||
|
restrict int
|
||||||
|
ips sql.NullString
|
||||||
)
|
)
|
||||||
if err := r.Scan(&a.ID, &a.DomainID, &a.Login, &a.AddressMode, &createdAt); err != nil {
|
if err := r.Scan(&a.ID, &a.DomainID, &a.Login, &a.AddressMode, &createdAt, &restrict, &ips); err != nil {
|
||||||
return Application{}, err
|
return Application{}, err
|
||||||
}
|
}
|
||||||
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
a.AuthIPRestrict = restrict != 0
|
||||||
|
a.AuthAllowedIPs = splitIPs(ips.String)
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,3 +187,43 @@ func TestListLoginsByDomain(t *testing.T) {
|
|||||||
t.Fatalf("logins = %v, want [a b]", logins)
|
t.Fatalf("logins = %v, want [a b]", logins)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplicationAuthIPs(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
d := addTestDomain(t, st, "example.com")
|
||||||
|
a, err := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.AuthIPRestrict || len(a.AuthAllowedIPs) != 0 {
|
||||||
|
t.Fatalf("new app should have no IP restriction: %+v", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.UpdateApplicationAuthIPs(a.ID, true, []string{"203.0.113.1", "2001:db8::1"}); err != nil {
|
||||||
|
t.Fatalf("UpdateApplicationAuthIPs: %v", err)
|
||||||
|
}
|
||||||
|
got, err := st.GetApplication(a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.AuthIPRestrict || len(got.AuthAllowedIPs) != 2 {
|
||||||
|
t.Fatalf("after update: %+v", got)
|
||||||
|
}
|
||||||
|
if !got.AllowsAuthFromIP("203.0.113.1") || !got.AllowsAuthFromIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
|
||||||
|
t.Fatal("listed IPs should match")
|
||||||
|
}
|
||||||
|
if got.AllowsAuthFromIP("198.51.100.7") {
|
||||||
|
t.Fatal("unlisted IP must not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.UpdateApplicationAuthIPs(a.ID, false, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ = st.GetApplication(a.ID)
|
||||||
|
if got.AuthIPRestrict || len(got.AuthAllowedIPs) != 0 {
|
||||||
|
t.Fatalf("cleared restriction: %+v", got)
|
||||||
|
}
|
||||||
|
if !got.AllowsAuthFromIP("198.51.100.7") {
|
||||||
|
t.Fatal("restriction off should allow any IP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DMARCReportsMaxKeep is how many parsed reports are kept before pruning.
|
||||||
|
DMARCReportsMaxKeep = 500
|
||||||
|
// DMARCReportsMaxAgeDays drops reports older than this window.
|
||||||
|
DMARCReportsMaxAgeDays = 90
|
||||||
|
// DMARCParseFailuresKey counts ingest failures (settings).
|
||||||
|
DMARCParseFailuresKey = "dmarc_parse_failures_total"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrDMARCReportNotFound is returned when a report id does not exist.
|
||||||
|
var ErrDMARCReportNotFound = errors.New("dmarc report not found")
|
||||||
|
|
||||||
|
// DMARCReport is one parsed aggregate summary.
|
||||||
|
type DMARCReport struct {
|
||||||
|
ID int64
|
||||||
|
Domain string
|
||||||
|
Reporter string
|
||||||
|
ReportID string
|
||||||
|
PeriodBegin time.Time
|
||||||
|
PeriodEnd time.Time
|
||||||
|
ReceivedAt time.Time
|
||||||
|
ContactEmail string
|
||||||
|
PolicyP string
|
||||||
|
PolicySP string
|
||||||
|
PolicyPct int
|
||||||
|
PolicyADKIM string
|
||||||
|
PolicyASPF string
|
||||||
|
PassCount int
|
||||||
|
FailCount int
|
||||||
|
Recipient string
|
||||||
|
Records []DMARCReportRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARCReportRecord is one source row inside a report.
|
||||||
|
type DMARCReportRecord struct {
|
||||||
|
SourceIP string
|
||||||
|
Count int
|
||||||
|
Disposition string
|
||||||
|
SPFResult string
|
||||||
|
DKIMResult string
|
||||||
|
HeaderFrom string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARCReportSummary is a list-row without per-record detail.
|
||||||
|
type DMARCReportSummary struct {
|
||||||
|
ID int64
|
||||||
|
Domain string
|
||||||
|
Reporter string
|
||||||
|
PeriodBegin time.Time
|
||||||
|
PeriodEnd time.Time
|
||||||
|
ReceivedAt time.Time
|
||||||
|
PassCount int
|
||||||
|
FailCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARCSourceRollup aggregates pass/fail per source over a window.
|
||||||
|
type DMARCSourceRollup struct {
|
||||||
|
SourceIP string
|
||||||
|
PassCount int
|
||||||
|
FailCount int
|
||||||
|
Disposition string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARCIngestStats is panel-facing ingest health.
|
||||||
|
type DMARCIngestStats struct {
|
||||||
|
LastReceivedAt *time.Time
|
||||||
|
KeptThisWeek int
|
||||||
|
ParseFailures int
|
||||||
|
IngestOK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertDMARCReport stores a parsed report and its records, replacing any prior
|
||||||
|
// row with the same reporter/report_id/domain triple.
|
||||||
|
func (s *Store) InsertDMARCReport(rep DMARCReport) (int64, error) {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("begin insert dmarc report: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`DELETE FROM dmarc_reports WHERE reporter = ? AND report_id = ? AND domain = ?`,
|
||||||
|
rep.Reporter, rep.ReportID, rep.Domain,
|
||||||
|
); err != nil {
|
||||||
|
return 0, fmt.Errorf("delete prior dmarc report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := tx.Exec(`
|
||||||
|
INSERT INTO dmarc_reports (
|
||||||
|
domain, reporter, report_id, period_begin, period_end, received_at,
|
||||||
|
contact_email, policy_p, policy_sp, policy_pct, policy_adkim, policy_aspf,
|
||||||
|
pass_count, fail_count, recipient
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
rep.Domain, rep.Reporter, rep.ReportID,
|
||||||
|
rep.PeriodBegin.UTC().Format(time.RFC3339),
|
||||||
|
rep.PeriodEnd.UTC().Format(time.RFC3339),
|
||||||
|
rep.ReceivedAt.UTC().Format(time.RFC3339),
|
||||||
|
rep.ContactEmail, rep.PolicyP, rep.PolicySP, rep.PolicyPct,
|
||||||
|
rep.PolicyADKIM, rep.PolicyASPF, rep.PassCount, rep.FailCount, rep.Recipient,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert dmarc report: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("dmarc report id: %w", err)
|
||||||
|
}
|
||||||
|
for _, rec := range rep.Records {
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
INSERT INTO dmarc_report_records (
|
||||||
|
report_row_id, source_ip, count, disposition, spf_result, dkim_result, header_from
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, rec.SourceIP, rec.Count, rec.Disposition, rec.SPFResult, rec.DKIMResult, rec.HeaderFrom,
|
||||||
|
); err != nil {
|
||||||
|
return 0, fmt.Errorf("insert dmarc record: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, fmt.Errorf("commit dmarc report: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneDMARCReports enforces count and age caps.
|
||||||
|
func (s *Store) PruneDMARCReports() error {
|
||||||
|
cutoff := time.Now().UTC().AddDate(0, 0, -DMARCReportsMaxAgeDays).Format(time.RFC3339)
|
||||||
|
if _, err := s.db.Exec(`DELETE FROM dmarc_reports WHERE received_at < ?`, cutoff); err != nil {
|
||||||
|
return fmt.Errorf("prune dmarc by age: %w", err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM dmarc_reports`).Scan(&count); err != nil {
|
||||||
|
return fmt.Errorf("count dmarc reports: %w", err)
|
||||||
|
}
|
||||||
|
if count <= DMARCReportsMaxKeep {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
excess := count - DMARCReportsMaxKeep
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
DELETE FROM dmarc_reports WHERE id IN (
|
||||||
|
SELECT id FROM dmarc_reports ORDER BY received_at ASC LIMIT ?
|
||||||
|
)`, excess)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prune dmarc by count: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrDMARCParseFailures bumps the failure counter in settings.
|
||||||
|
func (s *Store) IncrDMARCParseFailures() error {
|
||||||
|
raw, err := s.GetSetting(DMARCParseFailuresKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
if raw != "" {
|
||||||
|
fmt.Sscanf(raw, "%d", &n)
|
||||||
|
}
|
||||||
|
return s.SetSetting(DMARCParseFailuresKey, fmt.Sprintf("%d", n+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDMARCReports returns recent summaries, optionally limited to domains.
|
||||||
|
func (s *Store) ListDMARCReports(domains []string, limit int) ([]DMARCReportSummary, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
rows *sql.Rows
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if len(domains) == 0 {
|
||||||
|
rows, err = s.db.Query(`
|
||||||
|
SELECT id, domain, reporter, period_begin, period_end, received_at, pass_count, fail_count
|
||||||
|
FROM dmarc_reports ORDER BY received_at DESC LIMIT ?`, limit)
|
||||||
|
} else {
|
||||||
|
placeholders := make([]any, 0, len(domains)+1)
|
||||||
|
q := `SELECT id, domain, reporter, period_begin, period_end, received_at, pass_count, fail_count
|
||||||
|
FROM dmarc_reports WHERE domain IN (`
|
||||||
|
for i, d := range domains {
|
||||||
|
if i > 0 {
|
||||||
|
q += ","
|
||||||
|
}
|
||||||
|
q += "?"
|
||||||
|
placeholders = append(placeholders, d)
|
||||||
|
}
|
||||||
|
q += `) ORDER BY received_at DESC LIMIT ?`
|
||||||
|
placeholders = append(placeholders, limit)
|
||||||
|
rows, err = s.db.Query(q, placeholders...)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list dmarc reports: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []DMARCReportSummary
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
summary DMARCReportSummary
|
||||||
|
begin string
|
||||||
|
end string
|
||||||
|
recv string
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&summary.ID, &summary.Domain, &summary.Reporter, &begin, &end, &recv, &summary.PassCount, &summary.FailCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
summary.PeriodBegin, _ = time.Parse(time.RFC3339, begin)
|
||||||
|
summary.PeriodEnd, _ = time.Parse(time.RFC3339, end)
|
||||||
|
summary.ReceivedAt, _ = time.Parse(time.RFC3339, recv)
|
||||||
|
out = append(out, summary)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDMARCReport loads one report with records.
|
||||||
|
func (s *Store) GetDMARCReport(id int64) (DMARCReport, error) {
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
SELECT id, domain, reporter, report_id, period_begin, period_end, received_at,
|
||||||
|
contact_email, policy_p, policy_sp, policy_pct, policy_adkim, policy_aspf,
|
||||||
|
pass_count, fail_count, recipient
|
||||||
|
FROM dmarc_reports WHERE id = ?`, id)
|
||||||
|
rep, err := scanDMARCReport(row)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return DMARCReport{}, ErrDMARCReportNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return DMARCReport{}, err
|
||||||
|
}
|
||||||
|
recs, err := s.listDMARCReportRecords(id)
|
||||||
|
if err != nil {
|
||||||
|
return DMARCReport{}, err
|
||||||
|
}
|
||||||
|
rep.Records = recs
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDMARCReportsForDomain returns summaries for one sending domain.
|
||||||
|
func (s *Store) ListDMARCReportsForDomain(domain string, limit int) ([]DMARCReportSummary, error) {
|
||||||
|
return s.ListDMARCReports([]string{domain}, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARCDomainRollup summarises pass/fail for a domain over the last windowDays.
|
||||||
|
func (s *Store) DMARCDomainRollup(domain string, windowDays int) (pass, fail int, err error) {
|
||||||
|
cutoff := time.Now().UTC().AddDate(0, 0, -windowDays).Format(time.RFC3339)
|
||||||
|
err = s.db.QueryRow(`
|
||||||
|
SELECT COALESCE(SUM(pass_count), 0), COALESCE(SUM(fail_count), 0)
|
||||||
|
FROM dmarc_reports WHERE domain = ? AND received_at >= ?`,
|
||||||
|
domain, cutoff,
|
||||||
|
).Scan(&pass, &fail)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("dmarc domain rollup: %w", err)
|
||||||
|
}
|
||||||
|
return pass, fail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARCSourceRollups aggregates per-source rows for a domain over windowDays.
|
||||||
|
func (s *Store) DMARCSourceRollups(domain string, windowDays int) ([]DMARCSourceRollup, error) {
|
||||||
|
cutoff := time.Now().UTC().AddDate(0, 0, -windowDays).Format(time.RFC3339)
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT r.source_ip,
|
||||||
|
SUM(CASE WHEN r.spf_result = 'pass' OR r.dkim_result = 'pass' THEN r.count ELSE 0 END),
|
||||||
|
SUM(CASE WHEN r.spf_result != 'pass' AND r.dkim_result != 'pass' THEN r.count ELSE 0 END),
|
||||||
|
MAX(r.disposition)
|
||||||
|
FROM dmarc_report_records r
|
||||||
|
INNER JOIN dmarc_reports d ON d.id = r.report_row_id
|
||||||
|
WHERE d.domain = ? AND d.received_at >= ?
|
||||||
|
GROUP BY r.source_ip
|
||||||
|
ORDER BY 2 DESC, 3 DESC`, domain, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dmarc source rollups: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []DMARCSourceRollup
|
||||||
|
for rows.Next() {
|
||||||
|
var rollup DMARCSourceRollup
|
||||||
|
if err := rows.Scan(&rollup.SourceIP, &rollup.PassCount, &rollup.FailCount, &rollup.Disposition); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, rollup)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARCIngestStats returns ingest health for the panel.
|
||||||
|
func (s *Store) DMARCIngestStats() (DMARCIngestStats, error) {
|
||||||
|
var stats DMARCIngestStats
|
||||||
|
var last sql.NullString
|
||||||
|
err := s.db.QueryRow(`SELECT MAX(received_at) FROM dmarc_reports`).Scan(&last)
|
||||||
|
if err != nil {
|
||||||
|
return stats, fmt.Errorf("dmarc last received: %w", err)
|
||||||
|
}
|
||||||
|
if last.Valid && last.String != "" {
|
||||||
|
t, _ := time.Parse(time.RFC3339, last.String)
|
||||||
|
stats.LastReceivedAt = &t
|
||||||
|
stats.IngestOK = time.Since(t) < 8*24*time.Hour
|
||||||
|
}
|
||||||
|
weekCutoff := time.Now().UTC().AddDate(0, 0, -7).Format(time.RFC3339)
|
||||||
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM dmarc_reports WHERE received_at >= ?`, weekCutoff).Scan(&stats.KeptThisWeek); err != nil {
|
||||||
|
return stats, fmt.Errorf("dmarc week count: %w", err)
|
||||||
|
}
|
||||||
|
raw, err := s.GetSetting(DMARCParseFailuresKey)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
if raw != "" {
|
||||||
|
fmt.Sscanf(raw, "%d", &stats.ParseFailures)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listDMARCReportRecords(reportID int64) ([]DMARCReportRecord, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT source_ip, count, disposition, spf_result, dkim_result, header_from
|
||||||
|
FROM dmarc_report_records WHERE report_row_id = ? ORDER BY count DESC`, reportID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list dmarc records: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []DMARCReportRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var rec DMARCReportRecord
|
||||||
|
if err := rows.Scan(&rec.SourceIP, &rec.Count, &rec.Disposition, &rec.SPFResult, &rec.DKIMResult, &rec.HeaderFrom); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDMARCReport(r scanRow) (DMARCReport, error) {
|
||||||
|
var (
|
||||||
|
rep DMARCReport
|
||||||
|
begin string
|
||||||
|
end string
|
||||||
|
recv string
|
||||||
|
)
|
||||||
|
if err := r.Scan(
|
||||||
|
&rep.ID, &rep.Domain, &rep.Reporter, &rep.ReportID, &begin, &end, &recv,
|
||||||
|
&rep.ContactEmail, &rep.PolicyP, &rep.PolicySP, &rep.PolicyPct,
|
||||||
|
&rep.PolicyADKIM, &rep.PolicyASPF, &rep.PassCount, &rep.FailCount, &rep.Recipient,
|
||||||
|
); err != nil {
|
||||||
|
return DMARCReport{}, err
|
||||||
|
}
|
||||||
|
rep.PeriodBegin, _ = time.Parse(time.RFC3339, begin)
|
||||||
|
rep.PeriodEnd, _ = time.Parse(time.RFC3339, end)
|
||||||
|
rep.ReceivedAt, _ = time.Parse(time.RFC3339, recv)
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDMARCReportRoundTrip(t *testing.T) {
|
||||||
|
st, err := Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
_, err = st.InsertDMARCReport(DMARCReport{
|
||||||
|
Domain: "example.com",
|
||||||
|
Reporter: "google.com",
|
||||||
|
ReportID: "abc",
|
||||||
|
PeriodBegin: now.Add(-24 * time.Hour),
|
||||||
|
PeriodEnd: now,
|
||||||
|
ReceivedAt: now,
|
||||||
|
PassCount: 5,
|
||||||
|
FailCount: 1,
|
||||||
|
Records: []DMARCReportRecord{{
|
||||||
|
SourceIP: "203.0.113.1", Count: 5, SPFResult: "pass", DKIMResult: "pass",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := st.ListDMARCReports([]string{"example.com"}, 10)
|
||||||
|
if err != nil || len(list) != 1 {
|
||||||
|
t.Fatalf("list: %v err=%v", list, err)
|
||||||
|
}
|
||||||
|
got, err := st.GetDMARCReport(list[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.PassCount != 5 || len(got.Records) != 1 {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneDMARCReports(t *testing.T) {
|
||||||
|
st, err := Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
old := time.Now().UTC().AddDate(0, 0, -120)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := st.InsertDMARCReport(DMARCReport{
|
||||||
|
Domain: "example.com", Reporter: "r", ReportID: string(rune('a' + i)),
|
||||||
|
PeriodBegin: old, PeriodEnd: old, ReceivedAt: old,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := st.PruneDMARCReports(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, _ := st.ListDMARCReports(nil, 100)
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("expected prune by age, got %d", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user