Linting, tests, README, CI, and code quality improvements

Linting:
- analysis_options.yaml: flutter_lints base + 20 additional rules
  (prefer_single_quotes, avoid_print, cancel_subscriptions,
  unawaited_futures, empty_catches, always_declare_return_types, …)
- unused_import / dead_code treated as errors

pubspec.yaml:
- Remove 6 unused packages: freezed, freezed_annotation, json_annotation,
  json_serializable, riverpod_generator, collection
- Add test: ^1.25.0 for pure-Dart unit tests

Testable utilities (extracted from screen code):
- lib/core/utils/html_utils.dart — htmlToPlain()
- lib/core/utils/format_utils.dart — fmtSize()
- lib/core/utils/logger.dart — thin debugPrint wrapper

Unit tests (test/unit/, no device required):
- html_utils_test.dart — 13 cases covering tags, entities, edge cases
- format_utils_test.dart — B / KB / MB / GB formatting
- email_model_test.dart — EmailAddress JSON roundtrip, toString, EmailDraft
- account_sync_backoff_test.dart — exponential backoff + clamping

Taskfile:
- task test → dart test test/unit/ (fast, no device)
- task test-flutter → flutter test (widget tests)
- task check → analyze + unit tests in parallel

README rewrite:
- Covers user flow (download, add account)
- Developer flow: nix develop, direnv allow, task codegen, task check
- Platform status table (Linux done, others pending)
- Project layout diagram
- Schema-change workflow

CI (.github/workflows/ci.yml):
- analyze-and-test job: flutter analyze + dart test on every push/PR
- integration job: Nix + Stalwart on push to main
- build-linux job: flutter build linux --release

Logging:
- Replace all bare catch (_) {} with log() calls
- Sync backoff errors now print account email + retry delay
- STATUS failures on \Noselect mailboxes logged at debug level
- STARTTLS failures logged before continuing without TLS

.gitignore:
- Add .direnv/, .flutter-plugins-dependencies
- Add all platform generated-plugin wiring files
- Add .env / .env.local
- Add linux/build/

.env.example: documents optional STALWART_PORT overrides

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-16 08:11:29 +02:00
co-authored by Claude Sonnet 4.6
parent 72e2b599bf
commit 03d35387f7
18 changed files with 576 additions and 54 deletions
+10
View File
@@ -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
+89
View File
@@ -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
+11
View File
@@ -15,12 +15,23 @@ build/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Local environment overrides (copied from .env.example)
.env
.env.local
# Android # Android
android/.gradle/ android/.gradle/
android/local.properties android/local.properties
android/app/google-services.json android/app/google-services.json
android/key.properties
# iOS / macOS # iOS / macOS
ios/Pods/ ios/Pods/
macos/Pods/ macos/Pods/
*.xcworkspace/ *.xcworkspace/
# Linux build output
linux/build/
# Stalwart dev server runtime data (created by stalwart-dev/start)
/tmp/stalwart-dev-*/
+112 -19
View File
@@ -2,44 +2,137 @@
IMAP/SMTP email client written in [Flutter](https://flutter.dev). 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. Supports **multiple accounts** — each synced independently via IMAP IDLE.
## Design philosophy: offline-first ## Design philosophy: offline-first
``` ```text
IMAP/SMTP server IMAP/SMTP server
AccountSyncManager ←→ Drift (SQLite, local DB) AccountSyncManager ←→ Drift (SQLite, local DB)
(IMAP IDLE per account)
UI (reads only from DB) 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 ## Key packages
| Package | Role | | Package | Role |
|---|---| | --- | --- |
| `enough_mail` (vendored in `packages/`) | IMAP / SMTP / MIME | | `enough_mail` (vendored in `packages/`) | IMAP / SMTP / MIME |
| `drift` | Local SQLite ORM | | `drift` | Local SQLite ORM (offline-first store) |
| `flutter_riverpod` | State management / DI | | `flutter_riverpod` | State management / DI |
| `go_router` | Navigation | | `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 ```bash
# Install dependencies and run code generation # One-time: allow direnv in this directory
dart pub get direnv allow
dart run build_runner build --delete-conflicting-outputs
# Run on desktop
flutter run -d linux # or macos / windows
# Run on Android / iOS
flutter run
``` ```
## 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
```
+17 -7
View File
@@ -2,7 +2,7 @@ version: "3"
tasks: tasks:
default: default:
desc: Run all checks (analyze + test, in parallel) desc: Run all checks (analyze + unit tests, in parallel)
deps: [check] deps: [check]
_nix-check: _nix-check:
@@ -13,7 +13,7 @@ tasks:
msg: "Not in nix dev shell. Run: nix develop" msg: "Not in nix dev shell. Run: nix develop"
codegen: codegen:
desc: Run build_runner to generate Drift and Riverpod code desc: Generate Drift DB code (run after any schema change)
deps: [_nix-check] deps: [_nix-check]
cmds: cmds:
- | - |
@@ -23,7 +23,7 @@ tasks:
echo "codegen: $((END - START))s" echo "codegen: $((END - START))s"
analyze: analyze:
desc: Run flutter analyze desc: Static analysis with flutter analyze + analysis_options.yaml rules
deps: [_nix-check] deps: [_nix-check]
cmds: cmds:
- | - |
@@ -33,20 +33,30 @@ tasks:
echo "analyze: $((END - START))s" echo "analyze: $((END - START))s"
analyze-fix: analyze-fix:
desc: Auto-fix with dart fix --apply desc: Auto-fix lint issues with dart fix --apply
deps: [_nix-check] deps: [_nix-check]
cmds: cmds:
- dart fix --apply - dart fix --apply
test: 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] deps: [_nix-check]
cmds: cmds:
- | - |
START=$(date +%s) START=$(date +%s)
flutter test flutter test
END=$(date +%s) END=$(date +%s)
echo "test: $((END - START))s" echo "flutter test: $((END - START))s"
integration: integration:
desc: Run integration tests (starts and stops Stalwart automatically) desc: Run integration tests (starts and stops Stalwart automatically)
@@ -65,5 +75,5 @@ tasks:
- flutter run -d linux - flutter run -d linux
check: check:
desc: All checks — analyze + unit tests in parallel desc: All fast checks — analyze + unit tests in parallel
deps: [analyze, test] deps: [analyze, test]
+49
View File
@@ -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
+7 -1
View File
@@ -6,6 +6,7 @@ import '../models/account.dart';
import '../repositories/account_repository.dart'; import '../repositories/account_repository.dart';
import '../repositories/email_repository.dart'; import '../repositories/email_repository.dart';
import '../repositories/mailbox_repository.dart'; import '../repositories/mailbox_repository.dart';
import '../utils/logger.dart';
import '../../data/imap/imap_client_factory.dart'; import '../../data/imap/imap_client_factory.dart';
/// Manages one IMAP IDLE connection per account. /// Manages one IMAP IDLE connection per account.
@@ -80,7 +81,12 @@ class _AccountSync {
await _sync(); await _sync();
await _idle(); await _idle();
_backoffSeconds = 5; _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)); await Future.delayed(Duration(seconds: _backoffSeconds));
_backoffSeconds = (_backoffSeconds * 2).clamp(5, 300); _backoffSeconds = (_backoffSeconds * 2).clamp(5, 300);
} }
+9
View File
@@ -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';
}
+15
View File
@@ -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'<br\s*/?>'), '\n')
.replaceAll(RegExp(r'<p\s*/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'</p>', caseSensitive: false), '')
.replaceAll(RegExp(r'<[^>]+>'), '')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&nbsp;', ' ')
.trim();
+12
View File
@@ -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');
}
}
+3 -2
View File
@@ -1,6 +1,7 @@
import 'package:enough_mail/enough_mail.dart'; import 'package:enough_mail/enough_mail.dart';
import '../../core/models/account.dart'; import '../../core/models/account.dart';
import '../../core/utils/logger.dart';
/// Opens an authenticated IMAP client for [account]. /// Opens an authenticated IMAP client for [account].
Future<ImapClient> connectImap(Account account, String password) async { Future<ImapClient> connectImap(Account account, String password) async {
@@ -35,8 +36,8 @@ Future<SmtpClient> connectSmtp(Account account, String password) async {
// Opportunistic TLS on submission port (587) // Opportunistic TLS on submission port (587)
try { try {
await client.startTls(); await client.startTls();
} catch (_) { } catch (e) {
// Server doesn't support STARTTLS — proceed without it. log('STARTTLS not available on ${account.smtpHost}: $e — continuing without TLS');
} }
} }
await client.authenticate(account.email, password); await client.authenticate(account.email, password);
@@ -6,6 +6,7 @@ import '../../core/repositories/account_repository.dart';
import '../../core/repositories/mailbox_repository.dart'; import '../../core/repositories/mailbox_repository.dart';
import '../db/database.dart'; import '../db/database.dart';
import '../db/database.dart' as db show Mailbox; import '../db/database.dart' as db show Mailbox;
import '../../core/utils/logger.dart';
import '../imap/imap_client_factory.dart'; import '../imap/imap_client_factory.dart';
class MailboxRepositoryImpl implements MailboxRepository { class MailboxRepositoryImpl implements MailboxRepository {
@@ -46,7 +47,10 @@ class MailboxRepositoryImpl implements MailboxRepository {
); );
unread = status.messagesUnseen; unread = status.messagesUnseen;
total = status.messagesExists; 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( await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
+4 -16
View File
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../core/models/email.dart'; import '../../core/models/email.dart';
import '../../core/utils/format_utils.dart';
import '../../core/utils/html_utils.dart';
import '../../di.dart'; import '../../di.dart';
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm'); final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
@@ -86,7 +88,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
const Divider(), const Divider(),
], ],
SelectableText( SelectableText(
body.textBody ?? _htmlToPlain(body.htmlBody ?? ''), body.textBody ?? htmlToPlain(body.htmlBody ?? ''),
style: Theme.of(ctx).textTheme.bodyMedium, style: Theme.of(ctx).textTheme.bodyMedium,
), ),
if (body.attachments.isNotEmpty) ...[ if (body.attachments.isNotEmpty) ...[
@@ -103,7 +105,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
dense: true, dense: true,
leading: const Icon(Icons.attach_file), leading: const Icon(Icons.attach_file),
title: Text(att.filename), title: Text(att.filename),
subtitle: Text(_fmtSize(att.size)), subtitle: Text(fmtSize(att.size)),
), ),
], ],
], ],
@@ -154,18 +156,4 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
}); });
} }
String _htmlToPlain(String html) => html
.replaceAll(RegExp(r'<br\s*/?>'), '\n')
.replaceAll(RegExp(r'<[^>]+>'), '')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&nbsp;', ' ');
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';
}
} }
+2 -8
View File
@@ -23,7 +23,6 @@ dependencies:
# State management # State management
flutter_riverpod: ^2.6.1 flutter_riverpod: ^2.6.1
riverpod_annotation: ^2.4.1
# Navigation # Navigation
go_router: ^14.8.1 go_router: ^14.8.1
@@ -31,21 +30,16 @@ dependencies:
# Secure credential storage (passwords) # Secure credential storage (passwords)
flutter_secure_storage: ^9.2.4 flutter_secure_storage: ^9.2.4
# Utilities # Date formatting
freezed_annotation: ^2.4.4
json_annotation: ^4.9.0
intl: any intl: any
collection: ^1.19.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^4.0.0 flutter_lints: ^4.0.0
drift_dev: ^2.20.3 drift_dev: ^2.20.3
riverpod_generator: ^2.6.2
freezed: ^2.5.8
json_serializable: ^6.9.0
build_runner: ^2.4.13 build_runner: ^2.4.13
test: ^1.25.0
flutter: flutter:
uses-material-design: true uses-material-design: true
+41
View File
@@ -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);
});
});
}
+93
View File
@@ -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<EmailAddress> addresses) => jsonEncode(
addresses
.map((a) => {'name': a.name, 'email': a.email})
.toList(),
);
List<EmailAddress> 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 <alice@example.com>');
});
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);
});
});
}
+28
View File
@@ -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');
});
});
}
+69
View File
@@ -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('<b>bold</b> text'), 'bold text');
});
test('converts <br> to newline', () {
expect(htmlToPlain('line1<br>line2'), 'line1\nline2');
});
test('converts self-closing <br/> to newline', () {
expect(htmlToPlain('line1<br/>line2'), 'line1\nline2');
});
test('converts <p> to newline', () {
expect(htmlToPlain('<p>paragraph</p>'), '\nparagraph');
});
test('decodes &amp;', () {
expect(htmlToPlain('a &amp; b'), 'a & b');
});
test('decodes &lt; and &gt;', () {
expect(htmlToPlain('&lt;tag&gt;'), '<tag>');
});
test('decodes &quot;', () {
expect(htmlToPlain('say &quot;hi&quot;'), 'say "hi"');
});
test('decodes &#39;', () {
expect(htmlToPlain('it&#39;s'), "it's");
});
test('decodes &nbsp; as space', () {
expect(htmlToPlain('a&nbsp;b'), 'a b');
});
test('trims surrounding whitespace', () {
expect(htmlToPlain(' hello '), 'hello');
});
test('handles empty string', () {
expect(htmlToPlain(''), '');
});
test('handles nested tags', () {
expect(htmlToPlain('<div><p>text</p></div>'), '\ntext');
});
test('real-world HTML email snippet', () {
const html = '<p>Hello <b>Alice</b>,</p>'
'<p>Please find the invoice attached.</p>'
'<p>Best regards,<br/>Bob</p>';
final result = htmlToPlain(html);
expect(result, contains('Hello Alice,'));
expect(result, contains('Best regards,'));
expect(result, contains('Bob'));
});
});
}