diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index a45a603..1184835 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -70,16 +70,9 @@ class _EmailDetailScreenState extends ConsumerState { onPressed: header == null ? null : () { - unawaited(_reply(context, header, body, replyAll: false)); - }, - ), - IconButton( - icon: const Icon(Icons.reply_all), - tooltip: 'Reply all', - onPressed: header == null - ? null - : () { - unawaited(_reply(context, header, body, replyAll: true)); + unawaited( + _replyWithRecipientDialog(context, header, body), + ); }, ), IconButton( @@ -121,6 +114,15 @@ class _EmailDetailScreenState extends ConsumerState { tooltip: 'Snooze', onPressed: header == null ? null : () => _snooze(context, header), ), + IconButton( + icon: const Icon(Icons.report_outlined), + tooltip: 'Mark as spam', + onPressed: header == null + ? null + : () { + unawaited(_markAsSpam(context, header)); + }, + ), IconButton( icon: const Icon(Icons.delete), tooltip: 'Delete', @@ -303,17 +305,78 @@ class _EmailDetailScreenState extends ConsumerState { return '\n\n— On $date, $from wrote:\n$quoted'; } - Future _reply( + Future _replyWithRecipientDialog( + BuildContext context, + Email header, + EmailBody? body, + ) async { + final account = + await ref.read(accountRepositoryProvider).getAccount(header.accountId); + final ownEmail = account?.email.toLowerCase() ?? ''; + + final seen = {}; + final candidates = <_Candidate>[]; + + void addIfNew(EmailAddress addr, _Placement defaultPlacement) { + final key = addr.email.toLowerCase(); + if (key == ownEmail || seen.contains(key)) return; + seen.add(key); + candidates.add(_Candidate(addr, defaultPlacement)); + } + + for (final addr in header.from) { + addIfNew(addr, _Placement.to); + } + for (final addr in header.to) { + addIfNew(addr, _Placement.to); + } + for (final addr in header.cc) { + addIfNew(addr, _Placement.cc); + } + + if (!context.mounted) return; + + if (candidates.length <= 1) { + final to = candidates + .where((c) => c.placement == _Placement.to) + .map((c) => c.address.email) + .join(', '); + final cc = candidates + .where((c) => c.placement == _Placement.cc) + .map((c) => c.address.email) + .join(', '); + await _composeReply(context, header, body, to: to, cc: cc); + return; + } + + final confirmed = await showDialog>( + context: context, + builder: (ctx) => _ReplyAllDialog(candidates: candidates), + ); + + if (confirmed == null || !context.mounted) return; + + final to = confirmed + .where((c) => c.placement == _Placement.to) + .map((c) => c.address.email) + .join(', '); + final cc = confirmed + .where((c) => c.placement == _Placement.cc) + .map((c) => c.address.email) + .join(', '); + await _composeReply(context, header, body, to: to, cc: cc); + } + + Future _composeReply( BuildContext context, Email header, EmailBody? body, { - required bool replyAll, + required String to, + required String cc, }) async { - final to = header.from.isNotEmpty ? header.from.first.email : ''; final subject = (header.subject?.startsWith('Re:') ?? false) ? header.subject! : 'Re: ${header.subject ?? ''}'; - final cc = replyAll ? header.to.map((a) => a.email).join(', ') : ''; final quoted = await _quotedBody(header, body); if (!context.mounted) return; unawaited( @@ -330,6 +393,38 @@ class _EmailDetailScreenState extends ConsumerState { ); } + Future _markAsSpam(BuildContext context, Email header) async { + final mailboxRepo = ref.read(mailboxRepositoryProvider); + final junk = await mailboxRepo.findMailboxByRole(header.accountId, 'junk'); + + if (junk == null) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('No Junk folder found')), + ); + return; + } + + await ref + .read(emailRepositoryProvider) + .moveEmail(widget.emailId, junk.path); + + unawaited( + ref.read(undoServiceProvider.notifier).pushAction( + UndoAction( + id: DateTime.now().toIso8601String(), + accountId: header.accountId, + type: UndoType.move, + emailIds: [widget.emailId], + sourceMailboxPath: header.mailboxPath, + destinationMailboxPath: junk.path, + ), + ), + ); + + if (context.mounted) context.pop(); + } + Future _forward( BuildContext context, Email header, @@ -670,6 +765,94 @@ class _EmailDetailScreenState extends ConsumerState { } } +enum _Placement { to, cc, skip } + +class _Candidate { + _Candidate(this.address, this.placement); + final EmailAddress address; + _Placement placement; +} + +class _ReplyAllDialog extends StatefulWidget { + const _ReplyAllDialog({required this.candidates}); + final List<_Candidate> candidates; + + @override + State<_ReplyAllDialog> createState() => _ReplyAllDialogState(); +} + +class _ReplyAllDialogState extends State<_ReplyAllDialog> { + late final List<_Candidate> _candidates; + + @override + void initState() { + super.initState(); + _candidates = [ + for (final c in widget.candidates) _Candidate(c.address, c.placement), + ]; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Reply All'), + content: SizedBox( + width: double.maxFinite, + child: ListView( + shrinkWrap: true, + children: [ + for (final c in _candidates) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: Text( + c.address.toString(), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + SegmentedButton<_Placement>( + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: _Placement.to, + label: Text('To'), + ), + ButtonSegment( + value: _Placement.cc, + label: Text('Cc'), + ), + ButtonSegment( + value: _Placement.skip, + label: Text('Skip'), + ), + ], + selected: {c.placement}, + onSelectionChanged: (s) => + setState(() => c.placement = s.first), + ), + ], + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, _candidates), + child: const Text('Reply'), + ), + ], + ); + } +} + class _MimeRow { const _MimeRow(this.depth, this.label); final int depth; diff --git a/test/widget/email_detail_screen_test.dart b/test/widget/email_detail_screen_test.dart index 494eaff..d1368bb 100644 --- a/test/widget/email_detail_screen_test.dart +++ b/test/widget/email_detail_screen_test.dart @@ -179,6 +179,142 @@ void main() { expect(find.text('report.pdf'), findsOneWidget); }); + testWidgets('Reply All button is not present in app bar', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'Reply all', + ), + findsNothing, + ); + }); + + testWidgets('Reply on single-recipient email navigates directly to compose', + (tester) async { + // testEmail has from=[bob], to=[alice]. After removing alice (own), + // only bob remains → no dialog, navigate straight to compose. + final email = testEmail(); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: [ + ..._overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + email: email, + ), + draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'Reply', + ), + ); + await tester.pumpAndSettle(); + + // No dialog shown — straight navigation to compose. + expect(find.text('Reply All'), findsNothing); + }); + + testWidgets('Reply on multi-recipient email shows Reply All dialog', + (tester) async { + // Email with an extra Cc recipient so the dialog is triggered. + final email = Email( + id: 'acc-1:42', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 42, + subject: 'Hello world', + receivedAt: DateTime(2024, 6), + sentAt: DateTime(2024, 6), + from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')], + to: const [EmailAddress(email: 'alice@example.com')], + cc: const [EmailAddress(name: 'Carol', email: 'carol@example.com')], + isSeen: false, + isFlagged: false, + hasAttachment: false, + ); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + email: email, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'Reply', + ), + ); + await tester.pumpAndSettle(); + + // Dialog must appear with title 'Reply All'. + expect(find.text('Reply All'), findsOneWidget); + // Both non-own addresses should be listed in the dialog. + expect(find.textContaining('bob@example.com'), findsAtLeastNWidgets(1)); + expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1)); + }); + + testWidgets('Mark as spam button is present in app bar', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'Mark as spam', + ), + findsOneWidget, + ); + }); + + testWidgets( + 'Mark as spam moves email to junk and shows snackbar when no junk folder', + (tester) async { + // FakeMailboxRepository has no mailboxes by default → findMailboxByRole + // returns null → snackbar shown. + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', + overrides: _overrides( + body: const EmailBody(emailId: 'acc-1:42', attachments: []), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byWidgetPredicate( + (w) => w is Tooltip && w.message == 'Mark as spam', + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No Junk folder found'), findsOneWidget); + }); + testWidgets('Show Raw Email dialog shows size of email', (tester) async { // 'A' * 2048 → fmtSize(2048) == '2.0 KB' final rawContent = 'A' * 2048;