From a901482e5dd068c2eec322dab2c8331a663569b1 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 11 Sep 2026 13:33:12 +0000 Subject: [PATCH] feat: per-message extra headers in the mail envelope --- mail/sender.go | 14 ++++++++++++++ mail/sender_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/mail/sender.go b/mail/sender.go index c021a4d..9f5eaa7 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -20,6 +20,7 @@ import ( "net/smtp" "net/textproto" "os" + "sort" "strings" "time" ) @@ -76,6 +77,11 @@ type message struct { Subject string HTML string Text string + + // Headers are extra header lines for this one message, such as an + // announcement's List-Unsubscribe pair. Keys and values are CR/LF-stripped + // like every other header. + Headers map[string]string } // sendTemplate renders name against data and delivers the result. @@ -226,6 +232,14 @@ func (s Sender) envelope(m message) ([]byte, error) { b.WriteString("Message-ID: " + messageID(s.From) + "\r\n") b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(m.Subject)) + "\r\n") b.WriteString("MIME-Version: 1.0\r\n") + keys := make([]string, 0, len(m.Headers)) + for k := range m.Headers { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b.WriteString(sanitizeHeader(k) + ": " + sanitizeHeader(m.Headers[k]) + "\r\n") + } b.WriteString("Content-Type: multipart/alternative; boundary=" + w.Boundary() + "\r\n") b.WriteString("\r\n") b.WriteString(parts.String()) diff --git a/mail/sender_test.go b/mail/sender_test.go index 6ddb906..fa776ff 100644 --- a/mail/sender_test.go +++ b/mail/sender_test.go @@ -83,3 +83,30 @@ func TestEnvelopePartsAreQuotedPrintable(t *testing.T) { } } } + +func TestEnvelopeWritesExtraHeadersSanitised(t *testing.T) { + s := Sender{From: "Vantage "} + raw, err := s.envelope(message{ + To: "a@example.com", Subject: "s", Text: "t", HTML: "

h

", + Headers: map[string]string{ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "\r\nBcc: evil@example.com", + }, + }) + if err != nil { + t.Fatal(err) + } + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + if got := msg.Header.Get("List-Unsubscribe-Post"); got != "List-Unsubscribe=One-Click" { + t.Fatalf("List-Unsubscribe-Post = %q", got) + } + if got := msg.Header.Get("Bcc"); got != "" { + t.Fatalf("header injection: Bcc = %q", got) + } + if !strings.HasPrefix(msg.Header.Get("List-Unsubscribe"), "") { + t.Fatalf("List-Unsubscribe = %q", msg.Header.Get("List-Unsubscribe")) + } +}