Implement internal mail skill MVP

This commit is contained in:
赵义仑
2026-06-06 22:28:36 +08:00
commit 097df73495
22 changed files with 4059 additions and 0 deletions

View 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")
}