diff --git a/docs/plans/2026-03-10-email-outreach-features.md b/docs/plans/2026-03-10-email-outreach-features.md new file mode 100644 index 0000000..0b936d2 --- /dev/null +++ b/docs/plans/2026-03-10-email-outreach-features.md @@ -0,0 +1,2175 @@ +# Email Outreach Features Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add email outreach capabilities to gdrive MCP — template rendering, batch sending, dry-run preview, sheet-as-records, and tracking infrastructure. + +**Architecture:** Thin wrappers over existing `buildEmailMessage()` + `sendMessage()` for P0. New CF Worker HTTP routes + KV schema for P1 tracking. All new operations follow the existing SDK pattern: types in module, function in module, spec in `src/sdk/spec.ts`, runtime registration in `src/sdk/runtime.ts`. + +**Tech Stack:** TypeScript ES2022, Cloudflare Workers, KV, Gmail API, Sheets API, Jest + +--- + +## Phase 1 — P0 Campaign Essentials + +### Task 1: `sheets.readAsRecords` — Read Sheet as Keyed Objects + +**Linear Issue:** `sheets.readAsRecords` — read Sheet as array of keyed objects (2 pts, High) + +**Files:** +- Modify: `src/modules/sheets/read.ts` (add function + type after line 89) +- Modify: `src/modules/sheets/index.ts` (add export) +- Modify: `src/sdk/spec.ts` (add spec entry in sheets section) +- Modify: `src/sdk/runtime.ts` (add runtime registration in sheets section, after line 97) +- Create: `src/modules/sheets/__tests__/readAsRecords.test.ts` + +**Step 1: Write the failing test** + +Create `src/modules/sheets/__tests__/readAsRecords.test.ts`: + +```typescript +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { readAsRecords } from '../read.js'; + +describe('readAsRecords', () => { + let mockContext: any; + let mockSheetsApi: any; + + beforeEach(() => { + mockSheetsApi = { + spreadsheets: { + values: { + get: jest.fn(), + }, + }, + }; + mockContext = { + sheets: mockSheetsApi, + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { track: jest.fn() }, + startTime: Date.now(), + }; + }); + + test('zips headers with rows into keyed objects', async () => { + mockSheetsApi.spreadsheets.values.get.mockResolvedValue({ + data: { + range: 'Contacts!A1:C3', + values: [ + ['name', 'email', 'status'], + ['Amy', 'amy@example.com', 'pending'], + ['Bob', 'bob@example.com', 'sent'], + ], + }, + }); + + const result = await readAsRecords({ + spreadsheetId: 'abc123', + range: 'Contacts!A:C', + }, mockContext); + + expect(result.records).toEqual([ + { name: 'Amy', email: 'amy@example.com', status: 'pending' }, + { name: 'Bob', email: 'bob@example.com', status: 'sent' }, + ]); + expect(result.count).toBe(2); + expect(result.columns).toEqual(['name', 'email', 'status']); + }); + + test('returns empty records for header-only sheet', async () => { + mockSheetsApi.spreadsheets.values.get.mockResolvedValue({ + data: { + range: 'Sheet1!A1:B1', + values: [['name', 'email']], + }, + }); + + const result = await readAsRecords({ + spreadsheetId: 'abc123', + range: 'Sheet1!A:B', + }, mockContext); + + expect(result.records).toEqual([]); + expect(result.count).toBe(0); + expect(result.columns).toEqual(['name', 'email']); + }); + + test('maps sparse rows to null for missing values', async () => { + mockSheetsApi.spreadsheets.values.get.mockResolvedValue({ + data: { + range: 'Sheet1!A1:C2', + values: [ + ['name', 'email', 'status'], + ['Amy'], // only 1 cell instead of 3 + ], + }, + }); + + const result = await readAsRecords({ + spreadsheetId: 'abc123', + range: 'Sheet1!A:C', + }, mockContext); + + expect(result.records).toEqual([ + { name: 'Amy', email: null, status: null }, + ]); + }); + + test('throws on empty sheet (no headers)', async () => { + mockSheetsApi.spreadsheets.values.get.mockResolvedValue({ + data: { range: 'Sheet1!A1:A1', values: [] }, + }); + + await expect(readAsRecords({ + spreadsheetId: 'abc123', + range: 'Sheet1!A:C', + }, mockContext)).rejects.toThrow('No header row found'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npx jest src/modules/sheets/__tests__/readAsRecords.test.ts --no-coverage` +Expected: FAIL — `readAsRecords` is not exported from `../read.js` + +**Step 3: Add types and implement `readAsRecords` in `src/modules/sheets/read.ts`** + +Add after the existing `ReadSheetResult` interface (line 23): + +```typescript +/** + * Result of reading sheet as keyed records + */ +export interface ReadAsRecordsResult { + records: Record[]; + count: number; + columns: string[]; +} +``` + +Add after the existing `readSheet` function (after line 89): + +```typescript +/** + * Read sheet data as an array of keyed objects. + * First row is treated as headers (keys). Each subsequent row becomes an object. + * + * @param options Read parameters (same as readSheet) + * @param context Sheets API context + * @returns Array of keyed objects with column names as keys + */ +export async function readAsRecords( + options: ReadSheetOptions, + context: SheetsContext +): Promise { + const { values } = await readSheet(options, context); + + if (!values || values.length === 0) { + throw new Error('No header row found in the specified range'); + } + + const columns = (values[0] as string[]).map(h => String(h)); + const rows = values.slice(1); + + const records = rows.map(row => { + const record: Record = {}; + for (let i = 0; i < columns.length; i++) { + record[columns[i]] = i < row.length ? row[i] : null; + } + return record; + }); + + return { records, count: records.length, columns }; +} +``` + +**Step 4: Export from `src/modules/sheets/index.ts`** + +Update the read exports section (line 38-42): + +```typescript +export { + readSheet, + readAsRecords, + type ReadSheetOptions, + type ReadSheetResult, + type ReadAsRecordsResult, +} from './read.js'; +``` + +**Step 5: Add SDK spec entry in `src/sdk/spec.ts`** + +Add after the `appendRows` spec entry in the sheets section: + +```typescript + readAsRecords: { + signature: "readAsRecords(options: { spreadsheetId: string, range: string, sheetName?: string }): Promise<{ records: Record[], count: number, columns: string[] }>", + description: "Read a Sheet range as an array of keyed objects. First row is treated as header row (keys). Each subsequent row becomes an object with header names as keys. Sparse rows map missing values to null.", + example: "const { records } = await sdk.sheets.readAsRecords({ spreadsheetId: 'abc123', range: 'Contacts!A:G' });\nrecords.forEach(r => console.log(r.name, r.email));", + params: { + spreadsheetId: "string (required) — Google Sheets spreadsheet ID", + range: "string (required) — A1 notation range (e.g., 'Contacts!A:G')", + sheetName: "string (optional) — sheet name if not in range", + }, + returns: "{ records: Record[], count: number, columns: string[] }", + }, +``` + +**Step 6: Add runtime registration in `src/sdk/runtime.ts`** + +Add after the `appendRows` entry (after line 97): + +```typescript + readAsRecords: limiter.wrap('sheets', async (opts: unknown) => { + const { readAsRecords } = await import('../modules/sheets/index.js'); + return readAsRecords(opts as Parameters[0], context); + }), +``` + +**Step 7: Run tests to verify they pass** + +Run: `npx jest src/modules/sheets/__tests__/readAsRecords.test.ts --no-coverage` +Expected: PASS (all 4 tests) + +**Step 8: Run type-check** + +Run: `npm run type-check` +Expected: PASS + +**Step 9: Commit** + +```bash +git add src/modules/sheets/read.ts src/modules/sheets/index.ts src/sdk/spec.ts src/sdk/runtime.ts src/modules/sheets/__tests__/readAsRecords.test.ts +git commit -m "feat(sheets): add readAsRecords operation — read sheet as keyed objects" +``` + +--- + +### Task 2: `renderTemplate()` — Shared Template Rendering Utility + +**Linear Issue:** (Part of `gmail.sendFromTemplate` — template rendering (subject + body) + send, 3 pts, Urgent) + +**Files:** +- Create: `src/modules/gmail/templates.ts` +- Create: `src/modules/gmail/__tests__/templates.test.ts` +- Modify: `src/modules/gmail/index.ts` (add export) + +**Step 1: Write the failing test** + +Create `src/modules/gmail/__tests__/templates.test.ts`: + +```typescript +import { describe, test, expect } from '@jest/globals'; +import { renderTemplate } from '../templates.js'; + +describe('renderTemplate', () => { + test('replaces {{variable}} placeholders in text', () => { + const result = renderTemplate( + 'Hey {{firstName}}, this is about {{topic}}.', + { firstName: 'Amy', topic: 'claims' } + ); + expect(result).toBe('Hey Amy, this is about claims.'); + }); + + test('replaces variables in subject line', () => { + const result = renderTemplate( + '{{firstName}}, quick follow-up on {{topic}}', + { firstName: 'Amy', topic: 'claims' } + ); + expect(result).toBe('Amy, quick follow-up on claims'); + }); + + test('throws on missing variable', () => { + expect(() => renderTemplate( + 'Hey {{firstName}}, your {{plan}} is ready.', + { firstName: 'Amy' } + )).toThrow("Missing template variable: 'plan'"); + }); + + test('handles multiple occurrences of same variable', () => { + const result = renderTemplate( + '{{name}} said hi. Hi {{name}}!', + { name: 'Amy' } + ); + expect(result).toBe('Amy said hi. Hi Amy!'); + }); + + test('HTML-escapes values when isHtml is true', () => { + const result = renderTemplate( + '

Hey {{name}}, check this: {{note}}

', + { name: 'Amy & Co', note: '' }, + { isHtml: true } + ); + expect(result).toBe('

Hey Amy & Co, check this: <script>alert("xss")</script>

'); + }); + + test('does NOT HTML-escape values when isHtml is false', () => { + const result = renderTemplate( + 'Hey {{name}}', + { name: 'Amy & Co' }, + { isHtml: false } + ); + expect(result).toBe('Hey Amy & Co'); + }); + + test('handles template with no variables', () => { + const result = renderTemplate( + 'No variables here.', + {} + ); + expect(result).toBe('No variables here.'); + }); + + test('handles whitespace in variable names', () => { + const result = renderTemplate( + 'Hey {{ firstName }}', + { firstName: 'Amy' } + ); + expect(result).toBe('Hey Amy'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npx jest src/modules/gmail/__tests__/templates.test.ts --no-coverage` +Expected: FAIL — `../templates.js` does not exist + +**Step 3: Implement `renderTemplate` in `src/modules/gmail/templates.ts`** + +```typescript +/** + * Gmail template rendering utilities + * + * Shared by sendFromTemplate, sendBatch, and dryRun operations. + * Handles {{variable}} replacement with optional HTML escaping. + */ + +/** + * Escape HTML special characters to prevent XSS when injecting into HTML templates. + */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Render a template string by replacing {{variable}} placeholders with values. + * + * @param template Template string with {{variable}} placeholders + * @param variables Key-value pairs for replacement + * @param options.isHtml When true, HTML-escapes variable values before insertion + * @returns Rendered string with all placeholders replaced + * @throws Error if a placeholder has no matching variable + */ +export function renderTemplate( + template: string, + variables: Record, + options: { isHtml?: boolean } = {} +): string { + const { isHtml = false } = options; + + return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key: string) => { + if (!(key in variables)) { + throw new Error(`Missing template variable: '${key}'`); + } + const value = variables[key]; + return isHtml ? escapeHtml(value) : value; + }); +} +``` + +**Step 4: Export from `src/modules/gmail/index.ts`** + +Add after the send exports section (after line 84): + +```typescript +// Template utilities +export { renderTemplate } from './templates.js'; +``` + +**Step 5: Run tests to verify they pass** + +Run: `npx jest src/modules/gmail/__tests__/templates.test.ts --no-coverage` +Expected: PASS (all 8 tests) + +**Step 6: Commit** + +```bash +git add src/modules/gmail/templates.ts src/modules/gmail/__tests__/templates.test.ts src/modules/gmail/index.ts +git commit -m "feat(gmail): add renderTemplate utility for {{variable}} replacement" +``` + +--- + +### Task 3: `gmail.dryRun` — Preview Rendered Email Without Sending + +**Linear Issue:** `gmail.dryRun` — preview rendered email without sending (1 pt, High) + +**Files:** +- Modify: `src/modules/gmail/compose.ts` (add dryRunMessage function) +- Modify: `src/modules/gmail/types.ts` (add types) +- Modify: `src/modules/gmail/index.ts` (add exports) +- Modify: `src/sdk/spec.ts` (add spec entry) +- Modify: `src/sdk/runtime.ts` (add runtime registration) +- Create: `src/modules/gmail/__tests__/dryRun.test.ts` + +**Step 1: Write the failing test** + +Create `src/modules/gmail/__tests__/dryRun.test.ts`: + +```typescript +import { describe, test, expect } from '@jest/globals'; +import { dryRunMessage } from '../compose.js'; + +describe('dryRunMessage', () => { + test('renders template variables in subject and body', () => { + const result = dryRunMessage({ + to: ['amy@example.com'], + subject: '{{firstName}}, quick follow-up', + template: 'Hey {{firstName}},\n\n{{personalNote}}\n\nBest regards', + variables: { firstName: 'Amy', personalNote: 'We met at the conference' }, + }); + + expect(result.to).toEqual(['amy@example.com']); + expect(result.subject).toBe('Amy, quick follow-up'); + expect(result.body).toBe('Hey Amy,\n\nWe met at the conference\n\nBest regards'); + expect(result.isHtml).toBe(false); + expect(result.wouldSend).toBe(false); + }); + + test('HTML-escapes variables when isHtml is true', () => { + const result = dryRunMessage({ + to: ['bob@example.com'], + subject: 'Hello {{name}}', + template: '

{{note}}

', + variables: { name: 'Bob', note: 'Tom & Jerry ' }, + isHtml: true, + }); + + expect(result.subject).toBe('Hello Bob'); + expect(result.body).toBe('

Tom & Jerry <friends>

'); + expect(result.isHtml).toBe(true); + }); + + test('throws on missing template variable', () => { + expect(() => dryRunMessage({ + to: ['amy@example.com'], + subject: '{{firstName}}, follow-up', + template: 'Hey {{firstName}}, about {{topic}}', + variables: { firstName: 'Amy' }, + })).toThrow("Missing template variable: 'topic'"); + }); + + test('validates email addresses', () => { + expect(() => dryRunMessage({ + to: ['not-an-email'], + subject: 'Test', + template: 'Body', + variables: {}, + })).toThrow('Invalid email address in to'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npx jest src/modules/gmail/__tests__/dryRun.test.ts --no-coverage` +Expected: FAIL — `dryRunMessage` is not exported from `../compose.js` + +**Step 3: Add types in `src/modules/gmail/types.ts`** + +Add at the end of the file (after line 619): + +```typescript +// ============================================================================ +// Template & Outreach Operations +// ============================================================================ + +/** + * Options for dry-running a templated email (preview without sending) + */ +export interface DryRunOptions { + /** Recipient email addresses */ + to: string[]; + /** Subject template with {{variable}} placeholders */ + subject: string; + /** Body template with {{variable}} placeholders */ + template: string; + /** Variables to replace in subject and body */ + variables: Record; + /** Whether the template is HTML (default: false) */ + isHtml?: boolean; + /** CC recipients */ + cc?: string[]; + /** BCC recipients */ + bcc?: string[]; +} + +/** + * Result of a dry run — rendered email without sending + */ +export interface DryRunResult { + to: string[]; + cc?: string[]; + bcc?: string[]; + subject: string; + body: string; + isHtml: boolean; + wouldSend: false; +} + +/** + * Options for sending a templated email + */ +export interface SendFromTemplateOptions { + /** Recipient email addresses */ + to: string[]; + /** Subject template with {{variable}} placeholders */ + subject: string; + /** Body template with {{variable}} placeholders */ + template: string; + /** Variables to replace in subject and body */ + variables: Record; + /** Whether the template is HTML (default: false) */ + isHtml?: boolean; + /** CC recipients */ + cc?: string[]; + /** BCC recipients */ + bcc?: string[]; + /** Send from a different email address (send-as alias) */ + from?: string; +} + +/** + * Result of sending a templated email + */ +export interface SendFromTemplateResult { + messageId: string; + threadId: string; + rendered: true; +} + +/** + * Per-recipient entry in a batch send + */ +export interface BatchRecipient { + /** Recipient email address */ + to: string; + /** Per-recipient template variables */ + variables: Record; + /** CC recipients for this message */ + cc?: string[]; + /** BCC recipients for this message */ + bcc?: string[]; +} + +/** + * Options for batch sending templated emails + */ +export interface BatchSendOptions { + /** Body template with {{variable}} placeholders */ + template: string; + /** Subject template with {{variable}} placeholders */ + subject: string; + /** Array of recipients with per-recipient variables */ + recipients: BatchRecipient[]; + /** Delay between sends in ms (default: 5000) */ + delayMs?: number; + /** Whether the template is HTML (default: false) */ + isHtml?: boolean; + /** Send from a different email address (send-as alias) */ + from?: string; + /** When true, return previews without sending (default: false) */ + dryRun?: boolean; +} + +/** + * Per-recipient result in a batch send + */ +export interface BatchSendItemResult { + to: string; + messageId: string; + threadId: string; + status: 'sent' | 'failed'; + error?: string; +} + +/** + * Per-recipient preview in a batch dry run + */ +export interface BatchPreviewItem { + to: string; + subject: string; + body: string; + wouldSend: false; +} + +/** + * Result of a batch send operation + */ +export interface BatchSendResult { + sent: number; + failed: number; + results?: BatchSendItemResult[]; + previews?: BatchPreviewItem[]; +} +``` + +**Step 4: Implement `dryRunMessage` in `src/modules/gmail/compose.ts`** + +Add these imports at the top (after existing imports): + +```typescript +import type { DryRunOptions, DryRunResult } from './types.js'; +import { renderTemplate } from './templates.js'; +import { validateAndSanitizeRecipients } from './utils.js'; +``` + +Add after the `createDraft` function (after line 68): + +```typescript +/** + * Preview a templated email without sending. + * Renders variables in subject and body, validates recipients, returns the result. + * + * @param options Template and variables + * @returns Rendered email preview with wouldSend: false + */ +export function dryRunMessage(options: DryRunOptions): DryRunResult { + const { to, subject, template, variables, isHtml = false, cc, bcc } = options; + + // Validate recipients + validateAndSanitizeRecipients(to, 'to'); + if (cc) validateAndSanitizeRecipients(cc, 'cc'); + if (bcc) validateAndSanitizeRecipients(bcc, 'bcc'); + + const renderedSubject = renderTemplate(subject, variables); + const renderedBody = renderTemplate(template, variables, { isHtml }); + + return { + to, + ...(cc ? { cc } : {}), + ...(bcc ? { bcc } : {}), + subject: renderedSubject, + body: renderedBody, + isHtml, + wouldSend: false, + }; +} +``` + +**Step 5: Export from `src/modules/gmail/index.ts`** + +Update the compose exports section and add types: + +```typescript +// Compose operations +export { createDraft, dryRunMessage } from './compose.js'; +``` + +Add to the type exports block: + +```typescript + // Template & Outreach types + DryRunOptions, + DryRunResult, + SendFromTemplateOptions, + SendFromTemplateResult, + BatchRecipient, + BatchSendOptions, + BatchSendItemResult, + BatchPreviewItem, + BatchSendResult, +``` + +**Step 6: Add SDK spec entry in `src/sdk/spec.ts`** + +Add after the `archiveMessage` spec entry in the gmail section: + +```typescript + dryRun: { + signature: "dryRun(options: { to: string[], subject: string, template: string, variables: Record, isHtml?: boolean, cc?: string[], bcc?: string[] }): Promise<{ to, subject, body, isHtml, wouldSend: false }>", + description: "Preview a templated email without sending. Renders {{variable}} placeholders in both subject and body. Use to review before sending.", + example: "const preview = await sdk.gmail.dryRun({\n to: ['amy@example.com'],\n subject: '{{firstName}}, quick follow-up',\n template: 'Hey {{firstName}},\\n\\n{{personalNote}}',\n variables: { firstName: 'Amy', personalNote: 'Great meeting you' },\n});\nconsole.log(preview.subject, preview.body);", + params: { + to: "string[] (required) — recipient emails", + subject: "string (required) — subject template with {{variable}} placeholders", + template: "string (required) — body template with {{variable}} placeholders", + variables: "Record (required) — key-value pairs for placeholder replacement", + isHtml: "boolean (optional, default false) — whether template is HTML (enables HTML escaping of variable values)", + cc: "string[] (optional)", + bcc: "string[] (optional)", + }, + returns: "{ to, cc?, bcc?, subject, body, isHtml, wouldSend: false }", + }, +``` + +**Step 7: Add runtime registration in `src/sdk/runtime.ts`** + +Add after the `archiveMessage` entry in the gmail section (after line 230): + +```typescript + dryRun: limiter.wrap('gmail', async (opts: unknown) => { + const { dryRunMessage } = await import('../modules/gmail/index.js'); + return dryRunMessage(opts as Parameters[0]); + }), +``` + +Note: `dryRunMessage` does NOT need context — it's a pure function (no API calls). + +**Step 8: Run tests to verify they pass** + +Run: `npx jest src/modules/gmail/__tests__/dryRun.test.ts --no-coverage` +Expected: PASS (all 4 tests) + +**Step 9: Run type-check and full test suite** + +Run: `npm run type-check && npm test` +Expected: PASS + +**Step 10: Commit** + +```bash +git add src/modules/gmail/compose.ts src/modules/gmail/types.ts src/modules/gmail/index.ts src/modules/gmail/__tests__/dryRun.test.ts src/sdk/spec.ts src/sdk/runtime.ts +git commit -m "feat(gmail): add dryRun operation — preview templated email without sending" +``` + +--- + +### Task 4: `gmail.sendFromTemplate` — Template Rendering + Send + +**Linear Issue:** `gmail.sendFromTemplate` — template rendering (subject + body) + send (3 pts, Urgent) + +**Files:** +- Modify: `src/modules/gmail/send.ts` (add sendFromTemplate function) +- Modify: `src/modules/gmail/index.ts` (add export) +- Modify: `src/sdk/spec.ts` (add spec entry) +- Modify: `src/sdk/runtime.ts` (add runtime registration) +- Create: `src/modules/gmail/__tests__/sendFromTemplate.test.ts` + +**Step 1: Write the failing test** + +Create `src/modules/gmail/__tests__/sendFromTemplate.test.ts`: + +```typescript +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { sendFromTemplate } from '../send.js'; + +describe('sendFromTemplate', () => { + let mockContext: any; + let mockGmailApi: any; + + beforeEach(() => { + mockGmailApi = { + users: { + messages: { + send: jest.fn().mockResolvedValue({ + data: { id: 'msg123', threadId: 'thread123', labelIds: ['SENT'] }, + }), + }, + }, + }; + mockContext = { + gmail: mockGmailApi, + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { track: jest.fn() }, + startTime: Date.now(), + }; + }); + + test('renders template and sends email', async () => { + const result = await sendFromTemplate({ + to: ['amy@example.com'], + subject: '{{firstName}}, quick follow-up', + template: 'Hey {{firstName}},\n\n{{personalNote}}', + variables: { firstName: 'Amy', personalNote: 'Great meeting you' }, + }, mockContext); + + expect(result.messageId).toBe('msg123'); + expect(result.threadId).toBe('thread123'); + expect(result.rendered).toBe(true); + + // Verify the rendered content was sent + const call = mockGmailApi.users.messages.send.mock.calls[0][0]; + const raw = Buffer.from(call.requestBody.raw, 'base64').toString(); + expect(raw).toContain('Amy, quick follow-up'); + expect(raw).toContain('Hey Amy,'); + expect(raw).toContain('Great meeting you'); + }); + + test('throws on missing template variable', async () => { + await expect(sendFromTemplate({ + to: ['amy@example.com'], + subject: '{{firstName}}, follow-up', + template: 'Hey {{firstName}}, about {{topic}}', + variables: { firstName: 'Amy' }, + }, mockContext)).rejects.toThrow("Missing template variable: 'topic'"); + }); + + test('sends HTML email with escaped variables', async () => { + await sendFromTemplate({ + to: ['bob@example.com'], + subject: 'Hello {{name}}', + template: '

{{note}}

', + variables: { name: 'Bob', note: 'Tom & Jerry' }, + isHtml: true, + }, mockContext); + + const call = mockGmailApi.users.messages.send.mock.calls[0][0]; + const raw = Buffer.from(call.requestBody.raw, 'base64').toString(); + expect(raw).toContain('text/html'); + expect(raw).toContain('Tom & Jerry'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npx jest src/modules/gmail/__tests__/sendFromTemplate.test.ts --no-coverage` +Expected: FAIL — `sendFromTemplate` is not exported + +**Step 3: Implement `sendFromTemplate` in `src/modules/gmail/send.ts`** + +Add imports at the top: + +```typescript +import type { SendFromTemplateOptions, SendFromTemplateResult } from './types.js'; +import { renderTemplate } from './templates.js'; +``` + +Add after `sendDraft` function (after line 143): + +```typescript +/** + * Render a template with variables and send the email. + * + * @param options Template, variables, and recipients + * @param context Gmail API context + * @returns Sent message info with rendered: true + */ +export async function sendFromTemplate( + options: SendFromTemplateOptions, + context: GmailContext +): Promise { + const { to, subject, template, variables, isHtml = false, cc, bcc, from } = options; + + const renderedSubject = renderTemplate(subject, variables); + const renderedBody = renderTemplate(template, variables, { isHtml }); + + const result = await sendMessage({ + to, + subject: renderedSubject, + body: renderedBody, + isHtml, + cc, + bcc, + from, + }, context); + + return { + messageId: result.messageId, + threadId: result.threadId, + rendered: true, + }; +} +``` + +**Step 4: Export from `src/modules/gmail/index.ts`** + +Update the send exports: + +```typescript +export { sendMessage, sendDraft, sendFromTemplate } from './send.js'; +``` + +**Step 5: Add SDK spec entry in `src/sdk/spec.ts`** + +Add after the `dryRun` spec entry: + +```typescript + sendFromTemplate: { + signature: "sendFromTemplate(options: { to: string[], subject: string, template: string, variables: Record, isHtml?: boolean, cc?: string[], bcc?: string[], from?: string }): Promise<{ messageId, threadId, rendered: true }>", + description: "Render a template with {{variable}} placeholders (both subject and body), then send the email. Use dryRun() first to preview.", + example: "const result = await sdk.gmail.sendFromTemplate({\n to: ['amy@example.com'],\n subject: '{{firstName}}, quick follow-up on claims',\n template: 'Hey {{firstName}},\\n\\n{{personalNote}}\\n\\nBest regards',\n variables: { firstName: 'Amy', personalNote: 'We met at the dental conference' },\n});\nreturn result.messageId;", + params: { + to: "string[] (required) — recipient emails", + subject: "string (required) — subject template with {{variable}} placeholders", + template: "string (required) — body template with {{variable}} placeholders", + variables: "Record (required) — key-value pairs for placeholder replacement", + isHtml: "boolean (optional, default false)", + cc: "string[] (optional)", + bcc: "string[] (optional)", + from: "string (optional) — send-as alias", + }, + returns: "{ messageId, threadId, rendered: true }", + }, +``` + +**Step 6: Add runtime registration in `src/sdk/runtime.ts`** + +Add after the `dryRun` runtime entry: + +```typescript + sendFromTemplate: limiter.wrap('gmail', async (opts: unknown) => { + const { sendFromTemplate } = await import('../modules/gmail/index.js'); + return sendFromTemplate(opts as Parameters[0], context); + }), +``` + +**Step 7: Run tests** + +Run: `npx jest src/modules/gmail/__tests__/sendFromTemplate.test.ts --no-coverage` +Expected: PASS (all 3 tests) + +**Step 8: Run type-check** + +Run: `npm run type-check` +Expected: PASS + +**Step 9: Commit** + +```bash +git add src/modules/gmail/send.ts src/modules/gmail/index.ts src/sdk/spec.ts src/sdk/runtime.ts src/modules/gmail/__tests__/sendFromTemplate.test.ts +git commit -m "feat(gmail): add sendFromTemplate operation — template rendering + send" +``` + +--- + +### Task 5: `gmail.sendBatch` — Batch Send with Throttling + Dry Run + +**Linear Issue:** `gmail.sendBatch` — batch send with throttling, dryRun flag, structured results (3 pts, High) + +**Files:** +- Modify: `src/modules/gmail/send.ts` (add sendBatch function) +- Modify: `src/modules/gmail/index.ts` (add export) +- Modify: `src/sdk/spec.ts` (add spec entry) +- Modify: `src/sdk/runtime.ts` (add runtime registration) +- Create: `src/modules/gmail/__tests__/sendBatch.test.ts` + +**Step 1: Write the failing test** + +Create `src/modules/gmail/__tests__/sendBatch.test.ts`: + +```typescript +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { sendBatch } from '../send.js'; + +describe('sendBatch', () => { + let mockContext: any; + let mockGmailApi: any; + let callCount: number; + + beforeEach(() => { + callCount = 0; + mockGmailApi = { + users: { + messages: { + send: jest.fn().mockImplementation(() => { + callCount++; + return Promise.resolve({ + data: { id: `msg${callCount}`, threadId: `thread${callCount}`, labelIds: ['SENT'] }, + }); + }), + }, + }, + }; + mockContext = { + gmail: mockGmailApi, + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { track: jest.fn() }, + startTime: Date.now(), + }; + }); + + test('sends to all recipients with rendered templates', async () => { + const result = await sendBatch({ + template: 'Hey {{firstName}}, {{note}}', + subject: '{{firstName}}, follow-up', + recipients: [ + { to: 'amy@example.com', variables: { firstName: 'Amy', note: 'great chat' } }, + { to: 'bob@example.com', variables: { firstName: 'Bob', note: 'nice meeting' } }, + ], + delayMs: 0, // no delay in tests + }, mockContext); + + expect(result.sent).toBe(2); + expect(result.failed).toBe(0); + expect(result.results).toHaveLength(2); + expect(result.results![0].to).toBe('amy@example.com'); + expect(result.results![0].status).toBe('sent'); + expect(result.results![1].to).toBe('bob@example.com'); + expect(mockGmailApi.users.messages.send).toHaveBeenCalledTimes(2); + }); + + test('returns previews when dryRun is true', async () => { + const result = await sendBatch({ + template: 'Hey {{firstName}}', + subject: '{{firstName}}, hi', + recipients: [ + { to: 'amy@example.com', variables: { firstName: 'Amy' } }, + { to: 'bob@example.com', variables: { firstName: 'Bob' } }, + ], + dryRun: true, + }, mockContext); + + expect(result.sent).toBe(0); + expect(result.previews).toHaveLength(2); + expect(result.previews![0].to).toBe('amy@example.com'); + expect(result.previews![0].subject).toBe('Amy, hi'); + expect(result.previews![0].body).toBe('Hey Amy'); + expect(result.previews![0].wouldSend).toBe(false); + expect(mockGmailApi.users.messages.send).not.toHaveBeenCalled(); + }); + + test('continues on individual send failure', async () => { + mockGmailApi.users.messages.send + .mockRejectedValueOnce(new Error('Rate limit')) + .mockResolvedValueOnce({ + data: { id: 'msg2', threadId: 'thread2', labelIds: ['SENT'] }, + }); + + const result = await sendBatch({ + template: 'Hey {{name}}', + subject: 'Hi {{name}}', + recipients: [ + { to: 'fail@example.com', variables: { name: 'Fail' } }, + { to: 'ok@example.com', variables: { name: 'OK' } }, + ], + delayMs: 0, + }, mockContext); + + expect(result.sent).toBe(1); + expect(result.failed).toBe(1); + expect(result.results![0].status).toBe('failed'); + expect(result.results![0].error).toBe('Rate limit'); + expect(result.results![1].status).toBe('sent'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npx jest src/modules/gmail/__tests__/sendBatch.test.ts --no-coverage` +Expected: FAIL — `sendBatch` is not exported + +**Step 3: Implement `sendBatch` in `src/modules/gmail/send.ts`** + +Add to the existing imports: + +```typescript +import type { + SendFromTemplateOptions, + SendFromTemplateResult, + BatchSendOptions, + BatchSendResult, + BatchSendItemResult, + BatchPreviewItem, +} from './types.js'; +``` + +Add after `sendFromTemplate` function: + +```typescript +/** + * Delay execution for a given number of milliseconds. + */ +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Send templated emails to multiple recipients with configurable delay. + * Supports dryRun mode to preview all rendered emails without sending. + * + * @param options Batch send parameters + * @param context Gmail API context + * @returns Aggregate results with per-recipient status + */ +export async function sendBatch( + options: BatchSendOptions, + context: GmailContext +): Promise { + const { template, subject, recipients, delayMs = 5000, isHtml = false, from, dryRun = false } = options; + + // Dry run mode — return previews without sending + if (dryRun) { + const previews: BatchPreviewItem[] = recipients.map(recipient => ({ + to: recipient.to, + subject: renderTemplate(subject, recipient.variables), + body: renderTemplate(template, recipient.variables, { isHtml }), + wouldSend: false as const, + })); + return { sent: 0, failed: 0, previews }; + } + + // Send mode — sequential sends with delay + const results: BatchSendItemResult[] = []; + let sent = 0; + let failed = 0; + + for (let i = 0; i < recipients.length; i++) { + const recipient = recipients[i]; + + try { + const renderedSubject = renderTemplate(subject, recipient.variables); + const renderedBody = renderTemplate(template, recipient.variables, { isHtml }); + + const sendResult = await sendMessage({ + to: [recipient.to], + subject: renderedSubject, + body: renderedBody, + isHtml, + cc: recipient.cc, + bcc: recipient.bcc, + from, + }, context); + + results.push({ + to: recipient.to, + messageId: sendResult.messageId, + threadId: sendResult.threadId, + status: 'sent', + }); + sent++; + } catch (err) { + results.push({ + to: recipient.to, + messageId: '', + threadId: '', + status: 'failed', + error: err instanceof Error ? err.message : String(err), + }); + failed++; + context.logger.error('Batch send failed for recipient', { to: recipient.to, error: err }); + } + + // Delay between sends (skip after last one) + if (delayMs > 0 && i < recipients.length - 1) { + await delay(delayMs); + } + } + + return { sent, failed, results }; +} +``` + +**Step 4: Export from `src/modules/gmail/index.ts`** + +Update the send exports: + +```typescript +export { sendMessage, sendDraft, sendFromTemplate, sendBatch } from './send.js'; +``` + +**Step 5: Add SDK spec entry in `src/sdk/spec.ts`** + +Add after the `sendFromTemplate` spec entry: + +```typescript + sendBatch: { + signature: "sendBatch(options: { template: string, subject: string, recipients: BatchRecipient[], delayMs?: number, isHtml?: boolean, from?: string, dryRun?: boolean }): Promise<{ sent, failed, results?, previews? }>", + description: "Send templated emails to multiple recipients with configurable delay between sends. Set dryRun: true to preview all rendered emails without sending. Each recipient has their own variables for personalization.", + example: "const result = await sdk.gmail.sendBatch({\n template: 'Hey {{firstName}},\\n\\n{{personalNote}}',\n subject: '{{firstName}}, quick follow-up',\n recipients: [\n { to: 'amy@example.com', variables: { firstName: 'Amy', personalNote: 'Great chat' } },\n { to: 'bob@example.com', variables: { firstName: 'Bob', personalNote: 'Nice meeting' } },\n ],\n delayMs: 5000,\n dryRun: false,\n});\nconsole.log(`Sent: ${result.sent}, Failed: ${result.failed}`);", + params: { + template: "string (required) — body template with {{variable}} placeholders", + subject: "string (required) — subject template with {{variable}} placeholders", + recipients: "BatchRecipient[] (required) — [{ to: string, variables: Record, cc?: string[], bcc?: string[] }]", + delayMs: "number (optional, default 5000) — delay between sends in milliseconds", + isHtml: "boolean (optional, default false)", + from: "string (optional) — send-as alias", + dryRun: "boolean (optional, default false) — when true, returns previews without sending", + }, + returns: "{ sent: number, failed: number, results?: BatchSendItemResult[], previews?: BatchPreviewItem[] }", + }, +``` + +**Step 6: Add runtime registration in `src/sdk/runtime.ts`** + +Add after the `sendFromTemplate` runtime entry: + +```typescript + sendBatch: limiter.wrap('gmail', async (opts: unknown) => { + const { sendBatch } = await import('../modules/gmail/index.js'); + return sendBatch(opts as Parameters[0], context); + }), +``` + +**Step 7: Run tests** + +Run: `npx jest src/modules/gmail/__tests__/sendBatch.test.ts --no-coverage` +Expected: PASS (all 3 tests) + +**Step 8: Run full test suite and type-check** + +Run: `npm run type-check && npm test` +Expected: PASS + +**Step 9: Commit** + +```bash +git add src/modules/gmail/send.ts src/modules/gmail/index.ts src/sdk/spec.ts src/sdk/runtime.ts src/modules/gmail/__tests__/sendBatch.test.ts +git commit -m "feat(gmail): add sendBatch operation — batch send with throttling and dryRun" +``` + +--- + +## Phase 2 — P1 Tracking Infrastructure + +### Task 6: CF Worker Tracking Pixel Endpoint + KV Schema + +**Linear Issue:** CF Worker tracking pixel endpoint + KV schema (2 pts, Medium) + +**Files:** +- Create: `src/server/tracking.ts` (tracking handler + KV schema) +- Modify: `worker.ts` (route `/track/*` to tracking handler) +- Create: `src/server/__tests__/tracking.test.ts` + +**Step 1: Write the failing test** + +Create `src/server/__tests__/tracking.test.ts`: + +```typescript +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { handleTrackingRequest, TRANSPARENT_GIF } from '../tracking.js'; + +describe('tracking pixel endpoint', () => { + let mockKV: any; + + beforeEach(() => { + mockKV = { + get: jest.fn().mockResolvedValue(null), + put: jest.fn().mockResolvedValue(undefined), + }; + }); + + test('returns 1x1 transparent GIF with correct headers', async () => { + const request = new Request('https://example.com/track/campaign1/recipient1/pixel.gif'); + const response = await handleTrackingRequest(request, mockKV); + + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('image/gif'); + expect(response.headers.get('Cache-Control')).toBe('no-store, no-cache, must-revalidate'); + + const body = await response.arrayBuffer(); + expect(new Uint8Array(body)).toEqual(TRANSPARENT_GIF); + }); + + test('writes open event to KV summary record', async () => { + const request = new Request('https://example.com/track/campaign1/recipient1/pixel.gif'); + await handleTrackingRequest(request, mockKV); + + expect(mockKV.get).toHaveBeenCalledWith('tracking:summary:campaign1', 'json'); + expect(mockKV.put).toHaveBeenCalledWith( + 'tracking:summary:campaign1', + expect.any(String), + { expirationTtl: 7776000 } // 90 days + ); + + const putCall = mockKV.put.mock.calls[0]; + const summary = JSON.parse(putCall[1]); + expect(summary.campaignId).toBe('campaign1'); + expect(summary.recipients.recipient1.opens).toBe(1); + expect(summary.recipients.recipient1.firstOpen).toBeDefined(); + }); + + test('increments open count on subsequent hits', async () => { + mockKV.get.mockResolvedValue({ + campaignId: 'campaign1', + recipients: { + recipient1: { opens: 2, firstOpen: '2026-03-10T00:00:00Z', lastOpen: '2026-03-10T01:00:00Z' }, + }, + }); + + const request = new Request('https://example.com/track/campaign1/recipient1/pixel.gif'); + await handleTrackingRequest(request, mockKV); + + const putCall = mockKV.put.mock.calls[0]; + const summary = JSON.parse(putCall[1]); + expect(summary.recipients.recipient1.opens).toBe(3); + }); + + test('returns 404 for malformed tracking URL', async () => { + const request = new Request('https://example.com/track/invalid-path'); + const response = await handleTrackingRequest(request, mockKV); + + expect(response.status).toBe(404); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npx jest src/server/__tests__/tracking.test.ts --no-coverage` +Expected: FAIL — `../tracking.js` does not exist + +**Step 3: Implement tracking handler in `src/server/tracking.ts`** + +```typescript +/** + * CF Worker tracking pixel endpoint. + * Serves a 1x1 transparent GIF and records open events in KV. + * + * URL pattern: /track/:campaignId/:recipientId/pixel.gif + * + * KV schema: summary record at tracking:summary:{campaignId} + * - Aggregated per-recipient open counts (single KV read to query) + * - 90-day TTL + */ + +/** Minimal 1x1 transparent GIF (43 bytes) */ +export const TRANSPARENT_GIF = new Uint8Array([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, + 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, + 0x01, 0x00, 0x3b, +]); + +/** 90 days in seconds */ +const TTL_90_DAYS = 90 * 24 * 60 * 60; + +/** Per-recipient tracking data */ +interface RecipientTracking { + opens: number; + firstOpen: string; + lastOpen: string; +} + +/** Summary record stored in KV */ +interface TrackingSummary { + campaignId: string; + recipients: Record; +} + +/** KV namespace interface (subset of CF KVNamespace) */ +interface KVLike { + get(key: string, type: 'json'): Promise; + put(key: string, value: string, options?: { expirationTtl?: number }): Promise; +} + +/** + * Parse tracking URL path into campaignId and recipientId. + * Expected: /track/:campaignId/:recipientId/pixel.gif + */ +function parseTrackingPath(url: string): { campaignId: string; recipientId: string } | null { + const path = new URL(url).pathname; + const match = path.match(/^\/track\/([^/]+)\/([^/]+)\/pixel\.gif$/); + if (!match) return null; + return { campaignId: match[1], recipientId: match[2] }; +} + +/** + * Handle a tracking pixel request. + * Returns a 1x1 GIF and records the open event in KV. + */ +export async function handleTrackingRequest( + request: Request, + kv: KVLike +): Promise { + const parsed = parseTrackingPath(request.url); + if (!parsed) { + return new Response('Not found', { status: 404 }); + } + + const { campaignId, recipientId } = parsed; + const now = new Date().toISOString(); + const summaryKey = `tracking:summary:${campaignId}`; + + // Read-modify-write the summary record + const existing = (await kv.get(summaryKey, 'json')) as TrackingSummary | null; + + const summary: TrackingSummary = existing || { campaignId, recipients: {} }; + + if (summary.recipients[recipientId]) { + summary.recipients[recipientId].opens++; + summary.recipients[recipientId].lastOpen = now; + } else { + summary.recipients[recipientId] = { opens: 1, firstOpen: now, lastOpen: now }; + } + + await kv.put(summaryKey, JSON.stringify(summary), { expirationTtl: TTL_90_DAYS }); + + return new Response(TRANSPARENT_GIF, { + status: 200, + headers: { + 'Content-Type': 'image/gif', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + 'Content-Length': String(TRANSPARENT_GIF.byteLength), + }, + }); +} +``` + +**Step 4: Update `worker.ts` to route `/track/*` requests** + +In `worker.ts`, modify the fetch handler (line 159-166). Replace the existing path check: + +```typescript + // Route tracking requests + if (url.pathname.startsWith('/track/')) { + const { handleTrackingRequest } = await import('./src/server/tracking.js'); + return handleTrackingRequest(request, env.GDRIVE_KV as unknown as Parameters[1]); + } + + // Only handle POST requests to /mcp (or root) + if (request.method !== 'POST' || (url.pathname !== '/' && url.pathname !== '/mcp')) { +``` + +**Step 5: Run tests** + +Run: `npx jest src/server/__tests__/tracking.test.ts --no-coverage` +Expected: PASS (all 4 tests) + +**Step 6: Run type-check** + +Run: `npm run type-check` +Expected: PASS + +**Step 7: Commit** + +```bash +git add src/server/tracking.ts src/server/__tests__/tracking.test.ts worker.ts +git commit -m "feat(tracking): add CF Worker tracking pixel endpoint with KV summary records" +``` + +--- + +### Task 7: `gmail.getTrackingData` — Query Open Events by Campaign + +**Linear Issue:** `gmail.getTrackingData` — query open events by campaign, Worker-only (1 pt, Medium) + +**Files:** +- Modify: `src/server/tracking.ts` (add getTrackingData function) +- Modify: `src/sdk/spec.ts` (add spec entry) +- Modify: `src/sdk/runtime.ts` (add runtime registration) +- Modify: `src/modules/gmail/index.ts` (add re-export) +- Add test to: `src/server/__tests__/tracking.test.ts` + +**Step 1: Write the failing test** + +Add to `src/server/__tests__/tracking.test.ts`: + +```typescript +describe('getTrackingData', () => { + let mockKV: any; + + beforeEach(() => { + mockKV = { + get: jest.fn(), + put: jest.fn(), + }; + }); + + test('returns tracking data for a campaign', async () => { + const { getTrackingData } = await import('../tracking.js'); + + mockKV.get.mockResolvedValue({ + campaignId: 'test-campaign', + recipients: { + amy: { opens: 3, firstOpen: '2026-03-10T09:00:00Z', lastOpen: '2026-03-11T14:00:00Z' }, + bob: { opens: 0, firstOpen: '', lastOpen: '' }, + }, + }); + + const result = await getTrackingData('test-campaign', mockKV); + + expect(result.campaignId).toBe('test-campaign'); + expect(result.recipients).toHaveLength(2); + expect(result.recipients[0]).toEqual({ + recipientId: 'amy', + opens: 3, + firstOpen: '2026-03-10T09:00:00Z', + lastOpen: '2026-03-11T14:00:00Z', + }); + }); + + test('returns empty recipients for unknown campaign', async () => { + const { getTrackingData } = await import('../tracking.js'); + + mockKV.get.mockResolvedValue(null); + + const result = await getTrackingData('nonexistent', mockKV); + + expect(result.campaignId).toBe('nonexistent'); + expect(result.recipients).toEqual([]); + }); +}); +``` + +**Step 2: Implement `getTrackingData` in `src/server/tracking.ts`** + +Add to the end of the file: + +```typescript +/** Result of querying tracking data */ +export interface TrackingDataResult { + campaignId: string; + recipients: Array<{ + recipientId: string; + opens: number; + firstOpen?: string; + lastOpen?: string; + }>; +} + +/** + * Query tracking data for a campaign. + * Worker-only — KV is not available on Node.js stdio runtime. + * + * @param campaignId The campaign to query + * @param kv KV namespace + * @returns Per-recipient open data + */ +export async function getTrackingData( + campaignId: string, + kv: KVLike +): Promise { + const summary = (await kv.get(`tracking:summary:${campaignId}`, 'json')) as TrackingSummary | null; + + if (!summary) { + return { campaignId, recipients: [] }; + } + + const recipients = Object.entries(summary.recipients).map(([recipientId, data]) => ({ + recipientId, + opens: data.opens, + ...(data.firstOpen ? { firstOpen: data.firstOpen } : {}), + ...(data.lastOpen ? { lastOpen: data.lastOpen } : {}), + })); + + return { campaignId, recipients }; +} +``` + +**Step 3: Add SDK spec entry and runtime registration** + +SDK spec (add after `sendBatch`): + +```typescript + getTrackingData: { + signature: "getTrackingData(options: { campaignId: string }): Promise<{ campaignId, recipients: TrackingRecipient[] }>", + description: "Query email open tracking data by campaign ID. Returns per-recipient open counts and timestamps. WORKER-ONLY — requires CF Worker deployment with KV. Returns error on Node.js stdio runtime.", + example: "const data = await sdk.gmail.getTrackingData({ campaignId: 'clarte-warm-v1' });\ndata.recipients.forEach(r => console.log(r.recipientId, r.opens));", + params: { + campaignId: "string (required) — campaign identifier used when sending tracked emails", + }, + returns: "{ campaignId, recipients: [{ recipientId, opens, firstOpen?, lastOpen? }] }", + }, +``` + +Runtime registration — this is Worker-only, so the runtime handler must check for KV availability: + +```typescript + getTrackingData: limiter.wrap('gmail', async (opts: unknown) => { + const options = opts as { campaignId: string }; + if (!context.kv) { + throw new Error('getTrackingData is Worker-only — KV is not available on Node.js stdio runtime'); + } + const { getTrackingData } = await import('../server/tracking.js'); + return getTrackingData(options.campaignId, context.kv); + }), +``` + +> **Note:** This requires `kv` to be available on the `FullContext` type. Check `src/sdk/types.ts` — if `kv` is not on the context, add it as an optional property: `kv?: KVLike`. The Worker path in `worker.ts` passes the KV binding; the stdio path does not. + +**Step 4: Run tests** + +Run: `npx jest src/server/__tests__/tracking.test.ts --no-coverage` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/server/tracking.ts src/server/__tests__/tracking.test.ts src/sdk/spec.ts src/sdk/runtime.ts src/sdk/types.ts +git commit -m "feat(tracking): add getTrackingData — query open events by campaign (Worker-only)" +``` + +--- + +### Task 8: `gmail.detectReplies` — Find Replies by ThreadId Array + +**Linear Issue:** `gmail.detectReplies` — find replies by threadId array (2 pts, Medium) + +**Files:** +- Create: `src/modules/gmail/detect-replies.ts` +- Create: `src/modules/gmail/__tests__/detectReplies.test.ts` +- Modify: `src/modules/gmail/index.ts` (add export) +- Modify: `src/sdk/spec.ts` (add spec entry) +- Modify: `src/sdk/runtime.ts` (add runtime registration) + +**Step 1: Write the failing test** + +Create `src/modules/gmail/__tests__/detectReplies.test.ts`: + +```typescript +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { detectReplies } from '../detect-replies.js'; + +describe('detectReplies', () => { + let mockContext: any; + let mockGmailApi: any; + + beforeEach(() => { + mockGmailApi = { + users: { + threads: { + get: jest.fn(), + }, + getProfile: jest.fn().mockResolvedValue({ + data: { emailAddress: 'me@example.com' }, + }), + }, + }; + mockContext = { + gmail: mockGmailApi, + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { track: jest.fn() }, + startTime: Date.now(), + }; + }); + + test('detects replies from other participants', async () => { + mockGmailApi.users.threads.get.mockResolvedValue({ + data: { + id: 'thread1', + messages: [ + { + id: 'msg1', + payload: { headers: [{ name: 'From', value: 'me@example.com' }] }, + internalDate: '1710000000000', + }, + { + id: 'msg2', + payload: { headers: [{ name: 'From', value: 'amy@example.com' }] }, + internalDate: '1710100000000', + }, + ], + }, + }); + + const result = await detectReplies({ threadIds: ['thread1'] }, mockContext); + + expect(result.threads).toHaveLength(1); + expect(result.threads[0].threadId).toBe('thread1'); + expect(result.threads[0].hasReply).toBe(true); + expect(result.threads[0].replies).toHaveLength(1); + expect(result.threads[0].replies[0].from).toBe('amy@example.com'); + }); + + test('returns hasReply false for no external replies', async () => { + mockGmailApi.users.threads.get.mockResolvedValue({ + data: { + id: 'thread2', + messages: [ + { + id: 'msg1', + payload: { headers: [{ name: 'From', value: 'me@example.com' }] }, + internalDate: '1710000000000', + }, + ], + }, + }); + + const result = await detectReplies({ threadIds: ['thread2'] }, mockContext); + + expect(result.threads[0].hasReply).toBe(false); + expect(result.threads[0].replies).toHaveLength(0); + }); +}); +``` + +**Step 2: Implement `detectReplies` in `src/modules/gmail/detect-replies.ts`** + +```typescript +/** + * Gmail reply detection — find replies by thread IDs + * + * Composes existing getThread + getProfile to identify replies from + * participants other than the authenticated user. + */ + +import type { GmailContext } from '../types.js'; + +export interface DetectRepliesOptions { + /** Array of Gmail thread IDs to check for replies */ + threadIds: string[]; +} + +interface ReplyInfo { + messageId: string; + from: string; + date: string; +} + +interface ThreadReplyResult { + threadId: string; + hasReply: boolean; + replies: ReplyInfo[]; +} + +export interface DetectRepliesResult { + threads: ThreadReplyResult[]; +} + +/** + * Check threads for replies from external participants. + * Filters out messages from the authenticated user (sender). + * + * @param options Thread IDs to check + * @param context Gmail API context + * @returns Per-thread reply data + */ +export async function detectReplies( + options: DetectRepliesOptions, + context: GmailContext +): Promise { + // Get authenticated user email + const profile = await context.gmail.users.getProfile({ userId: 'me' }); + const myEmail = (profile.data.emailAddress || '').toLowerCase(); + + const threads: ThreadReplyResult[] = []; + + for (const threadId of options.threadIds) { + try { + const threadData = await context.gmail.users.threads.get({ + userId: 'me', + id: threadId, + format: 'metadata', + metadataHeaders: ['From'], + }); + + const messages = threadData.data.messages || []; + const replies: ReplyInfo[] = []; + + for (const msg of messages) { + const fromHeader = msg.payload?.headers?.find( + (h: { name?: string }) => h.name?.toLowerCase() === 'from' + ); + const from = fromHeader?.value || ''; + const fromEmail = from.match(/<([^>]+)>/)?.[1] || from; + + if (fromEmail.toLowerCase() !== myEmail) { + replies.push({ + messageId: msg.id || '', + from, + date: msg.internalDate + ? new Date(Number(msg.internalDate)).toISOString() + : '', + }); + } + } + + threads.push({ threadId, hasReply: replies.length > 0, replies }); + } catch (err) { + context.logger.error('Failed to check thread for replies', { threadId, error: err }); + threads.push({ threadId, hasReply: false, replies: [] }); + } + } + + context.performanceMonitor.track('gmail:detectReplies', Date.now() - context.startTime); + return { threads }; +} +``` + +**Step 3: Export, add spec, add runtime registration** + +Export from `src/modules/gmail/index.ts`: + +```typescript +// Reply detection +export { detectReplies } from './detect-replies.js'; +export type { DetectRepliesOptions, DetectRepliesResult } from './detect-replies.js'; +``` + +SDK spec (add after `getTrackingData`): + +```typescript + detectReplies: { + signature: "detectReplies(options: { threadIds: string[] }): Promise<{ threads: ThreadReplyResult[] }>", + description: "Check threads for replies from external participants. Returns per-thread reply data. Use with threadIds from sendBatch results.", + example: "const data = await sdk.gmail.detectReplies({ threadIds: ['thread1', 'thread2'] });\ndata.threads.filter(t => t.hasReply).forEach(t => console.log(t.threadId, t.replies));", + params: { + threadIds: "string[] (required) — Gmail thread IDs to check (from sendBatch/sendFromTemplate results)", + }, + returns: "{ threads: [{ threadId, hasReply, replies: [{ messageId, from, date }] }] }", + }, +``` + +Runtime registration: + +```typescript + detectReplies: limiter.wrap('gmail', async (opts: unknown) => { + const { detectReplies } = await import('../modules/gmail/index.js'); + return detectReplies(opts as Parameters[0], context); + }), +``` + +**Step 4: Run tests, type-check, commit** + +```bash +npx jest src/modules/gmail/__tests__/detectReplies.test.ts --no-coverage +npm run type-check +git add src/modules/gmail/detect-replies.ts src/modules/gmail/__tests__/detectReplies.test.ts src/modules/gmail/index.ts src/sdk/spec.ts src/sdk/runtime.ts +git commit -m "feat(gmail): add detectReplies — find external replies by thread IDs" +``` + +--- + +### Task 9: `sheets.updateRecords` — Update Sheet Rows by Key Match + +**Linear Issue:** `sheets.updateRecords` — update Sheet rows by key column match (2 pts, Medium) + +**Files:** +- Modify: `src/modules/sheets/update.ts` (add updateRecords function + types) +- Modify: `src/modules/sheets/index.ts` (add export) +- Modify: `src/sdk/spec.ts` (add spec entry) +- Modify: `src/sdk/runtime.ts` (add runtime registration) +- Create: `src/modules/sheets/__tests__/updateRecords.test.ts` + +**Step 1: Write the failing test** + +Create `src/modules/sheets/__tests__/updateRecords.test.ts`: + +```typescript +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { updateRecords } from '../update.js'; + +describe('updateRecords', () => { + let mockContext: any; + let mockSheetsApi: any; + + beforeEach(() => { + mockSheetsApi = { + spreadsheets: { + values: { + get: jest.fn(), + update: jest.fn().mockResolvedValue({}), + }, + }, + }; + mockContext = { + sheets: mockSheetsApi, + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { track: jest.fn() }, + startTime: Date.now(), + }; + }); + + test('updates cells by matching key column', async () => { + mockSheetsApi.spreadsheets.values.get.mockResolvedValue({ + data: { + range: 'Contacts!A1:D3', + values: [ + ['email', 'name', 'status', 'sentDate'], + ['amy@example.com', 'Amy', 'pending', ''], + ['bob@example.com', 'Bob', 'pending', ''], + ], + }, + }); + + const result = await updateRecords({ + spreadsheetId: 'abc123', + range: 'Contacts!A:D', + keyColumn: 'email', + updates: [ + { key: 'amy@example.com', values: { status: 'sent', sentDate: '2026-03-10' } }, + ], + }, mockContext); + + expect(result.updated).toBe(1); + // Should have called update for the matching row + expect(mockSheetsApi.spreadsheets.values.update).toHaveBeenCalled(); + }); + + test('reports not found keys', async () => { + mockSheetsApi.spreadsheets.values.get.mockResolvedValue({ + data: { + range: 'Sheet1!A1:B2', + values: [ + ['email', 'status'], + ['amy@example.com', 'pending'], + ], + }, + }); + + const result = await updateRecords({ + spreadsheetId: 'abc123', + range: 'Sheet1!A:B', + keyColumn: 'email', + updates: [ + { key: 'nonexistent@example.com', values: { status: 'sent' } }, + ], + }, mockContext); + + expect(result.updated).toBe(0); + expect(result.notFound).toEqual(['nonexistent@example.com']); + }); +}); +``` + +**Step 2: Implement `updateRecords` in `src/modules/sheets/update.ts`** + +Add after the `appendRows` function: + +```typescript +/** + * Options for updating records by key column match + */ +export interface UpdateRecordsOptions { + /** Spreadsheet ID */ + spreadsheetId: string; + /** Range in A1 notation covering all columns (e.g., "Contacts!A:G") */ + range: string; + /** Header name of the key column to match against */ + keyColumn: string; + /** Array of updates: each has a key to match and values to set */ + updates: Array<{ + key: string; + values: Record; + }>; + /** Optional sheet name (if not in range) */ + sheetName?: string; +} + +/** + * Result of updating records + */ +export interface UpdateRecordsResult { + updated: number; + notFound: string[]; + message: string; +} + +/** + * Convert a 0-based column index to A1 notation letter(s). + * 0 → A, 1 → B, 25 → Z, 26 → AA, etc. + */ +function columnToLetter(col: number): string { + let letter = ''; + let n = col; + while (n >= 0) { + letter = String.fromCharCode((n % 26) + 65) + letter; + n = Math.floor(n / 26) - 1; + } + return letter; +} + +/** + * Update cells in a Sheet by matching a key column. + * Reads the sheet, finds rows matching the key, computes cell ranges, and updates. + * + * @param options Update parameters with key column and values + * @param context Sheets API context + * @returns Update confirmation with counts + */ +export async function updateRecords( + options: UpdateRecordsOptions, + context: SheetsContext +): Promise { + const { spreadsheetId, range, keyColumn, updates, sheetName } = options; + + // Build resolved range + let resolvedRange = range; + if (sheetName && !range.includes('!')) { + resolvedRange = `${sheetName}!${range}`; + } + + // Read existing data + const response = await context.sheets.spreadsheets.values.get({ + spreadsheetId, + range: resolvedRange, + }); + + const values = (response.data.values ?? []) as unknown[][]; + if (values.length === 0) { + throw new Error('No data found in the specified range'); + } + + const headers = (values[0] as string[]).map(h => String(h)); + const keyColIndex = headers.indexOf(keyColumn); + if (keyColIndex === -1) { + throw new Error(`Key column '${keyColumn}' not found in headers: ${headers.join(', ')}`); + } + + // Extract sheet name prefix from the resolved range for building cell references + const sheetPrefix = resolvedRange.includes('!') ? resolvedRange.split('!')[0] + '!' : ''; + + let updated = 0; + const notFound: string[] = []; + + for (const update of updates) { + // Find matching row (1-indexed: row 1 = headers, data starts at row 2) + const rowIndex = values.findIndex((row, i) => i > 0 && String(row[keyColIndex]) === update.key); + + if (rowIndex === -1) { + notFound.push(update.key); + continue; + } + + // Update each specified column + for (const [colName, value] of Object.entries(update.values)) { + const colIndex = headers.indexOf(colName); + if (colIndex === -1) continue; // skip unknown columns + + const cellRef = `${sheetPrefix}${columnToLetter(colIndex)}${rowIndex + 1}`; + + await context.sheets.spreadsheets.values.update({ + spreadsheetId, + range: cellRef, + valueInputOption: 'USER_ENTERED', + requestBody: { values: [[value]] }, + }); + } + + updated++; + } + + // Invalidate cache + await context.cacheManager.invalidate(`sheet:${spreadsheetId}:*`); + context.performanceMonitor.track('sheets:updateRecords', Date.now() - context.startTime); + + return { + updated, + notFound, + message: `Updated ${updated} records. ${notFound.length > 0 ? `Not found: ${notFound.join(', ')}` : ''}`.trim(), + }; +} +``` + +**Step 3: Export, add spec, add runtime registration** + +Export from `src/modules/sheets/index.ts` (add to update exports): + +```typescript +export { + updateCells, + updateFormula, + appendRows, + updateRecords, + type UpdateCellsOptions, + type UpdateCellsResult, + type UpdateFormulaOptions, + type UpdateFormulaResult, + type AppendRowsOptions, + type AppendRowsResult, + type UpdateRecordsOptions, + type UpdateRecordsResult, +} from './update.js'; +``` + +SDK spec (add after `appendRows` in sheets section): + +```typescript + updateRecords: { + signature: "updateRecords(options: { spreadsheetId: string, range: string, keyColumn: string, updates: UpdateEntry[] }): Promise<{ updated, notFound, message }>", + description: "Update cells by matching a key column. Reads the sheet, finds rows where keyColumn matches, and updates specified columns. Closes the outreach loop: send emails → get results → write status back to Sheet.", + example: "await sdk.sheets.updateRecords({\n spreadsheetId: 'abc123',\n range: 'Contacts!A:G',\n keyColumn: 'email',\n updates: [\n { key: 'amy@example.com', values: { status: 'sent', sentDate: '2026-03-10' } },\n ],\n});", + params: { + spreadsheetId: "string (required) — Google Sheets spreadsheet ID", + range: "string (required) — A1 notation range covering all columns", + keyColumn: "string (required) — header name of the column to match against", + updates: "UpdateEntry[] (required) — [{ key: string, values: Record }]", + sheetName: "string (optional) — sheet name if not in range", + }, + returns: "{ updated: number, notFound: string[], message: string }", + }, +``` + +Runtime registration (add after `appendRows` in sheets section): + +```typescript + updateRecords: limiter.wrap('sheets', async (opts: unknown) => { + const { updateRecords } = await import('../modules/sheets/index.js'); + return updateRecords(opts as Parameters[0], context); + }), +``` + +**Step 4: Run tests, type-check, commit** + +```bash +npx jest src/modules/sheets/__tests__/updateRecords.test.ts --no-coverage +npm run type-check +git add src/modules/sheets/update.ts src/modules/sheets/index.ts src/modules/sheets/__tests__/updateRecords.test.ts src/sdk/spec.ts src/sdk/runtime.ts +git commit -m "feat(sheets): add updateRecords — update rows by key column match" +``` + +--- + +## Linear Issues Summary + +### P0 (GDRIVE team) — Phase 1 + +| # | Title | Priority | Estimate | Labels | +|---|-------|----------|----------|--------| +| 1 | `gmail.sendFromTemplate` — template rendering (subject + body) + send | Urgent | 3 pts | feat, gmail, outreach | +| 2 | `gmail.dryRun` — preview rendered email without sending | High | 1 pt | feat, gmail, outreach | +| 3 | `sheets.readAsRecords` — read Sheet as array of keyed objects | High | 2 pts | feat, sheets, outreach | +| 4 | `gmail.sendBatch` — batch send with throttling, dryRun flag, structured results | High | 3 pts | feat, gmail, outreach | + +### P1 (GDRIVE team) — Phase 2 + +| # | Title | Priority | Estimate | Labels | +|---|-------|----------|----------|--------| +| 5 | CF Worker tracking pixel endpoint + KV schema | Medium | 2 pts | feat, tracking, infra | +| 6 | `gmail.getTrackingData` — query open events by campaign (Worker-only) | Medium | 1 pt | feat, tracking | +| 7 | `gmail.detectReplies` — find replies by threadId array | Medium | 2 pts | feat, gmail, outreach | +| 8 | `sheets.updateRecords` — update Sheet rows by key column match | Medium | 2 pts | feat, sheets, outreach | + +### Pre-req (triage before P1) + +| # | Title | Priority | Estimate | Labels | +|---|-------|----------|----------|--------| +| — | Triage GDRIVE-10 monitoring alert | High | 1 pt | bug, ops | +| — | Triage GDRIVE-13 monitoring alert | High | 1 pt | bug, ops | + +**Total:** 17 pts across 10 issues + +--- + +## Dependency Graph + +``` +Task 1 (readAsRecords) ─────────────────────── independent +Task 2 (renderTemplate) ──┐ +Task 3 (dryRun) ──────────┤── depends on Task 2 +Task 4 (sendFromTemplate) ┤ +Task 5 (sendBatch) ───────┘── depends on Task 2 + Task 4 +Task 6 (tracking pixel) ──┐ +Task 7 (getTrackingData) ─┘── depends on Task 6 +Task 8 (detectReplies) ─────── independent +Task 9 (updateRecords) ──────── independent +``` + +**Parallelizable:** Tasks 1, 2, 8, 9 can all be built concurrently. diff --git a/docs/specs/outreach-features.md b/docs/specs/outreach-features.md new file mode 100644 index 0000000..9fb6cd7 --- /dev/null +++ b/docs/specs/outreach-features.md @@ -0,0 +1,319 @@ +--- +type: feature-spec +status: draft-v2 +created: 2026-03-10 +revised: 2026-03-10 +target_repo: ~/Projects/local-mcps/gdrive +notes: "v2 — revised after gap analysis against codebase (v4.0.0-alpha). Incorporates 6 findings from outreach-gap-analysis.md. DailyQuotaTracker removed — existing rate limiter is sufficient for 50-100 sends/day on Google Workspace (2,000/day limit)." +--- + +# gdrive MCP — Email Outreach Features (v2) + +## Why Build This + +**gog CLI** is a mature tool for Google Workspace (send, sheets, drive, 13 APIs, `--track`, `--dry-run`). + +**What gog cannot do:** +1. **Serve infrastructure.** CLI can't host tracking pixels, click redirects, or webhooks. +2. **Speak MCP.** AI agents call MCP tools natively — no shelling out, no stdout parsing. +3. **Compose across services atomically.** Read Sheet + render template + send email + write result back — one `execute` call. + +**gdrive MCP's unfair advantages:** +- **CF Worker runtime** — it IS a server. Tracking endpoints, webhooks, KV/D1 storage. +- **MCP protocol** — first-class tool for Claude Code, Cursor, Cline. +- **Cross-service orchestration** — Gmail + Sheets + Drive in one SDK. + +**The goal:** Build what gog structurally cannot — an AI-native email outreach platform with built-in tracking infrastructure. + +--- + +## Capability Comparison + +| Capability | gog CLI | gdrive MCP (current) | gdrive MCP (proposed) | +|---|---|---|---| +| Send plain/HTML email | Yes | `gmail.sendMessage` | Same | +| Attachments | Yes | `gmail.sendWithAttachments` | Same | +| Reply threading | Yes | `gmail.replyToMessage` | Same | +| Read Sheet as data | Yes | `sheets.readSheet` (2D array) | + `sheets.readAsRecords` (keyed objects) | +| Write to Sheet | Yes | `sheets.updateCells/appendRows` | + `sheets.updateRecords` (by key match) | +| Open tracking | `--track` | No | **CF Worker pixel endpoint** | +| Click tracking | No | No | **CF Worker redirect endpoint** (P2) | +| Dry run / preview | `--dry-run` | No | **`gmail.dryRun`** | +| Template rendering | No | No | **`gmail.sendFromTemplate`** (body + subject) | +| Batch with throttle | Shell loop | No | **`gmail.sendBatch`** (with dryRun flag) | +| Reply detection | No | `gmail.searchMessages` | **`gmail.detectReplies`** (by campaign) | +| Tracking data store | Impossible (CLI) | Possible (CF Worker) | **`/track/:id` + KV store** | + +**Bold = genuine advantages only the MCP can offer.** + +--- + +## P0 — Campaign Essentials + +The minimum to run outreach entirely through the gdrive MCP from Claude Code. + +### 1. `gmail.sendFromTemplate` + +Accepts an inline template string with `{{variable}}` placeholders + a variables object. Renders **both subject and body**, then sends. + +```typescript +// Input +{ + service: "gmail", + operation: "sendFromTemplate", + args: { + to: ["amy@todaysdental.com"], // string[] — always an array, not a bare string + subject: "{{firstName}}, quick follow-up on claims", + template: "Hey {{firstName}},\n\n{{personalNote}}\n\nYou know that thing we've all dealt with...", + variables: { firstName: "Amy", personalNote: "We rebuilt your claims sheet together last year" }, + isHtml: false + } +} + +// Output (Promise) +{ messageId: "abc123", threadId: "xyz789", rendered: true } +``` + +**Implementation notes:** +- `renderTemplate()` is a shared utility in `src/modules/gmail/templates.ts` — used by both this and `sendBatch` +- Handles `{{variable}}` replacement in both subject and body +- Missing variables throw an error (fail loud, not silent blanks) +- When `isHtml: true`, variable values are HTML-escaped before insertion + +### 2. `gmail.dryRun` + +Same signature as `sendFromTemplate` but returns the rendered email without sending. Ossie reviews, approves, then the real send fires. This is a pure function (no API calls) so it resolves instantly. + +```typescript +// Input +{ + service: "gmail", + operation: "dryRun", + args: { + to: ["amy@todaysdental.com"], // string[] — always an array + subject: "{{firstName}}, quick follow-up on claims", + template: "Hey {{firstName}},\n\n...", + variables: { firstName: "Amy" } + } +} + +// Output (Promise — use await) +{ + to: ["amy@todaysdental.com"], + subject: "Amy, quick follow-up on claims", + body: "Hey Amy,\n\nWe rebuilt your claims sheet together last year\n\nYou know that thing...", + isHtml: false, + wouldSend: false +} +``` + +### 3. `sheets.readAsRecords` + +Reads a Sheet tab and returns rows as keyed objects. First row = keys. + +```typescript +// Input +{ + service: "sheets", + operation: "readAsRecords", + args: { + spreadsheetId: "abc123", + range: "Contacts!A:G" + } +} + +// Output +{ + records: [ + { name: "Amy", email: "amy@todaysdental.com", version: "A", personalNote: "...", status: "pending", sentDate: "", replied: "" }, + { name: "Dr. Guiste", email: "elihu@todaysdental.com", version: "B", personalNote: "...", status: "pending", sentDate: "", replied: "" } + ], + count: 2, + columns: ["name", "email", "version", "personalNote", "status", "sentDate", "replied"] +} +``` + +**Implementation notes:** +- Thin wrapper over existing `readSheet()` — zip headers with rows +- **Sparse rows** (fewer cells than headers) produce `null` for missing values. This is by design — Google Sheets API omits trailing empty cells, so the code explicitly fills gaps with `null` rather than `undefined` +- Empty sheets (header only) → `records: [], count: 0` + +### 4. `gmail.sendBatch` + +Takes a template + array of recipients with per-recipient variables. Sends with configurable delay. Supports `dryRun` flag to preview the entire batch without sending. + +```typescript +// Input +{ + service: "gmail", + operation: "sendBatch", + args: { + template: "Hey {{firstName}},\n\n{{personalNote}}\n\nYou know that thing...", + subject: "{{firstName}}, quick follow-up on claims", + recipients: [ + { to: "amy@todaysdental.com", variables: { firstName: "Amy", personalNote: "..." } }, + { to: "haley@todaysdental.com", variables: { firstName: "Haley", personalNote: "..." } } + ], + delayMs: 5000, + isHtml: false, + dryRun: false + } +} + +// Output (when dryRun: false) +{ + sent: 2, + failed: 0, + results: [ + { to: "amy@todaysdental.com", messageId: "abc", threadId: "xyz", status: "sent" }, + { to: "haley@todaysdental.com", messageId: "def", threadId: "uvw", status: "sent" } + ] +} + +// Output (when dryRun: true) +{ + sent: 0, + failed: 0, + previews: [ + { to: "amy@todaysdental.com", subject: "Amy, quick follow-up...", body: "Hey Amy,\n\n...", wouldSend: false }, + { to: "haley@todaysdental.com", subject: "Haley, quick follow-up...", body: "Hey Haley,\n\n...", wouldSend: false } + ] +} +``` + +**Implementation notes:** +- Uses shared `renderTemplate()` from `src/modules/gmail/templates.ts` +- Sequential sends with `delayMs` between each (default 5s) +- Continues on individual send failure — reports per-recipient status +- Existing rate limiter handles API throttling — no additional quota tracking needed + +--- + +## P1 — Tracking Infrastructure + +Where the CF Worker advantage becomes real. gog structurally cannot do any of this. + +### 5. Tracking Pixel Endpoint + +New CF Worker route: `GET /track/:campaignId/:recipientId/pixel.gif` + +Returns a 1x1 transparent GIF. On each hit, writes to KV: +- `campaignId`, `recipientId`, timestamp, User-Agent +- Aggregated in a summary record at `tracking:summary:{campaignId}` (single KV read to query) +- TTL: 90 days + +**Implementation notes:** +- ~30 lines of CF Worker code in `src/server/worker-routes.ts` +- Worker routing: `worker.ts` splits `/track/*` requests to tracking handler, everything else to MCP +- KV uses summary-record pattern (read-modify-write on each hit) to avoid expensive list+get queries +- Tracking pixel URL must be configurable via `trackingBaseUrl` config, not hardcoded + +### 6. `gmail.insertTrackingPixel` + +When `track: true` is passed to `sendFromTemplate` or `sendBatch`, wraps body in minimal HTML and appends the tracking pixel `` tag. + +**Implementation notes:** +- Plain text bodies auto-converted to HTML — **must** convert `\n` to `
` to preserve formatting +- Pixel URL: `{trackingBaseUrl}/track/{campaignId}/{recipientId}/pixel.gif` + +### 7. `gmail.getTrackingData` + +Queries KV for tracking data by campaignId. Returns per-recipient open events. + +```typescript +// Output +{ + campaignId: "clarte-warm-v1", + recipients: [ + { recipientId: "amy", opens: 3, firstOpen: "2026-03-11T09:14:00Z", lastOpen: "2026-03-12T14:22:00Z" }, + { recipientId: "haley", opens: 0 } + ] +} +``` + +**Runtime constraint:** This operation is **Worker-only**. KV is not available on the Node.js stdio runtime. On Node.js, this operation returns an error explaining the requirement. + +### 8. `gmail.detectReplies` + +Given an array of threadIds (from `sendBatch` results), searches Gmail for replies not from the sender. Returns who replied and when. + +**Implementation notes:** +- Composes existing `searchMessages()` + `getThread()` — no new API surface needed +- Filters out messages from the authenticated user (sender) + +### 9. `sheets.updateRecords` + +Updates cells in a Sheet by matching a key column. E.g., "for row where email = amy@todaysdental.com, set status = 'sent', sentDate = '2026-03-10'". + +Closes the loop: send emails → get tracking data → write status back to the Sheet. The Google Sheet becomes a live campaign dashboard. + +**Implementation notes:** +- Reads sheet → finds matching row by key column → translates row index to A1 range → calls existing `updateCells()` + +--- + +## P2 — Platform Features (Future) + +| # | Feature | Why CF Worker | +|---|---------|--------------| +| 10 | **Click tracking** — `/click/:id?url=X` logs + redirects | Hosting redirect endpoint | +| 11 | **Webhook receiver** — Gmail push for real-time replies | Receiving inbound webhooks | +| 12 | **Sequence schema** — multi-email campaign cadence | Stored in KV/D1 | +| 13 | **Bounce detection** — scan bounced replies, flag in Sheet | Gmail search + Sheet update | +| 14 | **Unsubscribe endpoint** — serves page, updates Sheet | Hosting web page | +| 15 | **Stored templates** — reusable templates in Drive/KV | Beyond inline templates | + +--- + +## Implementation Order + +``` +Phase 1 — P0 (Week 1, ~15h): + 1. sheets.readAsRecords (2h) — simplest, no dependencies + 2. renderTemplate() utility (2h) — shared by 3, 4, 5 + 3. gmail.dryRun (3h) — uses renderTemplate, no send + 4. gmail.sendFromTemplate (3h) — renderTemplate + sendMessage + 5. gmail.sendBatch (5h) — renderTemplate + sendMessage + throttle + dryRun flag + +Phase 2 — P1 (Week 2, ~16h): + 6. CF Worker tracking routes (4h) — pixel endpoint + KV schema + 7. gmail.insertTrackingPixel (3h) — depends on #6 + 8. gmail.getTrackingData (3h) — depends on #6, Worker-only + 9. gmail.detectReplies (3h) — independent, uses existing search + 10. sheets.updateRecords (3h) — independent, closes the loop + +Pre-req: Triage GDRIVE-10/GDRIVE-13 monitoring alerts before P1. +``` + +--- + +## Linear Issues to Create + +### P0 (GDRIVE team) + +| # | Title | Priority | Estimate | +|---|-------|----------|----------| +| 1 | `gmail.sendFromTemplate` — template rendering (subject + body) + send | Urgent | 3 pts | +| 2 | `gmail.dryRun` — preview rendered email without sending | High | 1 pt | +| 3 | `sheets.readAsRecords` — read Sheet as array of keyed objects | High | 2 pts | +| 4 | `gmail.sendBatch` — batch send with throttling, dryRun flag, structured results | High | 3 pts | + +### P1 (GDRIVE team) + +| # | Title | Priority | Estimate | +|---|-------|----------|----------| +| 5 | CF Worker tracking pixel endpoint + KV schema | Medium | 2 pts | +| 6 | `gmail.insertTrackingPixel` — auto-wrap with tracking HTML on send | Medium | 2 pts | +| 7 | `gmail.getTrackingData` — query open events by campaign (Worker-only) | Medium | 1 pt | +| 8 | `gmail.detectReplies` — find replies by threadId array | Medium | 2 pts | +| 9 | `sheets.updateRecords` — update Sheet rows by key column match | Medium | 2 pts | + +--- + +## Open Source Positioning + +**What this is:** The first MCP server with built-in email campaign infrastructure. Not a cold email SaaS — a developer tool for AI-native email outreach. + +**Who it's for:** Developers using Claude Code, Cursor, or any MCP client who want to run email campaigns without paying for Instantly/Lemlist/Apollo. + +**Complementary with gog:** gog for interactive CLI work. gdrive MCP when your AI agent needs to orchestrate a campaign. diff --git a/docs/specs/outreach-gap-analysis.md b/docs/specs/outreach-gap-analysis.md new file mode 100644 index 0000000..62c858f --- /dev/null +++ b/docs/specs/outreach-gap-analysis.md @@ -0,0 +1,341 @@ +# Outreach Features — Gap Analysis + +**Date:** 2026-03-10 +**Spec:** `docs/specs/outreach-features.md` +**Codebase:** v4.0.0-alpha (commit 7692c86) +**Analyst:** Obi (PAI) + +--- + +## Executive Summary + +The gdrive MCP codebase is **60-70% ready for P0** and **20-30% ready for P1**. P0 features are thin wrappers around existing email building and send infrastructure. P1 features require new CF Worker HTTP routes and KV data structures that don't exist yet. + +**Key findings:** +1. All P0 features can reuse `buildEmailMessage()` and `sendMessage()` — no new Google API calls needed +2. The rate limiter handles per-request API throttling — sufficient for 50-100 sends/day on Google Workspace (2,000/day limit) +3. KV binding (`GDRIVE_KV`) exists but has no tracking schema — list-by-prefix is the only query pattern +4. The spec's inline template design (`{{variable}}`) is correct for MVP — don't over-engineer with stored templates +5. Two open monitoring alerts (GDRIVE-10, GDRIVE-13) should be triaged before new feature work + +--- + +## Existing Linear Issues (GDRIVE Team) + +| ID | Title | Status | Priority | Notes | +|----|-------|--------|----------|-------| +| GDRIVE-13 | Critical Monitoring Alert (Mar 5) | Todo | High | Health + perf checks failing | +| GDRIVE-10 | Critical Monitoring Alert (Feb 9) | Todo | High | Health + perf + metrics failing | +| GDRIVE-8 | Gmail API Integration v3.2.0 | Done | Urgent | Shipped — 12 operations | +| GDRIVE-5 | addQuestion JSON payload bug | Done | Urgent | Fixed | +| GDRIVE-4 | getAppScript ID resolution | Done | Medium | Fixed | + +**Related (AOJ team, all Done):** AOJ-318 (reply ops), AOJ-319 (forward ops), AOJ-320 (attachment ops), AOJ-321 (message management) + +**Recommendation:** Triage GDRIVE-10 and GDRIVE-13 before starting outreach work. They may indicate Worker deployment issues that would block P1 tracking features. + +--- + +## P0 Feature Gap Analysis + +### 1. `gmail.sendFromTemplate` — Template Rendering + Send + +**Readiness: 75%** + +| What Exists | File | Lines | +|-------------|------|-------| +| Email building (RFC 2822, MIME, sanitization) | `src/modules/gmail/utils.ts` | 92-146 | +| Send operation | `src/modules/gmail/send.ts` | 42-91 | +| HTML support (`isHtml` flag) | `src/modules/gmail/types.ts` | 184 | +| Rate limiting per service | `src/sdk/rate-limiter.ts` | all | +| Base64url encoding | `src/modules/gmail/utils.ts` | 153-159 | + +**What's Missing:** + +| Item | Action | File | +|------|--------|------| +| `renderTemplate()` function | CREATE | `src/modules/gmail/templates.ts` | +| `SendFromTemplateOptions` type | CREATE | `src/modules/gmail/types.ts` | +| SDK spec entry | UPDATE | `src/sdk/spec.ts` (gmail section ~line 430) | +| Runtime registration | UPDATE | `src/sdk/runtime.ts` (gmail section ~line 170) | +| Module export | UPDATE | `src/modules/gmail/index.ts` | + +**Spec Accuracy:** The spec's inline template design (`template` string + `variables` object) is correct and simpler than stored templates. No changes needed. + +**Design Decision:** `renderTemplate()` should be a shared utility used by both `sendFromTemplate` and `sendBatch`. Extract to `src/modules/gmail/templates.ts`. + +**Effort:** 3-4 hours + +--- + +### 2. `gmail.dryRun` — Preview Without Sending + +**Readiness: 80%** + +| What Exists | File | Lines | +|-------------|------|-------| +| `buildEmailMessage()` renders full RFC 2822 | `src/modules/gmail/utils.ts` | 92-146 | +| Header sanitization + validation | `src/modules/gmail/utils.ts` | 11-61 | +| `createDraft` already builds without sending | `src/modules/gmail/compose.ts` | 30-67 | + +**What's Missing:** + +| Item | Action | File | +|------|--------|------| +| `dryRunMessage()` function | CREATE | `src/modules/gmail/compose.ts` | +| `DryRunResult` type | CREATE | `src/modules/gmail/types.ts` | +| Base64url DECODE utility | CREATE | `src/modules/gmail/utils.ts` | +| SDK spec entry | UPDATE | `src/sdk/spec.ts` | +| Runtime registration | UPDATE | `src/sdk/runtime.ts` | + +**Gotcha:** `encodeToBase64Url()` exists (line 153) but there's no corresponding decode. Need ~5 lines to add `decodeFromBase64Url()` for parsing the rendered message back to readable text. + +**Spec Accuracy:** Spec is correct. The `wouldSend: false` field in the output is a nice touch for AI agents to reason about. + +**Effort:** 2-3 hours (simplest P0 feature) + +--- + +### 3. `sheets.readAsRecords` — Sheet as Keyed Objects + +**Readiness: 85%** + +| What Exists | File | Lines | +|-------------|------|-------| +| `readSheet()` returns 2D array | `src/modules/sheets/read.ts` | 51-89 | +| Range parsing + cache | `src/modules/sheets/read.ts` | 56-71 | +| 12 existing sheet operations | `src/sdk/runtime.ts` | 50-97 | + +**What's Missing:** + +| Item | Action | File | +|------|--------|------| +| `readAsRecords()` function (~20 lines) | CREATE | `src/modules/sheets/read.ts` | +| `ReadAsRecordsResult` type | CREATE | `src/modules/sheets/read.ts` | +| SDK spec entry | UPDATE | `src/sdk/spec.ts` (sheets section ~line 139) | +| Runtime registration | UPDATE | `src/sdk/runtime.ts` (sheets section ~line 54) | +| Module export | UPDATE | `src/modules/sheets/index.ts` | + +**Spec Accuracy:** Correct. This is literally a transform layer over `readSheet()` — zip headers with rows. + +**Gotcha:** Sparse rows (fewer cells than headers) should map to `null`, not `undefined`. Empty sheets (header only) should return `records: [], count: 0`. + +**Effort:** 2 hours (thinnest wrapper of all P0 features) + +--- + +### 4. `gmail.sendBatch` — Batch Send with Throttling + +**Readiness: 65%** + +| What Exists | File | Lines | +|-------------|------|-------| +| `sendMessage()` for single sends | `src/modules/gmail/send.ts` | 42-91 | +| Rate limiter wraps all operations | `src/sdk/rate-limiter.ts` | all | +| Error handling patterns | `src/modules/gmail/send.ts` | 78-89 | +| Logging + perf tracking | `src/modules/gmail/send.ts` | 78 | + +**What's Missing:** + +| Item | Action | File | +|------|--------|------| +| `sendBatch()` function | CREATE | `src/modules/gmail/send.ts` | +| `BatchSendOptions`, `BatchSendResult` types | CREATE | `src/modules/gmail/types.ts` | +| ~~Daily quota tracking~~ | DROPPED | Existing rate limiter sufficient for 50-100/day volume | +| SDK spec entry | UPDATE | `src/sdk/spec.ts` | +| Runtime registration | UPDATE | `src/sdk/runtime.ts` | + +**Spec vs Code Mismatch:** + +The spec designs `sendBatch` with a shared `template` + per-recipient `variables` array (outreach-oriented). This is better than the explore agent's suggestion of per-item full `SendMessageOptions`. **Keep the spec's design** — it pairs naturally with `renderTemplate()` from feature #1. + +However, the spec should also support a `dryRun: true` flag on `sendBatch` that returns all rendered emails without sending (preview the entire batch). This is missing from the spec. + +**Quota Note:** Google Workspace allows 2,000 sends/day. At 50-100 sends/day volume, the existing per-request rate limiter is sufficient — no daily quota tracker needed. + +**Effort:** 4-5 hours + +--- + +## P1 Feature Gap Analysis + +### 5. CF Worker Tracking Pixel Endpoint + +**Readiness: 30%** + +| What Exists | File | +|-------------|------| +| CF Worker config with KV binding | `wrangler.toml` (lines 1-8) | +| `WorkersKVCache` class | `src/storage/kv-store.ts` (lines 80-128) | +| Worker entry point | `worker.ts` | +| Web Crypto API (for hashing IPs) | `src/storage/kv-store.ts` (lines 13-76) | + +**What's Missing:** + +| Item | Action | File | +|------|--------|------| +| HTTP route handler for `/track/*` | CREATE | `src/server/worker-routes.ts` | +| Worker fetch routing (MCP vs tracking) | UPDATE | `worker.ts` | +| Tracking KV schema + types | CREATE | `src/storage/tracking-schema.ts` | +| 1x1 transparent GIF binary constant | CREATE | `src/server/worker-routes.ts` | + +**KV Limitation (Critical):** +KV does NOT support range queries. To get all events for a campaign, you must `list()` with prefix `tracking:{campaignId}:` — which returns keys, not values. Each value then requires a separate `get()`. For 100 recipients, that's 101 KV reads per query. + +**Workaround:** Write a summary record at `tracking:summary:{campaignId}` that aggregates per-recipient open counts. Update on each pixel hit (read-modify-write). Query returns one KV read. Trade-off: slight race condition on concurrent opens (acceptable — tracking is approximate). + +**Effort:** 4-5 hours + +--- + +### 6. `gmail.insertTrackingPixel` — Auto-Wrap HTML + +**Readiness: 70%** (depends on feature #5) + +Mostly a transform on existing `sendMessage()` — wraps body in HTML, appends `` tag. The `buildEmailMessage()` already handles `isHtml: true`. + +**Gotcha:** Plain text emails auto-converted to HTML. Must preserve newlines as `
` tags, not just `\n`. The spec doesn't mention this. + +**Effort:** 3 hours + +--- + +### 7. `gmail.getTrackingData` — Query KV + +**Readiness: 25%** (depends on feature #5) + +**Blocker:** This is a Worker-only operation. On Node.js runtime (stdio MCP), KV is not available. Need to either: +- Route this operation through the Worker HTTP endpoint (new pattern) +- Or only support it on Worker deployment + +The spec doesn't address this runtime split. **This is a design gap.** + +**Effort:** 3-4 hours + +--- + +### 8. `gmail.detectReplies` — Reply Detection by ThreadId + +**Readiness: 60%** + +| What Exists | File | Lines | +|-------------|------|-------| +| `searchMessages()` with Gmail query syntax | `src/modules/gmail/search.ts` | all | +| `getThread()` for thread retrieval | `src/modules/gmail/read.ts` | all | + +This is a composition of existing operations — search for messages in given threadIds, filter to messages NOT from the sender. The spec is correct that this automates what gog requires manually. + +**Effort:** 3 hours + +--- + +### 9. `sheets.updateRecords` — Update by Key Match + +**Readiness: 50%** + +| What Exists | File | Lines | +|-------------|------|-------| +| `readSheet()` for data retrieval | `src/modules/sheets/read.ts` | 51-89 | +| `updateCells()` for cell updates | `src/modules/sheets/update.ts` | all | + +Needs: read sheet → find row by key column match → compute cell range → call `updateCells()`. The tricky part is translating row index to A1 notation range. + +**Effort:** 3-4 hours + +--- + +## Cross-Cutting Concerns + +### 1. CF Worker vs Node.js Runtime Split + +The gdrive MCP runs in TWO runtimes: +- **Node.js** (stdio) — local MCP server, has `isolated-vm` sandbox for code execution +- **CF Worker** — deployed, has KV/D1/R2 but no `isolated-vm` + +| Feature | Node.js | CF Worker | +|---------|---------|-----------| +| P0: sendFromTemplate | Yes | Yes | +| P0: dryRun | Yes | Yes | +| P0: readAsRecords | Yes | Yes | +| P0: sendBatch | Yes | Yes | +| P1: Tracking pixel | No (no HTTP) | Yes | +| P1: insertTrackingPixel | Yes | Yes | +| P1: getTrackingData | **No (no KV)** | Yes | +| P1: detectReplies | Yes | Yes | +| P1: updateRecords | Yes | Yes | + +**Implication:** `getTrackingData` is Worker-only. The spec should acknowledge this and the SDK spec should mark it as Worker-only. + +### 2. Rate Limiter — Sufficient for Current Volume + +Current: `RateLimiter` in `src/sdk/rate-limiter.ts` wraps operations with per-request throttling (prevents 429s). +Google Workspace limit: 2,000 sends/day. Expected volume: 50-100/day. + +**Conclusion:** Existing rate limiter is sufficient. No daily quota tracker needed at this volume. If volume scales past 500/day, revisit then. + +### 3. Template Rendering — Shared Utility + +Both `sendFromTemplate` and `sendBatch` need template rendering. Extract `renderTemplate(template: string, variables: Record): string` into `src/modules/gmail/templates.ts`. Handle: +- `{{variable}}` replacement +- Missing variable → throw or warn (configurable) +- HTML escaping when `isHtml: true` +- Subject line template support (not just body) + +### 4. Spec Inaccuracies / Missing Items + +| Item | Issue | Recommendation | +|------|-------|----------------| +| `sendBatch` missing `dryRun` flag | Can't preview batch before sending | Add `dryRun: boolean` to `BatchSendOptions` | +| ~~No daily quota tracking~~ | DROPPED — 50-100/day vs 2,000 Workspace limit | Not needed | +| `getTrackingData` runtime assumption | Spec doesn't mention Worker-only limitation | Document runtime requirement | +| `insertTrackingPixel` plain→HTML conversion | Spec doesn't handle `\n` → `
` | Add to implementation notes | +| No subject template support | `sendFromTemplate` renders body but not subject with variables | Add subject template rendering | +| Tracking pixel URL hardcoded in spec | Uses `gdrive-mcp.workers.dev` — should be configurable | Add `trackingBaseUrl` config | + +--- + +## Recommended Implementation Order + +``` +Phase 1 (P0 Foundation — Week 1): + 1. sheets.readAsRecords (2h) — no dependencies, simplest + 2. renderTemplate() utility (2h) — shared by 3+4 + 3. gmail.dryRun (3h) — uses renderTemplate, no send + 4. gmail.sendFromTemplate (3h) — uses renderTemplate + sendMessage + 5. gmail.sendBatch (5h) — uses renderTemplate + sendMessage + throttle + +Phase 2 (P1 Tracking — Week 2): + 6. CF Worker route setup (4h) — tracking pixel endpoint + 7. gmail.insertTrackingPixel(3h) — depends on #6 for pixel URL + 8. gmail.getTrackingData (3h) — depends on #6 for KV schema + 9. gmail.detectReplies (3h) — independent, uses existing search + 10. sheets.updateRecords (3h) — independent, closes the loop + +Pre-req: + - Triage GDRIVE-10/13 (2h) — Worker health before P1 +``` + +**Total estimated effort:** ~31 hours across both phases + +--- + +## Files Changed Summary + +### New Files (7) +- `src/modules/gmail/templates.ts` — renderTemplate, sendFromTemplate +- `src/storage/tracking-schema.ts` — KV tracking types +- `src/server/worker-routes.ts` — tracking pixel HTTP handler +- `docs/specs/outreach-features.md` — original spec (copied) +- `docs/specs/outreach-gap-analysis.md` — this document + +### Modified Files (8) +- `src/modules/gmail/types.ts` — new types for template, batch, dryRun, tracking +- `src/modules/gmail/send.ts` — sendBatch function +- `src/modules/gmail/compose.ts` — dryRunMessage function +- `src/modules/gmail/utils.ts` — decodeFromBase64Url, escapeHtml +- `src/modules/gmail/index.ts` — new exports +- `src/modules/sheets/read.ts` — readAsRecords function +- `src/sdk/spec.ts` — 6 new operation specs +- `src/sdk/runtime.ts` — 6 new operation registrations +- `worker.ts` — route tracking requests +- `wrangler.toml` — route config (if needed) diff --git a/src/__tests__/sdk/runtime-rate-limiter-scope.test.ts b/src/__tests__/sdk/runtime-rate-limiter-scope.test.ts index ee7742e..a479479 100644 --- a/src/__tests__/sdk/runtime-rate-limiter-scope.test.ts +++ b/src/__tests__/sdk/runtime-rate-limiter-scope.test.ts @@ -12,10 +12,9 @@ describe('createSDKRuntime rate limiter injection', () => { createSDKRuntime(context, limiter); createSDKRuntime(context, limiter); - // 59 wrapped SDK operations per runtime creation (47 original + 12 new Gmail operations). - // Actual: replyToMessage, replyAllToMessage, forwardMessage, listAttachments, - // downloadAttachment, sendWithAttachments, trashMessage, untrashMessage, - // deleteMessage, markAsRead, markAsUnread, archiveMessage = 12 new. - expect(wrap).toHaveBeenCalledTimes(118); + // 65 wrapped SDK operations per runtime creation (47 original + 12 Gmail v3.2 ops + 6 outreach ops). + // Outreach P0: readAsRecords, sendFromTemplate, sendBatch = 3 (dryRun is unwrapped — pure function). + // Outreach P1: updateRecords, detectReplies, getTrackingData = 3. + expect(wrap).toHaveBeenCalledTimes(130); }); }); diff --git a/src/modules/gmail/__tests__/detectReplies.test.ts b/src/modules/gmail/__tests__/detectReplies.test.ts new file mode 100644 index 0000000..dec23db --- /dev/null +++ b/src/modules/gmail/__tests__/detectReplies.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { detectReplies } from '../detect-replies.js'; + +describe('detectReplies', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockContext: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockGmailApi: any; + + beforeEach(() => { + mockGmailApi = { + users: { + threads: { + get: jest.fn<() => Promise>(), + }, + getProfile: jest.fn<() => Promise>().mockResolvedValue({ + data: { emailAddress: 'me@example.com' }, + }), + }, + }; + mockContext = { + gmail: mockGmailApi, + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }, + cacheManager: { + get: jest.fn<() => Promise>().mockResolvedValue(null), + set: jest.fn<() => Promise>().mockResolvedValue(undefined), + invalidate: jest.fn<() => Promise>().mockResolvedValue(undefined), + }, + performanceMonitor: { track: jest.fn() }, + startTime: Date.now(), + }; + }); + + test('detects replies from other participants', async () => { + mockGmailApi.users.threads.get.mockResolvedValue({ + data: { + id: 'thread1', + messages: [ + { + id: 'msg1', + payload: { headers: [{ name: 'From', value: 'me@example.com' }] }, + internalDate: '1710000000000', + }, + { + id: 'msg2', + payload: { headers: [{ name: 'From', value: 'amy@example.com' }] }, + internalDate: '1710100000000', + }, + ], + }, + }); + + const result = await detectReplies({ threadIds: ['thread1'] }, mockContext); + + expect(result.threads).toHaveLength(1); + expect(result.threads[0]!.threadId).toBe('thread1'); + expect(result.threads[0]!.hasReply).toBe(true); + expect(result.threads[0]!.replies).toHaveLength(1); + expect(result.threads[0]!.replies[0]!.from).toBe('amy@example.com'); + }); + + test('returns hasReply false for no external replies', async () => { + mockGmailApi.users.threads.get.mockResolvedValue({ + data: { + id: 'thread2', + messages: [ + { + id: 'msg1', + payload: { headers: [{ name: 'From', value: 'me@example.com' }] }, + internalDate: '1710000000000', + }, + ], + }, + }); + + const result = await detectReplies({ threadIds: ['thread2'] }, mockContext); + + expect(result.threads[0]!.hasReply).toBe(false); + expect(result.threads[0]!.replies).toHaveLength(0); + }); +}); diff --git a/src/modules/gmail/__tests__/dryRun.test.ts b/src/modules/gmail/__tests__/dryRun.test.ts new file mode 100644 index 0000000..a8d9024 --- /dev/null +++ b/src/modules/gmail/__tests__/dryRun.test.ts @@ -0,0 +1,59 @@ +/** + * Tests for dryRunMessage — preview rendered email without sending + */ + +import { describe, test, expect } from '@jest/globals'; +import { dryRunMessage } from '../compose.js'; + +describe('dryRunMessage', () => { + test('renders template variables in subject and body', () => { + const result = dryRunMessage({ + to: ['amy@todaysdental.com'], + subject: '{{firstName}}, quick follow-up', + template: 'Hey {{firstName}},\n\n{{personalNote}}', + variables: { firstName: 'Amy', personalNote: 'We rebuilt your claims sheet' }, + }); + + expect(result.subject).toBe('Amy, quick follow-up'); + expect(result.body).toBe('Hey Amy,\n\nWe rebuilt your claims sheet'); + expect(result.wouldSend).toBe(false); + }); + + test('validates recipient email addresses', () => { + expect(() => + dryRunMessage({ + to: ['not-an-email'], + subject: 'Test', + template: 'Hello', + variables: {}, + }) + ).toThrow('Invalid email address in to'); + }); + + test('returns sanitized to array', () => { + const result = dryRunMessage({ + to: ['amy@todaysdental.com', 'haley@todaysdental.com'], + subject: 'Hi', + template: 'Body', + variables: {}, + }); + + expect(result.to).toEqual(['amy@todaysdental.com', 'haley@todaysdental.com']); + expect(result.isHtml).toBe(false); + }); + + test('HTML-escapes variables when isHtml is true', () => { + const result = dryRunMessage({ + to: ['user@example.com'], + subject: 'Hello {{name}}', + template: '

{{content}}

', + variables: { name: 'Amy', content: '' }, + isHtml: true, + }); + + expect(result.isHtml).toBe(true); + expect(result.body).toBe('

<script>alert("xss")</script>

'); + // Subject is never HTML-escaped (it's always plain text in email headers) + expect(result.subject).toBe('Hello Amy'); + }); +}); diff --git a/src/modules/gmail/__tests__/sendBatch.test.ts b/src/modules/gmail/__tests__/sendBatch.test.ts new file mode 100644 index 0000000..41a1f2d --- /dev/null +++ b/src/modules/gmail/__tests__/sendBatch.test.ts @@ -0,0 +1,132 @@ +/** + * Tests for sendBatch — batch send with throttling and dry-run + */ + +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { sendBatch } from '../send.js'; + +describe('sendBatch', () => { + let mockContext: any; + let mockGmailApi: any; + + beforeEach(() => { + mockGmailApi = { + users: { + messages: { + send: jest.fn(), + }, + }, + }; + + mockContext = { + gmail: mockGmailApi, + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('sends to multiple recipients with per-recipient variables', async () => { + mockGmailApi.users.messages.send + .mockResolvedValueOnce({ + data: { id: 'msg-a', threadId: 'thread-a', labelIds: ['SENT'] }, + }) + .mockResolvedValueOnce({ + data: { id: 'msg-b', threadId: 'thread-b', labelIds: ['SENT'] }, + }); + + const result = await sendBatch({ + subject: 'Hi {{name}}', + template: 'Hello {{name}}, {{note}}', + recipients: [ + { to: 'alice@example.com', variables: { name: 'Alice', note: 'note A' } }, + { to: 'bob@example.com', variables: { name: 'Bob', note: 'note B' } }, + ], + delayMs: 0, + }, mockContext); + + expect(result.sent).toBe(2); + expect(result.failed).toBe(0); + expect(result.results).toHaveLength(2); + expect(result.results![0]!.to).toBe('alice@example.com'); + expect(result.results![0]!.status).toBe('sent'); + expect(result.results![1]!.to).toBe('bob@example.com'); + + // Verify each send was called with correct rendered content + const rawA = Buffer.from( + mockGmailApi.users.messages.send.mock.calls[0][0].requestBody.raw, + 'base64' + ).toString(); + expect(rawA).toContain('Hello Alice, note A'); + + const rawB = Buffer.from( + mockGmailApi.users.messages.send.mock.calls[1][0].requestBody.raw, + 'base64' + ).toString(); + expect(rawB).toContain('Hello Bob, note B'); + }); + + test('dryRun returns previews without sending', async () => { + const result = await sendBatch({ + subject: 'Hi {{name}}', + template: 'Hello {{name}}!', + recipients: [ + { to: 'alice@example.com', variables: { name: 'Alice' } }, + { to: 'bob@example.com', variables: { name: 'Bob' } }, + ], + dryRun: true, + delayMs: 0, + }, mockContext); + + expect(result.sent).toBe(0); + expect(result.failed).toBe(0); + expect(result.previews).toHaveLength(2); + expect(result.previews![0]!.to).toBe('alice@example.com'); + expect(result.previews![0]!.subject).toBe('Hi Alice'); + expect(result.previews![0]!.body).toBe('Hello Alice!'); + expect(result.previews![0]!.wouldSend).toBe(false); + expect(result.previews![1]!.to).toBe('bob@example.com'); + expect(result.previews![1]!.subject).toBe('Hi Bob'); + + // Should NOT have called Gmail API + expect(mockGmailApi.users.messages.send).not.toHaveBeenCalled(); + }); + + test('continues on individual send failure and reports per-recipient status', async () => { + mockGmailApi.users.messages.send + .mockRejectedValueOnce(new Error('Gmail quota exceeded')) + .mockResolvedValueOnce({ + data: { id: 'msg-b', threadId: 'thread-b', labelIds: ['SENT'] }, + }); + + const result = await sendBatch({ + subject: 'Hi {{name}}', + template: 'Hello {{name}}', + recipients: [ + { to: 'fail@example.com', variables: { name: 'Fail' } }, + { to: 'pass@example.com', variables: { name: 'Pass' } }, + ], + delayMs: 0, + }, mockContext); + + expect(result.sent).toBe(1); + expect(result.failed).toBe(1); + expect(result.results).toHaveLength(2); + expect(result.results![0]!.status).toBe('failed'); + expect(result.results![0]!.error).toContain('Gmail quota exceeded'); + expect(result.results![1]!.status).toBe('sent'); + expect(result.results![1]!.messageId).toBe('msg-b'); + }); +}); diff --git a/src/modules/gmail/__tests__/sendFromTemplate.test.ts b/src/modules/gmail/__tests__/sendFromTemplate.test.ts new file mode 100644 index 0000000..ac04cc8 --- /dev/null +++ b/src/modules/gmail/__tests__/sendFromTemplate.test.ts @@ -0,0 +1,102 @@ +/** + * Tests for sendFromTemplate — template rendering + send + */ + +import { describe, test, expect, beforeEach, jest } from '@jest/globals'; +import { sendFromTemplate } from '../send.js'; + +describe('sendFromTemplate', () => { + let mockContext: any; + let mockGmailApi: any; + + beforeEach(() => { + mockGmailApi = { + users: { + messages: { + send: jest.fn(), + }, + }, + }; + + mockContext = { + gmail: mockGmailApi, + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + cacheManager: { + get: jest.fn(() => Promise.resolve(null)), + set: jest.fn(() => Promise.resolve(undefined)), + invalidate: jest.fn(() => Promise.resolve(undefined)), + }, + performanceMonitor: { + track: jest.fn(), + }, + startTime: Date.now(), + }; + }); + + test('renders template and sends email', async () => { + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'msg-001', + threadId: 'thread-001', + labelIds: ['SENT'], + }, + }); + + const result = await sendFromTemplate({ + to: ['amy@example.com'], + subject: 'Hey {{firstName}}', + template: 'Hello {{firstName}}, {{note}}', + variables: { firstName: 'Amy', note: 'quick follow-up' }, + }, mockContext); + + expect(result.messageId).toBe('msg-001'); + expect(result.threadId).toBe('thread-001'); + expect(result.rendered).toBe(true); + + // Verify sendMessage was called with rendered content + const call = mockGmailApi.users.messages.send.mock.calls[0][0]; + const raw = Buffer.from(call.requestBody.raw, 'base64').toString(); + expect(raw).toContain('Subject: Hey Amy'); + expect(raw).toContain('Hello Amy, quick follow-up'); + }); + + test('throws on missing template variable', async () => { + await expect(sendFromTemplate({ + to: ['test@example.com'], + subject: 'Hi {{firstName}}', + template: 'Hello {{firstName}}, {{missingVar}}', + variables: { firstName: 'Test' }, + }, mockContext)).rejects.toThrow('Missing template variable: missingVar'); + + // Should not have attempted to send + expect(mockGmailApi.users.messages.send).not.toHaveBeenCalled(); + }); + + test('HTML-escapes variables when isHtml is true', async () => { + mockGmailApi.users.messages.send.mockResolvedValue({ + data: { + id: 'msg-002', + threadId: 'thread-002', + labelIds: ['SENT'], + }, + }); + + await sendFromTemplate({ + to: ['bob@example.com'], + subject: 'Update for {{name}}', + template: '

Hello {{name}}, {{content}}

', + variables: { name: 'Bob', content: '' }, + isHtml: true, + }, mockContext); + + const call = mockGmailApi.users.messages.send.mock.calls[0][0]; + const raw = Buffer.from(call.requestBody.raw, 'base64').toString(); + expect(raw).toContain('<script>'); + expect(raw).not.toContain('' }, + true + ); + expect(result).toBe('

<script>alert("xss")</script>

'); + }); + + test('does not HTML-escape when isHtml is false', () => { + const result = renderTemplate( + 'Value: {{val}}', + { val: 'bold' }, + false + ); + expect(result).toBe('Value: bold'); + }); +}); diff --git a/src/modules/gmail/compose.ts b/src/modules/gmail/compose.ts index 76ab3ce..43c93e2 100644 --- a/src/modules/gmail/compose.ts +++ b/src/modules/gmail/compose.ts @@ -1,13 +1,16 @@ /** - * Gmail compose operations - createDraft + * Gmail compose operations - createDraft, dryRunMessage */ import type { GmailContext } from '../types.js'; import type { CreateDraftOptions, CreateDraftResult, + DryRunOptions, + DryRunResult, } from './types.js'; -import { buildEmailMessage, encodeToBase64Url } from './utils.js'; +import { buildEmailMessage, encodeToBase64Url, validateAndSanitizeRecipients } from './utils.js'; +import { renderTemplate } from './templates.js'; /** * Create a draft email @@ -66,3 +69,35 @@ export async function createDraft( message: 'Draft created successfully', }; } + +/** + * Preview a rendered templated email without sending. + * Pure function — does not call any APIs. + * + * Renders {{variable}} placeholders in both subject and template body, + * validates recipient addresses, and returns the fully rendered email + * for review before sending. + * + * @param options Template, variables, and recipients + * @returns Rendered email preview with wouldSend: false + */ +export function dryRunMessage(options: DryRunOptions): DryRunResult { + const { to, subject, template, variables, isHtml = false } = options; + + // Validate recipients (throws on invalid addresses) + const sanitizedTo = validateAndSanitizeRecipients(to, 'to'); + + // Render subject (never HTML-escaped — subjects are plain text headers) + const renderedSubject = renderTemplate(subject, variables, false); + + // Render body (HTML-escaped when isHtml is true) + const renderedBody = renderTemplate(template, variables, isHtml); + + return { + to: sanitizedTo, + subject: renderedSubject, + body: renderedBody, + isHtml, + wouldSend: false, + }; +} diff --git a/src/modules/gmail/detect-replies.ts b/src/modules/gmail/detect-replies.ts new file mode 100644 index 0000000..f5dabb3 --- /dev/null +++ b/src/modules/gmail/detect-replies.ts @@ -0,0 +1,88 @@ +/** + * Gmail reply detection — find replies by thread IDs + * + * Composes existing getThread + getProfile to identify replies from + * participants other than the authenticated user. + */ + +import type { GmailContext } from '../types.js'; + +export interface DetectRepliesOptions { + /** Array of Gmail thread IDs to check for replies */ + threadIds: string[]; +} + +interface ReplyInfo { + messageId: string; + from: string; + date: string; +} + +interface ThreadReplyResult { + threadId: string; + hasReply: boolean; + replies: ReplyInfo[]; +} + +export interface DetectRepliesResult { + threads: ThreadReplyResult[]; +} + +/** + * Check threads for replies from external participants. + * Filters out messages from the authenticated user (sender). + * + * @param options Thread IDs to check + * @param context Gmail API context + * @returns Per-thread reply data + */ +export async function detectReplies( + options: DetectRepliesOptions, + context: GmailContext +): Promise { + // Get authenticated user email + const profile = await context.gmail.users.getProfile({ userId: 'me' }); + const myEmail = (profile.data.emailAddress || '').toLowerCase(); + + const threads: ThreadReplyResult[] = []; + + for (const threadId of options.threadIds) { + try { + const threadData = await context.gmail.users.threads.get({ + userId: 'me', + id: threadId, + format: 'metadata', + metadataHeaders: ['From'], + }); + + const messages = threadData.data.messages || []; + const replies: ReplyInfo[] = []; + + for (const msg of messages) { + const fromHeader = msg.payload?.headers?.find( + (h) => h.name?.toLowerCase() === 'from' + ); + const from = fromHeader?.value || ''; + const fromEmail = from.match(/<([^>]+)>/)?.[1] || from; + + if (fromEmail.toLowerCase() !== myEmail) { + replies.push({ + messageId: msg.id || '', + from, + date: msg.internalDate + ? new Date(Number(msg.internalDate)).toISOString() + : '', + }); + } + } + + threads.push({ threadId, hasReply: replies.length > 0, replies }); + } catch (err) { + context.logger.error('Failed to check thread for replies', { threadId, error: err }); + threads.push({ threadId, hasReply: false, replies: [] }); + } + } + + context.performanceMonitor.track('gmail:detectReplies', Date.now() - context.startTime); + return { threads }; +} diff --git a/src/modules/gmail/index.ts b/src/modules/gmail/index.ts index 65a6e69..5e1770a 100644 --- a/src/modules/gmail/index.ts +++ b/src/modules/gmail/index.ts @@ -66,6 +66,16 @@ export type { MarkAsUnreadResult, ArchiveMessageOptions, ArchiveMessageResult, + // Template & outreach types + DryRunOptions, + DryRunResult, + SendFromTemplateOptions, + SendFromTemplateResult, + BatchRecipient, + BatchSendOptions, + BatchSendItemResult, + BatchPreviewItem, + BatchSendResult, } from './types.js'; // List operations @@ -78,10 +88,10 @@ export { getMessage, getThread } from './read.js'; export { searchMessages } from './search.js'; // Compose operations -export { createDraft } from './compose.js'; +export { createDraft, dryRunMessage } from './compose.js'; // Send operations -export { sendMessage, sendDraft } from './send.js'; +export { sendMessage, sendDraft, sendFromTemplate, sendBatch } from './send.js'; // Label operations export { listLabels, modifyLabels } from './labels.js'; @@ -97,3 +107,10 @@ export { listAttachments, downloadAttachment, sendWithAttachments } from './atta // Message management operations export { trashMessage, untrashMessage, deleteMessage, markAsRead, markAsUnread, archiveMessage } from './manage.js'; + +// Reply detection operations +export { detectReplies } from './detect-replies.js'; +export type { DetectRepliesOptions, DetectRepliesResult } from './detect-replies.js'; + +// Template operations +export { renderTemplate } from './templates.js'; diff --git a/src/modules/gmail/send.ts b/src/modules/gmail/send.ts index 9f7a73d..e5a7a56 100644 --- a/src/modules/gmail/send.ts +++ b/src/modules/gmail/send.ts @@ -1,5 +1,5 @@ /** - * Gmail send operations - sendMessage and sendDraft + * Gmail send operations - sendMessage, sendDraft, sendFromTemplate, sendBatch */ import type { gmail_v1 } from 'googleapis'; @@ -9,8 +9,15 @@ import type { SendMessageResult, SendDraftOptions, SendDraftResult, + SendFromTemplateOptions, + SendFromTemplateResult, + BatchSendOptions, + BatchSendResult, + BatchSendItemResult, + BatchPreviewItem, } from './types.js'; -import { buildEmailMessage, encodeToBase64Url } from './utils.js'; +import { buildEmailMessage, encodeToBase64Url, validateAndSanitizeRecipients } from './utils.js'; +import { renderTemplate } from './templates.js'; /** * Send a new email message @@ -141,3 +148,159 @@ export async function sendDraft( message: 'Draft sent successfully', }; } + +/** + * Simple delay helper — returns a promise that resolves after `ms` milliseconds. + */ +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Render a template and send the resulting email. + * + * Renders {{variable}} placeholders in both the subject and template body, + * then delegates to sendMessage() for the actual Gmail API call. + * + * @param options Template, variables, recipients + * @param context Gmail API context + * @returns Sent message info with `rendered: true` + */ +export async function sendFromTemplate( + options: SendFromTemplateOptions, + context: GmailContext +): Promise { + const { to, subject, template, variables, isHtml = false } = options; + + // Render subject (never HTML-escaped — subjects are plain text headers) + const renderedSubject = renderTemplate(subject, variables, false); + + // Render body (HTML-escaped when isHtml is true) + const renderedBody = renderTemplate(template, variables, isHtml); + + // Build sendMessage options, only including optional fields when present + const sendOpts: SendMessageOptions = { + to, + subject: renderedSubject, + body: renderedBody, + isHtml, + }; + // Validate recipients upfront (matches dryRunMessage behavior) + validateAndSanitizeRecipients(to, 'to'); + if (options.cc) { + validateAndSanitizeRecipients(options.cc, 'cc'); + sendOpts.cc = options.cc; + } + if (options.bcc) { + validateAndSanitizeRecipients(options.bcc, 'bcc'); + sendOpts.bcc = options.bcc; + } + if (options.from) { + sendOpts.from = options.from; + } + + const result = await sendMessage(sendOpts, context); + + return { + messageId: result.messageId, + threadId: result.threadId, + rendered: true, + }; +} + +/** + * Send a templated email to multiple recipients with per-recipient variables. + * + * Iterates through recipients sequentially with an optional delay between sends + * to respect Gmail API rate limits. Continues on individual send failure, + * reporting per-recipient status in the results. + * + * When `dryRun: true`, returns rendered previews without sending any emails. + * + * @param options Template, recipients, throttle settings + * @param context Gmail API context + * @returns Batch result with sent/failed counts and per-recipient details + */ +export async function sendBatch( + options: BatchSendOptions, + context: GmailContext +): Promise { + const { subject, template, recipients, delayMs = 5000, isHtml = false, dryRun = false, from } = options; + + // Dry-run mode: render all previews without sending + if (dryRun) { + const previews: BatchPreviewItem[] = recipients.map(recipient => { + const renderedSubject = renderTemplate(subject, recipient.variables, false); + const renderedBody = renderTemplate(template, recipient.variables, isHtml); + return { + to: recipient.to, + subject: renderedSubject, + body: renderedBody, + wouldSend: false as const, + }; + }); + + return { + sent: 0, + failed: 0, + previews, + }; + } + + // Live send mode: iterate recipients sequentially with throttling + const results: BatchSendItemResult[] = []; + let sent = 0; + let failed = 0; + + for (const [i, recipient] of recipients.entries()) { + try { + const renderedSubject = renderTemplate(subject, recipient.variables, false); + const renderedBody = renderTemplate(template, recipient.variables, isHtml); + + const sendOpts: SendMessageOptions = { + to: [recipient.to], + subject: renderedSubject, + body: renderedBody, + isHtml, + }; + if (from) { + sendOpts.from = from; + } + + const sendResult = await sendMessage(sendOpts, context); + + results.push({ + to: recipient.to, + messageId: sendResult.messageId, + threadId: sendResult.threadId, + status: 'sent', + }); + sent++; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + results.push({ + to: recipient.to, + messageId: '', + threadId: '', + status: 'failed', + error: errorMessage, + }); + failed++; + context.logger.error('Batch send failed for recipient', { + to: recipient.to, + error: errorMessage, + }); + } + + // Delay between sends (skip after last recipient) + if (delayMs > 0 && i < recipients.length - 1) { + await delay(delayMs); + } + } + + return { + sent, + failed, + results, + }; +} diff --git a/src/modules/gmail/templates.ts b/src/modules/gmail/templates.ts new file mode 100644 index 0000000..963d8ac --- /dev/null +++ b/src/modules/gmail/templates.ts @@ -0,0 +1,54 @@ +/** + * Template rendering utility for Gmail outreach operations. + * Shared by sendFromTemplate, sendBatch, and dryRun. + * + * Handles {{variable}} replacement in both subject and body strings. + * - Missing variables throw (fail loud, not silent blanks) + * - When isHtml is true, variable values are HTML-escaped before insertion + */ + +/** + * Escape HTML special characters to prevent XSS when inserting + * user-provided values into HTML email templates. + */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Render a template string by replacing {{variable}} placeholders + * with values from the provided variables object. + * + * @param template - Template string with {{variable}} placeholders + * @param variables - Key-value map of variable names to replacement values + * @param isHtml - When true, variable values are HTML-escaped before insertion (default: false) + * @returns Rendered string with all placeholders replaced + * @throws Error if a placeholder references a variable not present in the variables object + * + * @example + * ```typescript + * renderTemplate('Hello {{name}}!', { name: 'Amy' }); + * // => 'Hello Amy!' + * + * renderTemplate('

{{content}}

', { content: '