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,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))]
|
||||
}
|
||||
Reference in New Issue
Block a user