Files
felhom-controller/controller/internal/mailrelay/policy.go
T
admin 0e20eb19c1 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>
2026-06-29 08:45:04 +02:00

83 lines
2.6 KiB
Go

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))]
}