fix: send email parts as quoted-printable so they are 7-bit clean and DKIM-signed

This commit is contained in:
2026-09-11 09:57:40 +00:00
parent fbc9d4b9c4
commit 91841174dd
2 changed files with 97 additions and 15 deletions
+27 -15
View File
@@ -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 {
+70
View File
@@ -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 <support@example.com>"}
m := message{
To: "a@example.com",
Subject: "Smith & Sons · restored",
Text: "VANTAGE · Account\nBilling has resumed.",
HTML: "<p>VANTAGE · Account</p><p>" + strings.Repeat("x", 2000) + "</p>",
}
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)
}
}
}