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) <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-28 20:10:18 +02:00
co-authored by Claude Opus 4.7
parent ab6dc89665
commit 44d02afc46
4 changed files with 72 additions and 50 deletions
+22
View File
@@ -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 `<img>` 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
+47 -4
View File
@@ -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<EmailDetailScreen> {
late final Future<(Email?, EmailBody)> _dataFuture;
bool _isFlagged = false;
bool _loadRemoteImages = false;
final Set<String> _downloading = {};
@override
@@ -147,6 +149,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
}
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<EmailDetailScreen> {
_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<EmailDetailScreen> {
if (context.mounted) context.pop();
}
}
/// Replaces `<img>` 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<String> 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());
}
-46
View File
@@ -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.
+3
View File
@@ -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