## Summary
- The "Load remote images" snack bar in single-mail view (and the analogous thread view) never disappeared on its own — the user had to interact with it.
- Flutter's `SnackBar` defaults to `persist: true` whenever an `action` is provided (see `flutter/lib/src/material/snack_bar.dart`: `persist = persist ?? action != null`), which short-circuits the duration-based dismiss timer in `ScaffoldMessengerState.build`:
```dart
_snackBarTimer = Timer(snackBar.duration, () {
if (snackBar.persist) return; // <-- here
hideCurrentSnackBar(reason: SnackBarClosedReason.timeout);
});
```
So the explicit `duration: 3s` was set, but the "View" action made the snack bar persistent and the timer's callback returned early.
- Pass `persist: false` explicitly on both snack bars so the 3-second timer fires and the snack bar slides away on its own, while the "View" action button still works to navigate to the trusted-senders settings.
## Test plan
- [x] Added widget regression test in `test/widget/email_detail_screen_test.dart` (`Load remote images snack bar auto-dismisses after 3 seconds`).
- [x] Added analogous test in `test/widget/thread_detail_screen_test.dart`.
- [x] `task test-widget` — all 174 widget tests pass.
- [x] `scripts/run_unit_tests.sh` — all 552 unit tests pass.
- [x] `fvm dart analyze --fatal-infos` on changed files — no issues.
- [x] `fvm dart format` — no diffs.
- [ ] Manual: open a single mail with HTML body from an untrusted sender; tap "Load remote images"; verify the snack bar appears, images load, and the snack bar disappears after ~3 seconds while the "View" action button still navigates to `/accounts/trusted-senders` when tapped.
Closes #484
Co-authored-by: Agentloop Bot <agentloop-bot@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/548
SharedInbox 
IMAP/SMTP email client written in Flutter.
Targets Android, iOS, and Desktop (Linux done; macOS, Windows, Android, iOS scaffolded). Supports multiple accounts — each synced independently via IMAP IDLE.
Design philosophy: offline-first
IMAP/SMTP server
↓
AccountSyncManager ←→ Drift (SQLite, local DB)
(IMAP IDLE per account) ↓
UI (reads only from DB)
The UI never touches the network. The sync engine runs in the background and writes to a local Drift database. Screens observe reactive streams from that DB.
Platform support
| Platform | Status |
|---|---|
| Linux desktop | Working (task run) |
| Android | APK builds (task build-android) |
| macOS desktop | Scaffolded |
| Windows desktop | Scaffolded |
| iOS | Scaffolded |
Key packages
| Package | Role |
|---|---|
enough_mail |
IMAP / SMTP / MIME |
drift |
Local SQLite ORM (offline-first store) |
flutter_riverpod |
State management / DI |
go_router |
Navigation |
flutter_secure_storage |
Password storage |
For users
Run the app, tap +, and enter your IMAP/SMTP server details. The app syncs your INBOX in the background using IMAP IDLE and works offline — the network is only needed during initial sync and when sending mail.
For developers
Prerequisites
Nix with flakes enabled and direnv.
# One-time: allow direnv to load the Nix dev shell
direnv allow
# One-time: install the pinned Flutter version (fvm is provided by Nix)
fvm install
direnv loads the Nix flake automatically — it provides go-task, fvm, Android SDK, Stalwart, and Linux build tools. Flutter itself is managed by FVM (pinned in .fvmrc) rather than Nix, which avoids glibc compatibility issues on non-NixOS hosts. task check also runs fvm install automatically if Flutter is missing.
First-time setup
# Generate the Drift database layer (required before first build)
task codegen
# Verify everything compiles and tests pass
task check
Daily workflow
task analyze # flutter analyze (uses analysis_options.yaml)
task test # pure-Dart unit tests + coverage gate (≥85%)
task test-widget # widget tests — headless, no device needed
task test-flutter # full Flutter test suite (unit + widget + integration)
task integration # IMAP/SMTP integration tests via local Stalwart server
task build-linux # flutter build linux --debug (compile check)
task run # flutter run -d linux
task analyze-fix # dart fix --apply
task check runs analyze + test + test-widget + build-linux + integration in parallel — use it before every commit.
Running the app on desktop in mobile screen resolution
Start the app on the Linux desktop target:
task run # or: flutter run -d linux
After the window opens, resize it to a phone-like size. Typical reference dimensions:
| Device profile | Width × Height |
|---|---|
| Compact phone (e.g. Pixel 6a) | 360 × 800 |
| Large phone (e.g. iPhone 14 Pro) | 393 × 852 |
| Tall phone (e.g. Samsung S24) | 360 × 780 |
Drag the window border to those dimensions, or use your window manager's "set window size" feature. The Flutter layout engine responds to the window size exactly as it would on a real device — breakpoints, overflow, and scrolling behave identically. Hot-reload (r in the terminal) preserves the window size between reloads.
Building and installing an Android APK
Build a release APK with:
task build-android # or: flutter build apk --release
The signed APK is written to:
build/app/outputs/flutter-apk/app-release.apk
Install via ADB (USB cable or Wi-Fi ADB, device must have "Install from unknown sources" enabled):
adb install build/app/outputs/flutter-apk/app-release.apk
Install by side-loading (no cable):
- Copy
app-release.apkto the device (e.g. via USB file transfer, cloud storage, oradb push). - Open a file manager on the device, tap the
.apkfile, and confirm the install prompt.
Tip — split APKs for smaller size:
flutter build apk --split-per-abiproduces three smaller APKs (one per CPU architecture). Install the one matching the device:app-arm64-v8a-release.apkcovers almost all modern Android phones.
Widget tests
test/widget/ contains Flutter widget tests for every screen. They run headlessly — no display server, no device, no database, no network. Each test pumps the screen into a virtual render canvas and uses in-memory fakes for the Riverpod repository providers.
Run them locally:
task test-widget # or: flutter test test/widget/
They also run in CI on every push (see the Widget tests step in .forgejo/workflows/ci.yml).
After changing the DB schema
Edit lib/data/db/database.dart, then:
task codegen # regenerates lib/data/db/database.g.dart
database.g.dart is git-ignored; every developer must regenerate it after cloning or pulling schema changes.
Integration tests
task integration
Starts a local Stalwart mail server on random ports, runs the tests in test/integration/, then stops it. No manual setup needed — Stalwart is provided by the Nix flake.
Adding a screen
- Create
lib/ui/screens/my_screen.dart— extendConsumerWidget. - Add a
GoRouteinlib/ui/router.dart. - Read from Riverpod providers in
lib/di.dart; never call the network directly from UI.
Project layout
lib/
core/
models/ — plain Dart data classes (Account, Email, Mailbox, …)
repositories/ — abstract interfaces
sync/ — AccountSyncManager (IMAP IDLE + backoff)
utils/ — htmlToPlain, fmtSize (pure functions, unit-tested)
data/
db/ — Drift schema + generated code
imap/ — connectImap / connectSmtp helpers
repositories/ — concrete implementations
ui/
screens/ — one file per screen
router.dart — go_router route tree
di.dart — Riverpod providers
main.dart — entry point
packages/
enough_mail/ — vendored IMAP/SMTP library (editable)
stalwart-dev/ — local mail server config + start/test scripts
test/
unit/ — pure-Dart unit tests (no device)
widget/ — Flutter widget tests (headless, no device)
integration/ — IMAP/SMTP tests against local Stalwart
Working features
- Multiple accounts — add any number of IMAP/SMTP accounts; each syncs independently
- IMAP IDLE — background sync with push-like latency; exponential backoff (5 s → 5 min) on error
- Mailbox list — shows all folders with unread / total counts
- Email list — sender, subject, date; bold for unread; manual sync button
- Email detail — renders plain text; falls back to HTML→plain conversion; marks as read on open; shows attachment names and sizes
- Reply / Reply all — pre-fills To, Subject (
Re:), Cc from original - Compose — To, Cc, Subject, Body fields; sends via SMTP
- Flag / unflag — star button in detail view; amber star indicator in list; synced to server
- Move to folder — bottom-sheet folder picker; moves on server via IMAP MOVE
- Attachment indicators — paperclip icon in email list; filename + size in detail
- Delete email — removes from server (IMAP expunge) and local DB
- Settings — list and remove accounts
- Search — IMAP server-side search (subject + body); results shown inline, no navigation change
- Offline-first — all reads come from local Drift/SQLite DB; network only for sync and send