package smtp import ( "bytes" "encoding/base64" "io" "mime" "mime/multipart" "net/mail" "strings" "testing" ) func TestBuildMessage_plainOnly(t *testing.T) { msg := buildMessage(&SendRequest{ From: "alice@example.com", To: []string{"bob@example.com"}, Subject: "Hello", BodyText: "Plain body", }) if strings.Contains(msg, "multipart/") { t.Fatal("plain-only message must not be multipart") } if !strings.Contains(msg, "Content-Type: text/plain; charset=UTF-8") { t.Fatal("missing text/plain content type") } if !strings.Contains(msg, "Plain body") { t.Fatal("missing body text") } } func TestBuildMessage_alternativeOnly(t *testing.T) { msg := buildMessage(&SendRequest{ From: "alice@example.com", To: []string{"bob@example.com"}, Subject: "Hello", BodyText: "Plain body", BodyHTML: "

HTML body

", }) if !strings.Contains(msg, "Content-Type: multipart/alternative") { t.Fatal("missing multipart/alternative top-level content type") } if strings.Contains(msg, "multipart/mixed") { t.Fatal("alternative-only message must not be multipart/mixed") } if !strings.Contains(msg, "Content-Type: text/html; charset=UTF-8") { t.Fatal("missing text/html part") } } func TestBuildMessage_mixedWithPlainAttachment(t *testing.T) { payload := []byte("file-bytes") msg := buildMessage(&SendRequest{ From: "alice@example.com", To: []string{"bob@example.com"}, Subject: "With file", BodyText: "See attached", Attachments: []SendAttachment{{ Filename: "doc.pdf", ContentType: "application/pdf", Data: payload, }}, }) parsed, err := mail.ReadMessage(strings.NewReader(msg)) if err != nil { t.Fatalf("ReadMessage: %v", err) } mediaType, params, err := mime.ParseMediaType(parsed.Header.Get("Content-Type")) if err != nil { t.Fatalf("ParseMediaType: %v", err) } if mediaType != "multipart/mixed" { t.Fatalf("top-level content type = %q, want multipart/mixed", mediaType) } parts := readAllParts(t, parsed.Body, params["boundary"]) if len(parts) != 2 { t.Fatalf("part count = %d, want 2", len(parts)) } if got := parts[0].Header.Get("Content-Type"); got != "text/plain; charset=UTF-8" { t.Fatalf("body part content type = %q", got) } if string(parts[0].Body) != "See attached" { t.Fatalf("body part = %q", parts[0].Body) } attType, attParams, err := mime.ParseMediaType(parts[1].Header.Get("Content-Type")) if err != nil { t.Fatalf("ParseMediaType attachment: %v", err) } if attType != "application/pdf" { t.Fatalf("attachment content type = %q", attType) } if attParams["name"] != "doc.pdf" { t.Fatalf("attachment name = %q", attParams["name"]) } if parts[1].Header.Get("Content-Transfer-Encoding") != "base64" { t.Fatal("attachment missing base64 transfer encoding") } disp, dispParams, err := mime.ParseMediaType(parts[1].Header.Get("Content-Disposition")) if err != nil { t.Fatalf("ParseMediaType disposition: %v", err) } if disp != "attachment" { t.Fatalf("disposition = %q, want attachment", disp) } if dispParams["filename"] != "doc.pdf" { t.Fatalf("filename = %q", dispParams["filename"]) } if !bytes.Equal(decodeBase64Part(t, parts[1].Body), payload) { t.Fatal("attachment payload mismatch") } } func TestBuildMessage_mixedWithAlternativeAndAttachment(t *testing.T) { msg := buildMessage(&SendRequest{ From: "alice@example.com", To: []string{"bob@example.com"}, Subject: "Rich mail", BodyText: "Plain", BodyHTML: "HTML", Attachments: []SendAttachment{{ Filename: "note.txt", ContentType: "text/plain", Data: []byte("attachment"), }}, }) parsed, err := mail.ReadMessage(strings.NewReader(msg)) if err != nil { t.Fatalf("ReadMessage: %v", err) } mediaType, params, err := mime.ParseMediaType(parsed.Header.Get("Content-Type")) if err != nil { t.Fatalf("ParseMediaType: %v", err) } if mediaType != "multipart/mixed" { t.Fatalf("top-level content type = %q, want multipart/mixed", mediaType) } parts := readAllParts(t, parsed.Body, params["boundary"]) if len(parts) != 2 { t.Fatalf("part count = %d, want 2", len(parts)) } altType, altParams, err := mime.ParseMediaType(parts[0].Header.Get("Content-Type")) if err != nil { t.Fatalf("ParseMediaType alternative: %v", err) } if altType != "multipart/alternative" { t.Fatalf("first part type = %q, want multipart/alternative", altType) } altParts := readAllParts(t, bytes.NewReader(parts[0].Body), altParams["boundary"]) if len(altParts) != 2 { t.Fatalf("alternative part count = %d, want 2", len(altParts)) } if string(altParts[0].Body) != "Plain" { t.Fatalf("plain part = %q", altParts[0].Body) } if string(altParts[1].Body) != "HTML" { t.Fatalf("html part = %q", altParts[1].Body) } disp, _, err := mime.ParseMediaType(parts[1].Header.Get("Content-Disposition")) if err != nil { t.Fatalf("ParseMediaType disposition: %v", err) } if disp != "attachment" { t.Fatalf("attachment disposition = %q", disp) } } func TestBuildMessage_inlineAttachmentHeaders(t *testing.T) { msg := buildMessage(&SendRequest{ From: "alice@example.com", To: []string{"bob@example.com"}, Subject: "Inline", BodyHTML: ``, BodyText: "Logo", Attachments: []SendAttachment{{ Filename: "logo.png", ContentType: "image/png", ContentID: "logo@ultimail", IsInline: true, Data: []byte{0x89, 0x50, 0x4e, 0x47}, }}, }) parsed, err := mail.ReadMessage(strings.NewReader(msg)) if err != nil { t.Fatalf("ReadMessage: %v", err) } _, params, err := mime.ParseMediaType(parsed.Header.Get("Content-Type")) if err != nil { t.Fatalf("ParseMediaType: %v", err) } parts := readAllParts(t, parsed.Body, params["boundary"]) if len(parts) != 2 { t.Fatalf("part count = %d, want 2", len(parts)) } inline := parts[1] disp, _, err := mime.ParseMediaType(inline.Header.Get("Content-Disposition")) if err != nil { t.Fatalf("ParseMediaType disposition: %v", err) } if disp != "inline" { t.Fatalf("disposition = %q, want inline", disp) } if inline.Header.Get("Content-ID") != "" { t.Fatalf("content-id = %q", inline.Header.Get("Content-ID")) } } type mimePart struct { Header mail.Header Body []byte } func readAllParts(t *testing.T, r io.Reader, boundary string) []mimePart { t.Helper() mr := multipart.NewReader(r, boundary) var parts []mimePart for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { t.Fatalf("NextPart: %v", err) } body, err := io.ReadAll(part) if err != nil { t.Fatalf("ReadAll part: %v", err) } parts = append(parts, mimePart{ Header: mail.Header(part.Header), Body: body, }) } return parts } func decodeBase64Part(t *testing.T, raw []byte) []byte { t.Helper() clean := strings.Map(func(r rune) rune { if r == '\r' || r == '\n' { return -1 } return r }, string(raw)) out, err := base64.StdEncoding.DecodeString(clean) if err != nil { t.Fatalf("DecodeString: %v", err) } return out }