release: 0.6.0: Delivery detail page, DNS badge, machine metrics, wide layout

A page per delivery (`/deliveries/{id}`), reached from the *Details* link on
Added:
  - A page per delivery (`/deliveries/{id}`), reached from the *Details* link on
  - A **DNS** badge in the domain list, one per row, carrying the same
  - Machine metrics on the status page: a **Machine** card reporting the
  - An index of the current page's own sections in the navigation column, for the
Changed:
  - Every panel page is laid out in one column of the same width, so moving
  - The mark's small-size variant — the tab icon and the `SP` initials it
  - The stamp's `SELF-HOSTED SMTP RELAY` line is set at 11.5/0.15 instead of
  - The delivery log lists what identifies a message and nothing else: time,
  - Subjects are decoded for display as well as on the way in, so the rows the
  … (4 more in CHANGELOG)

Full detail: CHANGELOG.md (0.6.0).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
parent 5e5d157496
commit 83c488cc48
40 changed files with 2207 additions and 242 deletions
+2 -26
View File
@@ -14,7 +14,6 @@ package milter
import (
"context"
"log"
"mime"
"net"
"net/textproto"
"strings"
@@ -22,6 +21,7 @@ import (
"github.com/emersion/go-milter"
"github.com/mixeme/selfpost/internal/mailhdr"
"github.com/mixeme/selfpost/internal/store"
)
@@ -101,35 +101,11 @@ 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 = decodeSubject(value)
s.subject = mailhdr.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.
+8 -24
View File
@@ -3,7 +3,6 @@ package milter
import (
"errors"
"net"
"strings"
"testing"
"time"
@@ -115,30 +114,15 @@ 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.
// encoded-words; the journal stores the text, not the encoding. The decoding
// itself is mailhdr's; what this checks is that Header runs it.
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)
}
})
s := &session{rec: &fakeRecorder{}}
if _, err := s.Header("Subject", "=?utf-8?Q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=", mods(nil)); err != nil {
t.Fatalf("Header: %v", err)
}
if s.subject != "Проверка" {
t.Fatalf("subject = %q, want %q", s.subject, "Проверка")
}
}