From 156ccae83b6f334bc6dac0184ffa8926fe2934f8 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sat, 6 Jun 2026 23:41:13 +0200 Subject: [PATCH 01/12] fix(ci): forward SSH tunnel directly to dagger engine socket Eliminates the socat bridge dependency by using OpenSSH's built-in Unix socket forwarding (-L port:socket_path). The dagger user already owns /run/dagger/engine.sock so no intermediate TCP listener is needed. Co-Authored-By: Claude Sonnet 4.6 --- scripts/setup_dagger_remote.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/setup_dagger_remote.sh b/scripts/setup_dagger_remote.sh index 0f01768..974e93a 100755 --- a/scripts/setup_dagger_remote.sh +++ b/scripts/setup_dagger_remote.sh @@ -76,11 +76,12 @@ if [ "$_elapsed" -gt 10 ]; then echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond" fi -# Create a background SSH tunnel to the Dagger engine. -# We map local port 8080 to remote port 1774 (where our socat bridge is listening). +# Create a background SSH tunnel to the Dagger engine Unix socket. +# Forwards local TCP port 8080 directly to /run/dagger/engine.sock on the remote host, +# eliminating the need for a socat bridge on the server side. echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..." _t0=$SECONDS -timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST" +timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:/run/dagger/engine.sock "dagger@$DAGGER_ENGINE_HOST" _elapsed=$(( SECONDS - _t0 )) if [ "$_elapsed" -gt 10 ]; then echo "::warning::SSH tunnel setup took ${_elapsed}s" From a67b707a410a6a1c543fe28d63590a56f8085313 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sun, 7 Jun 2026 00:28:41 +0200 Subject: [PATCH 02/12] fix(test): sync before searching in searchEmails IMAP test searchEmails now queries local SQLite FTS5 instead of IMAP directly (since 65173d3). The test must call syncEmails first to populate the local index before searching. Co-Authored-By: Claude Sonnet 4.6 --- test/backend/email_repository_imap_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/test/backend/email_repository_imap_test.dart b/test/backend/email_repository_imap_test.dart index 19e92d9..50e009e 100644 --- a/test/backend/email_repository_imap_test.dart +++ b/test/backend/email_repository_imap_test.dart @@ -421,6 +421,7 @@ void main() { final r = makeRepo(); await r.accounts.addAccount(account, userPass); + await r.emails.syncEmails('test', 'INBOX'); final results = await r.emails.searchEmails('test', 'INBOX', uniqueWord); expect(results, hasLength(1)); From 916fc4bc6bf9ad5d9a98b605169af8c463133ac1 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sat, 6 Jun 2026 23:37:03 +0200 Subject: [PATCH 03/12] fix: swallow SQLITE_BUSY when setting WAL mode to prevent crash on startup (#508) A WorkManager background task may have the database open when the foreground app starts. Executing PRAGMA journal_mode = WAL on the second connection then fails with SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5), crashing the app before it renders. Two changes: 1. Move PRAGMA busy_timeout = 5000 before the WAL pragma so SQLite auto-retries plain SQLITE_BUSY (code 5) for up to 5 s. 2. Extract setup logic into _setupPragmas and catch SqliteException with resultCode == 5 (covers both SQLITE_BUSY and SQLITE_BUSY_SNAPSHOT). SQLITE_BUSY_SNAPSHOT only occurs when the DB is already in WAL mode, so the pragma is a no-op and it is safe to continue. Adds a regression test that opens a second connection while a read transaction holds a WAL snapshot open and verifies setupPragmasForTesting does not throw. Co-Authored-By: Claude Sonnet 4.6 --- lib/data/db/database.dart | 37 +++++++++++++++++++++++++---------- test/unit/migration_test.dart | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 103df36..93d3939 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sharedinbox/core/db_schema_version.dart'; +import 'package:sqlite3/sqlite3.dart' show Database; part 'database.g.dart'; @@ -793,18 +794,34 @@ Future resolveDatabasePathForTesting() => _resolveDatabasePath(); void resetDatabasePathForTesting() => _dbPath = null; Future androidFallbackPathForTesting() => _androidFallbackPath(); +/// Configures PRAGMAs on a newly opened SQLite connection. +/// +/// busy_timeout must come first so subsequent statements retry on SQLITE_BUSY +/// instead of immediately failing. +/// +/// journal_mode = WAL is wrapped in a try/catch because a concurrent +/// WorkManager background task may already have the DB open when the app +/// starts. SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) is +/// returned in that situation; it only occurs when the DB is already in WAL +/// mode, so the pragma would be a no-op anyway and it is safe to continue. +void _setupPragmas(Database db) { + db.execute('PRAGMA busy_timeout = 5000;'); + try { + db.execute('PRAGMA journal_mode = WAL;'); + } on SqliteException catch (e) { + // resultCode strips the extended bits: both SQLITE_BUSY (5) and + // SQLITE_BUSY_SNAPSHOT (261) reduce to 5. Re-throw anything else. + if (e.resultCode != 5) rethrow; + } +} + LazyDatabase _openConnection() { return LazyDatabase(() async { final file = File(await _resolveDatabasePath()); - return NativeDatabase.createInBackground( - file, - setup: (db) { - // WAL lets readers and writers proceed concurrently (different account - // sync loops share the same DB). busy_timeout makes SQLite retry for - // up to 5 s instead of immediately returning SQLITE_BUSY. - db.execute('PRAGMA journal_mode = WAL;'); - db.execute('PRAGMA busy_timeout = 5000;'); - }, - ); + return NativeDatabase.createInBackground(file, setup: _setupPragmas); }); } + +// Exposed so tests can run the exact production setup logic on a raw +// sqlite3 connection (same pattern as resolveDatabasePathForTesting). +void setupPragmasForTesting(Database db) => _setupPragmas(db); diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index e6e375f..a807456 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -510,4 +510,40 @@ void main() { await db.close(); }); }); + + // Regression test for https://codeberg.org/guettli/sharedinbox/issues/508: + // _openConnection's setup callback must not crash when PRAGMA journal_mode = + // WAL fails with SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) + // because a WorkManager background task already has the DB open in WAL mode. + group('WAL setup (#508)', () { + test( + 'setupPragmasForTesting does not throw when WAL is already active and ' + 'another connection holds an open read transaction', + () { + final dbFile = File('test_wal_busy_508.db'); + if (dbFile.existsSync()) dbFile.deleteSync(); + addTearDown(() { + if (dbFile.existsSync()) dbFile.deleteSync(); + }); + + // conn1: enable WAL and keep a read transaction open — simulates a + // WorkManager background task that opened the DB before the foreground + // app starts. + final conn1 = sqlite.sqlite3.open(dbFile.path); + conn1.execute('PRAGMA journal_mode = WAL;'); + conn1.execute('BEGIN;'); + conn1.select('SELECT 1;'); + + // conn2: run the exact production setup through setupPragmasForTesting. + // This must not throw even though conn1 holds an open transaction and + // the DB is already in WAL mode. + final conn2 = sqlite.sqlite3.open(dbFile.path); + expect(() => setupPragmasForTesting(conn2), returnsNormally); + + conn1.execute('ROLLBACK;'); + conn1.dispose(); + conn2.dispose(); + }, + ); + }); } From 1e2f124cd0b6935dcf6a934708218c85141a6d33 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sat, 6 Jun 2026 23:46:47 +0200 Subject: [PATCH 04/12] ci: re-trigger CI check From b7a8624c38fc1e746729c547035605d7f1aed741 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sun, 7 Jun 2026 00:13:09 +0200 Subject: [PATCH 05/12] fix(ci): forward SSH tunnel directly to dagger engine socket Co-Authored-By: Claude Sonnet 4.6 From 57b266a82bce2d271b5753e4ae17f2994be2c26a Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sun, 7 Jun 2026 00:19:01 +0200 Subject: [PATCH 06/12] fix(lint): move sqlite3 to dependencies, use close() instead of dispose() - sqlite3 is now imported in lib/ (production code), so it must be a regular dependency, not a dev_dependency - Replace deprecated conn.dispose() with conn.close() in the test Co-Authored-By: Claude Sonnet 4.6 --- pubspec.yaml | 2 +- test/unit/migration_test.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index a4b5218..99c9055 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: # Local persistence (offline-first) drift: ^2.20.3 + sqlite3: ^3.1.5 # used directly in lib/data/db/database.dart (_setupPragmas) sqlite3_flutter_libs: ^0.6.0+eol path_provider: ^2.1.5 path: ^1.9.1 @@ -78,7 +79,6 @@ dev_dependencies: mockito: ^5.4.4 fake_async: ^1.3.1 path_provider_platform_interface: ^2.1.2 - sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close() url_launcher_platform_interface: ^2.3.2 plugin_platform_interface: ^2.1.8 flutter_launcher_icons: ^0.14.0 diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index a807456..c52cfd6 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -541,8 +541,8 @@ void main() { expect(() => setupPragmasForTesting(conn2), returnsNormally); conn1.execute('ROLLBACK;'); - conn1.dispose(); - conn2.dispose(); + conn1.close(); + conn2.close(); }, ); }); From d92cfac761e79ab079af964435d79f43357e8c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 01:58:22 +0200 Subject: [PATCH 07/12] feat(search): include email notes in search results (#512) --- lib/core/repositories/email_repository.dart | 2 +- .../repositories/email_repository_impl.dart | 65 +++++++++++++- test/unit/email_repository_impl_test.dart | 90 +++++++++++++++++++ 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 28466bf..9a6e4b4 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -58,7 +58,7 @@ abstract class EmailRepository { ); /// Searches the local DB across all mailboxes of [accountId] (or all accounts - /// if null) by subject and preview. Fast, works offline. + /// if null) by subject, preview, and notes. Fast, works offline. Future> searchEmailsGlobal(String? accountId, String query); /// Returns all locally cached emails in any mailbox of [accountId] (or all diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 2d5218d..3bcdc01 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -2934,6 +2934,60 @@ class EmailRepositoryImpl implements EmailRepository { final emailRows = await Future.wait( queryRows.map((r) => _db.emails.mapFromRow(r)), ); + + final noteRows = await _searchEmailsByNotes(accountId, null, query); + + final seen = {}; + final merged = []; + for (final e in [...emailRows.map(_toModel), ...noteRows]) { + if (seen.add(e.id)) merged.add(e); + } + return merged; + } + + /// Returns emails whose associated notes contain all words from [query]. + /// Optionally filtered by [accountId] and [mailboxPath]. + Future> _searchEmailsByNotes( + String? accountId, + String? mailboxPath, + String query, + ) async { + final words = query + .trim() + .split(RegExp(r'\s+')) + .where((w) => w.isNotEmpty) + .toList(); + if (words.isEmpty) return []; + + final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND '); + final likeVars = + words.map((w) => Variable('%$w%')).toList(); + + final extraConditions = StringBuffer(); + final extraVars = >[]; + if (accountId != null) { + extraConditions.write(' AND e.account_id = ?'); + extraVars.add(Variable(accountId)); + } + if (mailboxPath != null) { + extraConditions.write(' AND e.mailbox_path = ?'); + extraVars.add(Variable(mailboxPath)); + } + + final sql = 'SELECT DISTINCT e.* FROM emails e' + ' JOIN email_notes n ON n.message_id = e.message_id' + ' AND n.account_id = e.account_id' + ' WHERE $noteConditions$extraConditions' + ' ORDER BY e.received_at DESC LIMIT 50'; + + final rows = await _db + .customSelect( + sql, + variables: [...likeVars, ...extraVars], + readsFrom: {_db.emails, _db.emailNotes}, + ) + .get(); + final emailRows = await Future.wait(rows.map((r) => _db.emails.mapFromRow(r))); return emailRows.map(_toModel).toList(); } @@ -3069,7 +3123,16 @@ class EmailRepositoryImpl implements EmailRepository { final emailRows = await Future.wait( queryRows.map((r) => _db.emails.mapFromRow(r)), ); - return emailRows.map(_toModel).toList(); + + final noteRows = + await _searchEmailsByNotes(accountId, mailboxPath, query); + + final seen = {}; + final merged = []; + for (final e in [...emailRows.map(_toModel), ...noteRows]) { + if (seen.add(e.id)) merged.add(e); + } + return merged; } // ── Helpers ──────────────────────────────────────────────────────────────── diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index 78ed2f9..ff24382 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -486,6 +486,96 @@ void main() { expect(results.first.mailboxPath, 'INBOX'); }); + test('searchEmailsGlobal includes emails matched by note text', () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + + // Email whose subject does NOT match — but its note does. + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + messageId: const Value(''), + subject: const Value('Weekly report'), + receivedAt: DateTime(2024), + ), + ); + // Add a note referencing the email's messageId. + await r.db.into(r.db.emailNotes).insert( + EmailNotesCompanion.insert( + id: 'note-1', + accountId: 'acc-1', + messageId: '', + noteText: 'Urgent follow-up needed', + serverId: '42', + createdAt: DateTime(2024), + ), + ); + + final results = + await r.emails.searchEmailsGlobal(null, 'urgent'); + expect(results, hasLength(1)); + expect(results.first.subject, 'Weekly report'); + }); + + test('searchEmails includes emails matched by note text in mailbox', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + messageId: const Value(''), + subject: const Value('Project update'), + receivedAt: DateTime(2024), + ), + ); + // Email in a different mailbox — its note must not appear in INBOX search. + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:2', + accountId: 'acc-1', + mailboxPath: 'Sent', + uid: 2, + messageId: const Value(''), + subject: const Value('Other email'), + receivedAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emailNotes).insert( + EmailNotesCompanion.insert( + id: 'note-1', + accountId: 'acc-1', + messageId: '', + noteText: 'remember to call client', + serverId: '42', + createdAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emailNotes).insert( + EmailNotesCompanion.insert( + id: 'note-2', + accountId: 'acc-1', + messageId: '', + noteText: 'remember to call client', + serverId: '43', + createdAt: DateTime(2024), + ), + ); + + final results = + await r.emails.searchEmails('acc-1', 'INBOX', 'client'); + expect(results, hasLength(1)); + expect(results.first.subject, 'Project update'); + expect(results.first.mailboxPath, 'INBOX'); + }); + test( 'searchAddresses returns results sorted by most recently used', () async { From f7fd30da15c3158c33cf5479b182bfd3e69c7ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 02:40:08 +0200 Subject: [PATCH 08/12] feat(ci): add Print runner wait time step to all workflow jobs (#517) --- .forgejo/workflows/ci.yml | 24 ++++++ .forgejo/workflows/deploy.yml | 120 ++++++++++++++++++++++++++ .forgejo/workflows/firebase-tests.yml | 48 +++++++++++ .forgejo/workflows/website.yml | 24 ++++++ 4 files changed, 216 insertions(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 3186575..7ad2ecb 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -10,6 +10,30 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data.get('workflow_runs', []): + if r.get('run_number') == $RUN_NUMBER: + print(r['created_at']) + break +" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 - name: Setup Dagger Remote Engine env: diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 1d6bc87..95bea84 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -15,6 +15,30 @@ jobs: linux: ${{ steps.diff.outputs.linux }} steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data.get('workflow_runs', []): + if r.get('run_number') == $RUN_NUMBER: + print(r['created_at']) + break +" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -141,6 +165,30 @@ jobs: if: needs.check-changes.outputs.android == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data.get('workflow_runs', []): + if r.get('run_number') == $RUN_NUMBER: + print(r['created_at']) + break +" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 100 @@ -175,6 +223,30 @@ jobs: if: needs.check-changes.outputs.android == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data.get('workflow_runs', []): + if r.get('run_number') == $RUN_NUMBER: + print(r['created_at']) + break +" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 100 @@ -203,6 +275,30 @@ jobs: if: needs.check-changes.outputs.linux == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data.get('workflow_runs', []): + if r.get('run_number') == $RUN_NUMBER: + print(r['created_at']) + break +" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 100 @@ -236,6 +332,30 @@ jobs: timeout-minutes: 5 steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data.get('workflow_runs', []): + if r.get('run_number') == $RUN_NUMBER: + print(r['created_at']) + break +" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue env: FORGEJO_TOKEN: ${{ github.token }} diff --git a/.forgejo/workflows/firebase-tests.yml b/.forgejo/workflows/firebase-tests.yml index edd3e81..7de2fc2 100644 --- a/.forgejo/workflows/firebase-tests.yml +++ b/.forgejo/workflows/firebase-tests.yml @@ -14,6 +14,30 @@ jobs: has_changes: ${{ steps.diff.outputs.has_changes }} steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data.get('workflow_runs', []): + if r.get('run_number') == $RUN_NUMBER: + print(r['created_at']) + break +" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -50,6 +74,30 @@ jobs: if: needs.check-changes.outputs.has_changes == 'true' steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data.get('workflow_runs', []): + if r.get('run_number') == $RUN_NUMBER: + print(r['created_at']) + break +" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: fetch-depth: 1 diff --git a/.forgejo/workflows/website.yml b/.forgejo/workflows/website.yml index 43c188d..ed48049 100644 --- a/.forgejo/workflows/website.yml +++ b/.forgejo/workflows/website.yml @@ -18,6 +18,30 @@ jobs: timeout-minutes: 60 steps: + - name: Print runner wait time + env: + FORGEJO_TOKEN: ${{ github.token }} + RUN_NUMBER: ${{ github.run_number }} + run: | + runner_start=$(date +%s) + created_at=$(curl -sf \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ + | python3 -c " +import sys, json +data = json.load(sys.stdin) +for r in data.get('workflow_runs', []): + if r.get('run_number') == $RUN_NUMBER: + print(r['created_at']) + break +" 2>/dev/null) + if [ -n "$created_at" ]; then + queued_epoch=$(date -d "$created_at" +%s) + wait_seconds=$((runner_start - queued_epoch)) + echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" + else + echo "Runner wait time: unknown (API lookup failed)" + fi - uses: actions/checkout@v4 with: submodules: recursive From d55b316d4cb90afd4554f501be72fbce1f4359a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 02:40:13 +0200 Subject: [PATCH 09/12] ci: add concurrency cancel-in-progress to ci.yml (#516) --- .forgejo/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 7ad2ecb..a78ea51 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -4,6 +4,9 @@ on: branches: - main pull_request: +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: check: name: Full Project Check From f5abe9132bdb40deeedc1a9fa545a3d3ee239a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 02:49:53 +0200 Subject: [PATCH 10/12] fix(test): sync before searching in second searchEmails IMAP test (#519) --- lib/data/repositories/email_repository_impl.dart | 2 ++ test/backend/email_repository_imap_test.dart | 1 + 2 files changed, 3 insertions(+) diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 3bcdc01..911c1a9 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -3101,6 +3101,8 @@ class EmailRepositoryImpl implements EmailRepository { } @override + // Results are limited to emails already synced into the local SQLite FTS5 + // index; call syncEmails first to ensure the index is up-to-date. Future> searchEmails( String accountId, String mailboxPath, diff --git a/test/backend/email_repository_imap_test.dart b/test/backend/email_repository_imap_test.dart index 50e009e..6d20993 100644 --- a/test/backend/email_repository_imap_test.dart +++ b/test/backend/email_repository_imap_test.dart @@ -433,6 +433,7 @@ void main() { final r = makeRepo(); await r.accounts.addAccount(account, userPass); + await r.emails.syncEmails('test', 'INBOX'); final results = await r.emails.searchEmails( 'test', From e2bb29930072c09aa89718ea3d729df92f27165e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 04:24:10 +0200 Subject: [PATCH 11/12] fix(ci): exclude chaos_monkey_test from regular CI (#518) --- .forgejo/workflows/ci.yml | 9 +--- .forgejo/workflows/deploy.yml | 45 +++---------------- .forgejo/workflows/firebase-tests.yml | 18 +------- .forgejo/workflows/website.yml | 9 +--- ci/main.go | 4 +- .../repositories/email_repository_impl.dart | 32 +++++-------- lib/main.dart | 2 + lib/ui/screens/crash_screen.dart | 1 + test/backend/chaos_monkey_test.dart | 3 ++ test/unit/email_repository_impl_test.dart | 6 +-- test/widget/about_screen_test.dart | 5 ++- 11 files changed, 35 insertions(+), 99 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index a78ea51..2ea8f0b 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -22,14 +22,7 @@ jobs: created_at=$(curl -sf \ -H "Authorization: token $FORGEJO_TOKEN" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ - | python3 -c " -import sys, json -data = json.load(sys.stdin) -for r in data.get('workflow_runs', []): - if r.get('run_number') == $RUN_NUMBER: - print(r['created_at']) - break -" 2>/dev/null) + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) if [ -n "$created_at" ]; then queued_epoch=$(date -d "$created_at" +%s) wait_seconds=$((runner_start - queued_epoch)) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 95bea84..7ad874a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -24,14 +24,7 @@ jobs: created_at=$(curl -sf \ -H "Authorization: token $FORGEJO_TOKEN" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ - | python3 -c " -import sys, json -data = json.load(sys.stdin) -for r in data.get('workflow_runs', []): - if r.get('run_number') == $RUN_NUMBER: - print(r['created_at']) - break -" 2>/dev/null) + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) if [ -n "$created_at" ]; then queued_epoch=$(date -d "$created_at" +%s) wait_seconds=$((runner_start - queued_epoch)) @@ -174,14 +167,7 @@ for r in data.get('workflow_runs', []): created_at=$(curl -sf \ -H "Authorization: token $FORGEJO_TOKEN" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ - | python3 -c " -import sys, json -data = json.load(sys.stdin) -for r in data.get('workflow_runs', []): - if r.get('run_number') == $RUN_NUMBER: - print(r['created_at']) - break -" 2>/dev/null) + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) if [ -n "$created_at" ]; then queued_epoch=$(date -d "$created_at" +%s) wait_seconds=$((runner_start - queued_epoch)) @@ -232,14 +218,7 @@ for r in data.get('workflow_runs', []): created_at=$(curl -sf \ -H "Authorization: token $FORGEJO_TOKEN" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ - | python3 -c " -import sys, json -data = json.load(sys.stdin) -for r in data.get('workflow_runs', []): - if r.get('run_number') == $RUN_NUMBER: - print(r['created_at']) - break -" 2>/dev/null) + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) if [ -n "$created_at" ]; then queued_epoch=$(date -d "$created_at" +%s) wait_seconds=$((runner_start - queued_epoch)) @@ -284,14 +263,7 @@ for r in data.get('workflow_runs', []): created_at=$(curl -sf \ -H "Authorization: token $FORGEJO_TOKEN" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ - | python3 -c " -import sys, json -data = json.load(sys.stdin) -for r in data.get('workflow_runs', []): - if r.get('run_number') == $RUN_NUMBER: - print(r['created_at']) - break -" 2>/dev/null) + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) if [ -n "$created_at" ]; then queued_epoch=$(date -d "$created_at" +%s) wait_seconds=$((runner_start - queued_epoch)) @@ -341,14 +313,7 @@ for r in data.get('workflow_runs', []): created_at=$(curl -sf \ -H "Authorization: token $FORGEJO_TOKEN" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ - | python3 -c " -import sys, json -data = json.load(sys.stdin) -for r in data.get('workflow_runs', []): - if r.get('run_number') == $RUN_NUMBER: - print(r['created_at']) - break -" 2>/dev/null) + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) if [ -n "$created_at" ]; then queued_epoch=$(date -d "$created_at" +%s) wait_seconds=$((runner_start - queued_epoch)) diff --git a/.forgejo/workflows/firebase-tests.yml b/.forgejo/workflows/firebase-tests.yml index 7de2fc2..7022957 100644 --- a/.forgejo/workflows/firebase-tests.yml +++ b/.forgejo/workflows/firebase-tests.yml @@ -23,14 +23,7 @@ jobs: created_at=$(curl -sf \ -H "Authorization: token $FORGEJO_TOKEN" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ - | python3 -c " -import sys, json -data = json.load(sys.stdin) -for r in data.get('workflow_runs', []): - if r.get('run_number') == $RUN_NUMBER: - print(r['created_at']) - break -" 2>/dev/null) + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) if [ -n "$created_at" ]; then queued_epoch=$(date -d "$created_at" +%s) wait_seconds=$((runner_start - queued_epoch)) @@ -83,14 +76,7 @@ for r in data.get('workflow_runs', []): created_at=$(curl -sf \ -H "Authorization: token $FORGEJO_TOKEN" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ - | python3 -c " -import sys, json -data = json.load(sys.stdin) -for r in data.get('workflow_runs', []): - if r.get('run_number') == $RUN_NUMBER: - print(r['created_at']) - break -" 2>/dev/null) + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) if [ -n "$created_at" ]; then queued_epoch=$(date -d "$created_at" +%s) wait_seconds=$((runner_start - queued_epoch)) diff --git a/.forgejo/workflows/website.yml b/.forgejo/workflows/website.yml index ed48049..ee5c575 100644 --- a/.forgejo/workflows/website.yml +++ b/.forgejo/workflows/website.yml @@ -27,14 +27,7 @@ jobs: created_at=$(curl -sf \ -H "Authorization: token $FORGEJO_TOKEN" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ - | python3 -c " -import sys, json -data = json.load(sys.stdin) -for r in data.get('workflow_runs', []): - if r.get('run_number') == $RUN_NUMBER: - print(r['created_at']) - break -" 2>/dev/null) + | python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) if [ -n "$created_at" ]; then queued_epoch=$(date -d "$created_at" +%s) wait_seconds=$((runner_start - queued_epoch)) diff --git a/ci/main.go b/ci/main.go index a7b8423..b167724 100644 --- a/ci/main.go +++ b/ci/main.go @@ -539,7 +539,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) { return m.WithStalwart(m.setup(m.backendSrc())). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + - `flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + + `flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). Stdout(ctx) } @@ -570,7 +570,7 @@ func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) { return m.WithStalwart(m.setup(m.backendSrc())). WithExec([]string{"/bin/bash", "-c", `tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` + - `flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + + `flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` + `grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}). Stdout(ctx) } diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 911c1a9..60bff5a 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -2952,16 +2952,12 @@ class EmailRepositoryImpl implements EmailRepository { String? mailboxPath, String query, ) async { - final words = query - .trim() - .split(RegExp(r'\s+')) - .where((w) => w.isNotEmpty) - .toList(); + final words = + query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); if (words.isEmpty) return []; final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND '); - final likeVars = - words.map((w) => Variable('%$w%')).toList(); + final likeVars = words.map((w) => Variable('%$w%')).toList(); final extraConditions = StringBuffer(); final extraVars = >[]; @@ -2980,14 +2976,13 @@ class EmailRepositoryImpl implements EmailRepository { ' WHERE $noteConditions$extraConditions' ' ORDER BY e.received_at DESC LIMIT 50'; - final rows = await _db - .customSelect( - sql, - variables: [...likeVars, ...extraVars], - readsFrom: {_db.emails, _db.emailNotes}, - ) - .get(); - final emailRows = await Future.wait(rows.map((r) => _db.emails.mapFromRow(r))); + final rows = await _db.customSelect( + sql, + variables: [...likeVars, ...extraVars], + readsFrom: {_db.emails, _db.emailNotes}, + ).get(); + final emailRows = + await Future.wait(rows.map((r) => _db.emails.mapFromRow(r))); return emailRows.map(_toModel).toList(); } @@ -2997,9 +2992,7 @@ class EmailRepositoryImpl implements EmailRepository { static String _toFtsQuery(String query) { final words = query .trim() - .split(RegExp(r'\s+')) - .where((w) => w.isNotEmpty) - .map((w) => w.replaceAll(RegExp(r'[^\w]'), '')) + .split(RegExp(r'[^\w]+')) .where((w) => w.isNotEmpty) .toList(); if (words.isEmpty) return ''; @@ -3126,8 +3119,7 @@ class EmailRepositoryImpl implements EmailRepository { queryRows.map((r) => _db.emails.mapFromRow(r)), ); - final noteRows = - await _searchEmailsByNotes(accountId, mailboxPath, query); + final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query); final seen = {}; final merged = []; diff --git a/lib/main.dart b/lib/main.dart index 20c6a2a..5bae652 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -109,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState { theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true, + splashFactory: NoSplash.splashFactory, ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( @@ -116,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState { brightness: Brightness.dark, ), useMaterial3: true, + splashFactory: NoSplash.splashFactory, ), routerConfig: router, ); diff --git a/lib/ui/screens/crash_screen.dart b/lib/ui/screens/crash_screen.dart index 1567556..c23f192 100644 --- a/lib/ui/screens/crash_screen.dart +++ b/lib/ui/screens/crash_screen.dart @@ -57,6 +57,7 @@ class CrashScreen extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), home: Scaffold( appBar: AppBar( title: const Text('Something went wrong'), diff --git a/test/backend/chaos_monkey_test.dart b/test/backend/chaos_monkey_test.dart index f6715a4..94e1285 100644 --- a/test/backend/chaos_monkey_test.dart +++ b/test/backend/chaos_monkey_test.dart @@ -10,6 +10,9 @@ // CHAOS_ROUNDS (default: 30) — number of random operations to perform // CHAOS_SEED (default: current epoch ms) — seed for reproducibility +@Tags(['nightly']) +library; + import 'dart:io'; import 'dart:math'; diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index ff24382..f6fc9da 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -514,8 +514,7 @@ void main() { ), ); - final results = - await r.emails.searchEmailsGlobal(null, 'urgent'); + final results = await r.emails.searchEmailsGlobal(null, 'urgent'); expect(results, hasLength(1)); expect(results.first.subject, 'Weekly report'); }); @@ -569,8 +568,7 @@ void main() { ), ); - final results = - await r.emails.searchEmails('acc-1', 'INBOX', 'client'); + final results = await r.emails.searchEmails('acc-1', 'INBOX', 'client'); expect(results, hasLength(1)); expect(results.first.subject, 'Project update'); expect(results.first.mailboxPath, 'INBOX'); diff --git a/test/widget/about_screen_test.dart b/test/widget/about_screen_test.dart index 2c3cdd7..64c5988 100644 --- a/test/widget/about_screen_test.dart +++ b/test/widget/about_screen_test.dart @@ -50,7 +50,10 @@ Widget _buildScreen({List accounts = const []}) { FakeAccountRepository(accounts), ), ], - child: const MaterialApp(home: AboutScreen()), + child: MaterialApp( + theme: ThemeData(splashFactory: NoSplash.splashFactory), + home: const AboutScreen(), + ), ); } From 76f2635700bb0bdea38eadeefc4856db4faa8130 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Sun, 7 Jun 2026 04:24:24 +0200 Subject: [PATCH 12/12] fix(search): sort search results by received date descending (#520) --- .../repositories/email_repository_impl.dart | 8 ++- test/backend/chaos_monkey_test.dart | 2 +- test/unit/email_repository_impl_test.dart | 64 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 60bff5a..2cfbc93 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -2922,9 +2922,9 @@ class EmailRepositoryImpl implements EmailRepository { final sql = accountId != null ? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' - ' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50' + ' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50' : 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' - ' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50'; + ' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50'; final variables = accountId != null ? [Variable(ftsQuery), Variable(accountId)] : [Variable(ftsQuery)]; @@ -2942,6 +2942,7 @@ class EmailRepositoryImpl implements EmailRepository { for (final e in [...emailRows.map(_toModel), ...noteRows]) { if (seen.add(e.id)) merged.add(e); } + merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); return merged; } @@ -3106,7 +3107,7 @@ class EmailRepositoryImpl implements EmailRepository { const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid' ' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?' - ' ORDER BY rank LIMIT 50'; + ' ORDER BY e.received_at DESC LIMIT 50'; final variables = [ Variable(ftsQuery), Variable(accountId), @@ -3126,6 +3127,7 @@ class EmailRepositoryImpl implements EmailRepository { for (final e in [...emailRows.map(_toModel), ...noteRows]) { if (seen.add(e.id)) merged.add(e); } + merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); return merged; } diff --git a/test/backend/chaos_monkey_test.dart b/test/backend/chaos_monkey_test.dart index 94e1285..485f387 100644 --- a/test/backend/chaos_monkey_test.dart +++ b/test/backend/chaos_monkey_test.dart @@ -135,7 +135,7 @@ void main() { tearDown(() => db.close()); test('chaos monkey — random operations do not crash the repository', - () async { + timeout: Timeout.none, () async { final seedStr = _env('CHAOS_SEED'); final seed = seedStr.isEmpty ? DateTime.now().millisecondsSinceEpoch diff --git a/test/unit/email_repository_impl_test.dart b/test/unit/email_repository_impl_test.dart index f6fc9da..710990e 100644 --- a/test/unit/email_repository_impl_test.dart +++ b/test/unit/email_repository_impl_test.dart @@ -574,6 +574,70 @@ void main() { expect(results.first.mailboxPath, 'INBOX'); }); + test('searchEmailsGlobal returns results sorted by receivedAt descending', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + subject: const Value('Older report'), + receivedAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:2', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 2, + subject: const Value('Newer report'), + receivedAt: DateTime(2024, 6), + ), + ); + + final results = await r.emails.searchEmailsGlobal(null, 'report'); + expect(results, hasLength(2)); + expect(results[0].subject, 'Newer report'); + expect(results[1].subject, 'Older report'); + }); + + test('searchEmails returns results sorted by receivedAt descending', + () async { + final r = _makeRepos(); + await r.accounts.addAccount(_account, 'pw'); + + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:1', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 1, + subject: const Value('Older meeting'), + receivedAt: DateTime(2024), + ), + ); + await r.db.into(r.db.emails).insert( + EmailsCompanion.insert( + id: 'acc-1:2', + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: 2, + subject: const Value('Newer meeting'), + receivedAt: DateTime(2024, 6), + ), + ); + + final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting'); + expect(results, hasLength(2)); + expect(results[0].subject, 'Newer meeting'); + expect(results[1].subject, 'Older meeting'); + }); + test( 'searchAddresses returns results sorted by most recently used', () async {