- 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.
337 lines
11 KiB
Go
337 lines
11 KiB
Go
// 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(
|
||
" ", " ", "&", "&", "<", "<", ">", ">", """, "\"",
|
||
"—", "—", "–", "–", "ä", "ä", "ö", "ö",
|
||
"ü", "ü", "Ä", "Ä", "Ö", "Ö", "Ü", "Ü", "ß", "ß",
|
||
"…", "…",
|
||
).Replace(s)
|
||
s = wsRE.ReplaceAllString(s, " ")
|
||
s = nlRE.ReplaceAllString(s, "\n\n")
|
||
return strings.TrimSpace(s)
|
||
}
|