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'; /// Builds the full HTML document string for rendering an email body. /// /// Forces `color-scheme: light` so that emails with black text remain readable /// when the device is in dark mode — the WebView would otherwise apply a dark /// background while leaving the email's own text colours unchanged. @visibleForTesting String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) { final imgSrc = 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 ''' $htmlBody '''; } /// 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 createState() => _SecureEmailWebViewState(); } class _SecureEmailWebViewState extends State { // 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() => buildEmailHtml( widget.htmlBody, loadRemoteImages: widget.loadRemoteImages, ); Future _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 _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( 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!), ); } }