fix: pipeline

This commit is contained in:
talatops 2026-03-30 20:50:15 +05:00
parent ef73a3b539
commit 27a63f766b
12 changed files with 966 additions and 146 deletions

View File

@ -148,14 +148,19 @@ jobs:
echo "=== ENSURE pgvector extension (vector type) ===" 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;\"" 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 # Start/roll app after pgvector extension is available.
echo "=== START APP (docker compose up -d) ===" # Force recreate so deploy is visible even when image tag remains :latest.
if ! docker compose -f docker-compose.ec2.yml --env-file .env up -d --remove-orphans app; then 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" echo "ERROR: Deployment failed"
docker compose -f docker-compose.ec2.yml down || true docker compose -f docker-compose.ec2.yml down || true
exit 1 exit 1
fi 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:" echo "✅ Deployment successful! Current services:"
docker compose -f docker-compose.ec2.yml --env-file .env ps docker compose -f docker-compose.ec2.yml --env-file .env ps

3
.gitignore vendored
View File

@ -34,6 +34,9 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env .env
# test artifacts
/tests/gmail-auto-reply/artifacts/
# vercel # vercel
.vercel .vercel

View File

@ -8,6 +8,7 @@
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test": "vitest run", "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", "re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
"db:reset": "prisma migrate reset", "db:reset": "prisma migrate reset",
"db:studio": "prisma studio", "db:studio": "prisma studio",

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { looksLikeSensitiveOrderTopic } from "./gmailOrderDisclosure" import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "./gmailOrderDisclosure"
describe("looksLikeSensitiveOrderTopic", () => { describe("looksLikeSensitiveOrderTopic", () => {
it("detects shipping and refund language", () => { it("detects shipping and refund language", () => {
@ -13,3 +13,15 @@ describe("looksLikeSensitiveOrderTopic", () => {
expect(looksLikeSensitiveOrderTopic("Hello")).toBe(false) 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)
})
})

View File

@ -27,3 +27,28 @@ export function looksLikeSensitiveOrderTopic(text: string): boolean {
] ]
return checks.some((re) => re.test(t)) 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))
}

View File

@ -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 `
<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. */
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 `
<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. */
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 `
<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. */
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 `
<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>
`
}
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<AutoReplyPreviewOutput> {
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<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 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,
}
}

View File

@ -10,7 +10,8 @@ import {
extractOrderNumberHints, extractOrderNumberHints,
stripQuotedEmailTrailForHints, stripQuotedEmailTrailForHints,
} from "@/lib/ai/gmailContext" } from "@/lib/ai/gmailContext"
import { looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure" import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
import { generateAutoReplyPreview } from "@/lib/gmail/autoReplyPreview"
import { import {
checkGmailAutoReplyRateLimits, checkGmailAutoReplyRateLimits,
parseEnvFloat, parseEnvFloat,
@ -312,156 +313,35 @@ export async function processIncomingEmail(
} }
try { try {
const classifyResult = await classifyCustomerEmail(emailSubject, plainForAi) const preview = await generateAutoReplyPreview({
const classification = classifyResult.classification storeId,
const hintSourceFull = `${emailSubject}\n${plainForAi}` fromEmail: senderEmail,
const hintSourceForHints = stripQuotedEmailTrailForHints(hintSourceFull) subject: emailSubject,
body: emailBody,
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", { logger.info("Gmail classification", {
userId, userId,
storeId, storeId,
intent: classification.intent, intent: preview.classification.intent,
mood: classification.mood, mood: preview.classification.mood,
confidence: classification.confidence, confidence: preview.classification.confidence,
classifySource: classifyResult.source, classifySource: preview.classifySource,
}) })
logger.info("Gmail order context for reply", { logger.info("Gmail order context for reply", {
storeId, storeId,
orderSummaryChars: orderSummary.trim().length, orderSummaryChars: preview.orderSummaryRaw.trim().length,
orderHints, orderHints: preview.orderHints,
offTopic: isOffTopic, offTopic: preview.classification.intent === "off_topic" || preview.offTopicByHeuristic,
needsOrderNumberFirst, offTopicByHeuristic: preview.offTopicByHeuristic,
hintUnmatched, needsOrderNumberFirst: preview.needsOrderNumberFirst,
lowConfidenceSuppressOrders, hintUnmatched: preview.hintUnmatched,
messageTooSparse, lowConfidenceSuppressOrders: preview.lowConfidenceSuppressOrders,
messageTooSparse: preview.messageTooSparse,
suppressOrdersOnFallbackClassification: preview.suppressOrdersOnFallbackClassification,
}) })
const store = await prisma.store.findUnique({ const finalReply = preview.html
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}` const replySubject = emailSubject.startsWith("Re:") ? emailSubject : `Re: ${emailSubject}`

View File

@ -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:

View File

@ -0,0 +1,2 @@
DATABASE_URL=postgresql://postgres:password@localhost:5544/dropshipping?schema=public

View File

@ -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<ReturnType<typeof generateAutoReplyPreview>>) {
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)
})

View File

@ -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: <script>alert(1)</script> and order #1002",
expect: { expectOrderLines: true },
},
]

View File

@ -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 = `
<div>
<p>Hello {{customer_name}},</p>
<p>{{ticket_summary}}</p>
<p>{{order_summary}}</p>
<p>— {{store_name}} Support</p>
</div>
`.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()
})