diff --git a/.gitignore b/.gitignore index 6b2330f..a973e7b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ # dependencies /node_modules +/worker/node_modules +/worker/package-lock.json +/worker/pnpm-lock.yaml /.pnp .pnp.js .yarn/install-state.gz diff --git a/README.md b/README.md index a0e12c3..0038fda 100644 --- a/README.md +++ b/README.md @@ -141,3 +141,86 @@ GRANT ALL ON TABLE "public"."reply_edit" TO "service_role"; | `NEXT_PUBLIC_BASE_URL` | Base URL of your deployment (e.g. `https://answerify.dev`) | | `RESEND_API_KEY` | Resend API key for sending emails | | `GEMINI_API_KEY` | Google Gemini API key for embeddings (`gemini-embedding-001`) and completions (`gemini-3-flash-preview`) | +| `CLOUDFLARE_AGENT_URL` | *(Optional)* URL of the deployed Cloudflare Worker (see below) | +| `CLOUDFLARE_AGENT_SECRET` | *(Optional)* Shared secret to authenticate calls from Next.js to the Worker | + +--- + +## Cloudflare Agents Integration + +The `worker/` directory contains a [Cloudflare Agents](https://agents.cloudflare.com/) Worker that handles inbound email processing and AI reply generation entirely on Cloudflare's infrastructure, replacing both the `/api/webhooks/inbound-email` and `/api/webhooks/reply` Next.js routes. + +### Why Cloudflare Agents? + +| | Next.js serverless | Cloudflare Agent Worker | +|---|---|---| +| Inbound email | HTTP webhook (base64 raw body) | Native `email` export – raw `EmailMessage` direct from Cloudflare Email Routing | +| Execution timeout | ~60 s (Vercel) | Minutes to hours (Durable Objects) | +| State persistence | None – stateless per request | Built-in SQLite per agent instance | +| Parallelism | One function per request | One Durable Object per email thread | +| Observability | Logs only | State introspection + scheduling | +| Outbound reply | Resend API with manual `In-Reply-To` header | `this.replyToEmail()` – Cloudflare sets `In-Reply-To` natively; reply lands in customer's existing thread | + +The `EmailReplyAgent` extends the Cloudflare [`Agent`](https://agents.cloudflare.com/) class. Each email thread gets its own Durable Object instance, so parallel threads are fully isolated and never block each other. Agent state (`status`, `findings`, `reply`, `confidence`) is persisted across the research and writing steps, enabling safe retries if any step fails. + +### Deploying the Worker + +```bash +cd worker +npm install +npm run deploy # wrangler deploy + +# Set secrets (run once): +wrangler secret put SUPABASE_URL +wrangler secret put SUPABASE_SERVICE_KEY +wrangler secret put INBOUND_WEBHOOK_SECRET # shared with Next.js CLOUDFLARE_AGENT_SECRET +``` + +After deploying: +1. Point your [Cloudflare Email Routing](https://developers.cloudflare.com/email-routing/) destination to this Worker (instead of the Next.js `/api/webhooks/inbound-email` URL). +2. Set `CLOUDFLARE_AGENT_URL` and `CLOUDFLARE_AGENT_SECRET` in your Next.js environment so the dashboard's "Generate Reply" action calls the Worker. + +Without these variables the app falls back to the built-in Next.js webhook (original behaviour). + +### How it works + +``` +Customer sends email + │ + ▼ +Cloudflare Email Routing + │ delivers raw EmailMessage + ▼ +Worker email handler + │ resolveEmailToAgent(): + │ 1. In-Reply-To lookup → existing thread via Supabase + │ 2. new UUID → new thread + │ + ▼ routeAgentEmail() +EmailReplyAgent (Durable Object, one per thread) + │ + ├─ onEmail() + │ ├─ isAutoReplyEmail check (RFC 3834) + │ ├─ Parse raw email (PostalMime) + │ ├─ Look up organization by recipient address + │ ├─ Create/reopen thread in Supabase + │ ├─ Insert email record + │ └─ generateReply() ──────────────────────────────────────┐ + │ │ + └─ onRequest() ◄── Next.js /api/generate-reply │ + (manual trigger from dashboard → always saves draft) │ + └─ generateReply() ────────────────────────────────────► │ + ▼ + Step 1: Research Agent (@cf/zai-org/glm-4.7-flash) + fetches up to 5 datasource URLs, extracts + plain text, summarises relevant facts + Step 2: Writing Agent (@cf/zai-org/glm-4.7-flash) + produces polished HTML reply (up to 4096 tokens) + Both steps use the Workers AI binding – no external + AI service or API key required. + Step 3: Auto-send or draft + • email path: this.replyToEmail() – Cloudflare + sets In-Reply-To natively; reply lands in the + customer's existing thread automatically + • HTTP path: always saves draft for review +``` diff --git a/app/api/generate-reply/route.ts b/app/api/generate-reply/route.ts index d60627c..db12a8c 100644 --- a/app/api/generate-reply/route.ts +++ b/app/api/generate-reply/route.ts @@ -7,7 +7,7 @@ export async function POST(request: Request) { const supabase = await createServiceClient(); const { data: record, error } = await supabase .from('email') - .select() + .select('thread_id') .eq('id', id) .single(); @@ -15,19 +15,62 @@ export async function POST(request: Request) { return new Response(JSON.stringify({ error: true }), { status: 500 }); } - // Fire webhook request and wait for completion to ensure it's processed + // When a Cloudflare Agent Worker URL is configured, forward the request to + // the EmailReplyAgent Durable Object for this thread. + // The agent fetches all context (datasources, org, thread history) from + // Supabase itself, so we only need to pass the email ID. + const agentUrl = process.env.CLOUDFLARE_AGENT_URL; + const agentSecret = process.env.CLOUDFLARE_AGENT_SECRET; + if (agentUrl && agentSecret) { + try { + const threadId = record.thread_id as string; + const agentEndpoint = `${agentUrl}/agents/email-reply-agent/${threadId}`; + + const agentResponse = await fetch(agentEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Agent-Secret': agentSecret, + }, + body: JSON.stringify({ emailId: id }), + }); + + if (!agentResponse.ok) { + console.error( + 'Cloudflare Agent request failed:', + await agentResponse.text() + ); + } + } catch (err) { + console.error('Cloudflare Agent request error:', err); + } + + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + + // Fallback: fire the local webhook (original behaviour, no Cloudflare Agent) try { + const { data: fullRecord, error: fetchError } = await supabase + .from('email') + .select() + .eq('id', id) + .single(); + + if (fetchError) { + return new Response(JSON.stringify({ error: true }), { status: 500 }); + } + const webhookResponse = await fetch(`${origin}/api/webhooks/reply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ record }), + body: JSON.stringify({ record: fullRecord }), }); if (!webhookResponse.ok) { console.error('Webhook failed:', await webhookResponse.text()); } - } catch (error) { - console.error('Webhook request failed:', error); + } catch (err) { + console.error('Webhook request failed:', err); } return new Response(JSON.stringify({ ok: true }), { status: 200 }); diff --git a/components/organization/WelcomeDashboard.tsx b/components/organization/WelcomeDashboard.tsx index 99ffda4..5d0bbd3 100644 --- a/components/organization/WelcomeDashboard.tsx +++ b/components/organization/WelcomeDashboard.tsx @@ -195,25 +195,25 @@ export function WelcomeDashboard({
  1. - + Copy your inbound email address
  2. 0} /> - + Set up email forwarding from your support account
  3. 0} /> - + Add data sources to power AI replies
  4. 0} /> - + Send a test email and watch Answerify reply!
  5. diff --git a/env.example b/env.example index 179e159..66bf484 100644 --- a/env.example +++ b/env.example @@ -4,3 +4,11 @@ SUPABASE_SERVICE_KEY= NEXT_PUBLIC_BASE_URL=http://localhost:3000 RESEND_API_KEY= GEMINI_API_KEY= + +# Cloudflare Agents (optional) +# Set these to route AI reply generation through the Cloudflare Worker instead +# of the built-in Next.js webhook. The Worker uses Durable Objects for +# stateful, timeout-resistant execution of the research + writing pipeline. +# Deploy the worker/ directory with `wrangler deploy` to get the URL. +CLOUDFLARE_AGENT_URL= +CLOUDFLARE_AGENT_SECRET= diff --git a/tsconfig.json b/tsconfig.json index be66c31..daacfa2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,6 @@ { "compilerOptions": { - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -22,9 +18,7 @@ } ], "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] }, "target": "ES2017" }, @@ -35,8 +29,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules", - "supabase" - ] + "exclude": ["node_modules", "supabase", "worker"] } diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 0000000..52f9ca8 --- /dev/null +++ b/worker/package.json @@ -0,0 +1,21 @@ +{ + "name": "answerify-worker", + "version": "0.1.0", + "private": true, + "main": "src/index.ts", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "cf-typegen": "wrangler types" + }, + "dependencies": { + "agents": "^0.6.0", + "common-tags": "^1.8.2", + "postal-mime": "^2.7.3" + }, + "devDependencies": { + "@types/common-tags": "^1.8.4", + "typescript": "^5", + "wrangler": "^4.69.0" + } +} diff --git a/worker/src/index.ts b/worker/src/index.ts new file mode 100644 index 0000000..f761de0 --- /dev/null +++ b/worker/src/index.ts @@ -0,0 +1,911 @@ +/** + * Answerify – Cloudflare Agents Worker + * + * This Worker integrates with Cloudflare Email Routing and the Cloudflare + * Agents SDK (https://agents.cloudflare.com/) to process inbound support + * emails end-to-end, replacing the Next.js /api/webhooks/inbound-email and + * /api/webhooks/reply routes. + * + * Flow: + * 1. Cloudflare Email Routing delivers raw emails to the `email` export. + * 2. A thread-aware resolver maps each message to the correct EmailReplyAgent + * Durable Object instance (one per thread, keyed by the Supabase thread ID). + * 3. EmailReplyAgent.onEmail() handles: + * a. Auto-reply detection (RFC 3834 / X-Auto-Response-Suppress headers) + * b. Raw email parsing via PostalMime + * c. Organization lookup by recipient address + * d. Thread creation/lookup in Supabase + * e. Email record insertion + * f. Research Agent – fetches datasource URLs, extracts relevant content, + * and summarises findings via @cf/zai-org/glm-4.7-flash + * g. Writing Agent – produces a polished HTML reply via @cf/zai-org/glm-4.7-flash + * (Cloudflare Workers AI binding – no external service needed) + * h. Auto-send via this.replyToEmail() (or save as draft for human review) + * + * Sending replies uses Cloudflare's native email.reply() via this.replyToEmail(). + * This ensures the reply lands in the customer's existing email thread automatically + * (In-Reply-To / References headers are set correctly) without any external + * email-sending service. + * + * Each email thread gets its own isolated Durable Object instance so parallel + * threads never block each other and pipeline state survives transient errors. + * + * An optional HTTP path (onRequest) lets the Next.js dashboard manually + * trigger AI reply generation for a specific email ID; it always saves a draft + * for human review rather than auto-sending. + */ + +import { Agent, routeAgentEmail, routeAgentRequest } from 'agents'; +import { isAutoReplyEmail } from 'agents/email'; +import type { AgentEmail } from 'agents/email'; +import { codeBlock } from 'common-tags'; +import PostalMime from 'postal-mime'; + +// --------------------------------------------------------------------------- +// Environment bindings (wrangler.toml / Cloudflare dashboard secrets) +// --------------------------------------------------------------------------- + +export interface Env { + EMAIL_REPLY_AGENT: DurableObjectNamespace; + /** Cloudflare Workers AI binding – used for both the Research and Writing Agents. */ + AI: Ai; + SUPABASE_URL: string; + SUPABASE_SERVICE_KEY: string; + /** + * Shared secret used to authenticate HTTP requests from the Next.js app to + * the Worker's onRequest handler. + */ + INBOUND_WEBHOOK_SECRET: string; +} + +// --------------------------------------------------------------------------- +// Agent state +// --------------------------------------------------------------------------- + +interface AgentState { + status: 'idle' | 'researching' | 'writing' | 'complete' | 'error'; + organizationId?: string; + threadId?: string; + emailId?: string; + findings?: string; + confidence?: number; + citations?: string[]; + reply?: string; + error?: string; +} + +// --------------------------------------------------------------------------- +// Supabase REST helpers (no SDK dependency required in Workers) +// --------------------------------------------------------------------------- + +async function supabaseFetch( + supabaseUrl: string, + serviceKey: string, + path: string, + options: RequestInit = {} +): Promise { + const response = await fetch(`${supabaseUrl}/rest/v1/${path}`, { + ...options, + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}`, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + ...(options.headers ?? {}), + }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error( + `Supabase ${options.method ?? 'GET'} /${path} failed (${response.status}): ${body}` + ); + } + + return response.json() as Promise; +} + +// --------------------------------------------------------------------------- +// URL content fetcher (Research Agent) +// +// Fetches datasource pages in parallel, strips HTML, and truncates to a safe +// per-URL length so the combined context fits inside the model's token budget. +// --------------------------------------------------------------------------- + +/** Maximum number of datasource URLs fetched per request. */ +const MAX_RESEARCH_URLS = 5; +/** Maximum plain-text characters kept per fetched URL. */ +const MAX_CHARS_PER_URL = 3000; +/** + * Confidence score assigned when at least one datasource URL was successfully + * fetched and contained relevant content. Matches the fallback value that was + * used with Gemini URL context when grounding metadata was unavailable. + */ +const URL_FETCH_SUCCESS_CONFIDENCE = 0.7; +/** Maximum tokens the Writing Agent may generate. */ +const MAX_WRITING_TOKENS = 4096; + +/** + * Fetch a list of URLs in parallel and extract their plain-text content. + * Returns only the URLs that responded successfully and had non-empty content. + */ +async function fetchUrlsContent( + urls: string[] +): Promise> { + const results = await Promise.allSettled( + urls.slice(0, MAX_RESEARCH_URLS).map(async (url) => { + const response = await fetch(url, { + headers: { 'User-Agent': 'Answerify-Support-Bot/1.0' }, + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) return { url, content: '' }; + // Use HTMLRewriter to remove