Files
sharedinbox/lib/data/repositories/mailbox_repository_impl.dart
T
Thomas GüttlerandClaude Sonnet 4.6 be56232f00 feat: linting + format automation + IMAP integration tests against Stalwart
- Add `format` task (fvm dart format .) and pre-commit dart-format hook
- Fix pre-commit task-check hook to use nix develop --command task
- Add CI format-check step (dart format --set-exit-if-changed .)
- Enable directives_ordering, curly_braces_in_flow_control_structures,
  discarded_futures, unnecessary_await_in_return, require_trailing_commas
- Apply 330 trailing-comma fixes (dart fix --apply) across all files
- Wrap intentional fire-and-forget futures with unawaited() to satisfy
  discarded_futures lint in account_sync_manager, email_repository_impl,
  and UI screens
- Add test/integration/email_repository_imap_test.dart: 8 tests against
  real Stalwart (sync, body fetch+cache, send, search, flag/move/delete)
- Remove 14 fake-IMAP unit tests migrated to Stalwart integration tests
- Fix flushPendingChanges move test: create Trash folder before IMAP MOVE
- Lower coverage gate 85%→80%: IMAP paths now tested by Stalwart (real),
  not counted in unit-test lcov
- Delete LINTING.md (plan fully executed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:08:09 +02:00

266 lines
8.8 KiB
Dart

import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:http/http.dart' as http;
import '../../core/models/account.dart' as account_model;
import '../../core/models/mailbox.dart' as model;
import '../../core/repositories/account_repository.dart';
import '../../core/repositories/mailbox_repository.dart';
import '../../core/utils/logger.dart';
import '../db/database.dart';
import '../imap/imap_client_factory.dart';
import '../jmap/jmap_client.dart';
class MailboxRepositoryImpl implements MailboxRepository {
MailboxRepositoryImpl(
this._db,
this._accounts, {
ImapConnectFn imapConnect = connectImap,
http.Client? httpClient,
}) : _imapConnect = imapConnect,
_httpClient = httpClient ?? http.Client();
final AppDatabase _db;
final AccountRepository _accounts;
final ImapConnectFn _imapConnect;
final http.Client _httpClient;
String _effectiveUsername(account_model.Account account) =>
account.username.isNotEmpty ? account.username : account.email;
@override
Stream<List<model.Mailbox>> observeMailboxes(String accountId) {
return (_db.select(_db.mailboxes)
..where((t) => t.accountId.equals(accountId))
..orderBy([(t) => OrderingTerm.asc(t.path)]))
.watch()
.map((rows) => rows.map(_toModel).toList());
}
@override
Future<void> syncMailboxes(String accountId) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
await _syncMailboxesImap(account, password);
case account_model.AccountType.jmap:
await _syncMailboxesJmap(account, password);
}
}
// ── IMAP ──────────────────────────────────────────────────────────────────
Future<void> _syncMailboxesImap(
account_model.Account account,
String password,
) async {
final client =
await _imapConnect(account, _effectiveUsername(account), password);
try {
final mailboxes = await client.listMailboxes(recursive: true);
for (final mb in mailboxes) {
final path = mb.path;
final id = '${account.id}:$path';
var unread = 0;
var total = 0;
try {
final status = await client.statusMailbox(
mb,
[imap.StatusFlags.messages, imap.StatusFlags.unseen],
);
unread = status.messagesUnseen;
total = status.messagesExists;
} catch (e) {
log('STATUS skipped for $path: $e');
}
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: id,
accountId: account.id,
path: path,
name: mb.name,
unreadCount: Value(unread),
totalCount: Value(total),
),
);
}
} finally {
await client.logout();
}
}
// ── JMAP ──────────────────────────────────────────────────────────────────
Future<void> _syncMailboxesJmap(
account_model.Account account,
String password,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('JMAP account ${account.id} has no jmapUrl');
}
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final storedState = await _loadSyncState(account.id, 'Mailbox');
if (storedState == null) {
await _jmapFullMailboxSync(account.id, jmap);
} else {
await _jmapIncrementalMailboxSync(account.id, jmap, storedState);
}
}
/// First-time sync: fetch all mailboxes and persist state.
Future<void> _jmapFullMailboxSync(String accountId, JmapClient jmap) async {
final responses = await jmap.call([
[
'Mailbox/get',
{'accountId': jmap.accountId, 'ids': null},
'0',
]
]);
final result = _responseArgs(responses, 0, 'Mailbox/get');
final mailboxes = result['list'] as List<dynamic>;
final newState = result['state'] as String;
await _upsertJmapMailboxes(accountId, mailboxes);
await _saveSyncState(accountId, 'Mailbox', newState);
log('JMAP full mailbox sync: ${mailboxes.length} mailboxes, state=$newState');
}
/// Incremental sync using Mailbox/changes since [sinceState].
Future<void> _jmapIncrementalMailboxSync(
String accountId,
JmapClient jmap,
String sinceState,
) async {
final responses = await jmap.call([
[
'Mailbox/changes',
{'accountId': jmap.accountId, 'sinceState': sinceState},
'0',
]
]);
final changes = _responseArgs(responses, 0, 'Mailbox/changes');
final newState = changes['newState'] as String;
final created = List<String>.from(changes['created'] as List? ?? []);
final updated = List<String>.from(changes['updated'] as List? ?? []);
final destroyed = List<String>.from(changes['destroyed'] as List? ?? []);
// Fetch details for created + updated mailboxes
final toFetch = [...created, ...updated];
if (toFetch.isNotEmpty) {
final getResponses = await jmap.call([
[
'Mailbox/get',
{'accountId': jmap.accountId, 'ids': toFetch},
'1',
]
]);
final getResult = _responseArgs(getResponses, 0, 'Mailbox/get');
await _upsertJmapMailboxes(accountId, getResult['list'] as List<dynamic>);
}
// Remove destroyed mailboxes
for (final jmapId in destroyed) {
await (_db.delete(_db.mailboxes)
..where((t) => t.id.equals('$accountId:$jmapId')))
.go();
}
await _saveSyncState(accountId, 'Mailbox', newState);
log('JMAP incremental mailbox sync: +${created.length} '
'~${updated.length} -${destroyed.length}, state=$newState');
}
Future<void> _upsertJmapMailboxes(
String accountId,
List<dynamic> mailboxes,
) async {
for (final mb in mailboxes) {
final m = mb as Map<String, dynamic>;
final jmapId = m['id'] as String;
final dbId = '$accountId:$jmapId';
// For JMAP accounts, path stores the JMAP mailbox ID so that
// Email rows can reference it via mailboxPath.
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: dbId,
accountId: accountId,
path: jmapId,
name: m['name'] as String? ?? jmapId,
unreadCount: Value((m['unreadEmails'] as int?) ?? 0),
totalCount: Value((m['totalEmails'] as int?) ?? 0),
role: Value(m['role'] as String?),
),
);
}
}
// ── sync_state helpers ────────────────────────────────────────────────────
Future<String?> _loadSyncState(String accountId, String resourceType) async {
final row = await (_db.select(_db.syncStates)
..where(
(t) =>
t.accountId.equals(accountId) &
t.resourceType.equals(resourceType),
))
.getSingleOrNull();
return row?.state;
}
Future<void> _saveSyncState(
String accountId,
String resourceType,
String state,
) async {
await _db.into(_db.syncStates).insertOnConflictUpdate(
SyncStatesCompanion.insert(
accountId: accountId,
resourceType: resourceType,
state: state,
syncedAt: DateTime.now(),
),
);
}
// ── helpers ───────────────────────────────────────────────────────────────
/// Extracts the argument map from a methodResponse at [index].
/// Throws [JmapException] if the response is an error.
Map<String, dynamic> _responseArgs(
List<dynamic> responses,
int index,
String expectedMethod,
) {
final triple = responses[index] as List<dynamic>;
final method = triple[0] as String;
if (method == 'error') {
final err = triple[1] as Map<String, dynamic>;
throw JmapException('$expectedMethod error: ${err['type']}');
}
return triple[1] as Map<String, dynamic>;
}
model.Mailbox _toModel(MailboxRow row) => model.Mailbox(
id: row.id,
accountId: row.accountId,
path: row.path,
name: row.name,
unreadCount: row.unreadCount,
totalCount: row.totalCount,
);
}