Commit Graph
210 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 6b4106f60e upstream-tls-verify: Verified TLS to an inbound domain's upstream
Owner, 2026-10-18: "Оба предложения делаем"; the mockup is c10cd78. The
security audit's M7: Required is Postfix's encrypt level, which checks no
certificate - it keeps the mail from a listener, not from one who answers
in the upstream's place.

- A fourth TLS mode, Verified, stored as Postfix's level secure (migration
  0014 rebuilds inbound_transports with the wider CHECK; schema 14) and
  written to the TLS policy map like the others. Postfix matches the
  certificate against the next hop's name (smtp_tls_secure_cert_match =
  nexthop, dot-nexthop, the default; postconf(5)), so Verified is refused
  for an upstream given by address (checkSecureHost).
- Postfix's SMTP client trusts Debian's ca-certificates bundle
  (smtp_tls_CAfile): Postfix trusts none by default (DEF_SMTP_TLS_CA_FILE
  "", DEF_TLS_APPEND_DEF_CA 0). The levels may and encrypt only log the
  verdict, so mail to recipients' MX is unchanged.
- The page: the select's fourth option, a help line under it, the lead
  "over verified TLS", the list's tag "verified"; Help word for word with
  the mockup; guide § Inbound relay, with a posttls-finger command to try
  an upstream first.

Tests: TestVerifiedTLSNeedsTheUpstreamsName, TestRenderInboundMapsSecure,
TestUpstreamTLSModeSecure; schema tests at 14. e2e
inbound_upstream_verified_tls: an address refused, a name saved as
"[sink]:25 secure" in tls_policy, the page says verified, the CA file set
and present, and posttls-finger -l secure with it calls the stand's
self-signed port 25 "Untrusted" (the stand has no TLS upstream). make e2e
(65 passed) and go vet + go test (26 packages) on the test server for this
tree. Tried against the live upstream from the 2.14.5 container:
mc-ent.mixfed.ru gives "Verified TLS connection established".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:27:37 +03:00
mixandClaude Opus 5.5 554e007f22 server-dmarc-hint: the server's own name gets a DMARC record
Owner, 2026-10-18: "Оба предложения делаем"; the mockup is 8b2dc8f. The
security audit's S3 left a product side: the panel showed the DKIM and SPF
records of the server's own name, not a DMARC one, and without it a
receiver does not refuse mail forged in that name - a false password-reset
message among it.

dnscheck.ServerMail checks a third record (Mail.DMARC, checkServerDMARC):
the sending-domain DMARC check held to more - missing is an error, as a
missing DKIM or SPF record of this name is (the page draws it as a
warning: notifications still go), p=none a warning ("weak": it refuses
nothing), quarantine or reject ok - and Overall includes it. The record to
publish is ServerMailDMARC, v=DMARC1; p=reject: only the panel sends as
the name, signed with its key, from the address its SPF names, so reject
costs nothing. Settings › Notification mail draws it after SPF
(view.ServerMailDMARC, folded when published), Health's card has the row,
Re-check DNS re-reads it with the others.

Tests: TestServerMailDMARC (missing, p=none, quarantine, reject); the
Settings and Health tests publish and expect three records. Help word for
word with the mockup; guide § Notifications; roadmap; CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:05:39 +03:00
mixandClaude Opus 5.5 c736a7e144 release: 2.14.5: ETRN discarded silently
test / test (push) Canceled after 0s
No migration; schema 13. The pins (compose, README, guide), the guide's
backup example and the planned cuts. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:01:53 +03:00
mixandClaude Opus 5.5 8eec6728d4 postfix: ETRN discarded from EHLO silently
After the 2.14.4 rollout the test server's mail.log held "discarding EHLO
keywords: ETRN" for every connection on every port: smtpd logs a discard
unless the mask has EHLO_MASK_SILENT (Postfix 3.10.13, src/smtpd/smtpd.c:2016;
src/global/ehlo_mask.c:88 "SILENT-DISCARD"; postconf(5)
smtpd_discard_ehlo_keywords, "Specify the silent-discard pseudo keyword to
prevent this action from being logged"). smtpd_discard_ehlo_keywords is now
silent-discard,etrn; ETRN is still refused (smtpd_etrn_restrictions=reject)
and still not offered.

make e2e (64 passed; checkPostfixHardening reads the value back,
testInboundRelay still sees no ETRN in EHLO and 554) and go vet + go test
(26 packages) on the test server for this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:01:41 +03:00
mixandClaude Opus 5.5 58356fb4d4 release: 2.14.4: the follow-ups of the security audit of 2026-10-17
test / test (push) Canceled after 0s
Stages 0-6 of docs/security-audit-2026-10-17.md in one cut: the high A2
(the user form asks for the account password), the seven mediums (M1, M2,
M3, W1, I1, I2, A1) and the lows and nits the plan names, with three
read-only security reviews of the fixes and their findings fixed. Stage H
(the test server's host) is the owner's.

No migration; schema 13. Upgrading: give the TLS key to group 10997;
copy antispam/ again where the sidecar runs (rspamd 4.2.2, a third file);
set TRUSTED_PROXY_CIDR behind Apache on the host if unset.

make e2e (64 passed) and go vet + go test (26 packages) on the test server
for 32f6ed6, the tree this release commit adds only pins and notes to. The
pins (compose, README, guide), the guide's backup example, the planned
cuts, the roadmap row and the plan's stages marked shipped. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 23:12:55 +03:00
mixandClaude Opus 5.5 ecfe23504e antispam: rspamd 4.2.2-0, no capabilities, only the milter on the network (audit S4)
Security audit 2026-10-17, finding S4 (low), stage 4. The sidecar ran
rspamd 3.11.1 (December 2024) with default capabilities; upstream is 4.2.2
(2026-10-10), and the line between fixed a heap overflow in
util.lower_utf8, a use-after-free on reload and parser crashes - rspamd
reads every byte a stranger sends to port 25.

A read-only reading of rspamd 3.11.1 and 4.2.2 and of rspamd-docker
v3.11.1+3 and v4.2.2+0, side by side (sources in dev/upstream), found every
function SelfPost relies on unchanged: milter_headers (skip_wanted, the
custom routine, remove 0, create_specific_keyed, get_queue_id from {i}),
the X-Spamd-Result text, multimap (from/smtp, ip, the reject pre-result,
file-map rereading), the image's uid 11333 and CMD. It also found that the
image binds the normal worker (which takes client-named file and shm
inputs) and the controller (default password "q1") on every address,
reachable from any container on the compose network - so:

- image rspamd/rspamd:4.2.2-0 (the one build of 4.2.2; the others float);
- cap_drop: [ALL], no-new-privileges (rspamd starts unprivileged, ports
  above 1024, nothing setuid);
- RSPAMD_LOCAL_ADDR=localhost keeps every worker on loopback, and a new
  antispam/rspamd/worker-proxy.inc puts the proxy alone on *:11332. rspamd
  expands its template syntax in comments too - the first version of that
  comment quoted it and the config failed to load; it quotes none now.

Checked in a throwaway container on the test server with the three files
and the fragment's options (dev/server/rspamd-try.sh): proxy on any
address, 11333/11334 on loopback; from a private address a sender's own
verdict headers removed and rspamd's added; X-SelfPost-Filter equal to
openssl's HMAC-SHA256 of the queue id; a denied address refused with the
deny list's message; healthy. The e2e has no rspamd; the live sidecar is
checked at the rollout. Citations re-pointed to 4.2.2.

Docs: guide § Inbound relay (the third file; copy antispam/ again);
CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:48:31 +03:00
mixandClaude Opus 5.5 4f999f4f95 auth, docs: a session from a longer window is cut; the remaining nits (audit A6, A7, A10, W7)
Security audit 2026-10-17, stage 6.

A10: a session created under a longer PANEL_SESSION_IDLE_DAYS kept its old
expiry after the setting was lowered (the renewal arithmetic took
ExpiresAt minus the new window for the last renewal). Touch now cuts such
a session to now plus the window at once. TestSessionMadeUnderALongerWindowIsCut.

A6, A7, W7 - security.md now says what the code does, which is what it
should do: a wrong method answers 405 with Allow from the router before the
role is looked at (the routes are public source); the setup token is made
anew at the first /setup/ request after expiry, a stranger's included, and
logged where only the operator reads it; two GETs make what must exist,
the server's own DKIM key on Settings and Health. README's local trial
says the compose file is the hardened deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:38:52 +03:00
mixandClaude Opus 5.5 5c5f49bad1 signer: MIME headers and Message-ID signed; a long header line logged (audit M6, M8)
Security audit 2026-10-17, M6 and M8 (nits), stage 6.

M6: Content-Type, MIME-Version and Message-ID were not in h= (as in
OpenDKIM's default list): a relay could change a MIME boundary or the type
and the signature still verified. They are signed when present; Message-ID
is not over-signed, since cleanup adds one a message lacks after the
milters and an absent name in h= would refuse it.

M8: a header line over smtp_line_length_limit (998) is broken by Postfix's
smtp client after signing and the signature fails silently; only body
lines were logged. longHeaderLine names the header, once per message, in
the same words.

Tests: TestSignLinesCoverTheMIMEHeadersAndLongHeaderLinesAreNamed; the h=
expectations gain Message-ID; e2e dkim_shapes expects content-type: in h=
(and the receiver's verification over it). Docs: security.md,
architecture.md, guide; CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:37:14 +03:00
mixandClaude Opus 5.5 820b1bb968 docs: the upstream's Required TLS checks no certificate (audit M7)
Security audit 2026-10-17, finding M7 (low), stage 6; Help's mockup is
c09368c. postfix/inbound.go writes "encrypt" for Required, and Postfix
verifies no certificate at that level: a domain user who chooses it gets
privacy against passive observers only. The guide § Inbound relay and Help
(word for word with the mockup) say so. Offering "secure" with the
upstream's name is a choice for domain users and hours of work: it goes on
the roadmap as a proposal, not into this plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:34:29 +03:00
mixandClaude Opus 5.5 68493dc097 ci: actions by commit, govulncheck, images pinned, base packages watched (audit I4, I5)
Security audit 2026-10-17, findings I4 and I5 (low), stage 5.

I4: every action is pinned by commit with its version in a comment - the
latest release of the major each used, so nothing changes in behaviour
(checkout v4.4.0, setup-go v5.6.0, setup-buildx-action v3.12.0,
build-push-action v6.19.2, login-action v3.7.0; commits read with git
ls-remote, the tags are lightweight). govulncheck v1.9.0 runs on the
source in test.yml, and in release.yml on the image's panel and
selfpost-backup binaries before the push (-mode=binary: the base image's
Go, which may lag the runner's - a fixed stdlib advisory then fails the
release until golang:1.26-trixie moves, which is the point). certbot is
pinned to v5.8.0 and Caddy to 2.11.7 (Docker Hub, 2026-10-17).
provenance/SBOM stay off until the GHCR cleanup keeps attestations, as the
plan says.

I5: published tags are never rebuilt, so a Debian security fix reaches
operators with the next release. The guide (§ Upgrading) and SECURITY.md
say so; base-packages.yml runs .github/scripts/base-packages.sh every
Monday: what debian:trixie-slim would install for postfix, libsasl2-2,
libsasl2-modules-db, sasl2-bin, openssl, libssl3t64, libdb5.3t64 against
the table in development.md (the 2.14.3 image's versions), red when one
moved. Tried on the test server: green as it stands, red with a version
changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:33:02 +03:00
mixandClaude Opus 5.5 bde6a4000e import: bounds on a domain export (audit W4, W5, W8)
Security audit 2026-10-17, findings W4/W5/W8 (low), stage 4 - all
global-only and self-inflicted, but unbounded:
- W4: ImportKey took any RSA key; a 16 384-bit key makes every signature
  of the domain cost seconds. Now 1024-4096 bits (MinImportKeyBits,
  MaxImportKeyBits); under 1024 Go's parser already refuses.
- W5: an export's applications were unbounded, each a copy of sasldb2 and
  a saslpasswd2 run on the panel's one connection. Now at most 500
  (maxImportApplications).
- W8: an import named as the hostname silently replaced the server's own
  signing key while DNS still held the old record. Now refused with a
  sentence.

Tests: TestImportKeyRefusesAnOversizedKey (4104 bits); the import cases.
Docs: guide § Moving a domain; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:27:21 +03:00
mixandClaude Opus 5.5 668e299c69 postfix: no reload for a map that did not change; DNS re-checks limited (audit W6/M9)
Security audit 2026-10-17, findings W6/M9 (low), stage 4. Every
application, upstream, recipient or report-address save reloaded Postfix
through supervisord, and POST .../dns-recheck bypassed the five-minute
cache for up to ~14 resolver queries - both at whatever rate an
authenticated user pressed.

- writeChanged: each generated map is written only when its content
  differs from the file on disk, and Postfix is reloaded only when one was
  (sender map, inbound maps, DMARC maps). Health's Reload configuration
  ends with an explicit reload (app.Service.ReloadPostfix, SenderMaps
  gains Reload), so it still re-reads whatever is on disk. No debounce:
  with unchanged saves gone, a burst is real changes Postfix must see.
- auth.AllowDNSRecheck: twenty forced re-checks in 15 minutes per user;
  past that the outbound, inbound and settings re-check show the cached
  result without the "re-checked" flash, and the log says why.

Tests: TestARebuildThatChangesNothingDoesNotReload,
TestDNSRechecksArePerUser; the fakes gain Reload. Docs: guide § Overview
and Health; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:25:20 +03:00
mixandClaude Opus 5.5 dd950364a3 deploy: a process ceiling on every container (audit I3)
Security audit 2026-10-17, finding I3 (low), stage 4. No compose file set
pids_limit: a fork bomb from a compromised panel or postfix had the host's
pid space as its limit. Now 1024 for SelfPost - the plan said 512, but
Postfix starts up to default_process_limit (100) processes per service,
and a busy server runs three smtpd services, cleanup and smtp at once -
256 for nginx, Caddy, Traefik and rspamd, 64 for certbot. The comment that
said root runs "very briefly" now says which root processes stay.

e2e: checkPidsLimit reads /sys/fs/cgroup/pids.max in the container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:19:36 +03:00
mixandClaude Opus 5.5 a327962008 web: /healthz is answered only to local probes (audit I6)
Security audit 2026-10-17, finding I6 (low), stage 3. /healthz is
registered outside auth and every proxy fragment passed it through: the
live server answered "ok" to the internet, and "unhealthy: postfix"
would have been a free tell.

Now handleHealth answers only a request with no X-Forwarded-For (nor
Forwarded) from loopback or a private address: the image's HEALTHCHECK
(127.0.0.1), the host through the loopback-published port (the Docker
gateway), a monitor on the Docker network. Every supported proxy appends
X-Forwarded-For, so through it the endpoint is 404. The plan named
"trusted-proxy peers"; that would have let the internet in again through
the trusted proxy, hence this shape. The e2e's readiness probe is the
host's, and stays answered.

Test: TestHealthzAnswersOnlyLocalProbes. Docs: guide § Container health,
architecture.md; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:17:27 +03:00
mixandClaude Opus 5.5 8aad5b6223 auth: reset links pause for a day after five (audit A3)
Security audit 2026-10-17, finding A3 (low), stage 3. sendReset spaced
reset mails ten minutes apart and capped them at five an hour per account,
so a stranger who repeated a username kept a live 30-minute link arriving
in the administrator's mailbox five times an hour, for ever.

Now five reset links a day per account (resetDayLimiter); the sixth
request sends one notice instead - "Reset links paused for a day", no
link, saying further requests are ignored for 24 hours and that a global
administrator can set a password on Server > Users - and every later one
in the window sends nothing (resetNoticeLimiter), logged either way. In
memory, like every limiter: a restart clears them.

Test: TestResetLinksPauseForADayAfterFive. Docs: security.md, guide §
Account; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:15:50 +03:00
mixandClaude Opus 5.5 3a8e19b71c auth: sign-in limits per address and name; successes free (audit A1, A8, A9)
Security audit 2026-10-17, findings A1 (medium), A8, A9 (low), stage 3.
Under the shipped Apache-on-host deployment with TRUSTED_PROXY_CIDR empty,
every visitor has the Docker gateway's address, and every public limit was
keyed on the address alone: ten POSTs to /login per 15 minutes (any body,
successes counted) locked out every administrator; likewise /login/reset,
the link pages and /setup/. IPv6 was keyed per /128.

Now (internal/web/auth/limitkeys.go):
- sign-in counts per address and username (10 / 15 min) and per address
  alone at 100; a right password refunds both (rateLimiter.Refund);
- a reset request counts per address and username (5) and per address
  alone (50); a link's page per link, by token hash (30), and per address
  alone (300); setup per address as before;
- an IPv6 address is keyed by its /64 (limitAddr);
- proxyWatch: when the first 20 requests after a start all come from one
  private or loopback peer with X-Forwarded-For and no proxy is trusted,
  the log says so once with the TRUSTED_PROXY_CIDR value to set;
- a refused sign-in or a lockout is logged, five lines per address in 15
  minutes, the typed name printable and cut (refusalLog, logName).
The shipped compose cannot know the gateway, so the guide's install step
prints it (docker network inspect) and says to set it.

Tests: TestLoginBehindOneUntrustedProxyKeepsOthersIn (the reader's case),
TestLoginSuccessesDoNotCount, TestIPv6IsLimitedPerSlash64,
TestProxyWatchAndRefusalsAreLogged; the existing lockout tests pass.

Docs: security.md § First-run setup, § Authentication; guide § Full
deployment; CHANGELOG; plan. Also the I1 plan line's Done note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:10:47 +03:00
mixandClaude Opus 5.5 1bafd78364 image: the TLS key's group is the panel's alone, 10997 (audit I1)
Security audit 2026-10-17, finding I1, stage 2; the help mockup is ff3a118.
The guide had operators give the TLS private key to group 10998 so the
panel can archive it, and the Dockerfile puts postfix in that group (for
sasldb2 and the maps): `docker exec -u postfix head privkey.pem` printed
the live key. postfix-config.sh copies the key to a root-only directory
precisely so that a compromised Postfix daemon cannot read it - the mounted
original undid that.

Now a group backupkey (gid 10997, outside the system range like 10998)
whose one member is the panel; the guide (Apache/certbot, nginx, Taking a
full backup, the backup table), Help, and the backup's refusal name 10997
and say why; postfix-config.sh, extract-cert.sh and the Dockerfile's
comments follow. A key still given to 10998 keeps working for the backup.
CHANGELOG has an Upgrading note.

e2e: the stage gives its key to 10997 at 0640 when the runner is root
(stageKeyMode; 0644 otherwise), and testFullBackupRestore, after the
refusal that now names chgrp 10997, checks that the postfix uid cannot read
/etc/postfix/tls/privkey.pem and the panel can.

Docs: security.md § Output and process (the groups); guide; CHANGELOG;
plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:03:10 +03:00
mixandClaude Opus 5.5 e9d2cbbe28 findings of the security review of stage 0
The review of 2c27275..2c8e981 (read-only, a separate agent) found no high
or medium; three small ones, fixed here:

- reauth took a limiter attempt before the compare, right password
  included: with A2 asking on every user save, ten saves in fifteen
  minutes locked the administrator out of Account's password change, the
  backup and the export. A right password now gives its attempt back
  (rateLimiter.Refund, Module.PasswordCheckPassed; a bucket already at its
  limit stays blocked). TestRightAccountPasswordsDoNotUseUpTheLimit.
- LoggedPath hid the tokens of /login/reset/ and /confirm/ but not of the
  first administrator's /setup/<token>; now it does (test case added).
- The entrypoint's own `[ -e ] || : >` lines (sender_login_maps, the four
  inbound maps, mail.log) get the `[ -L ]` guard postfix-config.sh got for
  I2. Safe before too - drop_links runs first on those paths and the panel
  is not running yet - this is the belt and braces.

The review also confirmed, with a probe of the regex against Postfix's
formats, that a port-25 client can place only its own envelope in an
Inbound log row, and that every path to a password, e-mail or role asks
for the signed-in password.

go test ./internal/web/handlers ./internal/web/auth; sh -n entrypoint.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:58:38 +03:00
mixandClaude Opus 5.5 fd29468e00 app: the sender map's rebuild is serialised (audit W3)
Security audit 2026-10-17, finding W3 (low), stage 1. Resync read the
bindings and wrote the map with no lock (the selector table has tableMu);
two application changes at once could write in the order opposite to their
reads and leave the map stale - an application whose binding was missing
refused with reject_sender_login_mismatch until the next change. Now a
mutex on the Service holds the read, the write and the reload together.

Test: TestResyncRunsOneAtATime (eight at once, never two inside the
rebuild).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:55:07 +03:00
mixandClaude Opus 5.5 b81ff9f135 domains: a domain's DMARC reports are deleted with it (audit A5/W2)
Security audit 2026-10-17, finding A5/W2 (low), stage 1; the mockup is
7effc98. dmarc_reports belong to a domain by name, outlive the domain (no
FK), and a domain deleted and added again handed its old reports to
whoever it was assigned to then - the logs were moved to ids in 2.5.0 for
exactly this case, reports were not.

The cheap form of the plan: store.DeleteDomain deletes the domain's
reports (their records by the table's cascade) in the same transaction as
the domain. The confirmation page says so when the domain has reports
("delete the 12 DMARC reports received for it;"), counted by the new
store.CountDMARCReports; NewOutDomainDelete takes the count.

Tests: TestDeletingADomainDeletesItsReports (the domain added again has
none, another domain keeps its report and record, a second delete is
ErrDomainNotFound); TestOutDomainDeleteNamesWhatGoesWithIt with 12, 1 and
0 reports.

Docs: security.md § Roles; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:52:06 +03:00
mixandClaude Opus 5.5 626bd337be dmarc: no custom report address on the server's own name (audit W1)
Security audit 2026-10-17, finding W1, stage 1. HandleDomainDMARC took any
address validate.Email takes; AllowedRecipients opened every stored address
on the hostname, not only the domain's hosted one; checkReportDomain
cross-checked the claimed domain only against a dmarc-reports+<tag> local
part. So a domain user of tenant A could store ops@<hostname> and from then
on anyone could mail a report claiming tenant B's domain to it, stored and
shown under B and counted toward B's 500; or store B's hosted address and
open B's mailbox without B choosing hosted. TestAllowedRecipients asserted
ops@<host> was allowed - a choice, now reversed.

Now: a custom address on the hostname is refused on the form ("choose
SelfPost hosted"); AllowedRecipients adds a domain's address only when it
is that domain's own hosted address; checkReportDomain requires a tag and
the claimed domain to equal it. The live registry (checked 2026-10-17)
holds two hosted addresses and no custom one on the hostname, so nothing
there changes.

Tests: TestCustomReportAddressOnTheHostnameIsRefused (typed, another
domain's hosted, the domain's own hosted typed as custom; an address
elsewhere still saves); TestAllowedRecipientsFollowTheDomainAddresses
inverted plus another domain's hosted address stored; untagged addresses
rejected in TestIngestMessageRejectsTagMismatch; the ingest tests deliver
to tagged addresses.

Docs: security.md § Accepted risks (DMARC), guide § DMARC reports;
CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:47:34 +03:00
mixandClaude Opus 5.5 c50314fb4c inbound journal: one row per recipient, and every row counts to the cap (audit M3)
Security audit 2026-10-17, finding M3, stage 1. inboundSession.RcptTo
appended every RCPT (the outbound session de-duplicates), and the end of
the message inserted a row per recipient one statement at a time inside
Postfix's callback; the cut to 10 000 rows every 500 counted only the log
tailer's rows. Postfix accepts a repeated recipient up to
smtpd_recipient_limit (1 000) and sends each to the milter, so one message
was a thousand rows, uncut until the six-hourly prune.

Now: the inbound milter folds a repeated recipient (case-insensitive, as
cleanup's been_here() with BH_FLAG_FOLD does, and as session.RcptTo does);
a message's rows go in one transaction (store.InsertInboundSpamMessage,
which replaces InsertInboundSpam in milter.InboundStore); the count and the
cut moved from the tailer into the store (countInboundWrites, an atomic
counter on Store) so the milter's rows, the tailer's and the tailer's
start-up re-reads all count. store.InboundSpamLogMaxRows is the one cap.

Tests: TestInboundRecipientRepeatedIsOneRow (1 000 RCPT in two cases, one
row); TestInboundJournalIsCutWhicheverWriterWrites (a small cap: a message's
rows and refusals together cross the mark, the table is cut); the tailer's
own counting test went with its counter. go test ./internal/store
./internal/milter ./internal/logtail (Windows-only Follow failures aside).
Also gofmt of a test line from the M2 commit.

Docs: security.md § Mail path, architecture.md; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:44:28 +03:00
mixandClaude Opus 5.5 d254a9952d milter: the level-2 domain limit is the login's domain's (audit M1)
Security audit 2026-10-17, finding M1, stage 1. overLimit took
domainOf(MAIL FROM) whatever the login, and MailFrom ran it with an empty
login: with smtpd_delay_reject at its default Postfix runs no sender
restriction at MAIL and sends the milter's MAIL event at once, so a
stranger on 465/587 with no AUTH, or another tenant's application, could
name a victim domain in MAIL FROM and take its slots for the 10-minute
in-flight TTL, get the victim's application answered 451, and have a limit
row (and the "a rate limit is refusing mail" notification) written under
the victim's domain.

Now overLimit returns at once without a login and takes the domain from
ApplicationByLogin (loginDomain, fail-open like the rest); recordRejected's
Domain is the login's too. The send log already counts a domain's messages
by the login's domain (store.CountMessages, loginDomainID), so the limit
and the count now agree. The null-sender special case is gone: every
message is held to its login's domain.

Tests: TestRateLimitIsNotReservedByAnotherSendersMailFrom (a stranger with
no login; another tenant's application) - fails on the old code at the
second MAIL FROM; the domain-limit tests sign in as app1 at example.com.
e2e domain_limit_not_reserved_by_a_stranger: a domain with a limit of one,
three unauthenticated MAIL FROM naming it on 465, then its own message
passes and its second is refused 451.

Docs: security.md § Mail path; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:41:06 +03:00
mixandClaude Opus 5.5 2c8e981f89 docs: SECURITY.md supports the 2.x line (audit X1)
Security audit 2026-10-17, finding X1, stage 0: the supported-versions
table named 1.x while the product is at 2.14 - an operator would have
read 2.x as unsupported. Latest 2.x yes, earlier 2.x and 1.x no.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:37:08 +03:00
mixandClaude Opus 5.5 8820294472 postfix: ETRN refused and not offered (audit S6)
Security audit 2026-10-17, finding S6 (low), stage 0 - proven from
outside: `ETRN <an inbound domain>` on port 25 answered "250 Queuing
started". smtpd_etrn_restrictions was empty (Postfix allows every client)
and fast_flush_domains = $relay_domains, so a stranger could flush the
deferred queue toward an inbound domain's upstream at will. Now
smtpd_etrn_restrictions=reject (etrn_cmd -> smtpd_check_etrn -> reject_code
554, Postfix 3.10.13 src/smtpd/smtpd.c) and smtpd_discard_ehlo_keywords=etrn.

e2e: testInboundRelay sends EHLO + ETRN for the inbound domain on port 25
and expects 554 and no ETRN in the EHLO reply (etrnPort25);
checkPostfixHardening reads both settings back. To be run on the server.

Docs: architecture.md § Postfix; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:36:55 +03:00
mixandClaude Opus 5.5 5210cb19f2 auth: the duplicate-cookie line hides a link's token (audit A4)
Security audit 2026-10-17, finding A4 (low), stage 0. The line logged when
a request carries two unprefixed session cookies printed r.URL.Path raw,
so GET /confirm/<token> wrote the token to the log (reachable only with
PANEL_COOKIE_SECURE=false, where a sibling host can plant the bare name).
loggedPath moves from internal/web to internal/web/auth as LoggedPath
(web imports auth, not the other way) and both lines - the origin check's
refusal and the duplicate-cookie one - go through it.

Tests: TestDuplicateCookieLineHidesALinksToken; TestLoggedPathHidesTokens
moved with the function. go test ./internal/web ./internal/web/auth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:35:32 +03:00
mixandClaude Opus 5.5 da2e8b2ffe milter: the sender check keeps two From: and two Sender: values (audit M5)
Security audit 2026-10-17, finding M5 (low), stage 0. senderSession.Header
appended every From:/Sender: value; Postfix bounds one header at 60 000
bytes but not their number, and the signer's 1 MiB cap runs after the
sender check, so an application could make it hold megabytes per
connection (the reader's test: a hundred lines, 6 MB). judge needs only
none / one / more, so two of each are kept; the refusal text joins what is
kept, as before.

Tests: "a hundred From: lines" in the judgement table (refused, more than
one From: line); TestSenderKeepsTwoFromAndSenderLines. go test
./internal/milter.

Docs: security.md (the sentence "a few headers each"); CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:33:53 +03:00
mixandClaude Opus 5.5 683fdafc6d logtail: Inbound log takes Postfix's own tail, not a recipient's (audit M2)
Security audit 2026-10-17, finding M2, stage 0 - proven on the live server:
Postfix quotes a refused recipient in its reply in internal form (quotes
removed, "; < > =" kept: extract_addr, tok822_internalize), so
RCPT TO:<"; from=<ceo@…> to=<board@…> proto=ESMTP helo=<x>; "@domain>
spelled a whole tail inside the reply, and inboundRejectRe, whose reply
ended at the FIRST "; from=<", took the stranger's sender, recipient and
inbound domain for the row.

Now the reply is greedy and the tail anchored: "; from=<…> to=<…>
proto=\S+", a HELO name holding none of Postfix's NEUTER_CHARACTERS
(smtpd.c:1634, helo_cmd neuters ` <>()\";@`), and one-word SASL fields to
the end of the line (a `.*` SASL tail would have let a quoted MAIL FROM
spell the whole tail). Belt and braces: a reply that still holds
"; from=<" - a line with two tails - makes no row, so neither a recipient
nor an un-neutered HELO name can choose one. The quote/bracket/space
filter on from/to stays.

Tests: TestInboundRecipientCannotWriteTheRow (the live line's shape, and
with a forged MAIL FROM too); TestInboundHeloCannotWriteTheRow now feeds
HELO names as Postfix logs them (neutered) and expects no row for the
un-neutered ones. go test ./internal/logtail on Windows (the three known
Windows-only Follow failures aside).

Docs: security.md § Mail path; CHANGELOG; plan line ticked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:33:12 +03:00
mixandClaude Opus 5.5 f2ef705470 entrypoint: no link followed at the DMARC maps (audit I2)
Security audit 2026-10-17, finding I2 (= M4), stage 0. drop_links listed
the outbound maps, sasldb2, the key tree and mail.log but not the three
DMARC maps that postfix-config.sh creates as root with `[ -e ] || : >`
when DMARC_REPORTS_ENABLE=true; -e is false for a dangling link and the
redirect follows it, and /data/postfix is the panel's. Now the three are in
drop_links, and both map loops refuse to create through a link
(`[ -e ] || [ -L ] || : >`).

e2e: TestStartsOnA28Data plants a dangling /data/postfix/dmarc_transport
with DMARC reports on and expects the "removing the symbolic link" line,
no file at the link's target, and a regular map. To be run on the server
with the stage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:03 +03:00
mixandClaude Opus 5.5 a37aba6594 user-reauth: the user form asks for your account password (audit A2)
Security audit 2026-10-17, finding A2 (high), stage 0; the mockup is
2c27275. A stolen global session could set its own password on the user
form - no current password asked, the session kept - and then pass the
backup's reauth with it, or add a second global user with a known
password. Now the user form calls reauth (the backup's check: bcrypt
against the signed-in user's own hash, under auth.AllowPasswordChange) when
it adds a user and when a save sets a password, changes the e-mail (reset
links go there) or changes the role, the own account included; deleting a
user asks too, refused on its own confirmation page (renderUserDelete, a
Flash on UserDelete). Name and reach alone are not asked. Missing or wrong:
401 on the same page, "Your account password is incorrect.", nothing saved.

Tests: TestUserFormAsksForTheAccountPassword (create, each of password,
e-mail, role, own password, reach alone, delete); the user-form tests send
the field (withAccountPassword gives root a real hash and h an auth
module). go vet, go test ./internal/web/... on Windows.

Docs: security.md § Backup and domain export, guide § Users, help word for
word with the mockup; CHANGELOG [Unreleased] Security; plan line ticked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:28:18 +03:00
mixandClaude Opus 5.5 22bfcdb5e4 release: 2.14.3: label-tag - a tag beside a label
test / test (push) Canceled after 0s
No migration; schema 13. The pins (README, compose, guide), the guide's
backup example and the planned cuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:02:52 +03:00
mixandClaude Opus 5.5 25813316b3 label-tag: a tag beside a label takes the label's line height
The owner's phone, 2026-10-17: "Маленький зазор над полем почты"; design
the commit before this one. panel.css takes the two rules theme.css gained
(TestPanelCSSIsTheMockupStylesheet holds them equal): a tag in a field's
label or a fact's dt has no padding of its own and line-height 1.5, so the
line keeps the label's height. CHANGELOG [Unreleased] § Fixed.

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>
2026-10-17 14:02:51 +03:00
mixandClaude Opus 5.5 7289aec60b release: 2.14.2: dmarc-day, health-sockets - the last small items
test / test (push) Canceled after 0s
No migration; schema 13. The pins (README, compose, guide), the guide's
backup example and the planned cuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:38:36 +03:00
mixandClaude Opus 5.5 0eaf69a830 dmarc-day, health-sockets: the reporter's day said; the inbound journal dialled
The owner, 2026-10-17: "Показ времени отчёта по UTC принимаем. Это не
проблема. Пояснения добавь. Все мелочи закрой." Designs 9b33b80, 2bf9056.

DMARC: a report's day stays the reporter's, in UTC (RFC 7489 § 7.2); the
report's Window now says so under its times (windowNote), and Settings'
Time zone box and Help (#settings, #dmarc) name it as the one exception to
the panel's zone. formatDMARCWindow counts a window that ends at midnight
as ending the day before, so a daily report is one day, not "20 Sep - 21
Sep" (TestDMARCWindowIsTheReportersDay, in Moscow's zone).

Health: with the inbound relay on, the inbound journal milter's socket is
dialled (InboundJournalSocket through web and handlers Config), fail-open
as the send log's, and Overview's card counts it; the send log's socket is
named journal-milter, as the mockup names it.

The confirmation link's sign-in state is drawn (confirm-email-signin.html);
the panel already renders it.

guide: /license and /server/components, the DMARC pages' paths and day,
selfpost-backup -o / -i / -version, the Inbound log's retention field,
PANEL_COOKIE_SECURE, the inbound journal's socket, the DMARC exception to
the zone. CHANGELOG [Unreleased].

(The e2e case negative_level1_ratelimit, listed as able to pass falsely,
was fixed on 2026-10-14 - the note was stale.)

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>
2026-10-17 13:38:34 +03:00
mixandClaude Opus 5.5 de4274ccc9 release: 2.14.1: overview-head - the mockup's sentence on Overview
test / test (push) Canceled after 0s
No migration; schema 13. The pins (README, compose, guide), the guide's
backup example and the planned cuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:48:36 +03:00
mixandClaude Opus 5.5 1eec1c6011 overview-head: the mockup's sentence, made from the cards
The owner's "1" (2026-10-17), the two shown side by side; design 9aebe1e.

overviewHead writes "Mail is flowing. Nothing needs attention." when every
card is fine, "Mail is flowing. One thing needs attention." (or "N things
need") for warnings, and "Mail may not be flowing. ..." when a card fails;
the lead names each card that is not fine, linked, with its value inside
the sentence ("TLS certificate: expires in 12 days."), separated by
semicolons. lowerFirst lowers a value's first letter unless it is an
abbreviation ("CPU 98 %"); every value the cards write is a phrase.

Tests: TestOverviewHeadSentences (the three states, two cards, an
abbreviation), the Overview page and fragment, TestFilterDownIsAMissingSocket.
go vet + go test (26 packages) and make e2e on the test server for this
tree.

Docs: guide § Overview, CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:48:34 +03:00
mixandClaude Opus 5.5 3d23c41ae7 release: 2.14.0: password-reset - a confirmed e-mail, then a link to reset
test / test (push) Canceled after 0s
Migration 0013; schema 13. 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>
2026-10-17 12:05:08 +03:00
mixandClaude Opus 5.5 b8e156c9f4 password-reset: a confirmed e-mail, then a link to reset the password
Agreed by the owner on 2026-10-13 ("the e-mail must be confirmed");
notifications to an unconfirmed address settled on 2026-10-17: "Без
подтверждения слать". Mockups 2f5f5b2, route guard b7bbb2c, panel guard
0774da2.

Migration 0013: users.email_confirmed_at ('' for every address saved
before it) and user_links - the SHA-256 of a link's token, its user,
purpose (confirm, reset), address, created and expiry. A changed address
loses its mark (keepConfirmed, in UpdateUser and SaveUser).

internal/web/auth/links.go. A link is 256 bits from crypto/rand, mailed as
https://SELFPOST_HOSTNAME/... - never the request's Host. Confirmation:
24 h, sent when an address is saved (Account, or the global role's user
form) and by Account's Send the link again; its page /confirm/<token> has
a button (a GET changes nothing) and confirms only for a session of its
own account. Reset: /login/reset asks for a username and answers the same
for every name (the sign-in page, "If that account has a confirmed e-mail,
the link is on its way"), mailing after the answer; only a confirmed
address gets one, none within ten minutes of the last, five links an hour
per account, five requests in 15 minutes per client address;
/login/reset/<token> (30 min) sets the password with the link taken in
the same transaction that checks the address again
(store.TakeResetLink), then ends every session. Changing the e-mail on
Account asks for the current password. A password set on Account or the
user form removes the reset links; a restore removes every link; the stop
waits for links being mailed (Server.Drain); a rejected request's log line
hides a link's token (loggedPath).

Reviewed for security before the commit (2026-10-17): no high; two
medium and five low, all fixed here - confirming needs the account's
session and the mail does not name the account (a mistyped address was a
takeover); a reset request neither replaces the links already sent nor
spends the account's quota inside ten minutes; take-and-set atomic; the
confirmation mail sent after the answer; the stop waits for mail; a
restore deletes the links; the token kept out of the log.

Tests: store TestUserLinks, TestUserLinkTakenOnce, TestEmailConfirmation,
TestTakeResetLink; auth TestResetSendsALinkOnlyToAConfirmedAddress,
TestResetLinkSetsThePasswordOnce, TestResetLinkDiesWithTheAddress,
TestResetRequestsAreLimited, TestResetRequestsAreSpaced, TestConfirmLink,
TestLinkPathsWithoutAToken; handlers TestAccountEmailIsConfirmedByItsLink,
TestUserFormSendsANewAddressItsLink; web TestLoggedPathHidesTokens; the
view's pages and fixtures; e2e password_reset_by_e_mail runs it through
the sink (and checks a link from before the restore is dead). go vet +
go test (26 packages) and make e2e on the test server for this tree.

Docs: guide § Account, security.md (the bullet and what ends sessions),
architecture (routes), schema-migrations (row 13, users, user_links),
help #account (word for word with the mockup), roadmap row done,
CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:03:27 +03:00
mixandClaude Opus 5.5 b7f04217f2 release: 2.13.1: docs-audit - the documents and the panel's words against the code
test / test (push) Canceled after 0s
No migration; schema 12. The pins (README, compose, guide), the guide's
backup example and the planned cuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:33:34 +03:00
mixandClaude Opus 5.5 ddafc96bab docs-audit: the guide and the panel's words against the code
The owner's "проверь, что документация актуальна с кодом" (2026-10-16);
design c5edbd8 and f1cc3f3 settled which side moves.

The panel follows its mockups: an inbound domain's MX box ends with
"Checked N ago" (dnscheck.InboundMXCheckedAt, from the cache) and its side
menu has See also › Log for this domain (the Inbound log, filtered); a
deferred message's history ends with the open dot of the wait in the
queue, the lifetime from this Postfix; Health's lead says six cards;
Overview's head says it refreshes on its own; DMARC's hint for an
unaligned third party is the mockup's sentence.

Help, word for word with the mockup and true to the code: the
application's password kept by the server (sasldb2) for an export, a
From:/Sender: refusal one row of the Outbound log, every backup password
check counted (AllowPasswordChange before the compare), a password set
for another user ends all their sessions, the limit mail daily per
sending domain. The Filter lists topic and its menu entry are no longer
shown to a domain administrator: their page is 404 to one
(requireInboundGlobal), and Help shows only what its viewer can open.

guide.md: Health's readings in the order of the boxes, with Notification
mail; the menu and role tables have the Inbound log and Filter lists; port
25's chain has the inbound journal; level 2 does not depend on the client
address, the client list refuses a message without one; four kinds of
refusal (wrong Sender); the log is filtered, not searched, and a message
is traced by its row; a bounced row shows only the queue lifetime;
TRUSTED_PROXY_CIDR serves sign-in and setup, the account-change limit is
per user; notifications' first check and hourly ceiling. The Apache
vhost's comment names mod_rewrite.

Tests: TestHelpHasTheInboundLogAndFilterListsTopics (the topic per
viewer), TestDeliveryPageDeferredUsesRetryPolicy (the wait),
TestInDomainShowsTheMockupsBoxes (checked, the log link), the Overview
head; the DMARC tests take the new hint. 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>
2026-10-17 02:32:44 +03:00
mixandClaude Opus 5.5 be98efa549 docs-audit: architecture, schema, security and the rest against the code
The owner's "проверь, что документация актуальна с кодом" (2026-10-16).
Read-only reviews checked every present-tense claim against the code;
each finding was read in the source before it was fixed here.

architecture.md: held inbound mail has its row from the journal milter,
not the tailer; the antispam action is accept or tempfail, anything else
stops the start; tls-internal's certificate is 0644; the signer logs to
stderr; the log's fingerprint is the inode, not the device; a mail.log
postlogd makes is 0640 (maillog_file_permissions); an unreadable stored
place starts at end-of-file too; the test-message route; no map in page
data; design-first compares whitespace aside; the queue and sasldb2.new
are not backed up; antispam/ under /data; POSTFIX_QUEUE_DIR's desync;
SELFPOST_BACKUP_PASSWORD (the CLI's).
schema-migrations.md: inbound_spam_log's upstream columns (v12),
send_log's sender reason, row 9's client_ip.
security.md: /data/opendkim since 2.9.0 (removed, or the start refused),
sasldb2 under /data/sasl, the trigger for mandatory encryption, the
password minimum counted in characters.
development.md: Staticcheck in CI, the e2e ports 20025 and 20053/udp.
README: the client IP list refuses at MAIL FROM, overrides are per
application. product.md: the optional rspamd sidecar.

Code: validate.AdminPassword counted bytes, so six Cyrillic letters
passed for twelve characters; it counts runes, as the backup password
always did (TestAdminPasswordCountsCharacters). envdoc reads
cmd/selfpost-backup too, and a name kept in a constant. A stale comment
of cmd/panel's run ("three roles").

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>
2026-10-17 02:07:55 +03:00
mixandClaude Opus 5.5 2dded921f5 release: 2.13.0: local-time - the panel in the container's time zone
test / test (push) Canceled after 0s
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>
2026-10-17 01:35:42 +03:00
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 4ff032a463 release: 2.12.0: test-email - a test message from Server › Health
test / test (push) Canceled after 0s
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>
2026-10-17 00:42:27 +03:00
mixandClaude Opus 5.5 f2ff8b4fea test-email: a test message from Server › Health
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>
2026-10-17 00:42:21 +03:00
mixandClaude Opus 5.5 546a1aa461 release: 2.11.0: health-setup-checks - name, greeting, proxy on Health
test / test (push) Canceled after 0s
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>
2026-10-16 23:51:43 +03:00
mixandClaude Opus 5.5 a8fd96c752 health-setup-checks: the certificate's name, Postfix's greeting, the proxy
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>
2026-10-16 23:51:42 +03:00
mixandClaude Opus 5.5 a1724ad72c release: 2.10.4: origin-strict - a change with neither header is refused
test / test (push) Canceled after 0s
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>
2026-10-16 23:16:28 +03:00
mixandClaude Opus 5.5 503939c6cf origin-strict: a change with neither Sec-Fetch-Site nor Origin is refused
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>
2026-10-16 23:16:27 +03:00
mixandClaude Opus 5.5 af778445d4 release: 2.10.3: backup-reauth - the account password before a backup
test / test (push) Canceled after 0s
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>
2026-10-16 22:39:14 +03:00