From 44d02afc469508d770e6b09fb8f3351a1246dd9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Tue, 28 Apr 2026 20:10:18 +0200 Subject: [PATCH] feat: render HTML email bodies via flutter_html Email detail screen now renders the message htmlBody with the flutter_html widget when present, falling back to SelectableText for plain-text-only mail. Remote http(s) images are blocked by default to defeat tracking pixels; an opt-in "Load remote images" button reveals them per-screen. Co-Authored-By: Claude Opus 4.7 (1M context) --- done.md | 22 +++++++++++ lib/ui/screens/email_detail_screen.dart | 51 +++++++++++++++++++++++-- next.md | 46 ---------------------- pubspec.yaml | 3 ++ 4 files changed, 72 insertions(+), 50 deletions(-) diff --git a/done.md b/done.md index 22ca256..2ed31c4 100644 --- a/done.md +++ b/done.md @@ -6,6 +6,28 @@ Tasks get moved from next.md to done.md ## Tasks +## Render HTML email bodies + +`lib/ui/screens/email_detail_screen.dart` now renders the message's +`htmlBody` with the `flutter_html` widget instead of stripping tags via +`htmlToPlain`. Plain-text-only messages still render through +`SelectableText` (no HTML widget instantiated when `htmlBody` is empty). + +Added `flutter_html: ^3.0.0` to `pubspec.yaml`. + +Remote (`http(s)`) images are blocked by default — defeats tracking +pixels. A small "Load remote images" button appears at the top of an +HTML body and flips a per-screen flag to re-render with images. Inline +`cid:` and `data:` images fall through to the default handler. Blocking +is implemented via a small `HtmlExtension` subclass +(`_BlockRemoteImagesExtension`) that matches `` whose `src` starts +with `http://` or `https://` and renders `SizedBox.shrink()`. + +`htmlToPlain` is kept — it's still used by `_quotedBody` for reply / +forward quoting where plain text is correct. + +No DB schema, no codegen, no migrations. + ## SMTP TLS enabled by default for new accounts User report: when creating a new Account, the SMTP SSL/TLS toggle is off by diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 2738f70..64040f1 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; @@ -24,6 +25,7 @@ class EmailDetailScreen extends ConsumerStatefulWidget { class _EmailDetailScreenState extends ConsumerState { late final Future<(Email?, EmailBody)> _dataFuture; bool _isFlagged = false; + bool _loadRemoteImages = false; final Set _downloading = {}; @override @@ -147,6 +149,7 @@ class _EmailDetailScreenState extends ConsumerState { } Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) { + final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty; return ListView( padding: const EdgeInsets.all(16), children: [ @@ -154,10 +157,30 @@ class _EmailDetailScreenState extends ConsumerState { _buildHeader(ctx, header), const Divider(), ], - SelectableText( - body.textBody ?? htmlToPlain(body.htmlBody ?? ''), - style: Theme.of(ctx).textTheme.bodyMedium, - ), + if (hasHtml) ...[ + if (!_loadRemoteImages) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: OutlinedButton.icon( + icon: const Icon(Icons.image_outlined, size: 18), + label: const Text('Load remote images'), + onPressed: () => setState(() => _loadRemoteImages = true), + ), + ), + ), + Html( + data: body.htmlBody!, + extensions: [ + if (!_loadRemoteImages) _BlockRemoteImagesExtension(), + ], + ), + ] else + SelectableText( + body.textBody ?? '', + style: Theme.of(ctx).textTheme.bodyMedium, + ), if (body.attachments.isNotEmpty) ...[ const Divider(), Padding( @@ -322,3 +345,23 @@ class _EmailDetailScreenState extends ConsumerState { if (context.mounted) context.pop(); } } + +/// Replaces `` tags whose src is an absolute http(s) URL with an empty +/// widget. Defeats tracking pixels and remote-image loading until the user +/// explicitly opts in. Inline `cid:` and `data:` images fall through and are +/// rendered by the default handler. +class _BlockRemoteImagesExtension extends HtmlExtension { + @override + Set get supportedTags => {'img'}; + + @override + bool matches(ExtensionContext context) { + if (context.elementName != 'img') return false; + final src = context.attributes['src'] ?? ''; + return src.startsWith('http://') || src.startsWith('https://'); + } + + @override + InlineSpan build(ExtensionContext context) => + const WidgetSpan(child: SizedBox.shrink()); +} diff --git a/next.md b/next.md index 31eab1b..470f762 100644 --- a/next.md +++ b/next.md @@ -17,49 +17,3 @@ Git repo should not contain unknown files. Then commit. ## Tasks - -### Render HTML email bodies - -#### Current state - -HTML mail *reading* (transport + storage) is already implemented: the IMAP -fetcher stores the HTML body on `EmailBody.htmlBody` -(`lib/core/models/email.dart:89`). What is missing is HTML *rendering*: -`lib/ui/screens/email_detail_screen.dart:158` currently strips tags via -`htmlToPlain(body.htmlBody ?? '')` and shows the result as plain text. - -#### Plan - -1. Add `flutter_html: ^3.0.0` to `pubspec.yaml`. - - Mature, pure-Dart (no JS / no platform views), works on desktop + - mobile + web. -2. Update `lib/ui/screens/email_detail_screen.dart` `_buildBody`: - - Prefer `body.htmlBody` over `body.textBody`. - - If `htmlBody` is present and non-empty, render with the `Html` widget. - - Else fall back to the current `SelectableText(textBody)` path. -3. Block remote image loading by default (privacy — defeats tracking - pixels). At the top of an HTML message, show a small - "Load remote images" button that flips a per-screen `bool` and - re-renders. Implement by passing a custom image-loading delegate to - `flutter_html` that returns an empty widget for `http(s)` images - until the flag is set. Inline `cid:` images are out of scope for - this task. -4. Leave `htmlToPlain` in place — it is still used for reply / forward - quoting (`_quotedBody`) where plain text is correct. -5. No DB schema changes, no generated-code changes, no migration. -6. No new tests required (pure UI rendering change). Existing widget / - integration tests must keep passing. - -#### Out of scope (follow-ups if needed) - -- Clickable / launchable links inside HTML. -- Dark-mode HTML colour normalisation. -- Sandboxed rendering via `WebView` / `flutter_inappwebview`. -- Inline `cid:` image resolution. - -#### Verification - -- `task deploy-android` succeeds. -- Manual: open an HTML newsletter — formatting (lists, bold, links) is - visible instead of raw tags or stripped text. -- Manual: a plain-text-only email still renders unchanged. diff --git a/pubspec.yaml b/pubspec.yaml index 8eb5407..bb3ee1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,9 @@ dependencies: open_filex: ^4.6.0 mime: ^2.0.0 + # HTML rendering for email bodies + flutter_html: ^3.0.0 + dev_dependencies: flutter_test: sdk: flutter