hub v0.18.0: app-email passthrough POST /api/v1/mail → Resend SMTP

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 08:45:22 +02:00
parent 4b97855cdd
commit fa3c4f2657
8 changed files with 695 additions and 72 deletions
+152
View File
@@ -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/<key>, 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
}
+63
View File
@@ -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 <vaultwarden@felhom.eu>\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)
}
}