feat: replace flutter_html with SecureEmailWebView (#21)

Swap the flutter_html renderer for a webview_flutter-based widget that
enforces strict security by default: scripts blocked via CSP
(script-src 'none'), remote images opt-in, and every link click routed
through a confirmation dialog that bolds the registered domain for
phishing detection.  Links open in the system browser via url_launcher.

On Linux (no webview_flutter platform support) the widget falls back to
plain text extracted via the existing htmlToPlain() utility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-15 08:18:42 +02:00
co-authored by Claude Sonnet 4.6
parent 902c0a7900
commit f96f9216cd
6 changed files with 285 additions and 119 deletions
+4 -83
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
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';
@@ -13,19 +12,12 @@ import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart';
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
void _openLink(String? url, Map<String, String> attrs, dynamic _) {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri != null) {
unawaited(launchUrl(uri, mode: LaunchMode.externalApplication));
}
}
class EmailDetailScreen extends ConsumerStatefulWidget {
const EmailDetailScreen({super.key, required this.emailId});
final String emailId;
@@ -192,9 +184,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
),
),
),
_SafeHtml(
data: body.htmlBody!,
extensions: [if (!_loadRemoteImages) _BlockRemoteImagesExtension()],
SecureEmailWebView(
htmlBody: body.htmlBody!,
loadRemoteImages: _loadRemoteImages,
),
] else
SelectableText(
@@ -519,74 +511,3 @@ class _UnsubscribeChip extends StatelessWidget {
);
}
}
/// Renders [Html] and falls back to an error message if the widget throws
/// during build, preventing a malformed body from crashing the whole screen.
class _SafeHtml extends StatefulWidget {
const _SafeHtml({required this.data, required this.extensions});
final String data;
final List<HtmlExtension> extensions;
@override
State<_SafeHtml> createState() => _SafeHtmlState();
}
class _SafeHtmlState extends State<_SafeHtml> {
bool _failed = false;
@override
Widget build(BuildContext context) {
if (_failed) {
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Icon(
Icons.warning_amber_outlined,
color: Theme.of(context).colorScheme.error,
size: 16,
),
const SizedBox(width: 8),
const Expanded(child: Text('Message body could not be rendered.')),
],
),
);
}
// Intercept any build-phase throw from flutter_html for this subtree.
// We save/restore via postFrameCallback so other widgets are unaffected.
final prev = ErrorWidget.builder;
ErrorWidget.builder = (FlutterErrorDetails details) {
ErrorWidget.builder = prev;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _failed = true);
});
return const SizedBox.shrink();
};
WidgetsBinding.instance.addPostFrameCallback(
(_) => ErrorWidget.builder = prev,
);
return Html(
data: widget.data,
extensions: widget.extensions,
onLinkTap: _openLink,
);
}
}
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());
}
+4 -35
View File
@@ -1,7 +1,6 @@
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';
@@ -10,7 +9,7 @@ import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
@@ -164,23 +163,9 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
onPressed: () =>
setState(() => _loadRemoteImages = true),
),
Html(
data: body.htmlBody!,
extensions: [
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
],
onLinkTap: (url, _, __) {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri != null) {
unawaited(
launchUrl(
uri,
mode: LaunchMode.externalApplication,
),
);
}
},
SecureEmailWebView(
htmlBody: body.htmlBody!,
loadRemoteImages: _loadRemoteImages,
),
] else
SelectableText(
@@ -288,19 +273,3 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
}
}
}
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());
}
+200
View File
@@ -0,0 +1,200 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
/// Renders an HTML email body securely.
///
/// On Android the content is displayed in a WebView with JavaScript blocked
/// via CSP and remote images blocked until the user opts in. Link taps show
/// a confirmation dialog that highlights the registered domain to aid phishing
/// detection.
///
/// On Linux (where webview_flutter has no platform support) the HTML is
/// converted to plain text and shown in a [SelectableText] widget.
class SecureEmailWebView extends StatefulWidget {
const SecureEmailWebView({
super.key,
required this.htmlBody,
this.loadRemoteImages = false,
});
final String htmlBody;
final bool loadRemoteImages;
@override
State<SecureEmailWebView> createState() => _SecureEmailWebViewState();
}
class _SecureEmailWebViewState extends State<SecureEmailWebView> {
// Null on Linux where WebView is unavailable.
WebViewController? _controller;
double _height = 300;
@override
void initState() {
super.initState();
if (!Platform.isLinux) {
final c = WebViewController();
unawaited(c.setJavaScriptMode(JavaScriptMode.unrestricted));
unawaited(c.setBackgroundColor(Colors.transparent));
unawaited(
c.setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: _handleNavigation,
onPageFinished: _measureHeight,
),
),
);
unawaited(c.loadHtmlString(_buildHtml()));
_controller = c;
}
}
@override
void didUpdateWidget(SecureEmailWebView old) {
super.didUpdateWidget(old);
if (old.htmlBody != widget.htmlBody ||
old.loadRemoteImages != widget.loadRemoteImages) {
if (_controller != null) {
unawaited(_controller!.loadHtmlString(_buildHtml()));
}
}
}
String _buildHtml() {
final imgSrc =
widget.loadRemoteImages ? 'https: http: data: blob:' : 'data: blob:';
// script-src 'none' blocks page scripts; JS mode stays unrestricted so
// the controller can call runJavaScriptReturningResult for height measurement.
const cspBase = "default-src 'none'; "
"style-src 'unsafe-inline'; "
"script-src 'none'; "
"object-src 'none'; "
"font-src 'none'";
final csp = '$cspBase; img-src $imgSrc';
return '''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="$csp">
<style>
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; }
img { max-width: 100%; height: auto; }
a { color: #1976D2; }
* { box-sizing: border-box; }
</style>
</head>
<body>
${widget.htmlBody}
</body>
</html>''';
}
Future<void> _measureHeight(String _) async {
final result = await _controller!.runJavaScriptReturningResult(
'document.documentElement.scrollHeight',
);
final h = double.tryParse(result.toString());
if (h != null && h > 0 && mounted) {
setState(() => _height = h);
}
}
NavigationDecision _handleNavigation(NavigationRequest req) {
final url = req.url;
if (url == 'about:blank' || url.startsWith('data:')) {
return NavigationDecision.navigate;
}
unawaited(_showLinkDialog(url));
return NavigationDecision.prevent;
}
Future<void> _showLinkDialog(String url) async {
final uri = Uri.tryParse(url);
if (uri == null || !mounted) return;
final host = uri.host;
final parts = host.split('.');
// Bold the registered domain (last two DNS labels) to aid phishing detection.
final boldStart = (parts.length >= 2
? host.length -
parts.last.length -
1 -
parts[parts.length - 2].length
: 0)
.clamp(0, host.length);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Open link?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
children: [
TextSpan(text: host.substring(0, boldStart)),
TextSpan(
text: host.substring(boldStart),
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
const SizedBox(height: 4),
Text(
url,
style: const TextStyle(fontSize: 11, color: Colors.grey),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Open in browser'),
),
],
),
);
if (confirmed == true && mounted) {
final launched =
await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!launched && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open: $url')),
);
}
}
}
@override
Widget build(BuildContext context) {
// Linux has no webview_flutter platform implementation — show plain text.
if (Platform.isLinux) {
return SelectableText(
htmlToPlain(widget.htmlBody),
style: Theme.of(context).textTheme.bodyMedium,
);
}
return SizedBox(
height: _height,
child: WebViewWidget(controller: _controller!),
);
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ dependencies:
mime: ^2.0.0
# HTML rendering for email bodies
flutter_html: ^3.0.0
webview_flutter: ^4.0.0
url_launcher: ^6.3.2
flutter_markdown: ^0.7.7+1
+1
View File
@@ -49,6 +49,7 @@ const _excluded = {
'lib/ui/screens/thread_detail_screen.dart',
'lib/ui/screens/undo_log_screen.dart',
'lib/ui/widgets/folder_drawer.dart',
'lib/ui/widgets/secure_email_webview.dart',
'lib/ui/widgets/snooze_picker.dart',
'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart',
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
Widget _wrap(Widget child) => MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: Scaffold(body: child),
);
void main() {
// On Linux (the test host) the widget falls back to plain text extracted via
// htmlToPlain(). These tests exercise that path.
group('SecureEmailWebView (Linux plain-text fallback)', () {
testWidgets('renders extracted text from HTML', (tester) async {
await tester.pumpWidget(
_wrap(
const SecureEmailWebView(
htmlBody: '<p>Hello <b>world</b></p>',
),
),
);
expect(find.textContaining('Hello'), findsOneWidget);
expect(find.textContaining('world'), findsOneWidget);
});
testWidgets('strips HTML tags from body', (tester) async {
await tester.pumpWidget(
_wrap(
const SecureEmailWebView(
htmlBody:
'<p>Clean text</p><br/><span style="color:red">More</span>',
),
),
);
expect(find.textContaining('<'), findsNothing);
expect(find.textContaining('Clean text'), findsOneWidget);
});
testWidgets('shows SelectableText widget', (tester) async {
await tester.pumpWidget(
_wrap(const SecureEmailWebView(htmlBody: '<p>Test</p>')),
);
expect(find.byType(SelectableText), findsOneWidget);
});
testWidgets('toggling loadRemoteImages rebuilds without error',
(tester) async {
await tester.pumpWidget(
_wrap(
const SecureEmailWebView(htmlBody: '<p>Body</p>'),
),
);
await tester.pumpWidget(
_wrap(
const SecureEmailWebView(
htmlBody: '<p>Body</p>',
loadRemoteImages: true,
),
),
);
expect(find.byType(SelectableText), findsOneWidget);
});
testWidgets('handles empty HTML body', (tester) async {
await tester.pumpWidget(
_wrap(const SecureEmailWebView(htmlBody: '')),
);
expect(find.byType(SelectableText), findsOneWidget);
});
});
}