From 490196b36ff1d4ea4b8a45cbc7d91b668995e6a5 Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 16 Dec 2024 15:39:33 +0700 Subject: [PATCH] TF-3349 Escape messages when forward and reply email --- .../extensions/html_extension.dart | 17 +++++++ .../extensions/html_extension_test.dart | 36 +++++++++++++++ .../email_action_type_extension.dart | 37 +++++++-------- .../presentation/view/editor_view_mixin.dart | 7 ++- .../view/mobile/mobile_editor_view.dart | 7 ++- .../view/web/web_editor_view.dart | 7 ++- .../extensions/email_address_extension.dart | 11 +++++ .../list_email_address_extension.dart | 13 ++++++ .../email_address_extension_test.dart | 39 ++++++++++++++++ .../list_email_address_extension_test.dart | 46 +++++++++++++++++++ 10 files changed, 196 insertions(+), 24 deletions(-) create mode 100644 core/test/presentation/extensions/html_extension_test.dart create mode 100644 model/test/extensions/email_address_extension_test.dart create mode 100644 model/test/extensions/list_email_address_extension_test.dart diff --git a/core/lib/presentation/extensions/html_extension.dart b/core/lib/presentation/extensions/html_extension.dart index 6256084cb..5296a807c 100644 --- a/core/lib/presentation/extensions/html_extension.dart +++ b/core/lib/presentation/extensions/html_extension.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; + extension HtmlExtension on String { static const String editorStartTags = '


'; @@ -40,4 +42,19 @@ extension HtmlExtension on String { 'cite', attribute: 'style="text-align: left;display: block;"' ); +} + +extension HtmlNullableExtension on String? { + String escapeHtmlString({HtmlEscapeMode escapeMode = HtmlEscapeMode.unknown}) { + try { + if (this?.trim().isNotEmpty != true) return ''; + + return HtmlEscape(escapeMode).convert(this!); + } catch (e) { + return ''; + } + } + + String escapeLtGtHtmlString() => + escapeHtmlString(escapeMode: HtmlEscapeMode.element); } \ No newline at end of file diff --git a/core/test/presentation/extensions/html_extension_test.dart b/core/test/presentation/extensions/html_extension_test.dart new file mode 100644 index 000000000..76958e69b --- /dev/null +++ b/core/test/presentation/extensions/html_extension_test.dart @@ -0,0 +1,36 @@ +import 'package:core/presentation/extensions/html_extension.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('HtmlNullableExtension::', () { + test('escapeHtmlString should escapes HTML containing onclick attribute', () { + String? input = "Click"; + expect(input.escapeHtmlString(), + '<a onclick="alert('hi')">Click</a>'); + }); + + test('escapeHtmlString should escapes HTML containing multiple events', () { + String? input = "
Hover
"; + expect(input.escapeHtmlString(), + '<div onmouseover="alert('hover')" onclick="doSomething()">Hover</div>'); + }); + + test('escapeLtGtHtmlString should escapes only < and > with events', () { + String? input = ""; + expect(input.escapeLtGtHtmlString(), + '<button onclick=\"run()\">Run</button>'); + }); + + test('escapeHtmlString should handles HTML with empty event attributes', () { + String? input = ""; + expect(input.escapeHtmlString(), + '<img src="image.png" onerror="">'); + }); + + test('escapeHtmlString should handles HTML with invalid syntax in events', () { + String? input = "Click"; + expect(input.escapeHtmlString(), + '<a onclick="alert('unclosed event)>Click</a>'); + }); + }); +} \ No newline at end of file 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 5c0a523d1..b664c3717 100644 --- a/lib/features/composer/presentation/extensions/email_action_type_extension.dart +++ b/lib/features/composer/presentation/extensions/email_action_type_extension.dart @@ -53,62 +53,63 @@ extension EmailActionTypeExtension on EmailActionType { } String? getHeaderEmailQuoted({ - required BuildContext context, + required Locale locale, + required AppLocalizations appLocalizations, required PresentationEmail presentationEmail }) { - final locale = Localizations.localeOf(context).toLanguageTag(); + final languageTag = locale.toLanguageTag(); switch(this) { case EmailActionType.reply: case EmailActionType.replyAll: final receivedAt = presentationEmail.receivedAt; - final emailAddress = presentationEmail.from.listEmailAddressToString(isFullEmailAddress: true); - return AppLocalizations.of(context).header_email_quoted( - receivedAt.formatDateToLocal(pattern: 'MMM d, y h:mm a', locale: locale), + final emailAddress = presentationEmail.from.toEscapeHtmlStringUseCommaSeparator(); + return appLocalizations.header_email_quoted( + receivedAt.formatDateToLocal(pattern: 'MMM d, y h:mm a', locale: languageTag), emailAddress ); case EmailActionType.forward: - var headerQuoted = '------- ${AppLocalizations.of(context).forwarded_message} -------'.addNewLineTag(); + var headerQuoted = '------- ${appLocalizations.forwarded_message} -------'.addNewLineTag(); - final subject = presentationEmail.subject ?? ''; + final subject = presentationEmail.subject?.escapeLtGtHtmlString() ?? ''; final receivedAt = presentationEmail.receivedAt; - final fromEmailAddress = presentationEmail.from.listEmailAddressToString(isFullEmailAddress: true); - final toEmailAddress = presentationEmail.to.listEmailAddressToString(isFullEmailAddress: true); - final ccEmailAddress = presentationEmail.cc.listEmailAddressToString(isFullEmailAddress: true); - final bccEmailAddress = presentationEmail.bcc.listEmailAddressToString(isFullEmailAddress: true); + final fromEmailAddress = presentationEmail.from.toEscapeHtmlStringUseCommaSeparator(); + final toEmailAddress = presentationEmail.to.toEscapeHtmlStringUseCommaSeparator(); + final ccEmailAddress = presentationEmail.cc.toEscapeHtmlStringUseCommaSeparator(); + final bccEmailAddress = presentationEmail.bcc.toEscapeHtmlStringUseCommaSeparator(); if (subject.isNotEmpty) { headerQuoted = headerQuoted - .append('${AppLocalizations.of(context).subject_email}: ') + .append('${appLocalizations.subject_email}: ') .append(subject) .addNewLineTag(); } if (receivedAt != null) { headerQuoted = headerQuoted - .append('${AppLocalizations.of(context).date}: ') - .append(receivedAt.formatDateToLocal(pattern: 'MMM d, y h:mm a', locale: locale)) + .append('${appLocalizations.date}: ') + .append(receivedAt.formatDateToLocal(pattern: 'MMM d, y h:mm a', locale: languageTag)) .addNewLineTag(); } if (fromEmailAddress.isNotEmpty) { headerQuoted = headerQuoted - .append('${AppLocalizations.of(context).from_email_address_prefix}: ') + .append('${appLocalizations.from_email_address_prefix}: ') .append(fromEmailAddress) .addNewLineTag(); } if (toEmailAddress.isNotEmpty) { headerQuoted = headerQuoted - .append('${AppLocalizations.of(context).to_email_address_prefix}: ') + .append('${appLocalizations.to_email_address_prefix}: ') .append(toEmailAddress) .addNewLineTag(); } if (ccEmailAddress.isNotEmpty) { headerQuoted = headerQuoted - .append('${AppLocalizations.of(context).cc_email_address_prefix}: ') + .append('${appLocalizations.cc_email_address_prefix}: ') .append(ccEmailAddress) .addNewLineTag(); } if (bccEmailAddress.isNotEmpty) { headerQuoted = headerQuoted - .append('${AppLocalizations.of(context).bcc_email_address_prefix}: ') + .append('${appLocalizations.bcc_email_address_prefix}: ') .append(bccEmailAddress) .addNewLineTag(); } diff --git a/lib/features/composer/presentation/view/editor_view_mixin.dart b/lib/features/composer/presentation/view/editor_view_mixin.dart index 8e54f1cb0..c319f8dd9 100644 --- a/lib/features/composer/presentation/view/editor_view_mixin.dart +++ b/lib/features/composer/presentation/view/editor_view_mixin.dart @@ -5,16 +5,19 @@ import 'package:flutter/material.dart'; import 'package:model/email/email_action_type.dart'; import 'package:model/email/presentation_email.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/email_action_type_extension.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; mixin EditorViewMixin { String getEmailContentQuotedAsHtml({ - required BuildContext context, + required Locale locale, + required AppLocalizations appLocalizations, required String emailContent, required EmailActionType emailActionType, required PresentationEmail presentationEmail, }) { final headerEmailQuoted = emailActionType.getHeaderEmailQuoted( - context: context, + locale: locale, + appLocalizations: appLocalizations, presentationEmail: presentationEmail ); log('EditorViewMixin::getEmailContentQuotedAsHtml:headerEmailQuoted: $headerEmailQuoted'); 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 b425d86ca..1859f9026 100644 --- a/lib/features/composer/presentation/view/mobile/mobile_editor_view.dart +++ b/lib/features/composer/presentation/view/mobile/mobile_editor_view.dart @@ -10,6 +10,7 @@ import 'package:tmail_ui_user/features/composer/presentation/widgets/mobile/mobi import 'package:tmail_ui_user/features/email/domain/state/get_email_content_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/transform_html_email_content_state.dart'; import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/utils/app_utils.dart'; class MobileEditorView extends StatelessWidget with EditorViewMixin { @@ -87,7 +88,8 @@ class MobileEditorView extends StatelessWidget with EditorViewMixin { return contentViewState!.fold( (failure) { final emailContentQuoted = getEmailContentQuotedAsHtml( - context: context, + locale: Localizations.localeOf(context), + appLocalizations: AppLocalizations.of(context), emailContent: '', emailActionType: arguments!.emailActionType, presentationEmail: arguments!.presentationEmail! @@ -104,7 +106,8 @@ class MobileEditorView extends StatelessWidget with EditorViewMixin { return const CupertinoLoadingWidget(padding: EdgeInsets.all(16.0)); } else { final emailContentQuoted = getEmailContentQuotedAsHtml( - context: context, + locale: Localizations.localeOf(context), + appLocalizations: AppLocalizations.of(context), emailContent: success is TransformHtmlEmailContentSuccess ? success.htmlContent : '', 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 f04efac66..f7cd0cca8 100644 --- a/lib/features/composer/presentation/view/web/web_editor_view.dart +++ b/lib/features/composer/presentation/view/web/web_editor_view.dart @@ -13,6 +13,7 @@ import 'package:tmail_ui_user/features/composer/presentation/widgets/web/web_edi import 'package:tmail_ui_user/features/email/domain/state/get_email_content_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/transform_html_email_content_state.dart'; import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/utils/app_utils.dart'; class WebEditorView extends StatelessWidget with EditorViewMixin { @@ -152,7 +153,8 @@ class WebEditorView extends StatelessWidget with EditorViewMixin { return contentViewState!.fold( (failure) { final emailContentQuoted = getEmailContentQuotedAsHtml( - context: context, + locale: Localizations.localeOf(context), + appLocalizations: AppLocalizations.of(context), emailContent: '', emailActionType: arguments!.emailActionType, presentationEmail: arguments!.presentationEmail! @@ -181,7 +183,8 @@ class WebEditorView extends StatelessWidget with EditorViewMixin { return const CupertinoLoadingWidget(padding: EdgeInsets.all(16.0)); } else { final emailContentQuoted = getEmailContentQuotedAsHtml( - context: context, + locale: Localizations.localeOf(context), + appLocalizations: AppLocalizations.of(context), emailContent: success is TransformHtmlEmailContentSuccess ? success.htmlContent : '', diff --git a/model/lib/extensions/email_address_extension.dart b/model/lib/extensions/email_address_extension.dart index 2a0b31d9c..cb1b7a0b1 100644 --- a/model/lib/extensions/email_address_extension.dart +++ b/model/lib/extensions/email_address_extension.dart @@ -27,6 +27,17 @@ extension EmailAddressExtension on EmailAddress { return ''; } + String asFullStringWithLtGtCharacter() { + if (displayName.isNotEmpty && emailAddress.isNotEmpty) { + return '${displayName.capitalizeFirstEach} <$emailAddress>'; + } else if (displayName.isNotEmpty) { + return displayName.capitalizeFirstEach; + } else if (emailAddress.isNotEmpty) { + return '<$emailAddress>'; + } + return ''; + } + String get emailAddress => email ?? ''; String get displayName => name ?? ''; diff --git a/model/lib/extensions/list_email_address_extension.dart b/model/lib/extensions/list_email_address_extension.dart index 84a9465bf..41b4628e8 100644 --- a/model/lib/extensions/list_email_address_extension.dart +++ b/model/lib/extensions/list_email_address_extension.dart @@ -1,3 +1,4 @@ +import 'package:core/presentation/extensions/html_extension.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:model/mailbox/expand_mode.dart'; import 'package:model/extensions/email_address_extension.dart'; @@ -27,6 +28,18 @@ extension SetEmailAddressExtension on Set? { return listEmail.isNotEmpty ? listEmail.join(', ') : ''; } + String toEscapeHtmlString(String separator) { + if (this?.isNotEmpty != true) return ''; + + final listEmail = this + !.map((emailAddress) => emailAddress.asFullStringWithLtGtCharacter().escapeLtGtHtmlString()) + .toList(); + + return listEmail.isNotEmpty ? listEmail.join(separator) : ''; + } + + String toEscapeHtmlStringUseCommaSeparator() => toEscapeHtmlString(', '); + int numberEmailAddress() => this != null ? this!.length : 0; List filterEmailAddress(String emailAddressNotExist) { diff --git a/model/test/extensions/email_address_extension_test.dart b/model/test/extensions/email_address_extension_test.dart new file mode 100644 index 000000000..af3bbd920 --- /dev/null +++ b/model/test/extensions/email_address_extension_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:model/extensions/email_address_extension.dart'; + +void main() { + group('EmailAddressExtension::asFullStringWithLtGtCharacter::', () { + test('Should returns displayName and emailAddress formatted correctly', () { + final emailAddress = EmailAddress( + 'john doe', + 'john.doe@example.com', + ); + expect( + emailAddress.asFullStringWithLtGtCharacter(), + 'John Doe ', + ); + }); + + test('Should returns displayName capitalized when emailAddress is empty', () { + final emailAddress = EmailAddress( + 'jane doe', + '', + ); + expect(emailAddress.asFullStringWithLtGtCharacter(), 'Jane Doe'); + }); + + test('Should returns emailAddress enclosed in <> when displayName is empty', () { + final emailAddress = EmailAddress( + '', + 'jane.doe@example.com', + ); + expect(emailAddress.asFullStringWithLtGtCharacter(), ''); + }); + + test('Should returns an empty string when both displayName and emailAddress are empty', () { + final emailAddress = EmailAddress('', ''); + expect(emailAddress.asFullStringWithLtGtCharacter(), ''); + }); + }); +} \ No newline at end of file diff --git a/model/test/extensions/list_email_address_extension_test.dart b/model/test/extensions/list_email_address_extension_test.dart new file mode 100644 index 000000000..2ade025f6 --- /dev/null +++ b/model/test/extensions/list_email_address_extension_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:model/extensions/list_email_address_extension.dart'; + +void main() { + group('ListEmailAddressExtension::toEscapeHtmlString::', () { + test('Should returns empty string for null or empty list', () { + expect(({}).toEscapeHtmlString(', '), ''); + + Set? listEmails; + expect(listEmails.toEscapeHtmlString(', '), ''); + }); + + test('Should returns joined HTML strings with separator', () { + final emails = { + EmailAddress('John Doe', 'john@example.com'), + EmailAddress('Jane Smith', 'jane@example.com'), + }; + final result = emails.toEscapeHtmlString(' | '); + + expect(result, 'John Doe <john@example.com> | Jane Smith <jane@example.com>'); + }); + + test('Should handles displayName-only or email-only cases', () { + final emails = { + EmailAddress('John Doe', ''), + EmailAddress('', 'jane@example.com'), + }; + final result = emails.toEscapeHtmlString(', '); + + expect(result, 'John Doe, <jane@example.com>'); + }); + }); + + group('ListEmailAddressExtension::toEscapeHtmlStringUseCommaSeparator::', () { + test('Should uses ", " as separator', () { + final emails = { + EmailAddress('John Doe', 'john@example.com'), + EmailAddress('Jane Smith', 'jane@example.com'), + }; + final result = emails.toEscapeHtmlStringUseCommaSeparator(); + + expect(result, 'John Doe <john@example.com>, Jane Smith <jane@example.com>'); + }); + }); +}