diff --git a/.github/workflows/deploy-ec2.yml b/.github/workflows/deploy-ec2.yml
index 0c4dd1a..4be151e 100644
--- a/.github/workflows/deploy-ec2.yml
+++ b/.github/workflows/deploy-ec2.yml
@@ -148,14 +148,19 @@ jobs:
echo "=== ENSURE pgvector extension (vector type) ==="
docker compose -f docker-compose.ec2.yml --env-file .env exec -T db sh -lc "psql -U \"\${POSTGRES_USER:-postgres}\" -d \"\${POSTGRES_DB:-dropshipping}\" -c \"CREATE EXTENSION IF NOT EXISTS vector;\""
- # Start/roll app after pgvector extension is available
- echo "=== START APP (docker compose up -d) ==="
- if ! docker compose -f docker-compose.ec2.yml --env-file .env up -d --remove-orphans app; then
+ # Start/roll app after pgvector extension is available.
+ # Force recreate so deploy is visible even when image tag remains :latest.
+ echo "=== START APP (docker compose up -d --force-recreate) ==="
+ if ! docker compose -f docker-compose.ec2.yml --env-file .env up -d --force-recreate --remove-orphans app; then
echo "ERROR: Deployment failed"
docker compose -f docker-compose.ec2.yml down || true
exit 1
fi
+ APP_CID="$(docker compose -f docker-compose.ec2.yml --env-file .env ps -q app)"
+ echo "App container details:"
+ docker inspect --format='id={{.Id}} startedAt={{.State.StartedAt}} image={{.Image}}' "$APP_CID" || true
+
echo "✅ Deployment successful! Current services:"
docker compose -f docker-compose.ec2.yml --env-file .env ps
diff --git a/.gitignore b/.gitignore
index 93bb99f..a1a6ba9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,9 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env
+# test artifacts
+/tests/gmail-auto-reply/artifacts/
+
# vercel
.vercel
diff --git a/package.json b/package.json
index c9fe950..ce70b6c 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"start": "next start",
"lint": "eslint",
"test": "vitest run",
+ "test:gmail-auto-reply": "set -e; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml up -d; export $(cat tests/gmail-auto-reply/env.test | grep -v '^#' | xargs); docker compose -f tests/gmail-auto-reply/docker-compose.test.yml exec -T db sh -lc \"psql -U postgres -d dropshipping -c 'CREATE EXTENSION IF NOT EXISTS vector;'\"; npx prisma db push --accept-data-loss; npx tsx tests/gmail-auto-reply/seed.ts; vitest run tests/gmail-auto-reply/run.test.ts; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml down -v",
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
"db:reset": "prisma migrate reset",
"db:studio": "prisma studio",
diff --git a/src/lib/ai/gmailOrderDisclosure.test.ts b/src/lib/ai/gmailOrderDisclosure.test.ts
index 8a8d9dc..703a396 100644
--- a/src/lib/ai/gmailOrderDisclosure.test.ts
+++ b/src/lib/ai/gmailOrderDisclosure.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"
-import { looksLikeSensitiveOrderTopic } from "./gmailOrderDisclosure"
+import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "./gmailOrderDisclosure"
describe("looksLikeSensitiveOrderTopic", () => {
it("detects shipping and refund language", () => {
@@ -13,3 +13,15 @@ describe("looksLikeSensitiveOrderTopic", () => {
expect(looksLikeSensitiveOrderTopic("Hello")).toBe(false)
})
})
+
+describe("looksLikeOffTopic", () => {
+ it("detects obvious non-store topics", () => {
+ expect(looksLikeOffTopic("Hey, what's the weather in Islamabad?")).toBe(true)
+ expect(looksLikeOffTopic("Can you tell me today's news headlines?")).toBe(true)
+ })
+
+ it("does not trigger on store-ish questions", () => {
+ expect(looksLikeOffTopic("Where is my package?")).toBe(false)
+ expect(looksLikeOffTopic("I need a refund for order #1002")).toBe(false)
+ })
+})
diff --git a/src/lib/ai/gmailOrderDisclosure.ts b/src/lib/ai/gmailOrderDisclosure.ts
index 81fd48f..7b6a873 100644
--- a/src/lib/ai/gmailOrderDisclosure.ts
+++ b/src/lib/ai/gmailOrderDisclosure.ts
@@ -27,3 +27,28 @@ export function looksLikeSensitiveOrderTopic(text: string): boolean {
]
return checks.some((re) => re.test(t))
}
+
+/**
+ * Obvious non-store topics that should route to `off_topic` even when classification fails.
+ * Keep conservative; this should not trigger on normal store support emails.
+ */
+export function looksLikeOffTopic(text: string): boolean {
+ const t = text.toLowerCase()
+ if (t.length < 3) return false
+
+ const checks: RegExp[] = [
+ /\bweather\b/,
+ /\btemperature\b/,
+ /\brain\b|\bsnow\b|\bforecast\b/,
+ /\bnews\b|\bheadline(s)?\b/,
+ /\bpolitic(s|al)?\b|\belection\b/,
+ /\bhomework\b|\bmath\b|\bessay\b|\bassignment\b/,
+ /\bjoke\b|\bfunny\b|\bmeme\b/,
+ /\bstock(s)?\b|\bcrypto\b|\bbitcoin\b/,
+ /\bsports?\b|\bscore\b|\bmatch\b/,
+ /\brestaurant\b|\bhotel\b|\bflight\b/,
+ /\bislamabad\b|\blahore\b|\bkarachi\b|\bparis\b|\blondon\b|\bnew york\b/,
+ ]
+
+ return checks.some((re) => re.test(t))
+}
diff --git a/src/lib/gmail/autoReplyPreview.ts b/src/lib/gmail/autoReplyPreview.ts
new file mode 100644
index 0000000..ae4f89e
--- /dev/null
+++ b/src/lib/gmail/autoReplyPreview.ts
@@ -0,0 +1,386 @@
+import { prisma } from "@/lib/prisma"
+import { logger } from "@/lib/logger"
+import { escapeHtmlForEmail, escapeTemplateContextForHtml, renderTemplate } from "@/lib/templates/simpleRenderer"
+import { classifyCustomerEmail, type ClassifyCustomerEmailResult } from "@/lib/ai/gmailGroq"
+import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety"
+import {
+ buildOrderSummaryForEmail,
+ extractOrderNumberHints,
+ stripQuotedEmailTrailForHints,
+} from "@/lib/ai/gmailContext"
+import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
+import { parseEnvFloat } from "@/lib/gmail/gmailAutoReplyGuards"
+import { findTemplateWithFallback, type TemplateRecord } from "@/lib/juno/templatesRepo"
+import type { GmailClassification } from "@/lib/ai/gmailTaxonomy"
+
+/** 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()
+}
+
+export function buildMinimalFallbackHtml(emailBody: string, storeName: string): string {
+ const safeSnippet = escapeHtmlForEmail(emailBody.replace(/<[^>]+>/g, "").slice(0, 400))
+ return `
+
+
+
Thanks for reaching out
+
+ We have received your message and our team will review it shortly.
+
+
Summary of what you sent:
+
+
+${safeSnippet || "No additional message content was detected."}
+
+
+
+ You can reply to this email if you have more details to share.
+
+
+ Best regards,
+ ${storeName} Support
+
+
+
+ `
+}
+
+/** When no template exists but we need an order # before sharing order rows. */
+export 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 `
+
+
+
Hello,
+
+ We didn't see an order number in your message. To look up the right order for ${storeName},
+ please reply with your order number (for example #1002 or 1002) from your order confirmation.
+
+
${contact}
+
+ Best regards,
+ ${storeName} Support
+
+
+
+ `
+}
+
+/** When hints did not match cache — no unrelated order rows. */
+export 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. */
+export 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 `
+
+
+
Hello,
+
+ This inbox only handles orders, products, shipping, returns, and billing for ${storeName}.
+ We can't answer unrelated requests (news, weather, other websites, or general topics).
+
+
${contact}
+
+ Best regards,
+ ${storeName} Support
+
+
+
+ `
+}
+
+function renderFromTemplate(
+ template: TemplateRecord,
+ context: Record,
+): string {
+ const safeContext = escapeTemplateContextForHtml(context)
+ return template.isHtml
+ ? renderTemplate(template.body, safeContext)
+ : `
+
+
${renderTemplate(
+ template.body,
+ safeContext,
+ )}
+
+ `
+}
+
+export type AutoReplyPreviewInput = {
+ storeId: string
+ fromEmail: string
+ subject: string
+ body: string
+}
+
+export type AutoReplyPreviewOutput = {
+ classification: GmailClassification
+ classifySource: ClassifyCustomerEmailResult["source"]
+ orderHints: string[]
+ offTopicByHeuristic: boolean
+ suppressOrdersOnFallbackClassification: boolean
+ needsOrderNumberFirst: boolean
+ hintUnmatched: boolean
+ lowConfidenceSuppressOrders: boolean
+ messageTooSparse: boolean
+ skipOrderCacheForQuestion: boolean
+ shouldLoadOrderCache: boolean
+ sensitiveTopic: boolean
+ orderSummaryRaw: string
+ orderSummaryForContext: string
+ ticketSummary: string
+ chosenTemplateId: string | null
+ usedFallback: "none" | "off_topic" | "need_order_id" | "hint_unmatched" | "minimal"
+ html: string
+ flagged: boolean
+ flaggedReason?: string
+}
+
+export async function generateAutoReplyPreview(
+ input: AutoReplyPreviewInput,
+): Promise {
+ const senderEmail = input.fromEmail.trim().toLowerCase()
+ const emailSubject = input.subject || "No Subject"
+ const emailBody = input.body || ""
+
+ const plainForAi = emailBody.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
+
+ const safety = await screenInboundEmailForAbuse(emailSubject, plainForAi)
+ if (safety?.flagged) {
+ const store = await prisma.store.findUnique({
+ where: { id: input.storeId },
+ select: { businessName: true, email: true },
+ })
+ const storeName = store?.businessName || "Your store"
+ return {
+ classification: { intent: "general", mood: "neutral", confidence: 0.0, short_summary: "" },
+ classifySource: "fallback",
+ orderHints: [],
+ offTopicByHeuristic: false,
+ suppressOrdersOnFallbackClassification: false,
+ needsOrderNumberFirst: false,
+ hintUnmatched: false,
+ lowConfidenceSuppressOrders: false,
+ messageTooSparse: false,
+ skipOrderCacheForQuestion: false,
+ shouldLoadOrderCache: false,
+ sensitiveTopic: false,
+ orderSummaryRaw: "",
+ orderSummaryForContext: "",
+ ticketSummary: "",
+ chosenTemplateId: null,
+ usedFallback: "minimal",
+ html: buildMinimalFallbackHtml(emailBody, storeName),
+ flagged: true,
+ flaggedReason: safety.reason,
+ }
+ }
+
+ const classifyResult = await classifyCustomerEmail(emailSubject, plainForAi)
+ const classification = classifyResult.classification
+
+ const hintSourceFull = `${emailSubject}\n${plainForAi}`
+ const hintSourceForHints = stripQuotedEmailTrailForHints(hintSourceFull)
+
+ const extractedHints = extractOrderNumberHints(hintSourceForHints)
+ const offTopicByHeuristic = looksLikeOffTopic(hintSourceFull) && extractedHints.length === 0
+ const isOffTopic = classification.intent === "off_topic" || offTopicByHeuristic
+ const orderHints = isOffTopic ? [] : extractedHints
+
+ 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
+
+ // Default-safe: if Groq classification failed, do not attach CachedOrder unless the customer provided an order number hint.
+ const suppressOrdersOnFallbackClassification =
+ classifyResult.source === "fallback" && extractedHints.length === 0 && !isOffTopic
+
+ const shouldLoadOrderCache =
+ !isOffTopic &&
+ !needsOrderNumberFirst &&
+ !lowConfidenceSuppressOrders &&
+ !messageTooSparse &&
+ !skipOrderCacheForQuestion &&
+ !suppressOrdersOnFallbackClassification
+
+ let orderSummaryRaw = ""
+ let hintUnmatched = false
+ if (shouldLoadOrderCache) {
+ const built = await buildOrderSummaryForEmail(input.storeId, senderEmail, hintSourceForHints)
+ orderSummaryRaw = built.summary
+ hintUnmatched = built.hintUnmatched
+ }
+
+ logger.info("Gmail auto-reply preview", {
+ storeId: input.storeId,
+ senderEmail,
+ intent: classification.intent,
+ mood: classification.mood,
+ confidence: classification.confidence,
+ classifySource: classifyResult.source,
+ orderHints,
+ offTopicByHeuristic,
+ needsOrderNumberFirst,
+ hintUnmatched,
+ lowConfidenceSuppressOrders,
+ messageTooSparse,
+ suppressOrdersOnFallbackClassification,
+ })
+
+ const store = await prisma.store.findUnique({
+ where: { id: input.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 = orderSummaryRaw || 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 html: string | null = null
+ let chosenTemplateId: string | null = null
+
+ try {
+ const template = await findTemplateWithFallback(
+ input.storeId,
+ classification.intent,
+ classification.mood,
+ )
+ if (template) {
+ chosenTemplateId = template.id
+ html = renderFromTemplate(template, context)
+ }
+ } catch (e) {
+ logger.error("generateAutoReplyPreview: template load/render failed", e)
+ }
+
+ let usedFallback: AutoReplyPreviewOutput["usedFallback"] = "none"
+ if (!html) {
+ if (isOffTopic) {
+ usedFallback = "off_topic"
+ html = buildOffTopicFallbackHtml(storeName, store?.email || "")
+ } else if (needsOrderNumberFirst) {
+ usedFallback = "need_order_id"
+ html = buildNeedOrderIdFallbackHtml(storeName, store?.email || "")
+ } else if (hintUnmatched) {
+ usedFallback = "hint_unmatched"
+ html = buildHintUnmatchedFallbackHtml(storeName, store?.email || "")
+ } else {
+ usedFallback = "minimal"
+ html = buildMinimalFallbackHtml(emailBody, storeName)
+ }
+ }
+
+ return {
+ classification,
+ classifySource: classifyResult.source,
+ orderHints,
+ offTopicByHeuristic,
+ suppressOrdersOnFallbackClassification,
+ needsOrderNumberFirst,
+ hintUnmatched,
+ lowConfidenceSuppressOrders,
+ messageTooSparse,
+ skipOrderCacheForQuestion,
+ shouldLoadOrderCache,
+ sensitiveTopic,
+ orderSummaryRaw,
+ orderSummaryForContext,
+ ticketSummary,
+ chosenTemplateId,
+ usedFallback,
+ html,
+ flagged: false,
+ }
+}
+
diff --git a/src/lib/gmail/emailProcessor.ts b/src/lib/gmail/emailProcessor.ts
index b68ec61..5de47c3 100644
--- a/src/lib/gmail/emailProcessor.ts
+++ b/src/lib/gmail/emailProcessor.ts
@@ -10,7 +10,8 @@ import {
extractOrderNumberHints,
stripQuotedEmailTrailForHints,
} from "@/lib/ai/gmailContext"
-import { looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
+import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
+import { generateAutoReplyPreview } from "@/lib/gmail/autoReplyPreview"
import {
checkGmailAutoReplyRateLimits,
parseEnvFloat,
@@ -312,156 +313,35 @@ export async function processIncomingEmail(
}
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
- }
+ const preview = await generateAutoReplyPreview({
+ storeId,
+ fromEmail: senderEmail,
+ subject: emailSubject,
+ body: emailBody,
+ })
logger.info("Gmail classification", {
userId,
storeId,
- intent: classification.intent,
- mood: classification.mood,
- confidence: classification.confidence,
- classifySource: classifyResult.source,
+ intent: preview.classification.intent,
+ mood: preview.classification.mood,
+ confidence: preview.classification.confidence,
+ classifySource: preview.classifySource,
})
logger.info("Gmail order context for reply", {
storeId,
- orderSummaryChars: orderSummary.trim().length,
- orderHints,
- offTopic: isOffTopic,
- needsOrderNumberFirst,
- hintUnmatched,
- lowConfidenceSuppressOrders,
- messageTooSparse,
+ orderSummaryChars: preview.orderSummaryRaw.trim().length,
+ orderHints: preview.orderHints,
+ offTopic: preview.classification.intent === "off_topic" || preview.offTopicByHeuristic,
+ offTopicByHeuristic: preview.offTopicByHeuristic,
+ needsOrderNumberFirst: preview.needsOrderNumberFirst,
+ hintUnmatched: preview.hintUnmatched,
+ lowConfidenceSuppressOrders: preview.lowConfidenceSuppressOrders,
+ messageTooSparse: preview.messageTooSparse,
+ suppressOrdersOnFallbackClassification: preview.suppressOrdersOnFallbackClassification,
})
- 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 finalReply = preview.html
const replySubject = emailSubject.startsWith("Re:") ? emailSubject : `Re: ${emailSubject}`
diff --git a/tests/gmail-auto-reply/docker-compose.test.yml b/tests/gmail-auto-reply/docker-compose.test.yml
new file mode 100644
index 0000000..7f5cfa5
--- /dev/null
+++ b/tests/gmail-auto-reply/docker-compose.test.yml
@@ -0,0 +1,22 @@
+services:
+ db:
+ image: pgvector/pgvector:pg17
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: password
+ POSTGRES_DB: dropshipping
+ ports:
+ - "5544:5432"
+ volumes:
+ - postgres_test_data:/var/lib/postgresql/data
+ - ../../prisma/init-pgvector.sql:/docker-entrypoint-initdb.d/init-pgvector.sql:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres -d dropshipping"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+
+volumes:
+ postgres_test_data:
+
diff --git a/tests/gmail-auto-reply/env.test b/tests/gmail-auto-reply/env.test
new file mode 100644
index 0000000..61c2983
--- /dev/null
+++ b/tests/gmail-auto-reply/env.test
@@ -0,0 +1,2 @@
+DATABASE_URL=postgresql://postgres:password@localhost:5544/dropshipping?schema=public
+
diff --git a/tests/gmail-auto-reply/run.test.ts b/tests/gmail-auto-reply/run.test.ts
new file mode 100644
index 0000000..f8bca23
--- /dev/null
+++ b/tests/gmail-auto-reply/run.test.ts
@@ -0,0 +1,114 @@
+import fs from "node:fs/promises"
+import path from "node:path"
+import { describe, it, expect } from "vitest"
+import { generateAutoReplyPreview } from "@/lib/gmail/autoReplyPreview"
+import { scenarios, type Scenario } from "./scenarios"
+
+function projectRoot(): string {
+ return process.cwd()
+}
+
+function artifactsDir(): string {
+ return path.join(projectRoot(), "tests", "gmail-auto-reply", "artifacts")
+}
+
+function normalizeForContains(html: string): string {
+ return html.replace(/\s+/g, " ").toLowerCase()
+}
+
+function assertScenarioInvariants(s: Scenario, out: Awaited>) {
+ const html = normalizeForContains(out.html)
+
+ if (s.expect.expectNeedsOrderNumberFirst != null) {
+ expect(out.needsOrderNumberFirst, `${s.id}: needsOrderNumberFirst`).toBe(
+ s.expect.expectNeedsOrderNumberFirst,
+ )
+ }
+
+ if (s.expect.expectHintUnmatched != null) {
+ expect(out.hintUnmatched, `${s.id}: hintUnmatched`).toBe(s.expect.expectHintUnmatched)
+ }
+
+ if (s.expect.expectOffTopic) {
+ const isOffTopic = out.classification.intent === "off_topic" || out.offTopicByHeuristic
+ expect(isOffTopic, `${s.id}: off_topic`).toBe(true)
+ }
+
+ // Order lines presence/absence (raw summary is the safest signal; template HTML varies).
+ if (s.expect.expectOrderLines) {
+ expect(out.orderSummaryRaw.trim().length, `${s.id}: orderSummaryRaw non-empty`).toBeGreaterThan(0)
+ } else {
+ expect(out.orderSummaryRaw.trim().length, `${s.id}: orderSummaryRaw empty`).toBe(0)
+ }
+
+ // A few “sanity contains” checks for the human-readable copy paths.
+ if (s.expect.expectNeedsOrderNumberFirst) {
+ expect(html, `${s.id}: counter-question copy present`).toContain("order number")
+ }
+ if (s.expect.expectHintUnmatched) {
+ expect(html, `${s.id}: hint-unmatched copy present`).toContain("could not match")
+ }
+ if (s.expect.expectQuestionNoOrders) {
+ expect(html, `${s.id}: question no-order-history copy present`).toContain("without attaching order history")
+ }
+ if (s.expect.expectOffTopic) {
+ expect(html, `${s.id}: off-topic boundary present`).toContain("unrelated")
+ }
+}
+
+describe("gmail auto-reply scenarios (preview mode)", () => {
+ it("runs scenarios and writes artifacts", async () => {
+ const storeId = process.env.TEST_STORE_ID || "test-store"
+
+ const results: Array<{
+ id: string
+ title: string
+ skipped?: string
+ output?: unknown
+ }> = []
+
+ await fs.mkdir(artifactsDir(), { recursive: true })
+
+ for (const s of scenarios) {
+ if (s.skip) {
+ results.push({ id: s.id, title: s.title, skipped: s.skip })
+ continue
+ }
+
+ const out = await generateAutoReplyPreview({
+ storeId,
+ fromEmail: s.from,
+ subject: s.subject,
+ body: s.body,
+ })
+
+ assertScenarioInvariants(s, out)
+
+ results.push({
+ id: s.id,
+ title: s.title,
+ output: {
+ classification: out.classification,
+ classifySource: out.classifySource,
+ orderHints: out.orderHints,
+ offTopicByHeuristic: out.offTopicByHeuristic,
+ needsOrderNumberFirst: out.needsOrderNumberFirst,
+ hintUnmatched: out.hintUnmatched,
+ lowConfidenceSuppressOrders: out.lowConfidenceSuppressOrders,
+ messageTooSparse: out.messageTooSparse,
+ skipOrderCacheForQuestion: out.skipOrderCacheForQuestion,
+ suppressOrdersOnFallbackClassification: out.suppressOrdersOnFallbackClassification,
+ orderSummaryRaw: out.orderSummaryRaw,
+ orderSummaryForContext: out.orderSummaryForContext,
+ usedFallback: out.usedFallback,
+ chosenTemplateId: out.chosenTemplateId,
+ html: out.html,
+ },
+ })
+ }
+
+ const outPath = path.join(artifactsDir(), "results.json")
+ await fs.writeFile(outPath, JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2))
+ }, 120_000)
+})
+
diff --git a/tests/gmail-auto-reply/scenarios.ts b/tests/gmail-auto-reply/scenarios.ts
new file mode 100644
index 0000000..739d069
--- /dev/null
+++ b/tests/gmail-auto-reply/scenarios.ts
@@ -0,0 +1,196 @@
+export type ScenarioExpectation = {
+ /** Expect CachedOrder lines to be included in the reply context (orderSummaryRaw non-empty). */
+ expectOrderLines: boolean
+ /** Expect the pipeline to require order number first (counter-question path). */
+ expectNeedsOrderNumberFirst?: boolean
+ /** Expect the hint-unmatched behavior when order # doesn't match cache. */
+ expectHintUnmatched?: boolean
+ /** Expect off-topic boundary behavior (no orders). */
+ expectOffTopic?: boolean
+ /** Expect question w/out order history line (no orders). */
+ expectQuestionNoOrders?: boolean
+}
+
+export type Scenario = {
+ id: string
+ title: string
+ from: string
+ subject: string
+ body: string
+ notes?: string
+ skip?: string
+ expect: ScenarioExpectation
+}
+
+export const DEFAULT_STORE_NAME = "YOUR_STORE"
+export const DEFAULT_CUSTOMER_EMAIL = "customer@gmail.com"
+
+export const scenarios: Scenario[] = [
+ {
+ id: "1",
+ title: "Counter-question (shipping-style, no order number)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Where is my package?",
+ body: "Hi, I still haven't received anything. Can you check the status for me? Thanks.",
+ expect: { expectOrderLines: false, expectNeedsOrderNumberFirst: true },
+ },
+ {
+ id: "2",
+ title: "Counter-question (refund language, no order #)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Refund",
+ body: "I need a refund on my last purchase. Please process it.",
+ expect: { expectOrderLines: false, expectNeedsOrderNumberFirst: true },
+ },
+ {
+ id: "3",
+ title: "Counter-question (billing language, no order #)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Wrong charge",
+ body: "I see a duplicate charge on my card for your store. Please fix it.",
+ expect: { expectOrderLines: false, expectNeedsOrderNumberFirst: true },
+ },
+ {
+ id: "4",
+ title: "Order context shown (single clear order hint)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Order status",
+ body: "Hi, can you confirm status for order #1002? Thanks.",
+ expect: { expectOrderLines: true, expectHintUnmatched: false },
+ },
+ {
+ id: "5",
+ title: "Order context shown (hint in subject only)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Re: #1002 shipping",
+ body: "When will it ship?",
+ expect: { expectOrderLines: true, expectHintUnmatched: false },
+ },
+ {
+ id: "6",
+ title: "Hint mismatch (order # not on file for this email)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Order 9999",
+ body: "I need help with order #9999.",
+ expect: { expectOrderLines: false, expectHintUnmatched: true },
+ },
+ {
+ id: "7",
+ title: "Multiple order numbers, not all match cache",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Orders",
+ body: "Please check orders #1002 and #8888.",
+ expect: { expectOrderLines: false, expectHintUnmatched: true },
+ },
+ {
+ id: "8",
+ title: "Off-topic (no store-related request)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Weather tomorrow",
+ body: "What is the weather in Paris tomorrow?",
+ expect: { expectOrderLines: false, expectOffTopic: true },
+ },
+ {
+ id: "9",
+ title: "Product/policy question without order number",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Sizing question",
+ body: "Does the medium hoodie run true to size?",
+ expect: { expectOrderLines: false, expectQuestionNoOrders: true },
+ },
+ {
+ id: "10",
+ title: "General thank-you (may include recent orders by design)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Thanks",
+ body: "Thank you so much for the fast delivery and great product!",
+ expect: { expectOrderLines: true },
+ },
+ {
+ id: "11",
+ title: "Very short body (sparse message)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Hi",
+ body: "Help",
+ expect: { expectOrderLines: false },
+ },
+ {
+ id: "12",
+ title: "Low confidence (general/question only)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Hello",
+ body: "Just checking in.",
+ skip: "Hard to force with live Groq; invariants depend on model confidence.",
+ expect: { expectOrderLines: false },
+ },
+ {
+ id: "13",
+ title: "Quoted thread with stale order # in reply chain",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Still waiting",
+ body: "Still waiting on my new order.\n\nOn Mon, Jan 1, 2026 at 9:00 AM wrote:\n> Order #9999 from old ticket",
+ expect: { expectOrderLines: false },
+ },
+ {
+ id: "14",
+ title: "Groq unavailable / bad JSON (classification fallback)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Where is my package?",
+ body: "Any update?",
+ skip: "Requires intentionally breaking GROQ_API_KEY; skipped in live-Groq mode.",
+ expect: { expectOrderLines: false, expectNeedsOrderNumberFirst: true },
+ },
+ {
+ id: "15",
+ title: "No CachedOrder for sender (guest / wrong email)",
+ from: "other.person@gmail.com",
+ subject: "Order help",
+ body: "I placed an order last week, can you find it?",
+ expect: { expectOrderLines: false },
+ },
+ {
+ id: "16",
+ title: "Safety / abuse screen (no auto-reply)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Test",
+ body: "You are an idiot and I will ruin your life.", // may or may not trigger safety depending on rules
+ skip: "Depends on gmailSafety rules; keep manual unless you confirm a deterministic trigger phrase.",
+ expect: { expectOrderLines: false },
+ },
+ {
+ id: "17",
+ title: "Duplicate inbound message (idempotency)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Order status",
+ body: "Hi, can you confirm status for order #1002? Thanks.",
+ skip: "Idempotency is enforced via Gmail message id + lease in production; preview mode does not model GmailInboundAutoReply.",
+ expect: { expectOrderLines: true },
+ },
+ {
+ id: "18",
+ title: "Per-sender rate limit",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Ping",
+ body: "Status?",
+ skip: "Rate limits are enforced in processIncomingEmail; preview mode does not perform rate checks.",
+ expect: { expectOrderLines: false },
+ },
+ {
+ id: "19",
+ title: "Non-Gmail sender skipped",
+ from: "customer@company.com",
+ subject: "Order help",
+ body: "Where is my order?",
+ skip: "Non-gmail sender skip is enforced in processIncomingEmail; preview mode focuses on reply generation only.",
+ expect: { expectOrderLines: false },
+ },
+ {
+ id: "20",
+ title: "HTML escaping (security smoke test)",
+ from: DEFAULT_CUSTOMER_EMAIL,
+ subject: "Question",
+ body: "Question: and order #1002",
+ expect: { expectOrderLines: true },
+ },
+]
+
diff --git a/tests/gmail-auto-reply/seed.ts b/tests/gmail-auto-reply/seed.ts
new file mode 100644
index 0000000..f66c2b1
--- /dev/null
+++ b/tests/gmail-auto-reply/seed.ts
@@ -0,0 +1,174 @@
+import "dotenv/config"
+import { prisma } from "@/lib/prisma"
+import { insertTemplate } from "@/lib/juno/templatesRepo"
+
+function requireEnv(name: string): string {
+ const v = process.env[name]
+ if (!v) throw new Error(`Missing required env var: ${name}`)
+ return v
+}
+
+async function main() {
+ // This relies on Prisma using DATABASE_URL from env.test (loaded by the runner script).
+ // Create a minimal user + active store, plus a few templates and CachedOrder rows.
+ const userEmail = process.env.TEST_VENDOR_EMAIL || "vendor@example.com"
+
+ const user = await prisma.user.upsert({
+ where: { email: userEmail },
+ update: { role: "VENDOR" },
+ create: {
+ email: userEmail,
+ role: "VENDOR",
+ },
+ select: { id: true },
+ })
+
+ const store = await prisma.store.upsert({
+ where: { id: process.env.TEST_STORE_ID || "test-store" },
+ update: {
+ userId: user.id,
+ isActive: true,
+ businessName: process.env.TEST_STORE_NAME || "YOUR_STORE",
+ email: process.env.TEST_STORE_EMAIL || "support@yourstore.test",
+ timezone: "UTC",
+ country: "US",
+ shopifyStoreName: "test",
+ shopifyDomain: "test.myshopify.com",
+ shopifyApiKey: "test",
+ shopifyApiSecret: "test",
+ shopifyAccessToken: "test",
+ },
+ create: {
+ id: process.env.TEST_STORE_ID || "test-store",
+ userId: user.id,
+ isActive: true,
+ businessName: process.env.TEST_STORE_NAME || "YOUR_STORE",
+ email: process.env.TEST_STORE_EMAIL || "support@yourstore.test",
+ timezone: "UTC",
+ country: "US",
+ shopifyStoreName: "test",
+ shopifyDomain: "test.myshopify.com",
+ shopifyApiKey: "test",
+ shopifyApiSecret: "test",
+ shopifyAccessToken: "test",
+ },
+ select: { id: true },
+ })
+
+ // Ensure the app logic sees an active store for the user.
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { activeStoreId: store.id },
+ })
+
+ // Minimal templates so we get stable rendering without requiring a full catalog.
+ // These templates reference {{ticket_summary}} and {{order_summary}}.
+ const templateBody = `
+
+
Hello {{customer_name}},
+
{{ticket_summary}}
+
{{order_summary}}
+
— {{store_name}} Support
+
+`.trim()
+
+ const intents = [
+ ["general", "neutral"],
+ ["general", "happy"],
+ ["shipping_delay", "neutral"],
+ ["refund_request", "neutral"],
+ ["account_billing", "neutral"],
+ ["question", "neutral"],
+ ["off_topic", "neutral"],
+ ] as const
+
+ for (const [intent, mood] of intents) {
+ await insertTemplate({
+ storeId: store.id,
+ name: `Test template — ${intent}/${mood}`,
+ intent,
+ mood,
+ description: "Test harness template",
+ body: templateBody,
+ variables: { ticket_summary: true, order_summary: true },
+ isHtml: true,
+ }).catch(() => {
+ // If duplicate-ish templates exist, ignore; findTemplateWithFallback selects latest.
+ })
+ }
+
+ const customerEmail = process.env.TEST_CUSTOMER_EMAIL || "customer@gmail.com"
+
+ const baseLineItems = [
+ { title: "Hoodie", quantity: 1, product_id: 111 },
+ { title: "Socks", quantity: 2, product_id: 222 },
+ ]
+
+ const now = new Date()
+
+ const orders = [
+ { orderNumber: "1002", shopifyOrderId: "gid://shopify/Order/1002", totalPrice: "49.99" },
+ { orderNumber: "1003", shopifyOrderId: "gid://shopify/Order/1003", totalPrice: "19.99" },
+ ]
+
+ for (const o of orders) {
+ await prisma.cachedOrder.upsert({
+ where: {
+ shopifyOrderId_storeId: {
+ shopifyOrderId: o.shopifyOrderId,
+ storeId: store.id,
+ },
+ },
+ update: {
+ orderNumber: o.orderNumber,
+ email: customerEmail,
+ totalPrice: o.totalPrice,
+ currency: "USD",
+ financialStatus: "paid",
+ fulfillmentStatus: "unfulfilled",
+ lineItems: baseLineItems as unknown as object,
+ customer: { email: customerEmail } as unknown as object,
+ shopifyCreatedAt: now,
+ lastSyncedAt: now,
+ },
+ create: {
+ shopifyOrderId: o.shopifyOrderId,
+ storeId: store.id,
+ orderNumber: o.orderNumber,
+ email: customerEmail,
+ totalPrice: o.totalPrice,
+ currency: "USD",
+ financialStatus: "paid",
+ fulfillmentStatus: "unfulfilled",
+ lineItems: baseLineItems as unknown as object,
+ customer: { email: customerEmail } as unknown as object,
+ shopifyCreatedAt: now,
+ lastSyncedAt: now,
+ },
+ })
+ }
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ storeId: store.id,
+ userEmail,
+ customerEmail,
+ seededOrders: orders.map((o) => o.orderNumber),
+ },
+ null,
+ 2,
+ ),
+ )
+}
+
+main()
+ .catch((e) => {
+ console.error(e)
+ process.exit(1)
+ })
+ .finally(async () => {
+ await prisma.$disconnect()
+ })
+