feat: add plain text only setting for email composer #105

This commit is contained in:
Linus Rath
2026-03-27 00:38:11 +01:00
parent a46fb9c8af
commit b75d064f20
13 changed files with 125 additions and 29 deletions
+1
View File
@@ -32,6 +32,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
+76 -29
View File
@@ -99,6 +99,7 @@ export function EmailComposer({
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
// Initialize with reply/forward data if provided
const getInitialTo = () => {
@@ -134,6 +135,26 @@ export function EmailComposer({
};
const getInitialBody = () => {
if (plainTextMode) {
// Plain text mode: produce plain text body with no HTML
const prefix = initialDraftText || "";
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : '');
const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n');
if (mode === 'forward') {
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`;
}
return prefix;
}
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>` : "";
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
@@ -369,12 +390,14 @@ export function EmailComposer({
? substitutePlaceholders(template.body, filledValues)
: template.body;
// Convert template plain text body to HTML for the rich text editor
const htmlBody = `<p>${filledBody.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`;
// In plain text mode, use template body as-is; otherwise convert to HTML
const bodyContent = plainTextMode
? filledBody
: `<p>${filledBody.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`;
if (mode === 'compose') {
setSubject(filledSubject);
setBody(htmlBody);
setBody(bodyContent);
if (template.defaultRecipients?.to?.length) {
setTo(template.defaultRecipients.to.join(', ') + ', ');
}
@@ -387,7 +410,7 @@ export function EmailComposer({
setShowBcc(true);
}
} else {
setBody((prev) => htmlBody + prev);
setBody((prev) => bodyContent + (plainTextMode ? '\n' : '') + prev);
}
if (template.identityId) {
@@ -395,7 +418,7 @@ export function EmailComposer({
}
setShowTemplatePicker(false);
}, [mode]);
}, [mode, plainTextMode]);
useEffect(() => {
const handleTemplateKey = (e: KeyboardEvent) => {
@@ -530,7 +553,7 @@ export function EmailComposer({
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
if (!toAddresses.length && !subject && !htmlToPlainText(body).trim()) {
if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) {
return null;
}
@@ -566,7 +589,7 @@ export function EmailComposer({
const savedDraftId = await client.createDraft(
toAddresses,
subject || t('no_subject'),
htmlToPlainText(body),
plainTextMode ? body : htmlToPlainText(body),
ccAddresses,
bccAddresses,
currentIdentity?.id,
@@ -630,7 +653,7 @@ export function EmailComposer({
}, []);
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const bodyPlainText = htmlToPlainText(body).trim();
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;
@@ -680,8 +703,8 @@ export function EmailComposer({
: currentIdentity.email
: undefined;
// Body is already HTML from the rich text editor.
// Build HTML signature block
// Body is already HTML from the rich text editor (or plain text in plain text mode).
// Build HTML signature block (used only in rich text mode)
const buildSignatureHtml = (): string => {
if (currentIdentity?.htmlSignature) {
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
@@ -692,13 +715,14 @@ export function EmailComposer({
return '';
};
const signatureHtml = buildSignatureHtml();
// In plain text mode, send text/plain only (no HTML body)
const finalBody = plainTextMode
? appendPlainTextSignature(body, currentIdentity)
: appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
// Build final HTML body: editor content + signature
const finalHtmlBody = `<div>${body}</div>${signatureHtml}`;
// Generate plain text version from the HTML body for multipart/alternative
const finalBody = appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
const finalHtmlBody = plainTextMode
? undefined
: `<div>${body}</div>${buildSignatureHtml()}`;
try {
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
@@ -1101,24 +1125,47 @@ export function EmailComposer({
</div>
</div>
{/* Body - Rich Text Editor */}
<RichTextEditor
content={body}
onChange={(html) => {
setBody(html);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
/>
{/* Body */}
{plainTextMode ? (
<textarea
value={body}
onChange={(e) => {
setBody(e.target.value);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
placeholder={t('body_placeholder')}
className={cn(
"w-full min-h-[300px] px-4 py-3 text-sm text-foreground bg-transparent resize-y focus:outline-none font-mono",
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400 rounded"
)}
style={{ height: 'calc(100vh - 350px)' }}
aria-invalid={validationErrors.body || undefined}
/>
) : (
<RichTextEditor
content={body}
onChange={(html) => {
setBody(html);
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
}}
onImageUpload={handleImageUpload}
placeholder={t('body_placeholder')}
hasError={validationErrors.body}
/>
)}
{composerSignatureHtml && (
{plainTextMode ? (
getPlainTextSignature(currentIdentity) ? (
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
{'-- \n'}{getPlainTextSignature(currentIdentity)}
</div>
) : null
) : composerSignatureHtml ? (
<div
className="px-4 pb-3 text-sm leading-6 text-foreground break-words [&_a]:text-primary [&_a]:underline-offset-2 [&_a:hover]:underline"
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
/>
)}
) : null}
</div>
{/* Attachments */}
+9
View File
@@ -40,6 +40,7 @@ export function EmailSettings() {
permanentlyDeleteJunk,
showPreview,
disableThreading,
plainTextMode,
emailsPerPage,
externalContentPolicy,
mailAttachmentAction,
@@ -221,6 +222,14 @@ export function EmailSettings() {
/>
</SettingItem>
{/* Plain Text Mode */}
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
<ToggleSwitch
checked={plainTextMode}
onChange={(checked) => updateSetting('plainTextMode', checked)}
/>
</SettingItem>
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
+4
View File
@@ -807,6 +807,10 @@
"label": "Konversationsgruppierung deaktivieren",
"description": "E-Mails als einzelne Nachrichten statt nach Konversation gruppiert anzeigen"
},
"plain_text_mode": {
"label": "Nur Klartext",
"description": "Rich-Text-Editor deaktivieren und alle E-Mails nur als Klartext senden, einschließlich Antworten und Weiterleitungen"
},
"attachment_click_action": {
"label": "Aktion beim Klick auf Anhänge",
"description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird",
+4
View File
@@ -807,6 +807,10 @@
"label": "Disable Conversation Grouping",
"description": "Show emails as individual messages instead of grouped by conversation"
},
"plain_text_mode": {
"label": "Plain Text Only",
"description": "Disable the rich text editor and send all emails as plain text only, including replies and forwards"
},
"attachment_click_action": {
"label": "Attachment Click Action",
"description": "Choose whether clicking a file attachment previews it or downloads it immediately",
+4
View File
@@ -803,6 +803,10 @@
"label": "Desactivar agrupación de conversaciones",
"description": "Mostrar correos como mensajes individuales en lugar de agruparlos por conversación"
},
"plain_text_mode": {
"label": "Solo texto sin formato",
"description": "Desactivar el editor de texto enriquecido y enviar todos los correos solo como texto sin formato, incluyendo respuestas y reenvíos"
},
"show_preview": {
"label": "Mostrar Vista Previa",
"description": "Mostrar vista previa del correo en la lista"
+4
View File
@@ -803,6 +803,10 @@
"label": "Désactiver le regroupement par conversation",
"description": "Afficher les e-mails comme messages individuels plutôt que groupés par conversation"
},
"plain_text_mode": {
"label": "Texte brut uniquement",
"description": "Désactiver l'éditeur de texte enrichi et envoyer tous les e-mails en texte brut uniquement, y compris les réponses et les transferts"
},
"show_preview": {
"label": "Afficher l'aperçu",
"description": "Afficher l'aperçu de l'email dans la liste"
+4
View File
@@ -803,6 +803,10 @@
"label": "Disabilita raggruppamento conversazioni",
"description": "Mostra le email come messaggi singoli anziché raggruppati per conversazione"
},
"plain_text_mode": {
"label": "Solo testo normale",
"description": "Disabilita l'editor di testo formattato e invia tutte le email solo come testo normale, incluse risposte e inoltri"
},
"show_preview": {
"label": "Mostra anteprima testo",
"description": "Visualizza l'anteprima del messaggio nell'elenco"
+4
View File
@@ -803,6 +803,10 @@
"label": "会話グループ化を無効にする",
"description": "メールを会話ごとにグループ化せず、個別のメッセージとして表示します"
},
"plain_text_mode": {
"label": "プレーンテキストのみ",
"description": "リッチテキストエディターを無効にし、返信や転送を含むすべてのメールをプレーンテキストのみで送信します"
},
"show_preview": {
"label": "プレビューテキストを表示",
"description": "リストにメールのプレビューを表示"
+4
View File
@@ -803,6 +803,10 @@
"label": "Conversatiegroepering uitschakelen",
"description": "Toon e-mails als afzonderlijke berichten in plaats van gegroepeerd op gesprek"
},
"plain_text_mode": {
"label": "Alleen platte tekst",
"description": "Schakel de rich text-editor uit en verzend alle e-mails alleen als platte tekst, inclusief antwoorden en doorgestuurde berichten"
},
"show_preview": {
"label": "Voorbeeldtekst tonen",
"description": "E-mailvoorbeeld weergeven in de lijst"
+4
View File
@@ -803,6 +803,10 @@
"label": "Desativar agrupamento de conversas",
"description": "Mostrar e-mails como mensagens individuais em vez de agrupados por conversa"
},
"plain_text_mode": {
"label": "Apenas texto simples",
"description": "Desativar o editor de texto formatado e enviar todos os e-mails apenas como texto simples, incluindo respostas e encaminhamentos"
},
"show_preview": {
"label": "Mostrar Texto de Visualização",
"description": "Exibir visualização do e-mail na lista"
+4
View File
@@ -803,6 +803,10 @@
"label": "Отключить группировку по беседам",
"description": "Отображать письма как отдельные сообщения, а не сгруппированные по беседам"
},
"plain_text_mode": {
"label": "Только простой текст",
"description": "Отключить редактор форматированного текста и отправлять все письма только в виде простого текста, включая ответы и пересылки"
},
"show_preview": {
"label": "Показывать текст предпросмотра",
"description": "Отображать предпросмотр письма в списке"
+3
View File
@@ -119,6 +119,7 @@ interface SettingsState {
autoSaveDraftInterval: number; // milliseconds
sendConfirmation: boolean;
defaultReplyMode: ReplyMode;
plainTextMode: boolean; // Send plain text only (no rich text editor)
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
@@ -237,6 +238,7 @@ const DEFAULT_SETTINGS = {
autoSaveDraftInterval: 60000, // 1 minute
sendConfirmation: false,
defaultReplyMode: 'reply' as ReplyMode,
plainTextMode: false,
// Privacy & Security
sessionTimeout: 0, // Never
@@ -344,6 +346,7 @@ export const useSettingsStore = create<SettingsState>()(
autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
plainTextMode: state.plainTextMode,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound,