From 0e291b509b66aae516e799238054815e98ec7c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Thu, 14 May 2026 00:27:47 +0200 Subject: [PATCH] feat(U2): sync local drafts with IMAP Drafts folder (#27) --- lib/core/models/draft.dart | 2 + lib/core/repositories/draft_repository.dart | 6 + lib/core/sync/account_sync_manager.dart | 12 +- lib/data/db/database.dart | 7 +- .../repositories/draft_repository_impl.dart | 117 +++++++++++++++++- lib/di.dart | 7 +- .../unit/account_sync_manager_test.mocks.dart | 20 +++ test/unit/draft_repository_impl_test.dart | 32 +++-- test/unit/undo_service_test.mocks.dart | 10 ++ test/widget/helpers.dart | 3 + 10 files changed, 204 insertions(+), 12 deletions(-) diff --git a/lib/core/models/draft.dart b/lib/core/models/draft.dart index 4570574..874794e 100644 --- a/lib/core/models/draft.dart +++ b/lib/core/models/draft.dart @@ -7,6 +7,7 @@ class SavedDraft { final String subjectText; final String bodyText; final DateTime updatedAt; + final String? imapServerId; const SavedDraft({ required this.id, @@ -17,5 +18,6 @@ class SavedDraft { required this.subjectText, required this.bodyText, required this.updatedAt, + this.imapServerId, }); } diff --git a/lib/core/repositories/draft_repository.dart b/lib/core/repositories/draft_repository.dart index e7cec78..6d26fa1 100644 --- a/lib/core/repositories/draft_repository.dart +++ b/lib/core/repositories/draft_repository.dart @@ -21,4 +21,10 @@ abstract class DraftRepository { /// Permanently removes the draft with [id]. Future deleteDraft(int id); + + /// Syncs local drafts with the server IMAP Drafts folder for [accountId]. + /// Uploads local drafts that have no [SavedDraft.imapServerId]; imports + /// server drafts that are not already tracked locally. + /// No-op when the implementation has no IMAP connection configured. + Future syncDrafts(String accountId, String password); } diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 5cf87c0..4f74022 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -4,6 +4,7 @@ import 'package:enough_mail/enough_mail.dart' as imap; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult; 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/sync_log_repository.dart'; @@ -22,14 +23,17 @@ class AccountSyncManager { this._emails, { ImapConnectFn imapConnect = connectImap, SyncLogRepository syncLog = const NoOpSyncLogRepository(), + DraftRepository? drafts, }) : _imapConnect = imapConnect, - _syncLog = syncLog; + _syncLog = syncLog, + _drafts = drafts; final AccountRepository _accounts; final MailboxRepository _mailboxes; final EmailRepository _emails; final ImapConnectFn _imapConnect; final SyncLogRepository _syncLog; + final DraftRepository? _drafts; final Map _active = {}; StreamSubscription>? _accountsSub; @@ -53,6 +57,7 @@ class AccountSyncManager { _emails, _imapConnect, _syncLog, + _drafts, ), AccountType.jmap => _JmapAccountSync( account, @@ -113,6 +118,7 @@ class AccountSyncManager { _emails, _imapConnect, _syncLog, + _drafts, ), AccountType.jmap => _JmapAccountSync( account, @@ -145,6 +151,7 @@ class _AccountSync implements _SyncLoop { this._emails, this._imapConnect, this._syncLog, + this._drafts, ); final Account account; @@ -153,6 +160,7 @@ class _AccountSync implements _SyncLoop { final EmailRepository _emails; final ImapConnectFn _imapConnect; final SyncLogRepository _syncLog; + final DraftRepository? _drafts; imap.ImapClient? _idleClient; bool _running = false; @@ -279,6 +287,8 @@ class _AccountSync implements _SyncLoop { Future<_SyncStats> _sync() async { final password = await _accounts.getPassword(account.id); + await _drafts?.syncDrafts(account.id, password); + // Check for expired snoozes and move them back to Inbox before syncing. await _emails.wakeUpEmails(account.id); diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 16b4f7a..a83dc08 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -230,6 +230,8 @@ class Drafts extends Table { TextColumn get subjectText => text().withDefault(const Constant(''))(); TextColumn get bodyText => text().withDefault(const Constant(''))(); DateTimeColumn get updatedAt => dateTime()(); + // Added in schema v24: IMAP UID string ("mailbox:uid") on the server. + TextColumn get imapServerId => text().nullable()(); } @DataClassName('UndoActionRow') @@ -267,7 +269,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 23; + int get schemaVersion => 24; @override MigrationStrategy get migration => MigrationStrategy( @@ -426,6 +428,9 @@ class AppDatabase extends _$AppDatabase { if (from < 23) { await m.addColumn(emails, emails.listUnsubscribeHeader); } + if (from >= 4 && from < 24) { + await m.addColumn(drafts, drafts.imapServerId); + } }, ); } diff --git a/lib/data/repositories/draft_repository_impl.dart b/lib/data/repositories/draft_repository_impl.dart index 6abf875..162afa6 100644 --- a/lib/data/repositories/draft_repository_impl.dart +++ b/lib/data/repositories/draft_repository_impl.dart @@ -1,13 +1,23 @@ import 'package:drift/drift.dart'; +import 'package:enough_mail/enough_mail.dart' as imap; +import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/draft.dart'; +import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/data/db/database.dart'; +import 'package:sharedinbox/data/imap/imap_client_factory.dart'; class DraftRepositoryImpl implements DraftRepository { - DraftRepositoryImpl(this._db); + DraftRepositoryImpl( + this._db, + this._accounts, { + ImapConnectFn? imapConnect, + }) : _imapConnect = imapConnect; final AppDatabase _db; + final AccountRepository _accounts; + final ImapConnectFn? _imapConnect; @override Future saveDraft({ @@ -95,6 +105,110 @@ class DraftRepositoryImpl implements DraftRepository { await (_db.delete(_db.drafts)..where((t) => t.id.equals(id))).go(); } + @override + Future syncDrafts(String accountId, String password) async { + final connect = _imapConnect; + if (connect == null) return; + + final account = await _accounts.getAccount(accountId); + if (account == null || account.type != AccountType.imap) return; + + final username = + account.username.isNotEmpty ? account.username : account.email; + imap.ImapClient? client; + try { + client = await connect(account, username, password); + await _syncWithServer(client, accountId); + } finally { + await client?.logout(); + } + } + + Future _syncWithServer( + imap.ImapClient client, + String accountId, + ) async { + // Create/select the Drafts folder. + try { + await client.createMailbox('Drafts'); + } catch (_) { + // Already exists. + } + final selectResult = await client.selectMailboxByPath('Drafts'); + final messageCount = selectResult.messagesExists; + + // Upload local drafts that have no server counterpart. + final localDrafts = await (_db.select(_db.drafts) + ..where( + (t) => t.accountId.equals(accountId) & t.imapServerId.isNull(), + )) + .get(); + + for (final row in localDrafts) { + final builder = imap.MessageBuilder() + ..to = _parseAddresses(row.toText) + ..cc = _parseAddresses(row.ccText) + ..subject = row.subjectText + ..text = row.bodyText; + final mime = builder.buildMimeMessage(); + final appendResult = await client.appendMessage( + mime, + targetMailboxPath: 'Drafts', + flags: [r'\Draft'], + ); + final uidList = + appendResult.responseCodeAppendUid?.targetSequence.toList(); + final uid = (uidList != null && uidList.isNotEmpty) + ? uidList.first.toString() + : null; + if (uid != null) { + await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))) + .write(DraftsCompanion(imapServerId: Value(uid))); + } + } + + // Download server drafts not tracked locally. + if (messageCount > 0) { + final knownServerIds = await (_db.select(_db.drafts) + ..where( + (t) => t.accountId.equals(accountId) & t.imapServerId.isNotNull(), + )) + .get(); + final knownIds = knownServerIds.map((r) => r.imapServerId!).toSet(); + + final seq = imap.MessageSequence.fromAll(); + final fetch = await client.uidFetchMessages(seq, '(UID FLAGS ENVELOPE)'); + for (final msg in fetch.messages) { + final uid = msg.uid?.toString(); + if (uid == null || knownIds.contains(uid)) continue; + if (msg.flags?.contains(r'\Deleted') ?? false) continue; + final env = msg.envelope; + final now = DateTime.now(); + await _db.into(_db.drafts).insert( + DraftsCompanion.insert( + accountId: Value(accountId), + toText: Value(_addressListToText(env?.to)), + ccText: Value(_addressListToText(env?.cc)), + subjectText: Value(env?.subject ?? ''), + bodyText: const Value(''), + updatedAt: now, + imapServerId: Value(uid), + ), + ); + } + } + } + + List _parseAddresses(String text) { + if (text.trim().isEmpty) return []; + return text.split(',').map((s) => imap.MailAddress('', s.trim())).toList(); + } + + String _addressListToText(List? addresses) { + if (addresses == null || addresses.isEmpty) return ''; + return addresses.map((a) => a.email).join(', '); + } + SavedDraft _toModel(Draft row) => SavedDraft( id: row.id, accountId: row.accountId, @@ -104,5 +218,6 @@ class DraftRepositoryImpl implements DraftRepository { subjectText: row.subjectText, bodyText: row.bodyText, updatedAt: row.updatedAt, + imapServerId: row.imapServerId, ); } diff --git a/lib/di.dart b/lib/di.dart index 8082e90..79b7693 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -65,7 +65,11 @@ final mailboxRepositoryProvider = Provider((ref) { }); final draftRepositoryProvider = Provider((ref) { - return DraftRepositoryImpl(ref.watch(dbProvider)); + return DraftRepositoryImpl( + ref.watch(dbProvider), + ref.watch(accountRepositoryProvider), + imapConnect: ref.watch(imapConnectProvider), + ); }); final emailRepositoryProvider = Provider((ref) { @@ -117,6 +121,7 @@ final syncManagerProvider = Provider((ref) { ref.watch(emailRepositoryProvider), syncLog: ref.watch(syncLogRepositoryProvider), imapConnect: ref.watch(imapConnectProvider), + drafts: ref.watch(draftRepositoryProvider), ); ref.onDispose(manager.dispose); return manager; diff --git a/test/unit/account_sync_manager_test.mocks.dart b/test/unit/account_sync_manager_test.mocks.dart index 36784ed..54eda9b 100644 --- a/test/unit/account_sync_manager_test.mocks.dart +++ b/test/unit/account_sync_manager_test.mocks.dart @@ -187,6 +187,16 @@ class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository { ), returnValue: _i4.Future<_i8.Mailbox?>.value(), ) as _i4.Future<_i8.Mailbox?>); + + @override + _i4.Future clearForResync(String? accountId) => (super.noSuchMethod( + Invocation.method( + #clearForResync, + [accountId], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); } /// A class which mocks [EmailRepository]. @@ -582,4 +592,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { ), )), ) as _i4.Future<_i2.ReliabilityResult>); + + @override + _i4.Future clearForResync(String? accountId) => (super.noSuchMethod( + Invocation.method( + #clearForResync, + [accountId], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); } diff --git a/test/unit/draft_repository_impl_test.dart b/test/unit/draft_repository_impl_test.dart index 16558d9..bab09b7 100644 --- a/test/unit/draft_repository_impl_test.dart +++ b/test/unit/draft_repository_impl_test.dart @@ -1,9 +1,25 @@ 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/draft_repository_impl.dart'; import 'db_test_helper.dart'; +class _StubAccounts implements AccountRepository { + @override + Stream> observeAccounts() => const Stream.empty(); + @override + Future getAccount(String id) async => null; + @override + Future addAccount(Account account, String password) async {} + @override + Future updateAccount(Account account, {String? password}) async {} + @override + Future removeAccount(String id) async {} + @override + Future getPassword(String accountId) async => ''; +} + void main() { setUpAll(configureSqliteForTests); @@ -11,7 +27,7 @@ void main() { test( 'saveDraft creates a new row and returns it with a non-zero id', () async { - final repo = DraftRepositoryImpl(openTestDatabase()); + final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts()); final draft = await repo.saveDraft( toText: 'bob@example.com', ccText: '', @@ -25,7 +41,7 @@ void main() { ); test('saveDraft with id updates existing row', () async { - final repo = DraftRepositoryImpl(openTestDatabase()); + final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts()); final created = await repo.saveDraft( toText: 'a@example.com', ccText: '', @@ -47,19 +63,19 @@ void main() { }); test('getDraft returns null for unknown id', () async { - final repo = DraftRepositoryImpl(openTestDatabase()); + final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts()); expect(await repo.getDraft(99999), isNull); }); test('findDraft returns null when no draft exists', () async { - final repo = DraftRepositoryImpl(openTestDatabase()); + final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts()); expect(await repo.findDraft(), isNull); }); test( 'findDraft returns most recent draft for matching replyToEmailId', () async { - final repo = DraftRepositoryImpl(openTestDatabase()); + final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts()); await repo.saveDraft( replyToEmailId: 'email-1', toText: 'a@example.com', @@ -83,7 +99,7 @@ void main() { test( 'findDraft with null replyToEmailId finds new-message drafts', () async { - final repo = DraftRepositoryImpl(openTestDatabase()); + final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts()); // This draft is a reply and should NOT be returned. await repo.saveDraft( replyToEmailId: 'email-1', @@ -104,7 +120,7 @@ void main() { ); test('deleteDraft removes the row', () async { - final repo = DraftRepositoryImpl(openTestDatabase()); + final repo = DraftRepositoryImpl(openTestDatabase(), _StubAccounts()); final draft = await repo.saveDraft( toText: 'a@example.com', ccText: '', diff --git a/test/unit/undo_service_test.mocks.dart b/test/unit/undo_service_test.mocks.dart index b7341dd..1a8d35c 100644 --- a/test/unit/undo_service_test.mocks.dart +++ b/test/unit/undo_service_test.mocks.dart @@ -452,6 +452,16 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository { ), )), ) as _i4.Future<_i2.ReliabilityResult>); + + @override + _i4.Future clearForResync(String? accountId) => (super.noSuchMethod( + Invocation.method( + #clearForResync, + [accountId], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); } /// A class which mocks [UndoRepository]. diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 310152d..0cf3c0b 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -114,6 +114,9 @@ class FakeDraftRepository implements DraftRepository { @override Future deleteDraft(int id) async => _drafts.remove(id); + + @override + Future syncDrafts(String accountId, String password) async {} } class FakeMailboxRepository implements MailboxRepository {