package email import ( "context" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestValidateMessage_RejectsMissingFields confirms the contract gate: // To, From, and at least one body are required. func TestValidateMessage_RejectsMissingFields(t *testing.T) { cases := []struct { name string msg Message wantErr string }{ {"empty To", Message{From: "f@x", BodyText: "b"}, "To is required"}, {"empty From", Message{To: "t@x", BodyText: "b"}, "From is required"}, {"empty body", Message{To: "t@x", From: "f@x"}, "BodyText or BodyHTML"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := validateMessage(tc.msg) require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) }) } } // TestValidateMessage_AcceptsMinimal confirms the happy path: To, From, // and BodyText alone is a valid message. func TestValidateMessage_AcceptsMinimal(t *testing.T) { err := validateMessage(Message{To: "t@x", From: "f@x", BodyText: "b"}) assert.NoError(t, err) } // TestBuildRFC5322_PlainText verifies that a text-only message produces // a single-part text/plain RFC 5322 body with the expected headers. func TestBuildRFC5322_PlainText(t *testing.T) { msg := Message{ To: "alice@example.com", From: "system@x", Subject: "Hi", BodyText: "Hello Alice", } body := string(buildRFC5322(msg)) assert.Contains(t, body, "To: alice@example.com\r\n") assert.Contains(t, body, "From: system@x\r\n") assert.Contains(t, body, "Subject: Hi\r\n") assert.Contains(t, body, "MIME-Version: 1.0\r\n") assert.Contains(t, body, "Content-Type: text/plain; charset=utf-8\r\n") assert.Contains(t, body, "\r\nHello Alice") assert.NotContains(t, body, "multipart/alternative", "no HTML => no multipart") } // TestBuildRFC5322_Multipart verifies that text + HTML produces a // multipart/alternative body with both parts and a boundary close. func TestBuildRFC5322_Multipart(t *testing.T) { msg := Message{ To: "alice@example.com", From: "system@x", Subject: "Hi", BodyText: "Plain hello", BodyHTML: "

HTML hello

", } body := string(buildRFC5322(msg)) assert.Contains(t, body, "Content-Type: multipart/alternative;") assert.Contains(t, body, "Plain hello", "plain part included") assert.Contains(t, body, "

HTML hello

", "HTML part included") // Boundary close marker assert.True(t, strings.Contains(body, "--alt-") && strings.HasSuffix(strings.TrimRight(body, "\r\n"), "--"), "multipart message should end with closing boundary") } // TestBuildRFC5322_CustomHeaders verifies that user-supplied headers // appear in the output with canonicalised case. func TestBuildRFC5322_CustomHeaders(t *testing.T) { msg := Message{ To: "alice@example.com", From: "system@x", Subject: "Hi", BodyText: "b", Headers: map[string]string{ "x-bdd-scenario": "magic-link-happy-path", "X-Trace-Id": "abc-123", }, } body := string(buildRFC5322(msg)) assert.Contains(t, body, "X-Bdd-Scenario: magic-link-happy-path\r\n", "lowercase key should be canonicalised to title-case") assert.Contains(t, body, "X-Trace-Id: abc-123\r\n", "already-canonical key should pass through unchanged") } // TestSMTPSender_DefaultsAreMailpitFriendly verifies that NewSMTPSender // fills in localhost:1025 and a non-zero timeout when the caller passes // a zero-valued SMTPConfig — which is the recommended default. func TestSMTPSender_DefaultsAreMailpitFriendly(t *testing.T) { s := NewSMTPSender(SMTPConfig{}) assert.Equal(t, "localhost", s.cfg.Host) assert.Equal(t, 1025, s.cfg.Port) assert.Greater(t, int64(s.cfg.Timeout), int64(0), "timeout must be set, default 10s") assert.Empty(t, s.cfg.Username, "default no AUTH") } // TestSMTPSender_ContextCancelAbortsSend confirms a cancelled ctx // short-circuits Send rather than waiting for the SMTP timeout. // We point at a port no SMTP server is listening on so the goroutine // will hang ; ctx cancel is what wins. func TestSMTPSender_ContextCancelAbortsSend(t *testing.T) { s := NewSMTPSender(SMTPConfig{ Host: "127.0.0.1", Port: 1, // privileged port, definitely no SMTP server here // keep the default 10s timeout — we want ctx to win }) ctx, cancel := context.WithCancel(context.Background()) cancel() // pre-cancelled err := s.Send(ctx, Message{To: "t@x", From: "f@x", BodyText: "b"}) require.Error(t, err) // The error is ctx.Err() OR the net/smtp connect error if the goroutine // happened to fail before the ctx select ran. Both are acceptable — // the only unacceptable outcome is the test taking 10 seconds. }