chore/docs: move to GitHub as the single home; drop archived-spec references

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>
This commit is contained in:
2026-08-06 22:14:13 +03:00
parent ed0a786739
commit d49351c022
87 changed files with 896 additions and 623 deletions
+19 -12
View File
@@ -1,9 +1,10 @@
// Package logtail follows Postfix's mail.log and reconciles the send-log
// delivery statuses the journal-milter could not know at receive time (spec
// 7.3). A milter row starts life as "queued"; Postfix only decides sent /
// deferred / bounced later, per recipient, and reports it in mail.log. This
// package parses those lines by queue-id + recipient and advances the matching
// rows, and prunes rows past the retention window.
// delivery statuses the journal-milter could not know at receive time
// (architecture.md § Persistence). A milter row starts life as "queued";
// Postfix only decides sent / deferred / bounced later, per recipient, and
// reports it in mail.log. This package parses those lines by queue-id +
// recipient and advances the matching rows, and prunes rows past the retention
// window.
package logtail
import (
@@ -17,7 +18,7 @@ import (
"strings"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
// StatusStore is the slice of the store the log-tailer needs: advancing
@@ -39,7 +40,7 @@ const (
// startup). The window itself is configurable; the cadence need not be.
retentionInterval = 6 * time.Hour
// defaultRetentionDays applies when the configured value is unset/invalid
// (spec 7.3).
// (README § Environment variables: SEND_LOG_RETENTION_DAYS).
defaultRetentionDays = 90
)
@@ -50,7 +51,13 @@ const (
//
// The "<queue-id>: to=<addr>, …, status=<word>" shape is specific to the
// delivery agents; qmgr/smtpd/cleanup lines do not match.
var deliveryRe = regexp.MustCompile(`\b([0-9A-Za-z]+): to=<([^>]*)>,.*\bstatus=(\w+)`)
//
// The run before status= is lazy on purpose. Postfix appends the remote
// server's reply verbatim, so a greedy match would take the *last* status= on
// the line — and that one can come from the reply text, which the far end
// controls. A bounce whose reply quoted "status=sent" would then be filed as a
// success. The real field is always the first one after to=<…>.
var deliveryRe = regexp.MustCompile(`\b([0-9A-Za-z]+): to=<([^>]*)>,.*?\bstatus=(\w+)`)
// parseDelivery extracts (queue-id, recipient, status) from a mail.log line.
// ok is false for lines that are not recognised delivery results.
@@ -124,10 +131,10 @@ func retentionLoop(ctx context.Context, st StatusStore, retentionDays int) {
}
// TailLines returns up to n of the most recent lines from path, for the
// panel's mail.log monitoring view (spec 7.2.13). It is a one-shot,
// point-in-time read on request — unrelated to the background follow loop
// above — that reads backwards in chunks so it stays cheap against a
// multi-megabyte log rather than reading the whole file every poll.
// panel's mail.log monitoring view (architecture.md § Panel HTTP surface). It
// is a one-shot, point-in-time read on request — unrelated to the background
// follow loop above — that reads backwards in chunks so it stays cheap against
// a multi-megabyte log rather than reading the whole file every poll.
func TailLines(path string, n int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
+54 -1
View File
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
func TestParseDelivery(t *testing.T) {
@@ -61,6 +61,59 @@ func TestParseDelivery(t *testing.T) {
line: "host postfix/smtpd[10]: 41E862C00D9E: client=unknown[203.0.113.7]",
wantOK: false,
},
{
// The remote server's reply is quoted verbatim at the end of the
// line and is entirely attacker-influenced text. A "status=" that
// appears in there must not win over the real field, or a bounce
// would be filed as a success.
name: "status= quoted in the remote reply does not win",
line: "host postfix/smtp[26]: 9F1A2C00D9E: to=<a@example.net>, relay=mx.example.net[203.0.113.9]:25, dsn=5.1.1, status=bounced (host mx.example.net said: 550 5.1.1 unknown status=sent (in reply to RCPT TO command))",
wantOK: true,
queueID: "9F1A2C00D9E",
recipient: "a@example.net",
status: store.StatusBounced,
},
{
// Postfix logs the null sender's own delivery (double bounce) with
// an empty recipient. It parses, and the empty recipient simply
// matches no send-log row — the panel only ever records mail it
// accepted from an authenticated client.
name: "null recipient parses with an empty address",
line: "host postfix/smtp[26]: A1B2C3: to=<>, relay=none, delay=0.1, dsn=2.0.0, status=sent (250 OK)",
wantOK: true,
queueID: "A1B2C3",
recipient: "",
status: store.StatusSent,
},
{
// An alias/virtual expansion carries orig_to= as well; the address
// the message was actually delivered to is the one in to=.
name: "orig_to is ignored in favour of to",
line: "host postfix/lmtp[26]: 4Xk9tS1abcz: to=<real@example.net>, orig_to=<alias@example.net>, relay=x, dsn=2.0.0, status=sent (ok)",
wantOK: true,
queueID: "4Xk9tS1abcz",
recipient: "real@example.net",
status: store.StatusSent,
},
{
// Postfix's own delivery agents write these two, but neither is a
// final result we model: "deliverable" comes from address
// verification probes, and anything unrecognised is dropped rather
// than guessed at, leaving the row in its previous state.
name: "unknown status word is not a delivery result",
line: "host postfix/smtp[26]: BEEF01: to=<a@example.net>, relay=x, status=deliverable (ok)",
wantOK: false,
},
{
name: "status matching is case-sensitive, as Postfix writes it",
line: "host postfix/smtp[26]: BEEF02: to=<a@example.net>, relay=x, dsn=4.0.0, status=Deferred (connect timed out)",
wantOK: false,
},
{
name: "cleanup message-id line ignored",
line: "host postfix/cleanup[12]: BEEF03: message-id=<x@example.com>",
wantOK: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"os"
"time"
"codeberg.org/mix/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/store"
)
const (