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)
300 lines
13 KiB
TypeScript
300 lines
13 KiB
TypeScript
// 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
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"> </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>
|
||
·
|
||
<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 };
|
||
}
|