diff --git a/.github/workflows/deploy-ec2.yml b/.github/workflows/deploy-ec2.yml index 36d04b2..51cf552 100644 --- a/.github/workflows/deploy-ec2.yml +++ b/.github/workflows/deploy-ec2.yml @@ -123,7 +123,7 @@ jobs: # Rolling update with docker compose (app + Postgres) 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" docker compose -f docker-compose.yml down || true exit 1 diff --git a/docker-compose.yml b/docker-compose.yml index 1a374f5..99fa89e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ services: retries: 5 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: context: . dockerfile: Dockerfile diff --git a/docs/gmail-auto-reply-test-scenarios.md b/docs/gmail-auto-reply-test-scenarios.md new file mode 100644 index 0000000..aaca7c6 --- /dev/null +++ b/docs/gmail-auto-reply-test-scenarios.md @@ -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: 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`.* diff --git a/docs/gmail-templates-catalog.md b/docs/gmail-templates-catalog.md index 91d153b..24a4cc4 100644 --- a/docs/gmail-templates-catalog.md +++ b/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) -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. --- diff --git a/package-lock.json b/package-lock.json index ae5db40..eb8717a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,8 @@ "eslint-config-next": "16.1.6", "tailwindcss": "^4", "tsx": "^4.21.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.0.5" } }, "node_modules/@alloc/quick-lru": { @@ -3577,6 +3578,331 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -5222,6 +5548,136 @@ "uncrypto": "^0.1.3" } }, + "node_modules/@vitest/expect": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz", + "integrity": "sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", + "chai": "^5.1.2", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.5.tgz", + "integrity": "sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.5.tgz", + "integrity": "sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.0.5", + "pathe": "^2.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.5.tgz", + "integrity": "sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.0.5", + "magic-string": "^0.30.17", + "pathe": "^2.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", + "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.5.tgz", + "integrity": "sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.5.tgz", + "integrity": "sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.0.5", + "loupe": "^3.1.2", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", + "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -5475,6 +5931,15 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5787,6 +6252,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -5867,6 +6341,22 @@ } ] }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5894,6 +6384,15 @@ "pnpm": ">=8" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -6208,6 +6707,15 @@ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6506,6 +7014,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -7022,6 +7536,15 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -7044,6 +7567,15 @@ "node": ">=0.4.x" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -8667,6 +9199,12 @@ "underscore": "^1.13.1" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -9366,6 +9904,15 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pdf-parse": { "version": "2.4.5", "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", @@ -10045,6 +10592,50 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10355,6 +10946,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -10406,6 +11003,12 @@ "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", "dev": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -10677,6 +11280,12 @@ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, "node_modules/tinyexec": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", @@ -10730,6 +11339,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11111,6 +11747,663 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.5.tgz", + "integrity": "sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.2", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz", + "integrity": "sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "3.0.5", + "@vitest/mocker": "3.0.5", + "@vitest/pretty-format": "^3.0.5", + "@vitest/runner": "3.0.5", + "@vitest/snapshot": "3.0.5", + "@vitest/spy": "3.0.5", + "@vitest/utils": "3.0.5", + "chai": "^5.1.2", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.5", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.5", + "@vitest/ui": "3.0.5", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, "node_modules/web-push": { "version": "3.6.7", "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", @@ -11232,6 +12525,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 78b436f..c9fe950 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260328120000_gmail_inbound_auto_reply/migration.sql b/prisma/migrations/20260328120000_gmail_inbound_auto_reply/migration.sql new file mode 100644 index 0000000..b3c5264 --- /dev/null +++ b/prisma/migrations/20260328120000_gmail_inbound_auto_reply/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ab590f2..980bd3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/lib/ai/gmailContext.test.ts b/src/lib/ai/gmailContext.test.ts new file mode 100644 index 0000000..cc5bd76 --- /dev/null +++ b/src/lib/ai/gmailContext.test.ts @@ -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"]), + ) + }) +}) diff --git a/src/lib/ai/gmailContext.ts b/src/lib/ai/gmailContext.ts index 34f5cbb..8b86d56 100644 --- a/src/lib/ai/gmailContext.ts +++ b/src/lib/ai/gmailContext.ts @@ -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 { +): Promise { 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` 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 } } } diff --git a/src/lib/ai/gmailGroq.ts b/src/lib/ai/gmailGroq.ts index 549439a..5a67810 100644 --- a/src/lib/ai/gmailGroq.ts +++ b/src/lib/ai/gmailGroq.ts @@ -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 { +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 { 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>(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 = { diff --git a/src/lib/ai/gmailOrderDisclosure.test.ts b/src/lib/ai/gmailOrderDisclosure.test.ts new file mode 100644 index 0000000..8a8d9dc --- /dev/null +++ b/src/lib/ai/gmailOrderDisclosure.test.ts @@ -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) + }) +}) diff --git a/src/lib/ai/gmailOrderDisclosure.ts b/src/lib/ai/gmailOrderDisclosure.ts new file mode 100644 index 0000000..81fd48f --- /dev/null +++ b/src/lib/ai/gmailOrderDisclosure.ts @@ -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)) +} diff --git a/src/lib/gmail/emailProcessor.ts b/src/lib/gmail/emailProcessor.ts index ed8138f..b68ec61 100644 --- a/src/lib/gmail/emailProcessor.ts +++ b/src/lib/gmail/emailProcessor.ts @@ -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 `
@@ -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 ` +
+
+

Hello,

+

+ We could not match the order number in your message to an order on file for ${storeName} under this email address. + Please double-check the number or confirm the email you used when placing the order. +

+

${contact}

+

+ Best regards,
+ ${storeName} Support +

+
+
+ ` +} + /** When no off_topic template exists — polite boundary, no order data. */ function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string { const contact = storeEmail.trim() @@ -122,13 +156,14 @@ function renderFromTemplate( template: TemplateRecord, context: Record ): string { + const safeContext = escapeTemplateContextForHtml(context) return template.isHtml - ? renderTemplate(template.body, context) + ? renderTemplate(template.body, safeContext) : `
${renderTemplate(
                                 template.body,
-                                context,
+                                safeContext,
                             )}
` @@ -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 = { - 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 = { + 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 { diff --git a/src/lib/gmail/gmailAutoReplyGuards.ts b/src/lib/gmail/gmailAutoReplyGuards.ts new file mode 100644 index 0000000..4a90562 --- /dev/null +++ b/src/lib/gmail/gmailAutoReplyGuards.ts @@ -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 { + 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 { + await prisma.gmailInboundAutoReply.deleteMany({ + where: { userId, gmailMessageId }, + }) +} diff --git a/src/lib/templates/simpleRenderer.ts b/src/lib/templates/simpleRenderer.ts index 872a2ad..b66453a 100644 --- a/src/lib/templates/simpleRenderer.ts +++ b/src/lib/templates/simpleRenderer.ts @@ -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, "&") + .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 +): Record { + const out: Record = { ...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 diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8a4f295 --- /dev/null +++ b/vitest.config.ts @@ -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"), + }, + }, +})