diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..a3e7f75
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+# Copy this file to .env and fill in values for local overrides.
+# .env is git-ignored. The Nix flake sets reasonable defaults for all vars below.
+#
+# Only needed if you want to run the Stalwart dev server on fixed ports
+# (e.g. to keep the same port across shell restarts).
+# Leave unset (or set to 0) to use random ports — the default.
+
+# STALWART_PORT=8080
+# STALWART_IMAP_PORT=1430
+# STALWART_SMTP_PORT=1025
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e03ba8e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,89 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ analyze-and-test:
+ name: Analyze & unit test
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: "3.41.6"
+ channel: stable
+ cache: true
+
+ - name: Install dependencies
+ run: flutter pub get
+
+ - name: Generate Drift code
+ run: dart run build_runner build --delete-conflicting-outputs
+
+ - name: Analyze
+ run: flutter analyze --fatal-infos
+
+ - name: Unit tests
+ run: dart test test/unit/
+
+ integration:
+ name: Integration tests (Stalwart)
+ runs-on: ubuntu-latest
+ # Run integration tests only on push to main, not on every PR,
+ # since they require downloading the Stalwart binary via Nix.
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: DeterminateSystems/nix-installer-action@v14
+
+ - uses: DeterminateSystems/magic-nix-cache-action@v8
+
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: "3.41.6"
+ channel: stable
+ cache: true
+
+ - name: Enter Nix shell and run integration tests
+ run: |
+ nix develop --command bash -c "
+ flutter pub get &&
+ dart run build_runner build --delete-conflicting-outputs &&
+ stalwart-dev/test.sh
+ "
+
+ build-linux:
+ name: Build Linux desktop
+ runs-on: ubuntu-latest
+ needs: analyze-and-test
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install GTK3 and build tools
+ run: |
+ sudo apt-get update -q
+ sudo apt-get install -y --no-install-recommends \
+ libgtk-3-dev pkg-config cmake ninja-build clang
+
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: "3.41.6"
+ channel: stable
+ cache: true
+
+ - name: Install dependencies
+ run: flutter pub get
+
+ - name: Generate Drift code
+ run: dart run build_runner build --delete-conflicting-outputs
+
+ - name: Build Linux release
+ run: flutter build linux --release
diff --git a/.gitignore b/.gitignore
index 23d0fe1..c558852 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,12 +15,23 @@ build/
.DS_Store
Thumbs.db
+# Local environment overrides (copied from .env.example)
+.env
+.env.local
+
# Android
android/.gradle/
android/local.properties
android/app/google-services.json
+android/key.properties
# iOS / macOS
ios/Pods/
macos/Pods/
*.xcworkspace/
+
+# Linux build output
+linux/build/
+
+# Stalwart dev server runtime data (created by stalwart-dev/start)
+/tmp/stalwart-dev-*/
diff --git a/README.md b/README.md
index 5f63b20..75805c9 100644
--- a/README.md
+++ b/README.md
@@ -2,44 +2,137 @@
IMAP/SMTP email client written in [Flutter](https://flutter.dev).
-Targets **Android, iOS, and Desktop** (Linux, macOS, Windows).
+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
-```
+```text
IMAP/SMTP server
↓
AccountSyncManager ←→ Drift (SQLite, local DB)
- ↓
- UI (reads only from 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.
+The UI never touches the network. The sync engine runs in the background and writes to a local [Drift](https://drift.simonbinder.eu/) database. Screens observe reactive streams from that DB.
+
+## Platform support
+
+| Platform | Status |
+| --- | --- |
+| Linux desktop | Working |
+| macOS desktop | Entry point pending |
+| Windows desktop | Entry point pending |
+| Android | Entry point pending |
+| iOS | Entry point pending |
## Key packages
| Package | Role |
-|---|---|
+| --- | --- |
| `enough_mail` (vendored in `packages/`) | IMAP / SMTP / MIME |
-| `drift` | Local SQLite ORM |
+| `drift` | Local SQLite ORM (offline-first store) |
| `flutter_riverpod` | State management / DI |
| `go_router` | Navigation |
+| `flutter_secure_storage` | Password storage |
-## Building
+`enough_mail` is vendored under `packages/` so it can be patched without waiting for an upstream release.
+
+---
+
+## For users
+
+Download the latest release from the [Releases page](https://github.com/guettli/sharedinbox3/releases) *(not yet published)*.
+
+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](https://nixos.org/download) with flakes enabled, and [direnv](https://direnv.net/).
```bash
-# Install dependencies and run code generation
-dart pub get
-dart run build_runner build --delete-conflicting-outputs
-
-# Run on desktop
-flutter run -d linux # or macos / windows
-
-# Run on Android / iOS
-flutter run
+# One-time: allow direnv in this directory
+direnv allow
```
-## Vendored enough_mail
+`direnv` loads the Nix flake automatically — no manual `nix develop` needed after that. The flake pins **Flutter 3.41.6**, Android SDK, Stalwart mail server (for integration tests), and all Linux desktop build tools (GTK3, clang, cmake).
-`packages/enough_mail/` is a local copy of [enough_mail](https://github.com/Enough-Software/enough_mail) so it can be modified if needed. It is referenced via a `path:` dependency in `pubspec.yaml`.
+### First-time setup
+
+```bash
+# Generate the Drift database layer (required before first build)
+task codegen
+
+# Verify everything compiles and tests pass
+task check
+```
+
+### Daily workflow
+
+```bash
+task analyze # flutter analyze (uses analysis_options.yaml)
+task test # pure-Dart unit tests — fast, no device
+task test-flutter # Flutter widget tests
+task integration # IMAP/SMTP integration tests via local Stalwart server
+task run # flutter run -d linux
+task analyze-fix # dart fix --apply
+```
+
+`task check` runs `analyze` + `test` in parallel — use it before every commit.
+
+### After changing the DB schema
+
+Edit `lib/data/db/database.dart`, then:
+
+```bash
+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
+
+```bash
+task integration
+```
+
+Starts a local [Stalwart](https://stalw.art) 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
+
+1. Create `lib/ui/screens/my_screen.dart` — extend `ConsumerWidget`.
+2. Add a `GoRoute` in `lib/ui/router.dart`.
+3. Read from Riverpod providers in `lib/di.dart`; never call the network directly from UI.
+
+### Project layout
+
+```text
+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)
+ integration/ — IMAP/SMTP tests against local Stalwart
+```
diff --git a/Taskfile.yml b/Taskfile.yml
index fc6edc8..ce62773 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -2,7 +2,7 @@ version: "3"
tasks:
default:
- desc: Run all checks (analyze + test, in parallel)
+ desc: Run all checks (analyze + unit tests, in parallel)
deps: [check]
_nix-check:
@@ -13,7 +13,7 @@ tasks:
msg: "Not in nix dev shell. Run: nix develop"
codegen:
- desc: Run build_runner to generate Drift and Riverpod code
+ desc: Generate Drift DB code (run after any schema change)
deps: [_nix-check]
cmds:
- |
@@ -23,7 +23,7 @@ tasks:
echo "codegen: $((END - START))s"
analyze:
- desc: Run flutter analyze
+ desc: Static analysis with flutter analyze + analysis_options.yaml rules
deps: [_nix-check]
cmds:
- |
@@ -33,20 +33,30 @@ tasks:
echo "analyze: $((END - START))s"
analyze-fix:
- desc: Auto-fix with dart fix --apply
+ desc: Auto-fix lint issues with dart fix --apply
deps: [_nix-check]
cmds:
- dart fix --apply
test:
- desc: Run unit tests
+ desc: Run pure-Dart unit tests (no device needed)
+ deps: [_nix-check]
+ cmds:
+ - |
+ START=$(date +%s)
+ dart test test/unit/
+ END=$(date +%s)
+ echo "test: $((END - START))s"
+
+ test-flutter:
+ desc: Run Flutter widget tests
deps: [_nix-check]
cmds:
- |
START=$(date +%s)
flutter test
END=$(date +%s)
- echo "test: $((END - START))s"
+ echo "flutter test: $((END - START))s"
integration:
desc: Run integration tests (starts and stops Stalwart automatically)
@@ -65,5 +75,5 @@ tasks:
- flutter run -d linux
check:
- desc: All checks — analyze + unit tests in parallel
+ desc: All fast checks — analyze + unit tests in parallel
deps: [analyze, test]
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..0c61865
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,49 @@
+include: package:flutter_lints/flutter.yaml
+
+analyzer:
+ errors:
+ # Treat these as errors, not warnings.
+ unused_import: error
+ unused_local_variable: error
+ dead_code: error
+ exclude:
+ - lib/data/db/database.g.dart
+ - "**/*.g.dart"
+ - "**/*.freezed.dart"
+
+linter:
+ rules:
+ # Style
+ - prefer_single_quotes
+ - prefer_const_constructors
+ - prefer_const_declarations
+ - prefer_const_collections
+ - prefer_final_locals
+ - prefer_final_in_for_each
+ - unnecessary_const
+ - unnecessary_new
+ - unnecessary_this
+ - avoid_redundant_argument_values
+
+ # Safety
+ - avoid_catching_errors
+ - avoid_dynamic_calls
+ - avoid_empty_else
+ - avoid_print
+ - avoid_returning_null_for_future
+ - avoid_returning_null_for_void
+ - avoid_type_to_string
+ - cancel_subscriptions
+ - close_sinks
+ - literal_only_boolean_expressions
+ - no_duplicate_case_values
+ - throw_in_finally
+ - unawaited_futures
+
+ # Correctness
+ - always_declare_return_types
+ - annotate_overrides
+ - empty_catches # flag silent catch{} — use comment if intentional
+ - hash_and_equals
+ - use_rethrow_when_possible
+ - valid_regexps
diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart
index a501b21..9668019 100644
--- a/lib/core/sync/account_sync_manager.dart
+++ b/lib/core/sync/account_sync_manager.dart
@@ -6,6 +6,7 @@ import '../models/account.dart';
import '../repositories/account_repository.dart';
import '../repositories/email_repository.dart';
import '../repositories/mailbox_repository.dart';
+import '../utils/logger.dart';
import '../../data/imap/imap_client_factory.dart';
/// Manages one IMAP IDLE connection per account.
@@ -80,7 +81,12 @@ class _AccountSync {
await _sync();
await _idle();
_backoffSeconds = 5;
- } catch (_) {
+ } catch (e, st) {
+ log(
+ 'Sync failed for ${account.email}, retrying in ${_backoffSeconds}s',
+ error: e,
+ stackTrace: st,
+ );
await Future.delayed(Duration(seconds: _backoffSeconds));
_backoffSeconds = (_backoffSeconds * 2).clamp(5, 300);
}
diff --git a/lib/core/utils/format_utils.dart b/lib/core/utils/format_utils.dart
new file mode 100644
index 0000000..d3ad44b
--- /dev/null
+++ b/lib/core/utils/format_utils.dart
@@ -0,0 +1,9 @@
+/// Returns a human-readable file size string (B / KB / MB / GB).
+String fmtSize(int bytes) {
+ if (bytes < 1024) return '$bytes B';
+ if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
+ if (bytes < 1024 * 1024 * 1024) {
+ return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
+ }
+ return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
+}
diff --git a/lib/core/utils/html_utils.dart b/lib/core/utils/html_utils.dart
new file mode 100644
index 0000000..cb24c3a
--- /dev/null
+++ b/lib/core/utils/html_utils.dart
@@ -0,0 +1,15 @@
+/// Strips HTML tags and decodes common entities to produce plain text.
+///
+/// Not a full HTML parser — handles the common subset found in email bodies.
+String htmlToPlain(String html) => html
+ .replaceAll(RegExp(r'
'), '\n')
+ .replaceAll(RegExp(r'
', caseSensitive: false), '\n') + .replaceAll(RegExp(r'
', caseSensitive: false), '') + .replaceAll(RegExp(r'<[^>]+>'), '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(' ', ' ') + .trim(); diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart new file mode 100644 index 0000000..801c7ab --- /dev/null +++ b/lib/core/utils/logger.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart'; + +/// Thin wrapper around [debugPrint] so log calls are easy to find and +/// can be swapped for a structured logger later without touching callers. +void log(String message, {Object? error, StackTrace? stackTrace}) { + if (error != null) { + debugPrint('[SharedInbox] $message — $error'); + if (stackTrace != null) debugPrint(stackTrace.toString()); + } else { + debugPrint('[SharedInbox] $message'); + } +} diff --git a/lib/data/imap/imap_client_factory.dart b/lib/data/imap/imap_client_factory.dart index b2ea89f..de0124d 100644 --- a/lib/data/imap/imap_client_factory.dart +++ b/lib/data/imap/imap_client_factory.dart @@ -1,6 +1,7 @@ import 'package:enough_mail/enough_mail.dart'; import '../../core/models/account.dart'; +import '../../core/utils/logger.dart'; /// Opens an authenticated IMAP client for [account]. Futureto newline', () { + expect(htmlToPlain('
paragraph
'), '\nparagraph'); + }); + + test('decodes &', () { + expect(htmlToPlain('a & b'), 'a & b'); + }); + + test('decodes < and >', () { + expect(htmlToPlain('<tag>'), 'text
Hello Alice,
' + 'Please find the invoice attached.
' + 'Best regards,
Bob