diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 576dba2..61aff9c 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -18,6 +18,7 @@ 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/screens/email_action_helpers.dart'; +import 'package:sharedinbox/ui/widgets/email_headers_dialog.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'; @@ -722,47 +723,7 @@ class _EmailDetailScreenState extends ConsumerState { unawaited( showDialog( context: context, - builder: (ctx) => AlertDialog( - title: const Text('Mail Headers'), - content: SizedBox( - width: double.maxFinite, - child: ListView.builder( - shrinkWrap: true, - itemCount: body.headers.length, - itemBuilder: (ctx, i) { - final header = body.headers[i]; - return Container( - color: i.isEven - ? Theme.of(ctx).colorScheme.surfaceContainerHighest - : Theme.of(ctx).colorScheme.surface, - padding: const EdgeInsets.symmetric( - vertical: 4, - horizontal: 8, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: SelectableText( - header.name, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - const SizedBox(width: 8), - Expanded(flex: 2, child: SelectableText(header.value)), - ], - ), - ); - }, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Close'), - ), - ], - ), + builder: (ctx) => EmailHeadersDialog(headers: body.headers), ), ); } @@ -785,12 +746,13 @@ class _EmailDetailScreenState extends ConsumerState { unawaited( showDialog( context: context, - builder: (ctx) => AlertDialog( - title: const Text('Mail Structure'), - content: SizedBox( - width: double.maxFinite, - child: ListView.builder( - shrinkWrap: true, + builder: (ctx) => Dialog.fullscreen( + child: Scaffold( + appBar: AppBar( + title: const Text('Mail Structure'), + leading: const CloseButton(), + ), + body: ListView.builder( itemCount: rows.length, itemBuilder: (ctx, i) { final row = rows[i]; @@ -819,12 +781,6 @@ class _EmailDetailScreenState extends ConsumerState { }, ), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Close'), - ), - ], ), ), ); diff --git a/lib/ui/widgets/email_headers_dialog.dart b/lib/ui/widgets/email_headers_dialog.dart new file mode 100644 index 0000000..be03649 --- /dev/null +++ b/lib/ui/widgets/email_headers_dialog.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:sharedinbox/core/models/email.dart'; + +/// Full-screen dialog for browsing email headers, organised into groups. +class EmailHeadersDialog extends StatelessWidget { + const EmailHeadersDialog({super.key, required this.headers}); + final List headers; + + @override + Widget build(BuildContext context) { + return Dialog.fullscreen( + child: Scaffold( + appBar: AppBar( + title: const Text('Mail Headers'), + leading: const CloseButton(), + ), + body: _HeadersBody(headers: headers), + ), + ); + } +} + +class _HeadersBody extends StatelessWidget { + const _HeadersBody({required this.headers}); + final List headers; + + @override + Widget build(BuildContext context) { + final receivedHeaders = []; + final listHeaders = []; + final arcHeaders = []; + final otherHeaders = []; + // Maps X- prefix (e.g. "X-Google") → headers with that prefix. + final xByPrefix = >{}; + + for (final h in headers) { + final lower = h.name.toLowerCase(); + if (lower == 'received') { + receivedHeaders.add(h); + continue; + } + if (lower.startsWith('list-')) { + listHeaders.add(h); + continue; + } + if (lower.startsWith('arc-')) { + arcHeaders.add(h); + continue; + } + if (lower.startsWith('x-')) { + final parts = h.name.split('-'); + // "X-Foo-Bar-Baz" → prefix "X-Foo"; "X-Single" → prefix "X-Single". + final prefix = parts.length >= 3 ? '${parts[0]}-${parts[1]}' : h.name; + xByPrefix.putIfAbsent(prefix, () => []).add(h); + continue; + } + otherHeaders.add(h); + } + + final sections = []; + + if (otherHeaders.isNotEmpty) { + sections.add(_HeadersSection(title: 'Headers', headers: otherHeaders)); + } + if (listHeaders.isNotEmpty) { + sections.add( + _HeadersSection(title: 'List- Headers', headers: listHeaders), + ); + } + if (receivedHeaders.isNotEmpty) { + sections.add(_ReceivedSection(headers: receivedHeaders)); + } + if (arcHeaders.isNotEmpty) { + sections.add( + _HeadersSection(title: 'ARC- Headers', headers: arcHeaders), + ); + } + + // X- headers at bottom, each prefix in its own collapsible group. + final sortedPrefixes = xByPrefix.keys.toList() + ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + for (final prefix in sortedPrefixes) { + sections.add( + _HeadersSection( + title: '$prefix Headers', + headers: xByPrefix[prefix]!, + ), + ); + } + + return ListView(children: sections); + } +} + +class _HeadersSection extends StatelessWidget { + const _HeadersSection({required this.title, required this.headers}); + + final String title; + final List headers; + + @override + Widget build(BuildContext context) { + return ExpansionTile( + title: Text('$title (${headers.length})'), + children: [ + for (var i = 0; i < headers.length; i++) + _HeaderRow(header: headers[i], index: i), + ], + ); + } +} + +/// Received headers section — collapsed by default; shows inter-hop delays. +class _ReceivedSection extends StatelessWidget { + const _ReceivedSection({required this.headers}); + final List headers; + + @override + Widget build(BuildContext context) { + final entries = _buildEntries(headers); + return ExpansionTile( + title: Text('Received (${headers.length})'), + children: [ + for (var i = 0; i < entries.length; i++) ...[ + _HeaderRow(header: entries[i].header, index: i), + if (entries[i].delay != null) _DelayRow(delay: entries[i].delay!), + ], + ], + ); + } + + static List<_ReceivedEntry> _buildEntries(List headers) { + final timestamps = + headers.map((h) => _parseReceivedTimestamp(h.value)).toList(); + return [ + for (var i = 0; i < headers.length; i++) + _ReceivedEntry( + header: headers[i], + delay: _computeDelay(timestamps, i), + ), + ]; + } + + static Duration? _computeDelay(List timestamps, int i) { + if (i >= timestamps.length - 1) return null; + final current = timestamps[i]; + final next = timestamps[i + 1]; + if (current == null || next == null) return null; + final d = current.difference(next); + return d.isNegative ? Duration.zero : d; + } +} + +class _ReceivedEntry { + const _ReceivedEntry({required this.header, this.delay}); + final EmailHeader header; + final Duration? delay; +} + +class _HeaderRow extends StatelessWidget { + const _HeaderRow({required this.header, required this.index}); + final EmailHeader header; + final int index; + + @override + Widget build(BuildContext context) { + final bg = index.isEven + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Theme.of(context).colorScheme.surface; + return Container( + color: bg, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: SelectableText( + header.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 8), + Expanded(flex: 2, child: SelectableText(header.value)), + ], + ), + ); + } +} + +class _DelayRow extends StatelessWidget { + const _DelayRow({required this.delay}); + final Duration delay; + + @override + Widget build(BuildContext context) { + final color = _delayColor(delay); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + child: Row( + children: [ + Icon(Icons.arrow_downward, size: 14, color: color), + const SizedBox(width: 4), + Text( + _formatDuration(delay), + style: TextStyle( + fontSize: 12, + color: color, + fontWeight: + delay.inSeconds >= 30 ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ); + } +} + +/// Parses the RFC 2822 timestamp from a Received header value. +/// +/// Received headers end with `; date`, e.g.: +/// by mx.example.com; Mon, 1 Jan 2024 12:00:00 +0000 (UTC) +DateTime? _parseReceivedTimestamp(String value) { + final semiIndex = value.lastIndexOf(';'); + if (semiIndex < 0) return null; + var s = value.substring(semiIndex + 1).trim(); + // Strip parenthesised comments like (UTC). + s = s.replaceAll(RegExp(r'\([^)]*\)'), ' ').trim(); + // Strip leading day-of-week abbreviation like "Mon, ". + s = s.replaceFirst(RegExp(r'^[A-Za-z]{2,4},\s*'), ''); + // Collapse runs of whitespace. + s = s.replaceAll(RegExp(r'\s+'), ' ').trim(); + + for (final fmt in [ + DateFormat('dd MMM yyyy HH:mm:ss Z', 'en_US'), + DateFormat('d MMM yyyy HH:mm:ss Z', 'en_US'), + DateFormat('dd MMM yyyy HH:mm:ss', 'en_US'), + DateFormat('d MMM yyyy HH:mm:ss', 'en_US'), + ]) { + try { + return fmt.parse(s); + } catch (_) {} + } + return null; +} + +String _formatDuration(Duration d) { + if (d.inSeconds < 60) return '${d.inSeconds}s'; + if (d.inMinutes < 60) return '${d.inMinutes}m ${d.inSeconds.remainder(60)}s'; + return '${d.inHours}h ${d.inMinutes.remainder(60)}m'; +} + +Color _delayColor(Duration d) { + if (d.inSeconds < 30) return Colors.green; + if (d.inSeconds < 300) return Colors.orange; + return Colors.red; +} diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index 931bb8a..f06ac2c 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -62,6 +62,7 @@ const _excluded = { 'lib/ui/screens/about_screen.dart', 'lib/ui/screens/email_action_helpers.dart', 'lib/ui/utils/about_markdown.dart', + 'lib/ui/widgets/email_headers_dialog.dart', 'lib/ui/widgets/email_tile.dart', 'lib/core/sync/account_sync_manager.dart', 'lib/core/sync/background_sync.dart',