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]. Future connectImap(Account account, String password) async { @@ -35,8 +36,8 @@ Future connectSmtp(Account account, String password) async { // Opportunistic TLS on submission port (587) try { await client.startTls(); - } catch (_) { - // Server doesn't support STARTTLS — proceed without it. + } catch (e) { + log('STARTTLS not available on ${account.smtpHost}: $e — continuing without TLS'); } } await client.authenticate(account.email, password); diff --git a/lib/data/repositories/mailbox_repository_impl.dart b/lib/data/repositories/mailbox_repository_impl.dart index 49cda35..5082eed 100644 --- a/lib/data/repositories/mailbox_repository_impl.dart +++ b/lib/data/repositories/mailbox_repository_impl.dart @@ -6,6 +6,7 @@ import '../../core/repositories/account_repository.dart'; import '../../core/repositories/mailbox_repository.dart'; import '../db/database.dart'; import '../db/database.dart' as db show Mailbox; +import '../../core/utils/logger.dart'; import '../imap/imap_client_factory.dart'; class MailboxRepositoryImpl implements MailboxRepository { @@ -46,7 +47,10 @@ class MailboxRepositoryImpl implements MailboxRepository { ); unread = status.messagesUnseen; total = status.messagesExists; - } catch (_) {} + } catch (e) { + // \Noselect mailboxes can't be STATUSed — skip counts silently. + log('STATUS skipped for $path: $e'); + } await _db.into(_db.mailboxes).insertOnConflictUpdate( MailboxesCompanion.insert( diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index 2d68591..2062c0f 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../core/models/email.dart'; +import '../../core/utils/format_utils.dart'; +import '../../core/utils/html_utils.dart'; import '../../di.dart'; final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm'); @@ -86,7 +88,7 @@ class _EmailDetailScreenState extends ConsumerState { const Divider(), ], SelectableText( - body.textBody ?? _htmlToPlain(body.htmlBody ?? ''), + body.textBody ?? htmlToPlain(body.htmlBody ?? ''), style: Theme.of(ctx).textTheme.bodyMedium, ), if (body.attachments.isNotEmpty) ...[ @@ -103,7 +105,7 @@ class _EmailDetailScreenState extends ConsumerState { dense: true, leading: const Icon(Icons.attach_file), title: Text(att.filename), - subtitle: Text(_fmtSize(att.size)), + subtitle: Text(fmtSize(att.size)), ), ], ], @@ -154,18 +156,4 @@ class _EmailDetailScreenState extends ConsumerState { }); } - String _htmlToPlain(String html) => html - .replaceAll(RegExp(r''), '\n') - .replaceAll(RegExp(r'<[^>]+>'), '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll(' ', ' '); - - String _fmtSize(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; - } } diff --git a/pubspec.yaml b/pubspec.yaml index 1065b7c..2ba610b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,6 @@ dependencies: # State management flutter_riverpod: ^2.6.1 - riverpod_annotation: ^2.4.1 # Navigation go_router: ^14.8.1 @@ -31,21 +30,16 @@ dependencies: # Secure credential storage (passwords) flutter_secure_storage: ^9.2.4 - # Utilities - freezed_annotation: ^2.4.4 - json_annotation: ^4.9.0 + # Date formatting intl: any - collection: ^1.19.1 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 drift_dev: ^2.20.3 - riverpod_generator: ^2.6.2 - freezed: ^2.5.8 - json_serializable: ^6.9.0 build_runner: ^2.4.13 + test: ^1.25.0 flutter: uses-material-design: true diff --git a/test/unit/account_sync_backoff_test.dart b/test/unit/account_sync_backoff_test.dart new file mode 100644 index 0000000..0a65aad --- /dev/null +++ b/test/unit/account_sync_backoff_test.dart @@ -0,0 +1,41 @@ +// Tests for the exponential-backoff clamping logic used in AccountSyncManager. +// The manager itself requires Flutter DI, so we test the math standalone. + +import 'package:test/test.dart'; + +int nextBackoff(int current) => (current * 2).clamp(5, 300); + +void main() { + group('sync backoff', () { + test('starts at 5 seconds', () { + expect(5, 5); + }); + + test('doubles each failure', () { + var backoff = 5; + backoff = nextBackoff(backoff); + expect(backoff, 10); + backoff = nextBackoff(backoff); + expect(backoff, 20); + backoff = nextBackoff(backoff); + expect(backoff, 40); + backoff = nextBackoff(backoff); + expect(backoff, 80); + backoff = nextBackoff(backoff); + expect(backoff, 160); + }); + + test('clamps at 300 seconds (5 minutes)', () { + var backoff = 5; + for (var i = 0; i < 20; i++) { + backoff = nextBackoff(backoff); + } + expect(backoff, 300); + }); + + test('never goes below 5 seconds', () { + expect(nextBackoff(1), 5); + expect(nextBackoff(0), 5); + }); + }); +} diff --git a/test/unit/email_model_test.dart b/test/unit/email_model_test.dart new file mode 100644 index 0000000..34c1e09 --- /dev/null +++ b/test/unit/email_model_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; + +import 'package:sharedinbox/core/models/email.dart'; + +// Mirrors the encoding logic in EmailRepositoryImpl so we can test it +// independently without spinning up a database. +String encodeAddresses(List addresses) => jsonEncode( + addresses + .map((a) => {'name': a.name, 'email': a.email}) + .toList(), + ); + +List decodeAddresses(String json) { + final list = jsonDecode(json) as List; + return list + .map( + (e) => EmailAddress( + name: e['name'] as String?, + email: e['email'] as String, + ), + ) + .toList(); +} + +void main() { + group('EmailAddress JSON roundtrip', () { + test('encodes and decodes a single address with name', () { + const addr = EmailAddress(name: 'Alice', email: 'alice@example.com'); + final decoded = decodeAddresses(encodeAddresses([addr])); + expect(decoded, hasLength(1)); + expect(decoded.first.name, 'Alice'); + expect(decoded.first.email, 'alice@example.com'); + }); + + test('encodes and decodes an address without a display name', () { + const addr = EmailAddress(email: 'bob@example.com'); + final decoded = decodeAddresses(encodeAddresses([addr])); + expect(decoded.first.name, isNull); + expect(decoded.first.email, 'bob@example.com'); + }); + + test('encodes and decodes multiple addresses', () { + final addresses = [ + const EmailAddress(name: 'Alice', email: 'alice@example.com'), + const EmailAddress(email: 'bob@example.com'), + ]; + final decoded = decodeAddresses(encodeAddresses(addresses)); + expect(decoded, hasLength(2)); + expect(decoded[0].email, 'alice@example.com'); + expect(decoded[1].email, 'bob@example.com'); + }); + + test('encodes empty list', () { + final decoded = decodeAddresses(encodeAddresses([])); + expect(decoded, isEmpty); + }); + + test('handles special characters in display name', () { + const addr = EmailAddress(name: 'Müller, Hans', email: 'hans@example.de'); + final decoded = decodeAddresses(encodeAddresses([addr])); + expect(decoded.first.name, 'Müller, Hans'); + }); + }); + + group('EmailAddress.toString', () { + test('includes name when present', () { + const addr = EmailAddress(name: 'Alice', email: 'alice@example.com'); + expect(addr.toString(), 'Alice '); + }); + + test('returns just email when name is null', () { + const addr = EmailAddress(email: 'alice@example.com'); + expect(addr.toString(), 'alice@example.com'); + }); + }); + + group('EmailDraft', () { + test('constructs with required fields', () { + final draft = EmailDraft( + from: const EmailAddress(name: 'Me', email: 'me@example.com'), + to: [const EmailAddress(email: 'you@example.com')], + cc: [], + subject: 'Hello', + body: 'World', + ); + expect(draft.subject, 'Hello'); + expect(draft.to, hasLength(1)); + expect(draft.cc, isEmpty); + }); + }); +} diff --git a/test/unit/format_utils_test.dart b/test/unit/format_utils_test.dart new file mode 100644 index 0000000..08a46bd --- /dev/null +++ b/test/unit/format_utils_test.dart @@ -0,0 +1,28 @@ +import 'package:test/test.dart'; + +import 'package:sharedinbox/core/utils/format_utils.dart'; + +void main() { + group('fmtSize', () { + test('formats bytes', () { + expect(fmtSize(0), '0 B'); + expect(fmtSize(1), '1 B'); + expect(fmtSize(1023), '1023 B'); + }); + + test('formats kilobytes', () { + expect(fmtSize(1024), '1.0 KB'); + expect(fmtSize(1536), '1.5 KB'); + expect(fmtSize(1024 * 1023), '1023.0 KB'); + }); + + test('formats megabytes', () { + expect(fmtSize(1024 * 1024), '1.0 MB'); + expect(fmtSize((1024 * 1024 * 2.5).round()), '2.5 MB'); + }); + + test('formats gigabytes', () { + expect(fmtSize(1024 * 1024 * 1024), '1.0 GB'); + }); + }); +} diff --git a/test/unit/html_utils_test.dart b/test/unit/html_utils_test.dart new file mode 100644 index 0000000..f9347f0 --- /dev/null +++ b/test/unit/html_utils_test.dart @@ -0,0 +1,69 @@ +import 'package:test/test.dart'; + +import 'package:sharedinbox/core/utils/html_utils.dart'; + +void main() { + group('htmlToPlain', () { + test('returns plain text unchanged', () { + expect(htmlToPlain('Hello world'), 'Hello world'); + }); + + test('strips simple tags', () { + expect(htmlToPlain('bold text'), 'bold text'); + }); + + test('converts
to newline', () { + expect(htmlToPlain('line1
line2'), 'line1\nline2'); + }); + + test('converts self-closing
to newline', () { + expect(htmlToPlain('line1
line2'), 'line1\nline2'); + }); + + test('converts

to newline', () { + expect(htmlToPlain('

paragraph

'), '\nparagraph'); + }); + + test('decodes &', () { + expect(htmlToPlain('a & b'), 'a & b'); + }); + + test('decodes < and >', () { + expect(htmlToPlain('<tag>'), ''); + }); + + test('decodes "', () { + expect(htmlToPlain('say "hi"'), 'say "hi"'); + }); + + test('decodes '', () { + expect(htmlToPlain('it's'), "it's"); + }); + + test('decodes   as space', () { + expect(htmlToPlain('a b'), 'a b'); + }); + + test('trims surrounding whitespace', () { + expect(htmlToPlain(' hello '), 'hello'); + }); + + test('handles empty string', () { + expect(htmlToPlain(''), ''); + }); + + test('handles nested tags', () { + expect(htmlToPlain('

text

'), '\ntext'); + }); + + test('real-world HTML email snippet', () { + const html = '

Hello Alice,

' + '

Please find the invoice attached.

' + '

Best regards,
Bob

'; + final result = htmlToPlain(html); + expect(result, contains('Hello Alice,')); + expect(result, contains('Best regards,')); + expect(result, contains('Bob')); + }); + }); +}