diff --git a/supabase/functions/_shared/edgeFunctionWrapper.ts b/supabase/functions/_shared/edgeFunctionWrapper.ts index 94558add..f67a8098 100644 --- a/supabase/functions/_shared/edgeFunctionWrapper.ts +++ b/supabase/functions/_shared/edgeFunctionWrapper.ts @@ -21,10 +21,13 @@ import { } from './logger.ts'; import { formatEdgeError, toError } from './errorFormatter.ts'; import { ValidationError, logValidationError } from './typeValidation.ts'; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; export interface EdgeFunctionConfig { name: string; requireAuth?: boolean; + requiredRoles?: string[]; + useServiceRole?: boolean; corsHeaders?: HeadersInit; logRequests?: boolean; logResponses?: boolean; @@ -34,6 +37,8 @@ export interface EdgeFunctionContext { requestId: string; span: Span; userId?: string; + user?: any; + supabase: any; } export type EdgeFunctionHandler = ( @@ -51,6 +56,8 @@ export function wrapEdgeFunction( const { name, requireAuth = true, + requiredRoles = [], + useServiceRole = false, corsHeaders = {}, logRequests = true, logResponses = true, @@ -100,13 +107,39 @@ export function wrapEdgeFunction( try { // ==================================================================== - // STEP 4: Authentication (if required) + // STEP 4: Create Supabase client + // ==================================================================== + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const authHeader = req.headers.get('Authorization'); + + let supabase; + if (useServiceRole) { + // Use service role key for backend operations + const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + supabase = createClient(supabaseUrl, serviceRoleKey); + addSpanEvent(span, 'supabase_client_created', { type: 'service_role' }); + } else if (authHeader) { + // Use anon key with user's auth header + const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!; + supabase = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: authHeader } } + }); + addSpanEvent(span, 'supabase_client_created', { type: 'authenticated' }); + } else { + // Use anon key without auth + const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!; + supabase = createClient(supabaseUrl, anonKey); + addSpanEvent(span, 'supabase_client_created', { type: 'anonymous' }); + } + + // ==================================================================== + // STEP 5: Authentication (if required) // ==================================================================== let userId: string | undefined; + let user: any = undefined; if (requireAuth) { addSpanEvent(span, 'authentication_start'); - const authHeader = req.headers.get('Authorization'); if (!authHeader) { addSpanEvent(span, 'authentication_failed', { reason: 'missing_header' }); @@ -125,21 +158,15 @@ export function wrapEdgeFunction( ); } - // Extract user ID from JWT (simplified - extend as needed) - try { - // Note: In production, validate the JWT properly - const token = authHeader.replace('Bearer ', ''); - const payload = JSON.parse(atob(token.split('.')[1])); - userId = payload.sub; - - addSpanEvent(span, 'authentication_success', { userId }); - span.attributes['user.id'] = userId; - } catch (error) { + // Get user from Supabase + const { data: { user: authUser }, error: userError } = await supabase.auth.getUser(); + + if (userError || !authUser) { addSpanEvent(span, 'authentication_failed', { reason: 'invalid_token', - error: formatEdgeError(error) + error: formatEdgeError(userError) }); - endSpan(span, 'error', error); + endSpan(span, 'error', userError); logSpan(span); return new Response( @@ -153,10 +180,55 @@ export function wrapEdgeFunction( } ); } + + user = authUser; + userId = authUser.id; + + addSpanEvent(span, 'authentication_success', { userId }); + span.attributes['user.id'] = userId; + + // ==================================================================== + // STEP 6: Role verification (if required) + // ==================================================================== + if (requiredRoles.length > 0) { + addSpanEvent(span, 'role_check_start', { requiredRoles }); + + let hasRequiredRole = false; + for (const role of requiredRoles) { + const { data: hasRole } = await supabase + .rpc('has_role', { _user_id: userId, _role: role }); + + if (hasRole) { + hasRequiredRole = true; + addSpanEvent(span, 'role_check_success', { role }); + break; + } + } + + if (!hasRequiredRole) { + addSpanEvent(span, 'role_check_failed', { + userId, + requiredRoles + }); + endSpan(span, 'error'); + logSpan(span); + + return new Response( + JSON.stringify({ + error: 'Insufficient permissions', + requestId + }), + { + status: 403, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ); + } + } } // ==================================================================== - // STEP 5: Execute handler + // STEP 7: Execute handler // ==================================================================== addSpanEvent(span, 'handler_start'); @@ -164,6 +236,8 @@ export function wrapEdgeFunction( requestId, span, userId, + user, + supabase, }; const response = await handler(req, context); diff --git a/supabase/functions/receive-inbound-email/index.ts b/supabase/functions/receive-inbound-email/index.ts index b891895c..ad5971ee 100644 --- a/supabase/functions/receive-inbound-email/index.ts +++ b/supabase/functions/receive-inbound-email/index.ts @@ -1,9 +1,7 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { corsHeaders } from '../_shared/cors.ts'; -import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; -import { createErrorResponse } from "../_shared/errorSanitizer.ts"; -import { formatEdgeError } from "../_shared/errorFormatter.ts"; +import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; interface InboundEmailPayload { from: string; @@ -17,251 +15,216 @@ interface InboundEmailPayload { headers: Record; } -const handler = async (req: Request): Promise => { - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); +const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext): Promise => { + const payload: InboundEmailPayload = await req.json(); + const { from, to, subject, text, html, messageId, inReplyTo, references, headers } = payload; + + addSpanEvent(span, 'email_received', { + from, + to, + messageId, + hasInReplyTo: !!inReplyTo + }); + + // Extract thread ID from headers or inReplyTo + let threadId = headers['X-Thread-ID'] || + (inReplyTo ? inReplyTo.replace(/<|>/g, '').split('@')[0] : null); + + // If no thread ID, this is a NEW direct email (not a reply) + const isNewEmail = !threadId; + + if (isNewEmail) { + addSpanEvent(span, 'new_direct_email', { from, subject }); } - const tracking = startRequest(); + // Find or create submission + let submission = null; - try { - const supabase = createClient( - Deno.env.get('SUPABASE_URL')!, - Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! - ); - - const payload: InboundEmailPayload = await req.json(); - const { from, to, subject, text, html, messageId, inReplyTo, references, headers } = payload; - - edgeLogger.info('Inbound email received', { - requestId: tracking.requestId, - from, - to, - messageId - }); - - // Extract thread ID from headers or inReplyTo - let threadId = headers['X-Thread-ID'] || - (inReplyTo ? inReplyTo.replace(/<|>/g, '').split('@')[0] : null); - - // If no thread ID, this is a NEW direct email (not a reply) - const isNewEmail = !threadId; - - if (isNewEmail) { - edgeLogger.info('New direct email received (no thread ID)', { - requestId: tracking.requestId, - from, - subject, - messageId + if (isNewEmail) { + // Extract sender email + const senderEmail = from.match(/<(.+)>/)?.[1] || from; + const senderName = from.match(/^(.+?)\s*/)?.[1] || from; - const senderName = from.match(/^(.+?)\s*/)?.[1] || from; - if (senderEmail.toLowerCase() !== submission.email.toLowerCase()) { - edgeLogger.warn('Sender email mismatch', { - requestId: tracking.requestId, - expected: submission.email, - received: senderEmail - }); - return new Response(JSON.stringify({ success: false, reason: 'email_mismatch' }), { - status: 200, - headers: { ...corsHeaders, 'Content-Type': 'application/json' } - }); - } } - // Insert email thread record + // Verify sender email matches (only for existing submissions) const senderEmail = from.match(/<(.+)>/)?.[1] || from; - const { error: insertError } = await supabase - .from('contact_email_threads') - .insert({ - submission_id: submission.id, - message_id: messageId, - in_reply_to: inReplyTo || null, - reference_chain: references || [], - from_email: senderEmail, - to_email: to, - subject, - body_text: text, - body_html: html, - direction: 'inbound', - metadata: { - received_at: new Date().toISOString(), - headers: headers, - is_new_ticket: isNewEmail - } + if (senderEmail.toLowerCase() !== submission.email.toLowerCase()) { + addSpanEvent(span, 'email_mismatch', { + expected: submission.email, + received: senderEmail }); - - if (insertError) { - edgeLogger.error('Failed to insert inbound email thread', { - requestId: tracking.requestId, - error: insertError + return new Response(JSON.stringify({ success: false, reason: 'email_mismatch' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } }); - return createErrorResponse(insertError, 500, corsHeaders); } - - // Update submission status if pending - if (submission.status === 'pending') { - await supabase - .from('contact_submissions') - .update({ status: 'in_progress' }) - .eq('id', submission.id); - } - - edgeLogger.info('Inbound email processed successfully', { - requestId: tracking.requestId, - submissionId: submission.id, - duration: endRequest(tracking) - }); - - return new Response( - JSON.stringify({ success: true }), - { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } - ); - - } catch (error) { - edgeLogger.error('Unexpected error in receive-inbound-email', { - requestId: tracking.requestId, - error: formatEdgeError(error) - }); - return createErrorResponse(error, 500, corsHeaders); } + + // Insert email thread record + const senderEmail = from.match(/<(.+)>/)?.[1] || from; + const { error: insertError } = await supabase + .from('contact_email_threads') + .insert({ + submission_id: submission.id, + message_id: messageId, + in_reply_to: inReplyTo || null, + reference_chain: references || [], + from_email: senderEmail, + to_email: to, + subject, + body_text: text, + body_html: html, + direction: 'inbound', + metadata: { + received_at: new Date().toISOString(), + headers: headers, + is_new_ticket: isNewEmail + } + }); + + if (insertError) { + addSpanEvent(span, 'thread_insert_failed', { error: insertError }); + throw insertError; + } + + addSpanEvent(span, 'thread_inserted'); + + // Update submission status if pending + if (submission.status === 'pending') { + await supabase + .from('contact_submissions') + .update({ status: 'in_progress' }) + .eq('id', submission.id); + + addSpanEvent(span, 'status_updated', { newStatus: 'in_progress' }); + } + + addSpanEvent(span, 'email_processed', { submissionId: submission.id }); + + return new Response( + JSON.stringify({ success: true }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); }; -serve(handler); \ No newline at end of file +serve(createEdgeFunction({ + name: 'receive-inbound-email', + requireAuth: false, + useServiceRole: true, + corsHeaders, + logRequests: true, + logResponses: true, +}, handler)); diff --git a/supabase/functions/send-admin-email-reply/index.ts b/supabase/functions/send-admin-email-reply/index.ts index 977d120d..8ade1a40 100644 --- a/supabase/functions/send-admin-email-reply/index.ts +++ b/supabase/functions/send-admin-email-reply/index.ts @@ -1,9 +1,7 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; -import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { corsHeaders } from '../_shared/cors.ts'; -import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; -import { createErrorResponse } from "../_shared/errorSanitizer.ts"; -import { formatEdgeError } from "../_shared/errorFormatter.ts"; +import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; interface AdminReplyRequest { submissionId: string; @@ -11,233 +9,217 @@ interface AdminReplyRequest { replySubject?: string; } -const handler = async (req: Request): Promise => { - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); - } - - const tracking = startRequest(); - - try { - const authHeader = req.headers.get('Authorization'); - if (!authHeader) { - return createErrorResponse({ message: 'Unauthorized' }, 401, corsHeaders); - } - - const supabase = createClient( - Deno.env.get('SUPABASE_URL')!, - Deno.env.get('SUPABASE_ANON_KEY')!, - { global: { headers: { Authorization: authHeader } } } - ); - - const { data: { user }, error: userError } = await supabase.auth.getUser(); - if (userError || !user) { - return createErrorResponse({ message: 'Unauthorized' }, 401, corsHeaders); - } - - // Verify admin, moderator, or superuser role - const { data: isSuperuser } = await supabase - .rpc('has_role', { _user_id: user.id, _role: 'superuser' }); - const { data: isAdmin } = await supabase - .rpc('has_role', { _user_id: user.id, _role: 'admin' }); - const { data: isModerator } = await supabase - .rpc('has_role', { _user_id: user.id, _role: 'moderator' }); - - if (!isSuperuser && !isAdmin && !isModerator) { - edgeLogger.warn('Non-privileged user attempted email reply', { - requestId: tracking.requestId, - userId: user.id - }); - return createErrorResponse({ message: 'Admin access required' }, 403, corsHeaders); - } - - const body: AdminReplyRequest = await req.json(); - const { submissionId, replyBody, replySubject } = body; - - if (!submissionId || !replyBody) { - return createErrorResponse({ message: 'Missing required fields' }, 400, corsHeaders); - } - - if (replyBody.length < 10 || replyBody.length > 5000) { - return createErrorResponse({ - message: 'Reply must be between 10 and 5000 characters' - }, 400, corsHeaders); - } - - // Get admin email from environment variable - const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS') || 'admins@thrillwiki.com'; - const adminDisplayName = Deno.env.get('ADMIN_EMAIL_DISPLAY_NAME') || 'ThrillWiki Admin'; - - // Fetch admin's profile for signature - const { data: adminProfile } = await supabase - .from('profiles') - .select('display_name, username') - .eq('user_id', user.id) - .single(); - - const adminName = adminProfile?.display_name || adminProfile?.username || 'ThrillWiki Team'; - - // Fetch submission - const { data: submission, error: fetchError } = await supabase - .from('contact_submissions') - .select('id, email, name, subject, thread_id, response_count, ticket_number') - .eq('id', submissionId) - .single(); - - if (fetchError || !submission) { - return createErrorResponse({ message: 'Submission not found' }, 404, corsHeaders); - } - - // Fetch email signature from admin settings - const { data: signatureSetting } = await supabase - .from('admin_settings') - .select('setting_value') - .eq('setting_key', 'email.signature') - .single(); - - const emailSignature = signatureSetting?.setting_value?.signature || ''; - - // Build signature with admin name + global signature - const finalReplyBody = emailSignature - ? `${replyBody}\n\n---\n${adminName}\n${emailSignature}` - : `${replyBody}\n\n---\n${adminName}`; - - // Rate limiting: max 10 replies per hour - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); - const { count } = await supabase - .from('contact_email_threads') - .select('*', { count: 'exact', head: true }) - .eq('submission_id', submissionId) - .eq('direction', 'outbound') - .gte('created_at', oneHourAgo); - - if (count && count >= 10) { - return createErrorResponse({ - message: 'Rate limit exceeded. Max 10 replies per hour.' - }, 429, corsHeaders); - } - - const ticketNumber = submission.ticket_number || 'UNKNOWN'; - const messageId = `<${ticketNumber}.${crypto.randomUUID()}@thrillwiki.com>`; - const finalSubject = replySubject || `Re: [${ticketNumber}] ${submission.subject}`; - - // Get previous message for threading - const { data: previousMessages } = await supabase - .from('contact_email_threads') - .select('message_id') - .eq('submission_id', submissionId) - .order('created_at', { ascending: false }) - .limit(1); - - const originalMessageId = `<${ticketNumber}.${submission.id}@thrillwiki.com>`; - const inReplyTo = previousMessages?.[0]?.message_id || originalMessageId; - - // Build reference chain for threading - const referenceChain = previousMessages?.[0]?.message_id - ? [originalMessageId, previousMessages[0].message_id].join(' ') - : originalMessageId; - - // Send email via ForwardEmail - const forwardEmailResponse = await fetch('https://api.forwardemail.net/v1/emails', { - method: 'POST', - headers: { - 'Authorization': `Basic ${btoa(Deno.env.get('FORWARDEMAIL_API_KEY') + ':')}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - from: `${adminDisplayName} <${adminEmail}>`, - to: `${submission.name} <${submission.email}>`, - subject: finalSubject, - text: finalReplyBody, - headers: { - 'Message-ID': messageId, - 'In-Reply-To': inReplyTo, - 'References': referenceChain, - 'X-Thread-ID': submission.thread_id, - 'X-Ticket-Number': ticketNumber - } - }) - }); - - if (!forwardEmailResponse.ok) { - const errorText = await forwardEmailResponse.text(); - edgeLogger.error('ForwardEmail API error', { - requestId: tracking.requestId, - status: forwardEmailResponse.status, - error: errorText - }); - return createErrorResponse({ message: 'Failed to send email' }, 500, corsHeaders); - } - - // Insert email thread record - const { error: insertError } = await supabase - .from('contact_email_threads') - .insert({ - submission_id: submissionId, - message_id: messageId, - in_reply_to: inReplyTo, - reference_chain: [inReplyTo], - from_email: adminEmail, - to_email: submission.email, - subject: finalSubject, - body_text: finalReplyBody, - direction: 'outbound', - sent_by: user.id, - metadata: { - admin_email: user.email, - sent_at: new Date().toISOString() - } - }); - - if (insertError) { - edgeLogger.error('Failed to insert email thread', { - requestId: tracking.requestId, - error: insertError - }); - } - - // Update submission - await supabase - .from('contact_submissions') - .update({ - last_admin_response_at: new Date().toISOString(), - response_count: (submission.response_count || 0) + 1, - status: 'in_progress' - }) - .eq('id', submissionId); - - // Audit log - await supabase - .from('admin_audit_log') - .insert({ - admin_user_id: user.id, - target_user_id: user.id, - action: 'send_contact_email_reply', - details: { - submission_id: submissionId, - recipient: submission.email, - subject: finalSubject - } - }); - - edgeLogger.info('Admin email reply sent successfully', { - requestId: tracking.requestId, - submissionId, - duration: endRequest(tracking) - }); +const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext): Promise => { + const body: AdminReplyRequest = await req.json(); + const { submissionId, replyBody, replySubject } = body; + // Validate request + if (!submissionId || !replyBody) { + addSpanEvent(span, 'validation_failed', { reason: 'missing_fields' }); return new Response( - JSON.stringify({ success: true, messageId }), - { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + JSON.stringify({ error: 'Missing required fields', requestId }), + { status: 400, headers: { 'Content-Type': 'application/json' } } ); - - } catch (error) { - edgeLogger.error('Unexpected error in send-admin-email-reply', { - requestId: tracking.requestId, - error: formatEdgeError(error) - }); - return createErrorResponse(error, 500, corsHeaders); } + + if (replyBody.length < 10 || replyBody.length > 5000) { + addSpanEvent(span, 'validation_failed', { reason: 'invalid_length', length: replyBody.length }); + return new Response( + JSON.stringify({ error: 'Reply must be between 10 and 5000 characters', requestId }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + addSpanEvent(span, 'request_validated', { submissionId }); + + // Get admin email from environment variable + const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS') || 'admins@thrillwiki.com'; + const adminDisplayName = Deno.env.get('ADMIN_EMAIL_DISPLAY_NAME') || 'ThrillWiki Admin'; + + // Fetch admin's profile for signature + const { data: adminProfile } = await supabase + .from('profiles') + .select('display_name, username') + .eq('user_id', user.id) + .single(); + + const adminName = adminProfile?.display_name || adminProfile?.username || 'ThrillWiki Team'; + + // Fetch submission + const { data: submission, error: fetchError } = await supabase + .from('contact_submissions') + .select('id, email, name, subject, thread_id, response_count, ticket_number') + .eq('id', submissionId) + .single(); + + if (fetchError || !submission) { + addSpanEvent(span, 'submission_not_found', { submissionId }); + return new Response( + JSON.stringify({ error: 'Submission not found', requestId }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + + addSpanEvent(span, 'submission_fetched', { + ticketNumber: submission.ticket_number, + recipientEmail: submission.email + }); + + // Fetch email signature from admin settings + const { data: signatureSetting } = await supabase + .from('admin_settings') + .select('setting_value') + .eq('setting_key', 'email.signature') + .single(); + + const emailSignature = signatureSetting?.setting_value?.signature || ''; + + // Build signature with admin name + global signature + const finalReplyBody = emailSignature + ? `${replyBody}\n\n---\n${adminName}\n${emailSignature}` + : `${replyBody}\n\n---\n${adminName}`; + + // Rate limiting: max 10 replies per hour + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const { count } = await supabase + .from('contact_email_threads') + .select('*', { count: 'exact', head: true }) + .eq('submission_id', submissionId) + .eq('direction', 'outbound') + .gte('created_at', oneHourAgo); + + if (count && count >= 10) { + addSpanEvent(span, 'rate_limit_exceeded', { count }); + return new Response( + JSON.stringify({ error: 'Rate limit exceeded. Max 10 replies per hour.', requestId }), + { status: 429, headers: { 'Content-Type': 'application/json' } } + ); + } + + const ticketNumber = submission.ticket_number || 'UNKNOWN'; + const messageId = `<${ticketNumber}.${crypto.randomUUID()}@thrillwiki.com>`; + const finalSubject = replySubject || `Re: [${ticketNumber}] ${submission.subject}`; + + // Get previous message for threading + const { data: previousMessages } = await supabase + .from('contact_email_threads') + .select('message_id') + .eq('submission_id', submissionId) + .order('created_at', { ascending: false }) + .limit(1); + + const originalMessageId = `<${ticketNumber}.${submission.id}@thrillwiki.com>`; + const inReplyTo = previousMessages?.[0]?.message_id || originalMessageId; + + // Build reference chain for threading + const referenceChain = previousMessages?.[0]?.message_id + ? [originalMessageId, previousMessages[0].message_id].join(' ') + : originalMessageId; + + addSpanEvent(span, 'sending_email', { + messageId, + recipient: submission.email, + subject: finalSubject + }); + + // Send email via ForwardEmail + const forwardEmailResponse = await fetch('https://api.forwardemail.net/v1/emails', { + method: 'POST', + headers: { + 'Authorization': `Basic ${btoa(Deno.env.get('FORWARDEMAIL_API_KEY') + ':')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + from: `${adminDisplayName} <${adminEmail}>`, + to: `${submission.name} <${submission.email}>`, + subject: finalSubject, + text: finalReplyBody, + headers: { + 'Message-ID': messageId, + 'In-Reply-To': inReplyTo, + 'References': referenceChain, + 'X-Thread-ID': submission.thread_id, + 'X-Ticket-Number': ticketNumber + } + }) + }); + + if (!forwardEmailResponse.ok) { + const errorText = await forwardEmailResponse.text(); + addSpanEvent(span, 'email_send_failed', { + status: forwardEmailResponse.status, + error: errorText + }); + throw new Error(`ForwardEmail API error: ${errorText}`); + } + + addSpanEvent(span, 'email_sent', { messageId }); + + // Insert email thread record + const { error: insertError } = await supabase + .from('contact_email_threads') + .insert({ + submission_id: submissionId, + message_id: messageId, + in_reply_to: inReplyTo, + reference_chain: [inReplyTo], + from_email: adminEmail, + to_email: submission.email, + subject: finalSubject, + body_text: finalReplyBody, + direction: 'outbound', + sent_by: user.id, + metadata: { + admin_email: user.email, + sent_at: new Date().toISOString() + } + }); + + if (insertError) { + addSpanEvent(span, 'thread_insert_failed', { error: insertError }); + } else { + addSpanEvent(span, 'thread_inserted'); + } + + // Update submission + await supabase + .from('contact_submissions') + .update({ + last_admin_response_at: new Date().toISOString(), + response_count: (submission.response_count || 0) + 1, + status: 'in_progress' + }) + .eq('id', submissionId); + + addSpanEvent(span, 'submission_updated'); + + // Audit log + await supabase + .from('admin_audit_log') + .insert({ + admin_user_id: user.id, + target_user_id: user.id, + action: 'send_contact_email_reply', + details: { + submission_id: submissionId, + recipient: submission.email, + subject: finalSubject + } + }); + + addSpanEvent(span, 'audit_logged'); + + return new Response( + JSON.stringify({ success: true, messageId }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); }; -serve(handler); \ No newline at end of file +serve(createEdgeFunction({ + name: 'send-admin-email-reply', + requireAuth: true, + requiredRoles: ['superuser', 'admin', 'moderator'], + corsHeaders, + logRequests: true, + logResponses: true, +}, handler)); diff --git a/supabase/functions/validate-email/index.ts b/supabase/functions/validate-email/index.ts index 31235bb2..4e18d94a 100644 --- a/supabase/functions/validate-email/index.ts +++ b/supabase/functions/validate-email/index.ts @@ -1,7 +1,7 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { corsHeaders } from '../_shared/cors.ts'; -import { startRequest, endRequest, edgeLogger } from "../_shared/logger.ts"; -import { formatEdgeError } from "../_shared/errorFormatter.ts"; +import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts'; +import { addSpanEvent } from '../_shared/logger.ts'; // Comprehensive list of disposable email domains const DISPOSABLE_DOMAINS = new Set([ @@ -64,143 +64,91 @@ interface ValidationResult { valid: boolean; reason?: string; suggestions?: string[]; + requestId: string; } -const handler = async (req: Request): Promise => { - // Handle CORS preflight requests - if (req.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); - } +const handler = async (req: Request, { span, requestId }: EdgeFunctionContext): Promise => { + const { email }: ValidateEmailRequest = await req.json(); - const tracking = startRequest('validate-email'); - - try { - const { email }: ValidateEmailRequest = await req.json(); - - if (!email || typeof email !== 'string') { - endRequest(tracking, 400, 'Email address is required'); - - return new Response( - JSON.stringify({ - valid: false, - reason: 'Email address is required', - requestId: tracking.requestId - } as ValidationResult), - { - status: 400, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - } - } - ); - } - - // Basic email format validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - endRequest(tracking, 400, 'Invalid email format'); - - return new Response( - JSON.stringify({ - valid: false, - reason: 'Invalid email format', - requestId: tracking.requestId - } as ValidationResult), - { - status: 400, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - } - } - ); - } - - // Extract domain - const domain = email.split('@')[1].toLowerCase(); - - // Check if domain is disposable - if (DISPOSABLE_DOMAINS.has(domain)) { - edgeLogger.info('Blocked disposable email domain', { - domain, - requestId: tracking.requestId - }); - - endRequest(tracking, 400, 'Disposable email domain blocked'); - - return new Response( - JSON.stringify({ - valid: false, - reason: 'Disposable email addresses are not allowed. Please use a permanent email address.', - suggestions: [ - 'Use a personal email (Gmail, Outlook, Yahoo, etc.)', - 'Use your work or school email address', - 'Use an email from your own domain' - ], - requestId: tracking.requestId - } as ValidationResult), - { - status: 400, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - } - } - ); - } - - // Email is valid - edgeLogger.info('Email validated successfully', { - email, - requestId: tracking.requestId - }); - - endRequest(tracking, 200); - - return new Response( - JSON.stringify({ - valid: true, - requestId: tracking.requestId - } as ValidationResult), - { - status: 200, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - } - } - ); - - } catch (error) { - const errorMessage = formatEdgeError(error); - edgeLogger.error('Error in validate-email function', { - error: errorMessage, - requestId: tracking.requestId - }); - - endRequest(tracking, 500, error.message); + if (!email || typeof email !== 'string') { + addSpanEvent(span, 'validation_failed', { reason: 'missing_email' }); return new Response( JSON.stringify({ valid: false, - reason: 'Internal server error during email validation', - requestId: tracking.requestId + reason: 'Email address is required', + requestId } as ValidationResult), { - status: 500, - headers: { - ...corsHeaders, - 'Content-Type': 'application/json', - 'X-Request-ID': tracking.requestId - } + status: 400, + headers: { 'Content-Type': 'application/json' } } ); } + + // Basic email format validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + addSpanEvent(span, 'validation_failed', { reason: 'invalid_format' }); + + return new Response( + JSON.stringify({ + valid: false, + reason: 'Invalid email format', + requestId + } as ValidationResult), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // Extract domain + const domain = email.split('@')[1].toLowerCase(); + addSpanEvent(span, 'domain_extracted', { domain }); + + // Check if domain is disposable + if (DISPOSABLE_DOMAINS.has(domain)) { + addSpanEvent(span, 'disposable_domain_blocked', { domain }); + + return new Response( + JSON.stringify({ + valid: false, + reason: 'Disposable email addresses are not allowed. Please use a permanent email address.', + suggestions: [ + 'Use a personal email (Gmail, Outlook, Yahoo, etc.)', + 'Use your work or school email address', + 'Use an email from your own domain' + ], + requestId + } as ValidationResult), + { + status: 400, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // Email is valid + addSpanEvent(span, 'email_validated', { email }); + + return new Response( + JSON.stringify({ + valid: true, + requestId + } as ValidationResult), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); }; -serve(handler); +serve(createEdgeFunction({ + name: 'validate-email', + requireAuth: false, + corsHeaders, + logRequests: true, + logResponses: true, +}, handler));