Dashboard-E-Mails (cockpitOS)
Was
Alle Dashboard-Benutzer-E-Mails laufen über Resend mit einheitlichem SaaS-Layout. Keine Passwörter im Klartext — nur Einmal-Links für Einrichtung und Reset. Center-Website-Formulare (Kontakt, Vermietung, ILG Promotionfläche) nutzen eigene Templates in email.ts mit Center-Kontext (Name, Formular-Typ, Website-URL).
Kunden-Postfächer und Weiterleitungen (info@kunde.de) sind ein anderes System — siehe Center-Mail. Formulare senden weiter über Resend an diese Adressen.
Warum
Professioneller SaaS-Standard: Setup- und Reset-Links statt Passwort per E-Mail (Risiko durch Postfach-Zugriff, Weiterleitung, Logs, Support-Screenshots).
Wer ist betroffen
| Gruppe | Inhalt |
|---|---|
| Redaktion / Admins | Willkommen, Einladung, Rollenänderung, Account-Status, Admin-Reset |
| Neue Nutzer | Account einrichten über /auth/activate |
| Entwickler | Templates, API-Hooks, Token-Logik |
| Betrieb / IT | RESEND_API_KEY, NEXTAUTH_URL, Absender-Domain |
| Besucher | nicht betroffen (Center-Mails separat) |
Nutzer-Doku (Bedienung im Dashboard): Benutzer und Organisation.
Wo
Code
| Bereich | Pfad |
|---|---|
| Layout & Copyright | apps/dashboard/src/lib/email-layout.ts |
| Templates (15 Dashboard-Typen) | apps/dashboard/src/lib/dashboard-email-templates.ts |
| Willkommen — rollenabhängige Bullets | apps/dashboard/src/lib/welcome-email-content.ts |
| Digest-Cron-Logik | apps/dashboard/src/lib/email-digest.ts |
| Versand-Helfer | apps/dashboard/src/lib/email.ts |
| Token, URLs, Platzhalter-PW | apps/dashboard/src/lib/user-account-tokens.ts |
| User-Onboarding-Flows | apps/dashboard/src/lib/user-account-email.ts (sendSelfServicePasswordResetEmail) |
| Auth-Footer (Copyright + Version) | apps/dashboard/src/components/auth/cockpit-auth-footer.tsx, apps/dashboard/src/lib/cockpit-meta.ts, apps/dashboard/VERSION |
| UI Account einrichten / Reset | apps/dashboard/src/components/auth/account-password-form.tsx |
| Testskript | scripts/test-dashboard-emails.ts → pnpm test:dashboard-emails |
URLs (Dashboard-App)
| Pfad | Zweck |
|---|---|
/auth/activate?token=… | Neuer Account / Einladung — Passwort festlegen |
/auth/reset-password?token=… | Passwort vergessen oder Admin-Reset |
/auth/unlock?token=… | Account nach Login-Lockout entsperren |
/auth/signin?message=account-activated | Erfolg nach Einrichtung |
/auth/signin?message=password-reset-success | Erfolg nach Reset |
/mail-alias/verify?token=… | Externe Center-Mail-Weiterleitung bestätigen (kein Login) |
API (Auslöser)
| Endpoint | |
|---|---|
POST /api/users | Willkommen oder Einladung (invite: true); Response enthält emailSent: boolean |
POST /api/users/{userId}/reset-password | Admin-Reset-Link |
PUT /api/users/{userId} (newPassword) | Admin-Reset-Link (kein direktes Setzen mehr) |
PUT /api/users/{userId} (Rolle / isActive) | Rolle / Deaktiviert / Reaktiviert |
DELETE /api/users/{userId} | Account gelöscht |
POST /api/auth/forgot-password | Passwort vergessen (Self-Service; gleiche Token-/Resend-Pipeline wie Admin-Reset) |
POST /api/auth/reset-password | Bestätigung Passwort geändert (nach Token-Reset) |
POST /api/users/me/change-password | Bestätigung Passwort geändert (Self-Service) |
| Social-Workflow | SOCIAL_REVIEW_REQUEST (Freigabe-Anfrage an Prüfer) immer sofort; SOCIAL_REVIEW (abgelehnt / Publish-Fehler sofort; freigegeben nur sofort wenn Digest aus) |
POST /api/cron/email-digest | EMAIL_DIGEST (täglich, opt-in, nur bei Inhalt) |
GET/PATCH /api/users/me/notification-preferences | Digest ein/aus + Versandstunde |
POST /api/centers/{centerId}/mail-aliases (externes Ziel) | MAIL_ALIAS_VERIFY an die Zieladresse |
POST /api/centers/{centerId}/mail-aliases/{aliasId}/verify | dieselbe Mail erneut |
Token-Speicher: User.resetToken, User.resetTokenExpiry, User.accountTokenPurpose (setup | reset | unlock). Lockout: failedLoginAttempts, lockedUntil. Migration: packages/database/migrations/20260606120000_add_user_account_security_SAFE.sql (additiv).
Templates (vollständig)
| ID | Betreff-Kontext | Link / Gültigkeit |
|---|---|---|
PASSWORD_RESET | Passwort vergessen | /auth/reset-password — 24 h |
WELCOME | User angelegt | /auth/activate (purpose=setup) — 7 Tage; Bullets nach Rolle (Admin / Redaktion / CM-App / Read-only), optional CM-App-Hinweis |
USER_INVITATION | Einladung (invite: true) | /auth/activate (purpose=setup) — 7 Tage |
PASSWORD_CHANGED | Nach erfolgreichem Reset | — |
ACCOUNT_LOCKED | Nach 5 Fehlversuchen (Login) | /auth/unlock (24 h) |
ROLE_CHANGE | Rolle geändert | — |
PASSWORD_RESET_BY_ADMIN | Admin-Reset | /auth/reset-password — 24 h |
ACCOUNT_DEACTIVATED | Account deaktiviert | — |
ACCOUNT_REACTIVATED | Account reaktiviert | Link zur Anmeldung |
ACCOUNT_DELETED | Account gelöscht | — |
SOCIAL_REVIEW_REQUEST | Freigabe angefordert (Approver) | Link zum Social-Workflow |
SOCIAL_REVIEW | Freigabe-Ergebnis (Redakteur) | Dashboard-URLs zum Post |
CLIENT_REPORT_AVAILABLE | Monatsbericht bereit (extern) | Schreibgeschützter Share-Link |
EMAIL_DIGEST | Tägliche Redaktions-Zusammenfassung | Einstellungen + Dashboard |
MAIL_ALIAS_VERIFY | Externe Weiterleitung bestätigen (an das Ziel) | /mail-alias/verify — 7 Tage |
Willkommens-Mail nach Rolle
Ein Template (WELCOME), vier Zielgruppen über welcome-email-content.ts:
| Zielgruppe | Rollen (Auszug) | Inhalt |
|---|---|---|
| Admin | SUPER_ADMIN, CENTER_ADMIN | Organisation, Center, Analytics, Kanäle |
| Redaktion | ORG_MARKETING_MANAGER, CONTENT_EDITOR | Content, Social, Workflow, Medien |
| CM-App | CENTER_MANAGER, CM_ASSISTANT, FACILITY_*, TENANT | Vor-Ort-Aufgaben + Hinweis manager.cockpit-os.de |
| Read-only | VIEWER | Einsehen, zugewiesene Bereiche |
Redaktions-Digest
| Aspekt | Verhalten |
|---|---|
| Opt-in | User.emailDigestEnabled (Default false) |
| Zeitzone | Europe/Berlin, Stunde emailDigestHour (7, 8, 9, 10, 12, 15, 18) |
| Frequenz | Cron stündlich; Versand nur in gewählter Stunde, max. 1×/Tag (emailDigestLastSentAt) |
| Inhalt | Glocke (social_review_approved, social_new_comment), Zähler offene Social-Freigaben + Workflow-Entwürfe |
| Keine Duplikate | SOCIAL_REVIEW_REQUEST + rejected / publish_failed → immer Sofort-Mail; approved → Digest wenn aktiv, sonst Sofort |
| Leer | Kein Versand wenn nichts ansteht |
| Cron | POST /api/cron/email-digest — Auth Bearer CRON_SECRET; Query ?dryRun=1, ?userId= |
| Migration | packages/database/migrations/20260606140000_add_email_digest_prefs_SAFE.sql |
Login: Dashboard = E-Mail + Passwort (NextAuth Credentials). Kein Magic-Link-Login im Dashboard. Magic Links existieren nur für AgencyOS / WordPress-Integration.
Copyright: Footer © {Jahr} SawatzkiMühlenbruch GmbH (Platzhalter im Template, zur Laufzeit EMAIL_BRAND.year); Produktname im Header: cockpitOS.
So testen
RESEND_API_KEYinapps/dashboard/.env(oder.env.local)pnpm test:dashboard-emails— sendet 18 Template-Varianten anTEST_EMAIL_RECIPIENT(Default intern:sb@schickma.de), Betreff-Prefix[TEST](inkl. Digest, Social-Anfrage, Client Report, Willkommen CM)- Im Dashboard: Test-User unter Einstellungen → Benutzer & Rollen anlegen → Willkommens-Mail prüfen; schlägt Versand fehl, erscheint eine Warn-Toast, User bleibt angelegt (
emailSent: falsein API) - Link Account einrichten öffnen → Passwort setzen → Anmeldung mit Hinweis
- Passwort zurücksetzen in der User-Liste → 24h-Link, kein Klartext
- Optional: Anlegen mit Als Einladung versenden → Einladungs-Template
- Statische Verdrahtung (ohne DB/Versand):
node scripts/verify-dashboard-email-wiring.mjs - Digest dry-run:
pnpm test:email-digest— optionalDIGEST_USER_ID=…; Versand:DIGEST_USER_ID=… pnpm test:email-digest -- --send - Cron-Test remote:
pnpm test:email-digest -- --cron(benötigtCRON_SECRET)
Betrieb
| Variable | Zweck |
|---|---|
RESEND_API_KEY | Resend API (Pflicht für Versand) |
NEXTAUTH_URL | Basis-URL für alle Links (z. B. https://dashboard.cockpit-os.de) |
RESEND_FROM_EMAIL | Absender-Adresse (Default: noreply@mail.cockpit-os.de) |
RESEND_FROM_NAME | Anzeigename (Default: cockpitOS) |
RESEND_REPLY_TO | Reply-To für Nutzer-Antworten (Default: support@schickma.de) |
CRON_SECRET | Digest-Cron + andere Scheduled Jobs (GitHub Actions → Dashboard) |
Digest-Workflow: .github/workflows/email-digest.yml — Details: Cron-Setup.
Reply-To in Code: support@schickma.de (env: RESEND_REPLY_TO) — technische Anfragen an Saad Badr, Technischer Leiter. Absender bleibt noreply@mail.cockpit-os.de.
Resend: Versandregion EU (Ireland, eu-west-1) für alle Resend-Domains (u. a. mail.cockpit-os.de) — verifiziert. DPA unterzeichnet (docs/compliance/resend-dpa-signed.pdf). Metadaten/Logs können zusätzlich in den USA verarbeitet werden — in AVV/DSE dokumentieren (SCC).
Domain und DNS für Resend müssen beim Betrieb freigeschaltet sein; ohne gültigen Key schlagen Versand und Testskript fehl.
Tonfall
- Keine dekorativen Emojis in Betreff, Body oder Buttons — wirkt unprofessionell und „KI-generiert“.
- Sachliche Anrede, klare Handlungsaufforderung (Link-Button), neutraler Hinweis bei Fristen.
- UI-Feedback im Dashboard ebenfalls ohne Emoji (Toasts, Alerts) — siehe Projektregel
.cursor/rules/no-decorative-emojis.mdc.
Abgrenzung
| Bereich | Templates | Datei |
|---|---|---|
| Dashboard-Benutzer | dashboard-email-templates.ts | Zentrales Layout (email-layout.ts) |
| Social Freigabe angefordert | SOCIAL_REVIEW_REQUEST | Zentrales Layout |
| Monatsbericht (extern) | CLIENT_REPORT_AVAILABLE | Zentrales Layout |
| Center Kontakt / Vermietung | Inline in email.ts | Center-Kontext-Block (Center, Formular, Website-URL), Reply-To = Absender |
Center-Website-Formulare (Kontakt / Vermietung)
Versand über send-contact-inquiry bzw. send-rental-inquiry. Jede E-Mail enthält:
| Feld | Quelle |
|---|---|
| Center | ShoppingCenter.name |
| Formular | formKey → Label (z. B. kontakt, promotionflaeche, vermietung) |
| Website | Production-URL / Custom Domain / Legacy website (resolveInquiryEmailCenterWebsiteUrl) |
| Branding | Header in baseColor, Akzent/Links in accentColor, optional logo — Fallback dunkles Neutralgrau |
| Betreff (Mail) | ILG Kontakt: Besucher-Betreff; Promotion: „Promotionfläche mieten – {Center}“ |
| Reply-To | E-Mail des Formular-Absenders |
Hilfsmodule: inquiry-email-html.ts (buildBrandedInquiryEmailHtml), contact-inquiry-subject.ts. v0 sendet nur Rohdaten — kein eigenes HTML-Template.
Spam-Schutz (Kontaktformular)
Was: Junk-Mails aus öffentlichen Formularen werden serverseitig gefiltert, bevor Resend sendet. ILG-Formulare (Kontakt, Promotionfläche) haben zusätzlich ein unsichtbares Honeypot-Feld und Cloudflare Turnstile — wie MEC.
Warum: Bots füllen offene Formulare automatisch aus. Center-Manager bekamen dadurch vermehrt Junk im Posteingang.
Wer ist betroffen: Center-Betrieb (weniger Junk), Besucher (unverändertes Formular, ggf. kurzes Turnstile-Widget), Betrieb/IT (Env-Keys).
Wo: Website /kontakt (ILG) bzw. Promotion-Formulare. Versand: POST /api/kontakt (Center-Website) → POST …/send-contact-inquiry (Dashboard).
So testen
- ILG-Kontaktseite öffnen, Pflichtfelder ausfüllen, senden — Mail kommt an.
- Im DevTools das versteckte Feld
websitebefüllen und erneut senden — UI zeigt Erfolg, es geht keine Mail raus. - Nachricht mit drei oder mehr Links senden — ebenfalls keine Mail.
Betrieb: NEXT_PUBLIC_TURNSTILE_SITE_KEY (Center-Website, öffentlich) und TURNSTILE_SECRET_KEY (Center-Website, nur Server). Ohne Keys bleibt Turnstile aus (Dev); Honeypot und Link-Filter greifen trotzdem. Rate-Limit: 10 Anfragen / 15 Min. pro IP.
Nutzungsstatistik: Seitenaufrufe werden anonymisiert erfasst. Im Umami-Dashboard nach diesem Pfad filtern: /developer-guide/dashboard-emails