Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-23 21:29:58 +00:00
co-authored by renee-png
parent 6e1ef08577
commit 6bb35f3692
6 changed files with 751 additions and 0 deletions
+23
View File
@@ -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,
}
+152
View File
@@ -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 })
},
},
},
})
+158
View File
@@ -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 })
},
},
},
})