Files
minstrel/internal/mailer/mailer.go
T
bvandeusenandClaude Opus 5.5 a8a2d20307
release / govulncheck (push) Successful in 28s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / android (push) Successful in 4m45s
release / Build signed APK (push) Successful in 4m57s
release / integration (push) Successful in 5m3s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: stored credentials are sealed at rest, so a copy of the database alone reveals none of them (#6767)
Minstrel keeps credentials it has to read back: indexer, Prowlarr and
download-client keys and passwords, the AcoustID key, cover-art and tag
provider keys, the SMTP password, and each user's ListenBrainz token and
Subsonic password. They were stored in the clear; they are now sealed with
AES-256-GCM under a per-column HKDF key derived from the stream secret, each
value bound to its row.

- internal/secret: Seal/Open/Reveal, and Columns, the one list of sealed
  columns (a backup will leave them out). Install takes the master key at
  startup; SealStored seals any value still in the clear, so an existing
  database is brought over in place on the first boot.
- Every write seals and every read opens: acquire (one read path, getService
  / usableServices), acoustid settings, SMTP, ListenBrainz, Subsonic auth and
  password generation, cover-art and tag provider settings.
- A value that does not open (the stream secret changed) is logged and reads
  as unset, to be entered again in Settings. docs/security.md says so.
- Tests: secret unit tests (wrong owner/purpose/key, malformed, no key), a
  SealStored integration test over all eight columns plus a schema check that
  each listed column exists, and the existing tests now assert sealed storage.
  dbtest installs a test key for integration tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:23:19 -05:00

227 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package mailer sends transactional email for the user-management
// flows (password reset, etc). The Sender interface lets handlers
// inject a fake in tests; SMTPSender is the production implementation
// that reads SMTP config from the smtp_config singleton at send time
// so admin edits take effect without a server restart.
package mailer
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"mime"
"net"
"net/smtp"
"strconv"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/secret"
)
// ErrNotConfigured is returned when smtp_config.enabled is false or
// required fields are empty. Handlers that depend on email send
// (forgot-password) treat this as a soft failure — log it and pretend
// the send succeeded so users don't enumerate which emails are on
// file.
var ErrNotConfigured = errors.New("mailer: SMTP not configured")
// Sender is the interface every caller of the mailer uses.
// Implementations: SMTPSender (production), FakeSender (tests).
type Sender interface {
Send(ctx context.Context, to, subject, textBody, htmlBody string) error
}
// SMTPSender is the production implementation. Reads smtp_config from
// the database at send time so config changes apply without restart.
type SMTPSender struct {
pool *pgxpool.Pool
logger *slog.Logger
}
func NewSMTPSender(pool *pgxpool.Pool, logger *slog.Logger) *SMTPSender {
return &SMTPSender{pool: pool, logger: logger}
}
// Send composes a multipart/alternative message with text + html and
// dispatches it via SMTP. Returns ErrNotConfigured when smtp_config
// is disabled or missing required fields; returns the underlying
// network/auth error otherwise.
func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody string) error {
q := dbq.New(s.pool)
cfg, err := q.GetSMTPConfig(ctx)
if err != nil {
return fmt.Errorf("mailer: load config: %w", err)
}
if !Configured(cfg) {
return ErrNotConfigured
}
msg := composeMessage(cfg, to, subject, textBody, htmlBody)
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(int(cfg.Port)))
var auth smtp.Auth
if cfg.Username != "" {
password := secret.Reveal(secret.SMTPPassword.Purpose(), "", cfg.Password)
auth = smtp.PlainAuth("", cfg.Username, password, cfg.Host)
}
// We respect the use_tls flag: if true, we require STARTTLS via
// smtp.SendMail's default behavior (which negotiates STARTTLS when
// the server advertises it). For implicit-TLS (port 465 style),
// we'd need a custom dial; instead this relies on STARTTLS via port
// 587 (the default).
dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
_ = dialCtx // smtp.SendMail doesn't accept ctx; the timeout is enforced via Dialer below
if err := sendMail(addr, cfg, auth, []string{to}, msg); err != nil {
s.logger.Error("mailer: send failed",
"to", to, "host", cfg.Host, "err", err)
return fmt.Errorf("mailer: send: %w", err)
}
return nil
}
// Configured reports whether cfg can send at all: enabled, with a host and a
// from address. Send refuses with ErrNotConfigured otherwise, and settings
// screens use it to say why email is unavailable before anyone tries.
func Configured(cfg dbq.SmtpConfig) bool {
return cfg.Enabled && cfg.Host != "" && cfg.FromAddress != ""
}
// sendMail wraps net/smtp's SendMail with optional TLS verification.
// Mostly identical to smtp.SendMail but explicitly handles the
// use_tls flag.
func sendMail(addr string, cfg dbq.SmtpConfig, auth smtp.Auth, to []string, msg []byte) error {
c, err := smtp.Dial(addr)
if err != nil {
return err
}
defer func() { _ = c.Close() }()
if cfg.UseTls {
if ok, _ := c.Extension("STARTTLS"); ok {
tlsConfig := &tls.Config{ServerName: cfg.Host}
if err := c.StartTLS(tlsConfig); err != nil {
return err
}
}
}
if auth != nil {
if ok, _ := c.Extension("AUTH"); ok {
if err := c.Auth(auth); err != nil {
return err
}
}
}
if err := c.Mail(cfg.FromAddress); err != nil {
return err
}
for _, addr := range to {
if err := c.Rcpt(addr); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
// composeMessage builds an RFC 5322 multipart/alternative message
// with the from/subject headers and both text + html bodies. Boundary
// is timestamp-based to keep the implementation dependency-free.
func composeMessage(cfg dbq.SmtpConfig, to, subject, textBody, htmlBody string) []byte {
var buf bytes.Buffer
boundary := fmt.Sprintf("minstrel-%d", time.Now().UnixNano())
from := cfg.FromAddress
if cfg.FromName != "" {
from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.FromAddress)
}
fmt.Fprintf(&buf, "From: %s\r\n", from)
fmt.Fprintf(&buf, "To: %s\r\n", to)
fmt.Fprintf(&buf, "Subject: %s\r\n", encodeSubject(subject))
fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
fmt.Fprintf(&buf, "--%s\r\n", boundary)
fmt.Fprintf(&buf, "Content-Type: text/plain; charset=\"utf-8\"\r\n\r\n")
buf.WriteString(textBody)
buf.WriteString("\r\n")
fmt.Fprintf(&buf, "--%s\r\n", boundary)
fmt.Fprintf(&buf, "Content-Type: text/html; charset=\"utf-8\"\r\n\r\n")
buf.WriteString(htmlBody)
buf.WriteString("\r\n")
fmt.Fprintf(&buf, "--%s--\r\n", boundary)
return buf.Bytes()
}
// encodeSubject makes subject safe as a header value. Line breaks are
// replaced, so a name carried into a subject cannot start a header of its
// own, and non-ASCII text ("Moe Shop – WWW") is RFC 2047 encoded, which
// mime.QEncoding leaves alone when there is nothing to encode.
func encodeSubject(subject string) string {
subject = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(subject)
return mime.QEncoding.Encode("utf-8", subject)
}
// SentEmail is the in-memory record FakeSender keeps. Tests assert
// against these.
type SentEmail struct {
To string
Subject string
TextBody string
HTMLBody string
}
// FakeSender records calls instead of sending. Safe for concurrent use.
type FakeSender struct {
mu sync.Mutex
Sent []SentEmail
// FailNext, when true, makes the next Send call return an error
// instead of recording. Useful for testing send-failure paths.
FailNext error
}
func (f *FakeSender) Send(_ context.Context, to, subject, textBody, htmlBody string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.FailNext != nil {
err := f.FailNext
f.FailNext = nil
return err
}
f.Sent = append(f.Sent, SentEmail{
To: to, Subject: subject, TextBody: textBody, HTMLBody: htmlBody,
})
return nil
}
// LastSent returns the most recently recorded email, or zero value
// if nothing has been sent.
func (f *FakeSender) LastSent() SentEmail {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.Sent) == 0 {
return SentEmail{}
}
return f.Sent[len(f.Sent)-1]
}