Migrate 3 edge functions to wrapper

- Refactor validate-email, receive-inbound-email, and send-admin-email-reply to use createEdgeFunction wrapper with automatic error handling, tracing, and reduced boilerplate.
- enrich wrapper to support service-role usage and role-based authorization context for complex flows.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-11 20:09:42 +00:00
parent afe7a93f69
commit 4040fd783e
4 changed files with 563 additions and 596 deletions

View File

@@ -21,10 +21,13 @@ import {
} from './logger.ts'; } from './logger.ts';
import { formatEdgeError, toError } from './errorFormatter.ts'; import { formatEdgeError, toError } from './errorFormatter.ts';
import { ValidationError, logValidationError } from './typeValidation.ts'; import { ValidationError, logValidationError } from './typeValidation.ts';
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
export interface EdgeFunctionConfig { export interface EdgeFunctionConfig {
name: string; name: string;
requireAuth?: boolean; requireAuth?: boolean;
requiredRoles?: string[];
useServiceRole?: boolean;
corsHeaders?: HeadersInit; corsHeaders?: HeadersInit;
logRequests?: boolean; logRequests?: boolean;
logResponses?: boolean; logResponses?: boolean;
@@ -34,6 +37,8 @@ export interface EdgeFunctionContext {
requestId: string; requestId: string;
span: Span; span: Span;
userId?: string; userId?: string;
user?: any;
supabase: any;
} }
export type EdgeFunctionHandler = ( export type EdgeFunctionHandler = (
@@ -51,6 +56,8 @@ export function wrapEdgeFunction(
const { const {
name, name,
requireAuth = true, requireAuth = true,
requiredRoles = [],
useServiceRole = false,
corsHeaders = {}, corsHeaders = {},
logRequests = true, logRequests = true,
logResponses = true, logResponses = true,
@@ -100,13 +107,39 @@ export function wrapEdgeFunction(
try { 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 userId: string | undefined;
let user: any = undefined;
if (requireAuth) { if (requireAuth) {
addSpanEvent(span, 'authentication_start'); addSpanEvent(span, 'authentication_start');
const authHeader = req.headers.get('Authorization');
if (!authHeader) { if (!authHeader) {
addSpanEvent(span, 'authentication_failed', { reason: 'missing_header' }); addSpanEvent(span, 'authentication_failed', { reason: 'missing_header' });
@@ -125,21 +158,15 @@ export function wrapEdgeFunction(
); );
} }
// Extract user ID from JWT (simplified - extend as needed) // Get user from Supabase
try { const { data: { user: authUser }, error: userError } = await supabase.auth.getUser();
// Note: In production, validate the JWT properly
const token = authHeader.replace('Bearer ', ''); if (userError || !authUser) {
const payload = JSON.parse(atob(token.split('.')[1]));
userId = payload.sub;
addSpanEvent(span, 'authentication_success', { userId });
span.attributes['user.id'] = userId;
} catch (error) {
addSpanEvent(span, 'authentication_failed', { addSpanEvent(span, 'authentication_failed', {
reason: 'invalid_token', reason: 'invalid_token',
error: formatEdgeError(error) error: formatEdgeError(userError)
}); });
endSpan(span, 'error', error); endSpan(span, 'error', userError);
logSpan(span); logSpan(span);
return new Response( 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'); addSpanEvent(span, 'handler_start');
@@ -164,6 +236,8 @@ export function wrapEdgeFunction(
requestId, requestId,
span, span,
userId, userId,
user,
supabase,
}; };
const response = await handler(req, context); const response = await handler(req, context);

View File

@@ -1,9 +1,7 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; 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 { corsHeaders } from '../_shared/cors.ts';
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
import { createErrorResponse } from "../_shared/errorSanitizer.ts"; import { addSpanEvent } from '../_shared/logger.ts';
import { formatEdgeError } from "../_shared/errorFormatter.ts";
interface InboundEmailPayload { interface InboundEmailPayload {
from: string; from: string;
@@ -17,251 +15,216 @@ interface InboundEmailPayload {
headers: Record<string, string>; headers: Record<string, string>;
} }
const handler = async (req: Request): Promise<Response> => { const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext): Promise<Response> => {
if (req.method === 'OPTIONS') { const payload: InboundEmailPayload = await req.json();
return new Response(null, { headers: corsHeaders }); 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 { if (isNewEmail) {
const supabase = createClient( // Extract sender email
Deno.env.get('SUPABASE_URL')!, const senderEmail = from.match(/<(.+)>/)?.[1] || from;
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! const senderName = from.match(/^(.+?)\s*</)?.[1]?.trim() || senderEmail.split('@')[0];
);
// Check for existing submission from this email in last 5 minutes (avoid duplicates)
const payload: InboundEmailPayload = await req.json(); const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const { from, to, subject, text, html, messageId, inReplyTo, references, headers } = payload; const { data: existingRecent } = await supabase
.from('contact_submissions')
edgeLogger.info('Inbound email received', { .select('id, ticket_number, thread_id, email')
requestId: tracking.requestId, .eq('email', senderEmail.toLowerCase())
from, .eq('subject', subject || '(No Subject)')
to, .gte('created_at', fiveMinutesAgo)
messageId .maybeSingle();
});
if (existingRecent) {
// Extract thread ID from headers or inReplyTo // Use existing recent submission (duplicate email)
let threadId = headers['X-Thread-ID'] || submission = existingRecent;
(inReplyTo ? inReplyTo.replace(/<|>/g, '').split('@')[0] : null); threadId = existingRecent.thread_id;
// If no thread ID, this is a NEW direct email (not a reply) addSpanEvent(span, 'duplicate_submission_found', {
const isNewEmail = !threadId; submissionId: existingRecent.id,
ticketNumber: existingRecent.ticket_number
if (isNewEmail) { });
edgeLogger.info('New direct email received (no thread ID)', { } else {
requestId: tracking.requestId, // Create new contact submission
from, const { data: newSubmission, error: createError } = await supabase
subject, .from('contact_submissions')
messageId .insert({
name: senderName,
email: senderEmail.toLowerCase(),
subject: subject || '(No Subject)',
message: text || html || '(Empty message)',
category: 'general',
status: 'pending',
user_agent: 'Email Client',
ip_address_hash: null
})
.select('id, ticket_number, email, status')
.single();
if (createError || !newSubmission) {
addSpanEvent(span, 'submission_creation_failed', { error: createError });
throw createError;
}
submission = newSubmission;
threadId = `${newSubmission.ticket_number}.${newSubmission.id}`;
// Update thread_id
await supabase
.from('contact_submissions')
.update({ thread_id: threadId })
.eq('id', newSubmission.id);
addSpanEvent(span, 'submission_created', {
submissionId: newSubmission.id,
ticketNumber: newSubmission.ticket_number,
threadId
}); });
} }
} else {
// Find submission by thread_id or ticket_number
const ticketMatch = threadId.match(/(?:ticket-)?(TW-\d+)/i);
const ticketNumber = ticketMatch ? ticketMatch[1] : null;
// Find or create submission addSpanEvent(span, 'thread_lookup', { threadId, ticketNumber });
let submission = null;
let submissionError = null;
if (isNewEmail) { // Strategy 1: Try exact thread_id match
// Extract sender email const { data: submissionByThreadId } = await supabase
const senderEmail = from.match(/<(.+)>/)?.[1] || from; .from('contact_submissions')
const senderName = from.match(/^(.+?)\s*</)?.[1]?.trim() || senderEmail.split('@')[0]; .select('id, email, status, ticket_number')
.eq('thread_id', threadId)
// Check for existing submission from this email in last 5 minutes (avoid duplicates) .maybeSingle();
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const { data: existingRecent } = await supabase if (submissionByThreadId) {
submission = submissionByThreadId;
addSpanEvent(span, 'submission_found_by_thread_id', { submissionId: submission.id });
} else if (ticketNumber) {
// Strategy 2: Try ticket_number match
const { data: submissionByTicket, error: error2 } = await supabase
.from('contact_submissions') .from('contact_submissions')
.select('id, ticket_number, thread_id, email') .select('id, email, status, ticket_number, thread_id')
.eq('email', senderEmail.toLowerCase()) .eq('ticket_number', ticketNumber)
.eq('subject', subject || '(No Subject)')
.gte('created_at', fiveMinutesAgo)
.maybeSingle(); .maybeSingle();
if (existingRecent) { if (submissionByTicket) {
// Use existing recent submission (duplicate email) submission = submissionByTicket;
submission = existingRecent;
threadId = existingRecent.thread_id;
edgeLogger.info('Using existing recent submission', { // Update thread_id if it's null or in old format
requestId: tracking.requestId, if (!submissionByTicket.thread_id || submissionByTicket.thread_id !== threadId) {
submissionId: existingRecent.id, await supabase
ticketNumber: existingRecent.ticket_number .from('contact_submissions')
}); .update({ thread_id: threadId })
} else { .eq('id', submissionByTicket.id);
// Create new contact submission
const { data: newSubmission, error: createError } = await supabase addSpanEvent(span, 'thread_id_updated', {
.from('contact_submissions') submissionId: submissionByTicket.id,
.insert({ oldThreadId: submissionByTicket.thread_id,
name: senderName, newThreadId: threadId
email: senderEmail.toLowerCase(),
subject: subject || '(No Subject)',
message: text || html || '(Empty message)',
category: 'general',
status: 'pending',
user_agent: 'Email Client',
ip_address_hash: null
})
.select('id, ticket_number, email, status')
.single();
if (createError || !newSubmission) {
edgeLogger.error('Failed to create submission from direct email', {
requestId: tracking.requestId,
error: createError
}); });
return createErrorResponse(createError, 500, corsHeaders);
} }
submission = newSubmission; addSpanEvent(span, 'submission_found_by_ticket_number', { submissionId: submission.id });
threadId = `${newSubmission.ticket_number}.${newSubmission.id}`; } else {
addSpanEvent(span, 'submission_not_found', { threadId, ticketNumber });
// Update thread_id return new Response(JSON.stringify({ success: false, reason: 'submission_not_found' }), {
await supabase status: 200,
.from('contact_submissions') headers: { 'Content-Type': 'application/json' }
.update({ thread_id: threadId })
.eq('id', newSubmission.id);
edgeLogger.info('Created new submission from direct email', {
requestId: tracking.requestId,
submissionId: newSubmission.id,
ticketNumber: newSubmission.ticket_number,
threadId
}); });
} }
} else { } else {
// EXISTING LOGIC: Find submission by thread_id or ticket_number addSpanEvent(span, 'submission_not_found', { threadId });
const ticketMatch = threadId.match(/(?:ticket-)?(TW-\d+)/i); return new Response(JSON.stringify({ success: false, reason: 'submission_not_found' }), {
const ticketNumber = ticketMatch ? ticketMatch[1] : null; status: 200,
headers: { 'Content-Type': 'application/json' }
edgeLogger.info('Thread ID extracted', {
requestId: tracking.requestId,
rawThreadId: threadId,
ticketNumber
}); });
// Strategy 1: Try exact thread_id match
const { data: submissionByThreadId, error: error1 } = await supabase
.from('contact_submissions')
.select('id, email, status, ticket_number')
.eq('thread_id', threadId)
.maybeSingle();
if (submissionByThreadId) {
submission = submissionByThreadId;
} else if (ticketNumber) {
// Strategy 2: Try ticket_number match
const { data: submissionByTicket, error: error2 } = await supabase
.from('contact_submissions')
.select('id, email, status, ticket_number, thread_id')
.eq('ticket_number', ticketNumber)
.maybeSingle();
if (submissionByTicket) {
submission = submissionByTicket;
// Update thread_id if it's null or in old format
if (!submissionByTicket.thread_id || submissionByTicket.thread_id !== threadId) {
await supabase
.from('contact_submissions')
.update({ thread_id: threadId })
.eq('id', submissionByTicket.id);
edgeLogger.info('Updated submission thread_id', {
requestId: tracking.requestId,
submissionId: submissionByTicket.id,
oldThreadId: submissionByTicket.thread_id,
newThreadId: threadId
});
}
} else {
submissionError = error2;
}
} else {
submissionError = error1;
}
if (submissionError || !submission) {
edgeLogger.warn('Submission not found for thread ID', {
requestId: tracking.requestId,
threadId,
ticketNumber,
error: submissionError
});
return new Response(JSON.stringify({ success: false, reason: 'submission_not_found' }), {
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
}
// Verify sender email matches (only for existing submissions)
const senderEmail = from.match(/<(.+)>/)?.[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 senderEmail = from.match(/<(.+)>/)?.[1] || from;
const { error: insertError } = await supabase if (senderEmail.toLowerCase() !== submission.email.toLowerCase()) {
.from('contact_email_threads') addSpanEvent(span, 'email_mismatch', {
.insert({ expected: submission.email,
submission_id: submission.id, received: senderEmail
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
}
}); });
return new Response(JSON.stringify({ success: false, reason: 'email_mismatch' }), {
if (insertError) { status: 200,
edgeLogger.error('Failed to insert inbound email thread', { headers: { 'Content-Type': 'application/json' }
requestId: tracking.requestId,
error: insertError
}); });
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); serve(createEdgeFunction({
name: 'receive-inbound-email',
requireAuth: false,
useServiceRole: true,
corsHeaders,
logRequests: true,
logResponses: true,
}, handler));

View File

@@ -1,9 +1,7 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; 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 { corsHeaders } from '../_shared/cors.ts';
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
import { createErrorResponse } from "../_shared/errorSanitizer.ts"; import { addSpanEvent } from '../_shared/logger.ts';
import { formatEdgeError } from "../_shared/errorFormatter.ts";
interface AdminReplyRequest { interface AdminReplyRequest {
submissionId: string; submissionId: string;
@@ -11,233 +9,217 @@ interface AdminReplyRequest {
replySubject?: string; replySubject?: string;
} }
const handler = async (req: Request): Promise<Response> => { const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext): Promise<Response> => {
if (req.method === 'OPTIONS') { const body: AdminReplyRequest = await req.json();
return new Response(null, { headers: corsHeaders }); const { submissionId, replyBody, replySubject } = body;
}
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)
});
// Validate request
if (!submissionId || !replyBody) {
addSpanEvent(span, 'validation_failed', { reason: 'missing_fields' });
return new Response( return new Response(
JSON.stringify({ success: true, messageId }), JSON.stringify({ error: 'Missing required fields', requestId }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } { 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); serve(createEdgeFunction({
name: 'send-admin-email-reply',
requireAuth: true,
requiredRoles: ['superuser', 'admin', 'moderator'],
corsHeaders,
logRequests: true,
logResponses: true,
}, handler));

View File

@@ -1,7 +1,7 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { corsHeaders } from '../_shared/cors.ts'; import { corsHeaders } from '../_shared/cors.ts';
import { startRequest, endRequest, edgeLogger } from "../_shared/logger.ts"; import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
import { formatEdgeError } from "../_shared/errorFormatter.ts"; import { addSpanEvent } from '../_shared/logger.ts';
// Comprehensive list of disposable email domains // Comprehensive list of disposable email domains
const DISPOSABLE_DOMAINS = new Set([ const DISPOSABLE_DOMAINS = new Set([
@@ -64,143 +64,91 @@ interface ValidationResult {
valid: boolean; valid: boolean;
reason?: string; reason?: string;
suggestions?: string[]; suggestions?: string[];
requestId: string;
} }
const handler = async (req: Request): Promise<Response> => { const handler = async (req: Request, { span, requestId }: EdgeFunctionContext): Promise<Response> => {
// Handle CORS preflight requests const { email }: ValidateEmailRequest = await req.json();
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const tracking = startRequest('validate-email'); if (!email || typeof email !== 'string') {
addSpanEvent(span, 'validation_failed', { reason: 'missing_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);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
valid: false, valid: false,
reason: 'Internal server error during email validation', reason: 'Email address is required',
requestId: tracking.requestId requestId
} as ValidationResult), } as ValidationResult),
{ {
status: 500, status: 400,
headers: { headers: { 'Content-Type': 'application/json' }
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}
} }
); );
} }
// 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));