diff --git a/package.json b/package.json index b1a82ca..1a967bd 100644 --- a/package.json +++ b/package.json @@ -112,5 +112,10 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.56.1", "vite": "^7.3.1" + }, + "pnpm": { + "overrides": { + "entities": "4.5.0" + } } } diff --git a/src/lib/email-templates/registry.ts b/src/lib/email-templates/registry.ts new file mode 100644 index 0000000..715dbed --- /dev/null +++ b/src/lib/email-templates/registry.ts @@ -0,0 +1,23 @@ +import type { ComponentType } from 'react' + +export interface TemplateEntry { + component: ComponentType + subject: string | ((data: Record) => string) + displayName?: string + previewData?: Record + /** 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 = { + // Add templates here as they are created, e.g.: + // 'welcome': welcomeTemplate, +} diff --git a/src/routes/email/unsubscribe.ts b/src/routes/email/unsubscribe.ts new file mode 100644 index 0000000..ecf6b6c --- /dev/null +++ b/src/routes/email/unsubscribe.ts @@ -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 }) + }, + }, + }, +}) diff --git a/src/routes/lovable/email/suppression.ts b/src/routes/lovable/email/suppression.ts new file mode 100644 index 0000000..402ede4 --- /dev/null +++ b/src/routes/lovable/email/suppression.ts @@ -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 + 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 }) + }, + }, + }, +}) diff --git a/src/routes/lovable/email/transactional/preview.ts b/src/routes/lovable/email/transactional/preview.ts new file mode 100644 index 0000000..376d2d1 --- /dev/null +++ b/src/routes/lovable/email/transactional/preview.ts @@ -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 }) + }, + }, + }, +}) diff --git a/src/routes/lovable/email/transactional/send.ts b/src/routes/lovable/email/transactional/send.ts new file mode 100644 index 0000000..5501887 --- /dev/null +++ b/src/routes/lovable/email/transactional/send.ts @@ -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 = {} + 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} `, + 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 }) + }, + }, + }, +})