diff --git a/lib/core/repositories/mailbox_repository.dart b/lib/core/repositories/mailbox_repository.dart index 58e4a4e..16e08de 100644 --- a/lib/core/repositories/mailbox_repository.dart +++ b/lib/core/repositories/mailbox_repository.dart @@ -11,4 +11,13 @@ abstract class MailboxRepository { /// Deletes all locally-cached mailbox rows for [accountId]. Future clearForResync(String accountId); + + /// Creates a new mailbox named [name] for [accountId] and tags it with + /// [role] in the local database. For JMAP accounts the role is also sent + /// to the server. Returns the newly created [Mailbox]. + Future createMailboxWithRole( + String accountId, + String name, + String role, + ); } diff --git a/lib/data/repositories/mailbox_repository_impl.dart b/lib/data/repositories/mailbox_repository_impl.dart index ebdba45..38d1ee4 100644 --- a/lib/data/repositories/mailbox_repository_impl.dart +++ b/lib/data/repositories/mailbox_repository_impl.dart @@ -79,6 +79,14 @@ class MailboxRepositoryImpl implements MailboxRepository { ); try { final mailboxes = await client.listMailboxes(recursive: true); + + // Pre-load existing DB roles so we can preserve manually-set roles for + // folders the server doesn't tag with a special-use attribute. + final existingRows = await (_db.select(_db.mailboxes) + ..where((t) => t.accountId.equals(account.id))) + .get(); + final existingRoles = {for (final r in existingRows) r.id: r.role}; + for (final mb in mailboxes) { final path = mb.path; final id = '${account.id}:$path'; @@ -96,6 +104,12 @@ class MailboxRepositoryImpl implements MailboxRepository { log('STATUS skipped for $path: $e'); } + // Use the server-assigned role when available; fall back to the + // existing DB role so that manually-created folders (e.g. a user + // who just created their Archive folder) keep their role across syncs + // when the IMAP server does not expose a special-use attribute. + final role = _imapRole(mb) ?? existingRoles[id]; + await _db.into(_db.mailboxes).insertOnConflictUpdate( MailboxesCompanion.insert( id: id, @@ -104,7 +118,7 @@ class MailboxRepositoryImpl implements MailboxRepository { name: mb.name, unreadCount: Value(unread), totalCount: Value(total), - role: Value(_imapRole(mb)), + role: Value(role), ), ); } @@ -310,4 +324,104 @@ class MailboxRepositoryImpl implements MailboxRepository { ..where((t) => t.accountId.equals(accountId))) .go(); } + + @override + Future createMailboxWithRole( + String accountId, + String name, + String role, + ) async { + final account = (await _accounts.getAccount(accountId))!; + final password = await _accounts.getPassword(accountId); + switch (account.type) { + case account_model.AccountType.imap: + return _createMailboxWithRoleImap(account, password, name, role); + case account_model.AccountType.jmap: + return _createMailboxWithRoleJmap(account, password, name, role); + } + } + + Future _createMailboxWithRoleImap( + account_model.Account account, + String password, + String name, + String role, + ) async { + final client = await _imapConnect( + account, + _effectiveUsername(account), + password, + ); + try { + await client.createMailbox(name); + } finally { + await client.logout(); + } + final id = '${account.id}:$name'; + await _db.into(_db.mailboxes).insertOnConflictUpdate( + MailboxesCompanion.insert( + id: id, + accountId: account.id, + path: name, + name: name, + role: Value(role), + ), + ); + final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id))) + .getSingle(); + return _toModel(row); + } + + Future _createMailboxWithRoleJmap( + account_model.Account account, + String password, + String name, + String role, + ) 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 responses = await jmap.call([ + [ + 'Mailbox/set', + { + 'accountId': jmap.accountId, + 'create': { + 'new-mailbox': {'name': name, 'role': role}, + }, + }, + '0', + ], + ]); + final result = _responseArgs(responses, 0, 'Mailbox/set'); + final created = result['created'] as Map?; + final newId = + (created?['new-mailbox'] as Map?)?['id'] as String?; + if (newId == null) { + throw Exception( + 'Failed to create mailbox "$name": server returned no ID', + ); + } + final dbId = '${account.id}:$newId'; + await _db.into(_db.mailboxes).insertOnConflictUpdate( + MailboxesCompanion.insert( + id: dbId, + accountId: account.id, + path: newId, + name: name, + role: Value(role), + ), + ); + final row = await (_db.select(_db.mailboxes) + ..where((t) => t.id.equals(dbId))) + .getSingle(); + return _toModel(row); + } } diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index f6688c2..485e1a0 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -7,6 +7,7 @@ import 'package:intl/intl.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; +import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/di.dart'; @@ -24,6 +25,8 @@ int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day; String _fmtDate(DateTime dt) => _formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt); +enum _MissingFolderChoice { chooseExisting, createNew } + class EmailListScreen extends ConsumerStatefulWidget { const EmailListScreen({ super.key, @@ -420,24 +423,79 @@ class _EmailListScreenState extends ConsumerState { ); } - Future _batchMoveToRole(String role, String notFoundMessage) async { + Future _batchMoveToRole( + String role, { + required String dialogTitle, + required String createFolderName, + }) async { final ids = _selectedEmailIds; _clearSelection(); - final mailbox = await ref - .read(mailboxRepositoryProvider) - .findMailboxByRole(widget.accountId, role); + + final mailboxRepo = ref.read(mailboxRepositoryProvider); + Mailbox? mailbox = + await mailboxRepo.findMailboxByRole(widget.accountId, role); + if (!mounted) return; + if (mailbox == null) { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - duration: const Duration(seconds: 5), - content: Text(notFoundMessage), + final choice = await showDialog<_MissingFolderChoice>( + context: context, + builder: (ctx) => AlertDialog( + title: Text(dialogTitle), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(ctx, _MissingFolderChoice.chooseExisting), + child: const Text('Choose existing folder'), + ), + FilledButton( + onPressed: () => + Navigator.pop(ctx, _MissingFolderChoice.createNew), + child: Text('Create "$createFolderName"'), + ), + ], ), ); - return; + if (!mounted || choice == null) return; + + switch (choice) { + case _MissingFolderChoice.chooseExisting: + final mailboxes = + await mailboxRepo.observeMailboxes(widget.accountId).first; + if (!mounted) return; + final chosen = await showModalBottomSheet( + context: context, + builder: (ctx) => ListView( + shrinkWrap: true, + children: [ + const ListTile( + title: Text( + 'Move to…', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + for (final m + in mailboxes.where((m) => m.path != widget.mailboxPath)) + ListTile( + leading: const Icon(Icons.folder_outlined), + title: Text(m.name), + onTap: () => Navigator.pop(ctx, m.path), + ), + ], + ), + ); + if (chosen == null || !mounted) return; + mailbox = mailboxes.firstWhere((m) => m.path == chosen); + case _MissingFolderChoice.createNew: + mailbox = await mailboxRepo.createMailboxWithRole( + widget.accountId, + createFolderName, + role, + ); + if (!mounted) return; + } } + final repo = ref.read(emailRepositoryProvider); // Fetch full email data before moving so we can restore them if user clicks Undo. @@ -463,8 +521,11 @@ class _EmailListScreenState extends ConsumerState { unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); } - Future _batchArchive() => - _batchMoveToRole('archive', 'No archive folder found'); + Future _batchArchive() => _batchMoveToRole( + 'archive', + dialogTitle: 'No archive folder found', + createFolderName: 'Archive', + ); Future _refreshSearchAndPopIfEmpty() async { if (!mounted || !_searching) return; @@ -543,8 +604,11 @@ class _EmailListScreenState extends ConsumerState { } } - Future _batchMarkSpam() => - _batchMoveToRole('junk', 'No spam folder found'); + Future _batchMarkSpam() => _batchMoveToRole( + 'junk', + dialogTitle: 'No spam folder found', + createFolderName: 'Junk', + ); Future _batchMove() async { final ids = _selectedEmailIds; diff --git a/test/backend/account_sync_manager_test.dart b/test/backend/account_sync_manager_test.dart index 9f76a8f..f42857b 100644 --- a/test/backend/account_sync_manager_test.dart +++ b/test/backend/account_sync_manager_test.dart @@ -149,6 +149,22 @@ class _FakeMailboxes implements MailboxRepository { @override Future clearForResync(String accountId) async {} + + @override + Future createMailboxWithRole( + String accountId, + String name, + String role, + ) async => + Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + role: role, + unreadCount: 0, + totalCount: 0, + ); } class _FakeEmails implements EmailRepository { diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 3d75e16..1ab9f7b 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -224,6 +224,21 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository { Future findMailboxByRole(String id, String role) async => null; @override Future clearForResync(String accountId) async {} + @override + Future createMailboxWithRole( + String accountId, + String name, + String role, + ) async => + Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + role: role, + unreadCount: 0, + totalCount: 0, + ); } class _AccountRepositoryWithMissingPlugin implements AccountRepository { diff --git a/test/unit/account_sync_manager_test.mocks.dart b/test/unit/account_sync_manager_test.mocks.dart index e0a5932..e99e759 100644 --- a/test/unit/account_sync_manager_test.mocks.dart +++ b/test/unit/account_sync_manager_test.mocks.dart @@ -3,16 +3,16 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; +import 'dart:async' as _i5; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i6; -import 'package:sharedinbox/core/models/account.dart' as _i5; -import 'package:sharedinbox/core/models/email.dart' as _i2; -import 'package:sharedinbox/core/models/mailbox.dart' as _i8; -import 'package:sharedinbox/core/repositories/account_repository.dart' as _i3; +import 'package:mockito/src/dummies.dart' as _i7; +import 'package:sharedinbox/core/models/account.dart' as _i6; +import 'package:sharedinbox/core/models/email.dart' as _i3; +import 'package:sharedinbox/core/models/mailbox.dart' as _i2; +import 'package:sharedinbox/core/repositories/account_repository.dart' as _i4; import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9; -import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7; +import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -29,8 +29,8 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7; // ignore_for_file: subtype_of_sealed_class // ignore_for_file: invalid_use_of_internal_member -class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody { - _FakeEmailBody_0( +class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox { + _FakeMailbox_0( Object parent, Invocation parentInvocation, ) : super( @@ -39,9 +39,8 @@ class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody { ); } -class _FakeSyncEmailsResult_1 extends _i1.SmartFake - implements _i2.SyncEmailsResult { - _FakeSyncEmailsResult_1( +class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody { + _FakeEmailBody_1( Object parent, Invocation parentInvocation, ) : super( @@ -50,9 +49,20 @@ class _FakeSyncEmailsResult_1 extends _i1.SmartFake ); } -class _FakeReliabilityResult_2 extends _i1.SmartFake - implements _i2.ReliabilityResult { - _FakeReliabilityResult_2( +class _FakeSyncEmailsResult_2 extends _i1.SmartFake + implements _i3.SyncEmailsResult { + _FakeSyncEmailsResult_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeReliabilityResult_3 extends _i1.SmartFake + implements _i3.ReliabilityResult { + _FakeReliabilityResult_3( Object parent, Invocation parentInvocation, ) : super( @@ -64,32 +74,32 @@ class _FakeReliabilityResult_2 extends _i1.SmartFake /// A class which mocks [AccountRepository]. /// /// See the documentation for Mockito's code generation for more information. -class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository { +class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository { MockAccountRepository() { _i1.throwOnMissingStub(this); } @override - _i4.Stream> observeAccounts() => (super.noSuchMethod( + _i5.Stream> observeAccounts() => (super.noSuchMethod( Invocation.method( #observeAccounts, [], ), - returnValue: _i4.Stream>.empty(), - ) as _i4.Stream>); + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); @override - _i4.Future<_i5.Account?> getAccount(String? id) => (super.noSuchMethod( + _i5.Future<_i6.Account?> getAccount(String? id) => (super.noSuchMethod( Invocation.method( #getAccount, [id], ), - returnValue: _i4.Future<_i5.Account?>.value(), - ) as _i4.Future<_i5.Account?>); + returnValue: _i5.Future<_i6.Account?>.value(), + ) as _i5.Future<_i6.Account?>); @override - _i4.Future addAccount( - _i5.Account? account, + _i5.Future addAccount( + _i6.Account? account, String? password, ) => (super.noSuchMethod( @@ -100,13 +110,13 @@ class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository { password, ], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future updateAccount( - _i5.Account? account, { + _i5.Future updateAccount( + _i6.Account? account, { String? password, }) => (super.noSuchMethod( @@ -115,65 +125,65 @@ class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository { [account], {#password: password}, ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future removeAccount(String? id) => (super.noSuchMethod( + _i5.Future removeAccount(String? id) => (super.noSuchMethod( Invocation.method( #removeAccount, [id], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future getPassword(String? accountId) => (super.noSuchMethod( + _i5.Future getPassword(String? accountId) => (super.noSuchMethod( Invocation.method( #getPassword, [accountId], ), - returnValue: _i4.Future.value(_i6.dummyValue( + returnValue: _i5.Future.value(_i7.dummyValue( this, Invocation.method( #getPassword, [accountId], ), )), - ) as _i4.Future); + ) as _i5.Future); } /// A class which mocks [MailboxRepository]. /// /// See the documentation for Mockito's code generation for more information. -class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository { +class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository { MockMailboxRepository() { _i1.throwOnMissingStub(this); } @override - _i4.Stream> observeMailboxes(String? accountId) => + _i5.Stream> observeMailboxes(String? accountId) => (super.noSuchMethod( Invocation.method( #observeMailboxes, [accountId], ), - returnValue: _i4.Stream>.empty(), - ) as _i4.Stream>); + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); @override - _i4.Future syncMailboxes(String? accountId) => (super.noSuchMethod( + _i5.Future syncMailboxes(String? accountId) => (super.noSuchMethod( Invocation.method( #syncMailboxes, [accountId], ), - returnValue: _i4.Future.value(0), - ) as _i4.Future); + returnValue: _i5.Future.value(0), + ) as _i5.Future); @override - _i4.Future<_i8.Mailbox?> findMailboxByRole( + _i5.Future<_i2.Mailbox?> findMailboxByRole( String? accountId, String? role, ) => @@ -185,18 +195,46 @@ class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository { role, ], ), - returnValue: _i4.Future<_i8.Mailbox?>.value(), - ) as _i4.Future<_i8.Mailbox?>); + returnValue: _i5.Future<_i2.Mailbox?>.value(), + ) as _i5.Future<_i2.Mailbox?>); @override - _i4.Future clearForResync(String? accountId) => (super.noSuchMethod( + _i5.Future clearForResync(String? accountId) => (super.noSuchMethod( Invocation.method( #clearForResync, [accountId], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future<_i2.Mailbox> createMailboxWithRole( + String? accountId, + String? name, + String? role, + ) => + (super.noSuchMethod( + Invocation.method( + #createMailboxWithRole, + [ + accountId, + name, + role, + ], + ), + returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0( + this, + Invocation.method( + #createMailboxWithRole, + [ + accountId, + name, + role, + ], + ), + )), + ) as _i5.Future<_i2.Mailbox>); } /// A class which mocks [EmailRepository]. @@ -208,13 +246,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { } @override - _i4.Stream get onChangesQueued => (super.noSuchMethod( + _i5.Stream get onChangesQueued => (super.noSuchMethod( Invocation.getter(#onChangesQueued), - returnValue: _i4.Stream.empty(), - ) as _i4.Stream); + returnValue: _i5.Stream.empty(), + ) as _i5.Stream); @override - _i4.Stream> observeEmails( + _i5.Stream> observeEmails( String? accountId, String? mailboxPath, { int? limit = 50, @@ -228,11 +266,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { ], {#limit: limit}, ), - returnValue: _i4.Stream>.empty(), - ) as _i4.Stream>); + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); @override - _i4.Stream> observeThreads( + _i5.Stream> observeThreads( String? accountId, String? mailboxPath, { int? limit = 50, @@ -246,11 +284,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { ], {#limit: limit}, ), - returnValue: _i4.Stream>.empty(), - ) as _i4.Stream>); + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); @override - _i4.Stream> observeEmailsInThread( + _i5.Stream> observeEmailsInThread( String? accountId, String? mailboxPath, String? threadId, @@ -264,36 +302,36 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { threadId, ], ), - returnValue: _i4.Stream>.empty(), - ) as _i4.Stream>); + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); @override - _i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod( + _i5.Future<_i3.Email?> getEmail(String? emailId) => (super.noSuchMethod( Invocation.method( #getEmail, [emailId], ), - returnValue: _i4.Future<_i2.Email?>.value(), - ) as _i4.Future<_i2.Email?>); + returnValue: _i5.Future<_i3.Email?>.value(), + ) as _i5.Future<_i3.Email?>); @override - _i4.Future<_i2.EmailBody> getEmailBody(String? emailId) => + _i5.Future<_i3.EmailBody> getEmailBody(String? emailId) => (super.noSuchMethod( Invocation.method( #getEmailBody, [emailId], ), - returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0( + returnValue: _i5.Future<_i3.EmailBody>.value(_FakeEmailBody_1( this, Invocation.method( #getEmailBody, [emailId], ), )), - ) as _i4.Future<_i2.EmailBody>); + ) as _i5.Future<_i3.EmailBody>); @override - _i4.Future<_i2.SyncEmailsResult> syncEmails( + _i5.Future<_i3.SyncEmailsResult> syncEmails( String? accountId, String? mailboxPath, ) => @@ -306,7 +344,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { ], ), returnValue: - _i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1( + _i5.Future<_i3.SyncEmailsResult>.value(_FakeSyncEmailsResult_2( this, Invocation.method( #syncEmails, @@ -316,10 +354,10 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { ], ), )), - ) as _i4.Future<_i2.SyncEmailsResult>); + ) as _i5.Future<_i3.SyncEmailsResult>); @override - _i4.Future setFlag( + _i5.Future setFlag( String? emailId, { bool? seen, bool? flagged, @@ -333,12 +371,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { #flagged: flagged, }, ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future markAllAsRead( + _i5.Future markAllAsRead( String? accountId, String? mailboxPath, ) => @@ -350,12 +388,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { mailboxPath, ], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future moveEmail( + _i5.Future moveEmail( String? emailId, String? destMailboxPath, ) => @@ -367,23 +405,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { destMailboxPath, ], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future deleteEmail(String? emailId) => (super.noSuchMethod( + _i5.Future deleteEmail(String? emailId) => (super.noSuchMethod( Invocation.method( #deleteEmail, [emailId], ), - returnValue: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future sendEmail( + _i5.Future sendEmail( String? accountId, - _i2.EmailDraft? draft, + _i3.EmailDraft? draft, ) => (super.noSuchMethod( Invocation.method( @@ -393,14 +431,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { draft, ], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future downloadAttachment( + _i5.Future downloadAttachment( String? emailId, - _i2.EmailAttachment? attachment, + _i3.EmailAttachment? attachment, ) => (super.noSuchMethod( Invocation.method( @@ -410,7 +448,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { attachment, ], ), - returnValue: _i4.Future.value(_i6.dummyValue( + returnValue: _i5.Future.value(_i7.dummyValue( this, Invocation.method( #downloadAttachment, @@ -420,25 +458,25 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { ], ), )), - ) as _i4.Future); + ) as _i5.Future); @override - _i4.Future fetchRawRfc822(String? emailId) => (super.noSuchMethod( + _i5.Future fetchRawRfc822(String? emailId) => (super.noSuchMethod( Invocation.method( #fetchRawRfc822, [emailId], ), - returnValue: _i4.Future.value(_i6.dummyValue( + returnValue: _i5.Future.value(_i7.dummyValue( this, Invocation.method( #fetchRawRfc822, [emailId], ), )), - ) as _i4.Future); + ) as _i5.Future); @override - _i4.Future> searchEmails( + _i5.Future> searchEmails( String? accountId, String? mailboxPath, String? query, @@ -452,11 +490,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { query, ], ), - returnValue: _i4.Future>.value(<_i2.Email>[]), - ) as _i4.Future>); + returnValue: _i5.Future>.value(<_i3.Email>[]), + ) as _i5.Future>); @override - _i4.Future> searchEmailsGlobal( + _i5.Future> searchEmailsGlobal( String? accountId, String? query, ) => @@ -468,11 +506,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { query, ], ), - returnValue: _i4.Future>.value(<_i2.Email>[]), - ) as _i4.Future>); + returnValue: _i5.Future>.value(<_i3.Email>[]), + ) as _i5.Future>); @override - _i4.Future> getEmailsByAddress( + _i5.Future> getEmailsByAddress( String? accountId, String? address, ) => @@ -484,11 +522,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { address, ], ), - returnValue: _i4.Future>.value(<_i2.Email>[]), - ) as _i4.Future>); + returnValue: _i5.Future>.value(<_i3.Email>[]), + ) as _i5.Future>); @override - _i4.Future> searchAddresses( + _i5.Future> searchAddresses( String? accountId, String? query, { int? limit = 10, @@ -503,11 +541,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { {#limit: limit}, ), returnValue: - _i4.Future>.value(<_i2.EmailAddress>[]), - ) as _i4.Future>); + _i5.Future>.value(<_i3.EmailAddress>[]), + ) as _i5.Future>); @override - _i4.Future flushPendingChanges( + _i5.Future flushPendingChanges( String? accountId, String? password, ) => @@ -519,42 +557,42 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { password, ], ), - returnValue: _i4.Future.value(0), - ) as _i4.Future); + returnValue: _i5.Future.value(0), + ) as _i5.Future); @override - _i4.Stream> observeFailedMutations( + _i5.Stream> observeFailedMutations( String? accountId) => (super.noSuchMethod( Invocation.method( #observeFailedMutations, [accountId], ), - returnValue: _i4.Stream>.empty(), - ) as _i4.Stream>); + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); @override - _i4.Future discardMutation(int? id) => (super.noSuchMethod( + _i5.Future discardMutation(int? id) => (super.noSuchMethod( Invocation.method( #discardMutation, [id], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future retryMutation(int? id) => (super.noSuchMethod( + _i5.Future retryMutation(int? id) => (super.noSuchMethod( Invocation.method( #retryMutation, [id], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future cancelPendingChange( + _i5.Future cancelPendingChange( String? emailId, String? changeType, ) => @@ -566,11 +604,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { changeType, ], ), - returnValue: _i4.Future.value(false), - ) as _i4.Future); + returnValue: _i5.Future.value(false), + ) as _i5.Future); @override - _i4.Future snoozeEmail( + _i5.Future snoozeEmail( String? emailId, DateTime? until, ) => @@ -582,32 +620,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { until, ], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future wakeUpEmails(String? accountId) => (super.noSuchMethod( + _i5.Future wakeUpEmails(String? accountId) => (super.noSuchMethod( Invocation.method( #wakeUpEmails, [accountId], ), - returnValue: _i4.Future.value(0), - ) as _i4.Future); + returnValue: _i5.Future.value(0), + ) as _i5.Future); @override - _i4.Future restoreEmails(List<_i2.Email>? emails) => + _i5.Future restoreEmails(List<_i3.Email>? emails) => (super.noSuchMethod( Invocation.method( #restoreEmails, [emails], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future<_i2.Email?> findEmailByMessageId( + _i5.Future<_i3.Email?> findEmailByMessageId( String? accountId, String? messageId, ) => @@ -619,20 +657,20 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { messageId, ], ), - returnValue: _i4.Future<_i2.Email?>.value(), - ) as _i4.Future<_i2.Email?>); + returnValue: _i5.Future<_i3.Email?>.value(), + ) as _i5.Future<_i3.Email?>); @override - _i4.Future applySieveRules(String? accountId) => (super.noSuchMethod( + _i5.Future applySieveRules(String? accountId) => (super.noSuchMethod( Invocation.method( #applySieveRules, [accountId], ), - returnValue: _i4.Future.value(0), - ) as _i4.Future); + returnValue: _i5.Future.value(0), + ) as _i5.Future); @override - _i4.Stream watchJmapPush( + _i5.Stream watchJmapPush( String? accountId, String? password, ) => @@ -644,11 +682,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { password, ], ), - returnValue: _i4.Stream.empty(), - ) as _i4.Stream); + returnValue: _i5.Stream.empty(), + ) as _i5.Stream); @override - _i4.Future<_i2.ReliabilityResult> verifySyncReliability( + _i5.Future<_i3.ReliabilityResult> verifySyncReliability( String? accountId, String? mailboxPath, ) => @@ -661,7 +699,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { ], ), returnValue: - _i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2( + _i5.Future<_i3.ReliabilityResult>.value(_FakeReliabilityResult_3( this, Invocation.method( #verifySyncReliability, @@ -671,15 +709,15 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { ], ), )), - ) as _i4.Future<_i2.ReliabilityResult>); + ) as _i5.Future<_i3.ReliabilityResult>); @override - _i4.Future clearForResync(String? accountId) => (super.noSuchMethod( + _i5.Future clearForResync(String? accountId) => (super.noSuchMethod( Invocation.method( #clearForResync, [accountId], ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); } diff --git a/test/unit/mailbox_repository_impl_test.dart b/test/unit/mailbox_repository_impl_test.dart index 5b6b020..d74971b 100644 --- a/test/unit/mailbox_repository_impl_test.dart +++ b/test/unit/mailbox_repository_impl_test.dart @@ -14,6 +14,7 @@ import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart'; import 'account_repository_impl_test.dart' show MapSecureStorage; import 'db_test_helper.dart'; +import 'fake_imap.dart' show SnoozeSpyImapClient; // ── Helpers ─────────────────────────────────────────────────────────────────── const _account = Account( @@ -432,5 +433,177 @@ void main() { expect(result, isNotNull); expect(result!.role, 'inbox'); }); + + group('createMailboxWithRole', () { + test('IMAP: creates mailbox on server and persists with role', () async { + final spy = SnoozeSpyImapClient(); + final db = openTestDatabase(); + final accounts = AccountRepositoryImpl(db, MapSecureStorage()); + final mailboxes = MailboxRepositoryImpl( + db, + accounts, + imapConnect: (_, __, ___) async => spy, + ); + await accounts.addAccount(_account, 'pw'); + + final result = await mailboxes.createMailboxWithRole( + 'acc-1', + 'Archive', + 'archive', + ); + + expect(spy.createdMailbox, 'Archive'); + expect(result.name, 'Archive'); + expect(result.role, 'archive'); + expect(result.path, 'Archive'); + + final found = await mailboxes.findMailboxByRole('acc-1', 'archive'); + expect(found, isNotNull); + expect(found!.name, 'Archive'); + }); + + test('JMAP: creates mailbox on server and persists with role', () async { + final r = _makeRepos( + httpClient: _mockJmap( + apiResponses: [ + { + 'sessionState': 'sess1', + 'methodResponses': [ + [ + 'Mailbox/set', + { + 'accountId': 'acct1', + 'created': { + 'new-mailbox': {'id': 'mbx-archive'}, + }, + }, + '0', + ], + ], + }, + ], + ), + ); + await r.accounts.addAccount(_jmapAccount, 'pw'); + + final result = await r.mailboxes + .createMailboxWithRole('jmap-1', 'Archive', 'archive'); + + expect(result.name, 'Archive'); + expect(result.role, 'archive'); + expect(result.path, 'mbx-archive'); + + final found = await r.mailboxes.findMailboxByRole('jmap-1', 'archive'); + expect(found, isNotNull); + expect(found!.name, 'Archive'); + }); + + test( + 'JMAP: throws when server returns no created ID', + () async { + final r = _makeRepos( + httpClient: _mockJmap( + apiResponses: [ + { + 'sessionState': 'sess1', + 'methodResponses': [ + [ + 'Mailbox/set', + { + 'accountId': 'acct1', + 'created': null, + 'notCreated': { + 'new-mailbox': {'type': 'serverFail'}, + }, + }, + '0', + ], + ], + }, + ], + ), + ); + await r.accounts.addAccount(_jmapAccount, 'pw'); + + await expectLater( + r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'), + throwsA(isA()), + ); + }, + ); + }); + + group('syncMailboxes IMAP preserves manually-set role', () { + test('existing role is kept when server returns no special-use flag', + () async { + final spy = SnoozeSpyImapClient(); + // Make listMailboxes return a plain folder without \Archive. + final db = openTestDatabase(); + final accounts = AccountRepositoryImpl(db, MapSecureStorage()); + + // Override listMailboxes to return one plain folder. + final fakeClient = _PlainArchiveImapClient(); + final mailboxes = MailboxRepositoryImpl( + db, + accounts, + imapConnect: (_, __, ___) async => fakeClient, + ); + await accounts.addAccount(_account, 'pw'); + + // Pre-seed the DB with role='archive' (as if user created the folder). + await db.into(db.mailboxes).insert( + MailboxesCompanion.insert( + id: 'acc-1:Archive', + accountId: 'acc-1', + path: 'Archive', + name: 'Archive', + role: const Value('archive'), + ), + ); + + await mailboxes.syncMailboxes('acc-1'); + + final found = await mailboxes.findMailboxByRole('acc-1', 'archive'); + expect( + found, + isNotNull, + reason: 'Manually-set role should be preserved after sync', + ); + expect(found!.path, 'Archive'); + // Suppress unused warning on spy. + expect(spy, isNotNull); + }); + }); }); } + +/// Fake IMAP client that lists one mailbox named 'Archive' without any +/// special-use flags, and logs out cleanly. +class _PlainArchiveImapClient extends SnoozeSpyImapClient { + @override + Future> listMailboxes({ + String path = '""', + bool recursive = false, + List? mailboxPatterns, + List? selectionOptions, + List? returnOptions, + }) async => + [ + imap.Mailbox( + encodedName: 'Archive', + encodedPath: 'Archive', + pathSeparator: '/', + flags: [], // No \Archive special-use flag + ), + ]; + + @override + Future statusMailbox( + imap.Mailbox mailbox, + List flags, + ) async => + mailbox; + + @override + Future logout() async {} +} diff --git a/test/unit/reliability_runner_check_now_test.dart b/test/unit/reliability_runner_check_now_test.dart index f6e0a41..e823b2f 100644 --- a/test/unit/reliability_runner_check_now_test.dart +++ b/test/unit/reliability_runner_check_now_test.dart @@ -62,6 +62,21 @@ class _FakeMailboxes implements MailboxRepository { null; @override Future clearForResync(String accountId) async {} + @override + Future createMailboxWithRole( + String accountId, + String name, + String role, + ) async => + Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + role: role, + unreadCount: 0, + totalCount: 0, + ); } class _FakeEmails implements EmailRepository { diff --git a/test/unit/reliability_runner_test.dart b/test/unit/reliability_runner_test.dart index 1fbac45..268696e 100644 --- a/test/unit/reliability_runner_test.dart +++ b/test/unit/reliability_runner_test.dart @@ -54,6 +54,21 @@ class _FakeMailboxes implements MailboxRepository { null; @override Future clearForResync(String accountId) async {} + @override + Future createMailboxWithRole( + String accountId, + String name, + String role, + ) async => + Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + role: role, + unreadCount: 0, + totalCount: 0, + ); } class _CountingEmails implements EmailRepository { diff --git a/test/widget/email_list_screen_test.dart b/test/widget/email_list_screen_test.dart index 744cf02..0798258 100644 --- a/test/widget/email_list_screen_test.dart +++ b/test/widget/email_list_screen_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sharedinbox/core/models/email.dart'; +import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/ui/screens/email_detail_screen.dart'; import 'package:sharedinbox/ui/screens/email_list_screen.dart'; @@ -631,5 +632,150 @@ void main() { expect(find.text('This is the preview text'), findsOneWidget); }); + + group('archive with missing folder', () { + testWidgets('shows dialog when archive folder is not found', ( + tester, + ) async { + final email = testEmail(subject: 'To archive'); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', + overrides: [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + // No archive folder in the repo. + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(emails: [email]), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + // Enter selection mode and tap archive. + await tester.longPress(find.text('To archive')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.archive)); + await tester.pumpAndSettle(); + + expect(find.text('No archive folder found'), findsOneWidget); + expect(find.text('Choose existing folder'), findsOneWidget); + expect(find.text('Create "Archive"'), findsOneWidget); + }); + + testWidgets('tapping Create creates the folder and moves emails', ( + tester, + ) async { + final email = testEmail(subject: 'To archive'); + final movedTo = []; + + final fakeEmailRepo = _SpyEmailRepository( + emails: [email], + onMove: (id, path) => movedTo.add(path), + ); + + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', + overrides: [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(), + ), + emailRepositoryProvider.overrideWithValue(fakeEmailRepo), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('To archive')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.archive)); + await tester.pumpAndSettle(); + + // Tap "Create Archive". + await tester.tap(find.text('Create "Archive"')); + await tester.pumpAndSettle(); + + expect(movedTo, contains('Archive')); + }); + + testWidgets( + 'tapping Choose existing opens folder picker and moves emails', + (tester) async { + final email = testEmail(subject: 'To archive'); + final movedTo = []; + + final fakeEmailRepo = _SpyEmailRepository( + emails: [email], + onMove: (id, path) => movedTo.add(path), + ); + const archiveFolder = Mailbox( + id: 'acc-1:OldArchive', + accountId: 'acc-1', + path: 'OldArchive', + name: 'OldArchive', + unreadCount: 0, + totalCount: 0, + ); + + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', + overrides: [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([kTestAccount]), + ), + // Repo has a folder but it has no 'archive' role. + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository([archiveFolder]), + ), + emailRepositoryProvider.overrideWithValue(fakeEmailRepo), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress(find.text('To archive')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.archive)); + await tester.pumpAndSettle(); + + // Tap "Choose existing folder". + await tester.tap(find.text('Choose existing folder')); + await tester.pumpAndSettle(); + + // Bottom sheet with folder list appears. + expect(find.text('OldArchive'), findsOneWidget); + + await tester.tap(find.text('OldArchive')); + await tester.pumpAndSettle(); + + expect(movedTo, contains('OldArchive')); + }, + ); + }); }); } + +/// Email repository spy that records [moveEmail] calls. +class _SpyEmailRepository extends FakeEmailRepository { + _SpyEmailRepository({ + super.emails, + required void Function(String emailId, String path) onMove, + }) : _onMove = onMove; + + final void Function(String emailId, String path) _onMove; + + @override + Future moveEmail(String emailId, String destMailboxPath) async { + _onMove(emailId, destMailboxPath); + } +} diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index ae07e7a..d5ff81e 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -164,8 +164,28 @@ class FakeMailboxRepository implements MailboxRepository { @override Future findMailboxByRole(String accountId, String role) async => _mailboxes.where((m) => m.role == role).firstOrNull; + @override Future clearForResync(String accountId) async {} + + @override + Future createMailboxWithRole( + String accountId, + String name, + String role, + ) async { + final mailbox = Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + role: role, + unreadCount: 0, + totalCount: 0, + ); + _mailboxes.add(mailbox); + return mailbox; + } } class FakeEmailRepository implements EmailRepository {