fix: drop a leading style sheet and padding from message previews (bulwarkmail/webmail#1136)

This commit is contained in:
Linus Rath
2026-10-31 04:42:37 +01:00
parent 04a962a69e
commit 219aef046a
5 changed files with 182 additions and 3 deletions
+2 -1
View File
@@ -10,6 +10,7 @@ import SenderAvatar from '../SenderAvatar';
import { MessageContent, type MessageContentProps } from './MessageContent';
import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date';
import { singleLine } from '../../lib/single-line';
import { cleanPreview } from '../../lib/preview-text';
import { useSenderWarning } from './use-sender-warning';
interface Props extends MessageContentProps {
@@ -58,7 +59,7 @@ export function ThreadMessageCard({
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
</View>
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(email.preview)}</Text>
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(cleanPreview(email.preview))}</Text>
</View>
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
</Pressable>
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { cleanPreview, stripEmphasisMarkers, stripLeadingCss } from '../preview-text';
// Copied from the webmail (lib/__tests__/preview-leading-css.test.ts), plus
// the check that the list rows use it (#1136).
// Some servers build a message preview from its HTML without skipping the
// <style> element, so a marketing mail's preview starts with its style sheet.
describe('stripLeadingCss', () => {
it('drops a leading media query and keeps the text after it', () => {
const preview = '@media screen and (min-width:600px){.hide{display:none!important;overflow:hidden!important}.show{display:block!important}} Your parcel is on its way';
expect(stripLeadingCss(preview)).toBe('Your parcel is on its way');
});
it('drops several leading rules in a row', () => {
expect(stripLeadingCss('.a{color:red} #b p{margin:0} td, th {padding:0}Hello')).toBe('Hello');
});
it('drops a leading @import that ends without a block', () => {
expect(stripLeadingCss("@import url('x.css'); Weekly digest")).toBe('Weekly digest');
});
it('empties a preview that ends inside the style sheet', () => {
expect(stripLeadingCss('@media screen and (min-width:600px){.hide{display:none!important;max-height:0!import')).toBe('');
});
it('leaves ordinary text alone, braces and all', () => {
for (const text of [
'Hi Luca, the meeting is at {time} tomorrow',
'#1234 has been shipped',
'@Luca can you check the figures?',
'Order {A-17} confirmed',
'Price {EUR}: 10 per box',
'Meeting at {room',
'@media team, the brief is attached',
'@Page can you check?',
'@import the CSV, then send it back',
'Reminder: your appointment {date: 2026-10-01} is confirmed',
]) expect(stripLeadingCss(text)).toBe(text);
});
});
describe('cleanPreview', () => {
it('strips padding and a style sheet together, and treats nothing left as no preview', () => {
expect(cleanPreview('\u034f \u200b.x{color:red} Real text')).toBe('Real text');
expect(cleanPreview('.x{color:red} ...')).toBe('');
expect(cleanPreview(null)).toBe('');
});
});
// The preview comes from the text/plain part, which marks bold with asterisks
// where the HTML part uses <strong> (#1260).
describe('stripEmphasisMarkers', () => {
it('drops emphasis markers around words', () => {
expect(cleanPreview('*@Claude* mentioned you: please review')).toBe('@Claude mentioned you: please review');
expect(stripEmphasisMarkers('This is **really** important.')).toBe('This is really important.');
expect(stripEmphasisMarkers('See _the docs_ and __this__, (*now*)')).toBe('See the docs and this, (now)');
});
it('leaves asterisks and underscores inside words or between spaces alone', () => {
for (const text of [
'2 * 3 * 4 = 24',
'a*b*c and snake_case_name',
'rating: * * *',
'my_var_ is set',
'footnote* and *',
'***',
'__init__.py changed',
]) expect(stripEmphasisMarkers(text)).toBe(text);
});
});
describe('stripLeadingCss pseudo-classes', () => {
it('still drops a rule whose selector has a pseudo-class', () => {
expect(stripLeadingCss('a:hover, a:focus{color:red} Hello')).toBe('Hello');
});
});
describe('list rows', () => {
it('show the cleaned preview', () => {
const read = (p: string) => fs.readFileSync(path.resolve(__dirname, '../..', p), 'utf8');
expect(read('screens/EmailListScreen.tsx')).toMatch(/singleLine\(cleanPreview\(item\.preview\)\)/);
expect(read('screens/UnifiedInboxScreen.tsx')).toMatch(/singleLine\(cleanPreview\(item\.preview\)\)/);
expect(read('components/email/ThreadMessageCard.tsx')).toMatch(/singleLine\(cleanPreview\(email\.preview\)\)/);
});
});
+88
View File
@@ -0,0 +1,88 @@
// Message previews ready to show (webmail lib/utils.ts cleanPreview, #1136,
// #1260): some servers build the preview from an HTML body without skipping
// its <style>, so a marketing mail's preview began with CSS; preheaders pad
// with invisible characters; text/plain emphasis markers ("*bold*") show.
// Copied from the webmail as it is.
// Marketing emails pad the preheader with whitespace, format chars (soft
// hyphens, zero-width chars, BOM, directional marks) and combining marks
// (e.g. U+034F) to push real content past the preview window. Strip them all.
// \p{Cf} = Format, \p{Mn} = combining marks; \s covers figure space, NBSP, etc.
const LEADING_INVISIBLE_RE = /^[\s\p{Cf}\p{Mn}]+/u;
// After stripping, a server-side truncation indicator like "..." may be all
// that's left. Treat that as no preview so callers can fall back.
const ONLY_PUNCTUATION_RE = /^[.\u2026\s]+$/;
export function stripInvisibleLeading(text: string): string {
const stripped = text.replace(LEADING_INVISIBLE_RE, '');
if (ONLY_PUNCTUATION_RE.test(stripped)) return '';
return stripped;
}
// A style sheet at the start of the text: an at-rule, or a selector opening a
// rule. Plain prose does not start like this - "@" or "#" followed by a word
// and then, before any sentence ends, a "{".
const DECLARATIONS_RE = /^\s*(?:[-\w]+\s*:|[.#@a-z*][^{}]*\{)/i;
// At-rule names are matched case-sensitively: style sheets write them in lower
// case, while "@Page …" at the start of a preview is a mention. A selector's
// colon is a pseudo-class ("a:hover"), never followed by a space as in
// "Reminder: your appointment {date: …}".
const AT_RULE_RE = /^@(?:media|font-face|import|supports|keyframes|charset|page)\b/;
const SELECTOR_RE = /^[.#]?[a-z*](?:[\w\-.#,>~+*\s[\]="']|:(?!\s))*\{/i;
/**
* Drops a style sheet from the start of a text preview.
*
* Some servers build the preview from an HTML body without skipping its
* <style> element, so a marketing mail's preview can begin with
* "@media screen and (min-width:600px){.hide{display:none!important;…" and
* never reach the text. Whole leading rules are removed; a preview that ends
* inside one was nothing but CSS and becomes empty, so callers fall back to
* their "no preview" text.
*/
export function stripLeadingCss(text: string): string {
let rest = text.trimStart();
for (let guard = 0; guard < 100 && (AT_RULE_RE.test(rest) || SELECTOR_RE.test(rest)); guard++) {
const brace = rest.indexOf('{');
const semicolon = rest.indexOf(';');
// `@import url(...);` and `@charset "x";` end without a block.
if (semicolon >= 0 && (brace < 0 || semicolon < brace) && /^@(?:import|charset)\b/.test(rest)) {
rest = rest.slice(semicolon + 1).trimStart();
continue;
}
// An at-rule word with no block after it ("@media team, …", "@page 3 …")
// starts prose, not a style sheet.
if (brace < 0) return rest;
let depth = 0;
let end = -1;
for (let i = brace; i < rest.length; i++) {
if (rest[i] === '{') depth++;
else if (rest[i] === '}' && --depth === 0) { end = i; break; }
}
if (end < 0) return rest.startsWith('@') || DECLARATIONS_RE.test(rest.slice(brace + 1)) ? '' : rest;
// A rule's block holds declarations ("color: red") or further rules; a
// brace in prose ("at {time} tomorrow") holds neither and ends the strip.
if (!rest.startsWith('@') && !DECLARATIONS_RE.test(rest.slice(brace + 1, end))) return rest;
rest = rest.slice(end + 1).trimStart();
}
return rest;
}
// Plain-text emphasis: "*bold*", "**bold**", "_italic_", "__bold__". The
// markers sit at word edges, so "2 * 3 * 4", "a*b*c", "snake_case_name" and
// "__init__.py" are left alone.
const EMPHASIS_RE = /(^|[\s([{"'])(\*\*|__|\*|_)([^\s*_]|[^\s*_].*?[^\s])\2(?=$|[\s)\]}"']|[.,;:!?](?!\w))/g;
/**
* Drops plain-text emphasis markers. The server builds the preview from the
* text/plain part, where senders like GitHub write "*@user* mentioned you"
* while the HTML part shows it in bold.
*/
export function stripEmphasisMarkers(text: string): string {
return text.replace(EMPHASIS_RE, '$1$3');
}
/** A message preview ready to show: no leading padding, style sheet or emphasis markers. */
export function cleanPreview(text: string | null | undefined): string {
return stripEmphasisMarkers(stripInvisibleLeading(stripLeadingCss(stripInvisibleLeading(text ?? ''))));
}
+2 -1
View File
@@ -51,6 +51,7 @@ import { toast } from '../stores/toast-store';
import { getContactDisplayName } from '../lib/contact-utils';
import { formatListDate } from '../lib/date-format';
import { singleLine } from '../lib/single-line';
import { cleanPreview } from '../lib/preview-text';
import {
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
} from '../lib/mailbox-tree';
@@ -304,7 +305,7 @@ const EmailRow = React.memo(function EmailRow({
{/* Row 3: Preview - hidden in compact density modes regardless of toggle */}
{showPreview && density.showPreview && (
<Text style={[styles.emailPreview, dyn.body]} numberOfLines={2}>
{singleLine(item.preview)}
{singleLine(cleanPreview(item.preview))}
</Text>
)}
{verificationCode && <VerificationCodeChip code={verificationCode} disabled={selectionMode} inList />}
+2 -1
View File
@@ -23,6 +23,7 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
import { useLocaleStore } from '../stores/locale-store';
import { formatListDate } from '../lib/date-format';
import { singleLine } from '../lib/single-line';
import { cleanPreview } from '../lib/preview-text';
import { prefetchMessage, rememberRows } from '../lib/email-detail-cache';
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
@@ -396,7 +397,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
</View>
{/* Same settings as the folder list: preview on/off, density, text size. */}
{item.preview && showPreview && density.showPreview ? (
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>{singleLine(item.preview)}</Text>
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>{singleLine(cleanPreview(item.preview))}</Text>
) : null}
</View>
</Pressable>