// 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 {}; });