diff --git a/docs/gmail-templates-catalog.md b/docs/gmail-templates-catalog.md new file mode 100644 index 0000000..91d153b --- /dev/null +++ b/docs/gmail-templates-catalog.md @@ -0,0 +1,616 @@ +# Gmail auto-reply templates — full catalog (manual setup) + +Use this document to create **one saved template per row** in **JUNO Engine → Email templates** for your **active store**. +Exact `**intent`** and `**mood**` strings must match the tables below so `findTemplateWithFallback` can select them. + +After these exist, **Gmail auto-replies use only saved templates** (plus a minimal fallback if none match). Groq is still used for **classification** (intent/mood) only — it does **not** draft replies or insert new `Template` rows. Include `{{order_summary}}` in bodies so order context from cache appears in the reply. + +--- + +## 1. `Template` schema (Prisma) + + +| Field | Type | Notes | +| ------------- | ------------- | ------------------------------------------------------------- | +| `id` | String (cuid) | Auto-generated when you create in UI | +| `storeId` | String | Your **active store** id (set by the app) | +| `name` | String | Human-readable label in the UI | +| `intent` | String | One of **Allowed intents** below | +| `mood` | String | One of **Allowed moods** below | +| `description` | String? | Optional internal note | +| `body` | String | HTML or text; supports `{{variables}}` (see §3) | +| `variables` | Json? | Optional metadata (e.g. `{"seed":"gmail-templates-catalog"}`) | +| `isHtml` | Boolean | Use `**true`** for the bodies in this file | + + +--- + +## 2. Allowed intents and moods (must match exactly) + +**Intents:** `general` · `shipping_delay` · `refund_request` · `question` · `account_billing` · **`off_topic`** + +**Moods:** `neutral` · `angry` · `confused` · `happy` + +**Matrix:** 6 × 4 = **24** templates for full coverage. + +**Fallback order** (if a cell is missing): for most intents, exact pair → `general` + same mood → same intent + `neutral` → `general` + `neutral`. For **`off_topic`**, the app avoids `general` + the same non-neutral mood first (so a “happy thanks” template is not used for CNN-style mail); see `findTemplateWithFallback` in `templatesRepo.ts`. + +**`off_topic` + `{{order_summary}}`:** The processor **replaces** `{{order_summary}}` with a fixed boundary line (no CachedOrder rows) so unrelated requests do not receive order history. + +--- + +## 3. Variables (filled by the app, not by the model) + +These keys are substituted by `renderTemplate` in `src/lib/templates/simpleRenderer.ts` using the `context` built in `src/lib/gmail/emailProcessor.ts`. + + +| Variable | Meaning | +| -------------------- | ------------------------------------------------------------------------------------- | +| `{{customer_name}}` | Greeting name from sender email local-part (title-cased, e.g. `Cloudtalat`) | +| `{{customer_email}}` | Sender email address | +| `{{store_name}}` | `Store.businessName` | +| `{{store_email}}` | `Store.email` | +| `{{subject}}` | Incoming subject line | +| `{{ticket_summary}}` | Short summary (Groq classification `short_summary` or message excerpt) | +| `{{order_summary}}` | Text built from **CachedOrder** for this sender + store, or fallback sentence if none | + + +Do **not** hardcode customer names or order numbers in the body. + +--- + +## 4. How to add manually + +1. Open **Dashboard → JUNO Engine → Email templates**. +2. Create **New template** (or duplicate). +3. Set **Intent** and **Mood** to the values in each section (exact strings). +4. Paste **Body** HTML. +5. Set **HTML** / `isHtml` = on. +6. Save. + +Repeat for all **20** sections (or start with **neutral** only — five templates — and add moods later). + +--- + +## 5. Templates (copy body only; set name / intent / mood as shown) + +### 5.1 `general` + `neutral` + + +| Field | Value | +| --------------- | --------------------------------- | +| **name** | General — Neutral | +| **intent** | `general` | +| **mood** | `neutral` | +| **description** | Catch-all polite acknowledgement. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thank you for contacting {{store_name}}.
+
Regarding your message: {{ticket_summary}}
+
What we have on file for {{customer_email}}:
{{order_summary}}
+
If you need anything else, reply to this email or write to us at {{store_email}}.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.2 `general` + `angry` + + +| Field | Value | +| --------------- | ------------------------------------------- | +| **name** | General — Angry | +| **intent** | `general` | +| **mood** | `angry` | +| **description** | Acknowledge frustration; stay professional. | + + +**body:** + +```html +
Hello {{customer_name}},
+
We’re sorry you’ve had a frustrating experience. Thank you for letting us know.
+
Your message: {{ticket_summary}}
+
Our records for your email:
{{order_summary}}
+
We’ll take this seriously. Please reply here or contact {{store_email}} so we can help.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.3 `general` + `confused` + + +| Field | Value | +| --------------- | -------------------------- | +| **name** | General — Confused | +| **intent** | `general` | +| **mood** | `confused` | +| **description** | Clarify next steps simply. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thanks for reaching out to {{store_name}}. We’re happy to clear things up.
+
What we understood: {{ticket_summary}}
+
What we show on file for {{customer_email}}:
{{order_summary}}
+
If something doesn’t match what you expected, reply with any extra details (order number, screenshots) and we’ll review.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.4 `general` + `happy` + + +| Field | Value | +| --------------- | --------------- | +| **name** | General — Happy | +| **intent** | `general` | +| **mood** | `happy` | +| **description** | Warm, brief. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thank you for the kind message — we really appreciate it.
+
Summary: {{ticket_summary}}
+
On file:
{{order_summary}}
+
We’re here if you need anything else at {{store_email}}.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.5 `shipping_delay` + `neutral` + + +| Field | Value | +| --------------- | ------------------------------------------------ | +| **name** | Shipping — Neutral | +| **intent** | `shipping_delay` | +| **mood** | `neutral` | +| **description** | Order / shipping status using cached facts only. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thank you for contacting {{store_name}} about your shipment.
+
Your question: {{ticket_summary}}
+
Order details we have on file:
{{order_summary}}
+
Reply to this thread if you need more detail, or email {{store_email}}.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.6 `shipping_delay` + `angry` + + +| Field | Value | +| --------------- | --------------------------- | +| **name** | Shipping — Angry | +| **intent** | `shipping_delay` | +| **mood** | `angry` | +| **description** | Empathy + facts from cache. | + + +**body:** + +```html +
Hello {{customer_name}},
+
We’re sorry for the stress this has caused. We want to get you a clear answer.
+
What you told us: {{ticket_summary}}
+
What our system shows for {{customer_email}}:
{{order_summary}}
+
If anything looks wrong, reply with your order number and we’ll escalate.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.7 `shipping_delay` + `confused` + + +| Field | Value | +| --------------- | ------------------- | +| **name** | Shipping — Confused | +| **intent** | `shipping_delay` | +| **mood** | `confused` | +| **description** | Step-by-step tone. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thanks for writing to {{store_name}}. Here’s what we can confirm from our records.
+
Your message: {{ticket_summary}}
+
Shipment / order info on file:
{{order_summary}}
+
If the status doesn’t match what you see, tell us the order number in your reply and we’ll investigate.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.8 `shipping_delay` + `happy` + + +| Field | Value | +| --------------- | ------------------ | +| **name** | Shipping — Happy | +| **intent** | `shipping_delay` | +| **mood** | `happy` | +| **description** | Friendly check-in. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thanks for checking in with {{store_name}}.
+
Note: {{ticket_summary}}
+
Your order snapshot:
{{order_summary}}
+
Reach us anytime at {{store_email}}.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.9 `refund_request` + `neutral` + + +| Field | Value | +| --------------- | --------------------------------------- | +| **name** | Refund — Neutral | +| **intent** | `refund_request` | +| **mood** | `neutral` | +| **description** | Refund / return path; facts from cache. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thank you for contacting {{store_name}} about a refund or return.
+
Request summary: {{ticket_summary}}
+
Related order info on file:
{{order_summary}}
+
Our team will review against store policy. For follow-up, reply here or use {{store_email}}.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.10 `refund_request` + `angry` + + +| Field | Value | +| --------------- | ---------------------------------------------- | +| **name** | Refund — Angry | +| **intent** | `refund_request` | +| **mood** | `angry` | +| **description** | Acknowledge anger; no promises outside policy. | + + +**body:** + +```html +
Hello {{customer_name}},
+
We’re sorry this situation has upset you. We take refund and return requests seriously.
+
What you raised: {{ticket_summary}}
+
Order details on file:
{{order_summary}}
+
We’ll review and respond with next steps. You can also email {{store_email}}.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.11 `refund_request` + `confused` + + +| Field | Value | +| --------------- | -------------------------------- | +| **name** | Refund — Confused | +| **intent** | `refund_request` | +| **mood** | `confused` | +| **description** | Clarify what you need from them. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thanks for reaching out to {{store_name}}. We want to handle your refund or return request correctly.
+
What we understood: {{ticket_summary}}
+
Order context:
{{order_summary}}
+
If you can confirm the order number and reason in your reply, that helps us move faster.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.12 `refund_request` + `happy` + + +| Field | Value | +| --------------- | ----------------- | +| **name** | Refund — Happy | +| **intent** | `refund_request` | +| **mood** | `happy` | +| **description** | Cooperative tone. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thank you for contacting {{store_name}} — we’ll help with your refund or return.
+
Summary: {{ticket_summary}}
+
On file:
{{order_summary}}
+
We’ll follow up shortly. Questions? {{store_email}}
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.13 `question` + `neutral` + + +| Field | Value | +| --------------- | ----------------------------------- | +| **name** | Product / policy question — Neutral | +| **intent** | `question` | +| **mood** | `neutral` | +| **description** | Product or how-to questions. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thank you for your question to {{store_name}}.
+
Topic: {{ticket_summary}}
+
Your email subject: {{subject}}
+
Order info on file (if any):
{{order_summary}}
+
For more detail, reply to this message or write to {{store_email}}.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.14 `question` + `angry` + + +| Field | Value | +| --------------- | --------------------------------- | +| **name** | Product / policy question — Angry | +| **intent** | `question` | +| **mood** | `angry` | +| **description** | De-escalate while answering. | + + +**body:** + +```html +
Hello {{customer_name}},
+
We’re sorry you’re having a rough experience. We’re here to help with your question.
+
What you asked: {{ticket_summary}}
+
Relevant order info:
{{order_summary}}
+
We’ll review and reply with specifics. Contact: {{store_email}}
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.15 `question` + `confused` + + +| Field | Value | +| --------------- | ------------------------------------ | +| **name** | Product / policy question — Confused | +| **intent** | `question` | +| **mood** | `confused` | +| **description** | Simple, structured. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thanks for writing to {{store_name}}. Here’s a quick reply based on what you sent.
+
Your message: {{ticket_summary}}
+
Order snapshot (if applicable):
{{order_summary}}
+
If we missed something, reply with a bit more detail and we’ll clarify.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.16 `question` + `happy` + + +| Field | Value | +| --------------- | --------------------------------- | +| **name** | Product / policy question — Happy | +| **intent** | `question` | +| **mood** | `happy` | +| **description** | Upbeat, helpful. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thanks for reaching out — we love helping customers.
+
Your question: {{ticket_summary}}
+
On file:
{{order_summary}}
+
Anything else, just reply or email {{store_email}}.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.17 `account_billing` + `neutral` + + +| Field | Value | +| --------------- | ---------------------------------- | +| **name** | Billing — Neutral | +| **intent** | `account_billing` | +| **mood** | `neutral` | +| **description** | Charges, invoices, payment issues. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thank you for contacting {{store_name}} about billing or your account.
+
Issue summary: {{ticket_summary}}
+
Order / payment-related info on file:
{{order_summary}}
+
Our team will review. For sensitive details, we may ask you to confirm via {{store_email}}.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.18 `account_billing` + `angry` + + +| Field | Value | +| --------------- | ------------------- | +| **name** | Billing — Angry | +| **intent** | `account_billing` | +| **mood** | `angry` | +| **description** | Serious, calm tone. | + + +**body:** + +```html +
Hello {{customer_name}},
+
We’re sorry for the concern about charges or your account. We’ll look into this carefully.
+
What you reported: {{ticket_summary}}
+
Related order info:
{{order_summary}}
+
We may follow up from {{store_email}} with next steps.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.19 `account_billing` + `confused` + + +| Field | Value | +| --------------- | --------------------------- | +| **name** | Billing — Confused | +| **intent** | `account_billing` | +| **mood** | `confused` | +| **description** | Ask for specifics politely. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thanks for writing to {{store_name}} about billing.
+
What we understood: {{ticket_summary}}
+
What we see on file for {{customer_email}}:
{{order_summary}}
+
If you can share the charge date or last four digits (never full card in email), that speeds things up.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.20 `account_billing` + `happy` + + +| Field | Value | +| --------------- | ------------------------- | +| **name** | Billing — Happy | +| **intent** | `account_billing` | +| **mood** | `happy` | +| **description** | Cooperative billing help. | + + +**body:** + +```html +
Hello {{customer_name}},
+
Thanks for getting in touch about your account or billing.
+
Summary: {{ticket_summary}}
+
Records:
{{order_summary}}
+
We’ll help sort this out — reach us at {{store_email}} if needed.
+
Best regards,
{{store_name}} Support
+``` + +--- + +### 5.21–5.24 `off_topic` (news, weather, unrelated) + +Use **`intent`: `off_topic`**. Bodies should state that only store-related topics are handled. **`{{order_summary}}`** is still substituted but the runtime uses a **non-sensitive boundary string**, not real orders. + +**5.21 `off_topic` + `neutral` (recommended minimum)** + +| Field | Value | +|--------|--------| +| **name** | Off-topic — Neutral | +| **intent** | `off_topic` | +| **mood** | `neutral` | + +**body:** + +```html +
Hello {{customer_name}},
+
Thanks for writing to {{store_name}}.
+
This inbox only helps with orders, products, shipping, returns, and billing. We can’t answer unrelated topics (news, weather, other sites, or general chat).
+
{{order_summary}}
+
For store help, reply with your question and order number if you have one, or email {{store_email}}.
+
Best regards,
{{store_name}} Support
+``` + +Repeat for **angry / confused / happy** with the same structure and a one-line empathy or tone tweak if you want full mood coverage (same pattern as other intents). + +--- + +## 6. Optional `variables` JSON (per template) + +You can paste this into **variables** metadata in the UI if your editor supports it, or leave empty: + +```json +{ + "seed": "gmail-templates-catalog", + "keys": [ + "customer_name", + "customer_email", + "store_name", + "store_email", + "subject", + "ticket_summary", + "order_summary" + ] +} +``` + +--- + +## 7. Reminder: routing in code + +- **Intent/mood:** Groq classifies the incoming message (`classifyCustomerEmail` in `src/lib/ai/gmailGroq.ts`). +- **Reply body:** `processIncomingEmail` in `src/lib/gmail/emailProcessor.ts` loads a template with `findTemplateWithFallback` and renders `{{variables}}`. **No** `generateReplyAndTemplateFields` / **no** auto-`insertTemplate` from Gmail. +- Templates are keyed by `storeId` + `intent` + `mood` (see `src/lib/juno/templatesRepo.ts`). +- `{{order_summary}}` comes from `CachedOrder` for the sender and active store — keep cache in sync with Shopify. +- If no template matches after fallbacks, a **minimal generic HTML** reply is sent (`buildMinimalFallbackHtml`). + +--- + +*Generated for JUNOHUB — intents/moods aligned with `src/lib/ai/gmailTaxonomy.ts` and `prisma/schema.prisma` `Template` model.* \ No newline at end of file diff --git a/scripts/seed-demo-cached-orders.ts b/scripts/seed-demo-cached-orders.ts new file mode 100644 index 0000000..a2ef6c6 --- /dev/null +++ b/scripts/seed-demo-cached-orders.ts @@ -0,0 +1,180 @@ +#!/usr/bin/env tsx +/** + * Insert demo CachedOrder rows for Gmail AI / dashboard demos. + * + * Orders are matched by customer email + active store — use the SAME email you send from in Gmail tests. + * + * Usage: + * npx tsx scripts/seed-demo-cached-orders.ts + * SEED_STORE_ID=clxxx npx tsx scripts/seed-demo-cached-orders.ts + * DEMO_CUSTOMER_EMAIL=talatcloud@gmail.com npx tsx scripts/seed-demo-cached-orders.ts + */ +import "dotenv/config" +import { prisma } from "../src/lib/prisma" +import { logger } from "../src/lib/logger" + +const DEMO_EMAIL = + process.env.DEMO_CUSTOMER_EMAIL?.trim().toLowerCase() || "talatcloud@gmail.com" + +async function main() { + const explicitStoreId = process.env.SEED_STORE_ID?.trim() + const store = explicitStoreId + ? await prisma.store.findUnique({ where: { id: explicitStoreId }, select: { id: true, businessName: true } }) + : await prisma.store.findFirst({ + where: { isActive: true }, + select: { id: true, businessName: true }, + orderBy: { updatedAt: "desc" }, + }) + + if (!store) { + throw new Error( + "No store found. Set SEED_STORE_ID or ensure at least one Store exists (ideally isActive: true).", + ) + } + + logger.info(`Seeding demo CachedOrder for storeId=${store.id} (${store.businessName}) customerEmail=${DEMO_EMAIL}`) + + const now = new Date() + const lineItemsSample = [ + { + id: 9001, + product_id: 7001, + name: "JUNO Demo Tee", + title: "JUNO Demo Tee", + quantity: 1, + price: "29.99", + }, + { + id: 9002, + product_id: 7002, + name: "Sticker Pack", + title: "Sticker Pack", + quantity: 2, + price: "9.50", + }, + ] + + const demos: Array<{ + shopifyOrderId: string + orderNumber: string + email: string + totalPrice: string + currency: string + financialStatus: string + fulfillmentStatus: string + lineItems: unknown + customer: unknown + shopifyCreatedAt: Date + }> = [ + { + shopifyOrderId: "demo-shopify-1002", + orderNumber: "#1002", + email: DEMO_EMAIL, + totalPrice: "49.99", + currency: "USD", + financialStatus: "paid", + fulfillmentStatus: "fulfilled", + lineItems: lineItemsSample, + customer: { + first_name: "Talat", + last_name: "Cloud", + email: DEMO_EMAIL, + }, + shopifyCreatedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000), + }, + { + shopifyOrderId: "demo-shopify-1001", + orderNumber: "#1001", + email: DEMO_EMAIL, + totalPrice: "19.00", + currency: "USD", + financialStatus: "paid", + fulfillmentStatus: "shipped", + lineItems: [ + { + id: 8999, + product_id: 7000, + name: "Welcome Kit", + title: "Welcome Kit", + quantity: 1, + price: "19.00", + }, + ], + customer: { + first_name: "Talat", + last_name: "Cloud", + email: DEMO_EMAIL, + }, + shopifyCreatedAt: new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000), + }, + { + shopifyOrderId: "demo-shopify-1003", + orderNumber: "#1003", + email: DEMO_EMAIL, + totalPrice: "0.00", + currency: "USD", + financialStatus: "pending", + fulfillmentStatus: "unfulfilled", + lineItems: [ + { + id: 9010, + product_id: 7010, + name: "Pre-order Item", + title: "Pre-order Item", + quantity: 1, + price: "0.00", + }, + ], + customer: { first_name: "Talat", last_name: "Cloud", email: DEMO_EMAIL }, + shopifyCreatedAt: new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000), + }, + ] + + for (const row of demos) { + await prisma.cachedOrder.upsert({ + where: { + shopifyOrderId_storeId: { + shopifyOrderId: row.shopifyOrderId, + storeId: store.id, + }, + }, + create: { + shopifyOrderId: row.shopifyOrderId, + storeId: store.id, + orderNumber: row.orderNumber, + email: row.email, + totalPrice: row.totalPrice, + currency: row.currency, + financialStatus: row.financialStatus, + fulfillmentStatus: row.fulfillmentStatus, + lineItems: row.lineItems as never, + customer: row.customer as never, + shopifyCreatedAt: row.shopifyCreatedAt, + }, + update: { + orderNumber: row.orderNumber, + email: row.email, + totalPrice: row.totalPrice, + currency: row.currency, + financialStatus: row.financialStatus, + fulfillmentStatus: row.fulfillmentStatus, + lineItems: row.lineItems as never, + customer: row.customer as never, + shopifyCreatedAt: row.shopifyCreatedAt, + lastSyncedAt: new Date(), + }, + }) + logger.info(`Upserted CachedOrder ${row.orderNumber} (${row.shopifyOrderId})`) + } + + logger.info("Done. Reminder: Gmail auto-reply only loads orders for the ACTIVE store and matches CachedOrder.email to the sender.") +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/src/app/api/integrations/gmail/poll/route.ts b/src/app/api/integrations/gmail/poll/route.ts index eaa30d6..300e2e7 100644 --- a/src/app/api/integrations/gmail/poll/route.ts +++ b/src/app/api/integrations/gmail/poll/route.ts @@ -2,12 +2,13 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" -import { getUnreadEmails, sendGmailReply, markEmailAsRead } from "@/lib/gmail/emailService" +import { processIncomingEmail } from "@/lib/gmail/emailProcessor" + +const MAX_ITERATIONS = 10 /** * POST /api/integrations/gmail/poll - * Manually trigger email polling and automated replies - * This can be called periodically via cron job or manually + * Manually trigger email polling and automated replies (same pipeline as Gmail webhook). */ export async function POST(_req: Request) { try { @@ -16,100 +17,54 @@ export async function POST(_req: Request) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) } - // Get user's active store const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { stores: { where: { isActive: true }, - take: 1 - } - } + take: 1, + }, + }, }) - const storeId = user?.stores[0]?.id + const storeId = user?.stores[0]?.id ?? null - // Process incoming emails - const unreadEmails = await getUnreadEmails(session.user.id, 10) - const processed: string[] = [] - const failed: string[] = [] + let iterations = 0 + let repliesAttempted = 0 + let flaggedCount = 0 + let lastSuccess = true + let lastError: string | undefined - for (const email of unreadEmails) { - try { - // Extract email domain from sender - const emailMatch = email.from.match(/<(.+)>/) - const senderEmail = emailMatch ? emailMatch[1] : email.from.split(" ").pop() || email.from - const senderDomain = senderEmail.split("@")[1]?.toLowerCase() - - // Only reply to gmail.com addresses - if (senderDomain !== "gmail.com") { - logger.info("Skipping email - not from gmail.com", { - userId: session.user.id, - from: email.from, - domain: senderDomain - }) - continue - } - - // Send automated reply (dummy response for testing) - const replySubject = email.subject.startsWith("Re:") ? email.subject : `Re: ${email.subject}` - const replyBody = ` -
-

Thank you for your email. This is an automated reply.

-

We have received your message and will get back to you soon.

-
-

Best regards,
JUNO Support Team

-
- ` - - const result = await sendGmailReply( - session.user.id, - senderEmail, - replySubject, - replyBody, - email.threadId, - { - storeId, - isAutomated: true, - trigger: "GMAIL_AUTO_REPLY", - originalMessageId: email.messageId, - } - ) - - if (result.success) { - // Mark email as read after replying - await markEmailAsRead(session.user.id, email.id) - processed.push(email.id) - logger.info("Automated reply sent", { - userId: session.user.id, - to: senderEmail, - subject: email.subject - }) - } else { - failed.push(email.id) - logger.error("Failed to send automated reply", { - userId: session.user.id, - to: senderEmail, - error: result.error - }) - } - } catch (error) { - failed.push(email.id) - logger.error("Error processing email", { emailId: email.id, error }) + for (let i = 0; i < MAX_ITERATIONS; i++) { + const result = await processIncomingEmail(session.user.id, storeId, undefined, { + replyTrigger: "GMAIL_POLL", + }) + iterations++ + lastSuccess = result.success + lastError = result.error + if (!result.success) { + break + } + if (result.flagged) { + flaggedCount++ + } + if (result.replied) { + repliesAttempted++ + } + if (!result.replied) { + break } } return NextResponse.json({ - success: true, - processed: processed.length, - failed: failed.length, - total: unreadEmails.length + success: lastSuccess, + iterations, + repliesAttempted, + flaggedCount, + error: lastError, }) } catch (error) { logger.error("Email polling error", error) - return NextResponse.json( - { message: "Failed to poll emails" }, - { status: 500 } - ) + return NextResponse.json({ message: "Failed to poll emails" }, { status: 500 }) } } diff --git a/src/app/api/shopify/orders/[orderId]/route.ts b/src/app/api/shopify/orders/[orderId]/route.ts index 3ea1ced..13f4a0c 100644 --- a/src/app/api/shopify/orders/[orderId]/route.ts +++ b/src/app/api/shopify/orders/[orderId]/route.ts @@ -55,8 +55,50 @@ export async function GET( }, { status: response.status }) } - const data = await response.json() - return NextResponse.json({ order: data.order }) + const data = (await response.json()) as { order?: Record } + const order = data.order + if (!order) { + return NextResponse.json({ message: "Invalid order payload from Shopify." }, { status: 502 }) + } + + // Shopify sometimes omits email/customer; overlay CachedOrder when present (same as Gmail cache). + const cached = await prisma.cachedOrder.findUnique({ + where: { + shopifyOrderId_storeId: { + shopifyOrderId: String(orderId), + storeId: store.id, + }, + }, + select: { email: true, customer: true }, + }) + + if (cached) { + const cachedEmail = cached.email?.trim() || "" + const shopifyEmail = typeof order.email === "string" ? order.email.trim() : "" + if (!shopifyEmail && cachedEmail) { + order.email = cachedEmail + } + + const cust = order.customer as Record | null | undefined + const cachedCust = + cached.customer && + typeof cached.customer === "object" && + cached.customer !== null && + !Array.isArray(cached.customer) + ? (cached.customer as Record) + : null + + if (!cust && cachedCust) { + order.customer = cachedCust + } else if (cust && cachedEmail) { + const nested = typeof cust.email === "string" ? cust.email.trim() : "" + if (!nested) { + order.customer = { ...cust, email: cachedEmail } + } + } + } + + return NextResponse.json({ order }) } catch (fetchError: unknown) { clearTimeout(timeoutId) if (fetchError instanceof Error && fetchError.name === "AbortError") { diff --git a/src/app/api/webhooks/gmail/route.ts b/src/app/api/webhooks/gmail/route.ts index d1e8e16..f6e1cf9 100644 --- a/src/app/api/webhooks/gmail/route.ts +++ b/src/app/api/webhooks/gmail/route.ts @@ -100,14 +100,20 @@ export async function POST(req: NextRequest) { historyId, success: result.success, error: result.error, + flagged: result.flagged, + replied: result.replied, }) return NextResponse.json({ success: result.success, error: result.error, - message: result.success - ? "Email processed successfully" - : "Email processing completed with errors (see logs)" + flagged: result.flagged ?? false, + replied: result.replied ?? false, + message: result.flagged + ? "Email flagged — no auto-reply sent" + : result.success + ? "Email processed successfully" + : "Email processing completed with errors (see logs)", }) } catch (error) { logger.error("Gmail webhook error", error) diff --git a/src/app/dashboard/juno-engine/email-templates/page.tsx b/src/app/dashboard/juno-engine/email-templates/page.tsx index 0a7ea3f..2da8263 100644 --- a/src/app/dashboard/juno-engine/email-templates/page.tsx +++ b/src/app/dashboard/juno-engine/email-templates/page.tsx @@ -7,7 +7,13 @@ import { FileText, Search, Plus, Loader2, Edit, Trash2 } from "lucide-react" import { renderTemplate } from "@/lib/templates/simpleRenderer" import { useTheme } from "@/components/ThemeProvider" -type TemplateIntent = "general" | "shipping_delay" | "refund_request" | "question" +type TemplateIntent = + | "general" + | "shipping_delay" + | "refund_request" + | "question" + | "account_billing" + | "off_topic" type TemplateMood = "neutral" | "angry" | "confused" | "happy" interface Template { @@ -28,6 +34,8 @@ const INTENT_OPTIONS: { value: TemplateIntent; label: string }[] = [ { value: "shipping_delay", label: "Shipping delay" }, { value: "refund_request", label: "Refund request" }, { value: "question", label: "General question" }, + { value: "account_billing", label: "Account / billing" }, + { value: "off_topic", label: "Off-topic (not store-related)" }, ] const MOOD_OPTIONS: { value: TemplateMood; label: string }[] = [ @@ -44,6 +52,13 @@ const exampleContext = { store_email: "support@junovendor.com", subject: "Where is my order?", ticket_summary: "Customer is asking about a shipping delay on order #1234.", + order_summary: + "Order #1001 (2025-03-01) | financial: paid | fulfillment: fulfilled | total: 49.99 USD | items: Tee ×1", +} + +function isAiGeneratedTemplate(variables: unknown): boolean { + if (!variables || typeof variables !== "object") return false + return (variables as Record).source === "ai-generated" } export default function EmailTemplatesPage() { @@ -249,12 +264,22 @@ export default function EmailTemplatesPage() { : "bg-white/5 border-white/10 hover:bg-white/[0.07] hover:border-indigo-500/30" }`} > -
+
-

- {template.name} -

+
+

+ {template.name} +

+ {isAiGeneratedTemplate(template.variables) && ( + + AI-generated + + )} +

Intent: {template.intent} • Mood:{" "} {template.mood} @@ -331,14 +356,22 @@ export default function EmailTemplatesPage() { }`} >

-

- {editing.id ? "Edit template" : "New template"} -

+
+

+ {editing.id ? "Edit template" : "New template"} +

+ {editing.id && isAiGeneratedTemplate(editing.variables) && ( + + AI-generated + + )} +

- Use {"{{variable}}"} placeholders to personalize replies. + Use {"{{variable}}"} placeholders (e.g. {"{{order_summary}}"},{" "} + {"{{ticket_summary}}"}) to personalize replies.