Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 83 additions & 33 deletions apps/mail/components/mail/reply-composer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand All @@ -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();
Expand All @@ -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: {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -257,19 +296,30 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) {
return (
<div className="w-full rounded-2xl overflow-visible border">
<EmailComposer
key={draftId || undoReplyEmailData?.to?.join(',') || 'reply-composer'}
editorClassName="min-h-[50px]"
className="w-full max-w-none! pb-1 overflow-visible"
onSendEmail={handleSendEmail}
onClose={async () => {
setMode(null);
setDraftId(null);
setActiveReplyId(null);
if (typeof window !== 'undefined') {
localStorage.removeItem('undoReplyEmailData');
}
}}
initialMessage={draft?.content ?? latestDraft?.decodedBody}
initialTo={ensureEmailArray(draft?.to)}
initialCc={ensureEmailArray(draft?.cc)}
initialBcc={ensureEmailArray(draft?.bcc)}
initialSubject={draft?.subject}
initialMessage={undoReplyEmailData?.message ?? draft?.content ?? latestDraft?.decodedBody}
initialTo={
undoReplyEmailData?.to ??
(ensureEmailArray(draft?.to).length ? ensureEmailArray(draft?.to) : defaultTo)
}
initialCc={
undoReplyEmailData?.cc ??
(ensureEmailArray(draft?.cc).length ? ensureEmailArray(draft?.cc) : defaultCc)
}
initialBcc={undoReplyEmailData?.bcc ?? ensureEmailArray(draft?.bcc)}
initialSubject={undoReplyEmailData?.subject ?? draft?.subject ?? defaultSubject}
initialAttachments={undoReplyEmailData?.attachments}
autofocus={true}
settingsLoading={settingsLoading}
replyingTo={replyToMessage?.sender.email}
Expand Down
52 changes: 45 additions & 7 deletions apps/mail/hooks/use-undo-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ type SerializableEmailData = Omit<EmailData, 'attachments'> & {
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<SerializedFile[]> => {
return Promise.all(
files.map(async (file) => ({
Expand Down Expand Up @@ -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;
Expand All @@ -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');
Expand Down