diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..d679821e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "gas"] + path = gas + url = https://github.com/jmbish04/core-template-gas.git + branch = master diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index d8359e8d..76a55395 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -23,5 +23,6 @@ "**/.next", "**/.astro", "**/.netlify", + "gas/**", ], } diff --git a/AGENTS.md b/AGENTS.md index 713820ee..630686eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ This repository relies heavily on AI agents for rapid prototyping and feature ge - **Inspecting failures:** to debug a failed run, `gh run list --workflow=deploy.yml` (or `ci.yml` / `migrate.yml`) to find the run id, then `gh run view --log-failed` for just the failing step's log (full log: `gh run view --log`). Fix, then re-trigger the workflow. 18. **Drive IDs, never URLs:** Google APIs key off the bare Drive **id**, not the url. Whenever a tool/utility accepts Drive ids as params — especially arrays — normalize every element first: use `extractGoogleId(input)` (single) or `parseDriveRefs(input: string | string[])` (array → `{ requested, id }[]`, deduped, blanks dropped) from `@/backend/google/core/ids`. This means a caller can pass a full Docs/Sheets/Drive **url** in any element and it still resolves. `sheet-export.ts` and `doc-export.ts` are the reference consumers. Never pass a raw url straight to a Google API call. 19. **Shared Data Toolkit:** This template ships an isomorphic data/array/object utility toolkit built on [Remeda](https://github.com/remeda/remeda). Reach for it before hand-rolling array/object plumbing. Import from `@/backend/utils/data` on the Worker side and `@/lib/data` on the frontend — both re-export the same isomorphic core at `@/shared/data-utils`. It exposes curated Remeda re-exports (`pipe`, `groupBy`, `unique`, `sortBy`, `pick`, `difference`, …), the full Remeda surface as `R`, and template helpers Remeda doesn't ship (`diffArrays`, `findWhere`, `toggleInArray`, `moveItem`, `keyBy`, `compact`, `ensureArray`, `deal`, `truncate`, `tryParseJson`). Add genuinely-shared helpers to the shared core (never duplicate per-surface). Live demo + docs at `/showcase/utilities`. See `.agent/rules/data-utilities.md`. +20. **Linked GAS submodule (`gas/` → core-template-gas):** Standalone Apps Script projects the worker RUNS via `scripts.run` (currently `email-to-pdf`) live in the separate [`core-template-gas`](https://github.com/jmbish04/core-template-gas) repo, linked here as the **`gas/` git submodule**; their source is at `gas/projects//`. This worker does **not** build or deploy them — the submodule is excluded from the worker toolchain (not a pnpm package; outside `tsconfig` `include`; ignored by vitest/oxlint). The worker only (a) stores each project's per-account `scriptId` in `backend/appscript/gas-projects.ts` (override at runtime via `set_gas_script` / `global_config` key `gas_script::`), and (b) invokes it (`gmail_to_pdf` with `via:"appscript"` → `scripts.run`). **Commits made inside `gas/` belong to core-template-gas, not this repo** — commit + push them to core-template-gas, then `git add gas` here to bump the recorded pointer. **Before editing anything under `gas/`, READ AND FOLLOW `gas/AGENTS.md`** — that repo has its own conventions (Deno/esbuild TS→GAS build, per-project `project.json` + root `projects.json` registry, deploy-affected CI, per-project `accounts[]` for multi-account deploy). Fresh clones must run `git submodule update --init` to populate `gas/`; after a core-template-gas merge, repoint with `git -C gas checkout master && git -C gas pull && git add gas`. ## Google Workspace MCP — Feature Map (this worker's real surface) @@ -75,6 +76,21 @@ the client tool-catalog under ~1k tokens. Only two tools are advertised; the ful `email_templates_list/get/add` + built-in Gmail-safe templates (`backend/gmail/ email-templates.ts`, table `email_templates`, seeded idempotently) + `/gws/email-templates` gallery. +- **Email → PDF** (`backend/gmail/thread-pdf.ts`): `gmail_to_pdf` prints a thread / + message / message-subset. `via:"render"` (default) → Browser Rendering REST `/pdf` + (`docs/browser-render.ts#renderHtmlToPdf`) into a worker-served `pdf_url` (previews + bucket, 48h) and supports `highlights` ([{term,color}] → colored ``, tag-safe). + `via:"appscript"` → the account's `email-to-pdf` GAS project (see directive #20) via + `scripts.run`, saving a native-fidelity PDF to the user's Drive (`drive_url`). +- **Email read** (`backend/gmail/body-extract.ts`): `gmail_get_message` returns body in + `bodyFormat` `text`(default)|`html`|`rfc`, ALWAYS with `urls:[{label,href}]`; + `gmail_get_thread` carries per-message `body`+`urls`. `cc`/`bcc` supported on all + compose tools (drafts included). +- **Doc/Sheet/PDF preview** (`docs/doc-preview.ts`): `preview_file` (+ `docs_create_from_markdown` + by default) exports to PDF, rasterizes EACH page to a PNG in the previews R2 bucket + (48h TTL via bucket lifecycle + hourly `purgeExpiredPreviews`, served `/api/preview/:id`), + and optionally critiques each page via Ollama (`docs/vision-critique.ts` → `lib/guardian-ai.ts`, + auth `WORKER_API_KEY`). Returns `{ pdf_url, pages:{pg_N:{image_url, vision_ai_notes}} }`. - **Exports**: `sheets_export_json` (`google/sheet-export.ts`, table `sheet_export_jobs`) and `docs_export` (`google/doc-export.ts`, table `doc_export_jobs`) — array of id/urls (via `parseDriveRefs`), cross-account fallback, per-element error items, D1 tracking diff --git a/gas b/gas new file mode 160000 index 00000000..e3164071 --- /dev/null +++ b/gas @@ -0,0 +1 @@ +Subproject commit e31640718e993ee6a545c4971f747bfd99b5e336 diff --git a/src/_worker.ts b/src/_worker.ts index 1e5e14d5..48e325d8 100644 --- a/src/_worker.ts +++ b/src/_worker.ts @@ -35,6 +35,7 @@ import { handleMcpRequest } from "./backend/mcp/server"; // added in Task 14 import { syncLabelsForAllAccounts } from "./backend/gmail/sync-service"; import { captureAllAccounts } from "./backend/gmail/capture-service"; import { purgeOldRenders } from "./backend/docs/browser-render"; +import { purgeExpiredPreviews } from "./backend/docs/preview-store"; import { sweepComments } from "./backend/docs/comment-collab"; import { sweepScheduledSends } from "./backend/gmail/scheduled-send"; import { sweepScheduledEmails } from "./backend/gmail/scheduled-email"; @@ -259,6 +260,7 @@ function makeHandler(): ExportedHandler { await syncLabelsForAllAccounts(env); await captureAllAccounts(env); await purgeOldRenders(env); // drop QC screenshots older than 90 days + await purgeExpiredPreviews(env); // drop doc-preview PNG/PDFs older than 48h })(), ); }, diff --git a/src/backend/api/index.ts b/src/backend/api/index.ts index 7e7b94b0..24b27e9f 100644 --- a/src/backend/api/index.ts +++ b/src/backend/api/index.ts @@ -33,6 +33,7 @@ import { driveRouter } from "./routes/drive"; import { schemaRouter } from "./routes/schema"; import { appscriptRouter } from "./routes/appscript"; import { renderRouter } from "./routes/render"; +import { previewRouter } from "./routes/preview"; import { healthRouter } from "./routes/health"; import { activityRouter } from "./routes/activity"; import { circuitRouter } from "./routes/circuit"; @@ -171,6 +172,7 @@ app.route("/api/gmail", gmailRouter); app.route("/api/schema", schemaRouter); app.route("/api/appscript", appscriptRouter); app.route("/api/render", renderRouter); +app.route("/api/preview", previewRouter); app.route("/api/projects", projectsRouter); app.route("/api/tasks", tasksRouter); // Comments / Subtasks / Attachments for a single task — mounted alongside diff --git a/src/backend/api/routes/preview.ts b/src/backend/api/routes/preview.ts new file mode 100644 index 00000000..d2f2069d --- /dev/null +++ b/src/backend/api/routes/preview.ts @@ -0,0 +1,30 @@ +/** + * @fileoverview Serve short-lived doc previews from R2. Mount at `/api/preview`. + * GET /:id → the PNG or PDF (gated by session cookie OR worker key). + * The id is self-describing (`{uuid}-p1.png`, `{uuid}.pdf`) and IS the R2 object + * key (dedicated previews bucket, no prefix). Objects are purged after 48h. + */ +import { Hono } from "hono"; + +import { getWorkerApiKey } from "@/backend/utils/secrets"; +import { constantTimeEqual } from "@/backend/lib/crypto"; +import { readVerifiedSession } from "@/backend/auth/read-session"; +import { previewContentType } from "@/backend/docs/preview-store"; + +export const previewRouter = new Hono<{ Bindings: Env }>(); + +previewRouter.get("/:id", async (c) => { + const key = await getWorkerApiKey(c.env); + const provided = c.req.header("x-worker-key") ?? (c.req.header("authorization") ?? "").replace(/^Bearer\s+/i, ""); + const keyed = !!key && !!provided && constantTimeEqual(provided, key); + const authed = keyed || (await readVerifiedSession(c.env, c.req.raw)).authed; + if (!authed) return c.json({ error: "unauthorized" }, 401); + + const id = c.req.param("id"); + const obj = await c.env.R2_PREVIEWS_BUCKET.get(id); + if (!obj) return c.json({ error: "expired" }, 404); + + return new Response(obj.body, { + headers: { "content-type": previewContentType(id), "cache-control": "private, max-age=3600" }, + }); +}); diff --git a/src/backend/appscript/gas-projects.ts b/src/backend/appscript/gas-projects.ts new file mode 100644 index 00000000..242abf15 --- /dev/null +++ b/src/backend/appscript/gas-projects.ts @@ -0,0 +1,76 @@ +/** + * @file appscript/gas-projects.ts + * @description Registry of deployed standalone Apps Script projects the worker + * RUNS (via `scripts.run`) but does NOT build — their source lives in + * `core-template-gas`, linked here as the `gas/` git submodule (edit the scripts + * at `gas/projects//`; commits there go to core-template-gas, whose CI + * deploys each project to BOTH accounts, one API-executable scriptId per env). + * The worker only needs the scriptId + entry function to invoke them. + * + * Fill in the per-account scriptIds once core-template-gas CI has created the + * dedicated projects, OR override at runtime (no redeploy) via the `global_config` + * key `gas_script::` → `{ scriptId }`. + */ +import { eq } from "drizzle-orm"; + +import { getDb } from "@/db"; +import { globalConfig } from "@db/schemas"; + +export interface GasProject { + /** API-executable entry function invoked via scripts.run. */ + entry: string; + /** Per-environment scriptId, keyed by lowercased account email. */ + scriptIds: Record; +} + +/** + * Known GAS projects deployed from core-template-gas. Seed scriptIds here as the + * CI creates them; `global_config` overrides win at runtime. + */ +export const GAS_PROJECTS: Record = { + "email-to-pdf": { + entry: "exportEmailPdf", + // scripts.run uses the SCRIPT id (the `1…` project id), not the `AKfycb…` + // deployment id — same as STANDING_SCRIPTS. Deployment ids (for CI redeploys) + // live in gas/projects/email-to-pdf/project.json accounts[]. + scriptIds: { + "jmbish04@gmail.com": "1dwFo9llZgMOXzV8ViXKtrW82v9CaHuarwpVyg-pIcFrhKpuiEAYGF648", + "justin@126colby.com": "1yCzRUF-KYX9mhz39t31dyZIfC1SU7tcWvZywX_wE8kTkjQvWQ5I6ydf-", + }, + }, +}; + +function configKey(project: string, accountEmail: string): string { + return `gas_script:${project}:${accountEmail.toLowerCase()}`; +} + +/** + * Resolve the scriptId + entry for a project in a given account. A `global_config` + * override wins over the seeded {@link GAS_PROJECTS} map; returns undefined when + * the project is unknown or has no scriptId for that account yet. + */ +export async function resolveGasScript( + env: Env, + project: string, + accountEmail: string, +): Promise<{ scriptId: string; entry: string } | undefined> { + const proj = GAS_PROJECTS[project]; + if (!proj) return undefined; + const email = accountEmail.toLowerCase(); + + const override = ( + await getDb(env).select().from(globalConfig).where(eq(globalConfig.key, configKey(project, email))).limit(1) + )[0]?.value as { scriptId?: string } | undefined; + + const scriptId = override?.scriptId ?? proj.scriptIds[email]; + return scriptId ? { scriptId, entry: proj.entry } : undefined; +} + +/** Register/override a project's scriptId for an account (no redeploy needed). */ +export async function setGasScript(env: Env, project: string, accountEmail: string, scriptId: string): Promise { + const now = new Date(); + await getDb(env) + .insert(globalConfig) + .values({ key: configKey(project, accountEmail), value: { scriptId }, updatedAt: now }) + .onConflictDoUpdate({ target: globalConfig.key, set: { value: { scriptId }, updatedAt: now } }); +} diff --git a/src/backend/db/schemas/scheduled-emails.ts b/src/backend/db/schemas/scheduled-emails.ts index 210ed799..4687b8b9 100644 --- a/src/backend/db/schemas/scheduled-emails.ts +++ b/src/backend/db/schemas/scheduled-emails.ts @@ -16,6 +16,8 @@ import { createInsertSchema, createSelectSchema } from "drizzle-zod"; /** The complete, self-contained message spec persisted for a scheduled send. */ export interface ScheduledEmailSpec { to: string; + cc?: string; + bcc?: string; subject: string; body?: string; html?: string; diff --git a/src/backend/docs/__tests__/html-to-braille.test.ts b/src/backend/docs/__tests__/html-to-braille.test.ts index 7daae1ec..cfc2dddd 100644 --- a/src/backend/docs/__tests__/html-to-braille.test.ts +++ b/src/backend/docs/__tests__/html-to-braille.test.ts @@ -33,4 +33,10 @@ describe("htmlToRequests", () => { it("returns nothing for empty html", () => { expect(htmlToRequests("
")).toEqual([]); }); + + it("decodes HTML entities in injected text (no literal "/'/&)", () => { + const reqs = htmlToRequests('

He said "hi" & it's fine

'); + const insert = reqs[0] as any; + expect(insert.insertText.text).toBe('He said "hi" & it\'s fine\n'); + }); }); diff --git a/src/backend/docs/__tests__/preview-store.test.ts b/src/backend/docs/__tests__/preview-store.test.ts new file mode 100644 index 00000000..2eeb5ea4 --- /dev/null +++ b/src/backend/docs/__tests__/preview-store.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from "vitest"; + +import { purgeExpiredPreviews, previewContentType, PREVIEW_TTL_MS } from "../preview-store"; + +describe("previewContentType", () => { + it("infers type from extension", () => { + expect(previewContentType("abc-p1.png")).toBe("image/png"); + expect(previewContentType("abc.pdf")).toBe("application/pdf"); + expect(previewContentType("abc.bin")).toBe("application/octet-stream"); + }); +}); + +describe("purgeExpiredPreviews", () => { + it("deletes only objects older than the 48h TTL, across pages", async () => { + const now = Date.now(); + const old1 = { key: "a.png", uploaded: new Date(now - PREVIEW_TTL_MS - 1000) }; + const fresh = { key: "b.png", uploaded: new Date(now - 1000) }; + const old2 = { key: "c.pdf", uploaded: new Date(now - PREVIEW_TTL_MS - 5000) }; + const deleted: string[] = []; + const env = { + R2_PREVIEWS_BUCKET: { + list: vi + .fn() + .mockResolvedValueOnce({ objects: [old1, fresh], truncated: true, cursor: "c1" }) + .mockResolvedValueOnce({ objects: [old2], truncated: false }), + delete: vi.fn(async (k: string) => { deleted.push(k); }), + }, + } as any; + + const removed = await purgeExpiredPreviews(env); + expect(removed).toBe(2); + expect(deleted).toEqual(["a.png", "c.pdf"]); + expect(deleted).not.toContain("b.png"); + }); +}); diff --git a/src/backend/docs/browser-render.ts b/src/backend/docs/browser-render.ts index 07a4e125..7987552c 100644 --- a/src/backend/docs/browser-render.ts +++ b/src/backend/docs/browser-render.ts @@ -42,6 +42,87 @@ const d=document.createElement('div');d.id='ready';document.body.appendChild(d); `; } +/** + * Render an HTML string to a PDF via Cloudflare Browser Rendering (REST `/pdf` + * endpoint — the headless-Chrome `page.pdf()` equivalent, no puppeteer binding). + * Best-effort: returns null when Browser Rendering isn't configured or the call + * fails, so callers degrade instead of throwing. + */ +export async function renderHtmlToPdf(env: Env, html: string): Promise { + const accountId = await getSecret(env, "CLOUDFLARE_ACCOUNT_ID"); + const token = await getSecret(env, "CLOUDFLARE_WRANGLER_API_TOKEN"); + if (!accountId || !token) return null; + + try { + const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/browser-rendering/pdf`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ + html, + gotoOptions: { waitUntil: "networkidle0", timeout: 30000 }, + }), + }); + if (!res.ok) return null; + if (!(res.headers.get("content-type") ?? "").includes("pdf")) return null; + return new Uint8Array(await res.arrayBuffer()); + } catch { + return null; + } +} + +/** Single-page pdf.js harness: renders ONLY page `pageNum` (1-based) full-bleed. */ +function pageHarness(b64: string, pageNum: number, scale: number): string { + const PDFJS = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174"; + return ` + +
+`; +} + +/** + * Rasterize a SINGLE PDF page (1-based) to a PNG via Browser Rendering. Null on + * failure / when Browser Rendering isn't configured. Used to produce one image + * per page (vs {@link rasterizePdf}, which stacks pages into one tall PNG). + */ +export async function rasterizePdfPage( + env: Env, + pdfBytes: Uint8Array, + pageNum: number, + scale = 1.6, +): Promise { + if (pdfBytes.length > MAX_PDF_BYTES) return null; + const accountId = await getSecret(env, "CLOUDFLARE_ACCOUNT_ID"); + const token = await getSecret(env, "CLOUDFLARE_WRANGLER_API_TOKEN"); + if (!accountId || !token) return null; + + const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/browser-rendering/screenshot`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ + html: pageHarness(toBase64(pdfBytes), pageNum, scale), + gotoOptions: { waitUntil: "networkidle0", timeout: 30000 }, + waitForSelector: "#ready", + screenshotOptions: { fullPage: true, type: "png" }, + }), + }); + if (!res.ok) return null; + if (!(res.headers.get("content-type") ?? "").includes("image")) return null; + return new Uint8Array(await res.arrayBuffer()); +} + /** Render a PDF's pages to one tall PNG via Browser Rendering. Null on failure. */ export async function rasterizePdf(env: Env, pdfBytes: Uint8Array, maxPages = 8): Promise { if (pdfBytes.length > MAX_PDF_BYTES) return null; diff --git a/src/backend/docs/doc-preview.ts b/src/backend/docs/doc-preview.ts new file mode 100644 index 00000000..d3e00eaa --- /dev/null +++ b/src/backend/docs/doc-preview.ts @@ -0,0 +1,107 @@ +/** + * @file docs/doc-preview.ts + * @description Turn a Drive-exportable file (Doc/Sheet/Slides/PDF) into a + * per-page visual preview the model can SEE: export to PDF, rasterize EACH page + * to a PNG (Browser Rendering), stash the PDF + every page image on R2 (served + * at /api/preview/:id, auto-expired after 48h), and — by default — ask an Ollama + * vision model to critique each page's formatting. + * + * Returns `{ pdf_url, pages: { pg_1: { image_url, vision_ai_notes }, ... }, meta }`. + * Best-effort end to end: a page with no rasterizer is skipped, and a missing + * Guardian route just omits `vision_ai_notes` — the create/preview that called + * this never fails because of it. + */ +import { getDocumentProxy } from "unpdf"; + +import type { DriveService } from "@/backend/mcp/services/drive"; +import { rasterizePdfPage } from "./browser-render"; +import { putPreview } from "./preview-store"; +import { critiquePageImage } from "./vision-critique"; + +export interface PagePreview { + image_url: string; + /** Ollama formatting critique — omitted when the vision route is unavailable. */ + vision_ai_notes?: string; +} + +export interface DocPreview { + /** Servable URL of the exported PDF (R2, 48h TTL). */ + pdf_url: string; + /** { pg_1: {...}, pg_2: {...} } — one entry per rendered page. */ + pages: Record; + meta: { pageCount: number; rendered: number; truncated: boolean; critique: boolean }; +} + +/** Default cap on pages rendered — each page is a Browser-Rendering call (+ a vision call). */ +export const DEFAULT_MAX_PREVIEW_PAGES = 5; + +/** + * Build a per-page preview for `fileId`. + * + * @param opts.maxPages cap on pages rendered (default {@link DEFAULT_MAX_PREVIEW_PAGES}) + * @param opts.critique run the Ollama formatting critique per page (default true) + * @param opts.sub acting user sub (unused for storage; kept for parity/logging) + */ +export async function buildDocPreview( + env: Env, + drive: DriveService, + fileId: string, + opts: { maxPages?: number; critique?: boolean; sub?: string } = {}, +): Promise { + const critique = opts.critique !== false; + const maxPages = opts.maxPages ?? DEFAULT_MAX_PREVIEW_PAGES; + + let pdf: Uint8Array; + try { + pdf = await drive.exportBinary(fileId, "application/pdf"); + } catch { + return null; + } + + // Stash the PDF itself (best-effort) so the model can pull the source too. + const runId = crypto.randomUUID(); + let pdfUrl = ""; + try { + pdfUrl = await putPreview(env, `${runId}.pdf`, pdf, "application/pdf"); + } catch { + pdfUrl = ""; + } + + let total: number; + try { + total = (await getDocumentProxy(pdf)).numPages; + } catch { + total = 1; + } + const count = Math.min(total, maxPages); + + // Render pages CONCURRENTLY — each page is an independent Browser-Rendering + // call (+ upload + optional critique); sequential would stack their latencies. + // Each page's pipeline is fully guarded so one bad page never breaks the rest + // or the create above it. + const rendered = await Promise.all( + Array.from({ length: count }, (_, i) => i + 1).map(async (p): Promise<[number, PagePreview] | null> => { + try { + const png = await rasterizePdfPage(env, pdf, p); + if (!png) return null; // Browser Rendering unavailable / page failed — skip it. + const imageUrl = await putPreview(env, `${runId}-p${p}.png`, png, "image/png"); + const entry: PagePreview = { image_url: imageUrl }; + if (critique) { + const notes = await critiquePageImage(env, png); + if (notes) entry.vision_ai_notes = notes; + } + return [p, entry]; + } catch { + return null; + } + }), + ); + const pages: Record = {}; + for (const r of rendered) if (r) pages[`pg_${r[0]}`] = r[1]; + + return { + pdf_url: pdfUrl, + pages, + meta: { pageCount: total, rendered: Object.keys(pages).length, truncated: total > count, critique }, + }; +} diff --git a/src/backend/docs/html-to-braille.ts b/src/backend/docs/html-to-braille.ts index 8c2fd03c..9feb5eef 100644 --- a/src/backend/docs/html-to-braille.ts +++ b/src/backend/docs/html-to-braille.ts @@ -22,9 +22,11 @@ const HEADING: Record = { h1: "HEADING_1", h2: "HEADING_2", h3: /** Collect a block element's plain text + inline style ranges (offsets within the block). */ function inlineWalk(node: Node, active: InlineRange["style"], acc: { text: string; ranges: InlineRange[] }): void { - // Text node + // Text node. Use `.text` (HTML entities decoded: "→", '→', &→&) + // NOT `.rawText` (raw source, entities intact) — otherwise encoded content the + // model hands us as HTML injects literal """/"'" into the doc. if ((node as any).nodeType === 3 || typeof (node as any).rawText === "string" && !(node as any).tagName) { - const t = (node as any).rawText ?? (node as any).text ?? ""; + const t = (node as any).text ?? (node as any).rawText ?? ""; const clean = t.replace(/\s+/g, " "); if (!clean) return; const start = acc.text.length; diff --git a/src/backend/docs/preview-store.ts b/src/backend/docs/preview-store.ts new file mode 100644 index 00000000..7a0c8b15 --- /dev/null +++ b/src/backend/docs/preview-store.ts @@ -0,0 +1,54 @@ +/** + * @file docs/preview-store.ts + * @description Short-lived doc-preview storage on a DEDICATED R2 bucket + * (R2_PREVIEWS_BUCKET → `google-workspace-mcp-previews`), served by the worker at + * `/api/preview/:id`. The bucket has a lifecycle rule ("expire-48h") that + * auto-deletes every object after 2 days, so TTL is automatic — no per-object + * bookkeeping. Objects are keyed by a self-describing id (`{uuid}-p1.png`, + * `{uuid}.pdf`) so the serve route can infer the content-type. No DB row. + * + * {@link purgeExpiredPreviews} is a belt-and-suspenders sweep on the hourly cron + * (exact 48h) in case the lifecycle rule is ever removed; the lifecycle rule is + * the primary mechanism. + */ +export const PREVIEW_TTL_MS = 48 * 60 * 60 * 1000; + +/** Store one preview object (PNG or PDF) and return its servable worker URL. */ +export async function putPreview( + env: Env, + id: string, + bytes: Uint8Array, + contentType: string, +): Promise { + await env.R2_PREVIEWS_BUCKET.put(id, bytes as unknown as ArrayBuffer, { + httpMetadata: { contentType }, + }); + return `/api/preview/${id}`; +} + +/** Content-type for a preview id, inferred from its extension. */ +export function previewContentType(id: string): string { + if (id.endsWith(".pdf")) return "application/pdf"; + if (id.endsWith(".png")) return "image/png"; + return "application/octet-stream"; +} + +/** + * Delete preview objects older than 48h. Returns the count removed. Belt for the + * bucket lifecycle rule; runs on the hourly cron and paginates the whole bucket. + */ +export async function purgeExpiredPreviews(env: Env): Promise { + const cutoff = Date.now() - PREVIEW_TTL_MS; + let removed = 0; + let cursor: string | undefined; + do { + const list = await env.R2_PREVIEWS_BUCKET.list({ cursor, limit: 1000 }); + const stale = list.objects.filter((o) => o.uploaded.getTime() < cutoff).map((o) => o.key); + for (const key of stale) { + await env.R2_PREVIEWS_BUCKET.delete(key); + removed++; + } + cursor = list.truncated ? list.cursor : undefined; + } while (cursor); + return removed; +} diff --git a/src/backend/docs/vision-critique.ts b/src/backend/docs/vision-critique.ts new file mode 100644 index 00000000..6c693454 --- /dev/null +++ b/src/backend/docs/vision-critique.ts @@ -0,0 +1,63 @@ +/** + * @file docs/vision-critique.ts + * @description Ask an Ollama Cloud vision model to critique a rendered document + * page's FORMATTING and visual presentation — is it professional / fun / + * creative, is the hierarchy clear, is spacing/alignment clean, does the style + * fit the content. Owns only the critique concern (prompt, image encoding, + * response parsing); the Guardian transport/auth lives in {@link guardianRun}. + * + * Best-effort: returns null when the route is unavailable or the call fails, so + * a preview still returns its images without the critique. + */ +import { guardianRun, extractChatText } from "@/backend/lib/guardian-ai"; +import { getSecret } from "@/backend/utils/secrets"; + +const DEFAULT_MODEL = "qwen3.5"; // Ollama Cloud vision model; override via OLLAMA_VISION_MODEL or the call arg. + +const CRITIQUE_PROMPT = + "You are a document design reviewer. This image is one rendered page of a document. " + + "Critique its FORMATTING and visual presentation only (not the writing). Judge: overall style " + + "and tone (professional, fun, creative, formal, playful, corporate, minimal), visual hierarchy, " + + "typography, spacing and whitespace, alignment, and whether the styling fits the apparent purpose. " + + "Call out concrete problems (crowding, misalignment, inconsistent headings, awkward wrapping, weak " + + "hierarchy) and what to change. If it looks clean and well-presented, say so and name the style. Be terse."; + +function pngToDataUrl(png: Uint8Array): string { + let s = ""; + const chunk = 0x8000; + for (let i = 0; i < png.length; i += chunk) s += String.fromCharCode(...png.subarray(i, i + chunk)); + return `data:image/png;base64,${btoa(s)}`; +} + +/** + * Critique one page image (`png` = raw PNG bytes for the page). Returns the + * model's notes, or null if the vision route is unavailable / the call fails. + * Never throws. + */ +export async function critiquePageImage(env: Env, png: Uint8Array, model?: string): Promise { + try { + const visionModel = model ?? (await getSecret(env, "OLLAMA_VISION_MODEL")) ?? DEFAULT_MODEL; + const result = await guardianRun(env, { + provider: "ollama", + model: visionModel, + importance: "low", + mode: "openai-compat", + input: { + messages: [ + { + role: "user", + content: [ + { type: "text", text: CRITIQUE_PROMPT }, + { type: "image_url", image_url: { url: pngToDataUrl(png) } }, + ], + }, + ], + }, + }); + if (!result) return null; + const text = extractChatText(result.body).trim(); + return text || null; + } catch { + return null; + } +} diff --git a/src/backend/gmail/__tests__/body-extract.test.ts b/src/backend/gmail/__tests__/body-extract.test.ts new file mode 100644 index 00000000..ec4bf4ff --- /dev/null +++ b/src/backend/gmail/__tests__/body-extract.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; + +import { extractBody, extractUrls } from "../body-extract"; + +const b64url = (s: string) => btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + +const payload = { + mimeType: "multipart/alternative", + parts: [ + { mimeType: "text/plain", body: { data: b64url("Hello world\nSee https://plain.example.com/x.") } }, + { mimeType: "text/html", body: { data: b64url('

Hi & Docs

') } }, + ], +}; + +describe("extractBody", () => { + it("defaults to decoded plain text", () => { + const r = extractBody(payload); + expect(r.bodyFormat).toBe("text"); + expect(r.body).toContain("Hello world"); + }); + + it("returns raw html when asked", () => { + const r = extractBody(payload, "html"); + expect(r.bodyFormat).toBe("html"); + expect(r.body).toContain(" { + const r = extractBody(payload, "rfc", b64url("From: a@b.com\r\n\r\nRaw body")); + expect(r.bodyFormat).toBe("rfc"); + expect(r.body).toContain("Raw body"); + }); + + it("falls back to text when html requested but absent", () => { + const plainOnly = { mimeType: "text/plain", body: { data: b64url("just text") } }; + expect(extractBody(plainOnly, "html").bodyFormat).toBe("text"); + }); + + it("always extracts urls (anchor label + bare link), deduped", () => { + const { urls } = extractBody(payload); + expect(urls).toContainEqual({ label: "Docs", href: "https://ex.com/a" }); + expect(urls).toContainEqual({ label: "https://plain.example.com/x", href: "https://plain.example.com/x" }); + }); +}); + +describe("extractUrls", () => { + it("skips mailto/# and trims trailing punctuation on bare urls", () => { + const urls = extractUrls('xm', "go to https://y.com), done"); + expect(urls).toEqual([{ label: "https://y.com", href: "https://y.com" }]); + }); +}); diff --git a/src/backend/gmail/__tests__/mime.test.ts b/src/backend/gmail/__tests__/mime.test.ts new file mode 100644 index 00000000..414763c3 --- /dev/null +++ b/src/backend/gmail/__tests__/mime.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; + +import { buildRawMessage } from "../mime"; + +const decode = (raw: string) => decodeURIComponent(escape(atob(raw.replace(/-/g, "+").replace(/_/g, "/")))); +const img = { filename: "logo.png", mimeType: "image/png", bytes: new Uint8Array([1, 2, 3]), contentId: "logo" }; +const file = { filename: "doc.pdf", mimeType: "application/pdf", bytes: new Uint8Array([4, 5, 6]) }; + +describe("buildRawMessage", () => { + it("embeds inline images as multipart/related with a Content-ID", () => { + const m = decode(buildRawMessage({ to: "a@b.com", subject: "s", text: "hi", html: '

', attachments: [img] })); + expect(m).toContain("multipart/related"); + expect(m).toContain("Content-ID: "); + expect(m).toContain("Content-Disposition: inline; filename=\"logo.png\""); + expect(m).not.toContain("multipart/mixed"); // no regular attachments + }); + + it("nests related inside mixed when there are BOTH inline images and file attachments", () => { + const m = decode(buildRawMessage({ to: "a@b.com", subject: "s", text: "hi", html: '', attachments: [img, file] })); + expect(m).toContain("multipart/mixed"); + expect(m).toContain("multipart/related"); + expect(m).toContain("Content-ID: "); + expect(m).toContain('Content-Disposition: attachment; filename="doc.pdf"'); + }); + + it("plain file attachment stays multipart/mixed (no related)", () => { + const m = decode(buildRawMessage({ to: "a@b.com", subject: "s", text: "hi", html: "

hi

", attachments: [file] })); + expect(m).toContain("multipart/mixed"); + expect(m).not.toContain("multipart/related"); + }); + + it("no attachments → alternative/plain body directly", () => { + const m = decode(buildRawMessage({ to: "a@b.com", subject: "s", text: "hi", html: "

hi

" })); + expect(m).not.toContain("multipart/mixed"); + expect(m).not.toContain("multipart/related"); + expect(m).toContain("multipart/alternative"); + }); +}); diff --git a/src/backend/gmail/__tests__/thread-pdf.test.ts b/src/backend/gmail/__tests__/thread-pdf.test.ts new file mode 100644 index 00000000..7cbe17fe --- /dev/null +++ b/src/backend/gmail/__tests__/thread-pdf.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; + +import { applyHighlights, buildThreadHtml, toRenderMessage } from "../thread-pdf"; + +const b64url = (s: string) => btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + +describe("applyHighlights", () => { + it("wraps matched terms in a colored mark (case-insensitive)", () => { + const out = applyHighlights("

Please send the Invoice for the invoice.

", [{ term: "invoice", color: "#ffe600" }]); + expect(out).toContain('Invoice'); + expect(out).toContain('invoice'); + }); + + it("never highlights inside tags/attributes (only text)", () => { + const out = applyHighlights('click', [{ term: "invoice", color: "#ff0" }]); + expect(out).toBe('click'); // href untouched, no text match + }); + + it("uses per-term colors and normalizes bare hex", () => { + const out = applyHighlights("refund and invoice", [ + { term: "refund", color: "d8b4ff" }, + { term: "invoice", color: "#ffe600" }, + ]); + expect(out).toContain('background-color:#d8b4ff'); + expect(out).toContain('background-color:#ffe600'); + }); + + it("ignores invalid colors and returns input when nothing valid", () => { + expect(applyHighlights("hello", [{ term: "hello", color: "notacolor" }])).toBe("hello"); + }); +}); + +describe("toRenderMessage", () => { + it("pulls headers + html body from a raw payload", () => { + const raw = { + payload: { + headers: [ + { name: "From", value: "Alice " }, + { name: "To", value: "b@y.com" }, + { name: "Date", value: "Mon, 1 Jan 2026 10:00:00 -0800" }, + { name: "Subject", value: "Hello" }, + ], + mimeType: "text/html", + body: { data: b64url("

Hi there

") }, + }, + }; + const m = toRenderMessage(raw); + expect(m.from).toBe("Alice "); + expect(m.subject).toBe("Hello"); + expect(m.bodyHtml).toContain("there"); + }); + + it("wraps plain-text bodies to preserve line breaks", () => { + const raw = { payload: { headers: [], mimeType: "text/plain", body: { data: b64url("line1\nline2") } } }; + expect(toRenderMessage(raw).bodyHtml).toContain("white-space:pre-wrap"); + }); +}); + +describe("buildThreadHtml", () => { + it("renders subject + each message and applies highlights to bodies", () => { + const html = buildThreadHtml( + "Q4 Invoices", + [{ from: "A", to: "B", date: "d", subject: "s", bodyHtml: "

invoice attached

" }], + [{ term: "invoice", color: "#ffe600" }], + ); + expect(html).toContain("Q4 Invoices"); + expect(html).toContain(' c.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +/** First leaf of the given MIME type anywhere in the tree. */ +function firstLeaf(payload: any, mimeType: string): string { + if (!payload) return ""; + if (payload.mimeType === mimeType && payload.body?.data) return decodeBase64Url(payload.body.data); + for (const p of payload.parts ?? []) { + const t = firstLeaf(p, mimeType); + if (t) return t; + } + return ""; +} + +/** Any decodable leaf (first one found) — last-ditch fallback. */ +function anyLeaf(payload: any): string { + if (!payload) return ""; + if (payload.body?.data && !payload.parts) return decodeBase64Url(payload.body.data); + for (const p of payload.parts ?? []) { + const t = anyLeaf(p); + if (t) return t; + } + return ""; +} + +/** Strip tags to readable text (entities decoded via node-html-parser). */ +export function htmlToText(html: string): string { + const root = parse(html, { comment: false }); + root.querySelectorAll("style,script").forEach((n) => n.remove()); + root.querySelectorAll("br").forEach((n) => n.replaceWith("\n")); + for (const tag of ["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr", "blockquote"]) { + root.querySelectorAll(tag).forEach((n) => n.insertAdjacentHTML("afterend", "\n")); + } + return root.textContent.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+\n/g, "\n").trim(); +} + +// Bare-URL matcher for plain text (trailing punctuation trimmed below). +const BARE_URL = /https?:\/\/[^\s<>()"']+/gi; + +/** + * Extract links as `{ label, href }`, deduped by href (first label wins). + * Anchors from the HTML part carry their visible text as the label; bare URLs + * in the plain text use the URL as the label. Skips empty/`#`/`mailto:` anchors. + */ +export function extractUrls(html: string, text: string): ExtractedUrl[] { + const seen = new Set(); + const out: ExtractedUrl[] = []; + const push = (href: string, label: string) => { + const h = href.trim(); + if (!/^https?:\/\//i.test(h) || seen.has(h)) return; + seen.add(h); + out.push({ label: label.trim() || h, href: h }); + }; + if (html) { + for (const a of parse(html, { comment: false }).querySelectorAll("a")) { + const href = a.getAttribute("href"); + if (href) push(href, a.textContent); + } + } + if (text) { + for (const m of text.matchAll(BARE_URL)) { + push(m[0].replace(/[.,;:!?)\]]+$/, ""), m[0].replace(/[.,;:!?)\]]+$/, "")); + } + } + return out; +} + +/** + * Extract the body in the requested format plus the URL list. + * + * @param payload the `payload` object from a Gmail `format=full` message + * @param format desired body format (default "text" — the most efficient) + * @param rawRfc optional raw RFC822 body (base64url `raw`); required for "rfc" + */ +export function extractBody(payload: any, format: BodyFormat = "text", rawRfc?: string): ExtractedBody { + const plain = firstLeaf(payload, "text/plain"); + const html = firstLeaf(payload, "text/html"); + const urls = extractUrls(html, plain || (html ? htmlToText(html) : "")); + + if (format === "rfc") { + if (rawRfc) return { body: decodeBase64Url(rawRfc), bodyFormat: "rfc", urls }; + // No raw available → fall back to the richest text we have. + return { body: html || plain || anyLeaf(payload), bodyFormat: html ? "html" : "text", urls }; + } + if (format === "html") { + if (html) return { body: html, bodyFormat: "html", urls }; + return { body: plain || anyLeaf(payload), bodyFormat: "text", urls }; + } + // "text" (default, most efficient) + const body = plain || (html ? htmlToText(html) : anyLeaf(payload)); + return { body, bodyFormat: "text", urls }; +} diff --git a/src/backend/gmail/mime.ts b/src/backend/gmail/mime.ts index 1dedfd44..dc2c6a0a 100644 --- a/src/backend/gmail/mime.ts +++ b/src/backend/gmail/mime.ts @@ -37,6 +37,12 @@ export interface MimeAttachment { filename: string; mimeType: string; bytes: Uint8Array; + /** + * When set, the part is embedded INLINE (multipart/related, `Content-Disposition: + * inline`, `Content-ID: `) so the HTML body can render it via + * ``. Otherwise it's a regular file attachment. + */ + contentId?: string; } export interface BuildMessageOptions { @@ -85,23 +91,54 @@ function bodyBlock(o: BuildMessageOptions, boundary: string): { contentType: str /** * Build the RFC822 message and base64url-encode it for Gmail's `raw` field. - * Structure: multipart/mixed[ alternative(text,html) | text , ...attachments ] - * when attachments exist; otherwise the alternative/plain body directly. + * + * Nesting (only the layers that are needed appear): + * multipart/mixed[ ← present iff there are file attachments + * multipart/related[ ← present iff there are inline images + * multipart/alternative(text, html) ← or a bare text part when no html + * ...inline image parts (Content-ID, inline) + * ] + * ...file attachment parts + * ] + * + * `Content-ID` inline parts let the HTML render ``. */ export function buildRawMessage(o: BuildMessageOptions): string { const headers = headerLines(o); const altBoundary = `alt_${crypto.randomUUID()}`; + const atts = o.attachments ?? []; + const inlineImgs = atts.filter((a) => a.contentId); + const files = atts.filter((a) => !a.contentId); - if (o.attachments && o.attachments.length > 0) { + const body = bodyBlock(o, altBoundary); + + // Content root = the body, wrapped in multipart/related when inline images exist. + let rootContentType = body.contentType; + let rootBody = body.body; + if (inlineImgs.length > 0) { + const rel = `rel_${crypto.randomUUID()}`; + const parts: string[] = [`--${rel}`, `Content-Type: ${body.contentType}`, "", body.body]; + for (const img of inlineImgs) { + parts.push( + `--${rel}`, + `Content-Type: ${img.mimeType}; name="${img.filename}"`, + "Content-Transfer-Encoding: base64", + `Content-ID: <${img.contentId}>`, + `Content-Disposition: inline; filename="${img.filename}"`, + "", + wrap76(bytesToBase64(img.bytes)), + ); + } + parts.push(`--${rel}--`); + rootContentType = `multipart/related; boundary="${rel}"`; + rootBody = parts.join("\r\n"); + } + + // Wrap in multipart/mixed when there are regular file attachments. + if (files.length > 0) { const mixed = `mixed_${crypto.randomUUID()}`; - const inner = bodyBlock(o, altBoundary); - const parts: string[] = [ - `--${mixed}`, - `Content-Type: ${inner.contentType}`, - "", - inner.body, - ]; - for (const att of o.attachments) { + const parts: string[] = [`--${mixed}`, `Content-Type: ${rootContentType}`, "", rootBody]; + for (const att of files) { parts.push( `--${mixed}`, `Content-Type: ${att.mimeType}; name="${att.filename}"`, @@ -116,7 +153,6 @@ export function buildRawMessage(o: BuildMessageOptions): string { return toBase64Url(mime); } - const inner = bodyBlock(o, altBoundary); - const mime = [...headers, `Content-Type: ${inner.contentType}`, "", inner.body].join("\r\n"); + const mime = [...headers, `Content-Type: ${rootContentType}`, "", rootBody].join("\r\n"); return toBase64Url(mime); } diff --git a/src/backend/gmail/outgoing-attachments.ts b/src/backend/gmail/outgoing-attachments.ts index 7a86952f..a0e529d3 100644 --- a/src/backend/gmail/outgoing-attachments.ts +++ b/src/backend/gmail/outgoing-attachments.ts @@ -22,10 +22,14 @@ import type { MimeAttachment } from "./mime"; /** Gmail's message ceiling in ENCODED (base64) bytes. */ export const GMAIL_MESSAGE_LIMIT = 25 * 1024 * 1024; -/** One attachment request. */ +/** + * One attachment request. `as:"inline"` embeds an image in the body via a + * `Content-ID` (provide `contentId` and reference it in the HTML as + * ``); inline items always attach (never link-fallback). + */ export type AttachmentSpec = - | { driveFileId: string; as?: "attach" | "link" } - | { blob: string; filename: string; mimeType?: string; as?: "attach" | "link" }; + | { driveFileId: string; as?: "attach" | "link" | "inline"; contentId?: string } + | { blob: string; filename: string; mimeType?: string; as?: "attach" | "link" | "inline"; contentId?: string }; /** Legacy inline-blob shape (the `blobs[]` tool param). */ export interface BlobInput { @@ -113,15 +117,19 @@ export async function resolveAttachments( report.push({ filename: name, source: "drive", ref: fileId, bytes, disposition, url }); }; + // ponytail: inline images always attach (a cid: ref can't point at a Drive link) + // and count toward the budget but don't trigger the over-limit fallback — a + // giant inline image could push the message over 25 MiB; acceptable (rare). for (const spec of specs) { + const inline = spec.as === "inline"; if ("driveFileId" in spec) { const meta = await drive.getContentMeta(spec.driveFileId); const enc = encodedSize(meta.size); if (spec.as === "link") { await linkDriveFile(spec.driveFileId, meta.name, meta.size, "linked-by-request", meta.webViewLink); - } else if (usedEncoded + enc <= GMAIL_MESSAGE_LIMIT) { + } else if (inline || usedEncoded + enc <= GMAIL_MESSAGE_LIMIT) { const bytes = await drive.downloadBytes(spec.driveFileId); - attachments.push({ filename: meta.name, mimeType: meta.mimeType, bytes }); + attachments.push({ filename: meta.name, mimeType: meta.mimeType, bytes, ...(inline ? { contentId: spec.contentId ?? crypto.randomUUID() } : {}) }); usedEncoded += enc; report.push({ filename: meta.name, source: "drive", ref: spec.driveFileId, bytes: meta.size, disposition: "attached" }); } else { @@ -134,7 +142,7 @@ export async function resolveAttachments( const bytes = base64ToBytes(spec.blob); const mimeType = spec.mimeType || "application/octet-stream"; const enc = encodedSize(bytes.length); - if (spec.as === "link" || usedEncoded + enc > GMAIL_MESSAGE_LIMIT) { + if (!inline && (spec.as === "link" || usedEncoded + enc > GMAIL_MESSAGE_LIMIT)) { // Blobs aren't in Drive — upload first, then share + link. const up = await drive.uploadBinary(spec.filename, mimeType, bytes); await drive.share(up.id, "reader", "anyone").catch(() => {}); @@ -149,7 +157,7 @@ export async function resolveAttachments( url, }); } else { - attachments.push({ filename: spec.filename, mimeType, bytes }); + attachments.push({ filename: spec.filename, mimeType, bytes, ...(inline ? { contentId: spec.contentId ?? crypto.randomUUID() } : {}) }); usedEncoded += enc; report.push({ filename: spec.filename, source: "blob", bytes: bytes.length, disposition: "attached" }); } diff --git a/src/backend/gmail/scheduled-email.ts b/src/backend/gmail/scheduled-email.ts index 9a8c5fdd..b7b4e736 100644 --- a/src/backend/gmail/scheduled-email.ts +++ b/src/backend/gmail/scheduled-email.ts @@ -123,6 +123,8 @@ export async function sweepScheduledEmails(env: Env, now: number = Date.now()): async send(row) { const s = row.spec; const res = await new GmailService(env, row.accountRef).send(s.to, s.subject, s.body ?? "", { + cc: s.cc, + bcc: s.bcc, html: s.html, markdown: s.markdown, attachments: s.attachments as never, diff --git a/src/backend/gmail/thread-pdf.ts b/src/backend/gmail/thread-pdf.ts new file mode 100644 index 00000000..d5c4094b --- /dev/null +++ b/src/backend/gmail/thread-pdf.ts @@ -0,0 +1,153 @@ +/** + * @file gmail/thread-pdf.ts + * @description Build a print-ready, Gmail-styled HTML document for one or more + * messages so it can be rendered to PDF (via Browser Rendering). Pure: turns raw + * Gmail `format=full` payloads into `{ from, to, date, subject, bodyHtml }` rows + * and lays them out as a thread. Header fields are HTML-escaped; message bodies + * are lightly sanitized (script/iframe/style stripped) but otherwise preserved. + */ +import { parse } from "node-html-parser"; + +import { extractBody } from "./body-extract"; + +export interface RenderMessage { + from: string; + to: string; + date: string; + subject: string; + bodyHtml: string; +} + +/** A term to highlight in message bodies + the background color to use. */ +export interface Highlight { + term: string; + /** Hex color, with or without leading `#` (e.g. "#ffe600" or "purple"→invalid, use hex). */ + color: string; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeColor(c: string): string { + return c.startsWith("#") ? c : `#${c}`; +} + +/** + * Wrap occurrences of each highlight `term` in `` with its color. Operates + * only on TEXT between tags (splits on `<...>`) so it never corrupts attributes + * or tag names, and uses ONE combined regex so freshly-inserted `` markup + * isn't re-matched. Case-insensitive; longer terms win when they overlap. + */ +export function applyHighlights(html: string, highlights: Highlight[]): string { + const valid = highlights + .filter((h) => h.term && /^#?[0-9a-fA-F]{3,8}$/.test(h.color)) + .sort((a, b) => b.term.length - a.term.length); + if (!valid.length) return html; + + const colorFor = new Map(valid.map((h) => [h.term.toLowerCase(), normalizeColor(h.color)])); + const re = new RegExp(`(${valid.map((h) => escapeRegex(h.term)).join("|")})`, "gi"); + const fallback = normalizeColor(valid[0].color); + + // Even indices are text between tags; odd indices are the tags themselves. + return html + .split(/(<[^>]+>)/) + .map((seg, i) => + i % 2 === 1 + ? seg + : seg.replace(re, (m) => `${m}`), + ) + .join(""); +} + +function escapeHtml(str: string): string { + return (str || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function headerValue(headers: any[], name: string): string { + return headers?.find((h) => h?.name?.toLowerCase() === name)?.value ?? ""; +} + +/** + * Strip active-content tags + handlers from a message body before it's rendered + * to PDF by Browser Rendering. Removes script/iframe/object/embed/link/meta/style, + * `on*` handlers, and `javascript:`/`vbscript:` URLs. + * + * ponytail: remote `` and CSS `url(…)` are left intact so the + * PDF matches the real email (inline logos etc). The headless render therefore + * fetches those URLs — same effect as opening the email (tracking pixels fire, + * outbound GETs originate from the render env). Acceptable: the user is rendering + * their OWN mail. Upgrade path if that matters: block remote fetches via a CSP on + * the rendered doc, or rewrite remote src to data: after fetching server-side. + */ +function sanitizeBody(html: string): string { + const root = parse(html, { comment: false }); + root.querySelectorAll("script,iframe,object,embed,link,meta,style").forEach((n) => n.remove()); + for (const el of root.querySelectorAll("*")) { + for (const attr of Object.keys(el.attributes)) { + if (/^on/i.test(attr)) el.removeAttribute(attr); + } + for (const urlAttr of ["href", "src", "xlink:href", "action", "formaction", "background"]) { + const v = el.getAttribute(urlAttr); + if (v && /^\s*(javascript|vbscript):/i.test(v)) el.removeAttribute(urlAttr); + } + } + return root.toString(); +} + +/** Convert a raw Gmail `format=full` message into a render row. */ +export function toRenderMessage(raw: any): RenderMessage { + const payload = raw?.payload ?? {}; + const headers: any[] = payload.headers ?? []; + const { body, bodyFormat } = extractBody(payload, "html"); + // A text-only message: preserve line breaks in the PDF. + const bodyHtml = + bodyFormat === "html" ? sanitizeBody(body) : `
${escapeHtml(body)}
`; + return { + from: headerValue(headers, "from"), + to: headerValue(headers, "to"), + date: headerValue(headers, "date"), + subject: headerValue(headers, "subject"), + bodyHtml, + }; +} + +/** Assemble the full Gmail-styled print HTML for a set of messages. */ +export function buildThreadHtml(threadSubject: string, messages: RenderMessage[], highlights: Highlight[] = []): string { + const messagesHtml = messages + .map( + (m) => ` + `, + ) + .join(""); + + return ` +

${escapeHtml(threadSubject)}

+ ${messagesHtml} + `.trim(); +} diff --git a/src/backend/lib/__tests__/guardian-ai.test.ts b/src/backend/lib/__tests__/guardian-ai.test.ts new file mode 100644 index 00000000..91eb5936 --- /dev/null +++ b/src/backend/lib/__tests__/guardian-ai.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +import { extractChatText, guardianRun } from "../guardian-ai"; + +describe("extractChatText", () => { + it("reads OpenAI-compatible responses", () => { + expect(extractChatText({ choices: [{ message: { content: "clean, professional" } }] })).toBe("clean, professional"); + }); + it("reads native Ollama /api/chat responses", () => { + expect(extractChatText({ message: { content: "crowded header" } })).toBe("crowded header"); + }); + it("reads native Ollama /api/generate responses", () => { + expect(extractChatText({ response: "playful style" })).toBe("playful style"); + }); + it("returns '' for an unrecognized shape", () => { + expect(extractChatText({ nope: true })).toBe(""); + }); +}); + +describe("guardianRun", () => { + afterEach(() => vi.restoreAllMocks()); + + it("returns null (never throws) when the token is absent", async () => { + const env = {} as any; // no WORKER_API_KEY + expect(await guardianRun(env, { provider: "ollama", model: "m", input: {} })).toBeNull(); + }); + + it("returns null (never throws) when fetch rejects", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); + const env = { WORKER_API_KEY: "tok" } as any; + expect(await guardianRun(env, { provider: "ollama", model: "m", input: {} })).toBeNull(); + }); + + it("posts to /api/ai-router/run with a bearer token and returns the parsed result", async () => { + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(JSON.stringify({ body: { response: "ok" } }), { status: 200 })); + const env = { WORKER_API_KEY: "tok" } as any; + const out = await guardianRun(env, { provider: "ollama", model: "qwen3.5", input: { messages: [] } }); + expect(out?.body).toEqual({ response: "ok" }); + const [url, init] = spy.mock.calls[0]; + expect(String(url)).toContain("/api/ai-router/run"); + expect((init as RequestInit).headers).toMatchObject({ authorization: "Bearer tok" }); + }); +}); diff --git a/src/backend/lib/guardian-ai.ts b/src/backend/lib/guardian-ai.ts new file mode 100644 index 00000000..60813857 --- /dev/null +++ b/src/backend/lib/guardian-ai.ts @@ -0,0 +1,96 @@ +/** + * @file lib/guardian-ai.ts + * @description Self-contained client for core-guardian's metered AI router + * (https://core-guardian.hacolby.workers.dev). One job: take a provider + model + + * OpenAI-compatible `input` and POST it to `/api/ai-router/run`, returning the + * provider's response. Auth is the Secret Store binding WORKER_API_KEY; identity + * (project / base URL) comes from the optional `GUARDIAN` env var. + * + * Every call is best-effort: token read, config parse, fetch, and JSON parse are + * all guarded, so this NEVER throws — it returns null on any failure and the + * caller degrades gracefully. This is the ONLY place that knows how to reach + * Guardian; feature modules (e.g. vision-critique) import `guardianRun` and stay + * free of transport/auth details. + */ +import { getWorkerApiKey } from "@/backend/utils/secrets"; + +const DEFAULT_BASE_URL = "https://core-guardian.hacolby.workers.dev"; +const DEFAULT_PROJECT = "google-workspace-mcp"; + +export type Importance = "low" | "medium" | "high"; + +export interface GuardianRunInput { + provider: string; + model: string; + /** Provider payload — OpenAI-compatible, e.g. `{ messages: [...] }`. */ + input: unknown; + importance?: Importance; + mode?: "gateway" | "gateway-custom" | "provider-sdk-gateway" | "openai-compat" | "native" | "gemini-native"; +} + +/** core-guardian `/api/ai-router/run` result. `body` is the raw provider response. */ +export interface GuardianRunResult { + request_uuid?: string; + status?: number; + provider?: string; + model?: string; + cost_usd?: number; + body?: unknown; +} + +interface GuardianConfig { + project?: string; + baseUrl?: string; +} + +function guardianConfig(env: Env): GuardianConfig { + const raw = (env as unknown as Record).GUARDIAN; + if (!raw) return {}; + try { + return (typeof raw === "string" ? JSON.parse(raw) : raw) as GuardianConfig; + } catch { + return {}; + } +} + +/** + * Route one AI call through core-guardian. Returns the parsed result, or null if + * Guardian is unreachable / unauthenticated / the call fails. Never throws. + */ +export async function guardianRun(env: Env, run: GuardianRunInput): Promise { + try { + const token = await getWorkerApiKey(env); + if (!token) return null; + const cfg = guardianConfig(env); + const baseUrl = (cfg.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + + const res = await fetch(`${baseUrl}/api/ai-router/run`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ + project: cfg.project ?? DEFAULT_PROJECT, + importance: run.importance ?? "low", + provider: run.provider, + model: run.model, + mode: run.mode, + stream: false, + input: run.input, + }), + }); + if (!res.ok) return null; + return (await res.json()) as GuardianRunResult; + } catch { + return null; + } +} + +/** Pull assistant text out of an OpenAI-compat OR native-Ollama chat response body. */ +export function extractChatText(body: unknown): string { + const b = body as any; + return ( + b?.choices?.[0]?.message?.content ?? // openai-compat + b?.message?.content ?? // native ollama /api/chat + b?.response ?? // native ollama /api/generate + (typeof b === "string" ? b : "") + ); +} diff --git a/src/backend/mcp/services/__tests__/gmail.test.ts b/src/backend/mcp/services/__tests__/gmail.test.ts index c95bde8d..24d1a509 100644 --- a/src/backend/mcp/services/__tests__/gmail.test.ts +++ b/src/backend/mcp/services/__tests__/gmail.test.ts @@ -33,6 +33,24 @@ describe("GmailService", () => { expect(typeof body.message.raw).toBe("string"); }); + it("createDraft threads Cc and Bcc into the raw MIME headers", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "draft1" }), { status: 200 })); + await new GmailService({} as any, "s1").createDraft("a@b.com", "Hi", "Body", { cc: "c@x.com", bcc: "d@y.com" }); + const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string); + const mime = decodeURIComponent(escape(atob((body.message.raw as string).replace(/-/g, "+").replace(/_/g, "/")))); + expect(mime).toContain("Cc: c@x.com"); + expect(mime).toContain("Bcc: d@y.com"); + }); + + it("createDraft supports multiple To recipients (comma-separated)", async () => { + const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "draft1" }), { status: 200 })); + await new GmailService({} as any, "s1").createDraft("a@b.com, c@d.com", "Hi", "Body", { cc: "e@f.com" }); + const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string); + const mime = decodeURIComponent(escape(atob((body.message.raw as string).replace(/-/g, "+").replace(/_/g, "/")))); + expect(mime).toContain("To: a@b.com, c@d.com"); + expect(mime).toContain("Cc: e@f.com"); + }); + function mockHeadersAndProfile(spy: ReturnType) { spy.mockImplementation(async (url: any) => { const u = String(url); diff --git a/src/backend/mcp/services/gmail.ts b/src/backend/mcp/services/gmail.ts index 7126dd11..7655c850 100644 --- a/src/backend/mcp/services/gmail.ts +++ b/src/backend/mcp/services/gmail.ts @@ -6,6 +6,10 @@ export type GmailMessage = { id: string; snippet: string; payload?: unknown }; /** Rich body + attachments accepted by send / draft helpers. */ export interface RichContent { + /** Cc recipients (comma-separated). Honored on drafts and sends alike. */ + cc?: string; + /** Bcc recipients (comma-separated). Honored on drafts and sends alike. */ + bcc?: string; /** Raw HTML body (sanitized + CSS-inlined for Gmail by the worker). */ html?: string; /** Markdown body (rendered + inlined for Gmail by the worker). */ @@ -66,6 +70,12 @@ export class GmailService { return googleJson>(this.env, this.sub, `${BASE}/messages/${id}?format=full`); } + /** Fetch the whole RFC822 message as a base64url string (`format=raw`). */ + async getMessageRfc(id: string): Promise { + const out = await googleJson<{ raw?: string }>(this.env, this.sub, `${BASE}/messages/${id}?format=raw`); + return out.raw ?? ""; + } + /** Fetch attachment bytes (base64url `data`) for a message part. */ async getAttachment(messageId: string, attachmentId: string): Promise<{ data: string; size: number }> { return googleJson<{ data: string; size: number }>( @@ -115,6 +125,8 @@ export class GmailService { const { raw, attachmentReport } = await buildOutgoingRaw(this.env, this.sub, { to, from: opts?.from, + cc: opts?.cc, + bcc: opts?.bcc, subject: finalSubject, inReplyTo, references, @@ -141,6 +153,8 @@ export class GmailService { ): Promise<{ id: string; message?: { id: string }; attachments: AttachmentReportItem[] }> { const { raw, attachmentReport } = await buildOutgoingRaw(this.env, this.sub, { to, + cc: opts?.cc, + bcc: opts?.bcc, subject, text: body, html: opts?.html, @@ -214,6 +228,8 @@ export class GmailService { const { raw, attachmentReport } = await buildOutgoingRaw(this.env, this.sub, { to: recipients.join(", "), + cc: opts?.cc, + bcc: opts?.bcc, subject, inReplyTo: messageIdHeader || undefined, references: references || undefined, diff --git a/src/backend/mcp/tools.ts b/src/backend/mcp/tools.ts index c31b00b8..87c5eca3 100644 --- a/src/backend/mcp/tools.ts +++ b/src/backend/mcp/tools.ts @@ -29,11 +29,14 @@ import { captureAccount, captureAllAccounts } from "@/backend/gmail/capture-serv import { searchGmail } from "@/backend/gmail/search-service"; import { uploadMessageAttachments, subjectFromPayload } from "@/backend/gmail/attachment-drive"; import { attachmentManifest } from "@/backend/gmail/attachments"; +import { extractBody, type BodyFormat } from "@/backend/gmail/body-extract"; +import { parseRawMessage } from "@/backend/gmail/parse-message"; import { walkFolder, auditSharing, applySharingActions, DEFAULT_MAX_NODES } from "@/backend/drive/sharing-audit"; import { buildFolderTree } from "@/backend/drive/folder-tree"; import { runCodeMode, runCodeModeSearch } from "./code-mode"; import { deployMergedVersion, rollbackDeployment, deploymentHistory } from "@/backend/appscript/deploy-pipeline"; import { resolveStandingScript, setStandingScript } from "@/backend/appscript/standing"; +import { resolveGasScript, setGasScript } from "@/backend/appscript/gas-projects"; import { buildCodeTextRequests, CODE_THEMES } from "@/backend/docs/code-format"; import { findLastTable } from "@/backend/docs/locate"; import { buildFillRequests, buildTableStyleRequests } from "@/backend/docs/table-format"; @@ -45,7 +48,10 @@ import { docBodyContent } from "@/backend/docs/locate"; import { analyzePages, collectHeadings, pdfToPages } from "@/backend/docs/render-qc"; import { SCRIPT_SCAFFOLDS } from "@/backend/docs/appscript-scaffolds"; import { buildTemplate, type BindConfig } from "@/backend/appscript-templates"; -import { rasterizePdf, storeRender } from "@/backend/docs/browser-render"; +import { rasterizePdf, storeRender, renderHtmlToPdf } from "@/backend/docs/browser-render"; +import { buildDocPreview, type DocPreview } from "@/backend/docs/doc-preview"; +import { putPreview } from "@/backend/docs/preview-store"; +import { buildThreadHtml, toRenderMessage } from "@/backend/gmail/thread-pdf"; import { DriveService, FOLDER_MIME, escapeDriveQuery, type DriveFile } from "./services/drive"; import { extractGoogleId } from "@/backend/google/core/ids"; import { DocsService } from "./services/docs"; @@ -103,6 +109,20 @@ const asUser = { ), }; +/** + * One-or-more email recipients: a single/comma-separated string OR an array of + * addresses. Normalize with {@link addrList} before handing to the Gmail service + * (which builds the RFC 2822 To/Cc/Bcc header from a comma-separated string). + */ +const recipients = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]); + +/** Normalize a recipients value (string | string[]) to a comma-separated header string. */ +function addrList(v: string | string[] | undefined): string | undefined { + if (v == null) return undefined; + const s = Array.isArray(v) ? v.filter(Boolean).join(", ") : v; + return s.trim() ? s : undefined; +} + /** * Rich-body + attachment fields mixed into the Gmail compose tools. The worker * owns formatting: it inlines CSS (Gmail ignores