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
new file mode 100644
index 0000000..06197af
--- /dev/null
+++ b/modules/smtp/api.go
@@ -0,0 +1,479 @@
+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
+}
+
+// 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 unixFloat(t)
+ }
+ if t, err := time.Parse(time.RFC3339, s); err == nil {
+ return unixFloat(t)
+ }
+ 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..bbc37e3
--- /dev/null
+++ b/modules/smtp/api_test.go
@@ -0,0 +1,764 @@
+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"}},
+ }, tsAgo(time.Second), "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 := tsNow()
+
+ 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: tsNow(),
+ 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
+}
+
+// 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
+}
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{}
}