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: ") { 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: ", "From: Alice Example ", "To: Agent ", "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 .") 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") }