- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded migration runner; each module's migrations are name-prefixed (D-012) - audit: the append-only audit log; the actor threads via context (R-1) - users: the user_id/GUID/email anchors (D-013) - config: the YAML settings file (D-035) + validation - httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security headers · redaction - middleware: correlation id · audit · rate limiting - mail: the invite/recovery mailer seam - server: the core DOOR — the resolution switch, the dispatch registry (RegisterModule), the role gates, /setup · enroll/login · recovery flows, the reserved `static` workflow, and the loopback-only bootstrap (D-025)
146 lines
3.9 KiB
Go
146 lines
3.9 KiB
Go
// Package mail is MyDay's outbound-mail seam (SPEC-007 §4/§5): recovery
|
|
// notifications and the enrolment link emailed after an identity rotation.
|
|
//
|
|
// Email is NEVER an authentication factor (D-005/D-009) — it only *initiates*
|
|
// recovery; a human in the chain authorises it. The seam is therefore tiny: an
|
|
// interface, an in-memory Fake for tests, and minimal SMTP wiring for
|
|
// production.
|
|
//
|
|
// Spec: docs/specs/spec-007-roles-chain-recovery.md.
|
|
package mail
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/smtp"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Message is one outbound email.
|
|
type Message struct {
|
|
To string
|
|
Subject string
|
|
Body string
|
|
}
|
|
|
|
// Mailer sends an email. Delivery failures are the caller's to log — the
|
|
// contract is deliberately fire-and-forget, like the audit trail.
|
|
type Mailer interface {
|
|
Send(ctx context.Context, m Message) error
|
|
}
|
|
|
|
// Fake records every message in memory. It is the test seam.
|
|
type Fake struct {
|
|
mu sync.Mutex
|
|
sent []Message
|
|
}
|
|
|
|
// NewFake returns an empty recording mailer.
|
|
func NewFake() *Fake { return &Fake{} }
|
|
|
|
// Send implements Mailer.
|
|
func (f *Fake) Send(_ context.Context, m Message) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.sent = append(f.sent, m)
|
|
return nil
|
|
}
|
|
|
|
// Sent returns a copy of every recorded message.
|
|
func (f *Fake) Sent() []Message {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]Message, len(f.sent))
|
|
copy(out, f.sent)
|
|
return out
|
|
}
|
|
|
|
// To returns every recorded message addressed to addr (case-insensitive).
|
|
func (f *Fake) To(addr string) []Message {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []Message
|
|
for _, m := range f.sent {
|
|
if strings.EqualFold(strings.TrimSpace(m.To), strings.TrimSpace(addr)) {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Nop discards messages. It is the fallback when SMTP is not configured.
|
|
type Nop struct{}
|
|
|
|
// Send implements Mailer.
|
|
func (Nop) Send(context.Context, Message) error { return nil }
|
|
|
|
// SMTP sends through a real SMTP server. It is kept deliberately minimal: the
|
|
// /setup wizard collects the settings; delivery is best-effort and never
|
|
// blocks a request's outcome.
|
|
type SMTP struct {
|
|
host string
|
|
port int
|
|
username string
|
|
password string
|
|
from string
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewSMTP builds an SMTP mailer.
|
|
func NewSMTP(host string, port int, username, password, from string) *SMTP {
|
|
return &SMTP{
|
|
host: strings.TrimSpace(host),
|
|
port: port,
|
|
username: username,
|
|
password: password,
|
|
from: strings.TrimSpace(from),
|
|
logger: slog.Default(),
|
|
}
|
|
}
|
|
|
|
// Send implements Mailer via net/smtp (STARTTLS/plain auth when a username is
|
|
// set). An unconfigured host is a hard error — the caller decides whether that
|
|
// matters.
|
|
func (s *SMTP) Send(_ context.Context, m Message) error {
|
|
if s.host == "" {
|
|
return errors.New("mail: SMTP host is not configured")
|
|
}
|
|
if strings.TrimSpace(m.To) == "" {
|
|
return errors.New("mail: recipient is required")
|
|
}
|
|
addr := fmt.Sprintf("%s:%d", s.host, s.port)
|
|
var auth smtp.Auth
|
|
if s.username != "" {
|
|
auth = smtp.PlainAuth("", s.username, s.password, s.host)
|
|
}
|
|
if err := smtp.SendMail(addr, auth, s.from, []string{m.To}, encode(s.from, m)); err != nil {
|
|
return fmt.Errorf("mail: send to %s: %w", m.To, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// encode renders the RFC 5322 message. The body is plain text; every header
|
|
// value is stripped of CR/LF first, so a crafted address or subject cannot
|
|
// inject extra headers (header-injection guard).
|
|
func encode(from string, m Message) []byte {
|
|
sanitize := strings.NewReplacer("\r", " ", "\n", " ").Replace
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "From: %s\r\n", sanitize(from))
|
|
fmt.Fprintf(&b, "To: %s\r\n", sanitize(m.To))
|
|
fmt.Fprintf(&b, "Subject: %s\r\n", sanitize(m.Subject))
|
|
b.WriteString("MIME-Version: 1.0\r\n")
|
|
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
|
b.WriteString("\r\n")
|
|
b.WriteString(m.Body)
|
|
b.WriteString("\r\n")
|
|
return []byte(b.String())
|
|
}
|
|
|
|
var (
|
|
_ Mailer = (*Fake)(nil)
|
|
_ Mailer = Nop{}
|
|
_ Mailer = (*SMTP)(nil)
|
|
)
|