The earlier fix (f3b68194) addressed the Pro/embedded composer-hoisting
path (composing FROM the Mail tab, then getting hoisted into a Pro
tab), but missed a second, entirely separate render path: viewing an
email that's already been popped into its own Pro tab
(components/pro/pro-email-tab-body.tsx). That component renders its
own <EmailViewer> with its own self-contained handleForward - it
fetches its own `email` and opens compose tabs directly via
useProTabStore, with no dependency on page.tsx's pendingDraft/
selectedEmail plumbing at all - so it never had onForwardAsAttachment
wired in the first place. The overflow menu there just silently had
no such item, since EmailViewer only renders it when the prop is
provided.
Add handleForwardAsAttachment here, mirroring handleForward but using
the shared buildForwardAsAttachmentPayload helper, with the same
filename-options handling as the page.tsx fix (7a483b3d/a34314ce). No
stale-closure risk here (unlike the list context menu fix) - `email`
is this component's own local per-tab state, not a global selection
being mutated synchronously before the call.
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { EmailViewer } from "@/components/email/email-viewer";
|
|
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { useEmailStore } from "@/stores/email-store";
|
|
import { useIdentityStore } from "@/stores/identity-store";
|
|
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
|
import { findDraftIdentityId } from "@/lib/reply-identity";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import { toast } from "@/stores/toast-store";
|
|
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
|
|
import type { Email } from "@/lib/jmap/types";
|
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
|
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
|
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
|
|
|
interface ProEmailTabBodyProps {
|
|
tabId: string;
|
|
data: ProEmailTabData;
|
|
}
|
|
|
|
function buildReplyContext(email: Email): ProReplyContext {
|
|
return {
|
|
from: email.from,
|
|
replyToAddresses: email.replyTo,
|
|
to: email.to,
|
|
cc: email.cc,
|
|
bcc: email.bcc,
|
|
subject: email.subject,
|
|
...getQuoteBodies(email),
|
|
receivedAt: email.receivedAt,
|
|
accountId: email.accountId,
|
|
attachments: email.attachments,
|
|
messageId: email.messageId,
|
|
inReplyTo: email.inReplyTo,
|
|
references: email.references,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Renders a single email in its own Pro tab. Fetches the email content on
|
|
* mount via `email-store.fetchEmailContent` so the tab is self-sufficient -
|
|
* it doesn't depend on what the Mail tab has selected.
|
|
*/
|
|
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
|
|
const t = useTranslations();
|
|
const tNotifications = useTranslations('notifications');
|
|
|
|
const client = useAuthStore((s) => s.client);
|
|
const fetchEmailContent = useEmailStore((s) => s.fetchEmailContent);
|
|
const deleteEmail = useEmailStore((s) => s.deleteEmail);
|
|
const markAsRead = useEmailStore((s) => s.markAsRead);
|
|
const toggleStar = useEmailStore((s) => s.toggleStar);
|
|
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
|
|
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
|
|
const mailboxes = useEmailStore((s) => s.mailboxes);
|
|
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
|
|
const identities = useIdentityStore((s) => s.identities);
|
|
const multiAccountIdentities = useProMultiAccountIdentities();
|
|
|
|
const closeTab = useProTabStore((s) => s.closeTab);
|
|
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
|
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
|
|
|
|
const [email, setEmail] = useState<Email | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const composerSessionIdRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
if (!client) return;
|
|
setIsLoading(true);
|
|
fetchEmailContent(client, data.emailId)
|
|
.then((loaded) => {
|
|
if (cancelled) return;
|
|
setEmail(loaded);
|
|
if (loaded?.subject) {
|
|
updateTabTitle(tabId, loaded.subject);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error('Failed to fetch email for Pro tab:', err);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setIsLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [client, data.emailId, fetchEmailContent, tabId, updateTabTitle]);
|
|
|
|
const currentMailboxRole = useMemo(() => {
|
|
if (!email) return undefined;
|
|
const mb = email.mailboxIds
|
|
? Object.keys(email.mailboxIds).map((id) => mailboxes.find((m) => m.id === id)).find(Boolean)
|
|
: undefined;
|
|
return mb?.role;
|
|
}, [email, mailboxes]);
|
|
|
|
const handleReply = useCallback((draftText?: string) => {
|
|
if (!email) return;
|
|
composerSessionIdRef.current += 1;
|
|
openComposeTab({
|
|
sessionId: composerSessionIdRef.current,
|
|
mode: 'reply',
|
|
replyTo: buildReplyContext(email),
|
|
sourceEmailId: email.id,
|
|
initialDraftText: draftText,
|
|
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
|
});
|
|
}, [email, openComposeTab, t]);
|
|
|
|
const handleReplyAll = useCallback(() => {
|
|
if (!email) return;
|
|
composerSessionIdRef.current += 1;
|
|
openComposeTab({
|
|
sessionId: composerSessionIdRef.current,
|
|
mode: 'replyAll',
|
|
replyTo: buildReplyContext(email),
|
|
sourceEmailId: email.id,
|
|
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
|
});
|
|
}, [email, openComposeTab, t]);
|
|
|
|
const handleForward = useCallback(() => {
|
|
if (!email) return;
|
|
composerSessionIdRef.current += 1;
|
|
openComposeTab({
|
|
sessionId: composerSessionIdRef.current,
|
|
mode: 'forward',
|
|
replyTo: buildReplyContext(email),
|
|
sourceEmailId: email.id,
|
|
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
|
|
});
|
|
}, [email, openComposeTab, t]);
|
|
|
|
// Mirrors handleForward, but attaches the original as a message/rfc822
|
|
// file instead of quoting it inline - see lib/forward-as-attachment.ts.
|
|
// This is a separate, self-contained render path from the main Mail
|
|
// tab's EmailViewer (page.tsx) - Pro tabs fetch their own `email` and
|
|
// open compose tabs directly via useProTabStore, not through
|
|
// page.tsx's pendingDraft/selectedEmail plumbing - so it needed its own
|
|
// wiring rather than falling out of the page.tsx fix automatically.
|
|
const handleForwardAsAttachment = useCallback(() => {
|
|
if (!email) return;
|
|
const {
|
|
emailDownloadTemplate,
|
|
filenameSpaceReplacement,
|
|
filenameLowercase,
|
|
filenameStripDiacritics,
|
|
filenameCollapseSeparators,
|
|
} = useSettingsStore.getState();
|
|
const payload = buildForwardAsAttachmentPayload(email, t('email_composer.prefix.forward'), {
|
|
template: emailDownloadTemplate,
|
|
spaceReplacement: filenameSpaceReplacement,
|
|
lowercase: filenameLowercase,
|
|
stripDiacritics: filenameStripDiacritics,
|
|
collapseSeparators: filenameCollapseSeparators,
|
|
});
|
|
if (!payload) return;
|
|
|
|
composerSessionIdRef.current += 1;
|
|
openComposeTab({
|
|
sessionId: composerSessionIdRef.current,
|
|
mode: 'forward',
|
|
replyTo: {
|
|
subject: email.subject,
|
|
attachments: [payload.attachment],
|
|
},
|
|
sourceEmailId: email.id,
|
|
title: payload.subject,
|
|
});
|
|
}, [email, openComposeTab, t]);
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (!client || !email) return;
|
|
try {
|
|
await deleteEmail(client, email.id);
|
|
closeTab(tabId);
|
|
} catch (err) {
|
|
console.error('Delete failed:', err);
|
|
toast.error(tNotifications('error_deleting'));
|
|
}
|
|
}, [client, email, deleteEmail, closeTab, tabId, tNotifications]);
|
|
|
|
const handleArchive = useCallback(async () => {
|
|
if (!client || !email) return;
|
|
const archiveMb = mailboxes.find((m) => m.role === 'archive');
|
|
if (!archiveMb) return;
|
|
try {
|
|
await moveToMailbox(client, email.id, archiveMb.id);
|
|
toast.success(tNotifications('email_archived'));
|
|
closeTab(tabId);
|
|
} catch (err) {
|
|
console.error('Archive failed:', err);
|
|
}
|
|
}, [client, email, mailboxes, moveToMailbox, closeTab, tabId, tNotifications]);
|
|
|
|
const handleToggleStar = useCallback(async () => {
|
|
if (!client || !email) return;
|
|
try {
|
|
await toggleStar(client, email.id);
|
|
// Reflect locally - the viewer re-reads from email-store's selectedEmail
|
|
// shape only for the mail tab; here we update our local copy too.
|
|
setEmail((prev) => prev ? {
|
|
...prev,
|
|
keywords: {
|
|
...prev.keywords,
|
|
$flagged: !prev.keywords?.$flagged,
|
|
},
|
|
} : prev);
|
|
} catch (err) {
|
|
console.error('Toggle star failed:', err);
|
|
}
|
|
}, [client, email, toggleStar]);
|
|
|
|
const handleMarkAsRead = useCallback(async (emailId: string, read: boolean) => {
|
|
if (!client) return;
|
|
try {
|
|
await markAsRead(client, emailId, read);
|
|
setEmail((prev) => prev && prev.id === emailId ? {
|
|
...prev,
|
|
keywords: { ...prev.keywords, $seen: read },
|
|
} : prev);
|
|
} catch (err) {
|
|
console.error('Mark as read failed:', err);
|
|
}
|
|
}, [client, markAsRead]);
|
|
|
|
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
|
|
if (!email || email.id !== emailId) return;
|
|
// Drop existing color keywords, optionally add the new one. Matches the
|
|
// mail page's local optimistic update.
|
|
const keywords = { ...(email.keywords ?? {}) };
|
|
for (const kw of settingsKeywords) {
|
|
delete keywords[`$label:${kw.id}`];
|
|
}
|
|
if (color) {
|
|
const def = settingsKeywords.find((k) => k.color === color);
|
|
if (def) keywords[`$label:${def.id}`] = true;
|
|
}
|
|
setEmailKeywordsLocal(emailId, keywords);
|
|
setEmail({ ...email, keywords });
|
|
}, [email, settingsKeywords, setEmailKeywordsLocal]);
|
|
|
|
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
|
if (!client || !email) return;
|
|
try {
|
|
await moveToMailbox(client, email.id, mailboxId);
|
|
closeTab(tabId);
|
|
} catch (err) {
|
|
console.error('Move failed:', err);
|
|
}
|
|
}, [client, email, moveToMailbox, closeTab, tabId]);
|
|
|
|
const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string) => {
|
|
if (!client) return;
|
|
try {
|
|
await client.downloadBlob(blobId, name, type);
|
|
} catch (err) {
|
|
console.error('Download failed:', err);
|
|
}
|
|
}, [client]);
|
|
|
|
const handleQuickReply = useCallback(async (body: string) => {
|
|
handleReply(body);
|
|
}, [handleReply]);
|
|
|
|
const handleEditDraft = useCallback(() => {
|
|
if (!email) return;
|
|
|
|
const bodyText = email.bodyValues
|
|
? Object.values(email.bodyValues).map((v) => v.value).join('\n')
|
|
: '';
|
|
// A plain-text-only draft lists its text/plain part under htmlBody
|
|
// (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is.
|
|
const draftHtmlPart = email.htmlBody?.[0];
|
|
const htmlBody = draftHtmlPart?.partId
|
|
&& (!draftHtmlPart.type || draftHtmlPart.type.toLowerCase() === 'text/html')
|
|
&& email.bodyValues?.[draftHtmlPart.partId]
|
|
? email.bodyValues[draftHtmlPart.partId].value
|
|
: undefined;
|
|
|
|
// Preserve the identity the draft was composed with — match name + address
|
|
// against the same list the composer renders (see findDraftIdentityId).
|
|
const composerIdentities = multiAccountIdentities.enabled
|
|
? multiAccountIdentities.allIdentities
|
|
: identities;
|
|
const matchedIdentityId = findDraftIdentityId(composerIdentities, email.from?.[0]);
|
|
|
|
composerSessionIdRef.current += 1;
|
|
openComposeTab({
|
|
sessionId: composerSessionIdRef.current,
|
|
mode: 'compose',
|
|
title: email.subject || t('email_composer.new_message'),
|
|
initialData: {
|
|
to: email.to?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
|
cc: email.cc?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
|
bcc: email.bcc?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
|
subject: email.subject || '',
|
|
body: htmlBody || bodyText,
|
|
showCc: (email.cc?.length || 0) > 0,
|
|
showBcc: (email.bcc?.length || 0) > 0,
|
|
selectedIdentityId: matchedIdentityId,
|
|
subAddressTag: '',
|
|
mode: 'compose',
|
|
draftId: email.id,
|
|
},
|
|
});
|
|
closeTab(tabId);
|
|
}, [email, identities, multiAccountIdentities, openComposeTab, closeTab, tabId, t]);
|
|
|
|
return (
|
|
<div className="flex h-full w-full flex-col bg-background">
|
|
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
|
<EmailViewer
|
|
email={email}
|
|
isLoading={isLoading}
|
|
onReply={handleReply}
|
|
onReplyAll={handleReplyAll}
|
|
onForward={handleForward}
|
|
onForwardAsAttachment={handleForwardAsAttachment}
|
|
onDelete={handleDelete}
|
|
onArchive={handleArchive}
|
|
onToggleStar={handleToggleStar}
|
|
onMarkAsRead={handleMarkAsRead}
|
|
onSetColorTag={handleSetColorTag}
|
|
onDownloadAttachment={handleDownloadAttachment}
|
|
onQuickReply={handleQuickReply}
|
|
onEditDraft={handleEditDraft}
|
|
onMoveToMailbox={handleMoveToMailbox}
|
|
currentUserEmail={client?.getUsername()}
|
|
currentUserName={client?.getUsername()?.split('@')[0]}
|
|
currentMailboxRole={currentMailboxRole}
|
|
mailboxes={mailboxes}
|
|
className="flex-1"
|
|
/>
|
|
</ErrorBoundary>
|
|
</div>
|
|
);
|
|
}
|