Zum Inhalt springen

Geolocation, E-Mail-System & 2FA Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: MaxMind-Geolocation, DB-gestuetztes E-Mail-System mit Template-Hierarchie und asynchronem Versand, sowie 2FA mit Device-Erkennung implementieren.

Architecture: Drei aufeinander aufbauende Features: (1) DB-Migrationen als Grundlage, (2) Geolocation-Feature fuer IP-Aufloesung, (3) E-Mail-System mit Template-Rendering und async Workers, (4) 2FA-Erweiterung mit Known-Device-Cookie. Alles im bestehenden 3-Schichten-Modell (HTTP → UseCase → Feature).

Tech Stack: Go 1.24, Chi v5, Jet Code-Gen, pgx v5, golang-migrate, oschwald/maxminddb-golang, AWS SES v2 SDK, slog

Spec: docs/superpowers/specs/2026-04-09-geolocation-email-2fa-design.md


  • cms/backend/migrations/000002_accounts_and_sessions.up.sql — Singular-Rename, email-Spalte entfernen
  • cms/backend/migrations/000002_accounts_and_sessions.down.sql — Anpassen
  • cms/backend/migrations/000003_geolocation_email_devices.up.sql — Alle neuen Tabellen + Seed-Daten
  • cms/backend/migrations/000003_geolocation_email_devices.down.sql — DROP
  • cms/backend/internal/feature/geolocation/geolocation.go — Geolocation struct, LocationFromIP(), Start(), Shutdown()
  • cms/backend/internal/feature/geolocation/geolocation_test.go — Tests
  • cms/backend/internal/feature/geolocation/maxmind_reader.go — Download, Laden, atomarer Swap, Cron
  • cms/backend/internal/feature/geolocation/errors.go — Sentinel Errors
  • cms/backend/internal/feature/email/types.go — MessageType, TemplateType, MailContext
  • cms/backend/internal/feature/email/renderer.go — Platzhalter-Ersetzung, Layout-Assembly
  • cms/backend/internal/feature/email/renderer_test.go — Renderer Tests
  • cms/backend/internal/feature/email/template_service.go — 3-Ebenen-Lookup
  • cms/backend/internal/feature/email/repository.go — Interfaces
  • cms/backend/internal/feature/email/jet_repository.go — Jet Implementierung
  • cms/backend/internal/feature/email/errors.go — Sentinel Errors
  • cms/backend/internal/feature/email/handler/handler.go — Handler Interface
  • cms/backend/internal/feature/email/handler/two_factor_code.go — 2FA-Handler
  • cms/backend/internal/feature/email/handler/welcome.go — Welcome-Handler
  • cms/backend/internal/feature/email/worker/preparation_worker.go — Preparation Goroutine
  • cms/backend/internal/feature/email/worker/dispatch_worker.go — Dispatch Goroutine
  • cms/backend/internal/feature/email/worker/mail_sender.go — SES/SMTP Abstraction
  • cms/backend/internal/feature/auth/usecase.go — Device-Check, Geolocation, Session 30d
  • cms/backend/internal/feature/auth/repository.go — Neue Interfaces (KnownDeviceRepository, EmailRepository)
  • cms/backend/internal/feature/auth/jet_repository.go — Neue Jet Implementierungen
  • cms/backend/internal/feature/auth/request.go — DeviceToken in Request/Response
  • cms/backend/internal/feature/auth/errors.go — Neue Errors
  • cms/backend/cmd/http/config.go — Neue Config-Felder (MaxMind, SES, SMTP)
  • cms/backend/cmd/http/main.go — Geolocation + Email Workers starten
  • cms/backend/cmd/http/middleware.go — Device-Cookie-Validierung
  • cms/backend/cmd/http/cookies.go — Device-Cookie
  • cms/backend/cmd/http/handlers_auth.go — DeviceToken lesen/setzen
  • cms/backend/internal/usecase/usecase.go — Email + Geolocation einbinden

Files:

  • Modify: cms/backend/migrations/000002_accounts_and_sessions.up.sql

  • Modify: cms/backend/migrations/000002_accounts_and_sessions.down.sql

  • Step 1: Migration 002 up anpassen

Alle Tabellennamen Plural → Singular. account.email und login_attempt.email entfernen. twofactor_email = TRUE fuer Admin-User. Alle Index-Namen anpassen. Alle REFERENCES anpassen. Alle create_base_triggers() Aufrufe anpassen.

Konkret diese Renames:

  • tenantstenant
  • sitessite
  • accountsaccount
  • sessionssession
  • login_attemptslogin_attempt
  • templatestemplate
  • template_fieldstemplate_field
  • pagespage
  • page_contentspage_content
  • storagestorage (schon Singular)
  • messagesmessage
  • analytics_eventsanalytics_event

Aus account: email VARCHAR(255) NOT NULL entfernen, idx_accounts_email entfernen. Aus login_attempt: email VARCHAR(255) NOT NULL entfernen, idx_login_attempts_email entfernen. Admin-User INSERT: twofactor_email = TRUE, kein email-Feld.

  • Step 2: Migration 002 down anpassen

DROP-Statements auf Singular-Namen anpassen.

  • Step 3: Jet Code-Gen neu ausfuehren

Run: cd cms/backend && make generate

Jet Code wird sich aendern da Tabellennamen anders sind. Alle generierten Dateien werden aktualisiert.

  • Step 4: Go-Code an neue Tabellennamen anpassen

Jet-generierter Code aendert Struct-Namen. Alle Referenzen in internal/feature/auth/jet_repository.go, internal/usecase/usecase.go, cmd/http/handlers_auth.go etc. muessen angepasst werden. Der Compiler zeigt die Stellen.

Run: cd cms/backend && make build

Alle Compile-Fehler fixen bis der Build durchgeht.

  • Step 5: Tests ausfuehren

Run: cd cms/backend && make test Expected: PASS

  • Step 6: Commit
Terminal-Fenster
git add -A && git commit -m "refactor: rename all tables to singular (PostgreSQL convention)"

Files:

  • Create: cms/backend/migrations/000003_geolocation_email_devices.up.sql

  • Create: cms/backend/migrations/000003_geolocation_email_devices.down.sql

  • Step 1: Up-Migration schreiben

Enthaelt in dieser Reihenfolge:

  1. email Tabelle + idx_email_hash Unique Index + Triggers
  2. email_gdpr_compliance Tabelle + Triggers
  3. email_relation_type Enum + email_relation Tabelle + Indizes + Triggers
  4. email_template Tabelle + Unique Index + Triggers
  5. email_translation_key Tabelle + Unique Index + Triggers
  6. system_message_type Enum + system_message Tabelle + Indizes + Triggers
  7. system_message_relation Tabelle + Indizes + Triggers
  8. geolocation Tabelle + Index + Triggers
  9. known_device Tabelle + Indizes + Triggers
  10. Admin-Email-Seed: email Eintrag fuer aleksandar.damjanovic@traffino.com + email_relation (type: account_primary, relation_id = Admin-Account-ID)
  11. System-Default email_template Eintraege: layout, signature, two_factor_code, password_reset, contact_notification, contact_confirmation, welcome — mit einfachem funktionalem HTML
  12. System-Default email_translation_key Eintraege: Alle Keys aus der Spec, jeweils DE + EN

SQL exakt wie in der Spec definiert. FK-Referenzen auf account (Singular, aus Migration 002).

Fuer die Admin-Email-Seed: Account-ID per Subquery holen:

WITH admin AS (SELECT id FROM traffino.account WHERE display_name = 'Aleksandar Damjanovic')
INSERT INTO traffino.email (id, email, hash) VALUES (gen_random_uuid(), 'aleksandar.damjanovic@traffino.com', sha256('aleksandar.damjanovic@traffino.com'::bytea));

Fuer die Default-Templates: Einfaches HTML ohne MJML. Layout mit {{ content.template }} und {{ content.signature }} Platzhaltern. Content-Templates mit den jeweiligen {{ email.* }} Translation-Keys und {{ custom.* }} Variablen.

  • Step 2: Down-Migration schreiben

DROP in umgekehrter Reihenfolge. Enums mit DROP TYPE entfernen. Admin-Email-Seed rueckgaengig machen.

  • Step 3: Jet Code-Gen

Run: cd cms/backend && make generate

Neue Jet-Structs fuer alle neuen Tabellen.

  • Step 4: Commit
Terminal-Fenster
git add -A && git commit -m "feat(db): add email, geolocation, system_message, known_device tables with seed data"

Files:

  • Create: cms/backend/internal/feature/geolocation/errors.go

  • Create: cms/backend/internal/feature/geolocation/geolocation.go

  • Create: cms/backend/internal/feature/geolocation/geolocation_test.go

  • Create: cms/backend/internal/feature/geolocation/maxmind_reader.go

  • Step 1: errors.go

package geolocation
import "errors"
var ErrUnavailable = errors.New("geolocation: service unavailable")
  • Step 2: geolocation.go — Location struct + Display methods
package geolocation
import (
"strings"
)
type Location struct {
CityDe string
CityEn string
RegionDe string
RegionEn string
CountryDe string
CountryEn string
CountryCode string
}
func (l Location) DisplayDe() string {
return joinNonEmpty(l.CityDe, l.RegionDe, l.CountryDe)
}
func (l Location) DisplayEn() string {
return joinNonEmpty(l.CityEn, l.RegionEn, l.CountryEn)
}
func joinNonEmpty(parts ...string) string {
var result []string
for _, p := range parts {
if p != "" {
result = append(result, p)
}
}
return strings.Join(result, ", ")
}
  • Step 3: Tests fuer Location.Display
package geolocation
import "testing"
func TestLocation_DisplayDe(t *testing.T) {
loc := Location{
CityDe: "Berlin", RegionDe: "Brandenburg", CountryDe: "Deutschland",
CityEn: "Berlin", RegionEn: "Brandenburg", CountryEn: "Germany",
CountryCode: "de",
}
if got := loc.DisplayDe(); got != "Berlin, Brandenburg, Deutschland" {
t.Errorf("DisplayDe() = %q, want %q", got, "Berlin, Brandenburg, Deutschland")
}
if got := loc.DisplayEn(); got != "Berlin, Brandenburg, Germany" {
t.Errorf("DisplayEn() = %q, want %q", got, "Berlin, Brandenburg, Germany")
}
}
func TestLocation_DisplayDe_MissingParts(t *testing.T) {
loc := Location{CountryDe: "Deutschland", CountryEn: "Germany", CountryCode: "de"}
if got := loc.DisplayDe(); got != "Deutschland" {
t.Errorf("DisplayDe() = %q, want %q", got, "Deutschland")
}
}
  • Step 4: Tests ausfuehren

Run: cd cms/backend && go test ./internal/feature/geolocation/ -v Expected: PASS

  • Step 5: maxmind_reader.go — Download, Load, Swap, Cron
package geolocation
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/oschwald/maxminddb-golang"
)
const (
downloadURL = "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb"
dbFilename = "GeoLite2-City.mmdb"
)
type Geolocation struct {
reader atomic.Pointer[maxminddb.Reader]
dbPath string
log *slog.Logger
cancel context.CancelFunc
}
func New(dbDir string, log *slog.Logger) *Geolocation {
return &Geolocation{
dbPath: filepath.Join(dbDir, dbFilename),
log: log,
}
}
func (g *Geolocation) Start(ctx context.Context) {
if err := g.downloadAndLoad(); err != nil {
g.log.Warn("geolocation: initial load failed, trying local file", "error", err)
if err := g.loadFromDisk(); err != nil {
g.log.Warn("geolocation: no local file available, service disabled", "error", err)
return
}
}
cronCtx, cancel := context.WithCancel(ctx)
g.cancel = cancel
go g.cronReload(cronCtx)
}
func (g *Geolocation) Shutdown() {
if g.cancel != nil {
g.cancel()
}
if r := g.reader.Load(); r != nil {
r.Close()
}
}
func (g *Geolocation) LocationFromIP(ip string) (Location, error) {
r := g.reader.Load()
if r == nil {
return Location{}, ErrUnavailable
}
var record struct {
City struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"`
Subdivisions []struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"subdivisions"`
Country struct {
Names map[string]string `maxminddb:"names"`
ISOCode string `maxminddb:"iso_code"`
} `maxminddb:"country"`
}
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return Location{}, fmt.Errorf("geolocation: invalid IP %q", ip)
}
if err := r.Lookup(parsedIP, &record); err != nil {
return Location{}, fmt.Errorf("geolocation: lookup failed: %w", err)
}
loc := Location{
CityDe: record.City.Names["de"],
CityEn: record.City.Names["en"],
CountryDe: record.Country.Names["de"],
CountryEn: record.Country.Names["en"],
CountryCode: strings.ToLower(record.Country.ISOCode),
}
if len(record.Subdivisions) > 0 {
loc.RegionDe = record.Subdivisions[0].Names["de"]
loc.RegionEn = record.Subdivisions[0].Names["en"]
}
return loc, nil
}
func (g *Geolocation) cronReload(ctx context.Context) {
for {
now := time.Now().UTC()
next := time.Date(now.Year(), now.Month(), now.Day()+1, 3, 0, 0, 0, time.UTC)
if now.After(next) {
next = next.Add(24 * time.Hour)
}
select {
case <-ctx.Done():
return
case <-time.After(time.Until(next)):
g.log.Info("geolocation: starting scheduled reload")
if err := g.downloadAndLoad(); err != nil {
g.log.Error("geolocation: scheduled reload failed", "error", err)
} else {
g.log.Info("geolocation: scheduled reload complete")
}
}
}
}
func (g *Geolocation) downloadAndLoad() error {
if err := os.MkdirAll(filepath.Dir(g.dbPath), 0o755); err != nil {
return fmt.Errorf("create dir: %w", err)
}
resp, err := http.Get(downloadURL)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download: status %d", resp.StatusCode)
}
tmpFile := g.dbPath + ".tmp"
f, err := os.Create(tmpFile)
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(tmpFile)
return fmt.Errorf("write: %w", err)
}
f.Close()
if err := os.Rename(tmpFile, g.dbPath); err != nil {
os.Remove(tmpFile)
return fmt.Errorf("rename: %w", err)
}
return g.loadFromDisk()
}
func (g *Geolocation) loadFromDisk() error {
r, err := maxminddb.Open(g.dbPath)
if err != nil {
return fmt.Errorf("open mmdb: %w", err)
}
old := g.reader.Swap(r)
if old != nil {
old.Close()
}
g.log.Info("geolocation: database loaded", "path", g.dbPath)
return nil
}

Hinweis: net Import muss ergaenzt werden (fehlt im Snippet, Compiler zeigt es).

  • Step 6: Build pruefen

Run: cd cms/backend && make build Expected: BUILD SUCCESS

  • Step 7: Commit
Terminal-Fenster
git add -A && git commit -m "feat: add geolocation feature with MaxMind GeoLite2 reader"

Task 4: E-Mail Feature — Types, Renderer, Errors

Abschnitt betitelt „Task 4: E-Mail Feature — Types, Renderer, Errors“

Files:

  • Create: cms/backend/internal/feature/email/types.go

  • Create: cms/backend/internal/feature/email/errors.go

  • Create: cms/backend/internal/feature/email/renderer.go

  • Create: cms/backend/internal/feature/email/renderer_test.go

  • Step 1: types.go

package email
type MessageType string
const (
MessageTypeTwoFactorCode MessageType = "two_factor_code"
MessageTypePasswordReset MessageType = "password_reset"
MessageTypeContactNotification MessageType = "contact_notification"
MessageTypeContactConfirmation MessageType = "contact_confirmation"
MessageTypeWelcome MessageType = "welcome"
)
type TemplateType string
const (
TemplateLayout TemplateType = "layout"
TemplateSignature TemplateType = "signature"
TemplateTwoFactorCode TemplateType = "two_factor_code"
TemplatePasswordReset TemplateType = "password_reset"
TemplateContactNotification TemplateType = "contact_notification"
TemplateContactConfirmation TemplateType = "contact_confirmation"
TemplateWelcome TemplateType = "welcome"
)
// MailContext holds all variables for template rendering.
type MailContext struct {
Locale string // "de" or "en"
TemplateType TemplateType // Which content template to use
TenantID *uuid.UUID // For 3-level lookup (nullable)
SiteID *uuid.UUID // For 3-level lookup (nullable)
CustomVariables map[string]string // custom.* variables from the UseCase
}
  • Step 2: errors.go
package email
import "errors"
var (
ErrTemplateNotFound = errors.New("email: template not found")
ErrMissingPlaceholder = errors.New("email: missing placeholder key")
ErrRenderFailed = errors.New("email: render failed")
)
  • Step 3: renderer.go — Platzhalter-Ersetzung
package email
import (
"fmt"
"regexp"
"strings"
)
var placeholderRegex = regexp.MustCompile(`\{\{\s*([^}]+?)\s*\}\}`)
// Render replaces all {{ key }} placeholders in the template with values from the map.
// Returns error if any placeholder key is not found in the map (hard error).
func Render(template string, variables map[string]string) (string, error) {
var missingKeys []string
result := placeholderRegex.ReplaceAllStringFunc(template, func(match string) string {
submatch := placeholderRegex.FindStringSubmatch(match)
if len(submatch) < 2 {
return match
}
key := strings.TrimSpace(submatch[1])
if val, ok := variables[key]; ok {
return val
}
missingKeys = append(missingKeys, key)
return match
})
if len(missingKeys) > 0 {
return "", fmt.Errorf("%w: %s", ErrMissingPlaceholder, strings.Join(missingKeys, ", "))
}
return result, nil
}
// RenderRecursive runs Render twice to resolve placeholders within already-inserted content.
func RenderRecursive(template string, variables map[string]string) (string, error) {
first, err := Render(template, variables)
if err != nil {
return "", err
}
return Render(first, variables)
}
// AssembleEmail builds the full email from layout, content template, and signature.
func AssembleEmail(layout, content, signature string, variables map[string]string) (string, error) {
// Phase 1: Insert content and signature into layout
vars := make(map[string]string, len(variables)+2)
for k, v := range variables {
vars[k] = v
}
vars["content.template"] = content
vars["content.signature"] = signature
// Phase 2: Replace all placeholders (recursive for nested placeholders)
return RenderRecursive(layout, vars)
}
  • Step 4: renderer_test.go
package email
import "testing"
func TestRender_Simple(t *testing.T) {
result, err := Render("Hallo {{ name }}", map[string]string{"name": "Max"})
if err != nil {
t.Fatal(err)
}
if result != "Hallo Max" {
t.Errorf("got %q, want %q", result, "Hallo Max")
}
}
func TestRender_MissingKey_HardError(t *testing.T) {
_, err := Render("Hallo {{ name }}", map[string]string{})
if err == nil {
t.Fatal("expected error for missing key")
}
if !errors.Is(err, ErrMissingPlaceholder) {
t.Errorf("got %v, want ErrMissingPlaceholder", err)
}
}
func TestRender_WhitespaceAroundKey(t *testing.T) {
result, err := Render("{{ name }}", map[string]string{"name": "Max"})
if err != nil {
t.Fatal(err)
}
if result != "Max" {
t.Errorf("got %q, want %q", result, "Max")
}
}
func TestAssembleEmail(t *testing.T) {
layout := "<html>{{ content.template }}{{ content.signature }}</html>"
content := "<p>{{ email.greeting }}</p>"
signature := "<p>Traffino</p>"
vars := map[string]string{"email.greeting": "Hallo Max"}
result, err := AssembleEmail(layout, content, signature, vars)
if err != nil {
t.Fatal(err)
}
expected := "<html><p>Hallo Max</p><p>Traffino</p></html>"
if result != expected {
t.Errorf("got %q, want %q", result, expected)
}
}
  • Step 5: Tests ausfuehren

Run: cd cms/backend && go test ./internal/feature/email/ -v Expected: PASS

  • Step 6: Commit
Terminal-Fenster
git add -A && git commit -m "feat(email): add types, renderer with placeholder replacement, tests"

Task 5: E-Mail Feature — Repository + Template Service

Abschnitt betitelt „Task 5: E-Mail Feature — Repository + Template Service“

Files:

  • Create: cms/backend/internal/feature/email/repository.go

  • Create: cms/backend/internal/feature/email/jet_repository.go

  • Create: cms/backend/internal/feature/email/template_service.go

  • Step 1: repository.go — Interfaces

package email
import (
"context"
"github.com/google/uuid"
)
type TemplateRepository interface {
// FindTemplate looks up a template by type with 3-level hierarchy (site → tenant → system).
FindTemplate(ctx context.Context, templateType TemplateType, tenantID, siteID *uuid.UUID) (string, error)
// FindTranslationKeys loads all translation keys for a locale with 3-level hierarchy.
FindTranslationKeys(ctx context.Context, locale string, tenantID, siteID *uuid.UUID) (map[string]string, error)
// FindSubjectKey returns the subject translation key for a template type.
FindSubjectKey(ctx context.Context, templateType TemplateType, tenantID, siteID *uuid.UUID) (string, error)
}
type SystemMessageRepository interface {
// CreateMessage inserts a new system_message with relations.
CreateMessage(ctx context.Context, msg SystemMessage, relationIDs []uuid.UUID) error
// FindPending returns messages ready for dispatch (sent_at IS NULL, sent_failed < 3).
FindPending(ctx context.Context, limit int) ([]SystemMessageWithEmail, error)
// MarkSent updates a message as sent.
MarkSent(ctx context.Context, id uuid.UUID) error
// MarkFailed updates a message as failed with error.
MarkFailed(ctx context.Context, id uuid.UUID, errMsg string) error
// ExistsForRelation checks if a system_message of given type exists for a relation_id.
ExistsForRelation(ctx context.Context, messageType MessageType, relationID uuid.UUID) (bool, error)
}
type EmailRelationRepository interface {
// FindByRelation finds an email_relation by relation_id and type.
FindByRelation(ctx context.Context, relationID uuid.UUID, relationType string) (*EmailRelation, error)
// FindEmailAddress resolves an email_relation_id to the actual email address.
FindEmailAddress(ctx context.Context, emailRelationID uuid.UUID) (string, error)
}
// SystemMessage represents a pending or sent email.
type SystemMessage struct {
ID uuid.UUID
MessageType MessageType
EmailRelationID uuid.UUID
Subject string
Content string
TenantID *uuid.UUID
SiteID *uuid.UUID
AccountID *uuid.UUID
}
// SystemMessageWithEmail includes the resolved email address for dispatch.
type SystemMessageWithEmail struct {
SystemMessage
RecipientEmail string
}
// EmailRelation represents a link between an email and an entity.
type EmailRelation struct {
ID uuid.UUID
EmailID uuid.UUID
Email string // Resolved email address
}
  • Step 2: jet_repository.go — Jet Implementierungen

Implementiert die drei Interfaces mit Jet-Queries. Die 3-Ebenen-Lookup-Query fuer Templates:

SELECT html_body FROM traffino.email_template
WHERE template_type = $1
AND (site_id = $2 OR site_id IS NULL)
AND (tenant_id = $3 OR tenant_id IS NULL)
ORDER BY
CASE WHEN site_id IS NOT NULL THEN 0 ELSE 1 END,
CASE WHEN tenant_id IS NOT NULL THEN 0 ELSE 1 END
LIMIT 1

Gleiche Logik fuer Translation-Keys: Site-spezifische Keys ueberschreiben Tenant-spezifische, die wiederum System-Defaults ueberschreiben.

  • Step 3: template_service.go — Orchestriert Rendering
package email
import (
"context"
"fmt"
"github.com/google/uuid"
)
type TemplateService struct {
templates TemplateRepository
}
func NewTemplateService(templates TemplateRepository) *TemplateService {
return &TemplateService{templates: templates}
}
// RenderEmail renders a complete email for the given context.
// Returns rendered subject and HTML body.
func (s *TemplateService) RenderEmail(ctx context.Context, mc MailContext) (subject string, body string, err error) {
// Load templates (3-level lookup each)
layout, err := s.templates.FindTemplate(ctx, TemplateLayout, mc.TenantID, mc.SiteID)
if err != nil {
return "", "", fmt.Errorf("load layout: %w", err)
}
content, err := s.templates.FindTemplate(ctx, mc.TemplateType, mc.TenantID, mc.SiteID)
if err != nil {
return "", "", fmt.Errorf("load content template %s: %w", mc.TemplateType, err)
}
signature, err := s.templates.FindTemplate(ctx, TemplateSignature, mc.TenantID, mc.SiteID)
if err != nil {
return "", "", fmt.Errorf("load signature: %w", err)
}
// Load translation keys
keys, err := s.templates.FindTranslationKeys(ctx, mc.Locale, mc.TenantID, mc.SiteID)
if err != nil {
return "", "", fmt.Errorf("load translations: %w", err)
}
// Merge translation keys + custom variables
variables := make(map[string]string, len(keys)+len(mc.CustomVariables))
for k, v := range keys {
variables[k] = v
}
for k, v := range mc.CustomVariables {
variables[k] = v
}
// Render subject
subjectKey, err := s.templates.FindSubjectKey(ctx, mc.TemplateType, mc.TenantID, mc.SiteID)
if err != nil {
return "", "", fmt.Errorf("find subject key: %w", err)
}
subjectTemplate, ok := variables[subjectKey]
if !ok {
return "", "", fmt.Errorf("%w: subject key %s", ErrMissingPlaceholder, subjectKey)
}
subject, err = Render(subjectTemplate, variables)
if err != nil {
return "", "", fmt.Errorf("render subject: %w", err)
}
// Render body
body, err = AssembleEmail(layout, content, signature, variables)
if err != nil {
return "", "", fmt.Errorf("render body: %w", err)
}
return subject, body, nil
}
  • Step 4: Build pruefen

Run: cd cms/backend && make build Expected: BUILD SUCCESS

  • Step 5: Commit
Terminal-Fenster
git add -A && git commit -m "feat(email): add repository interfaces, jet implementation, template service"

Files:

  • Create: cms/backend/internal/feature/email/handler/handler.go

  • Create: cms/backend/internal/feature/email/handler/two_factor_code.go

  • Create: cms/backend/internal/feature/email/handler/welcome.go

  • Create: cms/backend/internal/feature/email/worker/preparation_worker.go

  • Create: cms/backend/internal/feature/email/worker/dispatch_worker.go

  • Create: cms/backend/internal/feature/email/worker/mail_sender.go

  • Step 1: handler/handler.go — Interface

package handler
import "context"
// Handler checks for pending events that need an email and prepares them.
type Handler interface {
// Check queries for events that need an email and creates system_messages for them.
Check(ctx context.Context) error
}
  • Step 2: handler/two_factor_code.go

Queries login_attempt Tabelle:

  • twofactor_code IS NOT NULL

  • twofactor_verified_at IS NULL

  • NOT EXISTS (system_message_relation fuer diese login_attempt.id mit message_type = two_factor_code)

  • Laedt Account + Email-Relation + Geolocation

  • Baut MailContext mit Custom-Variablen (code, location, ip, device)

  • Ruft TemplateService.RenderEmail() auf

  • Erstellt system_message + system_message_relation

  • Step 3: handler/welcome.go

Queries account Tabelle:

  • created_at > (startup time oder letzter Check)

  • NOT EXISTS (system_message_relation fuer diese account.id mit message_type = welcome)

  • Baut MailContext mit Custom-Variablen (login_url, display_name)

  • Erstellt system_message + system_message_relation

  • Step 4: worker/mail_sender.go — SES/SMTP Abstraction

package worker
import "context"
type MailSender interface {
Send(ctx context.Context, to, subject, htmlBody string) error
}

Zwei Implementierungen:

  • SESMailSender — nutzt AWS SES v2 SDK
  • SMTPMailSender — nutzt net/smtp fuer lokale Entwicklung (MailHog)

Auswahl ueber Config: wenn SMTP_HOST gesetzt → SMTP, sonst SES.

  • Step 5: worker/preparation_worker.go
package worker
import (
"context"
"log/slog"
"time"
"github.com/traffino/cms/internal/feature/email/handler"
)
type PreparationWorker struct {
handlers []handler.Handler
log *slog.Logger
interval time.Duration
}
func NewPreparationWorker(handlers []handler.Handler, log *slog.Logger) *PreparationWorker {
return &PreparationWorker{handlers: handlers, log: log, interval: 10 * time.Second}
}
func (w *PreparationWorker) Start(ctx context.Context) {
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, h := range w.handlers {
if err := h.Check(ctx); err != nil {
w.log.Error("preparation handler failed", "error", err)
}
}
}
}
}
  • Step 6: worker/dispatch_worker.go

Alle 10s: FindPending() → fuer jede Mail: MailSender.Send()MarkSent() oder MarkFailed().

  • Step 7: Build pruefen

Run: cd cms/backend && make build Expected: BUILD SUCCESS

  • Step 8: Commit
Terminal-Fenster
git add -A && git commit -m "feat(email): add handlers, preparation worker, dispatch worker, SES/SMTP sender"

Task 7: Auth Feature — Device-Erkennung + Geolocation

Abschnitt betitelt „Task 7: Auth Feature — Device-Erkennung + Geolocation“

Files:

  • Modify: cms/backend/internal/feature/auth/repository.go

  • Modify: cms/backend/internal/feature/auth/jet_repository.go

  • Modify: cms/backend/internal/feature/auth/request.go

  • Modify: cms/backend/internal/feature/auth/usecase.go

  • Modify: cms/backend/internal/feature/auth/errors.go

  • Step 1: repository.go — Neue Interfaces

Hinzufuegen:

  • KnownDeviceRepository mit FindByToken(), Create(), UpdateLastUsed(), ExistsForAccount()

  • FindByEmail() im AccountRepository anpassen: JOIN ueber email_relation + email statt account.email

  • Step 2: jet_repository.go — Implementierungen

  • FindByEmail(): JOIN ueber email_relation (type: account_primary) → emailWHERE e.hash = sha256($1)

  • KnownDeviceRepository Implementierung mit Jet Queries

  • CreateGeolocation() Helper: erstellt geolocation Eintrag fuer eine gegebene relation_id

  • Step 3: request.go — DeviceToken

LoginRequest bekommt DeviceToken string (aus Cookie). LoginResponse bekommt DeviceToken *string (fuer neuen Cookie). Verify2FAResponse bekommt DeviceToken string.

  • Step 4: usecase.go — Login-Flow anpassen

Der Login() Methode wird Geolocation injected. Neuer Flow:

  1. Rate-Limiting (unveraendert)
  2. Account per Email-Relation suchen (statt account.email)
  3. Passwort pruefen
  4. 2FA-Check:
    • twofactor_email == false → Session (30 Tage), fertig
    • Device-Token vorhanden → DB-Lookup → bekannt → last_used_at update → Session, kein 2FA
    • Sonst: Geolocation-Lookup → Code generieren → LoginAttempt + Geolocation erstellen → Response mit twoFARequired
  5. Verify2FA(): Code pruefen → Session (30 Tage) + Known-Device + Geolocation erstellen → DeviceToken zurueckgeben

Session-Laufzeit von 24 * time.Hour auf 30 * 24 * time.Hour aendern.

  • Step 5: errors.go — ErrDeviceRevoked
var ErrDeviceRevoked = errors.New("auth: device token revoked")
  • Step 6: Build + Tests

Run: cd cms/backend && make build && make test Expected: PASS

  • Step 7: Commit
Terminal-Fenster
git add -A && git commit -m "feat(auth): device recognition, geolocation on login, session 30 days"

Task 8: HTTP Layer — Config, Middleware, Cookies, Wiring

Abschnitt betitelt „Task 8: HTTP Layer — Config, Middleware, Cookies, Wiring“

Files:

  • Modify: cms/backend/cmd/http/config.go

  • Modify: cms/backend/cmd/http/main.go

  • Modify: cms/backend/cmd/http/middleware.go

  • Modify: cms/backend/cmd/http/cookies.go

  • Modify: cms/backend/cmd/http/handlers_auth.go

  • Modify: cms/backend/internal/usecase/usecase.go

  • Step 1: config.go — Neue Felder

type Config struct {
Port int `envconfig:"PORT" default:"8080"`
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
MaxmindPath string `envconfig:"MAXMIND_PATH" default:".local/maxmind"`
AWSRegion string `envconfig:"AWS_REGION" default:"eu-central-1"`
SESFromEmail string `envconfig:"SES_FROM_EMAIL"`
SESFromName string `envconfig:"SES_FROM_NAME" default:"traffino"`
SMTPHost string `envconfig:"SMTP_HOST"`
SMTPPort int `envconfig:"SMTP_PORT" default:"1025"`
}
  • Step 2: main.go — Geolocation + Workers starten

In main():

  1. geolocation.New(cfg.MaxmindPath, logger) erstellen
  2. geo.Start(ctx) aufrufen
  3. defer geo.Shutdown()
  4. MailSender erstellen (SMTP wenn cfg.SMTPHost gesetzt, sonst SES)
  5. Email-Workers erstellen und als Goroutines starten
  6. UseCases mit Geolocation + Email verdrahten
  • Step 3: cookies.go — Device-Cookie
const DeviceCookieName = "device_token"
func SetDeviceCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: DeviceCookieName,
Value: token,
Path: "/",
MaxAge: 30 * 24 * 60 * 60, // 30 days
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
func GetDeviceCookie(r *http.Request) string {
c, err := r.Cookie(DeviceCookieName)
if err != nil {
return ""
}
return c.Value
}

Session-Cookie MaxAge ebenfalls auf 30 Tage anpassen.

  • Step 4: middleware.go — Device-Validierung

In der Auth-Middleware nach Session-Check:

deviceToken := GetDeviceCookie(r)
if deviceToken != "" {
exists, err := uc.Auth.KnownDeviceExists(ctx, accountInfo.AccountID, deviceToken)
if err != nil {
// Log but don't block
} else if !exists {
// Device was revoked — terminate session
_ = uc.Auth.Logout(ctx, rc, auth.LogoutRequest{SessionID: sessionID})
httputil.Error(w, http.StatusUnauthorized, "device revoked")
return
}
}
  • Step 5: handlers_auth.go — DeviceToken lesen/setzen

Im Login-Handler: DeviceToken aus Cookie lesen und in LoginRequest setzen. Im Verify2FA-Handler: DeviceToken aus Response als Cookie setzen.

  • Step 6: usecase.go — Wiring

UseCases struct erhaelt Geolocation und Email Worker-Referenzen. New() Funktion anpassen.

  • Step 7: Build + Tests

Run: cd cms/backend && make build && make test Expected: PASS

  • Step 8: Commit
Terminal-Fenster
git add -A && git commit -m "feat: wire geolocation, email workers, device cookies, middleware"

Files:

  • Modify: docs/src/content/docs/architektur/index.md

  • Modify: docs/src/content/docs/implementierung/stufe-1-uebersicht.md

  • Step 1: Architektur-Doku

In architektur/index.md neue Abschnitte hinzufuegen:

  • Geolocation — MaxMind Pattern, Tabelle, Integration
  • E-Mail-System — Architektur, Template-Hierarchie, Handler-Pattern
  • Device-Erkennung — Known-Device-Cookie, Security-Middleware

Datenmodell-Tabelle erweitern um: email, email_gdpr_compliance, email_relation, email_template, email_translation_key, system_message, system_message_relation, geolocation, known_device.

Hinweis zu Singular-Naming ergaenzen.

  • Step 2: Stufe-1-Fortschritt aktualisieren

In stufe-1-uebersicht.md:

  • “DB-Migrationen Singular-Rename” → Fertig

  • “Geolocation (MaxMind)” → Fertig

  • “E-Mail-System (Templates, Rendering, Queue)” → Fertig

  • “2FA Device-Erkennung” → Fertig

  • Entscheidungs-Tabelle erweitern

  • Step 3: Commit

Terminal-Fenster
git add -A && git commit -m "docs: update architecture and stufe-1 progress for geolocation, email, 2FA"