From 08a36de23a48209a9d55afbe1d241f8a936ca00f Mon Sep 17 00:00:00 2001 From: Nizzy Nizzman Date: Fri, 22 Aug 2025 12:01:02 -0400 Subject: [PATCH] founder mode --- apps/mail/app/(routes)/founder-mode/page.tsx | 657 +++++++++++++++++++ apps/mail/app/routes.ts | 1 + apps/mail/config/navigation.ts | 9 +- 3 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 apps/mail/app/(routes)/founder-mode/page.tsx diff --git a/apps/mail/app/(routes)/founder-mode/page.tsx b/apps/mail/app/(routes)/founder-mode/page.tsx new file mode 100644 index 0000000000..5bc9e79c9e --- /dev/null +++ b/apps/mail/app/(routes)/founder-mode/page.tsx @@ -0,0 +1,657 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useActiveConnection } from '@/hooks/use-connections'; +import { useTRPC } from '@/providers/query-provider'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Loader2, X, Check, RefreshCw } from 'lucide-react'; +import { useNavigate } from 'react-router'; +import { stripHtml } from 'string-strip-html'; +import { formatDistanceToNow } from 'date-fns'; + +interface EmailData { + threadId: string; + subject: string; + sender: string; + senderEmail: string; + content: string; + receivedOn: string; + generatedReply: string; + editedReply: string; + isGenerating: boolean; +} + +export default function FounderMode() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { data: activeConnection, isLoading: isLoadingConnection } = useActiveConnection(); + const userEmail = activeConnection?.email?.toLowerCase(); + const [currentIndex, setCurrentIndex] = useState(0); + const [emails, setEmails] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const [fetchingThreadId, setFetchingThreadId] = useState(null); + + // Use sessionStorage to persist archived IDs across refreshes + const [archivedIds] = useState>(() => { + const stored = sessionStorage.getItem('founderMode_archivedIds'); + return stored ? new Set(JSON.parse(stored)) : new Set(); + }); + const trpc = useTRPC(); + + // Fetch unread emails from INBOX only + const { data: unreadData, isLoading, refetch } = useQuery({ + ...trpc.mail.listThreads.queryOptions({ + folder: 'inbox', + q: 'is:unread in:inbox', + maxResults: 100, + }), + refetchInterval: 60000, // Refetch every minute + enabled: !!userEmail, // Only fetch when we have the user's email + }); + + // Get thread IDs to process (excluding archived ones) + const threadIds = useMemo(() => { + if (!unreadData?.threads) return []; + // Filter out any threads we've already archived + const ids = unreadData.threads + .map(t => t.id) + .filter(id => !archivedIds.has(id)); + return ids; + }, [unreadData, archivedIds]); + + // Process all threads at once + const [processedThreadIds, setProcessedThreadIds] = useState>(new Set()); + + useEffect(() => { + // Process all thread IDs that haven't been processed yet + const unprocessedIds = threadIds.filter(id => + !processedThreadIds.has(id) && + !archivedIds.has(id) && + !emails.some(e => e.threadId === id) + ); + + if (unprocessedIds.length > 0 && !fetchingThreadId) { + // Process the first unprocessed thread + const nextId = unprocessedIds[0]; + setFetchingThreadId(nextId); + setProcessedThreadIds(prev => new Set([...prev, nextId])); + } + }, [threadIds, processedThreadIds, archivedIds, fetchingThreadId, emails]); + + // Fetch current thread data + const { data: currentThreadData, error: threadError } = useQuery( + trpc.mail.get.queryOptions( + { id: fetchingThreadId! }, + { + enabled: !!fetchingThreadId, + staleTime: 60 * 1000, + retry: 0, // Don't retry to speed up processing + } + ) + ); + + // Handle fetch errors quickly + useEffect(() => { + if (threadError && fetchingThreadId) { + // Skip this thread and move to the next + setFetchingThreadId(null); + } + }, [threadError, fetchingThreadId]); + + // Mutations + const generateReply = useMutation(trpc.ai.compose.mutationOptions()); + const sendEmail = useMutation(trpc.mail.send.mutationOptions()); + const markAsRead = useMutation(trpc.mail.markAsRead.mutationOptions()); + const bulkArchive = useMutation(trpc.mail.bulkArchive.mutationOptions()); + + // Process fetched thread + useEffect(() => { + if (!currentThreadData || !fetchingThreadId) return; + + // Skip if archived + if (archivedIds.has(fetchingThreadId)) { + setFetchingThreadId(null); + return; + } + + // Get the latest non-draft message + const messages = currentThreadData.messages || []; + const nonDraftMessages = messages.filter(m => !m.isDraft); + + if (nonDraftMessages.length === 0) { + setFetchingThreadId(null); + return; + } + + // Sort by date to get latest + const latestMessage = nonDraftMessages.sort((a, b) => + new Date(b.receivedOn || 0).getTime() - new Date(a.receivedOn || 0).getTime() + )[0]; + + if (!latestMessage) { + setFetchingThreadId(null); + return; + } + + // Skip if it's from the user (double check) + const senderEmail = latestMessage.sender?.email?.toLowerCase(); + // @ts-expect-error - from field might exist in some email types + const fromEmail = latestMessage.from?.emailAddress?.address?.toLowerCase(); + + // Check both sender and from fields to be thorough + if (senderEmail === userEmail || fromEmail === userEmail) { + // Skip emails from self + setFetchingThreadId(null); + return; + } + + // Also skip if no valid sender email + if (!senderEmail && !fromEmail) { + setFetchingThreadId(null); + return; + } + + // Use the valid sender email we found + const validSenderEmail = senderEmail || fromEmail || ''; + + const emailData: EmailData = { + threadId: fetchingThreadId, + subject: latestMessage.subject || 'No Subject', + // @ts-expect-error - from field might exist in some email types + sender: latestMessage.sender?.name || latestMessage.sender?.email || latestMessage.from?.emailAddress?.name || 'Unknown', + senderEmail: validSenderEmail, + // @ts-expect-error - snippet field might exist in some email types + content: stripHtml(latestMessage.decodedBody || latestMessage.snippet || '').result.substring(0, 2000), + receivedOn: latestMessage.receivedOn, + generatedReply: '', + editedReply: '', + isGenerating: false, + }; + + setEmails(prev => { + // Don't add if already exists + if (prev.some(e => e.threadId === fetchingThreadId)) { + return prev; + } + return [...prev, emailData]; + }); + // Immediately process next thread + setFetchingThreadId(null); + }, [currentThreadData, fetchingThreadId, userEmail, archivedIds]); + + const currentEmail = emails[currentIndex]; + + // Generate AI reply for current email + useEffect(() => { + const generateCurrentReply = async () => { + if (!currentEmail) return; + if (currentEmail.generatedReply || currentEmail.isGenerating) return; + + setEmails(prev => + prev.map((e, i) => + i === currentIndex ? { ...e, isGenerating: true } : e + ) + ); + + try { + const result = await generateReply.mutateAsync({ + prompt: 'Reply to this email professionally and concisely. Be helpful and friendly.', + threadMessages: [{ + from: currentEmail.senderEmail, + to: [userEmail || ''], + subject: currentEmail.subject, + body: currentEmail.content, + cc: [], + }], + to: [currentEmail.senderEmail], + emailSubject: currentEmail.subject, + cc: [], + }); + + setEmails(prev => + prev.map((e, i) => + i === currentIndex + ? { ...e, generatedReply: result.newBody, editedReply: result.newBody, isGenerating: false } + : e + ) + ); + } catch { + // Fallback reply if AI generation fails + const fallbackReply = `Hi ${currentEmail.sender.split(' ')[0] || 'there'},\n\nThanks for your email. I'll review this and get back to you shortly.\n\nBest,\n${activeConnection?.name || userEmail?.split('@')[0]}`; + + setEmails(prev => + prev.map((e, i) => + i === currentIndex + ? { + ...e, + generatedReply: fallbackReply, + editedReply: fallbackReply, + isGenerating: false + } + : e + ) + ); + } + }; + + generateCurrentReply(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentIndex, emails.length]); + + // Send reply and archive + const sendReplyAndArchive = useCallback(async () => { + if (!currentEmail || !currentEmail.editedReply || currentEmail.isGenerating || isProcessing) return; + + setIsProcessing(true); + + try { + // Convert plain text with newlines to HTML format + const formattedMessage = currentEmail.editedReply + .split('\n') + .map(line => line.trim() ? `

${line}

` : '
') + .join(''); + + // Send the reply + await sendEmail.mutateAsync({ + to: [{ email: currentEmail.senderEmail }], + subject: currentEmail.subject.startsWith('Re:') + ? currentEmail.subject + : `Re: ${currentEmail.subject}`, + message: formattedMessage, + threadId: currentEmail.threadId, + }); + + // Use bulkArchive which marks as read AND removes from inbox + await bulkArchive.mutateAsync({ + ids: [currentEmail.threadId], + }); + + // Also explicitly mark as read to be sure + await markAsRead.mutateAsync({ + ids: [currentEmail.threadId], + }); + + // Add to archived set to prevent re-fetching + archivedIds.add(currentEmail.threadId); + sessionStorage.setItem('founderMode_archivedIds', JSON.stringify(Array.from(archivedIds))); + + // Also add to processed IDs to prevent re-processing + setProcessedThreadIds(prev => new Set([...prev, currentEmail.threadId])); + + // Remove from list + setEmails(prev => prev.filter(e => e.threadId !== currentEmail.threadId)); + + // Reset index if needed + if (emails.length <= 1) { + setCurrentIndex(0); + } else if (currentIndex >= emails.length - 1) { + setCurrentIndex(Math.max(0, currentIndex - 1)); + } + + // Invalidate queries to refresh data + await queryClient.invalidateQueries({ queryKey: ['mail.listThreads'] }); + await queryClient.invalidateQueries({ queryKey: ['mail.get', currentEmail.threadId] }); + await queryClient.invalidateQueries({ queryKey: ['useThreads'] }); + + // Force refetch to update the unread list + refetch(); + + } catch (error) { + console.error('Failed to send/archive:', error); + console.error('Failed to send email. Please try again.'); + } finally { + setIsProcessing(false); + } + }, [currentEmail, sendEmail, markAsRead, bulkArchive, currentIndex, emails.length, isProcessing, queryClient, archivedIds, refetch]); + + // Archive without sending + const archiveOnly = useCallback(async () => { + if (!currentEmail || isProcessing) return; + + setIsProcessing(true); + + try { + // Use bulkArchive which removes from inbox + await bulkArchive.mutateAsync({ + ids: [currentEmail.threadId], + }); + + // Also explicitly mark as read + await markAsRead.mutateAsync({ + ids: [currentEmail.threadId], + }); + + // Add to archived set to prevent re-fetching + archivedIds.add(currentEmail.threadId); + sessionStorage.setItem('founderMode_archivedIds', JSON.stringify(Array.from(archivedIds))); + + // Also add to processed IDs to prevent re-processing + setProcessedThreadIds(prev => new Set([...prev, currentEmail.threadId])); + + // Remove from list + setEmails(prev => prev.filter(e => e.threadId !== currentEmail.threadId)); + + // Reset index if needed + if (emails.length <= 1) { + setCurrentIndex(0); + } else if (currentIndex >= emails.length - 1) { + setCurrentIndex(Math.max(0, currentIndex - 1)); + } + + // Invalidate queries to refresh data + await queryClient.invalidateQueries({ queryKey: ['mail.listThreads'] }); + await queryClient.invalidateQueries({ queryKey: ['mail.get', currentEmail.threadId] }); + await queryClient.invalidateQueries({ queryKey: ['useThreads'] }); + + // Force refetch to update the unread list + refetch(); + + } catch (error) { + console.error('Failed to archive:', error); + } finally { + setIsProcessing(false); + } + }, [currentEmail, markAsRead, bulkArchive, currentIndex, emails.length, isProcessing, queryClient, archivedIds, refetch]); + + // Regenerate AI reply + const regenerateReply = useCallback(async () => { + if (!currentEmail || currentEmail.isGenerating) return; + + setEmails(prev => + prev.map((e, i) => + i === currentIndex ? { ...e, isGenerating: true } : e + ) + ); + + try { + const result = await generateReply.mutateAsync({ + prompt: 'Write a different reply to this email. Keep it professional and helpful.', + threadMessages: [{ + from: currentEmail.senderEmail, + to: [userEmail || ''], + subject: currentEmail.subject, + body: currentEmail.content, + cc: [], + }], + to: [currentEmail.senderEmail], + emailSubject: currentEmail.subject, + cc: [], + }); + + setEmails(prev => + prev.map((e, i) => + i === currentIndex + ? { ...e, generatedReply: result.newBody, editedReply: result.newBody, isGenerating: false } + : e + ) + ); + } catch (error) { + console.error('Failed to regenerate:', error); + } + }, [currentEmail, currentIndex, generateReply, userEmail]); + + // Update edited reply + const updateEditedReply = useCallback((value: string) => { + setEmails(prev => + prev.map((e, i) => + i === currentIndex ? { ...e, editedReply: value } : e + ) + ); + }, [currentIndex]); + + // Navigate between emails + const goToNext = useCallback(() => { + if (currentIndex < emails.length - 1) { + setCurrentIndex(currentIndex + 1); + } + }, [currentIndex, emails.length]); + + const goToPrevious = useCallback(() => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1); + } + }, [currentIndex]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't trigger shortcuts when typing in textarea/input + const target = e.target as HTMLElement; + const isTextarea = target.tagName === 'TEXTAREA'; + const isInput = target.tagName === 'INPUT'; + const isEditable = isTextarea || isInput; + + // Tab: Generate new AI Email (when not in editable fields) + if (e.key === 'Tab' && !isEditable) { + e.preventDefault(); + e.stopPropagation(); + regenerateReply(); + return false; + } + + // Cmd+Enter: Send & Archive (works everywhere) + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + sendReplyAndArchive(); + return false; + } + + // Cmd+Delete: Archive (when not in editable fields) + if ((e.key === 'Delete' || e.key === 'Backspace') && (e.metaKey || e.ctrlKey) && !isEditable) { + e.preventDefault(); + archiveOnly(); + return false; + } + + // Arrow navigation (when not in editable fields) + if (e.key === 'ArrowRight' && !isEditable) { + goToNext(); + } else if (e.key === 'ArrowLeft' && !isEditable) { + goToPrevious(); + } + }; + + // Use capture phase to catch Tab before default behavior + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [goToNext, goToPrevious, regenerateReply, sendReplyAndArchive, archiveOnly]); + + // Check if connection is loading + if (isLoadingConnection) { + return ( +
+
+ +

Loading connection...

+
+
+ ); + } + + // Check if user email is available + if (!userEmail) { + return ( +
+
+

No active email connection. Please connect your email account.

+ +
+
+ ); + } + + // Loading state - show loading only if we're actually loading initial data + if ((isLoading || fetchingThreadId) && emails.length === 0) { + return ( +
+
+ +

+ {isLoading ? 'Loading unread emails...' : 'Processing emails...'} +

+
+
+ ); + } + + + + // No emails state + if (emails.length === 0 && !fetchingThreadId && !isLoading) { + return ( +
+
+
+

Inbox Zero! 🎉

+

No unread emails in your inbox

+

+ {archivedIds.size > 0 && `Processed ${archivedIds.size} emails this session`} +

+
+ +
+ + + {archivedIds.size > 0 && ( + + )} +
+
+
+ ); + } + + const timeAgo = currentEmail ? formatDistanceToNow(new Date(currentEmail.receivedOn), { addSuffix: true }) : ''; + + return ( +
+
+ {/* Header */} +
+

+ {currentIndex + 1}/{emails.length} Unread Emails +

+

{timeAgo}

+
+ + {/* Email Content */} + {currentEmail && ( +
+ {/* Original Email */} +
+
+
+
+
+ {currentEmail.sender} + - + {currentEmail.subject} +
+

{currentEmail.senderEmail}

+
+
+ +
+ {currentEmail.content} +
+
+ + {/* AI Generated Reply */} +
+
+ AI Draft Reply (editable): + +
+ {currentEmail.isGenerating ? ( +
+ + Generating AI reply... +
+ ) : ( +