Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -9,6 +9,8 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma
|
|||||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { toast } from "@/stores/toast-store";
|
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 { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
@@ -246,17 +248,34 @@ export function EmailComposer({
|
|||||||
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
|
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
|
||||||
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
|
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 };
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize with reply/forward data if provided
|
// Initialize with reply/forward data if provided
|
||||||
const getInitialTo = () => {
|
const getInitialTo = () => {
|
||||||
if (!replyTo) return "";
|
if (!replyTo) return "";
|
||||||
// RFC 5322: use Reply-To header if present, otherwise fall back to From
|
// RFC 5322: use Reply-To header if present, otherwise fall back to From
|
||||||
const replyTarget = replyTo.replyToAddresses?.length
|
const replyTarget = replyTo.replyToAddresses?.length
|
||||||
? replyTo.replyToAddresses.filter(r => r.email).map(r => r.email).join(", ")
|
? replyTo.replyToAddresses.filter(r => r.email).map(formatAddr).join(", ")
|
||||||
: replyTo.from?.[0]?.email || "";
|
: (replyTo.from?.[0] ? formatAddr(replyTo.from[0]) : "");
|
||||||
if (mode === 'reply') {
|
if (mode === 'reply') {
|
||||||
return replyTarget ? replyTarget + ', ' : "";
|
return replyTarget ? replyTarget + ', ' : "";
|
||||||
} else if (mode === 'replyAll') {
|
} 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(", ");
|
const combined = [replyTarget, originalTo].filter(Boolean).join(", ");
|
||||||
return combined ? combined + ', ' : "";
|
return combined ? combined + ', ' : "";
|
||||||
}
|
}
|
||||||
@@ -265,7 +284,10 @@ export function EmailComposer({
|
|||||||
|
|
||||||
const getInitialCc = () => {
|
const getInitialCc = () => {
|
||||||
if (!replyTo || mode !== 'replyAll') return "";
|
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 + ', ' : "";
|
return cc ? cc + ', ' : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -867,7 +889,7 @@ export function EmailComposer({
|
|||||||
}, 200);
|
}, 200);
|
||||||
}, [getAutocomplete]);
|
}, [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 setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
|
||||||
const getter = field === 'to' ? to : field === 'cc' ? cc : bcc;
|
const getter = field === 'to' ? to : field === 'cc' ? cc : bcc;
|
||||||
|
|
||||||
@@ -875,7 +897,10 @@ export function EmailComposer({
|
|||||||
if (!getter.trimEnd().endsWith(',') && parts.length > 0) {
|
if (!getter.trimEnd().endsWith(',') && parts.length > 0) {
|
||||||
parts.pop();
|
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(', ') + ', ');
|
setter(parts.join(', ') + ', ');
|
||||||
setAutocompleteResults([]);
|
setAutocompleteResults([]);
|
||||||
setActiveAutoField(null);
|
setActiveAutoField(null);
|
||||||
@@ -908,7 +933,7 @@ export function EmailComposer({
|
|||||||
setAutoSelectedIndex((prev) => Math.max(prev - 1, -1));
|
setAutoSelectedIndex((prev) => Math.max(prev - 1, -1));
|
||||||
} else if (e.key === 'Enter' && autoSelectedIndex >= 0) {
|
} else if (e.key === 'Enter' && autoSelectedIndex >= 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
insertAutocomplete(autocompleteResults[autoSelectedIndex].email, field);
|
insertAutocomplete(autocompleteResults[autoSelectedIndex], field);
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setAutocompleteResults([]);
|
setAutocompleteResults([]);
|
||||||
setActiveAutoField(null);
|
setActiveAutoField(null);
|
||||||
@@ -1620,9 +1645,9 @@ export function EmailComposer({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const mimeBytes = buildMimeMessage({
|
const mimeBytes = buildMimeMessage({
|
||||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||||
to: toAddresses.map(e => ({ email: e })),
|
to: toAddresses.map(parseRecipient),
|
||||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
cc: ccAddresses.length > 0 ? ccAddresses.map(parseRecipient) : undefined,
|
||||||
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
|
bcc: bccAddresses.length > 0 ? bccAddresses.map(parseRecipient) : undefined,
|
||||||
subject,
|
subject,
|
||||||
inReplyTo: mimeInReplyTo,
|
inReplyTo: mimeInReplyTo,
|
||||||
references: mimeReferences,
|
references: mimeReferences,
|
||||||
@@ -1635,8 +1660,8 @@ export function EmailComposer({
|
|||||||
|
|
||||||
const smimeHeaders = {
|
const smimeHeaders = {
|
||||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||||
to: toAddresses.map(e => ({ email: e })),
|
to: toAddresses.map(parseRecipient),
|
||||||
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
|
cc: ccAddresses.length > 0 ? ccAddresses.map(parseRecipient) : undefined,
|
||||||
subject,
|
subject,
|
||||||
inReplyTo: mimeInReplyTo,
|
inReplyTo: mimeInReplyTo,
|
||||||
references: mimeReferences,
|
references: mimeReferences,
|
||||||
@@ -1658,7 +1683,7 @@ export function EmailComposer({
|
|||||||
|
|
||||||
// 6. Encrypt if enabled
|
// 6. Encrypt if enabled
|
||||||
if (smimeEncrypt_ && smimeKeyRecord) {
|
if (smimeEncrypt_ && smimeKeyRecord) {
|
||||||
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses];
|
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses].map(s => parseRecipient(s).email);
|
||||||
const { found, missing } = smimeStore.getRecipientCerts(allRecipients);
|
const { found, missing } = smimeStore.getRecipientCerts(allRecipients);
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new Error(`Missing certificates for: ${missing.join(', ')}`);
|
throw new Error(`Missing certificates for: ${missing.join(', ')}`);
|
||||||
@@ -1675,7 +1700,7 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Send via raw email path
|
// 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) {
|
if (effectiveDelayedUntil && finalDraftId) {
|
||||||
client.deleteEmail(finalDraftId).catch(err => {
|
client.deleteEmail(finalDraftId).catch(err => {
|
||||||
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
|
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
|
||||||
@@ -2657,7 +2682,7 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
|||||||
id: string;
|
id: string;
|
||||||
results: Array<{ name: string; email: string }>;
|
results: Array<{ name: string; email: string }>;
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (email: string) => void;
|
onSelect: (suggestion: { name: string; email: string }) => void;
|
||||||
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) {
|
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) {
|
||||||
return (
|
return (
|
||||||
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
|
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||||
@@ -2674,7 +2699,7 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
|
|||||||
)}
|
)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSelect(r.email);
|
onSelect(r);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-medium truncate">{r.name || r.email}</span>
|
<span className="font-medium truncate">{r.name || r.email}</span>
|
||||||
@@ -2717,16 +2742,87 @@ function RecipientChipInput({
|
|||||||
autocompleteResults: Array<{ name: string; email: string }>;
|
autocompleteResults: Array<{ name: string; email: string }>;
|
||||||
autoSelectedIndex: number;
|
autoSelectedIndex: number;
|
||||||
dropdownRef: React.RefObject<HTMLDivElement | null>;
|
dropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||||
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
|
onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void;
|
||||||
validationError?: boolean;
|
validationError?: boolean;
|
||||||
validationMessage?: string;
|
validationMessage?: string;
|
||||||
onTab?: () => void;
|
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<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
|
const allParts = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
const hasTrailingComma = value.trimEnd().endsWith(',');
|
const hasTrailingComma = value.trimEnd().endsWith(',');
|
||||||
const chips = hasTrailingComma ? allParts : allParts.slice(0, -1);
|
const chips = hasTrailingComma ? allParts : allParts.slice(0, -1);
|
||||||
const inputText = hasTrailingComma ? '' : (allParts[allParts.length - 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<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newInputText = e.target.value;
|
const newInputText = e.target.value;
|
||||||
const chipPart = chips.length > 0 ? chips.join(', ') + ', ' : '';
|
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) => {
|
const handleChipRemove = (index: number, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const remainingChips = chips.filter((_, i) => i !== index);
|
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 handleBlur = (e: React.FocusEvent) => {
|
||||||
const relatedTarget = e.relatedTarget as Node | null;
|
const relatedTarget = e.relatedTarget as Node | null;
|
||||||
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
|
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
|
||||||
@@ -2817,26 +2927,73 @@ function RecipientChipInput({
|
|||||||
)}
|
)}
|
||||||
onClick={() => inputRef.current?.focus()}
|
onClick={() => inputRef.current?.focus()}
|
||||||
>
|
>
|
||||||
{chips.map((chip, i) => (
|
{chips.map((chip, i) => {
|
||||||
|
const isEditing = editingChip?.index === i;
|
||||||
|
const chipDisplay = formatChipDisplay(chip);
|
||||||
|
return (
|
||||||
<span
|
<span
|
||||||
key={`${chip}-${i}`}
|
key={`${chip}-${i}`}
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-secondary text-secondary-foreground text-sm border border-border cursor-pointer hover:bg-accent transition-colors"
|
className={cn(
|
||||||
onClick={(e) => {
|
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
|
||||||
e.stopPropagation();
|
isEditing
|
||||||
handleChipClick(i);
|
? "bg-background ring-1 ring-ring"
|
||||||
}}
|
: "bg-secondary text-secondary-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)}
|
||||||
>
|
>
|
||||||
<span className="truncate max-w-[200px]">{chip}</span>
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
ref={editInputRef}
|
||||||
|
type="text"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="truncate max-w-[200px]">{chipDisplay}</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center justify-center w-4 h-4 rounded-full hover:bg-muted-foreground/20 transition-colors"
|
className="flex items-center justify-center w-4 h-4 rounded-full hover:bg-muted-foreground/20 transition-colors"
|
||||||
onClick={(e) => handleChipRemove(i, e)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isEditing) {
|
||||||
|
handleSaveEdit(editValue);
|
||||||
|
} else {
|
||||||
|
handleChipRemove(i, e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
|
{isEditing ? (
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
{!editingChip && (
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -2858,6 +3015,7 @@ function RecipientChipInput({
|
|||||||
data-lpignore="true"
|
data-lpignore="true"
|
||||||
data-form-type="other"
|
data-form-type="other"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{validationError && validationMessage && (
|
{validationError && validationMessage && (
|
||||||
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{validationMessage}</p>
|
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{validationMessage}</p>
|
||||||
@@ -2868,9 +3026,33 @@ function RecipientChipInput({
|
|||||||
id={`autocomplete-${field}`}
|
id={`autocomplete-${field}`}
|
||||||
results={autocompleteResults}
|
results={autocompleteResults}
|
||||||
selectedIndex={autoSelectedIndex}
|
selectedIndex={autoSelectedIndex}
|
||||||
onSelect={(email) => onInsertAutocomplete(email, field)}
|
onSelect={(suggestion) => onInsertAutocomplete(suggestion, field)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ContextMenu
|
||||||
|
isOpen={contextMenu.isOpen}
|
||||||
|
position={contextMenu.position}
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
ref={menuRef}
|
||||||
|
>
|
||||||
|
{contextMenu.data && (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground truncate max-w-[200px]">
|
||||||
|
{formatChipDisplay(contextMenu.data.chip)}
|
||||||
|
</div>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem label={t('recipient_edit_email')} onClick={handleEditEmail} />
|
||||||
|
<ContextMenuItem label={t('recipient_edit_name')} onClick={handleEditName} />
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem label={tCommon('delete')} onClick={() => {
|
||||||
|
if (contextMenu.data) {
|
||||||
|
handleChipRemove(contextMenu.data.index, { stopPropagation: () => {} } as React.MouseEvent);
|
||||||
|
}
|
||||||
|
closeContextMenu();
|
||||||
|
}} destructive />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -5,6 +5,16 @@ import { toWildcardQuery } from "./search-utils";
|
|||||||
import { debug } from "@/lib/debug";
|
import { debug } from "@/lib/debug";
|
||||||
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
||||||
|
|
||||||
|
/** Parse a recipient string that may be "Name <email>" 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 {
|
export class RateLimitError extends Error {
|
||||||
retryAfterMs: number;
|
retryAfterMs: number;
|
||||||
constructor(retryAfterMs: number) {
|
constructor(retryAfterMs: number) {
|
||||||
@@ -2296,12 +2306,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const emailCreate: Record<string, unknown> = {
|
const emailCreate: Record<string, unknown> = {
|
||||||
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
|
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
|
||||||
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
|
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
|
// 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,
|
// cc:[] makes the server emit a literal `Cc:` header with no addresses,
|
||||||
// which is malformed and a spam signal. Omit the field when empty.
|
// which is malformed and a spam signal. Omit the field when empty.
|
||||||
cc: cc?.length ? cc.map(email => ({ email })) : undefined,
|
cc: cc?.length ? cc.map(parseRecipientString) : undefined,
|
||||||
bcc: bcc?.length ? bcc.map(email => ({ email })) : undefined,
|
bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined,
|
||||||
subject,
|
subject,
|
||||||
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
|
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
|
||||||
references: normalizedReferences?.length ? normalizedReferences : undefined,
|
references: normalizedReferences?.length ? normalizedReferences : undefined,
|
||||||
|
|||||||
@@ -649,7 +649,11 @@
|
|||||||
"schedule_send_unsupported": "Scheduled send is not supported for this account.",
|
"schedule_send_unsupported": "Scheduled send is not supported for this account.",
|
||||||
"schedule_send_cleanup_warning": "Scheduled send was created, but draft cleanup failed.",
|
"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": "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_dialog": {
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
|||||||
+18
-7
@@ -909,9 +909,13 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addToTrustedSendersBook: async (client, email) => {
|
addToTrustedSendersBook: async (client, email) => {
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
// Parse "Name <email>" 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();
|
const { trustedSenderEmails } = get();
|
||||||
if (trustedSenderEmails.includes(normalizedEmail)) return;
|
if (trustedSenderEmails.includes(emailAddress)) return;
|
||||||
|
|
||||||
let bookId = get().trustedSendersBookId;
|
let bookId = get().trustedSendersBookId;
|
||||||
if (!bookId) {
|
if (!bookId) {
|
||||||
@@ -920,17 +924,21 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
}
|
}
|
||||||
if (!bookId) throw new Error('Could not find or create trusted senders address book');
|
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({
|
await client.createContact({
|
||||||
addressBookIds: { [bookId]: true },
|
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');
|
debug.log('contacts', 'Trusted sender added successfully');
|
||||||
},
|
},
|
||||||
|
|
||||||
removeFromTrustedSendersBook: async (client, email) => {
|
removeFromTrustedSendersBook: async (client, email) => {
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
// Parse "Name <email>" 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();
|
const { trustedSendersBookId } = get();
|
||||||
if (!trustedSendersBookId) return;
|
if (!trustedSendersBookId) return;
|
||||||
|
|
||||||
@@ -947,7 +955,10 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
isTrustedAddressBookSender: (email) => {
|
isTrustedAddressBookSender: (email) => {
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
// Parse "Name <email>" 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);
|
return get().trustedSenderEmails.includes(normalizedEmail);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -666,23 +666,32 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
|
|
||||||
// Trusted senders methods
|
// Trusted senders methods
|
||||||
addTrustedSender: (email: string) => {
|
addTrustedSender: (email: string) => {
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
// Parse "Name <email>" 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;
|
const current = get().trustedSenders;
|
||||||
if (!current.includes(normalizedEmail)) {
|
if (!current.includes(emailAddress)) {
|
||||||
set({ trustedSenders: [...current, normalizedEmail] });
|
set({ trustedSenders: [...current, emailAddress] });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
removeTrustedSender: (email: string) => {
|
removeTrustedSender: (email: string) => {
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
// Parse "Name <email>" 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({
|
set({
|
||||||
trustedSenders: get().trustedSenders.filter(e => e !== normalizedEmail)
|
trustedSenders: get().trustedSenders.filter(e => e !== emailAddress)
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
isSenderTrusted: (email: string) => {
|
isSenderTrusted: (email: string) => {
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
// Parse "Name <email>" format to extract just the email address
|
||||||
return get().trustedSenders.includes(normalizedEmail);
|
const trimmed = email.trim();
|
||||||
|
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
|
||||||
|
const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim();
|
||||||
|
return get().trustedSenders.includes(emailAddress);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Keyword methods
|
// Keyword methods
|
||||||
|
|||||||
Reference in New Issue
Block a user