From 72b09d60a643c1458a2649a69238d54cc0d9cc0f Mon Sep 17 00:00:00 2001 From: Mix Date: Tue, 22 Sep 2026 15:24:51 +0300 Subject: [PATCH] release: 0.4.0: Domain-admin groundwork, send-log auth, packaging fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project's mark is now in use rather than only on file. The README opens Added: - The project's mark is now in use rather than only on file. The README opens Changed: - panel: the sign-in and setup pages are now a column the width of their own - panel: the three monitoring pages — Deliveries, Mail queue, System log — are Fixed: - panel: Deliveries now shows the subject as text rather than as its MIME - panel: table cells may now break inside a word, so no single long value can - panel: the Applications list on a domain page no longer comes apart. It was a Full detail: CHANGELOG.md (0.4.0). --- CHANGELOG.md | 55 ++++++++++ README.md | 2 + docs/assets/selfpost-icon-16.svg | 11 +- docs/assets/selfpost-icon.svg | 13 +-- docs/assets/selfpost-proof.html | 10 +- docs/assets/selfpost-stamp-compact.svg | 11 +- docs/assets/selfpost-stamp.svg | 15 +-- internal/milter/milter.go | 27 ++++- internal/milter/milter_test.go | 29 ++++++ internal/web/handlers_auth.go | 9 +- internal/web/handlers_setup.go | 7 +- internal/web/static/favicon.png | Bin 5129 -> 1654 bytes internal/web/static/favicon.svg | 8 ++ internal/web/static/logo-compact.svg | 8 ++ internal/web/static/logo.svg | 10 ++ internal/web/static/panel.css | 108 ++++++++++++++++---- internal/web/templates/deliveries_rows.html | 4 +- internal/web/templates/domain_detail.html | 33 +++--- internal/web/templates/layout.html | 38 +++++-- internal/web/templates/login.html | 4 + internal/web/templates/setup.html | 1 + 21 files changed, 317 insertions(+), 86 deletions(-) create mode 100644 internal/web/static/favicon.svg create mode 100644 internal/web/static/logo-compact.svg create mode 100644 internal/web/static/logo.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 9929f99..5c05525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,61 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +## [0.4.0] - 2026-08-04 + +### Added + +- The project's mark is now in use rather than only on file. The README opens + with the full stamp; the panel carries the compact one at the left of its + navigation bar, linking to the status page, and the full one above the card + on the two pages that have no navigation — sign-in and first-run setup. The + browser tab icon changes with it, from the earlier envelope drawing to the + stamp's small-size variant, so the tab, the panel and the README are one + identity. The four brand files in `docs/assets/` had their wordmark converted + from live text to outlines: they were set in IBM Plex Sans, which is not + installed on the machines that render them, and the light/semibold contrast + between *Self* and *Post* — the whole of the mark — collapsed into whatever + fallback the viewer happened to have. + +### Changed + +- panel: the sign-in and setup pages are now a column the width of their own + card. Both are a single narrow card, which centred itself while the heading + above it stayed at the panel's left edge; adding the mark would have made + that three alignments on a page with four elements. + +- panel: the three monitoring pages — Deliveries, Mail queue, System log — are + now laid out wider (64rem against the 48rem the rest of the panel keeps). + They carry data rather than prose: the send-log's seven columns had no room + to breathe, and the raw `mail.log` lines wrapped every second line. + +### Fixed + +- panel: Deliveries now shows the subject as text rather than as its MIME + encoding. A subject in any non-Latin alphabet reaches the milter as RFC 2047 + encoded-words (`=?utf-8?Q?=D0=9F…?=`), and the panel printed that verbatim — + unreadable, and as one unbreakable run wide enough to push the Status column + outside the card. Subjects are decoded when the message is journalled and + capped at 200 characters; the column clips anything still too long to one + line, with the full text in the tooltip. Rows logged before this release keep + their raw string. Subjects in the legacy single-byte charsets (windows-1251, + koi8-r) are still stored as sent — there is no decoder for them. + +- panel: table cells may now break inside a word, so no single long value can + push a table past the edge of its card. A 40-character recipient address did + it just as readily as an undecoded subject: a column is at least as wide as + the longest unbreakable run it holds, and email addresses have nothing to + break on. Timestamps are exempt and stay on one line. + +- panel: the Applications list on a domain page no longer comes apart. It was a + four-column table whose last column held six controls, two of them expanding + panels with textareas — far more than the width of a column, so the controls + broke into a staircase, the login cell grew into a block as tall as the row, + and the two text columns were left stranded on the baseline halfway down it. + An application is now a block rather than a row: the login on one line, mode + and addresses on the next, and the controls in a single wrapping row, with an + opened panel claiming the full width for its fields. + ## [0.3.0] - 2026-08-03 ### Fixed diff --git a/README.md b/README.md index cccca31..d09ddd3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +![SelfPost](docs/assets/selfpost-stamp.svg) + # SelfPost Self-hosted outbound SMTP relay with a web control panel, shipped as a single diff --git a/docs/assets/selfpost-icon-16.svg b/docs/assets/selfpost-icon-16.svg index e6a77e7..9e714db 100644 --- a/docs/assets/selfpost-icon-16.svg +++ b/docs/assets/selfpost-icon-16.svg @@ -1,7 +1,8 @@ - - -SP - \ No newline at end of file + Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the + file needs no font installed to render as drawn. --> + + + + diff --git a/docs/assets/selfpost-icon.svg b/docs/assets/selfpost-icon.svg index 365fee4..8b29d72 100644 --- a/docs/assets/selfpost-icon.svg +++ b/docs/assets/selfpost-icon.svg @@ -1,8 +1,9 @@ - - -Self -Post - \ No newline at end of file + Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the + file needs no font installed to render as drawn. --> + + + + + diff --git a/docs/assets/selfpost-proof.html b/docs/assets/selfpost-proof.html index 11a688f..757dd0e 100644 --- a/docs/assets/selfpost-proof.html +++ b/docs/assets/selfpost-proof.html @@ -235,12 +235,12 @@

Файлы

- docs/brand/selfpost-stamp.svg — эталон, README, документация
- docs/brand/selfpost-stamp-compact.svg — шапка панели
- docs/brand/selfpost-icon.svg — аватар, фавикон от 32 px
- docs/brand/selfpost-icon-16.svg — фавикон 16–32 px + docs/assets/selfpost-stamp.svg — эталон, README, страницы входа панели
+ docs/assets/selfpost-stamp-compact.svg — шапка панели
+ docs/assets/selfpost-icon.svg — аватар, фавикон от 32 px
+ docs/assets/selfpost-icon-16.svg — фавикон 16–32 px
-
Осталось одно действие. В контейнере нет ни IBM Plex, ни сети, поэтому текст в файлах остался текстом. Перед коммитом его надо перевести в контуры, иначе на машине без Plex набор поедет:
inkscape --actions="select-all;object-to-path;export-filename:out.svg;export-do" in.svg
Пока в файлах прописан фолбэк 'IBM Plex Sans','Helvetica Neue',sans-serif — не спасёт вёрстку, но не даст ей развалиться совсем.
+
Контуры сделаны. Текст в четырёх файлах выше переведён в контуры по IBM Plex Sans (OFL) — шрифт на машине читателя больше не нужен, и Self с Post держат свои 200 и 600 везде. Панель отдаёт свои копии из internal/web/static/: logo.svg, logo-compact.svg, favicon.svg плюс растровый favicon.png 32×32 для браузеров без SVG-фавиконов — go:embed не дотягивается за пределы пакета, поэтому это копии, а не ссылки.
Макеты на этой странице остались с живым текстом: это протокол разбора знака, а не источник файлов.
\ No newline at end of file diff --git a/docs/assets/selfpost-stamp-compact.svg b/docs/assets/selfpost-stamp-compact.svg index 454ccb4..3c03928 100644 --- a/docs/assets/selfpost-stamp-compact.svg +++ b/docs/assets/selfpost-stamp-compact.svg @@ -1,7 +1,8 @@ - - -SelfPost - \ No newline at end of file + Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the + file needs no font installed to render as drawn. --> + + + + diff --git a/docs/assets/selfpost-stamp.svg b/docs/assets/selfpost-stamp.svg index edc7ed1..cb46ba3 100644 --- a/docs/assets/selfpost-stamp.svg +++ b/docs/assets/selfpost-stamp.svg @@ -1,9 +1,10 @@ - - -SelfPost - -SELF-HOSTED SMTP RELAY - \ No newline at end of file + Wordmark set in IBM Plex Sans (OFL) and converted to outlines: the + file needs no font installed to render as drawn. --> + + + + + + diff --git a/internal/milter/milter.go b/internal/milter/milter.go index 5f34d81..cb58a48 100644 --- a/internal/milter/milter.go +++ b/internal/milter/milter.go @@ -13,6 +13,7 @@ package milter import ( "context" "log" + "mime" "net" "net/textproto" "strings" @@ -92,11 +93,35 @@ func (s *session) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, erro // Header captures the Subject. Only the first Subject header is kept. func (s *session) Header(name, value string, m *milter.Modifier) (milter.Response, error) { if s.subject == "" && textproto.CanonicalMIMEHeaderKey(name) == "Subject" { - s.subject = value + s.subject = decodeSubject(value) } return milter.RespContinue, nil } +// subjectMaxRunes caps what the journal keeps of a subject. A Subject header +// may legally run to hundreds of characters; the log only needs enough to +// recognise the message, and the panel shows one row per recipient. +const subjectMaxRunes = 200 + +// decodeSubject turns the raw Subject header into display text. Anything +// non-ASCII arrives as RFC 2047 encoded-words (=?utf-8?Q?=D0=9F…?=), which the +// panel would otherwise show verbatim: unreadable, and — being one unbreakable +// run — wide enough to push the send-log table out of its card. Go's decoder +// covers the UTF-8 and ASCII charsets senders use in practice; for anything +// else (windows-1251, koi8-r) it fails and the raw header is kept, which is no +// worse than before. Truncation is applied after decoding so the cap counts +// characters of the subject, not bytes of its encoding. +func decodeSubject(v string) string { + if dec, err := (&mime.WordDecoder{}).DecodeHeader(v); err == nil { + v = dec + } + v = strings.TrimSpace(v) + if r := []rune(v); len(r) > subjectMaxRunes { + v = string(r[:subjectMaxRunes]) + "…" + } + return v +} + // Body fires at end-of-message, when the queue-id macro {i} is set and the // message is about to be committed to the queue. This is where the "queued" // rows are written. We accept (this milter is done) without ever rejecting. diff --git a/internal/milter/milter_test.go b/internal/milter/milter_test.go index 8b9bd41..999a43a 100644 --- a/internal/milter/milter_test.go +++ b/internal/milter/milter_test.go @@ -3,6 +3,7 @@ package milter import ( "errors" "net" + "strings" "testing" "time" @@ -113,6 +114,34 @@ func TestSessionRecordsRowPerRecipient(t *testing.T) { } } +// A subject in any non-ASCII alphabet reaches the milter as RFC 2047 +// encoded-words; the journal stores the text, not the encoding. +func TestHeaderDecodesEncodedSubject(t *testing.T) { + long := strings.Repeat("я", subjectMaxRunes+10) + + for _, tc := range []struct { + name, raw, want string + }{ + {"plain", "Hello there", "Hello there"}, + {"utf8 q", "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=", "Проверка"}, + {"utf8 b, folded across two words", "=?utf-8?B?0J/RgNC40LLQtdGC?=\r\n =?utf-8?B?INC80LjRgA==?=", "Привет мир"}, + // No decoder for the legacy single-byte charsets: keep the header as + // sent rather than losing the subject entirely. + {"unknown charset", "=?windows-1251?B?z/Do4uXy?=", "=?windows-1251?B?z/Do4uXy?="}, + {"too long", long, strings.Repeat("я", subjectMaxRunes) + "…"}, + } { + t.Run(tc.name, func(t *testing.T) { + s := &session{rec: &fakeRecorder{}} + if _, err := s.Header("Subject", tc.raw, mods(nil)); err != nil { + t.Fatalf("Header: %v", err) + } + if s.subject != tc.want { + t.Fatalf("subject = %q, want %q", s.subject, tc.want) + } + }) + } +} + func TestBodyAcceptsEvenWhenRecorderFails(t *testing.T) { rec := &fakeRecorder{fail: true} s := &session{rec: rec} diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index 0682e26..569c99a 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -98,6 +98,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { // a failing form. s.render(w, http.StatusOK, "login", map[string]any{ "Title": "SelfPost — Sign in", + "Active": "login", "SetupHint": true, }) return @@ -115,9 +116,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) { + // Active names the page for the layout even though no navigation is drawn + // here: it is what puts page-login on
, which the stylesheet uses to + // give the signed-out pages a column the width of their own card. s.render(w, status, "login", map[string]any{ - "Title": "SelfPost — Sign in", - "Error": formErr, + "Title": "SelfPost — Sign in", + "Active": "login", + "Error": formErr, }) } diff --git a/internal/web/handlers_setup.go b/internal/web/handlers_setup.go index 020aeeb..1505f75 100644 --- a/internal/web/handlers_setup.go +++ b/internal/web/handlers_setup.go @@ -42,9 +42,10 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { func (s *Server) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) { s.render(w, status, "setup", map[string]any{ - "Title": "SelfPost — Create administrator", - "Token": token, - "Error": formErr, + "Title": "SelfPost — Create administrator", + "Active": "setup", + "Token": token, + "Error": formErr, }) } diff --git a/internal/web/static/favicon.png b/internal/web/static/favicon.png index aa319b812437b0ea5089133632eaf7cb5d59ad42..cd7cc89be8bfa886858abccf23139520c4f231d1 100644 GIT binary patch literal 1654 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 rt;JfKr{CIX``J`_9Ak|o3 + + + + + diff --git a/internal/web/static/logo-compact.svg b/internal/web/static/logo-compact.svg new file mode 100644 index 0000000..3c03928 --- /dev/null +++ b/internal/web/static/logo-compact.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/internal/web/static/logo.svg b/internal/web/static/logo.svg new file mode 100644 index 0000000..cb46ba3 --- /dev/null +++ b/internal/web/static/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/internal/web/static/panel.css b/internal/web/static/panel.css index 5e4498b..2cede26 100644 --- a/internal/web/static/panel.css +++ b/internal/web/static/panel.css @@ -17,6 +17,13 @@ body { } main { max-width: 42rem; margin: 0 auto; } h1 { font-size: 1.4rem; margin: 0 0 1rem; } +/* The full mark, on the two pages that have no navigation bar to carry the + compact one. It takes the column's width so its edges line up with the card + below it, capped at that column's own 24rem; height stays automatic because + the stamp's proportions are part of the mark and it is never scaled unevenly. + The subtitle stops resolving below 280px, which a 320px viewport still clears + once the body's padding is taken off. */ +.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; } .card { background: #fff; border: 1px solid #e2e5e9; border-radius: 10px; padding: 1.5rem; margin: 0 auto; @@ -50,14 +57,47 @@ button:hover, a.btn:hover { background: #1d4ed8; } form's block layout; its button is styled like any other. */ form.inline { display: inline; margin: 0; } main { max-width: 48rem; } +/* 48rem is a reading measure: right for the forms and prose that make up most + of the panel, and the width the navigation bar's two rows were sized for. The + three monitoring pages hold data instead — seven columns of send-log, and raw + mail.log lines that are long by nature — and at that width the send-log's + Subject and Status were fighting over the last inch while the log pages wrapped + every second line. They get a wider measure; every other page keeps the narrow + one. The class comes from the layout, which stamps the page name onto
. */ +main.page-deliveries, main.page-mail_queue, main.page-system_log { max-width: 64rem; } +/* The signed-out pages are a single card and nothing else, and .card.narrow + centres itself inside whatever holds it — so at the panel's usual width the + card floated in the middle while the mark and the heading stayed at the far + left, three alignments on a page with four elements. Narrowing the column to + the card's own width makes the three line up and puts the block as a whole in + the middle of the page. */ +main.page-login, main.page-setup { max-width: 24rem; } .card + .card { margin-top: 1.2rem; } .flash { background: #ecfdf3; border: 1px solid #abefc6; color: #067647; padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; } @media (prefers-color-scheme: dark) { .flash { background: #0d2818 !important; border-color: #1a5336 !important; color: #75d99b !important; } } table { width: 100%; border-collapse: collapse; } -th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid #e2e5e9; } +/* A table column is at least as wide as the longest unbreakable run inside it, + and the panel's tables are full of runs with nothing to break on: email + addresses, domains, queue ids. One 40-character recipient was enough to widen + the send-log past its card and hang Status over the edge. Cells may break mid + word, so a column can always be squeezed to the width available. */ +th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid #e2e5e9; overflow-wrap: anywhere; } +/* The exception: a timestamp broken across two lines is unreadable, and it is + short enough to never be the reason a row does not fit. */ +td.time { white-space: nowrap; } @media (prefers-color-scheme: dark) { th, td { border-color: #2b3138 !important; } } th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; } td.actions { text-align: right; } +/* Subject is the one cell whose text we do not control. Breaking mid word (the + rule above) keeps it inside the card, but a long subject would do it by + growing the row several lines tall, which buries the rows around it. So the + subject is clipped to one line instead, with the whole of it in the tooltip. + The clamp sits on an inner block box rather than the cell because max-width + on a is only advisory in the automatic table layout. */ +td.subject span { + display: block; max-width: 18rem; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} .code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; background: #f0f2f4; border: 1px solid #e2e5e9; border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; } @media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } } @@ -77,12 +117,28 @@ textarea { resize: vertical; } } button.danger, a.danger { background: #b42318; } button.danger:hover, a.danger:hover { background: #912018; } -td.actions form.inline, td.actions details { margin: 0.4rem 0 0 0.5rem; } /* The disclosure toggle is an action too, so it is drawn as a button (see the compact rule below); the marker is dropped because the pressed background already shows the open state. */ -td.actions summary { display: inline-block; list-style: none; cursor: pointer; } -td.actions summary::-webkit-details-marker { display: none; } +.actions summary { display: inline-block; list-style: none; cursor: pointer; } +.actions summary::-webkit-details-marker { display: none; } +/* Applications are a list of blocks, not table rows. As a table it fell apart: + four columns of which the last held six controls — two of them
+ panels with textareas — never fit the panel's 48rem. The controls wrapped + into a staircase, .code on the login cell grew into a slab as tall as the + row, and the two text cells sat on the baseline halfway down it. One block + per application gives the identity a line of its own and the controls a row + of their own, at the width they actually need. */ +.apps { list-style: none; margin: 1.2rem 0 0; padding: 0; } +.app { padding: 0.9rem 0; border-top: 1px solid #e2e5e9; } +.app:last-child { padding-bottom: 0; } +@media (prefers-color-scheme: dark) { .app { border-color: #2b3138 !important; } } +.app-login { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; } +.app-addr { margin: 0.15rem 0 0; word-break: break-all; } +.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; } +/* An open panel claims a row to itself: its textareas and number inputs want + the block's full width, not the width of the summary that opened them. */ +.app .actions > details[open] { flex: 1 0 100%; } details form { margin-top: 0.6rem; } .credential { border-color: #f5c518; background: #fffbeb; } @media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } } @@ -107,9 +163,21 @@ details form { margin-top: 0.6rem; } } @media (prefers-color-scheme: dark) { .nav { border-color: #2b3138 !important; } } .nav .links, .nav .session { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; align-items: center; } -/* Pushed to the far edge so the two rows read as two blocks rather than as one +/* The top row: mark at one edge, session block at the other. Splitting them to + the edges is what makes the two rows read as two blocks rather than as one ragged list that happened to wrap. */ +.nav .top { display: flex; align-items: center; justify-content: space-between; gap: 0.9rem; } .nav .session { justify-content: flex-end; } +/* The mark is a link, but not one of the bar's entries: it takes none of the + padding and rounding the entry rule below applies, so its own edge lines up + with the page rather than sitting half a step inside it. The stamp carries + about 4px of field inside the file at this size, which is what puts it level + with the icons of the row underneath. */ +.nav .brand { padding: 0; } +/* width/height are on the element too, so the row reserves the space before the + SVG has loaded; these keep the ratio if the box is ever squeezed. The stamp's + proportions are part of the mark — it is never scaled unevenly. */ +.nav .brand img { display: block; width: 110px; height: auto; } /* Each entry pairs an icon with its label, so the entry itself is a flex row rather than a run of text — that is also why the bar centres its items instead of aligning them on the text baseline. Account is included: it is a @@ -152,30 +220,32 @@ details form { margin-top: 0.6rem; } .code-row .code { flex: 1; min-width: 0; } /* Compact outlined button: same affordance as the filled one but quiet enough that several can sit together without shouting — the Copy buttons beside a - value, the per-application actions in a table row. Sign out overrides this - with .danger below since signing out is a deliberate, singular action. */ -button.copy, td.actions button, td.actions summary, td.actions a.danger, .nav button { + value, the controls of a table row or of an application block. Sign out + overrides this with .danger below since signing out is a deliberate, + singular action. .actions is the shared hook: a cell that holds controls, or + the control row of an application. */ +button.copy, .actions button, .actions summary, .actions a.danger, .nav button { margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600; border-radius: 6px; white-space: nowrap; background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da; } -button.copy:hover, td.actions button:hover, td.actions summary:hover, -td.actions a.danger:hover, .nav button:hover { background: #e2e7ee; } +button.copy:hover, .actions button:hover, .actions summary:hover, +.actions a.danger:hover, .nav button:hover { background: #e2e7ee; } button.copy { flex: none; margin-top: 0.3rem; } -td.actions details[open] > summary { background: #dde3ec; } -td.actions button.danger, td.actions a.danger, .nav button.danger { +.actions details[open] > summary { background: #dde3ec; } +.actions button.danger, .actions a.danger, .nav button.danger { color: #b42318; background: #fef3f2; border-color: #fecdca; } -td.actions button.danger:hover, td.actions a.danger:hover, .nav button.danger:hover { background: #fee4e2; } +.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: #fee4e2; } @media (prefers-color-scheme: dark) { - button.copy, td.actions button, td.actions summary, td.actions a.danger, .nav button { + button.copy, .actions button, .actions summary, .actions a.danger, .nav button { background: #22262b !important; border-color: #2b3138 !important; } - button.copy:hover, td.actions button:hover, td.actions summary:hover, - td.actions a.danger:hover, .nav button:hover { background: #2b3138 !important; } - td.actions details[open] > summary { background: #313841 !important; } - td.actions button.danger, td.actions a.danger, .nav button.danger { + button.copy:hover, .actions button:hover, .actions summary:hover, + .actions a.danger:hover, .nav button:hover { background: #2b3138 !important; } + .actions details[open] > summary { background: #313841 !important; } + .actions button.danger, .actions a.danger, .nav button.danger { color: #f5a29b !important; background: #2d1211 !important; border-color: #6b201a !important; } - td.actions button.danger:hover, td.actions a.danger:hover, .nav button.danger:hover { background: #3d1a18 !important; } + .actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: #3d1a18 !important; } } diff --git a/internal/web/templates/deliveries_rows.html b/internal/web/templates/deliveries_rows.html index a7f5455..8aa6024 100644 --- a/internal/web/templates/deliveries_rows.html +++ b/internal/web/templates/deliveries_rows.html @@ -9,12 +9,12 @@ {{range .Rows}} - {{.CreatedAt.Format "2006-01-02 15:04:05"}} + {{.CreatedAt.Format "2006-01-02 15:04:05"}} {{.Domain}} {{.AppLogin}} {{.From}} {{.To}} - {{.Subject}} + {{.Subject}} {{.Status}} {{end}} diff --git a/internal/web/templates/domain_detail.html b/internal/web/templates/domain_detail.html index da04ef8..879102a 100644 --- a/internal/web/templates/domain_detail.html +++ b/internal/web/templates/domain_detail.html @@ -180,21 +180,15 @@ domain (wildcard) or only from a fixed list of addresses.

{{if .Apps}} - - - - - - {{range .Apps}} - - - - - - - {{end}} - -
LoginModeAddresses
{{.Login}}{{if eq .AddressMode $.Wildcard}}Any address (@{{$.Domain.Name}}){{else}}List{{end}} - {{if eq .AddressMode $.Wildcard}}*@{{$.Domain.Name}}{{else}} - {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}} - {{end}} - +
    + {{range .Apps}} +
  • + +

    + {{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}} + {{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}} +

    +
    Edit mode
    @@ -238,11 +232,10 @@ data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
    -
+ + + {{end}} + {{else}}

No applications yet. Add one above to get started.

{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index d4ec6d2..5b3249c 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -4,7 +4,13 @@ {{.Title}} - +{{/* The tab icon is the stamp's small-size variant — the initials rather than + the two-line wordmark, which stops resolving below 32px (the thresholds are + recorded in docs/assets/selfpost-proof.html). The PNG is declared first and + the SVG second: a browser takes the last icon whose type it understands, so + vector wins wherever it is supported and the raster catches the rest. */}} + + {{/* Unless told otherwise, htmx injects a stylesheet element of its own into the head for the request-indicator classes. The panel uses no @@ -16,7 +22,12 @@ -
+{{/* The page name travels onto
as a class so the stylesheet can size a + page to what it holds — the monitoring pages are wider than the panel's + form pages (see main.page-* in panel.css). It is .Active, already carried + for the navigation, rather than a second field every handler would have to + remember to set. */}} +
{{if .User}}{{template "nav" .}}{{end}} {{template "content" .}} {{/* The running version, on every authenticated page: it is what a backup @@ -35,15 +46,20 @@ .User and get no nav. */}} {{define "nav"}}