diff --git a/apps/mail/components/create/create-email.tsx b/apps/mail/components/create/create-email.tsx index 96bf7482c5..fd44c79fd1 100644 --- a/apps/mail/components/create/create-email.tsx +++ b/apps/mail/components/create/create-email.tsx @@ -10,6 +10,7 @@ import { EmailComposer } from './email-composer'; import { useSession } from '@/lib/auth-client'; import { serializeFiles } from '@/lib/schemas'; import { useDraft } from '@/hooks/use-drafts'; +import { useThreads } from '@/hooks/use-threads'; import { useEffect, useState } from 'react'; import type { Attachment } from '@/types'; @@ -54,6 +55,7 @@ export function CreateEmail({ data: draft, isLoading: isDraftLoading, error: draftError, + refetch: refetchDraft } = useDraft(draftId ?? propDraftId ?? null); const [, setIsDraftFailed] = useState(false); @@ -61,6 +63,7 @@ export function CreateEmail({ const { mutateAsync: sendEmail } = useMutation(trpc.mail.send.mutationOptions()); const [isComposeOpen, setIsComposeOpen] = useQueryState('isComposeOpen'); const [, setThreadId] = useQueryState('threadId'); + const [{refetch: refetchThreads }] = useThreads(); const [, setActiveReplyId] = useQueryState('activeReplyId'); const { data: activeConnection } = useActiveConnection(); const { data: settings, isLoading: settingsLoading } = useSettings(); @@ -144,6 +147,8 @@ export function CreateEmail({ } }; + + const base64ToFile = (base64: string, filename: string, mimeType: string): File | null => { try { const byteString = atob(base64); @@ -169,7 +174,12 @@ export function CreateEmail({
-
- {isDraftLoading ? ( -
-
-
-

Loading draft...

-
-
- ) : ( { + refetchDraft(); + }} initialAttachments={files} initialSubject={typedDraft?.subject || initialSubject} autofocus={false} settingsLoading={settingsLoading} /> - )}
diff --git a/apps/mail/components/create/email-composer.tsx b/apps/mail/components/create/email-composer.tsx index 5a1f676476..27a5e90f48 100644 --- a/apps/mail/components/create/email-composer.tsx +++ b/apps/mail/components/create/email-composer.tsx @@ -15,7 +15,7 @@ import { } from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; -import { Check, Command, Loader, Paperclip, Plus, Type, X as XIcon } from 'lucide-react'; +import { Check, Command, Loader, Paperclip, Trash, Plus, Type, X as XIcon } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { TextEffect } from '@/components/motion-primitives/text-effect'; import { ImageCompressionSettings } from './image-compression-settings'; @@ -31,11 +31,11 @@ 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 { useMutation, useQuery } from '@tanstack/react-query'; import { useSettings } from '@/hooks/use-settings'; import { cn, formatFileSize } from '@/lib/utils'; -import { useThread } from '@/hooks/use-threads'; +import { useThread, useThreads } from '@/hooks/use-threads'; import { serializeFiles } from '@/lib/schemas'; import { Input } from '@/components/ui/input'; import { EditorContent } from '@tiptap/react'; @@ -74,6 +74,7 @@ interface EmailComposerProps { fromEmail?: string; }) => Promise; onClose?: () => void; + onDraftUpdate?: () => void; className?: string; autofocus?: boolean; settingsLoading?: boolean; @@ -111,6 +112,8 @@ export function EmailComposer({ initialAttachments = [], onSendEmail, onClose, + onDraftUpdate, + // onDeleteDrafts, className, autofocus = false, settingsLoading = false, @@ -122,6 +125,8 @@ export function EmailComposer({ const [showBcc, setShowBcc] = useState(initialBcc.length > 0); const [isLoading, setIsLoading] = useState(false); const [isSavingDraft, setIsSavingDraft] = useState(false); + const [isDeleteDraft, setIsDeleteDraft] = useState(false); + const [{ isFetching, refetch: refetchThreads }] = useThreads(); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [messageLength, setMessageLength] = useState(0); const fileInputRef = useRef(null); @@ -251,6 +256,8 @@ export function EmailComposer({ const trpc = useTRPC(); const { mutateAsync: aiCompose } = useMutation(trpc.ai.compose.mutationOptions()); const { mutateAsync: createDraft } = useMutation(trpc.drafts.create.mutationOptions()); + const {mutateAsync: updateDraft} = useMutation(trpc.drafts.update.mutationOptions()); + const {mutateAsync: deleteDraft} = useMutation(trpc.drafts.delete.mutationOptions()); const { mutateAsync: generateEmailSubject } = useMutation( trpc.ai.generateEmailSubject.mutationOptions(), ); @@ -551,6 +558,8 @@ export function EmailComposer({ } }; + + const saveDraft = async () => { const values = getValues(); @@ -576,11 +585,34 @@ export function EmailComposer({ fromEmail: values.fromEmail ? values.fromEmail : null, }; + if(draftId){ + try { + const response = await updateDraft(draftData); + if(response?.id){ + setDraftId(response.id); + onDraftUpdate?.(); + toast.success("Your Draft has been Successfully Saved") + } + } catch (error) { + console.error("Failed to update draft:", error); + toast.error("Failed to update draft"); + } + } else { + const response = await createDraft(draftData); + if(response?.id){ + setDraftId(response.id); + toast.success("Your Draft has been Successfully Saved") + } else { + console.error("Failed Setting up Draft Id") + toast.error("Failed Setting up Draft Id") + } + } const response = await createDraft(draftData); - - if (response?.id && response.id !== draftId) { - setDraftId(response.id); + if(response?.id){ + setDraftId(response?.id); + toast.success("Your Draft has been Successfully Saved") } + } } catch (error) { console.error('Error saving draft:', error); toast.error('Failed to save draft'); @@ -592,6 +624,53 @@ export function EmailComposer({ } }; + useEffect(() => { + if (!hasUnsavedChanges) return; + + const autoSaveTimer = setTimeout(() => { + console.log('Draft Save TimeOut'); + saveDraft(); + }, 3000); + + return () => clearTimeout(autoSaveTimer); + }, [hasUnsavedChanges, saveDraft]); + + //ths function is going to be used to delete drafts + const handledeleteDraft = async () => { + const values = getValues(); + if (!draftId) { + toast.error('No draft Id available to delete any Draft.'); + return; + } + try{ + const response = await deleteDraft({id: draftId}); + if(response === ''){ + setDraftId(null); + setIsComposeOpen(null); + setTimeout(() => { + toast.success("Successfully Deleted Draft"); + refetchThreads(); + }, 500); + } + } catch (error) { + console.error('Failed to delete draft:', error); + toast.error('Failed to delete draft.'); + } finally { + setIsDeleteDraft(false); + } + }; + + // this handleclose button triggeres to auto-save draft upon close + const handleClose = () => { + const hasContent = editor?.getText()?.trim().length > 0; + if (hasContent) { + saveDraft(); + setShowLeaveConfirmation(true); + } else { + onClose?.(); + } + }; + const handleGenerateSubject = async () => { try { setIsGeneratingSubject(true); @@ -613,17 +692,10 @@ export function EmailComposer({ } }; - const handleClose = () => { - const hasContent = editor?.getText()?.trim().length > 0; - if (hasContent) { - setShowLeaveConfirmation(true); - } else { - onClose?.(); - } - }; const confirmLeave = () => { setShowLeaveConfirmation(false); + handledeleteDraft(); onClose?.(); }; @@ -644,16 +716,6 @@ export function EmailComposer({ }; }, [editor, showLeaveConfirmation]); - useEffect(() => { - if (!hasUnsavedChanges) return; - - const autoSaveTimer = setTimeout(() => { - console.log('timeout set'); - saveDraft(); - }, 3000); - - return () => clearTimeout(autoSaveTimer); - }, [hasUnsavedChanges, saveDraft]); useEffect(() => { const handlePasteFiles = (event: ClipboardEvent) => { @@ -921,7 +983,7 @@ export function EmailComposer({ tabIndex={-1} className="flex h-full items-center gap-2 text-sm font-medium text-[#8C8C8C] hover:text-[#A8A8A8]" onClick={handleClose} - > + > )} @@ -1467,7 +1529,13 @@ export function EmailComposer({ -
+
+ {/* */}
{aiGeneratedMessage !== null ? ( @@ -1566,7 +1634,7 @@ export function EmailComposer({ Stay diff --git a/apps/server/src/lib/driver/google.ts b/apps/server/src/lib/driver/google.ts index e36ac2a859..dd5df44a65 100644 --- a/apps/server/src/lib/driver/google.ts +++ b/apps/server/src/lib/driver/google.ts @@ -608,7 +608,7 @@ export class GoogleMailManager implements MailManager { const { html: message, inlineImages } = await sanitizeTipTapHtml(data.message); const msg = createMimeMessage(); msg.setSender('me'); - // name + const to = data.to.split(', ').map((recipient: string) => { if (recipient.includes('<')) { const [name, email] = recipient.split('<'); @@ -618,12 +618,10 @@ export class GoogleMailManager implements MailManager { }); msg.setTo(to); - if (data.cc) - msg.setCc(data.cc?.split(', ').map((recipient: string) => ({ addr: recipient }))); - if (data.bcc) - msg.setBcc(data.bcc?.split(', ').map((recipient: string) => ({ addr: recipient }))); - + if (data.cc) msg.setCc(data.cc.split(', ').map((addr) => ({ addr }))); + if (data.bcc) msg.setBcc(data.bcc.split(', ').map((addr) => ({ addr }))); msg.setSubject(data.subject); + msg.addMessage({ contentType: 'text/html', data: message || '', @@ -644,9 +642,11 @@ export class GoogleMailManager implements MailManager { } } - if (data.attachments && data.attachments?.length > 0) { + if (data.attachments?.length) { for (const attachment of data.attachments) { - const base64Data = attachment.base64; + // const arrayBuffer = await attachment.arrayBuffer(); + // const base64Data = Buffer.from(arrayBuffer).toString('base64'); + const base64Data = await attachment.base64; msg.addAttachment({ filename: attachment.name, contentType: attachment.type, @@ -669,24 +669,106 @@ export class GoogleMailManager implements MailManager { }, }; - let res; + const res = await this.gmail.users.drafts.create({ + userId: 'me', + requestBody, + }); - if (data.id) { - res = await this.gmail.users.drafts.update({ - userId: 'me', - id: data.id, - requestBody, - }); - } else { - res = await this.gmail.users.drafts.create({ - userId: 'me', - requestBody, - }); + return res.data; + }, + { data } + ); + } + public updateDraft(data: CreateDraftData) { + return this.withErrorHandler( + 'updateDraft', + async () => { + if (!data.id) throw new Error('Missing draft ID for update'); + + const { html: message, inlineImages } = await sanitizeTipTapHtml(data.message); + const msg = createMimeMessage(); + msg.setSender('me'); + + const to = data.to.split(', ').map((recipient: string) => { + if (recipient.includes('<')) { + const [name, email] = recipient.split('<'); + return { addr: email.replace('>', ''), name: name.replace('>', '') }; + } + return { addr: recipient }; + }); + + msg.setTo(to); + if (data.cc) msg.setCc(data.cc.split(', ').map((addr) => ({ addr }))); + if (data.bcc) msg.setBcc(data.bcc.split(', ').map((addr) => ({ addr }))); + msg.setSubject(data.subject); + + msg.addMessage({ + contentType: 'text/html', + data: message || '', + }); + if (inlineImages.length > 0) { + for (const image of inlineImages) { + msg.addAttachment({ + inline: true, + filename: `${image.cid}`, + contentType: image.mimeType, + data: image.data, + headers: { + 'Content-ID': `<${image.cid}>`, + 'Content-Disposition': 'inline', + }, + }); + } } + if (data.attachments?.length) { + for (const attachment of data.attachments) { + const base64Data = attachment.base64; + msg.addAttachment({ + filename: attachment.name, + contentType: attachment.type, + data: base64Data, + }); + } + } + + const mimeMessage = msg.asRaw(); + const encodedMessage = Buffer.from(mimeMessage) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + const requestBody = { + message: { + raw: encodedMessage, + threadId: data.threadId, + }, + }; + + const res = await this.gmail.users.drafts.update({ + userId: 'me', + id: data.id, + requestBody, + }); + return res.data; }, - { data }, + { data } + ); + } + public deleteDraft(draftId: string){ + return this.withErrorHandler( + 'deleteDraft', + async () =>{ + if (!draftId) throw new Error('Missing draft ID to delete'); + + const res = await this.gmail.users.drafts.delete({ + userId: 'me', + id: draftId, + }) + return res.data; + } , {draftId} ); } public async getUserLabels() { diff --git a/apps/server/src/lib/driver/microsoft.ts b/apps/server/src/lib/driver/microsoft.ts index bacce02bbd..7ba68bea5f 100644 --- a/apps/server/src/lib/driver/microsoft.ts +++ b/apps/server/src/lib/driver/microsoft.ts @@ -636,7 +636,6 @@ export class OutlookMailManager implements MailManager { const { html: message, inlineImages } = await sanitizeTipTapHtml(data.message); const toRecipients = Array.isArray(data.to) ? data.to : data.to.split(', '); - const outlookMessage: Message = { subject: data.subject, body: { @@ -707,35 +706,84 @@ export class OutlookMailManager implements MailManager { if (allAttachments.length > 0) { outlookMessage.attachments = allAttachments; } + const res = await this.graphClient + .api('/me/mailfolders/drafts/messages') + .post(outlookMessage); - let res; + return res; + }, + { data }, + ); + } + public updateDraft(data: CreateDraftData) { + return this.withErrorHandler( + 'updateDraft', + async () => { + if (!data.id) throw new Error('Draft ID is required to update a draft'); - if (data.id) { - try { - res = await this.graphClient - .api(`/me/mailfolders/drafts/messages/${data.id}`) - .patch(outlookMessage); - } catch (error) { - console.warn(`Failed to update draft ${data.id}, creating a new one`, error); - try { - await this.graphClient.api(`/me/mailfolders/drafts/messages/${data.id}`).delete(); - } catch (deleteError) { - console.error(`Failed to delete draft ${data.id}`, deleteError); - } + const { html: message, inlineImages } = await sanitizeTipTapHtml(data.message); - res = await this.graphClient - .api('/me/mailfolders/drafts/messages') - .post(outlookMessage); - } - } else { - res = await this.graphClient.api('/me/mailfolders/drafts/messages').post(outlookMessage); + const toRecipients = Array.isArray(data.to) ? data.to : data.to.split(', '); + const outlookMessage: Message = { + subject: data.subject, + body: { + contentType: 'html', + content: message || '', + }, + toRecipients: toRecipients.map((recipient) => ({ + emailAddress: { + address: typeof recipient === 'string' ? recipient : recipient.email, + name: typeof recipient === 'string' ? undefined : recipient.name || undefined, + }, + })), + }; + + if (data.cc) { + const ccRecipients = Array.isArray(data.cc) ? data.cc : data.cc.split(', '); + outlookMessage.ccRecipients = ccRecipients.map((recipient) => ({ + emailAddress: { + address: typeof recipient === 'string' ? recipient : recipient.email, + name: typeof recipient === 'string' ? undefined : recipient.name || undefined, + }, + })); + } + + if (data.bcc) { + const bccRecipients = Array.isArray(data.bcc) ? data.bcc : data.bcc.split(', '); + outlookMessage.bccRecipients = bccRecipients.map((recipient) => ({ + emailAddress: { + address: typeof recipient === 'string' ? recipient : recipient.email, + name: typeof recipient === 'string' ? undefined : recipient.name || undefined, + }, + })); } + if (data.attachments && data.attachments.length > 0) { + outlookMessage.attachments = await Promise.all( + data.attachments.map(async (file) => { + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const base64Content = buffer.toString('base64'); + + return { + '@odata.type': '#microsoft.graph.fileAttachment', + name: file.name, + contentType: file.type || 'application/octet-stream', + contentBytes: base64Content, + }; + }), + ); + } + const res = await this.graphClient + .api(`/me/mailfolders/drafts/messages/${data.id}`) + .patch(outlookMessage); + return res; }, { data }, ); } + public async getUserLabels() { try { // Get root mail folders diff --git a/apps/server/src/lib/driver/types.ts b/apps/server/src/lib/driver/types.ts index bdd04c0b62..fdd1086584 100644 --- a/apps/server/src/lib/driver/types.ts +++ b/apps/server/src/lib/driver/types.ts @@ -57,6 +57,12 @@ export interface MailManager { createDraft( data: CreateDraftData, ): Promise<{ id?: string | null; success?: boolean; error?: string }>; + updateDraft( + data: CreateDraftData, + ) : Promise<{id?: string | null, success?: boolean, error?: string}>; + deleteDraft( + draftId: string + ) : Promise; getDraft(id: string): Promise; listDrafts(params: { q?: string; maxResults?: number; pageToken?: string }): Promise<{ threads: { id: string; historyId: string | null; $raw: unknown }[]; diff --git a/apps/server/src/routes/chat.ts b/apps/server/src/routes/chat.ts index 700539505a..f59629c5d0 100644 --- a/apps/server/src/routes/chat.ts +++ b/apps/server/src/routes/chat.ts @@ -212,6 +212,14 @@ export class AgentRpcDO extends RpcTarget { return await this.mainDo.createDraft(draftData); } + async updateDraft(draftData: CreateDraftData){ + return await this.mainDo.updateDraft(draftData); + } + + async deleteDraft(id: string){ + return await this.mainDo.deleteDraft(id); + } + async getDraft(id: string) { return await this.mainDo.getDraft(id); } @@ -780,6 +788,20 @@ export class ZeroAgent extends AIChatAgent { return await this.driver.createDraft(draftData); } + async updateDraft(draftData: CreateDraftData){ + if(!this.driver){ + throw new Error('No driver available') + } + return await this.driver.updateDraft(draftData); + } + + async deleteDraft(id: string){ + if(!this.driver){ + throw new Error('No driver available') + } + return await this.driver.deleteDraft(id); + } + async getDraft(id: string) { if (!this.driver) { throw new Error('No driver available'); diff --git a/apps/server/src/trpc/routes/drafts.ts b/apps/server/src/trpc/routes/drafts.ts index 144f8d7f03..e1f26feabf 100644 --- a/apps/server/src/trpc/routes/drafts.ts +++ b/apps/server/src/trpc/routes/drafts.ts @@ -10,26 +10,43 @@ export const draftsRouter = router({ const agent = await getZeroAgent(activeConnection.id); return agent.createDraft(input); }), + update: activeDriverProcedure + .input(createDraftData) + .mutation(async ({input, ctx}) =>{ + const {activeConnection} = ctx; + const agent = await getZeroAgent(activeConnection.id); + const res = await agent.updateDraft(input); + return res; + }), + delete: activeDriverProcedure + .input(z.object({id: z.string()})) + .mutation(async({input,ctx})=>{ + const {activeConnection} = ctx; + const agent = await getZeroAgent(activeConnection.id); + const {id} = input; + const res = await agent.deleteDraft(id); + return res; + }), get: activeDriverProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => { const { activeConnection } = ctx; const agent = await getZeroAgent(activeConnection.id); const { id } = input; - return agent.getDraft(id) as ReturnType; + const res = agent.getDraft(id) as Awaited> + return res; }), list: activeDriverProcedure .input( z.object({ q: z.string().optional(), - maxResults: z.number().optional(), + max: z.number().optional(), pageToken: z.string().optional(), }), ) .query(async ({ input, ctx }) => { const { activeConnection } = ctx; const agent = await getZeroAgent(activeConnection.id); - const { q, maxResults, pageToken } = input; - return agent.listDrafts({ q, maxResults, pageToken }) as Awaited< - ReturnType - >; + const { q, max, pageToken } = input; + const res = agent.listDrafts({ q, maxResults: max, pageToken }) as Awaited>; + return res; }), });