Files
thrilltrack-explorer/supabase/functions/send-admin-email-reply/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

247 lines
8.2 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 AdminReplyRequest {
submissionId: string;
replyBody: string;
replySubject?: string;
}
const handler = async (req: Request): 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 { 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)
});
return new Response(
JSON.stringify({ success: true, messageId }),
{ status: 200, headers: { ...corsHeaders, '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);