release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 4m35s
release / android (push) Successful in 4m58s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
M489 step 2.
- GET /api/me/notifications?limit&before: newest first, keyset-paged on
(created_at, id) with an opaque cursor, plus the unread count.
- GET /api/me/notifications/unread-count: the badge's cheap call.
- POST /api/me/notifications/{id}/read and /read-all. Mark-read is
idempotent; another user's id is a 404, the same as a malformed one.
- GET/PUT /api/me/notification-settings: every kind the caller can receive
(admin kinds only for admins) with inbox/phone/email. PUT is partial, so an
offline replay sends only what was touched, and a batch with any invalid
change applies nothing. The response says whether email can be delivered
at all: no address on file, or SMTP not configured. A failed SMTP config
read is a 500, not "not configured".
notifications.Render turns kind + payload into title, body and link on the
server, so the web inbox, the Android inbox, the phone's shade and the email
digest all say the same thing. mailer.Configured lifts Send's readiness
check out so settings can report it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
6.3 KiB
Go
214 lines
6.3 KiB
Go
// 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"
|
|
"net"
|
|
"net/smtp"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// 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 != "" {
|
|
auth = smtp.PlainAuth("", cfg.Username, cfg.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 — skip that complexity for v1 and rely 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", 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()
|
|
}
|
|
|
|
// 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]
|
|
}
|