Commit Graph

139 Commits

Author SHA1 Message Date
mixeme bcfac99b55 fix(entrypoint): drop dash-incompatible ERR trap
test / test (push) Has been cancelled
CI logged only "trap: ERR: bad trap" — /bin/sh is dash, which rejects ERR
traps and then set -e exits before hostname checks or /data setup. Keep
portable step() echoes for the next CI diagnosis.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 09:42:41 +03:00
mixeme 1639701de3 test(e2e): entrypoint ERR trap and richer CI failure logs
test / test (push) Has been cancelled
CI showed exit=1 in ~3s with empty docker logs after /data/opendkim was
created. Log each entrypoint step and trap ERR with LINENO; also read the
container LogPath and docker logs by container id when compose logs are empty.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 09:12:47 +03:00
mixeme d43a86c4bd test(e2e): instrument hostname gate and startup for CI debug
test / test (push) Has been cancelled
Capture docker inspect, /data modes, supervisorctl, and entrypoint markers
on failure (stderr DEBUG816647 lines + debug-816647.log) so release CI can
show why valid_hostname_starts / startup_processes_running die with empty logs.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 08:51:48 +03:00
mixeme 231d341d0d fix(entrypoint): make /data traversable; hostname gate first (v1.0.0)
test / test (push) Has been cancelled
release / prepare (push) Has been cancelled
release / build (amd64, ubuntu-latest) (push) Has been cancelled
release / build (arm64, ubuntu-24.04-arm) (push) Has been cancelled
release / merge (push) Has been cancelled
Go TempDir bind mounts arrive as 0700; after chown panel:panel, OpenDKIM
could not reach KeyTable and the container crash-looped. chmod 755 /data,
check SELFPOST_HOSTNAME before /data setup, and harden the e2e stand
(restart: no, readiness logs).

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 01:22:50 +03:00
mixeme 3f0e7dc131 fix(e2e): wait for panel /healthz before setup (v1.0.0 gate)
test / test (push) Has been cancelled
release / prepare (push) Has been cancelled
release / build (amd64, ubuntu-latest) (push) Has been cancelled
release / build (arm64, ubuntu-24.04-arm) (push) Has been cancelled
release / merge (push) Has been cancelled
Supervisor RUNNING is not enough: the setup-token file is written before
ListenAndServe, and Docker host-port publish can lag. Poll /healthz first,
and stop ordered TestE2E steps after a failure so a nil panel cannot panic
and mask the real error.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 01:08:32 +03:00
mixeme fa9d2016cc release: cut 1.0.0
test / test (push) Has been cancelled
release / prepare (push) Has been cancelled
release / build (amd64, ubuntu-latest) (push) Has been cancelled
release / build (arm64, ubuntu-24.04-arm) (push) Has been cancelled
release / merge (push) Has been cancelled
Pin compose and local trial to ghcr.io/mixeme/selfpost:1.0.0, close the
CHANGELOG cut, retire implementation-plan and v1.x-closure-plan, and point
e2e/CI comments at development.md.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 00:52:49 +03:00
mixeme e0b754bafe docs: spell out .spbk and .spde extensions
test / test (push) Has been cancelled
SelfPost backup / SelfPost domain export — in the guide, security notes,
architecture, and Backup/Export panel copy.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 00:49:43 +03:00
mixeme 284bd22dfb docs: align operator and as-built docs with the code
Fix setup URL shape, import encryption UI, architecture layering/routes,
and stale plan/roadmap pointers so the prose matches what the tree does.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 00:46:03 +03:00
mixeme 5a0724778a docs: consolidate process docs into development.md (v1.x closure phase 3)
test / test (push) Has been cancelled
Fold documentation-plan and progress into development.md, drop docs/archive,
retarget live links, and point README plus agent-rules at the new home.

Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 00:37:35 +03:00
mixeme 9d6d89c79d fix(logtail): keep mail.log in /data and reconcile stuck rows (v1.x closure phase 2)
test / test (push) Has been cancelled
Move the delivery log from the ephemeral /var/log to /data/log/mail.log so
the lines that resolve a queued send-log row survive a container recreate.
postlogd writes it as postfix, the panel reads it through the selfpost group
(dir 2750, file 0640, normalised every start); backups exclude log/.

Close the residual gap with a queue sweep: rows queued for over two minutes
whose id postqueue -p no longer lists are marked bounced. The sweep waits
until the tailer has read the log to its end and does nothing when the queue
cannot be listed, so a message in flight is never touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:35:27 +03:00
mixeme c839f68c57 feat(panel): adaptive monitoring poll intervals (v1.x closure phase 1)
test / test (push) Has been cancelled
Replace fixed 5 s hx-trigger polling with data-poll markers and panel.js
scheduling: 5 s while active, 30 s when idle, none when tab is hidden.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:13:02 +03:00
mixeme 998b05821b docs: add v1.x closure plan with checklists and model routing
test / test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:10:04 +03:00
mixeme 1382f8b5f4 docs: translate development guide and agent rules to English
test / test (push) Has been cancelled
development.md and agent-rules.mdc in English; progress.md and roadmap.md stay Russian as internal docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:58:38 +03:00
mixeme c92f4bc616 docs: restructure development guide and move agent rules to .cursor
test / test (push) Has been cancelled
Reorganize development.md (stack, deps, build, release, testing, CI); relocate agent rules to .cursor/rules; remove dev-host and mixfed.ru references from docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:56:22 +03:00
mixeme 0025b5d85d docs: streamline README quick start section
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:48:46 +03:00
mixeme 7c27dc3b89 docs: improve quick start and reference deploy instructions
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:44:37 +03:00
mixeme c304c92955 docs: split README into overview and operator guide for release
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:36:24 +03:00
mixeme 2f01edff19 docs: add clickable table of contents to README
test / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:27:47 +03:00
mixeme 99a0483226 feat(panel): lay a delivery's log lines out as a table
test / test (push) Has been cancelled
The lines came out as one block of preformatted text, which is what the
system log page does with a tail of mail.log — right there, where the
lines are unrelated to each other and the block is the log itself. Here
they are one message's six or seven lines, and what is read off them is
the pace: the second between the connection and the banner, the ten
between DATA and the reply. Run together, every line began with a
different-width stamp and none of those numbers lined up.

So they are two columns now, when and what, the same shape the send log
itself has. logtail.SplitTimestamp takes the stamp off the head of a
line: postlogd's format, which is what this server writes, and syslog's
traditional one for a deployment that routes the log through syslogd
instead. The stamp loses its microseconds and its offset — five decimal
places are the widest part of the column and the least worth reading —
but is not converted, so the page shows the log's own wall clock rather
than a claim about which zone it was in.

A line whose head is not a stamp either parser recognises keeps its whole
text in the second column and leaves the first empty. The format is the
log's, not ours; a line we cannot split is a line we must not drop, and
the test says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:22:29 +03:00
mixeme 928d065d13 feat(panel): give a delivery its history and its own log lines
test / test (push) Has been cancelled
The delivery page was a list of the fields the send-log table has no
column for, stacked one per line down the reading measure. Six values of
a few characters each — domain, application, queue id, journal id and two
timestamps — came to a page of mostly empty rows, and none of them
answered the question the log raises when a row is opened: what actually
happened to this message.

So the page states that instead. The subject heads it and the sender,
recipient and outcome are the line under it, which puts what the message
was and how it ended on the first line. Below, two columns: what the
journal recorded on the left, as a grid of tiles rather than a stack, and
on the right the two timestamps stated as the steps they stand for —
accepted and queued, then delivered, deferred, bounced, or refused before
queueing. Each step carries its status in the panel's own
ok/warn/error/unknown vocabulary, so a colour means here what it means on
the status page. A message still queued shows the report it is waiting
for as a step that has not happened, rather than dating it with the
moment the row was written.

Under both, at full width, the mail.log lines for the message's queue id.
The queue id was printed on this page as something to go and search the
system log for by hand; logtail.QueueLines does that search. It scans a
bounded tail of the current file — finding one message's lines means
reading rather than seeking — and anchors the match on the character
before the id, since queue ids are hexadecimal runs and a shorter one is
regularly the tail of a longer one. Send-log rows outlive mail.log
(retention ninety days, rotation fourteen files), so a message with
nothing left to show says so; that is the normal end state, not a fault,
and only a log that cannot be read at all is reported as one.

Two cards abreast and a block of raw log lines do not fit the reading
measure, so the page now declares itself wide — the opposite of what it
did when the column width was unified, where it was the page that stayed
prose. The mechanism is unchanged and is why the reversal costs one line:
how wide a page needs to be is the page's own property, not the
navigation entry's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 10:14:27 +03:00
mixeme 57d09ce1e2 docs: cut CHANGELOG 0.6.0, reopen Unreleased
test / test (push) Has been cancelled
Everything under Unreleased goes out as 0.6.0: the per-delivery page, the
DNS badge in the domain list, the machine metrics card, the section index,
the navigation column, and the layout, brand and import-form changes.
Added entries, hence a minor bump.

The import form reading the file extension instead of an "is it encrypted"
checkbox was never written down; it is added under Changed, together with
the follow-up that keeps the field hidden while no file is chosen.

The heading is the version: there is no constant in the tree, only the
VERSION build arg that ldflags stamps into internal/buildinfo.Version, so
the image this deploys under is tagged from here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 09:50:50 +03:00
mixeme 94ab207752 fix(panel): lay every page out in one column width
test / test (push) Has been cancelled
The page column was the 48rem reading measure, which the send log, the mail
queue and the system log widened to 64rem for their tables. The navigation and
the page are centred as a pair, so that difference did not only change the
page's width: it moved the navigation column and the left edge of every card
on the way between two pages. The column is now 64rem throughout and the
measure lives inside it — a page's heading, cards, back link and version footer
are held to 48rem and centred in the column, and the pages made of data opt out
and take the column whole.

Which pages those are is declared by the page (a "wide" block in its template,
the same mechanism as the section index) rather than derived from the
navigation entry: .Active cannot tell the send log from a single delivery's
page, which is prose and now keeps the measure instead of inheriting the log's
width.

Two rules follow from centring the children rather than the cards. The heading
takes its margins as longhands, since the shorthand re-zeroed the auto side
margins and pinned it a measure's width left of the card under it; and the back
link is a block, since auto margins centre a block and do nothing for an
inline-block.

The scrollbar's width is reserved on every page as well. Without it a short
page and a long one are laid out in viewports differing by that width, which
moved the same things again, in the same direction, for a second reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 09:17:13 +03:00
mixeme bea482e30d fix(brand): set the small-size variant's S in Medium
test / test (push) Has been cancelled
The tab icon carries the initials rather than the two-line wordmark
because the wordmark stops resolving below 32px, but it kept the
wordmark's weights, and those do not survive the size either. The S is
ExtraLight against the P's SemiBold: a 0.90 stem against 3.40, which at
16px is a quarter of a pixel against most of one. The pair rasterised to
a P with a smudge beside it, the S reaching no solid pixel at all at 16
or 32.

It is Medium now. That gives up the Self/Post weight play inside this
one variant, which is the right trade: the contrast needs more pixels
than the variant exists to work in, and the variants big enough to carry
it keep it.

The outlines come from IBM Plex Sans as before, at the same font-size
26, letter-spacing -1 and baseline — only the S's weight moved, and the
pair re-centres on its advances the way live text would, which shifts
the P 0.57 right. The reconstruction was checked by regenerating the
committed ExtraLight/SemiBold outlines from the same pipeline first;
they came back identical, so the new S is the font's, not a thickened
copy of the old one. A stroke was tried before the font was to hand and
is not what shipped: it thickens uniformly, where Medium is modulated at
the joins and keeps the apertures open, which is visible by 64px.

favicon.png is regenerated from the same source. Its border is unchanged
to the pixel and its alpha to the count; only the lettering moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:24:16 +03:00
mixeme d2a151e752 fix(brand): make the stamp's tagline survive rasterisation
test / test (push) Has been cancelled
SELF-HOSTED SMTP RELAY was set at font-size 7.2 with letter-spacing 2.8 —
a cap height of 5.2 in a 330-unit artwork, which puts its stems at about
half a device pixel where the mark is actually used. More than half the
line's ink came out as antialiasing: measured against the brown, the
typical tagline pixel reached 2.1:1 where the two colours are worth
7.3:1, and at 330px not one pixel reached full strength. The wordmark
above it renders 66% of its pixels solid.

It is now 11.5/0.15. The line keeps its footprint (154 units against
153) and its monospaced cells, fitted from the grid the outlines were
generated on; the width the tracking was spending went to the glyphs
instead, taking cap height to 8.3. Mean ink coverage goes 0.39 to 0.60
at the README's width and 0.33 to 0.47 at the 330px the login and setup
pages use.

opacity=".78" is gone with it. It cost 30% of the available contrast to
mark the line as secondary, which a 3.4:1 difference in size already
does, and it was multiplying the alpha of stems that were mostly alpha
to begin with.

A stroke to thicken the stems was measured and rejected: at 0.12-0.30 it
lifted mean coverage 0.59 to 0.63 while dropping the share of solid
pixels, since it adds antialiased edges rather than filling stems.

internal/web/static/logo.svg is a byte copy of the docs asset and stays
one. selfpost-proof.html carries the lockup as live text and is where
the metrics come from, so it moves too, or the next regeneration would
put the old spec back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 23:05:31 +03:00
mixeme 82fc821d1a test(e2e): follow the panel's markup for applications and the send log
test / test (push) Has been cancelled
Two scrapes had drifted from the pages they read, and the suite has not
been run since either page changed.

applicationID still looked for an application as a table row
(<td class="code">login</td> ... /applications/N/mode). Applications became
a list of blocks in 8add005, so the lookup had been failing for several
commits, including the one currently deployed — this is stale test, not a
regression. It now anchors on the login heading and takes the id from the
first action posted under it, whichever that is, so reordering a block's
controls will not break it again.

The level-2 rate-limit check looked for the application's login among the
send-log rows. c70c115 took that column off the log — the log identifies a
message and names the application only on a row's own page — so the check
now filters the log by application instead. That is the same attribution
through a server-side WHERE app_login rather than a substring match on
rendered HTML.

Verified on selfpost.mixfed.ru: make e2e green, all subtests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:59:12 +03:00
mixeme d2dffc2e9a docs: center the readme logo and set its display width
test / test (push) Has been cancelled
Markdown's image syntax carries neither alignment nor size, so the stamp
rendered flush left at its intrinsic 330px. A centred `<p>` with an
`<img width>` is the usual way around that on GitHub, and survives its
HTML sanitiser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 22:46:40 +03:00
mixeme c70c115224 feat(panel): move a delivery's details onto its own page
The delivery log now lists what identifies a message and nothing else —
time, sender, recipient, subject, status — and links each row to
/deliveries/{id}, which carries the rest: the sending domain, the
application it was submitted under, the Postfix queue id to search the
system log for, and when the status was last reported. Domain and
application were a column each; they were the widest thing in the table
after the addresses and repeat down every filtered page, and they remain
the log's two filters. Back returns to the page and filters the row was
opened from, rebuilt from the log's own parameters only.

Subjects are now decoded for display as well as on the way in. The milter
has decoded them since 8add005, but the rows it wrote before that still
hold the raw =?utf-8?Q?...?= header, and those are the ones an operator is
most likely to still be reading. The decoder moves to internal/mailhdr,
shared by the milter and the panel; it is idempotent, so a row decoded
once passes through unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:43:07 +03:00
mixeme 795e945888 feat(panel): show each domain's DNS status in the domain list
The list gave no hint which domains still needed records published — the
verdict lived only on the domain page, one click away per domain. Each row
now carries a badge with the worst of that domain's DKIM, SPF and DMARC
checks, in the panel's shared ok/warn/error/unknown vocabulary, linking to
that domain's DNS status card.

The checks run concurrently across the listed domains: each carries its own
timeout, so in series a dead resolver would multiply that wait by the number
of domains and the list would look hung. They share the checker's cache with
the domain page, so a repeat view costs no lookups and opening a domain
after the list is free.

A domain whose DKIM key cannot be read stays "unknown" rather than being
reported as misconfigured — the missing half of the comparison is this
server's, not the domain's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 22:33:06 +03:00
mixeme ad991dea4b style(panel): give the navigation column's mark the column's full width
The mark kept the 110px it had as part of the bar, which was all there was
room for in a row; in a column it ended halfway across, sharing an edge with
nothing below it. At the column's width its edges line up with the page
entries, and the SVG's own inset puts the drawn mark on the entries' icons.
The width/height attributes carry the file's real proportions so the column
still reserves the right height before the SVG loads.

Where the column lies back down into a bar, the mark returns to the compact
size: at full width it would take a row to itself above the entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:30:21 +03:00
mixeme a8e28e1921 fix(panel): open an application's panels under its row of controls
test / test (push) Has been cancelled
Mode and rate limit were <details>, so each opened where its own toggle sat
and split the row of four controls in two, pushing New password and Delete
below a block of fields. The toggle is now a hidden checkbox with its label
drawn as the button and the panel is the last child of the row, so the
controls keep their places and the fields are laid out beneath all of them.
Pure CSS, so it still works with JavaScript blocked, as the disclosure did.

The panel buttons take back the form spacing the compact row style zeroes
out, and Save limit and Remove limit share one row: two posts mean two
forms, so the first button is bound to its form by the form attribute
instead of sitting inside it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 22:23:39 +03:00
mixeme db56fcc3f3 fix(panel): keep the import password field hidden until a file is chosen
test / test (push) Has been cancelled
The field was revealed whenever no file was selected, so the import card
opened asking for a password it had no use for yet. Hide it in that case
too; an unrecognised extension still reveals it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 22:12:05 +03:00
mix 2c5c21f212 feat(panel): detect import encryption from file extension, not a checkbox
The import form used to ask users to tick "the file is encrypted" before
showing the password field, even though the server already decides purely
from the envelope magic bytes. Reveal the password field automatically for
a .spde file (hide it for .json) so the checkbox is no longer needed.
2026-08-07 04:30:24 +03:00
mix 5d3000a48f feat(panel): navigation as a left column, with a section index on the long pages
The navigation was a bar across the top that did not fit on one row — six page
entries and the session block against the panel's width — and had to be split
into two, costing the top of every page. It is now a column down the left edge:
one left edge to scan, the current entry marked down its leading edge, sticky so
it stays in view, and room under the entries for the current page's own
sections. Below the width the two columns need it lies back down into the same
wrapping rows as before; six entries need no drawer.

The section index is for the two pages long enough to need one — the domain page
(nine cards) and the status page (eight). Each card carries an id and the page's
template defines the list by overriding an empty "sections" block in the layout,
so a page that defines nothing renders no index. panel.js marks the section in
view, looking targets up by id on each pass so the status page swapping its
cards out every five seconds cannot leave it measuring boxes that have left the
document; the links themselves are plain fragment links and need no script.

Verified against the real pages rendered by a local panel at 1300px, 924px and
481px wide.
2026-08-07 04:11:32 +03:00
mix 251344f880 feat: machine metrics (CPU, memory, network) on the status page
The status page answered "are the components running" but said nothing
about the machine underneath them, so a server slowed to a crawl by a
busy processor or one about to have Postfix OOM-killed looked entirely
healthy until the queue backed up.

internal/health/machine.go reads the kernel's counters in /proc: the
aggregate processor times and core count from /proc/stat, the load
average from /proc/loadavg, memory and swap from /proc/meminfo, and
per-interface byte counters from /proc/net/dev.

CPU busy time and network throughput are rates, so a MachineSampler holds
the previous reading and each call reports the difference — one shared
sampler on the Server, since a per-request one would never have anything
to subtract. A window longer than a minute only re-baselines: a page
opened after the panel sat idle would otherwise average that whole
stretch and present it as the current load.

Memory is derived from MemAvailable rather than MemFree, because Linux
spends every spare page on cache and MemFree would report a permanent
emergency. A fully busy processor (>=90%) warns and an exhausted machine
(>=97%) errors, both counting towards the page's headline verdict, since
either delays or kills the mail path. Throughput has no comparable
threshold — what counts as a lot depends on the link — so it is reported
and never graded. Loopback is excluded: that traffic is the container
talking to itself.

Like every other check here, an unreadable counter degrades to "unknown"
with an explanation instead of failing the page, so the panel still runs
outside Linux for development.

The usage bars are <meter> elements. The panel's CSP has no inline-style
exemption, so a bar's length has to travel on an attribute; the element
also grades its own colour from low/high/optimum, and the percentage is
printed beside it for anything that does not render meters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 03:49:23 +03:00
mix 3b0219f0eb docs: cut CHANGELOG 0.5.0, reopen Unreleased
Everything under Unreleased goes out as 0.5.0: the log-tailer bounce
mis-parse and offset-persistence fixes, the L2 rate-limit in-flight
accounting, the GitHub-only module path move, the code-review.md
retirement, the pre-release security pass (saslpasswd2 argv hardening),
and optional password encryption for backup/domain-export (Added, hence
a minor bump).

The heading is the version: there is no constant in the tree, only the
VERSION build arg that ldflags stamps into internal/buildinfo.Version,
so the image this deploys under is tagged from here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 22:29:02 +03:00
mix 18d90b2dfc docs: remove code-review.md, carry the open items into roadmap
The review's plan is finished — phase 0 (bar the two release-commit steps),
1, 1.5, 2 and 3 are all closed — and what remained in the document was a second
copy of things that already live in architecture.md, security.md, roadmap.md or
the code comments: the GUI compromise table is in panel.css/panel.js/
middleware.go/handlers_auth.go, the single SQLite connection and the dual
cookie names are explained where they are implemented, the accepted gaps are in
security.md, and the model-routing table names progress.md and development.md
as its own source. A second copy of a fact is a place for it to go stale.

Four items were genuinely open and had no other home, so they moved to
roadmap.md rather than disappearing:

- splitting internal/web into subpackages (2.x) — with the reason to wait: the
  flat package still reads at 47 files, and both 2.x features grow it, so the
  cut is worth making before that growth, not now;
- a consolidated documentation index in the README (v1.x tail);
- the adaptive polling interval for a tab that is visible but idle — the hidden
  case is already handled, and the remainder is explicitly allowed to end as
  "decided not to";
- CONTRIBUTING.md, already moved to 2.x in the previous commit.

References retargeted: progress.md (7), roadmap.md (5), implementation-plan.md
(1). The CHANGELOG entries that cite the document are left as written — they
describe what happened at the time. The review text stays in git history at
522425a.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:22:52 +03:00
mix d49351c022 chore/docs: move to GitHub as the single home; drop archived-spec references
Codeberg is being retired as the project's public site, so every reference now
points at GitHub. That includes the Go module path (codeberg.org/mix/selfpost →
github.com/mixeme/selfpost): leaving an import path on a host that is going
away would break `go get` and `go install`, so this is not only a docs change.
Touches go.mod, test/e2e/go.mod, all imports, Makefile MODULE, the -ldflags
version stamp in build/Dockerfile and docs/development.md, the licence headers
in the SVG/HTML assets, and README (no more primary/mirror pair).

Comments no longer cite the archived specification. "spec 7.6.1", "spec 5.1"
and friends pointed into docs/archive/specification-v1.0.md, which is marked as
not a source of truth; each is now a reference to the live document that owns
the subject — architecture.md (with section), product.md, security.md or the
README. The review only asked for the 7.x refs (code-review.md § 4), but 4/5/6/
8/9 had the same defect, so they went too. Comments only, no behaviour change.

Also closes the remaining review items: architecture.md gained a Code layers
section with the layer diagram (A2), and TestParseDelivery gained the exotic
mail.log cases (§ 3).

Fixes a bug that last test found: the delivery-line pattern matched status=
greedily, taking the *last* occurrence on the line. Postfix appends the remote
server's reply verbatim, so a rejection whose reply quoted "status=sent" was
filed as a delivered message in the send log. It now takes the first status=
after the recipient, which is the real field.

R7 (CONTRIBUTING.md) moved to roadmap 2.x — one developer, no external PR flow,
so the file would have no audience yet. R1 (compose image tag) and the git tag
stay in roadmap § v1.x as the release-commit steps.

gofmt/go vet clean on both modules; go test ./... green except the three known
Windows-only failures (file perms, backslash paths, renaming an open file).
Not exercised on the dev server — no Docker locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:14:13 +03:00
mix ed0a786739 feat: log-tailer offset persistence + in-flight L2 rate-limit accounting (code-review.md § Phase 3)
- logtail: persist the read position (offset + fingerprint of the log's
  first 512 bytes) in a new logtail_state table (migration 0003) and
  resume from it on start, so delivery lines written while the panel was
  down are parsed instead of skipped and their send-log rows no longer
  stay "queued" forever. Fingerprint mismatch (rotated/recreated while
  down) reads the file from the start — re-parsing is idempotent; a
  first-ever start with nothing stored still begins at end-of-file.
  Writes are throttled to one per 5s, forced on rotation and shutdown.

- milter: count messages that passed the level-2 check but have not
  reached the send log yet (internal/milter/inflight.go), so concurrent
  SMTP sessions cannot each spend the same last slot. A literal
  count+insert transaction, as the review suggested, is not possible:
  the count happens at MAIL FROM and the insert at end-of-message.
  Reservations are released after the insert, on ABORT, and after a
  10-minute TTL — a client that drops mid-transaction must not be able
  to hold a slot, since the limiter is fail-open by design.

Docs: architecture.md (log tailer, persistence, L2 counting),
security.md and roadmap.md (restart gap closed, container recreate
remains), CHANGELOG, progress.md, code-review.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:12:37 +03:00
mix 4b0f537508 style: GUI polish — visibility-aware polling, CSS vars for dark mode (code-review.md § Phase 2)
- panel.js: skip HTMX polling requests while the tab is hidden, via
  htmx:beforeRequest rather than htmx's eval-based trigger filter (the
  panel's CSP allows no unsafe-eval).
- panel.css: replace all dark-mode !important overrides with CSS custom
  properties reassigned once under prefers-color-scheme: dark.
- panel.css: consolidate the duplicate main{max-width} rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 16:57:05 +03:00
mix 6d2d49257d feat: optional password encryption for backup and domain export (code-review.md § Phase 1.5)
Both secret-bearing downloads can now be sealed with a password. Unticked, the
forms produce exactly the files they did before.

- internal/secretfile: envelope format — magic/type/scrypt params/salt/nonce
  prefix header, then 64 KiB AES-256-GCM chunks each authenticated with the
  header, its counter and an end-of-stream flag, so truncation, reordering and
  tampering fail to open instead of restoring a plausible prefix. Streams both
  ways, so a full backup never sits in memory.
- Panel: "Encrypt with a password" checkbox on the full-backup and
  domain-export forms (shared partial, toggled from panel.js — no inline
  script); domain import detects an encrypted export by magic bytes, not by
  extension, and asks for the password.
- selfpost-backup: writes .spbk when given a password and converts one back
  with -decrypt, which a restore needs. The password comes from
  SELFPOST_BACKUP_PASSWORD or -password-file, never argv.
- Docs: README, security.md (+ accepted risk: encryption stays opt-in),
  architecture.md, progress.md, CHANGELOG.

Verified locally: panel-encrypted archive decrypts through the CLI and unpacks;
wrong password and password mismatch are refused; UI checked in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 16:43:28 +03:00
mix 68f83139ee docs/chore: Phase 1 doc/code hygiene (code-review.md § Phase 1)
Removes ~30 stale "Phase N" / historical-staging comment references from
code and shell scripts now that v1.0 is done; fixes a stale dashboard
comment claiming applications/send-log were unimplemented; adds a CSRF ADR
to security.md documenting the Origin-check-over-tokens decision; resolves
docs/logo in roadmap.md (directory doesn't exist, criterion already met);
adds a gofmt -l check to CI so unformatted Go fails the build.

The known-limitations write-up for the log-tailer offset gap (the other
Phase 1 item) was already present in architecture.md § Log tailer, so no
change was needed there.

gofmt/go vet/go test clean on both Go modules (main + test/e2e), verified
on the dev server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 16:13:36 +03:00
mix bc68bfd4c7 docs: schedule implementation-plan.md retirement in roadmap
The document is closed: no unique content remains — § D (pre-release
security review) is duplicated in progress.md, security.md and the
CHANGELOG, and B.1-B.3/C.4 were trimmed in 22f86d1. It stays until the
tag only because it describes the release gate.

Record the retirement as a v1.x tail item in roadmap.md with the
concrete steps: archive the file and retarget its references, including
the stale "plan C.4" pointers in Makefile, release.yml and the e2e test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:36:51 +03:00
mix 429f9ad232 security: phase D pre-release review — pass; harden saslpasswd2 argv
Fable review of the full diff from the v1.0 audit (Phase 11, bd64e80) to
HEAD plus a complete pass over the docs/security.md checklist (former spec
7.6). No exploitable findings. One defence-in-depth fix: the application
login is passed to saslpasswd2 behind a -- end-of-options marker so a
login starting with - can never be parsed as a flag. Accepted risks
unchanged; plan § D closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:32:52 +03:00
mix 2595b0b673 docs: plan backup encryption and drop session-restore risk
Update code-review with phase 1.5 (optional .spbk/.spde encryption, checkbox UI). Remove session resurrection from backup as an accepted risk in security.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:07:48 +03:00
mix 522425a4da docs: add full codebase review and implementation plan
Record comprehensive code review in docs/code-review.md covering architecture, quality, documentation, GUI, legacy, and risks. Link from implementation-plan and progress; update CHANGELOG.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 10:31:15 +03:00
mix 22f86d120a docs: trim implementation-plan to open release gate
Leave only pre-release security review (section D) in implementation-plan.md. Move closed B.1-C.4 as-built detail to architecture.md and development.md; document accepted risks in security.md; optional send-log follow-ups in roadmap.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:50:13 +03:00
mix dbaed05f9c docs: close documentation plan; move v1.x tail to roadmap
Mark D1-D9 complete in a slim maintenance documentation-plan; defer
Codeberg Quick start, compose tag bump, and docs/logo to roadmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 00:45:07 +03:00
mix 995bd5db84 docs: D6-D9 — HEALTHCHECK, env regression test, new docs, archive spec
Add Docker HEALTHCHECK and mail-path /healthz liveness; env-doc regression
test; architecture.md and development.md; product.md and expanded security.md;
retire live specification.md to docs/archive/.

Co-Authored-By: Claude <claude-opus-5-thinking-high@noreply@anthropic.com>
2026-08-05 00:33:49 +03:00
mix e335526162 docs: D3 backup tar path, D4 README/compose fixes, D5 plan sync
Document stopped-container tar backup with WAL warning and manifest
consumption; refresh status banner and port-587 note; align
implementation-plan B.1 with actual session behaviour on password change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:28:59 +03:00
mix b041e279b0 docs: D1 Operations/Rate limiting and D2 env reference (README)
Close documentation-plan findings 1-3 and part of 10: panel operations
guide, two-level rate limits, public env table with TRUSTED_PROXY_CIDR
warning, and compose wiring for the proxy CIDR variable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:26:03 +03:00