Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 40 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,41 +90,51 @@ All servers are designed for testing purposes:

Both servers provide the following endpoints:

| Endpoint | Method | Description |
| ------------------- | ------ | ------------------------------- |
| `/` | GET | Web UI |
| `/doc` | GET | API documentation (Markdown) |
| `/api/entries` | GET | List all entries (newest first) |
| `/api/entries/{id}` | GET | Get specific entry |
| `/api/entries/{id}` | DELETE | Delete specific entry |
| `/api/entries` | DELETE | Clear all entries |
| `/api/stats` | GET | Store statistics |
| `/api/events` | GET | Server-Sent Events stream |
| `/health` | GET | Health check |
| Endpoint | Method | Description |
| ------------------- | ------ | -------------------------------- |
| `/` | GET | Web UI |
| `/doc` | GET | API documentation (Markdown) |
| `/api/entries` | GET | List all entries (newest first) |
| `/api/entries/{id}` | GET | Get specific entry |
| `/api/entries/{id}` | DELETE | Delete specific entry |
| `/api/entries` | DELETE | Clear all entries |
| `/api/stats` | GET | Store statistics |
| `/api/count` | GET | Count entries matching filter |
| `/api/await` | GET | Wait for entries matching filter |
| `/api/events` | GET | Server-Sent Events stream |
| `/health` | GET | Health check |
Comment thread
lambdalisue marked this conversation as resolved.

### Filtering

`GET /api/entries` supports query parameters for filtering:

| Parameter | Description | trap-smtp | trap-webhook |
| ---------------- | ------------------------------- | --------- | ------------ |
| `from` | Contains match on sender | ✓ | |
| `to` | Contains match on any recipient | ✓ | |
| `subject` | Contains match on subject | ✓ | |
| `method` | Exact match on HTTP method | | ✓ |
| `path` | Contains match on request path | | ✓ |
| `query` | Contains match on query string | | ✓ |
| `body` | Contains match on body | ✓ | ✓ |
| `jsonpath` | JSONPath expression for body | ✓ | ✓ |
| `jsonpath_value` | Expected value at JSONPath | ✓ | ✓ |
| `content_type` | Contains match on Content-Type | | ✓ |
| `header` | Header name to check | ✓ | ✓ |
| `header_value` | Header value contains match | ✓ | ✓ |
| `host` | Contains match on Host header | | ✓ |
| `since` | ReceivedAt after (RFC3339) | ✓ | ✓ |
| `until` | ReceivedAt before (RFC3339) | ✓ | ✓ |
| `limit` | Maximum number of results | ✓ | ✓ |
| `offset` | Skip first N results | ✓ | ✓ |
| Parameter | Description | trap-smtp | trap-webhook |
| -------------------- | ------------------------------- | --------- | ------------ |
| `from` | Contains match on sender | ✓ | |
| `from_regex` | Regex match on sender | ✓ | |
| `to` | Contains match on any recipient | ✓ | |
| `to_regex` | Regex match on any recipient | ✓ | |
| `subject` | Contains match on subject | ✓ | |
| `subject_regex` | Regex match on subject | ✓ | |
| `method` | Exact match on HTTP method | | ✓ |
| `path` | Contains match on request path | | ✓ |
| `path_regex` | Regex match on request path | | ✓ |
| `query` | Contains match on query string | | ✓ |
| `query_regex` | Regex match on query string | | ✓ |
| `body` | Contains match on body | ✓ | ✓ |
| `body_regex` | Regex match on body | ✓ | ✓ |
| `jsonpath` | JSONPath expression for body | ✓ | ✓ |
| `jsonpath_value` | Expected value at JSONPath | ✓ | ✓ |
| `content_type` | Contains match on Content-Type | | ✓ |
| `content_type_regex` | Regex match on Content-Type | | ✓ |
| `header` | Header name to check | ✓ | ✓ |
| `header_value` | Header value contains match | ✓ | ✓ |
| `host` | Contains match on Host header | | ✓ |
| `host_regex` | Regex match on Host header | | ✓ |
| `since` | ReceivedAt after (RFC3339) | ✓ | ✓ |
| `until` | ReceivedAt before (RFC3339) | ✓ | ✓ |
| `limit` | Maximum number of results | ✓ | ✓ |
| `offset` | Skip first N results | ✓ | ✓ |

### trap-smtp specific

Expand Down
2 changes: 1 addition & 1 deletion trap-smtp/go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module github.com/probitas-test/state-servers/state-smtp
module github.com/probitas-test/state-servers/trap-smtp

go 1.25

Expand Down
34 changes: 30 additions & 4 deletions trap-smtp/handlers/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ package handlers

import (
"encoding/json"
"fmt"
"mime"
"net/http"
"regexp"
"strconv"
"strings"
"time"

"github.com/go-chi/chi/v5"

"github.com/probitas-test/state-servers/state-smtp/store"
"github.com/probitas-test/state-servers/trap-smtp/store"
)

var emailStore *store.Store
Expand All @@ -22,7 +24,11 @@ func SetStore(s *store.Store) {

// ListEntriesHandler returns stored email entries with optional filtering
func ListEntriesHandler(w http.ResponseWriter, r *http.Request) {
filter := parseEmailFilter(r)
filter, err := parseEmailFilter(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
entries := emailStore.ListWithFilter(filter)

w.Header().Set("Content-Type", "application/json")
Expand All @@ -32,7 +38,7 @@ func ListEntriesHandler(w http.ResponseWriter, r *http.Request) {
}

// parseEmailFilter extracts filter parameters from the request
func parseEmailFilter(r *http.Request) *store.EmailFilter {
func parseEmailFilter(r *http.Request) (*store.EmailFilter, error) {
q := r.URL.Query()

filter := &store.EmailFilter{
Expand All @@ -46,6 +52,26 @@ func parseEmailFilter(r *http.Request) *store.EmailFilter {
HeaderValue: q.Get("header_value"),
}

// Parse regex filters
regexFields := []struct {
param string
dest **regexp.Regexp
}{
{"from_regex", &filter.FromRegex},
{"to_regex", &filter.ToRegex},
{"subject_regex", &filter.SubjectRegex},
{"body_regex", &filter.BodyRegex},
}
for _, rf := range regexFields {
if v := q.Get(rf.param); v != "" {
re, err := regexp.Compile(v)
if err != nil {
return nil, fmt.Errorf("invalid %s: %w", rf.param, err)
}
*rf.dest = re
}
}
Comment thread
lambdalisue marked this conversation as resolved.

// Parse time filters
if since := q.Get("since"); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
Expand All @@ -70,7 +96,7 @@ func parseEmailFilter(r *http.Request) *store.EmailFilter {
}
}

return filter
return filter, nil
}

// GetEntryHandler returns a specific entry by ID
Expand Down
122 changes: 122 additions & 0 deletions trap-smtp/handlers/await.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package handlers

import (
"encoding/json"
"net/http"
"strconv"
"time"

"github.com/probitas-test/state-servers/trap-smtp/store"
)

// AwaitHandler blocks until the specified number of entries match the filter,
// or the timeout is reached. Returns matched entries on success, 408 on timeout.
//
// Query parameters:
// - count: minimum number of matching entries to wait for (default: 1)
// - timeout: maximum wait duration, e.g. "5s", "500ms" (default: 10s)
// - All email filter parameters (from, to, subject, body, etc.)
func AwaitHandler(w http.ResponseWriter, r *http.Request) {
filter, err := parseAwaitFilter(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

count := 1
if c := r.URL.Query().Get("count"); c != "" {
n, err := strconv.Atoi(c)
if err != nil || n <= 0 {
http.Error(w, "invalid 'count' parameter: must be a positive integer", http.StatusBadRequest)
return
}
count = n
}

timeout := 10 * time.Second
if t := r.URL.Query().Get("timeout"); t != "" {
d, err := time.ParseDuration(t)
if err != nil || d <= 0 {
http.Error(w, "invalid 'timeout' parameter: must be a positive duration (e.g. '5s', '500ms')", http.StatusBadRequest)
return
}
timeout = d
}

// Subscribe before checking existing entries to avoid missing entries
// added between the check and subscribe.
ch := emailStore.Subscribe()
defer emailStore.Unsubscribe(ch)

// Check existing entries
entries := emailStore.ListWithFilter(filter)
if len(entries) >= count {
resp, err := json.Marshal(entries)
if err != nil {
http.Error(w, "Failed to encode entries", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(resp); err != nil {
// Unable to write response; nothing more we can do here.
return
}
return
}

// Wait for new entries
timer := time.NewTimer(timeout)
defer timer.Stop()

for {
select {
case _, ok := <-ch:
if !ok {
http.Error(w, "Store closed", http.StatusInternalServerError)
return
}
entries = emailStore.ListWithFilter(filter)
if len(entries) >= count {
resp, err := json.Marshal(entries)
if err != nil {
http.Error(w, "Failed to encode entries", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(resp); err != nil {
// Unable to write response; nothing more we can do here.
return
}
return
}
Comment thread
lambdalisue marked this conversation as resolved.
case <-timer.C:
resp, err := json.Marshal(map[string]interface{}{
"error": "timeout",
"matched": len(emailStore.ListWithFilter(filter)),
"expected": count,
})
if err != nil {
http.Error(w, "Failed to encode timeout response", http.StatusInternalServerError)
return
}
Comment thread
lambdalisue marked this conversation as resolved.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestTimeout)
_, _ = w.Write(resp)
return
case <-r.Context().Done():
return
}
}
}

// parseAwaitFilter extracts filter parameters without pagination (limit/offset).
func parseAwaitFilter(r *http.Request) (*store.EmailFilter, error) {
filter, err := parseEmailFilter(r)
if err != nil {
return nil, err
}
// Clear pagination fields (not supported by await)
filter.Limit = 0
filter.Offset = 0
return filter, nil
}
Loading
Loading