chahinebrini bdfcc40a6c feat(auth-hook): send-email hook for dynamic sender-name + i18n subject
GoTrue-Send-Email-Hook (v2.155+) → backend rendert Mails selbst, schickt
via Brevo Transactional API. Vorteil: pro Mail-Typ × Locale eigener
Sender-Display-Name UND Subject (GoTrue's eingebauter Mailer ist global
statisch).

Files:
- backend/server/utils/mail/templates.ts — 5 mail-types × 4 locales
  Sender-Name, Subject, Title, Body, HelpText, Privacy-Label.
  HTML-Renderer mit Nunito + Icon + DSGVO-Footer (analog public/templates).
- backend/server/utils/mail/brevo.ts — Brevo Transactional API client
  (POST /v3/smtp/email, separate REST-API-Key vom SMTP-Key)
- backend/server/api/auth-hooks/send-email.post.ts — Endpoint mit
  Standard-Webhooks Signature-Verify (HMAC-SHA256, timing-safe compare,
  multi-secret-rotation support)
- backend/nitro.config.ts — runtimeConfig: brevoApiKey,
  hookSendEmailSecrets, mailSenderEmail

Requires (außerhalb dieses commits):
- Infisical staging: BREVO_API_KEY (✓), HOOK_SEND_EMAIL_SECRETS (✓)
- docker-compose: GOTRUE_HOOK_SEND_EMAIL_{ENABLED,URI,SECRETS} (✓)
- GoTrue upgrade v2.154 → v2.189 (✓, mit auth.factor_type owner-transfer)
2026-05-19 18:04:14 +02:00

300 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Mail-Templates für Supabase Auth-Hook (Send-Email-Hook).
// GoTrue posted Hook-Body → wir rendern Subject + SenderName + HTML
// pro Mail-Typ × Locale, dann via Brevo Transactional API versendet.
//
// Vorteil vs Public-HTML-Templates: dynamic Sender-Name + dynamic Subject
// (GoTrue's eingebaute Mail-Config ist global statisch).
export type EmailActionType =
| 'signup'
| 'recovery'
| 'magiclink'
| 'invite'
| 'email_change';
export type Locale = 'de' | 'en' | 'fr' | 'ar';
const SUPPORTED_LOCALES: Locale[] = ['de', 'en', 'fr', 'ar'];
export function normalizeLocale(input: string | undefined | null): Locale {
if (!input) return 'en';
const short = String(input).split('-')[0].toLowerCase();
return (SUPPORTED_LOCALES as readonly string[]).includes(short)
? (short as Locale)
: 'en';
}
interface Texts {
senderName: string;
subject: string;
title: string;
body: string;
helpText: string;
privacyLabel: string;
}
const TEXTS: Record<EmailActionType, Record<Locale, Texts>> = {
signup: {
de: {
senderName: 'Willkommen bei ReBreak',
subject: 'Bestätige deine E-Mail ReBreak',
title: 'Willkommen bei ReBreak',
body: 'Gib diesen Code in der App ein, um deine E-Mail-Adresse zu bestätigen:',
helpText: 'Falls du dich nicht bei ReBreak registriert hast, kannst du diese E-Mail ignorieren.',
privacyLabel: 'Datenschutz',
},
en: {
senderName: 'Welcome to ReBreak',
subject: 'Confirm your email ReBreak',
title: 'Welcome to ReBreak',
body: 'Enter this code in the app to confirm your email address:',
helpText: "If you didn't sign up for ReBreak, you can safely ignore this email.",
privacyLabel: 'Privacy',
},
fr: {
senderName: 'Bienvenue sur ReBreak',
subject: 'Confirme ton e-mail ReBreak',
title: 'Bienvenue sur ReBreak',
body: "Saisis ce code dans l'application pour confirmer ton adresse e-mail :",
helpText: "Si tu n'as pas créé de compte ReBreak, tu peux ignorer ce message.",
privacyLabel: 'Confidentialité',
},
ar: {
senderName: 'مرحبًا بك في ReBreak',
subject: 'تأكيد البريد الإلكتروني ReBreak',
title: 'مرحبًا بك في ReBreak',
body: 'أدخل هذا الرمز في التطبيق لتأكيد بريدك الإلكتروني:',
helpText: 'إذا لم تقم بإنشاء حساب على ReBreak، يمكنك تجاهل هذه الرسالة.',
privacyLabel: 'الخصوصية',
},
},
recovery: {
de: {
senderName: 'ReBreak Passwort-Reset',
subject: 'Passwort zurücksetzen ReBreak',
title: 'Passwort zurücksetzen',
body: 'Gib diesen Code in der App ein, um ein neues Passwort zu setzen:',
helpText: 'Falls du das nicht angefordert hast, kannst du diese E-Mail ignorieren — dein Passwort bleibt unverändert.',
privacyLabel: 'Datenschutz',
},
en: {
senderName: 'ReBreak Password Reset',
subject: 'Reset your password ReBreak',
title: 'Reset your password',
body: 'Enter this code in the app to set a new password:',
helpText: "If you didn't request this, you can ignore this email — your password stays the same.",
privacyLabel: 'Privacy',
},
fr: {
senderName: 'Réinitialisation ReBreak',
subject: 'Réinitialise ton mot de passe ReBreak',
title: 'Réinitialise ton mot de passe',
body: "Saisis ce code dans l'application pour définir un nouveau mot de passe :",
helpText: "Si tu n'as pas demandé cette réinitialisation, ignore ce message — ton mot de passe reste inchangé.",
privacyLabel: 'Confidentialité',
},
ar: {
senderName: 'إعادة تعيين كلمة المرور ReBreak',
subject: 'إعادة تعيين كلمة المرور ReBreak',
title: 'إعادة تعيين كلمة المرور',
body: 'أدخل هذا الرمز في التطبيق لتعيين كلمة مرور جديدة:',
helpText: 'إذا لم تطلب إعادة التعيين، تجاهل هذه الرسالة — لن تتغير كلمة المرور.',
privacyLabel: 'الخصوصية',
},
},
magiclink: {
de: {
senderName: 'ReBreak Login',
subject: 'Dein Login-Code ReBreak',
title: 'Dein Login-Code',
body: 'Gib diesen Code in der App ein, um dich anzumelden:',
helpText: 'Falls du keinen Login angefordert hast, kannst du diese E-Mail ignorieren.',
privacyLabel: 'Datenschutz',
},
en: {
senderName: 'ReBreak Sign-In',
subject: 'Your sign-in code ReBreak',
title: 'Your sign-in code',
body: 'Enter this code in the app to sign in:',
helpText: "If you didn't request a sign-in, you can ignore this email.",
privacyLabel: 'Privacy',
},
fr: {
senderName: 'Connexion ReBreak',
subject: 'Ton code de connexion ReBreak',
title: 'Ton code de connexion',
body: "Saisis ce code dans l'application pour te connecter :",
helpText: "Si tu n'as pas demandé à te connecter, ignore ce message.",
privacyLabel: 'Confidentialité',
},
ar: {
senderName: 'تسجيل الدخول ReBreak',
subject: 'رمز تسجيل الدخول ReBreak',
title: 'رمز تسجيل الدخول',
body: 'أدخل هذا الرمز في التطبيق لتسجيل الدخول:',
helpText: 'إذا لم تطلب تسجيل الدخول، يمكنك تجاهل هذه الرسالة.',
privacyLabel: 'الخصوصية',
},
},
invite: {
de: {
senderName: 'ReBreak Einladung',
subject: 'Du wurdest eingeladen ReBreak',
title: 'Du wurdest zu ReBreak eingeladen',
body: 'Verwende diesen Code in der App, um deinen Account zu aktivieren:',
helpText: 'Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.',
privacyLabel: 'Datenschutz',
},
en: {
senderName: 'ReBreak Invite',
subject: "You've been invited ReBreak",
title: "You've been invited to ReBreak",
body: 'Use this code in the app to activate your account:',
helpText: "If you weren't expecting this invite, you can ignore this email.",
privacyLabel: 'Privacy',
},
fr: {
senderName: 'Invitation ReBreak',
subject: "Tu as été invité·e ReBreak",
title: 'Tu as été invité·e sur ReBreak',
body: "Utilise ce code dans l'application pour activer ton compte :",
helpText: "Si tu n'attendais pas cette invitation, tu peux ignorer ce message.",
privacyLabel: 'Confidentialité',
},
ar: {
senderName: 'دعوة ReBreak',
subject: 'تمت دعوتك ReBreak',
title: 'تمت دعوتك إلى ReBreak',
body: 'استخدم هذا الرمز في التطبيق لتفعيل حسابك:',
helpText: 'إذا لم تكن تتوقع هذه الدعوة، يمكنك تجاهل هذه الرسالة.',
privacyLabel: 'الخصوصية',
},
},
email_change: {
de: {
senderName: 'ReBreak E-Mail-Änderung',
subject: 'E-Mail-Adresse ändern ReBreak',
title: 'E-Mail-Adresse ändern',
body: 'Gib diesen Code in der App ein, um die Änderung deiner E-Mail-Adresse zu bestätigen:',
helpText: 'Falls du diese Änderung nicht angefordert hast, kannst du diese E-Mail ignorieren — deine alte E-Mail-Adresse bleibt erhalten.',
privacyLabel: 'Datenschutz',
},
en: {
senderName: 'ReBreak Email Change',
subject: 'Change your email ReBreak',
title: 'Change your email address',
body: 'Enter this code in the app to confirm your new email address:',
helpText: "If you didn't request this change, you can ignore this email — your current address stays the same.",
privacyLabel: 'Privacy',
},
fr: {
senderName: "Changement d'e-mail ReBreak",
subject: "Modifier ton e-mail ReBreak",
title: 'Modifier ton adresse e-mail',
body: "Saisis ce code dans l'application pour confirmer la modification de ton adresse e-mail :",
helpText: "Si tu n'as pas demandé ce changement, ignore ce message — ton adresse actuelle reste inchangée.",
privacyLabel: 'Confidentialité',
},
ar: {
senderName: 'تغيير البريد ReBreak',
subject: 'تغيير عنوان البريد ReBreak',
title: 'تغيير عنوان البريد الإلكتروني',
body: 'أدخل هذا الرمز في التطبيق لتأكيد تغيير عنوان بريدك الإلكتروني:',
helpText: 'إذا لم تطلب هذا التغيير، يمكنك تجاهل هذه الرسالة — سيبقى بريدك الحالي دون تغيير.',
privacyLabel: 'الخصوصية',
},
},
};
const ICON_URL = 'https://api.staging.rebreak.org/email-assets/icon.png';
const MARKETING_URL = 'https://rebreak.org';
const PRIVACY_URL = 'https://rebreak.org/datenschutz';
const FONT_FAMILY =
"'Nunito','Nunito Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif";
function htmlEscape(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
export interface RenderedEmail {
subject: string;
senderName: string;
html: string;
}
export function renderEmail(
action: EmailActionType,
locale: Locale,
token: string,
): RenderedEmail {
const t = TEXTS[action][locale];
const isRtl = locale === 'ar';
const dir = isRtl ? 'rtl' : 'ltr';
const textAlign = isRtl ? 'right' : 'left';
const safeToken = htmlEscape(token);
const html = `<!DOCTYPE html>
<html lang="${locale}" dir="${dir}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ReBreak</title>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<style>
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap');
body, h1, h2, p, div, a, span { font-family: ${FONT_FAMILY}; }
</style>
</head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:${FONT_FAMILY};color:#0a0a0a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;background:#ffffff;border-radius:16px;overflow:hidden;">
<tr><td style="padding:24px 24px;background:#0a0a0a;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="52" align="left" valign="middle" style="padding-right:16px;">
<img src="${ICON_URL}" width="48" height="48" alt="ReBreak" style="display:block;border-radius:12px;">
</td>
<td align="center" valign="middle">
<div style="color:#ffffff;font-size:24px;font-weight:700;letter-spacing:-0.5px;line-height:1;font-family:${FONT_FAMILY};">Re<span style="color:#f59e0b;">B</span>reak</div>
</td>
<td width="52" valign="middle">&nbsp;</td>
</tr>
</table>
</td></tr>
<tr><td style="padding:32px 24px;">
<h1 style="font-size:22px;font-weight:700;color:#0a0a0a;margin:0 0 12px;${isRtl ? 'text-align:right;' : ''}font-family:${FONT_FAMILY};">${t.title}</h1>
<p style="font-size:16px;line-height:24px;color:#525252;margin:0 0 24px;${isRtl ? 'text-align:right;' : ''}font-family:${FONT_FAMILY};">${t.body}</p>
<div style="background:#fafafa;border:1px solid #e5e5e5;border-radius:12px;padding:24px 16px;text-align:center;margin:0 0 24px;">
<div style="font-size:36px;font-weight:700;letter-spacing:10px;color:#f59e0b;font-family:'SF Mono','Menlo','Courier New',monospace;line-height:1;">${safeToken}</div>
</div>
<p style="font-size:13px;line-height:20px;color:#a3a3a3;margin:0;${isRtl ? 'text-align:right;' : ''}font-family:${FONT_FAMILY};">${t.helpText}</p>
</td></tr>
<tr><td align="center" style="padding:24px 24px 28px;background:#fafafa;border-top:1px solid #f0f0f0;">
<p style="font-size:12px;line-height:18px;color:#737373;margin:0 0 6px;font-family:${FONT_FAMILY};">
<a href="${MARKETING_URL}" style="color:#525252;text-decoration:none;font-family:${FONT_FAMILY};">rebreak.org</a>
&nbsp;·&nbsp;
<a href="${PRIVACY_URL}" style="color:#525252;text-decoration:none;font-family:${FONT_FAMILY};">${t.privacyLabel}</a>
</p>
<p style="font-size:11px;color:#a3a3a3;margin:0;font-family:${FONT_FAMILY};">© ReBreak</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
return { subject: t.subject, senderName: t.senderName, html };
}