feat(U2): sync local drafts with IMAP Drafts folder

- Add imapServerId column (schema v24) to Drafts table; guard migration
  with from>=4 since createTable already uses latest schema
- Extend SavedDraft model and DraftRepository interface with syncDrafts()
- Implement syncDrafts in DraftRepositoryImpl: create/select Drafts
  folder, upload local-only drafts via APPEND, import server drafts not
  tracked locally
- Wire AccountRepository and ImapConnectFn into DraftRepositoryImpl via
  draftRepositoryProvider
- Call syncDrafts from AccountSyncManager._AccountSync._sync() so each
  IMAP sync cycle also syncs the Drafts folder
- Add syncDrafts stub to FakeDraftRepository; update unit test
  constructor calls with _StubAccounts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas SharedInbox
2026-05-14 00:23:54 +02:00
co-authored by Claude Sonnet 4.6
parent 7421855922
commit fe154accea
10 changed files with 204 additions and 12 deletions
+2
View File
@@ -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,
});
}
@@ -21,4 +21,10 @@ abstract class DraftRepository {
/// Permanently removes the draft with [id].
Future<void> 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<void> syncDrafts(String accountId, String password);
}
+11 -1
View File
@@ -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<String, _SyncLoop> _active = {};
StreamSubscription<List<Account>>? _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);
+6 -1
View File
@@ -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);
}
},
);
}
@@ -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<SavedDraft> saveDraft({
@@ -95,6 +105,110 @@ class DraftRepositoryImpl implements DraftRepository {
await (_db.delete(_db.drafts)..where((t) => t.id.equals(id))).go();
}
@override
Future<void> 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<void> _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<imap.MailAddress> _parseAddresses(String text) {
if (text.trim().isEmpty) return [];
return text.split(',').map((s) => imap.MailAddress('', s.trim())).toList();
}
String _addressListToText(List<imap.MailAddress>? 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,
);
}
+6 -1
View File
@@ -65,7 +65,11 @@ final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
});
final draftRepositoryProvider = Provider<DraftRepository>((ref) {
return DraftRepositoryImpl(ref.watch(dbProvider));
return DraftRepositoryImpl(
ref.watch(dbProvider),
ref.watch(accountRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider),
);
});
final emailRepositoryProvider = Provider<EmailRepository>((ref) {
@@ -117,6 +121,7 @@ final syncManagerProvider = Provider<AccountSyncManager>((ref) {
ref.watch(emailRepositoryProvider),
syncLog: ref.watch(syncLogRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider),
drafts: ref.watch(draftRepositoryProvider),
);
ref.onDispose(manager.dispose);
return manager;
@@ -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<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
}
/// 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<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
}
+24 -8
View File
@@ -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<List<Account>> observeAccounts() => const Stream.empty();
@override
Future<Account?> getAccount(String id) async => null;
@override
Future<void> addAccount(Account account, String password) async {}
@override
Future<void> updateAccount(Account account, {String? password}) async {}
@override
Future<void> removeAccount(String id) async {}
@override
Future<String> 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: '',
+10
View File
@@ -452,6 +452,16 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
),
)),
) as _i4.Future<_i2.ReliabilityResult>);
@override
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
}
/// A class which mocks [UndoRepository].
+3
View File
@@ -114,6 +114,9 @@ class FakeDraftRepository implements DraftRepository {
@override
Future<void> deleteDraft(int id) async => _drafts.remove(id);
@override
Future<void> syncDrafts(String accountId, String password) async {}
}
class FakeMailboxRepository implements MailboxRepository {