diff --git a/mail/announcement.go b/mail/announcement.go index afc4316..3b98a06 100644 --- a/mail/announcement.go +++ b/mail/announcement.go @@ -50,20 +50,31 @@ func RenderAnnouncement(a Announcement, publicURL string) (subject, html, text s // SendAnnouncement sends one announcement to one address, with the // List-Unsubscribe pair Gmail and Yahoo require of bulk senders. func (s Sender) SendAnnouncement(to string, a Announcement) error { + m, err := s.announcementFor(to, a) + if err != nil { + return err + } + return s.send(m) +} + +// announcementFor builds the one-recipient message SendAnnouncement puts on +// the wire. Every refusal is a *SendError at PhasePrepare: it is about this +// one message, never the mail server, so a caller moves on to the next row. +func (s Sender) announcementFor(to string, a Announcement) (message, error) { if strings.Contains(to, ",") { - return fmt.Errorf("mail: an announcement goes to exactly one address") + return message{}, &SendError{Phase: PhasePrepare, Err: fmt.Errorf("mail: an announcement goes to exactly one address")} } if a.UnsubscribeURL == "" { - return fmt.Errorf("mail: announcement without an unsubscribe URL") + return message{}, &SendError{Phase: PhasePrepare, Err: fmt.Errorf("mail: announcement without an unsubscribe URL")} } m, err := announcementMessage(a, s.PublicURL) if err != nil { - return err + return message{}, &SendError{Phase: PhasePrepare, Err: err} } m.To = to m.Headers = map[string]string{ "List-Unsubscribe": "<" + a.UnsubscribeURL + ">", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", } - return s.send(m) + return m, nil } diff --git a/mail/announcement_test.go b/mail/announcement_test.go index 43bde96..e4b6d35 100644 --- a/mail/announcement_test.go +++ b/mail/announcement_test.go @@ -1,6 +1,9 @@ package mail import ( + "bytes" + "errors" + "net/mail" "strings" "testing" ) @@ -36,7 +39,7 @@ func TestRenderAnnouncement(t *testing.T) { t.Errorf("%s body missing %q", label, want) } } - if strings.Contains(body, "—") { + if strings.Contains(body, "\u2014") { t.Errorf("%s body contains an em dash", label) } } @@ -59,12 +62,35 @@ func TestRenderAnnouncementWithoutPostalAddress(t *testing.T) { func TestSendAnnouncementRefusesListsAndMissingLink(t *testing.T) { s := Sender{Host: "smtp.invalid", From: "updates@example.com"} - if err := s.SendAnnouncement("a@example.com, b@example.com", sampleAnnouncement()); err == nil { - t.Error("a comma-separated To must be refused") + prepare := func(what string, err error) { + t.Helper() + var se *SendError + if !errors.As(err, &se) || se.Phase != PhasePrepare { + t.Errorf("%s: err = %v, want a *SendError at PhasePrepare", what, err) + } } + prepare("a comma-separated To", s.SendAnnouncement("a@example.com, b@example.com", sampleAnnouncement())) a := sampleAnnouncement() a.UnsubscribeURL = "" - if err := s.SendAnnouncement("a@example.com", a); err == nil { - t.Error("an announcement without an unsubscribe URL must be refused") + prepare("no unsubscribe URL", s.SendAnnouncement("a@example.com", a)) +} + +// Gmail and Yahoo require both headers of the RFC 8058 pair on bulk mail. +func TestSendAnnouncementPutsListUnsubscribeOnTheWire(t *testing.T) { + port, got := fakeSMTPData(t, nil) + s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"} + a := sampleAnnouncement() + if err := s.SendAnnouncement("a@example.com", a); err != nil { + t.Fatal(err) + } + msg, err := mail.ReadMessage(bytes.NewReader(<-got)) + if err != nil { + t.Fatal(err) + } + if h := msg.Header.Get("List-Unsubscribe"); h != "<"+a.UnsubscribeURL+">" { + t.Errorf("List-Unsubscribe = %q", h) + } + if h := msg.Header.Get("List-Unsubscribe-Post"); h != "List-Unsubscribe=One-Click" { + t.Errorf("List-Unsubscribe-Post = %q", h) } }