TF-1718 Check List-Unsubscribe in header of email & parsing to EmailUnsubscribe

(cherry picked from commit 9dc046a951e5198461178860c8ebe4bedff9062f)
This commit is contained in:
dab246
2023-11-15 19:55:38 +07:00
committed by Dat Vu
parent 7cf5c08ef5
commit 94909470af
8 changed files with 168 additions and 18 deletions
@@ -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<CalendarEvent>();
final eventActions = <EventAction>[].obs;
final emailLoadedViewState = Rx<Either<Failure, Success>>(Right(UIState.idle));
final emailUnsubscribe = Rxn<EmailUnsubscribe>();
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) {
@@ -298,12 +298,13 @@ class EmailView extends GetWidget<SingleEmailController> {
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<SingleEmailController> {
EmailActionType.moveToSpam,
if (presentationEmail.from?.isNotEmpty == true)
EmailActionType.createRule,
EmailActionType.unsubscribe
if (controller.emailUnsubscribe.value != null)
EmailActionType.unsubscribe
];
if (position == null) {
@@ -0,0 +1,19 @@
import 'package:equatable/equatable.dart';
class EmailUnsubscribe with EquatableMixin {
final List<String> httpLinks;
final List<String> mailtoLinks;
EmailUnsubscribe({
required this.httpLinks,
required this.mailtoLinks
});
@override
List<Object?> get props => [
httpLinks,
mailtoLinks
];
}
@@ -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;
}
}
}
@@ -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<EmailReceiverWidget> {
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);
},
@@ -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,
)
]
),
+12
View File
@@ -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<MailboxId, PresentationMailbox> mapMailbox) {
final mailboxCurrent = findMailboxContain(mapMailbox);
return !hasMdnSent &&
@@ -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('<mailto:user@example.com>');
expect(emailUnsubscribe, isNotNull);
expect(emailUnsubscribe!.mailtoLinks, contains('mailto:user@example.com'));
expect(emailUnsubscribe.httpLinks, isEmpty);
});
test('parsingUnsubscribe parses http links', () {
final emailUnsubscribe = EmailUtils.parsingUnsubscribe('<http://example.com>');
expect(emailUnsubscribe, isNotNull);
expect(emailUnsubscribe!.httpLinks, contains('http://example.com'));
expect(emailUnsubscribe.mailtoLinks, isEmpty);
});
test('parsingUnsubscribe parses https links', () {
final emailUnsubscribe = EmailUtils.parsingUnsubscribe('<https://example.com>');
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('<http://example.com>, <https://example.com>');
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('<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 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('<mailto:support@example.com>, <http://example.com>, <http://example2.com>, <http://example3.com>, <mailto:support@example2.com>, <mailto:support@example3.com>');
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);
});
});
}