From e4cd94d646cc9b1216ba58322f6c15c784095d60 Mon Sep 17 00:00:00 2001 From: Darkclainer Date: Fri, 4 Aug 2023 00:58:43 +0400 Subject: [PATCH] fix: correct messages with empty/partially empty bodies (#85) (#86) --- mime.go | 53 ++++++++++++++++++++++---------- mime_test.go | 86 +++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 99 insertions(+), 40 deletions(-) diff --git a/mime.go b/mime.go index 2892723..73de730 100644 --- a/mime.go +++ b/mime.go @@ -47,6 +47,22 @@ func (m *MailYak) buildMimeWithBoundaries(w io.Writer, mb, ab string) error { return err } + var ( + hasBody = m.html.Len() != 0 || m.plain.Len() != 0 + hasAttachments = len(m.attachments) != 0 + ) + + // if we don't have text/html body or attachments we can skip Content-Type header + // in that case next default will be assumed + // Content-type: text/plain; charset=us-ascii + // See https://datatracker.ietf.org/doc/html/rfc2045#page-14 + if !hasBody && !hasAttachments { + // The RFC said that body can be ommited: https://datatracker.ietf.org/doc/html/rfc822#section-3.1 + // but for example mailhog can't correctly read message without any body. + // This CRLF just separate header section. + _, err := fmt.Fprint(w, "\r\n") + return err + } // Start our multipart/mixed part mixed := multipart.NewWriter(w) if err := mixed.SetBoundary(mb); err != nil { @@ -58,18 +74,17 @@ func (m *MailYak) buildMimeWithBoundaries(w io.Writer, mb, ab string) error { tryWrite := func() error { fmt.Fprintf(w, "Content-Type: multipart/mixed;\r\n\tboundary=\"%s\"; charset=UTF-8\r\n\r\n", mixed.Boundary()) - ctype := fmt.Sprintf("multipart/alternative;\r\n\tboundary=\"%s\"", ab) - - altPart, err := mixed.CreatePart(textproto.MIMEHeader{"Content-Type": {ctype}}) - if err != nil { - return err + if hasBody { + if err := m.writeAlternativePart(mixed, ab); err != nil { + return err + } } - - if err := m.writeBody(altPart, ab); err != nil { - return err + if hasAttachments { + if err := m.writeAttachments(mixed, lineSplitterBuilder{}); err != nil { + return err + } } - - return m.writeAttachments(mixed, lineSplitterBuilder{}) + return nil } if err := tryWrite(); err != nil { @@ -82,7 +97,6 @@ func (m *MailYak) buildMimeWithBoundaries(w io.Writer, mb, ab string) error { // writeHeaders writes the MIME-Version, Date, Reply-To, From, To and Subject headers, // plus any custom headers set via AddHeader(). func (m *MailYak) writeHeaders(w io.Writer) error { - if _, err := w.Write([]byte(m.fromHeader())); err != nil { return err } @@ -133,13 +147,20 @@ func (m *MailYak) fromHeader() string { return fmt.Sprintf("From: %s <%s>\r\n", m.fromName, m.fromAddr) } -// writeBody writes the text/plain and text/html mime parts. -func (m *MailYak) writeBody(w io.Writer, boundary string) error { - if m.plain.Len() == 0 && m.html.Len() == 0 { - // No body to write - just skip it - return nil +func (m *MailYak) writeAlternativePart(mixed *multipart.Writer, boundary string) error { + ctype := fmt.Sprintf("multipart/alternative;\r\n\tboundary=\"%s\"", boundary) + + altPart, err := mixed.CreatePart(textproto.MIMEHeader{"Content-Type": {ctype}}) + if err != nil { + return err } + return m.writeBody(altPart, boundary) +} + +// writeBody writes the text/plain and text/html mime parts. +// It's incorrect to call writeBody without html or plain content. +func (m *MailYak) writeBody(w io.Writer, boundary string) error { alt := multipart.NewWriter(w) if err := alt.SetBoundary(boundary); err != nil { diff --git a/mime_test.go b/mime_test.go index 68878d8..7798a08 100644 --- a/mime_test.go +++ b/mime_test.go @@ -5,7 +5,9 @@ import ( "fmt" "io" "io/ioutil" + "mime" "mime/multipart" + "net/mail" "regexp" "strings" "testing" @@ -216,14 +218,6 @@ func TestMailYakWriteBody(t *testing.T) { wantW string wantErr bool }{ - { - "Empty", - "", - "", - "test", - "", - false, - }, { "HTML", "HTML", @@ -287,13 +281,14 @@ func TestMailYakBuildMime(t *testing.T) { // Test description. name string // Receiver fields. - rHTML []byte - rPlain []byte - rtoAddrs []string - rsubject string - rfromAddr string - rfromName string - rreplyTo string + rHTML []byte + rPlain []byte + rtoAddrs []string + rsubject string + rfromAddr string + rfromName string + rreplyTo string + rAttachemnt string // Expected results. want string wantErr bool @@ -307,7 +302,8 @@ func TestMailYakBuildMime(t *testing.T) { "", "", "", - "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Type: multipart/alternative;\r\n\tboundary=\"alt\"\r\n\r\n\r\n--mixed--\r\n", + "", + "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\n\r\n", false, }, { @@ -319,6 +315,7 @@ func TestMailYakBuildMime(t *testing.T) { "", "", "", + "", "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Type: multipart/alternative;\r\n\tboundary=\"alt\"\r\n\r\n--alt\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=UTF-8\r\n\r\nHTML\r\n--alt--\r\n\r\n--mixed--\r\n", false, }, @@ -331,9 +328,23 @@ func TestMailYakBuildMime(t *testing.T) { "", "", "", + "", "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Type: multipart/alternative;\r\n\tboundary=\"alt\"\r\n\r\n--alt\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nPlain\r\n--alt--\r\n\r\n--mixed--\r\n", false, }, + { + "Attachemnt", + []byte{}, + []byte{}, + []string{""}, + "", + "", + "", + "", + "attachment", + "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Disposition: attachment;\n\tfilename=\"testAttachment\"; name=\"testAttachment\"\r\nContent-ID: \r\nContent-Transfer-Encoding: base64\r\nContent-Type: text/plain; charset=utf-8;\n\tfilename=\"testAttachment\"; name=\"testAttachment\"\r\n\r\nYXR0YWNobWVudA==\r\n--mixed--\r\n", + false, + }, { "Reply-To", []byte{}, @@ -343,7 +354,8 @@ func TestMailYakBuildMime(t *testing.T) { "", "", "reply", - "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nReply-To: reply\r\nSubject: \r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Type: multipart/alternative;\r\n\tboundary=\"alt\"\r\n\r\n\r\n--mixed--\r\n", + "", + "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nReply-To: reply\r\nSubject: \r\nTo: \r\n\r\n", false, }, { @@ -355,7 +367,8 @@ func TestMailYakBuildMime(t *testing.T) { "", "name", "", - "From: name <>\r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Type: multipart/alternative;\r\n\tboundary=\"alt\"\r\n\r\n\r\n--mixed--\r\n", + "", + "From: name <>\r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\n\r\n", false, }, { @@ -367,7 +380,8 @@ func TestMailYakBuildMime(t *testing.T) { "addr", "name", "", - "From: name \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Type: multipart/alternative;\r\n\tboundary=\"alt\"\r\n\r\n\r\n--mixed--\r\n", + "", + "From: name \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\n\r\n", false, }, { @@ -379,7 +393,8 @@ func TestMailYakBuildMime(t *testing.T) { "from", "", "", - "From: from\r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Type: multipart/alternative;\r\n\tboundary=\"alt\"\r\n\r\n\r\n--mixed--\r\n", + "", + "From: from\r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: \r\n\r\n", false, }, { @@ -391,7 +406,8 @@ func TestMailYakBuildMime(t *testing.T) { "", "", "", - "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: subject\r\nTo: \r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Type: multipart/alternative;\r\n\tboundary=\"alt\"\r\n\r\n\r\n--mixed--\r\n", + "", + "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: subject\r\nTo: \r\n\r\n", false, }, { @@ -403,7 +419,8 @@ func TestMailYakBuildMime(t *testing.T) { "", "", "", - "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: one,two\r\nContent-Type: multipart/mixed;\r\n\tboundary=\"mixed\"; charset=UTF-8\r\n\r\n--mixed\r\nContent-Type: multipart/alternative;\r\n\tboundary=\"alt\"\r\n\r\n\r\n--mixed--\r\n", + "", + "From: \r\nMIME-Version: 1.0\r\nDate: " + now + "\r\nSubject: \r\nTo: one,two\r\n\r\n", false, }, } @@ -426,6 +443,9 @@ func TestMailYakBuildMime(t *testing.T) { } m.HTML().Write(tt.rHTML) m.Plain().Write(tt.rPlain) + if tt.rAttachemnt != "" { + m.Attach("testAttachment", strings.NewReader(tt.rAttachemnt)) + } buf := &bytes.Buffer{} err := m.buildMimeWithBoundaries(buf, "mixed", "alt") @@ -563,10 +583,28 @@ func TestMailYakBuildMime_withAttachments(t *testing.T) { } seen := 0 - mr := multipart.NewReader(buf, "mixed") + msg, err := mail.ReadMessage(buf) + if err != nil { + t.Fatalf("%q. MailYak.buildMime() error %v", tt.name, err) + } + contentTypeHeader := msg.Header.Get("Content-Type") + if contentTypeHeader == "" { + if len(tt.rattachments) != 0 { + t.Errorf("%q. MailYak.buildMime() attachments were expected, but Content-Type header wasn't set", tt.name) + } + return + } + mediaType, mediaTypeParams, err := mime.ParseMediaType(contentTypeHeader) + if err != nil { + t.Fatalf("%q. MailYak.buildMime() error %v", tt.name, err) + } + if mediaType != "multipart/mixed" { + t.Fatalf("%q. MailYak.buildMime() Content-Type media type multipart/mixed was expected, but got %q", tt.name, mediaType) + } + mr := multipart.NewReader(msg.Body, mediaTypeParams["boundary"]) // Itterate over the mime parts, look for attachments - for { + for contentTypeHeader != "" { p, err := mr.NextPart() if err == io.EOF { break