0e20eb19c1
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>
83 lines
2.6 KiB
Go
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))]
|
|
}
|