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 ', '');
const payload = JSON.parse(atob(token.split('.')[1]));
userId = payload.sub;
addSpanEvent(span, 'authentication_success', { userId }); if (userError || !authUser) {
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,27 +15,15 @@ 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') {
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 payload: InboundEmailPayload = await req.json();
const { from, to, subject, text, html, messageId, inReplyTo, references, headers } = payload; const { from, to, subject, text, html, messageId, inReplyTo, references, headers } = payload;
edgeLogger.info('Inbound email received', { addSpanEvent(span, 'email_received', {
requestId: tracking.requestId,
from, from,
to, to,
messageId messageId,
hasInReplyTo: !!inReplyTo
}); });
// Extract thread ID from headers or inReplyTo // Extract thread ID from headers or inReplyTo
@@ -48,17 +34,11 @@ const handler = async (req: Request): Promise<Response> => {
const isNewEmail = !threadId; const isNewEmail = !threadId;
if (isNewEmail) { if (isNewEmail) {
edgeLogger.info('New direct email received (no thread ID)', { addSpanEvent(span, 'new_direct_email', { from, subject });
requestId: tracking.requestId,
from,
subject,
messageId
});
} }
// Find or create submission // Find or create submission
let submission = null; let submission = null;
let submissionError = null;
if (isNewEmail) { if (isNewEmail) {
// Extract sender email // Extract sender email
@@ -80,8 +60,7 @@ const handler = async (req: Request): Promise<Response> => {
submission = existingRecent; submission = existingRecent;
threadId = existingRecent.thread_id; threadId = existingRecent.thread_id;
edgeLogger.info('Using existing recent submission', { addSpanEvent(span, 'duplicate_submission_found', {
requestId: tracking.requestId,
submissionId: existingRecent.id, submissionId: existingRecent.id,
ticketNumber: existingRecent.ticket_number ticketNumber: existingRecent.ticket_number
}); });
@@ -103,11 +82,8 @@ const handler = async (req: Request): Promise<Response> => {
.single(); .single();
if (createError || !newSubmission) { if (createError || !newSubmission) {
edgeLogger.error('Failed to create submission from direct email', { addSpanEvent(span, 'submission_creation_failed', { error: createError });
requestId: tracking.requestId, throw createError;
error: createError
});
return createErrorResponse(createError, 500, corsHeaders);
} }
submission = newSubmission; submission = newSubmission;
@@ -119,26 +95,21 @@ const handler = async (req: Request): Promise<Response> => {
.update({ thread_id: threadId }) .update({ thread_id: threadId })
.eq('id', newSubmission.id); .eq('id', newSubmission.id);
edgeLogger.info('Created new submission from direct email', { addSpanEvent(span, 'submission_created', {
requestId: tracking.requestId,
submissionId: newSubmission.id, submissionId: newSubmission.id,
ticketNumber: newSubmission.ticket_number, ticketNumber: newSubmission.ticket_number,
threadId threadId
}); });
} }
} else { } else {
// EXISTING LOGIC: Find submission by thread_id or ticket_number // Find submission by thread_id or ticket_number
const ticketMatch = threadId.match(/(?:ticket-)?(TW-\d+)/i); const ticketMatch = threadId.match(/(?:ticket-)?(TW-\d+)/i);
const ticketNumber = ticketMatch ? ticketMatch[1] : null; const ticketNumber = ticketMatch ? ticketMatch[1] : null;
edgeLogger.info('Thread ID extracted', { addSpanEvent(span, 'thread_lookup', { threadId, ticketNumber });
requestId: tracking.requestId,
rawThreadId: threadId,
ticketNumber
});
// Strategy 1: Try exact thread_id match // Strategy 1: Try exact thread_id match
const { data: submissionByThreadId, error: error1 } = await supabase const { data: submissionByThreadId } = await supabase
.from('contact_submissions') .from('contact_submissions')
.select('id, email, status, ticket_number') .select('id, email, status, ticket_number')
.eq('thread_id', threadId) .eq('thread_id', threadId)
@@ -146,6 +117,7 @@ const handler = async (req: Request): Promise<Response> => {
if (submissionByThreadId) { if (submissionByThreadId) {
submission = submissionByThreadId; submission = submissionByThreadId;
addSpanEvent(span, 'submission_found_by_thread_id', { submissionId: submission.id });
} else if (ticketNumber) { } else if (ticketNumber) {
// Strategy 2: Try ticket_number match // Strategy 2: Try ticket_number match
const { data: submissionByTicket, error: error2 } = await supabase const { data: submissionByTicket, error: error2 } = await supabase
@@ -164,44 +136,39 @@ const handler = async (req: Request): Promise<Response> => {
.update({ thread_id: threadId }) .update({ thread_id: threadId })
.eq('id', submissionByTicket.id); .eq('id', submissionByTicket.id);
edgeLogger.info('Updated submission thread_id', { addSpanEvent(span, 'thread_id_updated', {
requestId: tracking.requestId,
submissionId: submissionByTicket.id, submissionId: submissionByTicket.id,
oldThreadId: submissionByTicket.thread_id, oldThreadId: submissionByTicket.thread_id,
newThreadId: threadId newThreadId: threadId
}); });
} }
} else {
submissionError = error2;
}
} else {
submissionError = error1;
}
if (submissionError || !submission) { addSpanEvent(span, 'submission_found_by_ticket_number', { submissionId: submission.id });
edgeLogger.warn('Submission not found for thread ID', { } else {
requestId: tracking.requestId, addSpanEvent(span, 'submission_not_found', { threadId, ticketNumber });
threadId,
ticketNumber,
error: submissionError
});
return new Response(JSON.stringify({ success: false, reason: 'submission_not_found' }), { return new Response(JSON.stringify({ success: false, reason: 'submission_not_found' }), {
status: 200, status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' } 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) // Verify sender email matches (only for existing submissions)
const senderEmail = from.match(/<(.+)>/)?.[1] || from; const senderEmail = from.match(/<(.+)>/)?.[1] || from;
if (senderEmail.toLowerCase() !== submission.email.toLowerCase()) { if (senderEmail.toLowerCase() !== submission.email.toLowerCase()) {
edgeLogger.warn('Sender email mismatch', { addSpanEvent(span, 'email_mismatch', {
requestId: tracking.requestId,
expected: submission.email, expected: submission.email,
received: senderEmail received: senderEmail
}); });
return new Response(JSON.stringify({ success: false, reason: 'email_mismatch' }), { return new Response(JSON.stringify({ success: false, reason: 'email_mismatch' }), {
status: 200, status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
} }
} }
@@ -229,39 +196,35 @@ const handler = async (req: Request): Promise<Response> => {
}); });
if (insertError) { if (insertError) {
edgeLogger.error('Failed to insert inbound email thread', { addSpanEvent(span, 'thread_insert_failed', { error: insertError });
requestId: tracking.requestId, throw insertError;
error: insertError
});
return createErrorResponse(insertError, 500, corsHeaders);
} }
addSpanEvent(span, 'thread_inserted');
// Update submission status if pending // Update submission status if pending
if (submission.status === 'pending') { if (submission.status === 'pending') {
await supabase await supabase
.from('contact_submissions') .from('contact_submissions')
.update({ status: 'in_progress' }) .update({ status: 'in_progress' })
.eq('id', submission.id); .eq('id', submission.id);
addSpanEvent(span, 'status_updated', { newStatus: 'in_progress' });
} }
edgeLogger.info('Inbound email processed successfully', { addSpanEvent(span, 'email_processed', { submissionId: submission.id });
requestId: tracking.requestId,
submissionId: submission.id,
duration: endRequest(tracking)
});
return new Response( return new Response(
JSON.stringify({ success: true }), JSON.stringify({ success: true }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } { status: 200, headers: { '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); 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,59 +9,29 @@ 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') {
return new Response(null, { headers: corsHeaders });
}
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 body: AdminReplyRequest = await req.json();
const { submissionId, replyBody, replySubject } = body; const { submissionId, replyBody, replySubject } = body;
// Validate request
if (!submissionId || !replyBody) { if (!submissionId || !replyBody) {
return createErrorResponse({ message: 'Missing required fields' }, 400, corsHeaders); addSpanEvent(span, 'validation_failed', { reason: 'missing_fields' });
return new Response(
JSON.stringify({ error: 'Missing required fields', requestId }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
} }
if (replyBody.length < 10 || replyBody.length > 5000) { if (replyBody.length < 10 || replyBody.length > 5000) {
return createErrorResponse({ addSpanEvent(span, 'validation_failed', { reason: 'invalid_length', length: replyBody.length });
message: 'Reply must be between 10 and 5000 characters' return new Response(
}, 400, corsHeaders); 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 // Get admin email from environment variable
const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS') || 'admins@thrillwiki.com'; const adminEmail = Deno.env.get('ADMIN_EMAIL_ADDRESS') || 'admins@thrillwiki.com';
const adminDisplayName = Deno.env.get('ADMIN_EMAIL_DISPLAY_NAME') || 'ThrillWiki Admin'; const adminDisplayName = Deno.env.get('ADMIN_EMAIL_DISPLAY_NAME') || 'ThrillWiki Admin';
@@ -85,9 +53,18 @@ const handler = async (req: Request): Promise<Response> => {
.single(); .single();
if (fetchError || !submission) { if (fetchError || !submission) {
return createErrorResponse({ message: 'Submission not found' }, 404, corsHeaders); 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 // Fetch email signature from admin settings
const { data: signatureSetting } = await supabase const { data: signatureSetting } = await supabase
.from('admin_settings') .from('admin_settings')
@@ -112,9 +89,11 @@ const handler = async (req: Request): Promise<Response> => {
.gte('created_at', oneHourAgo); .gte('created_at', oneHourAgo);
if (count && count >= 10) { if (count && count >= 10) {
return createErrorResponse({ addSpanEvent(span, 'rate_limit_exceeded', { count });
message: 'Rate limit exceeded. Max 10 replies per hour.' return new Response(
}, 429, corsHeaders); 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 ticketNumber = submission.ticket_number || 'UNKNOWN';
@@ -137,6 +116,12 @@ const handler = async (req: Request): Promise<Response> => {
? [originalMessageId, previousMessages[0].message_id].join(' ') ? [originalMessageId, previousMessages[0].message_id].join(' ')
: originalMessageId; : originalMessageId;
addSpanEvent(span, 'sending_email', {
messageId,
recipient: submission.email,
subject: finalSubject
});
// Send email via ForwardEmail // Send email via ForwardEmail
const forwardEmailResponse = await fetch('https://api.forwardemail.net/v1/emails', { const forwardEmailResponse = await fetch('https://api.forwardemail.net/v1/emails', {
method: 'POST', method: 'POST',
@@ -161,14 +146,15 @@ const handler = async (req: Request): Promise<Response> => {
if (!forwardEmailResponse.ok) { if (!forwardEmailResponse.ok) {
const errorText = await forwardEmailResponse.text(); const errorText = await forwardEmailResponse.text();
edgeLogger.error('ForwardEmail API error', { addSpanEvent(span, 'email_send_failed', {
requestId: tracking.requestId,
status: forwardEmailResponse.status, status: forwardEmailResponse.status,
error: errorText error: errorText
}); });
return createErrorResponse({ message: 'Failed to send email' }, 500, corsHeaders); throw new Error(`ForwardEmail API error: ${errorText}`);
} }
addSpanEvent(span, 'email_sent', { messageId });
// Insert email thread record // Insert email thread record
const { error: insertError } = await supabase const { error: insertError } = await supabase
.from('contact_email_threads') .from('contact_email_threads')
@@ -190,10 +176,9 @@ const handler = async (req: Request): Promise<Response> => {
}); });
if (insertError) { if (insertError) {
edgeLogger.error('Failed to insert email thread', { addSpanEvent(span, 'thread_insert_failed', { error: insertError });
requestId: tracking.requestId, } else {
error: insertError addSpanEvent(span, 'thread_inserted');
});
} }
// Update submission // Update submission
@@ -206,6 +191,8 @@ const handler = async (req: Request): Promise<Response> => {
}) })
.eq('id', submissionId); .eq('id', submissionId);
addSpanEvent(span, 'submission_updated');
// Audit log // Audit log
await supabase await supabase
.from('admin_audit_log') .from('admin_audit_log')
@@ -220,24 +207,19 @@ const handler = async (req: Request): Promise<Response> => {
} }
}); });
edgeLogger.info('Admin email reply sent successfully', { addSpanEvent(span, 'audit_logged');
requestId: tracking.requestId,
submissionId,
duration: endRequest(tracking)
});
return new Response( return new Response(
JSON.stringify({ success: true, messageId }), JSON.stringify({ success: true, messageId }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } { status: 200, 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);
}
}; };
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,35 +64,24 @@ 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
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const tracking = startRequest('validate-email');
try {
const { email }: ValidateEmailRequest = await req.json(); const { email }: ValidateEmailRequest = await req.json();
if (!email || typeof email !== 'string') { if (!email || typeof email !== 'string') {
endRequest(tracking, 400, 'Email address is required'); addSpanEvent(span, 'validation_failed', { reason: 'missing_email' });
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
valid: false, valid: false,
reason: 'Email address is required', reason: 'Email address is required',
requestId: tracking.requestId requestId
} as ValidationResult), } as ValidationResult),
{ {
status: 400, status: 400,
headers: { headers: { 'Content-Type': 'application/json' }
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}
} }
); );
} }
@@ -100,36 +89,28 @@ const handler = async (req: Request): Promise<Response> => {
// Basic email format validation // Basic email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) { if (!emailRegex.test(email)) {
endRequest(tracking, 400, 'Invalid email format'); addSpanEvent(span, 'validation_failed', { reason: 'invalid_format' });
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
valid: false, valid: false,
reason: 'Invalid email format', reason: 'Invalid email format',
requestId: tracking.requestId requestId
} as ValidationResult), } as ValidationResult),
{ {
status: 400, status: 400,
headers: { headers: { 'Content-Type': 'application/json' }
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}
} }
); );
} }
// Extract domain // Extract domain
const domain = email.split('@')[1].toLowerCase(); const domain = email.split('@')[1].toLowerCase();
addSpanEvent(span, 'domain_extracted', { domain });
// Check if domain is disposable // Check if domain is disposable
if (DISPOSABLE_DOMAINS.has(domain)) { if (DISPOSABLE_DOMAINS.has(domain)) {
edgeLogger.info('Blocked disposable email domain', { addSpanEvent(span, 'disposable_domain_blocked', { domain });
domain,
requestId: tracking.requestId
});
endRequest(tracking, 400, 'Disposable email domain blocked');
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@@ -140,67 +121,34 @@ const handler = async (req: Request): Promise<Response> => {
'Use your work or school email address', 'Use your work or school email address',
'Use an email from your own domain' 'Use an email from your own domain'
], ],
requestId: tracking.requestId requestId
} as ValidationResult), } as ValidationResult),
{ {
status: 400, status: 400,
headers: { headers: { 'Content-Type': 'application/json' }
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}
} }
); );
} }
// Email is valid // Email is valid
edgeLogger.info('Email validated successfully', { addSpanEvent(span, 'email_validated', { email });
email,
requestId: tracking.requestId
});
endRequest(tracking, 200);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
valid: true, valid: true,
requestId: tracking.requestId requestId
} as ValidationResult), } as ValidationResult),
{ {
status: 200, status: 200,
headers: { headers: { 'Content-Type': 'application/json' }
...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(
JSON.stringify({
valid: false,
reason: 'Internal server error during email validation',
requestId: tracking.requestId
} as ValidationResult),
{
status: 500,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}
}
);
}
}; };
serve(handler); serve(createEdgeFunction({
name: 'validate-email',
requireAuth: false,
corsHeaders,
logRequests: true,
logResponses: true,
}, handler));