Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 727f10462c fix: discard stale search results when a newer query supersedes them
Concurrent IMAP searches were triggered on every keystroke via
`unawaited(_runSearch(...))`. A slower earlier search completing after
a faster later search would overwrite `_searchResults` with stale data,
causing the wrong email to open when tapping a result.

Fix: add a `_searchGeneration` counter incremented on each search start;
results are only applied when the generation matches (i.e. no newer
search was started since). Clearing the query also resets state
immediately without waiting for any in-flight request.

Added two widget tests:
- tapping the first of multiple search results opens the correct email
- stale results from a slower concurrent search are discarded

Fixes #467

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:33:10 +02:00
guettlibotandBot of Thomas Güttler d994723a2d chore(deps): update plugin com.android.application to v9 (#465) 2026-06-06 09:04:32 +02:00
Bot of Thomas Güttler 145346c18a refactor: build Android bundle locally via fvm instead of Dagger (#463) 2026-06-06 09:04:13 +02:00
guettlibotandBot of Thomas Güttler f3e1ca13de chore(deps): update dependency flutter_launcher_icons to ^0.14.0 (#464) 2026-06-06 09:01:21 +02:00
d86ce7766c feat: add undo log detail view (#461)
## Summary

- Tapping a row in the Undo Log list opens a new `UndoLogDetailScreen`
- Detail screen shows: account ID, action type (with icon/colour), timestamp, source folder, destination folder (move only), and a list of all emails in the transaction (subject + sender)
- Navigation uses go_router nested route `/accounts/undo-log/:actionId` with `state.extra` to pass the `UndoAction` object
- AppBar has an **Undo** button that calls the existing undo service and pops back

## Also fixed

- `flake.nix`: replaced the broken dagger/nix 0.20.8 Nix wrapper (infinite self-exec loop) with a direct 0.21.4 `fetchurl` derivation; wired `DAGGER_HOST` so the pre-commit `dart-check` hook can reach the running engine
- `pubspec.lock`: bumped `meta` 1.17→1.18 and `test` 1.30→1.31 to match what the CI resolver picks up (eliminates spurious generated-files drift in CI)

## Verification

- `task test` — all 492 unit/widget tests pass
- `dart analyze --fatal-infos` — clean (no warnings or infos)
- Pre-commit hooks (including `dart-check` via Dagger) — all passed on commit

Closes #450

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/461
2026-06-06 05:43:17 +02:00
f88d14f362 fix: register SOPS-decrypted secrets for CI log redaction (#460)
## Summary

- The Forgejo/GitHub Actions runner only redacts values it has been explicitly told about. Secrets exported via `$GITHUB_ENV` in `setup_dagger_remote.sh` were never registered, so they could appear in plain text in CI log output.
- Added `::add-mask::` calls for every secret exported by `export_secret()`, and for the two inline variables `DAGGER_SSH_KEY` and `DAGGER_ENGINE_HOST` that bypass that function.
- Multiline values (e.g. SSH private keys, JSON key files) are masked line-by-line, since `::add-mask::` covers a single line at a time.

## Test plan

- [ ] Trigger a `workflow_dispatch` run of `deploy.yml` and confirm no secret values appear in plain text in the "Setup Dagger Remote Engine" step or any subsequent steps.
- [ ] Confirm the existing `[secrets] exported NAME (N chars)` log lines still appear (they log only the name and length, not the value).

Closes #434

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/460
2026-06-06 05:38:47 +02:00
3e2da2bdf8 feat: use icon.svg as app icon for Android and Linux (#459)
Closes #451

## What changed

Replaces the default Flutter blue logo with the project's rainbow-rings `icon.svg` on all supported platforms.

**Android** — all five mipmap densities regenerated (`mdpi` 48px through `xxxhdpi` 192px).

**Linux** — `linux/sharedinbox.png` (512×512) added, installed next to the binary via `CMakeLists.txt`, and set as the GTK window icon via `gtk_window_set_icon_from_file` in `my_application.cc`.

**Tooling** — `icon.png` (1024×1024 source raster) committed; `flutter_launcher_icons` added as dev dep with a `flutter_icons` config block; `task generate-icons` added to `Taskfile.yml` for future regeneration; `librsvg` added to `flake.nix` so `rsvg-convert` is available inside `nix develop`.

## How verified

Icons were generated with Inkscape from `icon.svg` and visually confirmed (rainbow-rings design appears correctly at all sizes). The `playstore/icon.png` was already correct and unchanged.

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/459
2026-06-06 05:32:29 +02:00
6a60c8d73b fix: resolve dart analyze failures in chaos_monkey_test.dart (#458)
## Summary

Fixes CI failures introduced by PR #455 (chaos monkey backend test).

The `dart analyze --fatal-infos` step in CI was failing because `test/backend/chaos_monkey_test.dart` had:

- **`avoid_print`** (5 instances): replaced `print(...)` with `stdout.writeln(...)` — `dart:io` is already imported
- **`avoid_redundant_argument_values`**: removed redundant `''` from `_env('CHAOS_SEED', '')` since `''` is the parameter default
- **`dart format`**: applied formatter fixes (trailing commas, line wrapping for long `connectToServer` calls)

## Verification

```
$ nix develop --command bash -c "fvm dart analyze --fatal-infos"
Analyzing 456...
No issues found!

$ nix develop --command bash -c "fvm dart format --output=none --set-exit-if-changed test/backend/chaos_monkey_test.dart"
Formatted 1 file (0 changed) in 0.01 seconds.
```

Closes #456

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/458
2026-06-06 05:29:40 +02:00
985bac7022 refactor: migrate deploy-android-bundle to Dagger (#449)
## Summary

- Deletes `scripts/build_android_bundle_local.sh`, which required a host Android SDK and failed with `No Android SDK found`
- Removes the `build-android-bundle-local` Taskfile task that invoked it
- Rewrites `deploy-android-bundle` to call the existing Dagger `publish-android` pipeline (build → stamp versionCode → sign → upload) via `sops exec-env` for local secret injection — no local Android SDK needed

The `publish-android` Dagger function (`ci/main.go`) already handles everything the old script did (keystore decode, AAB build, signing) plus version-code stamping, so no changes to `ci/main.go` are required.

Closes #444

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/449
2026-06-05 22:43:22 +02:00
aed0d63703 feat: track Flutter version in Renovate via Docker datasource (#452)
## Summary

- Adds a custom Renovate manager that reads the pinned Flutter version from `.fvmrc`
- Uses `ghcr.io/cirruslabs/flutter` as the Docker datasource so Renovate only proposes a bump when the corresponding image tag exists in the registry
- The CI pipeline (`ci/main.go`) already derives the Docker image tag from `.fvmrc` at runtime — `.fvmrc` is the single source of truth; no other files need grouping

## How it works

Renovate checks `ghcr.io/cirruslabs/flutter` for available tags. If `3.44.1` doesn't exist yet, no PR is opened. Once the image is published, Renovate opens a PR to bump `.fvmrc` — the only file that needs to change.

## Verification

- `renovate.json` schema validated
- Reviewed `ci/main.go`: `FlutterVersion` is read exclusively from `.fvmrc`; no hardcoded version strings elsewhere require additional grouping rules

Closes #447

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/452
2026-06-05 22:42:47 +02:00
26 changed files with 663 additions and 22 deletions
+20
View File
@@ -0,0 +1,20 @@
name: Chaos Monkey
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
jobs:
chaos-monkey-backend:
name: Chaos Monkey (backend)
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Setup Dagger Remote Engine
env:
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run backend chaos monkey
run: task chaos-monkey-backend
+20 -7
View File
@@ -58,6 +58,14 @@ tasks:
cmds:
- echo "Setup complete."
generate-icons:
desc: Rasterise icon.svg → icon.png and regenerate all platform launcher icons
deps: [_pub-get]
cmds:
- rsvg-convert -w 1024 -h 1024 icon.svg -o icon.png
- rsvg-convert -w 512 -h 512 icon.svg -o playstore/icon.png
- fvm flutter pub run flutter_launcher_icons
generate-changelog:
desc: Generate assets/changelog.txt from git history
cmds:
@@ -521,13 +529,6 @@ tasks:
cmds:
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal track (local/fvm)
deps: [build-android-bundle-local]
dotenv: [".env"]
cmds:
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
build-android-bundle-local:
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
@@ -542,6 +543,13 @@ tasks:
cmds:
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal track (local/fvm)
deps: [build-android-bundle-local]
dotenv: [".env"]
cmds:
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
deploy-android:
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
deps: [check, build-android]
@@ -722,6 +730,11 @@ tasks:
cmds:
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
chaos-monkey-backend:
desc: Chaos monkey — random IMAP/SMTP ops against Stalwart (via Dagger, headless)
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. chaos-monkey-backend
check:
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
deps: [analyze, build-linux, test]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+1 -1
View File
@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false
id("com.android.application") version "9.2.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
+10
View File
@@ -565,6 +565,16 @@ func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
Stdout(ctx)
}
// ChaosMonkeyBackend runs random IMAP/SMTP operations against Stalwart to surface crashes.
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; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
// Check runs the full check suite.
func (m *Ci) Check(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
+22 -1
View File
@@ -48,11 +48,28 @@
chmod +x $out/bin/fgj
'';
};
# The dagger/nix flake pins 0.20.8, whose Nix wrapper is a broken self-exec
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run.
dagger021 = pkgs.stdenv.mkDerivation {
pname = "dagger";
version = "0.21.4";
src = pkgs.fetchurl {
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
};
sourceRoot = ".";
installPhase = ''
mkdir -p $out/bin
cp dagger $out/bin/dagger
chmod +x $out/bin/dagger
'';
};
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Dagger CLI
dagger.packages.${system}.dagger
dagger021
# Go compiler — for Dagger development
go
@@ -100,12 +117,16 @@
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
librsvg # rsvg-convert — SVG→PNG for generate-icons task
]);
shellHook = ''
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
export IN_NIX_SHELL=1
# Point Dagger client at the running engine socket
export DAGGER_HOST=unix:///run/dagger/engine.sock
# Disable Flutter telemetry inside dev shell
export FLUTTER_SUPPRESS_ANALYTICS=true
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+10
View File
@@ -1,6 +1,7 @@
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/ui/screens/about_screen.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
@@ -22,6 +23,7 @@ import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
import 'package:sharedinbox/ui/screens/sync_log_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/undo_log_detail_screen.dart';
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
@@ -55,6 +57,14 @@ final router = GoRouter(
GoRoute(
path: 'undo-log',
builder: (ctx, state) => const UndoLogScreen(),
routes: [
GoRoute(
path: ':actionId',
builder: (ctx, state) => UndoLogDetailScreen(
action: state.extra as UndoAction,
),
),
],
),
GoRoute(
path: 'changelog',
+12 -2
View File
@@ -50,6 +50,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
// Pagination: number of threads currently requested from the DB.
static const _pageSize = 50;
int _limit = _pageSize;
// Incremented on every search start; stale completions are ignored when the
// generation has advanced (prevents out-of-order IMAP responses from
// overwriting fresh results with results for an older query).
int _searchGeneration = 0;
bool get _selecting =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@@ -121,14 +126,19 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
setState(() => _searchResults = null);
return;
}
final generation = ++_searchGeneration;
setState(() => _searchLoading = true);
try {
final results = await ref
.read(emailRepositoryProvider)
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
if (mounted) setState(() => _searchResults = results);
if (mounted && generation == _searchGeneration) {
setState(() => _searchResults = results);
}
} finally {
if (mounted) setState(() => _searchLoading = false);
if (mounted && generation == _searchGeneration) {
setState(() => _searchLoading = false);
}
}
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
final _dateTimeFmt = DateFormat('yyyy-MM-dd HH:mm:ss');
class UndoLogDetailScreen extends ConsumerWidget {
const UndoLogDetailScreen({super.key, required this.action});
final UndoAction action;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Undo Log Detail'),
actions: [
TextButton(
onPressed: () async {
await ref
.read(undoServiceProvider.notifier)
.undo(actionId: action.id);
if (context.mounted) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 5),
content: Text('Action undone.'),
),
);
}
},
child: const Text('Undo'),
),
],
),
body: ListView(
children: [
_SectionHeader(text: 'Transaction', theme: theme),
ListTile(
leading: const Icon(Icons.account_circle),
title: const Text('Account'),
subtitle: Text(action.accountId),
),
ListTile(
leading: Icon(
action.type == UndoType.delete
? Icons.delete_outline
: (action.type == UndoType.snooze
? Icons.access_time
: Icons.move_to_inbox),
color: action.type == UndoType.delete
? Colors.redAccent
: (action.type == UndoType.snooze
? Colors.orangeAccent
: Colors.blueAccent),
),
title: const Text('Action'),
subtitle: Text(action.type.name.toUpperCase()),
),
ListTile(
leading: const Icon(Icons.schedule),
title: const Text('Timestamp'),
subtitle: Text(_dateTimeFmt.format(action.timestamp.toLocal())),
),
_SectionHeader(text: 'Folders', theme: theme),
ListTile(
leading: const Icon(Icons.folder_open),
title: const Text('Source'),
subtitle: Text(action.sourceMailboxPath),
),
if (action.type == UndoType.move &&
action.destinationMailboxPath != null)
ListTile(
leading: const Icon(Icons.drive_file_move),
title: const Text('Destination'),
subtitle: Text(action.destinationMailboxPath!),
),
_SectionHeader(
text: 'Emails (${action.emailIds.length})',
theme: theme,
),
if (action.originalEmails.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'${action.emailIds.length} email(s) — details not available',
style: theme.textTheme.bodySmall,
),
),
...action.originalEmails.map((email) => _EmailTile(email: email)),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.text, required this.theme});
final String text;
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
text,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
),
),
);
}
}
class _EmailTile extends StatelessWidget {
const _EmailTile({required this.email});
final Email email;
@override
Widget build(BuildContext context) {
final sender = email.from.isNotEmpty
? (email.from.first.name ?? email.from.first.email)
: '(Unknown Sender)';
return ListTile(
leading: const Icon(Icons.email_outlined),
title: Text(email.subject ?? '(No Subject)'),
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
);
}
}
+5
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
@@ -55,6 +56,10 @@ class _UndoActionTile extends ConsumerWidget {
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
return ListTile(
onTap: () => context.go(
'/accounts/undo-log/${action.id}',
extra: action,
),
leading: Icon(
action.type == UndoType.delete
? Icons.delete_outline
+4
View File
@@ -102,3 +102,7 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/sharedinbox.png"
DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
+2
View File
@@ -31,6 +31,8 @@ static void my_application_activate(GApplication* application) {
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_window_set_icon_from_file(window, "sharedinbox.png", nullptr);
// Show AFTER adding FlView so GTK's first layout pass allocates the full
// window content area (1280×800) to FlView, not the default 1×1.
gtk_widget_show_all(GTK_WIDGET(window));
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+24 -8
View File
@@ -371,6 +371,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -562,6 +570,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
integration_test:
dependency: "direct dev"
description: flutter
@@ -659,10 +675,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: "direct main"
description:
@@ -1088,26 +1104,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
version: "1.31.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
version: "0.6.17"
timezone:
dependency: transitive
description:
+9
View File
@@ -81,6 +81,15 @@ dev_dependencies:
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
flutter_icons:
android: "ic_launcher"
ios: false
image_path: "icon.png"
linux:
generate: true
image_path: "icon.png"
flutter:
uses-material-design: true
+8
View File
@@ -19,6 +19,14 @@
}
],
"customManagers": [
{
"customType": "regex",
"fileMatch": ["^\\.fvmrc$"],
"matchStrings": ["\"flutter\":\\s*\"(?<currentValue>[^\"]+)\""],
"depNameTemplate": "ghcr.io/cirruslabs/flutter",
"datasourceTemplate": "docker",
"versioningTemplate": "semver"
},
{
"customType": "regex",
"fileMatch": ["^\\.forgejo/Dockerfile$"],
+1
View File
@@ -57,6 +57,7 @@ const _excluded = {
'lib/ui/screens/sieve_scripts_screen.dart',
'lib/ui/screens/sync_log_screen.dart',
'lib/ui/screens/thread_detail_screen.dart',
'lib/ui/screens/undo_log_detail_screen.dart',
'lib/ui/screens/undo_log_screen.dart',
'lib/ui/widgets/folder_drawer.dart',
'lib/ui/widgets/secure_email_webview.dart',
+13
View File
@@ -17,12 +17,25 @@ sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
# Register inline secrets for log redaction. Multiline values (e.g. SSH keys)
# must be masked line-by-line because ::add-mask:: covers one line at a time.
printf '::add-mask::%s\n' "$DAGGER_ENGINE_HOST"
while IFS= read -r line; do
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
done <<< "$DAGGER_SSH_KEY"
# Export all CI secrets to the GitHub Actions environment so subsequent steps
# can use them without referencing Forgejo secrets directly.
export_secret() {
local name="$1"
local value
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
# Register each non-empty line for log redaction in the Actions runner.
if [ -n "$value" ] && [ -n "${GITHUB_ENV:-}" ]; then
while IFS= read -r line; do
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
done <<< "$value"
fi
if [ -n "${GITHUB_ENV:-}" ]; then
# Use heredoc syntax for multiline-safe export.
# Avoid adding a second trailing newline for values that already end with one
+221
View File
@@ -0,0 +1,221 @@
// Chaos monkey test — drives the email repository through random operations
// against a live Stalwart instance to surface crashes and data-corruption bugs.
//
// Run via: stalwart-dev/test.sh
//
// Environment variables:
// STALWART_IMAP_HOST, STALWART_IMAP_PORT
// STALWART_SMTP_HOST, STALWART_SMTP_PORT
// STALWART_USER_B / STALWART_PASS_B (alice@example.com)
// CHAOS_ROUNDS (default: 30) — number of random operations to perform
// CHAOS_SEED (default: current epoch ms) — seed for reproducibility
import 'dart:io';
import 'dart:math';
import 'package:enough_mail/enough_mail.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart' as email_model;
import 'package:sharedinbox/data/db/database.dart' hide Account;
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'package:test/test.dart';
import '../unit/account_repository_impl_test.dart' show MapSecureStorage;
import '../unit/db_test_helper.dart';
String _env(String key, [String fallback = '']) =>
Platform.environment[key] ?? fallback;
Future<ImapClient> _imapConnectPlain(
Account account,
String username,
String password,
) async {
final client =
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
await client.connectToServer(
account.imapHost,
account.imapPort,
isSecure: false,
);
await client.login(username, password);
return client;
}
Future<SmtpClient> _smtpConnectPlain(
Account account,
String username,
String password,
) async {
final atIndex = account.email.lastIndexOf('@');
final domain =
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
final client = SmtpClient(domain);
await client.connectToServer(
account.smtpHost,
account.smtpPort,
isSecure: false,
);
await client.ehlo();
await client.authenticate(username, password);
return client;
}
Future<void> _clearMailbox(
Account account,
String userEmail,
String userPass,
String mailboxPath,
) async {
final client = await _imapConnectPlain(account, userEmail, userPass);
try {
final box = await client.selectMailboxByPath(mailboxPath);
if (box.messagesExists == 0) return;
final result = await client.uidSearchMessages(searchCriteria: 'ALL');
final uids = result.matchingSequence?.toList() ?? [];
if (uids.isEmpty) return;
final seq = MessageSequence.fromIds(uids, isUid: true);
await client.uidMarkDeleted(seq);
await client.uidExpunge(seq);
} finally {
await client.logout();
}
}
void main() {
late String imapHost;
late int imapPort;
late String smtpHost;
late int smtpPort;
late String userEmail;
late String userPass;
late Account account;
late AppDatabase db;
late EmailRepositoryImpl emails;
setUpAll(configureSqliteForTests);
setUp(() async {
imapHost = _env('STALWART_IMAP_HOST', '127.0.0.1');
imapPort = int.parse(_env('STALWART_IMAP_PORT', '1430'));
smtpHost = _env('STALWART_SMTP_HOST', '127.0.0.1');
smtpPort = int.parse(_env('STALWART_SMTP_PORT', '1025'));
userEmail = _env('STALWART_USER_B', 'alice@example.com');
userPass = _env('STALWART_PASS_B', 'secret');
account = Account(
id: 'chaos',
displayName: 'Chaos',
email: userEmail,
imapHost: imapHost,
imapPort: imapPort,
imapSsl: false,
smtpHost: smtpHost,
smtpPort: smtpPort,
);
db = openTestDatabase();
final secureStorage = MapSecureStorage();
final accounts = AccountRepositoryImpl(db, secureStorage);
await accounts.addAccount(account, userPass);
emails = EmailRepositoryImpl(
db,
accounts,
imapConnect: _imapConnectPlain,
smtpConnect: _smtpConnectPlain,
);
await _clearMailbox(account, userEmail, userPass, 'INBOX');
});
tearDown(() => db.close());
test('chaos monkey — random operations do not crash the repository',
() async {
final seedStr = _env('CHAOS_SEED');
final seed = seedStr.isEmpty
? DateTime.now().millisecondsSinceEpoch
: int.parse(seedStr);
final rounds = int.parse(_env('CHAOS_ROUNDS', '30'));
final rng = Random(seed);
stdout.writeln('chaos-monkey: seed=$seed rounds=$rounds');
// Seed INBOX with a few messages so early rounds have something to act on.
for (var i = 0; i < 3; i++) {
await emails.sendEmail(
account.id,
email_model.EmailDraft(
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
to: [email_model.EmailAddress(email: userEmail)],
cc: [],
subject: 'seed-$i',
body: 'Seed email $i.',
),
);
}
await emails.syncEmails(account.id, 'INBOX');
for (var round = 0; round < rounds; round++) {
final action = rng.nextInt(8);
stdout.writeln('chaos-monkey: round=$round action=$action');
switch (action) {
case 0: // sync INBOX
await emails.syncEmails(account.id, 'INBOX');
case 1: // sync Sent
await emails.syncEmails(account.id, 'Sent');
case 2: // send email to self
final subject = 'chaos-$round-${rng.nextInt(9999)}';
await emails.sendEmail(
account.id,
email_model.EmailDraft(
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
to: [email_model.EmailAddress(email: userEmail)],
cc: [],
subject: subject,
body: 'Round $round. Value: ${rng.nextInt(1000000)}.',
),
);
case 3: // mark random email seen
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, seen: true);
case 4: // mark random email unseen
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, seen: false);
case 5: // toggle flagged on random email
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.setFlag(e.id, flagged: !e.isFlagged);
case 6: // flush pending changes to server
final flushed =
await emails.flushPendingChanges(account.id, userPass);
stdout.writeln('chaos-monkey: flushed $flushed pending changes');
case 7: // delete random email
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
if (inbox.isEmpty) break;
final e = inbox[rng.nextInt(inbox.length)];
await emails.deleteEmail(e.id);
}
}
// Final flush and sync to confirm the server is in a consistent state.
final flushed = await emails.flushPendingChanges(account.id, userPass);
stdout.writeln('chaos-monkey: final flush flushed=$flushed');
final result = await emails.syncEmails(account.id, 'INBOX');
stdout.writeln('chaos-monkey: final sync fetched=${result.fetched}');
});
}
+124
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -430,6 +432,128 @@ void main() {
expect(find.text('Result email'), findsWidgets);
});
testWidgets(
'tapping first of multiple search results opens the first email',
(tester) async {
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
searchResults: [email1, email2],
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Match');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(find.text('Alpha Match'), findsOneWidget);
expect(find.text('Beta Match'), findsOneWidget);
// Tap the first result.
await tester.tap(find.text('Alpha Match'));
await tester.pumpAndSettle();
expect(find.byType(EmailDetailScreen), findsOneWidget);
// The detail AppBar title shows the first email's subject.
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.text('Alpha Match'),
),
findsOneWidget,
);
// The second email's subject must not appear in the detail view.
expect(
find.descendant(
of: find.byType(EmailDetailScreen),
matching: find.text('Beta Match'),
),
findsNothing,
);
},
);
testWidgets(
'stale search results from a slower concurrent search are discarded',
(tester) async {
// Reproduces: user types quickly, triggering multiple concurrent IMAP
// searches. An older, slower search must not overwrite the results for
// the user's current query (issue #467).
final staleEmail = testEmail(id: 'acc-1:1', subject: 'Stale Result');
final freshEmail = testEmail(id: 'acc-1:2', subject: 'Fresh Result');
// The first search call is held open by a Completer; all subsequent
// calls resolve immediately with freshEmail.
final staleCompleter = Completer<List<Email>>();
var firstCall = true;
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
onSearch: (_) {
if (firstCall) {
firstCall = false;
return staleCompleter.future;
}
return Future.value([freshEmail]);
},
emailBody: const EmailBody(emailId: '', attachments: []),
),
),
],
),
);
await tester.pumpAndSettle();
// Trigger the first (slow) search.
await tester.enterText(find.byType(TextField), 'slow');
await tester.testTextInput.receiveAction(TextInputAction.search);
// Do not pumpAndSettle yet — the slow search is still in flight.
// Trigger the second (fast) search by changing the query.
await tester.enterText(find.byType(TextField), 'fast');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle(); // fast searches settle immediately
// The fresh results must be shown.
expect(find.text('Fresh Result'), findsOneWidget);
expect(find.text('Stale Result'), findsNothing);
// Now let the stale search complete.
staleCompleter.complete([staleEmail]);
await tester.pumpAndSettle();
// The stale results must NOT replace the fresh ones.
expect(find.text('Fresh Result'), findsOneWidget);
expect(find.text('Stale Result'), findsNothing);
},
);
testWidgets('deleting all search results pops back to previous screen', (
tester,
) async {
+18 -3
View File
@@ -216,12 +216,17 @@ class FakeEmailRepository implements EmailRepository {
final List<Email> _searchResults;
/// Optional override: when set, [searchEmails] calls this instead of
/// returning [_searchResults]. Useful for testing race-condition fixes.
final Future<List<Email>> Function(String query)? onSearch;
FakeEmailRepository({
List<Email>? emails,
Email? emailDetail,
EmailBody? emailBody,
List<Email>? searchResults,
String rawRfc822 = '',
this.onSearch,
}) : _emails = emails ?? [],
_emailDetail = emailDetail,
_searchResults = searchResults ?? [],
@@ -274,7 +279,15 @@ class FakeEmailRepository implements EmailRepository {
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
@override
Future<Email?> getEmail(String emailId) async => _emailDetail;
Future<Email?> getEmail(String emailId) async {
for (final e in _searchResults) {
if (e.id == emailId) return e;
}
for (final e in _emails) {
if (e.id == emailId) return e;
}
return _emailDetail;
}
@override
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
@@ -340,8 +353,10 @@ class FakeEmailRepository implements EmailRepository {
String accountId,
String mailboxPath,
String query,
) async =>
_searchResults;
) async {
if (onSearch != null) return onSearch!(query);
return _searchResults;
}
@override
Future<List<Email>> searchEmailsGlobal(