Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 06d183cc6d test: make AccountSyncManager integration tests independent of real servers (A3)
Pass a fake imapConnect to the IMAP sync test so no real TCP connections
are made during CI. Add a parallel JMAP account test that exercises the
_JmapAccountSync loop entirely through injectable fake repositories.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:36:28 +02:00
29 changed files with 83 additions and 689 deletions
+1 -12
View File
@@ -368,18 +368,7 @@ tasks:
check-fast:
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
deps: [analyze, check-coverage, check-hygiene, check-layers]
check-layers:
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
cmds:
- |
VIOLATIONS=$(grep -rn "package:sharedinbox/data/" lib/ui/ 2>/dev/null || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):"
echo "$VIOLATIONS"
exit 1
fi
deps: [analyze, check-coverage, check-hygiene]
check-hygiene:
desc: Verify that no forbidden files (like home dir config) are tracked
@@ -27,7 +27,6 @@ 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
@@ -1,5 +0,0 @@
abstract interface class SearchHistoryRepository {
Future<List<String>> getRecentSearches();
Future<void> saveSearch(String query);
Future<void> clearHistory();
}
+6 -60
View File
@@ -6,40 +6,8 @@ import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/models/email.dart';
part 'database.g.dart';
// ── TypeConverters ────────────────────────────────────────────────────────────
class EmailAddressListConverter
extends TypeConverter<List<EmailAddress>, String> {
const EmailAddressListConverter();
@override
List<EmailAddress> fromSql(String fromDb) {
final list = jsonDecode(fromDb) as List<dynamic>;
return list
.map((e) => EmailAddress.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
String toSql(List<EmailAddress> value) =>
jsonEncode(value.map((e) => e.toJson()).toList());
}
class StringListConverter extends TypeConverter<List<String>, String> {
const StringListConverter();
@override
List<String> fromSql(String fromDb) =>
List<String>.from(jsonDecode(fromDb) as List);
@override
String toSql(List<String> value) => jsonEncode(value);
}
// ── Tables ────────────────────────────────────────────────────────────────────
class Accounts extends Table {
@@ -155,14 +123,11 @@ class Threads extends Table {
IntColumn get messageCount => integer().withDefault(const Constant(1))();
BoolColumn get hasUnread => boolean().withDefault(const Constant(false))();
BoolColumn get isFlagged => boolean().withDefault(const Constant(false))();
TextColumn get participantsJson => text()
.withDefault(const Constant('[]'))
.map(const EmailAddressListConverter())();
// JSON-encoded List<{name,email}>
TextColumn get participantsJson => text().withDefault(const Constant('[]'))();
TextColumn get preview => text().nullable()();
TextColumn get latestEmailId => text()();
TextColumn get emailIdsJson => text()
.withDefault(const Constant('[]'))
.map(const StringListConverter())();
TextColumn get emailIdsJson => text().withDefault(const Constant('[]'))();
@override
Set<Column> get primaryKey => {accountId, mailboxPath, id};
@@ -269,13 +234,6 @@ 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()();
@@ -305,14 +263,13 @@ class UndoActions extends Table {
SyncLogMailboxes,
SyncHealth,
UndoActions,
SearchHistoryEntries,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 27;
int get schemaVersion => 26;
Future<void> _createEmailFts() async {
await customStatement('''
@@ -446,18 +403,10 @@ class AppDatabase extends _$AppDatabase {
preview: Value(latest.preview),
latestEmailId: latest.id,
emailIdsJson: Value(
threadEmails.map((e) => e.id).toList(),
jsonEncode(threadEmails.map((e) => e.id).toList()),
),
participantsJson: Value(
(jsonDecode(latest.fromJson) as List<dynamic>)
.map(
(e) => EmailAddress(
name:
(e as Map<String, dynamic>)['name'] as String?,
email: e['email'] as String,
),
)
.toList(),
latest.fromJson,
), // Good enough for migration
),
);
@@ -543,9 +492,6 @@ class AppDatabase extends _$AppDatabase {
SELECT rowid, subject, preview, from_json FROM emails
''');
}
if (from < 27) {
await m.createTable(searchHistoryEntries);
}
},
);
}
@@ -92,6 +92,18 @@ class EmailRepositoryImpl implements EmailRepository {
}
model.EmailThread _threadRowToModel(ThreadRow row) {
List<model.EmailAddress> parseAddresses(String json) {
final list = jsonDecode(json) as List<dynamic>;
return list
.map(
(e) => model.EmailAddress(
name: (e as Map<String, dynamic>)['name'] as String?,
email: e['email'] as String,
),
)
.toList();
}
return model.EmailThread(
threadId: row.id,
accountId: row.accountId,
@@ -101,10 +113,10 @@ class EmailRepositoryImpl implements EmailRepository {
messageCount: row.messageCount,
hasUnread: row.hasUnread,
isFlagged: row.isFlagged,
participants: row.participantsJson,
participants: parseAddresses(row.participantsJson),
preview: row.preview,
latestEmailId: row.latestEmailId,
emailIds: row.emailIdsJson,
emailIds: List<String>.from(jsonDecode(row.emailIdsJson) as List),
);
}
@@ -144,11 +156,13 @@ class EmailRepositoryImpl implements EmailRepository {
// Collect unique participants across the whole thread.
final seen = <String>{};
final participants = <model.EmailAddress>[];
final participants = <Map<String, dynamic>>[];
for (final e in threadEmails) {
for (final a in _parseAddresses(e.fromJson)) {
if (seen.add(a.email)) {
participants.add(a);
final from = jsonDecode(e.fromJson) as List<dynamic>;
for (final a in from.cast<Map<String, dynamic>>()) {
final email = a['email'] as String;
if (seen.add(email)) {
participants.add({'name': a['name'], 'email': email});
}
}
}
@@ -163,10 +177,12 @@ class EmailRepositoryImpl implements EmailRepository {
messageCount: Value(threadEmails.length),
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
participantsJson: Value(participants),
participantsJson: Value(jsonEncode(participants)),
preview: Value(latest.preview),
latestEmailId: latest.id,
emailIdsJson: Value(threadEmails.map((e) => e.id).toList()),
emailIdsJson: Value(
jsonEncode(threadEmails.map((e) => e.id).toList()),
),
),
);
}
@@ -1504,63 +1520,6 @@ 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(
@@ -2692,6 +2651,18 @@ class EmailRepositoryImpl implements EmailRepository {
}
model.Email _toModel(Email row) {
List<model.EmailAddress> parseAddresses(String json) {
final list = jsonDecode(json) as List<dynamic>;
return list
.map(
(e) => model.EmailAddress(
name: (e as Map<String, dynamic>)['name'] as String?,
email: e['email'] as String,
),
)
.toList();
}
return model.Email(
id: row.id,
accountId: row.accountId,
@@ -2700,9 +2671,9 @@ class EmailRepositoryImpl implements EmailRepository {
subject: row.subject,
sentAt: row.sentAt,
receivedAt: row.receivedAt,
from: _parseAddresses(row.fromJson),
to: _parseAddresses(row.toAddresses),
cc: _parseAddresses(row.ccJson),
from: parseAddresses(row.fromJson),
to: parseAddresses(row.toAddresses),
cc: parseAddresses(row.ccJson),
preview: row.preview,
isSeen: row.isSeen,
isFlagged: row.isFlagged,
@@ -2738,18 +2709,6 @@ class EmailRepositoryImpl implements EmailRepository {
}
}
List<model.EmailAddress> _parseAddresses(String json) {
final list = jsonDecode(json) as List<dynamic>;
return list
.map(
(e) => model.EmailAddress(
name: (e as Map<String, dynamic>)['name'] as String?,
email: e['email'] as String,
),
)
.toList();
}
List<model.EmailAttachment> _parseAttachments(String json) {
final list = jsonDecode(json) as List<dynamic>;
return list
@@ -1,57 +0,0 @@
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,7 +9,6 @@ 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';
@@ -26,7 +25,6 @@ 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';
@@ -89,11 +87,6 @@ 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));
});
+12 -43
View File
@@ -1,6 +1,5 @@
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';
@@ -18,14 +17,6 @@ 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;
@@ -69,27 +60,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
tooltip: 'Reply',
onPressed: header == null
? null
: () {
unawaited(_reply(context, header, body, replyAll: false));
},
: () => _reply(context, header, body, replyAll: false),
),
IconButton(
icon: const Icon(Icons.reply_all),
tooltip: 'Reply all',
onPressed: header == null
? null
: () {
unawaited(_reply(context, header, body, replyAll: true));
},
: () => _reply(context, header, body, replyAll: true),
),
IconButton(
icon: const Icon(Icons.forward),
tooltip: 'Forward',
onPressed: header == null
? null
: () {
unawaited(_forward(context, header, body));
},
onPressed:
header == null ? null : () => _forward(context, header, body),
),
IconButton(
icon: const Icon(Icons.mark_email_unread_outlined),
@@ -279,31 +263,26 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
Future<String> _quotedBody(Email header, EmailBody? body) async {
String _quotedBody(Email header, EmailBody? body) {
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
final from =
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
final rawText = body?.textBody;
final text = (rawText != null && rawText.isNotEmpty)
? rawText
: await compute(htmlToPlain, body?.htmlBody ?? '');
final text = body?.textBody ?? htmlToPlain(body?.htmlBody ?? '');
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
return '\n\n— On $date, $from wrote:\n$quoted';
}
Future<void> _reply(
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',
@@ -311,29 +290,23 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
'replyToEmailId': widget.emailId,
'prefillTo': to,
'prefillSubject': subject,
'prefillBody': quoted,
'prefillBody': _quotedBody(header, body),
if (cc.isNotEmpty) 'prefillCc': cc,
},
),
);
}
Future<void> _forward(
BuildContext context,
Email header,
EmailBody? body,
) async {
void _forward(BuildContext context, Email header, EmailBody? body) {
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': quoted,
'prefillBody': _quotedBody(header, body),
},
),
);
@@ -561,11 +534,7 @@ class _SafeHtmlState extends State<_SafeHtml> {
(_) => ErrorWidget.builder = prev,
);
return Html(
data: widget.data,
extensions: widget.extensions,
onLinkTap: _openLink,
);
return Html(data: widget.data, extensions: widget.extensions);
}
}
+1 -25
View File
@@ -15,14 +15,6 @@ 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({
@@ -201,22 +193,6 @@ 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),
@@ -649,7 +625,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
_dateFmt.format(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
-86
View File
@@ -10,11 +10,6 @@ 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;
@@ -25,24 +20,13 @@ 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();
}
@@ -61,12 +45,6 @@ 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);
@@ -134,7 +112,6 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
appBar: AppBar(
title: TextField(
controller: _ctrl,
focusNode: _focusNode,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search folders, addresses, emails…',
@@ -160,9 +137,6 @@ 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!;
@@ -195,66 +169,6 @@ 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,7 +10,6 @@ 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');
@@ -169,18 +168,6 @@ 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(
+8 -4
View File
@@ -43,7 +43,10 @@ Files: `lib/ui/screens/email_list_screen.dart`, `lib/core/utils/format_utils.dar
### R4 — Done: https://codeberg.org/guettli/sharedinbox/pulls/23
### R5 — Done: https://codeberg.org/guettli/sharedinbox/pulls/45
### 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`.
### R6 — Done: https://codeberg.org/guettli/sharedinbox/pulls/24
@@ -104,8 +107,6 @@ 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`.
@@ -126,7 +127,10 @@ Files: `test/widget/email_list_screen_test.dart`.
### A2 — Done: https://codeberg.org/guettli/sharedinbox/pulls/33
### A3 — Done: https://codeberg.org/guettli/sharedinbox/pulls/46
### 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`.
### 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,7 +17,6 @@ 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',
};
@@ -62,7 +61,6 @@ 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() {
@@ -190,9 +190,6 @@ 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 {}
-2
View File
@@ -61,8 +61,6 @@ 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
+6 -23
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,23 +337,6 @@ 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,
@@ -126,35 +126,6 @@ abstract class EmailRepositoryContract {
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(
+1 -1
View File
@@ -332,7 +332,7 @@ void main() {
messageCount: const Value(2),
hasUnread: const Value(true),
latestEmailId: 'acc-1:2',
emailIdsJson: const Value(['acc-1:1', 'acc-1:2']),
emailIdsJson: const Value('["acc-1:1", "acc-1:2"]'),
),
);
+2 -13
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 27);
expect(db.schemaVersion, 26);
await db.close();
});
@@ -171,11 +171,6 @@ 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();
});
@@ -306,16 +301,11 @@ 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 27', () async {
test('fresh install creates all tables at schemaVersion 26', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -338,7 +328,6 @@ void main() {
'threads',
'sync_health',
'undo_actions',
'search_history_entries',
]),
);
-2
View File
@@ -103,8 +103,6 @@ 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;
+6 -23
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,23 +197,6 @@ 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,
@@ -1,158 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

-20
View File
@@ -17,7 +17,6 @@ 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';
@@ -214,8 +213,6 @@ 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 {}
@@ -511,20 +508,3 @@ 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();
}
+1 -20
View File
@@ -20,9 +20,6 @@ void main() {
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -45,9 +42,6 @@ void main() {
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -74,9 +68,6 @@ void main() {
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -106,9 +97,6 @@ void main() {
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]),
),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -144,9 +132,6 @@ void main() {
FakeMailboxRepository([archiveMailbox]),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -177,9 +162,6 @@ void main() {
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(searchResults: [email]),
),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
],
),
);
@@ -193,9 +175,8 @@ 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('found'), findsOneWidget);
expect(find.text('Type 3+ characters to search'), findsOneWidget);
});
});
}