From 91841174dd37c495dd35d8a66b2f7cfcafe46b8c Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 11 Sep 2026 09:57:40 +0000 Subject: [PATCH] fix: send email parts as quoted-printable so they are 7-bit clean and DKIM-signed --- mail/sender.go | 42 +++++++++++++++++---------- mail/sender_test.go | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 mail/sender_test.go diff --git a/mail/sender.go b/mail/sender.go index a5eda0c..5d4d506 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -14,6 +14,7 @@ import ( "fmt" "mime" "mime/multipart" + "mime/quotedprintable" "net" "net/smtp" "net/textproto" @@ -180,27 +181,21 @@ func recipients(to string) []string { // text part is sent beside every HTML one for the same reason, and because a // client that refuses HTML should not receive a blank message. Header values // are stripped of CR and LF so a crafted instance name cannot inject headers. +// +// Both parts are quoted-printable. They were raw UTF-8 with no +// Content-Transfer-Encoding, which means 7bit, and every template carries +// non-ASCII (the middot in the masthead at least). rspamd scored that +// R_BAD_CTE_7BIT, the message went out without a DKIM signature, and Gmail +// filed it as spam, while mail from the same mailbox via SOGo, sent +// quoted-printable, was signed and delivered. func (s Sender) envelope(m message) ([]byte, error) { var parts strings.Builder w := multipart.NewWriter(&parts) - textPart, err := w.CreatePart(textproto.MIMEHeader{ - "Content-Type": {"text/plain; charset=utf-8"}, - }) - if err != nil { + if err := writeQPPart(w, "text/plain; charset=utf-8", m.Text); err != nil { return nil, err } - if _, err := textPart.Write([]byte(m.Text)); err != nil { - return nil, err - } - - htmlPart, err := w.CreatePart(textproto.MIMEHeader{ - "Content-Type": {"text/html; charset=utf-8"}, - }) - if err != nil { - return nil, err - } - if _, err := htmlPart.Write([]byte(m.HTML)); err != nil { + if err := writeQPPart(w, "text/html; charset=utf-8", m.HTML); err != nil { return nil, err } @@ -224,6 +219,23 @@ func (s Sender) envelope(m message) ([]byte, error) { return []byte(b.String()), nil } +// writeQPPart adds one quoted-printable part, so the wire stays 7-bit and +// lines stay under SMTP's 998-byte limit whatever the template produced. +func writeQPPart(w *multipart.Writer, contentType, body string) error { + part, err := w.CreatePart(textproto.MIMEHeader{ + "Content-Type": {contentType}, + "Content-Transfer-Encoding": {"quoted-printable"}, + }) + if err != nil { + return err + } + qp := quotedprintable.NewWriter(part) + if _, err := qp.Write([]byte(body)); err != nil { + return err + } + return qp.Close() +} + func messageID(from string) string { domain := "vantage.local" if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 { diff --git a/mail/sender_test.go b/mail/sender_test.go new file mode 100644 index 0000000..e291d36 --- /dev/null +++ b/mail/sender_test.go @@ -0,0 +1,70 @@ +package mail + +import ( + "bytes" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "strings" + "testing" +) + +// Every part must declare quoted-printable and put only 7-bit bytes on the +// wire. Raw UTF-8 under an implied 7bit encoding scored R_BAD_CTE_7BIT in +// rspamd, went out without a DKIM signature, and landed in Gmail's spam. +func TestEnvelopePartsAreQuotedPrintable(t *testing.T) { + s := Sender{From: "Vantage "} + m := message{ + To: "a@example.com", + Subject: "Smith & Sons · restored", + Text: "VANTAGE · Account\nBilling has resumed.", + HTML: "

VANTAGE · Account

" + strings.Repeat("x", 2000) + "

", + } + raw, err := s.envelope(m) + if err != nil { + t.Fatal(err) + } + for i, c := range raw { + if c > 0x7e && c != '\r' && c != '\n' { + t.Fatalf("byte %d is 0x%x: the wire must be 7-bit", i, c) + } + } + for _, line := range strings.Split(string(raw), "\r\n") { + if len(line) > 998 { + t.Fatalf("line of %d bytes exceeds SMTP's 998", len(line)) + } + } + + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + _, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) + if err != nil { + t.Fatal(err) + } + r := multipart.NewReader(msg.Body, params["boundary"]) + want := []string{m.Text, m.HTML} + for i := range want { + p, err := r.NextRawPart() + if err != nil { + t.Fatalf("part %d: %v", i, err) + } + if cte := p.Header.Get("Content-Transfer-Encoding"); cte != "quoted-printable" { + t.Fatalf("part %d Content-Transfer-Encoding = %q", i, cte) + } + // NextPart would decode for us; NextRawPart keeps the check honest by + // decoding here, so a missing header cannot pass by accident. + body, err := io.ReadAll(quotedprintable.NewReader(p)) + if err != nil { + t.Fatal(err) + } + // Quoted-printable text mode writes line breaks as CRLF, which is the + // canonical form on the wire. + if strings.ReplaceAll(string(body), "\r\n", "\n") != want[i] { + t.Fatalf("part %d decodes to %q", i, body) + } + } +}