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
34 changed files with 1258 additions and 58 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
+12 -1
View File
@@ -368,7 +368,18 @@ tasks:
check-fast:
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:
desc: Verify that no forbidden files (like home dir config) are tracked
@@ -27,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') ||
+12 -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,13 +270,14 @@ class UndoActions extends Table {
SyncLogMailboxes,
SyncHealth,
UndoActions,
SearchHistoryEntries,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 26;
int get schemaVersion => 27;
Future<void> _createEmailFts() async {
await customStatement('''
@@ -492,6 +500,9 @@ class AppDatabase extends _$AppDatabase {
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);
}
@@ -1520,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(
@@ -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/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';
@@ -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/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';
@@ -87,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));
});
+43 -12
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;
@@ -60,20 +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),
onPressed: header == null
? null
: () {
unawaited(_forward(context, header, body));
},
),
IconButton(
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 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',
@@ -290,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,
},
),
);
@@ -534,7 +561,11 @@ class _SafeHtmlState extends State<_SafeHtml> {
(_) => ErrorWidget.builder = prev,
);
return Html(data: widget.data, extensions: widget.extensions);
return Html(
data: widget.data,
extensions: widget.extensions,
onLinkTap: _openLink,
);
}
}
+25 -1
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({
@@ -193,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),
@@ -625,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(
+5 -11
View File
@@ -43,10 +43,7 @@ Files: `lib/ui/screens/email_list_screen.dart`, `lib/core/utils/format_utils.dar
### R4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/23
### R5 🟡 Handle TLS certificate changes gracefully
`tls_error.dart` detects TLS errors but they bubble up as generic errors in the sync loop.
Detect `TlsError` specifically in `_AccountSync` and show a user-facing dialog offering to re-add the account or trust the new certificate.
Files: `lib/data/imap/tls_error.dart`, `lib/core/sync/account_sync_manager.dart`.
### R5 — Done: https://codeberg.org/guettli/sharedinbox/pulls/45
### R6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/24
@@ -86,9 +83,7 @@ Files: `lib/ui/screens/search_screen.dart`, `lib/data/db/database.dart`.
### U4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/28
### U5 🟡 Accessible swipe actions on email list items
Delete and Move are hidden behind long-press or detail-screen menus. Add leading/trailing swipe actions on the `EmailListScreen` tile (archive / delete) matching Material 3 patterns.
Files: `lib/ui/screens/email_list_screen.dart`.
### U5 Already implemented (Dismissible archive/delete swipes with undo, found in email_list_screen.dart)
### U6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/29
@@ -109,6 +104,8 @@ Files: `lib/ui/screens/email_list_screen.dart`, `lib/core/repositories/email_rep
### 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`.
@@ -129,10 +126,7 @@ Files: `test/widget/email_list_screen_test.dart`.
### A2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/33
### A3 🟡 Make AccountSyncManager testable without real IMAP connections
`AccountSyncManager` accepts `ImapConnectFn` as a dependency but `_JmapAccountSync` constructs its HTTP client internally.
Pass an injectable `http.Client` to `_JmapAccountSync` (already done in `EmailRepositoryImpl`; mirror the pattern here).
Files: `lib/core/sync/account_sync_manager.dart`, `test/unit/account_sync_manager_test.dart`.
### 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.
+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 -12
View File
@@ -4,6 +4,7 @@
import json
import os
import sys
import time
import google_auth_httplib2
import httplib2
@@ -15,6 +16,14 @@ 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():
@@ -32,22 +41,43 @@ def main():
scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
authorized_http = google_auth_httplib2.AuthorizedHttp(
creds, http=httplib2.Http(timeout=_TIMEOUT)
)
service = build("androidpublisher", "v3", http=authorized_http)
service = _make_service(creds)
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)
bundle = (
service.edits()
.bundles()
.upload(packageName=PACKAGE_NAME, editId=edit_id, media_body=media)
.execute(num_retries=3)
)
version_code = bundle["versionCode"]
# 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(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(
@@ -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;
@@ -140,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();
});
}
+2
View File
@@ -61,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
+23 -6
View File
@@ -215,9 +215,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
@override
_i4.Stream<List<_i2.Email>> observeEmails(
String accountId,
String mailboxPath, {
int limit = 50,
String? accountId,
String? mailboxPath, {
int? limit = 50,
}) =>
(super.noSuchMethod(
Invocation.method(
@@ -233,9 +233,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
@override
_i4.Stream<List<_i2.EmailThread>> observeThreads(
String accountId,
String mailboxPath, {
int limit = 50,
String? accountId,
String? mailboxPath, {
int? limit = 50,
}) =>
(super.noSuchMethod(
Invocation.method(
@@ -337,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();
});
}
+13 -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, 26);
expect(db.schemaVersion, 27);
await db.close();
});
@@ -171,6 +171,11 @@ void main() {
// 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();
});
@@ -301,11 +306,16 @@ void main() {
);
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 26', () async {
test('fresh install creates all tables at schemaVersion 27', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -328,6 +338,7 @@ void main() {
'threads',
'sync_health',
'undo_actions',
'search_history_entries',
]),
);
+2
View File
@@ -103,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;
+23 -6
View File
@@ -75,9 +75,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
@override
_i4.Stream<List<_i2.Email>> observeEmails(
String accountId,
String mailboxPath, {
int limit = 50,
String? accountId,
String? mailboxPath, {
int? limit = 50,
}) =>
(super.noSuchMethod(
Invocation.method(
@@ -93,9 +93,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
@override
_i4.Stream<List<_i2.EmailThread>> observeThreads(
String accountId,
String mailboxPath, {
int limit = 50,
String? accountId,
String? mailboxPath, {
int? limit = 50,
}) =>
(super.noSuchMethod(
Invocation.method(
@@ -197,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

+20
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';
@@ -213,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 {}
@@ -508,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);
});
});
}