Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[submodule "gas"]
path = gas
url = https://github.com/jmbish04/core-template-gas.git
branch = master
1 change: 1 addition & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@
"**/.next",
"**/.astro",
"**/.netlify",
"gas/**",
],
}
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --log-failed` for just the failing step's log (full log: `gh run view <id> --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/<name>/`. 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:<project>:<account>`), 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)

Expand Down Expand Up @@ -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 `<mark>`, 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
Expand Down
1 change: 1 addition & 0 deletions gas
Submodule gas added at e31640
2 changes: 2 additions & 0 deletions src/_worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -259,6 +260,7 @@ function makeHandler(): ExportedHandler<Env> {
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
})(),
);
},
Expand Down
2 changes: 2 additions & 0 deletions src/backend/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions src/backend/api/routes/preview.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
});
76 changes: 76 additions & 0 deletions src/backend/appscript/gas-projects.ts
Original file line number Diff line number Diff line change
@@ -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/<name>/`; 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:<project>:<accountEmail>` → `{ 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<string, string>;
}

/**
* 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<string, GasProject> = {
"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<void> {
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 } });
}
2 changes: 2 additions & 0 deletions src/backend/db/schemas/scheduled-emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/backend/docs/__tests__/html-to-braille.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,10 @@ describe("htmlToRequests", () => {
it("returns nothing for empty html", () => {
expect(htmlToRequests("<div></div>")).toEqual([]);
});

it("decodes HTML entities in injected text (no literal &quot;/&#39;/&amp;)", () => {
const reqs = htmlToRequests('<p>He said &quot;hi&quot; &amp; it&#39;s fine</p>');
const insert = reqs[0] as any;
expect(insert.insertText.text).toBe('He said "hi" & it\'s fine\n');
});
});
35 changes: 35 additions & 0 deletions src/backend/docs/__tests__/preview-store.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
81 changes: 81 additions & 0 deletions src/backend/docs/browser-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,87 @@ const d=document.createElement('div');d.id='ready';document.body.appendChild(d);
</script></body></html>`;
}

/**
* 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<Uint8Array | null> {
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 `<!doctype html><html><head><meta charset="utf-8">
<script src="${PDFJS}/pdf.min.js"></script></head>
<body style="margin:0;background:#fff"><div id="pages"></div>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc="${PDFJS}/pdf.worker.min.js";
(async()=>{try{
const data=Uint8Array.from(atob("${b64}"),c=>c.charCodeAt(0));
const pdf=await pdfjsLib.getDocument({data}).promise;
const n=Math.max(1,Math.min(${pageNum},pdf.numPages));
const p=await pdf.getPage(n);const vp=p.getViewport({scale:${scale}});
const c=document.createElement('canvas');c.width=vp.width;c.height=vp.height;
document.getElementById('pages').appendChild(c);
await p.render({canvasContext:c.getContext('2d'),viewport:vp}).promise;
}catch(e){document.body.setAttribute('data-error',String(e));}
const d=document.createElement('div');d.id='ready';document.body.appendChild(d);
})();
</script></body></html>`;
}

/**
* 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<Uint8Array | null> {
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<Uint8Array | null> {
if (pdfBytes.length > MAX_PDF_BYTES) return null;
Expand Down
Loading
Loading