diff --git a/lib/features/composer/domain/model/email_request.dart b/lib/features/composer/domain/model/email_request.dart index 9528e6a7b8..b5ba40d0e2 100644 --- a/lib/features/composer/domain/model/email_request.dart +++ b/lib/features/composer/domain/model/email_request.dart @@ -40,7 +40,9 @@ class EmailRequest with EquatableMixin { ]; bool get isEmailAnswered => emailIdAnsweredOrForwarded != null && - (emailActionType == EmailActionType.reply || emailActionType == EmailActionType.replyAll); + (emailActionType == EmailActionType.reply + || emailActionType == EmailActionType.replyToList + || emailActionType == EmailActionType.replyAll); bool get isEmailForwarded => emailIdAnsweredOrForwarded != null && emailActionType == EmailActionType.forward; } \ 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 9281901953..2e4887c67f 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -23,7 +23,6 @@ import 'package:jmap_dart_client/jmap/identities/identity.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:jmap_dart_client/jmap/mail/email/individual_header_identifier.dart'; -import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; import 'package:model/model.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; @@ -74,6 +73,7 @@ import 'package:tmail_ui_user/features/email/domain/state/get_email_content_stat import 'package:tmail_ui_user/features/email/domain/state/transform_html_email_content_state.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/get_email_content_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/transform_html_email_content_interactor.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart'; import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; @@ -640,11 +640,13 @@ class ComposerController extends BaseController _updateStatusEmailSendButton(); break; case EmailActionType.reply: + case EmailActionType.replyToList: case EmailActionType.replyAll: + log('ComposerController::_initEmail:listPost = ${arguments.listPost}'); _initEmailAddress( presentationEmail: arguments.presentationEmail!, actionType: arguments.emailActionType, - mailboxRole: arguments.presentationEmail!.mailboxContain?.role ?? mailboxDashBoardController.selectedMailbox.value?.role + listPost: arguments.listPost, ); _initSubjectEmail( presentationEmail: arguments.presentationEmail!, @@ -797,29 +799,23 @@ class ComposerController extends BaseController void _initEmailAddress({ required PresentationEmail presentationEmail, required EmailActionType actionType, - Role? mailboxRole, + String? listPost, }) { + final userName = mailboxDashBoardController.sessionCurrent?.username.value; + final isSender = presentationEmail.from + .asList() + .any((element) => element.emailAddress.isNotEmpty && element.emailAddress == userName); + final recipients = presentationEmail.generateRecipientsEmailAddressForComposer( emailActionType: actionType, - mailboxRole: mailboxRole + isSender: isSender, + userName: userName, + listPost: listPost, ); - final userName = mailboxDashBoardController.sessionCurrent?.username; - if (userName != null) { - final isSender = presentationEmail.from.asList().every((element) => element.email == userName.value); - if (isSender) { - listToEmailAddress = List.from(recipients.value1.toSet()); - listCcEmailAddress = List.from(recipients.value2.toSet()); - listBccEmailAddress = List.from(recipients.value3.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)); - } - } else { - listToEmailAddress = List.from(recipients.value1.toSet()); - listCcEmailAddress = List.from(recipients.value2.toSet()); - listBccEmailAddress = List.from(recipients.value3.toSet()); - } + + listToEmailAddress = List.from(recipients.value1); + listCcEmailAddress = List.from(recipients.value2); + listBccEmailAddress = List.from(recipients.value3); if (listToEmailAddress.isNotEmpty || listCcEmailAddress.isNotEmpty || listBccEmailAddress.isNotEmpty) { isInitialRecipient.value = true; 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..e2dda92579 100644 --- a/lib/features/composer/presentation/extensions/create_email_request_extension.dart +++ b/lib/features/composer/presentation/extensions/create_email_request_extension.dart @@ -74,6 +74,7 @@ extension CreateEmailRequestExtension on CreateEmailRequest { MessageIdsHeaderValue? createInReplyTo() { if (emailActionType == EmailActionType.reply || + emailActionType == EmailActionType.replyToList || emailActionType == EmailActionType.replyAll ) { return messageId; @@ -83,6 +84,7 @@ extension CreateEmailRequestExtension on CreateEmailRequest { MessageIdsHeaderValue? createReferences() { if (emailActionType == EmailActionType.reply || + emailActionType == EmailActionType.replyToList || emailActionType == EmailActionType.replyAll || emailActionType == EmailActionType.forward ) { 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 b664c37175..2a5b5b510f 100644 --- a/lib/features/composer/presentation/extensions/email_action_type_extension.dart +++ b/lib/features/composer/presentation/extensions/email_action_type_extension.dart @@ -12,6 +12,7 @@ extension EmailActionTypeExtension on EmailActionType { String getSubjectComposer(BuildContext? context, String subject) { switch(this) { case EmailActionType.reply: + case EmailActionType.replyToList: case EmailActionType.replyAll: if (subject.toLowerCase().startsWith('re:')) { return subject; @@ -60,6 +61,7 @@ extension EmailActionTypeExtension on EmailActionType { final languageTag = locale.toLanguageTag(); switch(this) { case EmailActionType.reply: + case EmailActionType.replyToList: case EmailActionType.replyAll: final receivedAt = presentationEmail.receivedAt; final emailAddress = presentationEmail.from.toEscapeHtmlStringUseCommaSeparator(); diff --git a/lib/features/composer/presentation/view/mobile/mobile_editor_view.dart b/lib/features/composer/presentation/view/mobile/mobile_editor_view.dart index 1859f9026f..28c6860667 100644 --- a/lib/features/composer/presentation/view/mobile/mobile_editor_view.dart +++ b/lib/features/composer/presentation/view/mobile/mobile_editor_view.dart @@ -80,6 +80,7 @@ class MobileEditorView extends StatelessWidget with EditorViewMixin { } ); case EmailActionType.reply: + case EmailActionType.replyToList: case EmailActionType.replyAll: case EmailActionType.forward: if (contentViewState == null) { diff --git a/lib/features/composer/presentation/view/web/web_editor_view.dart b/lib/features/composer/presentation/view/web/web_editor_view.dart index f7cd0cca81..519a8245df 100644 --- a/lib/features/composer/presentation/view/web/web_editor_view.dart +++ b/lib/features/composer/presentation/view/web/web_editor_view.dart @@ -145,6 +145,7 @@ class WebEditorView extends StatelessWidget with EditorViewMixin { } ); case EmailActionType.reply: + case EmailActionType.replyToList: case EmailActionType.replyAll: case EmailActionType.forward: if (contentViewState == null) { diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index 556c4a399b..7b3194df81 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -1422,6 +1422,21 @@ class SingleEmailController extends BaseController with AppLoaderMixin { mailboxRole: presentationEmail.mailboxContain?.role, messageId: currentEmailLoaded.value?.emailCurrent?.messageId, references: currentEmailLoaded.value?.emailCurrent?.references, + listPost: currentEmailLoaded.value?.emailCurrent?.listPost, + ) + ); + break; + case EmailActionType.replyToList: + log('SingleEmailController::pressEmailAction:replyToList'); + mailboxDashBoardController.goToComposer( + ComposerArguments.replyToListEmail( + presentationEmail: presentationEmail, + content: currentEmailLoaded.value?.htmlContent ?? '', + inlineImages: currentEmailLoaded.value?.inlineImages ?? [], + mailboxRole: presentationEmail.mailboxContain?.role, + messageId: currentEmailLoaded.value?.emailCurrent?.messageId, + references: currentEmailLoaded.value?.emailCurrent?.references, + listPost: currentEmailLoaded.value?.emailCurrent?.listPost, ) ); break; @@ -1434,6 +1449,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { mailboxRole: presentationEmail.mailboxContain?.role, messageId: currentEmailLoaded.value?.emailCurrent?.messageId, references: currentEmailLoaded.value?.emailCurrent?.references, + listPost: currentEmailLoaded.value?.emailCurrent?.listPost, ) ); break; diff --git a/lib/features/email/presentation/extensions/presentation_email_extension.dart b/lib/features/email/presentation/extensions/presentation_email_extension.dart new file mode 100644 index 0000000000..a2427a3101 --- /dev/null +++ b/lib/features/email/presentation/extensions/presentation_email_extension.dart @@ -0,0 +1,91 @@ + +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:model/email/email_action_type.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:model/extensions/list_email_address_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; + +extension PresentationEmailExtension on PresentationEmail { + Tuple3, List, List> generateRecipientsEmailAddressForComposer({ + required EmailActionType emailActionType, + bool isSender = false, + String? userName, + String? listPost, + }) { + final newFromAddress = from.removeDuplicateEmails(); + final newToAddress = to.removeDuplicateEmails(); + final newCcAddress = cc.removeDuplicateEmails(); + final newBccAddress = bcc.removeDuplicateEmails(); + final newReplyToAddress = replyTo.removeDuplicateEmails(); + + switch (emailActionType) { + case EmailActionType.reply: + final listReplyAddress = isSender ? newToAddress : newFromAddress; + final listReplyAddressWithoutUsername = listReplyAddress.withoutMe(userName); + + return Tuple3(listReplyAddressWithoutUsername, [], []); + case EmailActionType.replyToList: + final recipientRecord = EmailUtils.extractRecipientsFromListPost(listPost ?? ''); + + final listToAddressWithoutUsername = recipientRecord.toMailAddresses + .toSet() + .removeDuplicateEmails() + .withoutMe(userName); + + final listCcAddressWithoutUsername = recipientRecord.ccMailAddresses + .toSet() + .removeDuplicateEmails() + .withoutMe(userName); + + final listBccAddressWithoutUsername = recipientRecord.bccMailAddresses + .toSet() + .removeDuplicateEmails() + .withoutMe(userName); + + return Tuple3( + listToAddressWithoutUsername, + listCcAddressWithoutUsername, + listBccAddressWithoutUsername, + ); + case EmailActionType.replyAll: + final recipientRecord = EmailUtils.extractRecipientsFromListPost(listPost ?? ''); + + final listToAddress = recipientRecord.toMailAddresses + + newReplyToAddress + + newFromAddress + + newToAddress; + final listCcAddress = recipientRecord.ccMailAddresses + newCcAddress; + final listBccAddress = recipientRecord.bccMailAddresses + newBccAddress; + + final listToAddressWithoutUsername = listToAddress + .toSet() + .removeDuplicateEmails() + .withoutMe(userName); + final listCcAddressWithoutUsername = listCcAddress + .toSet() + .removeDuplicateEmails() + .withoutMe(userName); + final listBccAddressWithoutUsername = listBccAddress + .toSet() + .removeDuplicateEmails() + .withoutMe(userName); + + return Tuple3( + listToAddressWithoutUsername, + listCcAddressWithoutUsername, + listBccAddressWithoutUsername, + ); + default: + final listToAddressWithoutUsername = newToAddress.withoutMe(userName); + final listCcAddressWithoutUsername = newCcAddress.withoutMe(userName); + final listBccAddressWithoutUsername = newBccAddress.withoutMe(userName); + + return Tuple3( + listToAddressWithoutUsername, + listCcAddressWithoutUsername, + listBccAddressWithoutUsername, + ); + } + } +} \ No newline at end of file diff --git a/lib/features/email/presentation/model/composer_arguments.dart b/lib/features/email/presentation/model/composer_arguments.dart index 479d0e617a..25ce2fe911 100644 --- a/lib/features/email/presentation/model/composer_arguments.dart +++ b/lib/features/email/presentation/model/composer_arguments.dart @@ -32,6 +32,7 @@ class ComposerArguments extends RouterArguments { final ScreenDisplayMode displayMode; final List? cc; final List? bcc; + final String? listPost; ComposerArguments({ this.emailActionType = EmailActionType.compose, @@ -54,6 +55,7 @@ class ComposerArguments extends RouterArguments { this.displayMode = ScreenDisplayMode.normal, this.cc, this.bcc, + this.listPost, }); factory ComposerArguments.fromSendingEmail(SendingEmail sendingEmail) => @@ -120,6 +122,7 @@ class ComposerArguments extends RouterArguments { Role? mailboxRole, MessageIdsHeaderValue? messageId, MessageIdsHeaderValue? references, + String? listPost, }) => ComposerArguments( emailActionType: EmailActionType.reply, presentationEmail: presentationEmail, @@ -128,6 +131,26 @@ class ComposerArguments extends RouterArguments { mailboxRole: mailboxRole, messageId: messageId, references: references, + listPost: listPost, + ); + + factory ComposerArguments.replyToListEmail({ + required PresentationEmail presentationEmail, + required String content, + required List inlineImages, + Role? mailboxRole, + MessageIdsHeaderValue? messageId, + MessageIdsHeaderValue? references, + String? listPost, + }) => ComposerArguments( + emailActionType: EmailActionType.replyToList, + presentationEmail: presentationEmail, + emailContents: content, + inlineImages: inlineImages, + mailboxRole: mailboxRole, + messageId: messageId, + references: references, + listPost: listPost, ); factory ComposerArguments.replyAllEmail({ @@ -137,6 +160,7 @@ class ComposerArguments extends RouterArguments { Role? mailboxRole, MessageIdsHeaderValue? messageId, MessageIdsHeaderValue? references, + String? listPost, }) => ComposerArguments( emailActionType: EmailActionType.replyAll, presentationEmail: presentationEmail, @@ -145,6 +169,7 @@ class ComposerArguments extends RouterArguments { mailboxRole: mailboxRole, messageId: messageId, references: references, + listPost: listPost, ); factory ComposerArguments.forwardEmail({ @@ -206,6 +231,7 @@ class ComposerArguments extends RouterArguments { displayMode, cc, bcc, + listPost, ]; ComposerArguments copyWith({ @@ -229,6 +255,7 @@ class ComposerArguments extends RouterArguments { ScreenDisplayMode? displayMode, List? cc, List? bcc, + String? listPost, }) { return ComposerArguments( emailActionType: emailActionType ?? this.emailActionType, @@ -251,6 +278,7 @@ class ComposerArguments extends RouterArguments { displayMode: displayMode ?? this.displayMode, cc: cc ?? this.cc, bcc: bcc ?? this.bcc, + listPost: listPost ?? this.listPost, ); } } diff --git a/lib/features/email/presentation/utils/email_utils.dart b/lib/features/email/presentation/utils/email_utils.dart index d4eb188455..17b8e648b8 100644 --- a/lib/features/email/presentation/utils/email_utils.dart +++ b/lib/features/email/presentation/utils/email_utils.dart @@ -9,10 +9,12 @@ 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/properties/properties.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:tmail_ui_user/features/email/domain/state/download_attachment_for_web_state.dart'; import 'package:tmail_ui_user/features/email/presentation/model/email_unsubscribe.dart'; import 'package:tmail_ui_user/features/thread/domain/constants/thread_constants.dart'; import 'package:tmail_ui_user/main/error/capability_validator.dart'; +import 'package:tmail_ui_user/main/routes/route_utils.dart'; class EmailUtils { @@ -83,4 +85,109 @@ class EmailUtils { return false; } } + + static List extractMailtoLinksFromListPost(String listPost) { + try { + if (listPost.trim().isEmpty) return []; + + final decodedInput = Uri.decodeComponent(listPost); + + final mailtoRegex = RegExp(r'<(mailto:[^<>]+)>'); + + final matches = mailtoRegex.allMatches(decodedInput); + + if (matches.isEmpty) { + log('EmailUtils::extractMailtoLinksFromListPost: Not found mailto link'); + return []; + } + + return matches.map((match) => match.group(1)!).toList(); + } catch (e) { + logError('EmailUtils::extractMailtoLinksFromListPost:Exception = $e'); + return []; + } + } + + static ({ + List toMailAddresses, + List ccMailAddresses, + List bccMailAddresses, + }) extractRecipientsFromListMailtoLink(List mailtoLinks) { + try { + log('EmailUtils::extractRecipientsFromListMailtoLink: mailtoLinks: $mailtoLinks:'); + if (mailtoLinks.isEmpty) { + return ( + toMailAddresses: [], + ccMailAddresses: [], + bccMailAddresses: [], + ); + } + + final toMailAddresses = []; + final ccMailAddresses = []; + final bccMailAddresses = []; + + for (var mailtoLink in mailtoLinks) { + final recipientRecord = extractRecipientsFromMailtoLink(mailtoLink); + toMailAddresses.addAll(recipientRecord.toMailAddresses); + ccMailAddresses.addAll(recipientRecord.ccMailAddresses); + bccMailAddresses.addAll(recipientRecord.bccMailAddresses); + } + + return ( + toMailAddresses: toMailAddresses, + ccMailAddresses: ccMailAddresses, + bccMailAddresses: bccMailAddresses + ); + } catch (e) { + logError('EmailUtils::extractRecipientsFromListMailtoLink:Exception = $e'); + return ( + toMailAddresses: [], + ccMailAddresses: [], + bccMailAddresses: [], + ); + } + } + + static ({ + List toMailAddresses, + List ccMailAddresses, + List bccMailAddresses, + }) extractRecipientsFromMailtoLink(String mailtoLink) { + try { + log('EmailUtils::extractRecipientsFromMailtoLink:mailtoLink: $mailtoLink:'); + if (mailtoLink.isEmpty) { + return ( + toMailAddresses: [], + ccMailAddresses: [], + bccMailAddresses: [], + ); + } + + final navigationRouter = + RouteUtils.generateNavigationRouterFromMailtoLink(mailtoLink); + log('EmailUtils::extractRecipientsFromMailtoLink:navigationRouter = $navigationRouter'); + return ( + toMailAddresses: navigationRouter.listEmailAddress ?? [], + ccMailAddresses: navigationRouter.cc ?? [], + bccMailAddresses: navigationRouter.bcc ?? [], + ); + } catch (e) { + logError('EmailUtils::extractRecipientsFromMailtoLink:Exception = $e'); + return ( + toMailAddresses: [], + ccMailAddresses: [], + bccMailAddresses: [], + ); + } + } + + static ({ + List toMailAddresses, + List ccMailAddresses, + List bccMailAddresses, + }) extractRecipientsFromListPost(String listPost) { + final mailtoLinks = extractMailtoLinksFromListPost(listPost); + return extractRecipientsFromListMailtoLink(mailtoLinks); + } } \ No newline at end of file diff --git a/lib/features/email/presentation/widgets/email_view_bottom_bar_widget.dart b/lib/features/email/presentation/widgets/email_view_bottom_bar_widget.dart index 381ab3f984..4343695aab 100644 --- a/lib/features/email/presentation/widgets/email_view_bottom_bar_widget.dart +++ b/lib/features/email/presentation/widgets/email_view_bottom_bar_widget.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:model/email/email_action_type.dart'; import 'package:model/email/presentation_email.dart'; +import 'package:model/extensions/email_extension.dart'; import 'package:model/extensions/presentation_email_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; import 'package:tmail_ui_user/features/email/presentation/styles/email_view_bottom_bar_widget_styles.dart'; @@ -73,6 +74,37 @@ class EmailViewBottomBarWidget extends StatelessWidget { return const SizedBox.shrink(); } }), + Obx(() { + final currentEmailLoaded = _singleEmailController.currentEmailLoaded.value; + + if (currentEmailLoaded != null && + currentEmailLoaded.emailCurrent?.hasListPost == true) { + return Expanded( + child: TMailButtonWidget( + key: const Key('reply_to_list_email_button'), + text: AppLocalizations.of(context).replyToList, + icon: _imagePaths.icReply, + borderRadius: EmailViewBottomBarWidgetStyles.buttonRadius, + iconSize: EmailViewBottomBarWidgetStyles.buttonIconSize, + textAlign: TextAlign.center, + flexibleText: true, + padding: EmailViewBottomBarWidgetStyles.buttonPadding, + backgroundColor: EmailViewBottomBarWidgetStyles.buttonBackgroundColor, + textStyle: EmailViewBottomBarWidgetStyles.getButtonTextStyle( + context, + _responsiveUtils, + ), + verticalDirection: _responsiveUtils.isPortraitMobile(context), + onTapActionCallback: () => emailActionCallback.call( + EmailActionType.replyToList, + presentationEmail, + ), + ), + ); + } else { + return const SizedBox.shrink(); + } + }), Obx(() { if (_singleEmailController.currentEmailLoaded.value != null) { return Expanded( diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index c86038fb03..61d952d41a 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2024-12-31T12:11:05.777668", + "@@last_modified": "2025-01-09T04:35:09.125475", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", @@ -4083,5 +4083,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "replyToList": "Reply to list", + "@replyToList": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 0af16a8f84..ffaf432c0c 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -4286,4 +4286,12 @@ class AppLocalizations { name: 'getHelpOrReportABug', ); } + + String get replyToList { + return Intl.message( + 'Reply to list', + name: 'replyToList', + ); + } + } diff --git a/model/lib/email/email_action_type.dart b/model/lib/email/email_action_type.dart index a125b4c3ae..c158c2dc93 100644 --- a/model/lib/email/email_action_type.dart +++ b/model/lib/email/email_action_type.dart @@ -1,6 +1,7 @@ enum EmailActionType { reply, + replyToList, forward, replyAll, compose, diff --git a/model/lib/email/email_property.dart b/model/lib/email/email_property.dart index 28d3e42649..c8d0e4e3a5 100644 --- a/model/lib/email/email_property.dart +++ b/model/lib/email/email_property.dart @@ -24,4 +24,5 @@ class EmailProperty { static const String references = 'references'; static const String headerUnsubscribeKey = 'List-Unsubscribe'; static const String headerSMimeStatusKey = 'X-SMIME-Status'; + static const String headerListPostKey = 'List-Post'; } \ No newline at end of file diff --git a/model/lib/extensions/email_extension.dart b/model/lib/extensions/email_extension.dart index dca4929de6..e6002557fb 100644 --- a/model/lib/extensions/email_extension.dart +++ b/model/lib/extensions/email_extension.dart @@ -36,6 +36,10 @@ extension EmailExtension on Email { String get sMimeStatusHeaderParsed => sMimeStatusHeader?[IndividualHeaderIdentifier.sMimeStatusHeader]?.trim() ?? ''; + String get listPost => headers.listPost; + + bool get hasListPost => listPost.isNotEmpty; + IdentityId? get identityIdFromHeader { final rawIdentityId = identityHeader?[IndividualHeaderIdentifier.identityHeader]; if (rawIdentityId == null) return null; diff --git a/model/lib/extensions/list_email_address_extension.dart b/model/lib/extensions/list_email_address_extension.dart index ebece29a4f..8f6416794a 100644 --- a/model/lib/extensions/list_email_address_extension.dart +++ b/model/lib/extensions/list_email_address_extension.dart @@ -51,8 +51,27 @@ extension SetEmailAddressExtension on Set? { Set withoutMe(String userName) { return filterEmailAddress(userName).toSet(); } + + List removeDuplicateEmails() { + final seenEmails = {}; + return this?.where((emailAddress) { + if (emailAddress.emailAddress.isEmpty || + seenEmails.contains(emailAddress.emailAddress)) { + return false; + } else { + seenEmails.add(emailAddress.emailAddress); + return true; + } + }).toList() ?? []; + } } extension ListEmailAddressExtension on List { Set asSetAddress() => map((emailAddress) => emailAddress.emailAddress).toSet(); + + List withoutMe(String? userName) { + if (userName == null) return this; + + return where((emailAddress) => emailAddress.emailAddress != userName).toList(); + } } \ No newline at end of file diff --git a/model/lib/extensions/list_email_header_extension.dart b/model/lib/extensions/list_email_header_extension.dart index 8121e9b522..c3be896738 100644 --- a/model/lib/extensions/list_email_header_extension.dart +++ b/model/lib/extensions/list_email_header_extension.dart @@ -24,4 +24,9 @@ extension ListEmailHeaderExtension on Set? { logger.log('ListEmailHeaderExtension::sMimeStatus: $sMimeStatus'); return sMimeStatus?.value.trim() ?? ''; } + + String get listPost { + final listPost = this?.firstWhereOrNull((header) => header.name == EmailProperty.headerListPostKey); + return listPost?.value ?? ''; + } } \ 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 f329e164ab..9d6912aaa4 100644 --- a/model/lib/extensions/presentation_email_extension.dart +++ b/model/lib/extensions/presentation_email_extension.dart @@ -6,11 +6,9 @@ import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/utils/app_logger.dart'; import 'package:http_parser/http_parser.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; -import 'package:dartz/dartz.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; -import 'package:model/email/email_action_type.dart'; import 'package:model/email/eml_attachment.dart'; import 'package:model/email/presentation_email.dart'; import 'package:model/extensions/email_address_extension.dart'; @@ -54,7 +52,7 @@ extension PresentationEmailExtension on PresentationEmail { PresentationEmail toggleSelect() { return PresentationEmail( - id: this.id, + id: id, blobId: blobId, keywords: keywords, size: size, @@ -80,7 +78,7 @@ extension PresentationEmailExtension on PresentationEmail { PresentationEmail toSelectedEmail({required SelectMode selectMode}) { return PresentationEmail( - id: this.id, + id: id, blobId: blobId, keywords: keywords, size: size, @@ -106,7 +104,7 @@ extension PresentationEmailExtension on PresentationEmail { Email toEmail() { return Email( - id: this.id, + id: id, blobId: blobId, keywords: keywords, size: size, @@ -133,37 +131,13 @@ extension PresentationEmailExtension on PresentationEmail { return allEmailAddress.isNotEmpty ? allEmailAddress.join(', ') : ''; } - Tuple3, List, List> generateRecipientsEmailAddressForComposer({ - required EmailActionType emailActionType, - Role? mailboxRole - }) { - switch(emailActionType) { - case EmailActionType.reply: - if (mailboxRole == PresentationMailbox.roleSent) { - return Tuple3(to.asList(), [], []); - } else { - final replyToAddress = replyTo.asList().isNotEmpty ? replyTo.asList() : from.asList(); - return Tuple3(replyToAddress, [], []); - } - case EmailActionType.replyAll: - if (mailboxRole == PresentationMailbox.roleSent) { - return Tuple3(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()); - } - default: - return Tuple3(to.asList(), cc.asList(), bcc.asList()); - } - } - PresentationEmail toSearchPresentationEmail(Map mapMailboxes) { mailboxIds?.removeWhere((key, value) => !value); final matchedMailbox = findMailboxContain(mapMailboxes); return PresentationEmail( - id: this.id, + id: id, blobId: blobId, keywords: keywords, size: size, @@ -202,7 +176,7 @@ extension PresentationEmailExtension on PresentationEmail { PresentationEmail withRouteWeb(Uri routeWeb) { return PresentationEmail( - id: this.id, + id: id, blobId: blobId, keywords: keywords, size: size, @@ -231,7 +205,7 @@ extension PresentationEmailExtension on PresentationEmail { combinedMap.removeWhere((key, value) => !value); log('PresentationEmailExtension::updateKeywords:combinedMap = $combinedMap'); return PresentationEmail( - id: this.id, + id: id, blobId: blobId, keywords: combinedMap, size: size, @@ -257,7 +231,7 @@ extension PresentationEmailExtension on PresentationEmail { PresentationEmail syncPresentationEmail({PresentationMailbox? mailboxContain, Uri? routeWeb}) { return PresentationEmail( - id: this.id, + id: id, blobId: blobId, keywords: keywords, size: size, diff --git a/test/features/email/extract_email_list_post_test.dart b/test/features/email/extract_email_list_post_test.dart new file mode 100644 index 0000000000..82626f6421 --- /dev/null +++ b/test/features/email/extract_email_list_post_test.dart @@ -0,0 +1,186 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; + +void main() { + group('EmailUtils::extractMailtoLinksFromListPost::', () { + test('should return a list of mailto links when valid mailto links are present', () { + const listPost = ', '; + final result = EmailUtils.extractMailtoLinksFromListPost(listPost); + + expect(result, ['mailto:example1@test.com', 'mailto:example2@test.com']); + }); + + test('should return an empty list when no mailto links are present', () { + const listPost = ', '; + final result = EmailUtils.extractMailtoLinksFromListPost(listPost); + + expect(result, isEmpty); + }); + + test('should return an empty list for an empty string input', () { + const listPost = ''; + final result = EmailUtils.extractMailtoLinksFromListPost(listPost); + + expect(result, isEmpty); + }); + + test('should handle inputs with whitespace only', () { + const listPost = ' '; + final result = EmailUtils.extractMailtoLinksFromListPost(listPost); + + expect(result, isEmpty); + }); + + test('should handle unexpected exceptions and return an empty list', () { + const listPost = '\uD800'; + final result = EmailUtils.extractMailtoLinksFromListPost(listPost); + + expect(result, isEmpty); + }); + + test('should decode encoded URI input and extract mailto links', () { + const listPost = ', '; + final result = EmailUtils.extractMailtoLinksFromListPost(listPost); + + expect(result, ['mailto:example1@test.com', 'mailto:example2@test.com']); + }); + + test('should handle mailto links with additional parameters like cc and bcc', () { + const listPost = ', '; + final result = EmailUtils.extractMailtoLinksFromListPost(listPost); + + expect(result, ['mailto:johndoe@fakeemail.com', 'mailto:janedoe@fakeemail.com?cc=jackdoe@fakeemail.com&bcc=jennydoe@fakeemail.com']); + }); + + test('should handle mailto links with additional parameters like cc, bcc, subject and body', () { + const listPost = ', '; + final result = EmailUtils.extractMailtoLinksFromListPost(listPost); + + expect(result, [ + 'mailto:johndoe@fakeemail.com', + 'mailto:janedoe@fakeemail.com?cc=jackdoe@fakeemail.com&bcc=jennydoe@fakeemail.com&subject=TestSubject&body=TestBody', + ]); + }); + }); + + group('EmailUtils::extractRecipientsFromMailtoLink::', () { + test('should return empty lists for empty mailtoLink', () { + final result = EmailUtils.extractRecipientsFromMailtoLink(''); + expect(result.toMailAddresses, isEmpty); + expect(result.ccMailAddresses, isEmpty); + expect(result.bccMailAddresses, isEmpty); + }); + + test('should extract recipients from valid mailtoLink', () { + const mailtoLink = 'mailto:test@example.com?cc=cc@example.com&bcc=bcc@example.com'; + + final result = EmailUtils.extractRecipientsFromMailtoLink(mailtoLink); + + expect(result.toMailAddresses, [EmailAddress(null, 'test@example.com')]); + expect(result.ccMailAddresses, [EmailAddress(null, 'cc@example.com')]); + expect(result.bccMailAddresses, [EmailAddress(null, 'bcc@example.com')]); + }); + + test('should handle malformed mailtoLink gracefully', () { + const malformedMailtoLink = 'mailto:test@example.com?invalid=params'; + + final result = EmailUtils.extractRecipientsFromMailtoLink(malformedMailtoLink); + + expect(result.toMailAddresses, [EmailAddress(null, 'test@example.com')]); + expect(result.ccMailAddresses, isEmpty); + expect(result.bccMailAddresses, isEmpty); + }); + + test('should handle exceptions gracefully', () { + const invalidMailtoLink = 'mailto:invalid-link, mailto:%E0%A4%A'; + + final result = EmailUtils.extractRecipientsFromMailtoLink(invalidMailtoLink); + + expect(result.toMailAddresses, isEmpty); + expect(result.ccMailAddresses, isEmpty); + expect(result.bccMailAddresses, isEmpty); + }); + + test('should decode and extract recipients from encoded mailtoLink', () { + const encodedMailtoLink = 'mailto:test%40example.com?cc=cc1%40example.com%2Ccc2%40example.com&bcc=bcc%40example.com'; + + final result = EmailUtils.extractRecipientsFromMailtoLink(encodedMailtoLink); + + expect(result.toMailAddresses, [EmailAddress(null, 'test@example.com')]); + expect(result.ccMailAddresses, [EmailAddress(null, 'cc1@example.com'), EmailAddress(null, 'cc2@example.com')]); + expect(result.bccMailAddresses, [EmailAddress(null, 'bcc@example.com')]); + }); + + test('should extract recipients from mailto links with additional parameters like cc, bcc, subject and body', () { + const mailtoLink = 'mailto:test@example.com?cc=cc@example.com&bcc=bcc@example.com&subject=TestSubject&body=TestBody'; + + final result = EmailUtils.extractRecipientsFromMailtoLink(mailtoLink); + + expect(result.toMailAddresses, [EmailAddress(null, 'test@example.com')]); + expect(result.ccMailAddresses, [EmailAddress(null, 'cc@example.com')]); + expect(result.bccMailAddresses, [EmailAddress(null, 'bcc@example.com')]); + }); + }); + + group('EmailUtils::extractRecipientsFromListPost::', () { + test('should return empty lists for empty listPost', () { + final result = EmailUtils.extractRecipientsFromListPost(''); + + expect(result.toMailAddresses, isEmpty); + expect(result.ccMailAddresses, isEmpty); + expect(result.bccMailAddresses, isEmpty); + }); + + test('should extract recipients from a valid listPost', () { + const listPost = ', '; + + final result = EmailUtils.extractRecipientsFromListPost(listPost); + + expect(result.toMailAddresses, [ + EmailAddress(null, 'test@example.com'), + EmailAddress(null, 'another@example.com'), + ]); + expect(result.ccMailAddresses, [EmailAddress(null, 'cc@example.com')]); + expect(result.bccMailAddresses, [EmailAddress(null, 'bcc@example.com')]); + }); + + test('should handle listPost with no valid mailto links', () { + const listPost = 'content without mailto links'; + + final result = EmailUtils.extractRecipientsFromListPost(listPost); + + expect(result.toMailAddresses, isEmpty); + expect(result.ccMailAddresses, isEmpty); + expect(result.bccMailAddresses, isEmpty); + }); + + test('should decode and extract recipients from listPost with encoded mailto links', () { + const listPost = '%3Cmailto%3Atest%2540example.com%3Fcc%3Dcc1%2540example.com%252Ccc2%2540example.com%26bcc%3Dbcc%2540example.com%3E%2C%20%3Cmailto%3Aanother%2540example.com%3E'; + + final result = EmailUtils.extractRecipientsFromListPost(listPost); + + expect(result.toMailAddresses, [ + EmailAddress(null, 'test@example.com'), + EmailAddress(null, 'another@example.com'), + ]); + expect(result.ccMailAddresses, [ + EmailAddress(null, 'cc1@example.com'), + EmailAddress(null, 'cc2@example.com'), + ]); + expect(result.bccMailAddresses, [ + EmailAddress(null, 'bcc@example.com'), + ]); + }); + + test('should extract recipients from list post with additional parameters like cc, bcc, subject and body', () { + const listPost = ''; + + final result = EmailUtils.extractRecipientsFromListPost(listPost); + + expect(result.toMailAddresses, [EmailAddress(null, 'test@example.com')]); + expect(result.ccMailAddresses, [EmailAddress(null, 'cc@example.com')]); + expect(result.bccMailAddresses, [EmailAddress(null, 'bcc@example.com')]); + }); + }); +} diff --git a/test/model/lib/extensions/presentation_email_extension_test.dart b/test/model/lib/extensions/presentation_email_extension_test.dart index f8e964f8b5..f0fde8440b 100644 --- a/test/model/lib/extensions/presentation_email_extension_test.dart +++ b/test/model/lib/extensions/presentation_email_extension_test.dart @@ -3,8 +3,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:model/email/email_action_type.dart'; import 'package:model/email/presentation_email.dart'; -import 'package:model/extensions/presentation_email_extension.dart'; -import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:model/extensions/email_address_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart'; void main() { group('presentation email extension test', () { @@ -14,6 +14,7 @@ void main() { final userDEmailAddress = EmailAddress('User D', 'userD@domain.com'); final userEEmailAddress = EmailAddress('User E', 'userE@domain.com'); final replyToEmailAddress = EmailAddress('Reply To', 'replyToThis@domain.com'); + final replyToListEmailAddress = EmailAddress(null, 'replyToList@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', () { @@ -28,7 +29,8 @@ void main() { final result = emailToReply.generateRecipientsEmailAddressForComposer( emailActionType: EmailActionType.reply, - mailboxRole: PresentationMailbox.roleSent + isSender: true, + userName: userAEmailAddress.emailAddress, ); expect(result.value1, containsAll(expectedResult.value1)); @@ -48,7 +50,8 @@ void main() { final result = emailToReply.generateRecipientsEmailAddressForComposer( emailActionType: EmailActionType.replyAll, - mailboxRole: PresentationMailbox.roleSent + isSender: true, + userName: userAEmailAddress.emailAddress, ); expect(result.value1, containsAll(expectedResult.value1)); @@ -59,7 +62,7 @@ void main() { 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], [], []); + final expectedResult = Tuple3([userBEmailAddress], [], []); final emailToReply = PresentationEmail( from: {userBEmailAddress}, @@ -71,7 +74,7 @@ void main() { final result = emailToReply.generateRecipientsEmailAddressForComposer( emailActionType: EmailActionType.reply, - mailboxRole: PresentationMailbox.roleInbox + isSender: false, ); expect(result.value1, containsAll(expectedResult.value1)); @@ -92,7 +95,7 @@ void main() { final result = emailToReply.generateRecipientsEmailAddressForComposer( emailActionType: EmailActionType.replyAll, - mailboxRole: PresentationMailbox.roleInbox + isSender: false, ); expect(result.value1, containsAll(expectedResult.value1)); @@ -114,7 +117,7 @@ void main() { final result = emailToReply.generateRecipientsEmailAddressForComposer( emailActionType: EmailActionType.reply, - mailboxRole: PresentationMailbox.roleInbox + isSender: false, ); expect(result.value1, containsAll(expectedResult.value1)); @@ -134,7 +137,32 @@ void main() { final result = emailToReply.generateRecipientsEmailAddressForComposer( emailActionType: EmailActionType.replyAll, - mailboxRole: PresentationMailbox.roleInbox + isSender: false, + ); + + expect(result.value1, containsAll(expectedResult.value1)); + expect(result.value2, containsAll(expectedResult.value2)); + expect(result.value3, containsAll(expectedResult.value3)); + }); + + test( + 'THEN user A click reply to list, generateRecipientsEmailAddressForComposer\n' + 'SHOULD return email address in mailto of List-Post to reply\n', + () { + final expectedResult = Tuple3([replyToListEmailAddress], [], []); + + final emailToReplyToList = PresentationEmail( + from: {userBEmailAddress}, + replyTo: {replyToEmailAddress}, + to: {userAEmailAddress, userEEmailAddress}, + cc: {userCEmailAddress}, + bcc: {userDEmailAddress}, + ); + + final result = emailToReplyToList.generateRecipientsEmailAddressForComposer( + emailActionType: EmailActionType.replyToList, + isSender: false, + listPost: '', ); expect(result.value1, containsAll(expectedResult.value1)); @@ -156,7 +184,7 @@ void main() { final result = emailToReply.generateRecipientsEmailAddressForComposer( emailActionType: EmailActionType.forward, - mailboxRole: PresentationMailbox.roleInbox + isSender: false, ); expect(result.value1, containsAll(expectedResult.value1));