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:
2026-06-29 08:45:22 +02:00
parent 4b97855cdd
commit fa3c4f2657
8 changed files with 695 additions and 72 deletions
+8
View File
@@ -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":
+161
View File
@@ -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"}`))
}
+212
View File
@@ -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")
}
}