feat(email-viewer): message spacing setting (auto/always/edge-to-edge)

This commit is contained in:
Shuki Vaknin
2026-07-21 23:06:43 +02:00
committed by Linus Rath
parent 4d9d992f3f
commit 53461d1142
5 changed files with 57 additions and 4 deletions
+17 -4
View File
@@ -654,6 +654,7 @@ export function EmailViewer({
const tDemoWelcome = useTranslations('demo_welcome');
const tWelcome = useTranslations('welcome');
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
@@ -2116,9 +2117,21 @@ export function EmailViewer({
// Word/Outlook HTML emails ship a <style> block but put their gutter in
// @page margins (print-only), so they need a fallback body padding too.
const isWordHtml = /class=["']?(?:Mso|WordSection)|<o:p[\s>/]|urn:schemas-microsoft-com:office:office/i.test(effectiveEmailContent.html);
const hasOwnLayout = effectiveEmailContent.hasStyleTag && !isWordHtml;
const bodyPadding = hasOwnLayout ? '0' : '1rem 1.25rem';
const mobileBodyPaddingX = hasOwnLayout ? '0' : '0.75rem';
// "auto" spacing: only drop our gutter when the mail paints a full-bleed
// background canvas (a width:100% element carrying a background colour) --
// the one case where the gutter shows as a frame around the email's own
// background. A <style> tag alone is too weak a signal: plenty of
// transactional mails ship one for web fonts yet have no gutter of their
// own, and zeroing the padding glues their content to the corner.
const emailHtml = effectiveEmailContent.html;
const hasFullBleedCanvas =
/<(?:table|div|body)\b[^>]*(?:\bwidth\s*=\s*["']?\s*100%|width\s*:\s*100%)[^>]*(?:\bbgcolor\s*=|background(?:-color)?\s*:)/i.test(emailHtml) ||
/<(?:table|div|body)\b[^>]*(?:\bbgcolor\s*=|background(?:-color)?\s*:)[^>]*(?:\bwidth\s*=\s*["']?\s*100%|width\s*:\s*100%)/i.test(emailHtml);
const autoDropsGutter = effectiveEmailContent.hasStyleTag && !isWordHtml && hasFullBleedCanvas;
const dropGutter =
messageSpacing === 'edge' || (messageSpacing === 'auto' && autoDropsGutter);
const bodyPadding = dropGutter ? '0' : '1rem 1.25rem';
const mobileBodyPaddingX = dropGutter ? '0' : '0.75rem';
// Word emails rely on empty <p class=MsoNormal>&nbsp;</p> spacers for vertical
// rhythm. With our default line-height: 1.6 these stack into oversized gaps;
@@ -2179,7 +2192,7 @@ export function EmailViewer({
${wordHtmlCSS}
${darkModeCSS}
</style></head><body>${effectiveEmailContent.html}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode]);
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]);
// Unblocking external content is handled by rebuilding the iframe srcDoc:
// toggling allowExternalContent (both "Load images" and "Trust sender" set
+15
View File
@@ -35,6 +35,7 @@ export function ReadingSettings() {
hoverActionsCorner,
hideInlineImageAttachments,
attachmentImagePreviewsEnabled,
messageSpacing,
updateSetting,
} = useSettingsStore();
@@ -115,6 +116,20 @@ export function ReadingSettings() {
</SettingItem>
)}
{!isSettingHidden('messageSpacing') && (
<SettingItem label={t('message_spacing.label')} description={t('message_spacing.description')} locked={isSettingLocked('messageSpacing')}>
<Select
value={messageSpacing}
onChange={(value) => updateSetting('messageSpacing', value as typeof messageSpacing)}
options={[
{ value: 'auto', label: t('message_spacing.auto') },
{ value: 'always', label: t('message_spacing.always') },
{ value: 'edge', label: t('message_spacing.edge') },
]}
/>
</SettingItem>
)}
{!isSettingHidden('deleteAction') && (
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')} locked={isSettingLocked('deleteAction')}>
<div className="flex flex-col gap-2">
+7
View File
@@ -1121,6 +1121,13 @@
"permanent": "Delete Permanently",
"warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible."
},
"message_spacing": {
"label": "Message Spacing",
"description": "Padding around the message body in the reader",
"auto": "Auto (recommended)",
"always": "Always add spacing",
"edge": "Edge to edge"
},
"archive_mode": {
"label": "Archive in",
"description": "How to organize emails when archiving",
+7
View File
@@ -1070,6 +1070,13 @@
"warning": "הודעות אימייל יימחקו לצמיתות ולא ניתן לשחזר אותן. פעולה זו היא בלתי הפיכה.",
"trash_and_read": "העבר לאשפה וסמן כקרא"
},
"message_spacing": {
"label": "מרווח סביב ההודעה",
"description": "ריווח מסביב לגוף ההודעה בתצוגת הקריאה",
"auto": "אוטומטי (מומלץ)",
"always": "תמיד להוסיף מרווח",
"edge": "מקצה לקצה"
},
"archive_mode": {
"label": "ארכיון ב",
"description": "כיצד לארגן מיילים בעת אחסון בארכיון",
+11
View File
@@ -55,6 +55,14 @@ export type AttachmentPosition = 'beside-sender' | 'below-header';
export type ToolbarPosition = 'top' | 'below-subject';
export type ArchiveMode = 'single' | 'year' | 'month';
export type MailLayout = 'split' | 'focus' | 'horizontal';
/**
* Spacing around a message body in the reader.
* - 'auto' : add a gutter unless the email paints its own full-bleed background
* - 'always': always add the gutter
* - 'edge' : never add one (render edge-to-edge)
*/
export type MessageSpacing = 'auto' | 'always' | 'edge';
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
export type SendDelaySeconds = 0 | 10 | 30 | 60;
export type ProtocolOpenMode = 'active-session' | 'new-tab';
@@ -156,6 +164,7 @@ interface SettingsState {
mailLayout: MailLayout;
emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy;
messageSpacing: MessageSpacing; // Gutter around the message body in the reader
mailAttachmentAction: MailAttachmentAction;
attachmentPosition: AttachmentPosition;
emailAlwaysLightMode: boolean; // Always render email content in light mode
@@ -378,6 +387,7 @@ const DEFAULT_SETTINGS = {
mailLayout: 'split' as MailLayout,
emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy,
messageSpacing: 'auto' as MessageSpacing,
mailAttachmentAction: 'preview' as MailAttachmentAction,
attachmentPosition: 'beside-sender' as AttachmentPosition,
emailAlwaysLightMode: false,
@@ -591,6 +601,7 @@ export const useSettingsStore = create<SettingsState>()(
mailLayout: state.mailLayout,
emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy,
messageSpacing: state.messageSpacing,
mailAttachmentAction: state.mailAttachmentAction,
attachmentPosition: state.attachmentPosition,
archiveMode: state.archiveMode,