Approve database migration

This commit is contained in:
gpt-engineer-app[bot]
2025-10-28 17:35:40 +00:00
parent bf09abff36
commit c42b34b327
6 changed files with 827 additions and 96 deletions

View File

@@ -0,0 +1,149 @@
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";
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
interface InboundEmailPayload {
from: string;
to: string;
subject: string;
text: string;
html?: string;
messageId: string;
inReplyTo?: string;
references?: string[];
headers: Record<string, string>;
}
const handler = async (req: Request): Promise<Response> => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const tracking = startRequest();
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);
const payload: InboundEmailPayload = await req.json();
const { from, to, subject, text, html, messageId, inReplyTo, references, headers } = payload;
edgeLogger.info('Inbound email received', {
requestId: tracking.requestId,
from,
to,
messageId
});
// Extract thread ID from headers or inReplyTo
const threadId = headers['X-Thread-ID'] ||
(inReplyTo ? inReplyTo.replace(/<|>/g, '').split('@')[0] : null);
if (!threadId) {
edgeLogger.warn('Email missing thread ID', {
requestId: tracking.requestId,
messageId
});
return new Response(JSON.stringify({ success: false, reason: 'no_thread_id' }), {
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
}
// Find submission by thread_id
const { data: submission, error: submissionError } = await supabase
.from('contact_submissions')
.select('id, email, status')
.eq('thread_id', threadId)
.single();
if (submissionError || !submission) {
edgeLogger.warn('Submission not found for thread ID', {
requestId: tracking.requestId,
threadId
});
return new Response(JSON.stringify({ success: false, reason: 'submission_not_found' }), {
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
}
// Verify sender email matches
const senderEmail = from.match(/<(.+)>/)?.[1] || from;
if (senderEmail.toLowerCase() !== submission.email.toLowerCase()) {
edgeLogger.warn('Sender email mismatch', {
requestId: tracking.requestId,
expected: submission.email,
received: senderEmail
});
return new Response(JSON.stringify({ success: false, reason: 'email_mismatch' }), {
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
}
// Insert email thread record
const { error: insertError } = await supabase
.from('contact_email_threads')
.insert({
submission_id: submission.id,
message_id: messageId,
in_reply_to: inReplyTo,
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
}
});
if (insertError) {
edgeLogger.error('Failed to insert inbound email thread', {
requestId: tracking.requestId,
error: insertError
});
return createErrorResponse(insertError, 500, corsHeaders);
}
// Update submission status if pending
if (submission.status === 'pending') {
await supabase
.from('contact_submissions')
.update({ status: 'in_progress' })
.eq('id', submission.id);
}
edgeLogger.info('Inbound email processed successfully', {
requestId: tracking.requestId,
submissionId: submission.id,
duration: endRequest(tracking)
});
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
edgeLogger.error('Unexpected error in receive-inbound-email', {
requestId: tracking.requestId,
error: error instanceof Error ? error.message : String(error)
});
return createErrorResponse(error, 500, corsHeaders);
}
};
serve(handler);

View File

@@ -0,0 +1,207 @@
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";
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 role
const { data: isAdmin, error: roleError } = await supabase
.rpc('has_role', { _user_id: user.id, _role: 'admin' });
if (roleError || !isAdmin) {
edgeLogger.warn('Non-admin 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);
}
// Fetch submission
const { data: submission, error: fetchError } = await supabase
.from('contact_submissions')
.select('id, email, name, subject, thread_id, response_count')
.eq('id', submissionId)
.single();
if (fetchError || !submission) {
return createErrorResponse({ message: 'Submission not found' }, 404, corsHeaders);
}
// 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 messageId = `<${crypto.randomUUID()}@thrillwiki.com>`;
const finalSubject = replySubject || `Re: ${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 inReplyTo = previousMessages?.[0]?.message_id || `<${submission.thread_id}@thrillwiki.com>`;
// 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: 'ThrillWiki Admin <admin@thrillwiki.com>',
to: `${submission.name} <${submission.email}>`,
subject: finalSubject,
text: replyBody,
headers: {
'Message-ID': messageId,
'In-Reply-To': inReplyTo,
'References': inReplyTo,
'X-Thread-ID': submission.thread_id
}
})
});
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: 'admin@thrillwiki.com',
to_email: submission.email,
subject: finalSubject,
body_text: replyBody,
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: error instanceof Error ? error.message : String(error)
});
return createErrorResponse(error, 500, corsHeaders);
}
};
serve(handler);