feat: add plain text only setting for email composer #105
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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, '&').replace(/</g, '<').replace(/>/g, '>').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, '&').replace(/</g, '<').replace(/>/g, '>').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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -803,6 +803,10 @@
|
||||
"label": "会話グループ化を無効にする",
|
||||
"description": "メールを会話ごとにグループ化せず、個別のメッセージとして表示します"
|
||||
},
|
||||
"plain_text_mode": {
|
||||
"label": "プレーンテキストのみ",
|
||||
"description": "リッチテキストエディターを無効にし、返信や転送を含むすべてのメールをプレーンテキストのみで送信します"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "プレビューテキストを表示",
|
||||
"description": "リストにメールのプレビューを表示"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -803,6 +803,10 @@
|
||||
"label": "Отключить группировку по беседам",
|
||||
"description": "Отображать письма как отдельные сообщения, а не сгруппированные по беседам"
|
||||
},
|
||||
"plain_text_mode": {
|
||||
"label": "Только простой текст",
|
||||
"description": "Отключить редактор форматированного текста и отправлять все письма только в виде простого текста, включая ответы и пересылки"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Показывать текст предпросмотра",
|
||||
"description": "Отображать предпросмотр письма в списке"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user