- 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.
268 lines
7.0 KiB
Go
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
|
|
}
|