From 1e4d9c2cf94aa61cd4a3c9ff87e2a1b870bcae1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 06:30:07 +0000 Subject: [PATCH 1/3] Initial plan From f177c023f22c51cb634a2ea96bc7c414cfc99e89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 06:40:05 +0000 Subject: [PATCH 2/3] Add SMTP E2E testing API endpoints (search, wait, links, codes, raw, stats, delete) Agent-Logs-Url: https://github.com/buggregator/server/sessions/d7c6264b-fee5-4b75-bb9f-40076f256407 Co-authored-by: butschster <773481+butschster@users.noreply.github.com> --- modules/smtp/api.go | 473 ++++++++++++++++++++++++ modules/smtp/api_test.go | 754 +++++++++++++++++++++++++++++++++++++++ modules/smtp/handler.go | 16 +- modules/smtp/module.go | 54 +++ 4 files changed, 1295 insertions(+), 2 deletions(-) create mode 100644 modules/smtp/api.go create mode 100644 modules/smtp/api_test.go diff --git a/modules/smtp/api.go b/modules/smtp/api.go new file mode 100644 index 0000000..b66e604 --- /dev/null +++ b/modules/smtp/api.go @@ -0,0 +1,473 @@ +package smtp + +import ( + "context" + "encoding/json" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/buggregator/go-buggregator/internal/event" +) + +// MessageFilter holds filter criteria for SMTP message queries. +type MessageFilter struct { + To string + From string + Cc string + Subject string + SubjectContains string + SubjectRegex string + BodyContains string + Project string + Since float64 // unix timestamp (seconds) + Until float64 // unix timestamp (seconds) + Limit int + Offset int + Order string // "asc" or "desc" +} + +// Link represents a URL extracted from a message. +type Link struct { + URL string `json:"url"` + Text string `json:"text,omitempty"` + Source string `json:"source"` // "html" or "text" +} + +func registerSMTPAPI(mux *http.ServeMux, store event.Store, mod *Module) { + mux.HandleFunc("GET /api/smtp/cursor", handleCursor()) + mux.HandleFunc("GET /api/smtp/stats", handleStats(store)) + mux.HandleFunc("DELETE /api/smtp/messages", handleDeleteMessages(store)) + // /wait must be registered before the plain /messages pattern. + mux.HandleFunc("GET /api/smtp/messages/wait", handleMessagesWait(store, mod)) + mux.HandleFunc("GET /api/smtp/messages", handleMessages(store)) + mux.HandleFunc("GET /api/smtp/message/{uuid}/raw", handleRaw(store)) + mux.HandleFunc("GET /api/smtp/message/{uuid}/links", handleLinks(store)) + mux.HandleFunc("GET /api/smtp/message/{uuid}/codes", handleCodes(store)) +} + +// handleCursor returns the current server time as a cursor token. +// E2E tests grab the cursor before triggering an action, then pass it as +// `since` to the search / wait endpoints to avoid picking up stale messages. +func handleCursor() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cursor := time.Now().UTC().Format(time.RFC3339Nano) + smtpJSON(w, map[string]string{"cursor": cursor}) + } +} + +// handleMessages returns SMTP messages matching the given filter. +func handleMessages(store event.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f := parseFilter(r) + + events, err := store.FindAll(r.Context(), event.FindOptions{Type: "smtp", Project: f.Project}) + if err != nil { + smtpError(w, err.Error(), http.StatusInternalServerError) + return + } + + filtered := applyFilter(events, f) + + if f.Order == "asc" { + // Store returns desc; reverse for asc. + for i, j := 0, len(filtered)-1; i < j; i, j = i+1, j-1 { + filtered[i], filtered[j] = filtered[j], filtered[i] + } + } + + total := len(filtered) + + // Apply offset/limit after sorting. + if f.Offset > 0 { + if f.Offset >= len(filtered) { + filtered = nil + } else { + filtered = filtered[f.Offset:] + } + } + if f.Limit > 0 && len(filtered) > f.Limit { + filtered = filtered[:f.Limit] + } + if filtered == nil { + filtered = []event.Event{} + } + + smtpJSON(w, map[string]any{ + "data": filtered, + "meta": map[string]any{ + "total": total, + "limit": f.Limit, + "offset": f.Offset, + }, + }) + } +} + +// handleMessagesWait long-polls for a matching SMTP message. +// It holds the connection until a match arrives or the timeout expires. +// Returns 200 with the first matching event, or 408 on timeout. +func handleMessagesWait(store event.Store, mod *Module) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f := parseFilter(r) + + // Parse timeout (default 30 s, max 60 s). + timeout := 30 * time.Second + if t := r.URL.Query().Get("timeout"); t != "" { + if d, err := time.ParseDuration(t); err == nil && d > 0 { + if d > 60*time.Second { + d = 60 * time.Second + } + timeout = d + } + } + + // Subscribe BEFORE checking existing events to avoid the race where a + // matching event arrives between the check and the wait. + ch, unsub := mod.subscribe(f) + defer unsub() + + // Check for an already-stored matching event. + events, err := store.FindAll(r.Context(), event.FindOptions{Type: "smtp", Project: f.Project}) + if err != nil { + smtpError(w, err.Error(), http.StatusInternalServerError) + return + } + if matched := applyFilter(events, f); len(matched) > 0 { + smtpJSON(w, matched[0]) + return + } + + // Wait for a new matching event. + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + select { + case ev := <-ch: + smtpJSON(w, ev) + case <-ctx.Done(): + smtpError(w, "timeout waiting for message", http.StatusRequestTimeout) + } + } +} + +// handleDeleteMessages purges SMTP messages, optionally filtered by project. +func handleDeleteMessages(store event.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + project := r.URL.Query().Get("project") + opts := event.DeleteOptions{Type: "smtp", Project: project} + if err := store.DeleteAll(r.Context(), opts); err != nil { + smtpError(w, err.Error(), http.StatusInternalServerError) + return + } + smtpJSON(w, map[string]any{"status": true}) + } +} + +// handleStats returns count and last_received_at for SMTP messages. +func handleStats(store event.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + project := r.URL.Query().Get("project") + events, err := store.FindAll(r.Context(), event.FindOptions{Type: "smtp", Project: project}) + if err != nil { + smtpError(w, err.Error(), http.StatusInternalServerError) + return + } + + var lastReceivedAt *string + if len(events) > 0 { + // Events are ordered desc by timestamp; the first is the most recent. + t := time.Unix(0, int64(events[0].Timestamp*1e9)).UTC().Format(time.RFC3339) + lastReceivedAt = &t + } + + smtpJSON(w, map[string]any{ + "count": len(events), + "last_received_at": lastReceivedAt, + }) + } +} + +// handleRaw returns the original RFC 822 source of an SMTP message. +func handleRaw(store event.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uuid := r.PathValue("uuid") + ev, err := store.FindByUUID(r.Context(), uuid) + if err != nil { + smtpError(w, err.Error(), http.StatusInternalServerError) + return + } + if ev == nil || ev.Type != "smtp" { + smtpError(w, "message not found", http.StatusNotFound) + return + } + + var email ParsedEmail + if err := json.Unmarshal(ev.Payload, &email); err != nil { + smtpError(w, "failed to parse message", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "message/rfc822") + w.Write([]byte(email.Raw)) //nolint:errcheck + } +} + +// handleLinks extracts every hyperlink from a message's HTML and text parts. +func handleLinks(store event.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uuid := r.PathValue("uuid") + ev, err := store.FindByUUID(r.Context(), uuid) + if err != nil { + smtpError(w, err.Error(), http.StatusInternalServerError) + return + } + if ev == nil || ev.Type != "smtp" { + smtpError(w, "message not found", http.StatusNotFound) + return + } + + var email ParsedEmail + if err := json.Unmarshal(ev.Payload, &email); err != nil { + smtpError(w, "failed to parse message", http.StatusInternalServerError) + return + } + + links := extractLinks(&email) + smtpJSON(w, map[string]any{"data": links}) + } +} + +// handleCodes extracts codes (e.g. OTP digits) from a message using a regex pattern. +// Default pattern matches 4–8 digit sequences. +func handleCodes(store event.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + pattern := r.URL.Query().Get("pattern") + if pattern == "" { + pattern = `\b\d{4,8}\b` + } + + re, err := regexp.Compile(pattern) + if err != nil { + smtpError(w, "invalid pattern: "+err.Error(), http.StatusBadRequest) + return + } + + uuid := r.PathValue("uuid") + ev, err := store.FindByUUID(r.Context(), uuid) + if err != nil { + smtpError(w, err.Error(), http.StatusInternalServerError) + return + } + if ev == nil || ev.Type != "smtp" { + smtpError(w, "message not found", http.StatusNotFound) + return + } + + var email ParsedEmail + if err := json.Unmarshal(ev.Payload, &email); err != nil { + smtpError(w, "failed to parse message", http.StatusInternalServerError) + return + } + + seen := make(map[string]bool) + var codes []string + for _, s := range []string{email.Text, stripHTMLTags(email.HTML)} { + for _, m := range re.FindAllString(s, -1) { + if !seen[m] { + seen[m] = true + codes = append(codes, m) + } + } + } + if codes == nil { + codes = []string{} + } + + smtpJSON(w, map[string]any{"data": codes, "pattern": pattern}) + } +} + +// parseFilter extracts MessageFilter fields from query parameters. +func parseFilter(r *http.Request) MessageFilter { + q := r.URL.Query() + f := MessageFilter{ + To: q.Get("to"), + From: q.Get("from"), + Cc: q.Get("cc"), + Subject: q.Get("subject"), + SubjectContains: q.Get("subject_contains"), + SubjectRegex: q.Get("subject_regex"), + BodyContains: q.Get("body_contains"), + Project: q.Get("project"), + Order: q.Get("order"), + } + if f.Order == "" { + f.Order = "desc" + } + if s := q.Get("since"); s != "" { + f.Since = parseTimestamp(s) + } + if u := q.Get("until"); u != "" { + f.Until = parseTimestamp(u) + } + if l := q.Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 { + f.Limit = n + } + } + if o := q.Get("offset"); o != "" { + if n, err := strconv.Atoi(o); n >= 0 && err == nil { + f.Offset = n + } + } + return f +} + +// parseTimestamp parses a timestamp from an RFC3339 string, unix-ms integer, or +// plain float (unix seconds). Returns 0 on failure. +func parseTimestamp(s string) float64 { + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return float64(t.UnixMicro()) / 1_000_000 + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return float64(t.UnixMicro()) / 1_000_000 + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + // Heuristic: values > 1e12 are milliseconds, otherwise seconds. + if f > 1e12 { + return f / 1000 + } + return f + } + return 0 +} + +// applyFilter filters a slice of events in-memory. +func applyFilter(events []event.Event, f MessageFilter) []event.Event { + var result []event.Event + for _, ev := range events { + if matchesFilter(ev, f) { + result = append(result, ev) + } + } + return result +} + +// matchesFilter returns true if ev satisfies all criteria in f. +func matchesFilter(ev event.Event, f MessageFilter) bool { + if f.Since > 0 && ev.Timestamp < f.Since { + return false + } + if f.Until > 0 && ev.Timestamp > f.Until { + return false + } + + var email ParsedEmail + if err := json.Unmarshal(ev.Payload, &email); err != nil { + return false + } + + if f.To != "" && !matchAddresses(email.To, f.To) { + return false + } + if f.From != "" && !matchAddresses(email.From, f.From) { + return false + } + if f.Cc != "" && !matchAddresses(email.Cc, f.Cc) { + return false + } + if f.Subject != "" && email.Subject != f.Subject { + return false + } + if f.SubjectContains != "" && !strings.Contains(email.Subject, f.SubjectContains) { + return false + } + if f.SubjectRegex != "" { + re, err := regexp.Compile(f.SubjectRegex) + if err != nil || !re.MatchString(email.Subject) { + return false + } + } + if f.BodyContains != "" && + !strings.Contains(email.Text, f.BodyContains) && + !strings.Contains(email.HTML, f.BodyContains) { + return false + } + + return true +} + +func matchAddresses(addrs []EmailAddress, query string) bool { + lq := strings.ToLower(query) + for _, a := range addrs { + if strings.Contains(strings.ToLower(a.Email), lq) || + strings.Contains(strings.ToLower(a.Name), lq) { + return true + } + } + return false +} + +// extractLinks gathers all unique URLs from a message's HTML and text parts. +func extractLinks(email *ParsedEmail) []Link { + var links []Link + seen := make(map[string]bool) + add := func(l Link) { + if !seen[l.URL] { + seen[l.URL] = true + links = append(links, l) + } + } + for _, l := range extractHTMLLinks(email.HTML) { + add(l) + } + for _, l := range extractTextLinks(email.Text) { + add(l) + } + if links == nil { + links = []Link{} + } + return links +} + +var ( + reHTMLLink = regexp.MustCompile(`(?is)]+href=["']([^"']+)["'][^>]*>(.*?)`) + reHTMLTag = regexp.MustCompile(`<[^>]+>`) + reTextURL = regexp.MustCompile(`https?://[^\s<>"']+`) +) + +func extractHTMLLinks(htmlContent string) []Link { + var links []Link + for _, m := range reHTMLLink.FindAllStringSubmatch(htmlContent, -1) { + href := m[1] + text := strings.TrimSpace(reHTMLTag.ReplaceAllString(m[2], "")) + links = append(links, Link{URL: href, Text: text, Source: "html"}) + } + return links +} + +func extractTextLinks(text string) []Link { + var links []Link + for _, m := range reTextURL.FindAllString(text, -1) { + links = append(links, Link{URL: m, Source: "text"}) + } + return links +} + +func stripHTMLTags(s string) string { + return reHTMLTag.ReplaceAllString(s, " ") +} + +func smtpJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) //nolint:errcheck +} + +func smtpError(w http.ResponseWriter, msg string, code int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]any{"message": msg, "code": code}) //nolint:errcheck +} diff --git a/modules/smtp/api_test.go b/modules/smtp/api_test.go new file mode 100644 index 0000000..826f3b9 --- /dev/null +++ b/modules/smtp/api_test.go @@ -0,0 +1,754 @@ +package smtp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/buggregator/go-buggregator/internal/event" + "github.com/buggregator/go-buggregator/internal/storage" +) + +func setupAPITest(t *testing.T) (*http.ServeMux, *storage.SQLiteStore, *Module) { + t.Helper() + db, err := storage.Open(":memory:") + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS events ( + uuid TEXT PRIMARY KEY, type TEXT NOT NULL, payload TEXT NOT NULL, + timestamp TEXT NOT NULL, project TEXT, is_pinned INTEGER NOT NULL DEFAULT 0 + )`); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + + store := storage.NewSQLiteStore(db) + mod := New(":1025", nil, nil) + + mux := http.NewServeMux() + mod.RegisterRoutes(mux, store) + + return mux, store, mod +} + +func storeSMTPEvent(t *testing.T, store *storage.SQLiteStore, uuid string, email ParsedEmail, ts float64, project string) { + t.Helper() + payload, _ := json.Marshal(email) + ev := event.Event{ + UUID: uuid, + Type: "smtp", + Payload: payload, + Timestamp: ts, + Project: project, + } + if err := store.Store(context.Background(), ev); err != nil { + t.Fatal(err) + } +} + +// TestSMTPAPI_Cursor verifies the cursor endpoint returns a valid RFC3339 time. +func TestSMTPAPI_Cursor(t *testing.T) { + mux, _, _ := setupAPITest(t) + + r := httptest.NewRequest("GET", "/api/smtp/cursor", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d", w.Code) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if _, err := time.Parse(time.RFC3339Nano, resp["cursor"]); err != nil { + t.Errorf("cursor %q is not valid RFC3339Nano: %v", resp["cursor"], err) + } +} + +// TestSMTPAPI_Messages_Empty verifies an empty list when no messages exist. +func TestSMTPAPI_Messages_Empty(t *testing.T) { + mux, _, _ := setupAPITest(t) + + r := httptest.NewRequest("GET", "/api/smtp/messages", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d", w.Code) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) != 0 { + t.Errorf("expected empty, got %d", len(data)) + } +} + +// TestSMTPAPI_Messages_Filter verifies all filter parameters. +func TestSMTPAPI_Messages_Filter(t *testing.T) { + mux, store, _ := setupAPITest(t) + + storeSMTPEvent(t, store, "f-uuid1", ParsedEmail{ + Subject: "Hello", + To: []EmailAddress{{Email: "alice@example.com"}}, + From: []EmailAddress{{Email: "sender@example.com"}}, + Text: "Click here: https://example.com/reset/abc", + }, 1000.0, "default") + + storeSMTPEvent(t, store, "f-uuid2", ParsedEmail{ + Subject: "World", + To: []EmailAddress{{Email: "bob@example.com"}}, + From: []EmailAddress{{Email: "sender@example.com"}}, + Text: "Your code is 123456", + }, 1001.0, "default") + + storeSMTPEvent(t, store, "f-uuid3", ParsedEmail{ + Subject: "Other project", + To: []EmailAddress{{Email: "alice@example.com"}}, + }, 1002.0, "other") + + t.Run("filter by to", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/messages?to=alice", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) != 2 { // alice appears in default and other project + t.Errorf("expected 2, got %d", len(data)) + } + }) + + t.Run("filter by project", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/messages?project=default", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) != 2 { + t.Errorf("expected 2, got %d", len(data)) + } + }) + + t.Run("filter by subject_contains", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/messages?subject_contains=Hell", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) != 1 { + t.Errorf("expected 1, got %d", len(data)) + } + }) + + t.Run("filter by subject exact", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/messages?subject=World", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) != 1 { + t.Errorf("expected 1, got %d", len(data)) + } + }) + + t.Run("filter by subject_regex", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/messages?subject_regex=^(Hello|World)$", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) != 2 { + t.Errorf("expected 2, got %d", len(data)) + } + }) + + t.Run("filter by body_contains", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/messages?body_contains=123456", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) != 1 { + t.Errorf("expected 1, got %d", len(data)) + } + }) + + t.Run("filter by since", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/messages?since=1000.5", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) != 2 { // f-uuid2 (1001) and f-uuid3 (1002) are >= 1000.5 + t.Errorf("expected 2, got %d", len(data)) + } + }) + + t.Run("filter by until", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/messages?until=1000.5", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) != 1 { // only f-uuid1 (1000.0) is <= 1000.5 + t.Errorf("expected 1, got %d", len(data)) + } + }) + + t.Run("order asc", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/messages?order=asc", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) < 2 { + t.Fatalf("expected at least 2 items") + } + // First item should have the smallest timestamp. + first := data[0].(map[string]any) + last := data[len(data)-1].(map[string]any) + if first["timestamp"].(float64) > last["timestamp"].(float64) { + t.Error("expected ascending order") + } + }) + + t.Run("pagination limit offset", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/messages?limit=1&offset=1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) != 1 { + t.Errorf("expected 1, got %d", len(data)) + } + meta := resp["meta"].(map[string]any) + if meta["total"].(float64) != 3 { + t.Errorf("meta.total = %v, want 3", meta["total"]) + } + }) +} + +// TestSMTPAPI_Stats verifies the stats endpoint. +func TestSMTPAPI_Stats(t *testing.T) { + mux, store, _ := setupAPITest(t) + + t.Run("empty", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/stats", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["count"].(float64) != 0 { + t.Errorf("count = %v, want 0", resp["count"]) + } + if resp["last_received_at"] != nil { + t.Errorf("last_received_at should be nil for empty store, got %v", resp["last_received_at"]) + } + }) + + storeSMTPEvent(t, store, "st-uuid1", ParsedEmail{Subject: "Test"}, 1000.0, "default") + + t.Run("one message", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/stats", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["count"].(float64) != 1 { + t.Errorf("count = %v, want 1", resp["count"]) + } + if resp["last_received_at"] == nil { + t.Error("last_received_at should not be nil") + } + }) +} + +// TestSMTPAPI_Raw verifies the raw RFC822 endpoint. +func TestSMTPAPI_Raw(t *testing.T) { + mux, store, _ := setupAPITest(t) + + rawSrc := "From: sender@example.com\r\nTo: user@example.com\r\nSubject: Test\r\n\r\nBody" + storeSMTPEvent(t, store, "raw-uuid1", ParsedEmail{ + Subject: "Test", + Raw: rawSrc, + }, 1000.0, "default") + + t.Run("found", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/message/raw-uuid1/raw", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "message/rfc822" { + t.Errorf("Content-Type = %q, want %q", ct, "message/rfc822") + } + if w.Body.String() != rawSrc { + t.Errorf("body = %q, want %q", w.Body.String(), rawSrc) + } + }) + + t.Run("not found", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/message/nonexistent/raw", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } + }) +} + +// TestSMTPAPI_Links verifies link extraction from HTML and plain text. +func TestSMTPAPI_Links(t *testing.T) { + mux, store, _ := setupAPITest(t) + + storeSMTPEvent(t, store, "lnk-uuid1", ParsedEmail{ + Subject: "Test", + HTML: `Reset Password`, + Text: "Visit https://example.com or https://other.com", + }, 1000.0, "default") + + r := httptest.NewRequest("GET", "/api/smtp/message/lnk-uuid1/links", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d", w.Code) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + // HTML: 1 link; Text: 2 unique links (example.com already seen, other.com new) + if len(data) != 3 { + t.Errorf("expected 3 links, got %d: %v", len(data), data) + } + + // Verify the HTML link has anchor text. + htmlLink := data[0].(map[string]any) + if htmlLink["source"] != "html" { + t.Errorf("first link source = %v, want html", htmlLink["source"]) + } + if htmlLink["text"] != "Reset Password" { + t.Errorf("link text = %v, want Reset Password", htmlLink["text"]) + } +} + +// TestSMTPAPI_Links_NoDuplicates verifies that the same URL in HTML and text +// is deduplicated (HTML wins). +func TestSMTPAPI_Links_NoDuplicates(t *testing.T) { + mux, store, _ := setupAPITest(t) + + storeSMTPEvent(t, store, "dup-uuid1", ParsedEmail{ + Subject: "Test", + HTML: `Click`, + Text: "https://example.com", + }, 1000.0, "default") + + r := httptest.NewRequest("GET", "/api/smtp/message/dup-uuid1/links", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) != 1 { + t.Errorf("expected 1 unique link, got %d", len(data)) + } +} + +// TestSMTPAPI_Codes verifies OTP code extraction. +func TestSMTPAPI_Codes(t *testing.T) { + mux, store, _ := setupAPITest(t) + + storeSMTPEvent(t, store, "code-uuid1", ParsedEmail{ + Subject: "Your OTP", + Text: "Your code is 123456. Do not share it.", + }, 1000.0, "default") + + t.Run("default pattern", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/message/code-uuid1/codes", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d", w.Code) + } + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + found := false + for _, c := range data { + if c.(string) == "123456" { + found = true + } + } + if !found { + t.Errorf("expected to find 123456 in %v", data) + } + }) + + t.Run("custom pattern", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/message/code-uuid1/codes?pattern=\\d{6}", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + if len(data) == 0 { + t.Error("expected at least one code with custom pattern") + } + }) + + t.Run("invalid pattern", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/message/code-uuid1/codes?pattern=[invalid", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) + + t.Run("html body codes", func(t *testing.T) { + storeSMTPEvent(t, store, "code-uuid2", ParsedEmail{ + Subject: "HTML OTP", + HTML: "

Your verification code: 654321

", + }, 1001.0, "default") + + r := httptest.NewRequest("GET", "/api/smtp/message/code-uuid2/codes", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + data := resp["data"].([]any) + found := false + for _, c := range data { + if c.(string) == "654321" { + found = true + } + } + if !found { + t.Errorf("expected to find 654321 in %v", data) + } + }) +} + +// TestSMTPAPI_Delete verifies the delete endpoint. +func TestSMTPAPI_Delete(t *testing.T) { + mux, store, _ := setupAPITest(t) + ctx := context.Background() + + storeSMTPEvent(t, store, "del-uuid1", ParsedEmail{Subject: "Delete me"}, 1000.0, "default") + storeSMTPEvent(t, store, "del-uuid2", ParsedEmail{Subject: "Keep me"}, 1001.0, "other") + + t.Run("delete by project", func(t *testing.T) { + r := httptest.NewRequest("DELETE", "/api/smtp/messages?project=default", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d", w.Code) + } + + all, _ := store.FindAll(ctx, event.FindOptions{Type: "smtp"}) + if len(all) != 1 { + t.Errorf("expected 1 remaining, got %d", len(all)) + } + if all[0].UUID != "del-uuid2" { + t.Errorf("wrong event remaining: %s", all[0].UUID) + } + }) + + t.Run("delete all", func(t *testing.T) { + r := httptest.NewRequest("DELETE", "/api/smtp/messages", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + if w.Code != 200 { + t.Fatalf("status = %d", w.Code) + } + + all, _ := store.FindAll(ctx, event.FindOptions{Type: "smtp"}) + if len(all) != 0 { + t.Errorf("expected 0, got %d", len(all)) + } + }) +} + +// TestSMTPAPI_Wait verifies the long-poll wait endpoint. +func TestSMTPAPI_Wait(t *testing.T) { + mux, store, mod := setupAPITest(t) + + t.Run("timeout", func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/smtp/messages/wait?timeout=50ms&to=nobody@example.com", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + if w.Code != http.StatusRequestTimeout { + t.Errorf("expected 408, got %d", w.Code) + } + }) + + t.Run("existing matching event", func(t *testing.T) { + storeSMTPEvent(t, store, "wait-uuid1", ParsedEmail{ + Subject: "Existing", + To: []EmailAddress{{Email: "wait-user@example.com"}}, + }, float64(time.Now().Add(-time.Second).UnixMicro())/1_000_000, "default") + + r := httptest.NewRequest("GET", "/api/smtp/messages/wait?to=wait-user@example.com&timeout=1s", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + }) + + t.Run("new event arrives", func(t *testing.T) { + cursor := float64(time.Now().UnixMicro()) / 1_000_000 + + resultCh := make(chan *httptest.ResponseRecorder, 1) + go func() { + url := fmt.Sprintf("/api/smtp/messages/wait?to=newuser@example.com&timeout=5s&since=%.6f", cursor) + r := httptest.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + resultCh <- w + }() + + // Give the goroutine time to subscribe before the event arrives. + time.Sleep(30 * time.Millisecond) + + newEv := event.Event{ + UUID: "wait-uuid2", + Type: "smtp", + Payload: mustMarshalJSON(ParsedEmail{ + Subject: "New Arrival", + To: []EmailAddress{{Email: "newuser@example.com"}}, + }), + Timestamp: float64(time.Now().UnixMicro()) / 1_000_000, + Project: "default", + } + store.Store(context.Background(), newEv) + mod.OnEventStored(newEv) + + select { + case w := <-resultCh: + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + case <-time.After(5 * time.Second): + t.Error("timed out waiting for result") + } + }) +} + +// TestParseTimestamp verifies timestamp parsing from various formats. +func TestParseTimestamp(t *testing.T) { + tests := []struct { + input string + want float64 + }{ + {"1700000000", 1700000000}, + {"1700000000000", 1700000000}, // unix ms + {"1.5", 1.5}, + {"2023-11-14T22:13:20Z", 1700000000}, + } + for _, tt := range tests { + got := parseTimestamp(tt.input) + if got != tt.want { + t.Errorf("parseTimestamp(%q) = %v, want %v", tt.input, got, tt.want) + } + } + + // Bad input returns 0. + if parseTimestamp("not-a-timestamp") != 0 { + t.Error("expected 0 for invalid input") + } +} + +// TestMatchesFilter verifies in-memory filter logic. +func TestMatchesFilter(t *testing.T) { + makeEvent := func(email ParsedEmail, ts float64) event.Event { + payload, _ := json.Marshal(email) + return event.Event{ + UUID: "test", + Type: "smtp", + Payload: payload, + Timestamp: ts, + Project: "default", + } + } + + t.Run("to match", func(t *testing.T) { + ev := makeEvent(ParsedEmail{ + To: []EmailAddress{{Email: "alice@example.com", Name: "Alice"}}, + }, 1000) + if !matchesFilter(ev, MessageFilter{To: "alice"}) { + t.Error("expected match on partial email") + } + if !matchesFilter(ev, MessageFilter{To: "Alice"}) { + t.Error("expected case-insensitive name match") + } + if matchesFilter(ev, MessageFilter{To: "bob"}) { + t.Error("expected no match") + } + }) + + t.Run("from match", func(t *testing.T) { + ev := makeEvent(ParsedEmail{ + From: []EmailAddress{{Email: "sender@example.com"}}, + }, 1000) + if !matchesFilter(ev, MessageFilter{From: "sender"}) { + t.Error("expected match") + } + }) + + t.Run("cc match", func(t *testing.T) { + ev := makeEvent(ParsedEmail{ + Cc: []EmailAddress{{Email: "cc@example.com"}}, + }, 1000) + if !matchesFilter(ev, MessageFilter{Cc: "cc@"}) { + t.Error("expected match") + } + }) + + t.Run("subject exact", func(t *testing.T) { + ev := makeEvent(ParsedEmail{Subject: "Hello World"}, 1000) + if !matchesFilter(ev, MessageFilter{Subject: "Hello World"}) { + t.Error("expected exact match") + } + if matchesFilter(ev, MessageFilter{Subject: "Hello"}) { + t.Error("exact match should not match partial subject") + } + }) + + t.Run("subject_contains", func(t *testing.T) { + ev := makeEvent(ParsedEmail{Subject: "Hello World"}, 1000) + if !matchesFilter(ev, MessageFilter{SubjectContains: "World"}) { + t.Error("expected contains match") + } + }) + + t.Run("subject_regex", func(t *testing.T) { + ev := makeEvent(ParsedEmail{Subject: "OTP: 123456"}, 1000) + if !matchesFilter(ev, MessageFilter{SubjectRegex: `OTP: \d+`}) { + t.Error("expected regex match") + } + if matchesFilter(ev, MessageFilter{SubjectRegex: `^[invalid`}) { + t.Error("invalid regex should not match") + } + }) + + t.Run("body_contains text", func(t *testing.T) { + ev := makeEvent(ParsedEmail{Text: "Reset link here"}, 1000) + if !matchesFilter(ev, MessageFilter{BodyContains: "Reset"}) { + t.Error("expected text body match") + } + }) + + t.Run("body_contains html", func(t *testing.T) { + ev := makeEvent(ParsedEmail{HTML: "

Click to verify

"}, 1000) + if !matchesFilter(ev, MessageFilter{BodyContains: "verify"}) { + t.Error("expected html body match") + } + }) + + t.Run("since", func(t *testing.T) { + ev := makeEvent(ParsedEmail{}, 1000) + if !matchesFilter(ev, MessageFilter{Since: 999}) { + t.Error("expected match (ts >= since)") + } + if matchesFilter(ev, MessageFilter{Since: 1001}) { + t.Error("expected no match (ts < since)") + } + }) + + t.Run("until", func(t *testing.T) { + ev := makeEvent(ParsedEmail{}, 1000) + if !matchesFilter(ev, MessageFilter{Until: 1001}) { + t.Error("expected match (ts <= until)") + } + if matchesFilter(ev, MessageFilter{Until: 999}) { + t.Error("expected no match (ts > until)") + } + }) +} + +// TestExtractLinks verifies link extraction from HTML and text. +func TestExtractLinks(t *testing.T) { + email := &ParsedEmail{ + HTML: `Reset Other`, + Text: "See https://text.com for details", + } + links := extractLinks(email) + + if len(links) != 3 { + t.Fatalf("expected 3 links, got %d: %+v", len(links), links) + } + + if links[0].Source != "html" || links[0].URL != "https://example.com/reset" { + t.Errorf("link[0] = %+v", links[0]) + } + if links[0].Text != "Reset" { + t.Errorf("link[0].Text = %q, want Reset", links[0].Text) + } + if links[2].Source != "text" || links[2].URL != "https://text.com" { + t.Errorf("link[2] = %+v", links[2]) + } +} + +// TestSessionAuthPlain verifies project extraction from SMTP AUTH. +func TestSessionAuthPlain(t *testing.T) { + tests := []struct { + username string + want string + }{ + {"test-run-42@smtp", "test-run-42"}, + {"myproject", "myproject"}, + {"", ""}, + {"proj@host.com", "proj"}, + } + for _, tt := range tests { + s := &session{} + s.AuthPlain(tt.username, "password") + if s.project != tt.want { + t.Errorf("AuthPlain(%q) project = %q, want %q", tt.username, s.project, tt.want) + } + } +} + +func mustMarshalJSON(v any) json.RawMessage { + b, _ := json.Marshal(v) + return b +} diff --git a/modules/smtp/handler.go b/modules/smtp/handler.go index f259afe..289812c 100644 --- a/modules/smtp/handler.go +++ b/modules/smtp/handler.go @@ -76,9 +76,20 @@ type session struct { backend *backend from string to []string + project string // extracted from SMTP AUTH username } -func (s *session) AuthPlain(username, password string) error { return nil } +func (s *session) AuthPlain(username, password string) error { + if username != "" { + // Support "project@host" or plain "project" as the mailbox identifier. + if idx := strings.Index(username, "@"); idx > 0 { + s.project = username[:idx] + } else { + s.project = username + } + } + return nil +} func (s *session) Mail(from string, opts *gosmtp.MailOptions) error { s.from = from return nil @@ -127,6 +138,7 @@ func (s *session) Data(r io.Reader) error { UUID: eventUUID, Type: "smtp", Payload: json.RawMessage(payload), + Project: s.project, } if err := s.backend.eventService.HandleIncoming(context.Background(), inc); err != nil { @@ -135,7 +147,7 @@ func (s *session) Data(r io.Reader) error { return nil } -func (s *session) Reset() { s.from = ""; s.to = nil } +func (s *session) Reset() { s.from = ""; s.to = nil; s.project = "" } func (s *session) Logout() error { return nil } // EmailAddress represents an email address. diff --git a/modules/smtp/module.go b/modules/smtp/module.go index bdb1134..95f6ae8 100644 --- a/modules/smtp/module.go +++ b/modules/smtp/module.go @@ -3,6 +3,8 @@ package smtp import ( "context" "database/sql" + "net/http" + "sync" "github.com/buggregator/go-buggregator/internal/event" "github.com/buggregator/go-buggregator/internal/module" @@ -10,12 +12,21 @@ import ( "github.com/buggregator/go-buggregator/internal/storage" ) +// waiter holds a long-poll subscription waiting for a matching SMTP event. +type waiter struct { + filter MessageFilter + ch chan event.Event +} + type Module struct { module.BaseModule addr string eventService EventStorer attachments *storage.AttachmentStore db *sql.DB + + mu sync.Mutex + waiters []*waiter } type EventStorer interface { @@ -46,6 +57,49 @@ func (m *Module) TCPServers() []tcp.ServerConfig { } } +// RegisterRoutes registers the SMTP testing API endpoints. +func (m *Module) RegisterRoutes(mux *http.ServeMux, store event.Store) { + registerSMTPAPI(mux, store, m) +} + +// OnEventStored notifies any long-poll waiters when a new SMTP event arrives. +func (m *Module) OnEventStored(ev event.Event) { + if ev.Type != "smtp" { + return + } + m.mu.Lock() + defer m.mu.Unlock() + for _, w := range m.waiters { + if matchesFilter(ev, w.filter) { + select { + case w.ch <- ev: + default: + // Channel already has an event; waiter will pick it up. + } + } + } +} + +// subscribe registers a waiter for the wait endpoint and returns a channel and +// an unsubscribe function. The caller must call unsubscribe when done. +func (m *Module) subscribe(f MessageFilter) (<-chan event.Event, func()) { + ch := make(chan event.Event, 1) + w := &waiter{filter: f, ch: ch} + m.mu.Lock() + m.waiters = append(m.waiters, w) + m.mu.Unlock() + return ch, func() { + m.mu.Lock() + defer m.mu.Unlock() + for i, ww := range m.waiters { + if ww == w { + m.waiters = append(m.waiters[:i], m.waiters[i+1:]...) + return + } + } + } +} + func (m *Module) PreviewMapper() event.PreviewMapper { return &previewMapper{} } From 5ebc21822cd7275dc9c7d5b34596ad83e2a194ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 06:42:12 +0000 Subject: [PATCH 3/3] Address code review: extract timestamp helpers to reduce duplication Agent-Logs-Url: https://github.com/buggregator/server/sessions/d7c6264b-fee5-4b75-bb9f-40076f256407 Co-authored-by: butschster <773481+butschster@users.noreply.github.com> --- go.mod | 2 +- go.sum | 3 +-- internal/frontend/dist/index.html | 8 ++++---- modules/smtp/api.go | 10 ++++++++-- modules/smtp/api_test.go | 16 +++++++++++++--- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index b440503..8fc7d90 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 golang.org/x/oauth2 v0.35.0 + golang.org/x/text v0.35.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.48.0 nhooyr.io/websocket v1.8.17 @@ -34,7 +35,6 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect google.golang.org/protobuf v1.36.8 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 7b50f84..a517442 100644 --- a/go.sum +++ b/go.sum @@ -69,9 +69,8 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/frontend/dist/index.html b/internal/frontend/dist/index.html index 1e882e7..0dd8aa4 100644 --- a/internal/frontend/dist/index.html +++ b/internal/frontend/dist/index.html @@ -11,11 +11,11 @@ Buggregator - - - + + + - +
diff --git a/modules/smtp/api.go b/modules/smtp/api.go index b66e604..06197af 100644 --- a/modules/smtp/api.go +++ b/modules/smtp/api.go @@ -326,14 +326,20 @@ func parseFilter(r *http.Request) MessageFilter { return f } +// unixFloat converts a time.Time to a float64 unix timestamp with microsecond +// precision (matching the internal event.Event.Timestamp representation). +func unixFloat(t time.Time) float64 { + return float64(t.UnixMicro()) / 1_000_000 +} + // parseTimestamp parses a timestamp from an RFC3339 string, unix-ms integer, or // plain float (unix seconds). Returns 0 on failure. func parseTimestamp(s string) float64 { if t, err := time.Parse(time.RFC3339Nano, s); err == nil { - return float64(t.UnixMicro()) / 1_000_000 + return unixFloat(t) } if t, err := time.Parse(time.RFC3339, s); err == nil { - return float64(t.UnixMicro()) / 1_000_000 + return unixFloat(t) } if f, err := strconv.ParseFloat(s, 64); err == nil { // Heuristic: values > 1e12 are milliseconds, otherwise seconds. diff --git a/modules/smtp/api_test.go b/modules/smtp/api_test.go index 826f3b9..bbc37e3 100644 --- a/modules/smtp/api_test.go +++ b/modules/smtp/api_test.go @@ -523,7 +523,7 @@ func TestSMTPAPI_Wait(t *testing.T) { storeSMTPEvent(t, store, "wait-uuid1", ParsedEmail{ Subject: "Existing", To: []EmailAddress{{Email: "wait-user@example.com"}}, - }, float64(time.Now().Add(-time.Second).UnixMicro())/1_000_000, "default") + }, tsAgo(time.Second), "default") r := httptest.NewRequest("GET", "/api/smtp/messages/wait?to=wait-user@example.com&timeout=1s", nil) w := httptest.NewRecorder() @@ -535,7 +535,7 @@ func TestSMTPAPI_Wait(t *testing.T) { }) t.Run("new event arrives", func(t *testing.T) { - cursor := float64(time.Now().UnixMicro()) / 1_000_000 + cursor := tsNow() resultCh := make(chan *httptest.ResponseRecorder, 1) go func() { @@ -556,7 +556,7 @@ func TestSMTPAPI_Wait(t *testing.T) { Subject: "New Arrival", To: []EmailAddress{{Email: "newuser@example.com"}}, }), - Timestamp: float64(time.Now().UnixMicro()) / 1_000_000, + Timestamp: tsNow(), Project: "default", } store.Store(context.Background(), newEv) @@ -752,3 +752,13 @@ func mustMarshalJSON(v any) json.RawMessage { b, _ := json.Marshal(v) return b } + +// tsNow returns the current time as a unix float (matching event.Timestamp). +func tsNow() float64 { + return float64(time.Now().UnixMicro()) / 1_000_000 +} + +// tsAgo returns a timestamp N seconds before now. +func tsAgo(d time.Duration) float64 { + return float64(time.Now().Add(-d).UnixMicro()) / 1_000_000 +}