diff --git a/CHANGELOG.md b/CHANGELOG.md index 49637de..3fac906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### 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. + ## [0.3.0] - 2026-08-03 ### Fixed 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/static/panel.css b/internal/web/static/panel.css index 5e4498b..6e6fe57 100644 --- a/internal/web/static/panel.css +++ b/internal/web/static/panel.css @@ -58,6 +58,15 @@ th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid #e2e @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, and a table sizes a + column to its longest unbreakable run — a long subject widens the row until + Status hangs past the card's edge. 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; the full subject stays in the tooltip. */ +td.subject span { + display: block; max-width: 16rem; + 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 +86,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; } } @@ -152,30 +177,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..9db934f 100644 --- a/internal/web/templates/deliveries_rows.html +++ b/internal/web/templates/deliveries_rows.html @@ -14,7 +14,7 @@ {{.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}}