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
35 changed files with 1547 additions and 63 deletions
+66
View File
@@ -40,3 +40,69 @@ jobs:
- name: Build Linux - name: Build Linux
run: nix develop --command task build-linux-release 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`).
+12 -1
View File
@@ -368,7 +368,18 @@ tasks:
check-fast: check-fast:
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration) desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
deps: [analyze, check-coverage, check-hygiene] 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: check-hygiene:
desc: Verify that no forbidden files (like home dir config) are tracked desc: Verify that no forbidden files (like home dir config) are tracked
+9 -3
View File
@@ -1,14 +1,19 @@
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
abstract class EmailRepository { 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, /// Groups emails by threadId and returns one [EmailThread] per thread,
/// sorted by the latest message date descending. /// sorted by the latest message date descending.
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
String accountId, String accountId,
String mailboxPath, String mailboxPath, {
); int limit = 50,
});
/// Returns all emails belonging to [threadId] in [mailboxPath]. /// Returns all emails belonging to [threadId] in [mailboxPath].
Stream<List<Email>> observeEmailsInThread( Stream<List<Email>> observeEmailsInThread(
@@ -22,6 +27,7 @@ abstract class EmailRepository {
Future<EmailBody> getEmailBody(String emailId); Future<EmailBody> getEmailBody(String emailId);
Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath); Future<SyncEmailsResult> syncEmails(String accountId, String mailboxPath);
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}); Future<void> setFlag(String emailId, {bool? seen, bool? flagged});
Future<void> markAllAsRead(String accountId, String mailboxPath);
Future<void> moveEmail(String emailId, String destMailboxPath); Future<void> moveEmail(String emailId, String destMailboxPath);
/// Deletes the email. Returns the path of the mailbox it was moved to /// 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/core/utils/logger.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart' import 'package:sharedinbox/data/imap/imap_client_factory.dart'
show ImapConnectFn, connectImap, verboseLogKey; show ImapConnectFn, connectImap, verboseLogKey;
import 'package:sharedinbox/data/imap/tls_error.dart' show isTlsConfigError;
typedef OnNewMailCallback = Future<void> Function(String accountEmail); typedef OnNewMailCallback = Future<void> Function(String accountEmail);
@@ -291,6 +292,7 @@ class _AccountSync implements _SyncLoop {
} }
bool _isPermanentError(Object e) { bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
final s = e.toString().toLowerCase(); final s = e.toString().toLowerCase();
// enough_mail doesn't always have typed exceptions for auth, so we check strings. // enough_mail doesn't always have typed exceptions for auth, so we check strings.
return s.contains('invalid credentials') || return s.contains('invalid credentials') ||
@@ -528,6 +530,7 @@ class _JmapAccountSync implements _SyncLoop {
} }
bool _isPermanentError(Object e) { bool _isPermanentError(Object e) {
if (isTlsConfigError(e)) return true;
final s = e.toString().toLowerCase(); final s = e.toString().toLowerCase();
return s.contains('invalid credentials') || return s.contains('invalid credentials') ||
s.contains('authentication failed') || s.contains('authentication failed') ||
+12 -1
View File
@@ -234,6 +234,13 @@ class Drafts extends Table {
TextColumn get imapServerId => text().nullable()(); 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') @DataClassName('UndoActionRow')
class UndoActions extends Table { class UndoActions extends Table {
TextColumn get id => text()(); TextColumn get id => text()();
@@ -263,13 +270,14 @@ class UndoActions extends Table {
SyncLogMailboxes, SyncLogMailboxes,
SyncHealth, SyncHealth,
UndoActions, UndoActions,
SearchHistoryEntries,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 26; int get schemaVersion => 27;
Future<void> _createEmailFts() async { Future<void> _createEmailFts() async {
await customStatement(''' await customStatement('''
@@ -492,6 +500,9 @@ class AppDatabase extends _$AppDatabase {
SELECT rowid, subject, preview, from_json FROM emails 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'; 'STARTTLS). Original error: $original';
} }
/// If [error] is a TLS handshake failure caused by a wrong-version-number /// Wraps a TLS certificate verification failure into a user-actionable message.
/// (i.e. the server is not speaking TLS), throw a [TlsModeMismatchException] ///
/// with [host]/[port] context. Otherwise rethrow [error] unchanged. /// 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) { 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( Error.throwWithStackTrace(
TlsModeMismatchException(host, port, error), TlsModeMismatchException(host, port, error),
stack, 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); Error.throwWithStackTrace(error, stack);
} }
@@ -58,15 +58,17 @@ class EmailRepositoryImpl implements EmailRepository {
@override @override
Stream<List<model.Email>> observeEmails( Stream<List<model.Email>> observeEmails(
String accountId, String accountId,
String mailboxPath, String mailboxPath, {
) { int limit = 50,
}) {
return (_db.select(_db.emails) return (_db.select(_db.emails)
..where( ..where(
(t) => (t) =>
t.accountId.equals(accountId) & t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath), t.mailboxPath.equals(mailboxPath),
) )
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])) ..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
..limit(limit))
.watch() .watch()
.map((rows) => rows.map(_toModel).toList()); .map((rows) => rows.map(_toModel).toList());
} }
@@ -74,15 +76,17 @@ class EmailRepositoryImpl implements EmailRepository {
@override @override
Stream<List<model.EmailThread>> observeThreads( Stream<List<model.EmailThread>> observeThreads(
String accountId, String accountId,
String mailboxPath, String mailboxPath, {
) { int limit = 50,
}) {
return (_db.select(_db.threads) return (_db.select(_db.threads)
..where( ..where(
(t) => (t) =>
t.accountId.equals(accountId) & t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath), t.mailboxPath.equals(mailboxPath),
) )
..orderBy([(t) => OrderingTerm.desc(t.latestDate)])) ..orderBy([(t) => OrderingTerm.desc(t.latestDate)])
..limit(limit))
.watch() .watch()
.map((rows) => rows.map(_threadRowToModel).toList()); .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 @override
Future<void> moveEmail(String emailId, String destMailboxPath) async { Future<void> moveEmail(String emailId, String destMailboxPath) async {
final row = await (_db.select( final row = await (_db.select(
@@ -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();
}
}
+7
View File
@@ -9,6 +9,7 @@ import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_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/repositories/undo_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart';
@@ -25,6 +26,7 @@ import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/draft_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/email_repository_impl.dart';
import 'package:sharedinbox/data/repositories/mailbox_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/sync_log_repository_impl.dart';
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart'; import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart'; import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
@@ -87,6 +89,11 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
return UndoRepositoryImpl(ref.watch(dbProvider)); return UndoRepositoryImpl(ref.watch(dbProvider));
}); });
final searchHistoryRepositoryProvider =
Provider<SearchHistoryRepository>((ref) {
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
});
final syncLogRepositoryProvider = Provider((ref) { final syncLogRepositoryProvider = Provider((ref) {
return SyncLogRepositoryImpl(ref.watch(dbProvider)); return SyncLogRepositoryImpl(ref.watch(dbProvider));
}); });
+43 -12
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_riverpod/flutter_riverpod.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'); 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 { class EmailDetailScreen extends ConsumerStatefulWidget {
const EmailDetailScreen({super.key, required this.emailId}); const EmailDetailScreen({super.key, required this.emailId});
final String emailId; final String emailId;
@@ -60,20 +69,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
tooltip: 'Reply', tooltip: 'Reply',
onPressed: header == null onPressed: header == null
? null ? null
: () => _reply(context, header, body, replyAll: false), : () {
unawaited(_reply(context, header, body, replyAll: false));
},
), ),
IconButton( IconButton(
icon: const Icon(Icons.reply_all), icon: const Icon(Icons.reply_all),
tooltip: 'Reply all', tooltip: 'Reply all',
onPressed: header == null onPressed: header == null
? null ? null
: () => _reply(context, header, body, replyAll: true), : () {
unawaited(_reply(context, header, body, replyAll: true));
},
), ),
IconButton( IconButton(
icon: const Icon(Icons.forward), icon: const Icon(Icons.forward),
tooltip: 'Forward', tooltip: 'Forward',
onPressed: onPressed: header == null
header == null ? null : () => _forward(context, header, body), ? null
: () {
unawaited(_forward(context, header, body));
},
), ),
IconButton( IconButton(
icon: const Icon(Icons.mark_email_unread_outlined), icon: const Icon(Icons.mark_email_unread_outlined),
@@ -263,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 date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
final from = final from =
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)'; 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'); final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
return '\n\n— On $date, $from wrote:\n$quoted'; return '\n\n— On $date, $from wrote:\n$quoted';
} }
void _reply( Future<void> _reply(
BuildContext context, BuildContext context,
Email header, Email header,
EmailBody? body, { EmailBody? body, {
required bool replyAll, required bool replyAll,
}) { }) async {
final to = header.from.isNotEmpty ? header.from.first.email : ''; final to = header.from.isNotEmpty ? header.from.first.email : '';
final subject = (header.subject?.startsWith('Re:') ?? false) final subject = (header.subject?.startsWith('Re:') ?? false)
? header.subject! ? header.subject!
: 'Re: ${header.subject ?? ''}'; : 'Re: ${header.subject ?? ''}';
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : ''; final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
final quoted = await _quotedBody(header, body);
if (!context.mounted) return;
unawaited( unawaited(
context.push( context.push(
'/compose', '/compose',
@@ -290,23 +311,29 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
'replyToEmailId': widget.emailId, 'replyToEmailId': widget.emailId,
'prefillTo': to, 'prefillTo': to,
'prefillSubject': subject, 'prefillSubject': subject,
'prefillBody': _quotedBody(header, body), 'prefillBody': quoted,
if (cc.isNotEmpty) 'prefillCc': cc, 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) final subject = (header.subject?.startsWith('Fwd:') ?? false)
? header.subject! ? header.subject!
: 'Fwd: ${header.subject ?? ''}'; : 'Fwd: ${header.subject ?? ''}';
final quoted = await _quotedBody(header, body);
if (!context.mounted) return;
unawaited( unawaited(
context.push( context.push(
'/compose', '/compose',
extra: { extra: {
'prefillSubject': subject, 'prefillSubject': subject,
'prefillBody': _quotedBody(header, body), 'prefillBody': quoted,
}, },
), ),
); );
@@ -534,7 +561,11 @@ class _SafeHtmlState extends State<_SafeHtml> {
(_) => ErrorWidget.builder = prev, (_) => ErrorWidget.builder = prev,
); );
return Html(data: widget.data, extensions: widget.extensions); return Html(
data: widget.data,
extensions: widget.extensions,
onLinkTap: _openLink,
);
} }
} }
+42 -3
View File
@@ -15,6 +15,14 @@ import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
final _dateFmt = DateFormat('MMM d'); 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 { class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({ const EmailListScreen({
@@ -45,6 +53,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
List<EmailThread> _currentThreads = []; List<EmailThread> _currentThreads = [];
// Individual email selection used in search results. // Individual email selection used in search results.
final Set<String> _selectedSearchIds = {}; final Set<String> _selectedSearchIds = {};
// Pagination: number of threads currently requested from the DB.
static const _pageSize = 50;
int _limit = _pageSize;
bool get _selecting => bool get _selecting =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty; _selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@@ -189,6 +201,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
extra: {'accountId': widget.accountId}, 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( bottom: PreferredSize(
preferredSize: const Size.fromHeight(60), preferredSize: const Size.fromHeight(60),
@@ -343,7 +371,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
await emailRepo.syncEmails(widget.accountId, widget.mailboxPath); await emailRepo.syncEmails(widget.accountId, widget.mailboxPath);
}, },
child: StreamBuilder<List<EmailThread>>( child: StreamBuilder<List<EmailThread>>(
stream: emailRepo.observeThreads(widget.accountId, widget.mailboxPath), stream: emailRepo.observeThreads(
widget.accountId,
widget.mailboxPath,
limit: _limit,
),
builder: (ctx, snap) { builder: (ctx, snap) {
if (!snap.hasData) { if (!snap.hasData) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -539,9 +571,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
Widget _buildThreadList(List<EmailThread> threads) { Widget _buildThreadList(List<EmailThread> threads) {
final hasMore = threads.length == _limit;
return ListView.builder( return ListView.builder(
itemCount: threads.length, itemCount: threads.length + (hasMore ? 1 : 0),
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
if (i == threads.length) {
return TextButton(
onPressed: () => setState(() => _limit += _pageSize),
child: const Text('Load more'),
);
}
final t = threads[i]; final t = threads[i];
final isSelected = _selectedThreadIds.contains(t.threadId); final isSelected = _selectedThreadIds.contains(t.threadId);
final senderNames = final senderNames =
@@ -610,7 +649,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
const Icon(Icons.star, color: Colors.amber, size: 16), const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_dateFmt.format(t.latestDate), _fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall, 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/di.dart';
import 'package:sharedinbox/ui/widgets/email_tile.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 { class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key, this.accountId}); const SearchScreen({super.key, this.accountId});
final String? accountId; final String? accountId;
@@ -20,13 +25,24 @@ class SearchScreen extends ConsumerStatefulWidget {
class _SearchScreenState extends ConsumerState<SearchScreen> { class _SearchScreenState extends ConsumerState<SearchScreen> {
final _ctrl = TextEditingController(); final _ctrl = TextEditingController();
final _focusNode = FocusNode();
Timer? _debounce; Timer? _debounce;
_SearchResults? _results; _SearchResults? _results;
bool _loading = false; bool _loading = false;
bool _fieldFocused = false;
@override
void initState() {
super.initState();
_focusNode.addListener(() {
if (mounted) setState(() => _fieldFocused = _focusNode.hasFocus);
});
}
@override @override
void dispose() { void dispose() {
_ctrl.dispose(); _ctrl.dispose();
_focusNode.dispose();
_debounce?.cancel(); _debounce?.cancel();
super.dispose(); super.dispose();
} }
@@ -45,6 +61,12 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
Future<void> _search(String query) async { Future<void> _search(String query) async {
setState(() => _loading = true); setState(() => _loading = true);
unawaited(
ref
.read(searchHistoryRepositoryProvider)
.saveSearch(query)
.then((_) => ref.invalidate(_searchHistoryProvider)),
);
try { try {
final emailRepo = ref.read(emailRepositoryProvider); final emailRepo = ref.read(emailRepositoryProvider);
final mailboxRepo = ref.read(mailboxRepositoryProvider); final mailboxRepo = ref.read(mailboxRepositoryProvider);
@@ -112,6 +134,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
appBar: AppBar( appBar: AppBar(
title: TextField( title: TextField(
controller: _ctrl, controller: _ctrl,
focusNode: _focusNode,
autofocus: true, autofocus: true,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Search folders, addresses, emails…', hintText: 'Search folders, addresses, emails…',
@@ -137,6 +160,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
Widget _buildBody() { Widget _buildBody() {
if (_loading) return const Center(child: CircularProgressIndicator()); if (_loading) return const Center(child: CircularProgressIndicator());
if (_results == null) { if (_results == null) {
if (_fieldFocused && _ctrl.text.isEmpty) {
return _buildHistoryPanel();
}
return const Center(child: Text('Type 3+ characters to search')); return const Center(child: Text('Type 3+ characters to search'));
} }
final r = _results!; 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 { 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/models/undo_action.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:url_launcher/url_launcher.dart';
final _dateFmt = DateFormat('EEE, MMM d, HH:mm'); final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
@@ -168,6 +169,18 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
extensions: [ extensions: [
if (!_loadRemoteImages) _BlockRemoteImagesExtension(), if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
], ],
onLinkTap: (url, _, __) {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri != null) {
unawaited(
launchUrl(
uri,
mode: LaunchMode.externalApplication,
),
);
}
},
), ),
] else ] else
SelectableText( 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).
+2
View File
@@ -17,6 +17,7 @@ const _noCode = {
'lib/core/repositories/mailbox_repository.dart', 'lib/core/repositories/mailbox_repository.dart',
'lib/core/repositories/sync_log_repository.dart', 'lib/core/repositories/sync_log_repository.dart',
'lib/core/repositories/undo_repository.dart', 'lib/core/repositories/undo_repository.dart',
'lib/core/repositories/search_history_repository.dart',
'lib/core/models/undo_action.dart', 'lib/core/models/undo_action.dart',
'lib/core/storage/secure_storage.dart', 'lib/core/storage/secure_storage.dart',
}; };
@@ -61,6 +62,7 @@ const _excluded = {
'lib/data/repositories/mailbox_repository_impl.dart', 'lib/data/repositories/mailbox_repository_impl.dart',
'lib/data/repositories/sync_log_repository_impl.dart', 'lib/data/repositories/sync_log_repository_impl.dart',
'lib/data/repositories/undo_repository_impl.dart', 'lib/data/repositories/undo_repository_impl.dart',
'lib/data/repositories/search_history_repository_impl.dart',
}; };
void main() { void main() {
+42 -12
View File
@@ -4,6 +4,7 @@
import json import json
import os import os
import sys import sys
import time
import google_auth_httplib2 import google_auth_httplib2
import httplib2 import httplib2
@@ -15,6 +16,14 @@ PACKAGE_NAME = "de.sharedinbox.mua"
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab" AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
TRACK = "internal" TRACK = "internal"
_TIMEOUT = 300 # seconds — AAB uploads can be large _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(): def main():
@@ -32,22 +41,43 @@ def main():
scopes=["https://www.googleapis.com/auth/androidpublisher"], scopes=["https://www.googleapis.com/auth/androidpublisher"],
) )
authorized_http = google_auth_httplib2.AuthorizedHttp( service = _make_service(creds)
creds, http=httplib2.Http(timeout=_TIMEOUT)
)
service = build("androidpublisher", "v3", http=authorized_http)
edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute(num_retries=3) edit = service.edits().insert(body={}, packageName=PACKAGE_NAME).execute(num_retries=3)
edit_id = edit["id"] edit_id = edit["id"]
media = MediaFileUpload(AAB_PATH, mimetype="application/octet-stream", resumable=True) # The resumable upload can fail with RedirectMissingLocation on transient
bundle = ( # network hiccups. Retry the upload (with a fresh MediaFileUpload each
service.edits() # time) using exponential backoff before giving up.
.bundles() version_code = None
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media) last_exc = None
.execute(num_retries=3) for attempt in range(_MAX_UPLOAD_ATTEMPTS):
) try:
version_code = bundle["versionCode"] 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(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}") print(f"Uploaded AAB, version code: {version_code}")
service.edits().tracks().update( service.edits().tracks().update(
@@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.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/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.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() { void main() {
test('AccountSyncManager schedules sync for multiple accounts', () async { test('AccountSyncManager schedules IMAP sync for multiple accounts',
() async {
final accounts = _FakeAccounts('pw'); final accounts = _FakeAccounts('pw');
final mailboxes = _FakeMailboxes(); final mailboxes = _FakeMailboxes();
final emails = _FakeEmails(); final emails = _FakeEmails();
@@ -22,6 +32,7 @@ void main() {
mailboxes, mailboxes,
emails, emails,
syncLog: logs, syncLog: logs,
imapConnect: _fakeImapConnect,
); );
final a1 = _account('1'); final a1 = _account('1');
@@ -38,6 +49,34 @@ void main() {
manager.dispose(); 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( Account _account(String id) => Account(
@@ -52,6 +91,17 @@ Account _account(String id) => Account(
smtpSsl: false, 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 { class _FakeAccounts implements AccountRepository {
_FakeAccounts(this.password); _FakeAccounts(this.password);
final String password; final String password;
@@ -105,10 +155,19 @@ class _FakeEmails implements EmailRepository {
final syncCounts = <String, int>{}; final syncCounts = <String, int>{};
@override @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 @override
Stream<List<EmailThread>> observeThreads(String a, String m) => Stream<List<EmailThread>> observeThreads(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
@@ -131,6 +190,9 @@ class _FakeEmails implements EmailRepository {
@override @override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {} Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override @override
Future<void> moveEmail(String id, String dest) async {} 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 { class FakeEmailRepository implements EmailRepository {
@override @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 @override
Stream<List<EmailThread>> observeThreads(String a, String m) => Stream<List<EmailThread>> observeThreads(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) => Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
@@ -52,6 +61,8 @@ class FakeEmailRepository implements EmailRepository {
@override @override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {} Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override @override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String id, String dest) async {} Future<void> moveEmail(String id, String dest) async {}
@override @override
+25 -4
View File
@@ -216,8 +216,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
@override @override
_i4.Stream<List<_i2.Email>> observeEmails( _i4.Stream<List<_i2.Email>> observeEmails(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath, {
) => int? limit = 50,
}) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeEmails, #observeEmails,
@@ -225,6 +226,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
accountId, accountId,
mailboxPath, mailboxPath,
], ],
{#limit: limit},
), ),
returnValue: _i4.Stream<List<_i2.Email>>.empty(), returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>); ) as _i4.Stream<List<_i2.Email>>);
@@ -232,8 +234,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
@override @override
_i4.Stream<List<_i2.EmailThread>> observeThreads( _i4.Stream<List<_i2.EmailThread>> observeThreads(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath, {
) => int? limit = 50,
}) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeThreads, #observeThreads,
@@ -241,6 +244,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
accountId, accountId,
mailboxPath, mailboxPath,
], ],
{#limit: limit},
), ),
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(), returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.EmailThread>>); ) as _i4.Stream<List<_i2.EmailThread>>);
@@ -333,6 +337,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>); ) 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 @override
_i4.Future<void> moveEmail( _i4.Future<void> moveEmail(
String? emailId, 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();
});
}
+13 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () { group('Migration', () {
test('schemaVersion matches expected value', () async { test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 26); expect(db.schemaVersion, 27);
await db.close(); await db.close();
}); });
@@ -171,6 +171,11 @@ void main() {
// Verify FTS table was created and is queryable. // Verify FTS table was created and is queryable.
await db.customSelect('SELECT count(*) FROM email_fts').get(); 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(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
@@ -301,11 +306,16 @@ void main() {
); );
await db.customSelect('SELECT count(*) FROM email_fts').get(); 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(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
test('fresh install creates all tables at schemaVersion 26', () async { test('fresh install creates all tables at schemaVersion 27', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get(); await db.select(db.accounts).get();
@@ -328,6 +338,7 @@ void main() {
'threads', 'threads',
'sync_health', 'sync_health',
'undo_actions', 'undo_actions',
'search_history_entries',
]), ]),
); );
+13 -2
View File
@@ -79,9 +79,18 @@ class _CountingEmails implements EmailRepository {
@override @override
Future<int> flushPendingChanges(String accountId, String password) async => 0; Future<int> flushPendingChanges(String accountId, String password) async => 0;
@override @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 @override
Stream<List<EmailThread>> observeThreads(String a, String m) => Stream<List<EmailThread>> observeThreads(
String a,
String m, {
int limit = 50,
}) =>
Stream.value([]); Stream.value([]);
@override @override
Stream<List<Email>> observeEmailsInThread(String a, String m, String t) => Stream<List<Email>> observeEmailsInThread(String a, String m, String t) =>
@@ -94,6 +103,8 @@ class _CountingEmails implements EmailRepository {
@override @override
Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {} Future<void> setFlag(String id, {bool? seen, bool? flagged}) async {}
@override @override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override
Future<void> moveEmail(String id, String dest) async {} Future<void> moveEmail(String id, String dest) async {}
@override @override
Future<String?> deleteEmail(String id) async => null; Future<String?> deleteEmail(String id) async => null;
+25 -4
View File
@@ -76,8 +76,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
@override @override
_i4.Stream<List<_i2.Email>> observeEmails( _i4.Stream<List<_i2.Email>> observeEmails(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath, {
) => int? limit = 50,
}) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeEmails, #observeEmails,
@@ -85,6 +86,7 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
accountId, accountId,
mailboxPath, mailboxPath,
], ],
{#limit: limit},
), ),
returnValue: _i4.Stream<List<_i2.Email>>.empty(), returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>); ) as _i4.Stream<List<_i2.Email>>);
@@ -92,8 +94,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
@override @override
_i4.Stream<List<_i2.EmailThread>> observeThreads( _i4.Stream<List<_i2.EmailThread>> observeThreads(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath, {
) => int? limit = 50,
}) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeThreads, #observeThreads,
@@ -101,6 +104,7 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
accountId, accountId,
mailboxPath, mailboxPath,
], ],
{#limit: limit},
), ),
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(), returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.EmailThread>>); ) as _i4.Stream<List<_i2.EmailThread>>);
@@ -193,6 +197,23 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>); ) 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 @override
_i4.Future<void> moveEmail( _i4.Future<void> moveEmail(
String? emailId, 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/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_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/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_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: []); _emailBody = emailBody ?? const EmailBody(emailId: '', attachments: []);
@override @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)); Stream.value(List.of(_emails));
@override @override
Stream<List<EmailThread>> observeThreads( Stream<List<EmailThread>> observeThreads(
String accountId, String accountId,
String mailboxPath, String mailboxPath, {
) => int limit = 50,
}) =>
observeEmails(accountId, mailboxPath).map((emails) { observeEmails(accountId, mailboxPath).map((emails) {
return emails.map((e) { return emails.map((e) {
return EmailThread( return EmailThread(
@@ -208,6 +214,8 @@ class FakeEmailRepository implements EmailRepository {
@override @override
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {} Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
@override
Future<void> markAllAsRead(String accountId, String mailboxPath) async {}
@override @override
Future<void> moveEmail(String emailId, String destMailboxPath) async {} Future<void> moveEmail(String emailId, String destMailboxPath) async {}
@@ -503,3 +511,20 @@ Email testEmail({
isFlagged: isFlagged, isFlagged: isFlagged,
hasAttachment: hasAttachment, 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(), FakeMailboxRepository(),
), ),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
], ],
), ),
); );
@@ -42,6 +45,9 @@ void main() {
FakeMailboxRepository(), FakeMailboxRepository(),
), ),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
], ],
), ),
); );
@@ -68,6 +74,9 @@ void main() {
FakeMailboxRepository(), FakeMailboxRepository(),
), ),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
], ],
), ),
); );
@@ -97,6 +106,9 @@ void main() {
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]), FakeEmailRepository(searchResults: [email]),
), ),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
], ],
), ),
); );
@@ -132,6 +144,9 @@ void main() {
FakeMailboxRepository([archiveMailbox]), FakeMailboxRepository([archiveMailbox]),
), ),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
], ],
), ),
); );
@@ -162,6 +177,9 @@ void main() {
emailRepositoryProvider.overrideWithValue( emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]), FakeEmailRepository(searchResults: [email]),
), ),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
], ],
), ),
); );
@@ -175,8 +193,9 @@ void main() {
await tester.tap(find.byIcon(Icons.clear)); await tester.tap(find.byIcon(Icons.clear));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Results are gone; the recent-search chip for the prior query appears.
expect(find.text('Found email'), findsNothing); expect(find.text('Found email'), findsNothing);
expect(find.text('Type 3+ characters to search'), findsOneWidget); expect(find.text('found'), findsOneWidget);
}); });
}); });
} }