feat: update email composer to handle trailing commas and improve recipient input handling

This commit is contained in:
Linus Rath
2026-03-11 21:07:00 +01:00
parent 5f539161d5
commit 15dbb3d349
10 changed files with 262 additions and 114 deletions
+237 -89
View File
@@ -81,18 +81,21 @@ export function EmailComposer({
const getInitialTo = () => { const getInitialTo = () => {
if (!replyTo) return ""; if (!replyTo) return "";
if (mode === 'reply') { if (mode === 'reply') {
return replyTo.from?.[0]?.email || ""; const email = replyTo.from?.[0]?.email || "";
return email ? email + ', ' : "";
} else if (mode === 'replyAll') { } else if (mode === 'replyAll') {
const from = replyTo.from?.[0]?.email || ""; const from = replyTo.from?.[0]?.email || "";
const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || ""; const originalTo = replyTo.to?.filter(r => r.email).map(r => r.email).join(", ") || "";
return [from, originalTo].filter(Boolean).join(", "); const combined = [from, originalTo].filter(Boolean).join(", ");
return combined ? combined + ', ' : "";
} }
return ""; return "";
}; };
const getInitialCc = () => { const getInitialCc = () => {
if (!replyTo || mode !== 'replyAll') return ""; if (!replyTo || mode !== 'replyAll') return "";
return replyTo.cc?.map(r => r.email).join(", ") || ""; const cc = replyTo.cc?.map(r => r.email).join(", ") || "";
return cc ? cc + ', ' : "";
}; };
const getInitialSubject = () => { const getInitialSubject = () => {
@@ -236,10 +239,12 @@ export function EmailComposer({
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;
const parts = getter.split(','); const parts = getter.split(',').map(s => s.trim()).filter(Boolean);
parts.pop(); if (!getter.trimEnd().endsWith(',') && parts.length > 0) {
parts.push(` ${email}`); parts.pop();
setter(parts.join(',').replace(/^,\s*/, '')); }
parts.push(email);
setter(parts.join(', ') + ', ');
setAutocompleteResults([]); setAutocompleteResults([]);
setActiveAutoField(null); setActiveAutoField(null);
setAutoSelectedIndex(-1); setAutoSelectedIndex(-1);
@@ -291,14 +296,14 @@ export function EmailComposer({
setSubject(filledSubject); setSubject(filledSubject);
setBody(filledBody); setBody(filledBody);
if (template.defaultRecipients?.to?.length) { if (template.defaultRecipients?.to?.length) {
setTo(template.defaultRecipients.to.join(', ')); setTo(template.defaultRecipients.to.join(', ') + ', ');
} }
if (template.defaultRecipients?.cc?.length) { if (template.defaultRecipients?.cc?.length) {
setCc(template.defaultRecipients.cc.join(', ')); setCc(template.defaultRecipients.cc.join(', ') + ', ');
setShowCc(true); setShowCc(true);
} }
if (template.defaultRecipients?.bcc?.length) { if (template.defaultRecipients?.bcc?.length) {
setBcc(template.defaultRecipients.bcc.join(', ')); setBcc(template.defaultRecipients.bcc.join(', ') + ', ');
setShowBcc(true); setShowBcc(true);
} }
} else { } else {
@@ -715,37 +720,26 @@ export function EmailComposer({
{/* To field */} {/* To field */}
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}> <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> <span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
<div className="flex-1 relative min-w-0"> <RecipientChipInput
<Input value={to}
ref={toInputRef} onChange={(v) => {
type="email" setTo(v);
placeholder={t('to_placeholder')} if (validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false }));
value={to} }}
onChange={(e) => { inputRef={toInputRef}
setTo(e.target.value); placeholder={t('to_placeholder')}
if (validationErrors.to) setValidationErrors(prev => ({ ...prev, to: false })); field="to"
handleAutocomplete(e.target.value, 'to'); onAutocomplete={handleAutocomplete}
}} onAutoKeyDown={handleAutoKeyDown}
onKeyDown={(e) => handleAutoKeyDown(e, 'to')} onAutoBlur={handleAutoBlur}
onBlur={(e) => handleAutoBlur(e, 'to')} activeAutoField={activeAutoField}
className={cn( autocompleteResults={autocompleteResults}
"border-0 focus-visible:ring-0 h-8 px-0 text-sm", autoSelectedIndex={autoSelectedIndex}
validationErrors.to && "ring-2 ring-red-500 dark:ring-red-400" dropdownRef={toDropdownRef}
)} onInsertAutocomplete={insertAutocomplete}
role="combobox" validationError={validationErrors.to}
aria-expanded={activeAutoField === 'to' && autocompleteResults.length > 0} validationMessage={t('validation.recipient_required')}
aria-autocomplete="list" />
aria-controls={activeAutoField === 'to' ? 'autocomplete-to' : undefined}
aria-activedescendant={activeAutoField === 'to' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
aria-invalid={validationErrors.to || undefined}
/>
{validationErrors.to && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{t('validation.recipient_required')}</p>
)}
{activeAutoField === 'to' && autocompleteResults.length > 0 && (
<AutocompleteDropdown ref={toDropdownRef} id="autocomplete-to" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'to')} />
)}
</div>
<div className="flex gap-0.5 shrink-0"> <div className="flex gap-0.5 shrink-0">
<Button <Button
variant="ghost" variant="ghost"
@@ -770,29 +764,21 @@ export function EmailComposer({
{showCc && ( {showCc && (
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative"> <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> <span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('cc_label')}</span>
<div className="flex-1 relative min-w-0"> <RecipientChipInput
<Input value={cc}
ref={ccInputRef} onChange={setCc}
type="email" inputRef={ccInputRef}
placeholder={t('cc_placeholder')} placeholder={t('cc_placeholder')}
value={cc} field="cc"
onChange={(e) => { onAutocomplete={handleAutocomplete}
setCc(e.target.value); onAutoKeyDown={handleAutoKeyDown}
handleAutocomplete(e.target.value, 'cc'); onAutoBlur={handleAutoBlur}
}} activeAutoField={activeAutoField}
onKeyDown={(e) => handleAutoKeyDown(e, 'cc')} autocompleteResults={autocompleteResults}
onBlur={(e) => handleAutoBlur(e, 'cc')} autoSelectedIndex={autoSelectedIndex}
className="border-0 focus-visible:ring-0 h-8 px-0 text-sm" dropdownRef={ccDropdownRef}
role="combobox" onInsertAutocomplete={insertAutocomplete}
aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0} />
aria-autocomplete="list"
aria-controls={activeAutoField === 'cc' ? 'autocomplete-cc' : undefined}
aria-activedescendant={activeAutoField === 'cc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
/>
{activeAutoField === 'cc' && autocompleteResults.length > 0 && (
<AutocompleteDropdown ref={ccDropdownRef} id="autocomplete-cc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'cc')} />
)}
</div>
</div> </div>
)} )}
@@ -800,29 +786,21 @@ export function EmailComposer({
{showBcc && ( {showBcc && (
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative"> <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> <span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('bcc_label')}</span>
<div className="flex-1 relative min-w-0"> <RecipientChipInput
<Input value={bcc}
ref={bccInputRef} onChange={setBcc}
type="email" inputRef={bccInputRef}
placeholder={t('bcc_placeholder')} placeholder={t('bcc_placeholder')}
value={bcc} field="bcc"
onChange={(e) => { onAutocomplete={handleAutocomplete}
setBcc(e.target.value); onAutoKeyDown={handleAutoKeyDown}
handleAutocomplete(e.target.value, 'bcc'); onAutoBlur={handleAutoBlur}
}} activeAutoField={activeAutoField}
onKeyDown={(e) => handleAutoKeyDown(e, 'bcc')} autocompleteResults={autocompleteResults}
onBlur={(e) => handleAutoBlur(e, 'bcc')} autoSelectedIndex={autoSelectedIndex}
className="border-0 focus-visible:ring-0 h-8 px-0 text-sm" dropdownRef={bccDropdownRef}
role="combobox" onInsertAutocomplete={insertAutocomplete}
aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0} />
aria-autocomplete="list"
aria-controls={activeAutoField === 'bcc' ? 'autocomplete-bcc' : undefined}
aria-activedescendant={activeAutoField === 'bcc' && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
/>
{activeAutoField === 'bcc' && autocompleteResults.length > 0 && (
<AutocompleteDropdown ref={bccDropdownRef} id="autocomplete-bcc" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'bcc')} />
)}
</div>
</div> </div>
)} )}
@@ -1047,7 +1025,7 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
onSelect: (email: string) => void; onSelect: (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-popover 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">
{results.map((r, i) => ( {results.map((r, i) => (
<button <button
key={i} key={i}
@@ -1072,4 +1050,174 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
))} ))}
</div> </div>
); );
}); });
function RecipientChipInput({
value,
onChange,
inputRef,
placeholder,
field,
onAutocomplete,
onAutoKeyDown,
onAutoBlur,
activeAutoField,
autocompleteResults,
autoSelectedIndex,
dropdownRef,
onInsertAutocomplete,
validationError,
validationMessage,
}: {
value: string;
onChange: (value: string) => void;
inputRef: React.RefObject<HTMLInputElement | null>;
placeholder: string;
field: 'to' | 'cc' | 'bcc';
onAutocomplete: (value: 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;
autocompleteResults: Array<{ name: string; email: string }>;
autoSelectedIndex: number;
dropdownRef: React.RefObject<HTMLDivElement | null>;
onInsertAutocomplete: (email: string, field: 'to' | 'cc' | 'bcc') => void;
validationError?: boolean;
validationMessage?: string;
}) {
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] || '');
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);
};
const commitCurrentInput = () => {
if (inputText.trim()) {
const newChips = [...chips, inputText.trim()];
onChange(newChips.join(', ') + ', ');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (activeAutoField === field && autocompleteResults.length > 0) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Escape' ||
(e.key === 'Enter' && autoSelectedIndex >= 0)) {
onAutoKeyDown(e, field);
return;
}
}
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
if (e.key !== 'Tab') e.preventDefault();
commitCurrentInput();
setTimeout(() => inputRef.current?.focus(), 0);
return;
}
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);
return;
}
};
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);
if (remainingChips.length > 0) {
onChange(remainingChips.join(', ') + ', ' + inputText);
} else {
onChange(inputText);
}
};
const handleBlur = (e: React.FocusEvent) => {
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) {
return;
}
if (inputText.trim()) {
const newChips = [...chips, inputText.trim()];
onChange(newChips.join(', ') + ', ');
}
onAutoBlur(e, field);
};
return (
<div className="flex-1 relative min-w-0">
<div
className={cn(
"flex flex-wrap items-center gap-1 min-h-[32px] cursor-text",
validationError && "ring-2 ring-red-500 dark:ring-red-400 rounded"
)}
onClick={() => inputRef.current?.focus()}
>
{chips.map((chip, i) => (
<span
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"
onClick={(e) => {
e.stopPropagation();
handleChipClick(i);
}}
>
<span className="truncate max-w-[200px]">{chip}</span>
<button
type="button"
className="flex items-center justify-center w-4 h-4 rounded-full hover:bg-muted-foreground/20 transition-colors"
onClick={(e) => handleChipRemove(i, e)}
tabIndex={-1}
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
placeholder={chips.length === 0 ? placeholder : ''}
value={inputText}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground"
role="combobox"
aria-expanded={activeAutoField === field && autocompleteResults.length > 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}
/>
</div>
{validationError && validationMessage && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{validationMessage}</p>
)}
{activeAutoField === field && autocompleteResults.length > 0 && (
<AutocompleteDropdown
ref={dropdownRef}
id={`autocomplete-${field}`}
results={autocompleteResults}
selectedIndex={autoSelectedIndex}
onSelect={(email) => onInsertAutocomplete(email, field)}
/>
)}
</div>
);
}
+1 -1
View File
@@ -275,7 +275,7 @@ function PlaceholderDropdown({
return ( return (
<> <>
<div className="fixed inset-0 z-40" onClick={onClose} /> <div className="fixed inset-0 z-40" onClick={onClose} />
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border border-border rounded-md shadow-lg min-w-[180px]"> <div className="absolute right-0 top-full mt-1 z-50 bg-background border border-border rounded-md shadow-lg min-w-[180px]">
<div className="p-1"> <div className="p-1">
{BUILT_IN_PLACEHOLDERS.map((p) => ( {BUILT_IN_PLACEHOLDERS.map((p) => (
<button <button
+3 -3
View File
@@ -298,9 +298,9 @@
"saving": "Wird gespeichert...", "saving": "Wird gespeichert...",
"draft_saved": "Entwurf gespeichert", "draft_saved": "Entwurf gespeichert",
"save_failed": "Speichern fehlgeschlagen", "save_failed": "Speichern fehlgeschlagen",
"to_placeholder": "E-Mail-Adressen der Empfänger (durch Komma getrennt)", "to_placeholder": "E-Mail-Adressen der Empfänger",
"cc_placeholder": "CC-Empfänger (durch Komma getrennt)", "cc_placeholder": "CC-Empfänger",
"bcc_placeholder": "BCC-Empfänger (durch Komma getrennt)", "bcc_placeholder": "BCC-Empfänger",
"subject_placeholder": "Betreff", "subject_placeholder": "Betreff",
"cc_label": "Cc:", "cc_label": "Cc:",
"bcc_label": "Bcc:", "bcc_label": "Bcc:",
+3 -3
View File
@@ -299,9 +299,9 @@
"saving": "Saving...", "saving": "Saving...",
"draft_saved": "Draft saved", "draft_saved": "Draft saved",
"save_failed": "Failed to save", "save_failed": "Failed to save",
"to_placeholder": "Recipient email addresses (comma separated)", "to_placeholder": "Recipient email addresses",
"cc_placeholder": "Cc recipients (comma separated)", "cc_placeholder": "Cc recipients",
"bcc_placeholder": "Bcc recipients (comma separated)", "bcc_placeholder": "Bcc recipients",
"subject_placeholder": "Subject", "subject_placeholder": "Subject",
"cc_label": "Cc:", "cc_label": "Cc:",
"bcc_label": "Bcc:", "bcc_label": "Bcc:",
+3 -3
View File
@@ -298,9 +298,9 @@
"saving": "Guardando...", "saving": "Guardando...",
"draft_saved": "Borrador guardado", "draft_saved": "Borrador guardado",
"save_failed": "Error al guardar", "save_failed": "Error al guardar",
"to_placeholder": "Direcciones de correo de destinatarios (separadas por comas)", "to_placeholder": "Direcciones de correo de destinatarios",
"cc_placeholder": "Destinatarios CC (separados por comas)", "cc_placeholder": "Destinatarios CC",
"bcc_placeholder": "Destinatarios CCO (separados por comas)", "bcc_placeholder": "Destinatarios CCO",
"subject_placeholder": "Asunto", "subject_placeholder": "Asunto",
"cc_label": "CC:", "cc_label": "CC:",
"bcc_label": "CCO:", "bcc_label": "CCO:",
+3 -3
View File
@@ -298,9 +298,9 @@
"saving": "Enregistrement...", "saving": "Enregistrement...",
"draft_saved": "Brouillon enregistré", "draft_saved": "Brouillon enregistré",
"save_failed": "Échec de l'enregistrement", "save_failed": "Échec de l'enregistrement",
"to_placeholder": "Adresses email des destinataires (séparées par des virgules)", "to_placeholder": "Adresses email des destinataires",
"cc_placeholder": "Destinataires en copie (séparés par des virgules)", "cc_placeholder": "Destinataires en copie",
"bcc_placeholder": "Destinataires en copie cachée (séparés par des virgules)", "bcc_placeholder": "Destinataires en copie cachée",
"subject_placeholder": "Objet", "subject_placeholder": "Objet",
"cc_label": "Cc :", "cc_label": "Cc :",
"bcc_label": "Cci :", "bcc_label": "Cci :",
+3 -3
View File
@@ -298,9 +298,9 @@
"saving": "Salvataggio...", "saving": "Salvataggio...",
"draft_saved": "Bozza salvata", "draft_saved": "Bozza salvata",
"save_failed": "Salvataggio non riuscito", "save_failed": "Salvataggio non riuscito",
"to_placeholder": "Indirizzi email dei destinatari (separati da virgola)", "to_placeholder": "Indirizzi email dei destinatari",
"cc_placeholder": "Destinatari in copia (separati da virgola)", "cc_placeholder": "Destinatari in copia",
"bcc_placeholder": "Destinatari in copia nascosta (separati da virgola)", "bcc_placeholder": "Destinatari in copia nascosta",
"subject_placeholder": "Oggetto", "subject_placeholder": "Oggetto",
"cc_label": "Cc:", "cc_label": "Cc:",
"bcc_label": "Ccn:", "bcc_label": "Ccn:",
+3 -3
View File
@@ -298,9 +298,9 @@
"saving": "保存中...", "saving": "保存中...",
"draft_saved": "下書きを保存しました", "draft_saved": "下書きを保存しました",
"save_failed": "保存に失敗しました", "save_failed": "保存に失敗しました",
"to_placeholder": "受信者のメールアドレス(カンマ区切り)", "to_placeholder": "受信者のメールアドレス",
"cc_placeholder": "CC受信者(カンマ区切り)", "cc_placeholder": "CC受信者",
"bcc_placeholder": "BCC受信者(カンマ区切り)", "bcc_placeholder": "BCC受信者",
"subject_placeholder": "件名", "subject_placeholder": "件名",
"cc_label": "CC:", "cc_label": "CC:",
"bcc_label": "BCC:", "bcc_label": "BCC:",
+3 -3
View File
@@ -298,9 +298,9 @@
"saving": "Opslaan...", "saving": "Opslaan...",
"draft_saved": "Concept opgeslagen", "draft_saved": "Concept opgeslagen",
"save_failed": "Opslaan mislukt", "save_failed": "Opslaan mislukt",
"to_placeholder": "E-mailadressen van ontvangers (komma gescheiden)", "to_placeholder": "E-mailadressen van ontvangers",
"cc_placeholder": "CC-ontvangers (komma gescheiden)", "cc_placeholder": "CC-ontvangers",
"bcc_placeholder": "BCC-ontvangers (komma gescheiden)", "bcc_placeholder": "BCC-ontvangers",
"subject_placeholder": "Onderwerp", "subject_placeholder": "Onderwerp",
"cc_label": "CC:", "cc_label": "CC:",
"bcc_label": "BCC:", "bcc_label": "BCC:",
+3 -3
View File
@@ -298,9 +298,9 @@
"saving": "Salvando...", "saving": "Salvando...",
"draft_saved": "Rascunho salvo", "draft_saved": "Rascunho salvo",
"save_failed": "Falha ao salvar", "save_failed": "Falha ao salvar",
"to_placeholder": "Endereços de e-mail dos destinatários (separados por vírgula)", "to_placeholder": "Endereços de e-mail dos destinatários",
"cc_placeholder": "Destinatários CC (separados por vírgula)", "cc_placeholder": "Destinatários CC",
"bcc_placeholder": "Destinatários CCO (separados por vírgula)", "bcc_placeholder": "Destinatários CCO",
"subject_placeholder": "Assunto", "subject_placeholder": "Assunto",
"cc_label": "CC:", "cc_label": "CC:",
"bcc_label": "CCO:", "bcc_label": "CCO:",