Drop the app L1 backstop line, move trusted-IP help under the IP field,
shorten the domain rate-limit title, and align field-pair labels with DMARC.
Co-authored-by: Cursor <cursoragent@cursor.com>
Restore muted L1 backstop line, move domain limit help above Save, and drop
the duplicate Address mode field label.
Co-authored-by: Cursor <cursoragent@cursor.com>
Show the level-1 backstop as its own line and code-row, use st badges for
active/inactive domain and app limits, and fold the polish into 1.2.4.
Co-authored-by: Cursor <cursoragent@cursor.com>
Invert level-2 semantics so domain limits apply to every client IP and
application limits with trusted IPs raise the ceiling above the domain
(still capped by level 1). Panel shows L1, validates maxima, and documents
the model on Settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
Close CHANGELOG [Unreleased] as 1.2.3; bump the compose pin and image references. Includes domain detail layout polish and Domains add-row.
Co-authored-by: Cursor <cursoragent@cursor.com>
Settings moves to /settings with a 308 redirect from /account; domains, Status, Users, and user forms get matching browser titles and h1 text; backup page title reflects domain import.
Co-Authored-By: Composer <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Pair Machine|Processes, queue|cert, and sockets|hostname; fix .split
auto-margins so cards fill half the row; trim Status prose and machine
details; drop the page section index; note panel-docs on the roadmap.
Co-authored-by: Cursor <cursoragent@cursor.com>
Widen the status page and wrap the short queue/certificate cards in
.split so the stack is shorter; other checks stay full width.
Co-authored-by: Cursor <cursoragent@cursor.com>
Form-wide block labels and full-width inputs stacked the box above the
domain name; checkbox rows now share label.check and fieldset spacing.
Co-authored-by: Cursor <cursoragent@cursor.com>
IBM Plex Mono sits low in its em square; top-heavy badge padding left
ok/warn sunk below the adjacent heading. Bottom padding is now heavier.
Co-authored-by: Cursor <cursoragent@cursor.com>
Global administrators manage every domain; the assignment checkboxes are
now hidden when that role is selected.
Co-authored-by: Cursor <cursoragent@cursor.com>
Two full silhouettes with staggered baselines read lopsided at 16 px; the
rear person is now a right-side crescent behind an aligned front silhouette.
Co-authored-by: Cursor <cursoragent@cursor.com>
The icon was a sun-with-rays (circle plus spokes), not a cog, despite the
1.2.0 note that Settings used a gear.
Co-Authored-By: Cursor <cursoragent@cursor.com>
The user form had Back to users at the bottom of the card; all drill-down
pages now use a shared back_link template with a structural test.
Co-Authored-By: Claude <Composer 2.5> <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Global administrators get two cards in the .split layout; domain-scoped
users keep the single narrow form.
Co-Authored-By: Claude <Composer 2.5> <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Settings and the user form centred the title on the 48rem measure while
.card.narrow centred on 24rem, so their left edges drifted by 12rem. Cap every
direct child of main when a narrow card is present, without shrinking the
column, so navigation stays put and login/setup stay unchanged.
Co-Authored-By: Cursor <cursoragent@cursor.com>
Settings had been sharing the account silhouette with Users and the signed-in user label, so three different concepts read as the same icon. Settings now uses a gear, the user line the single-user icon, and Users a two-person group mark.
Co-Authored-By: Cursor <cursoragent@cursor.com>
The send log was the one place in the panel where a status was bare text, next
to a delivery's own page where the same value is a badge, and a domain list
where the DNS verdict is one too — so a message's outcome looked like a
different kind of fact depending on which page you read it from.
The level travels on the row rather than being worked out in the template:
deliveryLevel is the one place that decides what a status means, the delivery
page already reads it, and a second mapping written into the template or the
stylesheet would have been free to drift from it. sent is ok, deferred a
warning, bounced and rejected errors, and queued unknown — nothing has gone
wrong, nothing has been reported yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two fixes in one file, both from looking at the result.
Settings and the user form were narrowed to their card's width so their heading
would stop floating beside it. The shell centres the navigation column and the
page as a pair, so narrowing the page moved the navigation with it — 296px
sideways between Settings and Domains, which is the jump the width rules at the
top of this file exist to prevent, arrived at from the other direction. Every
way of aligning the heading with the card moves something else, so the geometry
goes back to what it was before the restyle, rendered against the previous
stylesheet page by page to confirm it. Those two headings are still not aligned
with their cards; that is worth fixing on its own terms rather than by making
the chrome move on every page.
And the badge text sat above the middle of its box: the badge inherited the
body's 1.5 line-height, and a lowercase word with no descender — ok, warn,
unknown — sits high in a line box that tall, which inside a border reads as
text stuck to the top. The box now hugs its line and the padding does the
centring, uneven on purpose to pay for the descender space the words rarely
use. The one that does, queued, keeps the same box.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The compact button rule is written for controls that cluster — several beside a
value or inside a table cell — where 0.8rem/600 is what stops four of them
shouting at once. Sign out borrows that rule for its colours, and was taking
the type with it: in the navigation column it has no cluster to belong to, so
it stood under Settings among entries set 0.95rem/400 as the one smaller,
heavier word on the column, reading as a different kind of object rather than
as the last item of the list.
Type and padding go back to the entries'. The red, the border and the tinted
background stay — those are what say it acts, and the restyle only made the
mismatch easier to see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last cell in a row is the one the automatic table layout squeezes first,
and in the send log it holds a fixed two-word link: "Details" was being split
after "Detail" on every row. Holding that cell on one line costs the row
nothing and hands the width back to the status column, which was breaking
"deferred" into "deferre" and "d" for the same reason — the status gets the
same nowrap the timestamp already had, through a class of its own since the
cell had nothing to select on.
Both predate the restyle; they surfaced while screenshotting the send log
against a seeded journal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stamp was approved on its own sheet and the panel kept the blue-on-cool-grey
it shipped with, so the mark read as pasted onto someone else's page. Brick
becomes the accent, the greys warm to paper, and the corners square up towards
the stamp's own edge.
Brick is two tokens rather than one. #7A3B2E under white text is 8.4:1, but as
text on the dark scheme's background it is about 2:1 — a link nobody can read.
So --accent-fill carries controls and --accent-text carries links and the active
navigation entry; in the light scheme they coincide, in the dark one they do not.
IBM Plex ships with the panel instead of being named and hoped for. The mark is
Plex converted to outlines, so a system stack left the panel's own name as the
only Plex on the page — and ui-monospace resolves to Consolas, SF Mono or DejaVu
Sans Mono depending on the machine, which laid the send log and mail.log tables
out differently for every operator. Three WOFF2 files, 76 KB, served from the
panel's own origin so default-src 'self' still covers them.
Two things changed shape rather than colour. Links had no rule at all and ran on
the browser's blue, invisible while the accent was blue and wrong the moment it
was not. And the column-narrowing that login and setup had by name now keys off
the narrow card itself, so Settings and the user form stop splitting their
heading from the card it belongs to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lay out view, auth, validate, and handlers under internal/web while keeping
the cmd/panel API unchanged; update roadmap and changelog for web-split closure.
Co-authored-by: Cursor <cursoragent@cursor.com>
The panel now suggests policy-only DMARC by default, lets operators configure a default and per-domain report address, and DNS-checks hub _report._dmarc records. Future in-panel report ingestion is tracked as dmarc-reports in the roadmap.
Co-authored-by: Cursor <cursoragent@cursor.com>
Name the copyright holder, ship NOTICE with the image, serve the licence
from the panel footer on every page, and record the vendored htmx licence.
Co-authored-by: Cursor <cursoragent@cursor.com>
The assets are baked into the binary, so their FS modification times are the
zero value and http.FileServer sent no Last-Modified. With no ETag either, a
static response carried no validator at all and the browser was free to guess
how long to keep it -- which is how the panel kept showing the previous favicon
in the tab well after the new mark was deployed.
Hash each asset once at startup and serve that as a strong ETag, with
Cache-Control: no-cache so the browser keeps its copy but revalidates. An
unchanged asset then costs a bodyless 304 and a changed one is picked up on the
next load.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
security.md is linked from the README documentation table and now from
SECURITY.md, so a reader following either link landed in a Russian document
while everything around it was English. Translated in full; the requirements,
the accepted risks, and the CSRF ADR are unchanged in substance.
The reviewing model is no longer named in the text — that the pre-release
review ran, and when, is what a reader needs; who ran it is process detail
kept in development.md.
extract-cert.sh keeps its spec 10.3 quotation, translated. In sasl.go the
quotation from the closed plan is dropped rather than translated: rendered in
English it restated the sentence it hung off.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The nav entry, page heading, and browser title now read Settings. The
route, template name, and Active key stay `account`, so existing links
and bookmarks keep working.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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 d35b309, 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>
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>
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>
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>
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>
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.
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.