From 94909470af88e8d7c2e6bf0917f927b3ea93aa07 Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 15 Nov 2023 19:55:38 +0700 Subject: [PATCH] TF-1718 Check List-Unsubscribe in header of email & parsing to EmailUnsubscribe (cherry picked from commit 9dc046a951e5198461178860c8ebe4bedff9062f) --- .../controller/single_email_controller.dart | 18 ++++- .../email/presentation/email_view.dart | 10 +-- .../presentation/model/email_unsubscribe.dart | 19 ++++++ .../email/presentation/utils/email_utils.dart | 34 ++++++++++ .../widgets/email_receiver_widget.dart | 9 ++- ...formation_sender_and_receiver_builder.dart | 16 +++-- model/lib/extensions/email_extension.dart | 12 ++++ .../email/parsing_email_unsubscribe_test.dart | 68 +++++++++++++++++++ 8 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 lib/features/email/presentation/model/email_unsubscribe.dart create mode 100644 test/features/email/parsing_email_unsubscribe_test.dart diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index b9edb37dd..6586e493a 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -58,6 +58,8 @@ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_ import 'package:tmail_ui_user/features/email/domain/usecases/move_to_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/send_receipt_to_sender_interactor.dart'; import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/email_unsubscribe.dart'; +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/attachment_list/attachment_list_bottom_sheet_builder.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/attachment_list/attachment_list_dialog_builder.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/email_address_bottom_sheet_builder.dart'; @@ -113,6 +115,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final calendarEvent = Rxn(); final eventActions = [].obs; final emailLoadedViewState = Rx>(Right(UIState.idle)); + final emailUnsubscribe = Rxn(); EmailId? _currentEmailId; Identity? _identitySelected; @@ -472,8 +475,8 @@ class SingleEmailController extends BaseController with AppLoaderMixin { emailId: currentEmail!.id!, createdTime: currentEmail?.receivedAt?.value ?? DateTime.now(), attachments: success.attachments, - headers: currentEmail?.emailHeader?.toSet(), - keywords: currentEmail?.keywords, + headers: success.emailCurrent?.headers, + keywords: success.emailCurrent?.keywords, htmlEmailContent: success.htmlEmailContent, messageId: success.emailCurrent?.messageId, references: success.emailCurrent?.references, @@ -491,9 +494,19 @@ class SingleEmailController extends BaseController with AppLoaderMixin { if (isShowMessageReadReceipt) { _handleReadReceipt(); } + + if (success.emailCurrent?.hasListUnsubscribe == true) { + _handleUnsubscribe(success.emailCurrent!.listUnsubscribe); + } else { + emailUnsubscribe.value = null; + } } } + void _handleUnsubscribe(String listUnsubscribe) { + emailUnsubscribe.value = EmailUtils.parsingUnsubscribe(listUnsubscribe); + } + void _handleReadReceipt() { if (currentContext != null) { showConfirmDialogAction(currentContext!, @@ -514,6 +527,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { attachments.clear(); calendarEvent.value = null; eventActions.clear(); + emailUnsubscribe.value = null; } PresentationMailbox? getMailboxContain(PresentationEmail email) { diff --git a/lib/features/email/presentation/email_view.dart b/lib/features/email/presentation/email_view.dart index 9c2944e54..ec0c69cdb 100644 --- a/lib/features/email/presentation/email_view.dart +++ b/lib/features/email/presentation/email_view.dart @@ -298,12 +298,13 @@ class EmailView extends GetWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ EmailSubjectWidget(presentationEmail: presentationEmail), - InformationSenderAndReceiverBuilder( - controller: controller, + Obx(() => InformationSenderAndReceiverBuilder( emailSelected: presentationEmail, imagePaths: controller.imagePaths, responsiveUtils: controller.responsiveUtils, - ), + emailUnsubscribe: controller.emailUnsubscribe.value, + openEmailAddressDetailAction: controller.openEmailAddressDialog, + )), Obx(() { final attachments = controller.attachments.listAttachmentsDisplayedOutSide; if (attachments.isNotEmpty) { @@ -429,7 +430,8 @@ class EmailView extends GetWidget { EmailActionType.moveToSpam, if (presentationEmail.from?.isNotEmpty == true) EmailActionType.createRule, - EmailActionType.unsubscribe + if (controller.emailUnsubscribe.value != null) + EmailActionType.unsubscribe ]; if (position == null) { diff --git a/lib/features/email/presentation/model/email_unsubscribe.dart b/lib/features/email/presentation/model/email_unsubscribe.dart new file mode 100644 index 000000000..44d109803 --- /dev/null +++ b/lib/features/email/presentation/model/email_unsubscribe.dart @@ -0,0 +1,19 @@ + +import 'package:equatable/equatable.dart'; + +class EmailUnsubscribe with EquatableMixin { + + final List httpLinks; + final List mailtoLinks; + + EmailUnsubscribe({ + required this.httpLinks, + required this.mailtoLinks + }); + + @override + List get props => [ + httpLinks, + mailtoLinks + ]; +} \ No newline at end of file diff --git a/lib/features/email/presentation/utils/email_utils.dart b/lib/features/email/presentation/utils/email_utils.dart index ed0cd8c8b..ea1dd4d93 100644 --- a/lib/features/email/presentation/utils/email_utils.dart +++ b/lib/features/email/presentation/utils/email_utils.dart @@ -1,8 +1,11 @@ +import 'package:collection/collection.dart'; +import 'package:core/utils/app_logger.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/properties/properties.dart'; import 'package:jmap_dart_client/jmap/core/session/session.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'; @@ -15,4 +18,35 @@ class EmailUtils { return ThreadConstants.propertiesDefault; } } + + static EmailUnsubscribe? parsingUnsubscribe(String listUnsubscribe) { + if (listUnsubscribe.isEmpty) { + return null; + } + + final regExpMailtoLinks = RegExp(r'mailto:([^>,]*)'); + final allMatchesMailtoLinks = regExpMailtoLinks.allMatches(listUnsubscribe); + final listMailtoLinks = allMatchesMailtoLinks + .map((match) => match.group(0)) + .whereNotNull() + .toList(); + log('EmailUtils::parsingUnsubscribe:listMailtoLinks: $listMailtoLinks'); + + final regExpHttpLinks = RegExp(r'http([^>,]*)'); + final allMatchesHttpLinks = regExpHttpLinks.allMatches(listUnsubscribe); + final listHttpLinks = allMatchesHttpLinks + .map((match) => match.group(0)) + .whereNotNull() + .toList(); + log('EmailUtils::parsingUnsubscribe:listHttpLinks: $listHttpLinks'); + + if (listMailtoLinks.isNotEmpty || listHttpLinks.isNotEmpty) { + return EmailUnsubscribe( + httpLinks: listHttpLinks, + mailtoLinks: listMailtoLinks + ); + } else { + return null; + } + } } \ No newline at end of file diff --git a/lib/features/email/presentation/widgets/email_receiver_widget.dart b/lib/features/email/presentation/widgets/email_receiver_widget.dart index 3465bf8aa..e07f3d3b7 100644 --- a/lib/features/email/presentation/widgets/email_receiver_widget.dart +++ b/lib/features/email/presentation/widgets/email_receiver_widget.dart @@ -15,22 +15,21 @@ import 'package:model/extensions/list_email_address_extension.dart'; import 'package:model/extensions/presentation_email_extension.dart'; import 'package:tmail_ui_user/features/base/widget/material_text_button.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/prefix_email_address_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/email_sender_builder.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/utils/app_utils.dart'; -typedef OnPreviewEmailAddressActionCallback = Function(BuildContext context, EmailAddress emailAddress); - class EmailReceiverWidget extends StatefulWidget { final PresentationEmail emailSelected; final double maxWidth; - final OnPreviewEmailAddressActionCallback? onPreviewEmailAddressActionCallback; + final OnOpenEmailAddressDetailAction? openEmailAddressDetailAction; const EmailReceiverWidget({ Key? key, required this.emailSelected, this.maxWidth = 200, - this.onPreviewEmailAddressActionCallback, + this.openEmailAddressDetailAction, }) : super(key: key); @override @@ -218,7 +217,7 @@ class _EmailReceiverWidgetState extends State { label: lastEmailAddress == emailAddress ? emailAddress.asString() : '${emailAddress.asString()},', - onTap: () => widget.onPreviewEmailAddressActionCallback?.call(context, emailAddress), + onTap: () => widget.openEmailAddressDetailAction?.call(context, emailAddress), onLongPress: () { AppUtils.copyEmailAddressToClipboard(context, emailAddress.emailAddress); }, diff --git a/lib/features/email/presentation/widgets/information_sender_and_receiver_builder.dart b/lib/features/email/presentation/widgets/information_sender_and_receiver_builder.dart index ce840034f..b48f9b6b5 100644 --- a/lib/features/email/presentation/widgets/information_sender_and_receiver_builder.dart +++ b/lib/features/email/presentation/widgets/information_sender_and_receiver_builder.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:model/email/presentation_email.dart'; import 'package:model/extensions/presentation_email_extension.dart'; import 'package:tmail_ui_user/features/base/widget/email_avatar_builder.dart'; -import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/email_unsubscribe.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/email_receiver_widget.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/email_sender_builder.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/received_time_builder.dart'; @@ -14,17 +14,19 @@ import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; class InformationSenderAndReceiverBuilder extends StatelessWidget { - final SingleEmailController controller; final PresentationEmail emailSelected; final ResponsiveUtils responsiveUtils; final ImagePaths imagePaths; + final EmailUnsubscribe? emailUnsubscribe; + final OnOpenEmailAddressDetailAction? openEmailAddressDetailAction; const InformationSenderAndReceiverBuilder({ Key? key, - required this.controller, required this.emailSelected, required this.responsiveUtils, - required this.imagePaths + required this.imagePaths, + required this.emailUnsubscribe, + this.openEmailAddressDetailAction, }) : super(key: key); @override @@ -54,10 +56,10 @@ class InformationSenderAndReceiverBuilder extends StatelessWidget { transform: Matrix4.translationValues(-5.0, 0.0, 0.0), child: EmailSenderBuilder( emailAddress: emailSelected.from!.first, - openEmailAddressDetailAction: controller.openEmailAddressDialog, + openEmailAddressDetailAction: openEmailAddressDetailAction, ) )), - if (!responsiveUtils.isPortraitMobile(context)) + if (emailUnsubscribe != null && !responsiveUtils.isPortraitMobile(context)) TMailButtonWidget.fromText( text: AppLocalizations.of(context).unsubscribe, textStyle: const TextStyle( @@ -81,7 +83,7 @@ class InformationSenderAndReceiverBuilder extends StatelessWidget { EmailReceiverWidget( emailSelected: emailSelected, maxWidth: constraints.maxWidth, - onPreviewEmailAddressActionCallback: controller.openEmailAddressDialog, + openEmailAddressDetailAction: openEmailAddressDetailAction, ) ] ), diff --git a/model/lib/extensions/email_extension.dart b/model/lib/extensions/email_extension.dart index afee2f592..745580ba3 100644 --- a/model/lib/extensions/email_extension.dart +++ b/model/lib/extensions/email_extension.dart @@ -1,6 +1,8 @@ import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:core/utils/app_logger.dart'; import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_body_part.dart'; @@ -11,6 +13,8 @@ import 'package:model/model.dart'; extension EmailExtension on Email { + static const String unsubscribeHeaderName = 'List-Unsubscribe'; + String asString() => jsonEncode(toJson()); bool get hasRead => keywords?.containsKey(KeyWordIdentifier.emailSeen) == true; @@ -23,6 +27,14 @@ extension EmailExtension on Email { bool get withAttachments => hasAttachment == true; + String get listUnsubscribe { + final listUnsubscribe = headers?.firstWhereOrNull((header) => header.name == unsubscribeHeaderName); + log('EmailExtension::listUnsubscribe: $listUnsubscribe'); + return listUnsubscribe?.value ?? ''; + } + + bool get hasListUnsubscribe => listUnsubscribe.isNotEmpty; + bool hasReadReceipt(Map mapMailbox) { final mailboxCurrent = findMailboxContain(mapMailbox); return !hasMdnSent && diff --git a/test/features/email/parsing_email_unsubscribe_test.dart b/test/features/email/parsing_email_unsubscribe_test.dart new file mode 100644 index 000000000..b586098a0 --- /dev/null +++ b/test/features/email/parsing_email_unsubscribe_test.dart @@ -0,0 +1,68 @@ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; + +void main() { + group('parsing email unsubscribe test', () { + test('parsingUnsubscribe returns null for empty input', () { + expect(EmailUtils.parsingUnsubscribe(''), isNull); + }); + + test('parsingUnsubscribe returns null for input without links', () { + expect(EmailUtils.parsingUnsubscribe('Some text without links'), isNull); + }); + + test('parsingUnsubscribe parses mailto links', () { + final emailUnsubscribe = EmailUtils.parsingUnsubscribe(''); + expect(emailUnsubscribe, isNotNull); + expect(emailUnsubscribe!.mailtoLinks, contains('mailto:user@example.com')); + expect(emailUnsubscribe.httpLinks, isEmpty); + }); + + test('parsingUnsubscribe parses http links', () { + final emailUnsubscribe = EmailUtils.parsingUnsubscribe(''); + expect(emailUnsubscribe, isNotNull); + expect(emailUnsubscribe!.httpLinks, contains('http://example.com')); + expect(emailUnsubscribe.mailtoLinks, isEmpty); + }); + + test('parsingUnsubscribe parses https links', () { + final emailUnsubscribe = EmailUtils.parsingUnsubscribe(''); + expect(emailUnsubscribe, isNotNull); + expect(emailUnsubscribe!.httpLinks, contains('https://example.com')); + expect(emailUnsubscribe.mailtoLinks, isEmpty); + }); + + test('parsingUnsubscribe parses both http and https links', () { + final emailUnsubscribe = EmailUtils.parsingUnsubscribe(', '); + expect(emailUnsubscribe, isNotNull); + expect(emailUnsubscribe!.httpLinks, containsAll(['http://example.com', 'https://example.com'])); + expect(emailUnsubscribe.mailtoLinks, isEmpty); + }); + + test('parsingUnsubscribe parses both mailto and http links', () { + final emailUnsubscribe = EmailUtils.parsingUnsubscribe(', '); + expect(emailUnsubscribe, isNotNull); + expect(emailUnsubscribe!.mailtoLinks, contains('mailto:support@example.com')); + expect(emailUnsubscribe.httpLinks, contains('http://example.com')); + }); + + test('parsingUnsubscribe parses both mailto and http links without <>', () { + final emailUnsubscribe = EmailUtils.parsingUnsubscribe('mailto:support@example.com, http://example.com'); + expect(emailUnsubscribe, isNotNull); + expect(emailUnsubscribe!.mailtoLinks, contains('mailto:support@example.com')); + expect(emailUnsubscribe.httpLinks, contains('http://example.com')); + }); + + test('parsingUnsubscribe parses more mailto and http links', () { + final emailUnsubscribe = EmailUtils.parsingUnsubscribe(', , , , , '); + expect(emailUnsubscribe, isNotNull); + expect(emailUnsubscribe!.mailtoLinks, containsAll(['mailto:support@example.com', 'mailto:support@example2.com', 'mailto:support@example3.com'])); + expect(emailUnsubscribe.httpLinks, containsAll(['http://example.com', 'http://example2.com', 'http://example3.com'])); + }); + + test('parsingUnsubscribe returns null for input with invalid links', () { + expect(EmailUtils.parsingUnsubscribe('Invalid link: invalid'), isNull); + }); + }); +} \ No newline at end of file