panel: show the subject as text, not as its MIME encoding
A non-Latin subject arrives as RFC 2047 encoded-words, which the send-log printed verbatim: unreadable, and one unbreakable run wide enough to push the Status column out of its card. Decode at journal time (UTF-8/ASCII; exotic charsets keep the raw header) and cap at 200 characters, then clip the column to one line with the full text in the tooltip so no subject can widen the row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.3.0] - 2026-08-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ package milter
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"strings"
|
"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.
|
// Header captures the Subject. Only the first Subject header is kept.
|
||||||
func (s *session) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
|
func (s *session) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
|
||||||
if s.subject == "" && textproto.CanonicalMIMEHeaderKey(name) == "Subject" {
|
if s.subject == "" && textproto.CanonicalMIMEHeaderKey(name) == "Subject" {
|
||||||
s.subject = value
|
s.subject = decodeSubject(value)
|
||||||
}
|
}
|
||||||
return milter.RespContinue, nil
|
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
|
// 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"
|
// 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.
|
// rows are written. We accept (this milter is done) without ever rejecting.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package milter
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestBodyAcceptsEvenWhenRecorderFails(t *testing.T) {
|
||||||
rec := &fakeRecorder{fail: true}
|
rec := &fakeRecorder{fail: true}
|
||||||
s := &session{rec: rec}
|
s := &session{rec: rec}
|
||||||
|
|||||||
@@ -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; } }
|
@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; }
|
th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; }
|
||||||
td.actions { text-align: right; }
|
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 <td> 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;
|
.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; }
|
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; } }
|
@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, a.danger { background: #b42318; }
|
||||||
button.danger:hover, a.danger:hover { background: #912018; }
|
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
|
/* 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
|
compact rule below); the marker is dropped because the pressed background
|
||||||
already shows the open state. */
|
already shows the open state. */
|
||||||
td.actions summary { display: inline-block; list-style: none; cursor: pointer; }
|
.actions summary { display: inline-block; list-style: none; cursor: pointer; }
|
||||||
td.actions summary::-webkit-details-marker { display: none; }
|
.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 <details>
|
||||||
|
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; }
|
details form { margin-top: 0.6rem; }
|
||||||
.credential { border-color: #f5c518; background: #fffbeb; }
|
.credential { border-color: #f5c518; background: #fffbeb; }
|
||||||
@media (prefers-color-scheme: dark) { .credential { background: #2a2408 !important; border-color: #6b5a10 !important; } }
|
@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; }
|
.code-row .code { flex: 1; min-width: 0; }
|
||||||
/* Compact outlined button: same affordance as the filled one but quiet enough
|
/* Compact outlined button: same affordance as the filled one but quiet enough
|
||||||
that several can sit together without shouting — the Copy buttons beside a
|
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
|
value, the controls of a table row or of an application block. Sign out
|
||||||
with .danger below since signing out is a deliberate, singular action. */
|
overrides this with .danger below since signing out is a deliberate,
|
||||||
button.copy, td.actions button, td.actions summary, td.actions a.danger, .nav button {
|
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;
|
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
|
||||||
border-radius: 6px; white-space: nowrap;
|
border-radius: 6px; white-space: nowrap;
|
||||||
background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da;
|
background: #eef1f5; color: #2563eb; border: 1px solid #cfd4da;
|
||||||
}
|
}
|
||||||
button.copy:hover, td.actions button:hover, td.actions summary:hover,
|
button.copy:hover, .actions button:hover, .actions summary:hover,
|
||||||
td.actions a.danger:hover, .nav button:hover { background: #e2e7ee; }
|
.actions a.danger:hover, .nav button:hover { background: #e2e7ee; }
|
||||||
button.copy { flex: none; margin-top: 0.3rem; }
|
button.copy { flex: none; margin-top: 0.3rem; }
|
||||||
td.actions details[open] > summary { background: #dde3ec; }
|
.actions details[open] > summary { background: #dde3ec; }
|
||||||
td.actions button.danger, td.actions a.danger, .nav button.danger {
|
.actions button.danger, .actions a.danger, .nav button.danger {
|
||||||
color: #b42318; background: #fef3f2; border-color: #fecdca;
|
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) {
|
@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;
|
background: #22262b !important; border-color: #2b3138 !important;
|
||||||
}
|
}
|
||||||
button.copy:hover, td.actions button:hover, td.actions summary:hover,
|
button.copy:hover, .actions button:hover, .actions summary:hover,
|
||||||
td.actions a.danger:hover, .nav button:hover { background: #2b3138 !important; }
|
.actions a.danger:hover, .nav button:hover { background: #2b3138 !important; }
|
||||||
td.actions details[open] > summary { background: #313841 !important; }
|
.actions details[open] > summary { background: #313841 !important; }
|
||||||
td.actions button.danger, td.actions a.danger, .nav button.danger {
|
.actions button.danger, .actions a.danger, .nav button.danger {
|
||||||
color: #f5a29b !important; background: #2d1211 !important; border-color: #6b201a !important;
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<td>{{.AppLogin}}</td>
|
<td>{{.AppLogin}}</td>
|
||||||
<td>{{.From}}</td>
|
<td>{{.From}}</td>
|
||||||
<td>{{.To}}</td>
|
<td>{{.To}}</td>
|
||||||
<td>{{.Subject}}</td>
|
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
|
||||||
<td>{{.Status}}</td>
|
<td>{{.Status}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -180,21 +180,15 @@
|
|||||||
domain (<em>wildcard</em>) or only from a fixed list of addresses.</p>
|
domain (<em>wildcard</em>) or only from a fixed list of addresses.</p>
|
||||||
|
|
||||||
{{if .Apps}}
|
{{if .Apps}}
|
||||||
<table>
|
<ul class="apps">
|
||||||
<thead>
|
|
||||||
<tr><th>Login</th><th>Mode</th><th>Addresses</th><th></th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Apps}}
|
{{range .Apps}}
|
||||||
<tr>
|
<li class="app">
|
||||||
<td class="code">{{.Login}}</td>
|
<p class="app-login">{{.Login}}</p>
|
||||||
<td>{{if eq .AddressMode $.Wildcard}}Any address (@{{$.Domain.Name}}){{else}}List{{end}}</td>
|
<p class="app-addr muted">
|
||||||
<td class="muted">
|
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
|
||||||
{{if eq .AddressMode $.Wildcard}}*@{{$.Domain.Name}}{{else}}
|
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
|
||||||
{{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}
|
</p>
|
||||||
{{end}}
|
<div class="actions">
|
||||||
</td>
|
|
||||||
<td class="actions">
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Edit mode</summary>
|
<summary>Edit mode</summary>
|
||||||
<form method="post" action="/applications/{{.ID}}/mode">
|
<form method="post" action="/applications/{{.ID}}/mode">
|
||||||
@@ -238,11 +232,10 @@
|
|||||||
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
|
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
|
||||||
<button type="submit" class="danger">Delete</button>
|
<button type="submit" class="danger">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</ul>
|
||||||
</table>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="muted">No applications yet. Add one above to get started.</p>
|
<p class="muted">No applications yet. Add one above to get started.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user