Commit Graph
9 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 faa3c11ae7 panel-dkim-signing stage 2: OpenDKIM removed
test / test (push) Canceled after 0s
The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.

Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.

Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.

Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.

KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.

e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:05:39 +03:00
mixandClaude Opus 5.5 ac985d03a8 sender check: connections and messages counted apart; downtime estimated when supervisord is silent (stage 2b)
Following the design commit df96690.

- Two columns (owner, 2026-10-14): sender_check_days.messages (migration
  0009, not deployed) holds the messages deferred mid-message, from
  cleanup's warning; deferred stays the connections smtpd turned away.
  AddSenderCheckDeferred takes which; the counter passes cleanup's as a
  message. The card has "Connections turned away" and "Messages deferred";
  it warns on either within 24 hours (one mark, as before).
- Supervisord silent at start: postfixStarted asks again every 2 s for 30 s
  (postfixState: started, running, known). With no answer the downtime is
  the time since the alive mark - an upper bound - booked with estimated = 1
  (sender_check_days.estimated, MAX over a day); the card prefixes such a
  period's time with "≈ " and its note says what ≈ means. Before, such a
  start counted nothing and the number the owner decides by read low.
- Help topic sender-check and the log topic word for word as the mockup
  (help.go).

Tests: store (connections and messages apart, a period with an estimated
day); logtail (cleanup's warnings are messages); cmd/panel (supervisord
silent: the time since the mark, estimated); view fixture and the Health
page at four columns with ≈; e2e reads the row's two counts (panel down:
connections; panel hung: messages). Schema at 0009 with the columns.

schema-migrations row 9, CHANGELOG [Unreleased], audit plan lines ticked
(two), roadmap 28/62.
Evidence: go vet ./... clean; go test of store, view, cmd/panel ok locally,
logtail 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 17:16:02 +03:00
mixandClaude Opus 5.5 8b9798884a staticcheck: the four items of the audit (stage 0)
test / test (push) Canceled after 0s
- handlers/authz.go: domainIDSet removed, nothing called it (U1000).
- handlers_outbound_pages_test.go: two refusals whose status code was
  assigned and never read (SA4006) now check it is 400 - a custom DMARC
  report mode without an address, export passwords that differ.
- view/pages_test.go: !strings.Contains for a missing box (S1003).
- Audit plan line ticked, roadmap 6/61. No CHANGELOG line: nothing an
  operator sees changes.

staticcheck ./... clean afterwards. Evidence on the test server: see the
previous commit (same tree run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:55:12 +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 b905f3e560 panel-notifications: Account, Server > Settings, Server > Health, help; e2e
test / test (push) Canceled after 0s
The pages of the mockups accepted in 87fa0a4, built from the kit's partials
(written by a Sonnet agent into the working tree, reviewed here):

- Account: "Send me notifications" in the Profile form, saved with the
  profile; an unticked box is off.
- Server > Settings: the instance switch (box 03), and box 04 Notification
  mail with the DKIM and SPF records of the server's own name, drawn with the
  record partial of a domain's DNS page and checked in DNS. Sending rate
  limits are box 02 in the left column. A record that is not in place is
  tagged as a warning, not a failure: notifications are sent without it.
- Server > Health: card 06 Notification mail with the state of each record
  and a link to Settings, beside Configuration (07); part of the polled
  fragment; Re-check DNS forces it too. Overview does not run this check.
- help.go: the Notifications topic and the clauses in Settings and Your
  account, word for word with help.html.

If the server's key cannot be read the pages still render: the DKIM record
is left out and SPF is shown unchecked.

e2e: the administrator gets a mailbox behind the sink and the server's DKIM
record from Settings is published; the level-2 refusal of the next step must
then arrive as a notification from postmaster@mail.e2e.test, signed as that
name with the key Settings shows, and must not be a row of the Outbound log.
The rotation step now waits for Postfix to be running: it follows a step that
restarts the container, and logrotate's `postfix reload` failed in between.

On the test server, for this tree: go vet clean, go test ./... 23 packages
ok, make e2e ok (147 s); the notification reached the sink 28 s after the
refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:21:48 +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 5af4d6c368 panel-redesign: the health cards of Overview carry no anchor
Follows the design commit before it: Machine, Processes, TLS certificate, Milter sockets and Reverse DNS lead to /server/health as it opens; so does the link in the Needs attention line, which takes its address from the card.

  go vet ./internal/web/...: ok
  go test -count=1 ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:14:17 +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 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