diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts index d1cfb35..c873a84 100644 --- a/backend/nitro.config.ts +++ b/backend/nitro.config.ts @@ -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_" (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. diff --git a/backend/server/api/auth-hooks/send-email.post.ts b/backend/server/api/auth-hooks/send-email.post.ts new file mode 100644 index 0000000..5c90101 --- /dev/null +++ b/backend/server/api/auth-hooks/send-email.post.ts @@ -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_ +// +// 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 {}; +}); diff --git a/backend/server/utils/mail/brevo.ts b/backend/server/utils/mail/brevo.ts new file mode 100644 index 0000000..06a7606 --- /dev/null +++ b/backend/server/utils/mail/brevo.ts @@ -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 { + 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}`, + }); + } +} diff --git a/backend/server/utils/mail/templates.ts b/backend/server/utils/mail/templates.ts new file mode 100644 index 0000000..102318f --- /dev/null +++ b/backend/server/utils/mail/templates.ts @@ -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> = { + 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, '>'); +} + +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 = ` + + + + +ReBreak + + + + + + +
+ + + + + + + + +
+ + + + + + +
+ ReBreak + +
ReBreak
+
 
+
+

${t.title}

+

${t.body}

+ +
+
${safeToken}
+
+ +

${t.helpText}

+
+

+ rebreak.org +  ·  + ${t.privacyLabel} +

+

© ReBreak

+
+
+ +`; + + return { subject: t.subject, senderName: t.senderName, html }; +}