Files
inbox-negotiator/src/pages/api/postmark.ts
Francisco Pessano 0d2ab87519 feat: enhance Navbar with user profile and configuration links
- Updated Navbar component to include a link to the configuration page.
- Added a new Settings icon and link for user configuration.
- Improved user session handling and UI updates based on authentication state.

feat: implement OnboardingDialog for user setup

- Created OnboardingDialog component to guide users through initial setup.
- Added functionality to collect additional email addresses during onboarding.
- Integrated toast notifications for error handling during email addition.

feat: extend Supabase admin functions for user management

- Added functions to retrieve user IDs and full user information by email.
- Implemented error handling and logging for database operations.

feat: update Supabase schema with new user features

- Created new tables: user_profiles, additional_emails, and email_processing_usage.
- Enabled Row Level Security (RLS) on new tables with appropriate policies.
- Added triggers and functions for automatic user profile creation and email usage tracking.

feat: create public users table for simplified access

- Established a public.users table to mirror relevant auth.users data.
- Implemented triggers to automatically populate public.users upon user creation.
- Set up RLS policies to restrict access to user data.

chore: add configuration files for Supabase local development

- Included .gitignore and config.toml for local Supabase setup.
- Configured email testing server and other development settings.

feat: add configuration page for user settings

- Created configuration.astro page to manage user settings.
- Integrated AuthGuard to protect the configuration route.
2025-06-07 04:37:03 -03:00

263 lines
7.7 KiB
TypeScript

import type { APIRoute } from "astro";
import {
createSupabaseAdmin,
handleDatabaseError,
getUserIdByEmail,
} from "../../lib/supabase-admin";
import { generateObject } from "ai";
import {
createGoogleGenerativeAI,
} from "@ai-sdk/google";
import { z } from "zod";
import type { SupabaseClient } from "@supabase/supabase-js";
// Schema for debt information extraction
const debtSchema = z.object({
amount: z.number().min(0).describe("The debt amount in dollars"),
vendor: z.string().describe("The name or identifier of the vendor/creditor"),
description: z.string().describe("Brief description of what the debt is for"),
dueDate: z.string().optional().describe("Due date if mentioned (ISO format)"),
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 ||
import.meta.env.GOOGLE_GENERATIVE_AI_API_KEY;
if (!googleApiKey) {
console.warn(
"Google API key not configured, falling back to regex parsing"
);
throw new Error("No Google API key configured");
}
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system: `You are an expert at analyzing debt collection and billing emails.
Extract key debt information from the email content.
Look for monetary amounts, creditor information, what the debt is for, and due dates.
If this doesn't appear to be a legitimate debt or billing notice, set amount to 0.
Be very accurate with amounts - look for dollar signs and numbers carefully.`,
prompt: `Parse this email for debt information:
From: ${fromEmail}
Content: ${emailText}`,
schema: debtSchema,
});
return result.object;
} catch (error) {
console.error("AI parsing error:", error);
// Fallback to regex if AI fails
const amountMatch = emailText.match(/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/);
return {
amount: amountMatch ? parseFloat(amountMatch[1].replace(/,/g, "")) : 0,
vendor: fromEmail || "unknown",
description: "Failed to parse with AI - using regex fallback",
isDebtCollection: amountMatch ? true : false,
successfullyParsed: false,
};
}
}
// Function to increment email processing usage
async function incrementEmailUsage(userId: string, supabaseAdmin: SupabaseClient) {
try {
// Call the database function to increment usage
const { error } = await supabaseAdmin.rpc('increment_email_usage', {
target_user_id: userId
});
if (error) {
console.error("Error incrementing email usage:", error);
}
} catch (error) {
console.error("Error calling increment_email_usage:", error);
}
}
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 || data.HtmlBody || "";
const fromEmail = data.FromFull?.Email || data.From || "unknown";
const toEmail = data.ToFull?.[0]?.Email || data.To || "";
// Find the user who should receive this debt
const userId = await getUserIdByEmail(toEmail, supabaseAdmin);
if (!userId) {
console.warn(`No user found for email: ${toEmail}`);
return new Response("No matching user found", { status: 200 });
}
// Increment email processing usage
await incrementEmailUsage(userId, supabaseAdmin);
const hasOptOut = optOutKeywords.some((keyword) =>
textBody.toUpperCase().includes(keyword)
);
if (hasOptOut) {
// Log opt-out and don't process further
const { error } = await supabaseAdmin.from("debts").insert({
user_id: userId,
vendor: fromEmail,
amount: 0,
raw_email: textBody,
status: "opted_out",
});
if (error) {
console.error("Error logging opt-out:", error);
const errorInfo = handleDatabaseError(error);
return new Response(JSON.stringify({ error: errorInfo.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response("Opt-out processed", { status: 200 });
}
// 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 supabaseAdmin
.from("debts")
.insert({
user_id: userId,
vendor: debtInfo.vendor,
amount: debtInfo.amount,
raw_email: textBody,
status: "received",
description: debtInfo.description,
due_date: debtInfo.dueDate,
metadata: {
isDebtCollection: debtInfo.isDebtCollection,
subject: data.Subject,
fromEmail: fromEmail,
toEmail: toEmail,
},
})
.select()
.single();
if (insertError) {
console.error("Error inserting debt:", insertError);
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 supabaseAdmin.from("audit_logs").insert({
debt_id: insertedDebt.id,
action: "email_received",
details: {
vendor: debtInfo.vendor,
amount: debtInfo.amount,
subject: data.Subject,
aiParsed: true,
},
});
// 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 || 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`;
try {
await fetch(negotiateUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${supabaseAnonKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ record: insertedDebt }),
});
} catch (negotiateError) {
console.error("Error triggering negotiation:", negotiateError);
// Don't fail the webhook if negotiation fails
}
} else {
console.warn(
"Supabase environment variables not configured for negotiation trigger"
);
}
}
return new Response("OK", { status: 200 });
} catch (error) {
console.error("Postmark webhook error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};