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

164 lines
4.5 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.

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