email drafts

This commit is contained in:
Arjun 2026-05-13 23:15:43 +05:30
parent 38b1f78ede
commit 75e0f50855
3 changed files with 154 additions and 18 deletions

View file

@ -398,6 +398,14 @@ function ComposeBox({
const latest = latestMessage(thread)
const to = mode === 'reply' ? extractAddress(latest?.from) : ''
const initialContent = useMemo(() => {
if (mode !== 'reply' || !thread.draft_response) return ''
return thread.draft_response
.split(/\n{2,}/)
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`)
.join('')
}, [mode, thread.draft_response])
const editor = useEditor({
extensions: [
StarterKit,
@ -409,7 +417,7 @@ function ComposeBox({
editorProps: {
attributes: { class: 'gmail-compose-content' },
},
content: '',
content: initialContent,
})
const [linkOpen, setLinkOpen] = useState(false)

View file

@ -1,7 +1,10 @@
import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import { generateObject } from 'ai';
import { google } from 'googleapis';
import type { OAuth2Client } from 'google-auth-library';
import { WorkDir } from '../config/config.js';
import { createProvider } from '../models/models.js';
import {
getDefaultModelAndProvider,
@ -11,6 +14,74 @@ import {
import { captureLlmUsage } from '../analytics/usage.js';
import type { GmailThreadSnapshot } from './sync_gmail.js';
const STYLE_GUIDE_PATH = path.join(WorkDir, 'knowledge', 'Agent Notes', 'style', 'email.md');
const CALENDAR_DIR = path.join(WorkDir, 'calendar_sync');
const CALENDAR_LOOKAHEAD_DAYS = 7;
const MAX_CALENDAR_EVENTS = 25;
function readEmailStyleGuide(): string | null {
try {
const raw = fs.readFileSync(STYLE_GUIDE_PATH, 'utf-8').trim();
return raw || null;
} catch {
return null;
}
}
interface CalendarSlice {
summary: string;
startIso: string;
endIso?: string;
}
function readUpcomingCalendar(): CalendarSlice[] {
if (!fs.existsSync(CALENDAR_DIR)) return [];
const now = Date.now();
const cutoff = now + CALENDAR_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000;
const out: CalendarSlice[] = [];
let names: string[];
try {
names = fs.readdirSync(CALENDAR_DIR);
} catch {
return [];
}
for (const name of names) {
if (!name.endsWith('.json')) continue;
try {
const raw = fs.readFileSync(path.join(CALENDAR_DIR, name), 'utf-8');
const ev = JSON.parse(raw) as {
summary?: string;
start?: { dateTime?: string; date?: string };
end?: { dateTime?: string; date?: string };
status?: string;
};
if (ev.status === 'cancelled') continue;
const startStr = ev.start?.dateTime ?? ev.start?.date;
if (!startStr) continue;
const startMs = Date.parse(startStr);
if (Number.isNaN(startMs)) continue;
if (startMs < now || startMs > cutoff) continue;
out.push({
summary: ev.summary || '(no title)',
startIso: startStr,
endIso: ev.end?.dateTime ?? ev.end?.date,
});
} catch {
// skip malformed
}
}
out.sort((a, b) => Date.parse(a.startIso) - Date.parse(b.startIso));
return out.slice(0, MAX_CALENDAR_EVENTS);
}
function formatCalendar(events: CalendarSlice[]): string {
if (events.length === 0) return '(no upcoming events)';
return events.map((e) => {
const end = e.endIso ? ` ${e.endIso}` : '';
return `- ${e.startIso}${end}: ${e.summary}`;
}).join('\n');
}
let cachedUserEmail: string | null = null;
export async function getUserEmail(auth: OAuth2Client): Promise<string | null> {
@ -31,23 +102,46 @@ export async function getUserEmail(auth: OAuth2Client): Promise<string | null> {
export interface Classification {
importance: 'important' | 'other';
summary?: string;
draftResponse?: string;
}
const ClassificationSchema = z.object({
importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'),
summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
});
const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view.
const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user.
# Importance
Decide if the thread is "important" or "other":
- important: real human correspondence the user is part of (customer, investor, team, vendor, candidate); a time-sensitive notification; a message that needs a response from the user; anything worth referencing later (contracts, pricing, deadlines, decisions).
- other: newsletters, industry digests, marketing or promotional, product tips from vendors, automated notifications (verifications, recording uploads, platform policy updates), transactional confirmations (payment receipts, GST/tax filings, salary disbursements), unsolicited cold outreach.
When the thread is important, write a 1-2 sentence summary that captures the gist and any action implied (e.g. "Customer requesting a demo next Tuesday; needs a calendar link." or "Investor following up on Q3 metrics; reply expected."). Omit the summary when the thread is "other".
# Summary (important only)
Be decisive pick exactly one label. Do not hedge.`;
When the thread is important, write a 1-2 sentence summary that captures the gist and any action implied. Omit when "other".
# Draft response (important only)
When the thread is important AND a reply is reasonably expected from the user, write a complete draft reply they could send as-is.
Apply the user's email-style guide (when provided below) match their tone, sign-off, length, and phrasing patterns. If no style guide is provided, default to a brief, warm, professional voice.
For scheduling-related threads (where the sender proposes meeting times, asks for the user's availability, or follows up on a meeting request), look at the user's upcoming calendar (provided below) and either:
- Propose 2-3 specific time windows from genuinely free slots, or
- Confirm/decline a specific time the sender proposed, based on calendar conflicts.
Use the same timezone the user appears to operate in (inferable from their previous messages or calendar events).
Omit the draft when:
- importance is "other"
- the thread is purely informational and doesn't ask for a reply
- the latest message is from the user (they already replied; no draft needed)
- you can't write a meaningful reply without information you don't have (don't fabricate)
Be decisive pick exactly one importance label. Do not hedge.`;
function userReplied(snapshot: GmailThreadSnapshot, userEmail: string | null): boolean {
if (!userEmail) return false;
@ -55,22 +149,50 @@ function userReplied(snapshot: GmailThreadSnapshot, userEmail: string | null): b
return snapshot.messages.some(m => (m.from || '').toLowerCase().includes(needle));
}
function buildPrompt(snapshot: GmailThreadSnapshot): string {
function buildPrompt(
snapshot: GmailThreadSnapshot,
userEmail: string | null,
styleGuide: string | null,
calendar: CalendarSlice[],
): string {
const lines: string[] = [];
if (userEmail) {
lines.push(`# Your identity`);
lines.push(`The user's own email is ${userEmail}. You write as this person when drafting replies.`);
lines.push('');
}
if (styleGuide) {
lines.push(`# Email style guide`);
lines.push(styleGuide);
lines.push('');
}
lines.push(`# User's upcoming calendar (next ${CALENDAR_LOOKAHEAD_DAYS} days)`);
lines.push(formatCalendar(calendar));
lines.push('');
lines.push(`# Thread to classify`);
lines.push(`Subject: ${snapshot.subject || '(no subject)'}`);
lines.push(`Message count: ${snapshot.messages.length}`);
lines.push('');
const latest = snapshot.messages[snapshot.messages.length - 1];
if (latest) {
lines.push(`Latest message:`);
lines.push(` From: ${latest.from || 'unknown'}`);
if (latest.to) lines.push(` To: ${latest.to}`);
if (latest.date) lines.push(` Date: ${latest.date}`);
for (let i = 0; i < snapshot.messages.length; i += 1) {
const msg = snapshot.messages[i];
const isLast = i === snapshot.messages.length - 1;
lines.push(`## Message ${i + 1}${isLast ? ' (latest)' : ''}`);
lines.push(`From: ${msg.from || 'unknown'}`);
if (msg.to) lines.push(`To: ${msg.to}`);
if (msg.date) lines.push(`Date: ${msg.date}`);
const body = (msg.body || '').replace(/\s+/g, ' ').slice(0, isLast ? 2000 : 600).trim();
if (body) {
lines.push('');
lines.push(body);
}
lines.push('');
const snippet = (latest.body || '').replace(/\s+/g, ' ').slice(0, 1200).trim();
lines.push(` Body:`);
lines.push(` ${snippet}`);
}
return lines.join('\n');
}
@ -83,6 +205,9 @@ export async function classifyThread(
}
try {
const styleGuide = readEmailStyleGuide();
const calendar = readUpcomingCalendar();
const modelId = await getKgModel();
const { provider } = await getDefaultModelAndProvider();
const config = await resolveProviderConfig(provider);
@ -91,7 +216,7 @@ export async function classifyThread(
const result = await generateObject({
model,
system: SYSTEM_PROMPT,
prompt: buildPrompt(snapshot),
prompt: buildPrompt(snapshot, userEmail, styleGuide, calendar),
schema: ClassificationSchema,
});
@ -104,8 +229,9 @@ export async function classifyThread(
});
const out: Classification = { importance: result.object.importance };
if (result.object.importance === 'important' && result.object.summary) {
out.summary = result.object.summary;
if (result.object.importance === 'important') {
if (result.object.summary) out.summary = result.object.summary;
if (result.object.draftResponse) out.draftResponse = result.object.draftResponse;
}
return out;
} catch (err) {

View file

@ -82,6 +82,7 @@ export interface GmailThreadSnapshot {
past_summary?: string;
unread?: boolean;
importance?: 'important' | 'other';
draft_response?: string;
messages: Array<{
id?: string;
from?: string;
@ -405,6 +406,7 @@ export async function fetchThreadSnapshot(threadId: string, expectedHistoryId?:
const classification = await classifyThread(snapshot, userEmail);
snapshot.importance = classification.importance;
if (classification.summary) snapshot.summary = classification.summary;
if (classification.draftResponse) snapshot.draft_response = classification.draftResponse;
} catch (err) {
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
}