mirror of
https://github.com/FranP-code/inbox-negotiator.git
synced 2025-10-13 00:42:26 +00:00
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:
235
supabase/functions/approve-debt/index.ts
Normal file
235
supabase/functions/approve-debt/index.ts
Normal 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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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" } },
|
||||
);
|
||||
}
|
||||
|
||||
341
supabase/functions/send-email/index.ts
Normal file
341
supabase/functions/send-email/index.ts
Normal 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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user