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
File Structure
Abschnitt betitelt „File Structure“Migrations (bestehend, angepasst)
Abschnitt betitelt „Migrations (bestehend, angepasst)“cms/backend/migrations/000002_accounts_and_sessions.up.sql— Singular-Rename, email-Spalte entfernencms/backend/migrations/000002_accounts_and_sessions.down.sql— Anpassen
Migration (neu)
Abschnitt betitelt „Migration (neu)“cms/backend/migrations/000003_geolocation_email_devices.up.sql— Alle neuen Tabellen + Seed-Datencms/backend/migrations/000003_geolocation_email_devices.down.sql— DROP
Geolocation Feature
Abschnitt betitelt „Geolocation Feature“cms/backend/internal/feature/geolocation/geolocation.go— Geolocation struct, LocationFromIP(), Start(), Shutdown()cms/backend/internal/feature/geolocation/geolocation_test.go— Testscms/backend/internal/feature/geolocation/maxmind_reader.go— Download, Laden, atomarer Swap, Croncms/backend/internal/feature/geolocation/errors.go— Sentinel Errors
Email Feature
Abschnitt betitelt „Email Feature“cms/backend/internal/feature/email/types.go— MessageType, TemplateType, MailContextcms/backend/internal/feature/email/renderer.go— Platzhalter-Ersetzung, Layout-Assemblycms/backend/internal/feature/email/renderer_test.go— Renderer Testscms/backend/internal/feature/email/template_service.go— 3-Ebenen-Lookupcms/backend/internal/feature/email/repository.go— Interfacescms/backend/internal/feature/email/jet_repository.go— Jet Implementierungcms/backend/internal/feature/email/errors.go— Sentinel Errorscms/backend/internal/feature/email/handler/handler.go— Handler Interfacecms/backend/internal/feature/email/handler/two_factor_code.go— 2FA-Handlercms/backend/internal/feature/email/handler/welcome.go— Welcome-Handlercms/backend/internal/feature/email/worker/preparation_worker.go— Preparation Goroutinecms/backend/internal/feature/email/worker/dispatch_worker.go— Dispatch Goroutinecms/backend/internal/feature/email/worker/mail_sender.go— SES/SMTP Abstraction
Auth Feature (bestehend, angepasst)
Abschnitt betitelt „Auth Feature (bestehend, angepasst)“cms/backend/internal/feature/auth/usecase.go— Device-Check, Geolocation, Session 30dcms/backend/internal/feature/auth/repository.go— Neue Interfaces (KnownDeviceRepository, EmailRepository)cms/backend/internal/feature/auth/jet_repository.go— Neue Jet Implementierungencms/backend/internal/feature/auth/request.go— DeviceToken in Request/Responsecms/backend/internal/feature/auth/errors.go— Neue Errors
HTTP Layer (bestehend, angepasst)
Abschnitt betitelt „HTTP Layer (bestehend, angepasst)“cms/backend/cmd/http/config.go— Neue Config-Felder (MaxMind, SES, SMTP)cms/backend/cmd/http/main.go— Geolocation + Email Workers startencms/backend/cmd/http/middleware.go— Device-Cookie-Validierungcms/backend/cmd/http/cookies.go— Device-Cookiecms/backend/cmd/http/handlers_auth.go— DeviceToken lesen/setzen
UseCase Layer (bestehend, angepasst)
Abschnitt betitelt „UseCase Layer (bestehend, angepasst)“cms/backend/internal/usecase/usecase.go— Email + Geolocation einbinden
Task 1: Migration 002 auf Singular umstellen
Abschnitt betitelt „Task 1: Migration 002 auf Singular umstellen“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:
tenants→tenantsites→siteaccounts→accountsessions→sessionlogin_attempts→login_attempttemplates→templatetemplate_fields→template_fieldpages→pagepage_contents→page_contentstorage→storage(schon Singular)messages→messageanalytics_events→analytics_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
git add -A && git commit -m "refactor: rename all tables to singular (PostgreSQL convention)"Task 2: Migration 003 — Neue Tabellen
Abschnitt betitelt „Task 2: Migration 003 — Neue Tabellen“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:
emailTabelle +idx_email_hashUnique Index + Triggersemail_gdpr_complianceTabelle + Triggersemail_relation_typeEnum +email_relationTabelle + Indizes + Triggersemail_templateTabelle + Unique Index + Triggersemail_translation_keyTabelle + Unique Index + Triggerssystem_message_typeEnum +system_messageTabelle + Indizes + Triggerssystem_message_relationTabelle + Indizes + TriggersgeolocationTabelle + Index + Triggersknown_deviceTabelle + Indizes + Triggers- Admin-Email-Seed:
emailEintrag fueraleksandar.damjanovic@traffino.com+email_relation(type:account_primary, relation_id = Admin-Account-ID) - System-Default
email_templateEintraege:layout,signature,two_factor_code,password_reset,contact_notification,contact_confirmation,welcome— mit einfachem funktionalem HTML - System-Default
email_translation_keyEintraege: 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
git add -A && git commit -m "feat(db): add email, geolocation, system_message, known_device tables with seed data"Task 3: Geolocation Feature
Abschnitt betitelt „Task 3: Geolocation Feature“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
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
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_templateWHERE 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 ENDLIMIT 1Gleiche 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
git add -A && git commit -m "feat(email): add repository interfaces, jet implementation, template service"Task 6: E-Mail Feature — Handler + Workers
Abschnitt betitelt „Task 6: E-Mail Feature — Handler + Workers“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 SDKSMTPMailSender— nutztnet/smtpfuer 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
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:
-
KnownDeviceRepositorymitFindByToken(),Create(),UpdateLastUsed(),ExistsForAccount() -
FindByEmail()imAccountRepositoryanpassen: JOIN ueberemail_relation+emailstattaccount.email -
Step 2: jet_repository.go — Implementierungen
-
FindByEmail(): JOIN ueberemail_relation(type:account_primary) →email→WHERE e.hash = sha256($1) -
KnownDeviceRepositoryImplementierung mit Jet Queries -
CreateGeolocation()Helper: erstelltgeolocationEintrag 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:
- Rate-Limiting (unveraendert)
- Account per Email-Relation suchen (statt
account.email) - Passwort pruefen
- 2FA-Check:
twofactor_email == false→ Session (30 Tage), fertig- Device-Token vorhanden → DB-Lookup → bekannt →
last_used_atupdate → Session, kein 2FA - Sonst: Geolocation-Lookup → Code generieren → LoginAttempt + Geolocation erstellen → Response mit
twoFARequired
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
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():
geolocation.New(cfg.MaxmindPath, logger)erstellengeo.Start(ctx)aufrufendefer geo.Shutdown()- MailSender erstellen (SMTP wenn
cfg.SMTPHostgesetzt, sonst SES) - Email-Workers erstellen und als Goroutines starten
- 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
git add -A && git commit -m "feat: wire geolocation, email workers, device cookies, middleware"Task 9: Architektur-Dokumentation aktualisieren
Abschnitt betitelt „Task 9: Architektur-Dokumentation aktualisieren“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
git add -A && git commit -m "docs: update architecture and stufe-1 progress for geolocation, email, 2FA"