Files
thrilltrack-explorer/supabase/functions/send-admin-email-reply/index.ts
gpt-engineer-app[bot] 4040fd783e Migrate 3 edge functions to wrapper
- Refactor validate-email, receive-inbound-email, and send-admin-email-reply to use createEdgeFunction wrapper with automatic error handling, tracing, and reduced boilerplate.
- enrich wrapper to support service-role usage and role-based authorization context for complex flows.
2025-11-11 20:09:42 +00:00

226 lines
7.2 KiB
TypeScript

import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { corsHeaders } from '../_shared/cors.ts';
import { createEdgeFunction, type EdgeFunctionContext } from '../_shared/edgeFunctionWrapper.ts';
import { addSpanEvent } from '../_shared/logger.ts';
interface AdminReplyRequest {
submissionId: string;
replyBody: string;
replySubject?: string;
}
const handler = async (req: Request, { supabase, user, span, requestId }: EdgeFunctionContext): Promise<Response> => {
const body: AdminReplyRequest = await req.json();
const { submissionId, replyBody, replySubject } = body;
// Validate request
if (!submissionId || !replyBody) {
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) {
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(createEdgeFunction({
name: 'send-admin-email-reply',
requireAuth: true,
requiredRoles: ['superuser', 'admin', 'moderator'],
corsHeaders,
logRequests: true,
logResponses: true,
}, handler));