Files
2026-06-01 20:19:26 -04:00

354 lines
11 KiB
TypeScript

import * as React from 'npm:react@18.3.1'
import { renderAsync } from 'npm:@react-email/components@0.0.22'
import { createClient } from 'npm:@supabase/supabase-js@2'
import { corsHeaders } from 'npm:@supabase/supabase-js@2/cors'
import { TEMPLATES } from '../_shared/transactional-email-templates/registry.ts'
// Configuration baked in at scaffold time — do NOT change these manually.
// To update, re-run the email domain setup flow.
const SITE_NAME = "avria-community-cloud"
// SENDER_DOMAIN is the verified sender subdomain FQDN (e.g., "notify.example.com").
// It MUST match the subdomain delegated to Lovable's nameservers — never the root domain.
// The email API looks up this exact domain; a mismatch causes "No email domain record found".
const SENDER_DOMAIN = "notify.avriamail.com"
// FROM_DOMAIN is the domain shown in the From: header (e.g., "example.com").
// When display_from_root is enabled, this can be the root domain for cleaner branding,
// even though actual sending uses the subdomain above.
const FROM_DOMAIN = "notify.avriamail.com"
// Generate a cryptographically random 32-byte hex token
function generateToken(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
// Auth note: this function is invoked by trusted app flows and other functions.
// It uses the service client internally for queueing and suppression checks.
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders })
}
const supabaseUrl = Deno.env.get('SUPABASE_URL')
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
if (!supabaseUrl || !supabaseServiceKey) {
console.error('Missing required environment variables')
return new Response(
JSON.stringify({ error: 'Server configuration error' }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
}
// Parse request body
let templateName: string
let recipientEmail: string
let idempotencyKey: string
let messageId: string
let templateData: Record<string, any> = {}
try {
const body = await req.json()
templateName = body.templateName || body.template_name
recipientEmail = body.recipientEmail || body.recipient_email
messageId = crypto.randomUUID()
idempotencyKey = body.idempotencyKey || body.idempotency_key || messageId
if (body.templateData && typeof body.templateData === 'object') {
templateData = body.templateData
}
} catch {
return new Response(
JSON.stringify({ error: 'Invalid JSON in request body' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
}
if (!templateName) {
return new Response(
JSON.stringify({ error: 'templateName is required' }),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
}
// 1. Look up template from registry (early — needed to resolve recipient)
const template = TEMPLATES[templateName]
if (!template) {
console.error('Template not found in registry', { templateName })
return new Response(
JSON.stringify({
error: `Template '${templateName}' not found. Available: ${Object.keys(TEMPLATES).join(', ')}`,
}),
{
status: 404,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
}
// Resolve effective recipient: template-level `to` takes precedence over
// the caller-provided recipientEmail. This allows notification templates
// to always send to a fixed address (e.g., site owner from env var).
const effectiveRecipient = template.to || recipientEmail
if (!effectiveRecipient) {
return new Response(
JSON.stringify({
error: 'recipientEmail is required (unless the template defines a fixed recipient)',
}),
{
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
}
// Create Supabase client with service role (bypasses RLS)
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// 2. Check suppression list (fail-closed: if we can't verify, don't send)
const { data: suppressed, error: suppressionError } = await supabase
.from('suppressed_emails')
.select('id')
.eq('email', effectiveRecipient.toLowerCase())
.maybeSingle()
if (suppressionError) {
console.error('Suppression check failed — refusing to send', {
error: suppressionError,
effectiveRecipient,
})
return new Response(
JSON.stringify({ error: 'Failed to verify suppression status' }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
}
if (suppressed) {
// Log the suppressed attempt
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'suppressed',
})
console.log('Email suppressed', { effectiveRecipient, templateName })
return new Response(
JSON.stringify({ success: false, reason: 'email_suppressed' }),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
}
// 3. Get or create unsubscribe token (one token per email address)
const normalizedEmail = effectiveRecipient.toLowerCase()
let unsubscribeToken: string
// Check for existing token for this email
const { data: existingToken, error: tokenLookupError } = await supabase
.from('email_unsubscribe_tokens')
.select('token, used_at')
.eq('email', normalizedEmail)
.maybeSingle()
if (tokenLookupError) {
console.error('Token lookup failed', {
error: tokenLookupError,
email: normalizedEmail,
})
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'failed',
error_message: 'Failed to look up unsubscribe token',
})
return new Response(
JSON.stringify({ error: 'Failed to prepare email' }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
}
if (existingToken && !existingToken.used_at) {
// Reuse existing unused token
unsubscribeToken = existingToken.token
} else if (!existingToken) {
// Create new token — upsert handles concurrent inserts gracefully
unsubscribeToken = generateToken()
const { error: tokenError } = await supabase
.from('email_unsubscribe_tokens')
.upsert(
{ token: unsubscribeToken, email: normalizedEmail },
{ onConflict: 'email', ignoreDuplicates: true }
)
if (tokenError) {
console.error('Failed to create unsubscribe token', {
error: tokenError,
})
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'failed',
error_message: 'Failed to create unsubscribe token',
})
return new Response(
JSON.stringify({ error: 'Failed to prepare email' }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
}
// If another request raced us, our upsert was silently ignored.
// Re-read to get the actual stored token.
const { data: storedToken, error: reReadError } = await supabase
.from('email_unsubscribe_tokens')
.select('token')
.eq('email', normalizedEmail)
.maybeSingle()
if (reReadError || !storedToken) {
console.error('Failed to read back unsubscribe token after upsert', {
error: reReadError,
email: normalizedEmail,
})
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'failed',
error_message: 'Failed to confirm unsubscribe token storage',
})
return new Response(
JSON.stringify({ error: 'Failed to prepare email' }),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
}
unsubscribeToken = storedToken.token
} else {
// Token exists but is already used — email should have been caught by suppression check above.
// This is a safety fallback; log and skip sending.
console.warn('Unsubscribe token already used but email not suppressed', {
email: normalizedEmail,
})
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
status: 'suppressed',
error_message:
'Unsubscribe token used but email missing from suppressed list',
})
return new Response(
JSON.stringify({ success: false, reason: 'email_suppressed' }),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
}
// 4. Render React Email template to HTML and plain text
const html = await renderAsync(
React.createElement(template.component, templateData)
)
const plainText = await renderAsync(
React.createElement(template.component, templateData),
{ plainText: true }
)
// Resolve subject — supports static string or dynamic function
const resolvedSubject =
typeof template.subject === 'function'
? template.subject(templateData)
: template.subject
// 5. Enqueue the pre-rendered email for async processing by the dispatcher.
// The dispatcher (process-email-queue) handles sending, retries, and rate-limit backoff.
// 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: templateName,
recipient_email: effectiveRecipient,
status: 'pending',
})
const { error: enqueueError } = await supabase.rpc('enqueue_email', {
queue_name: 'transactional_emails',
payload: {
message_id: messageId,
to: effectiveRecipient,
from: `${SITE_NAME} <noreply@${FROM_DOMAIN}>`,
sender_domain: SENDER_DOMAIN,
subject: resolvedSubject,
html,
text: plainText,
purpose: 'transactional',
label: templateName,
idempotency_key: idempotencyKey,
unsubscribe_token: unsubscribeToken,
queued_at: new Date().toISOString(),
},
})
if (enqueueError) {
console.error('Failed to enqueue email', {
error: enqueueError,
templateName,
effectiveRecipient,
})
await supabase.from('email_send_log').insert({
message_id: messageId,
template_name: templateName,
recipient_email: effectiveRecipient,
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('Transactional email enqueued', { templateName, effectiveRecipient })
return new Response(
JSON.stringify({ success: true, queued: true }),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}
)
})