This commit is contained in:
Linus Rath
2026-06-15 23:16:10 +02:00
13 changed files with 650 additions and 270 deletions
@@ -237,7 +237,7 @@ describe('RecipientChipInput drag and drop', () => {
expect(chipSpan).toHaveAttribute('draggable', 'true');
});
it('onDragStart encodes chip value and source field into dataTransfer', async () => {
it('onDragStart encodes the recipient and source field into dataTransfer', async () => {
render(<EmailComposer initialData={BASE_DATA} />);
const chipText = await screen.findByText('alice@example.com');
@@ -247,7 +247,21 @@ describe('RecipientChipInput drag and drop', () => {
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ chip: 'alice@example.com', fromField: 'to' });
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
});
it('keeps a display name with a comma in a single chip (array model)', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, to: '"Doo, John" <john@doo.org>, ' }} />);
// One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma.
const chip = await screen.findByText('Doo, John (john@doo.org)');
const chipSpan = chip.closest('[draggable]') as HTMLElement;
expect(chipSpan).not.toBeNull();
const dt = new MockDataTransfer();
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
});
it('onDragEnd clears the opacity class on the chip', async () => {
@@ -277,7 +291,7 @@ describe('RecipientChipInput drag and drop', () => {
if (!ccContainer) return;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ chip: 'alice@example.com', fromField: 'to' }));
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
expect(ccContainer.className).toContain('ring-primary');
@@ -297,7 +311,7 @@ describe('RecipientChipInput drag and drop', () => {
if (!toContainer || !ccContainer) return;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ chip: 'alice@example.com', fromField: 'to' }));
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccContainer, { dataTransfer: dt });
act(() => {
fireEvent.drop(ccContainer, { dataTransfer: dt });
@@ -319,7 +333,7 @@ describe('RecipientChipInput drag and drop', () => {
const toContainer = allContainers.find(el => el.querySelector('[draggable]')) as HTMLElement;
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ chip: 'alice@example.com', fromField: 'to' }));
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(toContainer, { dataTransfer: dt });
act(() => {
fireEvent.drop(toContainer, { dataTransfer: dt });
@@ -336,7 +350,7 @@ describe('RecipientChipInput drag and drop', () => {
const ccButton = screen.getByRole('button', { name: 'Cc' });
const dt = new MockDataTransfer();
dt.setData('application/x-recipient-chip', JSON.stringify({ chip: 'alice@example.com', fromField: 'to' }));
dt.setData('application/x-recipient-chip', JSON.stringify({ recipient: { email: 'alice@example.com' }, fromField: 'to' }));
fireEvent.dragOver(ccButton, { dataTransfer: dt });
act(() => {
fireEvent.drop(ccButton, { dataTransfer: dt });
+173 -186
View File
@@ -45,8 +45,11 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
removeChipFromFieldValue,
addChipToFieldValue,
formatRecipient,
parseRecipient,
parseRecipientList,
formatRecipientList,
type Recipient,
} from "@/lib/email-composer-utils";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
@@ -284,46 +287,34 @@ export function EmailComposer({
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
// Format a single EmailAddress for display in the composer input
const formatAddr = (r: { name?: string; email?: string }) =>
r.email ? (r.name && r.name !== r.email ? `${r.name} <${r.email}>` : r.email) : "";
// Parse a recipient string that may be "Name <email>" or bare "email"
const parseRecipient = (s: string): { name?: string; email: string } => {
const trimmed = s.trim();
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
if (angleMatch) {
return { name: angleMatch[1].trim(), email: angleMatch[2].trim() };
}
return { email: trimmed };
};
const toRecipient = (r: { name?: string; email?: string }): Recipient =>
({ name: r.name && r.name !== r.email ? r.name : undefined, email: r.email ?? "" });
// Initialize with reply/forward data if provided
const getInitialTo = () => {
if (!replyTo) return "";
const getInitialTo = (): Recipient[] => {
if (!replyTo) return [];
// RFC 5322: use Reply-To header if present, otherwise fall back to From
const replyTarget = replyTo.replyToAddresses?.length
? replyTo.replyToAddresses.filter(r => r.email).map(formatAddr).join(", ")
: (replyTo.from?.[0] ? formatAddr(replyTo.from[0]) : "");
? replyTo.replyToAddresses.filter(r => r.email).map(toRecipient)
: (replyTo.from?.[0]?.email ? [toRecipient(replyTo.from[0])] : []);
if (mode === 'reply') {
return replyTarget ? replyTarget + ', ' : "";
return replyTarget;
} else if (mode === 'replyAll') {
const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean));
const originalTo = replyTo.to
?.filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase()))
.map(formatAddr).join(", ") || "";
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
return combined ? combined + ', ' : "";
const originalTo = (replyTo.to ?? [])
.filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase()))
.map(toRecipient);
return [...replyTarget, ...originalTo];
}
return "";
return [];
};
const getInitialCc = () => {
if (!replyTo || mode !== 'replyAll') return "";
const getInitialCc = (): Recipient[] => {
if (!replyTo || mode !== 'replyAll') return [];
const ownEmails = new Set(identities.map(i => i.email?.trim().toLowerCase()).filter(Boolean));
const cc = replyTo.cc
?.filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase()))
.map(formatAddr).join(", ") || "";
return cc ? cc + ', ' : "";
return (replyTo.cc ?? [])
.filter(r => r.email && !ownEmails.has(r.email.trim().toLowerCase()))
.map(toRecipient);
};
const getInitialSubject = () => {
@@ -463,13 +454,25 @@ export function EmailComposer({
return prefix;
};
const [to, setTo] = useState(initialData?.to ?? getInitialTo());
const [cc, setCc] = useState(initialData?.cc ?? getInitialCc());
const [bcc, setBcc] = useState(initialData?.bcc ?? "");
// Committed recipients are structured arrays; the in-progress text the user
// is typing lives in a separate `*Input` string per field. This keeps a
// display name containing a comma (e.g. "Doo, John") intact instead of
// tearing it apart on a delimiter.
const [to, setTo] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.to) : getInitialTo());
const [cc, setCc] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.cc) : getInitialCc());
const [bcc, setBcc] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.bcc) : []);
const [toInput, setToInput] = useState('');
const [ccInput, setCcInput] = useState('');
const [bccInput, setBccInput] = useState('');
const [subject, setSubject] = useState(initialData?.subject ?? getInitialSubject());
const [body, setBody] = useState(initialData?.body ?? getInitialBody());
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
const [showCc, setShowCc] = useState(initialData?.showCc ?? getInitialCc().length > 0);
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
// Committed recipients plus any not-yet-committed text the user has typed.
// Send/validation/draft paths treat a typed-but-uncommitted address as a
// real recipient, matching the previous string-based behavior.
const withInput = (chips: Recipient[], input: string): Recipient[] =>
input.trim() ? [...chips, parseRecipient(input)] : chips;
const [isDraggingChipOverCc, setIsDraggingChipOverCc] = useState(false);
const [isDraggingChipOverBcc, setIsDraggingChipOverBcc] = useState(false);
const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault);
@@ -798,10 +801,11 @@ export function EmailComposer({
const canSmimeSign = !!smimeKeyRecord;
const canSmimeEncrypt = (() => {
if (!smimeKeyRecord) return false;
const toAddrs = to.split(',').map(e => e.trim()).filter(Boolean);
const ccAddrs = cc.split(',').map(e => e.trim()).filter(Boolean);
const bccAddrs = bcc.split(',').map(e => e.trim()).filter(Boolean);
const allRecipients = [...toAddrs, ...ccAddrs, ...bccAddrs];
const allRecipients = [
...withInput(to, toInput),
...withInput(cc, ccInput),
...withInput(bcc, bccInput),
].map(r => r.email);
if (allRecipients.length === 0) return false;
const { missing } = smimeStore.getRecipientCerts(allRecipients);
return missing.length === 0;
@@ -817,15 +821,21 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSmimeIdentityId]);
// Serialized recipient strings for ComposerDraftData (string-shaped) and for
// by-value dirty comparison. Folds in any uncommitted typed text.
const toStr = formatRecipientList(withInput(to, toInput));
const ccStr = formatRecipientList(withInput(cc, ccInput));
const bccStr = formatRecipientList(withInput(bcc, bccInput));
// Keep a ref to current state for the unmount save
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName });
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName };
const stateRef = useRef({ to: toStr, cc: ccStr, bcc: bccStr, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName });
stateRef.current = { to: toStr, cc: ccStr, bcc: bccStr, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName };
// Track initial values for dirty detection (captured once on first render)
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length });
const initialValuesRef = useRef({ to: toStr, cc: ccStr, bcc: bccStr, subject, body, attachmentCount: attachments.length });
const isDirtyRef = useRef(false);
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
isDirtyRef.current = toStr !== initialValuesRef.current.to || ccStr !== initialValuesRef.current.cc ||
bccStr !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
body !== initialValuesRef.current.body || attachments.length > initialValuesRef.current.attachmentCount;
// Ref to latest saveDraft for use in event handlers with stale closures
@@ -902,21 +912,26 @@ export function EmailComposer({
}
}, [plainTextMode]);
const handleMoveChip = useCallback((chip: string, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => {
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => {
if (fromField === toField) return;
const setters = { to: setTo, cc: setCc, bcc: setBcc };
setters[fromField](prev => removeChipFromFieldValue(prev, chip));
setters[toField](prev => addChipToFieldValue(prev, chip));
const sameRecipient = (a: Recipient, b: Recipient) => a.email === b.email && (a.name ?? '') === (b.name ?? '');
setters[fromField](prev => {
const idx = prev.findIndex(r => sameRecipient(r, recipient));
return idx === -1 ? prev : prev.filter((_, i) => i !== idx);
});
setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]);
if (toField === 'cc') setShowCc(true);
if (toField === 'bcc') setShowBcc(true);
}, [setTo, setCc, setBcc, setShowCc, setShowBcc]);
const handleAutocomplete = useCallback((value: string, field: 'to' | 'cc' | 'bcc') => {
const handleAutocomplete = useCallback((inputText: string, field: 'to' | 'cc' | 'bcc') => {
if (autocompleteTimeoutRef.current) {
clearTimeout(autocompleteTimeoutRef.current);
}
const lastPart = value.split(',').pop()?.trim() || '';
if (lastPart.length < 1) {
const query = inputText.trim();
if (query.length < 1) {
setAutocompleteResults([]);
setActiveAutoField(null);
setAutoSelectedIndex(-1);
@@ -924,10 +939,10 @@ export function EmailComposer({
}
autocompleteTimeoutRef.current = setTimeout(async () => {
const localResults = getAutocomplete(lastPart);
const localResults = getAutocomplete(query);
// Let plugins contribute extra suggestions (Slack handles, GitHub, CRM, …).
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query: lastPart });
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query });
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
setActiveAutoField(merged.length > 0 ? field : null);
setAutoSelectedIndex(-1);
@@ -936,17 +951,10 @@ export function EmailComposer({
const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => {
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
const getter = field === 'to' ? to : field === 'cc' ? cc : bcc;
const inputSetter = field === 'to' ? setToInput : field === 'cc' ? setCcInput : setBccInput;
const parts = getter.split(',').map(s => s.trim()).filter(Boolean);
if (!getter.trimEnd().endsWith(',') && parts.length > 0) {
parts.pop();
}
const formatted = suggestion.name && suggestion.name !== suggestion.email
? `${suggestion.name} <${suggestion.email}>`
: suggestion.email;
parts.push(formatted);
setter(parts.join(', ') + ', ');
setter(prev => [...prev, toRecipient(suggestion)]);
inputSetter('');
setAutocompleteResults([]);
setActiveAutoField(null);
setAutoSelectedIndex(-1);
@@ -1003,14 +1011,14 @@ export function EmailComposer({
setSubject(filledSubject);
setBody(bodyContent);
if (template.defaultRecipients?.to?.length) {
setTo(template.defaultRecipients.to.join(', ') + ', ');
setTo(template.defaultRecipients.to.map(parseRecipient));
}
if (template.defaultRecipients?.cc?.length) {
setCc(template.defaultRecipients.cc.join(', ') + ', ');
setCc(template.defaultRecipients.cc.map(parseRecipient));
setShowCc(true);
}
if (template.defaultRecipients?.bcc?.length) {
setBcc(template.defaultRecipients.bcc.join(', ') + ', ');
setBcc(template.defaultRecipients.bcc.map(parseRecipient));
setShowBcc(true);
}
} else {
@@ -1237,9 +1245,9 @@ export function EmailComposer({
const saveDraftOnce = async (): Promise<string | null> => {
if (!client || !composerClient) return null;
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
const toAddresses = withInput(to, toInput).map(r => formatRecipient(r.name, r.email));
const ccAddresses = withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email));
const bccAddresses = withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email));
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
return null;
@@ -1362,9 +1370,9 @@ export function EmailComposer({
saveTimeoutRef.current = null;
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
emailHooks.onDraftChange.emit({
to: to.split(',').map(s => s.trim()).filter(Boolean),
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
subject,
htmlBody: plainTextMode ? '' : body,
textBody: plainTextMode ? body : htmlToPlainText(body),
@@ -1383,7 +1391,7 @@ export function EmailComposer({
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up
}, [to, cc, bcc, subject, body, attachments]);
}, [toStr, ccStr, bccStr, subject, body, attachments]);
useEffect(() => {
return () => {
@@ -1393,7 +1401,7 @@ export function EmailComposer({
};
}, []);
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const toAddresses = withInput(to, toInput);
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
const canSend = toAddresses.length > 0 && !!subject && hasContent;
@@ -1477,8 +1485,8 @@ export function EmailComposer({
};
const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = withInput(cc, ccInput);
const bccAddresses = withInput(bcc, bccInput);
if (!canSend) {
const errors: { to?: boolean; subject?: boolean; body?: boolean } = {};
@@ -1600,9 +1608,9 @@ export function EmailComposer({
// guards, etc.). Returning false from any handler aborts before either
// the S/MIME or standard JMAP path runs.
const sendablePreview: OutgoingEmail = {
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
to: toAddresses.map(r => formatRecipient(r.name, r.email)),
cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
subject,
htmlBody: finalHtmlBody || '',
textBody: finalBody,
@@ -1690,9 +1698,9 @@ export function EmailComposer({
: undefined;
const mimeBytes = buildMimeMessage({
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
to: toAddresses.map(parseRecipient),
cc: ccAddresses.length > 0 ? ccAddresses.map(parseRecipient) : undefined,
bcc: bccAddresses.length > 0 ? bccAddresses.map(parseRecipient) : undefined,
to: toAddresses,
cc: ccAddresses.length > 0 ? ccAddresses : undefined,
bcc: bccAddresses.length > 0 ? bccAddresses : undefined,
subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
@@ -1705,8 +1713,8 @@ export function EmailComposer({
const smimeHeaders = {
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
to: toAddresses.map(parseRecipient),
cc: ccAddresses.length > 0 ? ccAddresses.map(parseRecipient) : undefined,
to: toAddresses,
cc: ccAddresses.length > 0 ? ccAddresses : undefined,
subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
@@ -1728,7 +1736,7 @@ export function EmailComposer({
// 6. Encrypt if enabled
if (smimeEncrypt_ && smimeKeyRecord) {
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses].map(s => parseRecipient(s).email);
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email);
const { found, missing } = smimeStore.getRecipientCerts(allRecipients);
if (missing.length > 0) {
throw new Error(`Missing certificates for: ${missing.join(', ')}`);
@@ -1745,7 +1753,7 @@ export function EmailComposer({
}
// 7. Send via raw email path
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses].map(s => parseRecipient(s).email));
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email));
if (effectiveDelayedUntil && finalDraftId) {
client.deleteEmail(finalDraftId).catch(err => {
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
@@ -1766,9 +1774,9 @@ export function EmailComposer({
// Let plugins (signatures, link-rewriting, encryption, AI rewrite, …)
// transform the outgoing message immediately before submission.
const transformInput: OutgoingEmail = {
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
to: toAddresses.map(r => formatRecipient(r.name, r.email)),
cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
subject,
htmlBody: finalHtmlBody || '',
textBody: finalBody,
@@ -1821,9 +1829,12 @@ export function EmailComposer({
}
}
setTo("");
setCc("");
setBcc("");
setTo([]);
setCc([]);
setBcc([]);
setToInput("");
setCcInput("");
setBccInput("");
setSubject("");
setBody("");
draftIdRef.current = null;
@@ -2106,7 +2117,7 @@ export function EmailComposer({
? identities.find(id => id.id === selectedIdentityId)?.email
: primaryIdentity?.email) || ''
}
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
recipientEmails={withInput(to, toInput).map(r => r.email)}
onSelectTag={setSubAddressTag}
/>
)}
@@ -2151,11 +2162,13 @@ export function EmailComposer({
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
<RecipientChipInput
value={to}
onChange={(v) => {
setTo(v);
chips={to}
onChipsChange={(next) => {
setTo(next);
if (validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false }));
}}
inputText={toInput}
onInputChange={setToInput}
inputRef={toInputRef}
placeholder={t('to_placeholder')}
field="to"
@@ -2190,8 +2203,8 @@ export function EmailComposer({
setIsDraggingChipOverCc(false);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
const { chip, fromField } = JSON.parse(raw) as { chip: string; fromField: 'to' | 'cc' | 'bcc' };
if (fromField !== 'cc') handleMoveChip(chip, fromField, 'cc');
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
if (fromField !== 'cc') handleMoveChip(recipient, fromField, 'cc');
setShowCc(true);
}}
>
@@ -2214,8 +2227,8 @@ export function EmailComposer({
setIsDraggingChipOverBcc(false);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
const { chip, fromField } = JSON.parse(raw) as { chip: string; fromField: 'to' | 'cc' | 'bcc' };
if (fromField !== 'bcc') handleMoveChip(chip, fromField, 'bcc');
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
if (fromField !== 'bcc') handleMoveChip(recipient, fromField, 'bcc');
setShowBcc(true);
}}
>
@@ -2229,8 +2242,10 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('cc_label')}</span>
<RecipientChipInput
value={cc}
onChange={setCc}
chips={cc}
onChipsChange={setCc}
inputText={ccInput}
onInputChange={setCcInput}
inputRef={ccInputRef}
placeholder={t('cc_placeholder')}
field="cc"
@@ -2252,8 +2267,10 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('bcc_label')}</span>
<RecipientChipInput
value={bcc}
onChange={setBcc}
chips={bcc}
onChipsChange={setBcc}
inputText={bccInput}
onInputChange={setBccInput}
inputRef={bccInputRef}
placeholder={t('bcc_placeholder')}
field="bcc"
@@ -2588,9 +2605,9 @@ export function EmailComposer({
initialData={{
subject,
body,
to: to.split(',').map(s => s.trim()).filter(Boolean),
cc: cc.split(',').map(s => s.trim()).filter(Boolean),
bcc: bcc.split(',').map(s => s.trim()).filter(Boolean),
to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
}}
onSave={(data) => {
addTemplate(data);
@@ -2794,8 +2811,10 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
});
function RecipientChipInput({
value,
onChange,
chips,
onChipsChange,
inputText,
onInputChange,
inputRef,
placeholder,
field,
@@ -2812,12 +2831,14 @@ function RecipientChipInput({
onTab,
onMoveChip,
}: {
value: string;
onChange: (value: string) => void;
chips: Recipient[];
onChipsChange: (chips: Recipient[]) => void;
inputText: string;
onInputChange: (text: string) => void;
inputRef: React.RefObject<HTMLInputElement | null>;
placeholder: string;
field: 'to' | 'cc' | 'bcc';
onAutocomplete: (value: string, field: 'to' | 'cc' | 'bcc') => void;
onAutocomplete: (inputText: string, field: 'to' | 'cc' | 'bcc') => void;
onAutoKeyDown: (e: React.KeyboardEvent, field: 'to' | 'cc' | 'bcc') => void;
onAutoBlur: (e: React.FocusEvent, field: 'to' | 'cc' | 'bcc') => void;
activeAutoField: 'to' | 'cc' | 'bcc' | null;
@@ -2828,22 +2849,17 @@ function RecipientChipInput({
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
onMoveChip: (chip: string, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void;
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void;
}) {
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<{ index: number; chip: string }>();
const [editingChip, setEditingChip] = useState<{ index: number; chip: string; editType: 'email' | 'name' } | null>(null);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<{ index: number; recipient: Recipient }>();
const [editingChip, setEditingChip] = useState<{ index: number; editType: 'email' | 'name' } | null>(null);
const [editValue, setEditValue] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const editInputRef = useRef<HTMLInputElement | null>(null);
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
const hasTrailingComma = value.trimEnd().endsWith(',');
const chips = hasTrailingComma ? allParts : allParts.slice(0, -1);
const inputText = hasTrailingComma ? '' : (allParts[allParts.length - 1] || '');
// Focus edit input when editing starts
useEffect(() => {
if (editingChip) {
@@ -2856,70 +2872,50 @@ function RecipientChipInput({
}
}, [editingChip]);
// Parse a chip string to extract name and email
const parseChip = (chip: string): { name?: string; email: string } => {
const angleMatch = chip.match(/^(.+?)\s*<([^>]+)>$/);
if (angleMatch) {
return { name: angleMatch[1].trim(), email: angleMatch[2].trim() };
}
return { email: chip };
};
// Format a chip for display
const formatChipDisplay = (chip: string): string => {
const parsed = parseChip(chip);
if (parsed.name && parsed.name !== parsed.email) {
return `${parsed.name} (${parsed.email})`;
}
return parsed.email;
};
// Format a recipient for display in a chip / context menu
const formatChipDisplay = (r: Recipient): string =>
r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email;
// Handle saving an edited chip
const handleSaveEdit = (newValue: string) => {
if (!editingChip) return;
const { index, editType } = editingChip;
const chip = chips[index];
const parsed = parseChip(chip);
if (!chip) {
setEditingChip(null);
return;
}
const trimmedNew = newValue.trim();
let newChip: string;
let newChip: Recipient;
if (editType === 'email') {
// Update email, keep name
const trimmedNew = newValue.trim();
// Update email, keep name. Empty email is a no-op (can't drop the email).
if (!trimmedNew) {
setEditingChip(null);
return;
}
newChip = parsed.name ? `${parsed.name} <${trimmedNew}>` : trimmedNew;
newChip = { name: chip.name, email: trimmedNew };
} else {
// Update name, keep email
const trimmedNew = newValue.trim();
if (trimmedNew) {
newChip = `${trimmedNew} <${parsed.email}>`;
} else {
// Name cleared, remove from format
newChip = parsed.email;
}
// Update name, keep email. Empty name clears the display name.
newChip = { name: trimmedNew || undefined, email: chip.email };
}
// Replace the chip in the value
const newChips = [...chips];
newChips[index] = newChip;
onChange(newChips.join(', ') + ', ');
onChipsChange(newChips);
setEditingChip(null);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newInputText = e.target.value;
const chipPart = chips.length > 0 ? chips.join(', ') + ', ' : '';
const newValue = chipPart + newInputText;
onChange(newValue);
onAutocomplete(newValue, field);
onInputChange(newInputText);
onAutocomplete(newInputText, field);
};
const commitCurrentInput = () => {
if (inputText.trim()) {
const newChips = [...chips, inputText.trim()];
onChange(newChips.join(', ') + ', ');
onChipsChange([...chips, parseRecipient(inputText)]);
onInputChange('');
}
};
@@ -2950,45 +2946,39 @@ function RecipientChipInput({
return;
}
// Backspace on an empty input pulls the last chip back into the input for
// quick editing.
if (e.key === 'Backspace' && !inputText && chips.length > 0) {
const lastChip = chips[chips.length - 1];
const remainingChips = chips.slice(0, -1);
const chipPart = remainingChips.length > 0 ? remainingChips.join(', ') + ', ' : '';
onChange(chipPart + lastChip);
onChipsChange(chips.slice(0, -1));
onInputChange(formatRecipient(lastChip.name, lastChip.email));
return;
}
};
const handleChipRemove = (index: number, e: React.MouseEvent) => {
e.stopPropagation();
const remainingChips = chips.filter((_, i) => i !== index);
if (remainingChips.length > 0) {
onChange(remainingChips.join(', ') + ', ' + inputText);
} else {
onChange(inputText);
}
onChipsChange(chips.filter((_, i) => i !== index));
};
const handleContextMenu = (e: React.MouseEvent, index: number, chip: string) => {
openContextMenu(e, { index, chip });
const handleContextMenu = (e: React.MouseEvent, index: number, recipient: Recipient) => {
openContextMenu(e, { index, recipient });
};
const handleEditEmail = () => {
if (!contextMenu.data) return;
const { index, chip } = contextMenu.data;
const parsed = parseChip(chip);
const { index, recipient } = contextMenu.data;
closeContextMenu();
setEditValue(parsed.email);
setEditingChip({ index, chip, editType: 'email' });
setEditValue(recipient.email);
setEditingChip({ index, editType: 'email' });
};
const handleEditName = () => {
if (!contextMenu.data) return;
const { index, chip } = contextMenu.data;
const parsed = parseChip(chip);
const { index, recipient } = contextMenu.data;
closeContextMenu();
setEditValue(parsed.name || '');
setEditingChip({ index, chip, editType: 'name' });
setEditValue(recipient.name || '');
setEditingChip({ index, editType: 'name' });
};
const handleBlur = (e: React.FocusEvent) => {
@@ -2996,10 +2986,7 @@ function RecipientChipInput({
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
return;
}
if (inputText.trim()) {
const newChips = [...chips, inputText.trim()];
onChange(newChips.join(', ') + ', ');
}
commitCurrentInput();
onAutoBlur(e, field);
};
@@ -3021,9 +3008,9 @@ function RecipientChipInput({
setIsDragOver(false);
const raw = e.dataTransfer.getData('application/x-recipient-chip');
if (!raw) return;
const { chip: draggedChip, fromField } = JSON.parse(raw) as { chip: string; fromField: 'to' | 'cc' | 'bcc' };
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
if (fromField === field) return;
onMoveChip(draggedChip, fromField, field);
onMoveChip(recipient, fromField, field);
};
return (
@@ -3044,14 +3031,14 @@ function RecipientChipInput({
const chipDisplay = formatChipDisplay(chip);
return (
<span
key={`${chip}-${i}`}
key={`${chip.email}-${i}`}
draggable={!isEditing}
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ chip, fromField: field }));
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field }));
// Show the address while dragging, matching the email-list drag preview.
const dragPreview = createChipDragPreview(parseChip(chip).email);
const dragPreview = createChipDragPreview(chip.email);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
requestAnimationFrame(() => dragPreview.remove());
setDraggingIndex(i);
@@ -3163,7 +3150,7 @@ function RecipientChipInput({
{contextMenu.data && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground truncate max-w-[200px]">
{formatChipDisplay(contextMenu.data.chip)}
{formatChipDisplay(contextMenu.data.recipient)}
</div>
<ContextMenuSeparator />
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
+7 -3
View File
@@ -100,6 +100,8 @@ export function EmailList({
isLoadingThread,
toggleThreadExpansion,
fetchThreadEmails,
markThreadAsRead,
threadEmailCounts,
searchFilters,
setSearchFilters,
clearSearchFilters,
@@ -110,9 +112,9 @@ export function EmailList({
const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
return sortThreadGroups(groups);
}, [emails, disableThreading, isScheduledView]);
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -250,10 +252,12 @@ export function EmailList({
if (!isExpanded && client) {
toggleThreadExpansion(threadId);
await fetchThreadEmails(client, threadId);
// Mark all unread emails in this thread as read
void markThreadAsRead(client, threadId);
} else {
toggleThreadExpansion(threadId);
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
// Range-based load more: trigger when last visible item is near the end.
// Debounce to prevent rapid cascade when thread grouping reduces item
+19 -1
View File
@@ -26,6 +26,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const client = useAuthStore((s) => s.client);
const sendEmail = useEmailStore((s) => s.sendEmail);
const fetchEmails = useEmailStore((s) => s.fetchEmails);
const refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox);
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
@@ -92,7 +93,24 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
// Refresh the currently-active mail list so the new sent message /
// updated keyword status shows up.
await fetchEmails(client, selectedMailbox);
await refreshCurrentMailbox(client);
// Re-fetch the replied thread's cross-folder data so the expanded
// view shows the newly sent reply without collapsing.
if (data.sourceEmailId) {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === data.sourceEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
const accountId = client.getAccountId();
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
c.set(repliedEmail.threadId!, fullEmails);
return { threadEmailsCache: c };
});
}
}
}
closeTab(tabIdRef.current);
} catch (error) {
console.error('Failed to send email:', error);