Compare commits
1
Commits
main
...
issue-260-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1ce093c33 |
@@ -70,16 +70,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
unawaited(_reply(context, header, body, replyAll: false));
|
unawaited(
|
||||||
},
|
_replyWithRecipientDialog(context, header, body),
|
||||||
),
|
);
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.reply_all),
|
|
||||||
tooltip: 'Reply all',
|
|
||||||
onPressed: header == null
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
unawaited(_reply(context, header, body, replyAll: true));
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -121,6 +114,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
tooltip: 'Snooze',
|
tooltip: 'Snooze',
|
||||||
onPressed: header == null ? null : () => _snooze(context, header),
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
tooltip: 'Delete',
|
tooltip: 'Delete',
|
||||||
@@ -303,17 +305,78 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reply(
|
Future<void> _replyWithRecipientDialog(
|
||||||
|
BuildContext context,
|
||||||
|
Email header,
|
||||||
|
EmailBody? body,
|
||||||
|
) async {
|
||||||
|
final account =
|
||||||
|
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
|
||||||
|
final ownEmail = account?.email.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
final seen = <String>{};
|
||||||
|
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<List<_Candidate>>(
|
||||||
|
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<void> _composeReply(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
EmailBody? body, {
|
EmailBody? body, {
|
||||||
required bool replyAll,
|
required String to,
|
||||||
|
required String cc,
|
||||||
}) async {
|
}) async {
|
||||||
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
|
||||||
final subject = (header.subject?.startsWith('Re:') ?? false)
|
final subject = (header.subject?.startsWith('Re:') ?? false)
|
||||||
? header.subject!
|
? header.subject!
|
||||||
: 'Re: ${header.subject ?? ''}';
|
: 'Re: ${header.subject ?? ''}';
|
||||||
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
|
||||||
final quoted = await _quotedBody(header, body);
|
final quoted = await _quotedBody(header, body);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -330,6 +393,38 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _forward(
|
Future<void> _forward(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
@@ -670,6 +765,94 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _MimeRow {
|
||||||
const _MimeRow(this.depth, this.label);
|
const _MimeRow(this.depth, this.label);
|
||||||
final int depth;
|
final int depth;
|
||||||
|
|||||||
@@ -179,6 +179,142 @@ void main() {
|
|||||||
expect(find.text('report.pdf'), findsOneWidget);
|
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 {
|
testWidgets('Show Raw Email dialog shows size of email', (tester) async {
|
||||||
// 'A' * 2048 → fmtSize(2048) == '2.0 KB'
|
// 'A' * 2048 → fmtSize(2048) == '2.0 KB'
|
||||||
final rawContent = 'A' * 2048;
|
final rawContent = 'A' * 2048;
|
||||||
|
|||||||
Reference in New Issue
Block a user