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): Promise { 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 { 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 { 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")}`; }