diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9929f99..495c71f 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}}
-
-
- | Login | Mode | Addresses | |
-
-
- {{range .Apps}}
-
- | {{.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}}
+ -
+
{{.Login}}
+
+ {{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
- |
-
- {{end}}
-
-
+
+
+ {{end}}
+
{{else}}
No applications yet. Add one above to get started.
{{end}}
|