NextMyDay/internal/core/mail/mail.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- 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)
2026-10-14 10:53:51 +00:00

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