Commit Graph
19 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 af130e268a findings of the reviews of audit stage 2b
Technical review (Opus) and security review (Fable) of 5be109b..5154bba;
reports in the session's scratchpad. Nothing above low from security; one
bug and three risks from the technical review. Migration 0009 is not
deployed, so it was extended.

- Bug: two refusals of one second swapped their NOQUEUE lines (each line
  went to the newest lineless row). AttachRefusalLine now matches the
  client address too (send_log.client_ip, written by the journal milter -
  Postfix's line names the client and the sender, never the login: the
  sasl_username part of the pattern never matched and could be fed by HELO,
  security F1/F2), takes the nearest row in time and the oldest of equals,
  and a line already on a row goes on no other (security F6).
- Waiting lines and recoveries lived only in memory while the saved offset
  moved past them: a stop within 30 s lost them. The tracker now never saves
  an offset past the oldest waiting item in its file (tracker.hold,
  refusalLines.oldest) - for a recovery, the client= line that names its
  login - so they are read again; the "once" guards make that harmless.
- A recovered row whose login the tailer did not see was nobody's: its
  domain_id falls back to the envelope domain's.
- The fingerprint's file identity is the inode alone: the device number can
  change across a host reboot and make the same log look rotated.
- A refused row carries its queue id; UpdateStatus's "newest row" now skips
  refused rows, so a later message with that id does not flip it to sent.
- The alive mark: its age is read first and kept from then on, so asking
  supervisord for 30 s cannot make it stale; the entrypoint removes it with
  the sockets - a container restart is not the check's downtime, and a mark
  from before would have counted the whole outage as "≈".
- Security F3: the sender check prints no ";" of the client's in the From:
  text of its reply, and the tailer reads the envelope after the first ";"
  - Postfix's own text.
- A refused row with a queue id and no stored lines says so (it was told the
  mail.log text of 2.2.0); the fact's em dash is the house hyphen.
- Docs: architecture (client address, no login), guide § Health (warns on
  either count), help topics log and sender-check (design 1ca87f2).
- e2e: refusals_show_postfixs_line wants the refusal's own sender in the
  line; refusal_recovered_from_the_log waits past the tailer's 30 s and
  checks that a refusal the panel wrote got no recovered twin.

Not changed, written down: review 9 (mail.log.1 read to its end at that
moment; postlogd may still write to it until the reload), 10 (a log under
512 bytes has no fingerprint, so places fall back to time briefly after a
rotation), 6 (a 451 at MAIL FROM of another milter reads the same - the
client and sender must match too now), security F5/F12 (info). Review 12:
the refused and closed-by-the-panel variants of out-message were shown to
the owner as pictures of the real templates, not drawn in out-message.html:
the guard ties every drawn screen to a page of the engine, and a variant
has the page's outline.

Evidence on the test server for this tree: go vet clean, go test ./... 23
packages ok, make e2e exit 0 (refusal_recovered_from_the_log 70 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:38:22 +03:00
mixandClaude Opus 5.5 e166a2ebd6 refused messages: the page shows what Postfix wrote (stage 2b, 0009)
The Delivery log box of every refused row said "This message never reached
the queue, so Postfix wrote no delivery lines for it" - untrue: Postfix
wrote a line for each. The owner, 2026-10-13: the box is required on every
message page. Pictured for him first (dev/shots, the refused variant of
out-message: wrong From with its queue id lines, rate-limited with the
NOQUEUE line); the variant has no mockup screen of its own - the guard ties
screens to pages, and its outline is out-message's.

- Wrong From: cleanup gives the message a queue id before the headers are
  judged; the sender check reads {i} at end-of-headers and writes it on the
  refused row. Its lines - stored under the id like any other's - are on the
  page, but only those from 10 minutes before to 1 minute after the refusal
  (store.SendLogLines for a rejected row): Postfix frees the id at once, and
  a later message with it lends nothing. No mail.log fallback for a refused
  row, for the same reason.
- Rate-limited and wrong client: refused at MAIL FROM, no queue id. Migration
  0009 adds send_log.refusal_line; logtail.refusalLines matches smtpd's
  "NOQUEUE: milter-reject: MAIL from ...; from=<...>" (not port 25's, not
  CONNECT/EHLO) and store.AttachRefusalLine puts it on the newest refused row
  of that sender (and login, when the line names it) within two minutes; a
  line whose row is not written yet waits up to 30 s (retried at each pass).
- The page: the queue id fact says "given, the message was not queued" or
  "none - refused at MAIL FROM, before one is given"; the box's caption is
  "The line from mail.log for this refusal" for the latter; a row from
  before says the line was not kept. LogLineLevel colours a refusal by its
  reply: 5xx red, 4xx amber (IPv6 clients included). The id in the fact is
  not muted as in the picture: the kit has no muted inline.

Tests: store (attach by sender, login, time; not a queued row, not one an
hour off; a refused row's lines are its own minutes); logtail (attach, wait
and retry, port 25 and other lines ignored); milter (the refused row carries
{i}); view (facts, caption, line levels); handlers (the line on the page,
the sentence without it). e2e refusals_show_postfixs_line: a client-address
refusal made there, and the pages of a wrong From, a rate-limited and a
wrong client refusal each show Postfix's line.

schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 25/62.
Evidence on the test server for this tree: make e2e exit 0 (the new case
in 5.4 s); go test ./... ok but for two schema tests, fixed after the run
(the schema at 0009) - store tests ok locally; the full run follows before
the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:56:29 +03:00
mixandClaude Opus 5.5 c452606175 logtail: the deferred counter remembers a place in the log, not a second (stage 2b)
test / test (push) Canceled after 0s
The counter passed over, while catching up after a start, every warning no
later than the last one counted - by time; mail.log stamps whole seconds,
so a second warning of that second, later in the file, was lost.

- The tailer gives each line its place (Place: the file's fingerprint and
  the offset just past the line): follow tracks the bytes the reader hands
  over, readRotated likewise in mail.log.1. Run hands tr.line to the
  counter.
- store.AddSenderCheckDeferred(at, n, place) counts, moves the
  last-deferral mark (never back - an upsert WHERE) and saves the place
  (settings sender_check_counted_place) in one transaction.
- Catching up, a warning in the file of the last one counted is passed over
  by place (end <= counted end); in another file - a rotated log read again
  - by time, as before. The fingerprint carries the inode, so the place
  stays true across a rotation.

Tests: two warnings of one second, an unclean stop between them, all lines
read again - each counted once; the store saves the place with the count
and keeps the latest deferral time. Existing counter tests pass a zero
place (time rule).

CHANGELOG [Unreleased], audit plan line ticked, roadmap 24/62.
Evidence: go vet ./... clean; go test ./internal/store, ./internal/logtail ok
(but for the Windows-only test); the server run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:28:13 +03:00
mixandClaude Opus 5.5 4678015bbd logtail: a log rotated while the panel was down is read to its end (stage 2b)
At start, a saved offset whose fingerprint was not the current file's meant
"start on the new file": what Postfix wrote between the panel's stop and the
rotation was never read - delivery statuses, stored lines, refusals, inbound
journal rows, the deferred counter.

- tracker.resume marks the case (behind, prev); follow calls readRotated
  before the new file: it opens mail.log.1 (delaycompress leaves it plain),
  checks by fingerprint that it is the file the place belongs to, reads it
  from the saved offset to its end through handle (catching up: the
  once-protections apply) and flushes the stored lines.
- Not found, not that file, or the offset past its end (rotated twice):
  logged, with the saved place's time - from when nothing was read.
- The fingerprint gains the file's identity (device.inode, fileid_unix.go;
  empty elsewhere): two logs that begin alike are two files - the
  review-2026-08-followups row on collisions. A fingerprint saved before
  (hash alone) still matches by its hash (sameLog), so the first start after
  the upgrade resumes where it was.
- store.LogtailState reads back updated_at.

Tests: stop, append, rotate, append, start - both halves read, once each,
the earlier line not again; a mail.log.1 that is another file is not read
from the place; sameLog with an old and a new fingerprint.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 23/62.
Evidence: GOOS=linux go vet ./internal/logtail clean; go test ./internal/logtail
ok on Windows but for TestFollowTailsAndRotates; the server run follows
before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:25:29 +03:00
mixandClaude Opus 5.5 60c7b85f84 findings of the reviews of audit stage 1
test / test (push) Canceled after 0s
Technical review (Opus) and security review (Fable) of 9efc9c7..a59e3d4;
reports in the session's scratchpad. Fixed:

- B1 (verified bug): store.UpdateStatus read before writing in its
  transaction; a commit by another connection in between (the dmarc-ingest
  process opens the same file) failed it at once with "database is locked"
  and the delivery line was lost. The first statement is now a write.
- R3: a delivery line updated every row of its queue id and recipient; it
  now updates only the newest (Postfix reuses short ids). The sweep closes
  by row id (QueuedDelivery.ID, CloseUnreported(id)).
- R4: a contradicted sweep keeps a mark, reason "reconcile-contradicted",
  instead of losing which row it was.
- R1: contradicted cannot see a lost line (never read late). New counter
  reconcile_missed: at close, the sweep looks for the row's delivery line
  in mail.log and mail.log.1 (QueueLines). 0007 edited - it has run on no
  database but throwaway stands.
- R2: queued_over_lifetime cannot measure "what removing the sweep would
  leave" while the sweep runs; its text now says rows stuck despite the
  sweep, and the rule uses closed - missed - contradicted.
- cert-reload (R5, R6, R7, security medium/low): both files copied to .new
  and the copies checked as a pair before both are renamed; acts on the copy
  only when postconf says Postfix reads it; a failed reload is retried next
  pass; umask 077; says so when the mounted files cannot be read. The e2e
  case asserts Postfix reads the copy, the copy equals the mount, the key is
  600 root.
- Security low: a recipient with a quoted local part - the journal cut
  "a b"@x at the space, deliveryRe cut "a>b"@x at the ">" - matched no
  delivery line and was closed by the sweep. cleanAddress ends a bracketed
  address at the first ">" outside quotes; deliveryRe takes the recipient
  up to the ">, " before relay= (test fixtures given the relay= every real
  delivery line has).
- Security low: postqueue -p's error carries at most 512 bytes of output.
- Nits: addDeliveryLogDay refuses a column not on its list; the truncation
  test checks its message.
- To stage 2b (page wording, mockup-first): the history step of a bounced
  row says the receiving server refused it, false for a row the sweep
  closed. To the owner when stage 2b is reached: since M13 the "deferred"
  figure counts whole messages too, which "Connections turned away" would
  not name.

Found while verifying: the e2e's main positive path failed once with "553
not owned by user app1" - an smtpd idle from before the panel's reload took
the connection in the reload's second. Measured on the stand (one-off
probe, not committed): sending 0, 10, 40, 110 s after the save (two seconds
at the soonest) always met an smtpd started after the reload. The e2e now
waits for the sender map after each application it creates
(waitForSenderMap: AUTH, MAIL, RCPT, RSET - no message, no row), and the
level-1 case uses it instead of its own loop.

Docs: architecture § Queue reconcile, schema-migrations (rows 7 and 8,
chain head 8), CHANGELOG, audit plan (stage 1 note, the measured window,
a new stage 2b line: 62 lines), roadmap 15/62.

Evidence on the test server for this tree: go vet clean, go test ./... 23
packages ok, make e2e exit 0 (renewed certificate, hung panel, level 1 at
49/60).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:21:52 +03:00
mixandClaude Opus 5.5 63520f1678 logtail: deliveryRe anchored at the head of the line (audit M12)
The pattern that writes a row's status matched anywhere in a line: a
port-25 reject line ending helo=<ID: to=<victim@...>, status=sent> parsed
as (ID, victim, sent). parseDelivery now takes the stamp off with
SplitTimestamp and matches `^host program[pid]: <id>: to=<...>, ...
status=`, as queueLineRe and the inbound patterns do.

Tests: the probed HELO line, the same text in a quoted sender on a qmgr
line, the text after an smtpd line's queue id - none parse; a syslog-stamped
line as Postfix 3.7 writes it parses. The existing cases unchanged.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 11/61.
Evidence: go test ./internal/logtail ok but for TestFollowTailsAndRotates
(Windows); the server run of the stage follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:22:03 +03:00
mixandClaude Opus 5.5 ffefa9d6df delivery log: measure the sweep before deciding its fate (audit H3, 0007)
Migration 0007 (DDL only): delivery_log_days, a row per UTC day, kept a
year, shown nowhere - read off a copy of the database after a month by the
decision rule written in the audit plan.

- reconcile_closed: the sweep adds the rows it actually closed.
- reconcile_contradicted: store.UpdateStatus, in one transaction, counts the
  rows with reason "reconcile" a delivery line now speaks of, writes the
  log's status and clears the reason - so a re-read line counts once.
- tailer_blind_seconds: follow() tracks stretches in which the file is gone
  or reopening fails (logtail.blindness); stored when a stretch ends and
  every minute while it lasts; under 3 s not counted (the rotation's tick
  between rename and create loses nothing).
- queued_over_lifetime: every sweep overwrites the day's level with the rows
  queued past LongestLifetime + MaximalBackoff (a message Postfix still
  works on stays queued until its first retry after the lifetime).
- retentionLoop prunes days older than a year.

Tests: store (sums per day, the level overwritten, under a second nothing,
prune; a contradicted sweep counted once and its reason cleared; count of
queued rows); logtail (the sweep counts what it closed; countStuck records
the level; blindness - a one-tick gap not counted, 75 s stored as 60 + 15
from where the last part ended). Schema tests at user_version 7.

schema-migrations.md: chain head 7 (it still said 5 since 2.4.0), row 7.
CHANGELOG [Unreleased], audit plan line ticked, roadmap 10/61.
Evidence: go vet ./... clean; go test ./internal/store ok, ./internal/logtail
ok but for TestFollowTailsAndRotates (Windows); the server run of the stage
follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:20:44 +03:00
mixandClaude Opus 5.5 9adb44845c logtail: the sweep closes only queued rows, with reason "reconcile" (audit H3)
reconcile() closed a row through UpdateStatus (WHERE queue_id AND to_addr),
which could overwrite a "sent" the tailer wrote between the listing and the
close, and rewrite an old row of a reused queue id. store.CloseUnreported
sets status bounced and reason "reconcile" (store.ReasonReconcile; the
0006 column, no schema change) WHERE ... AND status = 'queued'; the sweep
counts rows actually closed.

Tests: store - a queued row is closed with the reason, a sent row is left
alone (0 rows); logtail - the sweep's close goes through CloseUnreported.

CHANGELOG [Unreleased], audit plan line ticked, roadmap 9/61.
Evidence: go vet ./... clean; go test ./internal/store ok, ./internal/logtail
ok but for TestFollowTailsAndRotates (Windows rename of an open file, fails
here always); the server run of the stage follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:16:26 +03:00
mixandClaude Opus 5.5 c817f0833c from-header-binding: what the reviews had left as found, and address extensions
test / test (push) Canceled after 0s
The owner on the three things the reviews' commit left as found: can they be
fixed? They can.

- Downtime that crossed a UTC midnight was all booked on the day of the
  restart: it is cut at the midnights between (byDay).
- A message with the null envelope sender - a bounce an application writes
  itself - left a refusal row without a domain, which no domain
  administrator would see: the row takes the domain the login is bound to.
- A log read again after an unclean stop counted its deferrals again: while
  the tailer catches up, a warning no later than the last one counted is
  passed over. The mark is kept to the fraction of a second the log gives.

And on the check being stricter than Postfix for an address with an
extension (invoices+x@ passed in the envelope, was refused in the header):
it makes Postfix's second try now, the address without its extension.
recipient_delimiter was the distribution's default; it is set in
postfix-config.sh, since the check depends on it. e2e sends one through.

Test server, this tree: go vet ok, go test 23 packages ok, make e2e ok (339 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:29:17 +03:00
mixandClaude Opus 5.5 c171fb5bc7 from-header-binding: the sender check, what it costs, its pages; guide, architecture, security
test / test (push) Canceled after 0s
A message's From: line is held against the application that sends it, under
the rule Postfix holds the envelope sender to. OpenDKIM chooses the key by
that line, so until now an application could have mail signed as another
domain of the server or as the server itself.

The check (internal/milter/sender.go)
- A role of the panel on a socket of its own (SENDER_CHECK_SOCKET), first in
  smtpd_milters and strict: default_action=tempfail. The journal milter keeps
  its socket and stays fail-open. postfix-wrapper waits for the new socket.
- It reads no database: what an application may send as comes from Postfix's
  own sender_login_maps, what the server signs for from OpenDKIM's
  SigningTable. Both are read again when they change; one that cannot be
  read defers mail.
- One From: field, one address, allowed by the envelope's lookup; then no
  other name of the SigningTable may appear anywhere in the raw line.
  Refused: 550 5.7.1 naming the line, reduced to printable ASCII.
- A refusal is a send-log row with reason "from" and the header address
  (migration 0006), written by another goroutine and not waited for. The
  journal's refusals record their reason too (limit, client), and the
  notifier takes only a limit's.

What it costs (docs/plans/from-header-binding.md, Statistics)
- The role marks itself alive every 2 s; at start the gap since the mark is
  downtime, counted from Postfix's start if later and not at all when
  supervisord says Postfix is not running.
- The log tailer counts the connections Postfix turned away: a warning that
  names the socket, then milter-reject "Service unavailable" from the same
  smtpd. Seen on the stand: the client is refused from CONNECT on and leaves
  at AUTH (454), so no MAIL line is ever written.
- sender_check_days, a row per UTC day, kept a year.

Pages
- Health: the card "Sender check", the socket's row, and the cards layout -
  boxes numbered by importance in two stacks read in rows; the left stack
  polls, the three readings of the right one are swapped in by id.
- Outbound log: a refused row says why in its tag (wrong From, wrong client,
  rate-limited); the message's page shows the From: line and what the
  application may send as.
- Help: topic "Sender check", word for word the mockup's.

Found by the stand, fixed here: the names for the second look came from the
sender bindings, and a domain with a key and no application yet was not
among them.

e2e: negative_journal_milter_fail_open is gone - it proved that mail passes
with the panel down, which is no longer so. In its place
sender_check_holds_the_from_line (another domain's From:, the hostname's, a
stranger's, an address off the list, another sender in the display name -
refused; own addresses - delivered) and panel_down_defers_mail (4xx with the
panel stopped, the container alive, mail again after, downtime and one
deferred message on Health, the card warning).

Test server, this tree before the last cosmetic edits (a fixture row, a
fact's size, docs): go vet ok, go test 23 packages ok, make e2e ok (337 s).
Locally after them: internal/web/view ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:35:43 +03:00
mixandClaude Opus 5.5 a990e7cc23 delivery-log-storage: findings of the technical and security reviews
test / test (push) Canceled after 0s
Neither review (read-only agents, Opus and Fable, over ae06d8b..815236a) has
a Critical or High finding. What they found and what was done:

- A stranger on port 25 could grow the table: every queue-id line of inbound
  mail was stored and pruned an hour or more later, his own message-id among
  them. The tailer now leaves out the lines of a queue id that
  postfix/inbound/smtpd opened, until qmgr removes the message or another
  listener opens one under the id; 4096 such ids are remembered.
- A commit, so a disk sync, per stored line on the connection the journal
  milter shares, and a backlog written line by line. Lines are now written in
  one transaction per pass over the log, 500 at most, before the read offset
  is saved (InsertSendLogLines; follow() calls the keeper's flush).
- The prune asked the send log once per line and ran hourly. It asks once per
  queue id and runs at start and with the six-hourly sweep; with port-25 mail
  left out there is little without a row to wait for.
- The 4096-byte cut could split a character; it keeps characters whole.
- security.md did not say that a backup now carries these lines, and the plan
  said the time window excludes a reused queue id where it only limits the
  chance to the row's queue lifetime. Both are corrected; the collision is
  recorded as an accepted risk with its trigger.
- Tests that did not prove their comments: the prune is now tested with the
  sweep, a rejected row next to the prune, batching and the change from
  catching up to following, port-25 mail left out and its id reused.

Left as found and written down in the plan: lines the tailer never reads
(a file rotated away while the panel was down), and a message in the queue
across the upgrade, which shows only its later lines.

On the test server, for this tree: go vet clean, go test ./... 22 packages
ok, make e2e ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:41:04 +03:00
mixandClaude Opus 5.5 81df131217 delivery-log-storage: the tailer keeps the lines of queued messages
Every mail.log line headed by a queue id is written to send_log_lines as the
tailer reads it, dated by the line's own stamp. The id is taken from the head
of the line only - the tail is the far end's text - and has to look like one:
"NOQUEUE:" and "warning:" stand in the same place.

The tailer does not check for a send-log row first: a message's first lines
come before the journal milter writes it. Lines that never get a row are
removed by the prune, which deletes lines older than an hour whose queue id
no row carries. It runs at start, after each send-log sweep and hourly.

While the tailer catches up at start a line is written only if it is not
there already, as the inbound journal does: its place is saved every few
seconds, so an unclean stop means reading some lines twice.

Checked on Windows: go vet clean; go test ./internal/logtail passes except
TestFollowTailsAndRotates, which fails there on renaming an open file and
always did. New tests: which line heads are queue ids, a line dated by its
stamp, no second copy after a rewound offset, the prune cadence and cutoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:11:28 +03:00
mixandClaude Opus 5.5 89774ab847 inbound-antispam-panel: three things the reviews left as found
test / test (push) Canceled after 0s
The owner asked for them to be put right (2026-10-12).

  - A refusal is not logged twice after an unclean stop. The tailer
    saves its place every five seconds, so on start it reads some
    lines again; for the lines it reads on start a row is written only
    if the same row is not there already (store.InsertInboundSpamOnce:
    same moment, client, sender, recipient, decision, engine, detail).
    Lines read as they are written are always new, so two identical
    refusals in one second stay two rows.
  - The Inbound log offers Deny / Allow sender only for a sender the
    lists would take (inbound.Listable): an envelope sender may be
    spelt in ways the form refuses, and the link led to a refusal.
  - A network of one address is stored as the address: 192.0.2.7/32
    and 192.0.2.7 were two entries of one list.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:17:23 +03:00
mixandClaude Opus 5.5 27fd355e53 inbound-antispam-panel: refused inbound mail from mail.log; retention
Step 4/12 of docs/plans/inbound-antispam-panel.md.

The log tailer writes an inbound_spam_log row for mail port 25 refused
(internal/logtail/inbound.go): a "reject:" line of the port-25 smtpd is
Postfix's own policy; a milter-reject / milter-hold / milter-discard
line of cleanup is the filter's verdict. Only a recipient at an inbound
domain is a row, so "Relay access denied" for a stranger's domain is
not. The row is dated by its log line; the reply text is cut to 255
bytes of printable ASCII; a reply starting with 4 is a tempfail.

To tell port 25 from 465 / 587 the listener now logs as
postfix/inbound (syslog_name). cleanup logs under one name for every
listener, so its verdict counts only for a queue id the port-25 smtpd
was seen to open (4096 remembered). Lines are matched from the head,
after timestamp and host: their tail is the sender's own text.

Retention: PruneInboundSpam every six hours - older than the
inbound_spam_log_retention_days setting (seeded from
INBOUND_SPAM_LOG_RETENTION_DAYS, 90, range 7-365), then down to 10 000
rows. The setting has no field in the panel yet.

Known limits, in architecture.md: a log line names one recipient, so a
message refused at end-of-message is one row; a line re-read after an
unclean stop would be a second row.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - the unknown recipient at
the inbound domain refused on port 25 has its row (reject by postfix),
the stranger's domain has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:42:25 +03:00
mix 60b743e6c2 release: 1.6.0: 30-day send stats and optional auto level-2 rate limits
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
  - Level-2 rate limit **Manual** / **Auto** mode on domain and application
  - Domain export JSON now includes rate limits (`mode`, `auto_multiplier`,
Changed:
  - Auto mode uses the level-1 window (`RATE_LIMIT_WINDOW_SECONDS`); manual

Full detail: CHANGELOG.md (1.6.0).
2026-09-22 15:24:52 +03:00
mix dca7d99c1b release: 1.5.0: Send-log retention configurable in panel Settings
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)** —
Changed:
  - Deliveries list and delivery detail pages show the configured retention

Full detail: CHANGELOG.md (1.5.0).
2026-09-22 15:24:52 +03:00
mix 75207f0d1e release: 1.0.0: Public 1.0 image; delivery log; docs and SECURITY.md (incl. 1.0.1)
[1.0.0]
Added:
  - A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
  - A **History** block on the same page: the journal's two timestamps stated as
Fixed:
  - Postfix config: copy TLS cert/key from the (often `:ro`, host-owned) mount
  - Postfix config: allow `maillog_file` under `/data` via
  - E2e: read `/data/setup-token` via `docker compose exec` (file is `0600`
  - Entrypoint: check `SELFPOST_HOSTNAME` before `/data` setup (so a bad identity
  - E2e gate: wait for host-published `/healthz` before panel setup, and stop
  … (3 more in CHANGELOG)
Changed:
  - Document what `.spbk` and `.spde` stand for (SelfPost backup / SelfPost domain
  - Docs aligned with the code: setup URL is `/setup/<token>` (README and
  - Full backups no longer carry `/data/log`. It is Postfix's raw log plus its
  - Monitoring screens (status, mail queue, system log, deliveries) use adaptive
  - Documentation package consolidated into `docs/development.md`: Documentation
  … (4 more in CHANGELOG)
[1.0.1]
A documentation and packaging release: no change to the mail path, the database, or the on-disk layout. Upgrading is a tag bump.
Added:
  - `SECURITY.md` — how to report a vulnerability privately (GitHub private
  - [docs/plans/](docs/plans/) — one document per agreed extension: the optional
Changed:
  - The panel's **Account** entry is now called **Settings** — nav link, page
  - The signed-in name in the panel's nav is now labelled `User:`, so it reads as
  - [docs/product.md](docs/product.md) reframes the future line: the inbound
  - [docs/security.md](docs/security.md) is now in English, matching the rest of
  - The README documentation table now points at `SECURITY.md` for reporting a
  … (2 more in CHANGELOG)
Fixed:
  - The panel's static assets are served with a content ETag and

Squashed CHANGELOG sections: [1.0.0], [1.0.1].

Full detail: CHANGELOG.md (1.0.0, 1.0.1).
2026-09-22 15:24:51 +03:00
mix 5e5d157496 release: 0.5.0: Pre-1.0 security review, domain-admin role, doc consolidation
A bounce could be recorded as a successful delivery. The log-tailer's
Fixed:
  - A bounce could be recorded as a successful delivery. The log-tailer's
  - Log-tailer resumes where it stopped instead of jumping to end-of-file on
  - Level-2 rate limit no longer overshoots under concurrency: messages that
Changed:
  - The project has a single public home: `github.com/mixeme/selfpost`. Codeberg
  - Code comments no longer cite the archived specification. References like
  - `docs/code-review.md` is gone. Its plan is finished — phases 0 (bar the
  - `docs/architecture.md` gained a *Code layers* section: a diagram of
  - Phase 1 doc/code hygiene (`docs/code-review.md`): removed ~30 stale
  … (1 more in CHANGELOG)
Security:
  - Pre-release security review (plan § D, model Fable, 2026-08-06): full pass
  - `saslpasswd2` argv: the application login is now passed after a `--`
Added:
  - Optional password encryption for the two secret-bearing downloads (plan
  - Domain import accepts an encrypted export: the envelope is detected by its
  - `selfpost-backup` writes encrypted archives and reads them back:
  - `TestParseDelivery` covers the exotic mail.log shapes the review asked for
  - docs: README *Encrypting a backup or export*; `docs/security.md` §
  … (14 more in CHANGELOG)
Removed:
  - docs: `security.md` — accepted risk «restore old backup revives session rows»
Changed:
  - docs: `implementation-plan.md` trimmed to the sole open v1.x gate — pre-release
  - docs: `architecture.md` — sessions (SQLite, idle renew, password change),
  - docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow.
  - docs: `security.md` — accepted risk for send-log rows stuck at `queued` after
  - docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail.
  … (10 more in CHANGELOG)

Full detail: CHANGELOG.md (0.5.0).
2026-09-22 15:24:51 +03:00
mix 8ce050ef7f release: 0.1.0: Phases 0-11: panel, Postfix/DKIM, milter, backup, Docker image
Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`).
Added:
  - Panel (Go, single static binary) with SQLite persistence, one-time
  - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and
  - Application (sender identity) management: SASL credentials via `sasldb2`,
  - Full Postfix relay config generated from env at container start: SMTPS 465,
  - Journal milter (pure Go, `go-milter`) recording every send to `send_log`;
  … (6 more in CHANGELOG)

Full detail: CHANGELOG.md (0.1.0).
2026-09-22 15:24:51 +03:00