Invert level-2 semantics so domain limits apply to every client IP and
application limits with trusted IPs raise the ceiling above the domain
(still capped by level 1). Panel shows L1, validates maxima, and documents
the model on Settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
The delivery log now lists what identifies a message and nothing else —
time, sender, recipient, subject, status — and links each row to
/deliveries/{id}, which carries the rest: the sending domain, the
application it was submitted under, the Postfix queue id to search the
system log for, and when the status was last reported. Domain and
application were a column each; they were the widest thing in the table
after the addresses and repeat down every filtered page, and they remain
the log's two filters. Back returns to the page and filters the row was
opened from, rebuilt from the log's own parameters only.
Subjects are now decoded for display as well as on the way in. The milter
has decoded them since d35b309, but the rows it wrote before that still
hold the raw =?utf-8?Q?...?= header, and those are the ones an operator is
most likely to still be reading. The decoder moves to internal/mailhdr,
shared by the milter and the panel; it is idempotent, so a row decoded
once passes through unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codeberg is being retired as the project's public site, so every reference now
points at GitHub. That includes the Go module path (codeberg.org/mix/selfpost →
github.com/mixeme/selfpost): leaving an import path on a host that is going
away would break `go get` and `go install`, so this is not only a docs change.
Touches go.mod, test/e2e/go.mod, all imports, Makefile MODULE, the -ldflags
version stamp in build/Dockerfile and docs/development.md, the licence headers
in the SVG/HTML assets, and README (no more primary/mirror pair).
Comments no longer cite the archived specification. "spec 7.6.1", "spec 5.1"
and friends pointed into docs/archive/specification-v1.0.md, which is marked as
not a source of truth; each is now a reference to the live document that owns
the subject — architecture.md (with section), product.md, security.md or the
README. The review only asked for the 7.x refs (code-review.md § 4), but 4/5/6/
8/9 had the same defect, so they went too. Comments only, no behaviour change.
Also closes the remaining review items: architecture.md gained a Code layers
section with the layer diagram (A2), and TestParseDelivery gained the exotic
mail.log cases (§ 3).
Fixes a bug that last test found: the delivery-line pattern matched status=
greedily, taking the *last* occurrence on the line. Postfix appends the remote
server's reply verbatim, so a rejection whose reply quoted "status=sent" was
filed as a delivered message in the send log. It now takes the first status=
after the recipient, which is the real field.
R7 (CONTRIBUTING.md) moved to roadmap 2.x — one developer, no external PR flow,
so the file would have no audience yet. R1 (compose image tag) and the git tag
stay in roadmap § v1.x as the release-commit steps.
gofmt/go vet clean on both modules; go test ./... green except the three known
Windows-only failures (file perms, backslash paths, renaming an open file).
Not exercised on the dev server — no Docker locally.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- logtail: persist the read position (offset + fingerprint of the log's
first 512 bytes) in a new logtail_state table (migration 0003) and
resume from it on start, so delivery lines written while the panel was
down are parsed instead of skipped and their send-log rows no longer
stay "queued" forever. Fingerprint mismatch (rotated/recreated while
down) reads the file from the start — re-parsing is idempotent; a
first-ever start with nothing stored still begins at end-of-file.
Writes are throttled to one per 5s, forced on rotation and shutdown.
- milter: count messages that passed the level-2 check but have not
reached the send log yet (internal/milter/inflight.go), so concurrent
SMTP sessions cannot each spend the same last slot. A literal
count+insert transaction, as the review suggested, is not possible:
the count happens at MAIL FROM and the insert at end-of-message.
Reservations are released after the insert, on ABORT, and after a
10-minute TTL — a client that drops mid-transaction must not be able
to hold a slot, since the limiter is fail-open by design.
Docs: architecture.md (log tailer, persistence, L2 counting),
security.md and roadmap.md (restart gap closed, container recreate
remains), CHANGELOG, progress.md, code-review.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A non-Latin subject arrives as RFC 2047 encoded-words, which the send-log
printed verbatim: unreadable, and one unbreakable run wide enough to push the
Status column out of its card. Decode at journal time (UTF-8/ASCII; exotic
charsets keep the raw header) and cap at 200 characters, then clip the column
to one line with the full text in the tooltip so no subject can widen the row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The journal-milter, until now a pure monitor, now refuses a message with a
4xx tempfail (RespTempFail/451) at MAIL FROM when a per-domain or per-
application limit is exceeded. Key is the client IP; the count is
COUNT(DISTINCT queue_id) over a sliding window reusing the send log; the
limit applies only when a non-empty IP binding matches the client (empty
binding => level-1 only, per spec 7.4). Enforcement is fail-open on the
milter's own errors — a limiter malfunction never blocks mail, and Postfix's
level-1 anvil limit stays the independent backstop. Refused messages are
recorded in send_log with status "rejected" for UI visibility.
- store/ratelimits.go: RateLimit type (+Active/AllowsIP), id-keyed get/set/
delete for the panel, name/login-keyed lookup + windowed distinct-message
count for the milter, DeleteRateLimitsForDomain. No migration — the
rate_limits table has existed since Phase 2.
- milter: enforce at MailFrom, fail-open helper overLimit, InsertRejected.
- web: server-side validated IP/ceiling/window forms on the domain page and
per application; routes POST /domains/{id}/ratelimit and
/applications/{aid}/ratelimit. Milter reads rows live, so no reload.
- domain/app services clear limits on deletion (rate_limits has no FK cascade).
Unit tests + container e2e (p8) green: refusal on both scopes, unregistered
IP ignored, fail-open with the panel stopped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement the structured send log (spec 7.3), the project's highest-risk
component since a milter bug can break the relay itself.
- internal/milter: go-milter v0.4.1 journal-milter. Per-connection session
collects SASL login, From, recipients and Subject across callbacks and
writes one send_log "queued" row per (queue-id, recipient) at EOM
(spec 7.3.3). Monitoring only: callbacks return Continue/Accept, recorder
errors are logged never propagated, so it can never block mail.
- internal/logtail: polling mail.log tailer with rotation handling (inode
change / truncation), parses sent/deferred/bounced/expired by queue-id +
recipient and advances rows; background retention sweep prunes rows past
SEND_LOG_RETENTION_DAYS (default 90) at startup and every 6h.
- internal/store/sendlog.go: InsertQueued, UpdateStatus (case-insensitive
recipient match), DeleteSendLogBefore + status constants.
- cmd/panel: open the store once and share it across http/milter/tailer;
replace the journal/logtail stubs with the real roles.
- build/postfix-config.sh: bounded milter timeouts (15/15/30s) so a hung
milter also fails open in seconds, not the 300s default.
Fix found in-container: SASL login (app_login) was empty because go-milter
keys macros exactly as Postfix sends them, and multi-character macro names
arrive brace-wrapped ({auth_authen}); the SASL-less Phase 0 spike could not
observe this. Added a brace-tolerant macro lookup.
Verified on selfpost.example.com: gofmt/vet/unit tests green; container e2e
records rows with correct fields and advances status via the tailer; fail-open
confirmed for both an unreachable and a hung milter; retention prunes at start.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>