Files
thrilltrack-explorer/supabase/functions/receive-inbound-email/index.ts
gpt-engineer-app[bot] 4040fd783e 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.
2025-11-11 20:09:42 +00:00

231 lines
7.5 KiB
TypeScript

import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { corsHeaders } from '../_shared/cors.ts';
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
import { addSpanEvent } from '../_shared/logger.ts';
interface InboundEmailPayload {
from: string;
to: string;
subject: string;
text: string;
html?: string;
messageId: string;
inReplyTo?: string;
references?: string[];
headers: Record<string, string>;
}
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 });
}
// Find or create submission
let submission = null;
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;
addSpanEvent(span, 'thread_lookup', { threadId, ticketNumber });
// 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, 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);
addSpanEvent(span, 'thread_id_updated', {
submissionId: submissionByTicket.id,
oldThreadId: submissionByTicket.thread_id,
newThreadId: 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 {
addSpanEvent(span, 'submission_not_found', { threadId });
return new Response(JSON.stringify({ success: false, reason: 'submission_not_found' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
// Verify sender email matches (only for existing submissions)
const senderEmail = from.match(/<(.+)>/)?.[1] || from;
if (senderEmail.toLowerCase() !== submission.email.toLowerCase()) {
addSpanEvent(span, 'email_mismatch', {
expected: submission.email,
received: senderEmail
});
return new Response(JSON.stringify({ success: false, reason: 'email_mismatch' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
}
// 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(createEdgeFunction({
name: 'receive-inbound-email',
requireAuth: false,
useServiceRole: true,
corsHeaders,
logRequests: true,
logResponses: true,
}, handler));