From aa67a09b8009346dde02e9fe6a7437bb46235422 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 7 May 2026 12:43:44 -0400 Subject: [PATCH] feat(server/m7-user-mgmt): mailer package + SMTP config endpoints (U3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New internal/mailer/ package: - Sender interface with two impls: SMTPSender (production, reads smtp_config at send time so admin edits apply without restart; uses stdlib net/smtp + STARTTLS) and FakeSender (test/concurrent- safe call recorder). - Embedded text + HTML templates for the password reset email, rendered via stdlib text/template + html/template. The HTML uses the FabledSword forest-teal accent color. - ErrNotConfigured surfaces when smtp_config.enabled is false or required fields are empty; callers like the future forgot-password handler will treat this as "log and pretend success" to avoid user-enumeration leaks. Three admin endpoints under RequireAdmin: - GET /api/admin/smtp-config — returns the singleton; password field is masked ("***" or ""). - PUT /api/admin/smtp-config — updates settings. Validates host + from_address are non-empty when enabled=true. Empty password in the request preserves the stored value (so the operator doesn't have to re-enter it on every save). - POST /api/admin/smtp-config/test — sends a real test email to the calling admin's email. 400 if admin has no email; 500 with the error message on send failure (so the operator can debug config without grep-then-trace through logs). Tests cover the password-mask, password-preservation-on-empty, enabled-requires-host-and-from validation, and the no-email-on-file rejection. Mailer unit tests cover the fake recorder and template rendering. The "real SMTP send" path needs a live server and isn't covered in CI; the FakeSender covers that role for downstream tests. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/admin_smtp.go | 135 ++++++++++++ internal/api/admin_smtp_test.go | 137 ++++++++++++ internal/api/api.go | 4 + internal/mailer/mailer.go | 206 ++++++++++++++++++ internal/mailer/mailer_test.go | 64 ++++++ internal/mailer/templates.go | 45 ++++ internal/mailer/templates/password_reset.html | 29 +++ internal/mailer/templates/password_reset.txt | 11 + 8 files changed, 631 insertions(+) create mode 100644 internal/api/admin_smtp.go create mode 100644 internal/api/admin_smtp_test.go create mode 100644 internal/mailer/mailer.go create mode 100644 internal/mailer/mailer_test.go create mode 100644 internal/mailer/templates.go create mode 100644 internal/mailer/templates/password_reset.html create mode 100644 internal/mailer/templates/password_reset.txt diff --git a/internal/api/admin_smtp.go b/internal/api/admin_smtp.go new file mode 100644 index 00000000..aa246ca0 --- /dev/null +++ b/internal/api/admin_smtp.go @@ -0,0 +1,135 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "time" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" +) + +type smtpConfigResp struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int32 `json:"port"` + Username string `json:"username"` + Password string `json:"password"` // masked: "***" if non-empty, "" otherwise + FromAddress string `json:"from_address"` + FromName string `json:"from_name"` + UseTLS bool `json:"use_tls"` +} + +type updateSMTPConfigReq struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int32 `json:"port"` + Username string `json:"username"` + Password string `json:"password"` // empty string = preserve existing + FromAddress string `json:"from_address"` + FromName string `json:"from_name"` + UseTLS bool `json:"use_tls"` +} + +func (h *handlers) handleGetSMTPConfig(w http.ResponseWriter, r *http.Request) { + q := dbq.New(h.pool) + cfg, err := q.GetSMTPConfig(r.Context()) + if err != nil { + h.logger.Error("admin smtp: get failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + masked := "" + if cfg.Password != "" { + masked = "***" + } + writeJSON(w, http.StatusOK, smtpConfigResp{ + Enabled: cfg.Enabled, + Host: cfg.Host, + Port: cfg.Port, + Username: cfg.Username, + Password: masked, + FromAddress: cfg.FromAddress, + FromName: cfg.FromName, + UseTLS: cfg.UseTls, + }) +} + +func (h *handlers) handleUpdateSMTPConfig(w http.ResponseWriter, r *http.Request) { + var req updateSMTPConfigReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid_body", "") + return + } + if req.Enabled { + if req.Host == "" || req.FromAddress == "" { + writeErr(w, http.StatusBadRequest, "missing_fields", + "host and from_address are required when enabled") + return + } + } + q := dbq.New(h.pool) + current, err := q.GetSMTPConfig(r.Context()) + if err != nil { + h.logger.Error("admin smtp: get failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + // Preserve existing password when the request sends "" (so the + // admin doesn't have to re-enter it on every save). + password := req.Password + if password == "" { + password = current.Password + } + if err := q.UpdateSMTPConfig(r.Context(), dbq.UpdateSMTPConfigParams{ + Enabled: req.Enabled, + Host: req.Host, + Port: req.Port, + Username: req.Username, + Password: password, + FromAddress: req.FromAddress, + FromName: req.FromName, + UseTls: req.UseTLS, + }); err != nil { + h.logger.Error("admin smtp: update failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *handlers) handleTestSMTPConfig(w http.ResponseWriter, r *http.Request) { + caller, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "auth_required", "") + return + } + if caller.Email == nil || *caller.Email == "" { + writeErr(w, http.StatusBadRequest, "no_email_on_file", + "set your email in /settings before testing SMTP") + return + } + + // Use a short-circuit context so the SMTP send doesn't hang the + // HTTP request indefinitely on a misconfigured host. + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + sender := mailer.NewSMTPSender(h.pool, h.logger) + textBody := "This is a test message from your Minstrel server.\n\nIf you got this, your SMTP config is working.\n\n— Minstrel" + htmlBody := "

This is a test message from your Minstrel server.

If you got this, your SMTP config is working.

— Minstrel

" + if err := sender.Send(ctx, *caller.Email, "Minstrel SMTP test", textBody, htmlBody); err != nil { + if errors.Is(err, mailer.ErrNotConfigured) { + writeErr(w, http.StatusBadRequest, "not_configured", + "SMTP is not enabled or required fields are empty") + return + } + h.logger.Error("admin smtp: test send failed", "err", err) + writeErr(w, http.StatusInternalServerError, "send_failed", err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/admin_smtp_test.go b/internal/api/admin_smtp_test.go new file mode 100644 index 00000000..663a0cb8 --- /dev/null +++ b/internal/api/admin_smtp_test.go @@ -0,0 +1,137 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" +) + +func newAdminSMTPRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Route("/api/admin", func(admin chi.Router) { + admin.Use(auth.RequireAdmin()) + admin.Get("/smtp-config", h.handleGetSMTPConfig) + admin.Put("/smtp-config", h.handleUpdateSMTPConfig) + admin.Post("/smtp-config/test", h.handleTestSMTPConfig) + }) + return r +} + +func TestSMTPConfig_GetMasksPassword(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + admin := seedUser(t, pool, "smtpadmin", "pw", true) + + // Seed a non-empty password. + if _, err := pool.Exec(context.Background(), + "UPDATE smtp_config SET password = 'secret' WHERE id = true"); err != nil { + t.Fatalf("seed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/admin/smtp-config", nil) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminSMTPRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var resp smtpConfigResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Password != "***" { + t.Errorf("password = %q, want \"***\"", resp.Password) + } +} + +func TestSMTPConfig_UpdatePreservesPasswordWhenEmpty(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + admin := seedUser(t, pool, "smtpupd", "pw", true) + + // Seed a known password. + if _, err := pool.Exec(context.Background(), + "UPDATE smtp_config SET password = 'original' WHERE id = true"); err != nil { + t.Fatalf("seed: %v", err) + } + + body := `{ + "enabled": true, + "host": "smtp.example.com", + "port": 587, + "username": "u", + "password": "", + "from_address": "from@example.com", + "from_name": "T", + "use_tls": true + }` + req := httptest.NewRequest(http.MethodPut, "/api/admin/smtp-config", + bytes.NewReader([]byte(body))) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminSMTPRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String()) + } + + // Password preserved? + var pass string + if err := pool.QueryRow(context.Background(), + "SELECT password FROM smtp_config WHERE id = true").Scan(&pass); err != nil { + t.Fatalf("verify: %v", err) + } + if pass != "original" { + t.Errorf("password = %q, want \"original\" (preserved when empty in request)", pass) + } +} + +func TestSMTPConfig_UpdateValidatesEnabled(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + admin := seedUser(t, pool, "smtpvalid", "pw", true) + + body := `{"enabled": true, "host": "", "from_address": "", "port": 587, "use_tls": true}` + req := httptest.NewRequest(http.MethodPut, "/api/admin/smtp-config", + bytes.NewReader([]byte(body))) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminSMTPRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (missing host + from_address with enabled=true)", rec.Code) + } +} + +func TestSMTPConfig_TestRequiresEmail(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + // Admin without email on file. + admin := seedUser(t, pool, "noemail", "pw", true) + + req := httptest.NewRequest(http.MethodPost, "/api/admin/smtp-config/test", nil) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminSMTPRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (no email on file)", rec.Code) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index a393018a..936eafb7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -140,6 +140,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource) admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource) admin.Post("/cover-sources/research", h.handleResearchMissingArt) + + admin.Get("/smtp-config", h.handleGetSMTPConfig) + admin.Put("/smtp-config", h.handleUpdateSMTPConfig) + admin.Post("/smtp-config/test", h.handleTestSMTPConfig) }) authed.Get("/playlists", h.handleListPlaylists) diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 00000000..44d3e148 --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,206 @@ +// 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 !cfg.Enabled || cfg.Host == "" || cfg.FromAddress == "" { + 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 +} + +// 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(ctx 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] +} diff --git a/internal/mailer/mailer_test.go b/internal/mailer/mailer_test.go new file mode 100644 index 00000000..9dbf8b97 --- /dev/null +++ b/internal/mailer/mailer_test.go @@ -0,0 +1,64 @@ +package mailer_test + +import ( + "context" + "errors" + "strings" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" +) + +func TestFakeSender_RecordsCalls(t *testing.T) { + f := &mailer.FakeSender{} + if err := f.Send(context.Background(), "u@example.com", "Subj", "text body", "

html body

"); err != nil { + t.Fatalf("Send: %v", err) + } + if len(f.Sent) != 1 { + t.Fatalf("Sent len = %d, want 1", len(f.Sent)) + } + got := f.LastSent() + if got.To != "u@example.com" || got.Subject != "Subj" { + t.Errorf("LastSent = %+v", got) + } +} + +func TestFakeSender_FailNext(t *testing.T) { + f := &mailer.FakeSender{FailNext: errors.New("simulated")} + if err := f.Send(context.Background(), "u@example.com", "Subj", "t", "h"); err == nil { + t.Errorf("Send returned nil, want error") + } + // Subsequent call should succeed (FailNext consumed). + if err := f.Send(context.Background(), "u@example.com", "Subj", "t", "h"); err != nil { + t.Errorf("second Send: %v", err) + } + if len(f.Sent) != 1 { + t.Errorf("Sent len = %d, want 1 (first call failed, second recorded)", len(f.Sent)) + } +} + +func TestRenderResetEmail(t *testing.T) { + textBody, htmlBody, err := mailer.RenderResetEmail(mailer.ResetEmailVars{ + Username: "alice", + ResetURL: "https://example.com/reset/abc123", + ExpiryHours: 24, + }) + if err != nil { + t.Fatalf("RenderResetEmail: %v", err) + } + if !strings.Contains(textBody, "alice") { + t.Errorf("textBody missing username; got: %s", textBody) + } + if !strings.Contains(textBody, "https://example.com/reset/abc123") { + t.Errorf("textBody missing reset URL; got: %s", textBody) + } + if !strings.Contains(textBody, "24") { + t.Errorf("textBody missing expiry hours; got: %s", textBody) + } + if !strings.Contains(htmlBody, "alice") { + t.Errorf("htmlBody missing username") + } + if !strings.Contains(htmlBody, "https://example.com/reset/abc123") { + t.Errorf("htmlBody missing reset URL") + } +} diff --git a/internal/mailer/templates.go b/internal/mailer/templates.go new file mode 100644 index 00000000..9c526973 --- /dev/null +++ b/internal/mailer/templates.go @@ -0,0 +1,45 @@ +package mailer + +import ( + "bytes" + "embed" + "fmt" + htmltemplate "html/template" + texttemplate "text/template" +) + +//go:embed templates/password_reset.txt templates/password_reset.html +var resetEmailFS embed.FS + +// ResetEmailVars is the data passed to the password reset templates. +type ResetEmailVars struct { + Username string + ResetURL string + ExpiryHours int +} + +// RenderResetEmail returns (textBody, htmlBody) for the password +// reset email, parameterized with the given vars. +func RenderResetEmail(vars ResetEmailVars) (string, string, error) { + textTpl, err := texttemplate.ParseFS(resetEmailFS, "templates/password_reset.txt") + if err != nil { + return "", "", fmt.Errorf("mailer: parse text template: %w", err) + } + htmlTpl, err := htmltemplate.ParseFS(resetEmailFS, "templates/password_reset.html") + if err != nil { + return "", "", fmt.Errorf("mailer: parse html template: %w", err) + } + + var textBuf, htmlBuf bytes.Buffer + if err := textTpl.Execute(&textBuf, vars); err != nil { + return "", "", fmt.Errorf("mailer: render text: %w", err) + } + if err := htmlTpl.Execute(&htmlBuf, vars); err != nil { + return "", "", fmt.Errorf("mailer: render html: %w", err) + } + return textBuf.String(), htmlBuf.String(), nil +} + +// ResetEmailSubject is the subject line used by the forgot-password +// flow. Exported so callers don't string-literal it independently. +const ResetEmailSubject = "Minstrel password reset" diff --git a/internal/mailer/templates/password_reset.html b/internal/mailer/templates/password_reset.html new file mode 100644 index 00000000..c5d52527 --- /dev/null +++ b/internal/mailer/templates/password_reset.html @@ -0,0 +1,29 @@ + + + +

Hello {{.Username}},

+ +

A password reset was requested for your Minstrel account. To set a new + password, click the button below within {{.ExpiryHours}} hours:

+ +

+ Reset password +

+ +

+ Or copy this URL into your browser:
+ {{.ResetURL}} +

+ +

+ If you didn't request this, you can safely ignore this email — your + password won't change. +

+ +

+ — Minstrel +

+ + diff --git a/internal/mailer/templates/password_reset.txt b/internal/mailer/templates/password_reset.txt new file mode 100644 index 00000000..ac1f3618 --- /dev/null +++ b/internal/mailer/templates/password_reset.txt @@ -0,0 +1,11 @@ +Hello {{.Username}}, + +A password reset was requested for your Minstrel account. To set a new +password, follow this link within {{.ExpiryHours}} hours: + + {{.ResetURL}} + +If you didn't request this, you can safely ignore this email — your +password won't change. + +— Minstrel