ultisuite-backend/internal/mail/smtp/mime_test.go
R3D347HR4Y 65fc9e517a Implement outbox management features with scheduling and attachment support
- Added new API endpoints for sending, rescheduling, and canceling scheduled outbox messages.
- Implemented outbox processing logic to handle attachments and manage message statuses.
- Introduced a dead-letter strategy for failed outbox messages, enhancing reliability.
- Updated database schema to support new outbox statuses and dead-letter entries.
- Enhanced unit tests for outbox functionalities, ensuring robust error handling and validation.
- Improved attachment handling in the outbox processor to support inline and regular attachments.
2026-05-22 17:46:30 +02:00

268 lines
7.0 KiB
Go

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: "<p>HTML body</p>",
})
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: "<b>HTML</b>",
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) != "<b>HTML</b>" {
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: `<img src="cid:logo@ultimail">`,
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") != "<logo@ultimail>" {
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
}