Implement internal mail skill MVP
This commit is contained in:
571
internal/e2e/local_e2e_test.go
Normal file
571
internal/e2e/local_e2e_test.go
Normal file
@@ -0,0 +1,571 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"internal-mail-skill/internal/api"
|
||||
"internal-mail-skill/internal/config"
|
||||
"internal-mail-skill/internal/db"
|
||||
"internal-mail-skill/internal/pop3"
|
||||
"internal-mail-skill/internal/service"
|
||||
mailsmtp "internal-mail-skill/internal/smtp"
|
||||
)
|
||||
|
||||
func TestLocalPOP3SMTPHTTPFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 6, 6, 9, 30, 0, 0, time.UTC)
|
||||
raw := buildTestMessage(t, now)
|
||||
popServer := newFakePOP3Server(t, []popMessage{{uid: "uid-local-1", raw: raw}})
|
||||
smtpServer := newFakeSMTPServer(t)
|
||||
|
||||
dataDir := t.TempDir()
|
||||
store, err := db.Open(filepath.Join(dataDir, "mail.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if err := store.Migrate(ctx); err != nil {
|
||||
t.Fatalf("migrate db: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
AccountID: "local-e2e",
|
||||
POP3: config.POP3Config{
|
||||
Host: popServer.host,
|
||||
Port: popServer.port,
|
||||
Username: "local-user",
|
||||
Password: "local-pass",
|
||||
TimeoutSeconds: 5,
|
||||
},
|
||||
SMTP: config.SMTPConfig{
|
||||
Host: smtpServer.host,
|
||||
Port: smtpServer.port,
|
||||
FromAddress: "agent@example.local",
|
||||
FromName: "Local Agent",
|
||||
TimeoutSeconds: 5,
|
||||
AuthRequired: true,
|
||||
Username: "smtp-user",
|
||||
Password: "smtp-pass",
|
||||
AllowInsecureAuth: true,
|
||||
},
|
||||
Data: config.DataConfig{
|
||||
DataDir: dataDir,
|
||||
DatabasePath: filepath.Join(dataDir, "mail.db"),
|
||||
RawMailDir: filepath.Join(dataDir, "raw"),
|
||||
AttachmentDir: filepath.Join(dataDir, "attachments"),
|
||||
},
|
||||
}
|
||||
svc := service.NewWithOptions(
|
||||
store,
|
||||
pop3.NewFetcher(cfg.POP3),
|
||||
mailsmtp.NewSender(cfg.SMTP),
|
||||
cfg.AccountID,
|
||||
service.Options{RawDir: cfg.Data.RawMailDir, AttachmentDir: cfg.Data.AttachmentDir, FromAddress: cfg.SMTP.FromAddress, FromName: cfg.SMTP.FromName},
|
||||
)
|
||||
server := httptest.NewServer(api.New(cfg, store, svc).Handler())
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
health := getJSON[map[string]any](t, server.URL+"/health")
|
||||
if health["status"] != "ok" || health["database"] != "ok" {
|
||||
t.Fatalf("unexpected health: %#v", health)
|
||||
}
|
||||
|
||||
syncResult := postJSON[map[string]any](t, server.URL+"/sync", nil, http.StatusOK)
|
||||
if got := int(syncResult["stored"].(float64)); got != 1 {
|
||||
t.Fatalf("stored = %d, want 1; result=%#v", got, syncResult)
|
||||
}
|
||||
syncAgain := postJSON[map[string]any](t, server.URL+"/sync", nil, http.StatusOK)
|
||||
if got := int(syncAgain["skipped"].(float64)); got != 1 {
|
||||
t.Fatalf("skipped after second sync = %d, want 1; result=%#v", got, syncAgain)
|
||||
}
|
||||
|
||||
search := getJSON[map[string]any](t, server.URL+"/emails/search?q=Quarterly+Report&has_attachment=true")
|
||||
emails := search["emails"].([]any)
|
||||
if len(emails) != 1 {
|
||||
t.Fatalf("search returned %d emails, want 1: %#v", len(emails), search)
|
||||
}
|
||||
emailID := int64(emails[0].(map[string]any)["id"].(float64))
|
||||
|
||||
detail := getJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d", server.URL, emailID))
|
||||
if detail["subject"] != "Quarterly Report" {
|
||||
t.Fatalf("subject = %q", detail["subject"])
|
||||
}
|
||||
if !strings.Contains(detail["body_text"].(string), "The report is attached.") {
|
||||
t.Fatalf("body_text missing expected content: %q", detail["body_text"])
|
||||
}
|
||||
attachments := detail["attachments"].([]any)
|
||||
if len(attachments) != 1 {
|
||||
t.Fatalf("attachments = %d, want 1", len(attachments))
|
||||
}
|
||||
if _, ok := attachments[0].(map[string]any)["attachment_id"]; !ok {
|
||||
t.Fatalf("attachment id not exposed in detail: %#v", attachments[0])
|
||||
}
|
||||
if _, ok := attachments[0].(map[string]any)["storage_path"]; ok {
|
||||
t.Fatalf("attachment storage path leaked: %#v", attachments[0])
|
||||
}
|
||||
|
||||
label := postJSON[map[string]any](t, server.URL+"/labels", map[string]any{"name": "Local E2E", "color": "#336699"}, http.StatusCreated)
|
||||
postJSON[map[string]any](t, server.URL+"/emails/labels/apply", map[string]any{
|
||||
"email_ids": []int64{emailID},
|
||||
"label_ids": []int64{int64(label["id"].(float64))},
|
||||
"op": "add",
|
||||
}, http.StatusOK)
|
||||
postJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d/mark", server.URL, emailID), map[string]any{"read": true, "processed": true}, http.StatusOK)
|
||||
|
||||
marked := getJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d", server.URL, emailID))
|
||||
status := marked["local_status"].(map[string]any)
|
||||
if status["read"] != true || status["processed"] != true {
|
||||
t.Fatalf("local status not updated: %#v", status)
|
||||
}
|
||||
if got := len(marked["labels"].([]any)); got != 1 {
|
||||
t.Fatalf("labels after apply = %d, want 1", got)
|
||||
}
|
||||
|
||||
draft := postJSON[map[string]any](t, server.URL+"/drafts", map[string]any{
|
||||
"to": []string{"recipient@example.local"},
|
||||
"cc": []string{"copy@example.local"},
|
||||
"bcc": []string{"audit@example.local"},
|
||||
"subject": "Re: Quarterly Report",
|
||||
"body": "Thanks, received.",
|
||||
"reply_to_email_id": emailID,
|
||||
}, http.StatusCreated)
|
||||
outcome := postJSON[map[string]any](t, fmt.Sprintf("%s/drafts/%d/send", server.URL, int64(draft["id"].(float64))), nil, http.StatusOK)
|
||||
if sent := outcome["sent_message"].(map[string]any); sent["status"] != "sent" {
|
||||
t.Fatalf("draft send status = %#v", sent)
|
||||
}
|
||||
|
||||
sentMessages := smtpServer.messages()
|
||||
if len(sentMessages) != 1 {
|
||||
t.Fatalf("smtp captured %d messages, want 1", len(sentMessages))
|
||||
}
|
||||
if !containsAll(sentMessages[0].recipients, []string{"recipient@example.local", "copy@example.local", "audit@example.local"}) {
|
||||
t.Fatalf("smtp recipients = %#v", sentMessages[0].recipients)
|
||||
}
|
||||
rawSent := string(sentMessages[0].data)
|
||||
if strings.Contains(rawSent, "audit@example.local") {
|
||||
t.Fatalf("Bcc recipient leaked into message headers/body:\n%s", rawSent)
|
||||
}
|
||||
if !strings.Contains(rawSent, "In-Reply-To: <local-1@example.local>") {
|
||||
t.Fatalf("reply headers missing from SMTP message:\n%s", rawSent)
|
||||
}
|
||||
|
||||
replied := getJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d", server.URL, emailID))
|
||||
if replied["local_status"].(map[string]any)["replied"] != true {
|
||||
t.Fatalf("original message was not marked replied: %#v", replied["local_status"])
|
||||
}
|
||||
|
||||
postJSON[map[string]any](t, server.URL+"/send", map[string]any{
|
||||
"to": []string{"new@example.local"},
|
||||
"subject": "Direct local send",
|
||||
"body": "Hello from direct send.",
|
||||
}, http.StatusOK)
|
||||
if len(smtpServer.messages()) != 2 {
|
||||
t.Fatalf("smtp captured %d messages after /send, want 2", len(smtpServer.messages()))
|
||||
}
|
||||
|
||||
var sentCount int
|
||||
if err := store.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM sent_messages WHERE status = 'sent'").Scan(&sentCount); err != nil {
|
||||
t.Fatalf("count sent messages: %v", err)
|
||||
}
|
||||
if sentCount != 2 {
|
||||
t.Fatalf("sent_messages rows = %d, want 2", sentCount)
|
||||
}
|
||||
assertNoStoredAttachmentPathInAPI(t, detail)
|
||||
}
|
||||
|
||||
func buildTestMessage(t *testing.T, date time.Time) []byte {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
textPart, err := writer.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Type": {"text/plain; charset=utf-8"},
|
||||
"Content-Transfer-Encoding": {"quoted-printable"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create text part: %v", err)
|
||||
}
|
||||
_, _ = io.WriteString(textPart, "Hello Agent,\r\nThe report is attached.\r\n")
|
||||
attachmentPart, err := writer.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Type": {"text/plain; charset=utf-8"},
|
||||
"Content-Disposition": {`attachment; filename="../report.txt"`},
|
||||
"Content-Transfer-Encoding": {"base64"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create attachment part: %v", err)
|
||||
}
|
||||
_, _ = io.WriteString(attachmentPart, "cmVwb3J0LWJvZHk=")
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart: %v", err)
|
||||
}
|
||||
|
||||
headers := strings.Join([]string{
|
||||
"Message-ID: <local-1@example.local>",
|
||||
"From: Alice Example <alice@example.local>",
|
||||
"To: Agent <agent@example.local>",
|
||||
"Subject: Quarterly Report",
|
||||
"Date: " + date.Format(time.RFC1123Z),
|
||||
"Content-Type: multipart/mixed; boundary=" + writer.Boundary(),
|
||||
"MIME-Version: 1.0",
|
||||
"",
|
||||
"",
|
||||
}, "\r\n")
|
||||
return []byte(headers + body.String())
|
||||
}
|
||||
|
||||
type popMessage struct {
|
||||
uid string
|
||||
raw []byte
|
||||
}
|
||||
|
||||
type fakePOP3Server struct {
|
||||
host string
|
||||
port int
|
||||
ln net.Listener
|
||||
}
|
||||
|
||||
func newFakePOP3Server(t *testing.T, messages []popMessage) *fakePOP3Server {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen pop3: %v", err)
|
||||
}
|
||||
host, portString, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portString)
|
||||
server := &fakePOP3Server{host: host, port: port, ln: ln}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go handlePOP3Conn(conn, messages)
|
||||
}
|
||||
}()
|
||||
return server
|
||||
}
|
||||
|
||||
func handlePOP3Conn(conn net.Conn, messages []popMessage) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
reader := bufio.NewReader(conn)
|
||||
writer := bufio.NewWriter(conn)
|
||||
writeLine(writer, "+OK local POP3 ready")
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) == 0 {
|
||||
writeLine(writer, "-ERR empty command")
|
||||
continue
|
||||
}
|
||||
switch strings.ToUpper(parts[0]) {
|
||||
case "USER", "PASS", "NOOP":
|
||||
writeLine(writer, "+OK")
|
||||
case "UIDL":
|
||||
if len(parts) > 1 {
|
||||
idx, _ := strconv.Atoi(parts[1])
|
||||
if idx <= 0 || idx > len(messages) {
|
||||
writeLine(writer, "-ERR no such message")
|
||||
continue
|
||||
}
|
||||
writeLine(writer, fmt.Sprintf("+OK %d %s", idx, messages[idx-1].uid))
|
||||
continue
|
||||
}
|
||||
writeLine(writer, "+OK uid list follows")
|
||||
for i, msg := range messages {
|
||||
writeLine(writer, fmt.Sprintf("%d %s", i+1, msg.uid))
|
||||
}
|
||||
writeLine(writer, ".")
|
||||
case "RETR":
|
||||
if len(parts) < 2 {
|
||||
writeLine(writer, "-ERR message id required")
|
||||
continue
|
||||
}
|
||||
idx, _ := strconv.Atoi(parts[1])
|
||||
if idx <= 0 || idx > len(messages) {
|
||||
writeLine(writer, "-ERR no such message")
|
||||
continue
|
||||
}
|
||||
writeLine(writer, fmt.Sprintf("+OK %d octets", len(messages[idx-1].raw)))
|
||||
writeMultiline(writer, messages[idx-1].raw)
|
||||
case "QUIT":
|
||||
writeLine(writer, "+OK bye")
|
||||
return
|
||||
default:
|
||||
writeLine(writer, "-ERR unsupported command")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeLine(w *bufio.Writer, line string) {
|
||||
_, _ = w.WriteString(line + "\r\n")
|
||||
_ = w.Flush()
|
||||
}
|
||||
|
||||
func writeMultiline(w *bufio.Writer, raw []byte) {
|
||||
for _, line := range strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") {
|
||||
if strings.HasPrefix(line, ".") {
|
||||
line = "." + line
|
||||
}
|
||||
_, _ = w.WriteString(line + "\r\n")
|
||||
}
|
||||
_, _ = w.WriteString(".\r\n")
|
||||
_ = w.Flush()
|
||||
}
|
||||
|
||||
type smtpMessage struct {
|
||||
from string
|
||||
recipients []string
|
||||
data []byte
|
||||
}
|
||||
|
||||
type fakeSMTPServer struct {
|
||||
host string
|
||||
port int
|
||||
ln net.Listener
|
||||
mu sync.Mutex
|
||||
sent []smtpMessage
|
||||
}
|
||||
|
||||
func newFakeSMTPServer(t *testing.T) *fakeSMTPServer {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen smtp: %v", err)
|
||||
}
|
||||
host, portString, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portString)
|
||||
server := &fakeSMTPServer{host: host, port: port, ln: ln}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go server.handleConn(conn)
|
||||
}
|
||||
}()
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) messages() []smtpMessage {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]smtpMessage, len(s.sent))
|
||||
copy(out, s.sent)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
reader := bufio.NewReader(conn)
|
||||
writer := bufio.NewWriter(conn)
|
||||
writeLine(writer, "220 local SMTP ready")
|
||||
var current smtpMessage
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
upper := strings.ToUpper(line)
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "EHLO ") || strings.HasPrefix(upper, "HELO "):
|
||||
writeLine(writer, "250-localhost")
|
||||
writeLine(writer, "250-AUTH PLAIN LOGIN")
|
||||
writeLine(writer, "250 HELP")
|
||||
case strings.HasPrefix(upper, "AUTH "):
|
||||
writeLine(writer, "235 2.7.0 Authentication successful")
|
||||
case strings.HasPrefix(upper, "MAIL FROM:"):
|
||||
current = smtpMessage{from: extractSMTPPath(line)}
|
||||
writeLine(writer, "250 OK")
|
||||
case strings.HasPrefix(upper, "RCPT TO:"):
|
||||
current.recipients = append(current.recipients, extractSMTPPath(line))
|
||||
writeLine(writer, "250 OK")
|
||||
case upper == "DATA":
|
||||
writeLine(writer, "354 End data with <CR><LF>.<CR><LF>")
|
||||
data, err := readSMTPData(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
current.data = data
|
||||
s.mu.Lock()
|
||||
s.sent = append(s.sent, current)
|
||||
s.mu.Unlock()
|
||||
writeLine(writer, "250 OK queued")
|
||||
case upper == "RSET":
|
||||
current = smtpMessage{}
|
||||
writeLine(writer, "250 OK")
|
||||
case upper == "QUIT":
|
||||
writeLine(writer, "221 bye")
|
||||
return
|
||||
default:
|
||||
writeLine(writer, "250 OK")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractSMTPPath(line string) string {
|
||||
_, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimPrefix(value, "<")
|
||||
value = strings.TrimSuffix(value, ">")
|
||||
return value
|
||||
}
|
||||
|
||||
func readSMTPData(reader *bufio.Reader) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if line == ".\r\n" || line == ".\n" {
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
if strings.HasPrefix(line, "..") {
|
||||
line = line[1:]
|
||||
}
|
||||
out.WriteString(line)
|
||||
}
|
||||
}
|
||||
|
||||
func getJSON[T any](t *testing.T, url string) T {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new GET request: %v", err)
|
||||
}
|
||||
return doJSON[T](t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
func postJSON[T any](t *testing.T, url string, body any, status int) T {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
reader = bytes.NewReader(payload)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, reader)
|
||||
if err != nil {
|
||||
t.Fatalf("new POST request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return doJSON[T](t, req, status)
|
||||
}
|
||||
|
||||
func doJSON[T any](t *testing.T, req *http.Request, status int) T {
|
||||
t.Helper()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", req.Method, req.URL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response body: %v", err)
|
||||
}
|
||||
if resp.StatusCode != status {
|
||||
t.Fatalf("%s %s status=%d want=%d body=%s", req.Method, req.URL, resp.StatusCode, status, payload)
|
||||
}
|
||||
var out T
|
||||
if err := json.Unmarshal(payload, &out); err != nil {
|
||||
t.Fatalf("decode response JSON: %v body=%s", err, payload)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsAll(got, want []string) bool {
|
||||
seen := map[string]bool{}
|
||||
for _, item := range got {
|
||||
seen[item] = true
|
||||
}
|
||||
for _, item := range want {
|
||||
if !seen[item] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func assertNoStoredAttachmentPathInAPI(t *testing.T, detail map[string]any) {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal detail: %v", err)
|
||||
}
|
||||
if strings.Contains(string(encoded), string(os.PathSeparator)+"attachments"+string(os.PathSeparator)) {
|
||||
t.Fatalf("API response appears to expose attachment storage path: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentMessagesTableStoresFailedSendWithoutMarkingDraftSent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dataDir := t.TempDir()
|
||||
store, err := db.Open(filepath.Join(dataDir, "mail.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if err := store.Migrate(ctx); err != nil {
|
||||
t.Fatalf("migrate db: %v", err)
|
||||
}
|
||||
cfg := &config.Config{AccountID: "local-fail"}
|
||||
svc := service.NewWithOptions(store, nil, failingMailer{}, cfg.AccountID, service.Options{FromAddress: "agent@example.local"})
|
||||
server := httptest.NewServer(api.New(cfg, store, svc).Handler())
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
draft := postJSON[map[string]any](t, server.URL+"/drafts", map[string]any{
|
||||
"to": []string{"recipient@example.local"},
|
||||
"subject": "Will fail",
|
||||
"body": "Body",
|
||||
}, http.StatusCreated)
|
||||
postJSON[map[string]any](t, fmt.Sprintf("%s/drafts/%d/send", server.URL, int64(draft["id"].(float64))), nil, http.StatusBadGateway)
|
||||
|
||||
var status, lastError, sentStatus, errorMessage string
|
||||
err = store.DB.QueryRowContext(ctx, `
|
||||
SELECT d.status, COALESCE(d.last_error, ''), sm.status, COALESCE(sm.error_message, '')
|
||||
FROM drafts d JOIN sent_messages sm ON sm.draft_id = d.id
|
||||
WHERE d.id = ?`, int64(draft["id"].(float64))).Scan(&status, &lastError, &sentStatus, &errorMessage)
|
||||
if err != nil {
|
||||
t.Fatalf("query failed send rows: %v", err)
|
||||
}
|
||||
if status != "draft" || sentStatus != "failed" || lastError == "" || errorMessage == "" {
|
||||
t.Fatalf("unexpected failed send persistence: draft=%q lastError=%q sent=%q error=%q", status, lastError, sentStatus, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
type failingMailer struct{}
|
||||
|
||||
func (failingMailer) Send(context.Context, mailsmtp.SendRequest) (mailsmtp.SendResult, error) {
|
||||
return mailsmtp.SendResult{}, fmt.Errorf("local smtp failure")
|
||||
}
|
||||
Reference in New Issue
Block a user