import * as React from 'npm:react@18.3.1' import { renderAsync } from 'npm:@react-email/components@0.0.22' import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0' import { createClient } from 'npm:@supabase/supabase-js@2' import { SignupEmail } from '../_shared/email-templates/signup.tsx' import { InviteEmail } from '../_shared/email-templates/invite.tsx' import { MagicLinkEmail } from '../_shared/email-templates/magic-link.tsx' import { RecoveryEmail } from '../_shared/email-templates/recovery.tsx' import { EmailChangeEmail } from '../_shared/email-templates/email-change.tsx' import { ReauthenticationEmail } from '../_shared/email-templates/reauthentication.tsx' // This is the Supabase Auth "Send Email" hook. Supabase Auth POSTs a // Standard Webhooks-signed payload here whenever it needs to send an auth email // (signup confirmation, password reset, invite, magic link, email change, // reauthentication). We verify the signature, render the matching branded // template, and enqueue it for the dispatcher (process-email-queue) to send. // // Migrated off Lovable: verification now uses the native Supabase hook secret // (SEND_EMAIL_HOOK_SECRET, format "v1,whsec_") via the standardwebhooks // library — no @lovable.dev/* dependencies. const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, webhook-id, webhook-timestamp, webhook-signature', } const EMAIL_SUBJECTS: Record = { signup: 'Confirm your email', invite: "You've been invited", magiclink: 'Your login link', recovery: 'Reset your password', email_change: 'Confirm your new email', reauthentication: 'Your verification code', } const EMAIL_TEMPLATES: Record> = { signup: SignupEmail, invite: InviteEmail, magiclink: MagicLinkEmail, recovery: RecoveryEmail, email_change: EmailChangeEmail, reauthentication: ReauthenticationEmail, } const SITE_NAME = 'Avria Community Management, LLC' const ROOT_DOMAIN = 'avria.cloud' const FROM_ADDRESS = `"${SITE_NAME.replace(/"/g, '\\"')}" ` // Normalize GoTrue email_action_type to our template keys. function normalizeActionType(actionType: string): string { if (actionType.startsWith('email_change')) return 'email_change' return actionType } // Build the GoTrue verification URL from the hashed token. Hitting this URL // consumes the token and then redirects the user to redirect_to. function buildConfirmationUrl(supabaseUrl: string, emailData: any): string { const params = new URLSearchParams({ token: emailData.token_hash ?? '', type: emailData.email_action_type ?? '', }) if (emailData.redirect_to) params.set('redirect_to', emailData.redirect_to) return `${supabaseUrl.replace(/\/$/, '')}/auth/v1/verify?${params.toString()}` } async function handleWebhook(req: Request): Promise { const hookSecret = Deno.env.get('SEND_EMAIL_HOOK_SECRET') if (!hookSecret) { console.error('SEND_EMAIL_HOOK_SECRET not configured') return new Response( JSON.stringify({ error: 'Server configuration error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } const payloadRaw = await req.text() const headers = Object.fromEntries(req.headers) // Verify the Standard Webhooks signature. The library expects the base64 // secret without the "v1,whsec_" prefix Supabase displays. let verified: any try { const wh = new Webhook(hookSecret.replace(/^v1,whsec_/, '')) verified = wh.verify(payloadRaw, headers) } catch (error) { console.error('Auth hook signature verification failed', { error: error instanceof Error ? error.message : String(error), }) return new Response( JSON.stringify({ error: 'Invalid signature' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } const user = verified.user const emailData = verified.email_data const recipient: string | undefined = user?.email const rawActionType: string = emailData?.email_action_type ?? '' const emailType = normalizeActionType(rawActionType) console.log('Received auth send-email hook', { emailType, rawActionType, recipient }) const EmailTemplate = EMAIL_TEMPLATES[emailType] if (!EmailTemplate || !recipient) { console.error('Unknown email type or missing recipient', { emailType, hasRecipient: !!recipient }) return new Response( JSON.stringify({ error: `Unsupported email action type: ${rawActionType}` }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } const supabaseUrl = Deno.env.get('SUPABASE_URL')! const confirmationUrl = buildConfirmationUrl(supabaseUrl, emailData) const templateProps = { siteName: SITE_NAME, siteUrl: `https://${ROOT_DOMAIN}`, recipient, confirmationUrl, token: emailData?.token, email: recipient, newEmail: user?.new_email ?? recipient, } const html = await renderAsync(React.createElement(EmailTemplate, templateProps)) const text = await renderAsync(React.createElement(EmailTemplate, templateProps), { plainText: true, }) const supabase = createClient( supabaseUrl, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) const messageId = crypto.randomUUID() // Log pending BEFORE enqueue so we have a record even if enqueue crashes await supabase.from('email_send_log').insert({ message_id: messageId, template_name: emailType, recipient_email: recipient, status: 'pending', }) const { error: enqueueError } = await supabase.rpc('enqueue_email', { queue_name: 'auth_emails', payload: { message_id: messageId, to: recipient, from: FROM_ADDRESS, subject: EMAIL_SUBJECTS[emailType] || 'Notification', html, text, purpose: 'transactional', label: emailType, queued_at: new Date().toISOString(), }, }) if (enqueueError) { console.error('Failed to enqueue auth email', { error: enqueueError, emailType }) await supabase.from('email_send_log').insert({ message_id: messageId, template_name: emailType, recipient_email: recipient, status: 'failed', error_message: 'Failed to enqueue email', }) return new Response(JSON.stringify({ error: 'Failed to enqueue email' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } console.log('Auth email enqueued', { emailType, recipient }) // Supabase expects a 200 with an empty/again-acknowledged body on success. return new Response( JSON.stringify({}), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } Deno.serve(async (req) => { if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }) } try { return await handleWebhook(req) } catch (error) { console.error('Auth hook handler error:', error) const message = error instanceof Error ? error.message : 'Unknown error' return new Response(JSON.stringify({ error: message }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } })