feat: automations and security

This commit is contained in:
talatops 2026-03-28 17:10:15 +05:00
parent 066e491b1d
commit 6cec08dbc4
15 changed files with 2270 additions and 165 deletions

View 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` &lt; `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`.*

View File

@ -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)
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 |
@ -52,10 +52,21 @@ These keys are substituted by `renderTemplate` in `src/lib/templates/simpleRende
| `{{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 |
| `{{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
- **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.
- 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`).
- **Order context:** `buildOrderSummaryForEmail` reads **CachedOrder** only; keep cache in sync with Shopify.
- **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

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
"db:reset": "prisma migrate reset",
"db:studio": "prisma studio",
@ -63,7 +64,8 @@
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
"typescript": "^5",
"vitest": "^3.0.5"
},
"browserslist": [
"last 2 versions",

View File

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

View File

@ -40,6 +40,7 @@ model User {
notifications Notification[]
gmailIntegration GmailIntegration?
emailLogs EmailLog[]
gmailInboundAutoReplies GmailInboundAutoReply[]
pushSubscriptions PushSubscription[]
createdAt DateTime @default(now())
@ -117,6 +118,7 @@ model Store {
knowledgeChunks KnowledgeChunk[]
emailLogs EmailLog[]
flaggedEmails FlaggedEmail[]
gmailInboundAutoReplies GmailInboundAutoReply[]
templates Template[]
createdAt DateTime @default(now())
@ -391,6 +393,20 @@ model Notification {
@@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 {
id String @id @default(cuid())
userId String

View 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"]),
)
})
})

View File

@ -20,6 +20,36 @@ export function normalizeOrderNumberKey(orderNumber: string): string {
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").
*/
@ -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).
* 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(
storeId: string,
customerEmail: string,
hintText?: string
): Promise<string> {
): Promise<OrderSummaryForEmailResult> {
const normalized = customerEmail.trim().toLowerCase()
if (!normalized) return ""
if (!normalized) {
return { summary: "", hintUnmatched: false }
}
const hints = hintText ? extractOrderNumberHints(hintText) : []
try {
// Match top-level email or customer.email in JSON; fetch enough rows to honor order-number hints.
const rows = await prisma.$queryRaw<CachedOrderSummaryRow[]>`
SELECT
"orderNumber",
@ -95,22 +138,29 @@ export async function buildOrderSummaryForEmail(
`
let orders: CachedOrderSummaryRow[] = rows
if (hints.length > 0) {
const hintSet = new Set(hints)
const matched = orders.filter((o: CachedOrderSummaryRow) =>
hintSet.has(normalizeOrderNumberKey(o.orderNumber)),
)
if (matched.length > 0) {
orders = matched.slice(0, 3)
} else {
orders = orders.slice(0, 3)
const matched = orders.filter((o) => orderRowMatchesAnyHint(o, hints))
if (matched.length === 0) {
logger.info("buildOrderSummaryForEmail: order number hint(s) not among email-matched orders", {
storeId,
hints,
senderEmail: normalized,
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 {
orders = orders.slice(0, 3)
}
@ -121,7 +171,7 @@ export async function buildOrderSummaryForEmail(
senderEmail: normalized,
hints,
})
return ""
return { summary: "", hintUnmatched: false }
}
const parts: string[] = []
@ -148,9 +198,9 @@ export async function buildOrderSummaryForEmail(
parts.push(line)
}
return parts.join("\n")
return { summary: parts.join("\n"), hintUnmatched: false }
} catch (e) {
logger.error("buildOrderSummaryForEmail failed", e)
return ""
return { summary: "", hintUnmatched: false }
}
}

View File

@ -28,7 +28,16 @@ Moods (pick one):
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 { content, error } = await groqChatCompletion(
@ -41,22 +50,25 @@ export async function classifyCustomerEmail(subject: string, bodyExcerpt: string
if (!content) {
logger.info("Gmail classification skipped (Groq unavailable)", { error })
return normalizeClassification({})
return { classification: normalizeClassification({}), source: "fallback" }
}
const parsed = parseJsonObject<Record<string, unknown>>(content)
if (!parsed) {
logger.warn("Gmail classification JSON parse failed")
return normalizeClassification({})
return { classification: normalizeClassification({}), source: "fallback" }
}
return normalizeClassification({
intent: typeof parsed.intent === "string" ? parsed.intent : undefined,
mood: typeof parsed.mood === "string" ? parsed.mood : undefined,
confidence: typeof parsed.confidence === "number" ? parsed.confidence : undefined,
short_summary:
typeof parsed.short_summary === "string" ? parsed.short_summary : undefined,
})
return {
classification: normalizeClassification({
intent: typeof parsed.intent === "string" ? parsed.intent : undefined,
mood: typeof parsed.mood === "string" ? parsed.mood : undefined,
confidence: typeof parsed.confidence === "number" ? parsed.confidence : undefined,
short_summary:
typeof parsed.short_summary === "string" ? parsed.short_summary : undefined,
}),
source: "groq",
}
}
export type GeneratedReplyPayload = {

View 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)
})
})

View 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))
}

View File

@ -2,10 +2,21 @@ import { getGmailAccessToken } from "../gmail"
import { logger } from "../logger"
import { sendGmailReply } from "./emailService"
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 { 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"
export type ProcessIncomingEmailResult = {
@ -46,7 +57,7 @@ function greetingNameFromEmail(email: 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 `
<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;">
@ -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. */
function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string {
const contact = storeEmail.trim()
@ -122,13 +156,14 @@ function renderFromTemplate(
template: TemplateRecord,
context: Record<string, string | number | boolean | null | undefined>
): string {
const safeContext = escapeTemplateContextForHtml(context)
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;">
<pre style="white-space: pre-wrap; margin: 0;">${renderTemplate(
template.body,
context,
safeContext,
)}</pre>
</div>
`
@ -253,143 +288,233 @@ export async function processIncomingEmail(
logger.error("Failed to log incoming email to EmailLog", logError)
}
const classification = await classifyCustomerEmail(emailSubject, plainForAi)
const hintSource = `${emailSubject}\n${plainForAi}`
const isOffTopic = classification.intent === "off_topic"
const orderHints = isOffTopic ? [] : extractOrderNumberHints(hintSource)
/** Don’t load CachedOrder until the customer names an order (avoids guessing #1002 when they only say “where’s my package?”). */
const intentsNeedingOrderIdWhenAmbiguous = new Set([
"shipping_delay",
"refund_request",
"account_billing",
])
const needsOrderNumberFirst =
!isOffTopic &&
intentsNeedingOrderIdWhenAmbiguous.has(classification.intent) &&
orderHints.length === 0
let orderSummary = ""
if (!isOffTopic && !needsOrderNumberFirst) {
orderSummary = await buildOrderSummaryForEmail(storeId, senderEmail, hintSource)
}
logger.info("Gmail classification", {
userId,
storeId,
intent: classification.intent,
mood: classification.mood,
confidence: classification.confidence,
})
logger.info("Gmail order context for reply", {
storeId,
orderSummaryChars: orderSummary.trim().length,
orderHints,
offTopic: isOffTopic,
needsOrderNumberFirst,
})
const store = await prisma.store.findUnique({
where: { id: storeId },
select: {
businessName: true,
email: true,
},
})
const storeName = store?.businessName || "Your store"
const ticketSummary =
classification.short_summary?.trim() ||
plainForAi.slice(0, 500) ||
emailBody.slice(0, 500)
const offTopicOrderLine = `We don't include order or purchase history for unrelated topics. For help with ${storeName}, reply with a store-related question and your order number if you have one.`
const counterOrderIdLine = `No order details are shown yet — we did not see an order number in your message. Please reply with your order number (e.g. #1002 or 1002) from your ${storeName} confirmation email so we can look up the correct order.`
const context: Record<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(
const rateCheck = await checkGmailAutoReplyRateLimits(storeId, senderEmail)
if (!rateCheck.ok) {
logger.warn("Gmail auto-reply rate limited", {
userId,
storeId,
classification.intent,
classification.mood,
)
if (template) {
finalReply = renderFromTemplate(template, context)
}
} catch (replyBuildError) {
logger.error("Failed to load/render template for auto-reply", replyBuildError)
senderEmail,
reason: rateCheck.reason,
})
await markEmailAsRead(accessToken, email.id)
return { success: true, replied: false }
}
if (!finalReply) {
if (isOffTopic) {
finalReply = buildOffTopicFallbackHtml(storeName, store?.email || "")
} else if (needsOrderNumberFirst) {
finalReply = buildNeedOrderIdFallbackHtml(storeName, store?.email || "")
} else {
finalReply = buildMinimalFallbackHtml(emailBody, storeName)
}
}
const replySubject = emailSubject.startsWith("Re:") ? emailSubject : `Re: ${emailSubject}`
replied = true
const result = await sendGmailReply(
userId,
senderEmail,
replySubject,
finalReply,
email.threadId,
{
storeId,
isAutomated: true,
trigger: options?.replyTrigger ?? "GMAIL_WEBHOOK",
originalMessageId: email.messageId,
}
)
const markedRead = await markEmailAsRead(accessToken, email.id)
if (!markedRead) {
logger.warn("Failed to mark email as read after processing", {
const leaseAcquired = await tryAcquireGmailInboundLease(userId, storeId, email.id)
if (!leaseAcquired) {
logger.info("Gmail inbound already processed — skip duplicate auto-reply", {
userId,
storeId,
emailId: email.id,
})
await markEmailAsRead(accessToken, email.id)
return { success: true, replied: false }
}
if (result.success) {
logger.info("Automated reply sent successfully", {
userId,
storeId,
to: senderEmail,
subject: emailSubject,
})
} else {
logger.error("Failed to send automated reply", {
userId,
storeId,
to: senderEmail,
subject: emailSubject,
error: result.error,
})
}
try {
const classifyResult = await classifyCustomerEmail(emailSubject, plainForAi)
const classification = classifyResult.classification
const hintSourceFull = `${emailSubject}\n${plainForAi}`
const hintSourceForHints = stripQuotedEmailTrailForHints(hintSourceFull)
return { success: result.success, error: result.error, replied }
const isOffTopic = classification.intent === "off_topic"
const orderHints = isOffTopic ? [] : extractOrderNumberHints(hintSourceForHints)
const intentsNeedingOrderIdWhenAmbiguous = new Set([
"shipping_delay",
"refund_request",
"account_billing",
])
const sensitiveTopic = !isOffTopic && looksLikeSensitiveOrderTopic(hintSourceFull)
const needsOrderNumberFirst =
!isOffTopic &&
orderHints.length === 0 &&
(intentsNeedingOrderIdWhenAmbiguous.has(classification.intent) || sensitiveTopic)
const confidenceMin = parseEnvFloat("GMAIL_CLASSIFY_CONFIDENCE_MIN", 0.5)
const lowConfidenceSuppressOrders =
classifyResult.source === "groq" &&
classification.confidence < confidenceMin &&
orderHints.length === 0 &&
!isOffTopic &&
(classification.intent === "general" || classification.intent === "question")
const messageTooSparse = plainForAi.trim().length < 12 && orderHints.length === 0
const skipOrderCacheForQuestion =
classification.intent === "question" && orderHints.length === 0 && !isOffTopic
const shouldLoadOrderCache =
!isOffTopic &&
!needsOrderNumberFirst &&
!lowConfidenceSuppressOrders &&
!messageTooSparse &&
!skipOrderCacheForQuestion
let orderSummary = ""
let hintUnmatched = false
if (shouldLoadOrderCache) {
const built = await buildOrderSummaryForEmail(storeId, senderEmail, hintSourceForHints)
orderSummary = built.summary
hintUnmatched = built.hintUnmatched
}
logger.info("Gmail classification", {
userId,
storeId,
intent: classification.intent,
mood: classification.mood,
confidence: classification.confidence,
classifySource: classifyResult.source,
})
logger.info("Gmail order context for reply", {
storeId,
orderSummaryChars: orderSummary.trim().length,
orderHints,
offTopic: isOffTopic,
needsOrderNumberFirst,
hintUnmatched,
lowConfidenceSuppressOrders,
messageTooSparse,
})
const store = await prisma.store.findUnique({
where: { id: storeId },
select: {
businessName: true,
email: true,
},
})
const storeName = store?.businessName || "Your store"
const ticketSummary = messageTooSparse
? "We could not read enough text in your message to look up an order automatically. Please reply with a short description and your order number if you have one."
: classification.short_summary?.trim() ||
plainForAi.slice(0, 500) ||
emailBody.slice(0, 500)
const offTopicOrderLine = `We don't include order or purchase history for unrelated topics. For help with ${storeName}, reply with a store-related question and your order number if you have one.`
const counterOrderIdLine = `No order details are shown yet — we did not see an order number in your message. Please reply with your order number (e.g. #1002 or 1002) from your ${storeName} confirmation email so we can look up the correct order.`
const hintUnmatchedOrderLine = `We could not match the order number(s) in your message to an order on file for ${storeName} under this email. Please double-check the number or confirm the email address used at checkout.`
const lowConfidenceOrderLine = `We were not fully confident how to categorize your message. If this is about an order, reply with your order number from your ${storeName} confirmation email so we can pull up the right record.`
const sparseBodyOrderLine = `We could not read enough detail in your message to look anything up automatically. Please reply with a sentence or two and your order number if your question is about a ${storeName} order.`
const questionNoOrderLine = `We are replying without attaching order history. If your question relates to a specific purchase, reply with your order number (e.g. #1002).`
const noOrdersFallback = sensitiveTopic
? `No matching recent orders on file for ${storeName} under this email. If you used a different email at checkout, reply from that address or send your order number.`
: "No matching recent orders on file for this email."
let orderSummaryForContext: string
if (isOffTopic) {
orderSummaryForContext = offTopicOrderLine
} else if (needsOrderNumberFirst) {
orderSummaryForContext = counterOrderIdLine
} else if (hintUnmatched) {
orderSummaryForContext = hintUnmatchedOrderLine
} else if (lowConfidenceSuppressOrders) {
orderSummaryForContext = lowConfidenceOrderLine
} else if (messageTooSparse) {
orderSummaryForContext = sparseBodyOrderLine
} else if (skipOrderCacheForQuestion) {
orderSummaryForContext = questionNoOrderLine
} else {
orderSummaryForContext = orderSummary || noOrdersFallback
}
const context: Record<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) {
logger.error("Error processing incoming email", error)
return {

View 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 },
})
}

View File

@ -1,5 +1,39 @@
import { logger } from "@/lib/logger"
/** Escape string values injected into HTML email bodies (inbound-derived text). */
export function escapeHtmlForEmail(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
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.
* - No conditionals or loops

14
vitest.config.ts Normal file
View 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"),
},
},
})