mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 13:11:16 -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:
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user