Files
mats b585245d47 platform/email: add attachment support; order delegates SMTP to it
go-cart-actor's hand-rolled SMTP stack (SASL loginAuth/plainAuth, dial/
STARTTLS/auth/MAIL/RCPT/DATA wire code, MIME builders) is replaced by an
adapter over platform/email, which gains the multipart/mixed attachment
envelope ported from it. One SMTP implementation now serves both the
order/ledger senders and go-auth's AUTH_EMAIL_* flows; public surfaces
(SMTPEmailSenderConfig, NewSMTPEmailSender, EmailSender, EmailMessage)
are unchanged so all callers compile untouched. Bumps the go-cart-actor
pin to 7cad997.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-09-01 20:04:46 +02:00

224 lines
6.6 KiB
Go

package email
import (
"context"
"encoding/base64"
"net/mail"
"strings"
"testing"
"time"
)
func testFrom() mail.Address {
return mail.Address{Address: "no-reply@example.com"}
}
func TestLoginAuthChallengeResponse(t *testing.T) {
a := &loginAuth{username: "user", password: "pass"}
mech, initial, err := a.Start(nil)
if err != nil {
t.Fatalf("Start: %v", err)
}
if mech != "LOGIN" || initial != nil {
t.Fatalf("unexpected Start result: %q %v", mech, initial)
}
resp, err := a.Next([]byte("Username:"), true)
if err != nil || string(resp) != "user" {
t.Fatalf("username challenge: %q %v", resp, err)
}
resp, err = a.Next([]byte("Password"), true)
if err != nil || string(resp) != "pass" {
t.Fatalf("password challenge: %q %v", resp, err)
}
if _, err := a.Next([]byte("Bogus"), true); err == nil {
t.Fatal("expected error on unexpected challenge")
}
}
func TestPlainAuthSendsCredentialsInClear(t *testing.T) {
// Internal relays advertise only PLAIN over an unencrypted connection;
// stdlib smtp.PlainAuth refuses that (ErrUnencryptedConnection), so the
// sender uses the cleartext-capable plainAuth instead.
a := &plainAuth{username: "user", password: "pass"}
mech, initial, err := a.Start(nil)
if err != nil {
t.Fatalf("Start: %v", err)
}
if mech != "PLAIN" {
t.Fatalf("mechanism = %q, want PLAIN", mech)
}
want := "\x00user\x00pass"
if string(initial) != want {
t.Fatalf("initial response = %q, want %q", initial, want)
}
if resp, err := a.Next(nil, false); err != nil || resp != nil {
t.Fatalf("Next(done) = %q %v, want nil nil", resp, err)
}
if _, err := a.Next(nil, true); err == nil {
t.Fatal("expected error on unexpected challenge")
}
}
func TestNewSMTPValidation(t *testing.T) {
if _, err := NewSMTP(SMTPConfig{Port: 587}); err == nil ||
!strings.Contains(err.Error(), "missing host") {
t.Fatalf("want missing-host error, got %v", err)
}
if _, err := NewSMTP(SMTPConfig{Host: "relay.internal", Port: 0, DefaultFrom: testFrom()}); err == nil ||
!strings.Contains(err.Error(), "port") {
t.Fatalf("want invalid-port error, got %v", err)
}
s, err := NewSMTP(SMTPConfig{
Host: "relay.internal",
Port: 25,
SSL: false,
DefaultFrom: testFrom(),
Timeout: time.Second,
})
if err != nil {
t.Fatalf("NewSMTP: %v", err)
}
smtpS, ok := s.(*smtpSender)
if !ok {
t.Fatalf("unexpected sender type %T", s)
}
if smtpS.ssl || smtpS.addr != "relay.internal:25" {
t.Fatalf("unexpected config: %+v", smtpS)
}
}
func TestBuildMessageMultipartAlternative(t *testing.T) {
msg := Message{
From: mail.Address{Name: "go-auth", Address: "no-reply@example.com"},
To: []mail.Address{{Name: "Ada", Address: "ada@example.com"}},
Subject: "Reset your password — Åke & Östen",
TextBody: "Hello Ada,\nreset link: https://auth.example/reset?token=abc\n",
HTMLBody: "<p>Hello Ada,</p><p>reset link: https://auth.example/reset?token=abc</p>",
Headers: map[string]string{"List-Unsubscribe": "<https://auth.example/unsub>"},
}
raw, err := buildMessage(msg)
if err != nil {
t.Fatal(err)
}
out := string(raw)
for _, want := range []string{
"From: \"go-auth\" <no-reply@example.com>",
"To: \"Ada\" <ada@example.com>",
"Subject: =?utf-8?q?",
"MIME-Version: 1.0",
"multipart/alternative;",
"Content-Type: text/plain; charset=utf-8",
"Content-Type: text/html; charset=utf-8",
"List-Unsubscribe: <https://auth.example/unsub>",
"Hello Ada,",
} {
if !strings.Contains(out, want) {
t.Errorf("message missing %q\n%s", want, out)
}
}
// The multipart must terminate properly.
if !strings.Contains(out, "--alt-") || !strings.HasSuffix(out, "--\r\n") {
t.Errorf("multipart envelope not closed properly:\n%s", out)
}
}
func TestBuildMessageTextOnly(t *testing.T) {
raw, err := buildMessage(Message{
From: testFrom(),
To: []mail.Address{{Address: "ada@example.com"}},
Subject: "hi",
TextBody: "plain only",
})
if err != nil {
t.Fatal(err)
}
out := string(raw)
if strings.Contains(out, "multipart") {
t.Errorf("text-only message must not be multipart:\n%s", out)
}
if !strings.Contains(out, "Content-Type: text/plain; charset=utf-8") {
t.Errorf("missing text/plain content type:\n%s", out)
}
}
func TestBuildMessageRejectsHeaderInjection(t *testing.T) {
raw, err := buildMessage(Message{
From: testFrom(),
To: []mail.Address{{Address: "ada@example.com"}},
Subject: "hi",
TextBody: "body",
Headers: map[string]string{"X-Evil": "a\r\nBcc: victim@example.com"},
})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "Bcc: victim@example.com") {
t.Error("CR/LF injection survived into the message")
}
}
func TestBuildMessageAttachment(t *testing.T) {
body, err := buildMessage(Message{
From: mail.Address{Name: "B2B Demo AB", Address: "faktura@demo.se"},
To: []mail.Address{{Address: "faktura@falk.se"}},
Subject: "Faktura INV-000001",
TextBody: "Hej,\nFaktura bifogas.",
Attachments: []Attachment{{
Filename: "INV-000001.pdf",
Content: []byte("%PDF-1.4 fake"),
}},
})
if err != nil {
t.Fatalf("buildMessage: %v", err)
}
s := string(body)
for _, want := range []string{
"multipart/mixed;",
`Content-Type: application/pdf; name="INV-000001.pdf"`,
`Content-Disposition: attachment; filename="INV-000001.pdf"`,
"Content-Transfer-Encoding: base64",
} {
if !strings.Contains(s, want) {
t.Errorf("message missing %q", want)
}
}
// The attachment payload round-trips: extract the base64 blob between the
// attachment part header and the closing CRLF, decode, compare.
idx := strings.Index(s, `Content-Disposition: attachment; filename="INV-000001.pdf"`)
rest := s[idx:]
lineEnd := strings.Index(rest, "\r\n\r\n")
if lineEnd < 0 {
t.Fatal("attachment part has no body separator")
}
b64line := strings.SplitN(rest[lineEnd+4:], "\r\n", 2)[0]
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64line))
if err != nil {
t.Fatalf("decode attachment base64: %v", err)
}
if string(decoded) != "%PDF-1.4 fake" {
t.Fatalf("attachment content = %q", decoded)
}
}
func TestBuildMessageMissingBody(t *testing.T) {
if _, err := buildMessage(Message{
From: testFrom(), To: []mail.Address{{Address: "a@b"}}, Subject: "x",
}); err == nil {
t.Fatal("want missing-body error")
}
}
func TestLogSenderNeverSends(t *testing.T) {
s := NewLogSender(nil, testFrom())
if err := s.Send(context.Background(), Message{
From: testFrom(), To: []mail.Address{{Address: "a@b"}}, Subject: "x", TextBody: "y",
}); err != nil {
t.Fatalf("log sender: %v", err)
}
if s.DefaultFrom().Address != "no-reply@example.com" {
t.Fatalf("DefaultFrom = %q", s.DefaultFrom().Address)
}
}