05aad75272
Multi-tenant AI help desk SaaS for the firearms industry. Full monorepo: API (Express/Prisma), Worker (BullMQ), Frontend (React/Vite/Tailwind). PostgreSQL 16 + pgvector, Redis 7, JWT auth, RLS tenant isolation. Dark Armory theme with tactical branding throughout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
259 lines
6.9 KiB
TypeScript
259 lines
6.9 KiB
TypeScript
import { Job } from "bullmq";
|
|
import { ImapFlow } from "imapflow";
|
|
import { simpleParser, ParsedMail } from "mailparser";
|
|
import { prisma } from "../services/prisma";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
interface ImapPollData {
|
|
emailAccountId: string;
|
|
tenantId: string;
|
|
}
|
|
|
|
export async function imapPollProcessor(job: Job<ImapPollData>): Promise<void> {
|
|
const { emailAccountId, tenantId } = job.data;
|
|
|
|
const account = await prisma.emailAccount.findUnique({
|
|
where: { id: emailAccountId },
|
|
});
|
|
|
|
if (!account || !account.isActive) {
|
|
console.log(`[IMAP] Account ${emailAccountId} not found or inactive, skipping`);
|
|
return;
|
|
}
|
|
|
|
console.log(`[IMAP] Polling ${account.emailAddress} for tenant ${tenantId}`);
|
|
|
|
const client = new ImapFlow({
|
|
host: account.imapHost,
|
|
port: account.imapPort,
|
|
secure: account.imapTls,
|
|
auth: {
|
|
user: account.imapUser,
|
|
pass: account.imapPassword,
|
|
},
|
|
logger: false,
|
|
});
|
|
|
|
try {
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock("INBOX");
|
|
|
|
try {
|
|
// Fetch unseen messages
|
|
const messages: ParsedMail[] = [];
|
|
for await (const msg of client.fetch(
|
|
{ seen: false },
|
|
{ source: true, envelope: true, flags: true }
|
|
)) {
|
|
if (msg.source) {
|
|
const parsed = await simpleParser(msg.source);
|
|
messages.push(parsed);
|
|
|
|
// Mark as seen
|
|
await client.messageFlagsAdd(msg.seq, ["\\Seen"]);
|
|
}
|
|
}
|
|
|
|
console.log(`[IMAP] Found ${messages.length} new messages for ${account.emailAddress}`);
|
|
|
|
// Process each message
|
|
for (const mail of messages) {
|
|
await processIncomingEmail(mail, account, tenantId);
|
|
}
|
|
|
|
// Update last poll time
|
|
await prisma.emailAccount.update({
|
|
where: { id: emailAccountId },
|
|
data: { lastPollAt: new Date(), lastError: null },
|
|
});
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
|
|
await client.logout();
|
|
} catch (error: any) {
|
|
console.error(`[IMAP] Error polling ${account.emailAddress}:`, error.message);
|
|
|
|
// Store the error on the account
|
|
await prisma.emailAccount.update({
|
|
where: { id: emailAccountId },
|
|
data: {
|
|
lastPollAt: new Date(),
|
|
lastError: error.message,
|
|
},
|
|
});
|
|
|
|
throw error; // Let BullMQ handle retries
|
|
}
|
|
}
|
|
|
|
async function processIncomingEmail(
|
|
mail: ParsedMail,
|
|
account: any,
|
|
tenantId: string
|
|
): Promise<void> {
|
|
const fromEmail = mail.from?.value?.[0]?.address || "unknown@unknown.com";
|
|
const fromName = mail.from?.value?.[0]?.name || undefined;
|
|
const subject = mail.subject || "(No Subject)";
|
|
const bodyText = mail.text || "";
|
|
const bodyHtml = mail.html || undefined;
|
|
const messageId = mail.messageId || undefined;
|
|
const inReplyTo = mail.inReplyTo || undefined;
|
|
const references = Array.isArray(mail.references)
|
|
? mail.references.join(" ")
|
|
: mail.references || undefined;
|
|
|
|
// Thread detection: check if this is a reply to an existing ticket
|
|
let existingTicket = null;
|
|
|
|
// Strategy 1: Match by In-Reply-To or References headers
|
|
if (inReplyTo || references) {
|
|
const refIds = [inReplyTo, ...(references?.split(/\s+/) || [])].filter(Boolean);
|
|
|
|
if (refIds.length > 0) {
|
|
const existingMessage = await prisma.message.findFirst({
|
|
where: {
|
|
tenantId,
|
|
messageId: { in: refIds as string[] },
|
|
},
|
|
include: { ticket: true },
|
|
});
|
|
|
|
if (existingMessage) {
|
|
existingTicket = existingMessage.ticket;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 2: Match by subject line (strip Re:/Fwd: prefixes)
|
|
if (!existingTicket) {
|
|
const cleanSubject = subject.replace(/^(Re|Fwd|Fw):\s*/gi, "").trim();
|
|
existingTicket = await prisma.ticket.findFirst({
|
|
where: {
|
|
tenantId,
|
|
customerEmail: fromEmail,
|
|
subject: { contains: cleanSubject, mode: "insensitive" },
|
|
status: { notIn: ["archived", "resolved"] },
|
|
},
|
|
orderBy: { lastMessageAt: "desc" },
|
|
});
|
|
}
|
|
|
|
if (existingTicket) {
|
|
// Add message to existing ticket
|
|
await prisma.message.create({
|
|
data: {
|
|
tenantId,
|
|
ticketId: existingTicket.id,
|
|
direction: "inbound",
|
|
fromEmail,
|
|
fromName,
|
|
toEmail: account.emailAddress,
|
|
subject,
|
|
bodyText,
|
|
bodyHtml: bodyHtml || null,
|
|
messageId,
|
|
inReplyTo,
|
|
references,
|
|
sentAt: mail.date || new Date(),
|
|
},
|
|
});
|
|
|
|
// Update ticket
|
|
await prisma.ticket.update({
|
|
where: { id: existingTicket.id },
|
|
data: {
|
|
status: "incoming",
|
|
messageCount: { increment: 1 },
|
|
lastMessageAt: new Date(),
|
|
},
|
|
});
|
|
|
|
console.log(
|
|
`[IMAP] Added message to existing ticket ${existingTicket.ticketNumber}`
|
|
);
|
|
} else {
|
|
// Create new ticket
|
|
const ticketNumber = await generateNextTicketNumber(tenantId);
|
|
|
|
// Find or create customer profile
|
|
let profile = await prisma.customerProfile.findUnique({
|
|
where: { tenantId_email: { tenantId, email: fromEmail } },
|
|
});
|
|
|
|
if (!profile) {
|
|
profile = await prisma.customerProfile.create({
|
|
data: {
|
|
tenantId,
|
|
email: fromEmail,
|
|
name: fromName,
|
|
},
|
|
});
|
|
}
|
|
|
|
await prisma.ticket.create({
|
|
data: {
|
|
tenantId,
|
|
ticketNumber,
|
|
emailAccountId: account.id,
|
|
subject,
|
|
status: "incoming",
|
|
priority: "medium",
|
|
customerEmail: fromEmail,
|
|
customerName: fromName,
|
|
customerProfileId: profile.id,
|
|
messageCount: 1,
|
|
lastMessageAt: new Date(),
|
|
messages: {
|
|
create: {
|
|
tenantId,
|
|
direction: "inbound",
|
|
fromEmail,
|
|
fromName,
|
|
toEmail: account.emailAddress,
|
|
subject,
|
|
bodyText,
|
|
bodyHtml: bodyHtml || null,
|
|
messageId,
|
|
inReplyTo,
|
|
references,
|
|
sentAt: mail.date || new Date(),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Update customer profile
|
|
await prisma.customerProfile.update({
|
|
where: { id: profile.id },
|
|
data: {
|
|
ticketCount: { increment: 1 },
|
|
lastContactAt: new Date(),
|
|
name: profile.name || fromName,
|
|
},
|
|
});
|
|
|
|
console.log(`[IMAP] Created new ticket ${ticketNumber} from ${fromEmail}`);
|
|
}
|
|
}
|
|
|
|
async function generateNextTicketNumber(tenantId: string): Promise<string> {
|
|
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
|
|
const settings = (tenant?.settings as any) || {};
|
|
const prefix = settings.ticketPrefix || "FA";
|
|
|
|
const lastTicket = await prisma.ticket.findFirst({
|
|
where: { tenantId },
|
|
orderBy: { createdAt: "desc" },
|
|
select: { ticketNumber: true },
|
|
});
|
|
|
|
let nextNum = 1;
|
|
if (lastTicket) {
|
|
const match = lastTicket.ticketNumber.match(/(\d+)$/);
|
|
if (match) nextNum = parseInt(match[1], 10) + 1;
|
|
}
|
|
|
|
return `${prefix}-${String(nextNum).padStart(4, "0")}`;
|
|
}
|