No migration; schema 12. The pins (README, compose, guide), the guide's
backup example, the planned cuts and the roadmap row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Agreed with the owner on 2026-10-13 (the panel in a chosen zone, "today"
that zone's day, the UTC labels gone) and settled on 2026-10-16/17: the
container runs in his zone ("Контейнер в моем поясе") - TZ in .env, read
by every program at its start - so the zone is shown on Settings, not
chosen there ("Да"), as the level-1 limit is.
Every mockup loses "UTC": the column heads are Time, Received, Held,
Arrived; a stamp is a date and a time. Settings gets box 05, Time zone,
read-only: the zone, its offset, where it is set, and that every time and
day of the panel is that zone's, as are mail.log's stamps. The help's
Settings topic says so. outlines.json: settings gains the box. check.py: 0
violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15. A global user sends
one message from postmaster@ the hostname to an address typed; it opens in
the Outbound log, which follows it (the commit before this; mockups
e92769f, e3b2501, 8f0ccf9). Reviewed for security, its low findings fixed.
go vet, go test (26 packages) and make e2e passed on the test server for
that tree. No migration, schema 12.
CHANGELOG [2.12.0]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 38; roadmap row. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decided by the owner on 2026-10-13 (out of preflight); the sender is his
choice of 2026-10-16 ("Да, адрес годится"); mockups e92769f, e3b2501,
8f0ccf9, the route guard 75f97c9.
Card 09 on Health, in the right stack's static end (the left stack is
polled whole): one recipient and Send. HandleTestMessage (global role)
takes one address (validate.AccountEmail - no list, nothing that reads as
an option), at most ten an hour counted from the send log, writes the
message's send-log row first (store.InsertTestMessage: queued, the
hostname as domain, no application, no queue id), awaits its Message-ID
(logtail.AwaitMessageID) and hands it to sendmail as notifications go
(notify.Mailer.SendWithID: postmaster@ the hostname, signed by the local
socket with the key of the server's own name). The browser lands on the
row; the tailer gives it its queue id from cleanup's message-id= line
(src/cleanup/cleanup_message.c:639) and from then on its delivery lines
its status. Postfix refusing the hand-over removes the row; sendmail out
of time keeps it (postdrop may have queued it). The sweep closes a row
whose id was never seen once it is no longer awaited
(CloseUnmatchedTestMessages); an id awaited past ten minutes is not taken.
Reviewed for security before the commit (2026-10-17): no high or medium;
its three low findings are fixed in this commit (the unmatched row's
close, the awaited id's age, the timeout keeping the row) and the
security.md bullet says how the queue id is matched.
Tests: TestTestMessage (refusals, the row and the page, Postfix refusing,
the hourly ten), TestTestMessageTimeoutKeepsTheRow, TestTestMessageRows,
TestCloseUnmatchedTestMessages, TestAwaitedMessageID,
TestRunGivesTheTestMessageItsQueueID, TestHealthTestMessageCard; e2e sends
one to the sink: the row is sent with a long queue id, the sink has it
signed d=mail.e2e.test. go vet + go test (26 packages) and make e2e on the
test server for this tree.
Docs: help (word for word with the mockup), guide § Overview and Health,
architecture § Log tailer, security.md, CHANGELOG [Unreleased] § Added,
roadmap row done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The topic was added to the Overview group without its entry in the help's
side menu, which lists every topic of the group in order. check.py: 0
violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16: "Делай все согласованные" - test-email among them,
its sender his choice of the same day ("Да, адрес годится"); the form is
Health's card 09 (mockups e92769f, e3b2501) and posts to a route under the
page it belongs to, as Re-check DNS and Reload do. The guard's route table
gets the one pattern. Nothing else under internal/web changes in this
commit; the router and the handler follow in the next.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Health's left stack is one polled element (health_stack, swapped whole
every refresh): a form there would lose the address being typed. Card 09
moves under Configuration, the right stack's static end, as Configuration
itself sits outside the poll for its button. On a phone the cards keep the
order of their numbers (data-order). outlines.json rewritten for health.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decided by the owner on 2026-10-13 (out of preflight), taken up on
2026-10-16; the sender is the owner's choice of 2026-10-16 ("Да, адрес
годится"): postmaster@ the hostname, as notifications. Server › Health
gets card 09, Test message: one recipient and Send. The message goes as
notifications go - through Postfix, signed with the key of the server's
own name - and opens in the Outbound log, whose page follows it to the
receiving server; ten an hour. The help gets a Test message topic: what
the test proves (the server's path, not a domain's) and what it does not
(the inbox). outlines.json: health gains the box, help one topic.
check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15. Server › Health checks
the certificate's name against the hostname, shows Postfix's greeting and
where the panel sees the viewer from (the commit before this; mockups
b2dcefc, cc33890, 0feaa55). go vet, go test (26 packages) and make e2e
passed on the test server for that tree. No migration, schema 12.
CHANGELOG [2.11.0]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 37; roadmap row. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decided by the owner on 2026-10-13 (in place of preflight), taken up on
2026-10-16; mockups b2dcefc, cc33890, 0feaa55 (his: the greeting on a row
of its own, the name check a tag beside Names).
- The certificate: health.Certificate keeps its names (DNS names, or the
CN without any) and ForHost holds it to SELFPOST_HOSTNAME as a TLS
client does - VerifyHostname over the DNS names, the CN only without
any, as OpenSSL's X509_check_host and so a sending Postfix still take it
(src/tls/tls_client.c:439, 513). A certificate for another name fails;
the tag beside Names says matches / not for the hostname.
- The greeting: postconf -x -h smtpd_banner, read once at the panel's
start like the served certificate's path (main.cf is not rewritten
after), is what smtpd writes after "220 " (src/smtpd/smtpd.c:5979). A
first word other than the hostname warns, with what to set. A fact may
now take the whole row (Fact.Wide, sp-wide) and carry a tag beside its
label (Fact.LabelTag); both on the kit page.
- The proxy: auth.Module.Seen says the address the panel counts sign-ins
against, the peer, and whether X-Forwarded-For was read or left unread
for an untrusted peer; the Configuration card shows "Your address" and,
in the second case, warns that every visitor shares one sign-in limit
and names the /32 (or /128) to put in TRUSTED_PROXY_CIDR.
Tests: TestCertificateForHost (CN-only, DNS names, a wildcard, the CN
ignored beside DNS names), TestSeenAddress, TestHealthSaysWhereItSeesTheViewer,
TestHealthGreetingAndCertificateName; e2e reads the stand's right state on
Health (matches, "220 mail.e2e.test ESMTP", a direct client). go vet + go
test (26 packages) and make e2e on the test server for this tree.
Docs: help (word for word with the mockup), guide § Overview and Health,
CHANGELOG [Unreleased] § Added, roadmap row done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16: "Может match ставить бэйджем в заголовок names?"
The certificate card loses its own "Matches the hostname" row: a tag
beside Names says it - "matches", or "not for the hostname" in red. A
fact's label may carry a tag (sp-facts dt .tag keeps the tag's own type),
shown on the kit page. Help says where the verdict is. check.py: 0
violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16, on the mockup: "Может Greeting на всю ширину
карточки?" The facts grid gets sp-wide, a fact over the whole row (shown
on the kit page), and the greeting uses it. The greeting itself is what
this image's Postfix writes: "220 mail.example.org ESMTP"
(build/postfix-config.sh sets smtpd_banner to "$myhostname ESMTP"),
not Debian's longer default. The Configuration card's address sits in a
block of its own above the text. check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Decided by the owner on 2026-10-13 (in place of preflight), taken up on
2026-10-16 ("Делай все согласованные"). Three lines in Health's cards,
no new card: TLS certificate - "Matches the hostname"; Hostname and
reverse DNS - "Greeting", Postfix's 220 line; Configuration - "Your
address", the address the panel sees the browser at and where it took it
from. The help says when each fails and what to do. check.py: 0
violations; outlines unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15. A POST with neither
Sec-Fetch-Site nor Origin is refused (the commit before this); the e2e
client sends Origin. go vet, go test (26 packages) and make e2e passed on
the test server for that tree. No migration, schema 12.
CHANGELOG [2.10.4]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 36; roadmap row. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agreed with the owner on 2026-10-13 (in place of csrf-tokens), taken up on
2026-10-16 ("Да"). originAllowed (internal/web/security.go) returns false
in the "neither header" branch: such a POST is answered 403 and logged
like a cross-origin one. It was let through until now as an accepted
risk, which left a browser that sends neither open to a request forged by
any site. A browser that follows Fetch § 3.2 never sends a POST without
Origin; one too old to (Firefox before 70) can no longer save anything.
What posts to the panel without a browser: the e2e suite's client, which
now sends Origin on every form (panelClient.postForm); cmd/panel's tests
already did. Nothing else posts to it (the CLI runs in the container).
Tests: security_test's case of no headers is refused; e2e posts with
neither header and gets 403 (testFormPostWithTheBrowsersOrigin). go vet +
go test (26 packages) and make e2e on the test server for this tree.
Docs: security.md - the accepted risk struck, the ADR's decision and
trade-off say refused, the origin-check bullet; architecture § Web,
guide § the proxy; CHANGELOG [Unreleased] § Security; roadmap row done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15. The full backup and a
domain's export ask for the signed-in user's account password under the
password change's limit; the import's file field is drawn as designed
(1888dce design, the code commit before this). go vet, go test (26
packages) and make e2e passed on the test server for that tree. No
migration, schema 12.
CHANGELOG [2.10.3]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 35; roadmap row. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agreed with the owner on 2026-10-13, taken up on 2026-10-16 ("Да"); the
mockup is 1888dce. Both secret downloads - the full backup on Server ›
Backup and a domain's export on its settings - ask for "Your account
password": reauth (handlers_backup.go) checks the signed-in user's own
password with bcrypt, as a password change does, under the same limiter
(auth.AllowPasswordChange: ten in fifteen minutes per user, shared with the
Account form). A wrong or missing one is 401 on the same page with "Your
account password is incorrect.", logged; nothing is sent. The import and
the selfpost-backup CLI are not asked.
Also, the owner's phone screenshot of 2026-10-16 ("кнопка выбора файла не
такая, как в макете. Это косяк?"): the import's file field was a bare
<input type="file"> drawn by the browser ("Browse…"), where the mockup has
Bulma's .file - "Choose a file…" and the name chosen. The template now
follows the mockup; panel.js writes the name into .file-name on change and
at load (testdata/filename_test.js, run by TestPanelJS). No other file
field exists.
Tests: TestSecretDownloadsAskForTheAccountPassword (missing, wrong, right,
the limiter), the export refusals of the settings page with the password,
cmd/panel's restore tests and e2e's backups with it, e2e's refusal of a
backup without and with a wrong password. go vet + go test (26 packages)
and make e2e on the test server for this tree.
Docs: security.md § Backup and domain export, guide § Full backup and
restore, help (word for word with the mockup); CHANGELOG [Unreleased];
roadmap row done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agreed with the owner on 2026-10-13 (roadmap backup-reauth), taken up on
2026-10-16 ("Да"). The Full backup form on Server › Backup and the Export
domain form on a domain's settings get one field each, "Your account
password" (autocomplete current-password, required), before the button,
saying why it is asked at every download. The help's Backup topic says both
ask for it and that wrong guesses count against the password change's
limit. check.py: 0 violations; outlines unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15, after his choice of
2026-10-16 ("Вариант 1 ... сворачивание разделов"). Below 1024 px the
navigation is a Menu whose sections fold, the current one open; the page
has a gutter (e0e30e2 design, the code commit before this). go vet, go test
(26 packages) and make e2e passed on the test server for that tree. No
migration, schema 12.
CHANGELOG [2.10.2]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 34; roadmap row. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16: "Вариант 1. Но меню выглядит длинной простынёй. Там
бы сделать сворачивание разделов." As the mockup of e0e30e2: the bar's
navbar-menu loses is-active, so Bulma hides it below 1024 px, and the new
touchnav partial (layout.html) takes its place in the brand - Menu, a
<details> whose panel holds Overview, each group as a fold (the current
one open, its page aria-current), and the user's group last with Account,
Help and Sign out (the same POST form as the bar's). The same Show* flags
as the bar decide what is there. panel.css takes the mockup's rules (the
touch navigation, a 1rem gutter for the page and the sibling strip below
1024 px). No script.
The cause, measured, is written in the roadmap row: every width under
1024 px had the always-open menu; "desktop site" (~980 px) added Bulma's
side-by-side columns from 769 px; Bulma's container has no side margin
below 1024 px.
Tests: the Menu marks the current group and page and the bar's menu has
no is-active (TestMenuHighlightsTheCurrentGroupEntryAndSibling), it offers
nothing the bar does not to a domain administrator (TestTouchMenuFollowsTheBar),
the shell's Sign out is counted twice on the password page;
TestPanelCSSIsTheMockupStylesheet. go vet + go test (26 packages) and make
e2e on the test server for this tree. CHANGELOG [Unreleased]; roadmap row
done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16, of two ways for the navigation of a narrow screen
(pictures at 390 and 980 px): "Вариант 1. Но меню выглядит длинной
простынёй. Там бы сделать сворачивание разделов."
The cause, measured: the bar's menu was always open (navbar-menu
is-active, no burger), and Bulma lays it out below 1024 px as a sheet of
every section's pages, filling the first screen - on every phone, and on
a phone in "desktop site" mode (about 980 px) under columns that Bulma
puts side by side from 769 px; and a container has no side margin below
1024 px, so the page met the screen's edge.
Now, below 1024 px: the bar's menu is hidden (no is-active), and a Menu at
the right of the brand opens a panel over the page - Overview, then each
section as a fold, the current one open, the user's last (Account, Help,
Sign out). <details> all through: no script, like the folded DNS records.
The page and the strip of sibling pages get a 1rem gutter. From 1024 px
nothing changes. check.py: 0 violations; outlines unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15, after his choice of
2026-10-16 ("1"): Postfix makes long queue ids (enable_long_queue_ids=yes),
so a later message can no longer meet an earlier one's rows. go vet, go test
(26 packages) and make e2e passed on the test server for that tree, long
ids on both an outbound and an inbound row. No migration, schema 12.
CHANGELOG [2.10.1]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 33. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16, of three ways to stop a reused queue id reaching an
older row (found by the technical review of inbound-upstream-result, L6):
"1" - enable_long_queue_ids=yes. A short id is the microsecond of arrival
and the queue file's inode in hex (Postfix 3.10.13, src/global/mail_queue.h,
MQID_SH_*) and repeats once the inode comes round; a later message with no
send-log row of its own (port-25 mail, a bounce Postfix sends) could then
rewrite an older Outbound log row of the same id and recipient, its page
could show the later message's stored lines (security.md's known exposure,
"enable_long_queue_ids ... ends it"), and the inbound journal's rows needed
detaching. A long id adds the time in seconds and does not repeat
(MQID_LG_*; proto/postconf.proto: existing queue files keep their names,
postqueue -p gets a wider Queue ID column). Every pattern of the panel
takes both shapes (queueLineRe's long branch, the [0-9A-Za-z]+ of the
others, queueEntryRe) - checked one by one.
Tests: e2e reads a long id on an outbound send-log row
(testExpiredMessageIsClosed) and on an inbound journal row
(testInboundUpstreamDown). go vet + go test (26 packages) and make e2e on
the test server for this tree.
Docs: security.md (the collision bullet closed for new mail, the upstream
bullet), architecture § Log tailer, delivery-log-storage's note, the
ForgetInboundQueueID comment; CHANGELOG [Unreleased] § Fixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut at the owner's word (2026-10-16): "потом режь" after the check, which
he allowed ("Отправляй").
Inbound › Log says what became of accepted mail - queued, held, sent,
deferred, bounced - with the upstream's last reply, the host and when
(5348330, 295d72e, 46b90d2 design and plan; d515493 code, the reviews'
findings fixed in it). Migration 0012, schema 12.
On the test server as 2.9.1-post.d515493 (the same code), 2026-10-16:
healthy, every program RUNNING, keys unchanged, schema 12. One message
sent from the server to its own port 25 for selfpost@dev.mixdep.ru: its
row sent, "250 2.0.0 Ok: queued as 04F0D1023EF" from mc-ent.mixfed.ru. (The
owner's own message had gone to mc-ent directly: SelfPost is
dev.mixdep.ru's second MX.)
CHANGELOG [2.10.0]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and row
32; roadmap row and recommended order; plan closed. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16: the Inbound log lacks the upstream's answer - "Не
понятна судьба письма: принято, отклонено, хост не доступен"; the result
in the row only, a page per message later if needed. Mockups 5348330 and
46b90d2, plan 295d72e.
- Migration 0012: inbound_spam_log gains queue_id, upstream_status ('',
queued, held, sent, deferred, bounced; CHECK), upstream_relay,
upstream_reply, upstream_at, and a partial index on queue_id. Schema 12.
- The inbound journal milter writes an accepted row with the queue id of
end-of-message and "queued" ("held" when the filter had it held), the
recipient as Postfix logs it (recipientAsLogged), after detaching older
rows of a queue id Postfix uses again; no queue id, no result.
- The tailer: a delivery line no send-log row took goes to the journal -
status, relay= ("" for none), the reply after the first status=, cut to
255 printable bytes, the line's time; queued/held/deferred rows only,
sent and bounced final. qmgr's expiry makes them bounced, keeping the
last reply. cleanup's "removed (" detaches rows of a message never
queued. Addresses in delivery lines are read by Postfix's quoting
(loggedAddress): a quoted RCPT TO cannot end one early.
- The page: an Upstream column - a plain tag for queued and held, else a
tag opening the host, the reply, when and what follows; "—" for refused
mail and older rows. Help (word for word with the mockup), guide,
architecture, security, schema-migrations, CHANGELOG.
Reviewed before the commit (technical and security, 2026-10-16); the plan
lists each finding and what it became.
Tests: store (results, finality, expiry, held, a reused id, the queue-id
index in every plan), milter (queue id, held, quoted recipient, no {i}),
logtail (relay and reply, a forged quoted recipient, Run hands the journal
what the send log did not take, cleanup's removal), view (the column, held,
null sender), e2e (both relayed messages sent with the sink's 250; an
upstream refusing connections deferred with the reason and no host, then
expired to bounced; the refused row has none). go vet + go test (26
packages) and make e2e on the test server for this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Findings of the reviews of the row (2026-10-16): a message the filter had
held is "held" in the Upstream column - in the queue, not tried until it is
released - and not "queued"; and a bounced message is said to have bounced
to its sender only when it had a return address (Postfix does not bounce
mail from the null sender). The help's Inbound log topic says both.
check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut without asking, as the owner said on 2026-10-15, for his report of
2026-10-16 (two scroll bars over a message's Delivery log on his phone).
Log panes wrap their lines under their start instead of scrolling
sideways (216f366 design, bd21ff0 code): the Delivery log of a message,
System log, the queue's listing. go vet, go test (26 packages) and make
e2e passed on the test server for that tree. No migration, schema 11.
The inbound-upstream-result mockup and plan (5348330, 295d72e) ride
along; its code follows for 2.10.0.
CHANGELOG [2.9.1]; pins in deploy/docker-compose.yml, README and the
guide (pin, build, the sample restore message); development's "from pin"
and row 31. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16, a phone screenshot of a message's Delivery log:
"На телефоне при просмотре лога, появляются две полосы. Мы это можем
поправить?" - the horizontal scroll bars Chrome on Android drew over a
pre whose mail.log lines were wider than it. As the mockup of 216f366:
sp-log wraps (pre-wrap, overflow-wrap: anywhere) and no longer scrolls;
log_pane writes each line as a block, sp-ll, with a 4ch hanging indent and
its newline inside it, so copied text keeps one line per line. One
partial: the Delivery log of a message, System log, the outbound queue's
listing.
Tests: TestLogPaneIsAPreWithEscapedLines reads the new shape; the page
tests' substrings stand; TestPanelCSSIsTheMockupStylesheet holds panel.css
to the mockup. go vet + go test (26 packages) and make e2e on the test
server for this tree. CHANGELOG [Unreleased] § Fixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16, a phone screenshot of an outbound message's Delivery
log: "На телефоне при просмотре лога, появляются две полосы. Мы это можем
поправить?" The two stripes are the horizontal scroll bars Chrome on
Android draws over a pre whose mail.log lines are wider than it
(overflow-x: auto); and what matters on a delivery line - status= and the
reply - was at its end, out of sight.
sp-log wraps (white-space: pre-wrap, overflow-wrap: anywhere) and no longer
scrolls; each line is a block, sp-ll, with a hanging indent of 4ch, so the
rest of a wrapped line stands under its start and a new line is told by its
time at the left. The line keeps its newline inside its block, so copied
text still has one line per line. Every pane of the kit: components,
system-log, out-message, in-quarantine-message. check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16: the Inbound log lacks the upstream's answer; the
result in the row only, a page per message later if needed. One stage,
2.10.0: migration 0012, the milter keeps the queue id, the tailer hands
port-25 delivery lines to the journal, an Upstream column (mockup 5348330).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16: "Нам не хватает в журнале для Inbound ответа
upstream. Не понятна судьба письма: принято, отклонено, хост не доступен и
пр." and, asked whether a result in the row or a page per message: "Да,
согласен. Пока только результат. Отдельная страница в план в случае
появления потребности."
Inbound › Log gets an Upstream column after Decision: what became of an
accepted message - queued (a plain tag: not tried yet), sent, deferred,
bounced - each but queued a tag that opens an "Upstream" sheet with the
host that answered (left out when Postfix had no session: relay=none,
src/smtp/smtp_trouble.c:273, 421 in Postfix 3.10.13), the last reply
(the "(...)" of the delivery line, src/global/log_adhoc.c) and when it
came, with what happens next for deferred and bounced. A refused message
has "—". The lead and the box foot say so; the help's Inbound log topic
replaces "Accept says what this server did, not what the upstream did"
with what each result means, and notes that rows logged before 2.10.0
have none. A new first row shows queued.
Outline unchanged (the box is still a table). check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut at the owner's word (2026-10-16, "потом режь 2.9.0").
dns-record-collapse (fffee9c, 51f303c): a record DNS holds as shown is one
line, its values behind Show values. settings-save-row (f95c553,
82a53e1): the Settings form is the left column, Save under its fields.
notification-mail-recheck (98e4dfc, 52a9811, 3f5cd9a): Re-check DNS on
Settings, POST /server/settings/dns-recheck. And the release after 2.8.0
removes /data/opendkim (9420d8c): the entrypoint deletes the 2.7.x key
tree once /data/dkim holds a key, and stops on a /data that never ran
2.8.x.
Each tree passed go vet, go test (26 packages) and make e2e on the test
server; dns-record-collapse and settings-save-row ran there as
2.8.0-post.51f303c and 2.8.0-post.82a53e1. No migration, schema 11.
CHANGELOG [2.9.0]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message; the 2.7.x rollback command keeps
naming 2.7.0); development's "from pin" and row 30; roadmap rows and
recommended order. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2.8.x copied the keys from /data/opendkim to /data/dkim and kept the old
tree for a rollback to 2.7.x "until the next release"
(docs/plans/panel-dkim-signing.md, Q2); the owner's "потом режь 2.9.0"
(2026-10-16) makes this that release. The entrypoint now removes the tree
once /data/dkim holds a key, and says so. A /data with keys under the
old tree only - one that never ran 2.8.x - stops the start with a message
to run 2.8.x first, rather than let the panel give the server's own name
a new key its published record would not match; the tree is left as it
was. Gone with it: the copy into /data/dkim.new, the tree's
normalisation, its exemption from the general hand-over, its key link in
drop_links, and the backup's exclusion of "opendkim" (dead: the panel
never sees the tree now).
Tests: e2e TestStartsOnA28Data replaces TestStartsOnA27Data - a /data as
2.8.x left it (both trees, a queue of foreign uids, a rotation due): the
old tree is gone, the key under /data/dkim is the one left, the selector
table names it, the queue is Postfix's by name; TestRefusesA27Data - the
start stops, says why, leaves the old key and makes no new one. go vet +
go test (26 packages) and make e2e (all pass) on the test server for this
tree.
Docs: guide § Upgrading "To 2.9.0" (rollback to 2.8.x needs nothing, to
2.7.x only from a 2.7.x backup; the 2.7.x rollback paragraph says it is
for 2.8.x only and keeps naming 2.7.0), guide § backup, architecture
(the old tree, § Persistence's table and the restore line), security
§ Backup, development's e2e paragraph, the plan's key step; CHANGELOG
[Unreleased] § Removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16: "Да, бери notification-mail-recheck". The records
of the server's own name can be checked again where they are shown: Re-
check DNS in Settings' page head (mockup 98e4dfc) posts to
POST /server/settings/dns-recheck (guard 52a9811), global role, which
runs Health's lookups - the server's address, then serverMail(true) - and
returns to /server/settings?rechecked=1, "DNS re-checked.". The
Notification mail box's end slot says when the records were checked
("The server's own name · checked 3 min ago", from the check's
CheckedAt; the old wording while nothing was looked up). A record that
has turned right comes back folded.
Tests: the page head's form and the end slot, and the Settings form
holding neither the re-check nor the right column
(TestSettingsRechecksTheMailRecords); the re-check skips the cache,
redirects and the page shows both records folded
(TestSettingsRecheckAsksForNotificationMailAgain); a domain
administrator gets 404 (globalOnlyRoutes). go vet + go test (26
packages) and make e2e on the test server for this tree.
Docs: architecture.md routes, the panel-redesign plan's route table,
guide § Settings, help (help.go word for word with the mockup's);
CHANGELOG [Unreleased]; roadmap row done. The roadmap's settings-save-row
note no longer claims the left-column form was needed for this - the
action went to the page head.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16: "Да, бери notification-mail-recheck, потом режь
2.9.0." The row's goal is a Re-check DNS action where the records of the
server's own name are shown (Server › Settings, mockup 98e4dfc); it
posts to a route of its own under the page it returns to, as a domain's
`.../dns-recheck` does. The guard's route table gets the one pattern.
Nothing else under internal/web changes in this commit; the router and
the handler follow in the next.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16: "Да, бери notification-mail-recheck, потом режь
2.9.0."
Server › Settings gets Re-check DNS in its page head, as an outbound
domain's page has it beside its own DNS records box; the Notification
mail box's end slot says when the records were checked ("The server's
own name · checked 3 min ago"), as the domain's DNS box does. The help's
Notifications topic says Settings shows whether the records are in place
and re-checks them. The button sits in the page head rather than in the
box head: box heads hold a note, a tag, a link or a filter form, and the
page head is where the domain page and Health put the same action.
outlines.json: settings "head" -> "head+actions". check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner chose Variant 2 on 2026-10-16 ("форма в левой колонке, кнопка
под своими полями"); the mockup is f95c553. Server › Settings: the form
wraps only the left column - Log retention, Notifications and Save
settings under them - and the right column, outside the form, holds
Notification mail and the read-only Sending rate limits. The boxes are
renumbered column by column (Notifications 02, Notification mail 03,
Sending rate limits 04). The fields, their names and the POST are
unchanged.
Tests: the page's outline matches the mockup's (TestPanelOutlinesMatchMockups);
go vet + go test (26 packages) and make e2e on the test server for this
tree.
CHANGELOG [Unreleased]; roadmap row done, its section quotes the choice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The owner, 2026-10-16, on the pictures of both layouts with the records
missing and right: "Вариант 2: форма в левой колонке, кнопка под своими
полями."
Server › Settings: the form holds only the left column - 01 Log
retention, 02 Notifications, Save settings under them - so the button
sits under the fields it saves whatever the records' state. The right
column, outside the form, holds what is not a field: 03 Notification
mail (the records of the server's own name) and 04 Sending rate limits
(read-only). The boxes are numbered column by column, as before.
outlines.json rewritten for settings (check.py --write): the form moved
from around the columns into the first one; nothing else changed.
check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dns_record partial draws a record whose tag is "published" and that
has no "In DNS now" (Record.Folded) as a <details> whose summary is the
head - name, tag, "Show values" - with the host, type and value behind
it, as the mockup of fffee9c. Every other record stays a <div>, open.
One partial, so the outbound domain's page, the inbound domain's and
Settings › Notification mail change together; panel.css takes the
mockup's rules, help.go the help's sentence word for word.
Re-check DNS posts and redirects to the page, which is drawn from the
fresh check: a record that has just turned right comes back folded. No
page that draws records polls them, so nothing has to keep a fold open.
Tests: the dns_record partial folds only a published record and closes
the block it opened (TestDNSRecordFoldsOnlyWhenPublished); the mismatch
test reads the folded head. go vet + go test (26 packages) and make e2e
on the test server for this tree.
CHANGELOG [Unreleased]; roadmap row done, its section says what was
decided.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A record DNS already holds as shown is a <details> whose summary is the
record's head: its name, its tag and "Show values" (ti-eye - the icon
subset has no chevron); the host, type and value open behind it. The
head's note is hidden while folded, so the fold stays one line in a half
column (the server's SPF on Settings); it shows when opened. Missing,
weak, mismatched and unchecked records stay open as before.
Mockups: the kit's dns_record example, out-domain (DKIM and DMARC
folded, SPF open on its mismatch), in-domain (MX), settings (DKIM
folded, SPF missing and open); the help's DNS topic says so. Outlines
unchanged (the block keeps sp-record). check.py: 0 violations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The issue, the PR text and a branch of go-milter (the map emptied on
SMFIC_MAIL, a test that fails on master) are in dev/upstream-pr.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cut at the owner's word (2026-10-16, "Выпускай").
The panel signs outgoing mail in a milter of its own (go-msgauth,
relaxed/simple, the login's key on 465/587 and the server's name's on
local mail); OpenDKIM, the syslog relay, the USR1 reload and the From:
parser port are gone. Keys move to /data/dkim (copied once; /data/opendkim
kept for a rollback, left out of backups). The sender check holds Sender:
like From:. go-milter is built patched not to keep every header. The queue
is handed to Postfix's users by name before "postfix check". Commits
005922b..ca3a183 (005922b, faa3c11, c2f63d2, f0dd9f9, 2cfd239, d9d9af3,
0cceb54, 80faf1a, f1d90a2, ca3a183).
On the test server since 2026-10-15 22:48 UTC (2.7.0-post.c2f63d2, now
2.7.0-post.f1d90a2, the same code as this cut): healthy, keys unchanged.
The first rollout (faa3c11) crash-looped five minutes on the queue's owner
numbers and was rolled back; fixed in c2f63d2. The owner's canary: Gmail
dkim/spf/dmarc pass with d=carrel.mixfed.ru, Yandex dkim/spf pass, not
spam; his From:/Sender: check, thirteen cases, all as expected.
CHANGELOG [2.8.0]; pins in deploy/docker-compose.yml, README and the guide
(the guide's rollback command keeps naming 2.7.0 on purpose); development's
"from pin"; roadmap row and recommended order; plan stage 3 closed. No tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agreed by the owner (2026-10-16): prepare an issue and a small compatible
PR for go-milter (the map emptied when a message starts), with an option
not to keep it proposed; the owner posts them. Then third_party/go-milter
can go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The security documentation's last open item of panel-dkim-signing (the
owner's decision, 2026-10-16): go-milter v0.4.1 added every header of a
session to a map of its own, emptied only on ABORT, and handed it to
Headers - which no milter of the panel reads. Postfix bounds one header,
not their number, so a message of nothing but headers up to the message
size limit was held whole by the library in the sender check, the signer
and the journal milter on 465/587 and in the inbound journal milter on port
25, where strangers send; the signer's 1 MiB refusal came after it was all
in.
third_party/go-milter is v0.4.1 from the module cache (BSD 2-Clause,
LICENSE kept) with session.go changed: CodeHeader hands the header to
Header and keeps nothing, CodeEOH hands Headers an empty map. PATCHES.md
says what and why, and how to take a new upstream version. go.mod replaces
the module with it; the Dockerfile copies third_party before "go mod
download". TestTheLibraryKeepsNoHeaders holds it through the library's own
client over two messages of one connection. Comments, security.md (the
accepted risk becomes a note), development.md's library table, the plan and
CHANGELOG follow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rollout of 0cceb54 to the test server (2026-10-16 04:44 UTC) logged
"postfix/postlog: fatal: the Postfix mail system is not running" and
"error running non-shared postrotate script": the day's rotation was due,
logrotate-loop.sh runs at once when the container starts, and the wrapper
starts Postfix only once the milter sockets are there. Nothing was lost -
no postlogd held the old file, and the one Postfix started a second later
opened the new - but logrotate reported a failure and the loop waited six
hours. The postrotate now runs "postfix reload" only when "postfix status"
says a master runs (Postfix 3.10.13, conf/postfix-script:297-307).
e2e TestStartsOnA27Data also starts on a mail.log whose rotation is due
(a state file of two days before) and wants it rotated with no postrotate
error in the container's log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The owner's decision (2026-10-16, security review of panel-dkim-signing,
F1): a Sender: line, shown beside From: as "on behalf of", is held to the
same rule when a message has one. The topic says how a system that writes
for people outside the server's domains does it instead (its own From:, the
person in Reply-To:), and the Outbound log topic names Sender: among the
refusals.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
architecture § OpenDKIM becomes § DKIM signing (keys, the selector table
and when it is rewritten, the two sockets, what is signed and the limits,
the old tree); the process table, start order, liveness, the mail-path
diagram, § Sender check, Persistence and the restore Resync follow the
code. security.md: the panel signs with the login's key, the keys are the
panel's alone, group selfpost and CAP_KILL as they are now; OpenDKIM's
accepted risks go; new ones: what sendmail(1) submits is signed as the
server, Sender: is signed but not judged (the owner's decision), and
go-milter keeps every header per session, so the signer's 1 MiB refusal
does not bound the library's copy. guide: Health, the container log's
dkim-signer lines, backups, the long-line limit, and upgrading to 2.8.0 -
with the rollback to 2.7.x: its start reads the queue as its own postfix
(uid 101) before it hands the queue over, so the hand-over by name is run
in the 2.7.x image first (measured on a copy of the test server's queue:
postsuper failed before, passed after). development, README, SECURITY,
product, the assumptions register's header: what left the image.
entrypoint.sh: a comment, the rollback note as the guide gives it. Plan
stage 3 lines ticked, roadmap 12/14.
Written by four agents, one document each, against a brief of what the
code does; reviewed here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
internal/milter/sign.go imports it since stage 1; go.mod still had it as
indirect, as the e2e suite's dependency.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>