mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 06:31:13 -05:00
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:
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
const handler = async (req: Request): Promise<Response> => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
const handler = async (req: Request, { supabase, span, requestId }: EdgeFunctionContext): Promise<Response> => {
|
||||
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]?.trim() || senderEmail.split('@')[0];
|
||||
|
||||
// Check for existing submission from this email in last 5 minutes (avoid duplicates)
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
const { data: existingRecent } = await supabase
|
||||
.from('contact_submissions')
|
||||
.select('id, ticket_number, thread_id, email')
|
||||
.eq('email', senderEmail.toLowerCase())
|
||||
.eq('subject', subject || '(No Subject)')
|
||||
.gte('created_at', fiveMinutesAgo)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingRecent) {
|
||||
// Use existing recent submission (duplicate email)
|
||||
submission = existingRecent;
|
||||
threadId = existingRecent.thread_id;
|
||||
|
||||
addSpanEvent(span, 'duplicate_submission_found', {
|
||||
submissionId: existingRecent.id,
|
||||
ticketNumber: existingRecent.ticket_number
|
||||
});
|
||||
} else {
|
||||
// Create new contact submission
|
||||
const { data: newSubmission, error: createError } = await supabase
|
||||
.from('contact_submissions')
|
||||
.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
|
||||
let submission = null;
|
||||
let submissionError = null;
|
||||
addSpanEvent(span, 'thread_lookup', { threadId, ticketNumber });
|
||||
|
||||
if (isNewEmail) {
|
||||
// Extract sender email
|
||||
const senderEmail = from.match(/<(.+)>/)?.[1] || from;
|
||||
const senderName = from.match(/^(.+?)\s*</)?.[1]?.trim() || senderEmail.split('@')[0];
|
||||
|
||||
// Check for existing submission from this email in last 5 minutes (avoid duplicates)
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
const { data: existingRecent } = await supabase
|
||||
// Strategy 1: Try exact thread_id match
|
||||
const { data: submissionByThreadId } = await supabase
|
||||
.from('contact_submissions')
|
||||
.select('id, email, status, ticket_number')
|
||||
.eq('thread_id', threadId)
|
||||
.maybeSingle();
|
||||
|
||||
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')
|
||||
.select('id, ticket_number, thread_id, email')
|
||||
.eq('email', senderEmail.toLowerCase())
|
||||
.eq('subject', subject || '(No Subject)')
|
||||
.gte('created_at', fiveMinutesAgo)
|
||||
.select('id, email, status, ticket_number, thread_id')
|
||||
.eq('ticket_number', ticketNumber)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingRecent) {
|
||||
// Use existing recent submission (duplicate email)
|
||||
submission = existingRecent;
|
||||
threadId = existingRecent.thread_id;
|
||||
if (submissionByTicket) {
|
||||
submission = submissionByTicket;
|
||||
|
||||
edgeLogger.info('Using existing recent submission', {
|
||||
requestId: tracking.requestId,
|
||||
submissionId: existingRecent.id,
|
||||
ticketNumber: existingRecent.ticket_number
|
||||
});
|
||||
} else {
|
||||
// Create new contact submission
|
||||
const { data: newSubmission, error: createError } = await supabase
|
||||
.from('contact_submissions')
|
||||
.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) {
|
||||
edgeLogger.error('Failed to create submission from direct email', {
|
||||
requestId: tracking.requestId,
|
||||
error: createError
|
||||
// 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);
|
||||
|
||||
addSpanEvent(span, 'thread_id_updated', {
|
||||
submissionId: submissionByTicket.id,
|
||||
oldThreadId: submissionByTicket.thread_id,
|
||||
newThreadId: threadId
|
||||
});
|
||||
return createErrorResponse(createError, 500, corsHeaders);
|
||||
}
|
||||
|
||||
submission = newSubmission;
|
||||
threadId = `${newSubmission.ticket_number}.${newSubmission.id}`;
|
||||
|
||||
// Update thread_id
|
||||
await supabase
|
||||
.from('contact_submissions')
|
||||
.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
|
||||
addSpanEvent(span, 'submission_found_by_ticket_number', { submissionId: submission.id });
|
||||
} else {
|
||||
addSpanEvent(span, 'submission_not_found', { threadId, ticketNumber });
|
||||
return new Response(JSON.stringify({ success: false, reason: 'submission_not_found' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// EXISTING LOGIC: Find submission by thread_id or ticket_number
|
||||
const ticketMatch = threadId.match(/(?:ticket-)?(TW-\d+)/i);
|
||||
const ticketNumber = ticketMatch ? ticketMatch[1] : null;
|
||||
|
||||
edgeLogger.info('Thread ID extracted', {
|
||||
requestId: tracking.requestId,
|
||||
rawThreadId: threadId,
|
||||
ticketNumber
|
||||
addSpanEvent(span, 'submission_not_found', { threadId });
|
||||
return new Response(JSON.stringify({ success: false, reason: 'submission_not_found' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
// 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 { 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);
|
||||
serve(createEdgeFunction({
|
||||
name: 'receive-inbound-email',
|
||||
requireAuth: false,
|
||||
useServiceRole: true,
|
||||
corsHeaders,
|
||||
logRequests: true,
|
||||
logResponses: true,
|
||||
}, handler));
|
||||
|
||||
Reference in New Issue
Block a user