mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
354 lines
11 KiB
TypeScript
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' },
|
|
}
|
|
)
|
|
})
|