Adds email approval & Postmark integration for negotiations

Enables users to approve and send negotiation emails directly via Postmark after configuring a server token in settings. Introduces new debt statuses ("approved", "sent"), UI for token management, and approval/rejection flows. Refactors notifications to use a modern toast library, adjusts dashboard status filters, and updates DB schema for new flows.

Empowers compliant, user-controlled negotiation and automated email delivery.
This commit is contained in:
2025-06-07 18:48:07 -03:00
parent 7f70ec880f
commit 0c6f72761d
15 changed files with 1392 additions and 197 deletions

13
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Dev Server",
"type": "shell",
"command": "pnpm dev",
"group": "build",
"isBackground": true,
"problemMatcher": []
}
]
}

View File

@@ -5,20 +5,20 @@ import {
type UserProfile,
type EmailProcessingUsage,
} from "../lib/supabase";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
} from "./ui/card";
import { Badge } from "./ui/badge";
import { Progress } from "./ui/progress";
import { Alert, AlertDescription } from "./ui/alert";
import { Separator } from "./ui/separator";
import {
Settings,
Mail,
@@ -31,7 +31,7 @@ import {
AlertCircle,
UserCheck,
} from "lucide-react";
import { toast } from "../hooks/use-toast";
import { toast } from "sonner";
export function Configuration() {
const [profile, setProfile] = useState<UserProfile | null>(null);
@@ -55,6 +55,10 @@ export function Configuration() {
});
const [savingPersonalData, setSavingPersonalData] = useState(false);
// Server token state
const [serverToken, setServerToken] = useState("");
const [savingServerToken, setSavingServerToken] = useState(false);
useEffect(() => {
fetchUserData();
}, []);
@@ -112,6 +116,11 @@ export function Configuration() {
phone_number: userData.phone_number || "",
});
}
// Set server token
if (profileData) {
setServerToken(profileData.postmark_server_token || "");
}
} catch (error) {
console.error("Error fetching user data:", error);
} finally {
@@ -142,21 +151,52 @@ export function Configuration() {
if (error) throw error;
toast({
title: "Personal data updated",
toast.success("Personal data updated", {
description: "Your personal information has been saved successfully.",
});
} catch (error: any) {
toast({
title: "Error saving personal data",
toast.error("Error saving personal data", {
description: error.message,
variant: "destructive",
});
} finally {
setSavingPersonalData(false);
}
};
const saveServerToken = async () => {
setSavingServerToken(true);
try {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
const { error } = await supabase
.from("user_profiles")
.update({
postmark_server_token: serverToken || null,
})
.eq("user_id", user.id);
if (error) throw error;
// Update local profile state
setProfile((prev) =>
prev ? { ...prev, postmark_server_token: serverToken || null } : null
);
toast.success("Server token updated", {
description: "Your Postmark server token has been saved successfully.",
});
} catch (error: any) {
toast.error("Error saving server token", {
description: error.message,
});
} finally {
setSavingServerToken(false);
}
};
const addAdditionalEmail = async () => {
if (!newEmail || !profile) return;
@@ -180,15 +220,12 @@ export function Configuration() {
setAdditionalEmails([data, ...additionalEmails]);
setNewEmail("");
toast({
title: "Email added successfully",
toast.success("Email added successfully", {
description: "Additional email has been added to your account.",
});
} catch (error: any) {
toast({
title: "Error adding email",
toast.error("Error adding email", {
description: error.message,
variant: "destructive",
});
} finally {
setAddingEmail(false);
@@ -207,15 +244,12 @@ export function Configuration() {
setAdditionalEmails(
additionalEmails.filter((email) => email.id !== emailId)
);
toast({
title: "Email removed",
toast.success("Email removed", {
description: "Additional email has been removed from your account.",
});
} catch (error: any) {
toast({
title: "Error removing email",
toast.error("Error removing email", {
description: error.message,
variant: "destructive",
});
}
};
@@ -318,6 +352,55 @@ export function Configuration() {
</CardContent>
</Card>
{/* Email Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Email Settings
</CardTitle>
<CardDescription>
Configure your Postmark server token to enable sending approved
negotiation emails
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="server_token">Postmark Server Token</Label>
<Input
id="server_token"
type="password"
placeholder="Enter your Postmark server token"
value={serverToken}
onChange={(e) => setServerToken(e.target.value)}
/>
<p className="text-sm text-muted-foreground">
Required to send approved negotiation emails. You can get this
from your{" "}
<a
href="https://account.postmarkapp.com/servers"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Postmark dashboard
</a>
.
</p>
</div>
<div className="flex justify-end">
<Button
onClick={saveServerToken}
disabled={savingServerToken}
className="min-w-[120px]"
>
{savingServerToken ? "Saving..." : "Save Token"}
</Button>
</div>
</CardContent>
</Card>
{/* Personal Data */}
<Card>
<CardHeader>

View File

@@ -169,7 +169,9 @@ export function Dashboard() {
active: debts.filter((debt) =>
["received", "negotiating"].includes(debt.status)
),
settled: debts.filter((debt) => debt.status === "settled"),
settled: debts.filter((debt) =>
["settled", "approved", "sent"].includes(debt.status)
),
failed: debts.filter((debt) =>
["failed", "opted_out"].includes(debt.status)
),

View File

@@ -19,6 +19,17 @@ import {
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "./ui/alert-dialog";
import {
Calendar,
DollarSign,
@@ -26,9 +37,14 @@ import {
FileText,
TrendingUp,
Edit3,
CheckCircle,
XCircle,
AlertCircle,
ExternalLink,
Eye,
} from "lucide-react";
import { supabase, type Debt, type DebtVariable } from "../lib/supabase";
import { toast } from "../hooks/use-toast";
import { toast } from "sonner";
interface DebtCardProps {
debt: Debt;
@@ -40,6 +56,9 @@ const statusColors = {
"bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800",
negotiating:
"bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-800",
approved:
"bg-teal-100 text-teal-800 border-teal-200 dark:bg-teal-900/20 dark:text-teal-300 dark:border-teal-800",
sent: "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800",
settled:
"bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800",
failed:
@@ -51,12 +70,25 @@ const statusColors = {
const statusLabels = {
received: "Received",
negotiating: "Negotiating",
approved: "Approved",
sent: "Sent",
settled: "Settled",
failed: "Failed",
opted_out: "Opted Out",
};
export function DebtCard({ debt, onUpdate }: DebtCardProps) {
const [isApproving, setIsApproving] = useState(false);
const [isRejecting, setIsRejecting] = useState(false);
const [userProfile, setUserProfile] = useState<any>(null);
const [hasServerToken, setHasServerToken] = useState(false);
const isReadOnly =
debt.status === "approved" ||
debt.status === "sent" ||
debt.status === "failed" ||
debt.status === "opted_out";
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
@@ -110,6 +142,8 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
const [body, setBody] = useState("");
const [variables, setVariables] = useState<Record<string, string>>({});
// Check if debt is in read-only state (approved or sent)
// Load variables from database
const loadVariables = async () => {
try {
@@ -284,10 +318,8 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
if (error) {
console.error("Error saving debt metadata:", error);
toast({
title: "Error",
toast.error("Error", {
description: "Failed to save email changes. Please try again.",
variant: "destructive",
});
return;
}
@@ -295,8 +327,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
// Save variables to database
await saveVariables(variables);
toast({
title: "Changes saved",
toast.success("Changes saved", {
description:
"Your email and variables have been updated successfully.",
});
@@ -309,10 +340,8 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
setIsEditing(false);
} catch (error) {
console.error("Error saving changes:", error);
toast({
title: "Error",
toast.error("Error", {
description: "Failed to save changes. Please try again.",
variant: "destructive",
});
} finally {
setIsSaving(false);
@@ -325,15 +354,25 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="flex-1">
<Edit3 className="h-4 w-4 mr-2" />
Edit Response
{isReadOnly ? (
<Eye className="h-4 w-4 mr-2" />
) : (
<Edit3 className="h-4 w-4 mr-2" />
)}
{isReadOnly ? "See Response" : "Edit Response"}
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Negotiation Response</DialogTitle>
<DialogTitle>
{isReadOnly
? "View Negotiation Response"
: "Edit Negotiation Response"}
</DialogTitle>
<DialogDescription>
Customize your FDCPA-compliant response before sending
{isReadOnly
? "Review your FDCPA-compliant response"
: "Customize your FDCPA-compliant response before sending"}
</DialogDescription>
</DialogHeader>
@@ -363,10 +402,12 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
id={`var-${variableName}`}
value={value}
onChange={(e) =>
!isReadOnly &&
handleVariableChange(variableName, e.target.value)
}
placeholder={`Enter ${variableName.toLowerCase()}`}
className="w-full"
readOnly={isReadOnly}
/>
</div>
))}
@@ -383,9 +424,12 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
<Input
id="subject"
value={subject}
onChange={(e) => handleSubjectChange(e.target.value)}
onChange={(e) =>
!isReadOnly && handleSubjectChange(e.target.value)
}
placeholder="Enter email subject (use {{ Variable Name }} for placeholders)"
className="w-full"
readOnly={isReadOnly}
/>
{/* Preview of subject with variables replaced */}
{Object.keys(variables).length > 0 && (
@@ -401,9 +445,12 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
<Textarea
id="body"
value={body}
onChange={(e) => handleBodyChange(e.target.value)}
onChange={(e) =>
!isReadOnly && handleBodyChange(e.target.value)
}
placeholder="Enter email body (use {{ Variable Name }} for placeholders)"
className="w-full min-h-[300px] font-mono text-sm"
readOnly={isReadOnly}
/>
{/* Preview of body with variables replaced */}
{Object.keys(variables).length > 0 && (
@@ -417,20 +464,183 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
</div>
{!isReadOnly && (
<div className="flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onClick={() => setIsEditing(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
// Check if user has server token configured
useEffect(() => {
checkServerToken();
}, []);
const checkServerToken = async () => {
try {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
const { data: profile } = await supabase
.from("user_profiles")
.select("postmark_server_token")
.eq("user_id", user.id)
.single();
setUserProfile(profile);
setHasServerToken(!!profile?.postmark_server_token);
} catch (error) {
console.error("Error checking server token:", error);
}
};
// Handle approve action
const handleApprove = async (sendEmail = true) => {
if (!hasServerToken && sendEmail) {
toast.error("Server Token Required", {
description:
"Please configure your Postmark server token in settings first.",
});
return;
}
setIsApproving(true);
try {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("User not authenticated");
if (sendEmail) {
// Call the send-email function
const { data, error } = await supabase.functions.invoke("send-email", {
body: {
debtId: debt.id,
},
});
if (error) throw error;
if (data.requiresConfiguration) {
toast.error("Configuration Required", {
description:
"Please set up your Postmark server token in configuration.",
});
return;
}
toast.success("Email Sent Successfully", {
description: `Negotiation email sent to ${data.sentTo}`,
});
} else {
// Call the approve-debt function to handle approval without sending email
const { data, error } = await supabase.functions.invoke(
"approve-debt",
{
body: {
debtId: debt.id,
approvalNote: "Approved by user without sending email",
},
}
);
if (error) throw error;
toast.success("Debt Approved", {
description: `Negotiation for ${data.vendor} has been approved and saved.`,
});
}
// Refresh the data
if (onUpdate) onUpdate();
} catch (error: any) {
console.error("Error in approval process:", error);
const action = sendEmail ? "send email" : "approve debt";
toast.error(`Failed to ${action}`, {
description:
error.message || `An error occurred while trying to ${action}.`,
});
} finally {
setIsApproving(false);
}
};
// Handle reject action
const handleReject = async () => {
setIsRejecting(true);
try {
const { error } = await supabase
.from("debts")
.update({
status: "opted_out",
metadata: {
...debt.metadata,
rejected: {
rejectedAt: new Date().toISOString(),
reason: "User rejected negotiation",
},
},
})
.eq("id", debt.id);
if (error) throw error;
// Log the action
await supabase.from("audit_logs").insert({
debt_id: debt.id,
action: "negotiation_rejected",
details: {
rejectedAt: new Date().toISOString(),
reason: "User rejected negotiation",
},
});
toast.success("Negotiation Rejected", {
description: "The negotiation has been marked as rejected.",
});
// Refresh the data
if (onUpdate) onUpdate();
} catch (error: any) {
console.error("Error rejecting negotiation:", error);
toast.error("Failed to Reject", {
description:
error.message || "An error occurred while rejecting the negotiation.",
});
} finally {
setIsRejecting(false);
}
};
// Check if approve button should be enabled
const canApprove = (sendEmail = true) => {
if (sendEmail) {
return (
debt.metadata?.aiEmail &&
debt.status === "negotiating" &&
hasServerToken
);
} else {
return debt.metadata?.aiEmail && debt.status === "negotiating";
}
};
// Check if buttons should be shown
const showApproveRejectButtons = () => {
return debt.metadata?.aiEmail && debt.status === "negotiating";
};
return (
<Card className="w-full hover:shadow-lg transition-all duration-300 border-l-4 border-l-primary">
<CardHeader className="pb-3">
@@ -470,31 +680,141 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
)}
</div>
<div className="flex gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="flex-1">
<Mail className="h-4 w-4 mr-2" />
View Email
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Original Email</DialogTitle>
<DialogDescription>
From: {debt.vendor} {formatDate(debt.created_at)}
</DialogDescription>
</DialogHeader>
<div className="mt-4">
<pre className="whitespace-pre-wrap text-sm bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border dark:border-gray-700">
{debt.raw_email || "No email content available"}
</pre>
</div>
</DialogContent>
</Dialog>
<div className="space-y-3">
<div className="flex gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="flex-1">
<Mail className="h-4 w-4 mr-2" />
View Email
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Original Email</DialogTitle>
<DialogDescription>
From: {debt.vendor} {formatDate(debt.created_at)}
</DialogDescription>
</DialogHeader>
<div className="mt-4">
<pre className="whitespace-pre-wrap text-sm bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border dark:border-gray-700">
{debt.raw_email || "No email content available"}
</pre>
</div>
</DialogContent>
</Dialog>
{debt.metadata?.aiEmail && <EditableResponseDialog />}
{debt.metadata?.aiEmail && <EditableResponseDialog />}
</div>
{/* Approve/Reject Buttons */}
{showApproveRejectButtons() && (
<div className="space-y-2">
{!hasServerToken && (
<div className="flex items-center gap-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm text-amber-700 dark:text-amber-300 flex-1">
Configure your Postmark server token to enable email
sending.
</span>
<Button
variant="outline"
size="sm"
onClick={() => (window.location.href = "/configuration")}
className="text-amber-700 border-amber-300 hover:bg-amber-100"
>
<ExternalLink className="h-3 w-3 mr-1" />
Settings
</Button>
</div>
)}
<div className="flex gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="default"
size="sm"
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
disabled={!canApprove(true) || isApproving}
>
<CheckCircle className="h-4 w-4 mr-2" />
{isApproving ? "Sending..." : "Approve & Send"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Send Negotiation Email
</AlertDialogTitle>
<AlertDialogDescription>
This will send the approved negotiation email to{" "}
{debt.vendor}. Make sure you have reviewed and are
satisfied with the email content.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleApprove()}
className="bg-green-600 hover:bg-green-700"
>
Send Email
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
variant="outline"
className="flex-1"
onClick={() => handleApprove(false)}
disabled={isApproving || isRejecting || !canApprove(false)}
>
<CheckCircle className="h-4 w-4 mr-2" />
Approve
</Button>
</div>
</div>
)}
</div>
{/* Reject Button - only show if debt is not approved or sent */}
{!isReadOnly && (
<div className="flex justify-end gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="flex-1"
disabled={isRejecting}
>
<XCircle className="h-4 w-4 mr-2" />
{isRejecting ? "Rejecting..." : "Reject"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reject Negotiation</AlertDialogTitle>
<AlertDialogDescription>
This will mark the negotiation as rejected and opt you out
of this debt collection process. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleReject}
className="bg-red-600 hover:bg-red-700"
>
Reject
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</CardContent>
</Card>
);

View File

@@ -1,18 +1,18 @@
import React, { useState } from "react";
import { supabase } from "../lib/supabase";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
} from "./ui/dialog";
import { Alert, AlertDescription } from "./ui/alert";
import { Mail, Plus, CheckCircle, ArrowRight, UserCheck } from "lucide-react";
import { toast } from "../hooks/use-toast";
import { toast } from "sonner";
interface OnboardingDialogProps {
open: boolean;
@@ -63,10 +63,8 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
setStep("email");
} catch (error: any) {
toast({
title: "Error",
toast.error("Error", {
description: error.message,
variant: "destructive",
});
} finally {
setLoading(false);
@@ -102,10 +100,8 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
setStep("complete");
} catch (error: any) {
toast({
title: "Error",
toast.error("Error", {
description: error.message,
variant: "destructive",
});
} finally {
setLoading(false);

View File

@@ -22,7 +22,14 @@ export type Debt = {
vendor: string;
amount: number;
raw_email: string | null;
status: "received" | "negotiating" | "settled" | "failed" | "opted_out";
status:
| "received"
| "negotiating"
| "approved"
| "sent"
| "settled"
| "failed"
| "opted_out";
negotiated_plan: string | null;
projected_savings: number;
user_id: string;
@@ -47,6 +54,7 @@ export type UserProfile = {
onboarding_completed: boolean;
first_login_at: string | null;
email_processing_limit: number;
postmark_server_token: string | null;
};
export type AdditionalEmail = {

View File

@@ -1,13 +1,11 @@
import type { APIRoute } from "astro";
import {
createSupabaseAdmin,
handleDatabaseError,
getUserIdByEmail,
handleDatabaseError,
} from "../../lib/supabase-admin";
import { generateObject } from "ai";
import {
createGoogleGenerativeAI,
} from "@ai-sdk/google";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { z } from "zod";
import type { SupabaseClient } from "@supabase/supabase-js";
@@ -27,7 +25,9 @@ const debtSchema = z.object({
// Schema for opt-out detection
const optOutSchema = z.object({
isOptOut: z.boolean().describe("Whether this email contains an opt-out request"),
isOptOut: z.boolean().describe(
"Whether this email contains an opt-out request",
),
confidence: z
.number()
.min(0)
@@ -41,11 +41,12 @@ const optOutSchema = z.object({
// Function to detect opt-out requests using AI
async function detectOptOutWithAI(emailText: string, fromEmail: string) {
try {
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 keyword detection");
console.warn(
"Google API key not configured, falling back to keyword detection",
);
return null;
}
@@ -53,7 +54,8 @@ async function detectOptOutWithAI(emailText: string, fromEmail: string) {
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system: `You are an expert at analyzing email content to detect opt-out requests.
system:
`You are an expert at analyzing email content to detect opt-out requests.
Analyze the email to determine if the sender is requesting to opt-out, unsubscribe,
or stop receiving communications. Consider:
- Explicit opt-out keywords (STOP, UNSUBSCRIBE, REMOVE, etc.)
@@ -79,12 +81,11 @@ async function detectOptOutWithAI(emailText: string, fromEmail: string) {
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"
"Google API key not configured, falling back to regex parsing",
);
throw new Error("No Google API key configured");
}
@@ -93,7 +94,8 @@ async function parseDebtWithAI(emailText: string, fromEmail: string) {
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system: `You are an expert at analyzing debt collection and billing emails.
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.
@@ -120,13 +122,15 @@ async function parseDebtWithAI(emailText: string, fromEmail: string) {
}
}
// Function to increment email processing usage
async function incrementEmailUsage(userId: string, supabaseAdmin: SupabaseClient) {
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
const { error } = await supabaseAdmin.rpc("increment_email_usage", {
target_user_id: userId,
});
if (error) {
@@ -150,7 +154,7 @@ export const POST: APIRoute = async ({ request }) => {
{
status: 500,
headers: { "Content-Type": "application/json" },
}
},
);
}
@@ -185,7 +189,9 @@ export const POST: APIRoute = async ({ request }) => {
if (optOutDetection) {
hasOptOut = optOutDetection.isOptOut && optOutDetection.confidence > 0.7;
console.log(`AI opt-out detection: ${hasOptOut} (confidence: ${optOutDetection.confidence})`);
console.log(
`AI opt-out detection: ${hasOptOut} (confidence: ${optOutDetection.confidence})`,
);
} else {
// Fallback to keyword matching if AI is unavailable
const optOutKeywords = ["STOP", "UNSUBSCRIBE", "OPT-OUT", "REMOVE"];
@@ -227,7 +233,7 @@ export const POST: APIRoute = async ({ request }) => {
{
status: 400,
headers: { "Content-Type": "application/json" },
}
},
);
}
@@ -264,7 +270,7 @@ export const POST: APIRoute = async ({ request }) => {
{
status: 500,
headers: { "Content-Type": "application/json" },
}
},
);
}
@@ -283,20 +289,19 @@ 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 || import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseAnonKey =
process.env.SUPABASE_ANON_KEY ||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
const supabaseUrl = process.env.SUPABASE_URL ||
import.meta.env.PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ||
import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
if (supabaseUrl && supabaseAnonKey) {
if (supabaseUrl && supabaseServiceKey) {
const negotiateUrl = `${supabaseUrl}/functions/v1/negotiate`;
try {
await fetch(negotiateUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${supabaseAnonKey}`,
Authorization: `Bearer ${supabaseServiceKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ record: insertedDebt }),
@@ -307,7 +312,7 @@ export const POST: APIRoute = async ({ request }) => {
}
} else {
console.warn(
"Supabase environment variables not configured for negotiation trigger"
"Supabase environment variables not configured for negotiation trigger",
);
}
}

View File

@@ -4,6 +4,7 @@ import Layout from "../layouts/Layout.astro";
import { Configuration as ConfigComponent } from "../components/Configuration";
import { Navbar } from "../components/Navbar";
import { AuthGuard } from "../components/AuthGuard";
import { Toaster } from "../components/ui/sonner";
---
<Layout title="Configuration - InboxNegotiator">
@@ -12,4 +13,5 @@ import { AuthGuard } from "../components/AuthGuard";
<AuthGuard requireAuth={true} client:load>
<ConfigComponent client:load />
</AuthGuard>
<Toaster client:load />
</Layout>

View File

@@ -1,15 +1,17 @@
---
import '@/styles/globals.css'
import Layout from '../layouts/Layout.astro';
import { Dashboard } from '../components/Dashboard';
import { AuthGuard } from '../components/AuthGuard';
import { Navbar } from '../components/Navbar';
import "@/styles/globals.css";
import Layout from "../layouts/Layout.astro";
import { Dashboard } from "../components/Dashboard";
import { AuthGuard } from "../components/AuthGuard";
import { Navbar } from "../components/Navbar";
import { Toaster } from "../components/ui/sonner";
---
<Layout title="Dashboard - InboxNegotiator">
<Navbar client:load />
<AuthGuard requireAuth={true} client:load>
<Dashboard client:load />
</AuthGuard>
</Layout>
<Navbar client:load />
<AuthGuard requireAuth={true} client:load>
<Dashboard client:load />
</AuthGuard>
<Toaster client:load />
</Layout>

View File

@@ -0,0 +1,235 @@
/*
# Debt Approval Edge Function
This function handles debt approval without sending emails:
- Updates debt status to "approved"
- Logs the approval action
- Saves finalized negotiation data
- Updates metadata with approval timestamp
*/
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
interface ApproveDebtRequest {
debtId: string;
approvalNote?: string;
}
interface DebtRecord {
id: string;
vendor: string;
amount: number;
raw_email: string;
negotiated_plan?: string;
status: string;
metadata?: {
aiEmail?: {
subject: string;
body: string;
strategy: string;
};
fromEmail?: string;
};
}
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
if (req.method !== "POST") {
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Initialize Supabase client with auth context
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization") ?? "" },
},
},
);
// Get the authenticated user
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Authorization header required" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const token = authHeader.replace("Bearer ", "");
const { data: userData } = await supabaseClient.auth.getUser(token);
const user = userData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const { debtId, approvalNote }: ApproveDebtRequest = await req.json();
if (!debtId) {
return new Response(
JSON.stringify({ error: "Missing debtId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch debt record - RLS will ensure user can only access their own debts
const { data: debtRecord, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.eq("user_id", user.id)
.single();
if (debtError || !debtRecord) {
return new Response(
JSON.stringify({ error: "Debt not found or access denied" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const debt = debtRecord as DebtRecord;
// Validate that the debt is in negotiating status
if (debt.status !== "negotiating") {
return new Response(
JSON.stringify({
error: "Debt is not in negotiating status",
currentStatus: debt.status,
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Validate that AI email exists
if (!debt.metadata?.aiEmail) {
return new Response(
JSON.stringify({ error: "No AI-generated email found for this debt" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const currentTimestamp = new Date().toISOString();
// Update debt status to approved - using authenticated client
const { error: updateError } = await supabaseClient
.from("debts")
.update({
status: "approved",
metadata: {
...debt.metadata,
approved: {
approvedAt: currentTimestamp,
approvalNote: approvalNote || "Approved without sending email",
strategy: debt.metadata.aiEmail.strategy,
finalizedSubject: debt.metadata.aiEmail.subject,
finalizedBody: debt.metadata.aiEmail.body,
},
},
})
.eq("id", debtId);
if (updateError) {
console.error("Error updating debt status:", updateError);
return new Response(
JSON.stringify({
error: "Failed to update debt status",
details: updateError.message,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Log the approval action - using authenticated client
const { error: auditError } = await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: "debt_approved",
details: {
approvedAt: currentTimestamp,
approvalNote: approvalNote || "Approved without sending email",
strategy: debt.metadata.aiEmail.strategy,
subject: debt.metadata.aiEmail.subject,
vendor: debt.vendor,
amount: debt.amount,
},
});
if (auditError) {
console.warn("Failed to log approval action:", auditError);
// Don't fail the entire operation for audit log issues
}
return new Response(
JSON.stringify({
success: true,
debtId: debtId,
status: "approved",
approvedAt: currentTimestamp,
vendor: debt.vendor,
amount: debt.amount,
}),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error in approve-debt function:", error);
return new Response(
JSON.stringify({
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});

View File

@@ -222,29 +222,32 @@ I am writing to propose a payment arrangement to resolve this matter.`;
let proposal = "";
switch (strategy) {
case "extension":
case "extension": {
proposal =
` I respectfully request a 30-day extension to arrange full payment. I anticipate being able to settle this account in full by {{ Proposed Payment Date }}.
During this extension period, I request that no additional fees or interest be applied to maintain the current balance.`;
break;
}
case "installment":
case "installment": {
const monthlyPayment = (record.amount / 3).toFixed(2);
proposal = ` I am able to pay the full balance of $${
record.amount.toFixed(2)
} through an installment plan. I propose to make three (3) equal monthly payments of $${monthlyPayment}, with the first payment to be made on {{ Proposed Start Date }}.`;
break;
}
case "settlement":
case "settlement": {
const settlementAmount = (record.amount * 0.6).toFixed(2);
proposal =
` I would like to propose a lump-sum settlement offer of $${settlementAmount} (60% of the current balance) to resolve this matter completely.
This settlement would be paid within 10 business days of written acceptance of this offer. Upon payment, I request written confirmation that this account will be considered paid in full and closed.`;
break;
}
case "dispute":
case "dispute": {
proposal =
` I am formally disputing this debt and requesting validation under Section 809(b) of the Fair Debt Collection Practices Act.
@@ -256,6 +259,7 @@ Please provide:
Until proper validation is provided, I request that all collection activities cease.`;
break;
}
}
const closingResponse = `
@@ -273,88 +277,129 @@ ${personalData.full_name || "{{ Your Typed Name }}"}`;
}
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
if (req.method !== "POST") {
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const { record }: { record: DebtRecord } = await req.json();
// Check if this is a webhook call (using service role) or authenticated user call
const authHeader = req.headers.get("Authorization");
const isServiceRoleCall = authHeader?.includes(
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "",
);
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseKey);
let user: { id: string } | null = null;
let supabaseClient;
// Fetch user personal data
const { data: userData, error: userError } = await supabase
.from("users")
.select(
"full_name, address_line_1, address_line_2, city, state, zip_code, phone_number",
)
.eq("id", record.user_id)
.single();
if (isServiceRoleCall) {
// This is a webhook/service call - use admin client
supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
);
if (userError) {
console.error("Error fetching user data:", userError);
}
// For webhook calls, we'll get the userId from the request body along with the record
const { record }: { record: DebtRecord } = await req.json();
const personalData: PersonalData = userData || {};
if (!record || !record.user_id) {
return new Response(
JSON.stringify({
error: "Missing record or user_id for service call",
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Generate AI-powered negotiation email
const emailResult = await generateNegotiationEmail(record, personalData);
user = { id: record.user_id };
// Update debt record with AI-generated content
const { error: updateError } = await supabase
.from("debts")
.update({
negotiated_plan:
`Subject: ${emailResult.subject}\n\n${emailResult.body}`,
projected_savings: emailResult.projectedSavings,
status: "negotiating",
metadata: {
...record.metadata,
aiEmail: {
subject: emailResult.subject,
body: emailResult.body,
strategy: emailResult.strategy,
confidence: emailResult.confidenceLevel,
reasoning: emailResult.reasoning,
customTerms: emailResult.customTerms,
// Use the record as-is for webhook calls
const personalData = await fetchUserPersonalData(supabaseClient, user.id);
return await processNegotiation(supabaseClient, record, personalData);
} else {
// This is an authenticated user call
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Authorization header required" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Initialize Supabase client with auth context
supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: authHeader },
},
},
})
.eq("id", record.id);
);
if (updateError) {
throw updateError;
const token = authHeader.replace("Bearer ", "");
const { data: userData } = await supabaseClient.auth.getUser(token);
user = userData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const { debtId }: { debtId: string } = await req.json();
if (!debtId) {
return new Response(
JSON.stringify({ error: "Missing debtId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch debt record - RLS will ensure user can only access their own debts
const { data: debtRecord, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.eq("user_id", user.id)
.single();
if (debtError || !debtRecord) {
return new Response(
JSON.stringify({ error: "Debt not found or access denied" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const record = debtRecord as DebtRecord;
const personalData = await fetchUserPersonalData(supabaseClient, user.id);
return await processNegotiation(supabaseClient, record, personalData);
}
// Log the action
await supabase
.from("audit_logs")
.insert({
debt_id: record.id,
action: "negotiation_generated",
details: {
strategy: emailResult.strategy,
amount: record.amount,
projected_savings: emailResult.projectedSavings,
ai_confidence: emailResult.confidenceLevel,
reasoning: emailResult.reasoning,
},
});
return new Response(
JSON.stringify({
success: true,
strategy: emailResult.strategy,
projected_savings: emailResult.projectedSavings,
confidence: emailResult.confidenceLevel,
reasoning: emailResult.reasoning,
subject: emailResult.subject,
body: emailResult.body,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Negotiation function error:", error);
const errorMessage = error instanceof Error
@@ -372,3 +417,107 @@ Deno.serve(async (req) => {
);
}
});
// Helper function to fetch user personal data
async function fetchUserPersonalData(
supabaseClient: ReturnType<typeof createClient>,
userId: string,
): Promise<PersonalData>;
async function fetchUserPersonalData(
supabaseClient: unknown,
userId: string,
): Promise<PersonalData>;
async function fetchUserPersonalData(
supabaseClient: unknown,
userId: string,
): Promise<PersonalData> {
const client = supabaseClient as ReturnType<typeof createClient>;
const { data: userPersonalData, error: userError } = await client
.from("users")
.select(
"full_name, address_line_1, address_line_2, city, state, zip_code, phone_number",
)
.eq("id", userId)
.single();
if (userError) {
console.error("Error fetching user data:", userError);
}
return (userPersonalData as PersonalData) || {};
}
// Helper function to process the negotiation
async function processNegotiation(
supabaseClient: ReturnType<typeof createClient>,
record: DebtRecord,
personalData: PersonalData,
): Promise<Response>;
async function processNegotiation(
supabaseClient: unknown,
record: DebtRecord,
personalData: PersonalData,
): Promise<Response>;
async function processNegotiation(
supabaseClient: unknown,
record: DebtRecord,
personalData: PersonalData,
): Promise<Response> {
const client = supabaseClient as ReturnType<typeof createClient>;
// Generate AI-powered negotiation email
const emailResult = await generateNegotiationEmail(record, personalData);
// Update debt record with AI-generated content - using provided client
const { error: updateError } = await client
.from("debts")
.update({
negotiated_plan: `Subject: ${emailResult.subject}\n\n${emailResult.body}`,
projected_savings: emailResult.projectedSavings,
status: "negotiating",
metadata: {
...record.metadata,
aiEmail: {
subject: emailResult.subject,
body: emailResult.body,
strategy: emailResult.strategy,
confidence: emailResult.confidenceLevel,
reasoning: emailResult.reasoning,
customTerms: emailResult.customTerms,
},
},
})
.eq("id", record.id);
if (updateError) {
throw updateError;
}
// Log the action - using provided client
await client
.from("audit_logs")
.insert({
debt_id: record.id,
action: "negotiation_generated",
details: {
strategy: emailResult.strategy,
amount: record.amount,
projected_savings: emailResult.projectedSavings,
ai_confidence: emailResult.confidenceLevel,
reasoning: emailResult.reasoning,
},
});
return new Response(
JSON.stringify({
success: true,
strategy: emailResult.strategy,
projected_savings: emailResult.projectedSavings,
confidence: emailResult.confidenceLevel,
reasoning: emailResult.reasoning,
subject: emailResult.subject,
body: emailResult.body,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}

View File

@@ -0,0 +1,341 @@
/*
# Email Sending Edge Function
This function sends negotiated emails via Postmark:
- Validates user has server token configured
- Sends the approved negotiation email to the debt collector
- Updates debt status and logs the action
- Ensures FDCPA compliance
*/
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
interface SendEmailRequest {
debtId: string;
}
interface UserProfile {
postmark_server_token?: string;
}
interface DebtRecord {
id: string;
vendor: string;
amount: number;
raw_email: string;
negotiated_plan?: string;
metadata?: {
aiEmail?: {
subject: string;
body: string;
strategy: string;
};
toEmail?: string;
fromEmail?: string;
};
}
// Send email using Postmark
async function sendEmailViaPostmark(
serverToken: string,
fromEmail: string,
toEmail: string,
subject: string,
body: string,
) {
const postmarkEndpoint = "https://api.postmarkapp.com/email";
const emailData = {
From: fromEmail,
To: toEmail,
Subject: subject,
TextBody: body,
MessageStream: "outbound",
};
const response = await fetch(postmarkEndpoint, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"X-Postmark-Server-Token": serverToken,
},
body: JSON.stringify(emailData),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Postmark API error: ${response.status} - ${errorData}`);
}
return await response.json();
}
// Extract email address from various formats
function extractEmailAddress(emailString: string): string {
// Handle formats like "Name <email@domain.com>" or just "email@domain.com"
const emailMatch = emailString.match(/<([^>]+)>/) ||
emailString.match(/([^\s<>]+@[^\s<>]+)/);
return emailMatch ? emailMatch[1] : emailString;
}
Deno.serve(async (req) => {
try {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
// Initialize Supabase client with auth context
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization") ?? "" },
},
},
);
// Get the authenticated user
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Authorization header required" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const token = authHeader.replace("Bearer ", "");
const { data: userData } = await supabaseClient.auth.getUser(token);
const user = userData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const { debtId }: SendEmailRequest = await req.json();
if (!debtId) {
return new Response(
JSON.stringify({ error: "Missing debtId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch user profile with server token - using authenticated client
const { data: userProfile, error: userError } = await supabaseClient
.from("user_profiles")
.select("postmark_server_token")
.eq("user_id", user.id)
.single();
if (userError || !userProfile) {
return new Response(
JSON.stringify({ error: "User not found" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const userProfileData = userProfile as UserProfile;
if (!userProfileData.postmark_server_token) {
return new Response(
JSON.stringify({
error: "Postmark server token not configured",
requiresConfiguration: true,
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch debt record - RLS will ensure user can only access their own debts
const { data: debtRecord, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.eq("user_id", user.id)
.single();
if (debtError || !debtRecord) {
return new Response(
JSON.stringify({ error: "Debt record not found" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const debt = debtRecord as DebtRecord;
// Validate that negotiated plan exists
if (!debt.negotiated_plan || !debt.metadata?.aiEmail) {
return new Response(
JSON.stringify({
error: "No negotiated plan found. Please generate negotiation first.",
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Extract email details
const { subject, body } = debt.metadata.aiEmail;
const fromEmail = debt.metadata?.toEmail || user.email;
if (!fromEmail) {
return new Response(
JSON.stringify({ error: "No valid sender email found" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Determine recipient email
let toEmail = debt.vendor;
if (debt.metadata?.fromEmail) {
toEmail = extractEmailAddress(debt.metadata.fromEmail);
} else if (debt.vendor.includes("@")) {
toEmail = extractEmailAddress(debt.vendor);
} else {
// If vendor doesn't contain email, try to construct one
toEmail = `collections@${
debt.vendor.toLowerCase().replace(/\s+/g, "")
}.com`;
}
try {
// Send email via Postmark
const emailResult = await sendEmailViaPostmark(
userProfileData.postmark_server_token,
fromEmail,
toEmail,
subject,
body,
);
// Update debt status to sent - using authenticated client
const { error: updateError } = await supabaseClient
.from("debts")
.update({
status: "sent",
metadata: {
...debt.metadata,
emailSent: {
sentAt: new Date().toISOString(),
messageId: emailResult.MessageID,
to: toEmail,
from: fromEmail,
subject: subject,
},
},
})
.eq("id", debtId);
if (updateError) {
console.error("Error updating debt status:", updateError);
}
// Log the action - using authenticated client
await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: "email_sent",
details: {
messageId: emailResult.MessageID,
to: toEmail,
from: fromEmail,
subject: subject,
strategy: debt.metadata.aiEmail.strategy,
},
});
return new Response(
JSON.stringify({
success: true,
messageId: emailResult.MessageID,
sentTo: toEmail,
sentFrom: fromEmail,
subject: subject,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (emailError) {
console.error("Email sending error:", emailError);
const errorMessage = emailError instanceof Error
? emailError.message
: String(emailError);
// Log the failed attempt - using authenticated client
await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: "email_send_failed",
details: {
error: errorMessage,
to: toEmail,
from: fromEmail,
subject: subject,
},
});
return new Response(
JSON.stringify({
error: "Failed to send email",
details: errorMessage,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
} catch (error) {
console.error("Send email function error:", error);
const errorMessage = error instanceof Error
? error.message
: "An unknown error occurred" +
(Deno.env.get("NODE_ENV") === "development"
? `: ${JSON.stringify(error)}`
: "");
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});

View File

@@ -0,0 +1,14 @@
/*
# Add Postmark Server Token
1. Changes
- Add postmark_server_token column to user_profiles table
- This will be used for sending approved emails
*/
-- Add postmark_server_token to user_profiles
ALTER TABLE user_profiles
ADD COLUMN IF NOT EXISTS postmark_server_token text;
-- Add comment for documentation
COMMENT ON COLUMN user_profiles.postmark_server_token IS 'Postmark server token for sending approved negotiation emails';

View File

@@ -0,0 +1,18 @@
-- Add postmark_server_token to user_profiles table
ALTER TABLE user_profiles
ADD COLUMN IF NOT EXISTS postmark_server_token TEXT;
-- Update debts table status constraint to include 'sent'
ALTER TABLE debts
DROP CONSTRAINT IF EXISTS debts_status_check;
ALTER TABLE debts
ADD CONSTRAINT debts_status_check
CHECK (status IN ('received', 'negotiating', 'sent', 'settled', 'failed', 'opted_out'));
-- Create index on postmark_server_token for faster lookups
CREATE INDEX IF NOT EXISTS idx_user_profiles_postmark_token
ON user_profiles(user_id) WHERE postmark_server_token IS NOT NULL;
-- Add comment for documentation
COMMENT ON COLUMN user_profiles.postmark_server_token IS 'Postmark server token for sending negotiation emails';

View File

@@ -0,0 +1,7 @@
-- Add 'approved' status to debts table
ALTER TABLE debts
DROP CONSTRAINT IF EXISTS debts_status_check;
ALTER TABLE debts
ADD CONSTRAINT debts_status_check
CHECK (status IN ('received', 'negotiating', 'approved', 'sent', 'settled', 'failed', 'opted_out'));