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)
This commit is contained in:
chahinebrini 2026-05-19 18:04:14 +02:00
parent a0dff80ced
commit bdfcc40a6c
4 changed files with 532 additions and 0 deletions

View File

@ -70,6 +70,19 @@ export default defineNitroConfig({
// ─── Email / External APIs ───────────────────────────────────────────
resendApiKey: process.env.RESEND_API_KEY ?? "",
// ─── Brevo (Mail-Versand) ────────────────────────────────────────────
// Auth-Hook (send-email.post.ts) → Brevo Transactional API.
// BREVO_API_KEY ist Brevo REST API-Key (Format: xkeysib-...),
// separater Key vom SMTP-Key der für GoTrue-Fallback genutzt wird.
brevoApiKey: process.env.BREVO_API_KEY ?? "",
// Send-Email-Hook Webhook-Secret (Standard-Webhooks Format).
// Liste comma-separated: "v1,whsec_<base64>" (multi-secret für Rotation).
// Muss IDENTISCH zu GOTRUE_HOOK_SEND_EMAIL_SECRETS im docker-compose sein.
hookSendEmailSecrets: process.env.HOOK_SEND_EMAIL_SECRETS ?? "",
// Sender-Email-Adresse im From-Header. Sender-Name wird pro Mail-Typ
// dynamisch in templates.ts gesetzt.
mailSenderEmail: process.env.MAIL_SENDER_EMAIL ?? "welcome@rebreak.org",
// ─── Microsoft OAuth (PKCE, Public Client) ───────────────────────────────
// Client-ID der Azure-App-Registrierung "Rebreak Mail Access".
// Tenant: 'common' (Multi-Tenant + Personal-Accounts) — hardcoded im Code.

View File

@ -0,0 +1,163 @@
// Supabase Send-Email-Hook receiver.
//
// GoTrue (v2.155+) postet hier rein wenn Mail-Versand getriggert wird
// (signup/recovery/magiclink/invite/email_change). Wir rendern selbst +
// senden via Brevo Transactional API — Vorteil: dynamic Sender-Name +
// Subject pro Mail-Typ × User-Locale (GoTrue's eingebauter Mailer ist
// global statisch).
//
// Config (im Compose): GOTRUE_HOOK_SEND_EMAIL_ENABLED=true,
// GOTRUE_HOOK_SEND_EMAIL_URI auf diesen Endpoint,
// GOTRUE_HOOK_SEND_EMAIL_SECRETS=v1,whsec_<base64>
//
// Verifikation: Standard-Webhooks-Signatur (HMAC-SHA256).
import { createHmac, timingSafeEqual } from 'node:crypto';
import {
renderEmail,
normalizeLocale,
type EmailActionType,
} from '../../utils/mail/templates';
import { sendBrevoMail } from '../../utils/mail/brevo';
interface HookBody {
user: {
id: string;
email: string;
user_metadata?: { locale?: string | null } | null;
};
email_data: {
token: string;
token_hash?: string;
redirect_to?: string;
email_action_type: string;
site_url?: string;
};
}
function verifySignature(
webhookId: string,
webhookTimestamp: string,
rawBody: string,
signatureHeader: string,
secretsCsv: string,
): boolean {
// secretsCsv can have multiple comma-separated entries "v1,whsec_X,v1,whsec_Y"
// Pairs: ["v1", "whsec_X"], ["v1", "whsec_Y"]
const parts = secretsCsv.split(',').map((s) => s.trim());
const secrets: string[] = [];
for (let i = 0; i + 1 < parts.length; i += 2) {
if (parts[i] === 'v1' && parts[i + 1].startsWith('whsec_')) {
secrets.push(parts[i + 1].slice(6));
}
}
if (secrets.length === 0) return false;
const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`;
// signatureHeader format: "v1,BASE64 [v1,BASE64 ...]"
const headerSigs = signatureHeader
.split(' ')
.map((s) => s.split(','))
.filter((pair) => pair[0] === 'v1' && pair[1])
.map((pair) => pair[1]);
for (const secret of secrets) {
const secretBytes = Buffer.from(secret, 'base64');
const expected = createHmac('sha256', secretBytes)
.update(signedContent)
.digest();
for (const headerSig of headerSigs) {
try {
const received = Buffer.from(headerSig, 'base64');
if (
received.length === expected.length &&
timingSafeEqual(received, expected)
) {
return true;
}
} catch {
// malformed base64, skip
}
}
}
return false;
}
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
// Read raw body for HMAC verification (must hash exact bytes GoTrue sent)
const rawBody = await readRawBody(event, 'utf8');
if (!rawBody) {
throw createError({ statusCode: 400, message: 'empty body' });
}
const webhookId = getHeader(event, 'webhook-id');
const webhookTimestamp = getHeader(event, 'webhook-timestamp');
const webhookSignature = getHeader(event, 'webhook-signature');
const secretsCsv = (config.hookSendEmailSecrets as string) || '';
if (!webhookId || !webhookTimestamp || !webhookSignature) {
throw createError({
statusCode: 400,
message: 'missing webhook-* headers',
});
}
if (!secretsCsv) {
throw createError({
statusCode: 500,
message: 'hookSendEmailSecrets not configured',
});
}
const ok = verifySignature(
webhookId,
webhookTimestamp,
rawBody,
webhookSignature,
secretsCsv,
);
if (!ok) {
throw createError({ statusCode: 401, message: 'invalid signature' });
}
const payload = JSON.parse(rawBody) as HookBody;
const action = payload.email_data.email_action_type as EmailActionType;
const validActions: EmailActionType[] = [
'signup',
'recovery',
'magiclink',
'invite',
'email_change',
];
if (!validActions.includes(action)) {
console.warn(
`[auth-hook/send-email] unknown action=${action}, falling back to signup`,
);
}
const locale = normalizeLocale(payload.user.user_metadata?.locale);
const rendered = renderEmail(
validActions.includes(action) ? action : 'signup',
locale,
payload.email_data.token,
);
const senderEmail = (config.mailSenderEmail as string) || 'welcome@rebreak.org';
await sendBrevoMail(event, {
to: payload.user.email,
subject: rendered.subject,
senderName: rendered.senderName,
senderEmail,
htmlContent: rendered.html,
});
console.log(
`[auth-hook/send-email] sent action=${action} locale=${locale} to=${payload.user.email}`,
);
return {};
});

View File

@ -0,0 +1,57 @@
// Brevo Transactional API Client.
// Genutzt vom Auth-Hook (send-email.post.ts) für dynamic Sender-Name +
// Subject pro Mail-Typ × Locale. Replaces GoTrue's eingebauten SMTP-Sender.
import type { H3Event } from 'h3';
const BREVO_API_URL = 'https://api.brevo.com/v3/smtp/email';
interface BrevoSendParams {
to: string;
subject: string;
senderName: string;
senderEmail: string;
htmlContent: string;
}
export async function sendBrevoMail(
event: H3Event,
params: BrevoSendParams,
): Promise<void> {
const config = useRuntimeConfig(event);
const apiKey = config.brevoApiKey as string;
if (!apiKey) {
throw createError({
statusCode: 500,
message: 'BREVO_API_KEY not configured',
});
}
const body = {
sender: { name: params.senderName, email: params.senderEmail },
to: [{ email: params.to }],
subject: params.subject,
htmlContent: params.htmlContent,
};
const res = await fetch(BREVO_API_URL, {
method: 'POST',
headers: {
'api-key': apiKey,
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errText = await res.text().catch(() => '');
console.error(
`[brevo] send failed status=${res.status} to=${params.to} body=${errText.slice(0, 300)}`,
);
throw createError({
statusCode: 502,
message: `Brevo API error ${res.status}`,
});
}
}

View File

@ -0,0 +1,299 @@
// 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 };
}