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:
co-authored by
Claude Opus 4.7
parent
ab6dc89665
commit
44d02afc46
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user