Files
thrilltrack-explorer/supabase/functions/receive-inbound-email/index.ts
gpt-engineer-app[bot] 2d65f13b85 Connect to Lovable Cloud
Add centralized errorFormatter to convert various error types into readable messages, and apply it across edge functions. Replace String(error) usage with formatEdgeError, update relevant imports, fix a throw to use toError, and enhance logger to log formatted errors. Includes new errorFormatter.ts and widespread updates to 18+ edge functions plus logger integration.
2025-11-10 18:09:15 +00:00

271 lines
8.9 KiB
TypeScript

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 { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts";
import { createErrorResponse } from "../_shared/errorSanitizer.ts";
import { formatEdgeError } from "../_shared/errorFormatter.ts";
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
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): Promise<Response> => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const tracking = startRequest();
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
});
}
// Find or create submission
let submission = null;
let submissionError = 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;
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
});
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
});
}
} 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
});
// 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
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) {
edgeLogger.error('Failed to insert inbound email thread', {
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);
}
};
serve(handler);