diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 523b58c9..7f6a92ca 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -9,6 +9,8 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma 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 } from "@/lib/email-sanitization"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { isFilePreviewable } from "@/lib/file-preview"; @@ -246,17 +248,34 @@ export function EmailComposer({ // `replyTo` from a still-selected email; getInitialBody short-circuits below. 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 " 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 }; + }; + // Initialize with reply/forward data if provided const getInitialTo = () => { 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(r => r.email).join(", ") - : replyTo.from?.[0]?.email || ""; + ? replyTo.replyToAddresses.filter(r => r.email).map(formatAddr).join(", ") + : (replyTo.from?.[0] ? formatAddr(replyTo.from[0]) : ""); if (mode === 'reply') { return replyTarget ? replyTarget + ', ' : ""; } else if (mode === 'replyAll') { - const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || ""; + 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 + ', ' : ""; } @@ -265,7 +284,10 @@ export function EmailComposer({ const getInitialCc = () => { if (!replyTo || mode !== 'replyAll') return ""; - const cc = replyTo.cc?.map(r => r.email).join(", ") || ""; + 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 + ', ' : ""; }; @@ -867,7 +889,7 @@ export function EmailComposer({ }, 200); }, [getAutocomplete]); - const insertAutocomplete = (email: string, field: 'to' | 'cc' | 'bcc') => { + 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; @@ -875,7 +897,10 @@ export function EmailComposer({ if (!getter.trimEnd().endsWith(',') && parts.length > 0) { parts.pop(); } - parts.push(email); + const formatted = suggestion.name && suggestion.name !== suggestion.email + ? `${suggestion.name} <${suggestion.email}>` + : suggestion.email; + parts.push(formatted); setter(parts.join(', ') + ', '); setAutocompleteResults([]); setActiveAutoField(null); @@ -908,7 +933,7 @@ export function EmailComposer({ setAutoSelectedIndex((prev) => Math.max(prev - 1, -1)); } else if (e.key === 'Enter' && autoSelectedIndex >= 0) { e.preventDefault(); - insertAutocomplete(autocompleteResults[autoSelectedIndex].email, field); + insertAutocomplete(autocompleteResults[autoSelectedIndex], field); } else if (e.key === 'Escape') { setAutocompleteResults([]); setActiveAutoField(null); @@ -1620,9 +1645,9 @@ export function EmailComposer({ : undefined; const mimeBytes = buildMimeMessage({ from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, - to: toAddresses.map(e => ({ email: e })), - cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, - bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined, + to: toAddresses.map(parseRecipient), + cc: ccAddresses.length > 0 ? ccAddresses.map(parseRecipient) : undefined, + bcc: bccAddresses.length > 0 ? bccAddresses.map(parseRecipient) : undefined, subject, inReplyTo: mimeInReplyTo, references: mimeReferences, @@ -1635,8 +1660,8 @@ export function EmailComposer({ const smimeHeaders = { from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, - to: toAddresses.map(e => ({ email: e })), - cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, + to: toAddresses.map(parseRecipient), + cc: ccAddresses.length > 0 ? ccAddresses.map(parseRecipient) : undefined, subject, inReplyTo: mimeInReplyTo, references: mimeReferences, @@ -1658,7 +1683,7 @@ export function EmailComposer({ // 6. Encrypt if enabled if (smimeEncrypt_ && smimeKeyRecord) { - const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses]; + const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses].map(s => parseRecipient(s).email); const { found, missing } = smimeStore.getRecipientCerts(allRecipients); if (missing.length > 0) { throw new Error(`Missing certificates for: ${missing.join(', ')}`); @@ -1675,7 +1700,7 @@ export function EmailComposer({ } // 7. Send via raw email path - const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses]); + const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses].map(s => parseRecipient(s).email)); if (effectiveDelayedUntil && finalDraftId) { client.deleteEmail(finalDraftId).catch(err => { debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err); @@ -2657,7 +2682,7 @@ const AutocompleteDropdown = React.forwardRef; selectedIndex: number; - onSelect: (email: string) => void; + onSelect: (suggestion: { name: string; email: string }) => void; }>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) { return (
@@ -2674,7 +2699,7 @@ const AutocompleteDropdown = React.forwardRef { e.preventDefault(); - onSelect(r.email); + onSelect(r); }} > {r.name || r.email} @@ -2717,16 +2742,87 @@ function RecipientChipInput({ autocompleteResults: Array<{ name: string; email: string }>; autoSelectedIndex: number; dropdownRef: React.RefObject; - onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void; + onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void; validationError?: boolean; validationMessage?: string; onTab?: () => 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 [editValue, setEditValue] = useState(''); + const editInputRef = useRef(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) { + setTimeout(() => { + if (editInputRef.current) { + editInputRef.current.focus(); + editInputRef.current.select(); + } + }, 0); + } + }, [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; + }; + + // Handle saving an edited chip + const handleSaveEdit = (newValue: string) => { + if (!editingChip) return; + const { index, editType } = editingChip; + const chip = chips[index]; + const parsed = parseChip(chip); + + let newChip: string; + if (editType === 'email') { + // Update email, keep name + const trimmedNew = newValue.trim(); + if (!trimmedNew) { + setEditingChip(null); + return; + } + newChip = parsed.name ? `${parsed.name} <${trimmedNew}>` : 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; + } + } + + // Replace the chip in the value + const newChips = [...chips]; + newChips[index] = newChip; + onChange(newChips.join(', ') + ', '); + setEditingChip(null); + }; + const handleInputChange = (e: React.ChangeEvent) => { const newInputText = e.target.value; const chipPart = chips.length > 0 ? chips.join(', ') + ', ' : ''; @@ -2778,14 +2874,6 @@ function RecipientChipInput({ } }; - const handleChipClick = (index: number) => { - const chipEmail = chips[index]; - const remainingChips = chips.filter((_, i) => i !== index); - const chipPart = remainingChips.length > 0 ? remainingChips.join(', ') + ', ' : ''; - onChange(chipPart + chipEmail); - setTimeout(() => inputRef.current?.focus(), 0); - }; - const handleChipRemove = (index: number, e: React.MouseEvent) => { e.stopPropagation(); const remainingChips = chips.filter((_, i) => i !== index); @@ -2796,6 +2884,28 @@ function RecipientChipInput({ } }; + const handleContextMenu = (e: React.MouseEvent, index: number, chip: string) => { + openContextMenu(e, { index, chip }); + }; + + const handleEditEmail = () => { + if (!contextMenu.data) return; + const { index, chip } = contextMenu.data; + const parsed = parseChip(chip); + closeContextMenu(); + setEditValue(parsed.email); + setEditingChip({ index, chip, editType: 'email' }); + }; + + const handleEditName = () => { + if (!contextMenu.data) return; + const { index, chip } = contextMenu.data; + const parsed = parseChip(chip); + closeContextMenu(); + setEditValue(parsed.name || ''); + setEditingChip({ index, chip, editType: 'name' }); + }; + const handleBlur = (e: React.FocusEvent) => { const relatedTarget = e.relatedTarget as Node | null; if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) { @@ -2817,47 +2927,95 @@ function RecipientChipInput({ )} onClick={() => inputRef.current?.focus()} > - {chips.map((chip, i) => ( - { - e.stopPropagation(); - handleChipClick(i); - }} - > - {chip} - - - ))} - 0} - aria-autocomplete="list" - aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined} - aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} - aria-invalid={validationError || undefined} - data-bwignore="true" - data-1p-ignore - data-op-ignore - data-lpignore="true" - data-form-type="other" - /> + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSaveEdit(editValue); + } else if (e.key === 'Escape') { + setEditingChip(null); + } else if (e.key === 'Tab') { + e.preventDefault(); + handleSaveEdit(editValue); + } + }} + onBlur={(e) => { + const relatedTarget = e.relatedTarget as Node | null; + if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) { + return; + } + handleSaveEdit(editValue); + }} + className="flex-1 min-w-[80px] border-0 outline-none h-5 text-sm bg-transparent text-foreground placeholder:text-muted-foreground" + placeholder={editingChip?.editType === 'email' ? t('recipient_email_placeholder') : t('recipient_name_placeholder')} + data-bwignore="true" + /> + ) : ( + {chipDisplay} + )} + + + ); + })} + {!editingChip && ( + 0} + aria-autocomplete="list" + aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined} + aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} + aria-invalid={validationError || undefined} + data-bwignore="true" + data-1p-ignore + data-op-ignore + data-lpignore="true" + data-form-type="other" + /> + )}
{validationError && validationMessage && (

{validationMessage}

@@ -2868,9 +3026,33 @@ function RecipientChipInput({ id={`autocomplete-${field}`} results={autocompleteResults} selectedIndex={autoSelectedIndex} - onSelect={(email) => onInsertAutocomplete(email, field)} + onSelect={(suggestion) => onInsertAutocomplete(suggestion, field)} /> )} + + {contextMenu.data && ( + <> +
+ {formatChipDisplay(contextMenu.data.chip)} +
+ + + + + { + if (contextMenu.data) { + handleChipRemove(contextMenu.data.index, { stopPropagation: () => {} } as React.MouseEvent); + } + closeContextMenu(); + }} destructive /> + + )} +
); } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index a075d9f9..61f80133 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -5,6 +5,16 @@ import { toWildcardQuery } from "./search-utils"; import { debug } from "@/lib/debug"; import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization"; +/** Parse a recipient string that may be "Name " or bare "email" into { name?, email }. */ +function parseRecipientString(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 }; +} + export class RateLimitError extends Error { retryAfterMs: number; constructor(retryAfterMs: number) { @@ -2296,12 +2306,12 @@ export class JMAPClient implements IJMAPClient { const emailCreate: Record = { from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }], replyTo: identityReplyTo?.length ? identityReplyTo : undefined, - to: to.map(email => ({ email })), + to: to.map(parseRecipientString), // RFC 5322 ยง3.6.3: To/Cc carry an address-list (non-empty). Sending // cc:[] makes the server emit a literal `Cc:` header with no addresses, // which is malformed and a spam signal. Omit the field when empty. - cc: cc?.length ? cc.map(email => ({ email })) : undefined, - bcc: bcc?.length ? bcc.map(email => ({ email })) : undefined, + cc: cc?.length ? cc.map(parseRecipientString) : undefined, + bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined, subject, inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined, references: normalizedReferences?.length ? normalizedReferences : undefined, diff --git a/locales/en/common.json b/locales/en/common.json index aecc09ff..3b3bebc5 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -649,7 +649,11 @@ "schedule_send_unsupported": "Scheduled send is not supported for this account.", "schedule_send_cleanup_warning": "Scheduled send was created, but draft cleanup failed.", "send_delay_unsupported": "Send delay is not supported for this account.", - "send_delay_unsupported_confirm": "This account does not support send delay. Send immediately instead?" + "send_delay_unsupported_confirm": "This account does not support send delay. Send immediately instead?", + "recipient_edit_email": "Edit email address", + "recipient_edit_name": "Edit display name", + "recipient_email_placeholder": "Email address", + "recipient_name_placeholder": "Display name" }, "confirm_dialog": { "confirm": "Confirm", diff --git a/stores/contact-store.ts b/stores/contact-store.ts index 8f908f22..a6159235 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -909,9 +909,13 @@ export const useContactStore = create()( }, addToTrustedSendersBook: async (client, email) => { - const normalizedEmail = email.toLowerCase().trim(); + // Parse "Name " format to extract display name and email + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const displayName = angleMatch ? angleMatch[1].trim() : undefined; + const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); const { trustedSenderEmails } = get(); - if (trustedSenderEmails.includes(normalizedEmail)) return; + if (trustedSenderEmails.includes(emailAddress)) return; let bookId = get().trustedSendersBookId; if (!bookId) { @@ -920,17 +924,21 @@ export const useContactStore = create()( } if (!bookId) throw new Error('Could not find or create trusted senders address book'); - debug.log('contacts', 'Adding trusted sender:', normalizedEmail, 'to book:', bookId); + debug.log('contacts', 'Adding trusted sender:', emailAddress, 'to book:', bookId); await client.createContact({ addressBookIds: { [bookId]: true }, - emails: { email: { address: normalizedEmail } }, + ...(displayName ? { name: { full: displayName } } : {}), + emails: { email: { address: emailAddress } }, }); - set((state) => ({ trustedSenderEmails: [...state.trustedSenderEmails, normalizedEmail] })); + set((state) => ({ trustedSenderEmails: [...state.trustedSenderEmails, emailAddress] })); debug.log('contacts', 'Trusted sender added successfully'); }, removeFromTrustedSendersBook: async (client, email) => { - const normalizedEmail = email.toLowerCase().trim(); + // Parse "Name " format to extract just the email address + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const normalizedEmail = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); const { trustedSendersBookId } = get(); if (!trustedSendersBookId) return; @@ -947,7 +955,10 @@ export const useContactStore = create()( }, isTrustedAddressBookSender: (email) => { - const normalizedEmail = email.toLowerCase().trim(); + // Parse "Name " format to extract just the email address + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const normalizedEmail = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); return get().trustedSenderEmails.includes(normalizedEmail); }, diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 4abfc3ef..3a0a44b2 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -666,23 +666,32 @@ export const useSettingsStore = create()( // Trusted senders methods addTrustedSender: (email: string) => { - const normalizedEmail = email.toLowerCase().trim(); + // Parse "Name " format to extract just the email address + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); const current = get().trustedSenders; - if (!current.includes(normalizedEmail)) { - set({ trustedSenders: [...current, normalizedEmail] }); + if (!current.includes(emailAddress)) { + set({ trustedSenders: [...current, emailAddress] }); } }, removeTrustedSender: (email: string) => { - const normalizedEmail = email.toLowerCase().trim(); + // Parse "Name " format to extract just the email address + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); set({ - trustedSenders: get().trustedSenders.filter(e => e !== normalizedEmail) + trustedSenders: get().trustedSenders.filter(e => e !== emailAddress) }); }, isSenderTrusted: (email: string) => { - const normalizedEmail = email.toLowerCase().trim(); - return get().trustedSenders.includes(normalizedEmail); + // Parse "Name " format to extract just the email address + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); + return get().trustedSenders.includes(emailAddress); }, // Keyword methods