// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package emlbuilder
import (
"encoding/base64"
"net/mail"
"os"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
var (
fixedDate = time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC)
testFIO = &localfileio.LocalFileIO{}
)
// parseEML splits an EML string into a header block and body.
func splitHeaderBody(eml string) (headers, body string) {
idx := strings.Index(eml, "\n\n")
if idx == -1 {
return eml, ""
}
return eml[:idx], eml[idx+2:]
}
func headerValue(eml, name string) string {
for _, line := range strings.Split(eml, "\n") {
if strings.HasPrefix(strings.ToLower(line), strings.ToLower(name)+":") {
return strings.TrimSpace(line[len(name)+1:])
}
}
return ""
}
// ── validation ────────────────────────────────────────────────────────────────
// TestBuild_MissingFrom verifies build missing from.
func TestBuild_MissingFrom(t *testing.T) {
_, err := New().To("", "bob@example.com").Subject("hi").Build()
if err == nil || !strings.Contains(err.Error(), "From") {
t.Fatalf("expected From error, got %v", err)
}
}
// TestBuild_MissingRecipient verifies build missing recipient.
func TestBuild_MissingRecipient(t *testing.T) {
_, err := New().From("", "alice@example.com").Subject("hi").Build()
if err == nil || !strings.Contains(err.Error(), "recipient") {
t.Fatalf("expected recipient error, got %v", err)
}
}
// ── single text/plain ─────────────────────────────────────────────────────────
// TestBuild_SingleTextPlain_ASCII verifies build single text plain ASCII.
func TestBuild_SingleTextPlain_ASCII(t *testing.T) {
raw, err := New().
From("Alice", "alice@example.com").
To("Bob", "bob@example.com").
Subject("Hello").
Date(fixedDate).
MessageID("test-id@lark-cli").
TextBody([]byte("Hello world")).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
// must use LF, not CRLF
if strings.Contains(eml, "\r\n") {
t.Error("EML must use LF line endings, not CRLF")
}
// required headers
for _, h := range []string{"Subject: Hello", "From:", "MIME-Version: 1.0", "Message-ID:"} {
if !strings.Contains(eml, h) {
t.Errorf("missing header %q in:\n%s", h, eml)
}
}
// content type must not be folded (all params on one line)
for _, line := range strings.Split(eml, "\n") {
if strings.Contains(line, "Content-Type:") && strings.Contains(line, "boundary=") {
if !strings.Contains(line, "boundary=") {
t.Errorf("Content-Type with boundary must be on a single line: %q", line)
}
}
}
// 7bit CTE for ASCII
if !strings.Contains(eml, "Content-Transfer-Encoding: 7bit") {
t.Errorf("expected 7bit CTE for ASCII body, got:\n%s", eml)
}
if !strings.Contains(eml, "Hello world") {
t.Error("body text missing")
}
}
// TestBuild_SingleTextPlain_NonASCII verifies build single text plain non ASCII.
func TestBuild_SingleTextPlain_NonASCII(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("你好").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("你好世界")).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
// subject must be RFC 2047 encoded
subj := headerValue(eml, "Subject")
if subj == "你好" {
t.Error("non-ASCII subject must be RFC 2047 encoded")
}
if !strings.HasPrefix(subj, "=?utf-8?") && !strings.HasPrefix(subj, "=?UTF-8?") {
t.Errorf("unexpected subject encoding: %q", subj)
}
// body must be base64
if !strings.Contains(eml, "Content-Transfer-Encoding: base64") {
t.Errorf("expected base64 CTE for non-ASCII body:\n%s", eml)
}
// body content must be valid base64 of the original text
headers, body := splitHeaderBody(eml)
_ = headers
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(body))
if err != nil {
t.Fatalf("body is not valid base64: %v\nbody=%q", err, body)
}
if string(decoded) != "你好世界" {
t.Errorf("decoded body mismatch: got %q", decoded)
}
}
// ── multipart/alternative ─────────────────────────────────────────────────────
// TestBuild_MultipartAlternative verifies build multipart alternative.
func TestBuild_MultipartAlternative(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("plain")).
HTMLBody([]byte("
html
")).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
if !strings.Contains(eml, "multipart/alternative") {
t.Error("expected multipart/alternative")
}
// boundary must be on the same line as Content-Type
for _, line := range strings.Split(eml, "\n") {
if strings.HasPrefix(line, "Content-Type: multipart/") {
if !strings.Contains(line, "boundary=") {
t.Errorf("Content-Type line missing boundary param: %q", line)
}
}
}
if !strings.Contains(eml, "text/plain") {
t.Error("missing text/plain part")
}
if !strings.Contains(eml, "text/html") {
t.Error("missing text/html part")
}
}
// ── multipart/mixed (with attachments) ───────────────────────────────────────
// TestBuild_WithAttachment verifies build with attachment.
func TestBuild_WithAttachment(t *testing.T) {
attContent := []byte("PDF content here")
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("with attachment").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("see attached")).
AddAttachment(attContent, "application/pdf", "doc.pdf").
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
if !strings.Contains(eml, "multipart/mixed") {
t.Error("expected multipart/mixed for message with attachments")
}
if !strings.Contains(eml, `Content-Disposition: attachment; filename="doc.pdf"`) {
t.Errorf("missing attachment disposition:\n%s", eml)
}
// attachment body must be base64 of attContent
expectedB64 := base64.StdEncoding.EncodeToString(attContent)
if !strings.Contains(eml, expectedB64) {
t.Errorf("attachment base64 not found in EML:\n%s", eml)
}
}
// ── reply threading headers ───────────────────────────────────────────────────
// TestBuild_ReplyHeaders verifies build reply headers.
func TestBuild_ReplyHeaders(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("Re: hello").
Date(fixedDate).
MessageID("reply@x").
InReplyTo("original@smtp").
References("").
TextBody([]byte("my reply")).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
inReplyTo := headerValue(eml, "In-Reply-To")
if inReplyTo != "" {
t.Errorf("In-Reply-To: got %q, want ", inReplyTo)
}
refs := headerValue(eml, "References")
if refs != "" {
t.Errorf("References: got %q, want ", refs)
}
}
// TestBuild_LMSReplyToMessageID verifies build LMS reply to message ID.
func TestBuild_LMSReplyToMessageID(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("Re: hello").
Date(fixedDate).
InReplyTo("original@smtp").
LMSReplyToMessageID("740000000000000067").
TextBody([]byte("my reply")).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
got := headerValue(eml, "X-LMS-Reply-To-Message-Id")
if got != "740000000000000067" {
t.Errorf("X-LMS-Reply-To-Message-Id: got %q, want 740000000000000067", got)
}
}
// TestBuild_LMSReplyToMessageID_NotWrittenWithoutInReplyTo verifies build LMS reply to message ID not written without in reply to.
func TestBuild_LMSReplyToMessageID_NotWrittenWithoutInReplyTo(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("hello").
Date(fixedDate).
LMSReplyToMessageID("740000000000000067").
TextBody([]byte("body")).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
got := headerValue(eml, "X-LMS-Reply-To-Message-Id")
if got != "" {
t.Errorf("X-LMS-Reply-To-Message-Id should be absent when In-Reply-To is not set, got %q", got)
}
}
// ── Disposition-Notification-To (read receipt) ───────────────────────────────
// TestBuild_DispositionNotificationTo verifies build disposition notification to.
func TestBuild_DispositionNotificationTo(t *testing.T) {
raw, err := New().
From("Alice", "alice@example.com").
To("", "bob@example.com").
Subject("hi").
Date(fixedDate).
MessageID("dnt@x").
DispositionNotificationTo("Alice", "alice@example.com").
TextBody([]byte("please ack")).
Build()
if err != nil {
t.Fatal(err)
}
got := headerValue(string(raw), "Disposition-Notification-To")
want := `"Alice" `
if got != want {
t.Errorf("Disposition-Notification-To: got %q, want %q", got, want)
}
}
// TestBuild_DispositionNotificationTo_MultipleAddresses verifies build disposition notification to multiple addresses.
func TestBuild_DispositionNotificationTo_MultipleAddresses(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("hi").
Date(fixedDate).
MessageID("dnt-multi@x").
DispositionNotificationTo("", "alice@example.com").
DispositionNotificationTo("", "carol@example.com").
TextBody([]byte("body")).
Build()
if err != nil {
t.Fatal(err)
}
got := headerValue(string(raw), "Disposition-Notification-To")
want := ", "
if got != want {
t.Errorf("Disposition-Notification-To: got %q, want %q", got, want)
}
}
// TestBuild_DispositionNotificationTo_NotWrittenWhenUnset verifies build disposition notification to not written when unset.
func TestBuild_DispositionNotificationTo_NotWrittenWhenUnset(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("hi").
Date(fixedDate).
MessageID("no-dnt@x").
TextBody([]byte("body")).
Build()
if err != nil {
t.Fatal(err)
}
if got := headerValue(string(raw), "Disposition-Notification-To"); got != "" {
t.Errorf("Disposition-Notification-To should be absent when unset, got %q", got)
}
}
// TestBuild_DispositionNotificationTo_EmptyAddressIgnored verifies build disposition notification to empty address ignored.
func TestBuild_DispositionNotificationTo_EmptyAddressIgnored(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("hi").
Date(fixedDate).
MessageID("empty-dnt@x").
DispositionNotificationTo("", "").
TextBody([]byte("body")).
Build()
if err != nil {
t.Fatal(err)
}
if got := headerValue(string(raw), "Disposition-Notification-To"); got != "" {
t.Errorf("empty address should be ignored; got header %q", got)
}
}
// TestBuild_DispositionNotificationTo_CRLFRejected verifies build disposition notification to CR LF rejected.
func TestBuild_DispositionNotificationTo_CRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("hi").
Date(fixedDate).
DispositionNotificationTo("Alice\r\nBcc: evil@evil.com", "alice@example.com").
TextBody([]byte("body")).
Build()
if err == nil || !strings.Contains(err.Error(), "display name") {
t.Fatalf("expected display-name CRLF error, got %v", err)
}
}
// TestBuild_DispositionNotificationTo_AddrCRLFRejected verifies build disposition notification to addr CR LF rejected.
func TestBuild_DispositionNotificationTo_AddrCRLFRejected(t *testing.T) {
// Injection via the address (not just the display name) must be blocked.
// A plain mail.Address.String() would emit ""
// unchanged, allowing the attacker to inject new headers.
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("hi").
Date(fixedDate).
DispositionNotificationTo("Alice", "alice@example.com\r\nX-Injected: pwned").
TextBody([]byte("body")).
Build()
if err == nil || !strings.Contains(err.Error(), "control character") {
t.Fatalf("expected addr CRLF error, got %v", err)
}
}
// TestBuild_DispositionNotificationTo_AddrBidiRejected verifies build disposition notification to addr bidi rejected.
func TestBuild_DispositionNotificationTo_AddrBidiRejected(t *testing.T) {
// Bidi overrides (U+202E RLO) enable visual spoofing (e.g. "gmail" + RLO + "com.evil.com"
// renders as gmail.com at the tail); they must be blocked in the addr
// too, not only in header names / display names.
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("hi").
Date(fixedDate).
DispositionNotificationTo("Alice", "alice@gma\u202eil.com").
TextBody([]byte("body")).
Build()
if err == nil || !strings.Contains(err.Error(), "dangerous Unicode") {
t.Fatalf("expected addr dangerous-Unicode error, got %v", err)
}
}
// ── X-Lark-Read-Receipt-Mail (read receipt response marker) ──────────────────
// TestBuild_IsReadReceiptMail_True verifies build is read receipt mail true.
func TestBuild_IsReadReceiptMail_True(t *testing.T) {
raw, err := New().
From("", "bob@example.com").
To("", "alice@example.com").
Subject("已读回执:hi").
Date(fixedDate).
MessageID("irrm@x").
IsReadReceiptMail(true).
TextBody([]byte("read")).
Build()
if err != nil {
t.Fatal(err)
}
got := headerValue(string(raw), "X-Lark-Read-Receipt-Mail")
if got != "1" {
t.Errorf("X-Lark-Read-Receipt-Mail: got %q, want 1", got)
}
}
// TestBuild_IsReadReceiptMail_DefaultAbsent verifies build is read receipt mail default absent.
func TestBuild_IsReadReceiptMail_DefaultAbsent(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("hi").
Date(fixedDate).
MessageID("no-irrm@x").
TextBody([]byte("body")).
Build()
if err != nil {
t.Fatal(err)
}
if got := headerValue(string(raw), "X-Lark-Read-Receipt-Mail"); got != "" {
t.Errorf("X-Lark-Read-Receipt-Mail should be absent by default, got %q", got)
}
}
// TestBuild_IsReadReceiptMail_ExplicitFalse verifies build is read receipt mail explicit false.
func TestBuild_IsReadReceiptMail_ExplicitFalse(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("hi").
Date(fixedDate).
MessageID("irrm-false@x").
IsReadReceiptMail(false).
TextBody([]byte("body")).
Build()
if err != nil {
t.Fatal(err)
}
if got := headerValue(string(raw), "X-Lark-Read-Receipt-Mail"); got != "" {
t.Errorf("X-Lark-Read-Receipt-Mail should be absent when set false, got %q", got)
}
}
// TestBuild_DispositionNotificationTo_PreservesPriorError verifies that once
// the Builder carries an error from a prior setter, DispositionNotificationTo
// short-circuits and does NOT clobber the existing error with a nil.
func TestBuild_DispositionNotificationTo_PreservesPriorError(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("bad\r\nheader"). // injects err
DispositionNotificationTo("Alice", "alice@example.com").
Date(fixedDate).
TextBody([]byte("body")).
Build()
if err == nil || !strings.Contains(err.Error(), "control character") {
t.Fatalf("expected original Subject CRLF error to survive DispositionNotificationTo, got %v", err)
}
}
// TestBuild_IsReadReceiptMail_PreservesPriorError verifies that once the
// Builder carries an error from a prior setter, IsReadReceiptMail short-
// circuits and does NOT clobber the existing error with a nil.
func TestBuild_IsReadReceiptMail_PreservesPriorError(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("bad\r\nheader"). // injects err
IsReadReceiptMail(true).
Date(fixedDate).
TextBody([]byte("body")).
Build()
if err == nil || !strings.Contains(err.Error(), "control character") {
t.Fatalf("expected original Subject CRLF error to survive IsReadReceiptMail, got %v", err)
}
}
// ── CC / BCC ──────────────────────────────────────────────────────────────────
// TestBuild_CCBCC verifies build c c b c c.
func TestBuild_CCBCC(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
CC("", "charlie@example.com").
BCC("", "dave@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("body")).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
if !strings.Contains(eml, "charlie@example.com") {
t.Errorf("missing Cc address:\n%s", eml)
}
if !strings.Contains(eml, "Cc:") {
t.Errorf("missing Cc header:\n%s", eml)
}
if !strings.Contains(eml, "Bcc:") {
t.Errorf("missing Bcc header:\n%s", eml)
}
if !strings.Contains(eml, "dave@example.com") {
t.Errorf("missing Bcc address:\n%s", eml)
}
}
// TestAllRecipients verifies all recipients.
func TestAllRecipients(t *testing.T) {
b := New().
From("", "alice@example.com").
To("", "bob@example.com").
CC("", "charlie@example.com").
BCC("", "dave@example.com")
recips := b.AllRecipients()
if len(recips) != 3 {
t.Fatalf("expected 3 recipients, got %d: %v", len(recips), recips)
}
}
// ── BuildBase64URL ────────────────────────────────────────────────────────────
// TestBuildBase64URL verifies build base64 URL.
func TestBuildBase64URL(t *testing.T) {
encoded, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("base64url test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hello")).
BuildBase64URL()
if err != nil {
t.Fatal(err)
}
// must be valid base64url
decoded, err := base64.URLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("BuildBase64URL produced invalid base64url: %v", err)
}
// decoded must be valid EML
if !strings.Contains(string(decoded), "Subject: base64url test") {
t.Errorf("decoded EML missing expected content:\n%s", decoded)
}
// must NOT contain standard base64 chars that differ from base64url
// ('+' → '-', '/' → '_')
if strings.ContainsAny(encoded, "+/") {
t.Error("BuildBase64URL must use base64url encoding (- and _ instead of + and /)")
}
}
// ── immutability ──────────────────────────────────────────────────────────────
// TestBuilder_Immutability verifies builder immutability.
func TestBuilder_Immutability(t *testing.T) {
base := New().From("", "alice@example.com").Subject("base")
b1 := base.To("", "bob@example.com")
b2 := base.To("", "charlie@example.com")
if len(b1.to) != 1 || b1.to[0].Address != "bob@example.com" {
t.Errorf("b1 unexpected to: %v", b1.to)
}
if len(b2.to) != 1 || b2.to[0].Address != "charlie@example.com" {
t.Errorf("b2 unexpected to: %v", b2.to)
}
// base should have no To
if len(base.to) != 0 {
t.Errorf("base was mutated: to=%v", base.to)
}
}
// ── ToAddrs / CCAddrs ─────────────────────────────────────────────────────────
// TestBuild_ToAddrs verifies build to addrs.
func TestBuild_ToAddrs(t *testing.T) {
addrs := []mail.Address{
{Name: "Bob", Address: "bob@example.com"},
{Name: "Carol", Address: "carol@example.com"},
}
raw, err := New().
From("", "alice@example.com").
ToAddrs(addrs).
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
if !strings.Contains(eml, "bob@example.com") || !strings.Contains(eml, "carol@example.com") {
t.Errorf("expected both recipients in EML:\n%s", eml)
}
}
// ── CalendarBody ──────────────────────────────────────────────────────────────
// TestBuild_CalendarBody_Single verifies build calendar body single.
func TestBuild_CalendarBody_Single(t *testing.T) {
calData := []byte("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR")
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("Meeting").
Date(fixedDate).
MessageID("test@x").
CalendarBody(calData).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
if !strings.Contains(eml, "text/calendar") {
t.Errorf("expected text/calendar in EML:\n%s", eml)
}
if strings.Contains(eml, "multipart") {
t.Errorf("single calendar body should not produce multipart:\n%s", eml)
}
}
// TestBuild_CalendarWithText verifies build calendar with text.
func TestBuild_CalendarWithText(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("Meeting").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("You are invited.")).
CalendarBody([]byte("BEGIN:VCALENDAR\r\nEND:VCALENDAR")).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
// text/calendar lives inside multipart/alternative as an alternative
// representation of the body, matching Feishu client behavior.
if !strings.Contains(eml, "multipart/alternative") {
t.Errorf("expected multipart/alternative for text+calendar:\n%s", eml)
}
if !strings.Contains(eml, "text/plain") {
t.Errorf("missing text/plain part:\n%s", eml)
}
if !strings.Contains(eml, "text/calendar") {
t.Errorf("missing text/calendar part:\n%s", eml)
}
}
// ── AddInline / multipart/related ────────────────────────────────────────────
// TestBuild_WithInline verifies build with inline.
func TestBuild_WithInline(t *testing.T) {
imgBytes := []byte("\x89PNG\r\n\x1a\n") // minimal PNG magic bytes
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("inline image").
Date(fixedDate).
MessageID("test@x").
HTMLBody([]byte(`
`)).
AddInline(imgBytes, "image/png", "logo.png", "logo").
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
if !strings.Contains(eml, "multipart/related") {
t.Errorf("expected multipart/related when inlines present:\n%s", eml)
}
if !strings.Contains(eml, "Content-Id: ") {
t.Errorf("missing Content-Id header:\n%s", eml)
}
if !strings.Contains(eml, "Content-Disposition: inline") {
t.Errorf("missing Content-Disposition: inline:\n%s", eml)
}
if !strings.Contains(eml, `Content-Disposition: inline; filename="logo.png"`) {
t.Errorf("missing quoted inline filename:\n%s", eml)
}
if !strings.Contains(eml, "X-Attachment-Id: logo") {
t.Errorf("missing X-Attachment-Id:\n%s", eml)
}
if !strings.Contains(eml, "X-Image-Id: logo") {
t.Errorf("missing X-Image-Id:\n%s", eml)
}
if !strings.Contains(eml, "image/png") {
t.Errorf("missing image/png Content-Type:\n%s", eml)
}
}
// TestBuild_WithOtherPart verifies build with other part.
func TestBuild_WithOtherPart(t *testing.T) {
calData := []byte("BEGIN:VCALENDAR\r\nEND:VCALENDAR")
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("other part").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("see embedded")).
AddOtherPart(calData, "text/calendar", "invite.ics", "cal001").
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
if !strings.Contains(eml, "multipart/related") {
t.Errorf("expected multipart/related for other parts:\n%s", eml)
}
if !strings.Contains(eml, "Content-Id: ") {
t.Errorf("missing Content-ID:\n%s", eml)
}
// AddOtherPart must NOT write Content-Disposition
if strings.Contains(eml, "Content-Disposition") {
t.Errorf("AddOtherPart must not include Content-Disposition:\n%s", eml)
}
}
// TestBuild_FoldBodyLines_Base64 verifies build fold body lines base64.
func TestBuild_FoldBodyLines_Base64(t *testing.T) {
body := strings.Repeat("你", 120)
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("fold html").
Date(fixedDate).
MessageID("test@x").
HTMLBody([]byte(body)).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
headers, bodyPart := splitHeaderBody(eml)
_ = headers
lines := strings.Split(strings.TrimSpace(bodyPart), "\n")
for i, line := range lines {
if len(line) > 76 {
t.Fatalf("base64 line %d too long: %d", i, len(line))
}
}
}
// TestBuild_FoldBodyLines_7bit verifies build fold body lines 7bit.
func TestBuild_FoldBodyLines_7bit(t *testing.T) {
body := strings.Repeat("A", 200)
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("fold text").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte(body)).
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
headers, bodyPart := splitHeaderBody(eml)
_ = headers
lines := strings.Split(strings.TrimSpace(bodyPart), "\n")
for i, line := range lines {
// RFC 5322: 7bit lines MUST NOT exceed 998 characters.
if len(line) > 998 {
t.Fatalf("7bit line %d too long: %d (RFC 5322 limit is 998)", i, len(line))
}
}
}
// TestBuild_InlineAndAttachment verifies build inline and attachment.
func TestBuild_InlineAndAttachment(t *testing.T) {
imgBytes := []byte("fake-png")
pdfBytes := []byte("fake-pdf")
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("inline+attachment").
Date(fixedDate).
MessageID("test@x").
HTMLBody([]byte(`
`)).
AddInline(imgBytes, "image/png", "img.png", "img1").
AddAttachment(pdfBytes, "application/pdf", "doc.pdf").
Build()
if err != nil {
t.Fatal(err)
}
eml := string(raw)
if !strings.Contains(eml, "multipart/mixed") {
t.Errorf("expected multipart/mixed (attachment present):\n%s", eml)
}
if !strings.Contains(eml, "multipart/related") {
t.Errorf("expected multipart/related (inline present):\n%s", eml)
}
if !strings.Contains(eml, "Content-Disposition: attachment") {
t.Errorf("missing attachment disposition:\n%s", eml)
}
if !strings.Contains(eml, "Content-Id: ") {
t.Errorf("missing inline Content-ID:\n%s", eml)
}
}
// ContentID without angle brackets is normalised to form.
func TestBuild_InlineContentIDNormalisation(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("cid test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("body")).
AddInline([]byte("data"), "image/gif", "a.gif", "already-no-brackets").
Build()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "Content-Id: ") {
t.Errorf("Content-ID should be wrapped in angle brackets:\n%s", raw)
}
}
// ── extra Header ─────────────────────────────────────────────────────────────
// TestBuild_ExtraHeader verifies build extra header.
func TestBuild_ExtraHeader(t *testing.T) {
raw, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
Header("X-Custom", "my-value").
Build()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "X-Custom: my-value") {
t.Errorf("extra header missing:\n%s", raw)
}
}
// ── CRLF / header-injection guards ───────────────────────────────────────────
// TestSubjectCRLFRejected verifies subject CR LF rejected.
func TestSubjectCRLFRejected(t *testing.T) {
for _, inj := range []string{"legit\r\nBcc: evil@evil.com", "legit\nBcc: evil@evil.com", "legit\rBcc: evil@evil.com"} {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject(inj).
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
Build()
if err == nil {
t.Errorf("Subject(%q): expected error, got nil", inj)
}
}
}
// TestMessageIDCRLFRejected verifies message ID CR LF rejected.
func TestMessageIDCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("bad\r\nX-Injected: hdr").
TextBody([]byte("hi")).
Build()
if err == nil {
t.Error("MessageID with CRLF: expected error, got nil")
}
}
// TestInReplyToCRLFRejected verifies in reply to CR LF rejected.
func TestInReplyToCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
InReplyTo("legit\r\nBcc: evil@evil.com").
TextBody([]byte("hi")).
Build()
if err == nil {
t.Error("InReplyTo with CRLF: expected error, got nil")
}
}
// TestReferencesCRLFRejected verifies references CR LF rejected.
func TestReferencesCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
References("legit@x\r\nBcc: evil@evil.com").
TextBody([]byte("hi")).
Build()
if err == nil {
t.Error("References with CRLF: expected error, got nil")
}
}
// TestHeaderNameColonRejected verifies header name colon rejected.
func TestHeaderNameColonRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
Header("X-Bad:Name", "value").
Build()
if err == nil {
t.Error("Header with colon in name: expected error, got nil")
}
}
// TestHeaderNameCRLFRejected verifies header name CR LF rejected.
func TestHeaderNameCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
Header("X-Bad\r\nBcc", "evil@evil.com").
Build()
if err == nil {
t.Error("Header with CRLF in name: expected error, got nil")
}
}
// TestHeaderValueCRLFRejected verifies header value CR LF rejected.
func TestHeaderValueCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
Header("X-Custom", "legit\r\nBcc: evil@evil.com").
Build()
if err == nil {
t.Error("Header with CRLF in value: expected error, got nil")
}
}
// TestFromDisplayNameCRLFRejected verifies from display name CR LF rejected.
func TestFromDisplayNameCRLFRejected(t *testing.T) {
_, err := New().
From("Alice\r\nBcc: evil@evil.com", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
Build()
if err == nil {
t.Error("From with CRLF in display name: expected error, got nil")
}
}
// TestToDisplayNameCRLFRejected verifies to display name CR LF rejected.
func TestToDisplayNameCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("Bob\r\nBcc: evil@evil.com", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
Build()
if err == nil {
t.Error("To with CRLF in display name: expected error, got nil")
}
}
// TestAddAttachmentContentTypeCRLFRejected verifies add attachment content type CR LF rejected.
func TestAddAttachmentContentTypeCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
AddAttachment([]byte("data"), "application/pdf\r\nBcc: evil@evil.com", "file.pdf").
Build()
if err == nil {
t.Error("AddAttachment with CRLF in contentType: expected error, got nil")
}
}
// TestAddAttachmentFileNameCRLFRejected verifies add attachment file name CR LF rejected.
func TestAddAttachmentFileNameCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
AddAttachment([]byte("data"), "application/pdf", "file.pdf\r\nBcc: evil@evil.com").
Build()
if err == nil {
t.Error("AddAttachment with CRLF in fileName: expected error, got nil")
}
}
// TestAddInlineContentTypeCRLFRejected verifies add inline content type CR LF rejected.
func TestAddInlineContentTypeCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
AddInline([]byte("data"), "image/png\r\nBcc: evil@evil.com", "img.png", "cid1").
Build()
if err == nil {
t.Error("AddInline with CRLF in contentType: expected error, got nil")
}
}
// TestAddInlineContentIDCRLFRejected verifies add inline content ID CR LF rejected.
func TestAddInlineContentIDCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
AddInline([]byte("data"), "image/png", "img.png", "cid1\r\nBcc: evil@evil.com").
Build()
if err == nil {
t.Error("AddInline with CRLF in contentID: expected error, got nil")
}
}
// TestAddInlineFileNameCRLFRejected verifies add inline file name CR LF rejected.
func TestAddInlineFileNameCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
AddInline([]byte("data"), "image/png", "img.png\r\nBcc: evil@evil.com", "cid1").
Build()
if err == nil {
t.Error("AddInline with CRLF in fileName: expected error, got nil")
}
}
// TestAddOtherPartFileNameCRLFRejected verifies add other part file name CR LF rejected.
func TestAddOtherPartFileNameCRLFRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
AddOtherPart([]byte("data"), "application/octet-stream", "file.bin\r\nBcc: evil@evil.com", "").
Build()
if err == nil {
t.Error("AddOtherPart with CRLF in fileName: expected error, got nil")
}
}
// TestAddInlineContentIDControlCharRejected verifies add inline content ID control char rejected.
func TestAddInlineContentIDControlCharRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
AddInline([]byte("data"), "image/png", "img.png", "cid1\x01evil").
Build()
if err == nil {
t.Error("AddInline with control char (0x01) in contentID: expected error, got nil")
}
}
// TestAddOtherPartContentIDControlCharRejected verifies add other part content ID control char rejected.
func TestAddOtherPartContentIDControlCharRejected(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
AddOtherPart([]byte("data"), "application/octet-stream", "file.bin", "cid1\x09evil").
Build()
if err == nil {
t.Error("AddOtherPart with control char (tab/0x09) in contentID: expected error, got nil")
}
}
// TestHeaderValueControlCharRejected verifies header value control char rejected.
func TestHeaderValueControlCharRejected(t *testing.T) {
cases := []struct {
name string
value string
}{
{"null byte", "hello\x00world"},
{"ESC", "hello\x1bworld"},
{"DEL", "hello\x7fworld"},
{"CR", "hello\rworld"},
{"LF", "hello\nworld"},
{"CRLF", "hello\r\nworld"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
Header("X-Custom", tc.value).
Build()
if err == nil {
t.Errorf("Header with %s in value: expected error, got nil", tc.name)
}
})
}
}
// TestHeaderValueDangerousUnicodeRejected verifies header value dangerous unicode rejected.
func TestHeaderValueDangerousUnicodeRejected(t *testing.T) {
cases := []struct {
name string
value string
}{
{"Bidi RLO (U+202E)", "file\u202Etxt.exe"},
{"zero-width space (U+200B)", "hello\u200Bworld"},
{"BOM (U+FEFF)", "hello\uFEFFworld"},
{"line separator (U+2028)", "hello\u2028world"},
{"Bidi isolate LRI (U+2066)", "hello\u2066world"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
Header("X-Custom", tc.value).
Build()
if err == nil {
t.Errorf("Header with %s in value: expected error, got nil", tc.name)
}
})
}
}
// ── blocked extension via AddFileAttachment ───────────────────────────────────
// TestAddFileAttachmentBlockedExtension verifies add file attachment blocked extension.
func TestAddFileAttachmentBlockedExtension(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
blocked := []string{"malware.exe", "script.BAT", "payload.js", "hack.ps1", "app.msi"}
for _, name := range blocked {
os.WriteFile(name, []byte("content"), 0o644)
}
for _, name := range blocked {
t.Run(name, func(t *testing.T) {
_, err := New().WithFileIO(testFIO).
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
AddFileAttachment(name).
Build()
if err == nil {
t.Fatalf("expected blocked extension error for %q", name)
}
if !strings.Contains(err.Error(), "not allowed") {
t.Fatalf("error = %v, want 'not allowed' message", err)
}
})
}
}
// TestAddFileInlineBlockedFormat verifies add file inline blocked format.
func TestAddFileInlineBlockedFormat(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
// PNG magic bytes but .svg extension → rejected (bad extension)
pngContent := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
os.WriteFile("icon.svg", pngContent, 0o644)
// .png extension but EXE content → rejected (bad content)
os.WriteFile("evil.png", []byte("MZ"), 0o644)
for _, name := range []string{"icon.svg", "evil.png"} {
t.Run(name, func(t *testing.T) {
_, err := New().WithFileIO(testFIO).
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
HTMLBody([]byte(`
`)).
AddFileInline(name, "img1").
Build()
if err == nil {
t.Fatalf("expected inline format error for %q", name)
}
})
}
}
// TestAddFileInlineAllowedFormat verifies add file inline allowed format.
func TestAddFileInlineAllowedFormat(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
pngContent := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
jpegContent := []byte{0xFF, 0xD8, 0xFF, 0xE0}
os.WriteFile("logo.png", pngContent, 0o644)
os.WriteFile("photo.jpg", jpegContent, 0o644)
for _, name := range []string{"logo.png", "photo.jpg"} {
t.Run(name, func(t *testing.T) {
_, err := New().WithFileIO(testFIO).
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
HTMLBody([]byte(`
`)).
AddFileInline(name, "img1").
Build()
if err != nil {
t.Fatalf("expected %q to be allowed, got: %v", name, err)
}
})
}
}
// TestAddFileAttachmentAllowedExtension verifies add file attachment allowed extension.
func TestAddFileAttachmentAllowedExtension(t *testing.T) {
dir := t.TempDir()
orig, _ := os.Getwd()
os.Chdir(dir)
t.Cleanup(func() { os.Chdir(orig) })
allowed := []string{"report.pdf", "photo.jpg", "data.csv", "page.html"}
for _, name := range allowed {
os.WriteFile(name, []byte("content"), 0o644)
}
for _, name := range allowed {
t.Run(name, func(t *testing.T) {
_, err := New().WithFileIO(testFIO).
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
AddFileAttachment(name).
Build()
if err != nil {
t.Fatalf("expected %q to be allowed, got: %v", name, err)
}
})
}
}
// TestHeaderValueTabAllowed verifies header value tab allowed.
func TestHeaderValueTabAllowed(t *testing.T) {
// Tab (\t) is valid in folded header values per RFC 5322
_, err := New().
From("", "alice@example.com").
To("", "bob@example.com").
Subject("test").
Date(fixedDate).
MessageID("test@x").
TextBody([]byte("hi")).
Header("X-Custom", "hello\tworld").
Build()
if err != nil {
t.Errorf("Header with tab in value: expected no error, got %v", err)
}
}
func TestWriteCalendarPart_MethodFromBody(t *testing.T) {
cases := []struct {
name string
ics string
wantCT string
}{
{"request", "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
{"cancel", "BEGIN:VCALENDAR\r\nMETHOD:CANCEL\r\nEND:VCALENDAR\r\n", "method=CANCEL"},
{"reply", "BEGIN:VCALENDAR\r\nMETHOD:REPLY\r\nEND:VCALENDAR\r\n", "method=REPLY"},
{"no method defaults to REQUEST", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "method=REQUEST"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
eml, err := New().
From("", "sender@example.com").
To("", "recipient@example.com").
Subject("Test").
Date(fixedDate).
MessageID("test-method@x").
HTMLBody([]byte("hi
")).
CalendarBody([]byte(tc.ics)).
Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
if !strings.Contains(string(eml), tc.wantCT) {
t.Errorf("expected Content-Type to contain %q\n%s", tc.wantCT, eml)
}
})
}
}