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)
164 lines
4.5 KiB
TypeScript
164 lines
4.5 KiB
TypeScript
// 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 {};
|
||
});
|