import { CommuneClient } from 'commune-ai';
import OpenAI from 'openai';
const commune = new CommuneClient({ apiKey: process.env.COMMUNE_API_KEY });
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
interface AtRiskCustomer {
email: string;
threadId: string;
lastActivity: string;
daysSinceLastActivity: number;
context: string;
}
async function findAtRiskCustomers(inboxId: string): Promise<AtRiskCustomer[]> {
const atRisk: AtRiskCustomer[] = [];
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// Get all threads
let cursor: string | undefined;
let hasMore = true;
while (hasMore) {
const result = await commune.threads.list({
inbox_id: inboxId,
limit: 100,
cursor,
order: 'desc',
});
for (const thread of result.data) {
const lastMessageDate = new Date(thread.last_message_at);
const daysSince = Math.floor(
(Date.now() - lastMessageDate.getTime()) / (1000 * 60 * 60 * 24)
);
// At risk: no activity in 14-60 days, had previous engagement
if (daysSince >= 14 && daysSince <= 60 && thread.message_count >= 2) {
// Get the last message to understand context
const messages = await commune.threads.messages(thread.thread_id, {
limit: 3,
order: 'desc',
});
const lastInbound = messages.find(m => m.direction === 'inbound');
if (!lastInbound) continue;
const sender = lastInbound.participants.find(p => p.role === 'sender');
if (!sender) continue;
atRisk.push({
email: sender.identity,
threadId: thread.thread_id,
lastActivity: thread.last_message_at,
daysSinceLastActivity: daysSince,
context: `Subject: ${thread.subject}\nLast message: ${lastInbound.content.slice(0, 200)}`,
});
}
}
hasMore = result.has_more;
cursor = result.next_cursor || undefined;
}
return atRisk;
}
async function sendReEngagement(customer: AtRiskCustomer) {
// Generate personalized re-engagement email
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: `You write friendly, non-pushy re-engagement emails for Acme Corp.
Keep it short (2-3 sentences). Reference their previous interaction naturally.
Include a clear call-to-action. Don't use aggressive sales language.`,
},
{
role: 'user',
content: `Customer hasn't been active for ${customer.daysSinceLastActivity} days.
Their last conversation context:
${customer.context}
Write a re-engagement email body (HTML).`,
},
],
});
const htmlBody = completion.choices[0].message.content;
await commune.messages.send({
to: customer.email,
subject: 'We miss you — anything we can help with?',
html: htmlBody,
thread_id: customer.threadId, // Continue the existing conversation
});
console.log(`Re-engagement sent to ${customer.email} (${customer.daysSinceLastActivity}d inactive)`);
}
async function runChurnPrevention() {
const inboxId = process.env.INBOX_ID!;
console.log('Finding at-risk customers...');
const atRisk = await findAtRiskCustomers(inboxId);
console.log(`Found ${atRisk.length} at-risk customers`);
// Send re-engagement emails (with rate limiting)
for (const customer of atRisk.slice(0, 50)) { // Max 50 per run
try {
await sendReEngagement(customer);
// Respect rate limits
await new Promise(resolve => setTimeout(resolve, 500));
} catch (err) {
console.error(`Failed for ${customer.email}:`, err);
}
}
// Check delivery metrics
const res = await fetch(
`https://api.commune.email/v1/delivery/metrics?inbox_id=${inboxId}&period=24h`,
{ headers: { Authorization: `Bearer ${process.env.COMMUNE_API_KEY}` } }
);
const { data: metrics } = await res.json() as any;
console.log(`Delivery report: ${metrics.sent} sent, ${metrics.delivery_rate} delivered`);
}
// Run daily via cron
runChurnPrevention();