@@ -95,6 +106,29 @@ function buildNeedOrderIdFallbackHtml(storeName: string, storeEmail: string): st
`
}
+/** 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 `
+
+
+
Hello,
+
+ We could not match the order number in your message to an order on file for ${storeName} under this email address.
+ Please double-check the number or confirm the email you used when placing the order.
+
+
${contact}
+
+ Best regards,
+ ${storeName} Support
+
+
+
+ `
+}
+
/** When no off_topic template exists — polite boundary, no order data. */
function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim()
@@ -122,13 +156,14 @@ function renderFromTemplate(
template: TemplateRecord,
context: Record
): string {
+ const safeContext = escapeTemplateContextForHtml(context)
return template.isHtml
- ? renderTemplate(template.body, context)
+ ? renderTemplate(template.body, safeContext)
: `
${renderTemplate(
template.body,
- context,
+ safeContext,
)}
`
@@ -253,143 +288,233 @@ export async function processIncomingEmail(
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 = {
- 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(
+ const rateCheck = await checkGmailAutoReplyRateLimits(storeId, senderEmail)
+ if (!rateCheck.ok) {
+ logger.warn("Gmail auto-reply rate limited", {
+ userId,
storeId,
- classification.intent,
- classification.mood,
- )
- if (template) {
- finalReply = renderFromTemplate(template, context)
- }
- } catch (replyBuildError) {
- logger.error("Failed to load/render template for auto-reply", replyBuildError)
+ senderEmail,
+ reason: rateCheck.reason,
+ })
+ await markEmailAsRead(accessToken, email.id)
+ return { success: true, replied: false }
}
- 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", {
+ 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 }
}
- 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,
- })
- }
+ try {
+ const classifyResult = await classifyCustomerEmail(emailSubject, plainForAi)
+ const classification = classifyResult.classification
+ const hintSourceFull = `${emailSubject}\n${plainForAi}`
+ const hintSourceForHints = stripQuotedEmailTrailForHints(hintSourceFull)
- return { success: result.success, error: result.error, replied }
+ 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 = {
+ 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 {
diff --git a/src/lib/gmail/gmailAutoReplyGuards.ts b/src/lib/gmail/gmailAutoReplyGuards.ts
new file mode 100644
index 0000000..4a90562
--- /dev/null
+++ b/src/lib/gmail/gmailAutoReplyGuards.ts
@@ -0,0 +1,89 @@
+import { Prisma } from "@prisma/client"
+import { prisma } from "@/lib/prisma"
+
+const GMAIL_AUTO_REPLY_TRIGGERS = ["GMAIL_WEBHOOK", "GMAIL_POLL"] as const
+
+export function parseEnvInt(name: string, defaultVal: number): number {
+ const v = process.env[name]
+ if (v == null || v === "") return defaultVal
+ const n = Number.parseInt(v, 10)
+ return Number.isFinite(n) && n >= 0 ? n : defaultVal
+}
+
+export function parseEnvFloat(name: string, defaultVal: number): number {
+ const v = process.env[name]
+ if (v == null || v === "") return defaultVal
+ const n = Number.parseFloat(v)
+ return Number.isFinite(n) ? n : defaultVal
+}
+
+/**
+ * Per-sender caps on automated Gmail replies (loop / abuse mitigation).
+ * Set env to 0 to disable that check.
+ */
+export async function checkGmailAutoReplyRateLimits(
+ storeId: string,
+ senderEmail: string
+): Promise<{ ok: true } | { ok: false; reason: string }> {
+ const maxPerHour = parseEnvInt("GMAIL_AUTO_REPLY_MAX_PER_SENDER_PER_HOUR", 15)
+ const burstMax = parseEnvInt("GMAIL_AUTO_REPLY_BURST_MAX_PER_10MIN", 6)
+ if (maxPerHour <= 0 && burstMax <= 0) return { ok: true }
+
+ const now = Date.now()
+ const oneHourAgo = new Date(now - 60 * 60 * 1000)
+ const tenMinAgo = new Date(now - 10 * 60 * 1000)
+
+ const baseWhere = {
+ storeId,
+ to: senderEmail,
+ isAutomated: true,
+ status: "SENT" as const,
+ trigger: { in: [...GMAIL_AUTO_REPLY_TRIGGERS] },
+ }
+
+ const [hourlyCount, burstCount] = await Promise.all([
+ maxPerHour > 0
+ ? prisma.emailLog.count({
+ where: { ...baseWhere, sentAt: { gte: oneHourAgo } },
+ })
+ : Promise.resolve(0),
+ burstMax > 0
+ ? prisma.emailLog.count({
+ where: { ...baseWhere, sentAt: { gte: tenMinAgo } },
+ })
+ : Promise.resolve(0),
+ ])
+
+ if (burstMax > 0 && burstCount >= burstMax) {
+ return { ok: false, reason: "burst_auto_reply_cap" }
+ }
+ if (maxPerHour > 0 && hourlyCount >= maxPerHour) {
+ return { ok: false, reason: "hourly_auto_reply_cap" }
+ }
+ return { ok: true }
+}
+
+/** Returns false if this inbound Gmail message was already processed (idempotency). */
+export async function tryAcquireGmailInboundLease(
+ userId: string,
+ storeId: string,
+ gmailMessageId: string
+): Promise {
+ try {
+ await prisma.gmailInboundAutoReply.create({
+ data: { userId, storeId, gmailMessageId },
+ })
+ return true
+ } catch (e) {
+ if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
+ return false
+ }
+ throw e
+ }
+}
+
+export async function releaseGmailInboundLease(userId: string, gmailMessageId: string): Promise {
+ await prisma.gmailInboundAutoReply.deleteMany({
+ where: { userId, gmailMessageId },
+ })
+}
diff --git a/src/lib/templates/simpleRenderer.ts b/src/lib/templates/simpleRenderer.ts
index 872a2ad..b66453a 100644
--- a/src/lib/templates/simpleRenderer.ts
+++ b/src/lib/templates/simpleRenderer.ts
@@ -1,5 +1,39 @@
import { logger } from "@/lib/logger"
+/** Escape string values injected into HTML email bodies (inbound-derived text). */
+export function escapeHtmlForEmail(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'")
+}
+
+const HTML_ESCAPED_CONTEXT_KEYS = new Set([
+ "customer_name",
+ "customer_email",
+ "subject",
+ "ticket_summary",
+ "store_name",
+ "store_email",
+ "order_summary",
+])
+
+/** Clone context with HTML-safe string values for isHtml templates. */
+export function escapeTemplateContextForHtml(
+ context: Record
+): Record {
+ const out: Record = { ...context }
+ for (const key of HTML_ESCAPED_CONTEXT_KEYS) {
+ const v = out[key]
+ if (typeof v === "string") {
+ out[key] = escapeHtmlForEmail(v)
+ }
+ }
+ return out
+}
+
/**
* Very small, safe renderer for {{variable}} placeholders.
* - No conditionals or loops
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..8a4f295
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,14 @@
+import path from "node:path"
+import { defineConfig } from "vitest/config"
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["src/**/*.test.ts"],
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+})