JUNOHUB-test/src/lib/gmail/emailProcessor.ts
2026-03-28 17:10:15 +05:00

719 lines
30 KiB
TypeScript

import { getGmailAccessToken } from "../gmail"
import { logger } from "../logger"
import { sendGmailReply } from "./emailService"
import { prisma } from "../prisma"
import { escapeHtmlForEmail, escapeTemplateContextForHtml, renderTemplate } from "@/lib/templates/simpleRenderer"
import { classifyCustomerEmail } from "@/lib/ai/gmailGroq"
import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety"
import {
buildOrderSummaryForEmail,
extractOrderNumberHints,
stripQuotedEmailTrailForHints,
} from "@/lib/ai/gmailContext"
import { looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
import {
checkGmailAutoReplyRateLimits,
parseEnvFloat,
releaseGmailInboundLease,
tryAcquireGmailInboundLease,
} from "@/lib/gmail/gmailAutoReplyGuards"
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 = escapeHtmlForEmail(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&apos;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 hints did not match cache — no unrelated order rows. */
function buildHintUnmatchedFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim()
? `Reply with the correct order number or email us at ${storeEmail}.`
: `Reply with the 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 could not match the order number in your message to an order on file for <strong style="color:#e5e7eb;">${storeName}</strong> under this email address.
Please double-check the number or confirm the email you used when placing the order.
</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&apos;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 {
const safeContext = escapeTemplateContextForHtml(context)
return template.isHtml
? renderTemplate(template.body, safeContext)
: `
<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,
safeContext,
)}</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 rateCheck = await checkGmailAutoReplyRateLimits(storeId, senderEmail)
if (!rateCheck.ok) {
logger.warn("Gmail auto-reply rate limited", {
userId,
storeId,
senderEmail,
reason: rateCheck.reason,
})
await markEmailAsRead(accessToken, email.id)
return { success: true, replied: false }
}
const leaseAcquired = await tryAcquireGmailInboundLease(userId, storeId, email.id)
if (!leaseAcquired) {
logger.info("Gmail inbound already processed — skip duplicate auto-reply", {
userId,
storeId,
emailId: email.id,
})
await markEmailAsRead(accessToken, email.id)
return { success: true, replied: false }
}
try {
const classifyResult = await classifyCustomerEmail(emailSubject, plainForAi)
const classification = classifyResult.classification
const hintSourceFull = `${emailSubject}\n${plainForAi}`
const hintSourceForHints = stripQuotedEmailTrailForHints(hintSourceFull)
const isOffTopic = classification.intent === "off_topic"
const orderHints = isOffTopic ? [] : extractOrderNumberHints(hintSourceForHints)
const intentsNeedingOrderIdWhenAmbiguous = new Set([
"shipping_delay",
"refund_request",
"account_billing",
])
const sensitiveTopic = !isOffTopic && looksLikeSensitiveOrderTopic(hintSourceFull)
const needsOrderNumberFirst =
!isOffTopic &&
orderHints.length === 0 &&
(intentsNeedingOrderIdWhenAmbiguous.has(classification.intent) || sensitiveTopic)
const confidenceMin = parseEnvFloat("GMAIL_CLASSIFY_CONFIDENCE_MIN", 0.5)
const lowConfidenceSuppressOrders =
classifyResult.source === "groq" &&
classification.confidence < confidenceMin &&
orderHints.length === 0 &&
!isOffTopic &&
(classification.intent === "general" || classification.intent === "question")
const messageTooSparse = plainForAi.trim().length < 12 && orderHints.length === 0
const skipOrderCacheForQuestion =
classification.intent === "question" && orderHints.length === 0 && !isOffTopic
const shouldLoadOrderCache =
!isOffTopic &&
!needsOrderNumberFirst &&
!lowConfidenceSuppressOrders &&
!messageTooSparse &&
!skipOrderCacheForQuestion
let orderSummary = ""
let hintUnmatched = false
if (shouldLoadOrderCache) {
const built = await buildOrderSummaryForEmail(storeId, senderEmail, hintSourceForHints)
orderSummary = built.summary
hintUnmatched = built.hintUnmatched
}
logger.info("Gmail classification", {
userId,
storeId,
intent: classification.intent,
mood: classification.mood,
confidence: classification.confidence,
classifySource: classifyResult.source,
})
logger.info("Gmail order context for reply", {
storeId,
orderSummaryChars: orderSummary.trim().length,
orderHints,
offTopic: isOffTopic,
needsOrderNumberFirst,
hintUnmatched,
lowConfidenceSuppressOrders,
messageTooSparse,
})
const store = await prisma.store.findUnique({
where: { id: storeId },
select: {
businessName: true,
email: true,
},
})
const storeName = store?.businessName || "Your store"
const ticketSummary = messageTooSparse
? "We could not read enough text in your message to look up an order automatically. Please reply with a short description and your order number if you have one."
: 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 hintUnmatchedOrderLine = `We could not match the order number(s) in your message to an order on file for ${storeName} under this email. Please double-check the number or confirm the email address used at checkout.`
const lowConfidenceOrderLine = `We were not fully confident how to categorize your message. If this is about an order, reply with your order number from your ${storeName} confirmation email so we can pull up the right record.`
const sparseBodyOrderLine = `We could not read enough detail in your message to look anything up automatically. Please reply with a sentence or two and your order number if your question is about a ${storeName} order.`
const questionNoOrderLine = `We are replying without attaching order history. If your question relates to a specific purchase, reply with your order number (e.g. #1002).`
const noOrdersFallback = sensitiveTopic
? `No matching recent orders on file for ${storeName} under this email. If you used a different email at checkout, reply from that address or send your order number.`
: "No matching recent orders on file for this email."
let orderSummaryForContext: string
if (isOffTopic) {
orderSummaryForContext = offTopicOrderLine
} else if (needsOrderNumberFirst) {
orderSummaryForContext = counterOrderIdLine
} else if (hintUnmatched) {
orderSummaryForContext = hintUnmatchedOrderLine
} else if (lowConfidenceSuppressOrders) {
orderSummaryForContext = lowConfidenceOrderLine
} else if (messageTooSparse) {
orderSummaryForContext = sparseBodyOrderLine
} else if (skipOrderCacheForQuestion) {
orderSummaryForContext = questionNoOrderLine
} else {
orderSummaryForContext = orderSummary || noOrdersFallback
}
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: orderSummaryForContext,
}
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 if (hintUnmatched) {
finalReply = buildHintUnmatchedFallbackHtml(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,
}
)
if (!result.success) {
await releaseGmailInboundLease(userId, email.id)
}
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 (processError) {
await releaseGmailInboundLease(userId, email.id)
throw processError
}
} 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
}
}