Files
paliad/internal/services/mail_service.go
m 11217f7bfa feat: email service — SMTP + deadline reminders + invitations (t-paliad-021)
- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465),
  html/template rendering, branded base layout + content templates, silent
  no-op when SMTP_* unset.
- internal/services/reminder_service.go: hourly scanner for Fristen that are
  overdue / due tomorrow / due within the week (Monday digest). Dedup via
  paliad.reminder_log (24h window).
- internal/services/invite_service.go: POST /api/invite flow with domain
  whitelist, in-memory 10/day/user rate limit, audit row in
  paliad.invitations.
- internal/handlers/invite.go: POST + GET /api/invite handlers.
- Sidebar "Kolleg:in einladen" button + modal on every page.
- migration 016: paliad.reminder_log, paliad.invitations, users.lang column.
- docker-compose: SMTP_* + PALIAD_BASE_URL env vars.
- docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open
  question; current pilot keeps identity mails on Supabase default sender.

Rationale: get Paliad off Supabase's best-effort outbound for the
inbox-facing stuff (reminders, invitations) and move deadline nudges from
passive dashboard to active email. Custom Supabase auth SMTP is blocked on
the shared ydb.youpc.org instance — deferred until Paliad has its own
project or GoTrue webhook relay.
2026-04-20 12:34:38 +02:00

337 lines
11 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 services — MailService — SMTP delivery for transactional email.
//
// Handles three kinds of messages: deadline reminders (reminder_service.go),
// colleague invitations (handlers/invite.go), and any other one-off email the
// app needs to send. All outgoing mail goes through Send / SendTemplate so
// branding stays consistent.
//
// Config is read from env vars at startup; when any required var is unset the
// service logs a warning and becomes a silent no-op. This lets the server run
// locally without SMTP credentials — no crashes, no surprise 500s.
//
// Port 465 uses implicit TLS (tls.Dial from the start), not STARTTLS. The
// Hostinger submission endpoint only accepts implicit TLS on that port.
package services
import (
"bytes"
"crypto/tls"
"errors"
"fmt"
"html/template"
"log/slog"
"maps"
"mime"
"net"
"net/smtp"
"os"
"regexp"
"strings"
"time"
"mgit.msbls.de/m/patholo/internal/templates"
)
// MailConfig holds resolved SMTP settings. Built once at startup.
type MailConfig struct {
Host string
Port string
Username string
Password string
From string
FromName string
UseTLS bool
}
// MailService sends branded HTML+text email over SMTP. Safe to use
// concurrently. When the service is disabled (missing env vars), every Send*
// call logs and returns nil so callers can treat it as fire-and-forget.
type MailService struct {
cfg MailConfig
enabled bool
templates *template.Template
}
// NewMailService reads SMTP_* from the environment. Returns a non-nil service
// either way; callers check Enabled() if they care whether mail actually went
// out. Parsing the embedded template set is fatal — a template error would
// silently break every email, which is worse than failing at boot.
func NewMailService() (*MailService, error) {
cfg := MailConfig{
Host: strings.TrimSpace(os.Getenv("SMTP_HOST")),
Port: strings.TrimSpace(os.Getenv("SMTP_PORT")),
Username: strings.TrimSpace(os.Getenv("SMTP_USERNAME")),
Password: os.Getenv("SMTP_PASSWORD"),
From: strings.TrimSpace(os.Getenv("SMTP_FROM")),
FromName: strings.TrimSpace(os.Getenv("SMTP_FROM_NAME")),
UseTLS: !strings.EqualFold(os.Getenv("SMTP_USE_TLS"), "false"),
}
if cfg.Port == "" {
cfg.Port = "465"
}
if cfg.From == "" && cfg.Username != "" {
cfg.From = cfg.Username
}
if cfg.FromName == "" {
cfg.FromName = "Paliad"
}
enabled := cfg.Host != "" && cfg.Username != "" && cfg.Password != "" && cfg.From != ""
if !enabled {
slog.Warn("mail: SMTP_* env vars incomplete — email delivery disabled",
"host_set", cfg.Host != "",
"username_set", cfg.Username != "",
"password_set", cfg.Password != "",
"from_set", cfg.From != "",
)
} else {
slog.Info("mail: SMTP configured", "host", cfg.Host, "port", cfg.Port, "from", cfg.From)
}
// Parse only base.html here. Each content template redefines
// {{define "content"}}; parsing them all at once would silently let the
// last one win. SendTemplate parses the chosen content file onto a clone
// of the base so every call gets the right override.
tpls, err := template.ParseFS(templates.EmailFS, "email/base.html")
if err != nil {
return nil, fmt.Errorf("parse email base template: %w", err)
}
return &MailService{cfg: cfg, enabled: enabled, templates: tpls}, nil
}
// Enabled reports whether SMTP is configured. Handlers can surface a clearer
// error when invite/reminder features require a live SMTP connection.
func (s *MailService) Enabled() bool { return s.enabled }
// Send delivers one multipart/alternative email (HTML + text). A zero or
// nil-value service (Enabled() == false) no-ops and returns nil.
//
// textBody may be empty; when omitted we auto-derive a plain-text fallback
// from the HTML so every message still has both parts (some clients flag
// HTML-only mail as spam).
func (s *MailService) Send(to, subject, htmlBody, textBody string) error {
if !s.enabled {
slog.Debug("mail: Send skipped (disabled)", "to", to, "subject", subject)
return nil
}
if to == "" {
return errors.New("mail: empty recipient")
}
if subject == "" {
return errors.New("mail: empty subject")
}
if textBody == "" {
textBody = htmlToText(htmlBody)
}
msg := buildMIME(s.cfg.From, s.cfg.FromName, to, subject, htmlBody, textBody)
return s.deliver(to, msg)
}
// TemplateData is the shape passed to SendTemplate. Lang defaults to "de"
// when empty. Subject is set by the caller (it can use Data in its own
// formatting before calling Send). To is the recipient; Data holds template
// fields. Name is the template's {{define "content"}} name — i.e.
// "deadline_reminder", "deadline_weekly", or "invitation".
type TemplateData struct {
To string
Subject string
Lang string
Name string
Data map[string]any
}
// SendTemplate renders the named content template inside the shared base
// layout and sends both HTML and a plain-text fallback. The fallback comes
// from tag-stripping the rendered HTML; for richer text output we can add
// a parallel .txt template later without changing callers.
//
// Rendering runs even when Enabled() is false, so template errors (typos,
// missing fields) surface in development and in tests that don't set
// SMTP_*. Actual network I/O is skipped in that case.
func (s *MailService) SendTemplate(in TemplateData) error {
html, err := s.RenderTemplate(in)
if err != nil {
return err
}
if !s.enabled {
slog.Debug("mail: SendTemplate skipped (disabled)", "to", in.To, "template", in.Name)
return nil
}
return s.Send(in.To, in.Subject, html, htmlToText(html))
}
// RenderTemplate produces the final HTML body without touching the network.
// Exposed for tests and for any future flow that wants to preview the
// rendered email (e.g. an admin tool).
func (s *MailService) RenderTemplate(in TemplateData) (string, error) {
lang := in.Lang
if lang == "" {
lang = "de"
}
// We need to bind the right {{define "content"}} — each of our templates
// redefines it. Clone the base template and parse the chosen file so only
// one definition is active for this render.
tpl, err := s.templates.Clone()
if err != nil {
return "", fmt.Errorf("clone templates: %w", err)
}
contentFile := in.Name + ".html"
_, err = tpl.ParseFS(templates.EmailFS, "email/"+contentFile)
if err != nil {
return "", fmt.Errorf("parse template %s: %w", contentFile, err)
}
payload := map[string]any{
"Lang": lang,
"Subject": in.Subject,
}
maps.Copy(payload, in.Data)
var out bytes.Buffer
if err := tpl.ExecuteTemplate(&out, "base.html", payload); err != nil {
return "", fmt.Errorf("render template %s: %w", in.Name, err)
}
return out.String(), nil
}
// --- SMTP transport ---------------------------------------------------------
func (s *MailService) deliver(to string, msg []byte) error {
addr := net.JoinHostPort(s.cfg.Host, s.cfg.Port)
tlsCfg := &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}
var (
client *smtp.Client
err error
)
if s.cfg.UseTLS {
// Implicit TLS (port 465). Establish the TLS connection first, then
// hand it to smtp.NewClient. STARTTLS upgrades on port 587 would go
// through smtp.Dial + client.StartTLS — different code path, not
// needed for Hostinger's submission endpoint.
conn, dialErr := tls.Dial("tcp", addr, tlsCfg)
if dialErr != nil {
return fmt.Errorf("smtp tls dial: %w", dialErr)
}
client, err = smtp.NewClient(conn, s.cfg.Host)
} else {
client, err = smtp.Dial(addr)
}
if err != nil {
return fmt.Errorf("smtp connect: %w", err)
}
defer client.Close()
if err := client.Hello(hostnameForHelo()); err != nil {
return fmt.Errorf("smtp helo: %w", err)
}
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
if err := client.Mail(s.cfg.From); err != nil {
return fmt.Errorf("smtp mail from: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("smtp rcpt to: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := w.Write(msg); err != nil {
w.Close()
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
return client.Quit()
}
func hostnameForHelo() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "localhost"
}
// --- MIME construction ------------------------------------------------------
// buildMIME assembles a multipart/alternative message with a fixed boundary.
// Subjects are encoded as UTF-8 per RFC 2047 so non-ASCII characters (umlauts)
// render correctly in every client.
func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
boundary := "paliad-mixed-" + randBoundary()
fromHeader := from
if fromName != "" {
fromHeader = fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("utf-8", fromName), from)
}
var b bytes.Buffer
fmt.Fprintf(&b, "From: %s\r\n", fromHeader)
fmt.Fprintf(&b, "To: %s\r\n", to)
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
// Plain text part
fmt.Fprintf(&b, "--%s\r\n", boundary)
fmt.Fprintf(&b, "Content-Type: text/plain; charset=\"utf-8\"\r\n")
fmt.Fprintf(&b, "Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(textBody)
b.WriteString("\r\n")
// HTML part
fmt.Fprintf(&b, "--%s\r\n", boundary)
fmt.Fprintf(&b, "Content-Type: text/html; charset=\"utf-8\"\r\n")
fmt.Fprintf(&b, "Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(htmlBody)
b.WriteString("\r\n")
fmt.Fprintf(&b, "--%s--\r\n", boundary)
return b.Bytes()
}
// randBoundary produces a short unique boundary marker. Crypto-strength isn't
// required — we only need to avoid collision with the body content.
func randBoundary() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
// --- HTML → plain text ------------------------------------------------------
var (
stripTagRE = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?</(script|style)>`)
stripHTMLRE = regexp.MustCompile(`<[^>]+>`)
wsRE = regexp.MustCompile(`[ \t]+`)
nlRE = regexp.MustCompile(`\n{3,}`)
)
// htmlToText produces a readable plain-text version of an HTML body. Good
// enough for the fallback part of a multipart/alternative message — users
// whose clients render HTML will see the styled version; this is only read
// by text-only clients and spam filters.
func htmlToText(html string) string {
s := stripTagRE.ReplaceAllString(html, "")
// Convert common block breaks to newlines before stripping.
s = strings.NewReplacer(
"<br>", "\n", "<br/>", "\n", "<br />", "\n",
"</p>", "\n\n", "</div>", "\n", "</tr>", "\n",
"</li>", "\n", "</h1>", "\n\n", "</h2>", "\n\n", "</h3>", "\n\n",
).Replace(s)
s = stripHTMLRE.ReplaceAllString(s, "")
s = strings.NewReplacer(
"&nbsp;", " ", "&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", "\"",
"&mdash;", "—", "&ndash;", "", "&auml;", "ä", "&ouml;", "ö",
"&uuml;", "ü", "&Auml;", "Ä", "&Ouml;", "Ö", "&Uuml;", "Ü", "&szlig;", "ß",
"&hellip;", "…",
).Replace(s)
s = wsRE.ReplaceAllString(s, " ")
s = nlRE.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}