Commit Graph
228 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 c1da784193 inbound-antispam-panel: the backup holds the lists; Inbound log accepted
Step 7/12 of docs/plans/inbound-antispam-panel.md. A full backup walks
all of /data, so data/antispam/ was in it from the day the directory
existed; TestCreateIncludesStateExcludesTransient now holds that. On
the first start after a restore the maps are written again from the
database (mailStack.Resync, already there). The guide says what is not
in a backup: the antispam/ directory with the compose fragment and the
rspamd configuration, which is installation and not state.

The plan records the owner's acceptance of Inbound > Log (2026-10-12,
"Этот экран утверждаем") and what he changed on the way to it.

go test ./internal/backup/ ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:36:59 +03:00
mixandClaude Opus 5.5 970c8ca8da design: the sheet on the components page is a table; no empty Subject
The sample sheet on the components page still held facts; it shows the
table of name and value the accepted sheet is. In in-log a refused
message's sheet has no Subject row - there is no subject to show whole.

check.py: 32 screens, 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:13:14 +03:00
mixandClaude Opus 5.5 19156814b8 design: in-log, Why is a sheet over the page; no Deny on a Postfix reject
Owner, 2026-10-11/12, on the Inbound log as built:
  - "Why сделать не столбцом, а листом поверх." Opened by the decision
    tag ("Тег."), drawn first, then: its content as a table ("В лист
    может быть таблицей?" - "Да. Но хорошо бы ещё её отцентрировать."),
    with a narrow column of names and only what the row does not show.
  - "Почему Deny у того, кто rejected?" - "Убирай": a message Postfix
    itself refused offers no Deny sender. Accepted mail offers Deny,
    mail the filter refused offers Allow.

A new component, sheet (theme.css, shown on the components page next to
status_tag): the browser's own popover, so no script - the tag is a
button with popovertarget; Esc, a click outside and the cross close it.
It rises from the bottom edge across the window; its content is a table
of name and value in a 44rem column in the middle. A tag that opens a
sheet carries a dotted underline to tell it from a plain tag.

in-log: the Why column is gone and rows are one height. A sheet holds
who decided, the verdict and the symbols (or the reply), and the
subject whole.

Guard: check.py WIDTH_OK gains ".sp-sheet" - the one component laid out
by the window and not by the page. The owner accepted the drawing with
that said.

check.py: 32 screens, 52 sp- classes, 0 violations; outlines.json
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:12:04 +03:00
mixandClaude Opus 5.5 d4efdb483d design: in-log, the sender column is headed From
Owner, 2026-10-11, of "From · client": "Client в названии столбца
зачем? Мне кажется, оно лишним." The client address stays under the
sender, in mono and muted; an address of that shape needs no label.

check.py: 32 screens, 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:59:18 +03:00
mixandClaude Opus 5.5 56c5dfee40 inbound-antispam-panel: Inbound > Log and Inbound > Filter lists
Step 6/12 of docs/plans/inbound-antispam-panel.md. Written by a Sonnet
subagent from the mockups, reviewed and corrected by me.

GET /inbound/log - the journal a page at a time (50), filtered by
domain, decision and day; QueryInboundSpam / CountInboundSpam with the
viewer's inbound domains as the scope. The global role reads all of it
and gets "Deny sender" / "Allow sender" links into the lists; a user
with the domain role reads the rows of the inbound domains assigned to
them, without the links. The plan said global-only for the journal;
the accepted mockup and 2.0's delegation of Inbound say otherwise -
recorded in the plan as built.

GET, POST /inbound/filter-lists and POST .../{id}/delete - global role,
404 otherwise. Add form (prefilled from the query), the two lists with
Remove as the kit's row action, and "Where the lists go" with the
filter's state and when the maps were last written
(Lists.LastWritten). A refused or duplicate entry is 422 with the
message and what was typed.

Server > Settings: "Keep inbound log rows, days" with the relay on.
Help: Inbound log, Filter lists. Navigation: Log for everyone who sees
Inbound, Filter lists for the global role (Shell.ShowFilterLists).
Status tags accept / reject / tempfail join the kit's mapping.

Tests: page fixtures for both screens, held to outlines.json; view and
handler tests (both roles, filters, paging, relay off, add / duplicate
/ invalid / delete, map file content, settings field, help). e2e reads
the journal from the page instead of the database and adds and removes
a deny entry through the panel, reading deny_domain.map after each.

Seen: both pages and Settings rendered from their fixtures and compared
with the mockups side by side (headless Edge, 1360 px) - same layout
and wording.

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-11 23:50:34 +03:00
mixandClaude Opus 5.5 82d4a209be design: in-log and in-filter-lists leave the deferred screens
Guards and mockups only, ahead of the pages (inbound-antispam-panel
6/12). The owner agreed the feature ("Следующая по дорожной карте -
inbound-antispam-panel" / "Да", 2026-10-11) and started this step
("Начинай"), so the two screens are no longer "drawn but not built":

  - guard_panel_test.go: in-log and in-filter-lists removed from
    deferredScreens. From here TestLegacyRatchetMatchesTheEngine wants
    the engine to have both pages - red until the next commit.
  - guard_routes_test.go: the four routes join routes.inbound, so they
    must exist with the relay on and answer 404 with it off.

Two mockup details brought to what the kit can draw, found when the
pages were built from it:
  - help: the two new topics are one paragraph with a break in it, as
    every topic is (panel.css has no gap between paragraphs of a topic);
  - in-filter-lists: the directory in "Files" is mono at the text size -
    the kit's inline code has no small variant.

check.py: 32 screens, 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:50:33 +03:00
mixandClaude Opus 5.5 737611a07e design: Inbound log and Filter lists as built; inbound retention; Help
Mockup-first step of inbound-antispam-panel 6/12. The two screens were
drawn for the redesign before the feature existed; this brings them to
what steps 3-5 built.

in-log: the lead says where rows come from and how long they are kept;
a help link in the box head; Why is the engine and the detail as the
journal holds them (rspamd: action, score / threshold, weighted
symbols; postfix and a refusing filter: the reply text). No row
invents what the journal cannot hold: the "selfpost - upstream timed
out" tempfail and the Review link to a quarantine page that does not
exist are gone, a refused message has no subject, an allow-listed one
shows SELFPOST_ALLOW_FROM(-100.00).

in-filter-lists: Remove is a POST - the kit's row action, not a link;
a domain entry is shown as stored, without "@"; "Where the lists go"
names the directory and its six files instead of allow.map / deny.map,
says rspamd rereads them by itself and that Deny wins.

settings: "Keep inbound log rows, days" in the Log retention box.

help: two topics under Inbound, Inbound log and Filter lists, and a
sentence in Settings.

outlines.json rewritten: Help's Inbound box has six topics, was four.
check.py: 32 screens, 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:18:57 +03:00
mixandClaude Opus 5.5 eea40bcd49 inbound-antispam-panel: filter lists, written as rspamd maps
Step 5/12 of docs/plans/inbound-antispam-panel.md.

Store: AddSpamListEntry, ListSpamListEntries, DeleteSpamListEntry.
internal/inbound/lists.go validates and normalises an entry - sender
address, sender domain, client address or network (canonical text, an
IPv4-mapped address as IPv4, /0 refused) - and writes the lists as six
map files under /data/antispam (ANTISPAM_DIR), each atomically, 0644 in
a 0755 directory: the filter is another user in another container. All
six are written on every change and on every start with the relay on,
empty ones included. An entry whose maps could not be written is taken
back out of the table. The writer refuses a stored value with
whitespace, a newline or # in it.

The plan's allow.map / deny.map became one file per list and entry
type: to rspamd an IP map and a sender map are different map types.

deploy/antispam/rspamd/multimap.conf holds the rules; the fragment
mounts it and ./data/antispam into the sidecar. Deny is a prefilter
reject, allow a symbol weighing -100. Tried in a throwaway rspamd 3.11
container on the test server:
  - with allow as a prefilter accept, an address on both lists was
    accepted and an IP on both was refused - the rule that ran first
    decided; with the weight, deny won in all five mixed cases;
  - IPv4, IPv6, address (any case), domain (not its subdomains) match;
  - a rewritten file is reread in about ten seconds, no restart;
  - files that appear after rspamd started are loaded the same way.

No page edits the lists yet (step 6).

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - with the relay on
/data/antispam is 755 and holds the six maps, 644, empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:07:10 +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
mixandClaude Opus 5.5 94e94490af roadmap: phone in "desktop site" mode gets the phone navigation
A candidate, recorded at the owner's word (2026-10-11, screenshot of
Server > Health on selfpost.mixfed.ru): the body is laid out in columns
while the navigation is the phone stack with every section open. The
item states what was seen and a first guess at the cause (980 px falls
between Bulma's 769 and 1024 px breakpoints); nothing is investigated
yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:42:08 +03:00
mixandClaude Opus 5.5 28ec784ae2 inbound-antispam-panel: inbound journal milter on port 25
Step 3/12 of docs/plans/inbound-antispam-panel.md.

The panel serves a second milter while the inbound relay is on
(internal/milter/inbound.go, socket INBOUND_JOURNAL_MILTER_SOCKET,
default /run/selfpost/inbound-journal.sock). build/postfix-config.sh
puts it last on port 25, after the optional antispam milter, with
default_action=accept; postfix-wrapper.sh waits for its socket. At
end-of-message it writes one inbound_spam_log row per recipient at an
inbound domain - other port-25 recipients (DMARC report addresses) get
none - and always accepts. With a filter attached it reads the verdict
from X-Rspamd-Action and X-Spamd-Result (action, score, up to six
weighted symbols, only what the patterns recognise, 255 bytes); without
one those headers are the sender's and are not read. The plan named
X-Rspamd-Score, which rspamd does not have.

The antispam compose fragment mounts rspamd/milter_headers.conf into
the sidecar so that it adds the two headers (checked in rspamd 3.11:
both routines exist and remove a sender's own headers of those names).

Store: InsertInboundSpam, IsInboundDomain.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - the relayed message of
inbound_relay_delivers_and_refuses has its journal row (accept by
selfpost) and the DMARC report sent through the same listener has none.

Not yet seen in a real run: a verdict written by rspamd reaching this
milter. The e2e stand has no filter, and the test server's only inbound
domain has no upstream, so it accepts no mail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:18:14 +03:00
mixandClaude Opus 5.5 a3cc52d6d4 inbound-antispam-panel: migration 0002, journal and filter-list tables
Step 2/12 of docs/plans/inbound-antispam-panel.md, the first migration
after the 2.0 baseline (2.0.1 was cut before it, as agreed).

0002_inbound_spam_log.sql adds inbound_spam_log (one row per recipient:
domain, client IP, envelope, subject, decision, engine, detail) with
indexes on created_at and (inbound_domain, created_at), and
inbound_spam_list (allow / deny entries, unique per list). DDL only;
decision, engine, list_type and entry_type carry CHECKs, as the
baseline does for its own enumerations. Nothing reads or writes the
tables yet.

Tests: TestBaselineSchema pins both tables and user_version 2;
TestOpenMigratesA20Database opens a file holding the baseline alone and
finds its user kept; TestInboundSpamConstraints.

go test ./internal/store/ ok; go vet ./... ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:05:20 +03:00
mixandClaude Opus 5.5 d65d632505 release: 2.0.1: DMARC ingest drops and bounds; Full backup from the panel
test / test (push) Canceled after 0s
A patch on 2.0.0, all of it found on the test server after the cut:
  - a message that is not a report this relay keeps is dropped at once
    instead of being retried for days;
  - a report is bounded before it is stored, and reports are kept per
    domain (security review of the ingest path, nothing Critical or High);
  - Full backup from the panel works (it had answered 200 with ten bytes
    since 1.3.0);
  - no backup holds the Postfix queue, and the panel says why.

Cut: CHANGELOG [Unreleased] -> [2.0.1]; image pin 2.0.1 in compose,
README, guide; version table.

On the test server, on dd0fefd: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.

Full detail: CHANGELOG.md (2.0.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:14:00 +03:00
mixandClaude Opus 5.5 dd0fefdcb7 backup: the panel's archive is the command's, private key included
Owner, 2026-10-11: the private key is needed, and a backup from the
panel and one from the console have to be the same.

The commit that made the panel's backup work skipped a file under
certs/ the panel could not open - on the test server the private key,
which the certificate hook leaves to root and Postfix's group. That gave
an archive with a certificate and no key, and two kinds of archive.

- Params.Unprivileged is gone. Nothing is left out on account of who
  makes the archive; the queue is out for everyone (previous commit).
- When the panel cannot read a file under certs/, backup.Check says so
  before the download and the panel refuses with what to do on the host:
  chgrp 998 certs/privkey.pem && chmod 640. backup.UnreadableCert tells
  that case from any other failure.
- 998 is the container's selfpost group, which holds the panel and
  Postfix. Its id was whatever groupadd gave; the Dockerfile now fixes
  it at the value it has always had, because an operator addresses it
  by number from the host.
- Guide: method 1 is the same archive as method 2, with the permission
  the key needs and a note for whatever renews the certificate. Help and
  the box follow the design commit before this one.
- e2e: the restore scenario first makes the key unreadable and expects
  the refusal naming the file and the way out, then takes the backup and
  expects the key in it; nothing is put back by hand before the restart.

  go vet ./internal/backup/ ./internal/web/handlers/: ok
  go test ./internal/backup/ ./cmd/... ./internal/web/...: ok
  make e2e on the test server: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:43:10 +03:00
mixandClaude Opus 5.5 75ebbf6a57 design: the panel's backup needs the private key readable, and says so
Owner, 2026-10-11: the key is needed and the panel's archive must be the console's. The box no longer says a certificate file may be left out; Help says the key has to be readable by the panel (group 998 on the host) and that the panel refuses, naming the file, until it is.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:43:10 +03:00
mixandClaude Opus 5.5 d70371a50c design: Full backup and Help say why the queue is left out
Owner, 2026-10-11: the user has to be told why. The box gives the reason in a sentence - the queue cannot be copied reliably while the server is running; the Help topic explains it (Postfix keeps moving messages, a copy could hold one twice or miss it), what follows (waiting mail is not carried over, a restored server starts with an empty queue) and what to do to keep the queue (stop the container, archive the project directory).

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:16:30 +03:00
mixandClaude Opus 5.5 21e961cb29 backup: no full backup holds the Postfix queue
Owner, 2026-10-11: the queue goes out of the command's archive too.

The commit that made the panel's backup work left the queue in the
archive of selfpost-backup, which runs as root and can read it. But that
copy was never the queue: the archive is made while the instance runs,
Postfix moves messages between the queue's directories all the time, and
a message on its way could be in the archive twice or not at all. One
rule now - QueueDir is left out of every archive - and
Params.Unprivileged is only about a certs/ file the panel may not open.

What is not carried over is mail still waiting for delivery; a restored
instance builds an empty queue (the e2e restore scenario starts one).
To keep the queue: stop the instance and tar the project directory, as
the guide's methods 3 and 4 already describe.

Guide: the description of a full backup, methods 1 and 2 and the
comparison table. Help follows the design commit before this one.

  go vet ./internal/backup/: ok
  go test ./internal/backup/ ./cmd/... ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:06:41 +03:00
mixandClaude Opus 5.5 82f212ab39 design: Help says no backup holds the Postfix queue
Owner, 2026-10-11: the queue is left out of the command's archive as well as the panel's. The Help topic no longer says selfpost-backup takes it; the command still takes the private key the panel cannot read.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:06:41 +03:00
mixandClaude Opus 5.5 8c9a71e6a0 backup: the panel's full backup works, without the queue
Found by the e2e restore scenario of the next commit: the Full backup
button answered 200 with ten bytes. The archive includes the Postfix
queue, Postfix keeps every queue file and directory readable by its own
user alone (not configurable), and the panel runs as its own user - the
walk stopped at /data/postfix/queue/active with "permission denied"
after the headers were out. So since the queue moved under /data in
1.3.0. Confirmed on the test server: selfpost-backup as root makes the
whole archive, the same command as user panel fails the same way.

Owner, 2026-10-11: the panel's archive comes without the queue.

- backup.Params.Unprivileged: the queue is left out, and so is a file
  under certs/ that cannot be opened (the private key on the test
  server is root and Postfix's group only), named through OnWarn. The
  selfpost-backup command leaves it unset and takes both, as before.
- backup.Check reads through everything Create would archive, writing
  nothing. The panel runs it before the first byte: a failure is a
  message on the page, not a download cut short.
- The box, Help (design commit before this one) and the guide say what
  the panel's archive leaves out; the guide's comparison table has rows
  for the queue and the private key, and tells to put the key back
  before starting a restored instance.

  go vet ./internal/backup/ ./internal/web/handlers/: ok
  go test ./internal/backup/ ./cmd/... ./internal/web/...: ok
  make e2e on the test server, with the restore scenario: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:55:18 +03:00
mixandClaude Opus 5.5 646ec1e149 design: Full backup says what the panel's archive leaves out
Owner, 2026-10-11: the archive made from the panel comes without the Postfix queue (variant 1). The Full backup box and the Help topic say so, and name the other thing the panel may not read - a certificate file, usually the private key - and the command that takes both.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:55:18 +03:00
mixandClaude Opus 5.5 4dd46ea355 dmarc: a report is bounded, kept per domain, and ingested one at a time
Security review of the DMARC report path (Fable, 2026-10-11), which ran
for the first time in 2.0.0. No Critical or High finding. Fixed:

- Medium: a report was bounded only as 8 MiB of XML - some 160 000
  records or one name of megabytes, all of it rows and table cells. At
  most 5 000 records (more is ErrRejected, dropped), text fields cut to
  255 bytes on a character boundary, a record's count capped.
- Medium: the cap of 500 reports was for the whole server, so a stranger
  filing under one domain's address evicted every other domain's
  reports. PruneDMARCReports counts per domain.
- Low: the pipe service ran up to default_process_limit (100) commands
  at once against the panel's database; maxproc is 1.
- Low: the reason logged for a dropped message carried the sender's
  bytes (header line, zip entry name) as they came; cut to 200 bytes and
  reduced to printable ASCII, zip entry names quoted.
- Low: SELFPOST_DATA_DIR / SELFPOST_DB_PATH with whitespace or "$" would
  reach the command through export_environment as another path; refused
  at start when DMARC ingest is on.

Left as they are, told to the owner: the parse-failure count is a
lifetime figure shown under "This week"; a report larger than the
command reads is dropped as unreadable; nested multipart is not searched.

The hub's Retention line follows the design commit before this one.
security.md: the review, and the accepted-risk entry says what a
stranger can and cannot do now. The roadmap no longer calls the review
pending.

  go vet ./cmd/... ./internal/dmarc/ ./internal/store/: ok
  go test ./cmd/... ./internal/dmarc/ ./internal/store/ ./internal/web/...: ok
  make e2e on the test server (with the port-25 scenarios of the next
  commit): ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:40:31 +03:00
mixandClaude Opus 5.5 505260e227 design: the DMARC hub says reports are kept 500 a domain
The retention cap is counted per domain (the security commit after this one); the Ingest box says so: 500 reports a domain or 90 days.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:40:30 +03:00
mixandClaude Opus 5.5 09d9625f32 dmarc-ingest: what is not a report this relay keeps is dropped, not retried
Owner, 2026-10-11: foreign and malformed reports are turned away.

The command answered every failure with EX_TEMPFAIL, so Postfix kept a
message that could never be stored and offered it again for days, each
attempt adding to the parse-failure count. IngestMessage now marks those
failures (unreadable as an aggregate report, a domain not sent from
here, another domain's report address) with ErrRejected; the command
counts one failure, writes the reason where Postfix puts it on the delivery line of the mail log
and reports the delivery as done. No bounce: the envelope sender of such
a message is whatever its author wrote, and the report addresses are
public. A failing store is still EX_TEMPFAIL.

  go vet ./cmd/... ./internal/dmarc/: ok
  go test ./cmd/panel/ ./internal/dmarc/ ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:28:12 +03:00
mixandClaude Opus 5.5 403ce93990 release: 2.0.0: Panel redesign; new URLs; database from zero
test / test (push) Canceled after 0s
The panel redesign (docs/plans/panel-redesign.md, 27/27). A breaking
release: every panel URL changed and the database starts from a single
baseline - a 1.x /data is not upgraded.

Since the last cut also fixed, all found on the test server:
  - the panel can read the mail queue;
  - port 25 answers with DMARC report ingest on, also next to the
    inbound relay;
  - DMARC reports reach the panel (the delivery service never worked
    since 1.7.0);
  - Postfix start-up warnings about the queue directory.

Cut: CHANGELOG [Unreleased] -> [2.0.0]; image pin 2.0.0 in compose,
README, guide; roadmap, plan and version table.

On the test server, on 252e5d1: go vet ./... ok, go test ./... ok
(22 packages), make e2e ok.

Full detail: CHANGELOG.md (2.0.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:32:08 +03:00
mixandClaude Opus 5.5 ddde177776 design: the health cards of Overview open Health at its top
test / test (push) Canceled after 0s
Owner, 2026-10-11, after looking at the test server: the anchors work but mean little, Health is short and there is nothing to scroll past. The five cards (Machine, Processes, TLS certificate, Milter sockets, Reverse DNS) lose their #machine ... #hostname, as the links into Domain settings lost theirs.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:12:53 +03:00
mixandClaude Opus 5.5 e69d139487 docs: the preflight and inbound-antispam plans use the 2.0 paths
test / test (push) Canceled after 0s
/preflight is /server/preflight and the retention setting is on /server/settings; the routes are renamed, so the plan no longer says once they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:31:36 +03:00
mixandClaude Opus 5.5 bbd2d83364 panel: show/hide rules reach a form that arrives in an htmx swap
initShowWhen bound a change listener to each control it found at DOMContentLoaded, so a form swapped in later would have been neither synced nor wired. The rules now listen once on the document and are applied again on htmx:load. No form arrives by a swap today; the roadmap row that recorded the gap is struck.

Checked in a browser on the application form: the rate-limit select toggles the manual and auto fields as before, and a copy of the form put into the page with htmx.swap is synced on arrival and follows its select. go vet and go test ./internal/web/... pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:31:36 +03:00
mixandClaude Opus 5.5 f6838e4696 design: the hosted address form on the DMARC hub is in the smaller mono size
On the test server the form dmarc-reports+<domain>@selfpost.mixfed.ru
broke over three lines in its column (2026-10-10). It takes the
supporting size the report id and rua of a report already use.

check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:13:51 +03:00
mixandClaude Opus 5.5 5b1a868ccf design: links into Domain settings open the page at its top
Seen by the owner on the test server (2026-10-10): following Report
address, Rate limit or Export from the domain's menu opened Domain
settings scrolled down to that box. The page is one screen and a bit, so
the anchor only pushed the title and the first row out of view. The
links (the domain menu, the DMARC record's note, the delete page, Server
> Settings) lose their #reports / #limit / #export.

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:57:35 +03:00
mixandClaude Opus 5.5 b951eae58c panel-redesign: no report mailbox for the server as a whole
test / test (push) Canceled after 0s
Follows the design commit before it.

  - internal/dmarc: the ingest allow-list is the report address of each
    domain that is on this host and nothing else;
    DefaultHostedReportAddress and DefaultHostedSuggestion are removed.
    Since 1.7.0 the general mailbox dmarc-reports@<hostname> was open
    while no panel-wide default existed; the three-choice scheme had
    left it open always.
  - The DMARC hub's Ingest box shows the form of a hosted address,
    dmarc-reports+<domain>@<hostname>.
  - guide.md: what port 25 accepts.

Evidence: docs/assets/panel-redesign/evidence/dmarc.png re-shot (the
address form wraps after the plus sign in its column at 1440 px).

  go test -count=1 ./internal/dmarc/... ./internal/web/... ./cmd/panel: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:01:51 +03:00
mixandClaude Opus 5.5 ea847fea44 design: the DMARC hub names the form of a hosted address, not a server mailbox
The owner's decision (2026-10-10): the mailbox for the server as a
whole, dmarc-reports@<hostname>, goes. Under the three-choice scheme a
domain can only choose its own hosted address, so nothing can be told
to report to the general one. The Ingest box's fact becomes "Hosted
addresses: dmarc-reports+<domain>@<hostname>".

check.py: 0 violations; outlines.json unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:01:50 +03:00
mixandClaude Opus 5.5 06ae9ca8d3 design: is-hidden is in the Bulma subset
The owner's instruction (2026-10-10). panel.js sets is-hidden, beside
the hidden attribute, on a block that a control switches off - the
fields of a mode that is not chosen, the password fields of an unticked
encryption box, the domain lists of a global user - because hidden alone
does not hide an element whose stylesheet gives it a display of its own
(a box, a grid row). No screen writes the class and it draws nothing, so
the components page has nothing to show for it; the list now names
everything the panel uses.

check.py: 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:22:14 +03:00
mixandClaude Opus 5.5 be85cb71c9 panel-redesign: technical review - every page re-shot and audited
Stage 3, step 1 (Opus). Each step of stage 2 was reviewed against its
mockup when it landed; this closes the review over the whole, after the
changes of stage 3 (Help topics and the ? on boxes, the three-choice
report address, row actions, the filter's socket).

  - Evidence: all 29 screenshots under docs/assets/panel-redesign/
    evidence/ are re-shot from the current templates - the 28 pages of
    pageFixtures and the sign-in page without an administrator - 1440 px
    wide at each page's full height. Headless Edge against the real
    templates served locally with their fixtures; NOT from the container
    (no Docker on this machine) - the container is checked on the test
    server at deployment.
  - panel/audit.js over the same 29 pages at 1440 and 1280: 4160 texts
    checked, problems: [] (type floor, contrast, sideways scroll,
    overflow in boxes and heads, scrolling tables, clipped card values,
    missing icons). Every ? link has the form /help#topic, and
    TestEveryHelpLinkLandsOnATopic proves each lands.
  - Guards: outlines, class vocabulary, page structure, components-only,
    stylesheet equality, routes, static allow-list - green; the ratchet
    is empty.

Where the product knowingly differs from a mockup (each recorded in the
commit of its step): the queue is the postqueue listing, not a table;
the outbound log has no status filter; the message history has no
"accepted from IP" step; Health draws the sockets this server has;
the Spam filter box states only what the configuration says; the user
form's e-mail help promises nothing that is not built; Overview's title
does not claim that mail is flowing. Roadmap screens (in-log, in-queue,
in-quarantine, in-filter-lists, preflight) are drawn and not built.

Open with the owner: is-hidden, which panel.js sets at run time, is not
in check.py's Bulma subset (the edit of check.py is his).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:07:21 +03:00
mixandClaude Opus 5.5 ed3cb4aaac docs: the panel as it is in 2.0
Stage 3, step 3. Written by Sonnet from the code and the plan, checked
by Opus.

  - guide.md: a section on the panel - menu, paths, the roles global and
    domain with outbound and inbound assigned separately -, Overview and
    Health, Queue and System log, Account and Server > Settings, the
    domain page / Domain settings / application form, Help as a page of
    topics. The DMARC report address is one of three choices set in
    Domain settings. The Spam filter box, the two INBOUND_ANTISPAM_*
    variables the panel reads, and the filter's socket on Health.
    Sessions; backup and export encrypted unless the box is unticked;
    1.x data is not restored into 2.0.
  - architecture.md: Panel HTTP surface rewritten from web.go (routes,
    the /server/ guard, per-object checks, <page>/fragment, one layout,
    typed page data, the kit and its guards, CSP and htmx); Persistence
    gains the database - one baseline, a 1.x file refused, the tables as
    they are.
  - security.md: roles and what each reaches, as built; sessions by user
    id and what ends them; state-changing requests; errors shown to
    users; the 2.0 review; two accepted risks from it - a delegated
    inbound upstream may be any host and port, and Account's profile is
    saved without the password.
  - CHANGELOG: Removed opens with "2.0 starts from an empty data
    directory"; entries that describe the user-default report address
    say they were replaced before release; the filter's socket.
  - roadmap.md: two review follow-up rows struck as retired by the
    redesign (.pair / .split, the two back links); password-reset notes
    that changing the account e-mail must then ask for the password.
    Progress 25/27.
  - development.md: the component kit and its guards; README and
    product.md: wording.

Left as they are
  - The third follow-up row the plan expected to strike - initShowWhen
    is not re-run after an htmx swap - still holds: no polled fragment
    carries such a control today, so nothing breaks, but the redesign did
    not retire it.
  - guide.md embeds no screenshots, so none needed retaking.
  - README and guide still name image 1.9.5 - the version cut.
  - plans/preflight.md and plans/inbound-antispam-panel.md use pre-2.0
    paths; roadmap's template-data-typing is largely done by the
    redesign.

  go test -count=1 ./cmd/panel (env-doc tables): ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:01:18 +03:00
mixandClaude Opus 5.5 96db0b7a86 panel-redesign: three choices for a domain's report address; row actions
Follows the design and guard commits before it. Written by Sonnet,
reviewed by Opus.

The report address
  - Baseline: users.dmarc_default_mode, users.dmarc_default_address and
    domains.dmarc_rua_user_id are gone. domains.dmarc_rua alone says
    where reports go: '' is no reports (a new domain), the address
    SelfPost derives for the domain is the hosted choice (while hosted
    reports are on), anything else is a typed address.
  - Removed: the user default and its store functions, a domain
    following a user, keep / inherit, followCreator, POST /account/dmarc
    with its handler, the DMARC box of Account, the user-delete item
    about domains losing their address.
  - Domain settings offers the three choices; beside the typed address a
    "Use my e-mail" button fills in the profile e-mail (panel.js: one
    generic data-fill hook; absent without a profile e-mail; it fills,
    it does not save).
  - The DMARC record's note names the address and no user. This closes
    L4 of the security review.
  - Domain export / import carries the address as a plain string.
  - The ingest allow-list is the generic mailbox plus each domain's
    address on this host; the generic mailbox is always open now.
  - dev/migrate1x (not in the tree): a 1.x domain that inherited the
    instance default gets that address as its own, so no published rua=
    changes.

Row actions
  - row_action partial (RowAction: Label, Post, Confirm, Danger);
    panel.css has the mockup's three rules. A row of the Applications
    table is Edit, New password, Delete; the confirmation texts are the
    ones the application page's head uses. Both roles, with the checks
    of the application page.

Tests cover: a new domain has no reports; each choice saved and shown
selected; hosted refused when hosted reports are off; a bad address
refused with what was typed kept; an untouched save changes nothing in
each state; the fill button with and without a profile e-mail; deleting
a user leaves every domain's address; the row's forms, targets and
confirmations for both roles and a 404 for a stranger.

Known: if hosted reports are switched off later, a stored hosted address
reads as a typed one. docs/guide.md still describes the old choices -
the documentation step.

Evidence
  - docs/assets/panel-redesign/evidence/account.png,
    out-domain-settings.png, out-domain.png, user-delete.png, help.png:
    1440 px wide, headless Edge, real templates served locally with
    their fixtures - NOT from the container.
  - panel/audit.js on out-domain, out-domain-settings, account, help,
    components and user-delete at 1440 and 1280: 1420 texts checked,
    problems: [].
  - In a browser: the fill button sets the field and does not submit;
    the row buttons have the font, size and colour of the Edit link,
    Delete the red of the other delete links; data-confirm fires on
    both. Hover and focus states were not observed.
  - go test -count=1 ./internal/web/... ./internal/store/...
    ./internal/dmarc/... ./internal/app/... ./internal/inbound/...
    ./cmd/panel: ok (ten packages; dev/migrate1x also ok).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:38:35 +03:00
mixandClaude Opus 5.5 fc53ad61ee design: a domain's report address is one of three choices; row actions are POSTs
Two decisions of the owner, 2026-10-10.

The report address. "Three choices: no reports, the SelfPost address, a
typed address. No reports by default. A button that fills the typed
address with one's own e-mail from the profile." This replaces the
2026-09-21 design in which a domain followed a named user's default: a
co-administrator of a domain was shown another user's login name, and
three explicit choices are simpler.
  - src/out-domain-settings.html: the box is a select of the three
    choices and the address field with a "Use my e-mail" button.
  - src/out-domain.html: the DMARC record's note names the address, with
    no user in it.
  - src/account.html: the DMARC reports box is gone; Account is Profile
    and Password, one box per column.
  - src/settings.html: the lead no longer points at Account for the
    report address.
  - docs/plans/panel-redesign.md: Account e-mail rewritten, the Routes
    row of account without /account/dmarc. docs/schema-migrations.md:
    the columns that go.

Row actions. New password and Delete in a row of the Applications table
are POSTs, so each is a form with a button that looks like the links
beside it - as Sign out became one.
  - src/out-domain.html: Edit / New password / Delete per row, the box
    foot about confirmations is back; the login cell does not wrap.
  - theme.css: .table td.sp-actions form sits inline with the links'
    spacing, and its button takes the link's colour and type.
  - src/components.html shows the pattern once.

check.py: 0 violations. outlines.json: only account changed (two equal
columns, one box each).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:38:35 +03:00
mixandClaude Opus 5.5 4ab63d08ae panel-redesign: Help with seventeen topics; every box links to its own
Follows the design commit before it. Written by Sonnet, reviewed by
Opus.

  - view/help.go, templates/help.html: the mockup's topics, texts, ids
    and menu. The page shows the groups the reader's menu shows:
    Overview and Server for the global role, Outbound and Inbound by
    reach (Inbound only with the feature on), Account for everyone;
    boxes are numbered as shown.
  - Box.Help set on the boxes the mockups mark; the inbound MX box has
    its link again.
  - TestEveryHelpLinkLandsOnATopic also fails on a topic that no page
    links to, unless it is listed as menu-only (account) - and on a
    stale exception.

Known until the change that follows: the dns, dmarc and account topics
describe the three-choice report address, which the code does not have
yet; Account's DMARC box still links to #dns.

Not verified by running: three sentences rest on Postfix behaviour
(delivery is deferred when a required TLS session cannot be made; a
milter with default_action=tempfail answers 4xx; with "any recipient"
an address the upstream refuses comes back as a bounce).

Evidence
  - docs/assets/panel-redesign/evidence/help.png (1440x3600) and
    in-domain.png: headless Edge, real templates served locally with
    their fixtures - NOT from the container.
  - go test -count=1 ./internal/web/... ./cmd/panel: ok (six packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:58:36 +03:00
mixandClaude Opus 5.5 ef19c1448e design: Help covers every section; boxes link to their topic
The owner's decision (2026-10-10): help is needed by almost every
section, not by Overview and Outbound alone. The Help mockup had six
topics while other screens already linked to topics it did not have
(#dmarc, #inbound).

  - src/help.html: seventeen topics in five boxes - Overview (checks,
    certificate, rdns), Outbound (dns, apps, limits, log, queue, dmarc),
    Inbound (inbound, upstream, recipients, filter), Server (backup,
    users, settings), Account (account) - one paragraph each, and the
    side menu lists them by group.
  - A box head gets the sp-help link where a topic now exists: health,
    out-domain-settings, out-app, out-log, out-message, out-queue, the
    three DMARC screens, in-domains, in-domain, backup, users, user,
    settings. No link on boxes that explain themselves (confirmations,
    the shown-once password, sign-in) and at most one per box.
  - outlines.json regenerated (check.py --write): only help changed -
    Outbound holds six topics, and Inbound, Server and Account are new
    boxes. 0 violations.

The dns, dmarc and account topics describe the report-address scheme the
owner decided the same day (no reports / the SelfPost address / a typed
address, no user default); the account and out-domain-settings mockups
are redrawn for it in the change that follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:58:35 +03:00
mixandClaude Opus 5.5 b2fbe91de9 panel-redesign: security review of 2.0 - sessions belong to a user by id
Stage 3, step 2. Reviewed by Fable (read-only), fixed by Sonnet with a
failing test before each fix, checked by Opus. No Critical or High
finding: route authorization, tenant isolation by id, the /server/
subtree guard, output encoding and the origin check were found sound.

Fixed
  - M1: a session was keyed by user name and had no link to the user. A
    deleted or renamed user's cookie stayed alive (renewed on every hit,
    before the user was looked up) and became a session of whoever was
    later created under that name, with that account's role. The
    baseline now has sessions.user_id REFERENCES users(id) ON DELETE
    CASCADE; the principal is resolved by id and a session is renewed
    only after its user was found. RenameSession is gone.
  - M2: any user's password change deleted every other user's sessions.
    It ends that user's other sessions only.
  - L1: a password set on the user form ends that user's sessions (an
    administrator editing their own account keeps the current one).
  - L2: store, saslpasswd2 and Postfix reload errors were shown verbatim
    on the application form, the inbound upstream / recipients forms and
    the limit recalculation - pages a domain administrator reaches.
    Validation errors (app.ValidationError, inbound.ValidationError,
    store.ErrNotAutoRateLimit) keep their text; anything else is logged
    and shown as "Could not save ... Check the logs and try again."
  - L3: store.ListDMARCReports treated an empty domain list as "all". It
    takes a scope now (AllDomains or a list; an empty list is no rows),
    and the DMARC hub is open to the domain role as the plan says: the
    reports of the assigned domains, without the instance-wide ingest
    box. The menu entry was already shown to them and led to a 404.
  - I1: /static/ answered with a listing of the embedded assets. A path
    that is not a known asset is a 404.

Consequences
  - The global DMARC hub lists reports of domains that were deleted
    since, too.
  - dev/migrate1x (not in the tree) no longer carries sessions over:
    everyone signs in again after the transfer.
  - docs/schema-migrations.md: the sessions row.

Recorded, not changed - for the owner:
  - L4: the report-address select names the followed user and their
    address to every administrator of the domain, by default the global
    administrator's login name. The plan asks for it.
  - L5: a delegated inbound upstream may be any host and port, loopback
    and private ranges included - within the trusted-operator model; to
    be listed among the accepted risks in security.md.
  - L6: Account's profile is saved without the password; becomes a
    requirement once password-reset mails to users.email.
  - Still echoing an error's text: backup and domain import (global
    only), the domain form's validation messages.

Not checked by the review: anything needing the container (e2e, real
Postfix / DNS), CSP behaviour in a live browser.

  go test -count=1 ./internal/web/... ./internal/store/... ./internal/app/...
    ./internal/inbound/... ./internal/dmarc/... ./cmd/panel
      ok  (all eleven packages; dev/migrate1x also ok)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:04:02 +03:00
mixandClaude Opus 5.5 32c6ae98a1 panel-redesign: the queue in the new shell; the old design is removed
Stage 2, steps 7 and 8 in one commit: the static guard fails as soon as
the ratchet holds no page while legacy.css still exists, and the tests
of the old layout need it while that layout exists - the two steps do
not split into states that pass on their own. Written by Sonnet,
reviewed by Opus.

The queue
  - out-queue, named after its mockup: today's content - the listing of
    postqueue -p in a log pane, polled as before (GET
    /outbound/queue/fragment) - and the "How retries work" facts. The
    mockup's table of messages is the roadmap's queue-as-a-table; the
    outline guard exempts this page for exactly that reason. Routes and
    the global-only check are unchanged; the page has no actions and
    never had.
  - Inbound has no Queue entry and no route serves one.

The old design is gone
  - Deleted: layout_legacy.html, legacy.css, logo-compact.svg,
    mail_queue.html, mail_queue_body.html.
  - view.go has one path: every page is layout.html + components.html +
    its own files; kitPages, kitFragments, the map-data branch of Render,
    the dict and back template functions and metaOf's map branch are
    removed. handlers: pageBase removed.
  - legacy_pages.txt holds its header and no entry.
  - panel.js: the old copy handler (button.copy in .code-row) is
    removed. A show-when rule now applies to every element it matches,
    and hiding adds Bulma's is-hidden beside the hidden attribute - a
    .box or a grid row has a display of its own and stayed visible.
  - The user form hides both domain lists again when the role is Global
    (Box.DomainPick, an attribute; data-global-role on the select).
  - Tests of the old layout are deleted; what they asserted about the
    shell (version only signed in, the menu follows the user's reach,
    Inbound only with the feature, Help present, the current entry
    marked) is asserted on the kit pages, two tests added for it.

For the owner: is-hidden is a Bulma class outside the subset of
check.py. No template writes it - the script sets it at run time to
hide a block - so no guard sees it; say if it should be on the list.

Evidence
  - docs/assets/panel-redesign/evidence/out-queue.png: 1440 px wide,
    headless Edge, the real template served locally with its fixture -
    NOT from the container (no Docker on this machine).
  - panel/audit.js on out-queue, user and backup at 1440 and 1280:
    366 texts checked, problems: [].
  - In a browser against the local fixture server: the encryption
    fields of Backup, the manual / auto limit fields and the custom
    report address of Domain settings show and hide with their control;
    all 28 fixture pages answer 200 with panel.css, /static/legacy.css
    answers 404.
  - Not exercised: polling against a real postqueue (none on Windows).
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:36:04 +03:00
mixandClaude Opus 5.5 fa696efa8b panel-redesign: Server pages and Help on the kit; the help drawer is gone
Stage 2, step 6. Written by Sonnet, reviewed by Opus.

  - Six pages named after their mockups: system-log, backup, users,
    user, user-delete, help, with typed page data (view/server.go,
    help.go), fixtures and tests. The old templates of the six are gone
    (user_form.html, encrypt_fields.html deleted, the others rewritten)
    and their names are off legacy_pages.txt, which now holds mail_queue
    alone.
  - Help is six help_topic blocks (checks, certificate, rdns, dns, apps,
    limits); a test renders every fixture page and fails on a Help link
    without a topic. The help drawer (help_drawer.html and its includes
    in layout_legacy.html) is removed; its rules in legacy.css go with
    the next step.
  - The user form sets the user's e-mail (plan, Account e-mail): create
    and update, validated, and a changed address resyncs DMARC for a
    user whose default is the account e-mail - through the helper the
    Account profile now shares.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization are unchanged. A test loads the user
    form for four reach combinations and for the only global user, posts
    it back untouched and finds the user unchanged.

Fixed on the way
  - The user delete confirmation said the domains were not affected. A
    domain that follows the user's default report address is left with
    none; the page names those domains now.
  - A refused backup lost the encryption choice and printed a literal
    <code> element in its message.

Differences from the mockups, for the owner:
  - Help has no DMARC and no Inbound topic: its outline fixes two boxes
    of three topics, although other mockups link to #dmarc and #inbound.
    Until the owner decides, Account's Help link goes to #dns and the
    inbound MX box has none;
  - system log: newest line first, as drawn; "refreshes on its own" (5 s
    active, 30 s idle); only status=, warning: and error: lines are
    coloured;
  - backup: the file field is a plain file input; the encryption box is
    ticked, as drawn and as on the domain export, so a plain download
    means unticking it first;
  - users: an E-mail column; the flash does not name the user;
  - user form: no lead; the domain lists are no longer hidden by script
    when Global is chosen (the server ignores them for that role).

Evidence
  - docs/assets/panel-redesign/evidence/system-log.png, backup.png,
    users.png, user.png, user-delete.png, help.png: 1440 px wide,
    headless Edge, real templates served locally with their fixtures -
    NOT from the container (no Docker on this machine).
  - panel/audit.js on the six pages at 1440 and 1280: 720 texts checked,
    problems: [].
  - The system log's polling swap was not exercised in a browser; it
    uses the mechanism of Overview and Health.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:14:25 +03:00
mixandClaude Opus 5.5 610023ef6a panel-redesign: Inbound domains, domain and delete on the kit; Spam filter box
Stage 2, step 5. Written by Sonnet, reviewed by Opus.

  - Three pages named after their mockups: in-domains, in-domain,
    in-domain-delete, with typed page data (view/inbound.go), fixtures
    and tests. The engine pages inbound, inbound_domain and
    inbound_delete are gone with their templates and are off
    legacy_pages.txt.
  - The Spam filter box (plan, Who sees what). The panel now reads
    INBOUND_ANTISPAM_MILTER and INBOUND_ANTISPAM_MILTER_ACTION, with the
    semantics of build/postfix-config.sh (on only with the inbound relay
    on and a milter set; accept | tempfail). It reports them, nothing
    more:
      on   Applies to / Filter (the address, global role only) / If the
           filter is down (accepted unfiltered | deferred)
      off  one sentence; the variable is named for the global role only
    The list shows "Spam filter on|off" in its head.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization (global: all inbound domains; domain
    role: the assigned ones, another tenant's id is a 404; add and
    delete are global-only) are unchanged. A test saves the upstream and
    recipients forms untouched and finds the domain unchanged.

Left out of the mockups' Spam filter box and menu - no source before
inbound-antispam-panel (2.1): the engine name, the quarantine row, the
"refused at the door, see the log" row, the lists row, "Log for this
domain". Also "Checked N min ago" on the MX box: the check carries no
time.

Not done: the filter's socket on Health and Overview. internal/health
checks a unix socket by stat; the documented form of the filter's
address is inet:host:port and needs a dial that does not exist yet.

Differences from the mockups, for the owner:
  - refusals and confirmations are the kit flash above the boxes;
  - the MX tag is coloured by the check in every state; an MX that
    points elsewhere shows "In DNS now";
  - the lead of the domain page follows what is saved ("Not accepting
    mail yet: no upstream is saved");
  - the delete page lists only what the domain has;
  - /help#inbound opens Help at the top until the Help step.

Evidence
  - docs/assets/panel-redesign/evidence/in-domains.png, in-domain.png,
    in-domain-delete.png: 1440 px wide, headless Edge, real templates
    served locally with their fixtures (filter on) - NOT from the
    container (no Docker on this machine).
  - panel/audit.js on the three pages at 1440 and 1280: 352 texts
    checked, problems: [].
  - That the panel process sees the two variables inside the container
    is read from supervisord.conf and entrypoint.sh, not run.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:39:20 +03:00
mixandClaude Opus 5.5 2f1ec65b30 panel-redesign: Outbound log, message and the DMARC report pages on the kit
Stage 2, step 4. Written by Sonnet, reviewed by Opus.

  - Five pages named after their mockups: out-log, out-message, dmarc,
    dmarc-domain, dmarc-report, with typed page data
    (view/outbound_log.go, dmarc.go), fixtures and tests. The engine
    pages deliveries and delivery are gone with their templates; the
    three DMARC templates are written anew. All five old names are off
    legacy_pages.txt.
  - The log polls its table (GET /outbound/log/fragment, as before); the
    page count in the box foot follows out of band, and the filter in
    the head is no longer swapped away while it is being chosen.
  - Handlers pass typed data; routes, query parameters, filters, paging
    and authorization are unchanged.
  - Kit: Foot.ID / Foot.OOB (attributes only, as on Head) and Fact.Small,
    the mockup's "sp-mono sp-small" value, for a report id and an rua
    that wrapped mid-word in their column.
  - test/e2e status scrapers match the new tag markup. Not run: no
    Docker here.

Fixed on the way
  - The DMARC domain page read a field its source rows do not have and
    answered 500 as soon as the domain had a source. It builds the rows
    from the store's rollup now, with a test.

Differences from the mockups, for the owner:
  - the log has no Status filter (the store filters by domain and
    application only); a refused message is "rejected", a neutral tag as
    the mockup's "rate-limited" is;
  - the message history keeps the panel's own wording with full dates and
    has no "accepted from <IP>" step - the client address is not stored;
  - dmarc: before the first report the box says "None yet" with what to
    check; a quiet mailbox is an amber "quiet" tag;
  - dmarc-domain: the stamp is pass / (pass + fail) rounded down, amber
    on any failure, red when nothing passed, absent without mail;
  - dmarc-report: the title has no year; "this relay" marks record rows
    as well as sources.

Known and left as it is (not introduced here): the DMARC hub answers 404
to a domain administrator while the menu shows the entry and the plan
gives that role the reports of its own domains. Opening it needs the
report list to refuse an empty domain filter first - for the stage-3
security review.

Evidence
  - docs/assets/panel-redesign/evidence/out-log.png, out-message.png,
    dmarc.png, dmarc-domain.png, dmarc-report.png: 1440 px wide, headless
    Edge, real templates served locally with their fixtures - NOT from
    the container (no Docker on this machine).
  - panel/audit.js on the five pages at 1440 and 1280: 784 texts checked,
    problems: [].
  - Polling of the log was exercised in a browser against a local
    fixture server with document.hidden overridden: four polls, one
    table and one foot after each, the filter untouched.
  - go test -count=1 ./internal/web/... ./cmd/panel
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view
      ok  cmd/panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:21:06 +03:00
mixandClaude Opus 5.5 acdfaaecd7 panel-redesign: Outbound domains, domain, settings, application on the kit
Stage 2, step 3. Written by Sonnet, reviewed by Opus.

  - Six pages named after their mockups: out-domains, out-domain,
    out-domain-settings, out-app, out-app-created, out-domain-delete,
    with typed page data (view/outbound.go, outbound_app.go), fixtures
    and tests. The engine pages dashboard, domain_detail and
    domain_delete are gone with their templates and are off
    legacy_pages.txt. GET .../settings has its own handler.
  - Handlers pass typed data; routes, POST targets, field names,
    validation, authorization (both roles, another tenant's id is a 404)
    and Cache-Control: no-store on the password page are unchanged.
  - Kit, attribute only: Action.Confirm writes data-confirm on a POST
    action's form (panel.js already reads it).
  - test/e2e/panel_client.go scrapes the new markup (copy fields, the
    application row's Edit link). Not run: no Docker here.

Fixes found on the way
  - Report address of a domain that follows another user's default: the
    select had one "inherit" option, selected, so saving the form
    untouched moved the domain to whoever saved it. It now offers that
    user's default as "keep" (selected, saves nothing) and "My default
    instead" as the change, as the mockup draws it; "keep" on a domain
    that does not follow someone else is refused.
  - panel.js: syncShowWhen stopped at the first matching rule, so the
    auto-limit fields were never toggled with the limit mode. Every
    matching rule applies now.

Differences from the mockups, for the owner:
  - application rows carry Edit only. New password and Delete are POSTs,
    and the kit has no link-styled POST control for a table row; both are
    in the application page's head with a confirmation. Drawing them in
    the row needs a design: change (as Sign out was);
  - the application login is read-only on edit (the handler refuses a
    rename); the submit says Create application on the empty form;
  - multiplier bounds are the store's 1.5-5, default 2.5;
  - "Recalculate" buttons exist where the stored limit is automatic;
  - a domain administrator has no add box and no Delete; Delete domain is
    global-only on the domain page and on settings;
  - a fourth record, Report authorization, appears when the report
    address is on another domain; record states missing / weak / not
    checked are tags with the explanation under the value;
  - refusals are the kit flash above the first box, and those of the
    settings forms now show on the settings page; successful saves still
    redirect to the domain page;
  - the export checkbox is ticked by default, as drawn.

Evidence
  - docs/assets/panel-redesign/evidence/out-domains.png, out-domain.png,
    out-domain-settings.png, out-app.png, out-app-created.png,
    out-domain-delete.png: 1440 px wide, headless Edge, real templates
    served locally with their fixtures - NOT from the container (no
    Docker on this machine). Taken before the report-address fix, which
    does not change what the fixture renders.
  - panel/audit.js on the six pages at 1440 and 1280 (text inside
    [hidden] blocks skipped): 866 texts checked, problems: [].
  - go test -count=1 ./internal/web/...
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:49:20 +03:00
mixandClaude Opus 5.5 5c654723c2 panel-redesign: Overview, Health, Account and Server > Settings on the kit
Stage 2, step 2. Written by Sonnet, reviewed by Opus.

  - Four pages named after their mockups: overview, health, account,
    settings, each with a typed page struct (view/overview.go, health.go,
    account.go), a fixture and tests. The engine page "status" is gone
    (status.html, status_body.html deleted); "settings" is Server >
    Settings only. status and settings are off legacy_pages.txt.
  - Overview: the stamp and its sentence are derived from the six cards
    (the worst card sets the level and the lead names the cards that are
    not fine); the outbound and inbound domain tables are built from the
    existing service calls. Without the inbound relay the outbound box
    takes the full width.
  - Both pages refresh themselves as the old Status page did. Health
    polls its first row of boxes and swaps the second out of band.
    Overview polls its Server health box and the page head follows out of
    band (GET /overview/fragment), so stamp, sentence and time move with
    the cards.
  - Kit, attributes only (no class, no CSS, markup unchanged when empty):
    Box.Poll and Head.ID / Head.OOB.
  - healthCards: the Queue card says "Empty" / "N queued" instead of
    postqueue's line, which was clipped in a card; Processes and Milter
    sockets carry a sub line. OverallStatus / OverallHeading are gone.
  - Handlers pass typed data; routes, POST targets, field names,
    validation and authorization are unchanged.

States the mockups do not draw, all from existing partials: errors and
successes as the kit flash above the boxes; Health with an unreadable
process list, a certificate without facts, a missing host name; Account
with the report authorization not published / not found / not checked
and with hosted reports off; empty domain tables as empty_state.

Differences from the mockups, for the owner:
  - Overview title is one of "Everything is running normally." /
    "Running, with warnings." / "A component needs attention.", not
    "Mail is flowing..." (the checks do not prove that); the note says
    "Updated HH:MM:SS UTC" without an interval, Health's lead says
    "Refreshes automatically" (panel.js polls at 5 s active, 30 s idle);
  - one Queue card ("outbound"), two milter sockets: what this build has;
  - the days column is the stats window, min(30, retention);
  - Health: no "check port 80" paragraph (the certificate's own detail
    says what to check); a failing socket shows its detail under the path;
    figures come from the sampler ("12%", "MiB");
  - Account: the DMARC form keeps the address field for "Another
    address" (hidden unless chosen); the count "N of M now" is the
    domains following this user's default;
  - Settings: Level 2 links the domain list only;
  - /help#checks and /help#dmarc open Help at the top until the Help step.

Evidence
  - docs/assets/panel-redesign/evidence/overview.png, health.png,
    account.png, settings.png: 1440 px wide, full height, headless Edge,
    real templates served locally with their fixtures - NOT from the
    container (no Docker on this machine).
  - panel/audit.js on the four pages at 1440 and 1280: 612 texts
    checked, problems: [].
  - Polling was exercised in a browser against a local fixture server
    with document.hidden overridden (the pane was hidden): one element
    per id after a swap, no leftover hx-swap-oob, about one request per
    5 s. The first request of a visible tab was not observed.
  - go test -count=1 ./internal/web/...
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:11:00 +03:00
mixandClaude Opus 5.5 5b979a0540 design: no Notifications box on Account and Server > Settings
Event notifications are not built (panel-notifications is a roadmap
candidate), and the outline of both screens forced a box that could only
say so. The owner's decision (2026-10-10): take the boxes out of the
mockups; they are drawn again as the first step of that feature's plan.

  - src/account.html: box 03 Notifications removed, DMARC reports is 03;
    the lead and the e-mail help no longer promise notifications or a
    reset link.
  - src/settings.html: box 02 Notifications removed, Sending rate limits
    is 02; the lead no longer lists notifications.
  - outlines.json regenerated (check.py --write): the two boxes are gone
    from account and settings, nothing else moved. 0 violations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:10:59 +03:00
mixandClaude Opus 5.5 f59f4778c9 panel-redesign: sign-in without an administrator follows its mockup
- panel.css: .sp-signin-form .sp-form, as theme.css has it now.
  - templates/login.html: the state is div.sp-form with the mockup's
    heading and line; the empty form of ef8a82e is gone.
  - tests: the hint is checked by its new wording, and that the page
    carries no form at all.

Evidence
  - docs/assets/panel-redesign/evidence/login-no-admin.png: 1440x900,
    headless Edge, real templates served locally (no Docker here).
    login.png re-shot after the selector change: byte-identical.
  - go test -count=1 ./internal/web/... : ok (web, auth, handlers,
    validate, view)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:33:26 +03:00
mixandClaude Opus 5.5 2b4a158423 design: sign-in before any administrator exists is a drawn screen
The owner asked for the state to be drawn (2026-10-09) instead of the
form.sp-form without action or controls that ef8a82e used to get the
column centred.

  - panel/src/login-no-admin.html: the signed-out screen with a
    div.sp-form in place of the form - host kicker, the heading
    No administrator yet, one muted line pointing at the one-time setup
    link. No new class: div.sp-form is on the components page already.
  - theme.css: the signed-out column is sized through .sp-form
    (.sp-signin-form .sp-form), not through the form element, so the
    column does not need a form to be centred. Same declarations.
  - index.html: the screen is on the map; thirty-four screens.

check.py: 0 violations; outlines.json unchanged (bare screens have no
outline).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:33:25 +03:00
mixandClaude Opus 5.5 ef8a82e4c3 panel-redesign: Sign in and Create administrator on the kit
Stage 2, step 1. Written by Sonnet, reviewed by Opus.

  - templates/login.html, setup.html: written from the mockups as the
    content of layout_signed_out - form.sp-form, kicker, h1, Bulma fields
    with icons, the full-width primary button. Actions, field names,
    autocomplete and autofocus are the old ones.
  - view/signin.go: typed Login and Setup (Meta carries a title only, so
    Engine.Render takes the signed-out layout); auth handlers pass them
    instead of maps.
  - login and setup are in kitPages, have fixtures, and are off
    legacy_pages.txt.

States the mockups do not draw:
  - a refused sign-in / setup is the kit's flash (notification is-danger
    is-light) above the fields;
  - login before any administrator exists shows the old sentence in a
    form.sp-form that has no action and no control: the signed-out
    stylesheet sizes and centres its column through "form" alone. A
    workaround, listed for the owner.

Differences from the mockups, for the owner:
  - the kicker is <p><span class="sp-kicker"> (the kit's partial is a
    span; a page may not write the class), the mockup has <p
    class="sp-kicker">: the block sits about 3px lower;
  - login's kicker is the configured host name and is absent when there
    is none; setup's is the mockup's fixed text;
  - the footer is the layout's legal line (full copyright holder name);
  - the user name field has autofocus, so it shows its focus ring.

Evidence
  - docs/assets/panel-redesign/evidence/login.png, setup.png: 1440x900,
    headless Edge, from the real templates served locally with their
    fixtures - NOT from the container (no Docker on this machine).
  - panel/audit.js on login, login with an error, login without an
    administrator, setup, setup with an error, at 1440 and 1280:
    118 texts checked, problems: [] ; no page is taller than 900px.
  - go test -count=1 ./internal/web/...
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:23:10 +03:00
mixandClaude Opus 5.5 783dabbea5 panel-redesign: Server > Health; Overview reduced to verdicts; @routes off
Stage 1, step 5 - and the end of stage 1.

  - GET /server/health (+ /fragment) shows every check in full and is the
    home of POST .../recheck and POST .../reload, which now return there.
  - GET /overview (+ /fragment) shows the overall verdict and six cards -
    machine, processes, TLS certificate, queue, milter sockets, reverse DNS -
    each a view.HealthCard (the typed input of the kit's health_card) that
    links to its table on Health. A check that could not run is a warning,
    not "ok".
  - Both pages read healthChecks(), so a card and its table cannot disagree.
    Until stage 2 they share the old status template, switched by a flag.

legacy_pages.txt: @routes removed. TestRoutesFollowNavigation is enforced
from here on: every path of plan section Routes is registered with its
method, no pre-2.0 path is answered, and no template links to a path the
router does not serve.

Not in this step, left for the Overview page of stage 2: the outbound and
inbound domain summaries the mockup shows under the cards.

Tests: the six cards from one reading of the checks (values, levels, links,
and the unverified case); the two modes of the page - Overview without the
tables and actions, Health with them, each polling its own fragment.

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail.

Roadmap 15/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:07:10 +03:00