Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
6e1ef08577
commit
6bb35f3692
@@ -0,0 +1,23 @@
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
export interface TemplateEntry {
|
||||
component: ComponentType<any>
|
||||
subject: string | ((data: Record<string, any>) => string)
|
||||
displayName?: string
|
||||
previewData?: Record<string, any>
|
||||
/** Fixed recipient — overrides caller-provided recipientEmail when set. */
|
||||
to?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Template registry — maps template names to their React Email components.
|
||||
* Import and register new templates here after creating them in this directory.
|
||||
*
|
||||
* Example:
|
||||
* import { template as welcomeTemplate } from './welcome'
|
||||
* // then add to TEMPLATES: 'welcome': welcomeTemplate
|
||||
*/
|
||||
export const TEMPLATES: Record<string, TemplateEntry> = {
|
||||
// Add templates here as they are created, e.g.:
|
||||
// 'welcome': welcomeTemplate,
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
function redactEmail(email: string | null | undefined): string {
|
||||
if (!email) return '***'
|
||||
const [localPart, domain] = email.split('@')
|
||||
if (!localPart || !domain) return '***'
|
||||
return `${localPart[0]}***@${domain}`
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/email/unsubscribe")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return Response.json({ error: 'Server configuration error' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Extract token from query params
|
||||
const url = new URL(request.url)
|
||||
const token = url.searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return Response.json({ error: 'Token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Look up the token
|
||||
const { data: tokenRecord, error: lookupError } = await supabase
|
||||
.from('email_unsubscribe_tokens')
|
||||
.select('*')
|
||||
.eq('token', token)
|
||||
.maybeSingle()
|
||||
|
||||
if (lookupError || !tokenRecord) {
|
||||
return Response.json({ error: 'Invalid or expired token' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (tokenRecord.used_at) {
|
||||
return Response.json({ valid: false, reason: 'already_unsubscribed' })
|
||||
}
|
||||
|
||||
return Response.json({ valid: true })
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return Response.json({ error: 'Server configuration error' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Extract token from query params (always present for RFC 8058 one-click)
|
||||
const url = new URL(request.url)
|
||||
let token: string | null = url.searchParams.get('token')
|
||||
|
||||
// Detect RFC 8058 one-click unsubscribe: POST with form-encoded body
|
||||
// containing "List-Unsubscribe=One-Click". Email clients (Gmail, Apple Mail,
|
||||
// etc.) send this when the user clicks "Unsubscribe" in the mail UI.
|
||||
const contentType = request.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const formText = await request.text()
|
||||
const params = new URLSearchParams(formText)
|
||||
// For one-click, token comes from query param (already set above).
|
||||
// Otherwise, token may be in the form body.
|
||||
if (!params.get('List-Unsubscribe')) {
|
||||
const formToken = params.get('token')
|
||||
if (formToken) {
|
||||
token = formToken
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// JSON body (from the app's unsubscribe page)
|
||||
try {
|
||||
const body = await request.json()
|
||||
if (body.token) {
|
||||
token = body.token
|
||||
}
|
||||
} catch {
|
||||
// Fall through — token stays from query param
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return Response.json({ error: 'Token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Look up the token
|
||||
const { data: tokenRecord, error: lookupError } = await supabase
|
||||
.from('email_unsubscribe_tokens')
|
||||
.select('*')
|
||||
.eq('token', token)
|
||||
.maybeSingle()
|
||||
|
||||
if (lookupError || !tokenRecord) {
|
||||
return Response.json({ error: 'Invalid or expired token' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (tokenRecord.used_at) {
|
||||
return Response.json({ success: false, reason: 'already_unsubscribed' })
|
||||
}
|
||||
|
||||
// Atomic check-and-update to avoid TOCTOU race
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('email_unsubscribe_tokens')
|
||||
.update({ used_at: new Date().toISOString() })
|
||||
.eq('token', token)
|
||||
.is('used_at', null)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to mark token as used', { error: updateError, token })
|
||||
return Response.json({ error: 'Failed to process unsubscribe' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
return Response.json({ success: false, reason: 'already_unsubscribed' })
|
||||
}
|
||||
|
||||
// Add email to suppressed list (upsert to handle duplicates)
|
||||
const { error: suppressError } = await supabase
|
||||
.from('suppressed_emails')
|
||||
.upsert(
|
||||
{ email: tokenRecord.email.toLowerCase(), reason: 'unsubscribe' },
|
||||
{ onConflict: 'email' },
|
||||
)
|
||||
|
||||
if (suppressError) {
|
||||
console.error('Failed to suppress email', {
|
||||
error: suppressError,
|
||||
email_redacted: redactEmail(tokenRecord.email),
|
||||
})
|
||||
return Response.json({ error: 'Failed to process unsubscribe' }, { status: 500 })
|
||||
}
|
||||
|
||||
console.log('Email unsubscribed', {
|
||||
email_redacted: redactEmail(tokenRecord.email),
|
||||
})
|
||||
|
||||
return Response.json({ success: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { WebhookError, verifyWebhookRequest } from '@lovable.dev/webhooks-js'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
// Suppression event payload sent by the Go API when Mailgun reports
|
||||
// a bounce, complaint, or unsubscribe.
|
||||
interface SuppressionPayload {
|
||||
email: string
|
||||
reason: 'bounce' | 'complaint' | 'unsubscribe'
|
||||
message_id?: string
|
||||
metadata?: Record<string, unknown>
|
||||
is_retry: boolean
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
function parseSuppressionPayload(body: string): SuppressionPayload {
|
||||
const parsed = JSON.parse(body)
|
||||
if (!parsed.data) {
|
||||
throw new Error('Missing data field in payload')
|
||||
}
|
||||
const data = parsed.data as SuppressionPayload
|
||||
if (!data.email || !data.reason) {
|
||||
throw new Error('Missing required fields: email, reason')
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function mapReasonToStatus(
|
||||
reason: string,
|
||||
): 'bounced' | 'complained' | 'suppressed' {
|
||||
switch (reason) {
|
||||
case 'bounce':
|
||||
return 'bounced'
|
||||
case 'complaint':
|
||||
return 'complained'
|
||||
default:
|
||||
return 'suppressed'
|
||||
}
|
||||
}
|
||||
|
||||
function mapReasonToMessage(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'bounce':
|
||||
return 'Permanent bounce — email address is invalid or rejected'
|
||||
case 'complaint':
|
||||
return 'Spam complaint — recipient marked email as spam'
|
||||
case 'unsubscribe':
|
||||
return 'Recipient unsubscribed'
|
||||
default:
|
||||
return 'Email suppressed'
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/lovable/email/suppression")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const apiKey = process.env.LOVABLE_API_KEY
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!apiKey || !supabaseUrl || !supabaseServiceKey) {
|
||||
console.error('Missing required environment variables')
|
||||
return Response.json({ error: 'Server configuration error' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Verify HMAC signature using the Lovable API Key (same as auth-email-hook)
|
||||
let payload: SuppressionPayload
|
||||
try {
|
||||
const verified = await verifyWebhookRequest({
|
||||
req: request,
|
||||
secret: apiKey,
|
||||
parser: parseSuppressionPayload,
|
||||
})
|
||||
payload = verified.payload
|
||||
} catch (error) {
|
||||
if (error instanceof WebhookError) {
|
||||
switch (error.code) {
|
||||
case 'invalid_signature':
|
||||
console.error('Invalid webhook signature')
|
||||
return Response.json({ error: 'Invalid signature' }, { status: 401 })
|
||||
case 'stale_timestamp':
|
||||
console.error('Stale webhook timestamp')
|
||||
return Response.json({ error: 'Stale timestamp' }, { status: 401 })
|
||||
case 'invalid_payload':
|
||||
case 'invalid_json':
|
||||
console.error('Invalid payload', { code: error.code })
|
||||
return Response.json({ error: 'Invalid payload' }, { status: 400 })
|
||||
default:
|
||||
console.error('Webhook verification failed', {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
})
|
||||
return Response.json({ error: 'Verification failed' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
console.error('Unexpected error during verification', { error })
|
||||
return Response.json({ error: 'Internal error' }, { status: 500 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
const normalizedEmail = payload.email.toLowerCase()
|
||||
|
||||
// 1. Upsert to suppressed_emails (idempotent — safe for retries)
|
||||
const { error: suppressError } = await supabase
|
||||
.from('suppressed_emails')
|
||||
.upsert(
|
||||
{
|
||||
email: normalizedEmail,
|
||||
reason: payload.reason,
|
||||
metadata: payload.metadata ?? null,
|
||||
},
|
||||
{ onConflict: 'email' },
|
||||
)
|
||||
|
||||
if (suppressError) {
|
||||
console.error('Failed to upsert suppressed email', {
|
||||
error: suppressError,
|
||||
email_redacted: normalizedEmail[0] + '***@' + normalizedEmail.split('@')[1],
|
||||
})
|
||||
return Response.json({ error: 'Failed to write suppression' }, { status: 500 })
|
||||
}
|
||||
|
||||
// 2. Append a new log entry for the suppression event (never update existing rows)
|
||||
const sendLogStatus = mapReasonToStatus(payload.reason)
|
||||
const sendLogMessage = mapReasonToMessage(payload.reason)
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('email_send_log')
|
||||
.insert({
|
||||
message_id: payload.message_id ?? null,
|
||||
template_name: 'system',
|
||||
recipient_email: normalizedEmail,
|
||||
status: sendLogStatus,
|
||||
error_message: sendLogMessage,
|
||||
metadata: payload.metadata ?? null,
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
// Non-fatal — log and continue. The suppression was already recorded.
|
||||
console.warn('Failed to insert email_send_log', {
|
||||
error: insertError,
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Suppression processed', {
|
||||
email_redacted: normalizedEmail[0] + '***@' + normalizedEmail.split('@')[1],
|
||||
reason: payload.reason,
|
||||
is_retry: payload.is_retry,
|
||||
retry_count: payload.retry_count,
|
||||
has_message_id: !!payload.message_id,
|
||||
})
|
||||
|
||||
return Response.json({ success: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react'
|
||||
import { renderAsync } from '@react-email/components'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TEMPLATES } from '@/lib/email-templates/registry'
|
||||
|
||||
// Renders all registered templates with their previewData.
|
||||
// Gated by LOVABLE_API_KEY — only the Go API calls this.
|
||||
|
||||
export const Route = createFileRoute("/lovable/email/transactional/preview")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const apiKey = process.env.LOVABLE_API_KEY
|
||||
if (!apiKey) {
|
||||
return Response.json(
|
||||
{ error: 'Server configuration error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the caller is authorized with LOVABLE_API_KEY
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
const token = authHeader?.replace(/^Bearer\s+/i, '')
|
||||
if (token !== apiKey) {
|
||||
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const templateNames = Object.keys(TEMPLATES)
|
||||
const results: Array<{
|
||||
templateName: string
|
||||
displayName: string
|
||||
subject: string
|
||||
html: string
|
||||
status: 'ready' | 'preview_data_required' | 'render_failed'
|
||||
errorMessage?: string
|
||||
}> = []
|
||||
|
||||
for (const name of templateNames) {
|
||||
const entry = TEMPLATES[name]
|
||||
const displayName = entry.displayName || name
|
||||
|
||||
if (!entry.previewData) {
|
||||
results.push({
|
||||
templateName: name,
|
||||
displayName,
|
||||
subject: '',
|
||||
html: '',
|
||||
status: 'preview_data_required',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const html = await renderAsync(
|
||||
React.createElement(entry.component, entry.previewData)
|
||||
)
|
||||
const resolvedSubject =
|
||||
typeof entry.subject === 'function'
|
||||
? entry.subject(entry.previewData)
|
||||
: entry.subject
|
||||
|
||||
results.push({
|
||||
templateName: name,
|
||||
displayName,
|
||||
subject: resolvedSubject,
|
||||
html,
|
||||
status: 'ready',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to render template for preview', {
|
||||
template: name,
|
||||
error: err,
|
||||
})
|
||||
results.push({
|
||||
templateName: name,
|
||||
displayName,
|
||||
subject: '',
|
||||
html: '',
|
||||
status: 'render_failed',
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ templates: results })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,324 @@
|
||||
import * as React from 'react'
|
||||
import { renderAsync } from '@react-email/components'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TEMPLATES } from '@/lib/email-templates/registry'
|
||||
|
||||
// Configuration baked in at scaffold time
|
||||
const SITE_NAME = "hoa-guard-desk"
|
||||
// SENDER_DOMAIN is the verified sender subdomain FQDN (e.g., "notify.example.com").
|
||||
// It MUST match the subdomain delegated to Lovable's nameservers. NEVER use the root domain.
|
||||
const SENDER_DOMAIN = "notify.stagelaw.com"
|
||||
// FROM_DOMAIN is the domain shown in the From: header (e.g., "example.com").
|
||||
// Can be the root domain when display_from_root is enabled — this is cosmetic only.
|
||||
const FROM_DOMAIN = "notify.stagelaw.com"
|
||||
|
||||
function redactEmail(email: string | null | undefined): string {
|
||||
if (!email) return '***'
|
||||
const [localPart, domain] = email.split('@')
|
||||
if (!localPart || !domain) return '***'
|
||||
return `${localPart[0]}***@${domain}`
|
||||
}
|
||||
|
||||
// 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('')
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/lovable/email/transactional/send")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
console.error('Missing required environment variables')
|
||||
return Response.json(
|
||||
{ error: 'Server configuration error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the caller has a valid Supabase auth token.
|
||||
// In TanStack, there is no Supabase gateway — we validate the JWT ourselves.
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const token = authHeader.slice('Bearer '.length).trim()
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser(token)
|
||||
|
||||
if (authError || !user) {
|
||||
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
let templateName: string
|
||||
let recipientEmail: string
|
||||
let idempotencyKey: string
|
||||
let messageId: string
|
||||
let templateData: Record<string, any> = {}
|
||||
try {
|
||||
const body = await request.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 Response.json(
|
||||
{ error: 'Invalid JSON in request body' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!templateName) {
|
||||
return Response.json(
|
||||
{ error: 'templateName is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 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 Response.json(
|
||||
{
|
||||
error: `Template '${templateName}' not found. Available: ${Object.keys(TEMPLATES).join(', ')}`,
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 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 Response.json(
|
||||
{
|
||||
error: 'recipientEmail is required (unless the template defines a fixed recipient)',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
recipient_redacted: redactEmail(effectiveRecipient),
|
||||
})
|
||||
return Response.json(
|
||||
{ error: 'Failed to verify suppression status' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
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', {
|
||||
templateName,
|
||||
recipient_redacted: redactEmail(effectiveRecipient),
|
||||
})
|
||||
return Response.json({ success: false, reason: 'email_suppressed' })
|
||||
}
|
||||
|
||||
// 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_redacted: redactEmail(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 Response.json(
|
||||
{ error: 'Failed to prepare email' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
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 Response.json(
|
||||
{ error: 'Failed to prepare email' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// 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_redacted: redactEmail(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 Response.json(
|
||||
{ error: 'Failed to prepare email' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
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_redacted: redactEmail(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 Response.json({ success: false, reason: 'email_suppressed' })
|
||||
}
|
||||
|
||||
// 4. Render React Email template to HTML and plain text
|
||||
const element = React.createElement(template.component, templateData)
|
||||
const html = await renderAsync(element)
|
||||
const plainText = await renderAsync(element, { 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,
|
||||
recipient_redacted: redactEmail(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 Response.json(
|
||||
{ error: 'Failed to enqueue email' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Transactional email enqueued', {
|
||||
templateName,
|
||||
recipient_redacted: redactEmail(effectiveRecipient),
|
||||
})
|
||||
|
||||
return Response.json({ success: true, queued: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user