"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
import { buildSignatureBlock } from "@/components/email/signature-block";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useAccountStore } from "@/stores/account-store";
import { useSettingsStore } from "@/stores/settings-store";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
import { substitutePlaceholders } from "@/lib/template-utils";
import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
formatRecipient,
parseRecipient,
parseRecipientList,
formatRecipientList,
splitPastedRecipients,
type Recipient,
} from "@/lib/email-composer-utils";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
/**
* Derives the text/plain alternative from the composer's HTML body, preserving
* line structure from block elements and
tags. Paragraph spacing is on so
*
blocks are separated by a blank line, matching their visual rendering (#421).
*/
function htmlToPlainText(html: string): string {
return htmlToPlainTextShared(html, { paragraphSpacing: true });
}
/**
* Build a floating drag image showing the recipient's address, mirroring the
* email-list drag preview so dragging a chip feels consistent. The element is
* positioned off-screen; the browser snapshots it for the drag cursor.
*/
function createChipDragPreview(label: string): HTMLElement {
const preview = document.createElement("div");
preview.className = "drag-preview";
preview.style.cssText = `
position: fixed;
top: -9999px;
left: 0;
padding: 4px 12px;
background-color: var(--color-primary, #3b82f6);
color: var(--color-primary-foreground, #ffffff);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 13px;
font-weight: 500;
z-index: 9999;
white-space: nowrap;
pointer-events: none;
`;
preview.textContent = label;
document.body.appendChild(preview);
return preview;
}
export interface ComposerDraftData {
to: string;
cc: string;
bcc: string;
subject: string;
body: string;
showCc: boolean;
showBcc: boolean;
selectedIdentityId: string | null;
subAddressTag: string;
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: EmailComposerProps['replyTo'];
draftId: string | null;
/** When set, overrides the header From: - sent through the selected identity's envelope. */
fromOverrideEmail?: string;
fromOverrideName?: string;
fromOverrideEnabled?: boolean;
}
interface EmailComposerProps {
onSend?: (data: {
to: string[];
cc: string[];
bcc: string[];
subject: string;
body: string;
htmlBody?: string;
draftId?: string;
fromEmail?: string;
fromName?: string;
identityId?: string;
envelopeMailFrom?: string;
/** Local account ID owning the selected identity. Set when the user
* picked an identity from a non-active account in the Pro multi-
* account dropdown; parents should send through that account's
* client instead of the currently-active one. */
localAccountId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
delayedUntil?: string;
requestReadReceipt?: boolean;
}) => void | Promise;
onScheduledSendCreated?: () => void | Promise;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
onSaveState?: (data: ComposerDraftData) => void;
className?: string;
initialDraftText?: string;
initialData?: ComposerDraftData | null;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: {
from?: { email?: string; name?: string }[];
replyToAddresses?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[];
bcc?: { email?: string; name?: string }[];
subject?: string;
body?: string;
htmlBody?: string;
receivedAt?: string;
accountId?: string;
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
// Threading: parent's Message-ID and References, used to set RFC 5322
// In-Reply-To and References on outgoing replies. See #234.
messageId?: string;
inReplyTo?: string[];
references?: string[];
// Pre-built quote header block. Supplied by the composer opener after it
// runs emailHooks.onBuildQuoteHeader through plugin transforms. When set,
// the composer uses these verbatim instead of building its own default
// "On X, Y wrote:" / "---------- Forwarded message ----------" block.
quoteHeaderHtml?: string;
quoteHeaderText?: string;
/** Mirror of QuoteHeader.wrapInBlockquote. Defaults to true. */
quoteWrapInBlockquote?: boolean;
};
}
type ComposerAttachment = {
file?: File;
name: string;
type: string;
size: number;
blobId?: string;
uploading?: boolean;
error?: boolean;
abortController?: AbortController;
};
type SignatureIdentityLike = {
htmlSignature?: string;
textSignature?: string;
} | null | undefined;
// Render the embedded signature. Bracketed with `data-signature-block` marker
// paragraphs so we can swap the inner content when the user switches identity
// without losing the surrounding draft or quoted message. The markers are
// preserved through TipTap by the StyledParagraph extension. The HTML
// signature itself is wrapped in a SignatureBlock atom node so its inline
// styling survives the editor (see signature-block.ts) instead of being
// flattened by the schema.
function buildEmbeddedSignatureHtml(
identity: SignatureIdentityLike,
options: { embed: boolean; separator: boolean }
): string {
if (!options.embed) return '';
const startMarker = options.separator
? `--
`
: ``;
const endMarker = ``;
if (identity?.htmlSignature) {
return `${startMarker}${buildSignatureBlock(sanitizeSignatureHtml(identity.htmlSignature))}${endMarker}`;
}
if (identity?.textSignature) {
const escaped = identity.textSignature
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/\n/g, '
');
return `${startMarker}${escaped}
${endMarker}`;
}
return '';
}
function formatLocalDateTimeInput(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function getDefaultScheduleValue(): string {
const tomorrowAtEight = new Date();
tomorrowAtEight.setDate(tomorrowAtEight.getDate() + 1);
tomorrowAtEight.setHours(8, 0, 0, 0);
return formatLocalDateTimeInput(tomorrowAtEight);
}
export function EmailComposer({
onSend,
onScheduledSendCreated,
onClose,
onDiscardDraft,
onSaveState,
className,
initialDraftText,
initialData,
mode = 'compose',
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault);
const activeIdentities = useIdentityStore((s) => s.identities);
// Pro shell: surface identities from every connected account, grouped
// for the From dropdown's