From f22f211e8ace243075aa4728d72d3fd2f137ddab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 04:38:19 +0200 Subject: [PATCH 1/6] docs: update AGENTS.md for new agentloop defaults (merge prompt + label rename) (#471) --- AGENTS.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3e90786..cbbc22b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,23 +13,27 @@ Automation is handled by [agentloop](https://github.com/guettli/agentloop) runni | Label | Trigger | Outcome | |---|---|---| | `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` | -| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` | +| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue routes to `loop/merge` | +| `loop/merge` | Merge agent rebases, waits for CI, and merges the PR | Issue moves to `loop/merge-done` | **State machine:** ``` -loop/plan → loop/plan-in-progress → loop/plan-done - ↘ NeedSupervisor (on failure) +loop/plan → loop/plan-in-process → loop/plan-done + ↘ NeedSupervisor (on failure) -loop/code → loop/code-in-progress → loop/code-done - ↘ NeedSupervisor (on failure) +loop/code → loop/code-in-process → loop/merge (via route) + ↘ NeedSupervisor (on failure) + +loop/merge → loop/merge-in-process → loop/merge-done + ↘ NeedSupervisor (on failure) ``` **Rules:** - Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions). - An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label. -- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging. +- The merge agent merges the PR automatically once CI is green. A human still reviews the PR before it merges if branch protection requires a review. - Planning agents only post a comment — they do NOT write code or open PRs. - `loop/*` labels are managed by agentloop — do not set them manually while an agent is active. @@ -39,9 +43,9 @@ loop/code → loop/code-in-progress → loop/code-done 1. Create issue 2. Add label loop/plan → agent writes plan as comment 3. Review plan, request changes or approve -4. Add label loop/code → agent implements + opens PR -5. Review PR, merge -6. Close issue +4. Add label loop/code → agent implements + opens PR + hands off to merge +5. (Optional) Review PR before it merges +6. Merge agent waits for CI and merges the PR automatically ``` ## Code conventions From b1e1ac1de7043235c798a92e655637407e150a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 04:38:21 +0200 Subject: [PATCH 2/6] fix: remove dual-stack [::]:PORT bind (silences spurious EADDRINUSE errors) (#481) --- ci/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/main.go b/ci/main.go index b167724..09820c1 100644 --- a/ci/main.go +++ b/ci/main.go @@ -388,7 +388,7 @@ func (m *Ci) Stalwart() *dagger.Service { return dag.Container(). From("stalwartlabs/stalwart:v0.14.1"). WithFile("/etc/stalwart/config.toml.orig", config). - WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' -e 's/bind = \\[\"0.0.0.0:\\([0-9]*\\)\"\\]/bind = [\"0.0.0.0:\\1\", \"[::]:\\1\"]/g' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}). + WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}). WithDirectory("/tmp/stalwart", dataDir). WithExposedPort(8080). // JMAP WithExposedPort(1430). // IMAP From b9ccafc70967922dc90d687cad67ca3dd747c258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 04:38:22 +0200 Subject: [PATCH 3/6] feat: allow manual entry of glob patterns for trusted image senders (#480) --- lib/core/sieve/sieve_interpreter.dart | 10 +- lib/core/utils/glob_match.dart | 9 + lib/ui/screens/email_detail_screen.dart | 5 +- lib/ui/screens/thread_detail_screen.dart | 5 +- .../screens/trusted_image_senders_screen.dart | 65 ++++++- test/unit/glob_match_test.dart | 50 ++++++ test/widget/helpers.dart | 10 ++ .../trusted_image_senders_screen_test.dart | 163 ++++++++++++++++++ 8 files changed, 304 insertions(+), 13 deletions(-) create mode 100644 lib/core/utils/glob_match.dart create mode 100644 test/unit/glob_match_test.dart create mode 100644 test/widget/trusted_image_senders_screen_test.dart 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); + }); + }); +} From 9081b452f3014fcd520c5ddc0cbd478ff648824f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 04:38:28 +0200 Subject: [PATCH 4/6] feat: add structured search with visual filter builder (#469) --- lib/core/filter/filter_expression.dart | 88 +++++ lib/core/filter/filter_sieve_converter.dart | 358 ++++++++++++++++++ lib/core/repositories/email_repository.dart | 7 + lib/core/sieve/sieve_serializer.dart | 100 +++++ .../repositories/email_repository_impl.dart | 86 +++++ lib/ui/screens/search_screen.dart | 117 +++++- lib/ui/screens/sieve_script_edit_screen.dart | 290 +++++++++++++- lib/ui/widgets/filter_builder.dart | 312 +++++++++++++++ pubspec.lock | 16 +- scripts/check_coverage.dart | 1 + test/backend/account_sync_manager_test.dart | 8 + test/unit/account_sync_manager_test.dart | 7 + .../unit/account_sync_manager_test.mocks.dart | 17 + test/unit/filter_and_sieve_test.dart | 337 +++++++++++++++++ .../reliability_runner_check_now_test.dart | 7 + test/unit/reliability_runner_test.dart | 7 + test/unit/undo_service_test.mocks.dart | 31 +- test/widget/helpers.dart | 8 + 18 files changed, 1758 insertions(+), 39 deletions(-) create mode 100644 lib/core/filter/filter_expression.dart create mode 100644 lib/core/filter/filter_sieve_converter.dart create mode 100644 lib/core/sieve/sieve_serializer.dart create mode 100644 lib/ui/widgets/filter_builder.dart create mode 100644 test/unit/filter_and_sieve_test.dart diff --git a/lib/core/filter/filter_expression.dart b/lib/core/filter/filter_expression.dart new file mode 100644 index 0000000..7052d60 --- /dev/null +++ b/lib/core/filter/filter_expression.dart @@ -0,0 +1,88 @@ +enum FilterField { + from_, + to, + cc, + subject, + size; + + String get label => switch (this) { + FilterField.from_ => 'From', + FilterField.to => 'To', + FilterField.cc => 'CC', + FilterField.subject => 'Subject', + FilterField.size => 'Size (bytes)', + }; + + List get allowedComparisons => switch (this) { + FilterField.size => [FilterComparison.over, FilterComparison.under], + _ => [ + FilterComparison.contains, + FilterComparison.is_, + FilterComparison.matches, + ], + }; +} + +enum FilterComparison { + contains, + is_, + matches, + over, + under; + + String get label => switch (this) { + FilterComparison.contains => 'contains', + FilterComparison.is_ => 'is', + FilterComparison.matches => 'matches', + FilterComparison.over => 'over', + FilterComparison.under => 'under', + }; +} + +enum FilterOperator { and_, or_ } + +sealed class FilterNode {} + +final class FilterLeaf extends FilterNode { + FilterLeaf({ + required this.field, + required this.comparison, + required this.value, + }); + + final FilterField field; + final FilterComparison comparison; + final String value; + + FilterLeaf copyWith({ + FilterField? field, + FilterComparison? comparison, + String? value, + }) => + FilterLeaf( + field: field ?? this.field, + comparison: comparison ?? this.comparison, + value: value ?? this.value, + ); +} + +final class FilterGroup extends FilterNode { + FilterGroup({required this.operator, required this.children}); + + final FilterOperator operator; + final List children; + + bool get isEmpty => children.isEmpty; + + FilterGroup copyWith({ + FilterOperator? operator, + List? children, + }) => + FilterGroup( + operator: operator ?? this.operator, + children: children ?? this.children, + ); + + static FilterGroup empty() => + FilterGroup(operator: FilterOperator.and_, children: []); +} diff --git a/lib/core/filter/filter_sieve_converter.dart b/lib/core/filter/filter_sieve_converter.dart new file mode 100644 index 0000000..fe70219 --- /dev/null +++ b/lib/core/filter/filter_sieve_converter.dart @@ -0,0 +1,358 @@ +import 'package:sharedinbox/core/filter/filter_expression.dart'; +import 'package:sharedinbox/core/sieve/sieve_actions.dart'; + +/// Converts a Sieve script (RFC 5228 subset) to a [FilterGroup] + actions, +/// suitable for display in the visual filter editor. +/// +/// Returns null if the script uses features outside the supported subset. +class FilterSieveConverter { + ({FilterGroup group, List actions})? parse(String script) { + try { + final s = _Sc(script); + s.skip(); + if (s.peekWord() == 'require') { + s.readWord(); + s.skip(); + _parseStringOrList(s); + s.skip(); + s.expectChar(';'); + s.skip(); + } + if (s.peekWord() != 'if') return null; + s.readWord(); + s.skip(); + final node = _parseTest(s); + if (node == null) return null; + s.skip(); + s.expectChar('{'); + s.skip(); + final actions = []; + while (s.peek() != '}' && !s.isAtEnd) { + final action = _parseAction(s); + if (action == null) return null; + actions.add(action); + s.skip(); + } + s.expectChar('}'); + final group = switch (node) { + final FilterGroup g => g, + final FilterLeaf l => + FilterGroup(operator: FilterOperator.and_, children: [l]), + }; + return (group: group, actions: actions); + } catch (_) { + return null; + } + } + + FilterNode? _parseTest(_Sc s) { + s.skip(); + final word = s.peekWord()?.toLowerCase(); + if (word == null) return null; + if (word == 'allof' || word == 'anyof') { + s.readWord(); + s.skip(); + s.expectChar('('); + final op = word == 'allof' ? FilterOperator.and_ : FilterOperator.or_; + final children = []; + while (true) { + s.skip(); + if (s.peek() == ')') break; + final child = _parseTest(s); + if (child == null) return null; + children.add(child); + s.skip(); + if (s.peek() == ',') s.advance(); + } + s.expectChar(')'); + return FilterGroup(operator: op, children: children); + } + return _parseSingleTest(s); + } + + FilterLeaf? _parseSingleTest(_Sc s) { + s.skip(); + final word = s.peekWord()?.toLowerCase(); + if (word == null) return null; + + if (word == 'address') { + s.readWord(); + s.skip(); + final matchType = s.readTaggedArg(); + s.skip(); + final headers = _parseStringOrList(s); + s.skip(); + final values = _parseStringOrList(s); + final field = switch (headers.firstOrNull?.toLowerCase()) { + 'from' => FilterField.from_, + 'to' => FilterField.to, + 'cc' => FilterField.cc, + _ => null, + }; + if (field == null) return null; + final comp = _comp(matchType); + if (comp == null) return null; + return FilterLeaf( + field: field, + comparison: comp, + value: values.firstOrNull ?? '', + ); + } + + if (word == 'header') { + s.readWord(); + s.skip(); + final matchType = s.readTaggedArg(); + s.skip(); + final headers = _parseStringOrList(s); + s.skip(); + final values = _parseStringOrList(s); + if (headers.firstOrNull?.toLowerCase() != 'subject') return null; + final comp = _comp(matchType); + if (comp == null) return null; + return FilterLeaf( + field: FilterField.subject, + comparison: comp, + value: values.firstOrNull ?? '', + ); + } + + if (word == 'size') { + s.readWord(); + s.skip(); + final compTag = s.readTaggedArg(); + s.skip(); + final numStr = s.readDigits(); + final comp = switch (compTag.toLowerCase()) { + ':over' => FilterComparison.over, + ':under' => FilterComparison.under, + _ => null, + }; + if (comp == null) return null; + return FilterLeaf( + field: FilterField.size, + comparison: comp, + value: numStr, + ); + } + + return null; + } + + FilterComparison? _comp(String tag) => switch (tag.toLowerCase()) { + ':contains' => FilterComparison.contains, + ':is' => FilterComparison.is_, + ':matches' => FilterComparison.matches, + _ => null, + }; + + SieveAction? _parseAction(_Sc s) { + s.skip(); + final word = s.peekWord()?.toLowerCase(); + if (word == null) return null; + if (word == 'fileinto') { + s.readWord(); + s.skip(); + final folder = _parseString(s); + s.skip(); + s.expectChar(';'); + return FileIntoAction(folder); + } + if (word == 'keep') { + s.readWord(); + s.skip(); + s.expectChar(';'); + return KeepAction(); + } + if (word == 'discard') { + s.readWord(); + s.skip(); + s.expectChar(';'); + return DiscardAction(); + } + if (word == 'setflag' || word == 'addflag') { + s.readWord(); + s.skip(); + final flags = _parseStringOrList(s); + s.skip(); + s.expectChar(';'); + if (flags.any( + (f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen', + )) { + return MarkAsSeenAction(); + } + return FlagAction(flags); + } + return null; + } + + List _parseStringOrList(_Sc s) { + s.skip(); + if (s.peek() == '[') { + s.advance(); + final items = []; + while (true) { + s.skip(); + if (s.peek() == ']') { + s.advance(); + break; + } + items.add(_parseString(s)); + s.skip(); + if (s.peek() == ',') s.advance(); + } + return items; + } + return [_parseString(s)]; + } + + String _parseString(_Sc s) { + s.skip(); + return s.readQuotedString(); + } +} + +// Minimal scanner for the supported Sieve subset. +class _Sc { + _Sc(this._src); + final String _src; + int _pos = 0; + + bool get isAtEnd => _pos >= _src.length; + String? peek() => isAtEnd ? null : _src[_pos]; + + String advance() { + if (isAtEnd) throw _ScanErr('Unexpected end'); + return _src[_pos++]; + } + + void skip() { + while (!isAtEnd) { + final ch = _src[_pos]; + if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') { + _pos++; + } else if (ch == '#') { + while (!isAtEnd && _src[_pos] != '\n') { + _pos++; + } + } else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') { + _pos += 2; + while (_pos + 1 < _src.length) { + if (_src[_pos] == '*' && _src[_pos + 1] == '/') { + _pos += 2; + break; + } + _pos++; + } + } else { + break; + } + } + } + + String? peekWord() { + if (isAtEnd) return null; + final ch = _src[_pos]; + if ('{}();[],'.contains(ch)) return ch; + if (ch == ':') { + var end = _pos + 1; + while (end < _src.length && _wc(_src[end])) { + end++; + } + return _src.substring(_pos, end).toLowerCase(); + } + if (_wc(ch)) { + var end = _pos + 1; + while (end < _src.length && _wc(_src[end])) { + end++; + } + return _src.substring(_pos, end).toLowerCase(); + } + return null; + } + + String readWord() { + final start = _pos; + final ch = _src[_pos]; + if ('{}();[],'.contains(ch)) { + _pos++; + return ch; + } + if (ch == ':') { + _pos++; + while (!isAtEnd && _wc(_src[_pos])) { + _pos++; + } + } else { + while (!isAtEnd && _wc(_src[_pos])) { + _pos++; + } + } + return _src.substring(start, _pos).toLowerCase(); + } + + String readTaggedArg() { + if (!isAtEnd && _src[_pos] == ':') return readWord(); + throw _ScanErr('Expected tagged arg at $_pos'); + } + + String readDigits() { + final start = _pos; + while (!isAtEnd && _dig(_src[_pos])) { + _pos++; + } + if (_pos == start) throw _ScanErr('Expected digits at $_pos'); + return _src.substring(start, _pos); + } + + String readQuotedString() { + if (isAtEnd || _src[_pos] != '"') throw _ScanErr('Expected " at $_pos'); + _pos++; + final buf = StringBuffer(); + while (!isAtEnd) { + final ch = _src[_pos]; + if (ch == '"') { + _pos++; + return buf.toString(); + } + if (ch == '\\' && _pos + 1 < _src.length) { + _pos++; + buf.write(_src[_pos]); + _pos++; + } else { + buf.write(ch); + _pos++; + } + } + throw _ScanErr('Unterminated string'); + } + + void expectChar(String ch) { + skip(); + if (isAtEnd || _src[_pos] != ch) { + throw _ScanErr( + 'Expected "$ch" at $_pos, got ${isAtEnd ? "EOF" : _src[_pos]}', + ); + } + _pos++; + } + + static bool _wc(String ch) { + final c = ch.codeUnitAt(0); + return (c >= 0x41 && c <= 0x5A) || + (c >= 0x61 && c <= 0x7A) || + (c >= 0x30 && c <= 0x39) || + c == 0x5F || + c == 0x2D; + } + + static bool _dig(String ch) { + final c = ch.codeUnitAt(0); + return c >= 0x30 && c <= 0x39; + } +} + +class _ScanErr implements Exception { + _ScanErr(this.message); + final String message; +} diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 9a6e4b4..fac7283 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -1,3 +1,4 @@ +import 'package:sharedinbox/core/filter/filter_expression.dart'; import 'package:sharedinbox/core/models/email.dart'; abstract class EmailRepository { @@ -61,6 +62,12 @@ abstract class EmailRepository { /// if null) by subject, preview, and notes. Fast, works offline. Future> searchEmailsGlobal(String? accountId, String query); + /// Searches the local DB using a structured [FilterGroup]. Fast, works offline. + Future> searchEmailsStructured( + String? accountId, + FilterGroup filter, + ); + /// Returns all locally cached emails in any mailbox of [accountId] (or all /// accounts if null) whose from, to, or cc fields contain [address]. Future> getEmailsByAddress(String? accountId, String address); diff --git a/lib/core/sieve/sieve_serializer.dart b/lib/core/sieve/sieve_serializer.dart new file mode 100644 index 0000000..f781d1a --- /dev/null +++ b/lib/core/sieve/sieve_serializer.dart @@ -0,0 +1,100 @@ +import 'package:sharedinbox/core/filter/filter_expression.dart'; +import 'package:sharedinbox/core/sieve/sieve_actions.dart'; + +/// Serialises a [FilterGroup] + list of [SieveAction]s to a Sieve script +/// (RFC 5228 subset). +class SieveSerializer { + String serialize(FilterGroup filter, List actions) { + final buf = StringBuffer(); + final requires = _collectRequires(actions); + if (requires.isNotEmpty) { + buf.writeln( + 'require [${requires.map((r) => '"$r"').join(', ')}];', + ); + } + if (filter.isEmpty) { + for (final a in actions) { + buf.writeln(_serializeAction(a)); + } + return buf.toString(); + } + buf.write('if '); + buf.write(_serializeNode(filter)); + buf.writeln(' {'); + for (final a in actions) { + buf.writeln(' ${_serializeAction(a)}'); + } + buf.writeln('}'); + return buf.toString(); + } + + List _collectRequires(List actions) { + final req = []; + for (final a in actions) { + if (a is FileIntoAction && !req.contains('fileinto')) req.add('fileinto'); + if ((a is FlagAction || a is MarkAsSeenAction) && + !req.contains('imap4flags')) { + req.add('imap4flags'); + } + } + return req; + } + + String _serializeNode(FilterNode node) => switch (node) { + final FilterLeaf leaf => _serializeLeaf(leaf), + final FilterGroup group => _serializeGroup(group), + }; + + String _serializeGroup(FilterGroup group) { + if (group.isEmpty) return 'true'; + if (group.children.length == 1) return _serializeNode(group.children.first); + final op = group.operator == FilterOperator.and_ ? 'allof' : 'anyof'; + final parts = group.children.map(_serializeNode).join(',\n '); + return '$op(\n $parts\n)'; + } + + String _serializeLeaf(FilterLeaf leaf) => switch (leaf.field) { + FilterField.from_ || + FilterField.to || + FilterField.cc => + _serializeAddressLeaf(leaf), + FilterField.subject => _serializeHeaderLeaf(leaf), + FilterField.size => _serializeSizeLeaf(leaf), + }; + + String _serializeAddressLeaf(FilterLeaf leaf) { + final header = switch (leaf.field) { + FilterField.from_ => 'from', + FilterField.to => 'to', + FilterField.cc => 'cc', + _ => throw StateError('not an address field'), + }; + return 'address ${_matchType(leaf.comparison)} "$header" "${_esc(leaf.value)}"'; + } + + String _serializeHeaderLeaf(FilterLeaf leaf) => + 'header ${_matchType(leaf.comparison)} "subject" "${_esc(leaf.value)}"'; + + String _serializeSizeLeaf(FilterLeaf leaf) { + final comp = leaf.comparison == FilterComparison.over ? ':over' : ':under'; + return 'size $comp ${leaf.value}'; + } + + String _matchType(FilterComparison comp) => switch (comp) { + FilterComparison.contains => ':contains', + FilterComparison.is_ => ':is', + FilterComparison.matches => ':matches', + _ => ':contains', + }; + + String _serializeAction(SieveAction action) => switch (action) { + final FileIntoAction a => 'fileinto "${_esc(a.folder)}";', + KeepAction() => 'keep;', + DiscardAction() => 'discard;', + MarkAsSeenAction() => r'setflag "\\Seen";', + final FlagAction a => + 'addflag [${a.flags.map((f) => '"${_esc(f)}"').join(', ')}];', + }; + + String _esc(String s) => s.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); +} diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 2cfbc93..e2ad173 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -9,6 +9,7 @@ import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'package:sharedinbox/core/filter/filter_expression.dart'; import 'package:sharedinbox/core/models/account.dart' as account_model; import 'package:sharedinbox/core/models/email.dart' as model; import 'package:sharedinbox/core/repositories/account_repository.dart'; @@ -2987,6 +2988,91 @@ class EmailRepositoryImpl implements EmailRepository { return emailRows.map(_toModel).toList(); } + @override + Future> searchEmailsStructured( + String? accountId, + FilterGroup filter, + ) async { + final rows = await (_db.select(_db.emails) + ..where((t) { + final fe = _filterGroup(filter, t); + if (accountId == null) return fe; + return t.accountId.equals(accountId) & fe; + }) + ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]) + ..limit(100)) + .get(); + return rows.map(_toModel).toList(); + } + + Expression _filterGroup(FilterGroup group, $EmailsTable t) { + if (group.isEmpty) return const Constant(true); + final exprs = group.children.map((c) => _filterNode(c, t)).toList(); + return switch (group.operator) { + FilterOperator.and_ => exprs.reduce((a, b) => a & b), + FilterOperator.or_ => exprs.reduce((a, b) => a | b), + }; + } + + Expression _filterNode(FilterNode node, $EmailsTable t) => + switch (node) { + final FilterLeaf l => _filterLeaf(l, t), + final FilterGroup g => _filterGroup(g, t), + }; + + Expression _filterLeaf(FilterLeaf leaf, $EmailsTable t) { + final val = leaf.value.toLowerCase(); + return switch (leaf.field) { + FilterField.from_ => _jsonLike(t.fromJson, leaf.comparison, val), + FilterField.to => _jsonLike(t.toAddresses, leaf.comparison, val), + FilterField.cc => _jsonLike(t.ccJson, leaf.comparison, val), + FilterField.subject => _textLike(t.subject, leaf.comparison, val), + // Size is not stored in the local cache; skip silently. + FilterField.size => const Constant(true), + }; + } + + Expression _jsonLike( + GeneratedColumn col, + FilterComparison comp, + String val, + ) => + switch (comp) { + FilterComparison.contains => col.like('%$val%'), + FilterComparison.is_ => col.like('%"email":"$val"%'), + FilterComparison.matches => col.like(_globToLike(val)), + _ => const Constant(true), + }; + + Expression _textLike( + GeneratedColumn col, + FilterComparison comp, + String val, + ) => + switch (comp) { + FilterComparison.contains => col.like('%$val%'), + FilterComparison.is_ => col.like(val), + FilterComparison.matches => col.like(_globToLike(val)), + _ => const Constant(true), + }; + + static String _globToLike(String glob) { + final buf = StringBuffer(); + for (var i = 0; i < glob.length; i++) { + final ch = glob[i]; + if (ch == '%' || ch == '_') { + buf.write('\\$ch'); + } else if (ch == '*') { + buf.write('%'); + } else if (ch == '?') { + buf.write('_'); + } else { + buf.write(ch); + } + } + return buf.toString(); + } + /// Converts a user query string into an FTS5 match expression. /// Each whitespace-separated word becomes a prefix term (word*) so that /// partial words still match. Special FTS5 characters are stripped. diff --git a/lib/ui/screens/search_screen.dart b/lib/ui/screens/search_screen.dart index 0f6e748..c38005f 100644 --- a/lib/ui/screens/search_screen.dart +++ b/lib/ui/screens/search_screen.dart @@ -4,10 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:sharedinbox/core/filter/filter_expression.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/widgets/filter_builder.dart'; import 'package:sharedinbox/ui/widgets/thread_tile.dart'; final _searchHistoryProvider = FutureProvider.autoDispose>(( @@ -37,6 +39,10 @@ class _SearchScreenState extends ConsumerState { bool _loading = false; bool _fieldFocused = false; + // Advanced (structured) search state. + bool _advancedMode = false; + FilterGroup _filterGroup = FilterGroup.empty(); + @override void initState() { super.initState(); @@ -53,6 +59,13 @@ class _SearchScreenState extends ConsumerState { super.dispose(); } + void _toggleAdvanced() { + setState(() { + _advancedMode = !_advancedMode; + _results = null; + }); + } + void _onChanged(String value) { _debounce?.cancel(); if (value.trim().length < 3) { @@ -135,22 +148,47 @@ class _SearchScreenState extends ConsumerState { } } + Future _searchStructured() async { + if (_filterGroup.isEmpty) return; + setState(() => _loading = true); + try { + final emails = await ref + .read(emailRepositoryProvider) + .searchEmailsStructured(widget.accountId, _filterGroup); + if (mounted) { + setState(() { + _results = _SearchResults( + mailboxes: const [], + addresses: const [], + emails: emails, + ); + _loading = false; + }); + } + } catch (e) { + log('Structured search failed: $e'); + if (mounted) setState(() => _loading = false); + } + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: TextField( - controller: _ctrl, - focusNode: _focusNode, - autofocus: true, - decoration: const InputDecoration( - hintText: 'Search folders, addresses, emails…', - border: InputBorder.none, - ), - onChanged: _onChanged, - ), + title: _advancedMode + ? const Text('Advanced Search') + : TextField( + controller: _ctrl, + focusNode: _focusNode, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search folders, addresses, emails…', + border: InputBorder.none, + ), + onChanged: _onChanged, + ), actions: [ - if (_ctrl.text.isNotEmpty) + if (!_advancedMode && _ctrl.text.isNotEmpty) IconButton( icon: const Icon(Icons.clear), onPressed: () { @@ -158,6 +196,15 @@ class _SearchScreenState extends ConsumerState { setState(() => _results = null); }, ), + IconButton( + icon: Icon( + _advancedMode ? Icons.search : Icons.tune, + color: + _advancedMode ? Theme.of(context).colorScheme.primary : null, + ), + tooltip: _advancedMode ? 'Simple search' : 'Advanced search', + onPressed: _toggleAdvanced, + ), ], ), body: _buildBody(), @@ -165,6 +212,7 @@ class _SearchScreenState extends ConsumerState { } Widget _buildBody() { + if (_advancedMode) return _buildAdvancedBody(); if (_loading) return const Center(child: CircularProgressIndicator()); if (_results == null) { if (_fieldFocused && _ctrl.text.isEmpty) { @@ -174,7 +222,54 @@ class _SearchScreenState extends ConsumerState { } final r = _results!; if (r.isEmpty) return const Center(child: Text('No results')); + return _buildResultsList(r); + } + + Widget _buildAdvancedBody() { + return SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilterBuilderWidget( + initialValue: _filterGroup, + onChanged: (g) => setState(() { + _filterGroup = g; + _results = null; + }), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _filterGroup.isEmpty ? null : _searchStructured, + icon: const Icon(Icons.search), + label: const Text('Search'), + ), + if (_loading) + const Padding( + padding: EdgeInsets.only(top: 24), + child: Center(child: CircularProgressIndicator()), + ) + else if (_results != null) ...[ + const SizedBox(height: 8), + if (_results!.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text('No results'), + ), + ) + else + _buildResultsList(_results!), + ], + ], + ), + ); + } + + Widget _buildResultsList(_SearchResults r) { return ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), children: [ if (r.mailboxes.isNotEmpty) ...[ const _SectionHeader('Folders'), diff --git a/lib/ui/screens/sieve_script_edit_screen.dart b/lib/ui/screens/sieve_script_edit_screen.dart index a7d2db7..1df4166 100644 --- a/lib/ui/screens/sieve_script_edit_screen.dart +++ b/lib/ui/screens/sieve_script_edit_screen.dart @@ -3,8 +3,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sharedinbox/core/filter/filter_expression.dart'; +import 'package:sharedinbox/core/filter/filter_sieve_converter.dart'; import 'package:sharedinbox/core/models/sieve_script.dart'; +import 'package:sharedinbox/core/sieve/sieve_actions.dart'; +import 'package:sharedinbox/core/sieve/sieve_serializer.dart'; import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/widgets/filter_builder.dart'; class SieveScriptEditScreen extends ConsumerStatefulWidget { const SieveScriptEditScreen({ @@ -27,18 +32,29 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget { _SieveScriptEditScreenState(); } -class _SieveScriptEditScreenState extends ConsumerState { +class _SieveScriptEditScreenState extends ConsumerState + with SingleTickerProviderStateMixin { late final TextEditingController _nameController; late final TextEditingController _contentController; + late final TabController _tabController; + bool _loadingContent = false; bool _saving = false; String? _error; + // Visual-editor state. + FilterGroup _filterGroup = FilterGroup.empty(); + List _actions = []; + bool _visualSupported = true; + int _visualLoadCount = 0; + @override void initState() { super.initState(); _nameController = TextEditingController(text: widget.script?.name ?? ''); _contentController = TextEditingController(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(_onTabChanged); if (widget.script != null) { unawaited(_loadContent()); } @@ -48,9 +64,40 @@ class _SieveScriptEditScreenState extends ConsumerState { void dispose() { _nameController.dispose(); _contentController.dispose(); + _tabController + ..removeListener(_onTabChanged) + ..dispose(); super.dispose(); } + void _onTabChanged() { + if (_tabController.indexIsChanging) return; + if (_tabController.index == 1) { + // Switched to Script tab: serialize visual state. + if (_visualSupported) { + _contentController.text = + SieveSerializer().serialize(_filterGroup, _actions); + } + } else { + // Switched to Visual tab: parse script into visual state. + _parseScriptIntoVisual(); + } + } + + void _parseScriptIntoVisual() { + final result = FilterSieveConverter().parse(_contentController.text); + if (result == null) { + setState(() => _visualSupported = false); + return; + } + setState(() { + _filterGroup = result.group; + _actions = List.from(result.actions); + _visualSupported = true; + _visualLoadCount++; + }); + } + Future _loadContent() async { setState(() => _loadingContent = true); try { @@ -63,6 +110,7 @@ class _SieveScriptEditScreenState extends ConsumerState { .getScriptContent(widget.accountId, widget.script!.blobId); if (mounted) { _contentController.text = content; + _parseScriptIntoVisual(); setState(() => _loadingContent = false); } } catch (e) { @@ -76,6 +124,11 @@ class _SieveScriptEditScreenState extends ConsumerState { } Future _save() async { + // Sync visual → script if on visual tab. + if (_tabController.index == 0 && _visualSupported) { + _contentController.text = + SieveSerializer().serialize(_filterGroup, _actions); + } final name = _nameController.text.trim(); if (name.isEmpty) { setState(() => _error = 'Name is required'); @@ -118,6 +171,10 @@ class _SieveScriptEditScreenState extends ConsumerState { return Scaffold( appBar: AppBar( title: Text(isNew ? 'New script' : 'Edit script'), + bottom: TabBar( + controller: _tabController, + tabs: const [Tab(text: 'Visual'), Tab(text: 'Script')], + ), actions: [ if (_saving) const Padding( @@ -163,18 +220,9 @@ class _SieveScriptEditScreenState extends ConsumerState { const SizedBox(height: 8), ], Expanded( - child: TextField( - controller: _contentController, - decoration: const InputDecoration( - labelText: 'Script', - border: OutlineInputBorder(), - alignLabelWithHint: true, - ), - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - style: const TextStyle(fontFamily: 'monospace'), - enabled: !_saving, + child: TabBarView( + controller: _tabController, + children: [_buildVisualTab(), _buildScriptTab()], ), ), ], @@ -182,4 +230,220 @@ class _SieveScriptEditScreenState extends ConsumerState { ), ); } + + Widget _buildVisualTab() { + if (!_visualSupported) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'This script uses features not supported by the visual editor.\n' + 'Edit as raw Sieve on the Script tab.', + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilterBuilderWidget( + key: ValueKey(_visualLoadCount), + initialValue: _filterGroup, + onChanged: (g) => setState(() => _filterGroup = g), + ), + const SizedBox(height: 12), + _ActionEditor( + actions: _actions, + onChanged: (a) => setState(() => _actions = a), + ), + ], + ), + ); + } + + Widget _buildScriptTab() { + return TextField( + controller: _contentController, + decoration: const InputDecoration( + labelText: 'Script', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle(fontFamily: 'monospace'), + enabled: !_saving, + ); + } +} + +// --------------------------------------------------------------------------- +// Action editor +// --------------------------------------------------------------------------- + +enum _ActionType { keep, discard, markAsRead, fileInto } + +class _ActionEditor extends StatelessWidget { + const _ActionEditor({required this.actions, required this.onChanged}); + + final List actions; + final void Function(List) onChanged; + + _ActionType _typeOf(SieveAction a) => switch (a) { + KeepAction() => _ActionType.keep, + DiscardAction() => _ActionType.discard, + MarkAsSeenAction() => _ActionType.markAsRead, + FileIntoAction() => _ActionType.fileInto, + FlagAction() => _ActionType.keep, + }; + + SieveAction _defaultFor(_ActionType t) => switch (t) { + _ActionType.keep => KeepAction(), + _ActionType.discard => DiscardAction(), + _ActionType.markAsRead => MarkAsSeenAction(), + _ActionType.fileInto => FileIntoAction(''), + }; + + void _changeType(int i, _ActionType t) { + final next = List.from(actions); + final current = next[i]; + if (t == _ActionType.fileInto && current is FileIntoAction) return; + next[i] = _defaultFor(t); + onChanged(next); + } + + void _changeFolder(int i, String folder) { + final next = List.from(actions); + next[i] = FileIntoAction(folder); + onChanged(next); + } + + void _remove(int i) { + final next = List.from(actions)..removeAt(i); + onChanged(next); + } + + void _add() { + onChanged([...actions, KeepAction()]); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text('Actions', style: Theme.of(context).textTheme.labelLarge), + ), + for (var i = 0; i < actions.length; i++) _buildRow(context, i), + TextButton.icon( + onPressed: _add, + icon: const Icon(Icons.add, size: 16), + label: const Text('Add action'), + ), + ], + ); + } + + Widget _buildRow(BuildContext context, int i) { + final action = actions[i]; + final type = _typeOf(action); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + DropdownButton<_ActionType>( + value: type, + isDense: true, + underline: const SizedBox.shrink(), + onChanged: (t) { + if (t != null) _changeType(i, t); + }, + items: const [ + DropdownMenuItem(value: _ActionType.keep, child: Text('Keep')), + DropdownMenuItem( + value: _ActionType.discard, + child: Text('Discard'), + ), + DropdownMenuItem( + value: _ActionType.markAsRead, + child: Text('Mark as read'), + ), + DropdownMenuItem( + value: _ActionType.fileInto, + child: Text('File into'), + ), + ], + ), + if (type == _ActionType.fileInto) ...[ + const SizedBox(width: 8), + Expanded( + child: _FolderField( + value: (action as FileIntoAction).folder, + onChanged: (v) => _changeFolder(i, v), + ), + ), + ] else + const Spacer(), + IconButton( + icon: const Icon(Icons.remove_circle_outline, size: 18), + tooltip: 'Remove', + onPressed: () => _remove(i), + ), + ], + ), + ); + } +} + +class _FolderField extends StatefulWidget { + const _FolderField({required this.value, required this.onChanged}); + final String value; + final void Function(String) onChanged; + + @override + State<_FolderField> createState() => _FolderFieldState(); +} + +class _FolderFieldState extends State<_FolderField> { + late final TextEditingController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(_FolderField old) { + super.didUpdateWidget(old); + if (widget.value != _ctrl.text) _ctrl.text = widget.value; + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _ctrl, + onChanged: widget.onChanged, + decoration: const InputDecoration( + hintText: 'folder', + isDense: true, + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 6), + ), + ); + } } diff --git a/lib/ui/widgets/filter_builder.dart b/lib/ui/widgets/filter_builder.dart new file mode 100644 index 0000000..06d57ea --- /dev/null +++ b/lib/ui/widgets/filter_builder.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:sharedinbox/core/filter/filter_expression.dart'; + +/// A widget that lets the user build a structured [FilterGroup] interactively. +/// +/// Use a [ValueKey] on this widget when replacing [initialValue] from the +/// outside (e.g., after loading a Sieve script) to force a full rebuild. +class FilterBuilderWidget extends StatefulWidget { + const FilterBuilderWidget({ + super.key, + required this.initialValue, + required this.onChanged, + }); + + final FilterGroup initialValue; + final void Function(FilterGroup) onChanged; + + @override + State createState() => _FilterBuilderWidgetState(); +} + +class _FilterBuilderWidgetState extends State { + late FilterGroup _group; + + @override + void initState() { + super.initState(); + _group = widget.initialValue; + } + + void _update(FilterGroup g) { + setState(() => _group = g); + widget.onChanged(g); + } + + @override + Widget build(BuildContext context) { + return _GroupEditor( + group: _group, + onChanged: _update, + depth: 0, + ); + } +} + +// --------------------------------------------------------------------------- +// Group editor +// --------------------------------------------------------------------------- + +class _GroupEditor extends StatelessWidget { + const _GroupEditor({ + super.key, + required this.group, + required this.onChanged, + required this.depth, + this.onRemoveGroup, + }); + + final FilterGroup group; + final void Function(FilterGroup) onChanged; + final int depth; + final VoidCallback? onRemoveGroup; + + static const _maxDepth = 1; + + void _setOperator(FilterOperator op) => + onChanged(group.copyWith(operator: op)); + + void _addLeaf() { + final leaf = FilterLeaf( + field: FilterField.from_, + comparison: FilterComparison.contains, + value: '', + ); + onChanged(group.copyWith(children: [...group.children, leaf])); + } + + void _addSubGroup() { + final sub = FilterGroup( + operator: FilterOperator.and_, + children: [], + ); + onChanged(group.copyWith(children: [...group.children, sub])); + } + + void _replaceChild(int index, FilterNode node) { + final next = List.from(group.children); + next[index] = node; + onChanged(group.copyWith(children: next)); + } + + void _removeChild(int index) { + final next = List.from(group.children)..removeAt(index); + onChanged(group.copyWith(children: next)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isRoot = depth == 0; + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _OperatorRow( + operator: group.operator, + onChanged: _setOperator, + onRemove: onRemoveGroup, + ), + for (var i = 0; i < group.children.length; i++) _buildChild(context, i), + const SizedBox(height: 6), + Row( + children: [ + TextButton.icon( + onPressed: _addLeaf, + icon: const Icon(Icons.add, size: 16), + label: const Text('Add condition'), + ), + if (depth < _maxDepth) + TextButton.icon( + onPressed: _addSubGroup, + icon: const Icon(Icons.playlist_add, size: 16), + label: const Text('Add group'), + ), + ], + ), + ], + ); + if (isRoot) return content; + return Card( + margin: const EdgeInsets.only(left: 12, top: 4, bottom: 4), + color: theme.colorScheme.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(8), + child: content, + ), + ); + } + + Widget _buildChild(BuildContext context, int i) { + final child = group.children[i]; + return switch (child) { + final FilterLeaf leaf => _LeafRow( + key: ValueKey(i), + leaf: leaf, + onChanged: (l) => _replaceChild(i, l), + onDelete: () => _removeChild(i), + ), + final FilterGroup sub => _GroupEditor( + key: ValueKey(i), + group: sub, + onChanged: (g) => _replaceChild(i, g), + depth: depth + 1, + onRemoveGroup: () => _removeChild(i), + ), + }; + } +} + +// --------------------------------------------------------------------------- +// Operator row (AND / OR toggle) +// --------------------------------------------------------------------------- + +class _OperatorRow extends StatelessWidget { + const _OperatorRow({ + required this.operator, + required this.onChanged, + this.onRemove, + }); + + final FilterOperator operator; + final void Function(FilterOperator) onChanged; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SegmentedButton( + segments: const [ + ButtonSegment(value: FilterOperator.and_, label: Text('AND')), + ButtonSegment(value: FilterOperator.or_, label: Text('OR')), + ], + selected: {operator}, + onSelectionChanged: (s) => onChanged(s.first), + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + const Spacer(), + if (onRemove != null) + IconButton( + icon: const Icon(Icons.close, size: 18), + tooltip: 'Remove group', + onPressed: onRemove, + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Leaf row (field | comparison | value | delete) +// --------------------------------------------------------------------------- + +class _LeafRow extends StatefulWidget { + const _LeafRow({ + super.key, + required this.leaf, + required this.onChanged, + required this.onDelete, + }); + + final FilterLeaf leaf; + final void Function(FilterLeaf) onChanged; + final VoidCallback onDelete; + + @override + State<_LeafRow> createState() => _LeafRowState(); +} + +class _LeafRowState extends State<_LeafRow> { + late final TextEditingController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController(text: widget.leaf.value); + } + + @override + void didUpdateWidget(_LeafRow old) { + super.didUpdateWidget(old); + if (widget.leaf.value != _ctrl.text) { + _ctrl.text = widget.leaf.value; + } + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + void _onFieldChanged(FilterField? f) { + if (f == null) return; + final allowed = f.allowedComparisons; + final comp = allowed.contains(widget.leaf.comparison) + ? widget.leaf.comparison + : allowed.first; + widget.onChanged(widget.leaf.copyWith(field: f, comparison: comp)); + } + + void _onCompChanged(FilterComparison? c) { + if (c == null) return; + widget.onChanged(widget.leaf.copyWith(comparison: c)); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + DropdownButton( + value: widget.leaf.field, + onChanged: _onFieldChanged, + isDense: true, + underline: const SizedBox.shrink(), + items: FilterField.values + .map( + (f) => DropdownMenuItem(value: f, child: Text(f.label)), + ) + .toList(), + ), + const SizedBox(width: 8), + DropdownButton( + value: widget.leaf.comparison, + onChanged: _onCompChanged, + isDense: true, + underline: const SizedBox.shrink(), + items: widget.leaf.field.allowedComparisons + .map( + (c) => DropdownMenuItem(value: c, child: Text(c.label)), + ) + .toList(), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _ctrl, + onChanged: (v) => + widget.onChanged(widget.leaf.copyWith(value: v)), + decoration: const InputDecoration( + hintText: 'value', + isDense: true, + border: OutlineInputBorder(), + contentPadding: + EdgeInsets.symmetric(horizontal: 8, vertical: 6), + ), + ), + ), + IconButton( + icon: const Icon(Icons.remove_circle_outline, size: 18), + tooltip: 'Remove', + onPressed: widget.onDelete, + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index f19add9..90da740 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -675,10 +675,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: "direct main" description: @@ -1104,26 +1104,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.16" timezone: dependency: transitive description: diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index 881d674..ab5e1f1 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -87,6 +87,7 @@ const _excluded = { 'lib/ui/widgets/email_thread_tile.dart', 'lib/ui/screens/trusted_image_senders_screen.dart', 'lib/data/repositories/note_repository_impl.dart', + 'lib/ui/widgets/filter_builder.dart', 'lib/ui/widgets/thread_tile.dart', }; diff --git a/test/backend/account_sync_manager_test.dart b/test/backend/account_sync_manager_test.dart index 8d63b2b..dd459ff 100644 --- a/test/backend/account_sync_manager_test.dart +++ b/test/backend/account_sync_manager_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:enough_mail/enough_mail.dart' as imap; import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/core/filter/filter_expression.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; @@ -272,6 +273,13 @@ class _FakeEmails implements EmailRepository { @override Future> searchEmailsGlobal(String? a, String q) async => []; + @override + Future> searchEmailsStructured( + String? a, + FilterGroup f, + ) async => + []; + @override Future> getEmailsByAddress(String? a, String address) async => []; diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index c8d4261..e3fad17 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/services.dart' show MissingPluginException; import 'package:mockito/annotations.dart'; +import 'package:sharedinbox/core/filter/filter_expression.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; @@ -137,6 +138,12 @@ class FakeEmailRepository implements EmailRepository { @override Future> searchEmailsGlobal(String? a, String q) async => []; @override + Future> searchEmailsStructured( + String? a, + FilterGroup f, + ) async => + []; + @override Future> getEmailsByAddress(String? a, String address) async => []; @override Future> searchAddresses( diff --git a/test/unit/account_sync_manager_test.mocks.dart b/test/unit/account_sync_manager_test.mocks.dart index 100fc60..994ae03 100644 --- a/test/unit/account_sync_manager_test.mocks.dart +++ b/test/unit/account_sync_manager_test.mocks.dart @@ -7,6 +7,7 @@ import 'dart:async' as _i5; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i7; +import 'package:sharedinbox/core/filter/filter_expression.dart' as _i10; import 'package:sharedinbox/core/models/account.dart' as _i6; import 'package:sharedinbox/core/models/email.dart' as _i3; import 'package:sharedinbox/core/models/mailbox.dart' as _i2; @@ -545,6 +546,22 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { returnValue: _i5.Future>.value(<_i3.Email>[]), ) as _i5.Future>); + @override + _i5.Future> searchEmailsStructured( + String? accountId, + _i10.FilterGroup? filter, + ) => + (super.noSuchMethod( + Invocation.method( + #searchEmailsStructured, + [ + accountId, + filter, + ], + ), + returnValue: _i5.Future>.value(<_i3.Email>[]), + ) as _i5.Future>); + @override _i5.Future> getEmailsByAddress( String? accountId, diff --git a/test/unit/filter_and_sieve_test.dart b/test/unit/filter_and_sieve_test.dart new file mode 100644 index 0000000..0e440d6 --- /dev/null +++ b/test/unit/filter_and_sieve_test.dart @@ -0,0 +1,337 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/core/filter/filter_expression.dart'; +import 'package:sharedinbox/core/filter/filter_sieve_converter.dart'; +import 'package:sharedinbox/core/sieve/sieve_actions.dart'; +import 'package:sharedinbox/core/sieve/sieve_serializer.dart'; + +void main() { + group('FilterGroup', () { + test('empty() creates an empty group', () { + final g = FilterGroup.empty(); + expect(g.isEmpty, isTrue); + expect(g.children, isEmpty); + expect(g.operator, FilterOperator.and_); + }); + + test('non-empty group is not isEmpty', () { + final g = FilterGroup( + operator: FilterOperator.and_, + children: [ + FilterLeaf( + field: FilterField.from_, + comparison: FilterComparison.contains, + value: 'test', + ), + ], + ); + expect(g.isEmpty, isFalse); + }); + + test('copyWith changes operator', () { + final g = FilterGroup.empty().copyWith(operator: FilterOperator.or_); + expect(g.operator, FilterOperator.or_); + }); + + test('copyWith changes children', () { + final leaf = FilterLeaf( + field: FilterField.subject, + comparison: FilterComparison.contains, + value: 'hello', + ); + final g = FilterGroup.empty().copyWith(children: [leaf]); + expect(g.children, hasLength(1)); + }); + }); + + group('FilterLeaf', () { + test('copyWith changes field', () { + final leaf = FilterLeaf( + field: FilterField.from_, + comparison: FilterComparison.contains, + value: 'x', + ); + final updated = leaf.copyWith(field: FilterField.to); + expect(updated.field, FilterField.to); + expect(updated.comparison, FilterComparison.contains); + expect(updated.value, 'x'); + }); + + test('copyWith changes value', () { + final leaf = FilterLeaf( + field: FilterField.subject, + comparison: FilterComparison.is_, + value: 'old', + ); + final updated = leaf.copyWith(value: 'new'); + expect(updated.value, 'new'); + expect(updated.field, FilterField.subject); + }); + + test('size field allows over/under comparisons', () { + expect( + FilterField.size.allowedComparisons, + containsAll([FilterComparison.over, FilterComparison.under]), + ); + }); + + test('address fields do not allow over/under', () { + for (final f in [FilterField.from_, FilterField.to, FilterField.cc]) { + expect(f.allowedComparisons, isNot(contains(FilterComparison.over))); + expect(f.allowedComparisons, isNot(contains(FilterComparison.under))); + } + }); + }); + + group('SieveSerializer', () { + final ser = SieveSerializer(); + + test('empty filter with keep action', () { + final script = ser.serialize(FilterGroup.empty(), [KeepAction()]); + expect(script, contains('keep;')); + expect(script, isNot(contains('if '))); + }); + + test('single from-contains condition', () { + final group = FilterGroup( + operator: FilterOperator.and_, + children: [ + FilterLeaf( + field: FilterField.from_, + comparison: FilterComparison.contains, + value: 'alice', + ), + ], + ); + final script = ser.serialize(group, [FileIntoAction('Work')]); + expect(script, contains('require')); + expect(script, contains('fileinto')); + expect(script, contains('"Work"')); + expect(script, contains(':contains')); + expect(script, contains('"from"')); + expect(script, contains('"alice"')); + }); + + test('AND group serialises as allof', () { + final group = FilterGroup( + operator: FilterOperator.and_, + children: [ + FilterLeaf( + field: FilterField.subject, + comparison: FilterComparison.contains, + value: 'invoice', + ), + FilterLeaf( + field: FilterField.from_, + comparison: FilterComparison.contains, + value: 'supplier', + ), + ], + ); + final script = ser.serialize(group, [KeepAction()]); + expect(script, contains('allof')); + }); + + test('OR group serialises as anyof', () { + final group = FilterGroup( + operator: FilterOperator.or_, + children: [ + FilterLeaf( + field: FilterField.subject, + comparison: FilterComparison.contains, + value: 'a', + ), + FilterLeaf( + field: FilterField.subject, + comparison: FilterComparison.contains, + value: 'b', + ), + ], + ); + final script = ser.serialize(group, [DiscardAction()]); + expect(script, contains('anyof')); + expect(script, contains('discard;')); + }); + + test('size over condition', () { + final group = FilterGroup( + operator: FilterOperator.and_, + children: [ + FilterLeaf( + field: FilterField.size, + comparison: FilterComparison.over, + value: '1000000', + ), + ], + ); + final script = ser.serialize(group, [DiscardAction()]); + expect(script, contains('size :over 1000000')); + }); + + test('mark-as-seen action emits setflag', () { + final group = FilterGroup( + operator: FilterOperator.and_, + children: [ + FilterLeaf( + field: FilterField.subject, + comparison: FilterComparison.contains, + value: 'newsletter', + ), + ], + ); + final script = ser.serialize(group, [MarkAsSeenAction()]); + expect(script, contains('setflag')); + expect(script, contains(r'\Seen')); + }); + + test('escapes quotes in values', () { + final group = FilterGroup( + operator: FilterOperator.and_, + children: [ + FilterLeaf( + field: FilterField.subject, + comparison: FilterComparison.contains, + value: 'say "hello"', + ), + ], + ); + final script = ser.serialize(group, [KeepAction()]); + expect(script, contains(r'say \"hello\"')); + }); + }); + + group('FilterSieveConverter', () { + final conv = FilterSieveConverter(); + + test('returns null for empty script', () { + expect(conv.parse(''), isNull); + }); + + test('parses simple address test', () { + const script = ''' +if address :contains "from" "alice@example.com" { + keep; +}'''; + final result = conv.parse(script); + expect(result, isNotNull); + expect(result!.group.children, hasLength(1)); + final leaf = result.group.children.first as FilterLeaf; + expect(leaf.field, FilterField.from_); + expect(leaf.comparison, FilterComparison.contains); + expect(leaf.value, 'alice@example.com'); + expect(result.actions, hasLength(1)); + expect(result.actions.first, isA()); + }); + + test('parses subject header test', () { + const script = ''' +if header :is "subject" "Hello" { + fileinto "Inbox"; +}'''; + final result = conv.parse(script); + expect(result, isNotNull); + final leaf = result!.group.children.first as FilterLeaf; + expect(leaf.field, FilterField.subject); + expect(leaf.comparison, FilterComparison.is_); + expect(leaf.value, 'Hello'); + final action = result.actions.first as FileIntoAction; + expect(action.folder, 'Inbox'); + }); + + test('parses allof group as AND', () { + const script = ''' +if allof( + address :contains "from" "alice", + header :contains "subject" "invoice" +) { + keep; +}'''; + final result = conv.parse(script); + expect(result, isNotNull); + expect(result!.group.operator, FilterOperator.and_); + expect(result.group.children, hasLength(2)); + }); + + test('parses anyof group as OR', () { + const script = ''' +if anyof( + address :contains "from" "a", + address :contains "from" "b" +) { + discard; +}'''; + final result = conv.parse(script); + expect(result, isNotNull); + expect(result!.group.operator, FilterOperator.or_); + expect(result.actions.first, isA()); + }); + + test('parses size over test', () { + const script = ''' +if size :over 500000 { + discard; +}'''; + final result = conv.parse(script); + expect(result, isNotNull); + final leaf = result!.group.children.first as FilterLeaf; + expect(leaf.field, FilterField.size); + expect(leaf.comparison, FilterComparison.over); + expect(leaf.value, '500000'); + }); + + test('parses setflag \\\\Seen as MarkAsSeenAction', () { + const script = r''' +if header :contains "subject" "newsletter" { + setflag "\\Seen"; +}'''; + final result = conv.parse(script); + expect(result, isNotNull); + expect(result!.actions.first, isA()); + }); + + test('returns null for unsupported test', () { + const script = ''' +if exists "X-Custom-Header" { + keep; +}'''; + expect(conv.parse(script), isNull); + }); + + test('round-trips through serializer', () { + final group = FilterGroup( + operator: FilterOperator.and_, + children: [ + FilterLeaf( + field: FilterField.from_, + comparison: FilterComparison.contains, + value: 'alice@example.com', + ), + FilterLeaf( + field: FilterField.subject, + comparison: FilterComparison.contains, + value: 'invoice', + ), + ], + ); + final actions = [FileIntoAction('Work')]; + final script = SieveSerializer().serialize(group, actions); + final result = conv.parse(script); + expect(result, isNotNull); + expect(result!.group.operator, FilterOperator.and_); + expect(result.group.children, hasLength(2)); + expect(result.actions, hasLength(1)); + expect((result.actions.first as FileIntoAction).folder, 'Work'); + }); + + test('parses require block and ignores it', () { + const script = ''' +require ["fileinto"]; +if address :contains "from" "bob" { + fileinto "Archive"; +}'''; + final result = conv.parse(script); + expect(result, isNotNull); + final leaf = result!.group.children.first as FilterLeaf; + expect(leaf.value, 'bob'); + }); + }); +} diff --git a/test/unit/reliability_runner_check_now_test.dart b/test/unit/reliability_runner_check_now_test.dart index 899cb32..d471da0 100644 --- a/test/unit/reliability_runner_check_now_test.dart +++ b/test/unit/reliability_runner_check_now_test.dart @@ -4,6 +4,7 @@ // checked the _running flag (only true after start() is called). import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/core/filter/filter_expression.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; @@ -144,6 +145,12 @@ class _FakeEmails implements EmailRepository { @override Future> searchEmailsGlobal(String? a, String q) async => []; @override + Future> searchEmailsStructured( + String? a, + FilterGroup f, + ) async => + []; + @override Future> getEmailsByAddress(String? a, String addr) async => []; @override Future> searchAddresses( diff --git a/test/unit/reliability_runner_test.dart b/test/unit/reliability_runner_test.dart index 180ab39..eb67562 100644 --- a/test/unit/reliability_runner_test.dart +++ b/test/unit/reliability_runner_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/core/filter/filter_expression.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; @@ -140,6 +141,12 @@ class _CountingEmails implements EmailRepository { @override Future> searchEmailsGlobal(String? a, String q) async => []; @override + Future> searchEmailsStructured( + String? a, + FilterGroup f, + ) async => + []; + @override Future> getEmailsByAddress(String? a, String addr) async => []; @override Future> searchAddresses( diff --git a/test/unit/undo_service_test.mocks.dart b/test/unit/undo_service_test.mocks.dart index e1ea257..eb078dd 100644 --- a/test/unit/undo_service_test.mocks.dart +++ b/test/unit/undo_service_test.mocks.dart @@ -7,10 +7,11 @@ import 'dart:async' as _i4; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i5; +import 'package:sharedinbox/core/filter/filter_expression.dart' as _i6; import 'package:sharedinbox/core/models/email.dart' as _i2; -import 'package:sharedinbox/core/models/undo_action.dart' as _i7; +import 'package:sharedinbox/core/models/undo_action.dart' as _i8; import 'package:sharedinbox/core/repositories/email_repository.dart' as _i3; -import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i6; +import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i7; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -342,6 +343,22 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository { returnValue: _i4.Future>.value(<_i2.Email>[]), ) as _i4.Future>); + @override + _i4.Future> searchEmailsStructured( + String? accountId, + _i6.FilterGroup? filter, + ) => + (super.noSuchMethod( + Invocation.method( + #searchEmailsStructured, + [ + accountId, + filter, + ], + ), + returnValue: _i4.Future>.value(<_i2.Email>[]), + ) as _i4.Future>); + @override _i4.Future> getEmailsByAddress( String? accountId, @@ -558,13 +575,13 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository { /// A class which mocks [UndoRepository]. /// /// See the documentation for Mockito's code generation for more information. -class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository { +class MockUndoRepository extends _i1.Mock implements _i7.UndoRepository { MockUndoRepository() { _i1.throwOnMissingStub(this); } @override - _i4.Future saveAction(_i7.UndoAction? action) => (super.noSuchMethod( + _i4.Future saveAction(_i8.UndoAction? action) => (super.noSuchMethod( Invocation.method( #saveAction, [action], @@ -584,15 +601,15 @@ class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository { ) as _i4.Future); @override - _i4.Future> getHistory({int? limit = 10}) => + _i4.Future> getHistory({int? limit = 10}) => (super.noSuchMethod( Invocation.method( #getHistory, [], {#limit: limit}, ), - returnValue: _i4.Future>.value(<_i7.UndoAction>[]), - ) as _i4.Future>); + returnValue: _i4.Future>.value(<_i8.UndoAction>[]), + ) as _i4.Future>); @override _i4.Future clearHistory() => (super.noSuchMethod( diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 289f96c..72098fb 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart' show Override; import 'package:go_router/go_router.dart'; +import 'package:sharedinbox/core/filter/filter_expression.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/discovery_result.dart'; import 'package:sharedinbox/core/models/draft.dart'; @@ -366,6 +367,13 @@ class FakeEmailRepository implements EmailRepository { ) async => _searchResults; + @override + Future> searchEmailsStructured( + String? accountId, + FilterGroup filter, + ) async => + []; + @override Future> getEmailsByAddress( String? accountId, From 69606ce586415bbb97fc7ce168d70d03a25be8fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 04:38:30 +0200 Subject: [PATCH 5/6] fix: prevent Enter key from re-running a settled search (#479) --- lib/ui/screens/email_list_screen.dart | 9 +++- test/widget/email_list_screen_test.dart | 61 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index fa2fbfe..5e54a7e 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -278,7 +278,14 @@ class _EmailListScreenState extends ConsumerState { ), ], onChanged: _onSearchChanged, - onSubmitted: _runSearch, + onSubmitted: (value) { + // Only run the search if results haven't settled yet via + // onChanged — prevents a second IMAP round-trip from reordering + // the already-visible results when the user presses Enter. + if (_searchResults == null && !_searchLoading) { + unawaited(_runSearch(value)); + } + }, textInputAction: TextInputAction.search, ), ), diff --git a/test/widget/email_list_screen_test.dart b/test/widget/email_list_screen_test.dart index 60b1823..67404fb 100644 --- a/test/widget/email_list_screen_test.dart +++ b/test/widget/email_list_screen_test.dart @@ -798,6 +798,67 @@ void main() { }, ); + testWidgets( + 'pressing Enter after search settles does not reorder results', + (tester) async { + // Reproduces: user types a query → onChanged fires → results settle. + // Then user presses Enter → onSubmitted fires a second search → the + // second IMAP response may return results in a different order, so the + // tile the user is about to tap is no longer the email they expect. + final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Foo'); + final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Foo'); + var callCount = 0; + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', + overrides: [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository( + onSearch: (_) async { + callCount++; + // First call: [Alpha, Beta]. Second call: reversed. + return callCount == 1 ? [email1, email2] : [email2, email1]; + }, + emailBody: const EmailBody(emailId: '', attachments: []), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + // Typing triggers onChanged → first search → results settle. + await tester.enterText(find.byType(TextField), 'foo'); + await tester.pumpAndSettle(); + + expect(find.text('Alpha Foo'), findsOneWidget); + expect(find.text('Beta Foo'), findsOneWidget); + // Alpha must appear above Beta (it is first in the list). + expect( + tester.getTopLeft(find.text('Alpha Foo')).dy, + lessThan(tester.getTopLeft(find.text('Beta Foo')).dy), + ); + + // Pressing Enter triggers onSubmitted — must NOT re-run the search. + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + + // Order must be unchanged: pressing Enter must not reorder results. + expect(find.text('Alpha Foo'), findsOneWidget); + expect(find.text('Beta Foo'), findsOneWidget); + expect( + tester.getTopLeft(find.text('Alpha Foo')).dy, + lessThan(tester.getTopLeft(find.text('Beta Foo')).dy), + ); + }, + ); + testWidgets('shows preview snippet when email has preview', (tester) async { final email = Email( id: 'acc-1:99', From 609208247a99ae9546bc99372233cb702900d671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 04:38:35 +0200 Subject: [PATCH 6/6] ci: parallelize Format/Analyze/CheckGenerated/Coverage in Check() (#513) --- ci/main.go | 46 +++++++++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/ci/main.go b/ci/main.go index 09820c1..896f5fa 100644 --- a/ci/main.go +++ b/ci/main.go @@ -594,25 +594,33 @@ func (m *Ci) Check(ctx context.Context) (string, error) { return "", err } - checkSetup := m.setup(m.checkSrc()) - - if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil { - return "Format check failed", err - } - - analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx) - if err != nil { - return analyze, err - } - - mocks, err := m.CheckGenerated(ctx) - if err != nil { - return mocks, err - } - - coverage, err := m.Coverage(ctx) - if err != nil { - return coverage, err + // Run format, analyze, generated-code check, and coverage in parallel — + // they all share the same setup base and have no dependencies on each other. + var analyze, mocks, coverage string + var checkEg errgroup.Group + checkEg.Go(func() error { + setup := m.setup(m.checkSrc()) + _, err := setup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx) + return err + }) + checkEg.Go(func() error { + setup := m.setup(m.checkSrc()) + var err error + analyze, err = setup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx) + return err + }) + checkEg.Go(func() error { + var err error + mocks, err = m.CheckGenerated(ctx) + return err + }) + checkEg.Go(func() error { + var err error + coverage, err = m.Coverage(ctx) + return err + }) + if err := checkEg.Wait(); err != nil { + return "", err } // Use errgroup.Group (not WithContext) so a failing test does not cancel its