hub v0.18.0: app-email passthrough POST /api/v1/mail → Resend SMTP
Raw-MIME passthrough (STARTTLS, AUTH LOGIN) — separate from the notify HTTP-API alert path (which drops inline CID images). Per-customer token-bucket rate limit, From-header allowlist backstop. Resend key stays hub-side. No new external dep. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,27 @@
|
||||
# Felhom Hub — Changelog
|
||||
|
||||
## v0.18.0 — App-email passthrough: POST /api/v1/mail → Resend SMTP (2026-06-29)
|
||||
|
||||
The hub can now relay a customer box's outbound app email to Resend, re-emitting the raw MIME
|
||||
**unchanged** over SMTP. This is the hub leg of the app-email relay (apps → on-box shim → hub →
|
||||
Resend); the Resend key stays hub-side. Implements `documentation/audits/SPIKE-smtp-app-relay-2026-06-28.md`.
|
||||
|
||||
- **New `internal/mailrelay/relay.go`:** `ResendSMTP` (a `Sender`) — STARTTLS to `smtp.resend.com:587`,
|
||||
`AUTH LOGIN resend/<key>` (a small `net/smtp.Auth` LOGIN impl; stdlib ships PlainAuth only), then raw
|
||||
`MAIL`/`RCPT`/`DATA`. **Raw passthrough** — NOT the `internal/notify` Resend **HTTP-API** path, which is
|
||||
unchanged for the hub's own structured alerts and silently drops inline CID images (spike §4). No new
|
||||
external dependency (stdlib `net/smtp`).
|
||||
- **New `POST /api/v1/mail`** (`internal/api/mail.go`): authenticates the box (`checkAuthCustomer`), enforces
|
||||
the **From-header** domain allowlist (backstop; reject 403), applies a **per-customer in-memory token-bucket
|
||||
rate limit** (default 30/min → 429 so one box can't drain the shared Resend quota), then passes the raw
|
||||
bytes through to Resend. Success→200, Resend failure→502 (the box's shim maps that to the app).
|
||||
- **Config:** new `mail` section (`per_customer_per_minute`, `from_domains`); wired in `cmd/hub/main.go` only
|
||||
when a Resend key is present (else the endpoint returns 503). The existing `notify/dispatcher.go` alert path
|
||||
is untouched.
|
||||
- **Tests:** passthrough byte-equality (raw bytes reach the sender unchanged, not parsed), From-reject +
|
||||
companion red-proof, per-customer rate-limit + isolation + companion, send-failure→502, 401/503/400 paths,
|
||||
token-bucket unit (injected clock), LOGIN auth + From-domain parsing.
|
||||
|
||||
## v0.17.0 — Resend key sourced from a Secret, out of git (2026-06-29)
|
||||
|
||||
Resend rotation + de-git hygiene. The hub's Resend API key was committed in plaintext in
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/api"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
@@ -65,6 +66,31 @@ type Config struct {
|
||||
// with DEFAULT_MIN_CONTROLLER_VERSION.
|
||||
DefaultMinVersion string `yaml:"default_min_version"`
|
||||
} `yaml:"controller_updates"`
|
||||
Mail MailConfig `yaml:"mail"`
|
||||
}
|
||||
|
||||
// MailConfig tunes the app-email passthrough (POST /api/v1/mail).
|
||||
type MailConfig struct {
|
||||
// PerCustomerPerMinute caps a single customer's forwarded messages per minute (abuse
|
||||
// containment — one box can't drain the shared Resend quota). Default 30.
|
||||
PerCustomerPerMinute int `yaml:"per_customer_per_minute"`
|
||||
// FromDomains is the From-header allowlist (backstop; Resend is the final backstop).
|
||||
// Default ["felhom.eu"].
|
||||
FromDomains []string `yaml:"from_domains"`
|
||||
}
|
||||
|
||||
func (m MailConfig) effectivePerMinute() int {
|
||||
if m.PerCustomerPerMinute <= 0 {
|
||||
return 30
|
||||
}
|
||||
return m.PerCustomerPerMinute
|
||||
}
|
||||
|
||||
func (m MailConfig) effectiveFromDomains() []string {
|
||||
if len(m.FromDomains) == 0 {
|
||||
return []string{"felhom.eu"}
|
||||
}
|
||||
return m.FromDomains
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -205,6 +231,18 @@ func main() {
|
||||
)
|
||||
apiHandler.SetDispatcher(dispatcher)
|
||||
|
||||
// App-email passthrough (POST /api/v1/mail): a customer box's shim forwards a raw message
|
||||
// here and the hub re-emits it to Resend over SMTP, UNCHANGED (raw passthrough — NOT the
|
||||
// dispatcher's structured HTTP-API path, which drops inline CID images). The Resend key stays
|
||||
// hub-side. Wired only when a key is present; otherwise the endpoint returns 503.
|
||||
if cfg.Notifications.ResendAPIKey != "" {
|
||||
mailSender := mailrelay.NewResendSMTP(cfg.Notifications.ResendAPIKey)
|
||||
apiHandler.SetMailRelay(mailSender, cfg.Mail.PerCustomerPerMinute, cfg.Mail.FromDomains)
|
||||
logger.Printf("[INFO] App-email relay enabled (limit %d/min/customer, From domains %v)", cfg.Mail.effectivePerMinute(), cfg.Mail.effectiveFromDomains())
|
||||
} else {
|
||||
logger.Printf("[INFO] App-email relay disabled (no Resend key)")
|
||||
}
|
||||
|
||||
webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger)
|
||||
webServer.SetTemplateFetcher(templateFetcher)
|
||||
webServer.SetAssetManager(assetsMgr)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
@@ -43,6 +44,11 @@ type Handler struct {
|
||||
dispatcher *notify.Dispatcher
|
||||
assetsMgr *assets.Manager
|
||||
latestVersion LatestVersionProvider
|
||||
|
||||
// App-email passthrough (POST /api/v1/mail). nil sender = endpoint returns 503.
|
||||
mailSender mailrelay.Sender
|
||||
mailLimiter *mailRateLimiter
|
||||
mailFromAllow map[string]bool
|
||||
}
|
||||
|
||||
// SetLatestVersionProvider wires the registry version checker so the controller report ACK can
|
||||
@@ -183,6 +189,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleAdminEnqueueJob(w, r, hostID)
|
||||
case r.Method == http.MethodPost && path == "/event":
|
||||
h.handleEvent(w, r)
|
||||
case r.Method == http.MethodPost && path == "/mail":
|
||||
h.handleMail(w, r)
|
||||
case r.Method == http.MethodPost && path == "/notify":
|
||||
h.handleNotify(w, r)
|
||||
case r.Method == http.MethodPost && path == "/preferences":
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
|
||||
)
|
||||
|
||||
// maxMailBytes bounds a forwarded message (raw_mime is base64 in the JSON envelope, so the
|
||||
// JSON body is ~4/3 of this; 12 MiB JSON comfortably covers a 10 MiB message).
|
||||
const maxMailBytes = 12 << 20
|
||||
|
||||
// mailRateLimiter is a per-key token bucket (key = customerID, or the envelope From when a
|
||||
// global key is used). Containment: one box can't drain the shared Resend quota for the
|
||||
// whole fleet. In-memory (lost on restart, acceptable — same posture as the dispatcher
|
||||
// cooldowns).
|
||||
type mailRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
perMinute int
|
||||
buckets map[string]*tokenBucket
|
||||
now func() time.Time // injectable for tests
|
||||
}
|
||||
|
||||
type tokenBucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newMailRateLimiter(perMinute int) *mailRateLimiter {
|
||||
if perMinute <= 0 {
|
||||
perMinute = 30
|
||||
}
|
||||
return &mailRateLimiter{
|
||||
perMinute: perMinute,
|
||||
buckets: make(map[string]*tokenBucket),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// allow consumes one token for key, refilling at perMinute/60 tokens per second up to a
|
||||
// burst capacity of perMinute. Returns false when the bucket is empty.
|
||||
func (rl *mailRateLimiter) allow(key string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
now := rl.now()
|
||||
cap := float64(rl.perMinute)
|
||||
b, ok := rl.buckets[key]
|
||||
if !ok {
|
||||
rl.buckets[key] = &tokenBucket{tokens: cap - 1, last: now}
|
||||
return true
|
||||
}
|
||||
// Refill based on elapsed time.
|
||||
elapsed := now.Sub(b.last).Seconds()
|
||||
b.tokens += elapsed * (cap / 60.0)
|
||||
if b.tokens > cap {
|
||||
b.tokens = cap
|
||||
}
|
||||
b.last = now
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// SetMailRelay wires the app-email passthrough: the Resend SMTP sender, the per-customer
|
||||
// rate limit (messages/min), and the From-header domain allowlist. nil sender = the
|
||||
// /api/v1/mail endpoint returns 503 (app mail not configured).
|
||||
func (h *Handler) SetMailRelay(sender mailrelay.Sender, perMinute int, allowedDomains []string) {
|
||||
h.mailSender = sender
|
||||
h.mailLimiter = newMailRateLimiter(perMinute)
|
||||
h.mailFromAllow = make(map[string]bool)
|
||||
if len(allowedDomains) == 0 {
|
||||
allowedDomains = []string{"felhom.eu"}
|
||||
}
|
||||
for _, d := range allowedDomains {
|
||||
d = strings.ToLower(strings.TrimSpace(d))
|
||||
if d != "" {
|
||||
h.mailFromAllow[d] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleMail accepts a raw message from a customer box and passes it through to Resend
|
||||
// over SMTP (raw, unchanged). Auth → From-policy → per-box rate limit → passthrough.
|
||||
func (h *Handler) handleMail(w http.ResponseWriter, r *http.Request) {
|
||||
custID, _, ok := h.checkAuthCustomer(r)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if h.mailSender == nil {
|
||||
http.Error(w, "App mail not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxMailBytes+1))
|
||||
if err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body) > maxMailBytes {
|
||||
http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
RawMIME []byte `json:"raw_mime"`
|
||||
MailFrom string `json:"mail_from"`
|
||||
RcptTo []string `json:"rcpt_to"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil || len(req.RawMIME) == 0 || len(req.RcptTo) == 0 {
|
||||
http.Error(w, "Invalid payload: raw_mime and rcpt_to required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// From-policy (backstop — the shim already enforced this; Resend is the final backstop).
|
||||
// Reject 4xx if the From-header domain is not allowlisted.
|
||||
dom, err := mailrelay.FromDomain(req.RawMIME)
|
||||
if err != nil {
|
||||
h.logger.Printf("[WARN] /api/v1/mail: unparseable From from %s: %v", custID, err)
|
||||
http.Error(w, "Invalid message: unparseable From header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !h.mailFromAllow[dom] {
|
||||
h.logger.Printf("[WARN] /api/v1/mail: rejected From domain %q from %s", dom, custID)
|
||||
http.Error(w, "Forbidden: From domain not permitted", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Per-box rate limit (key = customer; fall back to envelope From for a global-key caller).
|
||||
key := custID
|
||||
if key == "" {
|
||||
key = strings.ToLower(req.MailFrom)
|
||||
}
|
||||
if h.mailLimiter != nil && !h.mailLimiter.allow(key) {
|
||||
h.logger.Printf("[WARN] /api/v1/mail: rate limit hit for %q", key)
|
||||
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := h.mailSender.Send(ctx, req.RawMIME, req.MailFrom, req.RcptTo); err != nil {
|
||||
// A refusal/transport failure → 502; the box's shim surfaces it to the app (a 5xx
|
||||
// maps to a permanent SMTP error). The upstream reason is logged, not echoed in full.
|
||||
h.logger.Printf("[ERROR] /api/v1/mail: relay to Resend failed for %s (from=%s): %v", custID, req.MailFrom, err)
|
||||
http.Error(w, "Upstream mail delivery failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Printf("[INFO] /api/v1/mail: relayed for %s (from=%s rcpts=%d bytes=%d)", custID, req.MailFrom, len(req.RcptTo), len(req.RawMIME))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// fakeSender records every Send so tests can assert call count + byte fidelity.
|
||||
type fakeSender struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
lastRaw []byte
|
||||
from string
|
||||
to []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSender) Send(_ context.Context, raw []byte, mailFrom string, rcptTo []string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
f.lastRaw = append([]byte(nil), raw...)
|
||||
f.from = mailFrom
|
||||
f.to = append([]string(nil), rcptTo...)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeSender) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls
|
||||
}
|
||||
|
||||
func rawFrom(from string) []byte {
|
||||
return []byte("From: " + from + "\r\nTo: recipient@example.com\r\nSubject: test\r\n\r\nbody\r\n")
|
||||
}
|
||||
|
||||
func mailBody(t *testing.T, fromAddr string) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(map[string]interface{}{
|
||||
"raw_mime": rawFrom(fromAddr),
|
||||
"mail_from": fromAddr,
|
||||
"rcpt_to": []string{"recipient@example.com"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func withCustomer(t *testing.T, st *store.Store, id, key string) {
|
||||
t.Helper()
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: id, APIKey: key, RetrievalPassword: "p"}); err != nil {
|
||||
t.Fatalf("SaveCustomerConfig: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// §7 A / §10: happy path — the hub passes the RAW bytes through unchanged to the sender,
|
||||
// NOT a parsed/structured payload.
|
||||
func TestMail_HappyPath_PassthroughRawBytes(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
withCustomer(t, st, "c1", "ckey")
|
||||
fake := &fakeSender{}
|
||||
h.SetMailRelay(fake, 30, []string{"felhom.eu"})
|
||||
|
||||
rr := do(h, "POST", "/mail", "ckey", mailBody(t, "vaultwarden@felhom.eu"))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d (%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if fake.callCount() != 1 {
|
||||
t.Fatalf("expected 1 send, got %d", fake.callCount())
|
||||
}
|
||||
// Byte-equality: the sender must receive exactly what the box sent — no parse/re-encode
|
||||
// (the parse-then-API path drops inline CID images; §10).
|
||||
if !bytes.Equal(fake.lastRaw, rawFrom("vaultwarden@felhom.eu")) {
|
||||
t.Fatalf("sender got mutated bytes:\n got=%q", fake.lastRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMail_Unauthorized(t *testing.T) {
|
||||
h, _, _ := newTestHandler(t)
|
||||
h.SetMailRelay(&fakeSender{}, 30, []string{"felhom.eu"})
|
||||
if rr := do(h, "POST", "/mail", "wrongkey", mailBody(t, "x@felhom.eu")); rr.Code != 401 {
|
||||
t.Fatalf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMail_NotConfigured_503(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
withCustomer(t, st, "c1", "ckey")
|
||||
// No SetMailRelay → sender nil.
|
||||
if rr := do(h, "POST", "/mail", "ckey", mailBody(t, "x@felhom.eu")); rr.Code != 503 {
|
||||
t.Fatalf("expected 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// §7 B: From outside the allowlist is refused and the sender is NEVER called.
|
||||
func TestMail_FromOutsideAllowlist_Rejected_NoSend(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
withCustomer(t, st, "c1", "ckey")
|
||||
fake := &fakeSender{}
|
||||
h.SetMailRelay(fake, 30, []string{"felhom.eu"})
|
||||
|
||||
rr := do(h, "POST", "/mail", "ckey", mailBody(t, "evil@notfelhom.example"))
|
||||
if rr.Code != 403 {
|
||||
t.Fatalf("expected 403, got %d", rr.Code)
|
||||
}
|
||||
if fake.callCount() != 0 {
|
||||
t.Fatalf("sender must not be called on From-reject, got %d", fake.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// Companion red-proof for §7 B: allow the bad domain → the same message reaches the sender.
|
||||
func TestMail_FromReject_CompanionProof(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
withCustomer(t, st, "c1", "ckey")
|
||||
fake := &fakeSender{}
|
||||
h.SetMailRelay(fake, 30, []string{"notfelhom.example"}) // gate removed
|
||||
|
||||
rr := do(h, "POST", "/mail", "ckey", mailBody(t, "evil@notfelhom.example"))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("with the domain allowed, expected 200, got %d", rr.Code)
|
||||
}
|
||||
if fake.callCount() != 1 {
|
||||
t.Fatalf("companion: wrong-From should reach the sender once, got %d", fake.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// §7 C: per-box rate limit — over the limit returns 429; a different customer is unaffected.
|
||||
func TestMail_RateLimit_PerCustomer(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
withCustomer(t, st, "c1", "ckey")
|
||||
withCustomer(t, st, "c2", "ckey2")
|
||||
h.SetMailRelay(&fakeSender{}, 1, []string{"felhom.eu"}) // 1/min
|
||||
|
||||
if rr := do(h, "POST", "/mail", "ckey", mailBody(t, "a@felhom.eu")); rr.Code != 200 {
|
||||
t.Fatalf("first send should pass, got %d", rr.Code)
|
||||
}
|
||||
if rr := do(h, "POST", "/mail", "ckey", mailBody(t, "a@felhom.eu")); rr.Code != 429 {
|
||||
t.Fatalf("second send (over limit) should be 429, got %d", rr.Code)
|
||||
}
|
||||
// Different customer's quota is independent — one box can't starve the fleet.
|
||||
if rr := do(h, "POST", "/mail", "ckey2", mailBody(t, "b@felhom.eu")); rr.Code != 200 {
|
||||
t.Fatalf("other customer should be unaffected, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Companion red-proof for §7 C: a generous limit lets the N+1th through (proves the limiter bites).
|
||||
func TestMail_RateLimit_CompanionProof(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
withCustomer(t, st, "c1", "ckey")
|
||||
h.SetMailRelay(&fakeSender{}, 1000, []string{"felhom.eu"})
|
||||
for i := 0; i < 5; i++ {
|
||||
if rr := do(h, "POST", "/mail", "ckey", mailBody(t, "a@felhom.eu")); rr.Code != 200 {
|
||||
t.Fatalf("call %d should pass under a generous limit, got %d", i, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// §7 A failure surface: a Resend send failure becomes 502 (the box's shim maps that to a
|
||||
// permanent SMTP error to the app).
|
||||
func TestMail_SendFailure_502(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
withCustomer(t, st, "c1", "ckey")
|
||||
h.SetMailRelay(&fakeSender{err: fmt.Errorf("resend refused")}, 30, []string{"felhom.eu"})
|
||||
if rr := do(h, "POST", "/mail", "ckey", mailBody(t, "vaultwarden@felhom.eu")); rr.Code != 502 {
|
||||
t.Fatalf("expected 502 on send failure, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMail_BadBody_400(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
withCustomer(t, st, "c1", "ckey")
|
||||
h.SetMailRelay(&fakeSender{}, 30, []string{"felhom.eu"})
|
||||
if rr := do(h, "POST", "/mail", "ckey", "{not json"); rr.Code != 400 {
|
||||
t.Fatalf("expected 400 for bad body, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- rate limiter unit (deterministic clock) ---
|
||||
|
||||
func TestMailRateLimiter_ThresholdRefillIsolation(t *testing.T) {
|
||||
rl := newMailRateLimiter(2)
|
||||
now := time.Unix(1000, 0)
|
||||
rl.now = func() time.Time { return now }
|
||||
|
||||
if !rl.allow("a") || !rl.allow("a") {
|
||||
t.Fatal("first two for key a should pass (burst=2)")
|
||||
}
|
||||
if rl.allow("a") {
|
||||
t.Fatal("third for key a should be denied")
|
||||
}
|
||||
// Different key is independent.
|
||||
if !rl.allow("b") {
|
||||
t.Fatal("key b should be unaffected")
|
||||
}
|
||||
// Advance 30s → 2/min refills 1 token.
|
||||
now = now.Add(30 * time.Second)
|
||||
if !rl.allow("a") {
|
||||
t.Fatal("after 30s, key a should have refilled one token")
|
||||
}
|
||||
if rl.allow("a") {
|
||||
t.Fatal("only one token should have refilled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Package mailrelay is the hub's app-email passthrough: it re-emits a raw MIME message
|
||||
// from a customer box to Resend over SMTP, UNCHANGED. This is deliberately separate from
|
||||
// internal/notify (the hub's own structured-alert path, which uses the Resend HTTP API):
|
||||
// the spike proved parse-then-API silently drops inline CID images, so app mail must be
|
||||
// raw-SMTP passthrough (SPIKE-smtp-app-relay-2026-06-28.md §4). The Resend key lives only
|
||||
// here, hub-side — never on a box.
|
||||
package mailrelay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sender delivers a raw message to the upstream mail provider. Resend applies DKIM; the
|
||||
// bytes are passed through unchanged (no parse/re-encode). The seam keeps the API handler
|
||||
// unit-testable with a fake (no real Resend in unit tests).
|
||||
type Sender interface {
|
||||
Send(ctx context.Context, raw []byte, mailFrom string, rcptTo []string) error
|
||||
}
|
||||
|
||||
// ResendSMTP is the production passthrough sender: STARTTLS to smtp.resend.com:587,
|
||||
// AUTH LOGIN resend/<key>, then raw MAIL/RCPT/DATA.
|
||||
type ResendSMTP struct {
|
||||
apiKey string
|
||||
addr string // host:port
|
||||
host string // host (for TLS ServerName + auth)
|
||||
helo string // EHLO name
|
||||
}
|
||||
|
||||
// NewResendSMTP builds a sender for the live Resend SMTP endpoint.
|
||||
func NewResendSMTP(apiKey string) *ResendSMTP {
|
||||
return &ResendSMTP{
|
||||
apiKey: apiKey,
|
||||
addr: "smtp.resend.com:587",
|
||||
host: "smtp.resend.com",
|
||||
helo: "felhom.eu",
|
||||
}
|
||||
}
|
||||
|
||||
// Send re-emits the raw message to Resend. A connection/auth/transport failure or an SMTP
|
||||
// refusal is returned as an error (the API handler maps it to an HTTP status); on success
|
||||
// it returns nil.
|
||||
func (s *ResendSMTP) Send(ctx context.Context, raw []byte, mailFrom string, rcptTo []string) error {
|
||||
if len(rcptTo) == 0 {
|
||||
return fmt.Errorf("no recipients")
|
||||
}
|
||||
d := net.Dialer{Timeout: 15 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", s.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dialing resend: %w", err)
|
||||
}
|
||||
c, err := smtp.NewClient(conn, s.host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Hello(s.helo); err != nil {
|
||||
return fmt.Errorf("EHLO: %w", err)
|
||||
}
|
||||
if ok, _ := c.Extension("STARTTLS"); !ok {
|
||||
return fmt.Errorf("resend did not offer STARTTLS")
|
||||
}
|
||||
if err := c.StartTLS(&tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return fmt.Errorf("STARTTLS: %w", err)
|
||||
}
|
||||
if err := c.Auth(&loginAuth{username: "resend", password: s.apiKey}); err != nil {
|
||||
return fmt.Errorf("AUTH LOGIN: %w", err)
|
||||
}
|
||||
if err := c.Mail(mailFrom); err != nil {
|
||||
return fmt.Errorf("MAIL FROM: %w", err)
|
||||
}
|
||||
for _, rcpt := range rcptTo {
|
||||
if err := c.Rcpt(rcpt); err != nil {
|
||||
return fmt.Errorf("RCPT TO <%s>: %w", rcpt, err)
|
||||
}
|
||||
}
|
||||
wc, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA: %w", err)
|
||||
}
|
||||
if _, err := wc.Write(raw); err != nil {
|
||||
wc.Close()
|
||||
return fmt.Errorf("writing message: %w", err)
|
||||
}
|
||||
if err := wc.Close(); err != nil {
|
||||
// The 250/5xx verdict lands here (Resend returns the queued-id on 250, or e.g. a
|
||||
// 550 for an unverified From domain).
|
||||
return fmt.Errorf("delivery refused: %w", err)
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
// loginAuth implements the non-standard SMTP AUTH LOGIN exchange as a net/smtp.Auth
|
||||
// (stdlib ships PlainAuth only). Resend's SMTP accepts LOGIN with username "resend" and
|
||||
// the API key as the password (spike §4).
|
||||
type loginAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", nil, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if !more {
|
||||
return nil, nil
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(string(fromServer))) {
|
||||
case "username:":
|
||||
return []byte(a.username), nil
|
||||
case "password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected server challenge: %q", fromServer)
|
||||
}
|
||||
}
|
||||
|
||||
// FromDomain parses the From *header* of a raw MIME message (not the envelope — Resend
|
||||
// checks the header domain; the unverified-domain 550 lands at DATA). Returns the
|
||||
// lowercased domain.
|
||||
func FromDomain(raw []byte) (string, error) {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing message headers: %w", err)
|
||||
}
|
||||
from := strings.TrimSpace(msg.Header.Get("From"))
|
||||
if from == "" {
|
||||
return "", fmt.Errorf("missing From header")
|
||||
}
|
||||
addr, err := mail.ParseAddress(from)
|
||||
if err != nil {
|
||||
list, lerr := mail.ParseAddressList(from)
|
||||
if lerr != nil || len(list) == 0 {
|
||||
return "", fmt.Errorf("parsing From address %q: %w", from, err)
|
||||
}
|
||||
addr = list[0]
|
||||
}
|
||||
at := strings.LastIndex(addr.Address, "@")
|
||||
if at < 0 || at == len(addr.Address)-1 {
|
||||
return "", fmt.Errorf("From address %q has no domain", addr.Address)
|
||||
}
|
||||
return strings.ToLower(addr.Address[at+1:]), nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package mailrelay
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFromDomain(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"bare", "From: vaultwarden@felhom.eu\r\n\r\nbody", "felhom.eu", false},
|
||||
{"display name", "From: Vaultwarden <vaultwarden@felhom.eu>\r\n\r\nbody", "felhom.eu", false},
|
||||
{"uppercase domain", "From: x@FELHOM.EU\r\n\r\nbody", "felhom.eu", false},
|
||||
{"missing from", "To: x@y.eu\r\n\r\nbody", "", true},
|
||||
{"garbage", "From: not-an-address\r\n\r\nbody", "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := FromDomain([]byte(c.raw))
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// loginAuth must answer the LOGIN challenges with username then password and ignore the
|
||||
// final non-"more" continuation.
|
||||
func TestLoginAuth(t *testing.T) {
|
||||
a := &loginAuth{username: "resend", password: "secret-key"}
|
||||
|
||||
mech, ir, err := a.Start(nil)
|
||||
if err != nil || mech != "LOGIN" || ir != nil {
|
||||
t.Fatalf("Start = (%q,%v,%v), want (LOGIN,nil,nil)", mech, ir, err)
|
||||
}
|
||||
|
||||
resp, err := a.Next([]byte("Username:"), true)
|
||||
if err != nil || string(resp) != "resend" {
|
||||
t.Fatalf("username challenge → %q, %v", resp, err)
|
||||
}
|
||||
resp, err = a.Next([]byte("Password:"), true)
|
||||
if err != nil || string(resp) != "secret-key" {
|
||||
t.Fatalf("password challenge → %q, %v", resp, err)
|
||||
}
|
||||
// Unexpected challenge while more is expected → error (never blindly leak creds).
|
||||
if _, err := a.Next([]byte("Something:"), true); err == nil {
|
||||
t.Fatal("unexpected challenge should error")
|
||||
}
|
||||
// Non-more continuation → no response, no error.
|
||||
if resp, err := a.Next(nil, false); err != nil || resp != nil {
|
||||
t.Fatalf("non-more Next → %q, %v", resp, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user