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