Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91d10a7650 | ||
|
|
91841174dd |
+42
-17
@@ -14,7 +14,9 @@ import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
netmail "net/mail"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"os"
|
||||
@@ -135,11 +137,11 @@ func (s Sender) send(m message) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(s.From); err != nil {
|
||||
if err := client.Mail(addrSpec(s.From)); err != nil {
|
||||
return fmt.Errorf("smtp: mail from: %w", err)
|
||||
}
|
||||
for _, rcpt := range rcpts {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
if err := client.Rcpt(addrSpec(rcpt)); err != nil {
|
||||
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
|
||||
}
|
||||
}
|
||||
@@ -162,6 +164,20 @@ func (s Sender) send(m message) error {
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// addrSpec is the bare address for the SMTP envelope. SMTP_FROM is usually
|
||||
// "Vantage <support@example.com>", which belongs in the From header only:
|
||||
// sent as MAIL FROM it became "<Vantage <support@example.com>>". Postfix
|
||||
// salvaged the address, so delivery and Return-Path looked fine, but rspamd
|
||||
// saw no envelope sender and mailcow skipped DKIM signing, so Gmail filed
|
||||
// every Vantage email as spam. Anything that does not parse is passed through
|
||||
// for the server to judge.
|
||||
func addrSpec(v string) string {
|
||||
if a, err := netmail.ParseAddress(v); err == nil {
|
||||
return a.Address
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func recipients(to string) []string {
|
||||
parts := strings.Split(to, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
@@ -180,27 +196,19 @@ 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), which rspamd scored as
|
||||
// R_BAD_CTE_7BIT.
|
||||
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 +232,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 {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The envelope carries the bare address. "Vantage <x@y>" as MAIL FROM left
|
||||
// rspamd with no envelope sender, and mailcow skipped DKIM signing.
|
||||
func TestAddrSpecStripsDisplayName(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Vantage <support@example.com>": "support@example.com",
|
||||
"support@example.com": "support@example.com",
|
||||
" a@example.com ": "a@example.com",
|
||||
"not an address": "not an address",
|
||||
} {
|
||||
if got := addrSpec(in); got != want {
|
||||
t.Errorf("addrSpec(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user