Compare commits

..
Author SHA1 Message Date
Bot of Thomas Güttler 1e957305a6 Merge branch 'main' into issue-453-update-agentloop-defaults 2026-06-07 04:27:10 +02:00
Bot of Thomas Güttler 76f2635700 fix(search): sort search results by received date descending (#520) 2026-06-07 04:24:24 +02:00
Bot of Thomas Güttler e2bb299300 fix(ci): exclude chaos_monkey_test from regular CI (#518) 2026-06-07 04:24:10 +02:00
Bot of Thomas Güttler f5abe9132b fix(test): sync before searching in second searchEmails IMAP test (#519) 2026-06-07 02:49:53 +02:00
Bot of Thomas Güttler d55b316d4c ci: add concurrency cancel-in-progress to ci.yml (#516) 2026-06-07 02:40:13 +02:00
Bot of Thomas Güttler f7fd30da15 feat(ci): add Print runner wait time step to all workflow jobs (#517) 2026-06-07 02:40:08 +02:00
Bot of Thomas Güttler d92cfac761 feat(search): include email notes in search results (#512) 2026-06-07 01:58:22 +02:00
57b266a82b 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 <noreply@anthropic.com>
2026-06-07 00:32:13 +02:00
b7a8624c38 fix(ci): forward SSH tunnel directly to dagger engine socket
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:32:13 +02:00
Thomas SharedInboxandBot of Thomas Güttler 1e2f124cd0 ci: re-trigger CI check 2026-06-07 00:32:13 +02:00
916fc4bc6b 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 <noreply@anthropic.com>
2026-06-07 00:32:13 +02:00
Thomas SharedInbox 3928060169 Merge branch 'main' into issue-453-update-agentloop-defaults 2026-06-07 00:30:51 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a67b707a41 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 <noreply@anthropic.com>
2026-06-07 00:28:41 +02:00
Thomas SharedInbox 4e00e14a92 Merge branch 'main' into issue-453-update-agentloop-defaults 2026-06-07 00:15:46 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 156ccae83b 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 <noreply@anthropic.com>
2026-06-07 00:15:19 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 b09b43d989 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 <noreply@anthropic.com>
2026-06-06 23:43:33 +02:00
Thomas SharedInbox 9fd30d8f28 ci: re-trigger CI check 2026-06-06 22:34:16 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e22322166c feat: linkify #NNN references in ChangeLog to Codeberg issues
Closes #472

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 21:51:13 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e76f7a5af2 docs: update AGENTS.md for new agentloop label names and merge prompt
- Rename in-process labels: loop/plan-in-progress → loop/plan-in-process,
  loop/code-in-progress → loop/code-in-process (matches agentloop convention)
- Add loop/merge → loop/merge-in-process → loop/merge-done to table and
  state machine (auto-merge via new merge agent)
- Update lifecycle and rules to reflect automatic merge after CI passes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 21:51:03 +02:00
913f9e8855 fix: prevent duplicate CI runs on pull request pushes (#490)
## Summary

- The CI workflow used `on: [push, pull_request]`, which fires **two** runs whenever a commit is pushed to a branch with an open PR — one for the `push` event and one for the `pull_request` event.
- Scoped the `push` trigger to `branches: [main]` only. Feature-branch pushes now trigger only via `pull_request`; direct pushes to `main` (merge commits) still trigger via `push`.

## Test plan

- [ ] Open a PR and push a new commit — verify only one CI run appears, not two
- [ ] Merge a PR to `main` — verify CI still runs via the `push` trigger

Closes #483

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/490
2026-06-06 21:43:46 +02:00
20 changed files with 503 additions and 37 deletions
+25 -1
View File
@@ -1,11 +1,35 @@
name: CI
on: [push, pull_request]
on:
push:
branches:
- main
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: Full Project Check
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);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))
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:
+85
View File
@@ -15,6 +15,23 @@ 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);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))
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 +158,23 @@ 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);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))
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 +209,23 @@ 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);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))
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 +254,23 @@ 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);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))
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 +304,23 @@ 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);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))
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 }}
+34
View File
@@ -14,6 +14,23 @@ 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);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))
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 +67,23 @@ 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);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))
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
+17
View File
@@ -18,6 +18,23 @@ 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);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))
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
+13 -9
View File
@@ -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
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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<List<Email>> searchEmailsGlobal(String? accountId, String query);
/// Returns all locally cached emails in any mailbox of [accountId] (or all
+27 -10
View File
@@ -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<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
void resetDatabasePathForTesting() => _dbPath = null;
Future<String?> 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);
@@ -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<String>(ftsQuery), Variable<String>(accountId)]
: [Variable<String>(ftsQuery)];
@@ -2934,6 +2934,56 @@ 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 = <String>{};
final merged = <model.Email>[];
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;
}
/// Returns emails whose associated notes contain all words from [query].
/// Optionally filtered by [accountId] and [mailboxPath].
Future<List<model.Email>> _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<String>('%$w%')).toList();
final extraConditions = StringBuffer();
final extraVars = <Variable<String>>[];
if (accountId != null) {
extraConditions.write(' AND e.account_id = ?');
extraVars.add(Variable<String>(accountId));
}
if (mailboxPath != null) {
extraConditions.write(' AND e.mailbox_path = ?');
extraVars.add(Variable<String>(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();
}
@@ -2943,9 +2993,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 '';
@@ -3047,6 +3095,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<List<model.Email>> searchEmails(
String accountId,
String mailboxPath,
@@ -3057,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<String>(ftsQuery),
Variable<String>(accountId),
@@ -3069,7 +3119,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 = <String>{};
final merged = <model.Email>[];
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;
}
// ── Helpers ────────────────────────────────────────────────────────────────
+2
View File
@@ -109,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
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<SharedInboxApp> {
brightness: Brightness.dark,
),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
routerConfig: router,
);
+13 -1
View File
@@ -31,6 +31,17 @@ class ChangeLogScreen extends ConsumerWidget {
return '$h:$m, ${dt.day} $month ${dt.year}';
}
static const _repoUrl = 'https://codeberg.org/guettli/sharedinbox';
static final _issueRefPattern = RegExp(r'#(\d+)');
static String _linkifyIssueRefs(String text) {
return text.replaceAllMapped(
_issueRefPattern,
(m) => '[#${m[1]}]($_repoUrl/issues/${m[1]})',
);
}
// Changelog lines have the form:
// * 2026-06-05 [abc1234](https://...): subject
// This pattern captures the short hash inside the markdown link.
@@ -82,7 +93,8 @@ class ChangeLogScreen extends ConsumerWidget {
child: Text('Error loading changelog: ${snapshot.error}'),
);
}
final content = snapshot.data ?? 'No changelog entries found.';
final raw = snapshot.data ?? 'No changelog entries found.';
final content = _linkifyIssueRefs(raw);
final versions = installedVersions.value ?? {};
final annotated = _injectInstallMarkers(content, versions);
return Markdown(
+1
View File
@@ -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'),
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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"
+4 -1
View File
@@ -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';
@@ -132,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
@@ -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));
@@ -432,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',
+152
View File
@@ -486,6 +486,158 @@ 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('<msg1@example.com>'),
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: '<msg1@example.com>',
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('<msg1@example.com>'),
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('<msg2@example.com>'),
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: '<msg1@example.com>',
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: '<msg2@example.com>',
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('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 {
+36
View File
@@ -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.close();
conn2.close();
},
);
});
}
+4 -1
View File
@@ -50,7 +50,10 @@ Widget _buildScreen({List<Account> accounts = const []}) {
FakeAccountRepository(accounts),
),
],
child: const MaterialApp(home: AboutScreen()),
child: MaterialApp(
theme: ThemeData(splashFactory: NoSplash.splashFactory),
home: const AboutScreen(),
),
);
}
+14
View File
@@ -102,4 +102,18 @@ void main() {
expect(find.textContaining('Installed:'), findsNothing);
expect(find.textContaining('initial release'), findsOneWidget);
});
testWidgets('ChangeLogScreen renders #NNN as a tappable link', (
tester,
) async {
const changelog = '* 2024-03-01 fix: resolve crash, see #42\n';
await tester.pumpWidget(
_buildScreen(assets: {'assets/changelog.txt': changelog}),
);
await tester.pumpAndSettle();
// The link text "#42" must be visible in the rendered output.
expect(find.textContaining('#42'), findsOneWidget);
});
}