diff --git a/.env.example b/.env.example index c10b0cf..10830a1 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,11 @@ # Supabase Configuration SUPABASE_URL=your_supabase_url_here SUPABASE_ANON_KEY=your_supabase_anon_key_here +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here # Google Generative AI API Key for Gemini model GOOGLE_GENERATIVE_AI_API_KEY=your_google_api_key_here # Add these same variables to your actual .env file +# Note: The SUPABASE_SERVICE_ROLE_KEY is required for webhook operations +# to bypass Row Level Security (RLS) policies in server-side contexts diff --git a/README.md b/README.md index ef625f3..bb3473b 100644 --- a/README.md +++ b/README.md @@ -1 +1,59 @@ -inbox-negotiator +# Inbox Negotiator + +An AI-powered system that automatically negotiates debt collections and billing disputes through email processing. + +## Features + +- **AI Email Processing**: Automatically parses incoming emails to extract debt information using Google's Gemini AI +- **Automated Negotiation**: Triggers negotiation workflows for legitimate debt collection notices +- **Webhook Integration**: Seamlessly processes emails through Postmark webhook integration +- **Row Level Security**: Secure database operations with proper authentication handling + +## Environment Setup + +Copy `.env.example` to `.env` and configure the following variables: + +```bash +# Supabase Configuration +SUPABASE_URL=your_supabase_url_here +SUPABASE_ANON_KEY=your_supabase_anon_key_here +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here + +# Google Generative AI API Key for Gemini model +GOOGLE_GENERATIVE_AI_API_KEY=your_google_api_key_here +``` + +### Required Environment Variables + +- `SUPABASE_URL`: Your Supabase project URL +- `SUPABASE_ANON_KEY`: Supabase anonymous key for client-side operations +- `SUPABASE_SERVICE_ROLE_KEY`: Supabase service role key for server-side operations (bypasses RLS) +- `GOOGLE_GENERATIVE_AI_API_KEY`: Google API key for AI processing + +## Webhook Configuration + +The `/api/postmark` endpoint handles incoming email webhooks from Postmark. It: + +1. Validates incoming email data +2. Processes opt-out requests +3. Uses AI to extract debt information +4. Stores processed data in Supabase +5. Triggers automated negotiation workflows + +### RLS (Row Level Security) Handling + +The webhook uses a service role client to bypass RLS policies, ensuring server-side operations can write to the database without user authentication. This is essential for webhook operations where no user session exists. + +## Development + +```bash +# Install dependencies +pnpm install + +# Start development server +pnpm dev +``` + +## Deployment + +Ensure all environment variables are configured in your deployment environment, especially the `SUPABASE_SERVICE_ROLE_KEY` which is critical for webhook operations. diff --git a/src/lib/supabase-admin.ts b/src/lib/supabase-admin.ts new file mode 100644 index 0000000..9835764 --- /dev/null +++ b/src/lib/supabase-admin.ts @@ -0,0 +1,51 @@ +import { createClient } from "@supabase/supabase-js"; + +/** + * Creates a Supabase client with service role key for server-side operations + * This client bypasses Row Level Security (RLS) and should only be used in trusted contexts + * like webhooks, API routes, and server-side functions + */ +export function createSupabaseAdmin() { + const supabaseUrl = + process.env.PUBLIC_SUPABASE_URL || import.meta.env.PUBLIC_SUPABASE_URL; + const supabaseServiceKey = + process.env.SUPABASE_SERVICE_ROLE_KEY || + import.meta.env.SUPABASE_SERVICE_ROLE_KEY; + + console.log({ supabaseUrl, supabaseServiceKey }); + + if (!supabaseUrl || !supabaseServiceKey) { + throw new Error( + "Missing Supabase URL or Service Role Key for admin operations" + ); + } + + return createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); +} + +/** + * Handle database errors with more user-friendly messages + */ +export function handleDatabaseError(error: any) { + let errorMessage = error.message; + + if (error.message.includes("row-level security")) { + errorMessage = "Database access denied - please check RLS policies"; + } else if (error.message.includes("duplicate key")) { + errorMessage = "Duplicate entry detected"; + } else if (error.message.includes("foreign key")) { + errorMessage = "Invalid reference in data"; + } else if (error.message.includes("not null")) { + errorMessage = "Required field is missing"; + } + + return { + message: errorMessage, + originalError: process.env.NODE_ENV === "development" ? error : undefined, + }; +} diff --git a/src/pages/api/postmark.ts b/src/pages/api/postmark.ts index 5a9d34c..09ce319 100644 --- a/src/pages/api/postmark.ts +++ b/src/pages/api/postmark.ts @@ -1,5 +1,9 @@ import type { APIRoute } from "astro"; import { supabase } from "../../lib/supabase"; +import { + createSupabaseAdmin, + handleDatabaseError, +} from "../../lib/supabase-admin"; import { generateObject } from "ai"; import { createGoogleGenerativeAI, @@ -17,13 +21,18 @@ const debtSchema = z.object({ isDebtCollection: z .boolean() .describe("Whether this appears to be a debt collection notice"), + successfullyParsed: z + .boolean() + .describe("Whether the debt information was successfully parsed"), }); // Function to parse debt information using AI async function parseDebtWithAI(emailText: string, fromEmail: string) { try { // Check if Google API key is available - const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; + const googleApiKey = + process.env.GOOGLE_GENERATIVE_AI_API_KEY || + import.meta.env.GOOGLE_GENERATIVE_AI_API_KEY; if (!googleApiKey) { console.warn( "Google API key not configured, falling back to regex parsing" @@ -57,18 +66,42 @@ async function parseDebtWithAI(emailText: string, fromEmail: string) { vendor: fromEmail || "unknown", description: "Failed to parse with AI - using regex fallback", isDebtCollection: amountMatch ? true : false, + successfullyParsed: false, }; } } export const POST: APIRoute = async ({ request }) => { try { + // Create service role client for webhook operations (bypasses RLS) + let supabaseAdmin; + try { + supabaseAdmin = createSupabaseAdmin(); + } catch (configError) { + console.error("Supabase admin configuration error:", configError); + return new Response( + JSON.stringify({ error: "Server configuration error" }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); + } + const data = await request.json(); + // Validate essential webhook data + if (!data.TextBody && !data.HtmlBody) { + return new Response(JSON.stringify({ error: "No email content found" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + // Check for opt-out keywords const optOutKeywords = ["STOP", "UNSUBSCRIBE", "OPT-OUT", "REMOVE"]; - const textBody = data.TextBody || ""; - const fromEmail = data.FromFull?.Email || "unknown"; + const textBody = data.TextBody || data.HtmlBody || ""; + const fromEmail = data.FromFull?.Email || data.From || "unknown"; const hasOptOut = optOutKeywords.some((keyword) => textBody.toUpperCase().includes(keyword) @@ -76,7 +109,7 @@ export const POST: APIRoute = async ({ request }) => { if (hasOptOut) { // Log opt-out and don't process further - const { error } = await supabase.from("debts").insert({ + const { error } = await supabaseAdmin.from("debts").insert({ vendor: fromEmail, amount: 0, raw_email: textBody, @@ -85,7 +118,8 @@ export const POST: APIRoute = async ({ request }) => { if (error) { console.error("Error logging opt-out:", error); - return new Response(JSON.stringify({ error: error.message }), { + const errorInfo = handleDatabaseError(error); + return new Response(JSON.stringify({ error: errorInfo.message }), { status: 500, headers: { "Content-Type": "application/json" }, }); @@ -97,8 +131,19 @@ export const POST: APIRoute = async ({ request }) => { // Parse debt information using AI const debtInfo = await parseDebtWithAI(textBody, fromEmail); + if (!debtInfo || !debtInfo.successfullyParsed) { + console.warn("Failed to parse debt information"); + return new Response( + JSON.stringify({ error: "Failed to parse debt information" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + // Insert debt record with AI-extracted information - const { data: insertedDebt, error: insertError } = await supabase + const { data: insertedDebt, error: insertError } = await supabaseAdmin .from("debts") .insert({ vendor: debtInfo.vendor, @@ -118,14 +163,22 @@ export const POST: APIRoute = async ({ request }) => { if (insertError) { console.error("Error inserting debt:", insertError); - return new Response(JSON.stringify({ error: insertError.message }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); + const errorInfo = handleDatabaseError(insertError); + + return new Response( + JSON.stringify({ + error: errorInfo.message, + details: errorInfo.originalError, + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); } // Log the email receipt - await supabase.from("audit_logs").insert({ + await supabaseAdmin.from("audit_logs").insert({ debt_id: insertedDebt.id, action: "email_received", details: { @@ -139,8 +192,11 @@ export const POST: APIRoute = async ({ request }) => { // Trigger negotiation function if this is a legitimate debt if (debtInfo.amount > 0 && debtInfo.isDebtCollection) { // Access environment variables through Astro runtime - const supabaseUrl = process.env.SUPABASE_URL; - const supabaseAnonKey = process.env.SUPABASE_ANON_KEY; + const supabaseUrl = + process.env.SUPABASE_URL || import.meta.env.PUBLIC_SUPABASE_URL; + const supabaseAnonKey = + process.env.SUPABASE_ANON_KEY || + import.meta.env.PUBLIC_SUPABASE_ANON_KEY; if (supabaseUrl && supabaseAnonKey) { const negotiateUrl = `${supabaseUrl}/functions/v1/negotiate`;