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

@@ -52,4 +52,10 @@ verify_jwt = true
verify_jwt = false
[functions.send-contact-message]
verify_jwt = false
[functions.send-admin-email-reply]
verify_jwt = true
[functions.receive-inbound-email]
verify_jwt = false

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);

View File

@@ -0,0 +1,78 @@
-- Add email threading support to contact submissions
ALTER TABLE contact_submissions
ADD COLUMN IF NOT EXISTS thread_id TEXT UNIQUE,
ADD COLUMN IF NOT EXISTS last_admin_response_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS response_count INTEGER DEFAULT 0;
-- Generate thread_id for existing submissions
UPDATE contact_submissions
SET thread_id = 'thread_' || id::text
WHERE thread_id IS NULL;
-- Make thread_id NOT NULL after backfill
ALTER TABLE contact_submissions
ALTER COLUMN thread_id SET NOT NULL;
-- Create index
CREATE INDEX IF NOT EXISTS idx_contact_submissions_thread_id
ON contact_submissions(thread_id);
-- Create contact_email_threads table
CREATE TABLE IF NOT EXISTS contact_email_threads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
submission_id UUID NOT NULL REFERENCES contact_submissions(id) ON DELETE CASCADE,
-- Email metadata
message_id TEXT UNIQUE NOT NULL,
in_reply_to TEXT,
reference_chain TEXT[],
-- Email content
from_email TEXT NOT NULL,
to_email TEXT NOT NULL,
subject TEXT NOT NULL,
body_text TEXT NOT NULL,
body_html TEXT,
-- Direction & sender tracking
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
sent_by UUID REFERENCES auth.users(id),
-- Metadata
metadata JSONB DEFAULT '{}'::jsonb,
CONSTRAINT valid_direction CHECK (
(direction = 'outbound' AND sent_by IS NOT NULL) OR
(direction = 'inbound' AND sent_by IS NULL)
)
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_email_threads_submission ON contact_email_threads(submission_id);
CREATE INDEX IF NOT EXISTS idx_email_threads_message_id ON contact_email_threads(message_id);
CREATE INDEX IF NOT EXISTS idx_email_threads_created ON contact_email_threads(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_email_threads_direction ON contact_email_threads(direction);
-- Enable RLS
ALTER TABLE contact_email_threads ENABLE ROW LEVEL SECURITY;
-- Admin-only read policy
CREATE POLICY "Admins can view all email threads"
ON contact_email_threads FOR SELECT
TO authenticated
USING (has_role(auth.uid(), 'admin'::app_role));
-- Admin-only write policy
CREATE POLICY "Admins can insert email threads"
ON contact_email_threads FOR INSERT
TO authenticated
WITH CHECK (has_role(auth.uid(), 'admin'::app_role));
-- Grant select to authenticated users (RLS will filter)
GRANT SELECT ON contact_email_threads TO authenticated;
GRANT INSERT ON contact_email_threads TO authenticated;
-- Add comment for documentation
COMMENT ON TABLE contact_email_threads IS
'Stores email thread history for contact form submissions. Admin-only access.';