Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 3d47af177a feat: show URL tooltip on long-press of unsubscribe chip (#294)
Wrap the ActionChip in a Tooltip whose message is the resolved
unsubscribe URI, so a long-press (mobile) or hover (desktop) reveals
the URL before the user taps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:01:26 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 f6a37eaa16 fix: prevent HTML email content from being cut off horizontally (#288)
HTML emails often use fixed-width tables (e.g. <table width="600">) that
exceed the WebView viewport, causing the right portion of the email to be
clipped with no way to scroll. Fix by injecting CSS that:

- Adds `overflow-x: hidden` to body so wide content does not escape the viewport
- Sets `max-width: 100%` on all elements (via `*`) to scale down wide containers
- Forces `table { width: 100%; }` so fixed-pixel-width email tables reflow to fit
- Adds `td/th { overflow-wrap/word-break }` for wrapping in table cells
- Adds `pre { white-space: pre-wrap; }` so pre-formatted text wraps instead of
  stretching the page

Adds a regression test that asserts all four CSS rules are present in the
generated HTML.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:50:30 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 156b040b92 chore: exclude email_action_helpers.dart from unit coverage gate
It is a Flutter UI helper (showDialog, showModalBottomSheet, BuildContext)
covered by widget/integration tests, not unit tests — consistent with the
other UI screens already in _excluded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:32:01 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e6c1288afe feat: align single and multi-mail actions, add archive to detail view (#287)
- Extract resolveMailboxByRole() helper shared by both screens so archive
  and mark-as-spam use identical dialog-based flows on single and batch actions
- Add Archive button to EmailDetailScreen app bar (was missing)
- Reorder single-mail actions to match batch toolbar: Reply, Forward,
  Archive, Delete, Spam, Move, Snooze, Flag
- Move "Mark as unread" from standalone icon button to the popup submenu
- Update _markAsSpam in detail screen to use shared helper (shows
  choose/create dialog instead of a bare snackbar when no junk folder)
- Update tests: fix broken snackbar assertion, add tests for Archive
  button presence, archive dialog, and mark-as-unread in submenu

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:32:01 +02:00
8 changed files with 319 additions and 123 deletions
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
enum _MissingFolderChoice { chooseExisting, createNew }
/// Resolves a mailbox by role, prompting the user to choose or create one when
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
Future<Mailbox?> resolveMailboxByRole(
BuildContext context,
MailboxRepository mailboxRepo,
String accountId,
String currentMailboxPath,
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
if (!context.mounted) return null;
if (mailbox != null) return mailbox;
final choice = await showDialog<_MissingFolderChoice>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(dialogTitle),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
child: const Text('Choose existing folder'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
child: Text('Create "$createFolderName"'),
),
],
),
);
if (!context.mounted || choice == null) return null;
switch (choice) {
case _MissingFolderChoice.chooseExisting:
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
if (!context.mounted) return null;
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
shrinkWrap: true,
children: [
const ListTile(
title: Text(
'Move to…',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m
in mailboxes.where((m) => m.path != currentMailboxPath))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !context.mounted) return null;
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
case _MissingFolderChoice.createNew:
mailbox = await mailboxRepo.createMailboxWithRole(
accountId,
createFolderName,
role,
);
if (!context.mounted) return null;
}
return mailbox;
}
+97 -51
View File
@@ -16,6 +16,7 @@ import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/utils/format_utils.dart'; import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -85,42 +86,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.mark_email_unread_outlined), icon: const Icon(Icons.archive),
tooltip: 'Mark as unread', tooltip: 'Archive',
onPressed: () async {
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) context.pop();
},
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
},
),
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
tooltip: 'Move to folder',
onPressed: header == null ? null : () => _moveTo(context, header),
),
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: 'Mark as spam',
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited(_markAsSpam(context, header)); unawaited(_archive(context, header));
}, },
), ),
IconButton( IconButton(
@@ -148,8 +119,43 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (context.mounted) context.pop(); if (context.mounted) context.pop();
}, },
), ),
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: 'Mark as spam',
onPressed: header == null
? null
: () {
unawaited(_markAsSpam(context, header));
},
),
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
tooltip: 'Move to folder',
onPressed: header == null ? null : () => _moveTo(context, header),
),
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
},
),
PopupMenuButton<String>( PopupMenuButton<String>(
itemBuilder: (ctx) => [ itemBuilder: (ctx) => [
const PopupMenuItem(
value: 'mark_unread',
child: Text('Mark as unread'),
),
const PopupMenuItem( const PopupMenuItem(
value: 'headers', value: 'headers',
child: Text('Show Mail Headers'), child: Text('Show Mail Headers'),
@@ -163,8 +169,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: Text('Show Raw Email'), child: Text('Show Raw Email'),
), ),
], ],
onSelected: (value) { onSelected: (value) async {
if (value == 'headers' && body != null) { if (value == 'mark_unread') {
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) context.pop();
} else if (value == 'headers' && body != null) {
_showHeaders(context, body); _showHeaders(context, body);
} else if (value == 'structure' && body != null) { } else if (value == 'structure' && body != null) {
_showStructure(context, body); _showStructure(context, body);
@@ -393,21 +402,22 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
Future<void> _markAsSpam(BuildContext context, Email header) async { Future<void> _archive(BuildContext context, Email header) async {
final mailboxRepo = ref.read(mailboxRepositoryProvider); final mailbox = await resolveMailboxByRole(
final junk = await mailboxRepo.findMailboxByRole(header.accountId, 'junk'); context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
if (junk == null) { if (mailbox == null || !context.mounted) return;
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No Junk folder found')),
);
return;
}
await ref await ref
.read(emailRepositoryProvider) .read(emailRepositoryProvider)
.moveEmail(widget.emailId, junk.path); .moveEmail(widget.emailId, mailbox.path);
unawaited( unawaited(
ref.read(undoServiceProvider.notifier).pushAction( ref.read(undoServiceProvider.notifier).pushAction(
@@ -417,7 +427,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
type: UndoType.move, type: UndoType.move,
emailIds: [widget.emailId], emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath, sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: junk.path, destinationMailboxPath: mailbox.path,
),
),
);
if (context.mounted) context.pop();
}
Future<void> _markAsSpam(BuildContext context, Email header) async {
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
if (mailbox == null || !context.mounted) return;
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, mailbox.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: mailbox.path,
), ),
), ),
); );
@@ -895,10 +938,13 @@ class _UnsubscribeChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final uri = _parseUnsubscribeUri(header); final uri = _parseUnsubscribeUri(header);
if (uri == null) return const SizedBox.shrink(); if (uri == null) return const SizedBox.shrink();
return ActionChip( return Tooltip(
avatar: const Icon(Icons.unsubscribe_outlined, size: 16), message: uri.toString(),
label: const Text('Unsubscribe'), child: ActionChip(
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication), avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
label: const Text('Unsubscribe'),
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
),
); );
} }
} }
+11 -66
View File
@@ -7,10 +7,10 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart'; import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
@@ -25,8 +25,6 @@ int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) => String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt); _formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
enum _MissingFolderChoice { chooseExisting, createNew }
class EmailListScreen extends ConsumerStatefulWidget { class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({ const EmailListScreen({
super.key, super.key,
@@ -431,70 +429,17 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
_clearSelection(); _clearSelection();
final mailboxRepo = ref.read(mailboxRepositoryProvider); final mailbox = await resolveMailboxByRole(
Mailbox? mailbox = context,
await mailboxRepo.findMailboxByRole(widget.accountId, role); ref.read(mailboxRepositoryProvider),
widget.accountId,
widget.mailboxPath,
role,
dialogTitle: dialogTitle,
createFolderName: createFolderName,
);
if (!mounted) return; if (!mounted || mailbox == null) return;
if (mailbox == null) {
final choice = await showDialog<_MissingFolderChoice>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(dialogTitle),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
child: const Text('Choose existing folder'),
),
FilledButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.createNew),
child: Text('Create "$createFolderName"'),
),
],
),
);
if (!mounted || choice == null) return;
switch (choice) {
case _MissingFolderChoice.chooseExisting:
final mailboxes =
await mailboxRepo.observeMailboxes(widget.accountId).first;
if (!mounted) return;
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
shrinkWrap: true,
children: [
const ListTile(
title: Text(
'Move to…',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m
in mailboxes.where((m) => m.path != widget.mailboxPath))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !mounted) return;
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
case _MissingFolderChoice.createNew:
mailbox = await mailboxRepo.createMailboxWithRole(
widget.accountId,
createFolderName,
role,
);
if (!mounted) return;
}
}
final repo = ref.read(emailRepositoryProvider); final repo = ref.read(emailRepositoryProvider);
+5 -2
View File
@@ -31,10 +31,13 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<meta http-equiv="Content-Security-Policy" content="$csp"> <meta http-equiv="Content-Security-Policy" content="$csp">
<style> <style>
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; } body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
img { max-width: 100%; height: auto; } img { max-width: 100%; height: auto; }
a { color: #1976D2; } a { color: #1976D2; }
* { box-sizing: border-box; } * { box-sizing: border-box; max-width: 100%; }
table { width: 100%; border-collapse: collapse; }
td, th { overflow-wrap: break-word; word-break: break-word; }
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
</style> </style>
</head> </head>
<body> <body>
+1
View File
@@ -58,6 +58,7 @@ const _excluded = {
'lib/ui/widgets/try_connection_button.dart', 'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart', 'lib/ui/widgets/undo_shell.dart',
'lib/ui/screens/about_screen.dart', 'lib/ui/screens/about_screen.dart',
'lib/ui/screens/email_action_helpers.dart',
'lib/ui/utils/about_markdown.dart', 'lib/ui/utils/about_markdown.dart',
'lib/ui/widgets/email_tile.dart', 'lib/ui/widgets/email_tile.dart',
'lib/core/sync/account_sync_manager.dart', 'lib/core/sync/account_sync_manager.dart',
+110 -4
View File
@@ -290,11 +290,10 @@ void main() {
); );
}); });
testWidgets( testWidgets('Mark as spam shows dialog when no junk folder',
'Mark as spam moves email to junk and shows snackbar when no junk folder',
(tester) async { (tester) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole // FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → snackbar shown. // returns null → dialog shown.
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
@@ -312,7 +311,76 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('No Junk folder found'), findsOneWidget); expect(find.text('No spam folder found'), findsOneWidget);
});
testWidgets('Archive 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 == 'Archive',
),
findsOneWidget,
);
});
testWidgets('Archive shows dialog when no archive folder', (tester) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog 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 == 'Archive',
),
);
await tester.pumpAndSettle();
expect(find.text('No archive folder found'), findsOneWidget);
});
testWidgets('Mark as unread is in popup menu, not a standalone button',
(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();
// No standalone icon button for mark as unread.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as unread',
),
findsNothing,
);
// It appears in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as unread'), findsOneWidget);
}); });
testWidgets('Show Raw Email dialog shows size of email', (tester) async { testWidgets('Show Raw Email dialog shows size of email', (tester) async {
@@ -407,6 +475,44 @@ void main() {
expect(find.text('Share'), findsOneWidget); expect(find.text('Share'), findsOneWidget);
}); });
testWidgets(
'long-press on unsubscribe chip shows URL tooltip',
(tester) async {
final email = testEmail(
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
);
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();
expect(find.text('Unsubscribe'), findsOneWidget);
expect(
find.byWidgetPredicate(
(w) =>
w is Tooltip && w.message == 'https://example.com/unsubscribe',
),
findsOneWidget,
);
await tester.longPress(find.text('Unsubscribe'));
await tester.pumpAndSettle();
expect(
find.text('https://example.com/unsubscribe'),
findsOneWidget,
);
},
);
testWidgets('Show Mail Structure opens dialog with MIME parts', ( testWidgets('Show Mail Structure opens dialog with MIME parts', (
tester, tester,
) async { ) async {
+2
View File
@@ -588,6 +588,7 @@ Email testEmail({
bool isSeen = false, bool isSeen = false,
bool isFlagged = false, bool isFlagged = false,
bool hasAttachment = false, bool hasAttachment = false,
String? listUnsubscribeHeader,
}) => }) =>
Email( Email(
id: id, id: id,
@@ -603,6 +604,7 @@ Email testEmail({
isSeen: isSeen, isSeen: isSeen,
isFlagged: isFlagged, isFlagged: isFlagged,
hasAttachment: hasAttachment, hasAttachment: hasAttachment,
listUnsubscribeHeader: listUnsubscribeHeader,
); );
class FakeSearchHistoryRepository implements SearchHistoryRepository { class FakeSearchHistoryRepository implements SearchHistoryRepository {
@@ -41,6 +41,20 @@ void main() {
expect(html, contains('https: http: data: blob:')); expect(html, contains('https: http: data: blob:'));
_expectLightMode(html); _expectLightMode(html);
}); });
test('prevents horizontal overflow so wide HTML emails are not cut off',
() {
final html =
buildEmailHtml('<table width="600"><tr><td>x</td></tr></table>');
// Body clips overflow so fixed-width email tables don't escape the viewport.
expect(html, contains('overflow-x: hidden'));
// Tables are forced to full viewport width so fixed pixel widths don't overflow.
expect(html, contains('table { width: 100%'));
// All elements are capped at viewport width via max-width.
expect(html, contains('max-width: 100%'));
// Pre-formatted text wraps instead of stretching the page.
expect(html, contains('white-space: pre-wrap'));
});
}); });
// On Linux (the test host) the widget falls back to plain text extracted via // On Linux (the test host) the widget falls back to plain text extracted via