diff --git a/apps/mail/components/create/email-composer.tsx b/apps/mail/components/create/email-composer.tsx index 5118293146..e6bf47fb61 100644 --- a/apps/mail/components/create/email-composer.tsx +++ b/apps/mail/components/create/email-composer.tsx @@ -19,7 +19,6 @@ import { Check, Command, Loader, Paperclip, Plus, Type, X as XIcon } from 'lucid import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { TextEffect } from '@/components/motion-primitives/text-effect'; import { ScheduleSendPicker } from './schedule-send-picker'; -import { useActiveConnection } from '@/hooks/use-connections'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEmailAliases } from '@/hooks/use-email-aliases'; import useComposeEditor from '@/hooks/use-compose-editor'; @@ -27,7 +26,7 @@ import { CurvedArrow, Sparkles, X } from '../icons/icons'; import { gitHubEmojis } from '@tiptap/extension-emoji'; import { AnimatePresence, motion } from 'motion/react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Avatar, AvatarFallback } from '../ui/avatar'; + import { useTRPC } from '@/providers/query-provider'; import { useMutation } from '@tanstack/react-query'; import { useSettings } from '@/hooks/use-settings'; @@ -44,6 +43,8 @@ import { Toolbar } from './toolbar'; import pluralize from 'pluralize'; import { toast } from 'sonner'; import { z } from 'zod'; + +import { RecipientAutosuggest } from '@/components/ui/recipient-autosuggest'; import { ImageCompressionSettings } from './image-compression-settings'; import { compressImages } from '@/lib/image-compression'; import type { ImageQuality } from '@/lib/image-compression'; @@ -84,15 +85,7 @@ interface EmailComposerProps { editorClassName?: string; } -const isValidEmail = (email: string): boolean => { - // for format like test@example.com - const simpleEmailRegex = /^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; - - // for format like name - const displayNameEmailRegex = /^.+\s*<\s*[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\s*>$/; - return simpleEmailRegex.test(email) || displayNameEmailRegex.test(email); -}; const schema = z.object({ to: z.array(z.string().email()).min(1), @@ -129,24 +122,13 @@ export function EmailComposer({ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [messageLength, setMessageLength] = useState(0); const fileInputRef = useRef(null); - const toInputRef = useRef(null); - const ccInputRef = useRef(null); - const bccInputRef = useRef(null); const [threadId] = useQueryState('threadId'); - const [mode] = useQueryState('mode'); const [isComposeOpen, setIsComposeOpen] = useQueryState('isComposeOpen'); const { data: emailData } = useThread(threadId ?? null); const [draftId, setDraftId] = useQueryState('draftId'); const [aiGeneratedMessage, setAiGeneratedMessage] = useState(null); const [aiIsLoading, setAiIsLoading] = useState(false); const [isGeneratingSubject, setIsGeneratingSubject] = useState(false); - const [isAddingRecipients, setIsAddingRecipients] = useState(false); - const [isAddingCcRecipients, setIsAddingCcRecipients] = useState(false); - const [isAddingBccRecipients, setIsAddingBccRecipients] = useState(false); - const toWrapperRef = useRef(null); - const ccWrapperRef = useRef(null); - const bccWrapperRef = useRef(null); - const { data: activeConnection } = useActiveConnection(); const [showLeaveConfirmation, setShowLeaveConfirmation] = useState(false); const [scheduleAt, setScheduleAt] = useState(); const [isScheduleValid, setIsScheduleValid] = useState(true); @@ -225,28 +207,6 @@ export function EmailComposer({ } }; - // Add this function to handle clicks outside the input fields - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (toWrapperRef.current && !toWrapperRef.current.contains(event.target as Node)) { - setIsAddingRecipients(false); - } - if (ccWrapperRef.current && !ccWrapperRef.current.contains(event.target as Node)) { - setIsAddingCcRecipients(false); - } - if (bccWrapperRef.current && !bccWrapperRef.current.contains(event.target as Node)) { - setIsAddingBccRecipients(false); - } - } - - // Add event listener - document.addEventListener('mousedown', handleClickOutside); - return () => { - // Remove event listener on cleanup - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - const attachmentKeywords = [ 'attachment', 'attached', @@ -261,11 +221,6 @@ export function EmailComposer({ const { mutateAsync: generateEmailSubject } = useMutation( trpc.ai.generateEmailSubject.mutationOptions(), ); - useEffect(() => { - if (isComposeOpen === 'true' && toInputRef.current) { - toInputRef.current.focus(); - } - }, [isComposeOpen]); const form = useForm>({ resolver: zodResolver(schema), @@ -284,92 +239,6 @@ export function EmailComposer({ }, }); - useEffect(() => { - // Don't populate from threadId if we're in compose mode - if (isComposeOpen === 'true') return; - - if (!emailData?.latest || !mode || !activeConnection?.email) return; - - const userEmail = activeConnection.email.toLowerCase(); - const latestEmail = emailData.latest; - const senderEmail = latestEmail.replyTo; - - // Reset states - form.reset(); - setShowCc(false); - setShowBcc(false); - - // Set subject based on mode - const subject = - mode === 'forward' - ? `Fwd: ${latestEmail.subject || ''}` - : latestEmail.subject?.startsWith('Re:') - ? latestEmail.subject - : `Re: ${latestEmail.subject || ''}`; - form.setValue('subject', subject); - - if (mode === 'reply') { - // Reply to sender - form.setValue('to', [latestEmail.sender.email]); - } else if (mode === 'replyAll') { - const to: string[] = []; - const cc: string[] = []; - - // Add original sender if not current user - if (senderEmail !== userEmail) { - to.push(latestEmail.replyTo || latestEmail.sender.email); - } - - // Add original recipients from To field - latestEmail.to?.forEach((recipient) => { - const recipientEmail = recipient.email.toLowerCase(); - if (recipientEmail !== userEmail && recipientEmail !== senderEmail) { - to.push(recipient.email); - } - }); - - // Add CC recipients - latestEmail.cc?.forEach((recipient) => { - const recipientEmail = recipient.email.toLowerCase(); - if (recipientEmail !== userEmail && !to.includes(recipient.email)) { - cc.push(recipient.email); - } - }); - - // Add BCC recipients - latestEmail.bcc?.forEach((recipient) => { - const recipientEmail = recipient.email.toLowerCase(); - if ( - recipientEmail !== userEmail && - !to.includes(recipient.email) && - !cc.includes(recipient.email) - ) { - form.setValue('bcc', [...(bccEmails || []), recipient.email]); - setShowBcc(true); - } - }); - - form.setValue('to', to); - if (cc.length > 0) { - form.setValue('cc', cc); - setShowCc(true); - } - } - // For forward, we start with empty recipients - }, [mode, emailData?.latest, activeConnection?.email]); - - // keep fromEmail in sync when settings or aliases load afterwards - useEffect(() => { - const preferred = - settings?.settings?.defaultEmailAlias ?? - aliases?.find((a) => a.primary)?.email ?? - aliases?.[0]?.email; - - if (preferred && form.getValues('fromEmail') !== preferred) { - form.setValue('fromEmail', preferred, { shouldDirty: false }); - } - }, [settings?.settings?.defaultEmailAlias, aliases]); - const { watch, setValue, getValues } = form; const toEmails = watch('to'); const ccEmails = watch('cc'); @@ -421,6 +290,13 @@ export function EmailComposer({ } }, [editor, autofocus]); + // Remove the TRPC query - we'll use the component's internal logic instead + useEffect(() => { + if (isComposeOpen === 'true' && editor) { + editor.commands.focus(); + } + }, [isComposeOpen, editor]); + // Prevent browser navigation/refresh when there's unsaved content useEffect(() => { if (!editor) return; @@ -699,6 +575,19 @@ export function EmailComposer({ // await handleAiGenerate(); // }); + + // keep fromEmail in sync when settings or aliases load afterwards + useEffect(() => { + const preferred = + settings?.settings?.defaultEmailAlias ?? + aliases?.find((a) => a.primary)?.email ?? + aliases?.[0]?.email; + + if (preferred && getValues('fromEmail') !== preferred) { + setValue('fromEmail', preferred, { shouldDirty: false }); + } + }, [settings?.settings?.defaultEmailAlias, aliases, getValues, setValue]); + const handleQualityChange = async (newQuality: ImageQuality) => { setImageQuality(newQuality); await processAndSetAttachments(originalAttachments, newQuality, true); @@ -731,195 +620,16 @@ export function EmailComposer({ >
{/* To, Cc, Bcc */} -
+
-
{ - setIsAddingRecipients(true); - setTimeout(() => { - if (toInputRef.current) { - toInputRef.current.focus(); - } - }, 0); - }} - className="flex w-full items-center gap-2" - > +

To:

- {isAddingRecipients || toEmails.length === 0 ? ( -
- {toEmails.map((email, index) => ( -
- - - - {email.charAt(0).toUpperCase()} - - - - {email} - - - -
- ))} - { - e.preventDefault(); - const pastedText = e.clipboardData.getData('text'); - const emails = pastedText - .split(/[,;\s]+/) - .map((email) => email.trim()) - .filter((email) => email.length > 0); - - const validEmails: string[] = []; - const invalidEmails: string[] = []; - - emails.forEach((email) => { - if (isValidEmail(email)) { - const emailLower = email.toLowerCase(); - if (!toEmails.some((e) => e.toLowerCase() === emailLower)) { - validEmails.push(email); - } - } else { - invalidEmails.push(email); - } - }); - - if (validEmails.length > 0) { - setValue('to', [...toEmails, ...validEmails]); - setHasUnsavedChanges(true); - if (validEmails.length === 1) { - toast.success('Email address added'); - } else { - toast.success(`${validEmails.length} email addresses added`); - } - } - - if (invalidEmails.length > 0) { - toast.error( - `Invalid email ${invalidEmails.length === 1 ? 'address' : 'addresses'}: ${invalidEmails.join(', ')}`, - ); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter' && e.currentTarget.value.trim()) { - e.preventDefault(); - if (isValidEmail(e.currentTarget.value.trim())) { - if (toEmails.includes(e.currentTarget.value.trim())) { - toast.error('This email is already in the list'); - } else { - setValue('to', [...toEmails, e.currentTarget.value.trim()]); - e.currentTarget.value = ''; - setHasUnsavedChanges(true); - } - } else { - toast.error('Please enter a valid email address'); - } - } else if ( - (e.key === ' ' && e.currentTarget.value.trim()) || - (e.key === 'Tab' && e.currentTarget.value.trim()) - ) { - e.preventDefault(); - if (isValidEmail(e.currentTarget.value.trim())) { - if (toEmails.includes(e.currentTarget.value.trim())) { - toast.error('This email is already in the list'); - } else { - setValue('to', [...toEmails, e.currentTarget.value.trim()]); - e.currentTarget.value = ''; - setHasUnsavedChanges(true); - } - } else { - toast.error('Please enter a valid email address'); - } - } else if ( - e.key === 'Backspace' && - !e.currentTarget.value && - toEmails.length > 0 - ) { - setValue('to', toEmails.slice(0, -1)); - setHasUnsavedChanges(true); - } - }} - onFocus={() => { - setIsAddingRecipients(true); - }} - onBlur={(e) => { - if (e.currentTarget.value.trim()) { - if (isValidEmail(e.currentTarget.value.trim())) { - if (toEmails.includes(e.currentTarget.value.trim())) { - toast.error('This email is already in the list'); - } else { - setValue('to', [...toEmails, e.currentTarget.value.trim()]); - e.currentTarget.value = ''; - setHasUnsavedChanges(true); - } - } else { - toast.error('Please enter a valid email address'); - } - } - }} - /> -
- ) : ( -
- {toEmails.length > 0 && ( -
- {toEmails.slice(0, 3).map((email, index) => ( -
- - - - {email.charAt(0).toUpperCase()} - - - - {/* for email format: "Display Name" */} - {email.match(/^"?(.*?)"?\s*<[^>]+>$/)?.[1] ?? email} - - - -
- ))} - {toEmails.length > 3 && ( - - +{toEmails.length - 3} more - - )} -
- )} -
- )} +
@@ -952,293 +662,27 @@ export function EmailComposer({
{/* CC Section */} {showCc && ( -
{ - setIsAddingCcRecipients(true); - setTimeout(() => { - if (ccInputRef.current) { - ccInputRef.current.focus(); - } - }, 0); - }} - className="flex items-center gap-2 px-3" - > +

Cc:

- {isAddingCcRecipients || (ccEmails && ccEmails.length === 0) ? ( -
- {ccEmails?.map((email, index) => ( -
- - - - {email.charAt(0).toUpperCase()} - - - {email} - - -
- ))} - { - if (e.key === 'Enter' && e.currentTarget.value.trim()) { - e.preventDefault(); - if (isValidEmail(e.currentTarget.value.trim())) { - if (ccEmails?.includes(e.currentTarget.value.trim())) { - toast.error('This email is already in the list'); - } else { - setValue('cc', [...(ccEmails || []), e.currentTarget.value.trim()]); - e.currentTarget.value = ''; - setHasUnsavedChanges(true); - } - } else { - toast.error('Please enter a valid email address'); - } - } else if (e.key === ' ' && e.currentTarget.value.trim()) { - e.preventDefault(); - if (isValidEmail(e.currentTarget.value.trim())) { - if (ccEmails?.includes(e.currentTarget.value.trim())) { - toast.error('This email is already in the list'); - } else { - setValue('cc', [...(ccEmails || []), e.currentTarget.value.trim()]); - e.currentTarget.value = ''; - setHasUnsavedChanges(true); - } - } else { - toast.error('Please enter a valid email address'); - } - } else if ( - e.key === 'Backspace' && - !e.currentTarget.value && - ccEmails?.length - ) { - setValue('cc', ccEmails.slice(0, -1)); - setHasUnsavedChanges(true); - } - }} - onFocus={() => { - setIsAddingCcRecipients(true); - }} - onBlur={(e) => { - if (e.currentTarget.value.trim()) { - if (isValidEmail(e.currentTarget.value.trim())) { - if (ccEmails?.includes(e.currentTarget.value.trim())) { - toast.error('This email is already in the list'); - } else { - setValue('cc', [...(ccEmails || []), e.currentTarget.value.trim()]); - e.currentTarget.value = ''; - setHasUnsavedChanges(true); - } - } else { - toast.error('Please enter a valid email address'); - } - } - }} - /> -
- ) : ( -
- {ccEmails && ccEmails.length > 0 && ( -
- {ccEmails.slice(0, 3).map((email, index) => ( -
- - - - {email.charAt(0).toUpperCase()} - - - {email} - - -
- ))} - {ccEmails.length > 3 && ( - - +{ccEmails.length - 3} more - - )} -
- )} -
- )} +
)} {/* BCC Section */} {showBcc && ( -
{ - setIsAddingBccRecipients(true); - setTimeout(() => { - if (bccInputRef.current) { - bccInputRef.current.focus(); - } - }, 0); - }} - className="flex items-center gap-2 px-3" - > +

Bcc:

- {isAddingBccRecipients || (bccEmails && bccEmails.length === 0) ? ( -
- {bccEmails?.map((email, index) => ( -
- - - - {email.charAt(0).toUpperCase()} - - - {email} - - -
- ))} - { - if (e.key === 'Enter' && e.currentTarget.value.trim()) { - e.preventDefault(); - if (isValidEmail(e.currentTarget.value.trim())) { - if (bccEmails?.includes(e.currentTarget.value.trim())) { - toast.error('This email is already in the list'); - } else { - setValue('bcc', [...(bccEmails || []), e.currentTarget.value.trim()]); - e.currentTarget.value = ''; - setHasUnsavedChanges(true); - } - } else { - toast.error('Please enter a valid email address'); - } - } else if (e.key === ' ' && e.currentTarget.value.trim()) { - e.preventDefault(); - if (isValidEmail(e.currentTarget.value.trim())) { - if (bccEmails?.includes(e.currentTarget.value.trim())) { - toast.error('This email is already in the list'); - } else { - setValue('bcc', [...(bccEmails || []), e.currentTarget.value.trim()]); - e.currentTarget.value = ''; - setHasUnsavedChanges(true); - } - } else { - toast.error('Please enter a valid email address'); - } - } else if ( - e.key === 'Backspace' && - !e.currentTarget.value && - bccEmails?.length - ) { - setValue('bcc', bccEmails.slice(0, -1)); - setHasUnsavedChanges(true); - } - }} - onFocus={() => { - setIsAddingBccRecipients(true); - }} - onBlur={(e) => { - if (e.currentTarget.value.trim()) { - if (isValidEmail(e.currentTarget.value.trim())) { - if (bccEmails?.includes(e.currentTarget.value.trim())) { - toast.error('This email is already in the list'); - } else { - setValue('bcc', [...(bccEmails || []), e.currentTarget.value.trim()]); - e.currentTarget.value = ''; - setHasUnsavedChanges(true); - } - } else { - toast.error('Please enter a valid email address'); - } - } - }} - /> -
- ) : ( -
- {bccEmails && bccEmails.length > 0 && ( -
- {bccEmails.slice(0, 3).map((email, index) => ( -
- - - - {email.charAt(0).toUpperCase()} - - - {email} - - -
- ))} - {bccEmails.length > 3 && ( - - +{bccEmails.length - 3} more - - )} -
- )} -
- )} +
)}
@@ -1465,6 +909,7 @@ export function EmailComposer({ onClick={async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + try { await removeAttachment(index); } catch (error) { diff --git a/apps/mail/components/ui/recipient-autosuggest.tsx b/apps/mail/components/ui/recipient-autosuggest.tsx new file mode 100644 index 0000000000..3f0c25fd81 --- /dev/null +++ b/apps/mail/components/ui/recipient-autosuggest.tsx @@ -0,0 +1,312 @@ +'use client'; + +import { useState, useRef, useCallback, useMemo } from 'react'; +import { useController, type Control } from 'react-hook-form'; +import { useTRPC } from '@/providers/query-provider'; +import { useQuery } from '@tanstack/react-query'; +import { useDebounce } from '@/hooks/use-debounce'; +import { cn } from '@/lib/utils'; +import { X } from 'lucide-react'; +import { Avatar, AvatarFallback } from './avatar'; + +interface RecipientSuggestion { + email: string; + name?: string | null; + displayText: string; +} + +const isValidRecipientSuggestion = (item: unknown): item is RecipientSuggestion => { + if (typeof item !== 'object' || item === null) return false; + + const obj = item as Record; + + if (!('email' in obj) || !('displayText' in obj)) return false; + + const email = obj.email; + const displayText = obj.displayText; + const name = obj.name; + + if (typeof email !== 'string' || typeof displayText !== 'string') { + return false; + } + + if (name !== undefined && name !== null && typeof name !== 'string') { + return false; + } + + return true; +}; + +const validateSuggestions = (data: unknown): RecipientSuggestion[] => { + if (!Array.isArray(data)) { + if (data !== undefined && data !== null) { + console.warn('Expected array for recipient suggestions, got:', typeof data); + } + return []; + } + + const valid = data.filter(isValidRecipientSuggestion); + const invalid = data.length - valid.length; + + if (invalid > 0) { + console.warn(`Filtered out ${invalid} invalid recipient suggestions`); + } + + return valid; +}; + +interface RecipientAutosuggestProps { + control: Control; + name: string; + placeholder?: string; + className?: string; + disabled?: boolean; +} + +export function RecipientAutosuggest({ + control, + name, + placeholder = 'Enter email', + className, + disabled = false, +}: RecipientAutosuggestProps) { + + const { + field: { value: recipients = [], onChange: onRecipientsChange }, + } = useController({ + control, + name, + defaultValue: [], + }); + + const [inputValue, setInputValue] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isComposing, setIsComposing] = useState(false); + + const inputDomRef = useRef(null); + const dropdownRef = useRef(null); + + const debouncedSetQuery = useDebounce((query: string) => { + setDebouncedQuery(query); + }, 300); + + const [debouncedQuery, setDebouncedQuery] = useState(''); + + const trpc = useTRPC(); + const { data: allSuggestions = [], isLoading } = useQuery({ + ...trpc.mail.suggestRecipients.queryOptions({ + query: debouncedQuery, + limit: 10, + }), + enabled: debouncedQuery.trim().length > 0 && !isComposing, + }); + + const filteredSuggestions = useMemo(() => { + const validatedSuggestions = validateSuggestions(allSuggestions); + return validatedSuggestions.filter((suggestion) => + !recipients.includes(suggestion.email) + ); + }, [allSuggestions, recipients]); + + const isValidEmail = useCallback((email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }, []); + + const addRecipient = useCallback((email: string) => { + if (!recipients.includes(email) && isValidEmail(email)) { + onRecipientsChange([...recipients, email]); + setInputValue(''); + setIsOpen(false); + setSelectedIndex(-1); + } + }, [recipients, onRecipientsChange, isValidEmail]); + + const removeRecipient = useCallback((index: number) => { + const newRecipients = recipients.filter((_: string, i: number) => i !== index); + onRecipientsChange(newRecipients); + }, [recipients, onRecipientsChange]); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + setInputValue(value); + setSelectedIndex(-1); + debouncedSetQuery(value); + setIsOpen(value.trim().length > 0); + }, [debouncedSetQuery]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (isComposing) return; + + switch (e.key) { + case 'Enter': + e.preventDefault(); + if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) { + addRecipient(filteredSuggestions[selectedIndex].email); + } else if (inputValue.trim() && isValidEmail(inputValue.trim())) { + addRecipient(inputValue.trim()); + } + break; + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(prev => + prev < filteredSuggestions.length - 1 ? prev + 1 : 0 + ); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(prev => + prev > 0 ? prev - 1 : filteredSuggestions.length - 1 + ); + break; + case 'Escape': + setIsOpen(false); + setSelectedIndex(-1); + break; + case 'Backspace': + if (!inputValue && recipients.length > 0) { + removeRecipient(recipients.length - 1); + } + break; + case 'Tab': + if (inputValue.trim() && isValidEmail(inputValue.trim())) { + e.preventDefault(); + addRecipient(inputValue.trim()); + } + break; + } + }, [inputValue, selectedIndex, filteredSuggestions, recipients, isComposing, addRecipient, removeRecipient, isValidEmail]); + + const handleSuggestionClick = useCallback((suggestion: RecipientSuggestion) => { + addRecipient(suggestion.email); + }, [addRecipient]); + + const handlePaste = useCallback((e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedText = e.clipboardData.getData('text'); + const emails = pastedText + .split(/[,;\s]+/) + .map(email => email.trim()) + .filter(email => email.length > 0 && isValidEmail(email)) + .filter(email => !recipients.includes(email)); + + if (emails.length > 0) { + onRecipientsChange([...recipients, ...emails]); + } + }, [recipients, onRecipientsChange, isValidEmail]); + + const handleClickOutside = useCallback((event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputDomRef.current && + !inputDomRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + setSelectedIndex(-1); + } + }, []); + + const handleInputFocus = useCallback(() => { + document.addEventListener('mousedown', handleClickOutside); + }, [handleClickOutside]); + + const handleInputBlur = useCallback(() => { + setTimeout(() => { + document.removeEventListener('mousedown', handleClickOutside); + }, 150); + }, [handleClickOutside]); + + return ( +
+
+ {recipients.map((email: string, index: number) => ( +
+ + + + {email.charAt(0).toUpperCase()} + + + {email} + + +
+ ))} + setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + placeholder={recipients.length === 0 ? placeholder : ''} + className="h-6 flex-1 bg-transparent text-sm font-normal leading-normal text-black placeholder:text-[#797979] focus:outline-none dark:text-white" + disabled={disabled} + /> +
+ + {isOpen && (filteredSuggestions.length > 0 || isLoading) && ( +
+ {isLoading && ( +
+ Loading suggestions... +
+ )} + {!isLoading && filteredSuggestions.length === 0 && debouncedQuery.trim().length > 0 && ( +
No suggestions
+ )} + {filteredSuggestions.map((suggestion: RecipientSuggestion, index: number) => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index 5a4c839ac3..2d5b7a8d85 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -70,7 +70,7 @@ import { Effect, pipe } from 'effect'; import { groq } from '@ai-sdk/groq'; import { createDb } from '../../db'; import type { Message } from 'ai'; -import { eq } from 'drizzle-orm'; +import { eq, desc, isNotNull } from 'drizzle-orm'; import { create } from './db'; const decoder = new TextDecoder(); @@ -328,6 +328,53 @@ export class ZeroDriver extends DurableObject { private agent: DurableObjectStub | null = null; private name: string = 'general'; private connection: typeof connection.$inferSelect | null = null; + private recipientCache: { contacts: Array<{ email: string; name?: string | null; freq: number; last: number }>; hash: string } | null = null; + + private invalidateRecipientCache() { + this.recipientCache = null; + } + + private parseMalformedSender(rawData: string): { email: string; name?: string } | null { + const emailRegex = /([^\s@]+@[^\s@]+\.[^\s@]+)/; + + if (emailRegex.test(rawData.trim())) { + const email = rawData.trim(); + console.warn('[SuggestRecipients] Used fallback parsing for plain email:', email); + return { email, name: undefined }; + } + + const emailMatch = rawData.match(emailRegex); + if (!emailMatch) return null; + + const email = emailMatch[1]; + let name: string | undefined = undefined; + + const namePatterns = [ + /"name"\s*:\s*"([^"]+)"/, + /'name'\s*:\s*'([^']+)'/, + /name\s*:\s*([^,}\]]+)/, + /"([^"]+)"\s*<[^>]*>/, + /'([^']+)'\s*<[^>]*>/, + /([^<]+)\s*<[^>]*>/, + /"([^"]+)"/, + /'([^']+)'/, + ]; + + for (const pattern of namePatterns) { + const nameMatch = rawData.match(pattern); + if (nameMatch && nameMatch[1]) { + const potentialName = nameMatch[1].trim(); + if (potentialName && potentialName !== email && !potentialName.includes('@')) { + name = potentialName.replace(/[{}[\],]/g, '').trim(); + if (name) break; + } + } + } + + console.warn('[SuggestRecipients] Extracted from malformed data:', { email, name }); + return { email, name }; + } + constructor(ctx: DurableObjectState, env: ZeroEnv) { super(ctx, env); this.sql = ctx.storage.sql; @@ -589,14 +636,18 @@ export class ZeroDriver extends DurableObject { if (!this.driver) { throw new Error('No driver available'); } - return await this.driver.sendDraft(id, data); + const result = await this.driver.sendDraft(id, data); + this.invalidateRecipientCache(); + return result; } async create(data: IOutgoingMessage) { if (!this.driver) { throw new Error('No driver available'); } - return await this.driver.create(data); + const result = await this.driver.create(data); + this.invalidateRecipientCache(); + return result; } async delete(id: string) { @@ -842,6 +893,16 @@ export class ZeroDriver extends DurableObject { return `${this.name}/${threadId}.json`; } + + async deleteThread(id: string) { + await this.db.delete(threads).where(eq(threads.threadId, id)); + this.invalidateRecipientCache(); + this.agent?.broadcastChatMessage({ + type: OutgoingMessageType.Mail_List, + folder: 'trash', + }); + } + async reloadFolder(folder: string) { this.agent?.broadcastChatMessage({ type: OutgoingMessageType.Mail_List, @@ -922,7 +983,10 @@ export class ZeroDriver extends DurableObject { ), ).pipe( Effect.tap(() => - Effect.sync(() => console.log(`[syncThread] Updated database for ${threadId}`)), + Effect.sync(() => { + console.log(`[syncThread] Updated database for ${threadId}`); + this.invalidateRecipientCache(); + }), ), Effect.tap(() => Effect.sync(() => this.reloadFolder('inbox'))), Effect.catchAll((error) => { @@ -1432,6 +1496,99 @@ export class ZeroDriver extends DurableObject { return await this.getThreadsFromDB(params); } + async get(id: string) { + if (!this.driver) { + throw new Error('No driver available'); + } + return await this.getThreadFromDB(id); + } + + async suggestRecipients(query: string = '', limit: number = 10) { + const lower = query.toLowerCase(); + + const hashRows = await this.db.select({ id: threads.id }).from(threads).where(isNotNull(threads.latestSender)).orderBy(desc(threads.latestReceivedOn)).limit(100); + + const currentHash = hashRows.map(r => r.id).join(','); + + if (!this.recipientCache || this.recipientCache.hash !== currentHash) { + const rows = await this.db.select({ + latest_sender: threads.latestSender, + latest_received_on: threads.latestReceivedOn + }).from(threads).where(isNotNull(threads.latestSender)).orderBy(desc(threads.latestReceivedOn)).limit(100); + + const map = new Map(); + + for (const row of rows) { + if (!row?.latest_sender) continue; + + let sender: { email?: string; name?: string } | null = null; + + try { + const senderData = row.latest_sender; + if (typeof senderData === 'string') { + sender = JSON.parse(senderData); + } else if (typeof senderData === 'object' && senderData !== null) { + sender = senderData as { email?: string; name?: string }; + } else { + sender = this.parseMalformedSender(String(senderData)); + } + + if (!sender) { + console.error('[SuggestRecipients] Failed to parse latest_sender, no fallback possible. Raw data:', row.latest_sender); + continue; + } + } catch (error) { + sender = this.parseMalformedSender(String(row.latest_sender)); + if (!sender) { + console.error('[SuggestRecipients] Failed to parse latest_sender, no fallback possible:', error, 'Raw data:', row.latest_sender); + continue; + } + } + + if (!sender?.email) continue; + + const key = sender.email.toLowerCase(); + const lastTs = row.latest_received_on ? new Date(String(row.latest_received_on)).getTime() : 0; + + if (!map.has(key)) { + map.set(key, { + email: sender.email, + name: sender.name || null, + freq: 1, + last: lastTs, + }); + } else { + const entry = map.get(key)!; + entry.freq += 1; + if (lastTs > entry.last) entry.last = lastTs; + } + } + + this.recipientCache = { + contacts: Array.from(map.values()), + hash: currentHash, + }; + } + + let contacts = this.recipientCache.contacts.slice(); + + if (lower) { + contacts = contacts.filter( + (c) => + c.email.toLowerCase().includes(lower) || + (c.name && c.name.toLowerCase().includes(lower)), + ); + } + + contacts.sort((a, b) => b.freq - a.freq || b.last - a.last); + + return contacts.slice(0, limit).map((c) => ({ + email: c.email, + name: c.name, + displayText: c.name ? `${c.name} <${c.email}>` : c.email, + })); + } + // async get(id: string, includeDrafts: boolean = false) { // if (!this.driver) { // throw new Error('No driver available'); diff --git a/apps/server/src/trpc/routes/mail.ts b/apps/server/src/trpc/routes/mail.ts index f261d312e6..dd419428ea 100644 --- a/apps/server/src/trpc/routes/mail.ts +++ b/apps/server/src/trpc/routes/mail.ts @@ -41,6 +41,20 @@ const senderSchema = z.object({ // }; export const mailRouter = router({ + suggestRecipients: activeDriverProcedure + .input( + z.object({ + query: z.string().optional().default(''), + limit: z.number().optional().default(10), + }), + ) + .query(async ({ ctx, input }) => { + const { activeConnection } = ctx; + const executionCtx = getContext().executionCtx; + const { stub: agent } = await getZeroAgent(activeConnection.id, executionCtx); + + return await agent.suggestRecipients(input.query, input.limit); + }), forceSync: activeDriverProcedure.mutation(async ({ ctx }) => { const { activeConnection } = ctx; return await forceReSync(activeConnection.id);