Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 f6ca6a1108 ci: add macOS and Windows debug build jobs (D1)
Add build-macos and build-windows CI jobs that run flutter build
for each platform after the main check suite passes on main.

Both jobs use FVM to install the pinned Flutter version and run
codegen before building. They require platform-specific runners
labelled 'macos-latest' / 'windows-latest'; the jobs are skipped
automatically if no matching runner is registered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:55:46 +02:00
Bot of Thomas Güttler 2f1bff8922 ci: enforce ui/→data/ layer boundary (A5) (#53) 2026-05-14 11:41:34 +02:00
Bot of Thomas Güttler dd66c3834d test: golden tests for key EmailListScreen states (T5) (#52) 2026-05-14 11:33:45 +02:00
Bot of Thomas Güttler 548f4e92dc perf: cache formatted date strings in EmailListScreen (P5) (#51) 2026-05-14 11:31:19 +02:00
Bot of Thomas Güttler 5311720a7e fix: open HTML email links in external browser (S4) (#50) 2026-05-14 11:26:33 +02:00
Bot of Thomas Güttler a723380560 perf: defer HTML-to-plain conversion off the UI thread (P3) (#49) 2026-05-14 11:14:23 +02:00
Bot of Thomas Güttler 499774d1a6 feat: add 'Mark all as read' to mailbox overflow menu (U8) (#48) 2026-05-14 10:58:33 +02:00
Bot of Thomas Güttler 132b6aeb9a feat: recent searches history in SearchScreen (U3) (#47) 2026-05-14 10:51:28 +02:00
Bot of Thomas Güttler efd5a1fc17 test: AccountSyncManager integration tests without real servers (A3) (#46) 2026-05-14 10:49:29 +02:00
Bot of Thomas Güttler 44e387bfb3 fix: treat TLS config errors as permanent in sync loops (R5) (#45) 2026-05-14 10:29:07 +02:00
Bot of Thomas Güttler 546b06ba5a test(T3): add contract test suites for Account/Mailbox/Email repositories (#43) 2026-05-14 10:20:32 +02:00
Bot of Thomas Güttler 5ba24a66e0 fix: retry AAB upload on httplib2 RedirectMissingLocation error (#44) 2026-05-14 10:20:25 +02:00
Bot of Thomas Güttler 4f16587564 feat(P2): paginate email list — default 50 threads, Load more button (#42) 2026-05-14 10:09:05 +02:00
Bot of Thomas Güttler f0f81777b5 feat(P1): FTS5 virtual table for email search (replaces LIKE scan) (#41) 2026-05-14 10:01:42 +02:00
Bot of Thomas Güttler 64fdc53bbd refactor(A1): extract EmailDetailNotifier, drop initState DB coupling (#39) 2026-05-14 09:49:38 +02:00
Bot of Thomas Güttler 084ba2b7ba fix: increase Play Store upload timeout and add retries (#40) 2026-05-14 09:46:59 +02:00
Bot of Thomas Güttler 6d83a5670d fix: upgrade workmanager to 0.9.0+3 to fix Kotlin 2.x AAB build failure (#38) 2026-05-14 09:03:17 +02:00
Bot of Thomas Güttler 1d93eb10f3 fix: enable core library desugaring for flutter_local_notifications (#37) 2026-05-14 08:39:42 +02:00
Bot of Thomas Güttler f9030dc1e5 perf(P4): add indexes on mailboxes and threads for observeMailboxes/observeThreads (#36) 2026-05-14 08:37:00 +02:00
Bot of Thomas Güttler 92e91d9fad fix(R3): wrap flutter_html in error boundary to prevent screen crash (#35) 2026-05-14 08:27:02 +02:00
Bot of Thomas Güttler 1117cadf2a feat(D2): add task check-coverage and enforce coverage gate in check-fast (#34) 2026-05-14 05:29:41 +02:00
39 changed files with 1915 additions and 205 deletions
+66
View File
@@ -40,3 +40,69 @@ jobs:
- name: Build Linux
run: nix develop --command task build-linux-release
build-macos:
name: Build macOS Debug
runs-on: macos-latest
needs: check
if: github.ref == 'refs/heads/main'
# Requires a macOS runner labelled 'macos-latest'.
# Jobs are skipped automatically when no matching runner is registered.
steps:
- uses: actions/checkout@v4
- name: Install FVM
run: dart pub global activate fvm
- name: Install Flutter via FVM
run: |
fvm install --skip-pub-get
fvm use --skip-pub-get
- name: Pub get
run: fvm flutter pub get --suppress-analytics
- name: Generate code
run: fvm flutter pub run build_runner build
- name: Generate changelog
run: |
mkdir -p assets
git log -n 50 --pretty=format:"* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s" --date=short > assets/changelog.txt
- name: Build macOS
run: fvm flutter build macos --debug --no-pub
build-windows:
name: Build Windows Debug
runs-on: windows-latest
needs: check
if: github.ref == 'refs/heads/main'
# Requires a Windows runner labelled 'windows-latest'.
# Jobs are skipped automatically when no matching runner is registered.
steps:
- uses: actions/checkout@v4
- name: Install FVM
run: dart pub global activate fvm
- name: Install Flutter via FVM
run: |
fvm install --skip-pub-get
fvm use --skip-pub-get
- name: Pub get
run: fvm flutter pub get --suppress-analytics
- name: Generate code
run: fvm flutter pub run build_runner build
- name: Generate changelog
run: |
mkdir -p assets
git log -n 50 "--pretty=format:* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s" --date=short > assets/changelog.txt
- name: Build Windows
run: fvm flutter build windows --debug --no-pub
+59
View File
@@ -0,0 +1,59 @@
# Implementation Plan: Secure WebView for HTML Emails (#21)
## Goal
Replace the current `flutter_html` based rendering with a hardened WebView-based approach to improve rendering fidelity while strictly enforcing security and privacy.
## 1. Dependency Management
- **Core**: `webview_flutter` (v4+)
- **Linux Platform**: `webview_flutter_linux` (Official community-supported or WebKitGTK based implementation). *Note: I will verify the exact package name during implementation.*
- **Utilities**: `url_launcher` (existing) for opening links in the system browser.
## 2. Secure WebView Component (`lib/ui/widgets/secure_email_webview.dart`)
Create a new widget `SecureEmailWebView` that encapsulates the `WebViewWidget` and its controller.
### Configuration & Hardening
- **Disable JavaScript**: `controller.setJavaScriptMode(JavaScriptMode.disabled)`.
- **Background**: Match the application theme (e.g., transparent or surface color).
- **Security Headers/CSP**: Inject a Content Security Policy via `<meta>` tag in the HTML wrapper:
- `default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:;` (Blocks all external assets by default).
### Image Blocking Logic
- **Initial State**: Block remote images by injecting a CSP that restricts `img-src` to `data:` and local schemes.
- **Toggle Mechanism**:
- Provide a "Load Remote Images" button in the Flutter UI.
- When triggered, re-render the HTML with an updated CSP: `img-src * data:;`.
### Link Interception & Phishing Protection
- Implement `NavigationDelegate.onNavigationRequest`.
- **Process**:
1. Intercept any URL that doesn't start with `about:blank` or `data:`.
2. Block the navigation in the WebView.
3. Trigger a Flutter `showDialog` for confirmation.
- **Phishing Protection Dialog**:
- Show the full URL.
- **Bold the FQDN**: Parse the URL using `Uri.parse`.
- Example: `https://`**`important-bank.com`**`/login`
- "Open in Browser" button uses `url_launcher`.
## 3. Integration Plan
### Step 1: Initialization
Modify `lib/main.dart` to initialize the Linux WebView platform (using `webview_flutter_linux` or similar) during app startup.
### Step 2: Replace Renderer in Screens
- **EmailDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
- **ThreadDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
- Remove `flutter_html` imports and dependencies once migration is complete.
## 4. Verification & Security Audit
- **Manual Tests**:
- Open emails with complex HTML layouts.
- Verify images are blocked initially.
- Verify "Load images" works.
- Click various links (http, https, mailto) and verify the confirmation dialog and FQDN bolding.
- **Security Check**:
- Verify that `<script>` tags are not executed.
- Verify no network requests for external images occur before user consent (via DevTools or proxy).
## 5. Potential Challenges
- **Linux WebView Stability**: WebKitGTK on Linux can sometimes have rendering or sizing issues in Flutter.
- **Scrolling**: Ensuring the WebView integrates smoothly into the `ListView` of the email detail screen (might require fixed height or `SizedBox`).
+19 -2
View File
@@ -331,6 +331,12 @@ tasks:
cmds:
- fvm dart run scripts/check_coverage.dart
check-coverage:
desc: Run unit+widget tests with coverage, then fail if the gate is not met
deps: [test]
cmds:
- task: coverage
website-dev:
desc: Run Hugo development server
cmds:
@@ -361,8 +367,19 @@ tasks:
${SSH_USER}@${SSH_HOST}:public_html/
check-fast:
desc: Pre-commit checks — analyze + unit tests + widget tests (no build, no integration)
deps: [analyze, test, check-hygiene]
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
deps: [analyze, check-coverage, check-hygiene, check-layers]
check-layers:
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
cmds:
- |
VIOLATIONS=$(grep -rn "package:sharedinbox/data/" lib/ui/ 2>/dev/null || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):"
echo "$VIOLATIONS"
exit 1
fi
check-hygiene:
desc: Verify that no forbidden files (like home dir config) are tracked
+3
View File
@@ -13,6 +13,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@@ -65,6 +66,8 @@ flutter {
}
dependencies {
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
// integration_test is a dev dependency; the Flutter plugin loader adds it as
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
// references its class in all variants. Make it available for release compilation
+2
View File
@@ -84,6 +84,8 @@
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
(python3.withPackages (ps: with ps; [
google-api-python-client
google-auth-httplib2
httplib2
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
]);
+9 -3
View File
@@ -1,14 +1,19 @@
import 'package:sharedinbox/core/models/email.dart';
abstract class EmailRepository {
Stream<List<Email>> observeEmails(String accountId, String mailboxPath);
Stream<List<Email>> observeEmails(
String accountId,
String mailboxPath, {
int limit = 50,
});
/// Groups emails by threadId and returns one [EmailThread] per thread,
/// sorted by the latest message date descending.
Stream<List<EmailThread>> observeThreads(
String accountId,
String mailboxPath,
);
String mailboxPath, {
int limit = 50,
});
/// Returns all emails belonging to [threadId] in [mailboxPath].
Stream<List<Email>> observeEmailsInThread(
@@ -22,6 +27,7 @@ abstract class EmailRepository {
Future<EmailBody> getEmailBody(String emailId);
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
Future<void> markAllAsRead(String accountId, String mailboxPath);
Future<void> moveEmail(String emailId, String destMailboxPath);
/// Deletes the email. Returns the path of the mailbox it was moved to
@@ -0,0 +1,5 @@
abstract interface class SearchHistoryRepository {
Future<List<String>> getRecentSearches();
Future<void> saveSearch(String query);
Future<void> clearHistory();
}
+3
View File
@@ -11,6 +11,7 @@ import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'
show ImapConnectFn, connectImap, verboseLogKey;
import 'package:sharedinbox/data/imap/tls_error.dart' show isTlsConfigError;
typedef OnNewMailCallback = Future<void> Function(String accountEmail);
@@ -291,6 +292,7 @@ class _AccountSync implements _SyncLoop {
}
bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
final s = e.toString().toLowerCase();
// enough_mail doesn't always have typed exceptions for auth, so we check strings.
return s.contains('invalid credentials') ||
@@ -528,6 +530,7 @@ class _JmapAccountSync implements _SyncLoop {
}
bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
final s = e.toString().toLowerCase();
return s.contains('invalid credentials') ||
s.contains('authentication failed') ||
+1 -1
View File
@@ -38,7 +38,7 @@ Future<void> registerBackgroundSync() async {
_kTaskName,
frequency: const Duration(minutes: 15),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.keep,
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
);
}
+73 -1
View File
@@ -234,6 +234,13 @@ class Drafts extends Table {
TextColumn get imapServerId => text().nullable()();
}
@DataClassName('SearchHistoryRow')
class SearchHistoryEntries extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get query => text()();
DateTimeColumn get searchedAt => dateTime()();
}
@DataClassName('UndoActionRow')
class UndoActions extends Table {
TextColumn get id => text()();
@@ -263,16 +270,54 @@ class UndoActions extends Table {
SyncLogMailboxes,
SyncHealth,
UndoActions,
SearchHistoryEntries,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 24;
int get schemaVersion => 27;
Future<void> _createEmailFts() async {
await customStatement('''
CREATE VIRTUAL TABLE IF NOT EXISTS email_fts USING fts5(
subject, preview, from_json,
content='emails',
content_rowid='rowid'
)
''');
await customStatement('''
CREATE TRIGGER IF NOT EXISTS email_fts_ai
AFTER INSERT ON emails BEGIN
INSERT INTO email_fts(rowid, subject, preview, from_json)
VALUES (new.rowid, new.subject, new.preview, new.from_json);
END
''');
await customStatement('''
CREATE TRIGGER IF NOT EXISTS email_fts_au
AFTER UPDATE OF subject, preview, from_json ON emails BEGIN
INSERT INTO email_fts(email_fts, rowid, subject, preview, from_json)
VALUES ('delete', old.rowid, old.subject, old.preview, old.from_json);
INSERT INTO email_fts(rowid, subject, preview, from_json)
VALUES (new.rowid, new.subject, new.preview, new.from_json);
END
''');
await customStatement('''
CREATE TRIGGER IF NOT EXISTS email_fts_ad
AFTER DELETE ON emails BEGIN
INSERT INTO email_fts(email_fts, rowid, subject, preview, from_json)
VALUES ('delete', old.rowid, old.subject, old.preview, old.from_json);
END
''');
}
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async {
await m.createAll();
await _createEmailFts();
},
onUpgrade: (m, from, to) async {
// NOTE: m.createTable(T) creates the LATEST version of table T.
// If you later add a column C to T in version X, you must guard
@@ -431,6 +476,33 @@ class AppDatabase extends _$AppDatabase {
if (from >= 4 && from < 24) {
await m.addColumn(drafts, drafts.imapServerId);
}
if (from < 25) {
// For observeMailboxes: filter by account_id, sort by path.
await m.createIndex(
Index(
'mailboxes_account_id',
'CREATE INDEX IF NOT EXISTS mailboxes_account_id ON mailboxes (account_id, path);',
),
);
// For observeThreads: filter by account_id+mailbox_path, sort by latest_date.
await m.createIndex(
Index(
'threads_latest_date',
'CREATE INDEX IF NOT EXISTS threads_latest_date ON threads (account_id, mailbox_path, latest_date DESC);',
),
);
}
if (from < 26) {
await _createEmailFts();
// Backfill FTS index from existing rows.
await customStatement('''
INSERT INTO email_fts(rowid, subject, preview, from_json)
SELECT rowid, subject, preview, from_json FROM emails
''');
}
if (from < 27) {
await m.createTable(searchHistoryEntries);
}
},
);
}
+41 -4
View File
@@ -21,15 +21,52 @@ class TlsModeMismatchException implements Exception {
'STARTTLS). Original error: $original';
}
/// If [error] is a TLS handshake failure caused by a wrong-version-number
/// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException]
/// with [host]/[port] context. Otherwise rethrow [error] unchanged.
/// Wraps a TLS certificate verification failure into a user-actionable message.
///
/// Thrown when the server's certificate cannot be verified — either because it
/// is self-signed, expired, or the CA chain has changed since the account was
/// set up.
class TlsCertificateException implements Exception {
TlsCertificateException(this.host, this.port, this.original);
final String host;
final int port;
final Object original;
@override
String toString() =>
'TLS certificate error on $host:$port — the server certificate could '
'not be verified. The certificate may have changed or expired. '
'Please re-check your account settings or contact your mail provider. '
'Original error: $original';
}
/// Returns true if [error] is a permanent TLS configuration error that will
/// not resolve on its own and requires user action.
bool isTlsConfigError(Object error) =>
error is TlsModeMismatchException || error is TlsCertificateException;
/// If [error] is a recognisable TLS handshake failure, wraps it in a typed
/// exception and throws it. Otherwise rethrows [error] unchanged.
///
/// Recognised patterns:
/// - `WRONG_VERSION_NUMBER` → [TlsModeMismatchException] (port/mode mismatch)
/// - `CERTIFICATE_VERIFY_FAILED` / `HandshakeException` → [TlsCertificateException]
Never rethrowAsTlsHint(Object error, StackTrace stack, String host, int port) {
if (error.toString().contains('WRONG_VERSION_NUMBER')) {
final s = error.toString();
if (s.contains('WRONG_VERSION_NUMBER')) {
Error.throwWithStackTrace(
TlsModeMismatchException(host, port, error),
stack,
);
}
if (s.contains('CERTIFICATE_VERIFY_FAILED') ||
s.contains('HandshakeException') ||
s.contains('CERTIFICATE_EXPIRED') ||
s.contains('CERTIFICATE_UNKNOWN')) {
Error.throwWithStackTrace(
TlsCertificateException(host, port, error),
stack,
);
}
Error.throwWithStackTrace(error, stack);
}
@@ -58,15 +58,17 @@ class EmailRepositoryImpl implements EmailRepository {
@override
Stream<List<model.Email>> observeEmails(
String accountId,
String mailboxPath,
) {
String mailboxPath, {
int limit = 50,
}) {
return (_db.select(_db.emails)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath),
)
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
..limit(limit))
.watch()
.map((rows) => rows.map(_toModel).toList());
}
@@ -74,15 +76,17 @@ class EmailRepositoryImpl implements EmailRepository {
@override
Stream<List<model.EmailThread>> observeThreads(
String accountId,
String mailboxPath,
) {
String mailboxPath, {
int limit = 50,
}) {
return (_db.select(_db.threads)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath),
)
..orderBy([(t) => OrderingTerm.desc(t.latestDate)]))
..orderBy([(t) => OrderingTerm.desc(t.latestDate)])
..limit(limit))
.watch()
.map((rows) => rows.map(_threadRowToModel).toList());
}
@@ -1516,6 +1520,63 @@ class EmailRepositoryImpl implements EmailRepository {
);
}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {
final account = (await _accounts.getAccount(accountId))!;
final unread = await (_db.select(_db.emails)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath) &
t.isSeen.equals(false),
))
.get();
if (unread.isEmpty) return;
await _db.transaction(() async {
for (final row in unread) {
if (account.type == account_model.AccountType.jmap) {
await _enqueueChange(
accountId,
row.id,
'flag_seen',
jsonEncode({'seen': true}),
);
} else {
await _enqueueChange(
accountId,
row.id,
'flag_seen',
jsonEncode({
'uid': row.uid,
'mailboxPath': row.mailboxPath,
'seen': true,
}),
);
}
}
// Bulk mark all unread emails in this mailbox as seen.
await (_db.update(_db.emails)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath) &
t.isSeen.equals(false),
))
.write(const EmailsCompanion(isSeen: Value(true)));
// Update all threads in this mailbox to reflect no unread.
await (_db.update(_db.threads)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath),
))
.write(const ThreadsCompanion(hasUnread: Value(false)));
});
}
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {
final row = await (_db.select(
@@ -2470,28 +2531,39 @@ class EmailRepositoryImpl implements EmailRepository {
String? accountId,
String query,
) async {
final ftsQuery = _toFtsQuery(query);
if (ftsQuery.isEmpty) return [];
final sql = accountId != null
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
final variables = accountId != null
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
: [Variable<String>(ftsQuery)];
final queryRows = await _db
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
final emailRows = await Future.wait(
queryRows.map((r) => _db.emails.mapFromRow(r)),
);
return emailRows.map(_toModel).toList();
}
/// Converts a user query string into an FTS5 match expression.
/// Each whitespace-separated word becomes a prefix term (word*) so that
/// partial words still match. Special FTS5 characters are stripped.
static String _toFtsQuery(String query) {
final words = query
.toLowerCase()
.trim()
.split(RegExp(r'\s+'))
.where((w) => w.isNotEmpty)
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
.where((w) => w.isNotEmpty)
.toList();
final rows = await (_db.select(_db.emails)
..where((t) {
Expression<bool> condition = const Constant(true);
if (accountId != null) {
condition = t.accountId.equals(accountId);
}
for (final word in words) {
final pattern = '%$word%';
condition = condition &
(t.subject.like(pattern) | t.preview.like(pattern));
}
return condition;
})
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
..limit(50))
.get();
return rows.map(_toModel).toList();
if (words.isEmpty) return '';
return words.map((w) => '$w*').join(' ');
}
@override
@@ -0,0 +1,57 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
SearchHistoryRepositoryImpl(this._db);
final AppDatabase _db;
static const _maxEntries = 10;
@override
Future<List<String>> getRecentSearches() async {
final rows = await (_db.select(_db.searchHistoryEntries)
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
..limit(_maxEntries))
.get();
return rows.map((r) => r.query).toList();
}
@override
Future<void> saveSearch(String query) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return;
await _db.transaction(() async {
// Remove existing entry for same query (deduplication).
await (_db.delete(_db.searchHistoryEntries)
..where((t) => t.query.equals(trimmed)))
.go();
await _db.into(_db.searchHistoryEntries).insert(
SearchHistoryEntriesCompanion.insert(
query: trimmed,
searchedAt: DateTime.now(),
),
);
// Prune to the most recent _maxEntries.
final keepIds = await (_db.select(_db.searchHistoryEntries)
..orderBy([(t) => OrderingTerm.desc(t.searchedAt)])
..limit(_maxEntries))
.map((r) => r.id)
.get();
if (keepIds.isNotEmpty) {
await (_db.delete(_db.searchHistoryEntries)
..where((t) => t.id.isNotIn(keepIds)))
.go();
}
});
}
@override
Future<void> clearHistory() async {
await _db.delete(_db.searchHistoryEntries).go();
}
}
+30 -1
View File
@@ -3,11 +3,13 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
@@ -17,13 +19,14 @@ import 'package:sharedinbox/core/services/undo_service.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
import 'package:sharedinbox/core/sync/reliability_runner.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
@@ -86,6 +89,11 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
return UndoRepositoryImpl(ref.watch(dbProvider));
});
final searchHistoryRepositoryProvider =
Provider<SearchHistoryRepository>((ref) {
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
});
final syncLogRepositoryProvider = Provider((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider));
});
@@ -168,6 +176,27 @@ final undoServiceProvider =
return service;
});
/// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
final emailDetailProvider = AsyncNotifierProvider.autoDispose
.family<EmailDetailNotifier, (Email?, EmailBody), String>(
EmailDetailNotifier.new,
);
class EmailDetailNotifier
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
@override
Future<(Email?, EmailBody)> build(String emailId) async {
final repo = ref.read(emailRepositoryProvider);
final results = await Future.wait([
repo.getEmail(emailId),
repo.getEmailBody(emailId),
]);
unawaited(repo.setFlag(emailId, seen: true));
return (results[0] as Email?, results[1] as EmailBody);
}
}
final accountByIdProvider =
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
return ref.watch(accountRepositoryProvider).observeAccounts().map(
+111 -43
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -17,6 +18,14 @@ import 'package:url_launcher/url_launcher.dart';
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
void _openLink(String? url, Map<String, String> attrs, dynamic _) {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri != null) {
unawaited(launchUrl(uri, mode: LaunchMode.externalApplication));
}
}
class EmailDetailScreen extends ConsumerStatefulWidget {
const EmailDetailScreen({super.key, required this.emailId});
final String emailId;
@@ -26,36 +35,27 @@ class EmailDetailScreen extends ConsumerStatefulWidget {
}
class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
late final Future<(Email?, EmailBody)> _dataFuture;
bool _isFlagged = false;
bool _loadRemoteImages = false;
final Set<String> _downloading = {};
@override
void initState() {
super.initState();
final repo = ref.read(emailRepositoryProvider);
_dataFuture = Future.wait([
repo.getEmail(widget.emailId),
repo.getEmailBody(widget.emailId),
]).then((results) {
final email = results[0] as Email?;
Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider);
final detail = ref.watch(emailDetailProvider(widget.emailId));
ref.listen<AsyncValue<(Email?, EmailBody)>>(
emailDetailProvider(widget.emailId),
(_, next) {
final email = next.valueOrNull?.$1;
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
return (email, results[1] as EmailBody);
});
unawaited(repo.setFlag(widget.emailId, seen: true));
}
},
);
@override
Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider);
return FutureBuilder<(Email?, EmailBody)>(
future: _dataFuture,
builder: (ctx, snap) {
final header = snap.data?.$1;
final body = snap.data?.$2;
final header = detail.valueOrNull?.$1;
final body = detail.valueOrNull?.$2;
return Scaffold(
appBar: AppBar(
@@ -69,21 +69,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
tooltip: 'Reply',
onPressed: header == null
? null
: () => _reply(context, header, body, replyAll: false),
: () {
unawaited(_reply(context, header, body, replyAll: false));
},
),
IconButton(
icon: const Icon(Icons.reply_all),
tooltip: 'Reply all',
onPressed: header == null
? null
: () => _reply(context, header, body, replyAll: true),
: () {
unawaited(_reply(context, header, body, replyAll: true));
},
),
IconButton(
icon: const Icon(Icons.forward),
tooltip: 'Forward',
onPressed: header == null
? null
: () => _forward(context, header, body),
: () {
unawaited(_forward(context, header, body));
},
),
IconButton(
icon: const Icon(Icons.mark_email_unread_outlined),
@@ -108,14 +114,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
tooltip: 'Move to folder',
onPressed:
header == null ? null : () => _moveTo(context, header),
onPressed: header == null ? null : () => _moveTo(context, header),
),
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed:
header == null ? null : () => _snooze(context, header),
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton(
icon: const Icon(Icons.delete),
@@ -157,13 +161,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
),
],
),
body: snap.connectionState == ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: snap.hasError
? Center(child: Text('Error: ${snap.error}'))
: _buildBody(ctx, header, body!),
);
},
body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (d) => _buildBody(context, d.$1, d.$2),
),
);
}
@@ -186,7 +188,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
),
),
),
Html(
_SafeHtml(
data: body.htmlBody!,
extensions: [if (!_loadRemoteImages) _BlockRemoteImagesExtension()],
),
@@ -277,26 +279,31 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
String _quotedBody(Email header, EmailBody? body) {
Future<String> _quotedBody(Email header, EmailBody? body) async {
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
final from =
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
final text = body?.textBody ?? htmlToPlain(body?.htmlBody ?? '');
final rawText = body?.textBody;
final text = (rawText != null && rawText.isNotEmpty)
? rawText
: await compute(htmlToPlain, body?.htmlBody ?? '');
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
return '\n\n— On $date, $from wrote:\n$quoted';
}
void _reply(
Future<void> _reply(
BuildContext context,
Email header,
EmailBody? body, {
required bool replyAll,
}) {
}) async {
final to = header.from.isNotEmpty ? header.from.first.email : '';
final subject = (header.subject?.startsWith('Re:') ?? false)
? header.subject!
: 'Re: ${header.subject ?? ''}';
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
final quoted = await _quotedBody(header, body);
if (!context.mounted) return;
unawaited(
context.push(
'/compose',
@@ -304,23 +311,29 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
'replyToEmailId': widget.emailId,
'prefillTo': to,
'prefillSubject': subject,
'prefillBody': _quotedBody(header, body),
'prefillBody': quoted,
if (cc.isNotEmpty) 'prefillCc': cc,
},
),
);
}
void _forward(BuildContext context, Email header, EmailBody? body) {
Future<void> _forward(
BuildContext context,
Email header,
EmailBody? body,
) async {
final subject = (header.subject?.startsWith('Fwd:') ?? false)
? header.subject!
: 'Fwd: ${header.subject ?? ''}';
final quoted = await _quotedBody(header, body);
if (!context.mounted) return;
unawaited(
context.push(
'/compose',
extra: {
'prefillSubject': subject,
'prefillBody': _quotedBody(header, body),
'prefillBody': quoted,
},
),
);
@@ -501,6 +514,61 @@ class _UnsubscribeChip extends StatelessWidget {
}
}
/// Renders [Html] and falls back to an error message if the widget throws
/// during build, preventing a malformed body from crashing the whole screen.
class _SafeHtml extends StatefulWidget {
const _SafeHtml({required this.data, required this.extensions});
final String data;
final List<HtmlExtension> extensions;
@override
State<_SafeHtml> createState() => _SafeHtmlState();
}
class _SafeHtmlState extends State<_SafeHtml> {
bool _failed = false;
@override
Widget build(BuildContext context) {
if (_failed) {
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Icon(
Icons.warning_amber_outlined,
color: Theme.of(context).colorScheme.error,
size: 16,
),
const SizedBox(width: 8),
const Expanded(child: Text('Message body could not be rendered.')),
],
),
);
}
// Intercept any build-phase throw from flutter_html for this subtree.
// We save/restore via postFrameCallback so other widgets are unaffected.
final prev = ErrorWidget.builder;
ErrorWidget.builder = (FlutterErrorDetails details) {
ErrorWidget.builder = prev;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _failed = true);
});
return const SizedBox.shrink();
};
WidgetsBinding.instance.addPostFrameCallback(
(_) => ErrorWidget.builder = prev,
);
return Html(
data: widget.data,
extensions: widget.extensions,
onLinkTap: _openLink,
);
}
}
class _BlockRemoteImagesExtension extends HtmlExtension {
@override
Set<String> get supportedTags => {'img'};
+42 -3
View File
@@ -15,6 +15,14 @@ import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
final _dateFmt = DateFormat('MMM d');
// Cache formatted dates by local calendar day so DateFormat.format is called
// at most once per unique date rather than once per list item per rebuild.
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({
@@ -45,6 +53,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
List<EmailThread> _currentThreads = [];
// Individual email selection used in search results.
final Set<String> _selectedSearchIds = {};
// Pagination: number of threads currently requested from the DB.
static const _pageSize = 50;
int _limit = _pageSize;
bool get _selecting =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@@ -189,6 +201,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
extra: {'accountId': widget.accountId},
),
),
PopupMenuButton<String>(
onSelected: (value) async {
if (value == 'mark_all_read') {
await emailRepo.markAllAsRead(
widget.accountId,
widget.mailboxPath,
);
}
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'mark_all_read',
child: Text('Mark all as read'),
),
],
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
@@ -343,7 +371,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
await emailRepo.syncEmails(widget.accountId, widget.mailboxPath);
},
child: StreamBuilder<List<EmailThread>>(
stream: emailRepo.observeThreads(widget.accountId, widget.mailboxPath),
stream: emailRepo.observeThreads(
widget.accountId,
widget.mailboxPath,
limit: _limit,
),
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
@@ -539,9 +571,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}
Widget _buildThreadList(List<EmailThread> threads) {
final hasMore = threads.length == _limit;
return ListView.builder(
itemCount: threads.length,
itemCount: threads.length + (hasMore ? 1 : 0),
itemBuilder: (ctx, i) {
if (i == threads.length) {
return TextButton(
onPressed: () => setState(() => _limit += _pageSize),
child: const Text('Load more'),
);
}
final t = threads[i];
final isSelected = _selectedThreadIds.contains(t.threadId);
final senderNames =
@@ -610,7 +649,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_dateFmt.format(t.latestDate),
_fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
+86
View File
@@ -10,6 +10,11 @@ import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
final _searchHistoryProvider =
FutureProvider.autoDispose<List<String>>((ref) async {
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
});
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key, this.accountId});
final String? accountId;
@@ -20,13 +25,24 @@ class SearchScreen extends ConsumerStatefulWidget {
class _SearchScreenState extends ConsumerState<SearchScreen> {
final _ctrl = TextEditingController();
final _focusNode = FocusNode();
Timer? _debounce;
_SearchResults? _results;
bool _loading = false;
bool _fieldFocused = false;
@override
void initState() {
super.initState();
_focusNode.addListener(() {
if (mounted) setState(() => _fieldFocused = _focusNode.hasFocus);
});
}
@override
void dispose() {
_ctrl.dispose();
_focusNode.dispose();
_debounce?.cancel();
super.dispose();
}
@@ -45,6 +61,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
Future<void> _search(String query) async {
setState(() => _loading = true);
unawaited(
ref
.read(searchHistoryRepositoryProvider)
.saveSearch(query)
.then((_) => ref.invalidate(_searchHistoryProvider)),
);
try {
final emailRepo = ref.read(emailRepositoryProvider);
final mailboxRepo = ref.read(mailboxRepositoryProvider);
@@ -112,6 +134,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
appBar: AppBar(
title: TextField(
controller: _ctrl,
focusNode: _focusNode,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search folders, addresses, emails…',
@@ -137,6 +160,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
Widget _buildBody() {
if (_loading) return const Center(child: CircularProgressIndicator());
if (_results == null) {
if (_fieldFocused && _ctrl.text.isEmpty) {
return _buildHistoryPanel();
}
return const Center(child: Text('Type 3+ characters to search'));
}
final r = _results!;
@@ -169,6 +195,66 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
],
);
}
Widget _buildHistoryPanel() {
final history = ref.watch(_searchHistoryProvider);
return history.when(
loading: () => const Center(child: Text('Type 3+ characters to search')),
error: (_, __) =>
const Center(child: Text('Type 3+ characters to search')),
data: (terms) {
if (terms.isEmpty) {
return const Center(child: Text('Type 3+ characters to search'));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Recent searches',
style: Theme.of(context).textTheme.labelLarge,
),
TextButton(
onPressed: () async {
await ref
.read(searchHistoryRepositoryProvider)
.clearHistory();
ref.invalidate(_searchHistoryProvider);
},
child: const Text('Clear'),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final term in terms)
ActionChip(
label: Text(term),
onPressed: () {
_ctrl.text = term;
_ctrl.selection = TextSelection.fromPosition(
TextPosition(offset: term.length),
);
unawaited(_search(term));
},
),
],
),
),
],
);
},
);
}
}
class _SearchResults {
+13
View File
@@ -10,6 +10,7 @@ import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:url_launcher/url_launcher.dart';
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
@@ -168,6 +169,18 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
extensions: [
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
],
onLinkTap: (url, _, __) {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri != null) {
unawaited(
launchUrl(
uri,
mode: LaunchMode.externalApplication,
),
);
}
},
),
] else
SelectableText(
+155
View File
@@ -0,0 +1,155 @@
# SharedInbox — Improvement Plan
30 tasks across 7 perspectives. Priority markers: 🔴 high · 🟡 medium · 🟢 nice-to-have.
---
## Group 1: Performance
### P1 — Done: https://codeberg.org/guettli/sharedinbox/pulls/41
### P1 🔴 Replace LIKE-based search with FTS5 virtual table
The current `observeEmails` and search queries use `LIKE '%query%'` which becomes a full-table scan at scale.
Create an `email_fts` FTS5 virtual table (subject, preview, fromJson) populated via trigger or sync-time insert.
Wire `SearchScreen` to query the FTS table instead.
Files: `lib/data/db/database.dart`, `lib/data/repositories/email_repository_impl.dart`.
### P2 🔴 Lazy-load email bodies on scroll (pagination)
`observeThreads` and `observeEmails` return the full list with no limit. As the mailbox grows this streams thousands of rows into memory.
Add a page-size parameter (e.g. 50) with "load more" support in `EmailListScreen`.
The `EmailBodies` table is already separate — never fetch bodies in the list query.
Files: `lib/data/repositories/email_repository_impl.dart`, `lib/ui/screens/email_list_screen.dart`.
### P3 🟡 Defer HTML parsing off the UI thread using an Isolate
`flutter_html` parsing blocks the raster thread for large HTML bodies, causing jank when opening email detail.
Move the HTML→Widget tree conversion (or at minimum the `html_utils.dart` HTML-to-plain step) into a `compute()` call.
Files: `lib/ui/screens/email_detail_screen.dart`, `lib/core/utils/html_utils.dart`.
### P4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/36
### P5 🟢 Cache the formatted date strings in EmailListScreen
`DateFormat('MMM d').format(...)` is called for every email on every rebuild. Compute and cache these in the model layer or inside the list item widget's `build` method using a static cache map.
Files: `lib/ui/screens/email_list_screen.dart`, `lib/core/utils/format_utils.dart`.
---
## Group 2: Reliability & Resilience
### R1 — Done: https://codeberg.org/guettli/sharedinbox/pulls/20
### R2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/22
### R3 — Done: https://codeberg.org/guettli/sharedinbox/pulls/35
### R4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/23
### R5 — Done: https://codeberg.org/guettli/sharedinbox/pulls/45
### R6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/24
---
## Group 3: Security
### S1 🔴 Optional SQLCipher encryption for the Drift database
Emails cached locally are plaintext. Users on shared or rooted devices are exposed.
Add an opt-in "Encrypt local storage" setting using `drift`'s `encrypted` backend (`sqflite_cipher` / `sqlcipher_flutter_libs`).
Store the database key in `flutter_secure_storage` (already present).
Files: `lib/data/db/database.dart`, `pubspec.yaml`, a new settings toggle.
### S2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/25
### S3 🟡 Enforce certificate pinning for known providers (opt-in)
Auto-discovered accounts for major providers (Gmail, Fastmail, Proton) could be pinned to their known CA hierarchy.
Implement as an opt-in per-account setting; only applies when the account is auto-discovered via `AccountDiscoveryService`.
Files: `lib/core/services/account_discovery_service.dart`, `lib/data/imap/imap_client_factory.dart`.
### S4 🟢 Audit and restrict external link handling in HTML emails
`flutter_html` passes `<a href>` clicks to `url_launcher` without a prompt.
Before launching, show a confirmation dialog with the destination URL so phishing links are visible.
Files: `lib/ui/screens/email_detail_screen.dart`.
---
## Group 4: User Experience
### U1 — Done: https://codeberg.org/guettli/sharedinbox/pulls/26
### U2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/27
### U3 🟡 Add "Recent searches" history to SearchScreen
The search bar clears on navigation. Store the last 10 search terms in a local DB table and show them as chips below the search field when the field is focused but empty.
Files: `lib/ui/screens/search_screen.dart`, `lib/data/db/database.dart`.
### U4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/28
### U5 — Already implemented (Dismissible archive/delete swipes with undo, found in email_list_screen.dart)
### U6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/29
### U7 🟢 Onboarding walkthrough for first-time users
The app opens directly to an empty account list with only a `+` button. First-time users have no guidance.
Add a one-time welcome card or bottom-sheet with the three-step flow: Add account → wait for sync → open inbox.
Files: `lib/ui/screens/account_list_screen.dart`.
### U8 🟢 "Mark all as read" action in mailbox
Power users managing high-volume mailboxes need bulk read marking. Add a "Mark all as read" option in the mailbox overflow menu.
Files: `lib/ui/screens/email_list_screen.dart`, `lib/core/repositories/email_repository.dart`, `lib/data/repositories/email_repository_impl.dart`.
---
## Group 5: Testing
### T1 — Done: https://codeberg.org/guettli/sharedinbox/pulls/30
### T2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/31
### T3 — Done: https://codeberg.org/guettli/sharedinbox/pulls/43
### T3 🟡 Contract tests for all Repository interfaces
The interfaces in `core/repositories/` have no shared contract test suite. Concrete impls can silently diverge.
Add a shared `EmailRepositoryContract` abstract test class; run it against both `EmailRepositoryImpl` and any future mock/fake. Mirror this for `MailboxRepository` and `AccountRepository`.
Files: `test/unit/` (new contract test files).
### T4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/32
### T5 🟢 Snapshot / golden tests for key email list states
The email list has multiple states: loading, empty, normal, selection mode, search active, error banner.
Add golden tests using `matchesGoldenFile` for each state so visual regressions surface in CI.
Files: `test/widget/email_list_screen_test.dart`.
---
## Group 6: Architecture & Code Quality
### A1 — Done: https://codeberg.org/guettli/sharedinbox/pulls/39
### A2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/33
### A3 — Done: https://codeberg.org/guettli/sharedinbox/pulls/46
### A4 🟡 Replace raw JSON strings in DB with structured encoding
`fromJson`, `toAddresses`, `ccJson`, `references` are stored as raw JSON strings parsed on every model conversion.
Create typed value classes with `fromJson`/`toJson` in `core/models/email.dart` and add a `TypeConverter` in the Drift schema so the DB layer owns the serialisation.
Files: `lib/data/db/database.dart`, `lib/core/models/email.dart`, `lib/data/repositories/email_repository_impl.dart`.
### A5 🟢 Enforce layer boundaries via lint custom rules or barrel imports
The `ui/` layer directly imports `data/` concrete classes in several screens (e.g. `drift` types leak through).
Add a custom `analysis_options.yaml` rule or a CI lint step that flags any `ui/` import of `data/` (only `core/` interfaces are allowed from UI).
Files: `analysis_options.yaml`, CI config.
---
## Group 7: Developer Experience
### D1 🔴 CI matrix for macOS and Windows builds
The CI currently tests Linux and Android. The macOS and Windows targets are "scaffolded" and may have accumulated silent breakage.
Add `flutter build macos --debug` and `flutter build windows --debug` jobs to the CI workflow with the same failure threshold as Linux.
Files: `.github/workflows/ci.yml` (or Codeberg equivalent).
### D2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/34
### D3 🟢 Document the sync protocol in a SYNC.md architecture doc
`DB-SYNC.md` exists but focuses on the DB schema. The IMAP IDLE loop, exponential backoff, pending-change queue, and undo cancel logic are spread across four files with no single reference.
Write `SYNC.md` that describes the full lifecycle of an email action from UI tap to server confirmation.
Files: `SYNC.md` (new).
+1 -1
View File
@@ -47,7 +47,7 @@ dependencies:
# Background sync and local notifications
flutter_local_notifications: ^18.0.1
workmanager: ^0.5.2
workmanager: ^0.9.0
dev_dependencies:
flutter_test:
+2
View File
@@ -17,6 +17,7 @@ const _noCode = {
'lib/core/repositories/mailbox_repository.dart',
'lib/core/repositories/sync_log_repository.dart',
'lib/core/repositories/undo_repository.dart',
'lib/core/repositories/search_history_repository.dart',
'lib/core/models/undo_action.dart',
'lib/core/storage/secure_storage.dart',
};
@@ -61,6 +62,7 @@ const _excluded = {
'lib/data/repositories/mailbox_repository_impl.dart',
'lib/data/repositories/sync_log_repository_impl.dart',
'lib/data/repositories/undo_repository_impl.dart',
'lib/data/repositories/search_history_repository_impl.dart',
};
void main() {
+42 -6
View File
@@ -4,7 +4,10 @@
import json
import os
import sys
import time
import google_auth_httplib2
import httplib2
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
@@ -12,6 +15,15 @@ from googleapiclient.http import MediaFileUpload
PACKAGE_NAME = "de.sharedinbox.mua"
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
TRACK = "internal"
_TIMEOUT = 300 # seconds — AAB uploads can be large
_MAX_UPLOAD_ATTEMPTS = 3
def _make_service(creds):
authorized_http = google_auth_httplib2.AuthorizedHttp(
creds, http=httplib2.Http(timeout=_TIMEOUT)
)
return build("androidpublisher", "v3", http=authorized_http)
def main():
@@ -29,19 +41,43 @@ def main():
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
service = build("androidpublisher", "v3", credentials=creds)
service = _make_service(creds)
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute()
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute(num_retries=3)
edit_id = edit["id"]
media = MediaFileUpload(AAB_PATH, mimetype="application/octet-stream", resumable=True)
# The resumable upload can fail with RedirectMissingLocation on transient
# network hiccups. Retry the upload (with a fresh MediaFileUpload each
# time) using exponential backoff before giving up.
version_code = None
last_exc = None
for attempt in range(_MAX_UPLOAD_ATTEMPTS):
try:
media = MediaFileUpload(
AAB_PATH, mimetype="application/octet-stream", resumable=True
)
bundle = (
service.edits()
.bundles()
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
.execute()
.execute(num_retries=3)
)
version_code = bundle["versionCode"]
break
except httplib2.error.RedirectMissingLocation as exc:
last_exc = exc
if attempt < _MAX_UPLOAD_ATTEMPTS - 1:
delay = 10 * (2 ** attempt)
print(
f"Upload attempt {attempt + 1} failed (redirect error), "
f"retrying in {delay}s…"
)
time.sleep(delay)
else:
raise RuntimeError(
f"AAB upload failed after {_MAX_UPLOAD_ATTEMPTS} attempts"
) from last_exc
print(f"Uploaded AAB, version code: {version_code}")
service.edits().tracks().update(
@@ -49,9 +85,9 @@ def main():
editId=edit_id,
track=TRACK,
body={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
).execute()
).execute(num_retries=3)
service.edits().commit(packageName=PACKAGE_NAME, editId=edit_id).execute()
service.edits().commit(packageName=PACKAGE_NAME, editId=edit_id).execute(num_retries=3)
print(f"Deployed version {version_code} to {TRACK} track")
@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
@@ -10,8 +12,16 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
Future<imap.ImapClient> _fakeImapConnect(
Account account,
String username,
String password,
) async =>
throw const SocketException('fake — no real IMAP server in tests');
void main() {
test('AccountSyncManager schedules sync for multiple accounts', () async {
test('AccountSyncManager schedules IMAP sync for multiple accounts',
() async {
final accounts = _FakeAccounts('pw');
final mailboxes = _FakeMailboxes();
final emails = _FakeEmails();
@@ -22,6 +32,7 @@ void main() {
mailboxes,
emails,
syncLog: logs,
imapConnect: _fakeImapConnect,
);
final a1 = _account('1');
@@ -38,6 +49,34 @@ void main() {
manager.dispose();
});
test('AccountSyncManager schedules JMAP sync for multiple accounts',
() async {
final accounts = _FakeAccounts('pw');
final mailboxes = _FakeMailboxes();
final emails = _FakeEmails();
final logs = _FakeLogs();
final manager = AccountSyncManager(
accounts,
mailboxes,
emails,
syncLog: logs,
);
final a1 = _jmapAccount('1');
final a2 = _jmapAccount('2');
manager.start();
accounts.push([a1, a2]);
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(emails.syncCounts['1'], greaterThanOrEqualTo(1));
expect(emails.syncCounts['2'], greaterThanOrEqualTo(1));
manager.dispose();
});
}
Account _account(String id) => Account(
@@ -52,6 +91,17 @@ Account _account(String id) => Account(
smtpSsl: false,
);
Account _jmapAccount(String id) => Account(
id: id,
displayName: 'Account $id',
email: '$id@example.com',
type: AccountType.jmap,
jmapUrl: 'http://localhost:8080/.well-known/jmap',
smtpHost: 'localhost',
smtpPort: 25,
smtpSsl: false,
);
class _FakeAccounts implements AccountRepository {
_FakeAccounts(this.password);
final String password;
@@ -105,10 +155,19 @@ class _FakeEmails implements EmailRepository {
final syncCounts = <String, int>{};
@override
Stream<List<Email>> observeEmails(String a, String m) => Stream.value([]);
Stream<List<Email>> observeEmails(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]);
@override
Stream<List<EmailThread>> observeThreads(String a, String m) =>
Stream<List<EmailThread>> observeThreads(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]);
@override
@@ -131,6 +190,9 @@ class _FakeEmails implements EmailRepository {
@override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String id, String dest) async {}
@@ -0,0 +1,107 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart';
// ── Contract ──────────────────────────────────────────────────────────────────
/// Verifies the [AccountRepository] interface contract.
///
/// Subclass this and override [makeRepo] to run the same suite against any
/// concrete implementation.
abstract class AccountRepositoryContract {
AccountRepository makeRepo();
static const _a = Account(
id: 'c-1',
displayName: 'Contract',
email: 'c@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
void run() {
test('observeAccounts starts empty', () async {
final repo = makeRepo();
expect(await repo.observeAccounts().first, isEmpty);
});
test('addAccount makes account visible via observeAccounts', () async {
final repo = makeRepo();
await repo.addAccount(_a, 'pw');
final list = await repo.observeAccounts().first;
expect(list, hasLength(1));
expect(list.first.id, _a.id);
});
test('getAccount returns null for unknown id', () async {
final repo = makeRepo();
expect(await repo.getAccount('no-such'), isNull);
});
test('getAccount returns added account', () async {
final repo = makeRepo();
await repo.addAccount(_a, 'pw');
final a = await repo.getAccount(_a.id);
expect(a, isNotNull);
expect(a!.email, _a.email);
});
test('getPassword returns stored password', () async {
final repo = makeRepo();
await repo.addAccount(_a, 'secret123');
expect(await repo.getPassword(_a.id), 'secret123');
});
test('updateAccount reflects changes in observeAccounts', () async {
final repo = makeRepo();
await repo.addAccount(_a, 'pw');
final updated = _a.copyWith(displayName: 'Updated');
await repo.updateAccount(updated);
final list = await repo.observeAccounts().first;
expect(list.first.displayName, 'Updated');
});
test('updateAccount with password updates stored password', () async {
final repo = makeRepo();
await repo.addAccount(_a, 'old');
await repo.updateAccount(_a, password: 'new');
expect(await repo.getPassword(_a.id), 'new');
});
test('removeAccount makes account disappear from observeAccounts',
() async {
final repo = makeRepo();
await repo.addAccount(_a, 'pw');
await repo.removeAccount(_a.id);
expect(await repo.observeAccounts().first, isEmpty);
});
test('getAccount returns null after removeAccount', () async {
final repo = makeRepo();
await repo.addAccount(_a, 'pw');
await repo.removeAccount(_a.id);
expect(await repo.getAccount(_a.id), isNull);
});
}
}
// ── Impl under test ───────────────────────────────────────────────────────────
class _AccountRepositoryImplContract extends AccountRepositoryContract {
@override
AccountRepository makeRepo() =>
AccountRepositoryImpl(openTestDatabase(), MapSecureStorage());
}
void main() {
setUpAll(configureSqliteForTests);
group('AccountRepositoryImpl satisfies AccountRepository contract', () {
_AccountRepositoryImplContract().run();
});
}
+13 -2
View File
@@ -34,9 +34,18 @@ void main() {
class FakeEmailRepository implements EmailRepository {
@override
Stream<List<Email>> observeEmails(String a, String m) => Stream.value([]);
Stream<List<Email>> observeEmails(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]);
@override
Stream<List<EmailThread>> observeThreads(String a, String m) =>
Stream<List<EmailThread>> observeThreads(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]);
@override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
@@ -52,6 +61,8 @@ class FakeEmailRepository implements EmailRepository {
@override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String id, String dest) async {}
@override
+25 -4
View File
@@ -216,8 +216,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
@override
_i4.Stream<List<_i2.Email>> observeEmails(
String? accountId,
String? mailboxPath,
) =>
String? mailboxPath, {
int? limit = 50,
}) =>
(super.noSuchMethod(
Invocation.method(
#observeEmails,
@@ -225,6 +226,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
accountId,
mailboxPath,
],
{#limit: limit},
),
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>);
@@ -232,8 +234,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
@override
_i4.Stream<List<_i2.EmailThread>> observeThreads(
String? accountId,
String? mailboxPath,
) =>
String? mailboxPath, {
int? limit = 50,
}) =>
(super.noSuchMethod(
Invocation.method(
#observeThreads,
@@ -241,6 +244,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
accountId,
mailboxPath,
],
{#limit: limit},
),
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.EmailThread>>);
@@ -333,6 +337,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> markAllAsRead(
String? accountId,
String? mailboxPath,
) =>
(super.noSuchMethod(
Invocation.method(
#markAllAsRead,
[
accountId,
mailboxPath,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> moveEmail(
String? emailId,
@@ -0,0 +1,222 @@
import 'package:drift/drift.dart' show Value;
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
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 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart';
// ── Contract ──────────────────────────────────────────────────────────────────
/// Verifies the observable / local-state portion of the [EmailRepository]
/// interface contract.
///
/// Network-dependent methods (syncEmails, sendEmail, etc.) are intentionally
/// excluded — they are covered by the concrete impl tests.
abstract class EmailRepositoryContract {
static const _account = Account(
id: 'er-acc',
displayName: 'Contract',
email: 'er@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
/// Return a fresh [EmailRepository] with [_account] already persisted.
Future<EmailRepository> makeRepo();
/// Insert a raw email row so tests can assert on observable state without
/// triggering a network sync.
Future<void> insertEmail(
EmailRepository repo, {
required String id,
required String mailboxPath,
bool isSeen = true,
bool isFlagged = false,
DateTime? receivedAt,
});
void run() {
test('observeEmails starts empty', () async {
final repo = await makeRepo();
expect(
await repo.observeEmails(_account.id, 'INBOX').first,
isEmpty,
);
});
test('observeEmails emits inserted email', () async {
final repo = await makeRepo();
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
final emails = await repo.observeEmails(_account.id, 'INBOX').first;
expect(emails, hasLength(1));
expect(emails.first.id, 'er-acc:1');
});
test('observeEmails only returns emails for the given mailbox', () async {
final repo = await makeRepo();
await insertEmail(repo, id: 'er-acc:1', mailboxPath: 'INBOX');
expect(
await repo.observeEmails(_account.id, 'Sent').first,
isEmpty,
);
});
test('observeEmails orders by receivedAt descending', () async {
final repo = await makeRepo();
final older = DateTime(2024);
final newer = DateTime(2024, 6);
await insertEmail(
repo,
id: 'er-acc:1',
mailboxPath: 'INBOX',
receivedAt: older,
);
await insertEmail(
repo,
id: 'er-acc:2',
mailboxPath: 'INBOX',
receivedAt: newer,
);
final emails = await repo.observeEmails(_account.id, 'INBOX').first;
expect(emails.first.id, 'er-acc:2');
expect(emails.last.id, 'er-acc:1');
});
test('getEmail returns null for unknown id', () async {
final repo = await makeRepo();
expect(await repo.getEmail('no-such'), isNull);
});
test('getEmail returns inserted email', () async {
final repo = await makeRepo();
await insertEmail(repo, id: 'er-acc:7', mailboxPath: 'INBOX');
final email = await repo.getEmail('er-acc:7');
expect(email, isNotNull);
expect(email!.accountId, _account.id);
});
test('setFlag seen updates isSeen', () async {
final repo = await makeRepo();
await insertEmail(
repo,
id: 'er-acc:10',
mailboxPath: 'INBOX',
isSeen: false,
);
await repo.setFlag('er-acc:10', seen: true);
final email = await repo.getEmail('er-acc:10');
expect(email!.isSeen, isTrue);
});
test('setFlag flagged updates isFlagged', () async {
final repo = await makeRepo();
await insertEmail(
repo,
id: 'er-acc:11',
mailboxPath: 'INBOX',
);
await repo.setFlag('er-acc:11', flagged: true);
final email = await repo.getEmail('er-acc:11');
expect(email!.isFlagged, isTrue);
});
test('markAllAsRead marks every unread email in the mailbox', () async {
final repo = await makeRepo();
await insertEmail(
repo,
id: 'er-acc:20',
mailboxPath: 'INBOX',
isSeen: false,
);
await insertEmail(
repo,
id: 'er-acc:21',
mailboxPath: 'INBOX',
isSeen: false,
);
await insertEmail(
repo,
id: 'er-acc:22',
mailboxPath: 'Sent',
isSeen: false,
);
await repo.markAllAsRead(_account.id, 'INBOX');
expect((await repo.getEmail('er-acc:20'))!.isSeen, isTrue);
expect((await repo.getEmail('er-acc:21'))!.isSeen, isTrue);
// Email in a different mailbox should be untouched.
expect((await repo.getEmail('er-acc:22'))!.isSeen, isFalse);
});
test('observeThreads starts empty', () async {
final repo = await makeRepo();
expect(
await repo.observeThreads(_account.id, 'INBOX').first,
isEmpty,
);
});
}
}
// ── Impl under test ───────────────────────────────────────────────────────────
class _EmailRepositoryImplContract extends EmailRepositoryContract {
static const _account = EmailRepositoryContract._account;
late AppDatabase _db;
late AccountRepositoryImpl _accountRepo;
@override
Future<EmailRepository> makeRepo() async {
_db = openTestDatabase();
_accountRepo = AccountRepositoryImpl(_db, MapSecureStorage());
await _accountRepo.addAccount(_account, 'pw');
return EmailRepositoryImpl(
_db,
_accountRepo,
imapConnect: (_, __, ___) => Future<imap.ImapClient>.error(
UnsupportedError('no IMAP in unit tests'),
),
smtpConnect: (_, __, ___) => Future<imap.SmtpClient>.error(
UnsupportedError('no SMTP in unit tests'),
),
);
}
@override
Future<void> insertEmail(
EmailRepository repo, {
required String id,
required String mailboxPath,
bool isSeen = true,
bool isFlagged = false,
DateTime? receivedAt,
}) async {
await _db.into(_db.emails).insert(
EmailsCompanion.insert(
id: id,
accountId: _account.id,
mailboxPath: mailboxPath,
uid: int.parse(id.split(':').last),
receivedAt: receivedAt ?? DateTime.now(),
isSeen: Value(isSeen),
isFlagged: Value(isFlagged),
),
);
}
}
void main() {
setUpAll(configureSqliteForTests);
group('EmailRepositoryImpl satisfies EmailRepository contract', () {
_EmailRepositoryImplContract().run();
});
}
@@ -0,0 +1,137 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/data/db/database.dart' hide Account;
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart';
// ── Contract ──────────────────────────────────────────────────────────────────
/// Verifies the [MailboxRepository] interface contract.
///
/// Tests cover only the locally-observable part of the interface
/// (observe / find) since sync methods require live IMAP/JMAP servers.
abstract class MailboxRepositoryContract {
static const _account = Account(
id: 'm-acc',
displayName: 'Contract',
email: 'm@example.com',
imapHost: 'imap.example.com',
smtpHost: 'smtp.example.com',
);
/// Return a fresh [MailboxRepository] with [_account] already persisted.
Future<MailboxRepository> makeRepo();
/// Insert a mailbox row into the backing store so tests can verify
/// observeMailboxes without triggering a network sync.
Future<void> insertMailbox(
MailboxRepository repo, {
required String id,
required String path,
String? role,
int unread = 0,
int total = 0,
});
void run() {
test('observeMailboxes starts empty', () async {
final repo = await makeRepo();
expect(await repo.observeMailboxes(_account.id).first, isEmpty);
});
test('observeMailboxes emits inserted rows ordered by path', () async {
final repo = await makeRepo();
await insertMailbox(repo, id: 'z', path: 'Z');
await insertMailbox(repo, id: 'a', path: 'A');
final boxes = await repo.observeMailboxes(_account.id).first;
expect(boxes.map((b) => b.path), ['A', 'Z']);
});
test('observeMailboxes only returns rows for the given account', () async {
final repo = await makeRepo();
await insertMailbox(repo, id: 'mb1', path: 'INBOX');
expect(await repo.observeMailboxes('other-acc').first, isEmpty);
});
test('findMailboxByRole returns null when no match', () async {
final repo = await makeRepo();
expect(
await repo.findMailboxByRole(_account.id, 'archive'),
isNull,
);
});
test('findMailboxByRole returns the matching mailbox', () async {
final repo = await makeRepo();
await insertMailbox(repo, id: 'arch', path: 'Archive', role: 'archive');
final box = await repo.findMailboxByRole(_account.id, 'archive');
expect(box, isNotNull);
expect(box!.role, 'archive');
});
test('clearForResync removes all mailboxes for the account', () async {
final repo = await makeRepo();
await insertMailbox(repo, id: 'mb', path: 'INBOX');
await repo.clearForResync(_account.id);
expect(await repo.observeMailboxes(_account.id).first, isEmpty);
});
}
}
// ── Impl under test ───────────────────────────────────────────────────────────
class _MailboxRepositoryImplContract extends MailboxRepositoryContract {
static const _account = MailboxRepositoryContract._account;
late AppDatabase _db;
late AccountRepositoryImpl _accountRepo;
@override
Future<MailboxRepository> makeRepo() async {
_db = openTestDatabase();
_accountRepo = AccountRepositoryImpl(_db, MapSecureStorage());
await _accountRepo.addAccount(_account, 'pw');
return MailboxRepositoryImpl(
_db,
_accountRepo,
imapConnect: (_, __, ___) =>
Future.error(UnsupportedError('no IMAP in unit tests')),
);
}
@override
Future<void> insertMailbox(
MailboxRepository repo, {
required String id,
required String path,
String? role,
int unread = 0,
int total = 0,
}) async {
await _db.into(_db.mailboxes).insert(
MailboxesCompanion.insert(
id: id,
accountId: _account.id,
path: path,
name: path.split('/').last,
unreadCount: Value(unread),
totalCount: Value(total),
role: Value(role),
),
);
}
}
void main() {
setUpAll(configureSqliteForTests);
group('MailboxRepositoryImpl satisfies MailboxRepository contract', () {
_MailboxRepositoryImplContract().run();
});
}
+91 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 24);
expect(db.schemaVersion, 27);
await db.close();
});
@@ -141,6 +141,41 @@ void main() {
]),
);
// v18, v22, v25: indexes.
final allIndexes = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
.get();
final indexNames = allIndexes.map((r) => r.read<String>('name')).toSet();
expect(
indexNames,
containsAll([
'emails_received_at', // v18
'emails_thread_id', // v18
'pending_changes_account_id', // v18
'emails_snoozed_until', // v22
'mailboxes_account_id', // v25
'threads_latest_date', // v25
]),
);
// v26: FTS5 virtual table and triggers exist.
final allTriggers = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
.get();
final triggerNames =
allTriggers.map((r) => r.read<String>('name')).toSet();
expect(
triggerNames,
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
);
// Verify FTS table was created and is queryable.
await db.customSelect('SELECT count(*) FROM email_fts').get();
// v27: search_history_entries table.
await db
.customSelect('SELECT count(*) FROM search_history_entries')
.get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
@@ -186,6 +221,17 @@ void main() {
updated_at INTEGER NOT NULL
);
''');
rawDb.execute('''
CREATE TABLE mailboxes (
id TEXT NOT NULL PRIMARY KEY,
account_id TEXT NOT NULL,
path TEXT NOT NULL,
name TEXT NOT NULL,
unread_count INTEGER NOT NULL DEFAULT 0,
total_count INTEGER NOT NULL DEFAULT 0,
role TEXT NULL
);
''');
rawDb.execute('''
CREATE TABLE emails (
id TEXT NOT NULL PRIMARY KEY,
@@ -210,6 +256,23 @@ void main() {
snoozed_from_mailbox_path TEXT NULL
);
''');
rawDb.execute('''
CREATE TABLE threads (
account_id TEXT NOT NULL,
mailbox_path TEXT NOT NULL,
id TEXT NOT NULL,
subject TEXT NULL,
latest_date INTEGER NOT NULL,
message_count INTEGER NOT NULL DEFAULT 1,
has_unread INTEGER NOT NULL DEFAULT 0 CHECK ("has_unread" IN (0, 1)),
is_flagged INTEGER NOT NULL DEFAULT 0 CHECK ("is_flagged" IN (0, 1)),
participants_json TEXT NOT NULL DEFAULT '[]',
preview TEXT NULL,
latest_email_id TEXT NOT NULL,
email_ids_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (account_id, mailbox_path, id)
);
''');
rawDb.execute('PRAGMA user_version = 22;');
rawDb.close();
@@ -223,11 +286,36 @@ void main() {
final draftColumns = await _tableColumns(db, 'drafts');
expect(draftColumns, contains('imap_server_id'));
// v25: new indexes on mailboxes and threads.
final allIndexes = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='index'")
.get();
final indexNames = allIndexes.map((r) => r.read<String>('name')).toSet();
expect(indexNames, contains('mailboxes_account_id'));
expect(indexNames, contains('threads_latest_date'));
// v26: FTS5 virtual table and triggers.
final allTriggers = await db
.customSelect("SELECT name FROM sqlite_master WHERE type='trigger'")
.get();
final triggerNames =
allTriggers.map((r) => r.read<String>('name')).toSet();
expect(
triggerNames,
containsAll(['email_fts_ai', 'email_fts_au', 'email_fts_ad']),
);
await db.customSelect('SELECT count(*) FROM email_fts').get();
// v27: search_history_entries table.
await db
.customSelect('SELECT count(*) FROM search_history_entries')
.get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 24', () async {
test('fresh install creates all tables at schemaVersion 27', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -250,6 +338,7 @@ void main() {
'threads',
'sync_health',
'undo_actions',
'search_history_entries',
]),
);
+13 -2
View File
@@ -79,9 +79,18 @@ class _CountingEmails implements EmailRepository {
@override
Future<int> flushPendingChanges(String accountId, String password) async => 0;
@override
Stream<List<Email>> observeEmails(String a, String m) => Stream.value([]);
Stream<List<Email>> observeEmails(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]);
@override
Stream<List<EmailThread>> observeThreads(String a, String m) =>
Stream<List<EmailThread>> observeThreads(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]);
@override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
@@ -94,6 +103,8 @@ class _CountingEmails implements EmailRepository {
@override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String id, String dest) async {}
@override
Future<String?> deleteEmail(String id) async => null;
+25 -4
View File
@@ -76,8 +76,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
@override
_i4.Stream<List<_i2.Email>> observeEmails(
String? accountId,
String? mailboxPath,
) =>
String? mailboxPath, {
int? limit = 50,
}) =>
(super.noSuchMethod(
Invocation.method(
#observeEmails,
@@ -85,6 +86,7 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
accountId,
mailboxPath,
],
{#limit: limit},
),
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>);
@@ -92,8 +94,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
@override
_i4.Stream<List<_i2.EmailThread>> observeThreads(
String? accountId,
String? mailboxPath,
) =>
String? mailboxPath, {
int? limit = 50,
}) =>
(super.noSuchMethod(
Invocation.method(
#observeThreads,
@@ -101,6 +104,7 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
accountId,
mailboxPath,
],
{#limit: limit},
),
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.EmailThread>>);
@@ -193,6 +197,23 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> markAllAsRead(
String? accountId,
String? mailboxPath,
) =>
(super.noSuchMethod(
Invocation.method(
#markAllAsRead,
[
accountId,
mailboxPath,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i4.Future<void> moveEmail(
String? emailId,
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
// Fixed-date emails so golden files don't change day to day.
final _kDate = DateTime(2024, 6);
Email _email({
String id = 'acc-1:1',
String subject = 'Hello world',
bool isSeen = true,
bool isFlagged = false,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: int.parse(id.split(':').last),
subject: subject,
receivedAt: _kDate,
sentAt: _kDate,
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: false,
);
List<Override> _overrides({
List<Email> emails = const [],
List<Email> searchResults = const [],
String? syncError,
}) =>
[
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([kTestMailbox]),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: emails, searchResults: searchResults),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith(
(ref, _) => Stream.value(syncError),
),
];
void main() {
group('EmailListScreen goldens', () {
testWidgets('golden: empty state', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_empty.png'),
);
});
testWidgets('golden: list with emails', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
emails: [
_email(subject: 'Team standup notes', isSeen: false),
_email(id: 'acc-1:2', subject: 'Q3 review', isFlagged: true),
_email(id: 'acc-1:3', subject: 'Welcome to the project'),
],
),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_with_emails.png'),
);
});
testWidgets('golden: selection mode', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
emails: [
_email(subject: 'Team standup notes', isSeen: false),
_email(id: 'acc-1:2', subject: 'Q3 review'),
],
),
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Team standup notes'));
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_selection.png'),
);
});
testWidgets('golden: search with results', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
searchResults: [
_email(id: 'acc-1:5', subject: 'Project proposal'),
],
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(SearchBar), 'project');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_search_results.png'),
);
});
testWidgets('golden: error banner', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(syncError: 'Connection refused'),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_error_banner.png'),
);
});
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+28 -3
View File
@@ -17,6 +17,7 @@ import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -158,14 +159,19 @@ class FakeEmailRepository implements EmailRepository {
_emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []);
@override
Stream<List<Email>> observeEmails(String accountId, String mailboxPath) =>
Stream<List<Email>> observeEmails(
String accountId,
String mailboxPath, {
int limit = 50,
}) =>
Stream.value(List.of(_emails));
@override
Stream<List<EmailThread>> observeThreads(
String accountId,
String mailboxPath,
) =>
String mailboxPath, {
int limit = 50,
}) =>
observeEmails(accountId, mailboxPath).map((emails) {
return emails.map((e) {
return EmailThread(
@@ -208,6 +214,8 @@ class FakeEmailRepository implements EmailRepository {
@override
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {}
@@ -503,3 +511,20 @@ Email testEmail({
isFlagged: isFlagged,
hasAttachment: hasAttachment,
);
class FakeSearchHistoryRepository implements SearchHistoryRepository {
final List<String> _history = [];
@override
Future<List<String>> getRecentSearches() async => List.unmodifiable(_history);
@override
Future<void> saveSearch(String query) async {
_history.remove(query);
_history.insert(0, query);
if (_history.length > 10) _history.removeLast();
}
@override
Future<void> clearHistory() async => _history.clear();
}
+20 -1
View File
@@ -20,6 +20,9 @@ void main() {
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -42,6 +45,9 @@ void main() {
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -68,6 +74,9 @@ void main() {
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -97,6 +106,9 @@ void main() {
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]),
),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -132,6 +144,9 @@ void main() {
FakeMailboxRepository([archiveMailbox]),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -162,6 +177,9 @@ void main() {
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]),
),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -175,8 +193,9 @@ void main() {
await tester.tap(find.byIcon(Icons.clear));
await tester.pumpAndSettle();
// Results are gone; the recent-search chip for the prior query appears.
expect(find.text('Found email'), findsNothing);
expect(find.text('Type 3+ characters to search'), findsOneWidget);
expect(find.text('found'), findsOneWidget);
});
});
}