commit
8ad60220d9
2
.github/workflows/deploy-ec2.yml
vendored
2
.github/workflows/deploy-ec2.yml
vendored
@ -123,7 +123,7 @@ jobs:
|
|||||||
|
|
||||||
# Rolling update with docker compose (app + Postgres)
|
# Rolling update with docker compose (app + Postgres)
|
||||||
echo "=== ROLLING UPDATE (docker compose up -d) ==="
|
echo "=== ROLLING UPDATE (docker compose up -d) ==="
|
||||||
if ! docker compose -f docker-compose.yml --env-file .env up -d --remove-orphans; then
|
if ! docker compose -f docker-compose.yml --env-file .env up -d --no-build --remove-orphans; then
|
||||||
echo "ERROR: Deployment failed"
|
echo "ERROR: Deployment failed"
|
||||||
docker compose -f docker-compose.yml down || true
|
docker compose -f docker-compose.yml down || true
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@ -20,6 +20,8 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
app:
|
app:
|
||||||
|
# Use pre-built image on deploy (EC2 loads tar only; no Dockerfile on server). Local: docker compose up --build
|
||||||
|
image: ${IMAGE_NAME:-junohub-app}:latest
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
341
docs/gmail-auto-reply-test-scenarios.md
Normal file
341
docs/gmail-auto-reply-test-scenarios.md
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
# Gmail auto-reply — manual test scenarios
|
||||||
|
|
||||||
|
Use this checklist to send **real emails** into your connected Gmail inbox (from an address that matches your test customer / `CachedOrder` email when you expect order rows). Trigger processing via **webhook** or **poll** as you normally do.
|
||||||
|
|
||||||
|
**Prerequisites**
|
||||||
|
|
||||||
|
- Dashboard store is **active**; Gmail integration connected for the user processing mail.
|
||||||
|
- Sender is **`@gmail.com`** (non-Gmail senders are skipped by the processor).
|
||||||
|
- Saved **email templates** exist for the intent/mood pairs you expect (or you will see **fallback HTML**).
|
||||||
|
- **`CachedOrder`** has rows for your test sender email when you expect real order lines in `{{order_summary}}`.
|
||||||
|
- **Groq** is available for classification unless you are explicitly testing fallback (`source: fallback` in logs). Intent labels can vary slightly per model run; the **sensitive-topic heuristic** covers many “where’s my package?” cases even if intent is `general`.
|
||||||
|
|
||||||
|
**How to verify**
|
||||||
|
|
||||||
|
- Read the **auto-reply body**: check whether it **names a specific order, date, and total** from cache vs. **fixed policy text** asking for an order number or explaining a boundary.
|
||||||
|
- Optional: check app logs for flags like `needsOrderNumberFirst`, `hintUnmatched`, `classifySource`, `messageTooSparse`.
|
||||||
|
|
||||||
|
Replace placeholders:
|
||||||
|
|
||||||
|
- `YOUR_STORE` — business name from the active store.
|
||||||
|
- `YOU@gmail.com` — the inbox that receives mail (merchant).
|
||||||
|
- `CUSTOMER@gmail.com` — sender that matches `CachedOrder.email` (or customer JSON email) when testing cache hits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Counter-question (no order number, shipping-style message)
|
||||||
|
|
||||||
|
**Goal:** No CachedOrder rows should appear in the reply; customer is asked to send an order number.
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **From** | `CUSTOMER@gmail.com` (must match cache email if you want to prove orders exist but are hidden) |
|
||||||
|
| **Subject** | `Where is my package?` |
|
||||||
|
| **Body** | `Hi, I still haven't received anything. Can you check the status for me? Thanks.` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- **`{{order_summary}}`** resolves to text like: *no order details shown yet* / *did not see an order number* / *reply with your order number (e.g. #1002)* — **not** real order lines for #1002 or any order.
|
||||||
|
- **Template:** `shipping_delay` or `general` mood depending on Groq; if no template, **need-order-id fallback HTML** (`buildNeedOrderIdFallbackHtml`).
|
||||||
|
- **Log:** `needsOrderNumberFirst: true` (from intent `shipping_delay` / `refund_request` / `account_billing` **or** `looksLikeSensitiveOrderTopic` on subject+body).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Counter-question (explicit refund language, no order #)
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Subject** | `Refund` |
|
||||||
|
| **Body** | `I need a refund on my last purchase. Please process it.` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Same as §1: **no** order block from cache without an order hint; counter-question / policy line only.
|
||||||
|
- Often classified `refund_request`; heuristic also matches **refund**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Counter-question (billing language, no order #)
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Subject** | `Wrong charge` |
|
||||||
|
| **Body** | `I see a duplicate charge on my card for your store. Please fix it.` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- No order summary from cache until an order number appears; counter-style line.
|
||||||
|
- Heuristic may match **wrong charge** / **billing**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Order context shown (single clear order hint)
|
||||||
|
|
||||||
|
**Precondition:** `CachedOrder` includes order **#1002** (or `#1003`) for `CUSTOMER@gmail.com`.
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Subject** | `Order status` |
|
||||||
|
| **Body** | `Hi, can you confirm status for order #1002? Thanks.` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- **`{{order_summary}}`** contains **real** lines for order **1002** (date, financial/fulfillment, total, items) from cache.
|
||||||
|
- **Log:** `orderHints` includes `1002`, `needsOrderNumberFirst: false`, `hintUnmatched: false`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Order context shown (hint in subject only)
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Subject** | `Re: #1002 shipping` |
|
||||||
|
| **Body** | `When will it ship?` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Hints extracted from subject+body → **1002**; summary prefers matching order if present in cache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Hint mismatch (order # not on file for this email)
|
||||||
|
|
||||||
|
**Precondition:** Cache has orders for `CUSTOMER@gmail.com` but **not** `#9999**.
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Subject** | `Order 9999` |
|
||||||
|
| **Body** | `I need help with order #9999.` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- **No** substitution of unrelated orders (e.g. not “here is #1002” instead).
|
||||||
|
- **`{{order_summary}}`** uses **hint-unmatched** copy: cannot match order number(s) to orders on file under this email; ask to double-check / confirm checkout email.
|
||||||
|
- **Log:** `hintUnmatched: true`.
|
||||||
|
- If no template: **hint-unmatched fallback HTML**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Multiple order numbers, not all match cache
|
||||||
|
|
||||||
|
**Precondition:** Cache has **1002** but not **8888**.
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Body** | `Please check orders #1002 and #8888.` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- **hintUnmatched** path (ambiguous / not all hints resolved); **no** listing of 1002 as if everything were OK unless you change code — current behavior withholds mismatched multi-hint summaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Off-topic (no store-related request)
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Subject** | `Weather tomorrow` |
|
||||||
|
| **Body** | `What is the weather in Paris tomorrow?` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Intent **`off_topic`** (typical with Groq).
|
||||||
|
- **`{{order_summary}}`** = fixed **boundary** line (no CachedOrder data).
|
||||||
|
- **Template:** `off_topic` + mood, or **off-topic fallback HTML**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Product / policy question without order number
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Subject** | `Sizing question` |
|
||||||
|
| **Body** | `Does the medium hoodie run true to size?` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Intent **`question`** (typical).
|
||||||
|
- **`{{order_summary}}`** = line that **omits order history** (*replying without attaching order history* / send order # if about a purchase).
|
||||||
|
- **Log:** `skipOrderCacheForQuestion` effectively via `question` + no hints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. General thank-you (no shipping/refund/billing wording)
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Subject** | `Thanks` |
|
||||||
|
| **Body** | `Thank you so much for the fast delivery and great product!` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Intent **`general`**, **`happy`** possible.
|
||||||
|
- **No** sensitive heuristic → **with** cache for sender, **`{{order_summary}}`** may show **up to 3 recent orders** (normal path).
|
||||||
|
- If you need thanks to **never** show orders, that would require a separate product rule (not implemented).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Very short body (sparse message)
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Subject** | `Hi` |
|
||||||
|
| **Body** | `Help` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- `plainForAi.trim().length < 12` → **messageTooSparse**.
|
||||||
|
- Fixed **`ticket_summary`** about not enough text to look up an order; **`{{order_summary}}`** = **sparse** line (ask for a sentence or two + order # if about an order).
|
||||||
|
- **No** cache load for order block.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Low confidence (general / question only)
|
||||||
|
|
||||||
|
**Precondition:** Groq returns valid JSON with **`confidence` < `GMAIL_CLASSIFY_CONFIDENCE_MIN`** (default **0.5**) and intent **`general`** or **`question`**, no order hints.
|
||||||
|
|
||||||
|
**How to test**
|
||||||
|
|
||||||
|
- Hard to force manually; watch logs for `lowConfidenceSuppressOrders: true` if the model returns low confidence.
|
||||||
|
- **Expected:** **`{{order_summary}}`** = *not fully confident how to categorize* / ask for order number if about an order; **no** cache rows for that branch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Quoted thread with stale order # in reply chain
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Body** | `Still waiting on my new order.\n\nOn Mon, Jan 1, 2026 at 9:00 AM wrote:\n> Order #9999 from old ticket` |
|
||||||
|
|
||||||
|
**Precondition:** You only care about **new** content; hints should prefer **strip quoted** portion.
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Hints extracted **after** `stripQuotedEmailTrailForHints` — **#9999** often **dropped** if the quote block is removed; if no hint remains and the visible line is shipping-like, **counter-question** behavior applies.
|
||||||
|
|
||||||
|
*(Exact hint list depends on where the regex cuts; this scenario validates quote stripping.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Groq unavailable / bad JSON (classification fallback)
|
||||||
|
|
||||||
|
**How to test**
|
||||||
|
|
||||||
|
- Temporarily break **`GROQ_API_KEY`** or block Groq; or rely on parse failure (rare).
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- `classifySource: **fallback**` → `general` + `neutral` by default.
|
||||||
|
- If the message still matches **sensitive heuristic** (e.g. “where is my package”) **without** hints → **needsOrderNumberFirst** still true → **no** accidental order dump.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. No CachedOrder for sender (guest / wrong email)
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **From** | `other.person@gmail.com` (no rows in `CachedOrder` for this email) |
|
||||||
|
| **Subject** | `Order help` |
|
||||||
|
| **Body** | `I placed an order last week, can you find it?` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- If **needsOrderNumberFirst** or counter path: policy text only.
|
||||||
|
- If cache allowed but empty: **`noOrdersFallback`** — generic *no matching orders*; if **sensitiveTopic**, extra line about **different email at checkout**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Safety / abuse screen (no auto-reply)
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Body** | Content that triggers **`screenInboundEmailForAbuse`** (per `gmailSafety.ts` rules). |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- **No** customer reply email sent.
|
||||||
|
- Email may be stored as **flagged**; marked read per implementation.
|
||||||
|
- **Log:** inbound flagged, no auto-reply.
|
||||||
|
|
||||||
|
*(Use only in a test account; do not send real harmful content from production customers.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Duplicate inbound message (idempotency)
|
||||||
|
|
||||||
|
**How to test**
|
||||||
|
|
||||||
|
- Process the same Gmail **message id** twice (e.g. replay webhook or race poll + webhook).
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Second run: **no second auto-reply**; `GmailInboundAutoReply` unique constraint; log *duplicate / skip*.
|
||||||
|
- Message may still be marked read.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Per-sender rate limit
|
||||||
|
|
||||||
|
**Precondition:** Default caps — **15** auto-replies / hour and **6** / 10 minutes per `storeId` + recipient (`EmailLog` with triggers `GMAIL_WEBHOOK` / `GMAIL_POLL`).
|
||||||
|
|
||||||
|
**How to test**
|
||||||
|
|
||||||
|
- Send many distinct threads from the same `CUSTOMER@gmail.com` quickly until cap hits.
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Further auto-replies **suppressed** for that sender; log **rate limited**; **no** lease consumed for skipped path (lease is acquired after rate check — verify in code: rate check is **before** lease).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Non-Gmail sender skipped
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **From** | `customer@company.com` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Processor returns early; **no** auto-reply from this pipeline (by design in `emailProcessor.ts`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. HTML escaping (security smoke test)
|
||||||
|
|
||||||
|
| Field | Example |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Body** | `Question: <script>alert(1)</script> and order #1002` |
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Reply is HTML email; **script should not execute** in clients; template variables for HTML templates go through **`escapeTemplateContextForHtml`** — angle brackets in **`ticket_summary`** / snippets should appear **escaped** in source, not as raw tags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick matrix
|
||||||
|
|
||||||
|
| # | Scenario | Order # in email? | Expect real order lines in reply? |
|
||||||
|
|---|----------|-------------------|-----------------------------------|
|
||||||
|
| 1–3 | Counter / sensitive, no hint | No | **No** |
|
||||||
|
| 4–5 | Clear hint matching cache | Yes | **Yes** (matching order(s)) |
|
||||||
|
| 6–7 | Hint not matching / partial multi-hint | Yes (bad mix) | **No** (policy / unmatched copy) |
|
||||||
|
| 8 | Off-topic | — | **No** |
|
||||||
|
| 9 | Question, no hint | No | **No** (question-specific line) |
|
||||||
|
| 10 | General thanks | No | **Yes** if cache exists (by design) |
|
||||||
|
| 11 | Sparse body | No | **No** |
|
||||||
|
| 12 | Low confidence g/q | No | **No** |
|
||||||
|
| 15 | No cache row | — | **No** (fallback copy) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment variables (optional tuning)
|
||||||
|
|
||||||
|
| Variable | Default | Effect |
|
||||||
|
| -------- | ------- | ------ |
|
||||||
|
| `GMAIL_CLASSIFY_CONFIDENCE_MIN` | `0.5` | Below this, suppress cache for `general` / `question` without hints. |
|
||||||
|
| `GMAIL_AUTO_REPLY_MAX_PER_SENDER_PER_HOUR` | `15` | Max automated Gmail replies per hour per store + recipient. |
|
||||||
|
| `GMAIL_AUTO_REPLY_BURST_MAX_PER_10MIN` | `6` | Burst cap per 10 minutes. Set to `0` to disable that check’s enforcement (see `gmailAutoReplyGuards.ts`). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Aligned with `src/lib/gmail/emailProcessor.ts`, `gmailContext.ts`, `gmailOrderDisclosure.ts`, `gmailGroq.ts`, and `docs/gmail-templates-catalog.md`.*
|
||||||
@ -41,7 +41,7 @@ After these exist, **Gmail auto-replies use only saved templates** (plus a minim
|
|||||||
|
|
||||||
## 3. Variables (filled by the app, not by the model)
|
## 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`.
|
These keys are substituted by `renderTemplate` in `src/lib/templates/simpleRenderer.ts` using the `context` built in `src/lib/gmail/emailProcessor.ts`. For **HTML** templates (`isHtml`), string values are passed through `escapeTemplateContextForHtml` so inbound-derived text cannot break out of the layout.
|
||||||
|
|
||||||
|
|
||||||
| Variable | Meaning |
|
| Variable | Meaning |
|
||||||
@ -52,10 +52,21 @@ These keys are substituted by `renderTemplate` in `src/lib/templates/simpleRende
|
|||||||
| `{{store_email}}` | `Store.email` |
|
| `{{store_email}}` | `Store.email` |
|
||||||
| `{{subject}}` | Incoming subject line |
|
| `{{subject}}` | Incoming subject line |
|
||||||
| `{{ticket_summary}}` | Short summary (Groq classification `short_summary` or message excerpt) |
|
| `{{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 |
|
| `{{order_summary}}` | See **Order summary routing** below |
|
||||||
|
|
||||||
|
|
||||||
Do **not** hardcode customer names or order numbers in the body.
|
**Order summary routing (`{{order_summary}}`):**
|
||||||
|
|
||||||
|
- **Cached rows:** Plain-text lines from **CachedOrder** when the pipeline loads order context (sender email matches; order-number hints, when present, are taken from the message after quoted-reply stripping).
|
||||||
|
- **`off_topic`:** Fixed boundary line (no order rows).
|
||||||
|
- **Counter-question:** If the message looks like shipping / refund / billing (including via intent **or** keyword heuristic) and **no** order number was detected, a fixed line asks the customer to send an order number — **no** cache lookup.
|
||||||
|
- **Hint mismatch:** If the customer named an order number that does **not** match their cached orders for that email, a fixed clarification line is used — **other** orders are **not** substituted.
|
||||||
|
- **Low confidence:** If Groq returns `general` or `question` with confidence below `GMAIL_CLASSIFY_CONFIDENCE_MIN` (default `0.5`) and there are no hints, a conservative line is used instead of cache.
|
||||||
|
- **Sparse body:** Very short messages without hints use a fixed “please add detail” line instead of cache.
|
||||||
|
- **`question` intent, no hints:** Order history is omitted; a short line explains that replies without attaching order rows unless they send an order number.
|
||||||
|
- **No cache match (otherwise):** Fallback sentence; if the topic looks order-related, copy may mention trying another checkout email.
|
||||||
|
|
||||||
|
Do **not** hardcode customer names or order numbers in the body (example numbers like `#1002` in **instructions** to the customer are OK).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -605,11 +616,14 @@ You can paste this into **variables** metadata in the UI if your editor supports
|
|||||||
|
|
||||||
## 7. Reminder: routing in code
|
## 7. Reminder: routing in code
|
||||||
|
|
||||||
- **Intent/mood:** Groq classifies the incoming message (`classifyCustomerEmail` in `src/lib/ai/gmailGroq.ts`).
|
- **Intent/mood:** Groq classifies the incoming message (`classifyCustomerEmail` in `src/lib/ai/gmailGroq.ts`). Returns `source: "groq" \| "fallback"` when the API or JSON parse fails (defaults to `general` + `neutral`).
|
||||||
|
- **Sensitive-topic heuristic:** `looksLikeSensitiveOrderTopic` in `src/lib/ai/gmailOrderDisclosure.ts` aligns disclosure with shipping/refund/billing wording when the classifier misses.
|
||||||
|
- **Hints:** `stripQuotedEmailTrailForHints` + `extractOrderNumberHints` in `src/lib/ai/gmailContext.ts` reduce false hints from quoted threads.
|
||||||
- **Reply body:** `processIncomingEmail` in `src/lib/gmail/emailProcessor.ts` loads a template with `findTemplateWithFallback` and renders `{{variables}}`. **No** `generateReplyAndTemplateFields` / **no** auto-`insertTemplate` from Gmail.
|
- **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`).
|
- 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.
|
- **Order context:** `buildOrderSummaryForEmail` reads **CachedOrder** only; keep cache in sync with Shopify.
|
||||||
- If no template matches after fallbacks, a **minimal generic HTML** reply is sent (`buildMinimalFallbackHtml`).
|
- **Fallback HTML:** `buildOffTopicFallbackHtml`, `buildNeedOrderIdFallbackHtml`, `buildHintUnmatchedFallbackHtml`, or `buildMinimalFallbackHtml` if no template matches.
|
||||||
|
- **Abuse / idempotency:** Per-sender caps via `EmailLog` (`checkGmailAutoReplyGuards`); duplicate inbound Gmail `message` ids use `GmailInboundAutoReply` so the same message is not auto-replied twice. Env: `GMAIL_AUTO_REPLY_MAX_PER_SENDER_PER_HOUR` (default 15), `GMAIL_AUTO_REPLY_BURST_MAX_PER_10MIN` (default 6); set to `0` to disable that cap.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
1311
package-lock.json
generated
1311
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
"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",
|
||||||
@ -63,7 +64,8 @@
|
|||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"last 2 versions",
|
"last 2 versions",
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GmailInboundAutoReply" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"storeId" TEXT NOT NULL,
|
||||||
|
"gmailMessageId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "GmailInboundAutoReply_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GmailInboundAutoReply_userId_gmailMessageId_key" ON "GmailInboundAutoReply"("userId", "gmailMessageId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "GmailInboundAutoReply_storeId_createdAt_idx" ON "GmailInboundAutoReply"("storeId", "createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GmailInboundAutoReply" ADD CONSTRAINT "GmailInboundAutoReply_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GmailInboundAutoReply" ADD CONSTRAINT "GmailInboundAutoReply_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -40,6 +40,7 @@ model User {
|
|||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
gmailIntegration GmailIntegration?
|
gmailIntegration GmailIntegration?
|
||||||
emailLogs EmailLog[]
|
emailLogs EmailLog[]
|
||||||
|
gmailInboundAutoReplies GmailInboundAutoReply[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -117,6 +118,7 @@ model Store {
|
|||||||
knowledgeChunks KnowledgeChunk[]
|
knowledgeChunks KnowledgeChunk[]
|
||||||
emailLogs EmailLog[]
|
emailLogs EmailLog[]
|
||||||
flaggedEmails FlaggedEmail[]
|
flaggedEmails FlaggedEmail[]
|
||||||
|
gmailInboundAutoReplies GmailInboundAutoReply[]
|
||||||
templates Template[]
|
templates Template[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -391,6 +393,20 @@ model Notification {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row per inbound Gmail message id that received an auto-reply (idempotency). */
|
||||||
|
model GmailInboundAutoReply {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
storeId String
|
||||||
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
|
gmailMessageId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([userId, gmailMessageId])
|
||||||
|
@@index([storeId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
model EmailLog {
|
model EmailLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
33
src/lib/ai/gmailContext.test.ts
Normal file
33
src/lib/ai/gmailContext.test.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { extractOrderNumberHints, stripQuotedEmailTrailForHints } from "./gmailContext"
|
||||||
|
|
||||||
|
describe("stripQuotedEmailTrailForHints", () => {
|
||||||
|
it("keeps short message without markers", () => {
|
||||||
|
const s = "Where is my package? Order #1002"
|
||||||
|
expect(stripQuotedEmailTrailForHints(s)).toContain("1002")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("truncates after On ... wrote:", () => {
|
||||||
|
const s =
|
||||||
|
"Still waiting on order 5001.\n\nOn Mon, Jan 1, 2026 at 10:00 AM Someone wrote:\n> #9999 old thread"
|
||||||
|
const out = stripQuotedEmailTrailForHints(s)
|
||||||
|
expect(out).toContain("5001")
|
||||||
|
expect(out).not.toContain("9999")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("truncates after Original Message delimiter", () => {
|
||||||
|
const s =
|
||||||
|
"Refund for #4400 please\n\n-----Original Message-----\nFrom: x\nSubject: Re: #8800"
|
||||||
|
const out = stripQuotedEmailTrailForHints(s)
|
||||||
|
expect(out).toContain("4400")
|
||||||
|
expect(out).not.toContain("8800")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("extractOrderNumberHints", () => {
|
||||||
|
it("finds hash and order phrases", () => {
|
||||||
|
expect(extractOrderNumberHints("Re: #1002 and order 2003")).toEqual(
|
||||||
|
expect.arrayContaining(["1002", "2003"]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -20,6 +20,36 @@ export function normalizeOrderNumberKey(orderNumber: string): string {
|
|||||||
return orderNumber.replace(/^\s*#?\s*/i, "").trim()
|
return orderNumber.replace(/^\s*#?\s*/i, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove common quoted-reply / forward blocks so stale order numbers in threads
|
||||||
|
* are less likely to steer hint extraction (classification still uses full text elsewhere).
|
||||||
|
*/
|
||||||
|
export function stripQuotedEmailTrailForHints(text: string): string {
|
||||||
|
if (!text.trim()) return text
|
||||||
|
let s = text.replace(/\r\n/g, "\n")
|
||||||
|
|
||||||
|
const markers = [
|
||||||
|
/\nOn .{1,200}wrote:\s*\n/i,
|
||||||
|
/\n-{3,}\s*Original Message\s*-{3,}\s*\n/i,
|
||||||
|
/\nFrom:\s*.+\nSent:\s*.+\nTo:\s*.+\nSubject:\s*.+\n/i,
|
||||||
|
/\n________________________________\s*\n/i,
|
||||||
|
]
|
||||||
|
for (const re of markers) {
|
||||||
|
const m = s.match(re)
|
||||||
|
if (m?.index != null && m.index > 0) {
|
||||||
|
s = s.slice(0, m.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First line starting with ">" block (lazy: cut at first heavy quote run)
|
||||||
|
const quoteRun = s.search(/\n(?:>\s?.*\n){3,}/)
|
||||||
|
if (quoteRun > 40) {
|
||||||
|
s = s.slice(0, quoteRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.trim()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull likely order numbers from subject + body (e.g. "order id is 1002", "#1002").
|
* Pull likely order numbers from subject + body (e.g. "order id is 1002", "#1002").
|
||||||
*/
|
*/
|
||||||
@ -56,23 +86,36 @@ function parseLineItems(lineItems: unknown): ShopifyLineItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OrderSummaryForEmailResult = {
|
||||||
|
/** Plain-text order lines from CachedOrder only; empty when none or when withheld (hint issues). */
|
||||||
|
summary: string
|
||||||
|
/** Customer named order number(s) that do not match their cached orders, or ambiguous multi-hint cases. */
|
||||||
|
hintUnmatched: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderRowMatchesAnyHint(row: CachedOrderSummaryRow, hints: string[]): boolean {
|
||||||
|
const key = normalizeOrderNumberKey(row.orderNumber)
|
||||||
|
return hints.some((h) => normalizeOrderNumberKey(h) === key)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a short plain-text block of recent order context for the customer email (per store).
|
* Build a short plain-text block of recent order context for the customer email (per store).
|
||||||
* Uses CachedOrder only; does not invent data.
|
* Uses CachedOrder only; does not invent data.
|
||||||
* When `hintText` includes an order number (e.g. "1002"), matching rows are preferred so replies align with what the customer said.
|
* When hints exist but no row matches, returns empty summary and hintUnmatched (does not fall back to other orders).
|
||||||
*/
|
*/
|
||||||
export async function buildOrderSummaryForEmail(
|
export async function buildOrderSummaryForEmail(
|
||||||
storeId: string,
|
storeId: string,
|
||||||
customerEmail: string,
|
customerEmail: string,
|
||||||
hintText?: string
|
hintText?: string
|
||||||
): Promise<string> {
|
): Promise<OrderSummaryForEmailResult> {
|
||||||
const normalized = customerEmail.trim().toLowerCase()
|
const normalized = customerEmail.trim().toLowerCase()
|
||||||
if (!normalized) return ""
|
if (!normalized) {
|
||||||
|
return { summary: "", hintUnmatched: false }
|
||||||
|
}
|
||||||
|
|
||||||
const hints = hintText ? extractOrderNumberHints(hintText) : []
|
const hints = hintText ? extractOrderNumberHints(hintText) : []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Match top-level email or customer.email in JSON; fetch enough rows to honor order-number hints.
|
|
||||||
const rows = await prisma.$queryRaw<CachedOrderSummaryRow[]>`
|
const rows = await prisma.$queryRaw<CachedOrderSummaryRow[]>`
|
||||||
SELECT
|
SELECT
|
||||||
"orderNumber",
|
"orderNumber",
|
||||||
@ -95,22 +138,29 @@ export async function buildOrderSummaryForEmail(
|
|||||||
`
|
`
|
||||||
|
|
||||||
let orders: CachedOrderSummaryRow[] = rows
|
let orders: CachedOrderSummaryRow[] = rows
|
||||||
|
|
||||||
if (hints.length > 0) {
|
if (hints.length > 0) {
|
||||||
const hintSet = new Set(hints)
|
const matched = orders.filter((o) => orderRowMatchesAnyHint(o, hints))
|
||||||
const matched = orders.filter((o: CachedOrderSummaryRow) =>
|
if (matched.length === 0) {
|
||||||
hintSet.has(normalizeOrderNumberKey(o.orderNumber)),
|
|
||||||
)
|
|
||||||
if (matched.length > 0) {
|
|
||||||
orders = matched.slice(0, 3)
|
|
||||||
} else {
|
|
||||||
orders = orders.slice(0, 3)
|
|
||||||
logger.info("buildOrderSummaryForEmail: order number hint(s) not among email-matched orders", {
|
logger.info("buildOrderSummaryForEmail: order number hint(s) not among email-matched orders", {
|
||||||
storeId,
|
storeId,
|
||||||
hints,
|
hints,
|
||||||
senderEmail: normalized,
|
senderEmail: normalized,
|
||||||
emailMatchedCount: rows.length,
|
emailMatchedCount: rows.length,
|
||||||
})
|
})
|
||||||
|
return { summary: "", hintUnmatched: true }
|
||||||
}
|
}
|
||||||
|
const keysMatched = new Set(matched.map((o) => normalizeOrderNumberKey(o.orderNumber)))
|
||||||
|
const everyHintResolved = hints.every((h) => keysMatched.has(normalizeOrderNumberKey(h)))
|
||||||
|
if (hints.length > 1 && !everyHintResolved) {
|
||||||
|
logger.info("buildOrderSummaryForEmail: not all order hints matched cached rows", {
|
||||||
|
storeId,
|
||||||
|
hints,
|
||||||
|
senderEmail: normalized,
|
||||||
|
})
|
||||||
|
return { summary: "", hintUnmatched: true }
|
||||||
|
}
|
||||||
|
orders = matched.slice(0, 3)
|
||||||
} else {
|
} else {
|
||||||
orders = orders.slice(0, 3)
|
orders = orders.slice(0, 3)
|
||||||
}
|
}
|
||||||
@ -121,7 +171,7 @@ export async function buildOrderSummaryForEmail(
|
|||||||
senderEmail: normalized,
|
senderEmail: normalized,
|
||||||
hints,
|
hints,
|
||||||
})
|
})
|
||||||
return ""
|
return { summary: "", hintUnmatched: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
@ -148,9 +198,9 @@ export async function buildOrderSummaryForEmail(
|
|||||||
parts.push(line)
|
parts.push(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join("\n")
|
return { summary: parts.join("\n"), hintUnmatched: false }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("buildOrderSummaryForEmail failed", e)
|
logger.error("buildOrderSummaryForEmail failed", e)
|
||||||
return ""
|
return { summary: "", hintUnmatched: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,16 @@ Moods (pick one):
|
|||||||
|
|
||||||
Respond with JSON only: {"intent":"...","mood":"...","confidence":0.0-1.0,"short_summary":"one sentence"}`
|
Respond with JSON only: {"intent":"...","mood":"...","confidence":0.0-1.0,"short_summary":"one sentence"}`
|
||||||
|
|
||||||
export async function classifyCustomerEmail(subject: string, bodyExcerpt: string): Promise<GmailClassification> {
|
export type ClassifyCustomerEmailResult = {
|
||||||
|
classification: GmailClassification
|
||||||
|
/** `fallback` when Groq was unavailable or returned unparseable JSON. */
|
||||||
|
source: "groq" | "fallback"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function classifyCustomerEmail(
|
||||||
|
subject: string,
|
||||||
|
bodyExcerpt: string
|
||||||
|
): Promise<ClassifyCustomerEmailResult> {
|
||||||
const user = `Subject: ${subject.slice(0, 500)}\n\nMessage (excerpt):\n${bodyExcerpt.slice(0, 3500)}`
|
const user = `Subject: ${subject.slice(0, 500)}\n\nMessage (excerpt):\n${bodyExcerpt.slice(0, 3500)}`
|
||||||
|
|
||||||
const { content, error } = await groqChatCompletion(
|
const { content, error } = await groqChatCompletion(
|
||||||
@ -41,22 +50,25 @@ export async function classifyCustomerEmail(subject: string, bodyExcerpt: string
|
|||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
logger.info("Gmail classification skipped (Groq unavailable)", { error })
|
logger.info("Gmail classification skipped (Groq unavailable)", { error })
|
||||||
return normalizeClassification({})
|
return { classification: normalizeClassification({}), source: "fallback" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseJsonObject<Record<string, unknown>>(content)
|
const parsed = parseJsonObject<Record<string, unknown>>(content)
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
logger.warn("Gmail classification JSON parse failed")
|
logger.warn("Gmail classification JSON parse failed")
|
||||||
return normalizeClassification({})
|
return { classification: normalizeClassification({}), source: "fallback" }
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeClassification({
|
return {
|
||||||
intent: typeof parsed.intent === "string" ? parsed.intent : undefined,
|
classification: normalizeClassification({
|
||||||
mood: typeof parsed.mood === "string" ? parsed.mood : undefined,
|
intent: typeof parsed.intent === "string" ? parsed.intent : undefined,
|
||||||
confidence: typeof parsed.confidence === "number" ? parsed.confidence : undefined,
|
mood: typeof parsed.mood === "string" ? parsed.mood : undefined,
|
||||||
short_summary:
|
confidence: typeof parsed.confidence === "number" ? parsed.confidence : undefined,
|
||||||
typeof parsed.short_summary === "string" ? parsed.short_summary : undefined,
|
short_summary:
|
||||||
})
|
typeof parsed.short_summary === "string" ? parsed.short_summary : undefined,
|
||||||
|
}),
|
||||||
|
source: "groq",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GeneratedReplyPayload = {
|
export type GeneratedReplyPayload = {
|
||||||
|
|||||||
15
src/lib/ai/gmailOrderDisclosure.test.ts
Normal file
15
src/lib/ai/gmailOrderDisclosure.test.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { looksLikeSensitiveOrderTopic } from "./gmailOrderDisclosure"
|
||||||
|
|
||||||
|
describe("looksLikeSensitiveOrderTopic", () => {
|
||||||
|
it("detects shipping and refund language", () => {
|
||||||
|
expect(looksLikeSensitiveOrderTopic("Where is my package?")).toBe(true)
|
||||||
|
expect(looksLikeSensitiveOrderTopic("I need a refund")).toBe(true)
|
||||||
|
expect(looksLikeSensitiveOrderTopic("Wrong charge on my card")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for vague thanks", () => {
|
||||||
|
expect(looksLikeSensitiveOrderTopic("Thanks so much!")).toBe(false)
|
||||||
|
expect(looksLikeSensitiveOrderTopic("Hello")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
29
src/lib/ai/gmailOrderDisclosure.ts
Normal file
29
src/lib/ai/gmailOrderDisclosure.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Conservative heuristics for when auto-replies should not assume a specific order
|
||||||
|
* without an explicit order number (complements Groq intent classification).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Phrases suggesting shipping, refund, or billing — used when classifier fails or returns `general`. */
|
||||||
|
export function looksLikeSensitiveOrderTopic(text: string): boolean {
|
||||||
|
const t = text.toLowerCase()
|
||||||
|
if (t.length < 3) return false
|
||||||
|
|
||||||
|
const checks: RegExp[] = [
|
||||||
|
/\bwhere\s+is\s+my\s+(order|package|shipment|parcel|stuff)\b/,
|
||||||
|
/\bwhen\s+will\s+(my\s+)?(order|package|shipment)\b/,
|
||||||
|
/\b(haven'?t|didn'?t)\s+receive(d)?\b.*\b(order|package|item|anything)\b/,
|
||||||
|
/\btrack(ing)?\s*(number|link|info|details)?\b/,
|
||||||
|
/\b(lost|missing|late|delayed)\s+(package|shipment|order|delivery)\b/,
|
||||||
|
/\b(package|order)\s+(not\s+)?(arrived|come|delivered|here|yet)\b/,
|
||||||
|
/\bshipping\s+(status|update|delay|problem)\b/,
|
||||||
|
/\brefund\b/,
|
||||||
|
/\b(return|returned)\s+(this|my|the|item|order)\b/,
|
||||||
|
/\breturn\s+policy\b/,
|
||||||
|
/\bmoney\s+back\b/,
|
||||||
|
/\bcharge\s*back\b/,
|
||||||
|
/\b(wrong|duplicate|unauthorized)\s+charge\b/,
|
||||||
|
/\b(billing|invoice|payment)\s+(issue|problem|question|dispute)\b/,
|
||||||
|
/\bcharged\s+twice\b/,
|
||||||
|
]
|
||||||
|
return checks.some((re) => re.test(t))
|
||||||
|
}
|
||||||
@ -2,10 +2,21 @@ import { getGmailAccessToken } from "../gmail"
|
|||||||
import { logger } from "../logger"
|
import { logger } from "../logger"
|
||||||
import { sendGmailReply } from "./emailService"
|
import { sendGmailReply } from "./emailService"
|
||||||
import { prisma } from "../prisma"
|
import { prisma } from "../prisma"
|
||||||
import { renderTemplate } from "@/lib/templates/simpleRenderer"
|
import { escapeHtmlForEmail, escapeTemplateContextForHtml, renderTemplate } from "@/lib/templates/simpleRenderer"
|
||||||
import { classifyCustomerEmail } from "@/lib/ai/gmailGroq"
|
import { classifyCustomerEmail } from "@/lib/ai/gmailGroq"
|
||||||
import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety"
|
import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety"
|
||||||
import { buildOrderSummaryForEmail, extractOrderNumberHints } from "@/lib/ai/gmailContext"
|
import {
|
||||||
|
buildOrderSummaryForEmail,
|
||||||
|
extractOrderNumberHints,
|
||||||
|
stripQuotedEmailTrailForHints,
|
||||||
|
} from "@/lib/ai/gmailContext"
|
||||||
|
import { looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
|
||||||
|
import {
|
||||||
|
checkGmailAutoReplyRateLimits,
|
||||||
|
parseEnvFloat,
|
||||||
|
releaseGmailInboundLease,
|
||||||
|
tryAcquireGmailInboundLease,
|
||||||
|
} from "@/lib/gmail/gmailAutoReplyGuards"
|
||||||
import { findTemplateWithFallback, type TemplateRecord } from "@/lib/juno/templatesRepo"
|
import { findTemplateWithFallback, type TemplateRecord } from "@/lib/juno/templatesRepo"
|
||||||
|
|
||||||
export type ProcessIncomingEmailResult = {
|
export type ProcessIncomingEmailResult = {
|
||||||
@ -46,7 +57,7 @@ function greetingNameFromEmail(email: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildMinimalFallbackHtml(emailBody: string, storeName: string): string {
|
function buildMinimalFallbackHtml(emailBody: string, storeName: string): string {
|
||||||
const safeSnippet = emailBody.replace(/<[^>]+>/g, "").slice(0, 400)
|
const safeSnippet = escapeHtmlForEmail(emailBody.replace(/<[^>]+>/g, "").slice(0, 400))
|
||||||
return `
|
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="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;">
|
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
|
||||||
@ -95,6 +106,29 @@ function buildNeedOrderIdFallbackHtml(storeName: string, storeEmail: string): st
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** When hints did not match cache — no unrelated order rows. */
|
||||||
|
function buildHintUnmatchedFallbackHtml(storeName: string, storeEmail: string): string {
|
||||||
|
const contact = storeEmail.trim()
|
||||||
|
? `Reply with the correct order number or email us at ${storeEmail}.`
|
||||||
|
: `Reply with the order number from your confirmation email.`
|
||||||
|
return `
|
||||||
|
<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. */
|
/** When no off_topic template exists — polite boundary, no order data. */
|
||||||
function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string {
|
function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string {
|
||||||
const contact = storeEmail.trim()
|
const contact = storeEmail.trim()
|
||||||
@ -122,13 +156,14 @@ function renderFromTemplate(
|
|||||||
template: TemplateRecord,
|
template: TemplateRecord,
|
||||||
context: Record<string, string | number | boolean | null | undefined>
|
context: Record<string, string | number | boolean | null | undefined>
|
||||||
): string {
|
): string {
|
||||||
|
const safeContext = escapeTemplateContextForHtml(context)
|
||||||
return template.isHtml
|
return template.isHtml
|
||||||
? renderTemplate(template.body, context)
|
? 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;">
|
<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(
|
<pre style="white-space: pre-wrap; margin: 0;">${renderTemplate(
|
||||||
template.body,
|
template.body,
|
||||||
context,
|
safeContext,
|
||||||
)}</pre>
|
)}</pre>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
@ -253,143 +288,233 @@ export async function processIncomingEmail(
|
|||||||
logger.error("Failed to log incoming email to EmailLog", logError)
|
logger.error("Failed to log incoming email to EmailLog", logError)
|
||||||
}
|
}
|
||||||
|
|
||||||
const classification = await classifyCustomerEmail(emailSubject, plainForAi)
|
const rateCheck = await checkGmailAutoReplyRateLimits(storeId, senderEmail)
|
||||||
const hintSource = `${emailSubject}\n${plainForAi}`
|
if (!rateCheck.ok) {
|
||||||
const isOffTopic = classification.intent === "off_topic"
|
logger.warn("Gmail auto-reply rate limited", {
|
||||||
const orderHints = isOffTopic ? [] : extractOrderNumberHints(hintSource)
|
userId,
|
||||||
|
|
||||||
/** Don’t load CachedOrder until the customer names an order (avoids guessing #1002 when they only say “where’s my package?”). */
|
|
||||||
const intentsNeedingOrderIdWhenAmbiguous = new Set([
|
|
||||||
"shipping_delay",
|
|
||||||
"refund_request",
|
|
||||||
"account_billing",
|
|
||||||
])
|
|
||||||
const needsOrderNumberFirst =
|
|
||||||
!isOffTopic &&
|
|
||||||
intentsNeedingOrderIdWhenAmbiguous.has(classification.intent) &&
|
|
||||||
orderHints.length === 0
|
|
||||||
|
|
||||||
let orderSummary = ""
|
|
||||||
if (!isOffTopic && !needsOrderNumberFirst) {
|
|
||||||
orderSummary = await buildOrderSummaryForEmail(storeId, senderEmail, hintSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Gmail classification", {
|
|
||||||
userId,
|
|
||||||
storeId,
|
|
||||||
intent: classification.intent,
|
|
||||||
mood: classification.mood,
|
|
||||||
confidence: classification.confidence,
|
|
||||||
})
|
|
||||||
logger.info("Gmail order context for reply", {
|
|
||||||
storeId,
|
|
||||||
orderSummaryChars: orderSummary.trim().length,
|
|
||||||
orderHints,
|
|
||||||
offTopic: isOffTopic,
|
|
||||||
needsOrderNumberFirst,
|
|
||||||
})
|
|
||||||
|
|
||||||
const store = await prisma.store.findUnique({
|
|
||||||
where: { id: storeId },
|
|
||||||
select: {
|
|
||||||
businessName: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const storeName = store?.businessName || "Your store"
|
|
||||||
const ticketSummary =
|
|
||||||
classification.short_summary?.trim() ||
|
|
||||||
plainForAi.slice(0, 500) ||
|
|
||||||
emailBody.slice(0, 500)
|
|
||||||
|
|
||||||
const offTopicOrderLine = `We don't include order or purchase history for unrelated topics. For help with ${storeName}, reply with a store-related question and your order number if you have one.`
|
|
||||||
|
|
||||||
const counterOrderIdLine = `No order details are shown yet — we did not see an order number in your message. Please reply with your order number (e.g. #1002 or 1002) from your ${storeName} confirmation email so we can look up the correct order.`
|
|
||||||
|
|
||||||
const context: Record<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: isOffTopic
|
|
||||||
? offTopicOrderLine
|
|
||||||
: needsOrderNumberFirst
|
|
||||||
? counterOrderIdLine
|
|
||||||
: orderSummary || "No matching recent orders on file for this email.",
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalReply: string | null = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const template = await findTemplateWithFallback(
|
|
||||||
storeId,
|
storeId,
|
||||||
classification.intent,
|
senderEmail,
|
||||||
classification.mood,
|
reason: rateCheck.reason,
|
||||||
)
|
})
|
||||||
if (template) {
|
await markEmailAsRead(accessToken, email.id)
|
||||||
finalReply = renderFromTemplate(template, context)
|
return { success: true, replied: false }
|
||||||
}
|
|
||||||
} catch (replyBuildError) {
|
|
||||||
logger.error("Failed to load/render template for auto-reply", replyBuildError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!finalReply) {
|
const leaseAcquired = await tryAcquireGmailInboundLease(userId, storeId, email.id)
|
||||||
if (isOffTopic) {
|
if (!leaseAcquired) {
|
||||||
finalReply = buildOffTopicFallbackHtml(storeName, store?.email || "")
|
logger.info("Gmail inbound already processed — skip duplicate auto-reply", {
|
||||||
} else if (needsOrderNumberFirst) {
|
|
||||||
finalReply = buildNeedOrderIdFallbackHtml(storeName, store?.email || "")
|
|
||||||
} else {
|
|
||||||
finalReply = buildMinimalFallbackHtml(emailBody, storeName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const replySubject = emailSubject.startsWith("Re:") ? emailSubject : `Re: ${emailSubject}`
|
|
||||||
|
|
||||||
replied = true
|
|
||||||
const result = await sendGmailReply(
|
|
||||||
userId,
|
|
||||||
senderEmail,
|
|
||||||
replySubject,
|
|
||||||
finalReply,
|
|
||||||
email.threadId,
|
|
||||||
{
|
|
||||||
storeId,
|
|
||||||
isAutomated: true,
|
|
||||||
trigger: options?.replyTrigger ?? "GMAIL_WEBHOOK",
|
|
||||||
originalMessageId: email.messageId,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const markedRead = await markEmailAsRead(accessToken, email.id)
|
|
||||||
if (!markedRead) {
|
|
||||||
logger.warn("Failed to mark email as read after processing", {
|
|
||||||
userId,
|
userId,
|
||||||
storeId,
|
storeId,
|
||||||
emailId: email.id,
|
emailId: email.id,
|
||||||
})
|
})
|
||||||
|
await markEmailAsRead(accessToken, email.id)
|
||||||
|
return { success: true, replied: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.success) {
|
try {
|
||||||
logger.info("Automated reply sent successfully", {
|
const classifyResult = await classifyCustomerEmail(emailSubject, plainForAi)
|
||||||
userId,
|
const classification = classifyResult.classification
|
||||||
storeId,
|
const hintSourceFull = `${emailSubject}\n${plainForAi}`
|
||||||
to: senderEmail,
|
const hintSourceForHints = stripQuotedEmailTrailForHints(hintSourceFull)
|
||||||
subject: emailSubject,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
logger.error("Failed to send automated reply", {
|
|
||||||
userId,
|
|
||||||
storeId,
|
|
||||||
to: senderEmail,
|
|
||||||
subject: emailSubject,
|
|
||||||
error: result.error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: result.success, error: result.error, replied }
|
const isOffTopic = classification.intent === "off_topic"
|
||||||
|
const orderHints = isOffTopic ? [] : extractOrderNumberHints(hintSourceForHints)
|
||||||
|
|
||||||
|
const intentsNeedingOrderIdWhenAmbiguous = new Set([
|
||||||
|
"shipping_delay",
|
||||||
|
"refund_request",
|
||||||
|
"account_billing",
|
||||||
|
])
|
||||||
|
const sensitiveTopic = !isOffTopic && looksLikeSensitiveOrderTopic(hintSourceFull)
|
||||||
|
const needsOrderNumberFirst =
|
||||||
|
!isOffTopic &&
|
||||||
|
orderHints.length === 0 &&
|
||||||
|
(intentsNeedingOrderIdWhenAmbiguous.has(classification.intent) || sensitiveTopic)
|
||||||
|
|
||||||
|
const confidenceMin = parseEnvFloat("GMAIL_CLASSIFY_CONFIDENCE_MIN", 0.5)
|
||||||
|
const lowConfidenceSuppressOrders =
|
||||||
|
classifyResult.source === "groq" &&
|
||||||
|
classification.confidence < confidenceMin &&
|
||||||
|
orderHints.length === 0 &&
|
||||||
|
!isOffTopic &&
|
||||||
|
(classification.intent === "general" || classification.intent === "question")
|
||||||
|
|
||||||
|
const messageTooSparse = plainForAi.trim().length < 12 && orderHints.length === 0
|
||||||
|
|
||||||
|
const skipOrderCacheForQuestion =
|
||||||
|
classification.intent === "question" && orderHints.length === 0 && !isOffTopic
|
||||||
|
|
||||||
|
const shouldLoadOrderCache =
|
||||||
|
!isOffTopic &&
|
||||||
|
!needsOrderNumberFirst &&
|
||||||
|
!lowConfidenceSuppressOrders &&
|
||||||
|
!messageTooSparse &&
|
||||||
|
!skipOrderCacheForQuestion
|
||||||
|
|
||||||
|
let orderSummary = ""
|
||||||
|
let hintUnmatched = false
|
||||||
|
if (shouldLoadOrderCache) {
|
||||||
|
const built = await buildOrderSummaryForEmail(storeId, senderEmail, hintSourceForHints)
|
||||||
|
orderSummary = built.summary
|
||||||
|
hintUnmatched = built.hintUnmatched
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Gmail classification", {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
intent: classification.intent,
|
||||||
|
mood: classification.mood,
|
||||||
|
confidence: classification.confidence,
|
||||||
|
classifySource: classifyResult.source,
|
||||||
|
})
|
||||||
|
logger.info("Gmail order context for reply", {
|
||||||
|
storeId,
|
||||||
|
orderSummaryChars: orderSummary.trim().length,
|
||||||
|
orderHints,
|
||||||
|
offTopic: isOffTopic,
|
||||||
|
needsOrderNumberFirst,
|
||||||
|
hintUnmatched,
|
||||||
|
lowConfidenceSuppressOrders,
|
||||||
|
messageTooSparse,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = await prisma.store.findUnique({
|
||||||
|
where: { id: storeId },
|
||||||
|
select: {
|
||||||
|
businessName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const storeName = store?.businessName || "Your store"
|
||||||
|
const ticketSummary = messageTooSparse
|
||||||
|
? "We could not read enough text in your message to look up an order automatically. Please reply with a short description and your order number if you have one."
|
||||||
|
: classification.short_summary?.trim() ||
|
||||||
|
plainForAi.slice(0, 500) ||
|
||||||
|
emailBody.slice(0, 500)
|
||||||
|
|
||||||
|
const offTopicOrderLine = `We don't include order or purchase history for unrelated topics. For help with ${storeName}, reply with a store-related question and your order number if you have one.`
|
||||||
|
|
||||||
|
const counterOrderIdLine = `No order details are shown yet — we did not see an order number in your message. Please reply with your order number (e.g. #1002 or 1002) from your ${storeName} confirmation email so we can look up the correct order.`
|
||||||
|
|
||||||
|
const hintUnmatchedOrderLine = `We could not match the order number(s) in your message to an order on file for ${storeName} under this email. Please double-check the number or confirm the email address used at checkout.`
|
||||||
|
|
||||||
|
const lowConfidenceOrderLine = `We were not fully confident how to categorize your message. If this is about an order, reply with your order number from your ${storeName} confirmation email so we can pull up the right record.`
|
||||||
|
|
||||||
|
const sparseBodyOrderLine = `We could not read enough detail in your message to look anything up automatically. Please reply with a sentence or two and your order number if your question is about a ${storeName} order.`
|
||||||
|
|
||||||
|
const questionNoOrderLine = `We are replying without attaching order history. If your question relates to a specific purchase, reply with your order number (e.g. #1002).`
|
||||||
|
|
||||||
|
const noOrdersFallback = sensitiveTopic
|
||||||
|
? `No matching recent orders on file for ${storeName} under this email. If you used a different email at checkout, reply from that address or send your order number.`
|
||||||
|
: "No matching recent orders on file for this email."
|
||||||
|
|
||||||
|
let orderSummaryForContext: string
|
||||||
|
if (isOffTopic) {
|
||||||
|
orderSummaryForContext = offTopicOrderLine
|
||||||
|
} else if (needsOrderNumberFirst) {
|
||||||
|
orderSummaryForContext = counterOrderIdLine
|
||||||
|
} else if (hintUnmatched) {
|
||||||
|
orderSummaryForContext = hintUnmatchedOrderLine
|
||||||
|
} else if (lowConfidenceSuppressOrders) {
|
||||||
|
orderSummaryForContext = lowConfidenceOrderLine
|
||||||
|
} else if (messageTooSparse) {
|
||||||
|
orderSummaryForContext = sparseBodyOrderLine
|
||||||
|
} else if (skipOrderCacheForQuestion) {
|
||||||
|
orderSummaryForContext = questionNoOrderLine
|
||||||
|
} else {
|
||||||
|
orderSummaryForContext = orderSummary || noOrdersFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const context: Record<string, string | number | boolean | null | undefined> = {
|
||||||
|
customer_name: greetingNameFromEmail(senderEmail),
|
||||||
|
customer_email: senderEmail,
|
||||||
|
subject: emailSubject,
|
||||||
|
store_name: storeName,
|
||||||
|
store_email: store?.email || "",
|
||||||
|
ticket_summary: ticketSummary,
|
||||||
|
order_summary: orderSummaryForContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalReply: string | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const template = await findTemplateWithFallback(
|
||||||
|
storeId,
|
||||||
|
classification.intent,
|
||||||
|
classification.mood,
|
||||||
|
)
|
||||||
|
if (template) {
|
||||||
|
finalReply = renderFromTemplate(template, context)
|
||||||
|
}
|
||||||
|
} catch (replyBuildError) {
|
||||||
|
logger.error("Failed to load/render template for auto-reply", replyBuildError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalReply) {
|
||||||
|
if (isOffTopic) {
|
||||||
|
finalReply = buildOffTopicFallbackHtml(storeName, store?.email || "")
|
||||||
|
} else if (needsOrderNumberFirst) {
|
||||||
|
finalReply = buildNeedOrderIdFallbackHtml(storeName, store?.email || "")
|
||||||
|
} else if (hintUnmatched) {
|
||||||
|
finalReply = buildHintUnmatchedFallbackHtml(storeName, store?.email || "")
|
||||||
|
} else {
|
||||||
|
finalReply = buildMinimalFallbackHtml(emailBody, storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const replySubject = emailSubject.startsWith("Re:") ? emailSubject : `Re: ${emailSubject}`
|
||||||
|
|
||||||
|
replied = true
|
||||||
|
const result = await sendGmailReply(
|
||||||
|
userId,
|
||||||
|
senderEmail,
|
||||||
|
replySubject,
|
||||||
|
finalReply,
|
||||||
|
email.threadId,
|
||||||
|
{
|
||||||
|
storeId,
|
||||||
|
isAutomated: true,
|
||||||
|
trigger: options?.replyTrigger ?? "GMAIL_WEBHOOK",
|
||||||
|
originalMessageId: email.messageId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
await releaseGmailInboundLease(userId, email.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const markedRead = await markEmailAsRead(accessToken, email.id)
|
||||||
|
if (!markedRead) {
|
||||||
|
logger.warn("Failed to mark email as read after processing", {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
emailId: email.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info("Automated reply sent successfully", {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
to: senderEmail,
|
||||||
|
subject: emailSubject,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.error("Failed to send automated reply", {
|
||||||
|
userId,
|
||||||
|
storeId,
|
||||||
|
to: senderEmail,
|
||||||
|
subject: emailSubject,
|
||||||
|
error: result.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: result.success, error: result.error, replied }
|
||||||
|
} catch (processError) {
|
||||||
|
await releaseGmailInboundLease(userId, email.id)
|
||||||
|
throw processError
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error processing incoming email", error)
|
logger.error("Error processing incoming email", error)
|
||||||
return {
|
return {
|
||||||
|
|||||||
89
src/lib/gmail/gmailAutoReplyGuards.ts
Normal file
89
src/lib/gmail/gmailAutoReplyGuards.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { Prisma } from "@prisma/client"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
const GMAIL_AUTO_REPLY_TRIGGERS = ["GMAIL_WEBHOOK", "GMAIL_POLL"] as const
|
||||||
|
|
||||||
|
export function parseEnvInt(name: string, defaultVal: number): number {
|
||||||
|
const v = process.env[name]
|
||||||
|
if (v == null || v === "") return defaultVal
|
||||||
|
const n = Number.parseInt(v, 10)
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEnvFloat(name: string, defaultVal: number): number {
|
||||||
|
const v = process.env[name]
|
||||||
|
if (v == null || v === "") return defaultVal
|
||||||
|
const n = Number.parseFloat(v)
|
||||||
|
return Number.isFinite(n) ? n : defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-sender caps on automated Gmail replies (loop / abuse mitigation).
|
||||||
|
* Set env to 0 to disable that check.
|
||||||
|
*/
|
||||||
|
export async function checkGmailAutoReplyRateLimits(
|
||||||
|
storeId: string,
|
||||||
|
senderEmail: string
|
||||||
|
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||||
|
const maxPerHour = parseEnvInt("GMAIL_AUTO_REPLY_MAX_PER_SENDER_PER_HOUR", 15)
|
||||||
|
const burstMax = parseEnvInt("GMAIL_AUTO_REPLY_BURST_MAX_PER_10MIN", 6)
|
||||||
|
if (maxPerHour <= 0 && burstMax <= 0) return { ok: true }
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const oneHourAgo = new Date(now - 60 * 60 * 1000)
|
||||||
|
const tenMinAgo = new Date(now - 10 * 60 * 1000)
|
||||||
|
|
||||||
|
const baseWhere = {
|
||||||
|
storeId,
|
||||||
|
to: senderEmail,
|
||||||
|
isAutomated: true,
|
||||||
|
status: "SENT" as const,
|
||||||
|
trigger: { in: [...GMAIL_AUTO_REPLY_TRIGGERS] },
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hourlyCount, burstCount] = await Promise.all([
|
||||||
|
maxPerHour > 0
|
||||||
|
? prisma.emailLog.count({
|
||||||
|
where: { ...baseWhere, sentAt: { gte: oneHourAgo } },
|
||||||
|
})
|
||||||
|
: Promise.resolve(0),
|
||||||
|
burstMax > 0
|
||||||
|
? prisma.emailLog.count({
|
||||||
|
where: { ...baseWhere, sentAt: { gte: tenMinAgo } },
|
||||||
|
})
|
||||||
|
: Promise.resolve(0),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (burstMax > 0 && burstCount >= burstMax) {
|
||||||
|
return { ok: false, reason: "burst_auto_reply_cap" }
|
||||||
|
}
|
||||||
|
if (maxPerHour > 0 && hourlyCount >= maxPerHour) {
|
||||||
|
return { ok: false, reason: "hourly_auto_reply_cap" }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns false if this inbound Gmail message was already processed (idempotency). */
|
||||||
|
export async function tryAcquireGmailInboundLease(
|
||||||
|
userId: string,
|
||||||
|
storeId: string,
|
||||||
|
gmailMessageId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await prisma.gmailInboundAutoReply.create({
|
||||||
|
data: { userId, storeId, gmailMessageId },
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseGmailInboundLease(userId: string, gmailMessageId: string): Promise<void> {
|
||||||
|
await prisma.gmailInboundAutoReply.deleteMany({
|
||||||
|
where: { userId, gmailMessageId },
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,5 +1,39 @@
|
|||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
|
/** Escape string values injected into HTML email bodies (inbound-derived text). */
|
||||||
|
export function escapeHtmlForEmail(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTML_ESCAPED_CONTEXT_KEYS = new Set([
|
||||||
|
"customer_name",
|
||||||
|
"customer_email",
|
||||||
|
"subject",
|
||||||
|
"ticket_summary",
|
||||||
|
"store_name",
|
||||||
|
"store_email",
|
||||||
|
"order_summary",
|
||||||
|
])
|
||||||
|
|
||||||
|
/** Clone context with HTML-safe string values for isHtml templates. */
|
||||||
|
export function escapeTemplateContextForHtml(
|
||||||
|
context: Record<string, string | number | boolean | null | undefined>
|
||||||
|
): Record<string, string | number | boolean | null | undefined> {
|
||||||
|
const out: Record<string, string | number | boolean | null | undefined> = { ...context }
|
||||||
|
for (const key of HTML_ESCAPED_CONTEXT_KEYS) {
|
||||||
|
const v = out[key]
|
||||||
|
if (typeof v === "string") {
|
||||||
|
out[key] = escapeHtmlForEmail(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Very small, safe renderer for {{variable}} placeholders.
|
* Very small, safe renderer for {{variable}} placeholders.
|
||||||
* - No conditionals or loops
|
* - No conditionals or loops
|
||||||
|
|||||||
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import path from "node:path"
|
||||||
|
import { defineConfig } from "vitest/config"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user