10 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 bdeb01681d local-time: the panel in the container's zone, named on Settings
Agreed by the owner on 2026-10-13 (the local day, no "UTC" beside a
time); the zone is the container's TZ in .env and Settings only shows it,
his "Да" of 2026-10-17. Mockups c205106.

Every time the panel shows is formatted in time.Local and says no zone:
the logs' Time and the DMARC Received columns, a message's timeline,
Overview's "Updated", Health's certificate and renewal, the filter lists,
DMARC reports and windows, the auto limit's last recalculation. Stored
instants stay UTC. The days counted are the zone's: delivery_log_days and
sender_check_days key their rows by the local date, the sender check cuts
downtime at local midnights (byDay), the log filters' days run from local
midnight to local midnight. Notification mail and the test message write
the zone's abbreviation after a time - they are read away from the panel.
DMARC's one-day window label stays the reporter's (UTC) day.

Settings box 05, Time zone, read-only: the name (UTC without TZ), its
offset, and a TZ the image cannot load said to leave the times in UTC
(time/zoneinfo_unix.go, initLocal) rather than named. TZ is passed by the
compose file ("${TZ:-}"), in .env.example and the guide's table; tzdata is
named in the image (supervisor's python3.13 brings it today). Postfix
takes TZ through import_environment, so mail.log's stamps are the same
zone, which closes the trap the roadmap wrote down: the tailer has always
read them in time.Local.

Tests: a TestMain runs view, handlers, store and cmd/panel in UTC whatever
the machine's zone (as logtail's); TestSettingsShowsTheTimeZone,
TestTimesAreTheContainersZone, TestByDayInTheContainersZone, a Moscow day
in TestQueryInboundSpamFilters; envdoc's pattern takes two-letter names.
e2e time_zone_is_the_containers recreates the stand with
TZ=Europe/Moscow: date says MSK, Settings names the zone and UTC+03:00, a
test message's page gives its time and its mail.log lines in Moscow's.
go vet + go test (26 packages) and make e2e on the test server for this
tree.

Docs: guide (§ Settings, the TZ row, the days of Health and the logs),
architecture, schema-migrations, help (word for word with the mockup),
CHANGELOG [Unreleased] § Added, roadmap row done with the decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:34:28 +03:00
mixandClaude Opus 5.5 0cceb549cc sender check: a Sender: line is held to the rule From: is held to
test / test (push) Waiting to run
The owner's decision (2026-10-16) on the security review's F1: a Sender:
line - the sending agent's, shown beside From: as "on behalf of", and
signed since 2.8.0 - was neither held to the login nor, before, signed: an
application could write its own From: and Sender: ceo@bank.com under this
server's signature. Now, when a message has one, it must be one address
the application may send as, and at most one such line (RFC 5322 § 3.6);
From: is judged first.

The refusal is "550 5.7.1 Sender: <address> - <why>", a send-log row of
reason "sender" (store.ReasonSender), tagged "wrong Sender"; the message
page names the line. The log tailer recovers such a row from cleanup's line
as it does a From: refusal (fromRefusalRe reads either). Help text as in
the design commit d9d9af3; guide, architecture, security and CHANGELOG say
it, security.md keeps Resent-From: (signed, not judged) as an accepted
risk.

Tests: TestSenderLineHeldToTheSameRule (own address, a name, another
application's, a stranger's, two addresses, two lines, From: first), the
recovery of a Sender: refusal line, the tag, the message page; e2e
sender_check_holds_the_sender_line (own Sender: passes and is signed,
another domain's refused at DATA and logged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:41:42 +03:00
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 2b8e0c086d audit 4: a deleted domain says so, the Outbound log filters by status and day
test / test (push) Canceled after 0s
L17: past DeleteDomain both rebuilds run, their errors joined under
domain.ErrDeletedIncomplete; the key is removed only when OpenDKIM's tables
were rebuilt (they may still name it otherwise); the domain list says the
domain is deleted and that Reload configuration finishes it, not "internal
error". Tests in domain (the tables failing, the sender map failing) and
handlers.

The Outbound log's status and day filters (owner, 2026-10-13; the mockup
first, 80e3979): view.OutLogFilter carried by the poll, the pages, the rows'
links and the message's way back; SendLogFilter.Status, Since, Until (whole
UTC days, as the Inbound log's); rejected is the three refusals together; a
status not in the list is no narrowing. Tests: the filters through the
handler, the poll and the way back; the day boundaries in store.

The plan's milter-socket step is ticked: done in 2.5.2 (98e09fe).

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 642 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:34:24 +03:00
mixandClaude Opus 5.5 ca876c1631 audit 4: log paging at its edges, NaN refused, a red rate limit, recalculation asks, reload writes all
test / test (push) Canceled after 0s
Step 4 of stage 4 (L10, L11, L13, L18, L19 and a row of
review-2026-08-followups):

- The Outbound log counts first and clamps the page to the last; a number
  too large for an int (strconv's ErrRange with the largest int) is the
  last page on both logs. Tested at the edges: first, last, past the last
  however far, nonsense, an empty log asked for page 5.
- checkAutoMultiplier is "not within", so NaN and the infinities are out.
- rate-limited is red (components.go; the mockup first, 643892a).
- data-unsaved on both recalculate buttons: panel.js asks on submit
  (ev.submitter) when a field of the page differs from what it was drawn
  with; testdata/unsaved_test.js under TestPanelJS.
- HandleReload writes what a restore's first start writes - the filter
  lists and the DMARC maps too - and a failed step is an error flash on
  Health that names it, not plain text; the success flash says what was
  written.

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 695 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:20:18 +03:00
mixandClaude Opus 5.5 af130e268a findings of the reviews of audit stage 2b
Technical review (Opus) and security review (Fable) of 5be109b..5154bba;
reports in the session's scratchpad. Nothing above low from security; one
bug and three risks from the technical review. Migration 0009 is not
deployed, so it was extended.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:38:22 +03:00
mixandClaude Opus 5.5 65d916f26a logtail: a refusal the panel did not get to write is written from mail.log (stage 2b)
test / test (push) Canceled after 0s
The sender check answers 550 5.7.1 and writes the refused row through a
buffer a moment later; a panel stopped in between (a rollout, a kill) left
an application refused and a log that did not know it. A refusal at MAIL
FROM needs nothing: the journal milter writes its row before it answers.

- logtail.refusalLines remembers each queue id's login from smtpd's
  "<id>: client=..., sasl_username=<login>" (4096 ids at most) and, on
  cleanup's "<id>: milter-reject: END-OF-MESSAGE ...: 5.7.1 From: <line> -
  <why>; from=<...> to=<...>", waits 30 s and hands it to
  store.RecoverRefusal - an INSERT ... WHERE NOT EXISTS a refused row of that
  queue id within two minutes: the row the panel wrote, or one recovered
  before, means nothing is written. It is read from the saved offset on, so
  a kill is covered as well as a clean stop - which a flush of the buffer at
  shutdown would not.
- Migration 0009 (not deployed yet) gains send_log.recovered; the row is
  written with the line's time, the login's domain id, reason "from", the
  From: line as the check printed it (to the last " - " before ";").
- The message page: a fact "Written: from mail.log after a restart: the
  panel stopped before it wrote the refusal".

Tests: store (not written when the panel wrote it; written once however
often); logtail (the login from client=, the From: line with a " - " in it,
nothing before the wait is over); view (the fact). e2e
refusal_recovered_from_the_log: a From: refusal, the panel stopped, its row
deleted and the tailer's saved place moved before the message's first line
(python3 in the container, as the panel's user - a kill cannot be timed to
that moment from outside); started again, the row is back, recovered, once.

Also fixed: handlers TestMessagePageOfARefusedMessage still expected the
old sentence; it failed on the server after e166a2e, whose run showed only
the schema failures - now it wants the new one.

schema-migrations row 9, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 26/62.
Evidence on the test server for this tree but the handler-test fix: make e2e
exit 0 (the new case 37 s); go test 22/23 ok, the 23rd that test - fixed,
passes locally; the full run follows before the push.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:56:29 +03:00
mixandClaude Opus 5.5 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 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