/// /** * Email Notes Pattern * * Fetches emails with `label:task-current` that have no subject (notes sent to self), * displays them with copy buttons, and allows marking them as "done" by removing * the `task-current` label. * * Features: * - Embeds gmail-importer directly (like usps-informed-delivery.tsx) * - Pre-configured with task-current label filter * - Filters client-side to only show emails with empty subjects * - Copy note content to clipboard * - Mark as Done removes the task-current label * * Usage: * 1. Deploy this pattern * 2. Authenticate with Google (will auto-prompt) * 3. View notes, copy content, mark as done */ import { computed, derive, handler, ifElse, NAME, pattern, UI, Writable, } from "commontools"; import GmailExtractor, { type Email } from "../core/gmail-extractor.tsx"; import type { Auth } from "../core/util/google-auth-manager.tsx"; import { type GmailLabel, GmailSendClient, } from "../core/util/gmail-send-client.ts"; import { createGoogleAuth, type ScopeKey, } from "../core/util/google-auth-manager.tsx"; import ProcessingStatus from "../core/processing-status.tsx"; import type { Stream } from "commontools"; // Debug flag for development const DEBUG_NOTES = false; // ============================================================================= // TYPES // ============================================================================= interface Note { id: string; content: string; // Markdown content for display htmlContent: string; // Original HTML for rich-text copying date: string; snippet: string; } // ============================================================================= // HELPERS // ============================================================================= /** * Clean up content - minimal cleanup now that UTF-8 decoding is fixed */ function cleanContent(content: string): string { return content // Normalize multiple spaces .replace(/ +/g, " ") .trim(); } /** * Format date for display (relative dates for recent, otherwise short format) */ function formatDate(dateStr: string): string { try { const date = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) { return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); } else if (diffDays === 1) { return "Yesterday"; } else if (diffDays < 7) { return date.toLocaleDateString([], { weekday: "short" }); } else { return date.toLocaleDateString([], { month: "short", day: "numeric" }); } } catch { return dateStr; } } // ============================================================================= // HANDLERS // ============================================================================= /** * Mark a note as done by removing the task-current label */ const markAsDone = handler< unknown, { removeLabels: Stream<{ messageId: string; labels: string[] }>; noteId: string; taskCurrentLabelId: Writable; hiddenNotes: Writable; processingNotes: Writable; } >( ( _event, { removeLabels, noteId, taskCurrentLabelId, hiddenNotes, processingNotes }, ) => { const labelId = taskCurrentLabelId.get(); if (!labelId) { console.error("[EmailNotes] task-current label ID not found"); return; } // Add to processing list const currentProcessing = processingNotes.get(); processingNotes.set([...currentProcessing, noteId]); try { // Dispatch to extractor's removeLabels stream removeLabels.send({ messageId: noteId, labels: [labelId] }); if (DEBUG_NOTES) { console.log("[EmailNotes] Marked as done:", noteId); } // Add to hidden list to remove from display const currentHidden = hiddenNotes.get(); hiddenNotes.set([...currentHidden, noteId]); } catch (err) { console.error("[EmailNotes] Failed to mark as done:", err); } finally { // Remove from processing list const stillProcessing = processingNotes.get().filter((id) => id !== noteId ); processingNotes.set(stillProcessing); } }, ); /** * Fetch labels to find task-current label ID */ const fetchLabels = handler< unknown, { auth: Writable; taskCurrentLabelId: Writable; loadingLabels: Writable; } >(async (_event, { auth, taskCurrentLabelId, loadingLabels }) => { loadingLabels.set(true); try { const client = new GmailSendClient(auth, { debugMode: DEBUG_NOTES }); const labels = await client.listLabels(); // Find task-current label (case-insensitive) const taskLabel = labels.find( (l: GmailLabel) => l?.name?.toLowerCase() === "task-current", ); if (taskLabel) { taskCurrentLabelId.set(taskLabel.id); if (DEBUG_NOTES) { console.log("[EmailNotes] Found task-current label:", taskLabel.id); } } else { console.warn("[EmailNotes] task-current label not found"); } } catch (error) { console.error("[EmailNotes] Failed to fetch labels:", error); } finally { loadingLabels.set(false); } }); // ============================================================================= // PATTERN // ============================================================================= // deno-lint-ignore no-empty-interface interface PatternInput { // No inputs needed - pattern manages its own auth via createGoogleAuth() } /** Email notes manager for quick notes sent to self. #emailNotes */ interface PatternOutput { notes: Note[]; noteCount: number; previewUI: unknown; } export default pattern(() => { // State for label operations const taskCurrentLabelId = Writable.of("").for("taskCurrentLabelId"); const loadingLabels = Writable.of(false).for("loadingLabels"); const hiddenNotes = Writable.of([]).for("hiddenNotes"); const processingNotes = Writable.of([]).for("processingNotes"); const sortNewestFirst = Writable.of(true).for("sortNewestFirst"); // Use createGoogleAuth for scopes that include gmailModify // Note: We use auth directly (not wrapped in ifElse) because GmailSendClient // requires a Writable with .get() method. Wrapping in ifElse() creates // a derived value that loses writability. const { auth, fullUI: authUI, isReady, } = createGoogleAuth({ requiredScopes: ["gmail", "gmailModify"] as ScopeKey[], }); // Create a Stream from the fetchLabels handler for auto-triggering const labelFetcherStream = fetchLabels({ auth, taskCurrentLabelId, loadingLabels, }); // NOTE: Auto-fetch labels was removed because having side effects (writes to // Writable cells, sending to streams) inside computed() caused infinite reactive // loops that kept CPU at 88%. Users can click "Load Labels" button when needed. // See docs/common/concepts/computed/side-effects.md for why computed() with // side effects is problematic. // Directly instantiate GmailExtractor with task-current filter (raw mode) // Note: Gmail API doesn't support subject:"" for empty subjects, so we only // filter by label here and do client-side filtering for empty subjects // Pass auth directly to maintain Writable for token refresh const extractor = GmailExtractor({ gmailQuery: "label:task-current", limit: 100, overrideAuth: auth, }); // Get emails from extractor const allEmails = extractor.emails; // Filter for notes (empty subject) and exclude hidden ones const notes = computed(() => { const emails = allEmails || []; const hidden = new Set(hiddenNotes.get()); return emails .filter((email: Email) => { // Filter for empty/missing subject const hasNoSubject = !email.subject || email.subject.trim() === ""; // Exclude hidden notes const isNotHidden = !hidden.has(email.id); return hasNoSubject && isNotHidden; }) .map((email: Email) => ({ id: email.id, content: cleanContent( email.markdownContent || email.plainText || email.snippet, ), htmlContent: cleanContent( email.htmlContent || email.plainText || email.snippet, ), date: email.date, snippet: email.snippet, })) .sort((a, b) => { const dateA = new Date(a.date).getTime(); const dateB = new Date(b.date).getTime(); return sortNewestFirst.get() ? dateB - dateA : dateA - dateB; }); }); const noteCount = computed(() => notes?.length || 0); // No processing/analysis in this pattern, so pending is always 0 const pendingCount = computed(() => 0); const completedCount = computed(() => noteCount); // Preview UI for compact display const previewUI = (
{noteCount}
Email Notes
{derive(noteCount, (count) => count === 1 ? "1 note" : `${count} notes`)}
{/* Loading/progress indicator */}
); return { [NAME]: "Email Notes", notes, noteCount, previewUI, [UI]: (
Email Notes ({noteCount} notes) Newest first
{/* Auth UI */} {authUI} {/* Connection status and refresh */} {ifElse( isReady,
Connected {noteCount} notes found
, null, )} {/* Label status - only show when there's a problem or loading */} {ifElse( derive( { isReady, taskCurrentLabelId, loadingLabels }, ({ isReady, taskCurrentLabelId, loadingLabels }) => isReady && (!taskCurrentLabelId || loadingLabels), ),
{ifElse( loadingLabels, Loading labels..., task-current label not found - click Load Labels , )}
, null, )} {/* Notes list */} {ifElse( derive(noteCount, (count) => count === 0),
No notes found
Send yourself an email with no subject and the label "task-current" to see it here.
, {notes.map((note) => { // Check if this note is being processed // Extract noteId before computed to avoid OpaqueRef issues const noteId = note.id; const isProcessing = computed(() => (processingNotes.get() || []).includes(noteId) ); return (
{/* Header with date and actions */}
{derive(note, (n) => formatDate(n.date))}
{/* Copy button - copies both plain text and HTML for rich pasting */} ({ "text/plain": n.content, "text/html": n.htmlContent, }))} variant="outline" size="sm" /> {/* Mark as Done button */}
{/* Note content - rendered as markdown */} n.content)} compact style="font-size: 14px; line-height: 1.5; color: #374151;" />
); })}
, )}
), }; });