From d5ad0fca21b5d84e4688af23b80e23220933f5f0 Mon Sep 17 00:00:00 2001 From: amrit Date: Mon, 11 Aug 2025 12:57:06 +0530 Subject: [PATCH 1/4] add undo feature to reply mails and fix default mail while replying to emails --- apps/mail/components/mail/reply-composer.tsx | 116 +++++++++++++------ apps/mail/hooks/use-undo-send.ts | 52 +++++++-- 2 files changed, 128 insertions(+), 40 deletions(-) diff --git a/apps/mail/components/mail/reply-composer.tsx b/apps/mail/components/mail/reply-composer.tsx index 3c0da0e6e0..740aa64e55 100644 --- a/apps/mail/components/mail/reply-composer.tsx +++ b/apps/mail/components/mail/reply-composer.tsx @@ -1,4 +1,4 @@ -import { useUndoSend } from '@/hooks/use-undo-send'; +import { useUndoSend, deserializeFiles, type EmailData } from '@/hooks/use-undo-send'; import { constructReplyBody, constructForwardBody } from '@/lib/utils'; import { useActiveConnection } from '@/hooks/use-connections'; import { useEmailAliases } from '@/hooks/use-email-aliases'; @@ -14,7 +14,7 @@ import { useDraft } from '@/hooks/use-drafts'; import { m } from '@/paraglide/messages'; import type { Sender } from '@/types'; import { useQueryState } from 'nuqs'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import posthog from 'posthog-js'; import { toast } from 'sonner'; @@ -29,7 +29,7 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { const [draftId, setDraftId] = useQueryState('draftId'); const [threadId] = useQueryState('threadId'); - const [, setActiveReplyId] = useQueryState('activeReplyId'); + const [activeReplyId, setActiveReplyId] = useQueryState('activeReplyId'); const { data: emailData, refetch, latestDraft } = useThread(threadId); const { data: draft } = useDraft(draftId ?? null); const trpc = useTRPC(); @@ -43,59 +43,92 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { const replyToMessage = (messageId && emailData?.messages.find((msg) => msg.id === messageId)) || emailData?.latest; - // Initialize recipients and subject when mode changes - useEffect(() => { - if (!replyToMessage || !mode || !activeConnection?.email) return; + const undoReplyEmailData = useMemo((): EmailData | null => { + if (!mode) return null; + if (typeof window === 'undefined') return null; + + const stored = localStorage.getItem('undoReplyEmailData'); + if (!stored) return null; + + try { + const parsed = JSON.parse(stored); + const ctx = parsed?.__replyContext as + | { threadId: string; activeReplyId: string; mode: string; draftId?: string | null } + | undefined; + + const currentThread = threadId || replyToMessage?.threadId || ''; + const currentReply = messageId || activeReplyId || replyToMessage?.id || ''; + const matches = !!ctx && ctx.threadId === currentThread && ctx.activeReplyId === currentReply; + if (!matches) return null; + + if (parsed.attachments && Array.isArray(parsed.attachments)) { + parsed.attachments = deserializeFiles(parsed.attachments); + } + return parsed as EmailData; + } catch (err) { + console.error('Failed to parse undo reply email data:', err); + return null; + } + }, [mode, threadId, messageId, activeReplyId, replyToMessage?.id, replyToMessage?.threadId]); + + const { defaultTo, defaultCc, defaultSubject } = useMemo(() => { + const result = { defaultTo: [] as string[], defaultCc: [] as string[], defaultSubject: '' }; + if (!replyToMessage || !mode || !activeConnection?.email) { + return result; + } const userEmail = activeConnection.email.toLowerCase(); const senderEmail = replyToMessage.sender.email.toLowerCase(); - // Set subject based on mode + const baseSubject = replyToMessage.subject || ''; + const lower = baseSubject.trim().toLowerCase(); + const hasRePrefix = lower.startsWith('re:'); + const hasFwdPrefix = lower.startsWith('fwd:') || lower.startsWith('fw:'); + if (mode === 'forward') { + result.defaultSubject = hasFwdPrefix ? baseSubject : `Fwd: ${baseSubject}`.trim(); + } else { + result.defaultSubject = hasRePrefix ? baseSubject : `Re: ${baseSubject}`.trim(); + } if (mode === 'reply') { - // Reply to sender - const to: string[] = []; - - // If the sender is not the current user, add them to the recipients if (senderEmail !== userEmail) { - to.push(replyToMessage.sender.email); + result.defaultTo.push(replyToMessage.sender.email); } else if (replyToMessage.to && replyToMessage.to.length > 0 && replyToMessage.to[0]?.email) { - // If we're replying to our own email, reply to the first recipient - to.push(replyToMessage.to[0].email); + result.defaultTo.push(replyToMessage.to[0].email); } + return result; + } - // Initialize email composer with these recipients - // Note: The actual initialization happens in the EmailComposer component - } else if (mode === 'replyAll') { - const to: string[] = []; - const cc: string[] = []; - + if (mode === 'replyAll') { // Add original sender if not current user if (senderEmail !== userEmail) { - to.push(replyToMessage.sender.email); + result.defaultTo.push(replyToMessage.sender.email); } // Add original recipients from To field replyToMessage.to?.forEach((recipient) => { const recipientEmail = recipient.email.toLowerCase(); if (recipientEmail !== userEmail && recipientEmail !== senderEmail) { - to.push(recipient.email); + if (!result.defaultTo.includes(recipient.email)) { + result.defaultTo.push(recipient.email); + } } }); // Add CC recipients replyToMessage.cc?.forEach((recipient) => { const recipientEmail = recipient.email.toLowerCase(); - if (recipientEmail !== userEmail && !to.includes(recipient.email)) { - cc.push(recipient.email); + if (recipientEmail !== userEmail && !result.defaultTo.includes(recipient.email)) { + if (!result.defaultCc.includes(recipient.email)) { + result.defaultCc.push(recipient.email); + } } }); - // Initialize email composer with these recipients - } else if (mode === 'forward') { - // For forward, we start with empty recipients - // Just set the subject and include the original message + return result; } + + return result; }, [mode, replyToMessage, activeConnection?.email]); const handleSendEmail = async (data: { @@ -219,6 +252,12 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { message: data.message, attachments: data.attachments, scheduleAt: data.scheduleAt, + }, { + kind: 'reply', + threadId: replyToMessage.threadId || threadId || '', + mode: (mode as 'reply' | 'replyAll' | 'forward') ?? 'reply', + activeReplyId: replyToMessage.id, + draftId: draftId ?? undefined, }); } catch (error) { console.error('Error sending email:', error); @@ -257,6 +296,7 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { return (
& { attachments: SerializedFile[]; }; +type ReplyMode = 'reply' | 'replyAll' | 'forward'; + +type UndoContext = + | { kind: 'compose' } + | { + kind: 'reply'; + threadId: string; + mode: ReplyMode; + activeReplyId: string; + draftId?: string | null; + }; + const serializeFiles = async (files: File[]): Promise => { return Promise.all( files.map(async (file) => ({ @@ -61,9 +73,10 @@ export const useUndoSend = () => { const { mutateAsync: unsendEmail } = useMutation(trpc.mail.unsend.mutationOptions()); const handleUndoSend = ( - result: unknown, + result: unknown, settings: { settings: UserSettings } | undefined, - emailData?: EmailData + emailData?: EmailData, + context: UndoContext = { kind: 'compose' }, ) => { if (isSendResult(result) && settings?.settings?.undoSendEnabled) { const { messageId, sendAt } = result; @@ -84,14 +97,39 @@ export const useUndoSend = () => { ...emailData, attachments: serializedAttachments, }; - localStorage.setItem('undoEmailData', JSON.stringify(serializableData)); + if (context.kind === 'reply') { + const withContext = { + ...serializableData, + __replyContext: { + threadId: context.threadId, + activeReplyId: context.activeReplyId, + mode: context.mode, + draftId: context.draftId ?? null, + }, + } as const; + localStorage.setItem('undoReplyEmailData', JSON.stringify(withContext)); + } else { + localStorage.setItem('undoEmailData', JSON.stringify(serializableData)); + } } const url = new URL(window.location.href); - url.searchParams.delete('activeReplyId'); - url.searchParams.delete('mode'); - url.searchParams.delete('draftId'); - url.searchParams.set('isComposeOpen', 'true'); + if (context.kind === 'reply') { + url.searchParams.delete('isComposeOpen'); + url.searchParams.set('threadId', context.threadId); + url.searchParams.set('activeReplyId', context.activeReplyId); + url.searchParams.set('mode', context.mode); + if (context.draftId) { + url.searchParams.set('draftId', context.draftId); + } else { + url.searchParams.delete('draftId'); + } + } else { + url.searchParams.delete('activeReplyId'); + url.searchParams.delete('mode'); + url.searchParams.delete('draftId'); + url.searchParams.set('isComposeOpen', 'true'); + } window.history.replaceState({}, '', url.toString()); toast.info('Send cancelled'); From 63a2a446379c5c0ad4ade3bd1178ca3d9a54ec2b Mon Sep 17 00:00:00 2001 From: amrit Date: Mon, 11 Aug 2025 13:09:12 +0530 Subject: [PATCH 2/4] refactor --- apps/mail/components/mail/reply-composer.tsx | 35 +++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/mail/components/mail/reply-composer.tsx b/apps/mail/components/mail/reply-composer.tsx index 740aa64e55..e4bf041b44 100644 --- a/apps/mail/components/mail/reply-composer.tsx +++ b/apps/mail/components/mail/reply-composer.tsx @@ -14,7 +14,7 @@ import { useDraft } from '@/hooks/use-drafts'; import { m } from '@/paraglide/messages'; import type { Sender } from '@/types'; import { useQueryState } from 'nuqs'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useSyncExternalStore } from 'react'; import posthog from 'posthog-js'; import { toast } from 'sonner'; @@ -43,15 +43,34 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { const replyToMessage = (messageId && emailData?.messages.find((msg) => msg.id === messageId)) || emailData?.latest; + const undoReplyRaw = useSyncExternalStore( + (listener) => { + if (typeof window === 'undefined') return () => {}; + const handler = () => listener(); + window.addEventListener('storage', handler); + window.addEventListener('zero:undoReplyEmailData', handler as EventListener); + return () => { + window.removeEventListener('storage', handler); + window.removeEventListener('zero:undoReplyEmailData', handler as EventListener); + }; + }, + () => { + try { + return typeof window !== 'undefined' + ? window.localStorage.getItem('undoReplyEmailData') + : null; + } catch { + return null; + } + }, + () => null, + ); + const undoReplyEmailData = useMemo((): EmailData | null => { if (!mode) return null; - if (typeof window === 'undefined') return null; - - const stored = localStorage.getItem('undoReplyEmailData'); - if (!stored) return null; - + if (!undoReplyRaw) return null; try { - const parsed = JSON.parse(stored); + const parsed = JSON.parse(undoReplyRaw); const ctx = parsed?.__replyContext as | { threadId: string; activeReplyId: string; mode: string; draftId?: string | null } | undefined; @@ -69,7 +88,7 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { console.error('Failed to parse undo reply email data:', err); return null; } - }, [mode, threadId, messageId, activeReplyId, replyToMessage?.id, replyToMessage?.threadId]); + }, [undoReplyRaw, mode, threadId, messageId, activeReplyId, replyToMessage?.id, replyToMessage?.threadId]); const { defaultTo, defaultCc, defaultSubject } = useMemo(() => { const result = { defaultTo: [] as string[], defaultCc: [] as string[], defaultSubject: '' }; From d0901f88a8f24cd4ba33598ab7046c033b37e5fd Mon Sep 17 00:00:00 2001 From: amrit Date: Mon, 11 Aug 2025 13:17:36 +0530 Subject: [PATCH 3/4] prevent duplicate email addresses in 'To' and 'CC' fields during replies --- apps/mail/components/mail/reply-composer.tsx | 43 ++++++++++++-------- apps/mail/hooks/use-undo-send.ts | 4 +- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/apps/mail/components/mail/reply-composer.tsx b/apps/mail/components/mail/reply-composer.tsx index e4bf041b44..0e4d3358ea 100644 --- a/apps/mail/components/mail/reply-composer.tsx +++ b/apps/mail/components/mail/reply-composer.tsx @@ -1,4 +1,4 @@ -import { useUndoSend, deserializeFiles, type EmailData } from '@/hooks/use-undo-send'; +import { useUndoSend, deserializeFiles, type EmailData, type ReplyMode, type UndoContext } from '@/hooks/use-undo-send'; import { constructReplyBody, constructForwardBody } from '@/lib/utils'; import { useActiveConnection } from '@/hooks/use-connections'; import { useEmailAliases } from '@/hooks/use-email-aliases'; @@ -119,28 +119,39 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { } if (mode === 'replyAll') { + const seen = new Set(); + + const pushIfNew = (email: string, target: 'to' | 'cc') => { + const key = email.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + if (target === 'to') { + result.defaultTo.push(email); + } else { + result.defaultCc.push(email); + } + }; + // Add original sender if not current user if (senderEmail !== userEmail) { - result.defaultTo.push(replyToMessage.sender.email); + pushIfNew(replyToMessage.sender.email, 'to'); } // Add original recipients from To field replyToMessage.to?.forEach((recipient) => { - const recipientEmail = recipient.email.toLowerCase(); - if (recipientEmail !== userEmail && recipientEmail !== senderEmail) { - if (!result.defaultTo.includes(recipient.email)) { - result.defaultTo.push(recipient.email); - } + const recipientEmail = recipient.email; + const key = recipientEmail.toLowerCase(); + if (key !== userEmail && key !== senderEmail) { + pushIfNew(recipientEmail, 'to'); } }); // Add CC recipients replyToMessage.cc?.forEach((recipient) => { - const recipientEmail = recipient.email.toLowerCase(); - if (recipientEmail !== userEmail && !result.defaultTo.includes(recipient.email)) { - if (!result.defaultCc.includes(recipient.email)) { - result.defaultCc.push(recipient.email); - } + const recipientEmail = recipient.email; + const key = recipientEmail.toLowerCase(); + if (key !== userEmail) { + pushIfNew(recipientEmail, 'cc'); } }); @@ -274,10 +285,10 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { }, { kind: 'reply', threadId: replyToMessage.threadId || threadId || '', - mode: (mode as 'reply' | 'replyAll' | 'forward') ?? 'reply', + mode: (mode ?? 'reply') as ReplyMode, activeReplyId: replyToMessage.id, - draftId: draftId ?? undefined, - }); + draftId, + } satisfies UndoContext); } catch (error) { console.error('Error sending email:', error); toast.error(m['pages.createEmail.failedToSendEmail']()); @@ -315,7 +326,7 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) { return (
& { attachments: SerializedFile[]; }; -type ReplyMode = 'reply' | 'replyAll' | 'forward'; +export type ReplyMode = 'reply' | 'replyAll' | 'forward'; -type UndoContext = +export type UndoContext = | { kind: 'compose' } | { kind: 'reply'; From 546a00d8f75e482da248b9aa76be5bd005532c23 Mon Sep 17 00:00:00 2001 From: amrit Date: Mon, 11 Aug 2025 23:20:17 +0530 Subject: [PATCH 4/4] use nuqs instead of sloppy url management --- apps/mail/hooks/use-undo-send.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/mail/hooks/use-undo-send.ts b/apps/mail/hooks/use-undo-send.ts index 4cff3b11ed..8924facc17 100644 --- a/apps/mail/hooks/use-undo-send.ts +++ b/apps/mail/hooks/use-undo-send.ts @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { toast } from 'sonner'; - +import { useQueryState } from 'nuqs'; import { useTRPC } from '@/providers/query-provider'; import { isSendResult } from '@/lib/email-utils'; import type { UserSettings } from '@zero/server/schemas'; @@ -72,6 +72,12 @@ export const useUndoSend = () => { const trpc = useTRPC(); const { mutateAsync: unsendEmail } = useMutation(trpc.mail.unsend.mutationOptions()); + const [, setIsComposeOpen] = useQueryState('isComposeOpen'); + const [, setThreadId] = useQueryState('threadId'); + const [, setActiveReplyId] = useQueryState('activeReplyId'); + const [, setMode] = useQueryState('mode'); + const [, setDraftId] = useQueryState('draftId'); + const handleUndoSend = ( result: unknown, settings: { settings: UserSettings } | undefined, @@ -113,24 +119,18 @@ export const useUndoSend = () => { } } - const url = new URL(window.location.href); if (context.kind === 'reply') { - url.searchParams.delete('isComposeOpen'); - url.searchParams.set('threadId', context.threadId); - url.searchParams.set('activeReplyId', context.activeReplyId); - url.searchParams.set('mode', context.mode); - if (context.draftId) { - url.searchParams.set('draftId', context.draftId); - } else { - url.searchParams.delete('draftId'); - } + setIsComposeOpen(null); + setThreadId(context.threadId); + setActiveReplyId(context.activeReplyId); + setMode(context.mode); + setDraftId(context.draftId ?? null); } else { - url.searchParams.delete('activeReplyId'); - url.searchParams.delete('mode'); - url.searchParams.delete('draftId'); - url.searchParams.set('isComposeOpen', 'true'); + setActiveReplyId(null); + setMode(null); + setDraftId(null); + setIsComposeOpen('true'); } - window.history.replaceState({}, '', url.toString()); toast.info('Send cancelled'); } catch {