diff --git a/src/components/email-viewer/email-viewer.scss b/src/components/email-viewer/email-viewer.scss index fe01325301..c8b7978d3c 100644 --- a/src/components/email-viewer/email-viewer.scss +++ b/src/components/email-viewer/email-viewer.scss @@ -1,6 +1,8 @@ @use '../../style/mixins'; :host(limel-email-viewer) { + isolation: isolate; + display: block; width: 100%; height: 100%; @@ -72,6 +74,7 @@ &.date { position: absolute; + z-index: 1; right: 0.25rem; bottom: 0; transform: translateY(50%); @@ -156,7 +159,6 @@ section { flex-direction: column; border-top: 1px dashed rgba(var(--contrast-700)); min-height: 2rem; - overflow-y: auto; } limel-collapsible-section { @@ -198,7 +200,16 @@ limel-collapsible-section { .body { flex-grow: 1; max-width: 100%; - padding: 0.75rem; + padding: 1rem; + padding-bottom: 3rem; + overflow-y: auto; + + background-color: rgb(var(--contrast-300)); + color-scheme: light; + &.toggled-surface-background { + background-color: rgb(var(--contrast-1500)); + color-scheme: dark; + } &.plain-text { white-space: pre-wrap; @@ -211,3 +222,50 @@ limel-collapsible-section { max-width: 100% !important; } } + +.toggle-dark-light { + display: flex; + align-items: center; + justify-content: flex-end; + + position: sticky; + top: 0; + z-index: 1; + + padding: 0.25rem 0.5rem; + $height: 2.5rem; + height: $height; + margin-bottom: -$height; + + pointer-events: none; + + button { + pointer-events: all; + @include mixins.reset-button-user-agent-styles; + @include mixins.is-elevated-clickable(); + @include mixins.visualize-keyboard-focus(); + + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + + padding: 0.125rem 0.25rem; + height: fit-content; + + font-size: 0.75rem; + border-radius: 0.375rem; + border: 1px solid rgba(var(--contrast-700)); + + opacity: 0.6; + + &:hover, + &:focus-visible { + opacity: 1; + } + + limel-icon { + width: 1.125rem; + } + } +} diff --git a/src/components/email-viewer/email-viewer.tsx b/src/components/email-viewer/email-viewer.tsx index a9e06c341b..8e6e31bcdc 100644 --- a/src/components/email-viewer/email-viewer.tsx +++ b/src/components/email-viewer/email-viewer.tsx @@ -14,6 +14,7 @@ import { Email, EmailAttachment, EmailHeaderType } from './email-viewer.types'; import { applyRemoteImagesPolicy, containsRemoteImages } from './remote-images'; import { splitEmailAddressList } from './split-email-address-list'; import { formatBytes } from '../../util/format-bytes'; +import { createRandomString } from '../../util/random-string'; /** * This is a private component, used to render `.eml` files inside @@ -70,6 +71,11 @@ export class EmailViewer { @State() private allowRemoteImagesState = false; + @State() + private surfaceBackgroundState = false; + + private toggleButtonId = createRandomString(); + /** * Emitted when the user requests remote images to be loaded. * @@ -79,7 +85,11 @@ export class EmailViewer { public allowRemoteImagesChange: EventEmitter; @Watch('email') - protected resetAllowRemoteImages(newEmail?: Email, oldEmail?: Email) { + protected handleEmailChange(newEmail?: Email, oldEmail?: Email) { + // Surface preference is per-email-view: a new message always opens + // with the default light background. + this.surfaceBackgroundState = false; + if (!newEmail) { this.allowRemoteImagesState = false; return; @@ -98,6 +108,7 @@ export class EmailViewer { {this.renderRemoteImageBanner()}
{this.renderAttachments()} + {this.renderSurfaceToggle()} {this.renderBody()}
@@ -146,7 +157,13 @@ export class EmailViewer { this.getAllowRemoteImages() ); - return
; + return ( +
+ ); } private renderBodyText() { @@ -156,12 +173,59 @@ export class EmailViewer { } return ( -
+            
                 {bodyText}
             
); } + private getBodyClassNames(): string { + return this.surfaceBackgroundState + ? 'body toggled-surface-background' + : 'body'; + } + + private renderSurfaceToggle() { + const hasBody = Boolean(this.email?.bodyHtml || this.email?.bodyText); + if (!hasBody) { + return; + } + + const label = this.getTranslation('file-viewer.email.surface.toggle'); + const tooltipLabel = this.getTranslation( + 'file-viewer.email.surface.toggle.tooltip.label' + ); + const tooltipHelper = this.getTranslation( + 'file-viewer.email.surface.toggle.tooltip.helper' + ); + + return ( +
+ + +
+ ); + } + + private toggleSurfaceBackground = () => { + this.surfaceBackgroundState = !this.surfaceBackgroundState; + }; + private renderFallbackUrl() { if (!this.fallbackUrl) { return; diff --git a/src/components/file-viewer/file-viewer.spec.tsx b/src/components/file-viewer/file-viewer.spec.tsx index 2223061d3d..087c252d24 100644 --- a/src/components/file-viewer/file-viewer.spec.tsx +++ b/src/components/file-viewer/file-viewer.spec.tsx @@ -23,11 +23,29 @@ describe('limel-file-viewer', () => { '\r\n' + 'Hello from EML!\r\n'; - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - arrayBuffer: async () => - new TextEncoder().encode(eml).buffer, - } as any); + // Route the email URL to its EML payload, and return a + // benign empty response for any other fetches the email + // viewer's render tree may trigger (e.g. icon SVGs from + // , which calls `response.text()`). + globalThis.fetch = vi + .fn() + .mockImplementation(async (input: any) => { + const url = + typeof input === 'string' ? input : input.url; + if (url === testCase.url) { + return { + ok: true, + arrayBuffer: async () => + new TextEncoder().encode(eml).buffer, + } as any; + } + + return { + ok: true, + text: async () => '', + arrayBuffer: async () => new ArrayBuffer(0), + } as any; + }); } const { root, waitForChanges } = await render( diff --git a/src/components/markdown/allowed-css-properties.ts b/src/components/markdown/allowed-css-properties.ts index 3309e5779d..7fa2cf5d14 100644 --- a/src/components/markdown/allowed-css-properties.ts +++ b/src/components/markdown/allowed-css-properties.ts @@ -1,6 +1,4 @@ export const allowedCssProperties = [ - 'background-color', - 'color', 'font-style', 'font-weight', 'text-decoration-color', diff --git a/src/components/markdown/examples/markdown-html.tsx b/src/components/markdown/examples/markdown-html.tsx index b3c45cb993..3fb1f605ec 100644 --- a/src/components/markdown/examples/markdown-html.tsx +++ b/src/components/markdown/examples/markdown-html.tsx @@ -13,22 +13,19 @@ const markdown = `
It's better to use HTML tags.
Can you use text colors?
-
Yes, since I'm red!
+
No. Inline color styles are stripped so the text always inherits the surrounding theme — this stays readable in both light and dark mode.
Can you use background colors?
-
Yes, since I'm on a green background!
+
No. Inline background-color styles are stripped for the same reason.
-
Can you use more than one style at the same time?
-
Yes, since I'm a light sky blue on a dark coral background!
+
Can you make text bold or italic with inline styles?
+
Yes, since font-weight and font-style are allowed.
Can you use background images?
No, you should not be able to, so if there's a skull and crossbones background here, something is wrong.
-
Can you use background with a color value?
-
Yes. If the value is recognized as a color value, the value will be moved to background-color
-
Can you sneakily use background to insert an image?
-
No. If the value is not recognized as a color value, the background property will be stripped.
+
No, the background shorthand is stripped entirely.
`; diff --git a/src/components/markdown/markdown-parser.spec.ts b/src/components/markdown/markdown-parser.spec.ts index 820391232e..b430b34303 100644 --- a/src/components/markdown/markdown-parser.spec.ts +++ b/src/components/markdown/markdown-parser.spec.ts @@ -629,27 +629,25 @@ describe('sanitizeHTML', () => { describe('style attribute sanitization', () => { it('should allow safe CSS properties', async () => { const result = await sanitizeHTML( - '

Text

' + '

Text

' ); expect(result).toEqualHtml( - '

Text

' + '

Text

' ); }); it('should strip dangerous CSS properties', async () => { const result = await sanitizeHTML( - '

Text

' + '

Text

' ); - expect(result).toEqualHtml('

Text

'); + expect(result).toEqualHtml('

Text

'); }); - it('should normalize background to background-color', async () => { + it('should strip color and background-color', async () => { const result = await sanitizeHTML( - '

Text

' - ); - expect(result).toEqualHtml( - '

Text

' + '

Text

' ); + expect(result).toEqualHtml('

Text

'); }); }); diff --git a/src/components/markdown/sanitize-style.spec.ts b/src/components/markdown/sanitize-style.spec.ts index 85974bfd3d..89f2b89998 100644 --- a/src/components/markdown/sanitize-style.spec.ts +++ b/src/components/markdown/sanitize-style.spec.ts @@ -10,11 +10,11 @@ describe('sanitizeStyle', () => { const node = { tagName: 'div', properties: { - style: 'color: red; position: absolute;', + style: 'font-weight: bold; position: absolute;', }, }; sanitizeStyle(node); - expect(node.properties.style).toContain('color: red'); + expect(node.properties.style).toContain('font-weight: bold'); expect(node.properties.style).not.toContain('position: absolute'); }); @@ -40,7 +40,7 @@ describe('sanitizeStyle', () => { it('does nothing to a text node (node without `tagName`)', () => { const node = { properties: { - style: 'color: red; position: absolute;', + style: 'font-weight: bold; position: absolute;', }, }; const originalProperties = { ...node.properties }; @@ -52,13 +52,13 @@ describe('sanitizeStyle', () => { const node = { tagName: 'div', properties: { - style: 'color: red; animation: slidein 3s;', + style: 'font-weight: bold; animation: slidein 3s;', id: 'test-node', }, }; - // 'color' is allowed, but 'animation' is not. Also checks that 'id' remains unchanged. + // 'font-weight' is allowed, but 'animation' is not. Also checks that 'id' remains unchanged. sanitizeStyle(node); - expect(node.properties.style).toContain('color: red'); + expect(node.properties.style).toContain('font-weight: bold'); expect(node.properties.style).not.toContain('animation: slidein 3s'); expect(node.properties.id).toBe('test-node'); }); @@ -67,27 +67,29 @@ describe('sanitizeStyle', () => { describe('sanitizeStyleValue', () => { describe('with valid CSS', () => { it('retains allowed CSS properties', () => { - const style = 'color: blue; text-decoration: underline;'; + const style = 'font-weight: bold; text-decoration: underline;'; const sanitized = sanitizeStyleValue(style); - // 'color' and 'text-decoration' are in the allowedCssProperties list - expect(sanitized).toContain('color: blue'); + // 'font-weight' and 'text-decoration' are in the allowedCssProperties list + expect(sanitized).toContain('font-weight: bold'); expect(sanitized).toContain('text-decoration: underline'); }); it('removes disallowed CSS properties', () => { - const style = 'color: blue; margin: 10px;'; + const style = 'font-weight: bold; margin: 10px;'; const sanitized = sanitizeStyleValue(style); // 'margin' is not in the allowedCssProperties list - expect(sanitized).toContain('color: blue'); + expect(sanitized).toContain('font-weight: bold'); expect(sanitized).not.toContain('margin: 10px'); }); - it('converts background to background-color if valid and allowed', () => { - const style = 'background: red;'; + it('strips color and background-color', () => { + // Inline colors from email content can collide with the host + // theme (e.g. white text on a light surface), so they are not + // preserved by the markdown sanitizer. + const style = + 'color: blue; background-color: red; font-weight: bold;'; const sanitized = sanitizeStyleValue(style); - // 'background-color' is allowed and should replace 'background' - expect(sanitized).toContain('background-color: red'); - expect(sanitized).not.toContain('background: red'); + expect(sanitized).toBe('font-weight: bold'); }); }); @@ -100,12 +102,12 @@ describe('sanitizeStyleValue', () => { vi.restoreAllMocks(); }); it('returns an empty string for invalid CSS syntax', () => { - const style = 'color blue'; // Missing colon + const style = 'font-weight bold'; // Missing colon const sanitized = sanitizeStyleValue(style); expect(sanitized).toBe(''); }); it('logs an error to the console for invalid CSS syntax', () => { - const style = 'color blue'; // Missing colon + const style = 'font-weight bold'; // Missing colon sanitizeStyleValue(style); expect(console.error).toHaveBeenCalledTimes(1); }); @@ -113,26 +115,26 @@ describe('sanitizeStyleValue', () => { describe('special cases', () => { it('handles CSS with semi-colon at the end correctly', () => { - const style = 'color: blue;'; + const style = 'font-weight: bold;'; const sanitized = sanitizeStyleValue(style); // Ensure proper handling of trailing semi-colons - expect(sanitized).toBe('color: blue'); + expect(sanitized).toBe('font-weight: bold'); }); it('handles CSS without semi-colon at the end correctly', () => { - const style = 'color: blue'; + const style = 'font-weight: bold'; const sanitized = sanitizeStyleValue(style); - expect(sanitized).toBe('color: blue'); + expect(sanitized).toBe('font-weight: bold'); }); }); describe('integration with allowedCssProperties list', () => { it('only retains properties specified in allowedCssProperties', () => { const style = - 'color: blue; animation: slidein 3s; font-weight: bold;'; + 'font-style: italic; animation: slidein 3s; font-weight: bold;'; const sanitized = sanitizeStyleValue(style); - // 'animation' is not allowed, 'color' and 'font-weight' are allowed - expect(sanitized).toContain('color: blue'); + // 'animation' is not allowed, 'font-style' and 'font-weight' are allowed + expect(sanitized).toContain('font-style: italic'); expect(sanitized).toContain('font-weight: bold'); expect(sanitized).not.toContain('animation: slidein 3s'); }); diff --git a/src/translations/da.ts b/src/translations/da.ts index 44985d86ce..e1e423e516 100644 --- a/src/translations/da.ts +++ b/src/translations/da.ts @@ -56,6 +56,12 @@ Det kan afsløre for afsenderen, at du har åbnet beskeden, hvornår du åbnede Du kan fortsætte med at blokere billeder (e-mailen kan se ufuldstændig ud) eller indlæse dem, hvis du stoler på afsenderen.`, 'file-viewer.email.remote-images.load': 'Indlæs billeder', + 'file-viewer.email.surface.toggle': 'Svært at læse?', + 'file-viewer.email.surface.toggle.tooltip.label': + 'Klik for højere kontrast', + 'file-viewer.email.surface.toggle.tooltip.helper': `Nogle e-mails har deres egne hårdkodede tekstfarver – nogle gange meget lyse, nogle gange meget mørke. +Afhængigt af om appen er i lys eller mørk tilstand kan teksten være svær at læse. +Klik her for at vende e-mailens baggrund til den modsatte tone og forbedre kontrasten.`, 'editor-menu.bold': 'Fed', 'editor-menu.italic': 'Kursiv', 'editor-menu.strikethrough': 'Gennemstreget', diff --git a/src/translations/de.ts b/src/translations/de.ts index 4a903cb21f..53d3f98912 100644 --- a/src/translations/de.ts +++ b/src/translations/de.ts @@ -56,6 +56,12 @@ Dadurch kann der Absender erfahren, dass Sie die Nachricht geöffnet haben, wann Sie können Bilder weiterhin blockieren (die E-Mail kann unvollständig aussehen) oder sie laden, wenn Sie dem Absender vertrauen.`, 'file-viewer.email.remote-images.load': 'Bilder laden', + 'file-viewer.email.surface.toggle': 'Schwer lesbar?', + 'file-viewer.email.surface.toggle.tooltip.label': + 'Für mehr Kontrast klicken', + 'file-viewer.email.surface.toggle.tooltip.helper': `Manche E-Mails enthalten fest einprogrammierte Textfarben – manchmal sehr hell, manchmal sehr dunkel. +Je nachdem, ob die App im hellen oder dunklen Modus ist, kann der Text schwer lesbar werden. +Klicken Sie hier, um den Hintergrund der E-Mail in den gegenteiligen Ton umzuschalten und den Kontrast zu verbessern.`, 'editor-menu.bold': 'Fett', 'editor-menu.italic': 'Kursiv', 'editor-menu.strikethrough': 'Durchgestrichen', diff --git a/src/translations/en.ts b/src/translations/en.ts index ffdee04af6..873792fb67 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -55,6 +55,12 @@ This may reveal to the sender that you opened the message, when you opened it, a You can keep images blocked (the email may look incomplete), or load them if you trust the sender.`, 'file-viewer.email.remote-images.load': 'Load images', + 'file-viewer.email.surface.toggle': 'Hard to read?', + 'file-viewer.email.surface.toggle.tooltip.label': + 'Click to get higher contrast', + 'file-viewer.email.surface.toggle.tooltip.helper': `Some emails arrive with hardcoded text colors — sometimes very light, sometimes very dark. +Depending on whether the app is in light or dark mode, that text can be hard to read. +Click here to flip the email's background to the opposite tone and improve contrast.`, 'editor-menu.bold': 'Bold', 'editor-menu.italic': 'Italic', 'editor-menu.strikethrough': 'Strikethrough', diff --git a/src/translations/fi.ts b/src/translations/fi.ts index 3c95a2d4fc..1b42ae903b 100644 --- a/src/translations/fi.ts +++ b/src/translations/fi.ts @@ -56,6 +56,12 @@ Tämä voi paljastaa lähettäjälle, että avasit viestin, milloin avasit sen, Voit pitää kuvat estettyinä (sähköposti voi näyttää puutteelliselta) tai ladata ne, jos luotat lähettäjään.`, 'file-viewer.email.remote-images.load': 'Lataa kuvat', + 'file-viewer.email.surface.toggle': 'Vaikea lukea?', + 'file-viewer.email.surface.toggle.tooltip.label': + 'Klikkaa parantaaksesi kontrastia', + 'file-viewer.email.surface.toggle.tooltip.helper': `Joissakin sähköposteissa on omat kovakoodatut tekstivärit – joskus erittäin vaaleat, joskus erittäin tummat. +Sen mukaan, onko sovellus vaaleassa vai tummassa tilassa, teksti voi olla vaikealukuista. +Klikkaa tästä vaihtaaksesi sähköpostin taustan vastakkaiseen sävyyn ja parantaaksesi kontrastia.`, 'editor-menu.bold': 'Lihavoitu', 'editor-menu.italic': 'Kursivoitu', 'editor-menu.strikethrough': 'Yliviivaus', diff --git a/src/translations/fr.ts b/src/translations/fr.ts index a32caa7c54..fcf721e87d 100644 --- a/src/translations/fr.ts +++ b/src/translations/fr.ts @@ -56,6 +56,12 @@ Cela peut révéler à l'expéditeur que vous avez ouvert le message, quand vous Vous pouvez laisser les images bloquées (l'e-mail peut sembler incomplet) ou les charger si vous faites confiance à l'expéditeur.`, 'file-viewer.email.remote-images.load': 'Charger les images', + 'file-viewer.email.surface.toggle': 'Difficile à lire ?', + 'file-viewer.email.surface.toggle.tooltip.label': + 'Cliquez pour augmenter le contraste', + 'file-viewer.email.surface.toggle.tooltip.helper': `Certains e-mails contiennent des couleurs de texte codées en dur – parfois très claires, parfois très sombres. +Selon que l'application est en mode clair ou sombre, ce texte peut devenir difficile à lire. +Cliquez ici pour inverser le fond de l'e-mail et améliorer le contraste.`, 'editor-menu.bold': 'Gras', 'editor-menu.italic': 'Italique', 'editor-menu.strikethrough': 'Barré', diff --git a/src/translations/nl.ts b/src/translations/nl.ts index e09e8a7546..7653e262f8 100644 --- a/src/translations/nl.ts +++ b/src/translations/nl.ts @@ -55,6 +55,12 @@ Dit kan aan de afzender onthullen dat je het bericht hebt geopend, wanneer je he Je kunt afbeeldingen geblokkeerd houden (de e-mail kan er onvolledig uitzien) of ze laden als je de afzender vertrouwt.`, 'file-viewer.email.remote-images.load': 'Afbeeldingen laden', + 'file-viewer.email.surface.toggle': 'Moeilijk te lezen?', + 'file-viewer.email.surface.toggle.tooltip.label': + 'Klik voor hoger contrast', + 'file-viewer.email.surface.toggle.tooltip.helper': `Sommige e-mails bevatten hun eigen vaste tekstkleuren – soms zeer licht, soms zeer donker. +Afhankelijk van of de app in lichte of donkere modus staat, kan die tekst moeilijk leesbaar zijn. +Klik hier om de achtergrond van de e-mail naar de tegenovergestelde tint om te schakelen en het contrast te verbeteren.`, 'editor-menu.bold': 'Vet', 'editor-menu.italic': 'Cursief', 'editor-menu.strikethrough': 'Doorhalen', diff --git a/src/translations/no.ts b/src/translations/no.ts index 6f328a4f30..4a6f839414 100644 --- a/src/translations/no.ts +++ b/src/translations/no.ts @@ -55,6 +55,12 @@ Dette kan avsløre for avsenderen at du åpnet meldingen, når du åpnet den, og Du kan fortsette å blokkere bilder (e-posten kan se ufullstendig ut), eller laste dem inn hvis du stoler på avsenderen.`, 'file-viewer.email.remote-images.load': 'Last inn bilder', + 'file-viewer.email.surface.toggle': 'Vanskelig å lese?', + 'file-viewer.email.surface.toggle.tooltip.label': + 'Klikk for høyere kontrast', + 'file-viewer.email.surface.toggle.tooltip.helper': `Noen e-poster har sine egne hardkodede tekstfarger – noen ganger svært lyse, noen ganger svært mørke. +Avhengig av om appen er i lyst eller mørkt modus, kan teksten bli vanskelig å lese. +Klikk her for å bytte e-postens bakgrunn til motsatt tone og forbedre kontrasten.`, 'editor-menu.bold': 'Fet', 'editor-menu.italic': 'Kursiv', 'editor-menu.strikethrough': 'Gjennomstreking', diff --git a/src/translations/sv.ts b/src/translations/sv.ts index 96fce8337e..faba02cb57 100644 --- a/src/translations/sv.ts +++ b/src/translations/sv.ts @@ -56,6 +56,12 @@ Det kan avslöja för avsändaren att du öppnade meddelandet, när du öppnade Du kan fortsätta blockera bilder (e-posten kan se ofullständig ut) eller ladda dem om du litar på avsändaren.`, 'file-viewer.email.remote-images.load': 'Ladda bilder', + 'file-viewer.email.surface.toggle': 'Svår att läsa?', + 'file-viewer.email.surface.toggle.tooltip.label': + 'Klicka för högre kontrast', + 'file-viewer.email.surface.toggle.tooltip.helper': `Vissa e-postmeddelanden har egna fasta textfärger – ibland mycket ljusa, ibland mycket mörka. +Beroende på om appen är i ljust eller mörkt läge kan texten bli svår att läsa. +Klicka här för att vända e-postens bakgrund till motsatt ton och förbättra kontrasten.`, 'editor-menu.bold': 'Fet', 'editor-menu.italic': 'Kursiv', 'editor-menu.strikethrough': 'Genomstruken',