From fa3c4f26573157af36659bdbbee217cee29ecead Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 29 Jun 2026 08:45:22 +0200 Subject: [PATCH] =?UTF-8?q?hub=20v0.18.0:=20app-email=20passthrough=20POST?= =?UTF-8?q?=20/api/v1/mail=20=E2=86=92=20Resend=20SMTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- REPORT.md | 111 +++++--------- hub/CHANGELOG.md | 22 +++ hub/cmd/hub/main.go | 38 +++++ hub/internal/api/handler.go | 8 + hub/internal/api/mail.go | 161 ++++++++++++++++++++ hub/internal/api/mail_test.go | 212 +++++++++++++++++++++++++++ hub/internal/mailrelay/relay.go | 152 +++++++++++++++++++ hub/internal/mailrelay/relay_test.go | 63 ++++++++ 8 files changed, 695 insertions(+), 72 deletions(-) create mode 100644 hub/internal/api/mail.go create mode 100644 hub/internal/api/mail_test.go create mode 100644 hub/internal/mailrelay/relay.go create mode 100644 hub/internal/mailrelay/relay_test.go diff --git a/REPORT.md b/REPORT.md index f06f506..66fd857 100644 --- a/REPORT.md +++ b/REPORT.md @@ -2,82 +2,49 @@ > **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md). ---- +## App-email passthrough — hub leg (`POST /api/v1/mail` → Resend SMTP), hub v0.18.0 -# REPORT — Resend key rotation + de-git (2026-06-29) +**Task:** SMTP app-relay (apps → on-box shim → hub → Resend). Implements +`documentation/audits/SPIKE-smtp-app-relay-2026-06-28.md` (verdict READY). -**Task:** rotate the exposed (send-scoped) Resend API key and remove it from git without breaking any -consumer. **Model:** DECISION **A** (out-of-band `kubectl` + runbook) — operator-confirmed. **Status:** -S1–S6 complete; **STOPPED at the §13 gate before S7** (operator deletes the old key). No secret value -appears in this repo, this report, or any changelog. +### Baseline (verified live) +- Hub `main` @ `4b97855`, version **v0.17.0** (the Resend-key rotation already shipped earlier today 2026-06-29) + → target **v0.18.0**. (The prompt assumed v0.16.0→v0.17.0; the rotation took v0.17.0 first, so this leg is + v0.18.0.) Prerequisite satisfied: Resend key is out-of-band in `Secret/resend-api`, read via `RESEND_API_KEY`. -## S2 — consumer map (live cluster `felhom-system`) +### Files +- **Created** `internal/mailrelay/relay.go` — `ResendSMTP` (`Sender`): STARTTLS to `smtp.resend.com:587`, + `AUTH LOGIN resend/` (small stdlib `net/smtp.Auth` LOGIN impl), raw `MAIL`/`RCPT`/`DATA` **passthrough**. + `FromDomain` (From-header parser). **No new external dependency.** +- **Created** `internal/api/mail.go` — `handleMail`: `checkAuthCustomer` → From-domain allowlist (403 backstop) + → per-customer token-bucket rate limit (429) → passthrough to Resend (200 / 502). `SetMailRelay` wiring + + `mailRateLimiter`. +- **Modified** `internal/api/handler.go` — sender/limiter/allowlist fields + `POST /api/v1/mail` route. +- **Modified** `cmd/hub/main.go` — `MailConfig` (`per_customer_per_minute`, `from_domains`) + wire `ResendSMTP` + when a key is present (else 503). The `notify/dispatcher.go` HTTP-API alert path is **untouched**. -| Consumer | Source of key (before) | Live? | After | -|----------|------------------------|-------|-------| -| `hub` | ConfigMap `hub-config` → `resend_api_key` (plaintext in git) | **running** | env `RESEND_API_KEY` ← `Secret/resend-api`; ConfigMap field now empty placeholder; hub `0.17.0` | -| `contact-mailer` | Secret `contact-mailer-config` → `RESEND_API_KEY` (plaintext in git) | **running** | `secretKeyRef` repointed to `Secret/resend-api`; old Secret deleted | -| `healthchecks` | Secret `healthchecks-config` → `EMAIL_HOST_PASSWORD` (plaintext in git) | **NOT deployed** (no workload cluster-wide) | value blanked + comment; to be wired to `resend-api` if/when deployed | -| Gmail "Send mail as" | external SMTP password | n/a | **operator-manual (S4) — pending** | +### Green gate (local) +`go build ./... && go vet ./... && go test ./...` — **PASS** (6 packages ok, 0 failures). -`contact-mailer.yaml:16` held only a `re_xxxx` placeholder (not the real key). Both live consumers and -the live cluster confirmed to carry the **old** key before the change (hash match). +**Tests & §10 companion red-proofs** +- **Passthrough byte-equality (§7 A / §10):** `TestMail_HappyPath_PassthroughRawBytes` — the `Sender` receives + the raw bytes unchanged (not a parsed payload). PASS. +- **From-reject + companion (§7 B / §10):** `TestMail_FromOutsideAllowlist_Rejected_NoSend` (403, sender never + called) + `TestMail_FromReject_CompanionProof` (allowing the domain reaches the sender). PASS. +- **Per-box rate limit + isolation + companion (§7 C / §10):** `TestMail_RateLimit_PerCustomer` (429 on the 2nd + at 1/min; a different customer unaffected) + `TestMail_RateLimit_CompanionProof` (generous limit lets N+1 + through). PASS. +- Send-failure→502, 401/503/400 paths, token-bucket unit (injected clock), LOGIN auth + From-domain parse. PASS. -## What was created / changed / rolled +### Deployment & live validation +- **Deploy:** build `felhom-hub:0.18.0` on 180 → bump `manifests/hub.yaml` image → ArgoCD sync (auto-sync off). + The `mail` config is optional (defaults 30/min, `felhom.eu`); the Resend key is already injected via + `Secret/resend-api` (`RESEND_API_KEY`), so the relay activates on deploy. +- **End-to-end (app → shim → hub → Resend → real inbox):** result recorded here after the live run; method + stated. The Resend key is supplied to the hub out-of-band — never on the guest, never committed. -- **Created** `Secret/resend-api` (`RESEND_API_KEY`) imperatively from the out-of-band `$RESEND_API` on - host 180, value piped via stdin into `kubectl apply` — **never echoed, never written to a file, never - committed**. Confirmed to hold the **new** key (hash ≠ old). -- **Hub code:** `cmd/hub/main.go` — new `RESEND_API_KEY` env override (mirrors `REGISTRY_TOKEN`). Built + - pushed `felhom-hub:0.17.0`; manifest bumped; ArgoCD-synced. Running pod confirmed to carry the new key. -- **contact-mailer:** `secretKeyRef` → `resend-api`; rolled. Old `Secret/contact-mailer-config` (held the - old key) **deleted** from the cluster. -- **Rolled:** `deploy/hub`, `deploy/contact-mailer` — both `Healthy`, ArgoCD `Synced`. - -## De-git diff (paths only — no values) - -- `manifests/hub.yaml` — ConfigMap `resend_api_key: ""` → `""` + comment; Deployment gained - `RESEND_API_KEY` env from `Secret/resend-api`; image `0.16.0` → `0.17.0`. -- `manifests/felhom.secret.yaml` — `healthchecks-config` `EMAIL_HOST_PASSWORD: ""` → `""` + comment; - `Secret/contact-mailer-config` block (held the key) **removed**. -- `manifests/contact-mailer.yaml` — `secretKeyRef` name `contact-mailer-config` → `resend-api`; the - setup-comment `re_xxxx` placeholder replaced with the runbook command. -- `documentation/runbooks/secrets.md` — **new** out-of-band secret runbook (create/rotate/verify). -- `hub/cmd/hub/main.go`, `hub/CHANGELOG.md` — env override + changelog. - -**Verification:** `grep -rIn 're_' manifests/` → none; `grep -rIn 're_XZZenCJs' .` (real key) → none. - -## S6 — verify (before deleting old key) - -| Check | Result | -|-------|--------| -| New key send-capable | ✅ Resend `POST /emails` from host 180 → **HTTP 200**, id `79df5b65…` | -| contact-mailer end-to-end | ✅ form POST → **HTTP 200**; pod log `[OK] Email sent: resend_id=c13d86ae…` (new key via `resend-api`) | -| hub | ✅ **end-to-end** `/notify` (event_type=`test`) → log `Notification email sent to nagyfenyvesi.viktor@gmail.com`, `{"sent":true}`. Sent via `Secret/resend-api` (new key). Used a throwaway prefs row `_resend-rotation-test`, then **blanked it** (follow-up notify → `sent:false, no_email`). | -| Gmail | ✅ **S4 done** — operator updated "Send mail as" SMTP password; confirmed test mail arrives. | - -(contact-mailer/direct-key test mail went to `info@felhom.eu`; the hub test went to the operator's Gmail. -Operator confirmed arrival of both.) - -## STOP — §13 gate (only S7 remains — operator action) - -All consumers (hub, contact-mailer, Gmail) verified sending on the **new** key. The one remaining, -irreversible step is **operator-only**: - -- **S7 — delete the OLD key in Resend** (dashboard → API Keys). Then re-verify one consumer still sends - (e.g. submit the contact form again) to prove nothing was left on the old key. - -After S7 + re-verify, the rotation is fully closed (only the S8 history-scrub remains, deferred). - -## S8 — history scrub (deferred) - -The old key string is still in git **history** (`manifests/hub.yaml`, `felhom.secret.yaml`). Lower urgency -— rotation already invalidates it once S7 runs. Recommend scheduling a `git filter-repo`/BFG pass (also -scrubs the other still-committed secrets noted in the runbook). **Not done in this run.** - -## Backlog surfaced - -- `felhom.secret.yaml` still commits other plaintext secrets (`healthchecks-config` `SECRET_KEY` / - `SUPERUSER_PASSWORD`, `umami-config`, `gitea-creds`) — same hygiene gap, out of scope here; tracked in - the secrets runbook. -- `Secret/resend-api` is out-of-band: on a cluster rebuild it must be re-created per the runbook. +### Observations +- App-relay is a **separate** code path from the hub's own structured alerts (which keep using the Resend + **HTTP API**) — raw passthrough is required because the API path silently drops inline CID images (spike §4). +- v1: single-shot, no spool, no idempotency key. v2 would add accept-and-spool + `Resend-Idempotency-Key`. +- Fleet free-tier ceiling is 100 emails/day. No secrets in any committed file. diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 5cd9de0..8cfc5a7 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -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/` (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 diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 0fe24f4..8b9f0be 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -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) diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 61ba802..0ba3342 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -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": diff --git a/hub/internal/api/mail.go b/hub/internal/api/mail.go new file mode 100644 index 0000000..66167a4 --- /dev/null +++ b/hub/internal/api/mail.go @@ -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"}`)) +} diff --git a/hub/internal/api/mail_test.go b/hub/internal/api/mail_test.go new file mode 100644 index 0000000..71196cc --- /dev/null +++ b/hub/internal/api/mail_test.go @@ -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") + } +} diff --git a/hub/internal/mailrelay/relay.go b/hub/internal/mailrelay/relay.go new file mode 100644 index 0000000..bd26ff0 --- /dev/null +++ b/hub/internal/mailrelay/relay.go @@ -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/, 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 +} diff --git a/hub/internal/mailrelay/relay_test.go b/hub/internal/mailrelay/relay_test.go new file mode 100644 index 0000000..eff8df2 --- /dev/null +++ b/hub/internal/mailrelay/relay_test.go @@ -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 \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) + } +}