v0.88.0: app-email SMTP relay (in-process shim + per-app injection)
In-process go-smtp shim (Shape 1): apps → shim → hub → Resend, Resend key stays hub-side. From-header allowlist (reject 5xx pre-hub), single-shot raw-MIME forward, status→SMTP mapping. Global + per-app toggles gate compose-time env injection from .felhom.yml smtp_mapping. Hungarian UI on settings + app config pages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package mailrelay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Forwarder hands a raw message off to the hub for delivery.
|
||||
//
|
||||
// v1 is SINGLE-SHOT (§9 rule 9): exactly one attempt, no retry loop, no durable
|
||||
// spool. The hub's HTTP status is returned to the caller; the SMTP layer maps that
|
||||
// status to an SMTP reply so the sending app surfaces the real outcome. Forward has
|
||||
// no SMTP knowledge — the seam keeps the engine unit-testable with a fake.
|
||||
type Forwarder interface {
|
||||
Forward(ctx context.Context, raw []byte, mailFrom string, rcptTo []string) (status int, body string, err error)
|
||||
}
|
||||
|
||||
// MailForwardRequest is the JSON envelope POSTed to the hub POST /api/v1/mail.
|
||||
// RawMIME is a []byte so encoding/json base64-encodes it for safe transport and the
|
||||
// hub gets the message back byte-for-byte (raw passthrough — never parsed/re-encoded).
|
||||
type MailForwardRequest struct {
|
||||
RawMIME []byte `json:"raw_mime"`
|
||||
MailFrom string `json:"mail_from"`
|
||||
RcptTo []string `json:"rcpt_to"`
|
||||
}
|
||||
|
||||
// HubForwarder POSTs to the hub /api/v1/mail with the controller's existing hub
|
||||
// Bearer key — the same credential the notifier and report pusher already hold, so
|
||||
// there is no second place the hub credential lives (the Shape-1 decision).
|
||||
type HubForwarder struct {
|
||||
hubURL string
|
||||
apiKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewHubForwarder builds a forwarder against the hub base URL (e.g. https://hub.felhom.eu).
|
||||
func NewHubForwarder(hubURL, apiKey string) *HubForwarder {
|
||||
return &HubForwarder{
|
||||
hubURL: strings.TrimRight(hubURL, "/"),
|
||||
apiKey: apiKey,
|
||||
// Generous timeout: the hub leg dials Resend SMTP synchronously. Still a single attempt.
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Forward POSTs the raw message once and returns the hub's HTTP status + a short body
|
||||
// excerpt. A transport error (hub unreachable) returns (0, "", err); the SMTP layer
|
||||
// turns that into a transient 4xx so the app shows the user an error (no hang, no
|
||||
// silent drop — §7 scenario D).
|
||||
func (f *HubForwarder) Forward(ctx context.Context, raw []byte, mailFrom string, rcptTo []string) (int, string, error) {
|
||||
payload := MailForwardRequest{RawMIME: raw, MailFrom: mailFrom, RcptTo: rcptTo}
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("marshaling mail forward: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.hubURL+"/api/v1/mail", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("building request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+f.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// SINGLE attempt — no retry loop (a retry would risk a duplicate send; §9 rule 9).
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("hub unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return resp.StatusCode, string(body), nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mailrelay
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Lifecycle owns the shim's start/stop so the global app-email toggle can take effect
|
||||
// at runtime without a controller restart. The shim listens ONLY when app-email is
|
||||
// globally ON (Part 1: "start listeners only when hub.enabled and global app-email ON").
|
||||
// Apply is idempotent: enabling when already up (or disabling when already down) is a
|
||||
// no-op.
|
||||
type Lifecycle struct {
|
||||
mu sync.Mutex
|
||||
opts Options
|
||||
srv *Server
|
||||
logger *log.Logger
|
||||
stopped bool // Close was called; further Apply(true) is refused (shutdown)
|
||||
}
|
||||
|
||||
// NewLifecycle prepares (but does not start) a shim from the given options.
|
||||
func NewLifecycle(opts Options) *Lifecycle {
|
||||
lg := opts.Logger
|
||||
if lg == nil {
|
||||
lg = log.Default()
|
||||
}
|
||||
return &Lifecycle{opts: opts, logger: lg}
|
||||
}
|
||||
|
||||
// Apply reconciles the shim's running state with the desired toggle value. It returns
|
||||
// an error only if a requested START failed (a stop never errors meaningfully).
|
||||
func (l *Lifecycle) Apply(enabled bool) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.stopped {
|
||||
return nil
|
||||
}
|
||||
if enabled {
|
||||
if l.srv != nil {
|
||||
return nil // already running
|
||||
}
|
||||
srv, err := New(l.opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := srv.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
l.srv = srv
|
||||
l.logger.Printf("[INFO] [mailrelay] app-email shim ON (host=%s plain=%s tls=%s)",
|
||||
l.opts.ServiceName, srv.PlainAddr(), srv.TLSAddr())
|
||||
return nil
|
||||
}
|
||||
// disabled → stop if running
|
||||
if l.srv != nil {
|
||||
_ = l.srv.Close()
|
||||
l.srv = nil
|
||||
l.logger.Printf("[INFO] [mailrelay] app-email shim OFF")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Running reports whether the shim is currently listening.
|
||||
func (l *Lifecycle) Running() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.srv != nil
|
||||
}
|
||||
|
||||
// Close stops the shim permanently (shutdown). Idempotent.
|
||||
func (l *Lifecycle) Close() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.stopped = true
|
||||
if l.srv != nil {
|
||||
_ = l.srv.Close()
|
||||
l.srv = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package mailrelay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"log"
|
||||
netsmtp "net/smtp"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
// fakeForwarder records every Forward call so tests can assert call count + byte
|
||||
// fidelity, and returns a programmable status/err.
|
||||
type fakeForwarder struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
lastRaw []byte
|
||||
lastFrom string
|
||||
lastTo []string
|
||||
status int
|
||||
body string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeForwarder) Forward(_ context.Context, raw []byte, from string, to []string) (int, string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
f.lastRaw = append([]byte(nil), raw...)
|
||||
f.lastFrom = from
|
||||
f.lastTo = append([]string(nil), to...)
|
||||
return f.status, f.body, f.err
|
||||
}
|
||||
|
||||
func (f *fakeForwarder) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls
|
||||
}
|
||||
|
||||
func quietLogger() *log.Logger { return log.New(io.Discard, "", 0) }
|
||||
|
||||
func rawMsg(from string) []byte {
|
||||
return []byte("From: " + from + "\r\nTo: recipient@example.com\r\nSubject: test\r\n\r\nhello world\r\n")
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, allowed []string, fwd Forwarder) *Server {
|
||||
t.Helper()
|
||||
s, err := New(Options{
|
||||
Policy: NewPolicy(allowed),
|
||||
Forwarder: fwd,
|
||||
Logger: quietLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// dataResult drives a session.Data with the given raw message and returns the reply.
|
||||
func dataResult(srv *Server, from string, raw []byte) error {
|
||||
sess := &session{srv: srv, from: from, to: []string{"recipient@example.com"}}
|
||||
return sess.Data(bytes.NewReader(raw))
|
||||
}
|
||||
|
||||
// --- §7 A: happy path — passthrough, NOT parse (§10 byte-equality companion) ---
|
||||
|
||||
func TestData_HappyPath_ForwardsRawBytesUnchanged(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
srv := newTestServer(t, []string{"felhom.eu"}, fwd)
|
||||
|
||||
raw := rawMsg("vaultwarden@felhom.eu")
|
||||
if err := dataResult(srv, "vaultwarden@felhom.eu", raw); err != nil {
|
||||
t.Fatalf("expected success (250), got %v", err)
|
||||
}
|
||||
if fwd.callCount() != 1 {
|
||||
t.Fatalf("expected exactly 1 forward, got %d", fwd.callCount())
|
||||
}
|
||||
// Passthrough: the bytes handed to the hub must be byte-for-byte the input — no
|
||||
// MIME parse/re-encode (the parse path silently drops inline CID images; §10).
|
||||
if !bytes.Equal(fwd.lastRaw, raw) {
|
||||
t.Fatalf("forwarded bytes differ from input:\n got=%q\nwant=%q", fwd.lastRaw, raw)
|
||||
}
|
||||
if fwd.lastFrom != "vaultwarden@felhom.eu" {
|
||||
t.Fatalf("envelope from = %q", fwd.lastFrom)
|
||||
}
|
||||
}
|
||||
|
||||
// --- §7 B: From outside the allowlist is refused BEFORE any hub call ---
|
||||
|
||||
func TestData_FromOutsideAllowlist_Rejected_NoForward(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
srv := newTestServer(t, []string{"felhom.eu"}, fwd)
|
||||
|
||||
err := dataResult(srv, "vaultwarden@felhom.eu", rawMsg("evil@notfelhom.example"))
|
||||
smtpErr, ok := err.(*smtp.SMTPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *smtp.SMTPError, got %T (%v)", err, err)
|
||||
}
|
||||
if smtpErr.Code != 550 {
|
||||
t.Fatalf("expected 550, got %d", smtpErr.Code)
|
||||
}
|
||||
// The hub/Resend must NEVER be dialed for a doomed message.
|
||||
if fwd.callCount() != 0 {
|
||||
t.Fatalf("forwarder must not be called on From-reject, got %d calls", fwd.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// Companion red-proof for §7 B: REMOVE the gate (allow the bad domain) and the same
|
||||
// message reaches the forwarder. Proves the allowlist check is load-bearing.
|
||||
func TestData_FromReject_CompanionProof_GateRemovedLetsItThrough(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
srv := newTestServer(t, []string{"notfelhom.example"}, fwd) // gate effectively removed
|
||||
|
||||
if err := dataResult(srv, "x@notfelhom.example", rawMsg("evil@notfelhom.example")); err != nil {
|
||||
t.Fatalf("with the domain allowed the message should pass, got %v", err)
|
||||
}
|
||||
if fwd.callCount() != 1 {
|
||||
t.Fatalf("companion: expected the wrong-From to reach the forwarder once, got %d", fwd.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestData_UnparseableFrom_Rejected(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
srv := newTestServer(t, []string{"felhom.eu"}, fwd)
|
||||
|
||||
// No From header at all → fail-closed reject, no forward.
|
||||
noFrom := []byte("To: recipient@example.com\r\nSubject: test\r\n\r\nbody\r\n")
|
||||
err := dataResult(srv, "x@felhom.eu", noFrom)
|
||||
if smtpErr, ok := err.(*smtp.SMTPError); !ok || smtpErr.Code != 550 {
|
||||
t.Fatalf("expected 550 for missing From, got %v", err)
|
||||
}
|
||||
if fwd.callCount() != 0 {
|
||||
t.Fatalf("forwarder must not be called when From is unparseable, got %d", fwd.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// --- §7 D: transient hub outage — single-shot, surfaces transient error ---
|
||||
|
||||
func TestData_HubUnreachable_SingleShot_Transient(t *testing.T) {
|
||||
fwd := &fakeForwarder{err: io.ErrUnexpectedEOF} // simulate hub unreachable
|
||||
srv := newTestServer(t, []string{"felhom.eu"}, fwd)
|
||||
|
||||
err := dataResult(srv, "vaultwarden@felhom.eu", rawMsg("vaultwarden@felhom.eu"))
|
||||
smtpErr, ok := err.(*smtp.SMTPError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *smtp.SMTPError, got %T", err)
|
||||
}
|
||||
if smtpErr.Code/100 != 4 {
|
||||
t.Fatalf("expected transient 4xx, got %d", smtpErr.Code)
|
||||
}
|
||||
// §10 single-shot companion: EXACTLY one attempt — no retry loop.
|
||||
if fwd.callCount() != 1 {
|
||||
t.Fatalf("v1 forward must be single-shot; got %d attempts", fwd.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// --- status mapping (spike §6) ---
|
||||
|
||||
func TestMapStatusToSMTP(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
wantNil bool
|
||||
wantCode int
|
||||
}{
|
||||
{200, true, 0},
|
||||
{202, true, 0},
|
||||
{403, false, 451}, // hub From-reject backstop → transient per the prompt mapping
|
||||
{429, false, 451}, // rate limit → retry later
|
||||
{451, false, 451},
|
||||
{500, false, 554},
|
||||
{503, false, 554},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := mapStatusToSMTP(c.status, "")
|
||||
if c.wantNil {
|
||||
if got != nil {
|
||||
t.Errorf("status %d: expected nil, got %v", c.status, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
smtpErr, ok := got.(*smtp.SMTPError)
|
||||
if !ok {
|
||||
t.Errorf("status %d: expected *smtp.SMTPError, got %T", c.status, got)
|
||||
continue
|
||||
}
|
||||
if smtpErr.Code != c.wantCode {
|
||||
t.Errorf("status %d: expected SMTP %d, got %d", c.status, c.wantCode, smtpErr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeReason(t *testing.T) {
|
||||
if got := sanitizeReason("line one\r\nleaked@addr.example"); got != "line one" {
|
||||
t.Fatalf("sanitizeReason should keep only the first line, got %q", got)
|
||||
}
|
||||
long := make([]byte, 500)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
if got := sanitizeReason(string(long)); len(got) != 200 {
|
||||
t.Fatalf("sanitizeReason should cap at 200, got len %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// --- end-to-end over a real socket (Q1/Q2: real SMTP, STARTTLS, no auth) ---
|
||||
|
||||
func TestLifecycle_StartStopIdempotent(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
lc := NewLifecycle(Options{
|
||||
PlainAddr: "127.0.0.1:0",
|
||||
TLSAddr: "127.0.0.1:0",
|
||||
Policy: NewPolicy([]string{"felhom.eu"}),
|
||||
Forwarder: fwd,
|
||||
Logger: quietLogger(),
|
||||
})
|
||||
if lc.Running() {
|
||||
t.Fatal("should not be running before Apply")
|
||||
}
|
||||
if err := lc.Apply(true); err != nil {
|
||||
t.Fatalf("Apply(true): %v", err)
|
||||
}
|
||||
if !lc.Running() {
|
||||
t.Fatal("should be running after Apply(true)")
|
||||
}
|
||||
// idempotent enable
|
||||
if err := lc.Apply(true); err != nil {
|
||||
t.Fatalf("second Apply(true): %v", err)
|
||||
}
|
||||
if err := lc.Apply(false); err != nil {
|
||||
t.Fatalf("Apply(false): %v", err)
|
||||
}
|
||||
if lc.Running() {
|
||||
t.Fatal("should be stopped after Apply(false)")
|
||||
}
|
||||
// re-enable after disable works (uses a fresh server)
|
||||
if err := lc.Apply(true); err != nil {
|
||||
t.Fatalf("re-enable: %v", err)
|
||||
}
|
||||
if !lc.Running() {
|
||||
t.Fatal("should run after re-enable")
|
||||
}
|
||||
lc.Close()
|
||||
if lc.Running() {
|
||||
t.Fatal("Close should stop the shim")
|
||||
}
|
||||
// Apply(true) after Close is refused (no resurrection on shutdown)
|
||||
if err := lc.Apply(true); err != nil {
|
||||
t.Fatalf("Apply after Close should be a no-op nil, got %v", err)
|
||||
}
|
||||
if lc.Running() {
|
||||
t.Fatal("must stay stopped after Close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_EndToEnd_STARTTLS(t *testing.T) {
|
||||
fwd := &fakeForwarder{status: 200}
|
||||
s, err := New(Options{
|
||||
PlainAddr: "127.0.0.1:0",
|
||||
TLSAddr: "127.0.0.1:0",
|
||||
Policy: NewPolicy([]string{"felhom.eu"}),
|
||||
Forwarder: fwd,
|
||||
Logger: quietLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
c, err := netsmtp.Dial(s.PlainAddr())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.StartTLS(&tls.Config{InsecureSkipVerify: true}); err != nil {
|
||||
t.Fatalf("starttls: %v", err)
|
||||
}
|
||||
if err := c.Mail("vaultwarden@felhom.eu"); err != nil {
|
||||
t.Fatalf("mail: %v", err)
|
||||
}
|
||||
if err := c.Rcpt("recipient@example.com"); err != nil {
|
||||
t.Fatalf("rcpt: %v", err)
|
||||
}
|
||||
wc, err := c.Data()
|
||||
if err != nil {
|
||||
t.Fatalf("data: %v", err)
|
||||
}
|
||||
msg := rawMsg("vaultwarden@felhom.eu")
|
||||
if _, err := wc.Write(msg); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := wc.Close(); err != nil {
|
||||
t.Fatalf("close data (expected 250): %v", err)
|
||||
}
|
||||
_ = c.Quit()
|
||||
|
||||
if fwd.callCount() != 1 {
|
||||
t.Fatalf("expected 1 forward through the real listener, got %d", fwd.callCount())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package mailrelay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Policy enforces the From-header domain allowlist.
|
||||
//
|
||||
// Per the spike (§5/§9) the check is on the From *header* domain, not the SMTP
|
||||
// envelope MAIL FROM — Resend itself validates the header domain (the unverified-
|
||||
// domain 550 lands at DATA, after MAIL/RCPT are accepted), so the relay must check
|
||||
// the same thing. The recommendation is validate-and-REJECT (never silently rewrite),
|
||||
// so a misconfigured app surfaces loudly instead of having its From re-stamped.
|
||||
type Policy struct {
|
||||
allowed map[string]bool
|
||||
}
|
||||
|
||||
// NewPolicy builds a From-domain allowlist (case-insensitive). An empty list
|
||||
// defaults to {"felhom.eu"} (the one verified Resend domain).
|
||||
func NewPolicy(domains []string) *Policy {
|
||||
p := &Policy{allowed: make(map[string]bool)}
|
||||
if len(domains) == 0 {
|
||||
domains = []string{"felhom.eu"}
|
||||
}
|
||||
for _, d := range domains {
|
||||
d = strings.ToLower(strings.TrimSpace(d))
|
||||
if d != "" {
|
||||
p.allowed[d] = true
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// FromDomain parses the From *header* of a raw MIME message and returns the
|
||||
// lowercased domain of the first address. It reads only the headers — the body is
|
||||
// left untouched (the relay forwards raw bytes; this never re-serialises the message).
|
||||
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 {
|
||||
// Tolerate a From that is actually an address *list* (rare, but valid).
|
||||
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
|
||||
}
|
||||
|
||||
// Check returns nil iff the message's From-header domain is in the allowlist.
|
||||
// A parse failure is treated as a rejection (fail-closed): an app that can't be
|
||||
// parsed shouldn't reach Resend.
|
||||
func (p *Policy) Check(raw []byte) error {
|
||||
dom, err := FromDomain(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.allowed[dom] {
|
||||
return fmt.Errorf("From domain %q not in allowlist", dom)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Allowed reports whether a bare domain is in the allowlist (inspection/tests).
|
||||
func (p *Policy) Allowed(domain string) bool {
|
||||
return p.allowed[strings.ToLower(strings.TrimSpace(domain))]
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// Package mailrelay is the in-process SMTP shim that gives deployed apps outbound
|
||||
// email through one managed path. It accepts SMTP from apps on the Docker network
|
||||
// (no Resend key on the box), validates the From-header domain, and forwards the raw
|
||||
// MIME to the hub, which holds the Resend key and re-emits the message unchanged.
|
||||
//
|
||||
// Architecture: Shape 1 — the shim runs IN-PROCESS inside felhom-controller (operator-
|
||||
// confirmed), reusing the controller's existing hub client. Listeners bind to the app
|
||||
// Docker network only; they are NEVER published to the host or internet.
|
||||
//
|
||||
// Validated end-to-end in SPIKE-smtp-app-relay-2026-06-28.md (verdict READY).
|
||||
package mailrelay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
)
|
||||
|
||||
// maxMessageBytes bounds a single message. Transactional app mail (password resets,
|
||||
// invites) is small; 10 MiB leaves generous headroom for inline images/attachments.
|
||||
const maxMessageBytes int64 = 10 << 20
|
||||
|
||||
// forwardTimeout bounds the synchronous hub forward (which itself dials Resend).
|
||||
const forwardTimeout = 30 * time.Second
|
||||
|
||||
// Options configures the shim.
|
||||
type Options struct {
|
||||
PlainAddr string // plaintext + STARTTLS listener (default ":2525")
|
||||
TLSAddr string // implicit-TLS listener (default ":2465")
|
||||
ServiceName string // CN/SAN of the self-signed cert + SMTP greeting (e.g. "felhom-controller")
|
||||
Policy *Policy // From-domain allowlist (required)
|
||||
Forwarder Forwarder // hub forwarder (required)
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
// Server runs the two SMTP listeners.
|
||||
type Server struct {
|
||||
opts Options
|
||||
tlsConf *tls.Config
|
||||
plainSrv *smtp.Server
|
||||
tlsSrv *smtp.Server
|
||||
plainLn net.Listener
|
||||
tlsLn net.Listener
|
||||
}
|
||||
|
||||
// New builds the shim and its self-signed cert. It does not bind sockets — call Start.
|
||||
func New(opts Options) (*Server, error) {
|
||||
if opts.Policy == nil {
|
||||
return nil, fmt.Errorf("mailrelay: Policy is required")
|
||||
}
|
||||
if opts.Forwarder == nil {
|
||||
return nil, fmt.Errorf("mailrelay: Forwarder is required")
|
||||
}
|
||||
if opts.PlainAddr == "" {
|
||||
opts.PlainAddr = ":2525"
|
||||
}
|
||||
if opts.TLSAddr == "" {
|
||||
opts.TLSAddr = ":2465"
|
||||
}
|
||||
if opts.ServiceName == "" {
|
||||
opts.ServiceName = "felhom-controller"
|
||||
}
|
||||
if opts.Logger == nil {
|
||||
opts.Logger = log.Default()
|
||||
}
|
||||
|
||||
cert, err := selfSignedCert(opts.ServiceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mailrelay: generating self-signed cert: %w", err)
|
||||
}
|
||||
tlsConf := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}
|
||||
|
||||
s := &Server{opts: opts, tlsConf: tlsConf}
|
||||
be := &backend{srv: s}
|
||||
|
||||
// :2525 — plaintext with STARTTLS offered. AllowInsecureAuth lets an app present
|
||||
// AUTH before STARTTLS (apps send no creds, but some refuse to send without the offer).
|
||||
s.plainSrv = smtp.NewServer(be)
|
||||
s.plainSrv.Addr = opts.PlainAddr
|
||||
s.plainSrv.Domain = opts.ServiceName
|
||||
s.plainSrv.TLSConfig = tlsConf
|
||||
s.plainSrv.AllowInsecureAuth = true
|
||||
s.plainSrv.MaxMessageBytes = maxMessageBytes
|
||||
s.plainSrv.ReadTimeout = 60 * time.Second
|
||||
s.plainSrv.WriteTimeout = 60 * time.Second
|
||||
|
||||
// :2465 — implicit TLS (the whole connection is TLS; for apps that force_tls).
|
||||
s.tlsSrv = smtp.NewServer(be)
|
||||
s.tlsSrv.Addr = opts.TLSAddr
|
||||
s.tlsSrv.Domain = opts.ServiceName
|
||||
s.tlsSrv.TLSConfig = tlsConf
|
||||
s.tlsSrv.MaxMessageBytes = maxMessageBytes
|
||||
s.tlsSrv.ReadTimeout = 60 * time.Second
|
||||
s.tlsSrv.WriteTimeout = 60 * time.Second
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Start binds both listeners and serves them in background goroutines. A bind failure
|
||||
// is returned synchronously. The listeners must reach the app Docker network ONLY —
|
||||
// the caller is responsible for not publishing these ports to the host/internet.
|
||||
func (s *Server) Start() error {
|
||||
pl, err := net.Listen("tcp", s.opts.PlainAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailrelay: listen %s: %w", s.opts.PlainAddr, err)
|
||||
}
|
||||
tl, err := tls.Listen("tcp", s.opts.TLSAddr, s.tlsConf)
|
||||
if err != nil {
|
||||
pl.Close()
|
||||
return fmt.Errorf("mailrelay: listen TLS %s: %w", s.opts.TLSAddr, err)
|
||||
}
|
||||
s.plainLn, s.tlsLn = pl, tl
|
||||
|
||||
s.opts.Logger.Printf("[INFO] [mailrelay] plaintext+STARTTLS listener on %s", pl.Addr())
|
||||
s.opts.Logger.Printf("[INFO] [mailrelay] implicit-TLS listener on %s", tl.Addr())
|
||||
|
||||
go func() {
|
||||
if err := s.plainSrv.Serve(pl); err != nil && !isClosedErr(err) {
|
||||
s.opts.Logger.Printf("[ERROR] [mailrelay] plaintext listener stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
if err := s.tlsSrv.Serve(tl); err != nil && !isClosedErr(err) {
|
||||
s.opts.Logger.Printf("[ERROR] [mailrelay] implicit-TLS listener stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlainAddr / TLSAddr return the bound addresses (resolved ports — useful for tests
|
||||
// that listen on :0).
|
||||
func (s *Server) PlainAddr() string {
|
||||
if s.plainLn != nil {
|
||||
return s.plainLn.Addr().String()
|
||||
}
|
||||
return s.opts.PlainAddr
|
||||
}
|
||||
|
||||
func (s *Server) TLSAddr() string {
|
||||
if s.tlsLn != nil {
|
||||
return s.tlsLn.Addr().String()
|
||||
}
|
||||
return s.opts.TLSAddr
|
||||
}
|
||||
|
||||
// Close stops both listeners.
|
||||
func (s *Server) Close() error {
|
||||
var err error
|
||||
if s.plainSrv != nil {
|
||||
if e := s.plainSrv.Close(); e != nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
if s.tlsSrv != nil {
|
||||
if e := s.tlsSrv.Close(); e != nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isClosedErr(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "use of closed network connection")
|
||||
}
|
||||
|
||||
// ── go-smtp backend / session ────────────────────────────────────────────
|
||||
|
||||
type backend struct{ srv *Server }
|
||||
|
||||
func (b *backend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
|
||||
return &session{srv: b.srv}, nil
|
||||
}
|
||||
|
||||
// session implements smtp.Session + smtp.AuthSession.
|
||||
type session struct {
|
||||
srv *Server
|
||||
from string
|
||||
to []string
|
||||
}
|
||||
|
||||
func (s *session) Mail(from string, _ *smtp.MailOptions) error {
|
||||
s.from = from
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *session) Rcpt(to string, _ *smtp.RcptOptions) error {
|
||||
s.to = append(s.to, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthMechanisms advertises PLAIN + LOGIN. We accept ANY credentials and ignore them
|
||||
// (apps send none, but some refuse to send without an AUTH offer; no credential is a
|
||||
// secret on the Docker network — the shim holds no Resend key).
|
||||
func (s *session) AuthMechanisms() []string {
|
||||
return []string{sasl.Plain, sasl.Login}
|
||||
}
|
||||
|
||||
func (s *session) Auth(mech string) (sasl.Server, error) {
|
||||
switch mech {
|
||||
case sasl.Plain:
|
||||
return sasl.NewPlainServer(func(_, _, _ string) error { return nil }), nil
|
||||
case sasl.Login:
|
||||
return &loginServer{}, nil
|
||||
default:
|
||||
return nil, smtp.ErrAuthUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// Data reads the raw message, enforces the From policy BEFORE any hub call, then
|
||||
// forwards it single-shot and maps the hub status to an SMTP reply.
|
||||
func (s *session) Data(r io.Reader) error {
|
||||
raw, err := io.ReadAll(io.LimitReader(r, maxMessageBytes+1))
|
||||
if err != nil {
|
||||
s.srv.opts.Logger.Printf("[ERROR] [mailrelay] reading DATA: %v", err)
|
||||
return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 3, 0}, Message: "could not read message"}
|
||||
}
|
||||
if int64(len(raw)) > maxMessageBytes {
|
||||
return &smtp.SMTPError{Code: 552, EnhancedCode: smtp.EnhancedCode{5, 3, 4}, Message: "message too large"}
|
||||
}
|
||||
|
||||
// From-policy gate (§7 B): reject with a clean 5xx BEFORE dialing the hub — never
|
||||
// spend a Resend call on a doomed message, and surface the misconfig loudly.
|
||||
if err := s.srv.opts.Policy.Check(raw); err != nil {
|
||||
s.srv.opts.Logger.Printf("[WARN] [mailrelay] rejected message (from=%q): %v", s.from, err)
|
||||
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 7, 1}, Message: "sender address not permitted"}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), forwardTimeout)
|
||||
defer cancel()
|
||||
status, body, err := s.srv.opts.Forwarder.Forward(ctx, raw, s.from, s.to)
|
||||
if err != nil {
|
||||
// Transient: hub unreachable. Clean 4xx → the app shows the user an error; no
|
||||
// hang, no silent drop, no spool (§7 D / §9 rule 9).
|
||||
s.srv.opts.Logger.Printf("[ERROR] [mailrelay] forward to hub failed: %v", err)
|
||||
return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 4, 1}, Message: "upstream relay (hub) unavailable, try again later"}
|
||||
}
|
||||
if reply := mapStatusToSMTP(status, body); reply != nil {
|
||||
s.srv.opts.Logger.Printf("[WARN] [mailrelay] hub refused message (HTTP %d): %s", status, sanitizeReason(body))
|
||||
return reply
|
||||
}
|
||||
s.srv.opts.Logger.Printf("[INFO] [mailrelay] message relayed (from=%q rcpts=%d bytes=%d hub=%d)", s.from, len(s.to), len(raw), status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *session) Reset() {
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
}
|
||||
|
||||
func (s *session) Logout() error { return nil }
|
||||
|
||||
// mapStatusToSMTP turns the hub's HTTP status into an SMTP reply so the app surfaces
|
||||
// the real outcome (spike §6): 2xx → nil (250 OK), 4xx → 451 transient (incl. 429
|
||||
// rate-limit — retry later), 5xx → 554 permanent. The hub's reason is relayed where
|
||||
// present (sanitised to a single SMTP-safe line).
|
||||
func mapStatusToSMTP(status int, body string) error {
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
return nil
|
||||
case status == 429:
|
||||
return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 7, 0}, Message: msgOr(sanitizeReason(body), "rate limit exceeded, try again later")}
|
||||
case status >= 400 && status < 500:
|
||||
return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 4, 1}, Message: msgOr(sanitizeReason(body), "upstream rejected (temporary)")}
|
||||
default:
|
||||
return &smtp.SMTPError{Code: 554, EnhancedCode: smtp.EnhancedCode{5, 0, 0}, Message: msgOr(sanitizeReason(body), "upstream rejected")}
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeReason extracts a short, SMTP-safe single line from the hub's response body.
|
||||
// Never logs/relays a full body (which could echo addresses); caps at 200 chars.
|
||||
func sanitizeReason(body string) string {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexAny(body, "\r\n"); i >= 0 {
|
||||
body = body[:i]
|
||||
}
|
||||
if len(body) > 200 {
|
||||
body = body[:200]
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func msgOr(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// loginServer implements the SMTP AUTH LOGIN exchange as a sasl.Server. go-sasl ships
|
||||
// a LOGIN *client* but no server (spike §2). It accepts ANY credentials and ignores
|
||||
// them. The exchange: challenge "Username:" → username → challenge "Password:" →
|
||||
// password → done.
|
||||
type loginServer struct{ step int }
|
||||
|
||||
func (l *loginServer) Next(_ []byte) (challenge []byte, done bool, err error) {
|
||||
switch l.step {
|
||||
case 0:
|
||||
l.step++
|
||||
return []byte("Username:"), false, nil
|
||||
case 1:
|
||||
l.step++
|
||||
return []byte("Password:"), false, nil
|
||||
default:
|
||||
return nil, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// selfSignedCert generates an in-process self-signed leaf for the shim (CN/SAN = the
|
||||
// service name). Apps on the Docker network are told to accept invalid certs; the cert
|
||||
// only terminates TLS for force_tls/starttls apps, it is not a trust anchor.
|
||||
func selfSignedCert(serviceName string) (tls.Certificate, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: serviceName},
|
||||
DNSNames: []string{serviceName, "localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user