F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed m/patholo → mAi/paliad → m/paliad, but go.mod still declared `mgit.msbls.de/m/patholo` and every internal import echoed the pre-rebrand name. Sweep: - go.mod: module path → mgit.msbls.de/m/paliad - All *.go files: imports rewritten via sed - README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad - Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx, global.css Verified: go build/vet/test ./... clean, bun run build clean, no remaining mgit.msbls.de/m/patholo or mAi/paliad references outside docs that intentionally describe the rename history.
459 lines
16 KiB
Go
459 lines
16 KiB
Go
// Package services — EmailTemplateService — manages the active and
|
||
// versioned email-template rows surfaced through /admin/email-templates.
|
||
//
|
||
// The service is intentionally tolerant of a nil DB: knowledge-platform-only
|
||
// deployments (no DATABASE_URL) still send invitations and reminders if
|
||
// SMTP is configured, falling back to the embedded per-language template
|
||
// files. Mutating methods (Save / Reset / RestoreVersion) require a DB and
|
||
// return ErrTemplateStoreUnavailable when called against a nil store.
|
||
//
|
||
// Active row precedence: a row in paliad.email_templates wins over the
|
||
// embedded default. Removing the row (Reset) restores the default. Saves
|
||
// also append to paliad.email_template_versions; the most recent
|
||
// EmailTemplateVersionRetention versions per (key, lang) are kept.
|
||
//
|
||
// See docs/design-email-templates-2026-04-29.md for the full design and
|
||
// the rationale for keeping subjects editable but seeded with explicit
|
||
// SLO-framing comments.
|
||
package services
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
htmltemplate "html/template"
|
||
"slices"
|
||
"strings"
|
||
texttemplate "text/template"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jmoiron/sqlx"
|
||
|
||
"mgit.msbls.de/m/paliad/internal/templates"
|
||
)
|
||
|
||
// Canonical template keys. The editor and seed loop iterate this list; new
|
||
// templates land by adding a key here and shipping a per-language body file
|
||
// under internal/templates/email/.
|
||
const (
|
||
EmailTemplateKeyInvitation = "invitation"
|
||
EmailTemplateKeyDeadlineDigest = "deadline_digest"
|
||
EmailTemplateKeyBase = "base"
|
||
)
|
||
|
||
// CanonicalEmailTemplateKeys is the closed set in canonical display order.
|
||
var CanonicalEmailTemplateKeys = []string{
|
||
EmailTemplateKeyInvitation,
|
||
EmailTemplateKeyDeadlineDigest,
|
||
EmailTemplateKeyBase,
|
||
}
|
||
|
||
// EmailTemplateVersionRetention caps the per-(key, lang) version history.
|
||
// Storage is negligible (3 keys × 2 langs × 20 = 120 rows steady-state) so
|
||
// 20 leaves enough headroom that the version a user wants to restore is
|
||
// almost always still around.
|
||
const EmailTemplateVersionRetention = 20
|
||
|
||
// EmailTemplateLanguages is the closed set of editor-supported languages.
|
||
var EmailTemplateLanguages = []string{"de", "en"}
|
||
|
||
// EmailTemplateRow is the active subject+body for one (key, lang). When the
|
||
// row came from the embedded fallback (no DB override) IsDefault is true and
|
||
// UpdatedAt / UpdatedBy are nil.
|
||
type EmailTemplateRow struct {
|
||
Key string `db:"key" json:"key"`
|
||
Lang string `db:"lang" json:"lang"`
|
||
Subject string `db:"subject" json:"subject"`
|
||
Body string `db:"body" json:"body"`
|
||
UpdatedAt *time.Time `db:"updated_at" json:"updated_at,omitempty"`
|
||
UpdatedBy *uuid.UUID `db:"updated_by" json:"updated_by,omitempty"`
|
||
IsDefault bool `db:"-" json:"is_default"`
|
||
}
|
||
|
||
// EmailTemplateVersionRow is one entry in the per-(key, lang) save log.
|
||
type EmailTemplateVersionRow struct {
|
||
ID uuid.UUID `db:"id" json:"id"`
|
||
Key string `db:"key" json:"key"`
|
||
Lang string `db:"lang" json:"lang"`
|
||
Subject string `db:"subject" json:"subject"`
|
||
Body string `db:"body" json:"body"`
|
||
SavedAt time.Time `db:"saved_at" json:"saved_at"`
|
||
SavedBy *uuid.UUID `db:"saved_by" json:"saved_by,omitempty"`
|
||
Note string `db:"note" json:"note"`
|
||
}
|
||
|
||
// Sentinel errors so handlers can map cleanly to status codes.
|
||
var (
|
||
ErrTemplateUnknownKey = errors.New("unknown email template key")
|
||
ErrTemplateUnknownLang = errors.New("unknown email template language")
|
||
ErrTemplateBodySyntax = errors.New("template body has invalid syntax")
|
||
ErrTemplateSubjectSyntax = errors.New("template subject has invalid syntax")
|
||
ErrTemplateMissingContent = errors.New(`template body must contain {{define "content"}}…{{end}}`)
|
||
ErrTemplateMissingBaseBlock = errors.New(`base template must keep {{block "content" .}}{{end}}`)
|
||
ErrTemplateStoreUnavailable = errors.New("email template store unavailable (DATABASE_URL not set)")
|
||
ErrTemplateVersionNotFound = errors.New("email template version not found")
|
||
)
|
||
|
||
// EmailTemplateService is the read/write authority for active + versioned
|
||
// rows. db may be nil — see package docs.
|
||
type EmailTemplateService struct {
|
||
db *sqlx.DB
|
||
}
|
||
|
||
// NewEmailTemplateService accepts a possibly-nil DB. Callers can hand it the
|
||
// shared sqlx.DB pool or pass nil for fallback-only mode.
|
||
func NewEmailTemplateService(db *sqlx.DB) *EmailTemplateService {
|
||
return &EmailTemplateService{db: db}
|
||
}
|
||
|
||
// HasStore reports whether mutating operations will succeed. False during
|
||
// knowledge-platform-only deployments.
|
||
func (s *EmailTemplateService) HasStore() bool { return s != nil && s.db != nil }
|
||
|
||
// IsCanonicalKey reports whether key is in the editor's closed set.
|
||
func IsCanonicalKey(key string) bool {
|
||
return slices.Contains(CanonicalEmailTemplateKeys, key)
|
||
}
|
||
|
||
func canonicaliseLang(lang string) (string, error) {
|
||
switch lang {
|
||
case "":
|
||
return "de", nil
|
||
case "de", "en":
|
||
return lang, nil
|
||
default:
|
||
return "", ErrTemplateUnknownLang
|
||
}
|
||
}
|
||
|
||
// GetActive returns the active subject+body for (key, lang). DB row wins
|
||
// when present; the embedded default applies otherwise. Errors only when
|
||
// the key/lang are unknown or the embedded default is missing.
|
||
func (s *EmailTemplateService) GetActive(ctx context.Context, key, lang string) (EmailTemplateRow, error) {
|
||
if !IsCanonicalKey(key) {
|
||
return EmailTemplateRow{}, ErrTemplateUnknownKey
|
||
}
|
||
lang, err := canonicaliseLang(lang)
|
||
if err != nil {
|
||
return EmailTemplateRow{}, err
|
||
}
|
||
if s.HasStore() {
|
||
var row EmailTemplateRow
|
||
err := s.db.GetContext(ctx, &row, `
|
||
SELECT key, lang, subject, body, updated_at, updated_by
|
||
FROM paliad.email_templates
|
||
WHERE key = $1 AND lang = $2`, key, lang)
|
||
if err == nil {
|
||
return row, nil
|
||
}
|
||
if !errors.Is(err, sql.ErrNoRows) {
|
||
return EmailTemplateRow{}, fmt.Errorf("read email_templates: %w", err)
|
||
}
|
||
}
|
||
return embeddedDefault(key, lang)
|
||
}
|
||
|
||
// embeddedDefault returns the on-disk default for (key, lang). Subject
|
||
// defaults are Go consts (defaultSubjects) for two reasons: subjects are
|
||
// short, and the digest subject's conditional logic benefits from being
|
||
// colocated with the service that documents its intent.
|
||
func embeddedDefault(key, lang string) (EmailTemplateRow, error) {
|
||
body, err := readEmbeddedBody(key, lang)
|
||
if err != nil {
|
||
return EmailTemplateRow{}, err
|
||
}
|
||
return EmailTemplateRow{
|
||
Key: key,
|
||
Lang: lang,
|
||
Subject: defaultSubjects[key][lang],
|
||
Body: body,
|
||
IsDefault: true,
|
||
}, nil
|
||
}
|
||
|
||
func readEmbeddedBody(key, lang string) (string, error) {
|
||
path := fmt.Sprintf("email/%s.%s.html", key, lang)
|
||
data, err := templates.EmailFS.ReadFile(path)
|
||
if err != nil {
|
||
return "", fmt.Errorf("embedded body %s: %w", path, err)
|
||
}
|
||
return string(data), nil
|
||
}
|
||
|
||
// SaveInput is the payload for Save. Validation happens before any DB
|
||
// interaction so a typo is reported as 422 without a tx round-trip.
|
||
type SaveInput struct {
|
||
Key string
|
||
Lang string
|
||
Subject string
|
||
Body string
|
||
Note string // free-form admin annotation, optional
|
||
SavedBy uuid.UUID // uuid.Nil when actor is unknown — column is nullable
|
||
}
|
||
|
||
// Save validates and upserts the active row, appends a version, and GCs the
|
||
// version log to EmailTemplateVersionRetention. All in one transaction.
|
||
func (s *EmailTemplateService) Save(ctx context.Context, in SaveInput) (EmailTemplateVersionRow, error) {
|
||
if !s.HasStore() {
|
||
return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
|
||
}
|
||
if !IsCanonicalKey(in.Key) {
|
||
return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
|
||
}
|
||
lang, err := canonicaliseLang(in.Lang)
|
||
if err != nil {
|
||
return EmailTemplateVersionRow{}, err
|
||
}
|
||
in.Lang = lang
|
||
|
||
if err := ValidateTemplate(in.Key, in.Subject, in.Body); err != nil {
|
||
return EmailTemplateVersionRow{}, err
|
||
}
|
||
|
||
tx, err := s.db.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
return EmailTemplateVersionRow{}, fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
if _, err := tx.ExecContext(ctx, `
|
||
INSERT INTO paliad.email_templates (key, lang, subject, body, updated_at, updated_by)
|
||
VALUES ($1, $2, $3, $4, now(), $5)
|
||
ON CONFLICT (key, lang) DO UPDATE
|
||
SET subject = EXCLUDED.subject,
|
||
body = EXCLUDED.body,
|
||
updated_at = now(),
|
||
updated_by = EXCLUDED.updated_by`,
|
||
in.Key, in.Lang, in.Subject, in.Body, nullableUUID(in.SavedBy)); err != nil {
|
||
return EmailTemplateVersionRow{}, fmt.Errorf("upsert active: %w", err)
|
||
}
|
||
|
||
ver, err := insertVersion(ctx, tx, in.Key, in.Lang, in.Subject, in.Body, in.SavedBy, in.Note)
|
||
if err != nil {
|
||
return EmailTemplateVersionRow{}, err
|
||
}
|
||
if err := gcVersions(ctx, tx, in.Key, in.Lang); err != nil {
|
||
return EmailTemplateVersionRow{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return EmailTemplateVersionRow{}, fmt.Errorf("commit: %w", err)
|
||
}
|
||
return ver, nil
|
||
}
|
||
|
||
// Reset deletes the active row and appends a 'reset' version capturing the
|
||
// embedded default. Subsequent renders fall back to the embedded body.
|
||
func (s *EmailTemplateService) Reset(ctx context.Context, key, lang string, savedBy uuid.UUID) (EmailTemplateVersionRow, error) {
|
||
if !s.HasStore() {
|
||
return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
|
||
}
|
||
if !IsCanonicalKey(key) {
|
||
return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
|
||
}
|
||
lang, err := canonicaliseLang(lang)
|
||
if err != nil {
|
||
return EmailTemplateVersionRow{}, err
|
||
}
|
||
def, err := embeddedDefault(key, lang)
|
||
if err != nil {
|
||
return EmailTemplateVersionRow{}, err
|
||
}
|
||
tx, err := s.db.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
return EmailTemplateVersionRow{}, fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
if _, err := tx.ExecContext(ctx,
|
||
`DELETE FROM paliad.email_templates WHERE key = $1 AND lang = $2`,
|
||
key, lang); err != nil {
|
||
return EmailTemplateVersionRow{}, fmt.Errorf("delete active: %w", err)
|
||
}
|
||
ver, err := insertVersion(ctx, tx, key, lang, def.Subject, def.Body, savedBy, "reset")
|
||
if err != nil {
|
||
return EmailTemplateVersionRow{}, err
|
||
}
|
||
if err := gcVersions(ctx, tx, key, lang); err != nil {
|
||
return EmailTemplateVersionRow{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return EmailTemplateVersionRow{}, fmt.Errorf("commit: %w", err)
|
||
}
|
||
return ver, nil
|
||
}
|
||
|
||
// ListVersions returns the most recent EmailTemplateVersionRetention rows.
|
||
func (s *EmailTemplateService) ListVersions(ctx context.Context, key, lang string) ([]EmailTemplateVersionRow, error) {
|
||
if !s.HasStore() {
|
||
return nil, ErrTemplateStoreUnavailable
|
||
}
|
||
if !IsCanonicalKey(key) {
|
||
return nil, ErrTemplateUnknownKey
|
||
}
|
||
lang, err := canonicaliseLang(lang)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
rows := []EmailTemplateVersionRow{}
|
||
if err := s.db.SelectContext(ctx, &rows, `
|
||
SELECT id, key, lang, subject, body, saved_at, saved_by, note
|
||
FROM paliad.email_template_versions
|
||
WHERE key = $1 AND lang = $2
|
||
ORDER BY saved_at DESC
|
||
LIMIT $3`, key, lang, EmailTemplateVersionRetention); err != nil {
|
||
return nil, fmt.Errorf("list versions: %w", err)
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
// RestoreVersion copies a historical version back into the active row,
|
||
// appending a fresh version that records the restore source.
|
||
func (s *EmailTemplateService) RestoreVersion(ctx context.Context, key, lang string, versionID, savedBy uuid.UUID) (EmailTemplateVersionRow, error) {
|
||
if !s.HasStore() {
|
||
return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
|
||
}
|
||
if !IsCanonicalKey(key) {
|
||
return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
|
||
}
|
||
lang, err := canonicaliseLang(lang)
|
||
if err != nil {
|
||
return EmailTemplateVersionRow{}, err
|
||
}
|
||
var src EmailTemplateVersionRow
|
||
err = s.db.GetContext(ctx, &src, `
|
||
SELECT id, key, lang, subject, body, saved_at, saved_by, note
|
||
FROM paliad.email_template_versions
|
||
WHERE id = $1 AND key = $2 AND lang = $3`, versionID, key, lang)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return EmailTemplateVersionRow{}, ErrTemplateVersionNotFound
|
||
}
|
||
if err != nil {
|
||
return EmailTemplateVersionRow{}, fmt.Errorf("fetch version: %w", err)
|
||
}
|
||
return s.Save(ctx, SaveInput{
|
||
Key: key,
|
||
Lang: lang,
|
||
Subject: src.Subject,
|
||
Body: src.Body,
|
||
Note: fmt.Sprintf("restore from %s", versionID),
|
||
SavedBy: savedBy,
|
||
})
|
||
}
|
||
|
||
// ValidateTemplate checks subject + body against the templating engines.
|
||
// The structural checks ensure content templates re-define {{block "content"}}
|
||
// (otherwise the body silently vanishes inside the base wrapper) and that
|
||
// the base body still contains the {{block "content" .}} call (otherwise
|
||
// every email loses its body). Returns nil iff both are syntactically and
|
||
// structurally valid.
|
||
func ValidateTemplate(key, subject, body string) error {
|
||
if subject != "" {
|
||
if _, err := texttemplate.New("subject").Parse(subject); err != nil {
|
||
return fmt.Errorf("%w: %v", ErrTemplateSubjectSyntax, err)
|
||
}
|
||
}
|
||
if _, err := htmltemplate.New("body").Parse(body); err != nil {
|
||
return fmt.Errorf("%w: %v", ErrTemplateBodySyntax, err)
|
||
}
|
||
if key == EmailTemplateKeyBase {
|
||
if !strings.Contains(body, `block "content"`) {
|
||
return ErrTemplateMissingBaseBlock
|
||
}
|
||
} else {
|
||
if !strings.Contains(body, `define "content"`) {
|
||
return ErrTemplateMissingContent
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// --- internal helpers -------------------------------------------------------
|
||
|
||
func insertVersion(ctx context.Context, tx *sqlx.Tx, key, lang, subject, body string, savedBy uuid.UUID, note string) (EmailTemplateVersionRow, error) {
|
||
var ver EmailTemplateVersionRow
|
||
err := tx.GetContext(ctx, &ver, `
|
||
INSERT INTO paliad.email_template_versions (key, lang, subject, body, saved_by, note)
|
||
VALUES ($1, $2, $3, $4, $5, $6)
|
||
RETURNING id, key, lang, subject, body, saved_at, saved_by, note`,
|
||
key, lang, subject, body, nullableUUID(savedBy), note)
|
||
if err != nil {
|
||
return EmailTemplateVersionRow{}, fmt.Errorf("insert version: %w", err)
|
||
}
|
||
return ver, nil
|
||
}
|
||
|
||
func gcVersions(ctx context.Context, tx *sqlx.Tx, key, lang string) error {
|
||
if _, err := tx.ExecContext(ctx, `
|
||
DELETE FROM paliad.email_template_versions
|
||
WHERE key = $1 AND lang = $2
|
||
AND id NOT IN (
|
||
SELECT id FROM paliad.email_template_versions
|
||
WHERE key = $1 AND lang = $2
|
||
ORDER BY saved_at DESC
|
||
LIMIT $3
|
||
)`, key, lang, EmailTemplateVersionRetention); err != nil {
|
||
return fmt.Errorf("gc versions: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func nullableUUID(u uuid.UUID) any {
|
||
if u == uuid.Nil {
|
||
return nil
|
||
}
|
||
return u
|
||
}
|
||
|
||
// defaultSubjects is the embedded-fallback subject template per (key, lang).
|
||
// They're text/template (not html/template); subjects are plain strings,
|
||
// no HTML escaping required.
|
||
//
|
||
// Keep the SYSTEMAUSFALL / SYSTEM FAILURE phrasing — see
|
||
// docs/design-reminder-redesign-2026-04-28.md. Softening it weakens the
|
||
// zero-overdue SLO and the comment in the seed exists so the next admin
|
||
// who edits sees the rationale instead of pattern-matching the framing as
|
||
// noise.
|
||
var defaultSubjects = map[string]map[string]string{
|
||
EmailTemplateKeyInvitation: {
|
||
"de": `[Paliad] {{.InviterName}} lädt Sie zu Paliad ein`,
|
||
"en": `[Paliad] {{.InviterName}} invites you to Paliad`,
|
||
},
|
||
EmailTemplateKeyDeadlineDigest: {
|
||
"de": digestSubjectDE,
|
||
"en": digestSubjectEN,
|
||
},
|
||
EmailTemplateKeyBase: {"de": "", "en": ""},
|
||
}
|
||
|
||
const digestSubjectDE = `{{- /* keep the SYSTEMAUSFALL phrasing — see docs/design-reminder-redesign-2026-04-28.md */ -}}
|
||
{{- if and .IsEvening (gt .OverdueCount 0) -}}
|
||
[Paliad] SYSTEMAUSFALL: {{.OverdueCount}} überfällig — plus {{.DueTodayCount}} heute offen
|
||
{{- else if and .IsEvening (gt .DueTodayCount 0) -}}
|
||
[Paliad] DRINGEND — {{.DueTodayCount}} heute noch offen
|
||
{{- else if .IsEvening -}}
|
||
[Paliad] {{.OverdueCount}} überfällig
|
||
{{- else if gt .OverdueCount 0 -}}
|
||
[Paliad] ÜBERFÄLLIG: {{.OverdueCount}} — plus {{.OpenTotal}} weitere
|
||
{{- else if eq .OpenTotal 1 -}}
|
||
[Paliad] Frist-Erinnerung: 1 offen
|
||
{{- else -}}
|
||
[Paliad] Frist-Erinnerung: {{.OpenTotal}} offen
|
||
{{- end -}}`
|
||
|
||
const digestSubjectEN = `{{- /* keep the SYSTEM FAILURE phrasing — see docs/design-reminder-redesign-2026-04-28.md */ -}}
|
||
{{- if and .IsEvening (gt .OverdueCount 0) -}}
|
||
[Paliad] SYSTEM FAILURE: {{.OverdueCount}} overdue — plus {{.DueTodayCount}} still open today
|
||
{{- else if and .IsEvening (gt .DueTodayCount 0) -}}
|
||
[Paliad] URGENT — {{.DueTodayCount}} still open today
|
||
{{- else if .IsEvening -}}
|
||
[Paliad] {{.OverdueCount}} overdue
|
||
{{- else if gt .OverdueCount 0 -}}
|
||
[Paliad] OVERDUE: {{.OverdueCount}} — plus {{.OpenTotal}} more
|
||
{{- else if eq .OpenTotal 1 -}}
|
||
[Paliad] Deadline reminder: 1 open
|
||
{{- else -}}
|
||
[Paliad] Deadline reminder: {{.OpenTotal}} open
|
||
{{- end -}}`
|