From 1d038723a9eeace00e7992d4265c36bffd34e237 Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Mon, 21 Oct 2024 16:29:26 +0200 Subject: [PATCH 1/6] TF-3189 new option to enable/disable subaddressing for a personal folder --- assets/images/ic_subaddressing_allow.svg | 3 ++ assets/images/ic_subaddressing_disallow.svg | 8 +++ .../presentation/resources/image_paths.dart | 2 + .../upgrade_hive_database_steps_v13.dart | 17 ++++++ lib/features/caching/caching_manager.dart | 6 +++ .../caching/config/hive_cache_config.dart | 2 + .../data/datasource/mailbox_datasource.dart | 3 ++ .../mailbox_cache_datasource_impl.dart | 6 +++ .../mailbox_datasource_impl.dart | 8 +++ .../extensions/mailbox_cache_extension.dart | 3 +- .../data/extensions/mailbox_extension.dart | 3 +- .../mailbox/data/model/mailbox_cache.dart | 5 ++ .../mailbox/data/network/mailbox_api.dart | 53 ++++++++++++++++++ .../repository/mailbox_repository_impl.dart | 6 +++ .../domain/constants/mailbox_constants.dart | 3 +- .../null_session_or_accountid_exception.dart | 8 +++ .../set_mailbox_rights_exception.dart | 8 +++ .../domain/model/mailbox_right_request.dart | 23 ++++++++ .../model/mailbox_subaddressing_action.dart | 4 ++ .../domain/repository/mailbox_repository.dart | 3 ++ .../state/subaddressing_mailbox_state.dart | 37 +++++++++++++ .../usecases/subaddressing_interactor.dart | 36 +++++++++++++ .../presentation/mailbox_bindings.dart | 3 ++ .../presentation/mailbox_controller.dart | 50 +++++++++++++++++ .../mixin/mailbox_widget_mixin.dart | 4 ++ .../presentation/model/mailbox_actions.dart | 14 ++++- lib/l10n/intl_messages.arb | 30 +++++++++++ lib/main/localizations/app_localizations.dart | 31 +++++++++++ model/lib/extensions/mailbox_extension.dart | 3 ++ .../presentation_mailbox_extension.dart | 9 ++++ model/lib/mailbox/mailbox_constants.dart | 2 + model/lib/mailbox/mailbox_property.dart | 1 + model/lib/mailbox/presentation_mailbox.dart | 3 ++ .../update_rights_for_subaddressing_test.dart | 54 +++++++++++++++++++ .../mailbox_dashboard_controller_test.dart | 4 ++ .../mailbox_dashboard_view_widget_test.dart | 4 ++ 36 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 assets/images/ic_subaddressing_allow.svg create mode 100644 assets/images/ic_subaddressing_disallow.svg create mode 100644 lib/features/base/upgradeable/upgrade_hive_database_steps_v13.dart create mode 100644 lib/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart create mode 100644 lib/features/mailbox/domain/exceptions/set_mailbox_rights_exception.dart create mode 100644 lib/features/mailbox/domain/model/mailbox_right_request.dart create mode 100644 lib/features/mailbox/domain/model/mailbox_subaddressing_action.dart create mode 100644 lib/features/mailbox/domain/state/subaddressing_mailbox_state.dart create mode 100644 lib/features/mailbox/domain/usecases/subaddressing_interactor.dart create mode 100644 model/lib/mailbox/mailbox_constants.dart create mode 100644 test/features/mailbox/data/update_rights_for_subaddressing_test.dart diff --git a/assets/images/ic_subaddressing_allow.svg b/assets/images/ic_subaddressing_allow.svg new file mode 100644 index 0000000000..b9162f3b1e --- /dev/null +++ b/assets/images/ic_subaddressing_allow.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_subaddressing_disallow.svg b/assets/images/ic_subaddressing_disallow.svg new file mode 100644 index 0000000000..f3d19f3d34 --- /dev/null +++ b/assets/images/ic_subaddressing_disallow.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index 52b6013b14..d20c5d6058 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -221,6 +221,8 @@ class ImagePaths { String get icBadSignature => _getImagePath('ic_bad_signature.svg'); String get icDeleteSelection => _getImagePath('ic_delete_selection.svg'); String get icLogoTwakeWelcome => _getImagePath('ic_logo_twake_welcome.svg'); + String get icSubaddressingAllow => _getImagePath('ic_subaddressing_allow.svg'); + String get icSubaddressingDisallow => _getImagePath('ic_subaddressing_disallow.svg'); String _getImagePath(String imageName) { return AssetsPaths.images + imageName; diff --git a/lib/features/base/upgradeable/upgrade_hive_database_steps_v13.dart b/lib/features/base/upgradeable/upgrade_hive_database_steps_v13.dart new file mode 100644 index 0000000000..481b8ef406 --- /dev/null +++ b/lib/features/base/upgradeable/upgrade_hive_database_steps_v13.dart @@ -0,0 +1,17 @@ + +import 'package:tmail_ui_user/features/base/upgradeable/upgrade_database_steps.dart'; +import 'package:tmail_ui_user/features/caching/caching_manager.dart'; + +class UpgradeHiveDatabaseStepsV13 extends UpgradeDatabaseSteps { + + final CachingManager _cachingManager; + + UpgradeHiveDatabaseStepsV13(this._cachingManager); + + @override + Future onUpgrade(int oldVersion, int newVersion) async { + if (oldVersion > 0 && oldVersion < newVersion && newVersion == 13) { + await _cachingManager.clearMailboxCache(); + } + } +} \ No newline at end of file diff --git a/lib/features/caching/caching_manager.dart b/lib/features/caching/caching_manager.dart index 613751f07a..fb22bc36cd 100644 --- a/lib/features/caching/caching_manager.dart +++ b/lib/features/caching/caching_manager.dart @@ -118,6 +118,12 @@ class CachingManager { ], eagerError: true); } + Future clearMailboxCache() { + return Future.wait([ + _mailboxCacheClient.clearAllData(), + ], eagerError: true); + } + Future storeCacheVersion(int newVersion) async { log('CachingManager::storeCacheVersion():newVersion = $newVersion'); return _hiveCacheVersionClient.storeVersion(newVersion); diff --git a/lib/features/caching/config/hive_cache_config.dart b/lib/features/caching/config/hive_cache_config.dart index 7d4d2e56fb..5ae303e82d 100644 --- a/lib/features/caching/config/hive_cache_config.dart +++ b/lib/features/caching/config/hive_cache_config.dart @@ -9,6 +9,7 @@ import 'package:path_provider/path_provider.dart' as path_provider; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v10.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v11.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v12.dart'; +import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v13.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v7.dart'; import 'package:tmail_ui_user/features/caching/caching_manager.dart'; import 'package:tmail_ui_user/features/caching/config/cache_version.dart'; @@ -69,6 +70,7 @@ class HiveCacheConfig { await UpgradeHiveDatabaseStepsV10(cachingManager).onUpgrade(oldVersion, newVersion); await UpgradeHiveDatabaseStepsV11(cachingManager).onUpgrade(oldVersion, newVersion); await UpgradeHiveDatabaseStepsV12(cachingManager).onUpgrade(oldVersion, newVersion); + await UpgradeHiveDatabaseStepsV13(cachingManager).onUpgrade(oldVersion, newVersion); if (oldVersion != newVersion) { await cachingManager.storeCacheVersion(newVersion); diff --git a/lib/features/mailbox/data/datasource/mailbox_datasource.dart b/lib/features/mailbox/data/datasource/mailbox_datasource.dart index 4b9f30c7ef..dd781a6185 100644 --- a/lib/features/mailbox/data/datasource/mailbox_datasource.dart +++ b/lib/features/mailbox/data/datasource/mailbox_datasource.dart @@ -18,6 +18,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/model/get_mailbox_by_role_ import 'package:tmail_ui_user/features/mailbox/domain/model/jmap_mailbox_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; @@ -49,6 +50,8 @@ abstract class MailboxDataSource { Future> subscribeMultipleMailbox(Session session, AccountId accountId, SubscribeMultipleMailboxRequest subscribeRequest); + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request); + Future> createDefaultMailbox(Session session, AccountId accountId, List listRole); Future setRoleDefaultMailbox(Session session, AccountId accountId, List listMailbox); diff --git a/lib/features/mailbox/data/datasource_impl/mailbox_cache_datasource_impl.dart b/lib/features/mailbox/data/datasource_impl/mailbox_cache_datasource_impl.dart index b52e767b24..03cf6c6aff 100644 --- a/lib/features/mailbox/data/datasource_impl/mailbox_cache_datasource_impl.dart +++ b/lib/features/mailbox/data/datasource_impl/mailbox_cache_datasource_impl.dart @@ -21,6 +21,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/model/get_mailbox_by_role_ import 'package:tmail_ui_user/features/mailbox/domain/model/jmap_mailbox_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart'; @@ -97,6 +98,11 @@ class MailboxCacheDataSourceImpl extends MailboxDataSource { throw UnimplementedError(); } + @override + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request) { + throw UnimplementedError(); + } + @override Future> createDefaultMailbox(Session session, AccountId accountId, List listRole) { throw UnimplementedError(); diff --git a/lib/features/mailbox/data/datasource_impl/mailbox_datasource_impl.dart b/lib/features/mailbox/data/datasource_impl/mailbox_datasource_impl.dart index 062e7effa3..1dbb9c9f4d 100644 --- a/lib/features/mailbox/data/datasource_impl/mailbox_datasource_impl.dart +++ b/lib/features/mailbox/data/datasource_impl/mailbox_datasource_impl.dart @@ -22,6 +22,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/model/get_mailbox_by_role_ import 'package:tmail_ui_user/features/mailbox/domain/model/jmap_mailbox_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart'; @@ -121,6 +122,13 @@ class MailboxDataSourceImpl extends MailboxDataSource { }).catchError(_exceptionThrower.throwException); } + @override + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request) { + return Future.sync(() async { + return await mailboxAPI.handleMailboxRightRequest(session, accountId, request); + }).catchError(_exceptionThrower.throwException); + } + @override Future> createDefaultMailbox(Session session, AccountId accountId, List listRole) { return Future.sync(() async { diff --git a/lib/features/mailbox/data/extensions/mailbox_cache_extension.dart b/lib/features/mailbox/data/extensions/mailbox_cache_extension.dart index 84652e1126..594eb3e3b9 100644 --- a/lib/features/mailbox/data/extensions/mailbox_cache_extension.dart +++ b/lib/features/mailbox/data/extensions/mailbox_cache_extension.dart @@ -20,7 +20,8 @@ extension MailboxCacheExtension on MailboxCache { unreadThreads: unreadThreads != null ? UnreadThreads(UnsignedInt(unreadThreads!)) : null, myRights: myRights?.toMailboxRights(), isSubscribed: isSubscribed != null ? IsSubscribed(isSubscribed!) : null, - namespace: namespace != null ? Namespace(namespace!) : null + namespace: namespace != null ? Namespace(namespace!) : null, + rights: rights != null ? Map?>.from(rights!) : null, ); } } \ No newline at end of file diff --git a/lib/features/mailbox/data/extensions/mailbox_extension.dart b/lib/features/mailbox/data/extensions/mailbox_extension.dart index 6735803fe1..965d606a79 100644 --- a/lib/features/mailbox/data/extensions/mailbox_extension.dart +++ b/lib/features/mailbox/data/extensions/mailbox_extension.dart @@ -17,7 +17,8 @@ extension MailboxExtension on Mailbox { unreadThreads: unreadThreads?.value.value.round(), myRights: myRights?.toMailboxRightsCache(), isSubscribed: isSubscribed?.value, - namespace: namespace?.value + namespace: namespace?.value, + rights: rights?.map((key, value) => MapEntry(key, value)), ); } } \ No newline at end of file diff --git a/lib/features/mailbox/data/model/mailbox_cache.dart b/lib/features/mailbox/data/model/mailbox_cache.dart index 05fe3e005d..4463e7e450 100644 --- a/lib/features/mailbox/data/model/mailbox_cache.dart +++ b/lib/features/mailbox/data/model/mailbox_cache.dart @@ -48,6 +48,9 @@ class MailboxCache extends HiveObject with EquatableMixin { @HiveField(12) final String? namespace; + @HiveField(13) + final Map?>? rights; + MailboxCache( this.id, { @@ -63,6 +66,7 @@ class MailboxCache extends HiveObject with EquatableMixin { this.isSubscribed, this.lastOpened, this.namespace, + this.rights } ); @@ -80,5 +84,6 @@ class MailboxCache extends HiveObject with EquatableMixin { myRights, isSubscribed, namespace, + rights ]; } \ No newline at end of file diff --git a/lib/features/mailbox/data/network/mailbox_api.dart b/lib/features/mailbox/data/network/mailbox_api.dart index 462b72ebc9..5d53321209 100644 --- a/lib/features/mailbox/data/network/mailbox_api.dart +++ b/lib/features/mailbox/data/network/mailbox_api.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:core/utils/app_logger.dart'; +import 'package:flutter/material.dart' hide State; +import 'package:get/get.dart'; import 'package:jmap_dart_client/http/http_client.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; @@ -26,20 +28,24 @@ import 'package:jmap_dart_client/jmap/mail/mailbox/query/query_mailbox_method.da import 'package:jmap_dart_client/jmap/mail/mailbox/set/set_mailbox_method.dart'; import 'package:jmap_dart_client/jmap/mail/mailbox/set/set_mailbox_response.dart'; import 'package:model/error_type_handler/set_method_error_handler_mixin.dart'; +import 'package:model/mailbox/mailbox_constants.dart'; import 'package:model/model.dart'; import 'package:tmail_ui_user/features/base/mixin/handle_error_mixin.dart'; import 'package:tmail_ui_user/features/composer/domain/exceptions/set_method_exception.dart'; import 'package:tmail_ui_user/features/mailbox/data/model/mailbox_change_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/exceptions/mailbox_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/exceptions/set_mailbox_method_exception.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/set_mailbox_rights_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/extensions/list_mailbox_id_extension.dart'; import 'package:tmail_ui_user/features/mailbox/domain/extensions/role_extension.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/create_new_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/get_mailbox_by_role_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/jmap_mailbox_response.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subaddressing_action.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subscribe_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; import 'package:tmail_ui_user/main/error/capability_validator.dart'; @@ -401,6 +407,53 @@ class MailboxAPI with HandleSetErrorMixin { return listMailboxIdSubscribe ?? []; } + List _updateRightsForSubaddressing(MailboxSubaddressingAction action, List? currentRights) { + final updatedRights = List.from(currentRights ?? []); + + if (action == MailboxSubaddressingAction.allow) { + updatedRights.addIf(!updatedRights.contains(postingRight), postingRight); + } else { + updatedRights.remove(postingRight); + } + + return updatedRights; + } + + @visibleForTesting + List updateRightsForSubaddressing(MailboxSubaddressingAction action, List? currentRights) + => _updateRightsForSubaddressing(action, currentRights); + + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request) async { + final setMailboxMethod = SetMailboxMethod(accountId) + ..addUpdates({ + request.mailboxId.id : PatchObject({ + 'sharedWith/$anyoneIdentifier': _updateRightsForSubaddressing(request.subaddressingAction, request.currentRights?[anyoneIdentifier]) + }) + }); + + final requestBuilder = JmapRequestBuilder(httpClient, ProcessingInvocation()); + + final setMailboxInvocation = requestBuilder.invocation(setMailboxMethod); + + final capabilities = setMailboxMethod.requiredCapabilities + .toCapabilitiesSupportTeamMailboxes(session, accountId); + + final response = await (requestBuilder + ..usings(capabilities)) + .build() + .execute(); + + final setMailboxResponse = response.parse( + setMailboxInvocation.methodCallId, + SetMailboxResponse.deserialize); + + if (setMailboxResponse?.updated?.containsKey(request.mailboxId.id) ?? false) { + return true; + } else { + throw SetMailboxRightsException(); + } + } + Future> createDefaultMailbox( Session session, AccountId accountId, diff --git a/lib/features/mailbox/data/repository/mailbox_repository_impl.dart b/lib/features/mailbox/data/repository/mailbox_repository_impl.dart index a0fde06d72..8e28f45913 100644 --- a/lib/features/mailbox/data/repository/mailbox_repository_impl.dart +++ b/lib/features/mailbox/data/repository/mailbox_repository_impl.dart @@ -28,6 +28,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/model/jmap_mailbox_respons import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_response.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/repository/mailbox_repository.dart'; @@ -250,6 +251,11 @@ class MailboxRepositoryImpl extends MailboxRepository { return mapDataSource[DataSourceType.network]!.subscribeMultipleMailbox(session, accountId, subscribeRequest); } + @override + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request) { + return mapDataSource[DataSourceType.network]!.handleMailboxRightRequest(session, accountId, request); + } + @override Future> createDefaultMailbox(Session session, AccountId accountId, List listRole) { return mapDataSource[DataSourceType.network]!.createDefaultMailbox(session, accountId, listRole); diff --git a/lib/features/mailbox/domain/constants/mailbox_constants.dart b/lib/features/mailbox/domain/constants/mailbox_constants.dart index 36f7d97bf3..36b221ba85 100644 --- a/lib/features/mailbox/domain/constants/mailbox_constants.dart +++ b/lib/features/mailbox/domain/constants/mailbox_constants.dart @@ -16,7 +16,8 @@ class MailboxConstants { MailboxProperty.unreadEmails, MailboxProperty.unreadThreads, MailboxProperty.myRights, - MailboxProperty.namespace + MailboxProperty.namespace, + MailboxProperty.rights }); static final List defaultMailboxRoles = [ diff --git a/lib/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart b/lib/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart new file mode 100644 index 0000000000..2e9bc3e7bb --- /dev/null +++ b/lib/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart @@ -0,0 +1,8 @@ + +class NullSessionOrAccountIdException implements Exception { + + NullSessionOrAccountIdException(); + + @override + String toString() => 'NullSessionOrAccountIdException: session and accountId should not be null'; +} diff --git a/lib/features/mailbox/domain/exceptions/set_mailbox_rights_exception.dart b/lib/features/mailbox/domain/exceptions/set_mailbox_rights_exception.dart new file mode 100644 index 0000000000..211c4b3ce8 --- /dev/null +++ b/lib/features/mailbox/domain/exceptions/set_mailbox_rights_exception.dart @@ -0,0 +1,8 @@ + +class SetMailboxRightsException implements Exception { + + SetMailboxRightsException(); + + @override + String toString() => 'Failed to update mailbox rights.'; +} \ No newline at end of file diff --git a/lib/features/mailbox/domain/model/mailbox_right_request.dart b/lib/features/mailbox/domain/model/mailbox_right_request.dart new file mode 100644 index 0000000000..cf09072c2e --- /dev/null +++ b/lib/features/mailbox/domain/model/mailbox_right_request.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subaddressing_action.dart'; + +class MailboxRightRequest with EquatableMixin { + + final MailboxSubaddressingAction subaddressingAction; + final MailboxId mailboxId; + final Map?>? currentRights; + + MailboxRightRequest( + this.mailboxId, + this.currentRights, + this.subaddressingAction + ); + + @override + List get props => [ + mailboxId, + currentRights, + subaddressingAction, + ]; +} diff --git a/lib/features/mailbox/domain/model/mailbox_subaddressing_action.dart b/lib/features/mailbox/domain/model/mailbox_subaddressing_action.dart new file mode 100644 index 0000000000..cef83f61e0 --- /dev/null +++ b/lib/features/mailbox/domain/model/mailbox_subaddressing_action.dart @@ -0,0 +1,4 @@ +enum MailboxSubaddressingAction { + allow, + disallow, +} \ No newline at end of file diff --git a/lib/features/mailbox/domain/repository/mailbox_repository.dart b/lib/features/mailbox/domain/repository/mailbox_repository.dart index a3f40851e4..4b9e319ed8 100644 --- a/lib/features/mailbox/domain/repository/mailbox_repository.dart +++ b/lib/features/mailbox/domain/repository/mailbox_repository.dart @@ -19,6 +19,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; abstract class MailboxRepository { Stream getAllMailbox(Session session, AccountId accountId, {Properties? properties}); @@ -46,6 +47,8 @@ abstract class MailboxRepository { Future> subscribeMultipleMailbox(Session session, AccountId accountId, SubscribeMultipleMailboxRequest subscribeRequest); + Future handleMailboxRightRequest(Session session, AccountId accountId, MailboxRightRequest request); + Future> createDefaultMailbox(Session session, AccountId accountId, List listRole); Future setRoleDefaultMailbox(Session session, AccountId accountId, List listMailbox); diff --git a/lib/features/mailbox/domain/state/subaddressing_mailbox_state.dart b/lib/features/mailbox/domain/state/subaddressing_mailbox_state.dart new file mode 100644 index 0000000000..44a1ccb5f4 --- /dev/null +++ b/lib/features/mailbox/domain/state/subaddressing_mailbox_state.dart @@ -0,0 +1,37 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:tmail_ui_user/features/base/state/ui_action_state.dart'; +import 'package:jmap_dart_client/jmap/core/state.dart' as jmap; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subaddressing_action.dart'; + +class LoadingSubaddressingMailbox extends LoadingState {} + +class SubaddressingSuccess extends UIActionState { + final MailboxId mailboxId; + final MailboxSubaddressingAction subaddressingAction; + + SubaddressingSuccess( + this.mailboxId, + this.subaddressingAction, + { + jmap.State? currentEmailState, + jmap.State? currentMailboxState, + } + ) : super(currentEmailState, currentMailboxState); + + @override + List get props => [ + mailboxId, + subaddressingAction, + ...super.props + ]; +} + +class SubaddressingFailure extends FeatureFailure { + + SubaddressingFailure(dynamic exception) : super(exception: exception); + + SubaddressingFailure.withException(Exception exception) + : super(exception: exception); +} \ No newline at end of file diff --git a/lib/features/mailbox/domain/usecases/subaddressing_interactor.dart b/lib/features/mailbox/domain/usecases/subaddressing_interactor.dart new file mode 100644 index 0000000000..30361e4a5f --- /dev/null +++ b/lib/features/mailbox/domain/usecases/subaddressing_interactor.dart @@ -0,0 +1,36 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/repository/mailbox_repository.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/state/subaddressing_mailbox_state.dart'; + +class SubaddressingInteractor { + final MailboxRepository _mailboxRepository; + + SubaddressingInteractor(this._mailboxRepository); + + Stream> execute(Session session, AccountId accountId, MailboxRightRequest mailboxRightRequest) async* { + try { + yield Right(LoadingSubaddressingMailbox()); + + final currentMailboxState = await _mailboxRepository.getMailboxState(session, accountId); + + final result = await _mailboxRepository.handleMailboxRightRequest(session, accountId, mailboxRightRequest); + + if (result) { + yield Right(SubaddressingSuccess( + mailboxRightRequest.mailboxId, + currentMailboxState: currentMailboxState, + mailboxRightRequest.subaddressingAction)); + } else { + yield Left(SubaddressingFailure(null)); + } + + } catch (exception) { + yield Left(SubaddressingFailure(exception)); + } + } +} \ No newline at end of file diff --git a/lib/features/mailbox/presentation/mailbox_bindings.dart b/lib/features/mailbox/presentation/mailbox_bindings.dart index 2458018923..7e71a757c8 100644 --- a/lib/features/mailbox/presentation/mailbox_bindings.dart +++ b/lib/features/mailbox/presentation/mailbox_bindings.dart @@ -22,6 +22,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/get_all_mailbox_i import 'package:tmail_ui_user/features/mailbox/domain/usecases/move_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/refresh_all_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/rename_mailbox_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mailbox_controller.dart'; @@ -56,6 +57,7 @@ class MailboxBindings extends BaseBindings { Get.find(), Get.find(), Get.find(), + Get.find(), Get.find(), Get.find(), Get.find(), @@ -106,6 +108,7 @@ class MailboxBindings extends BaseBindings { Get.lazyPut(() => MoveMailboxInteractor(Get.find())); Get.lazyPut(() => SubscribeMailboxInteractor(Get.find())); Get.lazyPut(() => SubscribeMultipleMailboxInteractor(Get.find())); + Get.lazyPut(() => SubaddressingInteractor(Get.find())); Get.lazyPut(() => CreateDefaultMailboxInteractor(Get.find())); } diff --git a/lib/features/mailbox/presentation/mailbox_controller.dart b/lib/features/mailbox/presentation/mailbox_controller.dart index b0501879e4..dc278e217d 100644 --- a/lib/features/mailbox/presentation/mailbox_controller.dart +++ b/lib/features/mailbox/presentation/mailbox_controller.dart @@ -32,11 +32,14 @@ import 'package:tmail_ui_user/features/email/presentation/model/composer_argumen import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; import 'package:tmail_ui_user/features/mailbox/domain/constants/mailbox_constants.dart'; import 'package:tmail_ui_user/features/mailbox/domain/exceptions/set_mailbox_name_exception.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/create_new_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subaddressing_action.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subscribe_action_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subscribe_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/move_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/rename_mailbox_request.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_right_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_multiple_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/subscribe_request.dart'; @@ -49,6 +52,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/state/move_mailbox_state.d import 'package:tmail_ui_user/features/mailbox/domain/state/refresh_all_mailboxes_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/state/refresh_changes_all_mailboxes_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/state/rename_mailbox_state.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/state/subaddressing_mailbox_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/state/subscribe_mailbox_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/state/subscribe_multiple_mailbox_state.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/create_new_default_mailbox_interactor.dart'; @@ -58,6 +62,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/get_all_mailbox_i import 'package:tmail_ui_user/features/mailbox/domain/usecases/move_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/refresh_all_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/rename_mailbox_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/action/mailbox_ui_action.dart'; @@ -100,6 +105,7 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM final MoveMailboxInteractor _moveMailboxInteractor; final SubscribeMailboxInteractor _subscribeMailboxInteractor; final SubscribeMultipleMailboxInteractor _subscribeMultipleMailboxInteractor; + final SubaddressingInteractor _subaddressingInteractor; final CreateDefaultMailboxInteractor _createDefaultMailboxInteractor; IOSSharingManager? _iosSharingManager; @@ -125,6 +131,7 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM this._moveMailboxInteractor, this._subscribeMailboxInteractor, this._subscribeMultipleMailboxInteractor, + this._subaddressingInteractor, this._createDefaultMailboxInteractor, TreeBuilder treeBuilder, VerifyNameInteractor verifyNameInteractor, @@ -186,6 +193,8 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM _handleUnsubscribeMultipleMailboxAllSuccess(success); } else if (success is SubscribeMultipleMailboxHasSomeSuccess) { _handleUnsubscribeMultipleMailboxHasSomeSuccess(success); + } else if (success is SubaddressingSuccess) { + _handleSubaddressingSuccess(success); } else if (success is CreateDefaultMailboxAllSuccess) { _refreshMailboxChanges(currentMailboxState: success.currentMailboxState); } @@ -204,6 +213,8 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM _clearNewFolderId(); } else if (failure is CreateDefaultMailboxFailure) { _refreshMailboxChanges(currentMailboxState: failure.currentMailboxState); + } else if (failure is SubaddressingFailure) { + _handleSubaddressingFailure(failure); } } @@ -1068,6 +1079,10 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM case MailboxActions.disableMailbox: _unsubscribeMailboxAction(mailbox.id); break; + case MailboxActions.allowSubaddressing: + case MailboxActions.disallowSubaddressing: + _handleSubaddressingAction(mailbox.id, mailbox.rights, actions); + break; case MailboxActions.emptyTrash: emptyTrashAction(context, mailbox, mailboxDashBoardController); break; @@ -1338,6 +1353,41 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM } } + void _handleSubaddressingAction(MailboxId mailboxId, Map?>? currentRights, MailboxActions subaddressingAction) { + final accountId = mailboxDashBoardController.accountId.value; + final session = mailboxDashBoardController.sessionCurrent; + + if (session != null && accountId != null) { + final allowSubaddressingRequest = MailboxRightRequest( + mailboxId, + currentRights, + subaddressingAction == MailboxActions.allowSubaddressing ? MailboxSubaddressingAction.allow : MailboxSubaddressingAction.disallow + ); + + consumeState(_subaddressingInteractor.execute(session, accountId, allowSubaddressingRequest)); + } else { + _handleSubaddressingFailure(SubaddressingFailure.withException(NullSessionOrAccountIdException())); + } + + popBack(); + } + + void _handleSubaddressingFailure(SubaddressingFailure failure) { + if (currentOverlayContext != null && currentContext != null) { + final messageError = AppLocalizations.of(currentContext!).toastMessageSubaddressingFailure; + appToast.showToastErrorMessage(currentOverlayContext!, messageError); + } + } + + void _handleSubaddressingSuccess(SubaddressingSuccess success) { + appToast.showToastSuccessMessage( + currentOverlayContext!, + success.subaddressingAction == MailboxSubaddressingAction.allow + ? AppLocalizations.of(currentContext!).toastMessageAllowSubaddressingSuccess + : AppLocalizations.of(currentContext!).toastMessageDisallowSubaddressingSuccess, + ); + } + void _mailboxListScrollControllerListener() { _handleScrollTop(); _handleScrollBottom(); diff --git a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart index f39c778dee..f7320b6299 100644 --- a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart +++ b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart @@ -60,6 +60,10 @@ mixin MailboxWidgetMixin { MailboxActions.markAsRead, MailboxActions.move, MailboxActions.rename, + if (mailbox.isSubaddressingAllowed) + MailboxActions.disallowSubaddressing + else + MailboxActions.allowSubaddressing, if (mailbox.isSubscribedMailbox) MailboxActions.disableMailbox else diff --git a/lib/features/mailbox/presentation/model/mailbox_actions.dart b/lib/features/mailbox/presentation/model/mailbox_actions.dart index 722d55baac..2cb018b006 100644 --- a/lib/features/mailbox/presentation/model/mailbox_actions.dart +++ b/lib/features/mailbox/presentation/model/mailbox_actions.dart @@ -24,7 +24,9 @@ enum MailboxActions { emptySpam, newSubfolder, confirmMailSpam, - recoverDeletedMessages; + recoverDeletedMessages, + allowSubaddressing, + disallowSubaddressing; } extension MailboxActionsExtension on MailboxActions { @@ -74,6 +76,10 @@ extension MailboxActionsExtension on MailboxActions { return AppLocalizations.of(context).confirmAllEmailHereAreSpam; case MailboxActions.recoverDeletedMessages: return AppLocalizations.of(context).recoverDeletedMessages; + case MailboxActions.allowSubaddressing: + return AppLocalizations.of(context).allowSubaddressing; + case MailboxActions.disallowSubaddressing: + return AppLocalizations.of(context).disallowSubaddressing; default: return ''; } @@ -109,6 +115,10 @@ extension MailboxActionsExtension on MailboxActions { return imagePaths.icMarkAsRead; case MailboxActions.recoverDeletedMessages: return imagePaths.icRecoverDeletedMessages; + case MailboxActions.allowSubaddressing: + return imagePaths.icSubaddressingAllow; + case MailboxActions.disallowSubaddressing: + return imagePaths.icSubaddressingDisallow; default: return ''; } @@ -183,6 +193,8 @@ extension MailboxActionsExtension on MailboxActions { case MailboxActions.emptySpam: case MailboxActions.confirmMailSpam: case MailboxActions.recoverDeletedMessages: + case MailboxActions.allowSubaddressing: + case MailboxActions.disallowSubaddressing: return ContextMenuItemState.activated; case MailboxActions.markAsRead: return mailbox.countUnReadEmailsAsString.isNotEmpty diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index d4d794eb14..d7b560b4ef 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -2326,6 +2326,36 @@ "placeholders_order": [], "placeholders": {} }, + "allowSubaddressing": "Allow subaddressing", + "@allowSubaddressing": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "disallowSubaddressing": "Disallow subaddressing", + "@disallowSubaddressing": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "toastMessageAllowSubaddressingSuccess": "You have successfully allowed subaddressing for this folder", + "@toastMessageAllowSubaddressingSuccess": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "toastMessageDisallowSubaddressingSuccess": "You have successfully disallowed subaddressing for this folder", + "@toastMessageDisallowSubaddressingSuccess": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "toastMessageSubaddressingFailure": "There was an error dealing with the request", + "@toastMessageSubaddressingFailure": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "requestReadReceipt": "Request read receipt", "@requestReadReceipt": { "type": "text", diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 9dac836f6b..c02e63f313 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -2403,6 +2403,36 @@ class AppLocalizations { name: 'selectParentFolder'); } + String get allowSubaddressing { + return Intl.message( + 'Allow subaddressing', + name: 'allowSubaddressing'); + } + + String get disallowSubaddressing { + return Intl.message( + 'Disallow subaddressing', + name: 'disallowSubaddressing'); + } + + String get toastMessageAllowSubaddressingSuccess { + return Intl.message( + 'You have successfully allowed subaddressing for this folder', + name: 'toastMessageAllowSubaddressingSuccess'); + } + + String get toastMessageDisallowSubaddressingSuccess { + return Intl.message( + 'You have successfully disallowed subaddressing for this folder', + name: 'toastMessageDisallowSubaddressingSuccess'); + } + + String get toastMessageSubaddressingFailure { + return Intl.message( + 'There was an error dealing with the request', + name: 'toastMessageSubaddressingFailure'); + } + String get requestReadReceipt { return Intl.message( 'Request read receipt', @@ -2772,6 +2802,7 @@ class AppLocalizations { 'This folder is already displayed in your primary folder', name: 'toastMessageShowFolderSuccess'); } + String get folderVisibility { return Intl.message( 'Folder visibility', diff --git a/model/lib/extensions/mailbox_extension.dart b/model/lib/extensions/mailbox_extension.dart index 44dcefa265..542325beaf 100644 --- a/model/lib/extensions/mailbox_extension.dart +++ b/model/lib/extensions/mailbox_extension.dart @@ -32,6 +32,7 @@ extension MailboxExtension on Mailbox { myRights: myRights, isSubscribed: isSubscribed, namespace: namespace, + rights: rights ); } @@ -49,6 +50,7 @@ extension MailboxExtension on Mailbox { myRights: updatedProperties.contain(MailboxProperty.myRights) ? newMailbox.myRights : myRights, isSubscribed: updatedProperties.contain(MailboxProperty.isSubscribed) ? newMailbox.isSubscribed : isSubscribed, namespace: updatedProperties.contain(MailboxProperty.namespace) ? newMailbox.namespace : namespace, + rights: updatedProperties.contain(MailboxProperty.rights) ? newMailbox.rights : rights, ); } @@ -66,6 +68,7 @@ extension MailboxExtension on Mailbox { myRights: myRights, isSubscribed: isSubscribed, namespace: namespace, + rights: rights ); } } \ No newline at end of file diff --git a/model/lib/extensions/presentation_mailbox_extension.dart b/model/lib/extensions/presentation_mailbox_extension.dart index 6b1f57d360..eb58c78aa7 100644 --- a/model/lib/extensions/presentation_mailbox_extension.dart +++ b/model/lib/extensions/presentation_mailbox_extension.dart @@ -2,6 +2,7 @@ import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; import 'package:jmap_dart_client/jmap/mail/mailbox/namespace.dart'; import 'package:model/mailbox/mailbox_state.dart'; import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:model/mailbox/mailbox_constants.dart'; import 'package:model/mailbox/select_mode.dart'; extension PresentationMailboxExtension on PresentationMailbox { @@ -49,6 +50,8 @@ extension PresentationMailboxExtension on PresentationMailbox { bool get isSubscribedMailbox => isSubscribed != null && isSubscribed?.value == true; + bool get isSubaddressingAllowed => rights != null && rights?[anyoneIdentifier]?.contains(postingRight) == true; + bool get allowedToDisplayCountOfUnreadEmails => !(isTrash || isSpam || isDrafts || isTemplates || isSent) && countUnreadEmails > 0; bool get allowedToDisplayCountOfTotalEmails => (isTrash || isSpam || isDrafts) && countTotalEmails > 0; @@ -100,6 +103,7 @@ extension PresentationMailboxExtension on PresentationMailbox { state: state, namespace: namespace, displayName: displayName, + rights: rights ); } @@ -121,6 +125,7 @@ extension PresentationMailboxExtension on PresentationMailbox { state: state, namespace: namespace, displayName: displayName, + rights: rights ); } @@ -142,6 +147,7 @@ extension PresentationMailboxExtension on PresentationMailbox { state: newMailboxState, namespace: namespace, displayName: displayName, + rights: rights ); } @@ -159,6 +165,7 @@ extension PresentationMailboxExtension on PresentationMailbox { myRights: myRights, isSubscribed: isSubscribed, namespace: namespace, + rights: rights ); } @@ -180,6 +187,7 @@ extension PresentationMailboxExtension on PresentationMailbox { state: state, namespace: namespace, displayName: displayName, + rights: rights ); } @@ -201,6 +209,7 @@ extension PresentationMailboxExtension on PresentationMailbox { state: state, namespace: namespace, displayName: displayName, + rights: rights ); } } \ No newline at end of file diff --git a/model/lib/mailbox/mailbox_constants.dart b/model/lib/mailbox/mailbox_constants.dart new file mode 100644 index 0000000000..ae75773f98 --- /dev/null +++ b/model/lib/mailbox/mailbox_constants.dart @@ -0,0 +1,2 @@ +const String anyoneIdentifier = 'anyone'; +const String postingRight = 'p'; diff --git a/model/lib/mailbox/mailbox_property.dart b/model/lib/mailbox/mailbox_property.dart index 6fc1346278..9e152e95bb 100644 --- a/model/lib/mailbox/mailbox_property.dart +++ b/model/lib/mailbox/mailbox_property.dart @@ -12,4 +12,5 @@ class MailboxProperty { static const String myRights = 'myRights'; static const String isSubscribed = 'isSubscribed'; static const String namespace = 'namespace'; + static const String rights = 'rights'; } \ No newline at end of file diff --git a/model/lib/mailbox/presentation_mailbox.dart b/model/lib/mailbox/presentation_mailbox.dart index a810e1037c..56f9290115 100644 --- a/model/lib/mailbox/presentation_mailbox.dart +++ b/model/lib/mailbox/presentation_mailbox.dart @@ -48,6 +48,7 @@ class PresentationMailbox with EquatableMixin { final MailboxState? state; final Namespace? namespace; final String? displayName; + final Map?>? rights; PresentationMailbox( this.id, @@ -67,6 +68,7 @@ class PresentationMailbox with EquatableMixin { this.state = MailboxState.activated, this.namespace, this.displayName, + this.rights } ); @@ -88,5 +90,6 @@ class PresentationMailbox with EquatableMixin { state, namespace, displayName, + rights ]; } \ No newline at end of file diff --git a/test/features/mailbox/data/update_rights_for_subaddressing_test.dart b/test/features/mailbox/data/update_rights_for_subaddressing_test.dart new file mode 100644 index 0000000000..2c5398b4e9 --- /dev/null +++ b/test/features/mailbox/data/update_rights_for_subaddressing_test.dart @@ -0,0 +1,54 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/http/http_client.dart'; +import 'package:tmail_ui_user/features/mailbox/data/network/mailbox_api.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/model/mailbox_subaddressing_action.dart'; +import 'package:uuid/uuid.dart'; + +void main() { + group('MailboxAPI.updateRightsForSubaddressing tests', () { + const postingRight = 'p'; + final mailboxAPI = MailboxAPI(HttpClient(Dio()), const Uuid()); + + test('should add postingRight when action is allow and currentRights is empty', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.allow, []); + expect(result, contains(postingRight)); + expect(result.length, 1); + }); + + test('should add postingRight when action is allow and currentRights does not contain postingRight', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.allow, ['r', 'w']); + expect(result, contains(postingRight)); + expect(result.length, 3); + }); + + test('should not add postingRight if action is allow and postingRight already present', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.allow, ['r', postingRight, 'w']); + expect(result.where((right) => right == postingRight).length, 1); + expect(result.length, 3); + }); + + test('should remove postingRight if present and action is disallow', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.disallow, ['r', postingRight, 'w']); + expect(result, isNot(contains(postingRight))); + expect(result.length, 2); + }); + + test('should do nothing if postingRight is not present and action is disallow', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.disallow, ['r', 'w']); + expect(result, isNot(contains(postingRight))); + expect(result.length, 2); + }); + + test('should return empty list if action is disallow and currentRights is null', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.disallow, null); + expect(result, isEmpty); + }); + + test('should return list with only postingRight if action is allow and currentRights is null', () { + final result = mailboxAPI.updateRightsForSubaddressing(MailboxSubaddressingAction.allow, null); + expect(result, contains(postingRight)); + expect(result.length, 1); + }); + }); +} diff --git a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart index a6045ae75a..bdeedd328f 100644 --- a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart +++ b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart @@ -44,6 +44,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/mark_as_mailbox_r import 'package:tmail_ui_user/features/mailbox/domain/usecases/move_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/refresh_all_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/rename_mailbox_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mailbox_controller.dart'; @@ -146,6 +147,7 @@ const fallbackGenerators = { MockSpec(), MockSpec(), MockSpec(), + MockSpec(), MockSpec(), MockSpec(), MockSpec(), @@ -248,6 +250,7 @@ void main() { final moveMailboxInteractor = MockMoveMailboxInteractor(); final subscribeMailboxInteractor = MockSubscribeMailboxInteractor(); final subscribeMultipleMailboxInteractor = MockSubscribeMultipleMailboxInteractor(); + final subaddressingInteractor = MockSubaddressingInteractor(); final createDefaultMailboxInteractor = MockCreateDefaultMailboxInteractor(); final treeBuilder = MockTreeBuilder(); final verifyNameInteractor = MockVerifyNameInteractor(); @@ -359,6 +362,7 @@ void main() { moveMailboxInteractor, subscribeMailboxInteractor, subscribeMultipleMailboxInteractor, + subaddressingInteractor, createDefaultMailboxInteractor, treeBuilder, verifyNameInteractor, diff --git a/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart b/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart index 910f68bc9f..0d022941f9 100644 --- a/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart +++ b/test/features/mailbox_dashboard/presentation/view/mailbox_dashboard_view_widget_test.dart @@ -44,6 +44,7 @@ import 'package:tmail_ui_user/features/mailbox/domain/usecases/mark_as_mailbox_r import 'package:tmail_ui_user/features/mailbox/domain/usecases/move_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/refresh_all_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/rename_mailbox_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/usecases/subaddressing_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/subscribe_multiple_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mailbox_controller.dart'; @@ -150,6 +151,7 @@ const fallbackGenerators = { MockSpec(), MockSpec(), MockSpec(), + MockSpec(), MockSpec(), MockSpec(), MockSpec(), @@ -237,6 +239,7 @@ void main() { final moveMailboxInteractor = MockMoveMailboxInteractor(); final subscribeMailboxInteractor = MockSubscribeMailboxInteractor(); final subscribeMultipleMailboxInteractor = MockSubscribeMultipleMailboxInteractor(); + final subaddressingInteractor = MockSubaddressingInteractor(); final createDefaultMailboxInteractor = MockCreateDefaultMailboxInteractor(); final treeBuilder = MockTreeBuilder(); final verifyNameInteractor = MockVerifyNameInteractor(); @@ -352,6 +355,7 @@ void main() { moveMailboxInteractor, subscribeMailboxInteractor, subscribeMultipleMailboxInteractor, + subaddressingInteractor, createDefaultMailboxInteractor, treeBuilder, verifyNameInteractor, From f97054e015c3d0e49f6126341fd99b2043dcdfac Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Fri, 29 Nov 2024 14:29:59 +0100 Subject: [PATCH 2/6] TF-3189 new option to copy a folder's subaddress --- assets/images/ic_copy.svg | 3 + .../presentation/resources/image_paths.dart | 1 + .../base/base_mailbox_controller.dart | 12 ++- .../empty_folder_name_exception.dart | 9 +++ .../invalid_mail_format_exception.dart | 9 +++ .../presentation/mailbox_controller.dart | 29 +++++++ .../mixin/mailbox_widget_mixin.dart | 2 + .../presentation/model/mailbox_actions.dart | 6 ++ .../presentation/model/mailbox_tree.dart | 6 +- lib/l10n/intl_messages.arb | 12 +++ lib/main/localizations/app_localizations.dart | 20 +++++ .../mailbox_dashboard_controller_test.dart | 79 +++++++++++++++++++ 12 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 assets/images/ic_copy.svg create mode 100644 lib/features/mailbox/domain/exceptions/empty_folder_name_exception.dart create mode 100644 lib/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart diff --git a/assets/images/ic_copy.svg b/assets/images/ic_copy.svg new file mode 100644 index 0000000000..558c78aa86 --- /dev/null +++ b/assets/images/ic_copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index d20c5d6058..d320368cb1 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -221,6 +221,7 @@ class ImagePaths { String get icBadSignature => _getImagePath('ic_bad_signature.svg'); String get icDeleteSelection => _getImagePath('ic_delete_selection.svg'); String get icLogoTwakeWelcome => _getImagePath('ic_logo_twake_welcome.svg'); + String get icCopy => _getImagePath('ic_copy.svg'); String get icSubaddressingAllow => _getImagePath('ic_subaddressing_allow.svg'); String get icSubaddressingDisallow => _getImagePath('ic_subaddressing_disallow.svg'); diff --git a/lib/features/base/base_mailbox_controller.dart b/lib/features/base/base_mailbox_controller.dart index 854eca61a7..e1112bbb7a 100644 --- a/lib/features/base/base_mailbox_controller.dart +++ b/lib/features/base/base_mailbox_controller.dart @@ -210,14 +210,18 @@ abstract class BaseMailboxController extends BaseController { return teamMailboxesTree.value.findNode((node) => node.item.id == mailboxId); } - String? findNodePath(MailboxId mailboxId) { - var mailboxNodePath = defaultMailboxTree.value.getNodePath(mailboxId) - ?? personalMailboxTree.value.getNodePath(mailboxId) - ?? teamMailboxesTree.value.getNodePath(mailboxId); + String? findNodePathWithSeparator(MailboxId mailboxId, String pathSeparator) { + var mailboxNodePath = defaultMailboxTree.value.getNodePath(mailboxId, pathSeparator) + ?? personalMailboxTree.value.getNodePath(mailboxId, pathSeparator) + ?? teamMailboxesTree.value.getNodePath(mailboxId, pathSeparator); log('BaseMailboxController::findNodePath():mailboxNodePath: $mailboxNodePath'); return mailboxNodePath; } + String? findNodePath(MailboxId mailboxId) { + return findNodePathWithSeparator(mailboxId, '/'); + } + MailboxNode? findMailboxNodeByRole(Role role) { final mailboxNode = defaultMailboxTree.value.findNode((node) => node.item.role == role); return mailboxNode; diff --git a/lib/features/mailbox/domain/exceptions/empty_folder_name_exception.dart b/lib/features/mailbox/domain/exceptions/empty_folder_name_exception.dart new file mode 100644 index 0000000000..5a3ff99ddc --- /dev/null +++ b/lib/features/mailbox/domain/exceptions/empty_folder_name_exception.dart @@ -0,0 +1,9 @@ + +class EmptyFolderNameException implements Exception { + final String folderName; + + EmptyFolderNameException(this.folderName); + + @override + String toString() => 'EmptyFolderNameException: Folder name should not be empty: $folderName'; +} diff --git a/lib/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart b/lib/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart new file mode 100644 index 0000000000..bd15b87b2d --- /dev/null +++ b/lib/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart @@ -0,0 +1,9 @@ + +class InvalidMailFormatException implements Exception { + final String mail; + + InvalidMailFormatException(this.mail); + + @override + String toString() => 'InvalidMailFormatException: $mail'; +} diff --git a/lib/features/mailbox/presentation/mailbox_controller.dart b/lib/features/mailbox/presentation/mailbox_controller.dart index dc278e217d..5cee27cbfa 100644 --- a/lib/features/mailbox/presentation/mailbox_controller.dart +++ b/lib/features/mailbox/presentation/mailbox_controller.dart @@ -5,6 +5,7 @@ import 'package:core/core.dart'; import 'package:dartz/dartz.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; @@ -31,6 +32,8 @@ import 'package:tmail_ui_user/features/email/domain/state/move_to_mailbox_state. import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart'; import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; import 'package:tmail_ui_user/features/mailbox/domain/constants/mailbox_constants.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/empty_folder_name_exception.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/exceptions/set_mailbox_name_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/exceptions/null_session_or_accountid_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/model/create_new_mailbox_request.dart'; @@ -1072,6 +1075,14 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM case MailboxActions.openInNewTab: openMailboxInNewTabAction(mailbox); break; + case MailboxActions.copySubaddress: + try{ + final subaddress = getSubaddress(mailboxDashBoardController.userEmail, findNodePathWithSeparator(mailbox.id, '.')!); + copySubaddressAction(context, subaddress); + } catch (error) { + appToast.showToastErrorMessage(context, AppLocalizations.of(context).errorWhileFetchingSubaddress); + } + break; case MailboxActions.disableSpamReport: case MailboxActions.enableSpamReport: mailboxDashBoardController.storeSpamReportStateAction(); @@ -1460,4 +1471,22 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM mailboxDashBoardController.emptySpamFolderAction(spamFolderId: presentationMailbox.id); } } + + void copySubaddressAction(BuildContext context, String subaddress) { + Clipboard.setData(ClipboardData(text: subaddress)); + appToast.showToastSuccessMessage(context, AppLocalizations.of(context).emailSubaddressCopiedToClipboard); + } + + String getSubaddress(String userEmail, String folderName) { + if (folderName.isEmpty) { + throw EmptyFolderNameException(folderName); + } + + final atIndex = userEmail.indexOf('@'); + if (atIndex <= 0 || atIndex == userEmail.length - 1) { + throw InvalidMailFormatException(userEmail); + } + + return '${userEmail.substring(0, atIndex)}+$folderName@${userEmail.substring(atIndex + 1)}'; + } } \ No newline at end of file diff --git a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart index f7320b6299..f97a869ab3 100644 --- a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart +++ b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart @@ -64,6 +64,8 @@ mixin MailboxWidgetMixin { MailboxActions.disallowSubaddressing else MailboxActions.allowSubaddressing, + if (mailbox.isSubaddressingAllowed) + MailboxActions.copySubaddress, if (mailbox.isSubscribedMailbox) MailboxActions.disableMailbox else diff --git a/lib/features/mailbox/presentation/model/mailbox_actions.dart b/lib/features/mailbox/presentation/model/mailbox_actions.dart index 2cb018b006..e010aec1b3 100644 --- a/lib/features/mailbox/presentation/model/mailbox_actions.dart +++ b/lib/features/mailbox/presentation/model/mailbox_actions.dart @@ -16,6 +16,7 @@ enum MailboxActions { markAsRead, selectForRuleAction, openInNewTab, + copySubaddress, disableSpamReport, enableSpamReport, disableMailbox, @@ -50,6 +51,8 @@ extension MailboxActionsExtension on MailboxActions { switch(this) { case MailboxActions.openInNewTab: return AppLocalizations.of(context).openInNewTab; + case MailboxActions.copySubaddress: + return AppLocalizations.of(context).copySubaddress; case MailboxActions.newSubfolder: return AppLocalizations.of(context).newSubfolder; case MailboxActions.disableSpamReport: @@ -89,6 +92,8 @@ extension MailboxActionsExtension on MailboxActions { switch(this) { case MailboxActions.openInNewTab: return imagePaths.icOpenInNewTab; + case MailboxActions.copySubaddress: + return imagePaths.icCopy; case MailboxActions.newSubfolder: return imagePaths.icAddNewFolder; case MailboxActions.disableSpamReport: @@ -181,6 +186,7 @@ extension MailboxActionsExtension on MailboxActions { ContextMenuItemState getContextMenuItemState(PresentationMailbox mailbox) { switch(this) { case MailboxActions.openInNewTab: + case MailboxActions.copySubaddress: case MailboxActions.newSubfolder: case MailboxActions.disableSpamReport: case MailboxActions.enableSpamReport: diff --git a/lib/features/mailbox/presentation/model/mailbox_tree.dart b/lib/features/mailbox/presentation/model/mailbox_tree.dart index 125e6e3c63..c825ddcd69 100644 --- a/lib/features/mailbox/presentation/model/mailbox_tree.dart +++ b/lib/features/mailbox/presentation/model/mailbox_tree.dart @@ -83,7 +83,7 @@ class MailboxTree with EquatableMixin { } } - String? getNodePath(MailboxId mailboxId) { + String? getNodePath(MailboxId mailboxId, String pathSeparator) { final matchedNode = findNode((node) => node.item.id == mailboxId); if (matchedNode == null) { return null; @@ -103,9 +103,9 @@ class MailboxTree with EquatableMixin { break; } if (currentContext != null) { - path = '${parentNode.item.getDisplayName(currentContext!)}/$path'; + path = '${parentNode.item.getDisplayName(currentContext!)}$pathSeparator$path'; } else { - path = '${parentNode.item.name?.name}/$path'; + path = '${parentNode.item.name?.name}$pathSeparator$path'; } parentId = parentNode.item.parentId; } diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index d7b560b4ef..6780fade10 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -764,6 +764,12 @@ "placeholders_order": [], "placeholders": {} }, + "emailSubaddressCopiedToClipboard": "Email subaddress copied to clipboard", + "@emailSubaddressCopiedToClipboard": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "minimize": "Minimize", "@minimize": { "type": "text", @@ -2560,6 +2566,12 @@ "placeholders_order": [], "placeholders": {} }, + "copySubaddress": "Copy subaddress", + "@copySubaddress": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "regards": "Regards", "@regards": { "type": "text", diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index c02e63f313..0be68b3531 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -787,6 +787,12 @@ class AppLocalizations { name: 'email_address_copied_to_clipboard'); } + String get emailSubaddressCopiedToClipboard { + return Intl.message( + 'Email subaddress copied to clipboard', + name: 'emailSubaddressCopiedToClipboard'); + } + String get minimize { return Intl.message( 'Minimize', @@ -2628,6 +2634,13 @@ class AppLocalizations { ); } + String get copySubaddress { + return Intl.message( + 'Copy subaddress', + name: 'copySubaddress', + ); + } + String get regards { return Intl.message( 'Regards', @@ -2979,6 +2992,13 @@ class AppLocalizations { ); } + String get errorWhileFetchingSubaddress { + return Intl.message( + 'Error while fetching the subaddress', + name: 'errorWhileFetchingSubaddress', + ); + } + String get connectedToTheInternet { return Intl.message( 'Connected to the internet', diff --git a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart index bdeedd328f..a8b8a0122d 100644 --- a/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart +++ b/test/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller_test.dart @@ -36,6 +36,8 @@ import 'package:tmail_ui_user/features/login/domain/usecases/delete_authority_oi import 'package:tmail_ui_user/features/login/domain/usecases/delete_credential_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/get_authenticated_account_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/update_account_cache_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/empty_folder_name_exception.dart'; +import 'package:tmail_ui_user/features/mailbox/domain/exceptions/invalid_mail_format_exception.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/create_new_default_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/create_new_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/domain/usecases/delete_multiple_mailbox_interactor.dart'; @@ -570,4 +572,81 @@ void main() { expect(spamId, isNull); }); }); + + group('getSubaddress:test', () { + setUp(() { + getEmailsInMailboxInteractor = MockGetEmailsInMailboxInteractor(); + + when(emailReceiveManager.pendingSharedFileInfo).thenAnswer((_) => BehaviorSubject.seeded([])); + + Get.put(mailboxDashboardController); + mailboxDashboardController.onReady(); + + mailboxController = MailboxController( + createNewMailboxInteractor, + deleteMultipleMailboxInteractor, + renameMailboxInteractor, + moveMailboxInteractor, + subscribeMailboxInteractor, + subscribeMultipleMailboxInteractor, + subaddressingInteractor, + createDefaultMailboxInteractor, + treeBuilder, + verifyNameInteractor, + getAllMailboxInteractor, + refreshAllMailboxInteractor); + mailboxController.onReady(); + + threadController = ThreadController( + getEmailsInMailboxInteractor, + refreshChangesEmailsInMailboxInteractor, + loadMoreEmailsInMailboxInteractor, + searchEmailInteractor, + searchMoreEmailInteractor, + getEmailByIdInteractor); + Get.put(threadController); + + advancedFilterController = AdvancedFilterController(); + + mailboxDashboardController.sessionCurrent = testSession; + mailboxDashboardController.filterMessageOption.value = FilterMessageOption.all; + mailboxDashboardController.accountId.value = testAccountId; + }); + + test('should return subaddress with valid email and folder name', () { + const String userEmail = 'user@example.com'; + const String folderName = 'folder'; + final result = mailboxController.getSubaddress(userEmail, folderName); + + expect(result, equals('user+folder@example.com')); + }); + + test('should throw an error if empty local part', () { + const userEmail = '@example.com'; + const folderName = 'folder'; + + expect(() => mailboxController.getSubaddress(userEmail, folderName), throwsA(isA())); + }); + + test('should throw an error if empty folder name', () { + const userEmail = 'user@example.com'; + const folderName = ''; + + expect(() => mailboxController.getSubaddress(userEmail, folderName), throwsA(isA())); + }); + + test('should throw an error if empty domain', () { + const userEmail = 'user@'; + const folderName = 'folder'; + + expect(() => mailboxController.getSubaddress(userEmail, folderName), throwsA(isA())); + }); + + test('should throw an error if absent `@`', () { + const userEmail = 'invalid-email-format'; + const folderName = 'folder'; + + expect(() => mailboxController.getSubaddress(userEmail, folderName), throwsA(isA())); + }); + }); } From f99259586b37a7015866bd9d7adcce3c20d173bd Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Fri, 29 Nov 2024 12:30:22 +0100 Subject: [PATCH 3/6] TF-3189 subaddressing features only shown if supported by the server --- .../mixin/mailbox_widget_mixin.dart | 63 ++++++++++++----- .../presentation/search_mailbox_view.dart | 10 +++ model/lib/mailbox/mailbox_constants.dart | 1 + .../mailbox_widget_mixin_test.dart | 69 +++++++++++++++++++ 4 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 test/features/mailbox/presentation/mailbox_widget_mixin_test.dart diff --git a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart index f97a869ab3..f313f9d465 100644 --- a/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart +++ b/lib/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart @@ -4,9 +4,11 @@ import 'package:core/utils/direction_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; -import 'package:model/extensions/presentation_mailbox_extension.dart'; -import 'package:model/mailbox/expand_mode.dart'; -import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:model/mailbox/mailbox_constants.dart'; +import 'package:model/model.dart'; import 'package:tmail_ui_user/features/base/base_mailbox_controller.dart'; import 'package:tmail_ui_user/features/base/widget/popup_item_widget.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/mailbox_controller.dart'; @@ -15,6 +17,7 @@ import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_action import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_categories.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/widgets/mailbox_bottom_sheet_action_tile_builder.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/app_dashboard/app_list_dashboard_item.dart'; +import 'package:tmail_ui_user/main/error/capability_validator.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; mixin MailboxWidgetMixin { @@ -51,7 +54,7 @@ mixin MailboxWidgetMixin { ]; } - List _listActionForPersonalMailbox(PresentationMailbox mailbox) { + List _listActionForPersonalMailbox(PresentationMailbox mailbox, bool subaddressingSupported) { return [ if (PlatformInfo.isWeb && mailbox.isSubscribedMailbox) MailboxActions.openInNewTab, @@ -60,12 +63,14 @@ mixin MailboxWidgetMixin { MailboxActions.markAsRead, MailboxActions.move, MailboxActions.rename, - if (mailbox.isSubaddressingAllowed) - MailboxActions.disallowSubaddressing - else - MailboxActions.allowSubaddressing, - if (mailbox.isSubaddressingAllowed) - MailboxActions.copySubaddress, + if (subaddressingSupported) ...[ + if (mailbox.isSubaddressingAllowed) + MailboxActions.disallowSubaddressing + else + MailboxActions.allowSubaddressing, + if (mailbox.isSubaddressingAllowed) + MailboxActions.copySubaddress, + ], if (mailbox.isSubscribedMailbox) MailboxActions.disableMailbox else @@ -90,12 +95,13 @@ mixin MailboxWidgetMixin { List _listActionForAllMailboxType( PresentationMailbox mailbox, - bool spamReportEnabled + bool spamReportEnabled, + bool subaddressingSupported ) { if (mailbox.isDefault) { return _listActionForDefaultMailbox(mailbox, spamReportEnabled); } else if (mailbox.isPersonal) { - return _listActionForPersonalMailbox(mailbox); + return _listActionForPersonalMailbox(mailbox, subaddressingSupported); } else { return _listActionForTeamMailbox(mailbox); } @@ -107,9 +113,14 @@ mixin MailboxWidgetMixin { PresentationMailbox mailbox, MailboxController controller ) { + final bool subaddressingSupported = isSubaddressingSupported( + controller.mailboxDashBoardController.sessionCurrent, + controller.mailboxDashBoardController.accountId.value); + final contextMenuActions = listContextMenuItemAction( mailbox, - controller.mailboxDashBoardController.enableSpamReport + controller.mailboxDashBoardController.enableSpamReport, + subaddressingSupported ); if (contextMenuActions.isEmpty) { @@ -180,9 +191,10 @@ mixin MailboxWidgetMixin { List listContextMenuItemAction( PresentationMailbox mailbox, - bool spamReportEnabled + bool spamReportEnabled, + bool subaddressingSupported ) { - final mailboxActionsSupported = _listActionForAllMailboxType(mailbox, spamReportEnabled); + final mailboxActionsSupported = _listActionForAllMailboxType(mailbox, spamReportEnabled, subaddressingSupported); final listContextMenuItemAction = mailboxActionsSupported .map((action) => ContextMenuItemMailboxAction(action, action.getContextMenuItemState(mailbox))) @@ -199,9 +211,14 @@ mixin MailboxWidgetMixin { PresentationMailbox mailbox, MailboxController controller ) { + final bool subaddressingSupported = isSubaddressingSupported( + controller.mailboxDashBoardController.sessionCurrent, + controller.mailboxDashBoardController.accountId.value); + final contextMenuActions = listContextMenuItemAction( mailbox, - controller.mailboxDashBoardController.enableSpamReport + controller.mailboxDashBoardController.enableSpamReport, + subaddressingSupported ); if (contextMenuActions.isEmpty) { @@ -254,6 +271,20 @@ mixin MailboxWidgetMixin { .toList(); } + static bool isSubaddressingSupported(Session? session, AccountId? accountId) { + if (session == null || accountId == null) { + return false; + } + if (!CapabilityIdentifier.jmapTeamMailboxes.isSupported(session, accountId)) { + return false; + } + + return (session.getCapabilityProperties(accountId, CapabilityIdentifier.jmapTeamMailboxes) + ?.props[0] as Map?) + ?[subaddressingSupported] + ?? false; + } + PopupMenuItem _buildPopupMenuItem( BuildContext context, ImagePaths imagePaths, diff --git a/lib/features/search/mailbox/presentation/search_mailbox_view.dart b/lib/features/search/mailbox/presentation/search_mailbox_view.dart index beeb620712..b388f23fc9 100644 --- a/lib/features/search/mailbox/presentation/search_mailbox_view.dart +++ b/lib/features/search/mailbox/presentation/search_mailbox_view.dart @@ -196,9 +196,14 @@ class SearchMailboxView extends GetWidget } List _listPopupMenuItemAction(BuildContext context, PresentationMailbox mailbox) { + final bool subaddressingSupported = MailboxWidgetMixin.isSubaddressingSupported( + controller.dashboardController.sessionCurrent, + controller.dashboardController.accountId.value); + final contextMenuActions = listContextMenuItemAction( mailbox, controller.dashboardController.enableSpamReport, + subaddressingSupported ); return contextMenuActions .map((action) => _mailboxFocusedMenuItem(context, action, mailbox)) @@ -252,9 +257,14 @@ class SearchMailboxView extends GetWidget PresentationMailbox mailbox, {RelativeRect? position} ) { + final bool subaddressingSupported = MailboxWidgetMixin.isSubaddressingSupported( + controller.dashboardController.sessionCurrent, + controller.dashboardController.accountId.value); + final contextMenuActions = listContextMenuItemAction( mailbox, controller.dashboardController.enableSpamReport, + subaddressingSupported ); if (contextMenuActions.isEmpty) { diff --git a/model/lib/mailbox/mailbox_constants.dart b/model/lib/mailbox/mailbox_constants.dart index ae75773f98..dd945ace5c 100644 --- a/model/lib/mailbox/mailbox_constants.dart +++ b/model/lib/mailbox/mailbox_constants.dart @@ -1,2 +1,3 @@ const String anyoneIdentifier = 'anyone'; const String postingRight = 'p'; +const String subaddressingSupported = "subaddressingSupported"; diff --git a/test/features/mailbox/presentation/mailbox_widget_mixin_test.dart b/test/features/mailbox/presentation/mailbox_widget_mixin_test.dart new file mode 100644 index 0000000000..8fbf9da276 --- /dev/null +++ b/test/features/mailbox/presentation/mailbox_widget_mixin_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/account/account.dart'; +import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; +import 'package:jmap_dart_client/jmap/core/capability/default_capability.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:jmap_dart_client/jmap/core/state.dart'; +import 'package:jmap_dart_client/jmap/core/user_name.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/mixin/mailbox_widget_mixin.dart'; + +void main() { + group('MailboxWidgetMixin::isSubaddressingSupported::test', () { + + test( + 'should return true ' + 'when the server advertizes true', + () { + + // arrange + final session = Session( + {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({"subaddressingSupported": true})}, + {AccountId(Id("1")): Account(AccountName("name"), true, false, {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({"subaddressingSupported": true})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final subaddressingSupported = MailboxWidgetMixin.isSubaddressingSupported(session, AccountId(Id("1"))); + + // assert + expect(subaddressingSupported, true); + }); + + test( + 'should return false ' + 'when the server advertizes false', + () { + + // arrange + final session = Session( + {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({"subaddressingSupported": false})}, + {AccountId(Id("1")): Account(AccountName("name"), true, false, {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({"subaddressingSupported": false})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final subaddressingSupported = MailboxWidgetMixin.isSubaddressingSupported(session, AccountId(Id("1"))); + + // assert + expect(subaddressingSupported, false); + }); + + test( + 'should return false ' + 'when the server advertizes nothing', + () { + + // arrange + final session = Session( + {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({})}, + {AccountId(Id("1")): Account(AccountName("name"), true, false, {CapabilityIdentifier.jmapTeamMailboxes: DefaultCapability({})})}, + {}, UserName(''), Uri(), Uri(), Uri(), Uri(), State('')); + + // act + final subaddressingSupported = MailboxWidgetMixin.isSubaddressingSupported(session, AccountId(Id("1"))); + + // assert + expect(subaddressingSupported, false); + }); + }); +} \ No newline at end of file From 381c6d847a4199f5b755853693d5b1d297fbb859 Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Fri, 29 Nov 2024 12:25:42 +0100 Subject: [PATCH 4/6] TF-3189 new confirmation popup when enabling subaddressing for a folder --- .../dialog/confirmation_dialog_builder.dart | 18 ++++-- .../base/base_mailbox_controller.dart | 11 +++- .../presentation/mailbox_controller.dart | 62 +++++++++++++++++++ .../widgets/copy_subaddress_widget.dart | 50 +++++++++++++++ lib/l10n/intl_messages.arb | 16 +++++ lib/main/localizations/app_localizations.dart | 14 +++++ 6 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 lib/features/mailbox/presentation/widgets/copy_subaddress_widget.dart diff --git a/core/lib/presentation/views/dialog/confirmation_dialog_builder.dart b/core/lib/presentation/views/dialog/confirmation_dialog_builder.dart index 8cb4d41c75..9730257140 100644 --- a/core/lib/presentation/views/dialog/confirmation_dialog_builder.dart +++ b/core/lib/presentation/views/dialog/confirmation_dialog_builder.dart @@ -12,10 +12,11 @@ class ConfirmDialogBuilder { Key? _key; String _title = ''; - String _content = ''; + String _textContent = ''; String _confirmText = ''; String _cancelText = ''; Widget? _iconWidget; + Widget? _additionalWidgetContent; Color? _colorCancelButton; Color? _colorConfirmButton; TextStyle? _styleTextCancelButton; @@ -63,7 +64,11 @@ class ConfirmDialogBuilder { } void content(String content) { - _content = content; + _textContent = content; + } + + void addWidgetContent(Widget? icon) { + _additionalWidgetContent = icon; } void addIcon(Widget? icon) { @@ -210,11 +215,11 @@ class ConfirmDialogBuilder { ) ) ), - if (_content.isNotEmpty) + if (_textContent.isNotEmpty) Padding( padding: _paddingContent ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Center( - child: Text(_content, + child: Text(_textContent, textAlign: TextAlign.center, style: _styleContent ?? const TextStyle(fontSize: 17.0, color: AppColor.colorMessageDialog) ), @@ -233,6 +238,11 @@ class ConfirmDialogBuilder { ), ), ), + if (_additionalWidgetContent != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _additionalWidgetContent, + ), if (isArrangeActionButtonsVertical) ...[ if (_cancelText.isNotEmpty) diff --git a/lib/features/base/base_mailbox_controller.dart b/lib/features/base/base_mailbox_controller.dart index e1112bbb7a..c914969da9 100644 --- a/lib/features/base/base_mailbox_controller.dart +++ b/lib/features/base/base_mailbox_controller.dart @@ -49,6 +49,11 @@ import 'package:tmail_ui_user/main/routes/app_routes.dart'; import 'package:tmail_ui_user/main/routes/dialog_router.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; +typedef RenameMailboxActionCallback = void Function(PresentationMailbox mailbox, MailboxName newMailboxName); +typedef MovingMailboxActionCallback = void Function(PresentationMailbox mailboxSelected, PresentationMailbox? destinationMailbox); +typedef DeleteMailboxActionCallback = void Function(PresentationMailbox mailbox); +typedef AllowSubaddressingActionCallback = void Function(MailboxId, Map?>?, MailboxActions); + abstract class BaseMailboxController extends BaseController { final TreeBuilder _treeBuilder; final VerifyNameInteractor verifyNameInteractor; @@ -312,7 +317,7 @@ abstract class BaseMailboxController extends BaseController { BuildContext context, PresentationMailbox presentationMailbox, ResponsiveUtils responsiveUtils, { - required Function(PresentationMailbox mailbox, MailboxName newMailboxName) onRenameMailboxAction + required RenameMailboxActionCallback onRenameMailboxAction }) { final listMailboxName = getListMailboxNameInParentMailbox(presentationMailbox); @@ -382,7 +387,7 @@ abstract class BaseMailboxController extends BaseController { BuildContext context, PresentationMailbox mailboxSelected, MailboxDashBoardController dashBoardController, { - required Function(PresentationMailbox mailboxSelected, PresentationMailbox? destinationMailbox) onMovingMailboxAction + required MovingMailboxActionCallback onMovingMailboxAction }) async { final accountId = dashBoardController.accountId.value; final session = dashBoardController.sessionCurrent; @@ -415,7 +420,7 @@ abstract class BaseMailboxController extends BaseController { ResponsiveUtils responsiveUtils, ImagePaths imagePaths, PresentationMailbox presentationMailbox, { - required Function(PresentationMailbox mailbox) onDeleteMailboxAction + required DeleteMailboxActionCallback onDeleteMailboxAction }) { if (responsiveUtils.isLandscapeMobile(context) || responsiveUtils.isPortraitMobile(context)) { (ConfirmationDialogActionSheetBuilder(context) diff --git a/lib/features/mailbox/presentation/mailbox_controller.dart b/lib/features/mailbox/presentation/mailbox_controller.dart index 5cee27cbfa..c1f1370b05 100644 --- a/lib/features/mailbox/presentation/mailbox_controller.dart +++ b/lib/features/mailbox/presentation/mailbox_controller.dart @@ -77,6 +77,7 @@ import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_node.d import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_tree_builder.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/model/open_mailbox_view_event.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/utils/mailbox_utils.dart'; +import 'package:tmail_ui_user/features/mailbox/presentation/widgets/copy_subaddress_widget.dart'; import 'package:tmail_ui_user/features/mailbox_creator/domain/usecases/verify_name_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_creator/presentation/model/mailbox_creator_arguments.dart'; import 'package:tmail_ui_user/features/mailbox_creator/presentation/model/new_mailbox_arguments.dart'; @@ -1091,6 +1092,22 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM _unsubscribeMailboxAction(mailbox.id); break; case MailboxActions.allowSubaddressing: + try{ + final subaddress = getSubaddress(mailboxDashBoardController.userEmail, findNodePathWithSeparator(mailbox.id, '.')!); + openConfirmationDialogSubaddressingAction( + context, + responsiveUtils, + imagePaths, + mailbox.id, + mailbox.getDisplayName(context), + subaddress, + mailbox.rights, + onAllowSubaddressingAction: _handleSubaddressingAction + ); + } catch (error) { + appToast.showToastErrorMessage(context, AppLocalizations.of(context).errorWhileFetchingSubaddress); + } + break; case MailboxActions.disallowSubaddressing: _handleSubaddressingAction(mailbox.id, mailbox.rights, actions); break; @@ -1472,6 +1489,51 @@ class MailboxController extends BaseMailboxController with MailboxActionHandlerM } } + void openConfirmationDialogSubaddressingAction( + BuildContext context, + ResponsiveUtils responsiveUtils, + ImagePaths imagePaths, + MailboxId mailboxId, + String mailboxName, + String subaddress, + Map?>? currentRights, { + required AllowSubaddressingActionCallback onAllowSubaddressingAction + }) { + if (responsiveUtils.isLandscapeMobile(context) || responsiveUtils.isPortraitMobile(context)) { + (ConfirmationDialogActionSheetBuilder(context) + ..messageText(AppLocalizations.of(context).message_confirmation_dialog_allow_subaddressing(mailboxName)) + ..onCancelAction(AppLocalizations.of(context).cancel, () => popBack()) + ..onConfirmAction(AppLocalizations.of(context).allow, () => onAllowSubaddressingAction(mailboxId, currentRights, MailboxActions.allowSubaddressing)) + ).show(); + } else { + Get.dialog( + PointerInterceptor( + child: (ConfirmDialogBuilder(imagePaths) + ..key(const Key('confirm_dialog_subaddressing')) + ..title(AppLocalizations.of(context).allowSubaddressing) + ..styleTitle(const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w500, + color: AppColor.colorTextButton + )) + ..content(AppLocalizations.of(context).message_confirmation_dialog_allow_subaddressing(mailboxName)) + ..addIcon(SvgPicture.asset(imagePaths.icSubaddressingAllow, width: 64, height: 64)) + ..addWidgetContent(CopySubaddressWidget( + context: context, + imagePath: imagePaths, + subaddress: subaddress, + onCopyButtonAction: () => copySubaddressAction(context, subaddress), + )) + ..colorCancelButton(AppColor.colorContentEmail) + ..onCloseButtonAction(() => popBack()) + ..onConfirmButtonAction(AppLocalizations.of(context).allow, () => onAllowSubaddressingAction(mailboxId, currentRights, MailboxActions.allowSubaddressing)) + ..onCancelButtonAction(AppLocalizations.of(context).cancel, () => popBack()) + ).build()), + barrierColor: AppColor.colorDefaultCupertinoActionSheet, + ); + } + } + void copySubaddressAction(BuildContext context, String subaddress) { Clipboard.setData(ClipboardData(text: subaddress)); appToast.showToastSuccessMessage(context, AppLocalizations.of(context).emailSubaddressCopiedToClipboard); diff --git a/lib/features/mailbox/presentation/widgets/copy_subaddress_widget.dart b/lib/features/mailbox/presentation/widgets/copy_subaddress_widget.dart new file mode 100644 index 0000000000..9f4642c95b --- /dev/null +++ b/lib/features/mailbox/presentation/widgets/copy_subaddress_widget.dart @@ -0,0 +1,50 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:flutter/material.dart'; + +typedef OnCopyButtonAction = void Function(); + +class CopySubaddressWidget extends StatelessWidget { + + final BuildContext context; + final ImagePaths imagePath; + final String subaddress; + + final OnCopyButtonAction onCopyButtonAction; + + const CopySubaddressWidget({ + super.key, + required this.context, + required this.imagePath, + required this.subaddress, + required this.onCopyButtonAction + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + subaddress, + style: const TextStyle(fontSize: 17.0, color: AppColor.colorMessageDialog), + ), + ), + TMailButtonWidget.fromIcon( + icon: imagePath.icCopy, + iconSize: 30, + padding: const EdgeInsets.all(3), + backgroundColor: Colors.transparent, + margin: const EdgeInsetsDirectional.only(top: 16, end: 16), + onTapActionCallback: onCopyButtonAction + ) + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index 6780fade10..a5d98b2a5f 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -698,6 +698,16 @@ "nameMailbox": {} } }, + "message_confirmation_dialog_allow_subaddressing": "You are about to allow anyone to send emails directly to your folder \"{nameMailbox}\" using:", + "@message_confirmation_dialog_allow_subaddressing": { + "type": "text", + "placeholders_order": [ + "nameMailbox" + ], + "placeholders": { + "nameMailbox": {} + } + }, "renameFolder": "Rename folder", "@renameFolder": { "type": "text", @@ -2338,6 +2348,12 @@ "placeholders_order": [], "placeholders": {} }, + "allow": "Allow", + "@allow": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "disallowSubaddressing": "Disallow subaddressing", "@disallowSubaddressing": { "type": "text", diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 0be68b3531..424bbbdc6d 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -716,6 +716,14 @@ class AppLocalizations { ); } + String message_confirmation_dialog_allow_subaddressing(String nameMailbox) { + return Intl.message( + 'You are about to allow anyone to send emails directly to your folder "$nameMailbox" using:', + name: 'message_confirmation_dialog_allow_subaddressing', + args: [nameMailbox] + ); + } + String get renameFolder { return Intl.message( 'Rename folder', @@ -2415,6 +2423,12 @@ class AppLocalizations { name: 'allowSubaddressing'); } + String get allow { + return Intl.message( + 'Allow', + name: 'allow'); + } + String get disallowSubaddressing { return Intl.message( 'Disallow subaddressing', From 028e3d7cc728dfcd084a8ba4eea4c96b4b7ffd35 Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Wed, 30 Oct 2024 16:02:00 +0100 Subject: [PATCH 5/6] TF-3189 new `reply to` field in the mail composer --- .../utils/scenario_utils_mixin.dart | 1 + .../presentation/composer_controller.dart | 106 +++++++++++++++++- .../composer/presentation/composer_view.dart | 52 ++++++++- .../presentation/composer_view_web.dart | 72 ++++++++++++ .../create_email_request_extension.dart | 4 +- .../email_action_type_extension.dart | 7 ++ .../prefix_email_address_extension.dart | 4 + .../model/create_email_request.dart | 3 + .../presentation/model/saved_email_draft.dart | 3 + .../presentation/styles/composer_style.dart | 6 +- .../widgets/recipient_composer_widget.dart | 15 ++- lib/l10n/intl_messages.arb | 8 +- lib/main/localizations/app_localizations.dart | 7 ++ model/lib/email/prefix_email_address.dart | 3 +- .../presentation_email_extension.dart | 12 +- ..._save_email_to_drafts_interactor_test.dart | 2 + ...te_new_and_send_email_interactor_test.dart | 1 + .../composer_controller_test.dart | 37 ++++++ .../create_email_request_extension_test.dart | 1 + .../model/saved_email_draft_test.dart | 33 +++++- .../presentation_email_extension_test.dart | 43 ++++--- 21 files changed, 376 insertions(+), 44 deletions(-) diff --git a/integration_test/utils/scenario_utils_mixin.dart b/integration_test/utils/scenario_utils_mixin.dart index 76e5b35d9b..c70bfa9b0a 100644 --- a/integration_test/utils/scenario_utils_mixin.dart +++ b/integration_test/utils/scenario_utils_mixin.dart @@ -53,6 +53,7 @@ mixin ScenarioUtilsMixin { toRecipients: {EmailAddress(null, provisioningEmail.toEmail)}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, outboxMailboxId: mailboxDashBoardController.outboxMailbox?.mailboxId, sentMailboxId: mailboxDashBoardController.mapDefaultMailboxIdByRole[PresentationMailbox.roleSent], identity: identity, diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index c09f569a42..751f64c329 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -121,11 +121,13 @@ class ComposerController extends BaseController final toAddressExpandMode = ExpandMode.EXPAND.obs; final ccAddressExpandMode = ExpandMode.EXPAND.obs; final bccAddressExpandMode = ExpandMode.EXPAND.obs; + final replyToAddressExpandMode = ExpandMode.EXPAND.obs; final emailContentsViewState = Rxn>(); final hasRequestReadReceipt = false.obs; final fromRecipientState = PrefixRecipientState.disabled.obs; final ccRecipientState = PrefixRecipientState.disabled.obs; final bccRecipientState = PrefixRecipientState.disabled.obs; + final replyToRecipientState = PrefixRecipientState.disabled.obs; final identitySelected = Rxn(); final listFromIdentities = RxList(); @@ -150,6 +152,7 @@ class ComposerController extends BaseController List listToEmailAddress = []; List listCcEmailAddress = []; List listBccEmailAddress = []; + List listReplyToEmailAddress = []; ContactSuggestionSource _contactSuggestionSource = ContactSuggestionSource.tMailContact; final subjectEmailInputController = LanguageToolController( @@ -158,11 +161,13 @@ class ComposerController extends BaseController final toEmailAddressController = TextEditingController(); final ccEmailAddressController = TextEditingController(); final bccEmailAddressController = TextEditingController(); + final replyToEmailAddressController = TextEditingController(); final searchIdentitiesInputController = TextEditingController(); final GlobalKey keyToEmailTagEditor = GlobalKey(); final GlobalKey keyCcEmailTagEditor = GlobalKey(); final GlobalKey keyBccEmailTagEditor = GlobalKey(); + final GlobalKey keyReplyToEmailTagEditor = GlobalKey(); final GlobalKey headerEditorMobileWidgetKey = GlobalKey(); final GlobalKey identityDropdownKey = GlobalKey(); final double defaultPaddingCoordinateYCursorEditor = 8; @@ -171,10 +176,12 @@ class ComposerController extends BaseController FocusNode? toAddressFocusNode; FocusNode? ccAddressFocusNode; FocusNode? bccAddressFocusNode; + FocusNode? replyToAddressFocusNode; FocusNode? searchIdentitiesFocusNode; FocusNode? toAddressFocusNodeKeyboard; FocusNode? ccAddressFocusNodeKeyboard; FocusNode? bccAddressFocusNodeKeyboard; + FocusNode? replyToAddressFocusNodeKeyboard; StreamSubscription? _subscriptionOnDragEnter; StreamSubscription? _subscriptionOnDragOver; @@ -292,18 +299,23 @@ class ComposerController extends BaseController ccAddressFocusNode = null; bccAddressFocusNode?.dispose(); bccAddressFocusNode = null; + replyToAddressFocusNode?.dispose(); + replyToAddressFocusNode = null; toAddressFocusNodeKeyboard?.dispose(); toAddressFocusNodeKeyboard = null; ccAddressFocusNodeKeyboard?.dispose(); ccAddressFocusNodeKeyboard = null; bccAddressFocusNodeKeyboard?.dispose(); bccAddressFocusNodeKeyboard = null; + replyToAddressFocusNodeKeyboard?.dispose(); + replyToAddressFocusNodeKeyboard = null; searchIdentitiesFocusNode?.dispose(); searchIdentitiesFocusNode = null; subjectEmailInputController.dispose(); toEmailAddressController.dispose(); ccEmailAddressController.dispose(); bccEmailAddressController.dispose(); + replyToEmailAddressController.dispose(); uploadInlineImageWorker.dispose(); dashboardViewStateWorker.dispose(); scrollController.dispose(); @@ -473,6 +485,7 @@ class ComposerController extends BaseController toRecipients: listToEmailAddress.toSet(), ccRecipients: listCcEmailAddress.toSet(), bccRecipients: listBccEmailAddress.toSet(), + replyToRecipients: listReplyToEmailAddress.toSet(), hasRequestReadReceipt: hasRequestReadReceipt.value, identity: identitySelected.value, attachments: uploadController.attachmentsUploaded, @@ -500,16 +513,21 @@ class ComposerController extends BaseController if (bccEmailAddressController.text.isNotEmpty) { keyBccEmailTagEditor.currentState?.closeSuggestionBox(); } + if (replyToEmailAddressController.text.isNotEmpty) { + keyReplyToEmailTagEditor.currentState?.closeSuggestionBox(); + } } void createFocusNodeInput() { toAddressFocusNode = FocusNode(); ccAddressFocusNode = FocusNode(); bccAddressFocusNode = FocusNode(); + replyToAddressFocusNode = FocusNode(); searchIdentitiesFocusNode = FocusNode(); toAddressFocusNodeKeyboard = FocusNode(); ccAddressFocusNodeKeyboard = FocusNode(); bccAddressFocusNodeKeyboard = FocusNode(); + replyToAddressFocusNodeKeyboard = FocusNode(); subjectEmailInputFocusNode = FocusNode( onKeyEvent: PlatformInfo.isWeb ? _subjectEmailInputOnKeyListener : null, @@ -816,18 +834,21 @@ class ComposerController extends BaseController listToEmailAddress = List.from(recipients.value1.toSet()); listCcEmailAddress = List.from(recipients.value2.toSet()); listBccEmailAddress = List.from(recipients.value3.toSet()); + listReplyToEmailAddress = List.from(recipients.value4.toSet()); } else { listToEmailAddress = List.from(recipients.value1.toSet().filterEmailAddress(userName.value)); listCcEmailAddress = List.from(recipients.value2.toSet().filterEmailAddress(userName.value)); listBccEmailAddress = List.from(recipients.value3.toSet().filterEmailAddress(userName.value)); + listReplyToEmailAddress = List.from(recipients.value4.toSet()); } } else { listToEmailAddress = List.from(recipients.value1.toSet()); listCcEmailAddress = List.from(recipients.value2.toSet()); listBccEmailAddress = List.from(recipients.value3.toSet()); + listReplyToEmailAddress = List.from(recipients.value4.toSet()); } - if (listToEmailAddress.isNotEmpty || listCcEmailAddress.isNotEmpty || listBccEmailAddress.isNotEmpty) { + if (listToEmailAddress.isNotEmpty || listCcEmailAddress.isNotEmpty || listBccEmailAddress.isNotEmpty || listReplyToEmailAddress.isNotEmpty) { isInitialRecipient.value = true; toAddressExpandMode.value = ExpandMode.COLLAPSE; } @@ -842,6 +863,11 @@ class ComposerController extends BaseController bccAddressExpandMode.value = ExpandMode.COLLAPSE; } + if (listReplyToEmailAddress.isNotEmpty) { + replyToRecipientState.value = PrefixRecipientState.enabled; + replyToAddressExpandMode.value = ExpandMode.COLLAPSE; + } + _updateStatusEmailSendButton(); } @@ -859,6 +885,9 @@ class ComposerController extends BaseController case PrefixEmailAddress.bcc: listBccEmailAddress = List.from(newListEmailAddress); break; + case PrefixEmailAddress.replyTo: + listReplyToEmailAddress = List.from(newListEmailAddress); + break; default: break; } @@ -867,8 +896,9 @@ class ComposerController extends BaseController void _updateStatusEmailSendButton() { if (listToEmailAddress.isNotEmpty + || listCcEmailAddress.isNotEmpty || listBccEmailAddress.isNotEmpty - || listCcEmailAddress.isNotEmpty) { + || listReplyToEmailAddress.isNotEmpty) { isEnableEmailSendButton.value = true; } else { isEnableEmailSendButton.value = false; @@ -886,7 +916,8 @@ class ComposerController extends BaseController if (toEmailAddressController.text.isNotEmpty || ccEmailAddressController.text.isNotEmpty - || bccEmailAddressController.text.isNotEmpty) { + || bccEmailAddressController.text.isNotEmpty + || replyToEmailAddressController.text.isNotEmpty) { _collapseAllRecipient(); _autoCreateEmailTag(); } @@ -903,7 +934,7 @@ class ComposerController extends BaseController return; } - final allListEmailAddress = listToEmailAddress + listCcEmailAddress + listBccEmailAddress; + final allListEmailAddress = listToEmailAddress + listCcEmailAddress + listBccEmailAddress + listReplyToEmailAddress; final listEmailAddressInvalid = allListEmailAddress .where((emailAddress) => !EmailUtils.isEmailAddressValid(emailAddress.emailAddress)) .toList(); @@ -915,6 +946,7 @@ class ComposerController extends BaseController toAddressExpandMode.value = ExpandMode.EXPAND; ccAddressExpandMode.value = ExpandMode.EXPAND; bccAddressExpandMode.value = ExpandMode.EXPAND; + replyToAddressExpandMode.value = ExpandMode.EXPAND; }, showAsBottomSheet: true, title: AppLocalizations.of(context).sending_failed, @@ -1034,6 +1066,7 @@ class ComposerController extends BaseController toRecipients: listToEmailAddress.toSet(), ccRecipients: listCcEmailAddress.toSet(), bccRecipients: listBccEmailAddress.toSet(), + replyToRecipients: listReplyToEmailAddress.toSet(), hasRequestReadReceipt: hasRequestReadReceipt.value, identity: identitySelected.value, attachments: uploadController.attachmentsUploaded, @@ -1245,6 +1278,7 @@ class ComposerController extends BaseController toRecipients: listToEmailAddress.toSet(), ccRecipients: listCcEmailAddress.toSet(), bccRecipients: listBccEmailAddress.toSet(), + replyToRecipients: listReplyToEmailAddress.toSet(), identity: identitySelected.value, attachments: uploadController.attachmentsUploaded, hasReadReceipt: hasRequestReadReceipt.value, @@ -1521,6 +1555,9 @@ class ComposerController extends BaseController case PrefixEmailAddress.bcc: bccRecipientState.value = PrefixRecipientState.enabled; break; + case PrefixEmailAddress.replyTo: + replyToRecipientState.value = PrefixRecipientState.enabled; + break; default: break; } @@ -1539,6 +1576,11 @@ class ComposerController extends BaseController bccAddressFocusNode = FocusNode(); bccEmailAddressController.clear(); break; + case PrefixEmailAddress.replyTo: + replyToRecipientState.value = PrefixRecipientState.disabled; + replyToAddressFocusNode = FocusNode(); + replyToEmailAddressController.clear(); + break; default: break; } @@ -1548,12 +1590,14 @@ class ComposerController extends BaseController toAddressExpandMode.value = ExpandMode.COLLAPSE; ccAddressExpandMode.value = ExpandMode.COLLAPSE; bccAddressExpandMode.value = ExpandMode.COLLAPSE; + replyToAddressExpandMode.value = ExpandMode.COLLAPSE; } void _autoCreateEmailTag() { final inputToEmail = toEmailAddressController.text; final inputCcEmail = ccEmailAddressController.text; final inputBccEmail = bccEmailAddressController.text; + final inputReplyToEmail = replyToEmailAddressController.text; if (inputToEmail.isNotEmpty) { _autoCreateToEmailTag(inputToEmail); @@ -1564,6 +1608,9 @@ class ComposerController extends BaseController if (inputBccEmail.isNotEmpty) { _autoCreateBccEmailTag(inputBccEmail); } + if (inputReplyToEmail.isNotEmpty) { + _autoCreateReplyToEmailTag(inputReplyToEmail); + } } bool _isDuplicatedRecipient(String inputEmail, List listEmailAddress) { @@ -1616,6 +1663,20 @@ class ComposerController extends BaseController }); } + void _autoCreateReplyToEmailTag(String inputEmail) { + if (!_isDuplicatedRecipient(inputEmail, listReplyToEmailAddress)) { + final emailAddress = EmailAddress(null, inputEmail); + listReplyToEmailAddress.add(emailAddress); + isInitialRecipient.value = true; + isInitialRecipient.refresh(); + _updateStatusEmailSendButton(); + } + keyReplyToEmailTagEditor.currentState?.resetTextField(); + Future.delayed(const Duration(milliseconds: 300), () { + keyReplyToEmailTagEditor.currentState?.closeSuggestionBox(); + }); + } + void _closeSuggestionBox() { if (toEmailAddressController.text.isEmpty) { keyToEmailTagEditor.currentState?.closeSuggestionBox(); @@ -1626,6 +1687,9 @@ class ComposerController extends BaseController if (bccEmailAddressController.text.isEmpty) { keyBccEmailTagEditor.currentState?.closeSuggestionBox(); } + if (replyToEmailAddressController.text.isEmpty) { + keyReplyToEmailTagEditor.currentState?.closeSuggestionBox(); + } } void showFullEmailAddress(PrefixEmailAddress prefixEmailAddress) { @@ -1642,6 +1706,10 @@ class ComposerController extends BaseController bccAddressExpandMode.value = ExpandMode.EXPAND; bccAddressFocusNode?.requestFocus(); break; + case PrefixEmailAddress.replyTo: + replyToAddressExpandMode.value = ExpandMode.EXPAND; + replyToAddressFocusNode?.requestFocus(); + break; default: break; } @@ -1659,6 +1727,9 @@ class ComposerController extends BaseController case PrefixEmailAddress.bcc: bccAddressExpandMode.value = ExpandMode.EXPAND; break; + case PrefixEmailAddress.replyTo: + replyToAddressExpandMode.value = ExpandMode.EXPAND; + break; default: break; } @@ -1691,6 +1762,13 @@ class ComposerController extends BaseController _autoCreateBccEmailTag(inputBccEmail); } break; + case PrefixEmailAddress.replyTo: + replyToAddressExpandMode.value = ExpandMode.COLLAPSE; + final inputReplyToEmail = replyToEmailAddressController.text; + if (inputReplyToEmail.isNotEmpty) { + _autoCreateReplyToEmailTag(inputReplyToEmail); + } + break; default: break; } @@ -1920,12 +1998,14 @@ class ComposerController extends BaseController return toAddressFocusNode?.hasFocus == true || ccAddressFocusNode?.hasFocus == true || bccAddressFocusNode?.hasFocus == true || + replyToAddressFocusNode?.hasFocus == true || subjectEmailInputFocusNode?.hasFocus == true; } else if (PlatformInfo.isMobile) { final isEditorFocused = (await richTextMobileTabletController?.isEditorFocused) ?? false; return toAddressFocusNode?.hasFocus == true || ccAddressFocusNode?.hasFocus == true || bccAddressFocusNode?.hasFocus == true || + replyToAddressFocusNode?.hasFocus == true || subjectEmailInputFocusNode?.hasFocus == true || isEditorFocused; } @@ -1970,6 +2050,8 @@ class ComposerController extends BaseController return ccAddressFocusNode; } else if (bccRecipientState.value == PrefixRecipientState.enabled) { return bccAddressFocusNode; + } else if (replyToRecipientState.value == PrefixRecipientState.enabled) { + return replyToAddressFocusNode; } else { return subjectEmailInputFocusNode; } @@ -1978,6 +2060,16 @@ class ComposerController extends BaseController FocusNode? getNextFocusOfCcEmailAddress() { if (bccRecipientState.value == PrefixRecipientState.enabled) { return bccAddressFocusNode; + } else if (replyToRecipientState.value == PrefixRecipientState.enabled) { + return replyToAddressFocusNode; + } else { + return subjectEmailInputFocusNode; + } + } + + FocusNode? getNextFocusOfBccEmailAddress() { + if (replyToRecipientState.value == PrefixRecipientState.enabled) { + return replyToAddressFocusNode; } else { return subjectEmailInputFocusNode; } @@ -2033,6 +2125,10 @@ class ComposerController extends BaseController listBccEmailAddress.remove(draggableEmailAddress.emailAddress); bccAddressExpandMode.value = ExpandMode.EXPAND; break; + case PrefixEmailAddress.replyTo: + listReplyToEmailAddress.remove(draggableEmailAddress.emailAddress); + replyToAddressExpandMode.value = ExpandMode.EXPAND; + break; default: break; } @@ -2298,6 +2394,7 @@ class ComposerController extends BaseController toRecipients: listToEmailAddress.toSet(), ccRecipients: listCcEmailAddress.toSet(), bccRecipients: listBccEmailAddress.toSet(), + replyToRecipients: listReplyToEmailAddress.toSet(), hasRequestReadReceipt: hasRequestReadReceipt.value, identity: identitySelected.value, attachments: uploadController.attachmentsUploaded, @@ -2379,6 +2476,7 @@ class ComposerController extends BaseController fromRecipientState.value = isEnabled ? PrefixRecipientState.disabled : PrefixRecipientState.enabled; ccRecipientState.value = isEnabled ? PrefixRecipientState.disabled : PrefixRecipientState.enabled; bccRecipientState.value = isEnabled ? PrefixRecipientState.disabled : PrefixRecipientState.enabled; + replyToRecipientState.value = isEnabled ? PrefixRecipientState.disabled : PrefixRecipientState.enabled; } void _handleGetEmailContentFailure(GetEmailContentFailure failure) { diff --git a/lib/features/composer/presentation/composer_view.dart b/lib/features/composer/presentation/composer_view.dart index a7b9974bb4..5b0a52a0ed 100644 --- a/lib/features/composer/presentation/composer_view.dart +++ b/lib/features/composer/presentation/composer_view.dart @@ -130,6 +130,7 @@ class ComposerView extends GetWidget { fromState: controller.fromRecipientState.value, ccState: controller.ccRecipientState.value, bccState: controller.bccRecipientState.value, + replyToState: controller.replyToRecipientState.value, expandMode: controller.toAddressExpandMode.value, controller: controller.toEmailAddressController, focusNode: controller.toAddressFocusNode, @@ -190,9 +191,9 @@ class ComposerView extends GetWidget { focusNode: controller.bccAddressFocusNode, keyTagEditor: controller.keyBccEmailTagEditor, isInitial: controller.isInitialRecipient.value, - nextFocusNode: controller.subjectEmailInputFocusNode, padding: ComposerStyle.mobileRecipientPadding, margin: ComposerStyle.mobileRecipientMargin, + nextFocusNode: controller.getNextFocusOfBccEmailAddress(), onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, onShowFullListEmailAddressAction: controller.showFullEmailAddress, onDeleteEmailAddressTypeAction: controller.deleteEmailAddressType, @@ -204,6 +205,33 @@ class ComposerView extends GetWidget { return const SizedBox.shrink(); } }), + Obx(() { + if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) { + return RecipientComposerWidget( + prefix: PrefixEmailAddress.replyTo, + listEmailAddress: controller.listReplyToEmailAddress, + imagePaths: controller.imagePaths, + maxWidth: constraints.maxWidth, + expandMode: controller.replyToAddressExpandMode.value, + controller: controller.replyToEmailAddressController, + focusNode: controller.replyToAddressFocusNode, + keyTagEditor: controller.keyReplyToEmailTagEditor, + isInitial: controller.isInitialRecipient.value, + padding: ComposerStyle.mobileRecipientPadding, + margin: ComposerStyle.mobileRecipientMargin, + nextFocusNode: controller.subjectEmailInputFocusNode, + onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, + onShowFullListEmailAddressAction: controller.showFullEmailAddress, + onAddEmailAddressTypeAction: controller.addEmailAddressType, + onUpdateListEmailAddressAction: controller.updateListEmailAddress, + onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, + onFocusNextAddressAction: controller.handleFocusNextAddressAction, + onEnableAllRecipientsInputAction: controller.handleEnableRecipientsInputAction, + ); + } else { + return const SizedBox.shrink(); + } + }), SubjectComposerWidget( focusNode: controller.subjectEmailInputFocusNode, textController: controller.subjectEmailInputController, @@ -295,6 +323,7 @@ class ComposerView extends GetWidget { fromState: controller.fromRecipientState.value, ccState: controller.ccRecipientState.value, bccState: controller.bccRecipientState.value, + replyToState: controller.replyToRecipientState.value, expandMode: controller.toAddressExpandMode.value, controller: controller.toEmailAddressController, focusNode: controller.toAddressFocusNode, @@ -349,6 +378,27 @@ class ComposerView extends GetWidget { focusNode: controller.bccAddressFocusNode, keyTagEditor: controller.keyBccEmailTagEditor, isInitial: controller.isInitialRecipient.value, + nextFocusNode: controller.getNextFocusOfBccEmailAddress(), + padding: ComposerStyle.mobileRecipientPadding, + margin: ComposerStyle.mobileRecipientMargin, + onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, + onShowFullListEmailAddressAction: controller.showFullEmailAddress, + onDeleteEmailAddressTypeAction: controller.deleteEmailAddressType, + onUpdateListEmailAddressAction: controller.updateListEmailAddress, + onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, + onFocusNextAddressAction: controller.handleFocusNextAddressAction, + ), + if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) + RecipientComposerWidget( + prefix: PrefixEmailAddress.replyTo, + listEmailAddress: controller.listReplyToEmailAddress, + imagePaths: controller.imagePaths, + maxWidth: constraints.maxWidth, + expandMode: controller.replyToAddressExpandMode.value, + controller: controller.replyToEmailAddressController, + focusNode: controller.replyToAddressFocusNode, + keyTagEditor: controller.keyReplyToEmailTagEditor, + isInitial: controller.isInitialRecipient.value, nextFocusNode: controller.subjectEmailInputFocusNode, padding: ComposerStyle.mobileRecipientPadding, margin: ComposerStyle.mobileRecipientMargin, diff --git a/lib/features/composer/presentation/composer_view_web.dart b/lib/features/composer/presentation/composer_view_web.dart index 1fc90ffdbd..c27b1087b3 100644 --- a/lib/features/composer/presentation/composer_view_web.dart +++ b/lib/features/composer/presentation/composer_view_web.dart @@ -95,6 +95,7 @@ class ComposerView extends GetWidget { fromState: controller.fromRecipientState.value, ccState: controller.ccRecipientState.value, bccState: controller.bccRecipientState.value, + replyToState: controller.replyToRecipientState.value, expandMode: controller.toAddressExpandMode.value, controller: controller.toEmailAddressController, focusNode: controller.toAddressFocusNode, @@ -153,6 +154,29 @@ class ComposerView extends GetWidget { focusNodeKeyboard: controller.bccAddressFocusNodeKeyboard, keyTagEditor: controller.keyBccEmailTagEditor, isInitial: controller.isInitialRecipient.value, + nextFocusNode: controller.getNextFocusOfCcEmailAddress(), + padding: ComposerStyle.mobileRecipientPadding, + margin: ComposerStyle.mobileRecipientMargin, + onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, + onShowFullListEmailAddressAction: controller.showFullEmailAddress, + onDeleteEmailAddressTypeAction: controller.deleteEmailAddressType, + onUpdateListEmailAddressAction: controller.updateListEmailAddress, + onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, + onFocusNextAddressAction: controller.handleFocusNextAddressAction, + onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + ), + if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) + RecipientComposerWidget( + prefix: PrefixEmailAddress.replyTo, + listEmailAddress: controller.listReplyToEmailAddress, + imagePaths: controller.imagePaths, + maxWidth: constraints.maxWidth, + expandMode: controller.replyToAddressExpandMode.value, + controller: controller.replyToEmailAddressController, + focusNode: controller.replyToAddressFocusNode, + focusNodeKeyboard: controller.replyToAddressFocusNodeKeyboard, + keyTagEditor: controller.keyReplyToEmailTagEditor, + isInitial: controller.isInitialRecipient.value, nextFocusNode: controller.subjectEmailInputFocusNode, padding: ComposerStyle.mobileRecipientPadding, margin: ComposerStyle.mobileRecipientMargin, @@ -345,6 +369,7 @@ class ComposerView extends GetWidget { fromState: controller.fromRecipientState.value, ccState: controller.ccRecipientState.value, bccState: controller.bccRecipientState.value, + replyToState: controller.replyToRecipientState.value, expandMode: controller.toAddressExpandMode.value, controller: controller.toEmailAddressController, focusNode: controller.toAddressFocusNode, @@ -403,6 +428,29 @@ class ComposerView extends GetWidget { focusNodeKeyboard: controller.bccAddressFocusNodeKeyboard, keyTagEditor: controller.keyBccEmailTagEditor, isInitial: controller.isInitialRecipient.value, + nextFocusNode: controller.getNextFocusOfBccEmailAddress(), + padding: ComposerStyle.desktopRecipientPadding, + margin: ComposerStyle.desktopRecipientMargin, + onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, + onShowFullListEmailAddressAction: controller.showFullEmailAddress, + onDeleteEmailAddressTypeAction: controller.deleteEmailAddressType, + onUpdateListEmailAddressAction: controller.updateListEmailAddress, + onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, + onFocusNextAddressAction: controller.handleFocusNextAddressAction, + onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + ), + if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) + RecipientComposerWidget( + prefix: PrefixEmailAddress.replyTo, + listEmailAddress: controller.listReplyToEmailAddress, + imagePaths: controller.imagePaths, + maxWidth: constraints.maxWidth, + expandMode: controller.replyToAddressExpandMode.value, + controller: controller.replyToEmailAddressController, + focusNode: controller.replyToAddressFocusNode, + focusNodeKeyboard: controller.replyToAddressFocusNodeKeyboard, + keyTagEditor: controller.keyReplyToEmailTagEditor, + isInitial: controller.isInitialRecipient.value, nextFocusNode: controller.subjectEmailInputFocusNode, padding: ComposerStyle.desktopRecipientPadding, margin: ComposerStyle.desktopRecipientMargin, @@ -628,6 +676,7 @@ class ComposerView extends GetWidget { fromState: controller.fromRecipientState.value, ccState: controller.ccRecipientState.value, bccState: controller.bccRecipientState.value, + replyToState: controller.replyToRecipientState.value, expandMode: controller.toAddressExpandMode.value, controller: controller.toEmailAddressController, focusNode: controller.toAddressFocusNode, @@ -686,6 +735,29 @@ class ComposerView extends GetWidget { focusNodeKeyboard: controller.bccAddressFocusNodeKeyboard, keyTagEditor: controller.keyBccEmailTagEditor, isInitial: controller.isInitialRecipient.value, + nextFocusNode: controller.getNextFocusOfBccEmailAddress(), + padding: ComposerStyle.tabletRecipientPadding, + margin: ComposerStyle.tabletRecipientMargin, + onFocusEmailAddressChangeAction: controller.onEmailAddressFocusChange, + onShowFullListEmailAddressAction: controller.showFullEmailAddress, + onDeleteEmailAddressTypeAction: controller.deleteEmailAddressType, + onUpdateListEmailAddressAction: controller.updateListEmailAddress, + onSuggestionEmailAddress: controller.getAutoCompleteSuggestion, + onFocusNextAddressAction: controller.handleFocusNextAddressAction, + onRemoveDraggableEmailAddressAction: controller.removeDraggableEmailAddress, + ), + if (controller.replyToRecipientState.value == PrefixRecipientState.enabled) + RecipientComposerWidget( + prefix: PrefixEmailAddress.replyTo, + listEmailAddress: controller.listReplyToEmailAddress, + imagePaths: controller.imagePaths, + maxWidth: constraints.maxWidth, + expandMode: controller.replyToAddressExpandMode.value, + controller: controller.replyToEmailAddressController, + focusNode: controller.replyToAddressFocusNode, + focusNodeKeyboard: controller.replyToAddressFocusNodeKeyboard, + keyTagEditor: controller.keyReplyToEmailTagEditor, + isInitial: controller.isInitialRecipient.value, nextFocusNode: controller.subjectEmailInputFocusNode, padding: ComposerStyle.tabletRecipientPadding, margin: ComposerStyle.tabletRecipientMargin, diff --git a/lib/features/composer/presentation/extensions/create_email_request_extension.dart b/lib/features/composer/presentation/extensions/create_email_request_extension.dart index f6de928bb1..9d16ad83d2 100644 --- a/lib/features/composer/presentation/extensions/create_email_request_extension.dart +++ b/lib/features/composer/presentation/extensions/create_email_request_extension.dart @@ -39,7 +39,9 @@ extension CreateEmailRequestExtension on CreateEmailRequest { } Set createReplyToRecipients() { - if (identity?.replyTo?.isNotEmpty == true) { + if (replyToRecipients.isNotEmpty) { + return replyToRecipients.toSet(); + } else if (identity?.replyTo?.isNotEmpty == true) { return identity!.replyTo!.toSet(); } else { return { session.username.toEmailAddress() }; diff --git a/lib/features/composer/presentation/extensions/email_action_type_extension.dart b/lib/features/composer/presentation/extensions/email_action_type_extension.dart index 5c0a523d15..aaa4341fb8 100644 --- a/lib/features/composer/presentation/extensions/email_action_type_extension.dart +++ b/lib/features/composer/presentation/extensions/email_action_type_extension.dart @@ -75,6 +75,7 @@ extension EmailActionTypeExtension on EmailActionType { final toEmailAddress = presentationEmail.to.listEmailAddressToString(isFullEmailAddress: true); final ccEmailAddress = presentationEmail.cc.listEmailAddressToString(isFullEmailAddress: true); final bccEmailAddress = presentationEmail.bcc.listEmailAddressToString(isFullEmailAddress: true); + final replyToEmailAddress = presentationEmail.replyTo.listEmailAddressToString(isFullEmailAddress: true); if (subject.isNotEmpty) { headerQuoted = headerQuoted @@ -112,6 +113,12 @@ extension EmailActionTypeExtension on EmailActionType { .append(bccEmailAddress) .addNewLineTag(); } + if (replyToEmailAddress.isNotEmpty) { + headerQuoted = headerQuoted + .append('${AppLocalizations.of(context).reply_to_email_address_prefix}: ') + .append(replyToEmailAddress) + .addNewLineTag(); + } return headerQuoted; default: diff --git a/lib/features/composer/presentation/extensions/prefix_email_address_extension.dart b/lib/features/composer/presentation/extensions/prefix_email_address_extension.dart index b9780fb103..a425c6a62e 100644 --- a/lib/features/composer/presentation/extensions/prefix_email_address_extension.dart +++ b/lib/features/composer/presentation/extensions/prefix_email_address_extension.dart @@ -14,6 +14,8 @@ extension PrefixEmailAddressExtension on PrefixEmailAddress { return AppLocalizations.of(context).cc_email_address_prefix; case PrefixEmailAddress.bcc: return AppLocalizations.of(context).bcc_email_address_prefix; + case PrefixEmailAddress.replyTo: + return AppLocalizations.of(context).reply_to_email_address_prefix; case PrefixEmailAddress.from: return AppLocalizations.of(context).from_email_address_prefix; } @@ -27,6 +29,8 @@ extension PrefixEmailAddressExtension on PrefixEmailAddress { return email.cc?.toList() ?? List.empty(); case PrefixEmailAddress.bcc: return email.bcc?.toList() ?? List.empty(); + case PrefixEmailAddress.replyTo: + return email.replyTo?.toList() ?? List.empty(); case PrefixEmailAddress.from: return email.from?.toList() ?? List.empty(); } diff --git a/lib/features/composer/presentation/model/create_email_request.dart b/lib/features/composer/presentation/model/create_email_request.dart index b3f104cfa0..d6c82e44b3 100644 --- a/lib/features/composer/presentation/model/create_email_request.dart +++ b/lib/features/composer/presentation/model/create_email_request.dart @@ -23,6 +23,7 @@ class CreateEmailRequest with EquatableMixin { final Set toRecipients; final Set ccRecipients; final Set bccRecipients; + final Set replyToRecipients; final Identity? identity; final List? attachments; final Map? inlineAttachments; @@ -47,6 +48,7 @@ class CreateEmailRequest with EquatableMixin { required this.toRecipients, required this.ccRecipients, required this.bccRecipients, + required this.replyToRecipients, this.hasRequestReadReceipt = true, this.identity, this.attachments, @@ -74,6 +76,7 @@ class CreateEmailRequest with EquatableMixin { toRecipients, ccRecipients, bccRecipients, + replyToRecipients, identity, hasRequestReadReceipt, attachments, diff --git a/lib/features/composer/presentation/model/saved_email_draft.dart b/lib/features/composer/presentation/model/saved_email_draft.dart index a39809032d..5056e25e4e 100644 --- a/lib/features/composer/presentation/model/saved_email_draft.dart +++ b/lib/features/composer/presentation/model/saved_email_draft.dart @@ -9,6 +9,7 @@ class SavedEmailDraft with EquatableMixin { final Set toRecipients; final Set ccRecipients; final Set bccRecipients; + final Set replyToRecipients; final List attachments; final Identity? identity; final bool hasReadReceipt; @@ -19,6 +20,7 @@ class SavedEmailDraft with EquatableMixin { required this.toRecipients, required this.ccRecipients, required this.bccRecipients, + required this.replyToRecipients, required this.attachments, required this.identity, required this.hasReadReceipt, @@ -32,6 +34,7 @@ class SavedEmailDraft with EquatableMixin { {0: toRecipients}, {1: ccRecipients}, {2: bccRecipients}, + {3: replyToRecipients}, attachments, identity, hasReadReceipt diff --git a/lib/features/composer/presentation/styles/composer_style.dart b/lib/features/composer/presentation/styles/composer_style.dart index 26bf5e3e15..2e28770de6 100644 --- a/lib/features/composer/presentation/styles/composer_style.dart +++ b/lib/features/composer/presentation/styles/composer_style.dart @@ -155,10 +155,6 @@ class ComposerStyle { } static double getMaxHeightEmailAddressWidget(BuildContext context, BoxConstraints constraints, ResponsiveUtils responsiveUtils) { - if (responsiveUtils.isDesktop(context)) { - return constraints.maxHeight > 0 ? constraints.maxHeight * 0.3 : 150.0; - } else { - return constraints.maxHeight > 0 ? constraints.maxHeight * 0.4 : 150.0; - } + return constraints.maxHeight > 0 ? constraints.maxHeight * 0.4 : 150.0; } } \ No newline at end of file diff --git a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart index c58c846632..424278affd 100644 --- a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart @@ -46,6 +46,7 @@ class RecipientComposerWidget extends StatefulWidget { final PrefixRecipientState fromState; final PrefixRecipientState ccState; final PrefixRecipientState bccState; + final PrefixRecipientState replyToState; final bool? isInitial; final FocusNode? focusNode; final FocusNode? focusNodeKeyboard; @@ -76,6 +77,7 @@ class RecipientComposerWidget extends StatefulWidget { @visibleForTesting this.isTestingForWeb = false, this.ccState = PrefixRecipientState.disabled, this.bccState = PrefixRecipientState.disabled, + this.replyToState = PrefixRecipientState.disabled, this.fromState = PrefixRecipientState.disabled, this.isInitial, this.controller, @@ -353,6 +355,16 @@ class _RecipientComposerWidgetState extends State { margin: RecipientComposerWidgetStyle.recipientMargin, onTapActionCallback: () => widget.onAddEmailAddressTypeAction?.call(PrefixEmailAddress.bcc), ), + if (widget.replyToState == PrefixRecipientState.disabled) + TMailButtonWidget.fromText( + key: Key('prefix_${widget.prefix.name}_recipient_reply_to_button'), + text: AppLocalizations.of(context).reply_to_email_address_prefix, + textStyle: RecipientComposerWidgetStyle.prefixButtonTextStyle, + backgroundColor: Colors.transparent, + padding: RecipientComposerWidgetStyle.prefixButtonPadding, + margin: RecipientComposerWidgetStyle.recipientMargin, + onTapActionCallback: () => widget.onAddEmailAddressTypeAction?.call(PrefixEmailAddress.replyTo), + ), ] else if (PlatformInfo.isMobile) TMailButtonWidget.fromIcon( @@ -395,7 +407,8 @@ class _RecipientComposerWidgetState extends State { bool get _isAllRecipientInputEnabled => widget.fromState == PrefixRecipientState.enabled && widget.ccState == PrefixRecipientState.enabled - && widget.bccState == PrefixRecipientState.enabled; + && widget.bccState == PrefixRecipientState.enabled + && widget.replyToState == PrefixRecipientState.enabled; List get _collapsedListEmailAddress => _isCollapse ? _currentListEmailAddress.sublist(0, 1) diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index a5d98b2a5f..74f755910b 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2024-10-31T13:18:32.336494", + "@@last_modified": "2024-11-25T10:57:37.546143", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", @@ -186,6 +186,12 @@ "placeholders_order": [], "placeholders": {} }, + "reply_to_email_address_prefix": "Reply to", + "@reply_to_email_address_prefix": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, "cc_email_address_prefix": "Cc", "@cc_email_address_prefix": { "type": "text", diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 424bbbdc6d..44f25ca989 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -191,6 +191,13 @@ class AppLocalizations { ); } + String get reply_to_email_address_prefix { + return Intl.message( + 'Reply to', + name: 'reply_to_email_address_prefix', + ); + } + String get cc_email_address_prefix { return Intl.message( 'Cc', diff --git a/model/lib/email/prefix_email_address.dart b/model/lib/email/prefix_email_address.dart index 9b93fbec16..13a99d4aa7 100644 --- a/model/lib/email/prefix_email_address.dart +++ b/model/lib/email/prefix_email_address.dart @@ -3,5 +3,6 @@ enum PrefixEmailAddress { from, to, cc, - bcc + bcc, + replyTo } \ No newline at end of file diff --git a/model/lib/extensions/presentation_email_extension.dart b/model/lib/extensions/presentation_email_extension.dart index 0a0fa40266..23d557e304 100644 --- a/model/lib/extensions/presentation_email_extension.dart +++ b/model/lib/extensions/presentation_email_extension.dart @@ -124,27 +124,27 @@ extension PresentationEmailExtension on PresentationEmail { return allEmailAddress.isNotEmpty ? allEmailAddress.join(', ') : ''; } - Tuple3, List, List> generateRecipientsEmailAddressForComposer({ + Tuple4, List, List, List> generateRecipientsEmailAddressForComposer({ required EmailActionType emailActionType, Role? mailboxRole }) { switch(emailActionType) { case EmailActionType.reply: if (mailboxRole == PresentationMailbox.roleSent) { - return Tuple3(to.asList(), [], []); + return Tuple4(to.asList(), [], [], []); } else { final replyToAddress = replyTo.asList().isNotEmpty ? replyTo.asList() : from.asList(); - return Tuple3(replyToAddress, [], []); + return Tuple4(replyToAddress, [], [], []); } case EmailActionType.replyAll: if (mailboxRole == PresentationMailbox.roleSent) { - return Tuple3(to.asList(), cc.asList(), bcc.asList()); + return Tuple4(to.asList(), cc.asList(), bcc.asList(), []); } else { final senderReplyToAddress = replyTo.asList().isNotEmpty ? replyTo.asList() : from.asList(); - return Tuple3(to.asList() + senderReplyToAddress, cc.asList(), bcc.asList()); + return Tuple4(to.asList() + senderReplyToAddress, cc.asList(), bcc.asList(), []); } default: - return Tuple3(to.asList(), cc.asList(), bcc.asList()); + return Tuple4(to.asList(), cc.asList(), bcc.asList(), replyTo.asList()); } } diff --git a/test/features/composer/domain/usecases/create_new_and_save_email_to_drafts_interactor_test.dart b/test/features/composer/domain/usecases/create_new_and_save_email_to_drafts_interactor_test.dart index 0869982c1e..5471e6b306 100644 --- a/test/features/composer/domain/usecases/create_new_and_save_email_to_drafts_interactor_test.dart +++ b/test/features/composer/domain/usecases/create_new_and_save_email_to_drafts_interactor_test.dart @@ -52,6 +52,7 @@ void main() { toRecipients: {}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, draftsEmailId: EmailId(Id('some-id')) ); when(composerRepository.generateEmail(any, withIdentityHeader: anyNamed('withIdentityHeader'))) @@ -90,6 +91,7 @@ void main() { toRecipients: {}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, ); when(composerRepository.generateEmail(any, withIdentityHeader: anyNamed('withIdentityHeader'))) .thenAnswer((_) async => Email()); diff --git a/test/features/composer/domain/usecases/create_new_and_send_email_interactor_test.dart b/test/features/composer/domain/usecases/create_new_and_send_email_interactor_test.dart index 72027e6f49..d7e523de00 100644 --- a/test/features/composer/domain/usecases/create_new_and_send_email_interactor_test.dart +++ b/test/features/composer/domain/usecases/create_new_and_send_email_interactor_test.dart @@ -42,6 +42,7 @@ void main() { toRecipients: {}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, ); when(composerRepository.generateEmail(any, withIdentityHeader: anyNamed('withIdentityHeader'))) .thenAnswer((_) async => Email()); diff --git a/test/features/composer/presentation/composer_controller_test.dart b/test/features/composer/presentation/composer_controller_test.dart index fa4e11ed4d..d18e42785b 100644 --- a/test/features/composer/presentation/composer_controller_test.dart +++ b/test/features/composer/presentation/composer_controller_test.dart @@ -293,6 +293,7 @@ void main() { final toRecipient = EmailAddress('to', 'to@linagora.com'); final ccRecipient = EmailAddress('cc', 'cc@linagora.com'); final bccRecipient = EmailAddress('bcc', 'bcc@linagora.com'); + final replyToRecipient = EmailAddress('replyTo', 'replyTo@linagora.com'); final identity = Identity(); final attachment = Attachment(); const alwaysReadReceiptEnabled = true; @@ -318,6 +319,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.identitySelected.value = identity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final state = GetAlwaysReadReceiptSettingSuccess( @@ -329,6 +331,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: alwaysReadReceiptEnabled @@ -357,6 +360,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.identitySelected.value = identity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final state = GetAlwaysReadReceiptSettingFailure(Exception()); @@ -367,6 +371,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -394,6 +399,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; final selectedIdentity = Identity(id: IdentityId(Id('alice'))); when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -408,6 +414,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -438,6 +445,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; when(mockMailboxDashBoardController.composerArguments).thenReturn( ComposerArguments(identities: [identity])); when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -448,6 +456,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -478,6 +487,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final selectedIdentity = Identity( @@ -495,6 +505,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -525,6 +536,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final identity = Identity( @@ -542,6 +554,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -581,6 +594,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final selectedIdentity = Identity(id: IdentityId(Id('alice'))); @@ -599,6 +613,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -652,6 +667,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); final selectedIdentity = Identity(id: IdentityId(Id('alice'))); @@ -670,6 +686,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -722,6 +739,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.identitySelected.value = identity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -735,6 +753,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: alwaysReadReceiptEnabled @@ -763,6 +782,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.identitySelected.value = identity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -774,6 +794,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -810,6 +831,7 @@ void main() { to: {toRecipient}, cc: {ccRecipient}, bcc: {bccRecipient}, + replyTo: {replyToRecipient}, mailboxContain: PresentationMailbox( MailboxId(Id('some-mailbox-id')), role: PresentationMailbox.roleJunk)), @@ -824,6 +846,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -863,6 +886,7 @@ void main() { to: {toRecipient}, cc: {ccRecipient}, bcc: {bccRecipient}, + replyTo: {replyToRecipient}, mailboxContain: PresentationMailbox( MailboxId(Id('some-mailbox-id')), role: PresentationMailbox.roleJunk)), @@ -876,6 +900,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -917,6 +942,7 @@ void main() { to: {toRecipient}, cc: {ccRecipient}, bcc: {bccRecipient}, + replyTo: {replyToRecipient}, mailboxContain: PresentationMailbox( MailboxId(Id('some-mailbox-id')), role: PresentationMailbox.roleJunk)), @@ -933,6 +959,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -974,6 +1001,7 @@ void main() { to: {toRecipient}, cc: {ccRecipient}, bcc: {bccRecipient}, + replyTo: {replyToRecipient}, mailboxContain: PresentationMailbox( MailboxId(Id('some-mailbox-id')), role: PresentationMailbox.roleJunk)),)); @@ -989,6 +1017,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: false @@ -1028,6 +1057,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; final selectedIdentity = Identity(id: IdentityId(Id('alice'))); composerController?.identitySelected.value = selectedIdentity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -1047,6 +1077,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -1100,6 +1131,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; final selectedIdentity = Identity(id: IdentityId(Id('alice'))); composerController?.identitySelected.value = selectedIdentity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); @@ -1119,6 +1151,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: selectedIdentity, attachments: [attachment], hasReadReceipt: false @@ -1165,6 +1198,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.hasRequestReadReceipt.value = alwaysReadReceiptEnabled; const idenityId = 'some-identity-id'; @@ -1185,6 +1219,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: alwaysReadReceiptEnabled @@ -1214,6 +1249,7 @@ void main() { composerController?.listToEmailAddress = [toRecipient]; composerController?.listCcEmailAddress = [ccRecipient]; composerController?.listBccEmailAddress = [bccRecipient]; + composerController?.listReplyToEmailAddress = [replyToRecipient]; composerController?.identitySelected.value = identity; when(mockUploadController.attachmentsUploaded).thenReturn([attachment]); composerController?.hasRequestReadReceipt.value = alwaysReadReceiptEnabled; @@ -1224,6 +1260,7 @@ void main() { toRecipients: {toRecipient}, ccRecipients: {ccRecipient}, bccRecipients: {bccRecipient}, + replyToRecipients: {replyToRecipient}, identity: identity, attachments: [attachment], hasReadReceipt: alwaysReadReceiptEnabled diff --git a/test/features/composer/presentation/extensions/create_email_request_extension_test.dart b/test/features/composer/presentation/extensions/create_email_request_extension_test.dart index 84aa860197..956c807a29 100644 --- a/test/features/composer/presentation/extensions/create_email_request_extension_test.dart +++ b/test/features/composer/presentation/extensions/create_email_request_extension_test.dart @@ -19,6 +19,7 @@ void main() { toRecipients: {}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, ); group('create email request extension test:', () { diff --git a/test/features/composer/presentation/model/saved_email_draft_test.dart b/test/features/composer/presentation/model/saved_email_draft_test.dart index 1544ded790..cbc1dcf8a3 100644 --- a/test/features/composer/presentation/model/saved_email_draft_test.dart +++ b/test/features/composer/presentation/model/saved_email_draft_test.dart @@ -16,6 +16,7 @@ void main() { toRecipients: {EmailAddress('to name', 'to email')}, ccRecipients: {EmailAddress('cc name', 'cc email')}, bccRecipients: {EmailAddress('bcc name', 'bcc email')}, + replyToRecipients: {EmailAddress('replyTo name', 'replyTo email')}, identity: null, attachments: [], hasReadReceipt: false @@ -37,7 +38,7 @@ void main() { // arrange const subject = 'subject'; const content = 'content'; - final recipent = EmailAddress('recipent name', 'recipent email'); + final recipient = EmailAddress('recipient name', 'recipient email'); final identity = Identity(); final attachments = []; const hasReadReceipt = false; @@ -45,9 +46,10 @@ void main() { final toSavedEmailDraft = SavedEmailDraft( subject: subject, content: content, - toRecipients: {recipent}, + toRecipients: {recipient}, ccRecipients: {}, bccRecipients: {}, + replyToRecipients: {}, identity: identity, attachments: attachments, hasReadReceipt: hasReadReceipt @@ -57,8 +59,9 @@ void main() { subject: subject, content: content, toRecipients: {}, - ccRecipients: {recipent}, + ccRecipients: {recipient}, bccRecipients: {}, + replyToRecipients: {}, identity: identity, attachments: attachments, hasReadReceipt: hasReadReceipt @@ -69,21 +72,36 @@ void main() { content: content, toRecipients: {}, ccRecipients: {}, - bccRecipients: {recipent}, + bccRecipients: {recipient}, + replyToRecipients: {}, identity: identity, attachments: attachments, hasReadReceipt: hasReadReceipt ); + + final replyToSavedEmailDraft = SavedEmailDraft( + subject: subject, + content: content, + toRecipients: {}, + ccRecipients: {}, + bccRecipients: {}, + replyToRecipients: {recipient}, + identity: identity, + attachments: attachments, + hasReadReceipt: hasReadReceipt + ); // act final toProps = toSavedEmailDraft.props; final ccProps = ccSavedEmailDraft.props; final bccProps = bccSavedEmailDraft.props; - + final replyToProps = replyToSavedEmailDraft.props; + // assert expect(toProps.hashCode, isNot(ccProps.hashCode)); expect(ccProps.hashCode, isNot(bccProps.hashCode)); - expect(bccProps.hashCode, isNot(toProps.hashCode)); + expect(bccProps.hashCode, isNot(replyToProps.hashCode)); + expect(replyToProps.hashCode, isNot(toProps.hashCode)); }); test( @@ -100,6 +118,7 @@ void main() { toRecipients: listToRecipients, ccRecipients: {EmailAddress('cc name', 'cc email')}, bccRecipients: {EmailAddress('bcc name', 'bcc email')}, + replyToRecipients: {EmailAddress('replyTo name', 'replyTo email')}, identity: null, attachments: [], hasReadReceipt: false @@ -125,6 +144,7 @@ void main() { toRecipients: {EmailAddress('to name', 'to email')}, ccRecipients: {EmailAddress('cc name', 'cc email')}, bccRecipients: {EmailAddress('bcc name', 'bcc email')}, + replyToRecipients: {EmailAddress('replyTo name', 'replyTo email')}, identity: null, attachments: [], hasReadReceipt: false @@ -136,6 +156,7 @@ void main() { toRecipients: {EmailAddress('to name', 'to email')}, ccRecipients: {EmailAddress('cc name', 'cc email')}, bccRecipients: {EmailAddress('bcc name', 'bcc email')}, + replyToRecipients: {EmailAddress('replyTo name', 'replyTo email')}, identity: null, attachments: [], hasReadReceipt: false diff --git a/test/model/lib/extensions/presentation_email_extension_test.dart b/test/model/lib/extensions/presentation_email_extension_test.dart index f8e964f8b5..42e9a1e639 100644 --- a/test/model/lib/extensions/presentation_email_extension_test.dart +++ b/test/model/lib/extensions/presentation_email_extension_test.dart @@ -15,15 +15,15 @@ void main() { final userEEmailAddress = EmailAddress('User E', 'userE@domain.com'); final replyToEmailAddress = EmailAddress('Reply To', 'replyToThis@domain.com'); - group('GIVEN user A is the sender AND send an email to user B and user E, cc to user C, bcc to user D', () { - test('THEN user A click reply, generateRecipientsEmailAddressForComposer SHOULD return user B email + user E email to reply', () { - final expectedResult = Tuple3([userBEmailAddress, userEEmailAddress], [], []); + group('GIVEN user A is the sender AND sends an email to user B and user E, cc to user C, bcc to user D', () { + test('THEN user A clicks reply, generateRecipientsEmailAddressForComposer SHOULD return user B email + user E email to reply', () { + final expectedResult = Tuple4([userBEmailAddress, userEEmailAddress], [], [], []); final emailToReply = PresentationEmail( from: {userAEmailAddress}, to: {userBEmailAddress, userEEmailAddress}, cc: {userCEmailAddress}, - bcc: {userDEmailAddress} + bcc: {userDEmailAddress}, ); final result = emailToReply.generateRecipientsEmailAddressForComposer( @@ -34,10 +34,11 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); - test('THEN user A click reply all, generateRecipientsEmailAddressForComposer SHOULD return user B email + user E email to reply, user C email address to cc, user D email address to bcc', () { - final expectedResult = Tuple3([userBEmailAddress, userEEmailAddress], [userCEmailAddress], [userDEmailAddress]); + test('THEN user A clicks reply all, generateRecipientsEmailAddressForComposer SHOULD return user B email + user E email to reply, user C email address to cc, user D email address to bcc', () { + final expectedResult = Tuple4([userBEmailAddress, userEEmailAddress], [userCEmailAddress], [userDEmailAddress], []); final emailToReply = PresentationEmail( from: {userAEmailAddress}, @@ -54,12 +55,13 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); }); - group('GIVEN user B is the sender, SENDER configured the replyTo email AND send an email to user A and user E, cc to user C, bcc to user D', () { - test('THEN user A click reply, generateRecipientsEmailAddressForComposer SHOULD return only replyToEmailAddress email to reply' , () { - final expectedResult = Tuple3([replyToEmailAddress], [], []); + group('GIVEN user B is the sender, SENDER configured the replyTo email AND sends an email to user A and user E, cc to user C, bcc to user D', () { + test('THEN user A clicks reply, generateRecipientsEmailAddressForComposer SHOULD return only replyToEmailAddress email to reply' , () { + final expectedResult = Tuple4([replyToEmailAddress], [], [], []); final emailToReply = PresentationEmail( from: {userBEmailAddress}, @@ -77,10 +79,11 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); - test('THEN user A click reply all, generateRecipientsEmailAddressForComposer SHOULD return replyToEmailAddress + user A email + user E email to reply, user C email address to cc, user D email address to bcc', () { - final expectedResult = Tuple3([userAEmailAddress, userEEmailAddress, replyToEmailAddress], [userCEmailAddress], [userDEmailAddress]); + test('THEN user A clicks reply all, generateRecipientsEmailAddressForComposer SHOULD return replyToEmailAddress + user A email + user E email to reply, user C email address to cc, user D email address to bcc', () { + final expectedResult = Tuple4([userAEmailAddress, userEEmailAddress, replyToEmailAddress], [userCEmailAddress], [userDEmailAddress], []); final emailToReply = PresentationEmail( from: {userBEmailAddress}, @@ -98,12 +101,13 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); }); - group('GIVEN user B is the sender, SENDER does not have the replyTo email AND send an email to user A and user E, cc to user C, bcc to user D', () { - test('THEN user A click reply, generateRecipientsEmailAddressForComposer SHOULD return only user B email to reply', () { - final expectedResult = Tuple3([userBEmailAddress], [], []); + group('GIVEN user B is the sender, SENDER does not have the replyTo email AND sends an email to user A and user E, cc to user C, bcc to user D', () { + test('THEN user A clicks reply, generateRecipientsEmailAddressForComposer SHOULD return only user B email to reply', () { + final expectedResult = Tuple4([userBEmailAddress], [], [], []); final emailToReply = PresentationEmail( from: {userBEmailAddress}, @@ -120,10 +124,11 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); - test('THEN user A click reply all, generateRecipientsEmailAddressForComposer SHOULD return user A email + user E email + user B email to reply, user C email to cc, user D email to bcc', () { - final expectedResult = Tuple3([userAEmailAddress, userEEmailAddress, userBEmailAddress], [userCEmailAddress], [userDEmailAddress]); + test('THEN user A clicks reply all, generateRecipientsEmailAddressForComposer SHOULD return user A email + user E email + user B email to reply, user C email to cc, user D email to bcc', () { + final expectedResult = Tuple4([userAEmailAddress, userEEmailAddress, userBEmailAddress], [userCEmailAddress], [userDEmailAddress], []); final emailToReply = PresentationEmail( from: {userBEmailAddress}, @@ -140,12 +145,13 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); }); - group('Given user A is the sender AND send an email to user B + user E, cc to user C, bcc to user D THEN user B click forward', () { + group('Given user A is the sender AND sends an email to user B + user E, cc to user C, bcc to user D THEN user B clicks forward', () { test('generateRecipientsEmailAddressForComposer SHOULD return user user B email + user E email to reply, user C email to cc, user D email to bcc', () { - final expectedResult = Tuple3([userBEmailAddress, userEEmailAddress], [userCEmailAddress], [userDEmailAddress]); + final expectedResult = Tuple4([userBEmailAddress, userEEmailAddress], [userCEmailAddress], [userDEmailAddress], []); final emailToReply = PresentationEmail( from: {userAEmailAddress}, @@ -162,6 +168,7 @@ void main() { expect(result.value1, containsAll(expectedResult.value1)); expect(result.value2, containsAll(expectedResult.value2)); expect(result.value3, containsAll(expectedResult.value3)); + expect(result.value4, containsAll(expectedResult.value4)); }); }); }); From 2e44ad299b9c788dbe1ba8182e48bd61fbc9c532 Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Fri, 15 Nov 2024 14:01:33 +0100 Subject: [PATCH 6/6] TF-3189 composer now correctly encodes subaddresses --- core/lib/utils/mail/mail_address.dart | 128 ++++++++++++++++++ core/test/utils/mail_address_test.dart | 48 +++++++ .../presentation/composer_controller.dart | 45 +++--- .../extensions/mail_address_extension.dart | 17 +++ .../widgets/recipient_composer_widget.dart | 23 ++-- .../email/presentation/utils/email_utils.dart | 3 +- 6 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 lib/features/composer/presentation/extensions/mail_address_extension.dart diff --git a/core/lib/utils/mail/mail_address.dart b/core/lib/utils/mail/mail_address.dart index a791220063..5861b244fb 100644 --- a/core/lib/utils/mail/mail_address.dart +++ b/core/lib/utils/mail/mail_address.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:core/domain/exceptions/address_exception.dart'; import 'package:core/utils/app_logger.dart'; import 'package:core/utils/mail/domain.dart'; @@ -23,8 +24,15 @@ class MailAddress with EquatableMixin { final String localPart; final Domain domain; + static const String subaddressingLocalPartDelimiter = '+'; + MailAddress({required this.localPart, required this.domain}); + MailAddress.fromParts({required String localPartWithoutDetails, required String localPartDetails, required this.domain}) : localPart = + localPartDetails.isEmpty + ? localPartWithoutDetails + : '$localPartWithoutDetails$subaddressingLocalPartDelimiter$localPartDetails'; + factory MailAddress.validateAddress(String address) { log('MailAddress::validate: Address = $address'); String localPart; @@ -137,6 +145,89 @@ class MailAddress with EquatableMixin { return localPart; } + String? getLocalPartDetails() { + int separatorPosition = localPart.indexOf(subaddressingLocalPartDelimiter); + if (separatorPosition <= 0) { + return null; + } + return localPart.substring(separatorPosition + subaddressingLocalPartDelimiter.length); + } + + String getLocalPartWithoutDetails() { + int separatorPosition = localPart.indexOf(subaddressingLocalPartDelimiter); + if (separatorPosition <= 0) { + return localPart; + } + return localPart.substring(0, separatorPosition); + } + + MailAddress stripDetails() { + return MailAddress(localPart: getLocalPartWithoutDetails(), domain: domain); + } + + // cannot use Uri.encodeComponent because it is meant to be compliant with RFC2396 + // eg `-_.!~*'()` are not encoded, but we want `!*'()` to be + static final _needsNoEncoding = RegExp(r'^[a-zA-Z0-9._~-]+$'); + + // this table is adapted from `_unreserved2396Table` found at + // https://github.com/dart-lang/sdk/blob/58f9beb6d4ec9e93430454bb96c0b8f068d0b0bc/sdk/lib/core/uri.dart#L3382 + static const _customUnreservedTable = [ + // LSB MSB + // | | + 0x0000, // 0x00 - 0x0f 0000000000000000 + 0x0000, // 0x10 - 0x1f 0000000000000000 + // -. + 0x6000, // 0x20 - 0x2f 0000000000000110 + // 0123456789 + 0x03ff, // 0x30 - 0x3f 1111111111000000 + // ABCDEFGHIJKLMNO + 0xfffe, // 0x40 - 0x4f 0111111111111111 + // PQRSTUVWXYZ _ + 0x87ff, // 0x50 - 0x5f 1111111111100001 + // abcdefghijklmno + 0xfffe, // 0x60 - 0x6f 0111111111111111 + // pqrstuvwxyz ~ + 0x47ff, // 0x70 - 0x7f 1111111111100010 + ]; + + // this method is adapted from `_uriEncode()` found at: + // https://github.com/dart-lang/sdk/blob/bb8db16297e6b9994b08ecae6ee1dd45a0be587e/sdk/lib/_internal/wasm/lib/uri_patch.dart#L49 + static String customUriEncode(String text) { + if (_needsNoEncoding.hasMatch(text)) { + return text; + } + + // Encode the string into bytes then generate an ASCII only string + // by percent encoding selected bytes. + StringBuffer result = StringBuffer(''); + var bytes = utf8.encode(text); + for (int byte in bytes) { + if (byte < 128 && + ((_customUnreservedTable[byte >> 4] & (1 << (byte & 0x0f))) != 0)) { + result.writeCharCode(byte); + } else { + const String hexDigits = '0123456789ABCDEF'; + result.write('%'); + result.write(hexDigits[(byte >> 4) & 0x0f]); + result.write(hexDigits[byte & 0x0f]); + } + } + return result.toString(); + } + + String asEncodedString() { + String? localPartDetails = getLocalPartDetails(); + if(localPartDetails == null) { + return asString(); + } else { + return MailAddress.fromParts( + localPartWithoutDetails: getLocalPartWithoutDetails(), + localPartDetails: customUriEncode(localPartDetails), + domain: domain + ).asString(); + } + } + @override String toString() { return '$localPart@${domain.asString()}'; @@ -323,6 +414,10 @@ class MailAddress with EquatableMixin { lpSB.write('.'); pos++; lastCharDot = true; + } else if (postChar == subaddressingLocalPartDelimiter) { + // Start of local part details, jump to the `@` + lpSB.write(subaddressingLocalPartDelimiter); + pos = _parseLocalPartDetails(lpSB, address, pos+1); } else if (postChar == '@') { // End of local-part break; @@ -416,6 +511,39 @@ class MailAddress with EquatableMixin { return pos; } + static int _parseLocalPartDetails(StringBuffer localPartSB, String address, int pos) { + StringBuffer localPartDetailsSB = StringBuffer(); + + while (true) { + if (pos >= address.length) { + break; + } + var postChar = address[pos]; + if (postChar == '@') { + // End of local-part-details + break; + } else { + localPartDetailsSB.write(postChar); + pos++; + } + } + + String localPartDetails = localPartDetailsSB.toString(); + if (localPartDetails.isEmpty || localPartDetails.trim().isEmpty) { + throw AddressException("target mailbox name should not be empty"); + } + if (localPartDetails.startsWith('#')) { + throw AddressException("target mailbox name should not start with #"); + } + final forbiddenChars = RegExp(r'[*\r\n]'); + if (forbiddenChars.hasMatch(localPartDetails)) { + throw AddressException("target mailbox name should not contain special characters"); + } + + localPartSB.write(localPartDetails); + return pos; + } + @override List get props => [localPart, domain]; } diff --git a/core/test/utils/mail_address_test.dart b/core/test/utils/mail_address_test.dart index 2265458a8c..2998845ab8 100644 --- a/core/test/utils/mail_address_test.dart +++ b/core/test/utils/mail_address_test.dart @@ -20,6 +20,9 @@ void main() { "Abc.123@example.com", "user+mailbox/department=shipping@example.com", "user+mailbox@example.com", + "user+folder@james.apache.org", + "user+my folder@domain.com", + "user+Dossier d'été@domain.com", "\"Abc@def\"@example.com", "\"Fred Bloggs\"@example.com", "\"Joe.\\Blow\"@example.com", @@ -56,6 +59,10 @@ void main() { "server-dev@#123.apache.org", "server-dev@[127.0.1.1.1]", "server-dev@[127.0.1.-1]", + "user+@domain.com", + "user+ @domain.com", + "user+#folder@domain.com", + "user+test-_.!~*'() @domain.com", "\"a..b\"@domain.com", // jakarta.mail is unable to handle this so we better reject it "server-dev\\.@james.apache.org", // jakarta.mail is unable to handle this so we better reject it "a..b@domain.com", @@ -165,5 +172,46 @@ void main() { final mailAddress = MailAddress.validateAddress(GOOD_ADDRESS); expect(mailAddress.toString(), equals(GOOD_ADDRESS)); }); + + test('MailAddress.encodeLocalPartDetails() should work with characters to encode', () { + final mailAddress = MailAddress.validateAddress("user+my folder@domain.com"); + expect(mailAddress.asEncodedString(), equals("user+my%20folder@domain.com")); + }); + + test('MailAddress.encodeLocalPartDetails() should work with many characters to encode', () { + final mailAddress = MailAddress.validateAddress("user+Dossier d'été@domain.com"); + expect(mailAddress.asEncodedString(), equals("user+Dossier%20d%27%C3%A9t%C3%A9@domain.com")); + }); + + test('MailAddress.encodeLocalPartDetails() should encode the rights characters', () { + final mailAddress = MailAddress.validateAddress("user+test-_.!~'() @domain.com"); + expect(mailAddress.asEncodedString(), equals("user+test-_.%21~%27%28%29%20@domain.com")); + }); + + test('getLocalPartDetails() should work', () { + final mailAddress = MailAddress.validateAddress("user+details@domain.com"); + expect(mailAddress.getLocalPartDetails(), equals("details")); + }); + + test('getLocalPartWithoutDetails() should work', () { + final mailAddress = MailAddress.validateAddress("user+details@domain.com"); + expect(mailAddress.getLocalPartWithoutDetails(), equals("user")); + }); + + test('stripDetails() should work', () { + final mailAddress = MailAddress.validateAddress("user+details@domain.com"); + expect(mailAddress.stripDetails().asString(), equals("user@domain.com")); + }); + + test('stripDetails() should work with encoded local part', () { + final mailAddress = MailAddress.validateAddress("user+Dossier%20d%27%C3%A9t%C3%A9@domain.com"); + expect(mailAddress.stripDetails().asString(), equals("user@domain.com")); + }); + + test('stripDetails() should work when local part needs encoding', () { + final mailAddress = MailAddress.validateAddress("user+super folder@domain.com"); + expect(mailAddress.stripDetails().asString(), equals("user@domain.com")); + }); + }); } \ No newline at end of file diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index 751f64c329..d90e26d85a 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -58,6 +58,7 @@ import 'package:tmail_ui_user/features/composer/presentation/controller/rich_tex import 'package:tmail_ui_user/features/composer/presentation/extensions/email_action_type_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/list_identities_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/list_shared_media_file_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/mixin/drag_drog_file_mixin.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/create_email_request.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/draggable_email_address.dart'; @@ -1600,16 +1601,16 @@ class ComposerController extends BaseController final inputReplyToEmail = replyToEmailAddressController.text; if (inputToEmail.isNotEmpty) { - _autoCreateToEmailTag(inputToEmail); + _autoCreateToEmailTag(MailAddress.validateAddress(inputToEmail)); } if (inputCcEmail.isNotEmpty) { - _autoCreateCcEmailTag(inputCcEmail); + _autoCreateCcEmailTag(MailAddress.validateAddress(inputCcEmail)); } if (inputBccEmail.isNotEmpty) { - _autoCreateBccEmailTag(inputBccEmail); + _autoCreateBccEmailTag(MailAddress.validateAddress(inputBccEmail)); } if (inputReplyToEmail.isNotEmpty) { - _autoCreateReplyToEmailTag(inputReplyToEmail); + _autoCreateReplyToEmailTag(MailAddress.validateAddress(inputReplyToEmail)); } } @@ -1620,10 +1621,9 @@ class ComposerController extends BaseController .contains(inputEmail); } - void _autoCreateToEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listToEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listToEmailAddress.add(emailAddress); + void _autoCreateToEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listToEmailAddress)) { + listToEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1635,10 +1635,9 @@ class ComposerController extends BaseController }); } - void _autoCreateCcEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listCcEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listCcEmailAddress.add(emailAddress); + void _autoCreateCcEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listCcEmailAddress)) { + listCcEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1649,10 +1648,9 @@ class ComposerController extends BaseController }); } - void _autoCreateBccEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listBccEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listBccEmailAddress.add(emailAddress); + void _autoCreateBccEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listBccEmailAddress)) { + listBccEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1663,10 +1661,9 @@ class ComposerController extends BaseController }); } - void _autoCreateReplyToEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listReplyToEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listReplyToEmailAddress.add(emailAddress); + void _autoCreateReplyToEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listReplyToEmailAddress)) { + listReplyToEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1745,28 +1742,28 @@ class ComposerController extends BaseController toAddressExpandMode.value = ExpandMode.COLLAPSE; final inputToEmail = toEmailAddressController.text; if (inputToEmail.isNotEmpty) { - _autoCreateToEmailTag(inputToEmail); + _autoCreateToEmailTag(MailAddress.validateAddress(inputToEmail)); } break; case PrefixEmailAddress.cc: ccAddressExpandMode.value = ExpandMode.COLLAPSE; final inputCcEmail = ccEmailAddressController.text; if (inputCcEmail.isNotEmpty) { - _autoCreateCcEmailTag(inputCcEmail); + _autoCreateCcEmailTag(MailAddress.validateAddress(inputCcEmail)); } break; case PrefixEmailAddress.bcc: bccAddressExpandMode.value = ExpandMode.COLLAPSE; final inputBccEmail = bccEmailAddressController.text; if (inputBccEmail.isNotEmpty) { - _autoCreateBccEmailTag(inputBccEmail); + _autoCreateBccEmailTag(MailAddress.validateAddress(inputBccEmail)); } break; case PrefixEmailAddress.replyTo: replyToAddressExpandMode.value = ExpandMode.COLLAPSE; final inputReplyToEmail = replyToEmailAddressController.text; if (inputReplyToEmail.isNotEmpty) { - _autoCreateReplyToEmailTag(inputReplyToEmail); + _autoCreateReplyToEmailTag(MailAddress.validateAddress(inputReplyToEmail)); } break; default: diff --git a/lib/features/composer/presentation/extensions/mail_address_extension.dart b/lib/features/composer/presentation/extensions/mail_address_extension.dart new file mode 100644 index 0000000000..fd804db22e --- /dev/null +++ b/lib/features/composer/presentation/extensions/mail_address_extension.dart @@ -0,0 +1,17 @@ +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:core/core.dart'; + +extension MailAddressExtension on MailAddress { + String? get getDisplayName { + String? localPartDetails = getLocalPartDetails(); + if(localPartDetails == null) { + return null; + } else { + return '${getLocalPartWithoutDetails()} [${getLocalPartDetails()}]'; + } + } + + EmailAddress asEmailAddress() { + return EmailAddress(getDisplayName, asEncodedString()); + } +} \ No newline at end of file diff --git a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart index 424278affd..80a0163231 100644 --- a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart @@ -8,6 +8,7 @@ import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/utils/responsive_utils.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/mail/mail_address.dart'; import 'package:core/utils/platform_info.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -17,6 +18,7 @@ import 'package:model/extensions/email_address_extension.dart'; import 'package:model/mailbox/expand_mode.dart'; import 'package:super_tag_editor/tag_editor.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/prefix_email_address_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/draggable_email_address.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/prefix_recipient_state.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/suggestion_email_address.dart'; @@ -220,7 +222,7 @@ class _RecipientComposerWidgetState extends State { return RecipientSuggestionItemWidget( imagePaths: widget.imagePaths, suggestionState: suggestionEmailAddress.state, - emailAddress: suggestionEmailAddress.emailAddress, + emailAddress: MailAddress.validateAddress(suggestionEmailAddress.emailAddress.emailAddress).asEmailAddress(), suggestionValid: suggestionValid, highlight: highlight, onSelectedAction: (emailAddress) { @@ -304,7 +306,7 @@ class _RecipientComposerWidgetState extends State { return RecipientSuggestionItemWidget( imagePaths: widget.imagePaths, suggestionState: suggestionEmailAddress.state, - emailAddress: suggestionEmailAddress.emailAddress, + emailAddress: MailAddress.validateAddress(suggestionEmailAddress.emailAddress.emailAddress).asEmailAddress(), suggestionValid: suggestionValid, highlight: highlight, onSelectedAction: (emailAddress) { @@ -505,8 +507,9 @@ class _RecipientComposerWidgetState extends State { SuggestionEmailAddress suggestionEmailAddress, StateSetter stateSetter ) { - if (!_isDuplicatedRecipient(suggestionEmailAddress.emailAddress.emailAddress)) { - stateSetter(() => _currentListEmailAddress.add(suggestionEmailAddress.emailAddress)); + MailAddress mailAddress = MailAddress.validateAddress(suggestionEmailAddress.emailAddress.emailAddress); + if (!_isDuplicatedRecipient(mailAddress.asEncodedString())) { + stateSetter(() => _currentListEmailAddress.add(mailAddress.asEmailAddress())); _updateListEmailAddressAction(); } } @@ -515,9 +518,9 @@ class _RecipientComposerWidgetState extends State { String value, StateSetter stateSetter ) { - final textTrim = value.trim(); - if (!_isDuplicatedRecipient(textTrim)) { - stateSetter(() => _currentListEmailAddress.add(EmailAddress(null, textTrim))); + MailAddress mailAddress = MailAddress.validateAddress(value.trim()); + if (!_isDuplicatedRecipient(mailAddress.asEncodedString())) { + stateSetter(() => _currentListEmailAddress.add(mailAddress.asEmailAddress())); _updateListEmailAddressAction(); } } @@ -526,9 +529,9 @@ class _RecipientComposerWidgetState extends State { String value, StateSetter stateSetter ) { - final textTrim = value.trim(); - if (!_isDuplicatedRecipient(textTrim)) { - stateSetter(() => _currentListEmailAddress.add(EmailAddress(null, textTrim))); + MailAddress mailAddress = MailAddress.validateAddress(value.trim()); + if (!_isDuplicatedRecipient(mailAddress.asEncodedString())) { + stateSetter(() => _currentListEmailAddress.add(mailAddress.asEmailAddress())); _updateListEmailAddressAction(); } _gapBetweenTagChangedAndFindSuggestion = Timer( diff --git a/lib/features/email/presentation/utils/email_utils.dart b/lib/features/email/presentation/utils/email_utils.dart index d4eb188455..5b8576085a 100644 --- a/lib/features/email/presentation/utils/email_utils.dart +++ b/lib/features/email/presentation/utils/email_utils.dart @@ -77,7 +77,8 @@ class EmailUtils { static bool isEmailAddressValid(String address) { try { - return GetUtils.isEmail(address) && MailAddress.validateAddress(address).asString().isNotEmpty; + MailAddress mailAddress = MailAddress.validateAddress(address); + return GetUtils.isEmail(mailAddress.stripDetails().asString()) && mailAddress.asString().isNotEmpty; } catch(e) { logError('EmailUtils::isEmailAddressValid: Exception = $e'); return false;