594 lines
23 KiB
TypeScript
594 lines
23 KiB
TypeScript
import { getGmailAccessToken } from "../gmail"
|
||
import { logger } from "../logger"
|
||
import { sendGmailReply } from "./emailService"
|
||
import { prisma } from "../prisma"
|
||
import { renderTemplate } from "@/lib/templates/simpleRenderer"
|
||
import { classifyCustomerEmail } from "@/lib/ai/gmailGroq"
|
||
import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety"
|
||
import { buildOrderSummaryForEmail, extractOrderNumberHints } from "@/lib/ai/gmailContext"
|
||
import { findTemplateWithFallback, type TemplateRecord } from "@/lib/juno/templatesRepo"
|
||
|
||
export type ProcessIncomingEmailResult = {
|
||
success: boolean
|
||
error?: string
|
||
/** True when the pipeline attempted an auto-reply to a qualifying unread customer message */
|
||
replied?: boolean
|
||
/** True when the message was recorded as abusive/suspicious and no auto-reply was sent */
|
||
flagged?: boolean
|
||
}
|
||
|
||
/** Normalize Gmail / RFC5322 From header to a lowercase email address. */
|
||
function parseSenderAddress(fromHeader: string): string {
|
||
const raw = fromHeader.trim()
|
||
const inAngles = raw.match(/<([^>]+)>/)
|
||
if (inAngles) {
|
||
return inAngles[1].trim().replace(/^mailto:/i, "").toLowerCase()
|
||
}
|
||
const lone = raw.replace(/^mailto:/i, "").trim()
|
||
if (/^[^\s<>"']+@[^\s<>"']+$/.test(lone)) {
|
||
return lone.toLowerCase()
|
||
}
|
||
const tokens = raw.split(/\s+/)
|
||
for (let i = tokens.length - 1; i >= 0; i--) {
|
||
const t = tokens[i].replace(/[<>",]/g, "").trim()
|
||
if (t.includes("@") && !t.includes("..")) {
|
||
return t.replace(/^mailto:/i, "").toLowerCase()
|
||
}
|
||
}
|
||
return lone.toLowerCase()
|
||
}
|
||
|
||
/** Local-part of email, title-cased for greetings (templates use {{customer_name}}). */
|
||
function greetingNameFromEmail(email: string): string {
|
||
const local = email.split("@")[0]?.trim() || ""
|
||
if (!local) return "there"
|
||
return local.charAt(0).toUpperCase() + local.slice(1).toLowerCase()
|
||
}
|
||
|
||
function buildMinimalFallbackHtml(emailBody: string, storeName: string): string {
|
||
const safeSnippet = emailBody.replace(/<[^>]+>/g, "").slice(0, 400)
|
||
return `
|
||
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
|
||
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
|
||
<h1 style="margin: 0 0 12px; font-size: 20px; color: #e5e7eb;">Thanks for reaching out</h1>
|
||
<p style="margin: 0 0 12px; color: #9ca3af;">
|
||
We have received your message and our team will review it shortly.
|
||
</p>
|
||
<p style="margin: 0 0 16px; color: #9ca3af;">Summary of what you sent:</p>
|
||
<blockquote style="margin: 0 0 20px; padding: 12px 16px; border-left: 3px solid #6366f1; background: #020617;">
|
||
<p style="margin: 0; white-space: pre-wrap; color: #d1d5db; font-size: 14px;">
|
||
${safeSnippet || "No additional message content was detected."}
|
||
</p>
|
||
</blockquote>
|
||
<p style="margin: 0 0 8px; color: #9ca3af;">
|
||
You can reply to this email if you have more details to share.
|
||
</p>
|
||
<p style="margin: 0; color: #e5e7eb;">
|
||
Best regards,<br/>
|
||
<span style="color: #a5b4fc;">${storeName} Support</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
/** When no template exists but we need an order # before sharing order rows. */
|
||
function buildNeedOrderIdFallbackHtml(storeName: string, storeEmail: string): string {
|
||
const contact = storeEmail.trim()
|
||
? `Reply with your order number, or email ${storeEmail}.`
|
||
: `Reply with your order number from your confirmation email.`
|
||
return `
|
||
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
|
||
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
|
||
<p style="margin: 0 0 12px; color: #e5e7eb;">Hello,</p>
|
||
<p style="margin: 0 0 12px; color: #9ca3af;">
|
||
We didn't see an order number in your message. To look up the right order for <strong style="color:#e5e7eb;">${storeName}</strong>,
|
||
please reply with your order number (for example <strong style="color:#e5e7eb;">#1002</strong> or <strong style="color:#e5e7eb;">1002</strong>) from your order confirmation.
|
||
</p>
|
||
<p style="margin: 0; color: #9ca3af;">${contact}</p>
|
||
<p style="margin: 16px 0 0; color: #e5e7eb;">
|
||
Best regards,<br/>
|
||
<span style="color: #a5b4fc;">${storeName} Support</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
/** When no off_topic template exists — polite boundary, no order data. */
|
||
function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string {
|
||
const contact = storeEmail.trim()
|
||
? `If you have a question about an order from ${storeName}, reply with details or email us at ${storeEmail}.`
|
||
: `If you have a question about an order from ${storeName}, reply with your question and order number if you have one.`
|
||
return `
|
||
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
|
||
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
|
||
<p style="margin: 0 0 12px; color: #e5e7eb;">Hello,</p>
|
||
<p style="margin: 0 0 12px; color: #9ca3af;">
|
||
This inbox only handles orders, products, shipping, returns, and billing for <strong style="color:#e5e7eb;">${storeName}</strong>.
|
||
We can't answer unrelated requests (news, weather, other websites, or general topics).
|
||
</p>
|
||
<p style="margin: 0; color: #9ca3af;">${contact}</p>
|
||
<p style="margin: 16px 0 0; color: #e5e7eb;">
|
||
Best regards,<br/>
|
||
<span style="color: #a5b4fc;">${storeName} Support</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
function renderFromTemplate(
|
||
template: TemplateRecord,
|
||
context: Record<string, string | number | boolean | null | undefined>
|
||
): string {
|
||
return template.isHtml
|
||
? renderTemplate(template.body, context)
|
||
: `
|
||
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a;">
|
||
<pre style="white-space: pre-wrap; margin: 0;">${renderTemplate(
|
||
template.body,
|
||
context,
|
||
)}</pre>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
/**
|
||
* Process an incoming email and generate an AI-assisted reply (Groq classification + templates).
|
||
*/
|
||
export async function processIncomingEmail(
|
||
userId: string,
|
||
storeId: string | null,
|
||
_historyId?: string,
|
||
options?: { replyTrigger?: string }
|
||
): Promise<ProcessIncomingEmailResult> {
|
||
let replied = false
|
||
try {
|
||
const accessToken = await getGmailAccessToken(userId)
|
||
if (!accessToken) {
|
||
logger.warn("Cannot process email - Gmail not connected", { userId })
|
||
return { success: false, error: "Gmail not connected", replied: false }
|
||
}
|
||
|
||
if (!storeId) {
|
||
logger.info("No active store found for user, skipping email processing", { userId })
|
||
return { success: false, error: "No active store", replied: false }
|
||
}
|
||
|
||
const unreadEmails = await getUnreadEmails(accessToken, 1)
|
||
|
||
if (unreadEmails.length === 0) {
|
||
logger.info("No unread emails found", { userId })
|
||
return { success: true, replied: false }
|
||
}
|
||
|
||
const email = unreadEmails[0]
|
||
|
||
const senderEmail = parseSenderAddress(email.from)
|
||
|
||
if (!senderEmail.endsWith("@gmail.com")) {
|
||
logger.info("Skipping non-Gmail email", { userId, senderEmail })
|
||
return { success: true, replied: false }
|
||
}
|
||
|
||
const fullEmail = await getEmailContent(accessToken, email.id)
|
||
if (!fullEmail) {
|
||
logger.error("Failed to fetch email content", { emailId: email.id })
|
||
return { success: false, error: "Failed to fetch email content", replied: false }
|
||
}
|
||
|
||
const emailBody = extractEmailBody(fullEmail)
|
||
const emailSubject = email.subject || "No Subject"
|
||
|
||
logger.info("Processing incoming email", {
|
||
userId,
|
||
storeId,
|
||
from: senderEmail,
|
||
subject: emailSubject,
|
||
emailId: email.id,
|
||
})
|
||
|
||
const plainForAi = emailBody.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
|
||
|
||
const safety = await screenInboundEmailForAbuse(emailSubject, plainForAi)
|
||
if (safety?.flagged) {
|
||
try {
|
||
const existing = await prisma.flaggedEmail.findFirst({
|
||
where: { storeId, messageId: email.id },
|
||
})
|
||
if (!existing) {
|
||
await prisma.flaggedEmail.create({
|
||
data: {
|
||
storeId,
|
||
subject: emailSubject.slice(0, 5000),
|
||
from: senderEmail,
|
||
reason: safety.reason,
|
||
priority: safety.priority,
|
||
messageId: email.id,
|
||
},
|
||
})
|
||
}
|
||
logger.warn("Inbound email flagged — no auto-reply", {
|
||
userId,
|
||
storeId,
|
||
from: senderEmail,
|
||
priority: safety.priority,
|
||
})
|
||
} catch (flagErr) {
|
||
logger.error("Failed to persist flagged email", flagErr)
|
||
}
|
||
|
||
const markedRead = await markEmailAsRead(accessToken, email.id)
|
||
if (!markedRead) {
|
||
logger.warn("Failed to mark flagged email as read", { userId, emailId: email.id })
|
||
}
|
||
|
||
return { success: true, replied: false, flagged: true }
|
||
}
|
||
|
||
try {
|
||
const incomingPreview = emailBody
|
||
.replace(/<[^>]+>/g, "")
|
||
.replace(/\s+\n/g, "\n")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trim()
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
await (prisma as any).emailLog.create({
|
||
data: {
|
||
userId,
|
||
storeId,
|
||
to: senderEmail,
|
||
subject: emailSubject,
|
||
bodyPreview: incomingPreview,
|
||
isAutomated: false,
|
||
trigger: "GMAIL_INCOMING",
|
||
status: "RECEIVED",
|
||
providerMessageId: email.messageId,
|
||
errorMessage: null,
|
||
sentAt: new Date(),
|
||
},
|
||
})
|
||
} catch (logError) {
|
||
logger.error("Failed to log incoming email to EmailLog", logError)
|
||
}
|
||
|
||
const classification = await classifyCustomerEmail(emailSubject, plainForAi)
|
||
const hintSource = `${emailSubject}\n${plainForAi}`
|
||
const isOffTopic = classification.intent === "off_topic"
|
||
const orderHints = isOffTopic ? [] : extractOrderNumberHints(hintSource)
|
||
|
||
/** Don’t load CachedOrder until the customer names an order (avoids guessing #1002 when they only say “where’s my package?”). */
|
||
const intentsNeedingOrderIdWhenAmbiguous = new Set([
|
||
"shipping_delay",
|
||
"refund_request",
|
||
"account_billing",
|
||
])
|
||
const needsOrderNumberFirst =
|
||
!isOffTopic &&
|
||
intentsNeedingOrderIdWhenAmbiguous.has(classification.intent) &&
|
||
orderHints.length === 0
|
||
|
||
let orderSummary = ""
|
||
if (!isOffTopic && !needsOrderNumberFirst) {
|
||
orderSummary = await buildOrderSummaryForEmail(storeId, senderEmail, hintSource)
|
||
}
|
||
|
||
logger.info("Gmail classification", {
|
||
userId,
|
||
storeId,
|
||
intent: classification.intent,
|
||
mood: classification.mood,
|
||
confidence: classification.confidence,
|
||
})
|
||
logger.info("Gmail order context for reply", {
|
||
storeId,
|
||
orderSummaryChars: orderSummary.trim().length,
|
||
orderHints,
|
||
offTopic: isOffTopic,
|
||
needsOrderNumberFirst,
|
||
})
|
||
|
||
const store = await prisma.store.findUnique({
|
||
where: { id: storeId },
|
||
select: {
|
||
businessName: true,
|
||
email: true,
|
||
},
|
||
})
|
||
|
||
const storeName = store?.businessName || "Your store"
|
||
const ticketSummary =
|
||
classification.short_summary?.trim() ||
|
||
plainForAi.slice(0, 500) ||
|
||
emailBody.slice(0, 500)
|
||
|
||
const offTopicOrderLine = `We don't include order or purchase history for unrelated topics. For help with ${storeName}, reply with a store-related question and your order number if you have one.`
|
||
|
||
const counterOrderIdLine = `No order details are shown yet — we did not see an order number in your message. Please reply with your order number (e.g. #1002 or 1002) from your ${storeName} confirmation email so we can look up the correct order.`
|
||
|
||
const context: Record<string, string | number | boolean | null | undefined> = {
|
||
customer_name: greetingNameFromEmail(senderEmail),
|
||
customer_email: senderEmail,
|
||
subject: emailSubject,
|
||
store_name: storeName,
|
||
store_email: store?.email || "",
|
||
ticket_summary: ticketSummary,
|
||
order_summary: isOffTopic
|
||
? offTopicOrderLine
|
||
: needsOrderNumberFirst
|
||
? counterOrderIdLine
|
||
: orderSummary || "No matching recent orders on file for this email.",
|
||
}
|
||
|
||
let finalReply: string | null = null
|
||
|
||
try {
|
||
const template = await findTemplateWithFallback(
|
||
storeId,
|
||
classification.intent,
|
||
classification.mood,
|
||
)
|
||
if (template) {
|
||
finalReply = renderFromTemplate(template, context)
|
||
}
|
||
} catch (replyBuildError) {
|
||
logger.error("Failed to load/render template for auto-reply", replyBuildError)
|
||
}
|
||
|
||
if (!finalReply) {
|
||
if (isOffTopic) {
|
||
finalReply = buildOffTopicFallbackHtml(storeName, store?.email || "")
|
||
} else if (needsOrderNumberFirst) {
|
||
finalReply = buildNeedOrderIdFallbackHtml(storeName, store?.email || "")
|
||
} else {
|
||
finalReply = buildMinimalFallbackHtml(emailBody, storeName)
|
||
}
|
||
}
|
||
|
||
const replySubject = emailSubject.startsWith("Re:") ? emailSubject : `Re: ${emailSubject}`
|
||
|
||
replied = true
|
||
const result = await sendGmailReply(
|
||
userId,
|
||
senderEmail,
|
||
replySubject,
|
||
finalReply,
|
||
email.threadId,
|
||
{
|
||
storeId,
|
||
isAutomated: true,
|
||
trigger: options?.replyTrigger ?? "GMAIL_WEBHOOK",
|
||
originalMessageId: email.messageId,
|
||
}
|
||
)
|
||
|
||
const markedRead = await markEmailAsRead(accessToken, email.id)
|
||
if (!markedRead) {
|
||
logger.warn("Failed to mark email as read after processing", {
|
||
userId,
|
||
storeId,
|
||
emailId: email.id,
|
||
})
|
||
}
|
||
|
||
if (result.success) {
|
||
logger.info("Automated reply sent successfully", {
|
||
userId,
|
||
storeId,
|
||
to: senderEmail,
|
||
subject: emailSubject,
|
||
})
|
||
} else {
|
||
logger.error("Failed to send automated reply", {
|
||
userId,
|
||
storeId,
|
||
to: senderEmail,
|
||
subject: emailSubject,
|
||
error: result.error,
|
||
})
|
||
}
|
||
|
||
return { success: result.success, error: result.error, replied }
|
||
} catch (error) {
|
||
logger.error("Error processing incoming email", error)
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : "Unknown error",
|
||
replied,
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get unread emails from Gmail
|
||
*/
|
||
async function getUnreadEmails(
|
||
accessToken: string,
|
||
maxResults: number = 10
|
||
): Promise<
|
||
Array<{
|
||
id: string
|
||
threadId: string
|
||
from: string
|
||
subject: string
|
||
snippet: string
|
||
messageId: string
|
||
}>
|
||
> {
|
||
try {
|
||
const searchResponse = await fetch(
|
||
`https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is:unread newer_than:10m&maxResults=${maxResults}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
}
|
||
)
|
||
|
||
if (!searchResponse.ok) {
|
||
logger.error("Failed to search Gmail messages")
|
||
return []
|
||
}
|
||
|
||
const searchResult = await searchResponse.json()
|
||
const messages = searchResult.messages || []
|
||
|
||
const emailDetails = await Promise.all(
|
||
messages.map(async (msg: { id: string; threadId: string }) => {
|
||
try {
|
||
const messageResponse = await fetch(
|
||
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date&metadataHeaders=Message-ID`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
}
|
||
)
|
||
|
||
if (!messageResponse.ok) {
|
||
return null
|
||
}
|
||
|
||
const messageData = await messageResponse.json()
|
||
const headers = messageData.payload?.headers || []
|
||
const fromHeader = headers.find((h: { name: string }) => h.name.toLowerCase() === "from")
|
||
const subjectHeader = headers.find((h: { name: string }) => h.name.toLowerCase() === "subject")
|
||
const messageIdHeader = headers.find(
|
||
(h: { name: string }) => h.name.toLowerCase() === "message-id"
|
||
)
|
||
|
||
return {
|
||
id: msg.id,
|
||
threadId: msg.threadId,
|
||
from: fromHeader?.value || "",
|
||
subject: subjectHeader?.value || "",
|
||
snippet: messageData.snippet || "",
|
||
messageId: messageIdHeader?.value || "",
|
||
}
|
||
} catch (error) {
|
||
logger.error("Error fetching message details", { error, messageId: msg.id })
|
||
return null
|
||
}
|
||
})
|
||
)
|
||
|
||
return emailDetails.filter((e) => e !== null) as Array<{
|
||
id: string
|
||
threadId: string
|
||
from: string
|
||
subject: string
|
||
snippet: string
|
||
messageId: string
|
||
}>
|
||
} catch (error) {
|
||
logger.error("Error getting unread emails", error)
|
||
return []
|
||
}
|
||
}
|
||
|
||
async function getEmailContent(accessToken: string, messageId: string): Promise<unknown | null> {
|
||
try {
|
||
const response = await fetch(
|
||
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=full`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
},
|
||
}
|
||
)
|
||
|
||
if (!response.ok) {
|
||
return null
|
||
}
|
||
|
||
return await response.json()
|
||
} catch (error) {
|
||
logger.error("Error fetching email content", { error, messageId })
|
||
return null
|
||
}
|
||
}
|
||
|
||
function extractEmailBody(message: {
|
||
payload?: unknown
|
||
snippet?: string
|
||
}): string {
|
||
try {
|
||
const payload = message.payload as
|
||
| {
|
||
mimeType?: string
|
||
body?: { data?: string }
|
||
parts?: unknown[]
|
||
}
|
||
| undefined
|
||
if (!payload) {
|
||
return ""
|
||
}
|
||
|
||
const decodePart = (part: { body?: { data?: string } }): string => {
|
||
if (part.body?.data) {
|
||
return Buffer.from(part.body.data, "base64url").toString("utf-8")
|
||
}
|
||
return ""
|
||
}
|
||
|
||
const collectParts = (part: Record<string, unknown>, mimeType: string): string[] => {
|
||
const results: string[] = []
|
||
if (part.mimeType === mimeType && (part.body as { data?: string } | undefined)?.data) {
|
||
results.push(decodePart(part as { body?: { data?: string } }))
|
||
}
|
||
if (Array.isArray(part.parts)) {
|
||
for (const p of part.parts) {
|
||
results.push(...collectParts(p as Record<string, unknown>, mimeType))
|
||
}
|
||
}
|
||
return results
|
||
}
|
||
|
||
let body =
|
||
collectParts(payload as Record<string, unknown>, "text/plain").join("\n").trim() ||
|
||
collectParts(payload as Record<string, unknown>, "text/html").join("\n").trim() ||
|
||
message.snippet ||
|
||
""
|
||
|
||
const lines = body.split(/\r?\n/)
|
||
const cleanedLines: string[] = []
|
||
for (const line of lines) {
|
||
const trimmed = line.trim()
|
||
if (trimmed.startsWith(">")) continue
|
||
if (/^on .+wrote:$/i.test(trimmed)) break
|
||
cleanedLines.push(line)
|
||
}
|
||
|
||
body = cleanedLines.join("\n").trim()
|
||
|
||
return body
|
||
} catch (error) {
|
||
logger.error("Error extracting email body", error)
|
||
return ""
|
||
}
|
||
}
|
||
|
||
async function markEmailAsRead(accessToken: string, messageId: string): Promise<boolean> {
|
||
try {
|
||
const response = await fetch(
|
||
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/modify`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
removeLabelIds: ["UNREAD"],
|
||
}),
|
||
}
|
||
)
|
||
|
||
return response.ok
|
||
} catch (error) {
|
||
logger.error("Error marking email as read", error)
|
||
return false
|
||
}
|
||
}
|