mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Migrate email pipeline off Lovable + branded auth emails
Replace Lovable-bound email transport and auth webhook so the platform sends all automated email through its own infrastructure. - process-email-queue: drop sendLovableEmail/LOVABLE_API_KEY; send via the Hostinger Email API (primary) with automatic SMTP fallback. Shared transports added in _shared/hostinger-mail.ts and _shared/smtp-send.ts. - auth-email-hook: verify Supabase's native Send Email hook signature (Standard Webhooks via SEND_EMAIL_HOOK_SECRET) instead of Lovable's libs; build the GoTrue verify URL; keep enqueue → process-email-queue. - Recreate the 6 auth email templates under _shared/email-templates/ that previously only existed in the deployed function. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import * as React from 'npm:react@18.3.1'
|
||||
import { renderAsync } from 'npm:@react-email/components@0.0.22'
|
||||
import { parseEmailWebhookPayload } from 'npm:@lovable.dev/email-js'
|
||||
import { WebhookError, verifyWebhookRequest } from 'npm:@lovable.dev/webhooks-js'
|
||||
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'
|
||||
@@ -10,10 +9,20 @@ 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_<base64>") 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, x-lovable-signature, x-lovable-timestamp, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version',
|
||||
'authorization, x-client-info, apikey, content-type, webhook-id, webhook-timestamp, webhook-signature',
|
||||
}
|
||||
|
||||
const EMAIL_SUBJECTS: Record<string, string> = {
|
||||
@@ -25,7 +34,6 @@ const EMAIL_SUBJECTS: Record<string, string> = {
|
||||
reauthentication: 'Your verification code',
|
||||
}
|
||||
|
||||
// Template mapping
|
||||
const EMAIL_TEMPLATES: Record<string, React.ComponentType<any>> = {
|
||||
signup: SignupEmail,
|
||||
invite: InviteEmail,
|
||||
@@ -35,209 +43,93 @@ const EMAIL_TEMPLATES: Record<string, React.ComponentType<any>> = {
|
||||
reauthentication: ReauthenticationEmail,
|
||||
}
|
||||
|
||||
// Configuration
|
||||
const SITE_NAME = "Avria Community Management, LLC"
|
||||
const SENDER_DOMAIN = "notify.avriamail.com"
|
||||
const ROOT_DOMAIN = "avria.cloud"
|
||||
const FROM_DOMAIN = "notify.avriamail.com" // Domain shown in From address (may be root or sender subdomain)
|
||||
const FROM_ADDRESS = `"${SITE_NAME.replace(/"/g, '\\"')}" <noreply@${FROM_DOMAIN}>`
|
||||
const SITE_NAME = 'Avria Community Management, LLC'
|
||||
const ROOT_DOMAIN = 'avria.cloud'
|
||||
const FROM_ADDRESS = `"${SITE_NAME.replace(/"/g, '\\"')}" <noreply@${ROOT_DOMAIN}>`
|
||||
|
||||
// Sample data for preview mode ONLY (not used in actual email sending).
|
||||
// URLs are baked in at scaffold time from the project's real data.
|
||||
// The sample email uses a fixed placeholder (RFC 6761 .test TLD) so the Go backend
|
||||
// can always find-and-replace it with the actual recipient when sending test emails,
|
||||
// even if the project's domain has changed since the template was scaffolded.
|
||||
const SAMPLE_PROJECT_URL = "https://avria.cloud"
|
||||
const SAMPLE_EMAIL = "user@example.test"
|
||||
const SAMPLE_DATA: Record<string, object> = {
|
||||
signup: {
|
||||
siteName: SITE_NAME,
|
||||
siteUrl: SAMPLE_PROJECT_URL,
|
||||
recipient: SAMPLE_EMAIL,
|
||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
||||
},
|
||||
magiclink: {
|
||||
siteName: SITE_NAME,
|
||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
||||
},
|
||||
recovery: {
|
||||
siteName: SITE_NAME,
|
||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
||||
},
|
||||
invite: {
|
||||
siteName: SITE_NAME,
|
||||
siteUrl: SAMPLE_PROJECT_URL,
|
||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
||||
},
|
||||
email_change: {
|
||||
siteName: SITE_NAME,
|
||||
email: SAMPLE_EMAIL,
|
||||
newEmail: SAMPLE_EMAIL,
|
||||
confirmationUrl: SAMPLE_PROJECT_URL,
|
||||
},
|
||||
reauthentication: {
|
||||
token: '123456',
|
||||
},
|
||||
// Normalize GoTrue email_action_type to our template keys.
|
||||
function normalizeActionType(actionType: string): string {
|
||||
if (actionType.startsWith('email_change')) return 'email_change'
|
||||
return actionType
|
||||
}
|
||||
|
||||
// Preview endpoint handler - returns rendered HTML without sending email
|
||||
async function handlePreview(req: Request): Promise<Response> {
|
||||
const previewCorsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, content-type',
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: previewCorsHeaders })
|
||||
}
|
||||
|
||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
||||
const authHeader = req.headers.get('Authorization')
|
||||
|
||||
if (!apiKey || authHeader !== `Bearer ${apiKey}`) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
let type: string
|
||||
try {
|
||||
const body = await req.json()
|
||||
type = body.type
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid JSON in request body' }), {
|
||||
status: 400,
|
||||
headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const EmailTemplate = EMAIL_TEMPLATES[type]
|
||||
|
||||
if (!EmailTemplate) {
|
||||
return new Response(JSON.stringify({ error: `Unknown email type: ${type}` }), {
|
||||
status: 400,
|
||||
headers: { ...previewCorsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const sampleData = SAMPLE_DATA[type] || {}
|
||||
const html = await renderAsync(React.createElement(EmailTemplate, sampleData))
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { ...previewCorsHeaders, 'Content-Type': 'text/html; charset=utf-8' },
|
||||
// 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()}`
|
||||
}
|
||||
|
||||
// Webhook handler - verifies signature and sends email
|
||||
async function handleWebhook(req: Request): Promise<Response> {
|
||||
const apiKey = Deno.env.get('LOVABLE_API_KEY')
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('LOVABLE_API_KEY not configured')
|
||||
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' } }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify signature + timestamp, then parse payload.
|
||||
let payload: any
|
||||
let run_id = ''
|
||||
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 verified = await verifyWebhookRequest({
|
||||
req,
|
||||
secret: apiKey,
|
||||
parser: parseEmailWebhookPayload,
|
||||
})
|
||||
payload = verified.payload
|
||||
run_id = payload.run_id
|
||||
const wh = new Webhook(hookSecret.replace(/^v1,whsec_/, ''))
|
||||
verified = wh.verify(payloadRaw, headers)
|
||||
} catch (error) {
|
||||
if (error instanceof WebhookError) {
|
||||
switch (error.code) {
|
||||
case 'invalid_signature':
|
||||
case 'missing_timestamp':
|
||||
case 'invalid_timestamp':
|
||||
case 'stale_timestamp':
|
||||
console.error('Invalid webhook signature', { error: error.message })
|
||||
return new Response(JSON.stringify({ error: 'Invalid signature' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
case 'invalid_payload':
|
||||
case 'invalid_json':
|
||||
console.error('Invalid webhook payload', { error: error.message })
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid webhook payload' }),
|
||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Webhook verification failed', { error })
|
||||
console.error('Auth hook signature verification failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid webhook payload' }),
|
||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
JSON.stringify({ error: 'Invalid signature' }),
|
||||
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
if (!run_id) {
|
||||
console.error('Webhook payload missing run_id')
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid webhook payload' }),
|
||||
{
|
||||
status: 400,
|
||||
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)
|
||||
|
||||
if (payload.version !== '1') {
|
||||
console.error('Unsupported payload version', { version: payload.version, run_id })
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Unsupported payload version: ${payload.version}` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// The email action type is in payload.data.action_type (e.g., "signup", "recovery")
|
||||
// payload.type is the hook event type ("auth")
|
||||
const emailType = payload.data.action_type
|
||||
console.log('Received auth event', { emailType, email: payload.data.email, run_id })
|
||||
console.log('Received auth send-email hook', { emailType, rawActionType, recipient })
|
||||
|
||||
const EmailTemplate = EMAIL_TEMPLATES[emailType]
|
||||
if (!EmailTemplate) {
|
||||
console.error('Unknown email type', { emailType, run_id })
|
||||
if (!EmailTemplate || !recipient) {
|
||||
console.error('Unknown email type or missing recipient', { emailType, hasRecipient: !!recipient })
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Unknown email type: ${emailType}` }),
|
||||
JSON.stringify({ error: `Unsupported email action type: ${rawActionType}` }),
|
||||
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
// Build template props from payload.data (HookData structure)
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||
const confirmationUrl = buildConfirmationUrl(supabaseUrl, emailData)
|
||||
|
||||
const templateProps = {
|
||||
siteName: SITE_NAME,
|
||||
siteUrl: `https://${ROOT_DOMAIN}`,
|
||||
recipient: payload.data.email,
|
||||
confirmationUrl: payload.data.url,
|
||||
token: payload.data.token,
|
||||
email: payload.data.email,
|
||||
newEmail: payload.data.new_email,
|
||||
recipient,
|
||||
confirmationUrl,
|
||||
token: emailData?.token,
|
||||
email: recipient,
|
||||
newEmail: user?.new_email ?? recipient,
|
||||
}
|
||||
|
||||
// Render React Email to HTML and plain text
|
||||
const html = await renderAsync(React.createElement(EmailTemplate, templateProps))
|
||||
const text = await renderAsync(React.createElement(EmailTemplate, templateProps), {
|
||||
plainText: true,
|
||||
})
|
||||
|
||||
// Enqueue email for async processing by the dispatcher (process-email-queue).
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
supabaseUrl,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
@@ -247,18 +139,16 @@ async function handleWebhook(req: Request): Promise<Response> {
|
||||
await supabase.from('email_send_log').insert({
|
||||
message_id: messageId,
|
||||
template_name: emailType,
|
||||
recipient_email: payload.data.email,
|
||||
recipient_email: recipient,
|
||||
status: 'pending',
|
||||
})
|
||||
|
||||
const { error: enqueueError } = await supabase.rpc('enqueue_email', {
|
||||
queue_name: 'auth_emails',
|
||||
payload: {
|
||||
run_id,
|
||||
message_id: messageId,
|
||||
to: payload.data.email,
|
||||
to: recipient,
|
||||
from: FROM_ADDRESS,
|
||||
sender_domain: SENDER_DOMAIN,
|
||||
subject: EMAIL_SUBJECTS[emailType] || 'Notification',
|
||||
html,
|
||||
text,
|
||||
@@ -269,11 +159,11 @@ async function handleWebhook(req: Request): Promise<Response> {
|
||||
})
|
||||
|
||||
if (enqueueError) {
|
||||
console.error('Failed to enqueue auth email', { error: enqueueError, run_id, emailType })
|
||||
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: payload.data.email,
|
||||
recipient_email: recipient,
|
||||
status: 'failed',
|
||||
error_message: 'Failed to enqueue email',
|
||||
})
|
||||
@@ -283,32 +173,23 @@ async function handleWebhook(req: Request): Promise<Response> {
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Auth email enqueued', { emailType, email: payload.data.email, run_id })
|
||||
console.log('Auth email enqueued', { emailType, recipient })
|
||||
|
||||
// Supabase expects a 200 with an empty/again-acknowledged body on success.
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, queued: true }),
|
||||
JSON.stringify({}),
|
||||
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// Handle CORS preflight for main endpoint
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// Route to preview handler for /preview path
|
||||
if (url.pathname.endsWith('/preview')) {
|
||||
return handlePreview(req)
|
||||
}
|
||||
|
||||
// Main webhook handler
|
||||
try {
|
||||
return await handleWebhook(req)
|
||||
} catch (error) {
|
||||
console.error('Webhook handler error:', 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,
|
||||
|
||||
Reference in New Issue
Block a user