/// /** * Gmail Expect-Response Follow-up Pattern * * Monitors emails with an "expect-response" label, shows a dashboard of threads * awaiting responses, uses LLM to draft polite follow-up pings, and allows * easy sending via Gmail. * * Features: * - Dashboard showing email threads due for follow-up (filtered by age) * - Thread context display so user understands context * - LLM-generated polite follow-up emails asking for updates * - Send via embedded gmail-sender sub-pattern (with Review & Send confirmation) * - Ping tracking with suggestion to remove label after multiple unanswered pings * - Label management to remove "expect-response" when user gives up * * Usage: * 1. Deploy this pattern * 2. The pattern will auto-request Google auth (gmail, gmailSend, gmailModify scopes) * 3. Add the "expect-response" label to emails you're waiting on * 4. View threads, configure context, generate follow-ups, send or give up */ import { computed, derive, generateText, handler, ifElse, NAME, pattern, UI, Writable, } from "commontools"; import GmailExtractor, { type Auth, type Email, } from "../core/gmail-extractor.tsx"; import GmailSender from "../core/experimental/gmail-sender.tsx"; import { createGoogleAuth, type ScopeKey, } from "../core/util/google-auth-manager.tsx"; import { type GmailLabel, GmailSendClient, } from "../core/util/gmail-send-client.ts"; import type { Stream } from "commontools"; /** Email draft shape matching gmail-sender's expected input */ type EmailDraft = { to: string; subject: string; body: string; cc: string; bcc: string; replyToMessageId: string; replyToThreadId: string; }; // ============================================================================= // CONSTANTS // ============================================================================= const DEBUG = false; /** Thread context types - affects day calculation */ type ThreadContext = "personal" | "business" | "urgent"; /** Default settings per context */ const DEFAULT_SETTINGS: Record< ThreadContext, { days: number; maxPings: number } > = { personal: { days: 3, maxPings: 2 }, // Calendar days business: { days: 3, maxPings: 2 }, // Business days (Mon-Fri) urgent: { days: 1, maxPings: 3 }, // Calendar days, more persistent }; /** Personal email domains (suggest "personal" context) */ const PERSONAL_DOMAINS = [ "gmail.com", "yahoo.com", "hotmail.com", "outlook.com", "aol.com", "icloud.com", "me.com", "protonmail.com", "live.com", "msn.com", ]; // ============================================================================= // TYPES // ============================================================================= /** Per-thread settings (persisted) */ interface ThreadSettings { context: ThreadContext; daysThreshold: number; maxPings: number; } /** Per-thread metadata (persisted) */ interface ThreadMetadata { pingCount: number; settings: ThreadSettings; } /** Email type that accepts readonly arrays from reactive system */ type ReadonlyEmail = { readonly id: string; readonly threadId: string; readonly labelIds: readonly string[]; readonly snippet: string; readonly subject: string; readonly from: string; readonly date: string; readonly to: string; readonly plainText: string; readonly htmlContent: string; readonly markdownContent: string; }; /** Tracked thread (computed from emails + metadata) */ interface TrackedThread { threadId: string; subject: string; lastMessageDate: string; lastResponder: string; daysSinceLastResponse: number; pingCount: number; emails: readonly ReadonlyEmail[]; draftedFollowUp: string | null; settings: ThreadSettings; isDue: boolean; shouldGiveUp: boolean; /** Most recent message ID (for threading replies) */ lastMessageId: string; } // ============================================================================= // HELPERS // ============================================================================= /** * Calculate business days between two dates (excludes Saturdays and Sundays) */ function calculateBusinessDays(fromDate: Date, toDate: Date): number { let count = 0; const current = new Date(fromDate); current.setHours(0, 0, 0, 0); const end = new Date(toDate); end.setHours(0, 0, 0, 0); while (current < end) { current.setDate(current.getDate() + 1); const dayOfWeek = current.getDay(); if (dayOfWeek !== 0 && dayOfWeek !== 6) { count++; } } return count; } /** * Calculate calendar days between two dates */ function calculateCalendarDays(fromDate: Date, toDate: Date): number { const diff = toDate.getTime() - fromDate.getTime(); return Math.floor(diff / (1000 * 60 * 60 * 24)); } /** * Calculate days since date based on context */ function calculateDays( fromDateStr: string, context: ThreadContext, ): number { const fromDate = new Date(fromDateStr); const toDate = new Date(); if (context === "business") { return calculateBusinessDays(fromDate, toDate); } return calculateCalendarDays(fromDate, toDate); } /** * Extract domain from email address */ function getDomain(email: string): string { const match = email.match(/@([^@\s]+)$/); return match ? match[1].toLowerCase() : ""; } /** * Suggest context based on email domain */ function suggestContext(email: string): ThreadContext { const domain = getDomain(email); if (PERSONAL_DOMAINS.includes(domain)) { return "personal"; } return "business"; } /** * Format date for display */ function formatDate(dateStr: string): string { try { const date = new Date(dateStr); return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined, }); } catch { return dateStr; } } /** * Format days display based on context */ function formatDaysDisplay(days: number, context: ThreadContext): string { const unit = context === "business" ? "business day" : "day"; return days === 1 ? `1 ${unit}` : `${days} ${unit}s`; } // ============================================================================= // HANDLERS // ============================================================================= /** * Toggle expanded state for a thread */ const toggleExpanded = handler< unknown, { expandedThreads: Writable; threadId: string } >((_event, { expandedThreads, threadId }) => { const current = expandedThreads.get(); if (current.includes(threadId)) { expandedThreads.set(current.filter((id) => id !== threadId)); } else { expandedThreads.set([...current, threadId]); } }); /** * Update thread context */ const updateContext = handler< { target: { value: string } }, { threadMetadata: Writable>; threadId: string; } >(({ target }, { threadMetadata, threadId }) => { const newContext = target.value as ThreadContext; const current = threadMetadata.get(); const existing = current[threadId]; const defaults = DEFAULT_SETTINGS[newContext]; threadMetadata.set({ ...current, [threadId]: { pingCount: existing?.pingCount || 0, settings: { context: newContext, daysThreshold: defaults.days, maxPings: defaults.maxPings, }, }, }); }); /** * Update days threshold */ const updateDaysThreshold = handler< { target: { value: string } }, { threadMetadata: Writable>; threadId: string; context: ThreadContext; } >(({ target }, { threadMetadata, threadId, context }) => { const newDays = parseInt(target.value) || DEFAULT_SETTINGS[context].days; const current = threadMetadata.get(); const existing = current[threadId]; threadMetadata.set({ ...current, [threadId]: { pingCount: existing?.pingCount || 0, settings: { context: existing?.settings?.context || context, daysThreshold: newDays, maxPings: existing?.settings?.maxPings || DEFAULT_SETTINGS[context].maxPings, }, }, }); }); /** * Update max pings */ const updateMaxPings = handler< { target: { value: string } }, { threadMetadata: Writable>; threadId: string; context: ThreadContext; } >(({ target }, { threadMetadata, threadId, context }) => { const newMaxPings = parseInt(target.value) || DEFAULT_SETTINGS[context].maxPings; const current = threadMetadata.get(); const existing = current[threadId]; threadMetadata.set({ ...current, [threadId]: { pingCount: existing?.pingCount || 0, settings: { context: existing?.settings?.context || context, daysThreshold: existing?.settings?.daysThreshold || DEFAULT_SETTINGS[context].days, maxPings: newMaxPings, }, }, }); }); /** * Update draft text */ const updateDraft = handler< { target: { value: string } }, { drafts: Writable>; threadId: string } >(({ target }, { drafts, threadId }) => { const current = drafts.get(); drafts.set({ ...current, [threadId]: target.value, }); }); /** * Prepare to send follow-up via gmail-sender sub-pattern. * Populates the senderDraft with thread reply data and sets activeSendThread. */ const prepareSend = handler< unknown, { senderDraft: Writable; activeSendThread: Writable; drafts: Writable>; thread: TrackedThread; } >((_event, { senderDraft, activeSendThread, drafts, thread }) => { const threadId = thread.threadId; const draftBody = drafts.get()[threadId]; if (!draftBody) { console.error("[ExpectResponse] No draft to send"); return; } // Populate gmail-sender's draft input senderDraft.set({ to: thread.lastResponder, subject: thread.subject.startsWith("Re:") ? thread.subject : `Re: ${thread.subject}`, body: draftBody, cc: "", bcc: "", replyToMessageId: thread.lastMessageId, replyToThreadId: threadId, }); // Track which thread is being sent activeSendThread.set(threadId); }); /** * Cancel the send flow - clear activeSendThread and reset senderDraft. */ const cancelSendFlow = handler< unknown, { senderDraft: Writable; activeSendThread: Writable; } >((_event, { senderDraft, activeSendThread }) => { activeSendThread.set(null); senderDraft.set({ to: "", subject: "", body: "", cc: "", bcc: "", replyToMessageId: "", replyToThreadId: "", }); }); /** * Give up on a thread (remove expect-response label) */ const giveUp = handler< unknown, { removeLabels: Stream<{ messageId: string; labels: string[] }>; thread: TrackedThread; expectResponseLabelId: Writable; hiddenThreads: Writable; } >((_event, { removeLabels, thread, expectResponseLabelId, hiddenThreads }) => { const labelId = expectResponseLabelId.get(); if (!labelId) { console.error("[ExpectResponse] expect-response label ID not found"); return; } // Remove label from all messages in thread for (const email of thread.emails) { removeLabels.send({ messageId: email.id, labels: [labelId] }); } // Hide thread from display const current = hiddenThreads.get(); hiddenThreads.set([...current, thread.threadId]); if (DEBUG) { console.log("[ExpectResponse] Gave up on thread:", thread.threadId); } }); /** * Fetch labels to find expect-response label ID */ const fetchLabels = handler< unknown, { auth: Writable; expectResponseLabelId: Writable; loadingLabels: Writable; } >(async (_event, { auth, expectResponseLabelId, loadingLabels }) => { if (!auth.get()) { console.error("[ExpectResponse] No auth available for fetching labels"); return; } loadingLabels.set(true); try { const client = new GmailSendClient(auth, { debugMode: DEBUG }); const labels = await client.listLabels(); // Find expect-response label (case-insensitive) const targetLabel = labels.find( (l: GmailLabel) => l?.name?.toLowerCase() === "expect-response", ); if (targetLabel) { expectResponseLabelId.set(targetLabel.id); if (DEBUG) { console.log( "[ExpectResponse] Found expect-response label:", targetLabel.id, ); } } else { console.warn("[ExpectResponse] expect-response label not found"); } } catch (error) { console.error("[ExpectResponse] Failed to fetch labels:", error); } finally { loadingLabels.set(false); } }); /** * Toggle settings panel for a thread */ const toggleSettings = handler< unknown, { settingsOpenFor: Writable; threadId: string } >((_event, { settingsOpenFor, threadId }) => { const current = settingsOpenFor.get(); settingsOpenFor.set(current === threadId ? null : threadId); }); /** * Start generating a draft for a thread */ const startDraftGeneration = handler< unknown, { threadId: string; generatingDraftFor: Writable; drafts: Writable>; } >((_event, { threadId, generatingDraftFor, drafts }) => { // Clear any existing draft so we generate fresh const current = drafts.get(); const { [threadId]: _removed, ...remaining } = current; drafts.set(remaining); // Trigger generation generatingDraftFor.set(threadId); }); // ============================================================================= // PATTERN // ============================================================================= // deno-lint-ignore no-empty-interface interface PatternInput { // No inputs needed - pattern manages its own auth via createGoogleAuth() } /** Gmail expect-response follow-up manager. #expectResponseFollowup */ interface PatternOutput { threads: TrackedThread[]; threadCount: number; dueCount: number; } export default pattern(() => { // ========================================================================== // STATE // ========================================================================== // Persisted state const threadMetadata = Writable.of>( {}, ).for("threadMetadata"); const hiddenThreads = Writable.of([]).for("hiddenThreads"); // UI state const expandedThreads = Writable.of([]).for("expandedThreads"); const drafts = Writable.of>({}).for("drafts"); const expectResponseLabelId = Writable.of("").for("expectResponseLabelId"); // Gmail-sender integration state const emptyDraft: EmailDraft = { to: "", subject: "", body: "", cc: "", bcc: "", replyToMessageId: "", replyToThreadId: "", }; const senderDraft = Writable.of(emptyDraft).for("senderDraft"); const activeSendThread = Writable.of(null).for( "activeSendThread", ); const loadingLabels = Writable.of(false).for("loadingLabels"); const settingsOpenFor = Writable.of(null).for( "settingsOpenFor", ); const sortOldestFirst = Writable.of(true).for("sortOldestFirst"); // ========================================================================== // AUTH // ========================================================================== // Use createGoogleAuth to handle authentication with the wish system // This will auto-request a google-auth piece if one doesn't exist const { auth, fullUI: authUI, isReady, currentEmail, } = createGoogleAuth({ requiredScopes: ["gmail", "gmailModify"] as ScopeKey[], }); // ========================================================================== // GMAIL EXTRACTOR // ========================================================================== const extractor = GmailExtractor({ gmailQuery: "label:expect-response", limit: 100, overrideAuth: auth, }); const allEmails = extractor.emails; // ========================================================================== // THREAD PROCESSING // ========================================================================== /** * Group emails by threadId and compute thread state */ const threads = computed((): TrackedThread[] => { const emails = allEmails || []; const hidden = hiddenThreads.get(); const metadata = threadMetadata.get(); // User email from createGoogleAuth - used to filter out threads where user sent last message const currentUserEmail = (currentEmail || "").toLowerCase(); // Group by threadId const threadMap = new Map(); for (const email of emails) { if (!email.threadId) continue; if (hidden.includes(email.threadId)) continue; const existing = threadMap.get(email.threadId) || []; existing.push(email); threadMap.set(email.threadId, existing); } // Process each thread const result: TrackedThread[] = []; for (const [threadId, threadEmails] of threadMap) { // Sort by date ascending threadEmails.sort( (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), ); const lastEmail = threadEmails[threadEmails.length - 1]; const lastResponder = lastEmail.from.toLowerCase(); // Skip threads where user sent the last message (not waiting for response) if (lastResponder === currentUserEmail) { continue; } // Get or create metadata const meta = metadata[threadId]; const suggestedContext = suggestContext(lastResponder); const defaults = DEFAULT_SETTINGS[suggestedContext]; const settings: ThreadSettings = meta?.settings || { context: suggestedContext, daysThreshold: defaults.days, maxPings: defaults.maxPings, }; const pingCount = meta?.pingCount || 0; // Calculate days const daysSinceLastResponse = calculateDays( lastEmail.date, settings.context, ); // Determine if due const isDue = daysSinceLastResponse >= settings.daysThreshold; // Determine if should give up const shouldGiveUp = pingCount >= settings.maxPings; result.push({ threadId, subject: lastEmail.subject || "(No Subject)", lastMessageDate: lastEmail.date, lastResponder: lastEmail.from, daysSinceLastResponse, pingCount, emails: threadEmails, draftedFollowUp: null, // Drafts are computed separately in allDrafts settings, isDue, shouldGiveUp, lastMessageId: lastEmail.id, }); } // Sort by days waiting (oldest first by default) const oldest = sortOldestFirst.get(); result.sort((a, b) => { if (oldest) { return b.daysSinceLastResponse - a.daysSinceLastResponse; } return a.daysSinceLastResponse - b.daysSinceLastResponse; }); return result; }); const threadCount = computed(() => threads?.length || 0); const dueCount = computed( () => threads?.filter((t) => t.isDue)?.length || 0, ); // ========================================================================== // LLM DRAFT GENERATION // ========================================================================== // Track which thread is currently generating a draft const generatingDraftFor = Writable.of(null).for( "generatingDraftFor", ); // Single generateText call - only runs when a thread is selected for drafting // Uses computed prompt that builds from the selected thread's data // NOTE: When prompt is falsy/undefined, generateText should skip the API call const draftLlmResult = generateText({ prompt: computed((): string | undefined => { const threadId = generatingDraftFor.get(); if (!threadId) return undefined; // No thread selected, skip generation // Find the thread const currentThread = (threads || []).find((t) => t.threadId === threadId ); if (!currentThread) return undefined; const emailArray = Array.from(currentThread.emails || []); const threadSummary = emailArray .slice(-5) .map((email) => { const date = formatDate(String(email.date || "")); const from = String(email.from || ""); const snippetStr = String(email.snippet || ""); const snippet = snippetStr.slice(0, 200); return `[${date}] From: ${from}\n${snippet}`; }) .join("\n\n"); const subject = String(currentThread.subject || "(No Subject)"); const lastResponder = String(currentThread.lastResponder || ""); const context = currentThread.settings?.context || "personal"; const daysWaiting = formatDaysDisplay( Number(currentThread.daysSinceLastResponse) || 0, context, ); const pingCount = Number(currentThread.pingCount) || 0; return `Based on this email thread, draft a brief, polite follow-up email asking for an update. Keep it professional and friendly. Reference the original subject matter. Don't be pushy. Make it 2-3 sentences max. Do not include a subject line - only the body text. Thread summary: - Subject: ${subject} - Last message from: ${lastResponder} - Days waiting: ${daysWaiting} - Previous ping count: ${pingCount} Original context: ${threadSummary} Write only the email body, no subject line or greeting line (the greeting will be auto-added):`; }), system: "You are a helpful assistant that drafts professional follow-up emails.", model: "anthropic:claude-sonnet-4-5", }); // Auto-save LLM draft to drafts Writable when generation completes // This ensures the draft is available for the send handler const _autoSaveLlmDraft = computed(() => { const threadId = generatingDraftFor.get(); const result = draftLlmResult.result; const isPending = draftLlmResult.pending; // Only save when generation completes with a result if (!isPending && result && threadId) { const current = drafts.get(); // Idempotent check: only mutate if value changed if (current[threadId] !== result) { drafts.set({ ...current, [threadId]: result, }); } } return null; }); // ========================================================================== // GMAIL SENDER SUB-PATTERN // ========================================================================== const sender = GmailSender({ draft: senderDraft }); // Watch sender.result for successful sends → do bookkeeping const _autoHandleSendResult = computed(() => { const senderResult = sender.result; const threadId = activeSendThread.get(); if (senderResult?.success && threadId) { // Increment ping count const currentMeta = threadMetadata.get(); const existing = currentMeta[threadId]; threadMetadata.set({ ...currentMeta, [threadId]: { ...existing, pingCount: (existing?.pingCount || 0) + 1, }, }); // Clear draft const currentDrafts = drafts.get(); const { [threadId]: _removed, ...remainingDrafts } = currentDrafts; drafts.set(remainingDrafts); // Clear send state activeSendThread.set(null); senderDraft.set({ to: "", subject: "", body: "", cc: "", bcc: "", replyToMessageId: "", replyToThreadId: "", }); } return null; }); // ========================================================================== // UI // ========================================================================== const contextBadgeColors: Record< ThreadContext, { bg: string; text: string } > = { personal: { bg: "#dbeafe", text: "#1d4ed8" }, business: { bg: "#fef3c7", text: "#b45309" }, urgent: { bg: "#fee2e2", text: "#dc2626" }, }; return { [NAME]: "Expect Response Followup", threads, threadCount, dueCount, [UI]: (
Expect Response ({dueCount} due / {threadCount} total) Oldest first
{/* Auth UI - handles authentication via wish system */} {authUI} {/* Refresh button when authenticated */} {ifElse( isReady,
{threadCount} threads awaiting response
, null, )} {/* Label status */} {ifElse( derive( { isReady, expectResponseLabelId, loadingLabels }, ({ isReady, expectResponseLabelId, loadingLabels }) => isReady && (!expectResponseLabelId || loadingLabels), ),
{ifElse( loadingLabels, Loading labels..., expect-response label not found - click Load Labels , )}
, null, )} {/* Threads list */} {ifElse( derive(threadCount, (count) => count === 0),
No threads awaiting response
Add the "expect-response" label to emails you're waiting on.
, {threads.map((thread) => { const uiThreadId = thread.threadId; const isExpanded = computed(() => expandedThreads.get().includes(uiThreadId) ); const isSendingThis = computed(() => activeSendThread.get() === uiThreadId ); const settingsOpen = computed(() => settingsOpenFor.get() === uiThreadId ); return (
t.shouldGiveUp ? "2px solid #f97316" : t.isDue ? "2px solid #3b82f6" : "1px solid #e5e7eb", ), overflow: "hidden", }} > {/* Thread header */}
t.shouldGiveUp ? "#fff7ed" : t.isDue ? "#eff6ff" : "#f9fafb", ), borderBottom: "1px solid #e5e7eb", }} >
{/* Subject */}
{derive(thread, (t) => t.subject)}
{/* Ping count badge */} {ifElse( derive(thread, (t) => t.pingCount > 0), {derive(thread, (t) => t.pingCount)} pings , null, )} {/* Context badge */}
{/* Metadata row */}
From: {derive(thread, (t) => t.lastResponder)} Last: {derive( thread, (t) => formatDate(t.lastMessageDate), )} (t.isDue ? "#dc2626" : "#059669"), ), }} > {derive( thread, (t) => formatDaysDisplay( t.daysSinceLastResponse, t.settings.context, ), )} waiting
{/* Settings panel (collapsible) */} {ifElse( settingsOpen,
, null, )} {/* Thread history (expandable) */} {ifElse( isExpanded,
{derive(thread, (t) => t.emails.map((email, i) => (
{email.from} {formatDate(email.date)}
{email.snippet?.slice(0, 200)} {(email.snippet?.length || 0) > 200 ? "..." : ""}
)))}
, null, )} {/* Draft / Send area */}
{ifElse( isSendingThis, // Gmail-sender UI shown inline when this thread is active
{sender}
, // Draft editing UI (() => { const isGenerating = computed(() => generatingDraftFor.get() === uiThreadId && draftLlmResult.pending ); const hasDraft = computed(() => { const d = drafts.get(); if (d[uiThreadId]) return true; const genFor = generatingDraftFor.get(); if ( genFor === uiThreadId && !draftLlmResult.pending && draftLlmResult.result ) { return true; } return false; }); const draftText = computed((): string => { const d = drafts.get(); if (d[uiThreadId]) return String(d[uiThreadId]); const genFor = generatingDraftFor.get(); if (genFor === uiThreadId) { const result = draftLlmResult.result; const error = draftLlmResult.error; return String(result || error || ""); } return ""; }); return ifElse( isGenerating,
Generating follow-up draft...
, ifElse( hasDraft,