From 352a7e59a56f675ef0d4fb3ae1677e4b5f290e66 Mon Sep 17 00:00:00 2001 From: Florent Azavant Date: Fri, 15 Nov 2024 14:01:33 +0100 Subject: [PATCH] TF-3189 composer now correctly encodes subaddresses --- core/lib/utils/mail/mail_address.dart | 128 ++++++++++++++++++ core/test/utils/mail_address_test.dart | 49 +++++++ .../presentation/composer_controller.dart | 45 +++--- .../extensions/mail_address_extension.dart | 17 +++ .../widgets/recipient_composer_widget.dart | 23 ++-- .../email/presentation/utils/email_utils.dart | 3 +- 6 files changed, 230 insertions(+), 35 deletions(-) create mode 100644 lib/features/composer/presentation/extensions/mail_address_extension.dart diff --git a/core/lib/utils/mail/mail_address.dart b/core/lib/utils/mail/mail_address.dart index a791220063..5861b244fb 100644 --- a/core/lib/utils/mail/mail_address.dart +++ b/core/lib/utils/mail/mail_address.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:core/domain/exceptions/address_exception.dart'; import 'package:core/utils/app_logger.dart'; import 'package:core/utils/mail/domain.dart'; @@ -23,8 +24,15 @@ class MailAddress with EquatableMixin { final String localPart; final Domain domain; + static const String subaddressingLocalPartDelimiter = '+'; + MailAddress({required this.localPart, required this.domain}); + MailAddress.fromParts({required String localPartWithoutDetails, required String localPartDetails, required this.domain}) : localPart = + localPartDetails.isEmpty + ? localPartWithoutDetails + : '$localPartWithoutDetails$subaddressingLocalPartDelimiter$localPartDetails'; + factory MailAddress.validateAddress(String address) { log('MailAddress::validate: Address = $address'); String localPart; @@ -137,6 +145,89 @@ class MailAddress with EquatableMixin { return localPart; } + String? getLocalPartDetails() { + int separatorPosition = localPart.indexOf(subaddressingLocalPartDelimiter); + if (separatorPosition <= 0) { + return null; + } + return localPart.substring(separatorPosition + subaddressingLocalPartDelimiter.length); + } + + String getLocalPartWithoutDetails() { + int separatorPosition = localPart.indexOf(subaddressingLocalPartDelimiter); + if (separatorPosition <= 0) { + return localPart; + } + return localPart.substring(0, separatorPosition); + } + + MailAddress stripDetails() { + return MailAddress(localPart: getLocalPartWithoutDetails(), domain: domain); + } + + // cannot use Uri.encodeComponent because it is meant to be compliant with RFC2396 + // eg `-_.!~*'()` are not encoded, but we want `!*'()` to be + static final _needsNoEncoding = RegExp(r'^[a-zA-Z0-9._~-]+$'); + + // this table is adapted from `_unreserved2396Table` found at + // https://github.com/dart-lang/sdk/blob/58f9beb6d4ec9e93430454bb96c0b8f068d0b0bc/sdk/lib/core/uri.dart#L3382 + static const _customUnreservedTable = [ + // LSB MSB + // | | + 0x0000, // 0x00 - 0x0f 0000000000000000 + 0x0000, // 0x10 - 0x1f 0000000000000000 + // -. + 0x6000, // 0x20 - 0x2f 0000000000000110 + // 0123456789 + 0x03ff, // 0x30 - 0x3f 1111111111000000 + // ABCDEFGHIJKLMNO + 0xfffe, // 0x40 - 0x4f 0111111111111111 + // PQRSTUVWXYZ _ + 0x87ff, // 0x50 - 0x5f 1111111111100001 + // abcdefghijklmno + 0xfffe, // 0x60 - 0x6f 0111111111111111 + // pqrstuvwxyz ~ + 0x47ff, // 0x70 - 0x7f 1111111111100010 + ]; + + // this method is adapted from `_uriEncode()` found at: + // https://github.com/dart-lang/sdk/blob/bb8db16297e6b9994b08ecae6ee1dd45a0be587e/sdk/lib/_internal/wasm/lib/uri_patch.dart#L49 + static String customUriEncode(String text) { + if (_needsNoEncoding.hasMatch(text)) { + return text; + } + + // Encode the string into bytes then generate an ASCII only string + // by percent encoding selected bytes. + StringBuffer result = StringBuffer(''); + var bytes = utf8.encode(text); + for (int byte in bytes) { + if (byte < 128 && + ((_customUnreservedTable[byte >> 4] & (1 << (byte & 0x0f))) != 0)) { + result.writeCharCode(byte); + } else { + const String hexDigits = '0123456789ABCDEF'; + result.write('%'); + result.write(hexDigits[(byte >> 4) & 0x0f]); + result.write(hexDigits[byte & 0x0f]); + } + } + return result.toString(); + } + + String asEncodedString() { + String? localPartDetails = getLocalPartDetails(); + if(localPartDetails == null) { + return asString(); + } else { + return MailAddress.fromParts( + localPartWithoutDetails: getLocalPartWithoutDetails(), + localPartDetails: customUriEncode(localPartDetails), + domain: domain + ).asString(); + } + } + @override String toString() { return '$localPart@${domain.asString()}'; @@ -323,6 +414,10 @@ class MailAddress with EquatableMixin { lpSB.write('.'); pos++; lastCharDot = true; + } else if (postChar == subaddressingLocalPartDelimiter) { + // Start of local part details, jump to the `@` + lpSB.write(subaddressingLocalPartDelimiter); + pos = _parseLocalPartDetails(lpSB, address, pos+1); } else if (postChar == '@') { // End of local-part break; @@ -416,6 +511,39 @@ class MailAddress with EquatableMixin { return pos; } + static int _parseLocalPartDetails(StringBuffer localPartSB, String address, int pos) { + StringBuffer localPartDetailsSB = StringBuffer(); + + while (true) { + if (pos >= address.length) { + break; + } + var postChar = address[pos]; + if (postChar == '@') { + // End of local-part-details + break; + } else { + localPartDetailsSB.write(postChar); + pos++; + } + } + + String localPartDetails = localPartDetailsSB.toString(); + if (localPartDetails.isEmpty || localPartDetails.trim().isEmpty) { + throw AddressException("target mailbox name should not be empty"); + } + if (localPartDetails.startsWith('#')) { + throw AddressException("target mailbox name should not start with #"); + } + final forbiddenChars = RegExp(r'[*\r\n]'); + if (forbiddenChars.hasMatch(localPartDetails)) { + throw AddressException("target mailbox name should not contain special characters"); + } + + localPartSB.write(localPartDetails); + return pos; + } + @override List get props => [localPart, domain]; } diff --git a/core/test/utils/mail_address_test.dart b/core/test/utils/mail_address_test.dart index 2265458a8c..7425f96c8e 100644 --- a/core/test/utils/mail_address_test.dart +++ b/core/test/utils/mail_address_test.dart @@ -20,6 +20,9 @@ void main() { "Abc.123@example.com", "user+mailbox/department=shipping@example.com", "user+mailbox@example.com", + "user+folder@james.apache.org", + "user+my folder@domain.com", + "user+Dossier d'été@domain.com", "\"Abc@def\"@example.com", "\"Fred Bloggs\"@example.com", "\"Joe.\\Blow\"@example.com", @@ -56,6 +59,11 @@ void main() { "server-dev@#123.apache.org", "server-dev@[127.0.1.1.1]", "server-dev@[127.0.1.-1]", + "user+@domain.com", + "user+ @domain.com", + "user+#folder@domain.com", + "user+Dossier%20d%27%C3%A9t%C3%A9@domain.com", + "user+test-_.!~*'() @domain.com", "\"a..b\"@domain.com", // jakarta.mail is unable to handle this so we better reject it "server-dev\\.@james.apache.org", // jakarta.mail is unable to handle this so we better reject it "a..b@domain.com", @@ -165,5 +173,46 @@ void main() { final mailAddress = MailAddress.validateAddress(GOOD_ADDRESS); expect(mailAddress.toString(), equals(GOOD_ADDRESS)); }); + + test('MailAddress.encodeLocalPartDetails() should work with characters to encode', () { + final mailAddress = MailAddress.validateAddress("user+my folder@domain.com"); + expect(mailAddress.asEncodedString(), equals("user+my%20folder@domain.com")); + }); + + test('MailAddress.encodeLocalPartDetails() should work with many characters to encode', () { + final mailAddress = MailAddress.validateAddress("user+Dossier d'été@domain.com"); + expect(mailAddress.asEncodedString(), equals("user+Dossier%20d%27%C3%A9t%C3%A9@domain.com")); + }); + + test('MailAddress.encodeLocalPartDetails() should encode the rights characters', () { + final mailAddress = MailAddress.validateAddress("user+test-_.!~'() @domain.com"); + expect(mailAddress.asEncodedString(), equals("user+test-_.%21~%27%28%29%20@domain.com")); + }); + + test('getLocalPartDetails() should work', () { + final mailAddress = MailAddress.validateAddress("user+details@domain.com"); + expect(mailAddress.getLocalPartDetails(), equals("details")); + }); + + test('getLocalPartWithoutDetails() should work', () { + final mailAddress = MailAddress.validateAddress("user+details@domain.com"); + expect(mailAddress.getLocalPartWithoutDetails(), equals("user")); + }); + + test('stripDetails() should work', () { + final mailAddress = MailAddress.validateAddress("user+details@domain.com"); + expect(mailAddress.stripDetails().asString(), equals("user@domain.com")); + }); + + test('stripDetails() should work with encoded local part', () { + final mailAddress = MailAddress.validateAddress("user+Dossier%20d%27%C3%A9t%C3%A9@domain.com"); + expect(mailAddress.stripDetails().asString(), equals("user@domain.com")); + }); + + test('stripDetails() should work when local part needs encoding', () { + final mailAddress = MailAddress.validateAddress("user+super folder@domain.com"); + expect(mailAddress.stripDetails().asString(), equals("user@domain.com")); + }); + }); } \ 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 751f64c329..d90e26d85a 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -58,6 +58,7 @@ import 'package:tmail_ui_user/features/composer/presentation/controller/rich_tex import 'package:tmail_ui_user/features/composer/presentation/extensions/email_action_type_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/list_identities_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/list_shared_media_file_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/mixin/drag_drog_file_mixin.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/create_email_request.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/draggable_email_address.dart'; @@ -1600,16 +1601,16 @@ class ComposerController extends BaseController final inputReplyToEmail = replyToEmailAddressController.text; if (inputToEmail.isNotEmpty) { - _autoCreateToEmailTag(inputToEmail); + _autoCreateToEmailTag(MailAddress.validateAddress(inputToEmail)); } if (inputCcEmail.isNotEmpty) { - _autoCreateCcEmailTag(inputCcEmail); + _autoCreateCcEmailTag(MailAddress.validateAddress(inputCcEmail)); } if (inputBccEmail.isNotEmpty) { - _autoCreateBccEmailTag(inputBccEmail); + _autoCreateBccEmailTag(MailAddress.validateAddress(inputBccEmail)); } if (inputReplyToEmail.isNotEmpty) { - _autoCreateReplyToEmailTag(inputReplyToEmail); + _autoCreateReplyToEmailTag(MailAddress.validateAddress(inputReplyToEmail)); } } @@ -1620,10 +1621,9 @@ class ComposerController extends BaseController .contains(inputEmail); } - void _autoCreateToEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listToEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listToEmailAddress.add(emailAddress); + void _autoCreateToEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listToEmailAddress)) { + listToEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1635,10 +1635,9 @@ class ComposerController extends BaseController }); } - void _autoCreateCcEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listCcEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listCcEmailAddress.add(emailAddress); + void _autoCreateCcEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listCcEmailAddress)) { + listCcEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1649,10 +1648,9 @@ class ComposerController extends BaseController }); } - void _autoCreateBccEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listBccEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listBccEmailAddress.add(emailAddress); + void _autoCreateBccEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listBccEmailAddress)) { + listBccEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1663,10 +1661,9 @@ class ComposerController extends BaseController }); } - void _autoCreateReplyToEmailTag(String inputEmail) { - if (!_isDuplicatedRecipient(inputEmail, listReplyToEmailAddress)) { - final emailAddress = EmailAddress(null, inputEmail); - listReplyToEmailAddress.add(emailAddress); + void _autoCreateReplyToEmailTag(MailAddress inputMailAddress) { + if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listReplyToEmailAddress)) { + listReplyToEmailAddress.add(inputMailAddress.asEmailAddress()); isInitialRecipient.value = true; isInitialRecipient.refresh(); _updateStatusEmailSendButton(); @@ -1745,28 +1742,28 @@ class ComposerController extends BaseController toAddressExpandMode.value = ExpandMode.COLLAPSE; final inputToEmail = toEmailAddressController.text; if (inputToEmail.isNotEmpty) { - _autoCreateToEmailTag(inputToEmail); + _autoCreateToEmailTag(MailAddress.validateAddress(inputToEmail)); } break; case PrefixEmailAddress.cc: ccAddressExpandMode.value = ExpandMode.COLLAPSE; final inputCcEmail = ccEmailAddressController.text; if (inputCcEmail.isNotEmpty) { - _autoCreateCcEmailTag(inputCcEmail); + _autoCreateCcEmailTag(MailAddress.validateAddress(inputCcEmail)); } break; case PrefixEmailAddress.bcc: bccAddressExpandMode.value = ExpandMode.COLLAPSE; final inputBccEmail = bccEmailAddressController.text; if (inputBccEmail.isNotEmpty) { - _autoCreateBccEmailTag(inputBccEmail); + _autoCreateBccEmailTag(MailAddress.validateAddress(inputBccEmail)); } break; case PrefixEmailAddress.replyTo: replyToAddressExpandMode.value = ExpandMode.COLLAPSE; final inputReplyToEmail = replyToEmailAddressController.text; if (inputReplyToEmail.isNotEmpty) { - _autoCreateReplyToEmailTag(inputReplyToEmail); + _autoCreateReplyToEmailTag(MailAddress.validateAddress(inputReplyToEmail)); } break; default: diff --git a/lib/features/composer/presentation/extensions/mail_address_extension.dart b/lib/features/composer/presentation/extensions/mail_address_extension.dart new file mode 100644 index 0000000000..fd804db22e --- /dev/null +++ b/lib/features/composer/presentation/extensions/mail_address_extension.dart @@ -0,0 +1,17 @@ +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:core/core.dart'; + +extension MailAddressExtension on MailAddress { + String? get getDisplayName { + String? localPartDetails = getLocalPartDetails(); + if(localPartDetails == null) { + return null; + } else { + return '${getLocalPartWithoutDetails()} [${getLocalPartDetails()}]'; + } + } + + EmailAddress asEmailAddress() { + return EmailAddress(getDisplayName, asEncodedString()); + } +} \ No newline at end of file diff --git a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart index 424278affd..80a0163231 100644 --- a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart @@ -8,6 +8,7 @@ import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/utils/responsive_utils.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/mail/mail_address.dart'; import 'package:core/utils/platform_info.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -17,6 +18,7 @@ import 'package:model/extensions/email_address_extension.dart'; import 'package:model/mailbox/expand_mode.dart'; import 'package:super_tag_editor/tag_editor.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/prefix_email_address_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/draggable_email_address.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/prefix_recipient_state.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/suggestion_email_address.dart'; @@ -220,7 +222,7 @@ class _RecipientComposerWidgetState extends State { return RecipientSuggestionItemWidget( imagePaths: widget.imagePaths, suggestionState: suggestionEmailAddress.state, - emailAddress: suggestionEmailAddress.emailAddress, + emailAddress: MailAddress.validateAddress(suggestionEmailAddress.emailAddress.emailAddress).asEmailAddress(), suggestionValid: suggestionValid, highlight: highlight, onSelectedAction: (emailAddress) { @@ -304,7 +306,7 @@ class _RecipientComposerWidgetState extends State { return RecipientSuggestionItemWidget( imagePaths: widget.imagePaths, suggestionState: suggestionEmailAddress.state, - emailAddress: suggestionEmailAddress.emailAddress, + emailAddress: MailAddress.validateAddress(suggestionEmailAddress.emailAddress.emailAddress).asEmailAddress(), suggestionValid: suggestionValid, highlight: highlight, onSelectedAction: (emailAddress) { @@ -505,8 +507,9 @@ class _RecipientComposerWidgetState extends State { SuggestionEmailAddress suggestionEmailAddress, StateSetter stateSetter ) { - if (!_isDuplicatedRecipient(suggestionEmailAddress.emailAddress.emailAddress)) { - stateSetter(() => _currentListEmailAddress.add(suggestionEmailAddress.emailAddress)); + MailAddress mailAddress = MailAddress.validateAddress(suggestionEmailAddress.emailAddress.emailAddress); + if (!_isDuplicatedRecipient(mailAddress.asEncodedString())) { + stateSetter(() => _currentListEmailAddress.add(mailAddress.asEmailAddress())); _updateListEmailAddressAction(); } } @@ -515,9 +518,9 @@ class _RecipientComposerWidgetState extends State { String value, StateSetter stateSetter ) { - final textTrim = value.trim(); - if (!_isDuplicatedRecipient(textTrim)) { - stateSetter(() => _currentListEmailAddress.add(EmailAddress(null, textTrim))); + MailAddress mailAddress = MailAddress.validateAddress(value.trim()); + if (!_isDuplicatedRecipient(mailAddress.asEncodedString())) { + stateSetter(() => _currentListEmailAddress.add(mailAddress.asEmailAddress())); _updateListEmailAddressAction(); } } @@ -526,9 +529,9 @@ class _RecipientComposerWidgetState extends State { String value, StateSetter stateSetter ) { - final textTrim = value.trim(); - if (!_isDuplicatedRecipient(textTrim)) { - stateSetter(() => _currentListEmailAddress.add(EmailAddress(null, textTrim))); + MailAddress mailAddress = MailAddress.validateAddress(value.trim()); + if (!_isDuplicatedRecipient(mailAddress.asEncodedString())) { + stateSetter(() => _currentListEmailAddress.add(mailAddress.asEmailAddress())); _updateListEmailAddressAction(); } _gapBetweenTagChangedAndFindSuggestion = Timer( diff --git a/lib/features/email/presentation/utils/email_utils.dart b/lib/features/email/presentation/utils/email_utils.dart index d4eb188455..5b8576085a 100644 --- a/lib/features/email/presentation/utils/email_utils.dart +++ b/lib/features/email/presentation/utils/email_utils.dart @@ -77,7 +77,8 @@ class EmailUtils { static bool isEmailAddressValid(String address) { try { - return GetUtils.isEmail(address) && MailAddress.validateAddress(address).asString().isNotEmpty; + MailAddress mailAddress = MailAddress.validateAddress(address); + return GetUtils.isEmail(mailAddress.stripDetails().asString()) && mailAddress.asString().isNotEmpty; } catch(e) { logError('EmailUtils::isEmailAddressValid: Exception = $e'); return false;