Phase 1: Forward Assist initial build

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>
This commit is contained in:
Eric Jungbauer
2026-03-20 01:45:13 +00:00
parent 0bae347e65
commit 05aad75272
56 changed files with 11815 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
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")}`;
}
+93
View File
@@ -0,0 +1,93 @@
import { Job } from "bullmq";
import nodemailer from "nodemailer";
import { prisma } from "../services/prisma";
interface SmtpSendData {
messageId: string;
tenantId: string;
}
export async function smtpSendProcessor(job: Job<SmtpSendData>): Promise<void> {
const { messageId, tenantId } = job.data;
const message = await prisma.message.findUnique({
where: { id: messageId },
include: {
ticket: {
include: {
emailAccount: true,
},
},
},
});
if (!message) {
console.error(`[SMTP] Message ${messageId} not found`);
return;
}
const account = message.ticket.emailAccount;
if (!account) {
console.error(`[SMTP] No email account for ticket ${message.ticket.id}`);
return;
}
console.log(`[SMTP] Sending reply for ticket ${message.ticket.ticketNumber} via ${account.emailAddress}`);
const transporter = nodemailer.createTransport({
host: account.smtpHost,
port: account.smtpPort,
secure: account.smtpTls && account.smtpPort === 465,
auth: {
user: account.smtpUser,
pass: account.smtpPassword,
},
tls: {
rejectUnauthorized: false,
},
});
try {
// Find the original inbound message to get its Message-ID for threading
const originalMessage = await prisma.message.findFirst({
where: {
ticketId: message.ticketId,
direction: "inbound",
},
orderBy: { createdAt: "desc" },
});
const mailOptions: nodemailer.SendMailOptions = {
from: {
name: message.fromName || account.name,
address: account.emailAddress,
},
to: message.toEmail,
subject: message.subject,
text: message.bodyText,
html: message.bodyHtml || undefined,
};
// Add threading headers
if (originalMessage?.messageId) {
mailOptions.inReplyTo = originalMessage.messageId;
mailOptions.references = originalMessage.messageId;
}
const result = await transporter.sendMail(mailOptions);
// Update message with the sent Message-ID
await prisma.message.update({
where: { id: messageId },
data: {
messageId: result.messageId,
sentAt: new Date(),
},
});
console.log(`[SMTP] Sent successfully: ${result.messageId}`);
} catch (error: any) {
console.error(`[SMTP] Send failed for message ${messageId}:`, error.message);
throw error; // Let BullMQ retry
}
}