diff --git a/lib/core/sieve/sieve_interpreter.dart b/lib/core/sieve/sieve_interpreter.dart index d45680b..2ef3388 100644 --- a/lib/core/sieve/sieve_interpreter.dart +++ b/lib/core/sieve/sieve_interpreter.dart @@ -1,6 +1,7 @@ import 'package:sharedinbox/core/sieve/sieve_actions.dart'; import 'package:sharedinbox/core/sieve/sieve_conditions.dart'; import 'package:sharedinbox/core/sieve/sieve_rule.dart'; +import 'package:sharedinbox/core/utils/glob_match.dart'; /// A lightweight email representation used by [SieveInterpreter]. /// Header names are lower-cased. @@ -102,18 +103,11 @@ class SieveInterpreter { return switch (matchType) { ':contains' => k.isEmpty || v.contains(k), ':is' => v == k, - ':matches' => _globMatch(v, k), + ':matches' => globMatch(v, k), _ => false, }; } - bool _globMatch(String value, String pattern) { - final regexStr = RegExp.escape( - pattern, - ).replaceAll(r'\*', '.*').replaceAll(r'\?', '.'); - return RegExp('^$regexStr\$').hasMatch(value); - } - void _applyActions(List actions, SieveExecutionContext ctx) { for (final action in actions) { switch (action) { diff --git a/lib/core/utils/glob_match.dart b/lib/core/utils/glob_match.dart new file mode 100644 index 0000000..8e705a3 --- /dev/null +++ b/lib/core/utils/glob_match.dart @@ -0,0 +1,9 @@ +/// Returns true if [value] matches the glob [pattern]. +/// +/// Supports `*` (any number of characters) and `?` (exactly one character). +/// The comparison is case-insensitive, which is appropriate for email addresses. +bool globMatch(String value, String pattern) { + final regexStr = + RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.'); + return RegExp('^$regexStr\$', caseSensitive: false).hasMatch(value); +} diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 561a1b1..2709d03 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -16,6 +16,7 @@ import 'package:sharedinbox/core/models/note.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/user_preferences.dart'; import 'package:sharedinbox/core/utils/format_utils.dart'; +import 'package:sharedinbox/core/utils/glob_match.dart'; import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/ui/screens/email_action_helpers.dart'; @@ -208,8 +209,8 @@ class _EmailDetailScreenState extends ConsumerState { final senderEmail = header?.from.isNotEmpty == true ? header!.from.first.email.toLowerCase() : null; - final isTrusted = - senderEmail != null && trustedSenders.contains(senderEmail); + final isTrusted = senderEmail != null && + trustedSenders.any((p) => globMatch(senderEmail, p)); final effectiveLoadImages = _loadRemoteImages || isTrusted; return ListView( diff --git a/lib/ui/screens/thread_detail_screen.dart b/lib/ui/screens/thread_detail_screen.dart index 905dc57..9c0351f 100644 --- a/lib/ui/screens/thread_detail_screen.dart +++ b/lib/ui/screens/thread_detail_screen.dart @@ -8,6 +8,7 @@ import 'package:intl/intl.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/user_preferences.dart'; +import 'package:sharedinbox/core/utils/glob_match.dart'; import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; @@ -118,8 +119,8 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> { final senderEmail = widget.email.from.isNotEmpty ? widget.email.from.first.email.toLowerCase() : null; - final isTrusted = - senderEmail != null && trustedSenders.contains(senderEmail); + final isTrusted = senderEmail != null && + trustedSenders.any((p) => globMatch(senderEmail, p)); return Card( margin: const EdgeInsets.symmetric(vertical: 4), diff --git a/lib/ui/screens/trusted_image_senders_screen.dart b/lib/ui/screens/trusted_image_senders_screen.dart index 80d6e30..d6db1e3 100644 --- a/lib/ui/screens/trusted_image_senders_screen.dart +++ b/lib/ui/screens/trusted_image_senders_screen.dart @@ -16,6 +16,11 @@ class TrustedImageSendersScreen extends ConsumerWidget { return Scaffold( appBar: AppBar(title: const Text('Allowed addresses for images')), + floatingActionButton: FloatingActionButton( + tooltip: 'Add address', + onPressed: () => _showAddDialog(context, ref), + child: const Icon(Icons.add), + ), body: trustedSendersAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (_, __) => @@ -26,7 +31,8 @@ class TrustedImageSendersScreen extends ConsumerWidget { padding: EdgeInsets.all(16), child: Text( 'No addresses added yet. ' - 'Tap "Load remote images" in an email to add the sender.', + 'Tap + to add an address or pattern (e.g. *@example.com), ' + 'or tap "Load remote images" in an email to add the sender automatically.', ), ); } @@ -60,4 +66,61 @@ class TrustedImageSendersScreen extends ConsumerWidget { ), ); } + + Future _showAddDialog(BuildContext context, WidgetRef ref) async { + final controller = TextEditingController(); + + await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setState) { + return AlertDialog( + title: const Text('Add allowed address'), + content: TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email address or pattern', + hintText: '*@example.com', + helperText: '* matches any characters, e.g. *@example.com', + ), + onChanged: (_) => setState(() {}), + onSubmitted: (value) { + if (value.trim().isNotEmpty) { + _addSender(ref, value); + Navigator.of(ctx).pop(); + } + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: controller.text.trim().isEmpty + ? null + : () { + _addSender(ref, controller.text); + Navigator.of(ctx).pop(); + }, + child: const Text('Add'), + ), + ], + ); + }, + ); + }, + ); + } + + void _addSender(WidgetRef ref, String value) { + unawaited( + ref + .read(userPreferencesRepositoryProvider) + .addTrustedImageSender(value.trim()), + ); + } } diff --git a/test/unit/glob_match_test.dart b/test/unit/glob_match_test.dart new file mode 100644 index 0000000..881d7f0 --- /dev/null +++ b/test/unit/glob_match_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/core/utils/glob_match.dart'; + +void main() { + group('globMatch', () { + test('exact match (no wildcards)', () { + expect(globMatch('alice@example.com', 'alice@example.com'), isTrue); + expect(globMatch('alice@example.com', 'bob@example.com'), isFalse); + }); + + test('* matches any domain wildcard', () { + expect(globMatch('alice@example.com', '*@example.com'), isTrue); + expect(globMatch('bob@example.com', '*@example.com'), isTrue); + expect(globMatch('alice@other.com', '*@example.com'), isFalse); + }); + + test('* matches zero or more characters', () { + expect( + globMatch('newsletter@news.example.com', '*@*.example.com'), + isTrue, + ); + expect(globMatch('alice@example.com', 'alice*'), isTrue); + expect(globMatch('alice@example.com', '*example*'), isTrue); + }); + + test('? matches exactly one character', () { + expect(globMatch('alice@example.com', 'alice@exampl?.com'), isTrue); + expect(globMatch('alice@example.com', 'alice@exampl??.com'), isFalse); + }); + + test('case-insensitive comparison', () { + expect(globMatch('Alice@Example.COM', '*@example.com'), isTrue); + expect(globMatch('alice@example.com', '*@EXAMPLE.COM'), isTrue); + }); + + test('no wildcards — mismatch is false', () { + expect(globMatch('alice@example.com', 'alice@other.com'), isFalse); + }); + + test('bare * matches everything', () { + expect(globMatch('alice@example.com', '*'), isTrue); + expect(globMatch('', '*'), isTrue); + }); + + test('empty pattern only matches empty string', () { + expect(globMatch('', ''), isTrue); + expect(globMatch('alice@example.com', ''), isFalse); + }); + }); +} diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 3415708..289f96c 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -43,6 +43,7 @@ import 'package:sharedinbox/ui/screens/email_list_screen.dart'; import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart'; import 'package:sharedinbox/ui/screens/search_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; +import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart'; import 'package:sharedinbox/ui/screens/user_preferences_screen.dart'; // --------------------------------------------------------------------------- @@ -476,6 +477,12 @@ Widget buildApp({ path: 'preferences', builder: (ctx, state) => const UserPreferencesScreen(), ), + GoRoute( + path: 'trusted-senders', + builder: (ctx, state) => TrustedImageSendersScreen( + highlightedSender: state.extra as String?, + ), + ), GoRoute( path: ':accountId/edit', builder: (ctx, state) => EditAccountScreen( @@ -688,6 +695,9 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository { AfterMailViewAction afterMailViewAction; final List _trustedImageSenders; + List get trustedImageSendersForTest => + List.unmodifiable(_trustedImageSenders); + @override Stream observePreferences() => Stream.value( UserPreferences( diff --git a/test/widget/trusted_image_senders_screen_test.dart b/test/widget/trusted_image_senders_screen_test.dart new file mode 100644 index 0000000..066d4d7 --- /dev/null +++ b/test/widget/trusted_image_senders_screen_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'helpers.dart'; + +void main() { + group('TrustedImageSendersScreen', () { + testWidgets('shows empty state with glob hint when no senders', ( + tester, + ) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/trusted-senders', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('*@example.com'), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget); + }); + + testWidgets('lists existing senders', (tester) async { + final repo = FakeUserPreferencesRepository( + trustedImageSenders: ['alice@example.com', '*@work.com'], + ); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/trusted-senders', + overrides: baseOverrides(), + userPreferences: repo, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('alice@example.com'), findsOneWidget); + expect(find.text('*@work.com'), findsOneWidget); + }); + + testWidgets('add dialog shows glob hint text', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/trusted-senders', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + expect(find.text('Add allowed address'), findsOneWidget); + expect(find.textContaining('*@example.com'), findsWidgets); + expect(find.textContaining('* matches any characters'), findsOneWidget); + }); + + testWidgets('Add button is disabled when input is empty', (tester) async { + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/trusted-senders', + overrides: baseOverrides(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + final addButton = find.widgetWithText(TextButton, 'Add'); + final button = tester.widget(addButton); + expect(button.onPressed, isNull); + }); + + testWidgets('typing in dialog enables Add button and adds sender', ( + tester, + ) async { + final repo = FakeUserPreferencesRepository(); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/trusted-senders', + overrides: baseOverrides(), + userPreferences: repo, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '*@example.com'); + await tester.pumpAndSettle(); + + final addButton = find.widgetWithText(TextButton, 'Add'); + final button = tester.widget(addButton); + expect(button.onPressed, isNotNull); + + await tester.tap(addButton); + await tester.pumpAndSettle(); + + expect(repo.trustedImageSendersForTest, contains('*@example.com')); + }); + + testWidgets('cancel closes dialog without adding', (tester) async { + final repo = FakeUserPreferencesRepository(); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/trusted-senders', + overrides: baseOverrides(), + userPreferences: repo, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'someone@test.com'); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(repo.trustedImageSendersForTest, isEmpty); + }); + + testWidgets('delete button removes a sender', (tester) async { + final repo = FakeUserPreferencesRepository( + trustedImageSenders: ['alice@example.com'], + ); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/trusted-senders', + overrides: baseOverrides(), + userPreferences: repo, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.delete_outline)); + await tester.pumpAndSettle(); + + expect(repo.trustedImageSendersForTest, isEmpty); + }); + + testWidgets('lists existing glob patterns', (tester) async { + final repo = FakeUserPreferencesRepository( + trustedImageSenders: ['*@example.com', 'alice@other.com'], + ); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/trusted-senders', + overrides: baseOverrides(), + userPreferences: repo, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('*@example.com'), findsOneWidget); + expect(find.text('alice@other.com'), findsOneWidget); + }); + }); +}