TF-3413 Support Reply to list for email

This commit is contained in:
dab246
2025-01-09 05:04:55 +07:00
committed by Dat H. Pham
parent 1689f709b5
commit 77b756cbb6
20 changed files with 277 additions and 32 deletions
@@ -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;
}
@@ -74,6 +74,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 +641,15 @@ 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
mailboxRole: arguments.presentationEmail!.mailboxContain?.role
?? mailboxDashBoardController.selectedMailbox.value?.role,
listPost: arguments.listPost,
);
_initSubjectEmail(
presentationEmail: arguments.presentationEmail!,
@@ -798,12 +803,15 @@ class ComposerController extends BaseController
required PresentationEmail presentationEmail,
required EmailActionType actionType,
Role? mailboxRole,
String? listPost,
}) {
log('ComposerController::_initEmailAddress:listPost = $listPost');
final recipients = presentationEmail.generateRecipientsEmailAddressForComposer(
emailActionType: actionType,
mailboxRole: mailboxRole
mailboxRole: mailboxRole,
listPost: listPost,
);
final userName = mailboxDashBoardController.sessionCurrent?.username;
final userName = mailboxDashBoardController.sessionCurrent?.username;
if (userName != null) {
final isSender = presentationEmail.from.asList().every((element) => element.email == userName.value);
if (isSender) {
@@ -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
) {
@@ -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();
@@ -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) {
@@ -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) {
@@ -1425,6 +1425,20 @@ class SingleEmailController extends BaseController with AppLoaderMixin {
)
);
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;
case EmailActionType.replyAll:
mailboxDashBoardController.goToComposer(
ComposerArguments.replyAllEmail(
@@ -0,0 +1,49 @@
import 'package:core/utils/app_logger.dart';
import 'package:dartz/dartz.dart';
import 'package:jmap_dart_client/jmap/mail/email/email_address.dart';
import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.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:model/mailbox/presentation_mailbox.dart';
import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart';
extension PresentationEmailExtension on PresentationEmail {
Tuple3<List<EmailAddress>, List<EmailAddress>, List<EmailAddress>> generateRecipientsEmailAddressForComposer({
required EmailActionType emailActionType,
Role? mailboxRole,
String? listPost,
}) {
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.replyToList:
final listEmailAddress = EmailUtils.parsingListPost(listPost ?? '') ?? [];
log('PresentationEmailExtension::generateRecipientsEmailAddressForComposer:listEmailAddress = $listEmailAddress');
return Tuple3(listEmailAddress, [], []);
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());
}
}
}
@@ -32,6 +32,7 @@ class ComposerArguments extends RouterArguments {
final ScreenDisplayMode displayMode;
final List<EmailAddress>? cc;
final List<EmailAddress>? 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) =>
@@ -130,6 +132,25 @@ class ComposerArguments extends RouterArguments {
references: references,
);
factory ComposerArguments.replyToListEmail({
required PresentationEmail presentationEmail,
required String content,
required List<Attachment> 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({
required PresentationEmail presentationEmail,
required String content,
@@ -206,6 +227,7 @@ class ComposerArguments extends RouterArguments {
displayMode,
cc,
bcc,
listPost,
];
ComposerArguments copyWith({
@@ -229,6 +251,7 @@ class ComposerArguments extends RouterArguments {
ScreenDisplayMode? displayMode,
List<EmailAddress>? cc,
List<EmailAddress>? bcc,
String? listPost,
}) {
return ComposerArguments(
emailActionType: emailActionType ?? this.emailActionType,
@@ -251,6 +274,7 @@ class ComposerArguments extends RouterArguments {
displayMode: displayMode ?? this.displayMode,
cc: cc ?? this.cc,
bcc: bcc ?? this.bcc,
listPost: listPost ?? this.listPost,
);
}
}
@@ -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,38 @@ class EmailUtils {
return false;
}
}
static List<EmailAddress>? parsingListPost(String listPost) {
try {
if (listPost.isEmpty) {
return null;
}
final regExpMailtoLinks = RegExp(r'mailto:([^>,]*)');
final allMatchesMailtoLinks = regExpMailtoLinks.allMatches(listPost);
final listMailtoLinks = allMatchesMailtoLinks
.map((match) => match.group(0))
.whereNotNull()
.toList();
log('EmailUtils::parsingListPost:listMailtoLinks: $listMailtoLinks');
if (listMailtoLinks.isNotEmpty) {
return listMailtoLinks
.map((mailto) {
final mapMailto = RouteUtils.parseMapMailtoFromUri(mailto);
final emailAddress = mapMailto[RouteUtils.paramMailtoAddress];
return emailAddress != null
? EmailAddress(null, emailAddress)
: null;
})
.whereNotNull()
.toList();
} else {
return null;
}
} catch (e) {
logError('EmailUtils::parsingListPost:Exception = $e');
return null;
}
}
}
@@ -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(
+7 -1
View File
@@ -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": {}
}
}
@@ -4286,4 +4286,12 @@ class AppLocalizations {
name: 'getHelpOrReportABug',
);
}
String get replyToList {
return Intl.message(
'Reply to list',
name: 'replyToList',
);
}
}
+1
View File
@@ -1,6 +1,7 @@
enum EmailActionType {
reply,
replyToList,
forward,
replyAll,
compose,
+1
View File
@@ -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';
}
@@ -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;
@@ -24,4 +24,9 @@ extension ListEmailHeaderExtension on Set<EmailHeader>? {
logger.log('ListEmailHeaderExtension::sMimeStatus: $sMimeStatus');
return sMimeStatus?.value.trim() ?? '';
}
String get listPost {
final listPost = this?.firstWhereOrNull((header) => header.name == EmailProperty.headerListPostKey);
return listPost?.value ?? '';
}
}
@@ -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';
@@ -133,30 +131,6 @@ extension PresentationEmailExtension on PresentationEmail {
return allEmailAddress.isNotEmpty ? allEmailAddress.join(', ') : '';
}
Tuple3<List<EmailAddress>, List<EmailAddress>, List<EmailAddress>> 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<MailboxId, PresentationMailbox> mapMailboxes) {
mailboxIds?.removeWhere((key, value) => !value);
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:model/extensions/list_email_address_extension.dart';
import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart';
void main() {
group('parsing email list post test', () {
test('parsingListPost returns null for empty input', () {
expect(EmailUtils.parsingListPost(''), isNull);
});
test('parsingListPost returns null for input without links', () {
expect(EmailUtils.parsingListPost('Some text without links'), isNull);
});
test('parsingListPost parses mailto links', () {
final listEmailAddress =
EmailUtils.parsingListPost('<mailto:user@example.com>');
expect(listEmailAddress, isNotNull);
expect(listEmailAddress![0].email, contains('user@example.com'));
});
test('parsingListPost parses both mailto without <>', () {
final listEmailAddress =
EmailUtils.parsingListPost('mailto:support@example.com');
expect(listEmailAddress, isNotNull);
expect(listEmailAddress![0].email, contains('support@example.com'));
});
test('parsingListPost parses more mailto', () {
final listEmailAddress = EmailUtils.parsingListPost(
'<mailto:support@example.com>, <mailto:support@example2.com>, <mailto:support@example3.com>');
expect(listEmailAddress, isNotNull);
expect(listEmailAddress!.length, equals(3));
expect(
listEmailAddress.asSetAddress(),
containsAll({
'support@example.com',
'support@example2.com',
'support@example3.com'
}),
);
});
test('parsingListPost returns null for input with invalid links', () {
expect(EmailUtils.parsingListPost('Invalid link: invalid'), isNull);
});
});
}
@@ -3,8 +3,9 @@ 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/extensions/email_address_extension.dart';
import 'package:model/mailbox/presentation_mailbox.dart';
import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart';
void main() {
group('presentation email extension test', () {
@@ -14,6 +15,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', () {
@@ -141,6 +143,31 @@ void main() {
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,
mailboxRole: PresentationMailbox.roleInbox,
listPost: '<mailto:${replyToListEmailAddress.emailAddress}>',
);
expect(result.value1, containsAll(expectedResult.value1));
expect(result.value2, containsAll(expectedResult.value2));
expect(result.value3, containsAll(expectedResult.value3));
});
});
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', () {