diff --git a/core/lib/utils/mail/named_address.dart b/core/lib/utils/mail/named_address.dart index 252ea8555..0886ead51 100644 --- a/core/lib/utils/mail/named_address.dart +++ b/core/lib/utils/mail/named_address.dart @@ -8,4 +8,7 @@ class NamedAddress with EquatableMixin { @override List get props => [name, address]; + + @override + String toString() => name.isNotEmpty ? '$name <$address>' : address; } \ No newline at end of file diff --git a/core/lib/utils/string_convert.dart b/core/lib/utils/string_convert.dart index 2f3f9f42c..c490c8755 100644 --- a/core/lib/utils/string_convert.dart +++ b/core/lib/utils/string_convert.dart @@ -139,15 +139,61 @@ class StringConvert { return isMarkdown || isAsciiArt; } - static NamedAddress? parseNamedAddress(String input) { - final regex = RegExp(r'''^(['"])(.+?)\1\s*<([^>\s]+)>$'''); - final match = regex.firstMatch(input.trim()); - if (match != null) { - final name = match.group(2)!; - final address = match.group(3)!; + static List extractNamedAddresses(String input) { + try { + if (input.contains('%')) { + input = Uri.decodeComponent(input); + } - return NamedAddress(name: name, address: address); + if (input.length % 4 == 0 && + input.contains(RegExp(r'^[A-Za-z0-9+/=]+$'))) { + try { + input = utf8.decode(base64.decode(input)); + } catch (_) {} + } + + input = input.replaceAll('\n', ' '); + final results = []; + + final pattern = RegExp(r'''(?:"([^"]+)"|'([^']+)'|)\s*<([^>]+)>'''); + + int currentIndex = 0; + final matches = pattern.allMatches(input).toList(); + + for (final match in matches) { + if (match.start > currentIndex) { + final between = input.substring(currentIndex, match.start); + results.addAll(_splitPlainAddresses(between, emailSeparatorPattern)); + } + + final name = match.group(1) ?? match.group(2) ?? ''; + final email = match.group(3) ?? ''; + results.add(NamedAddress(name: name.trim(), address: email.trim())); + + currentIndex = match.end; + } + + if (currentIndex < input.length) { + final tail = input.substring(currentIndex); + results.addAll(_splitPlainAddresses(tail, emailSeparatorPattern)); + } + log('StringConvert::extractNamedAddresses:results = $results'); + return results; + } catch (_) { + return []; } - return null; + } + + static List _splitPlainAddresses( + String input, + String emailSeparatorPattern, + ) { + final separator = RegExp(emailSeparatorPattern); + return input + .split(separator) + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .map((e) => NamedAddress(name: '', address: e)) + .toList(); } } \ No newline at end of file diff --git a/core/test/utils/string_convert_test.dart b/core/test/utils/string_convert_test.dart index 756b33471..0141cd32a 100644 --- a/core/test/utils/string_convert_test.dart +++ b/core/test/utils/string_convert_test.dart @@ -518,62 +518,302 @@ void main() { }); }); - group('StringConvert.parseNamedAddress', () { - test('Parses valid double-quoted input', () { - const input = '"John Doe" '; - final result = StringConvert.parseNamedAddress(input); + group('StringConvert.extractNamedAddresses', () { + test('Extracts plain emails', () { + const input = 'user1@example.com, user2@example.com; user3@example.com'; + final result = StringConvert.extractNamedAddresses(input); + expect( result, - NamedAddress(name: 'John Doe', address: 'john@example.com'), + equals([ + NamedAddress(name: '', address: 'user1@example.com'), + NamedAddress(name: '', address: 'user2@example.com'), + NamedAddress(name: '', address: 'user3@example.com'), + ]), ); }); - test('Parses valid single-quoted input', () { - const input = "'Jane Smith' "; - final result = StringConvert.parseNamedAddress(input); + test('Extracts quoted name and email', () { + const input = '"John Doe" , "Jane" '; + final result = StringConvert.extractNamedAddresses(input); + expect( result, - NamedAddress(name: 'Jane Smith', address: 'jane123@abc.com'), + equals([ + NamedAddress(name: 'John Doe', address: 'john@example.com'), + NamedAddress(name: 'Jane', address: 'jane@example.com'), + ]), ); }); - test('Keeps encoded values as-is (no decode)', () { - const input = '"John%20Doe" '; - final result = StringConvert.parseNamedAddress(input); + test('Extracts mixed quoted and plain emails', () { + const input = + '"Alex" , user@example.com, "Sara" '; + final result = StringConvert.extractNamedAddresses(input); + expect( result, - NamedAddress(name: 'John%20Doe', address: 'john%40example.com'), + equals([ + NamedAddress(name: 'Alex', address: 'alex@example.com'), + NamedAddress(name: '', address: 'user@example.com'), + NamedAddress(name: 'Sara', address: 'sara@example.com'), + ]), ); }); - test('Accepts whitespace between name and
', () { - const input = '"Name" '; - final result = StringConvert.parseNamedAddress(input); - expect(result, NamedAddress(name: 'Name', address: 'value')); + test('Handles URL-encoded input', () { + const input = + 'user1%40example.com%2C%20%22User%202%22%20%3Cuser2%40example.com%3E'; + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: '', address: 'user1@example.com'), + NamedAddress(name: 'User 2', address: 'user2@example.com'), + ]), + ); }); - test('Returns null for empty name', () { - const input = '"" '; - final result = StringConvert.parseNamedAddress(input); - expect(result, isNull); + test('Handles base64 encoded input', () { + const raw = + 'user1@example.com user2@example.com "Name Three" '; + final encoded = base64.encode(utf8.encode(raw)); + + final result = StringConvert.extractNamedAddresses(encoded); + + expect( + result, + equals([ + NamedAddress(name: '', address: 'user1@example.com'), + NamedAddress(name: '', address: 'user2@example.com'), + NamedAddress(name: 'Name Three', address: 'user3@example.com'), + ]), + ); }); - test('Returns null for empty address', () { - const input = '"Valid" <>'; - final result = StringConvert.parseNamedAddress(input); - expect(result, isNull); + test('Parses even invalid-looking emails without filtering', () { + const input = 'user@example.com, , ; not-an-email, "Bad" '; + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: '', address: 'user@example.com'), + NamedAddress(name: '', address: 'not-an-email'), + NamedAddress(name: 'Bad', address: 'not-email'), + ]), + ); }); - test('Returns null for missing quotes', () { - const input = 'NoQuotes '; - final result = StringConvert.parseNamedAddress(input); - expect(result, isNull); + test('Extracts duplicated emails without filtering', () { + const input = + '"A" , user@EXAMPLE.com, "B" '; + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: 'A', address: 'user@example.com'), + NamedAddress(name: '', address: 'user@EXAMPLE.com'), + NamedAddress(name: 'B', address: 'USER@example.com'), + ]), + ); }); - test('Returns null for mismatched quotes', () { - const input = "'Mismatch\" "; - final result = StringConvert.parseNamedAddress(input); - expect(result, isNull); + test('Handles single email', () { + const input = 'single@example.com'; + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: '', address: 'single@example.com'), + ]), + ); + }); + + test('Handles input with newlines and spacing', () { + const input = ''' + "John" + user2@example.com ; + "Jane" + '''; + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: 'John', address: 'john@example.com'), + NamedAddress(name: '', address: 'user2@example.com'), + NamedAddress(name: 'Jane', address: 'jane@example.com'), + ]), + ); + }); + + test('Parses mixed plain and quoted named emails', () { + const input = + 'john.doe@example.com, jane.smith@example.com, alex.wilson@example.com, "ng van a" '; + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: '', address: 'john.doe@example.com'), + NamedAddress(name: '', address: 'jane.smith@example.com'), + NamedAddress(name: '', address: 'alex.wilson@example.com'), + NamedAddress(name: 'ng van a', address: 'ttnn@gmail.com'), + ]), + ); + }); + + test('Parses multiple quoted named emails without delimiters', () { + const input = + '"ng van a" "ng van b" "ng van c" "ng van d" '; + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: 'ng van a', address: 'ttnn@gmail.com'), + NamedAddress(name: 'ng van b', address: 'ttnn1@gmail.com'), + NamedAddress(name: 'ng van c', address: 'ttnn2@gmail.com'), + NamedAddress(name: 'ng van d', address: 'ttnn3@gmail.com'), + ]), + ); + }); + + test('Handles semicolon after an email', () { + const input = + 'john.doe@example.com;, jane.smith@example.com, alex.wilson@example.com'; + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: '', address: 'john.doe@example.com'), + NamedAddress(name: '', address: 'jane.smith@example.com'), + NamedAddress(name: '', address: 'alex.wilson@example.com'), + ]), + ); + }); + + test('Handles completely empty string', () { + const input = ''; + final result = StringConvert.extractNamedAddresses(input); + expect(result, isEmpty); + }); + + test('Handles only spaces and separators', () { + const inputs = [ + ' ', + ',', + ';', + ',, ;; ', + ' , ; ', + ' , ; \n', + '\n', + ]; + + for (final input in inputs) { + final result = StringConvert.extractNamedAddresses(input); + expect(result, isEmpty, reason: 'Failed on input: "$input"'); + } + }); + + test('Handles multiple delimiters between addresses', () { + const input = 'user1@example.com,,; user2@example.com;; user3@example.com'; + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: '', address: 'user1@example.com'), + NamedAddress(name: '', address: 'user2@example.com'), + NamedAddress(name: '', address: 'user3@example.com'), + ]), + ); + }); + + test('Handles multiple delimiters and spaces', () { + const input = + 'user1@example.com , user2@example.com ; user3@example.com'; + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: '', address: 'user1@example.com'), + NamedAddress(name: '', address: 'user2@example.com'), + NamedAddress(name: '', address: 'user3@example.com'), + ]), + ); + }); + + test('Handles a large number of addresses', () { + const base = 'user'; + const domain = '@example.com'; + final buffer = StringBuffer(); + + for (var i = 0; i < 1000; i++) { + buffer.write('$base$i$domain'); + if (i != 999) buffer.write(', '); + } + + final input = buffer.toString(); + final result = StringConvert.extractNamedAddresses(input); + + expect(result.length, equals(1000)); + expect(result.first, equals(NamedAddress(name: '', address: 'user0@example.com'))); + expect(result.last, equals(NamedAddress(name: '', address: 'user999@example.com'))); + }); + + test('Handles mixed valid, invalid, and named addresses correctly', () { + const input = ''' + user@example.com, , ; not-an-email, + "Bad" "Bad-2" , + 'Bad-3' ; + 'useb' + '''; + + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: '', address: 'user@example.com'), + NamedAddress(name: '', address: 'not-an-email'), + NamedAddress(name: 'Bad', address: 'not-email'), + NamedAddress(name: 'Bad-2', address: 'not-email-2'), + NamedAddress(name: 'Bad-3', address: 'not-email-3'), + NamedAddress(name: 'useb', address: 'userb@example.com'), + ]), + ); + }); + + test('Handles subaddressing with + and subfolders', () { + const input = ''' + john.doe@example.com; + ; + ; + ; + ; + alex.wilson@example.com + '''; + + final result = StringConvert.extractNamedAddresses(input); + + expect( + result, + equals([ + NamedAddress(name: '', address: 'john.doe@example.com'), + NamedAddress(name: '', address: 'userA+foldeA@example.com'), + NamedAddress(name: '', address: 'userA+folder Hello@exmaple.com'), + NamedAddress(name: '', address: 'userA+folderA.subFolderA@exmaple.com'), + NamedAddress(name: '', address: 'user+folder Hello.subFolder Hello@exmaple.com'), + NamedAddress(name: '', address: 'alex.wilson@example.com'), + ]), + ); }); }); } diff --git a/lib/features/composer/presentation/extensions/auto_create_tag_for_recipients_extension.dart b/lib/features/composer/presentation/extensions/auto_create_tag_for_recipients_extension.dart index 8ebd27866..f63e6dfb6 100644 --- a/lib/features/composer/presentation/extensions/auto_create_tag_for_recipients_extension.dart +++ b/lib/features/composer/presentation/extensions/auto_create_tag_for_recipients_extension.dart @@ -1,14 +1,12 @@ - -import 'package:core/utils/mail/mail_address.dart'; +import 'package:core/utils/app_logger.dart'; import 'package:core/utils/string_convert.dart'; import 'package:flutter/cupertino.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:model/email/prefix_email_address.dart'; -import 'package:model/extensions/email_address_extension.dart'; import 'package:super_tag_editor/tag_editor.dart'; import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; -import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart'; -import 'package:tmail_ui_user/features/composer/presentation/extensions/named_address_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/list_address_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/list_named_address_extension.dart'; extension AutoCreateTagForRecipientsExtension on ComposerController { @@ -42,36 +40,28 @@ extension AutoCreateTagForRecipientsExtension on ComposerController { }; void autoCreateEmailTagForType(PrefixEmailAddress type, String input) { - final namedAddress = StringConvert.parseNamedAddress(input); - Set addressSet = {}; + final namedAddresses = StringConvert.extractNamedAddresses(input); + List newListEmailAddress = []; + final existingEmailList = _emailLists[type]!; - if (namedAddress == null) { - addressSet = StringConvert.extractEmailAddress(input).toSet(); - if (addressSet.isEmpty) return; + if (namedAddresses.isNotEmpty) { + final emailAddressListFromNamed = namedAddresses + .toFilteredEmailAddressList(existingEmailList); + log('$runtimeType::autoCreateEmailTagForType: Create email tag from named address list with length ${emailAddressListFromNamed.length}'); + newListEmailAddress = emailAddressListFromNamed; } - final emailList = _emailLists[type]!; - final keyEditor = _emailEditors[type]!; - List listEmailAddress = []; - - final emailSet = emailList.map((email) => email.emailAddress).toSet(); - - if (namedAddress != null && !emailSet.contains(namedAddress.address)) { - listEmailAddress.add(namedAddress.toEmailAddress()); - } else if (addressSet.isNotEmpty) { - final listEmailAddressRecord = addressSet - .map(_generateEmailAddressFromString) - .toList(); - - listEmailAddress = listEmailAddressRecord - .where((emailRecord) => !emailSet.contains(emailRecord.$1)) - .map((emailRecord) => emailRecord.$2) - .toList(); + if (newListEmailAddress.isEmpty) { + List addresses = StringConvert.extractEmailAddress(input); + final emailAddressListFromAddress = + addresses.toFilteredEmailAddressList(existingEmailList); + log('$runtimeType::autoCreateEmailTagForType: Create email tag from address list with length ${emailAddressListFromAddress.length}'); + newListEmailAddress = emailAddressListFromAddress; } - if (listEmailAddress.isEmpty) return; + if (newListEmailAddress.isEmpty) return; - emailList.addAll(listEmailAddress); + existingEmailList.addAll(newListEmailAddress); if (!isInitialRecipient.value) { isInitialRecipient.value = true; @@ -80,19 +70,11 @@ extension AutoCreateTagForRecipientsExtension on ComposerController { updateStatusEmailSendButton(); + final keyEditor = _emailEditors[type]!; keyEditor.currentState?.resetTextField(); Future.delayed( const Duration(milliseconds: 300), keyEditor.currentState?.closeSuggestionBox, ); } - - (String email, EmailAddress emailAddress) _generateEmailAddressFromString(String input) { - try { - final mailAddress = MailAddress.validateAddress(input); - return (mailAddress.asEncodedString(), mailAddress.asEmailAddress()); - } catch (e) { - return (input, EmailAddress(null, input)); - } - } } \ No newline at end of file diff --git a/lib/features/composer/presentation/extensions/list_address_extension.dart b/lib/features/composer/presentation/extensions/list_address_extension.dart new file mode 100644 index 000000000..b313aec76 --- /dev/null +++ b/lib/features/composer/presentation/extensions/list_address_extension.dart @@ -0,0 +1,38 @@ +import 'package:core/utils/mail/mail_address.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:model/extensions/email_address_extension.dart'; +import 'package:model/extensions/list_email_address_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart'; + +extension ListAddressExtension on List { + List toFilteredEmailAddressList( + List existingEmails, + ) { + final existingEmailSet = existingEmails.toNormalizedEmailSet(); + + final seen = {}; + final result = []; + + for (final address in this) { + final emailAddress = _validateToEmailAddress(address); + final lowerAddress = emailAddress.emailAddress.toLowerCase(); + + if (existingEmailSet.contains(lowerAddress) || !seen.add(lowerAddress)) { + continue; + } + + result.add(emailAddress); + } + + return result; + } + + EmailAddress _validateToEmailAddress(String address) { + try { + final mailAddress = MailAddress.validateAddress(address); + return mailAddress.asEmailAddress(); + } catch (_) { + return EmailAddress(null, address); + } + } +} diff --git a/lib/features/composer/presentation/extensions/list_named_address_extension.dart b/lib/features/composer/presentation/extensions/list_named_address_extension.dart new file mode 100644 index 000000000..6af79d88b --- /dev/null +++ b/lib/features/composer/presentation/extensions/list_named_address_extension.dart @@ -0,0 +1,17 @@ +import 'package:core/utils/mail/named_address.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:model/extensions/list_email_address_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/named_address_extension.dart'; + +extension ListNamedAddressExtension on List { + List toFilteredEmailAddressList( + List existingEmails, + ) { + final existingEmailSet = existingEmails.toNormalizedEmailSet(); + final seen = {}; + return where((named) { + final normalized = named.normalizedAddress; + return !existingEmailSet.contains(normalized) && seen.add(normalized); + }).map((named) => named.validateToEmailAddress()).toList(); + } +} diff --git a/lib/features/composer/presentation/extensions/named_address_extension.dart b/lib/features/composer/presentation/extensions/named_address_extension.dart index f8216d224..954932600 100644 --- a/lib/features/composer/presentation/extensions/named_address_extension.dart +++ b/lib/features/composer/presentation/extensions/named_address_extension.dart @@ -1,7 +1,25 @@ - +import 'package:core/utils/mail/mail_address.dart'; import 'package:core/utils/mail/named_address.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart'; extension NamedAddressExtension on NamedAddress { + bool get isValid => name.isNotEmpty && address.isNotEmpty; + + String get normalizedAddress => address.toLowerCase(); + EmailAddress toEmailAddress() => EmailAddress(name, address); + + EmailAddress validateToEmailAddress() { + try { + if (isValid) { + return toEmailAddress(); + } else { + final mailAddress = MailAddress.validateAddress(address); + return mailAddress.asEmailAddress(); + } + } catch (_) { + return toEmailAddress(); + } + } } \ 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 b71484b69..3058c3130 100644 --- a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart @@ -18,7 +18,8 @@ import 'package:model/email/prefix_email_address.dart'; 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/named_address_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/list_address_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/list_named_address_extension.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'; @@ -558,34 +559,33 @@ class _RecipientComposerWidgetState extends State { ) => _createMailTag(value, stateSetter); void _createMailTag(String value, StateSetter stateSetter) { - final namedAddress = StringConvert.parseNamedAddress(value.trim()); - if (namedAddress != null) { - if (!_isDuplicatedRecipient(namedAddress.address)) { + final valueTrimmed = value.trim(); + final namedAddresses = StringConvert.extractNamedAddresses(valueTrimmed); + if (namedAddresses.isNotEmpty) { + final emailAddressListFromNamed = namedAddresses + .toFilteredEmailAddressList(_currentListEmailAddress); + log('$runtimeType::_createMailTag: Create email tag from named address list with length ${emailAddressListFromNamed.length}'); + if (emailAddressListFromNamed.isNotEmpty) { stateSetter( - () => _currentListEmailAddress.add(namedAddress.toEmailAddress()), + () => _currentListEmailAddress.addAll(emailAddressListFromNamed), ); _updateListEmailAddressAction(); } return; } - final listString = StringConvert.extractEmailAddress(value.trim()).toSet(); + List addresses = StringConvert.extractEmailAddress(valueTrimmed); + final emailAddressListFromAddress = + addresses.toFilteredEmailAddressList(_currentListEmailAddress); + log('$runtimeType::_createMailTag: Create email tag from address list with length ${emailAddressListFromAddress.length}'); - if (listString.isEmpty && !_isDuplicatedRecipient(value)) { + if (emailAddressListFromAddress.isNotEmpty) { + stateSetter( + () => _currentListEmailAddress.addAll(emailAddressListFromAddress), + ); + _updateListEmailAddressAction(); + } else if (!_isDuplicatedRecipient(valueTrimmed)) { _onEmailAddressReceived(value, stateSetter); - } else if (listString.isNotEmpty) { - final listStringNotExist = listString - .where((text) => !_isDuplicatedRecipient(text)) - .toList(); - - if (listStringNotExist.isNotEmpty) { - final listAddress = listStringNotExist - .map((text) => EmailAddress(null, text)) - .toList(); - - stateSetter(() => _currentListEmailAddress.addAll(listAddress)); - _updateListEmailAddressAction(); - } } } diff --git a/model/lib/extensions/list_email_address_extension.dart b/model/lib/extensions/list_email_address_extension.dart index 386347b9d..1e7a2ded6 100644 --- a/model/lib/extensions/list_email_address_extension.dart +++ b/model/lib/extensions/list_email_address_extension.dart @@ -69,6 +69,9 @@ extension SetEmailAddressExtension on Set? { extension ListEmailAddressExtension on List { Set asSetAddress() => map((emailAddress) => emailAddress.emailAddress).toSet(); + Set toNormalizedEmailSet() => + map((e) => e.email?.toLowerCase()).whereType().toSet(); + List removeInvalidEmails(String username) { final Set seenEmails = {}; return where((email) { diff --git a/test/features/composer/presentation/extensions/list_address_extension_test.dart b/test/features/composer/presentation/extensions/list_address_extension_test.dart new file mode 100644 index 000000000..8ffc47fd1 --- /dev/null +++ b/test/features/composer/presentation/extensions/list_address_extension_test.dart @@ -0,0 +1,93 @@ +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'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/list_address_extension.dart'; + +void main() { + group('ListAddressExtension.toFilteredEmailAddressList', () { + test('removes existing emails and duplicates', () { + final addresses = [ + 'a@example.com', + 'b@example.com', + 'B@EXAMPLE.COM', // duplicate in different case + 'c@example.com', + ]; + final existingEmails = [ + EmailAddress('B', 'b@example.com'), + EmailAddress('X', 'x@example.com'), + ]; + + final result = addresses.toFilteredEmailAddressList(existingEmails); + + expect(result.map((e) => e.emailAddress), [ + 'a@example.com', + 'c@example.com', + ]); + }); + + test('keeps all unique and non-existing emails', () { + final addresses = [ + 'a@example.com', + 'b@example.com', + 'c@example.com', + ]; + final existingEmails = [ + EmailAddress('Someone', 'z@example.com'), + ]; + + final result = addresses.toFilteredEmailAddressList(existingEmails); + + expect(result.map((e) => e.emailAddress), [ + 'a@example.com', + 'b@example.com', + 'c@example.com', + ]); + }); + + test('skips invalid emails', () { + final addresses = [ + 'invalid-email', + 'valid@example.com', + ]; + final existingEmails = []; + + final result = addresses.toFilteredEmailAddressList(existingEmails); + + expect(result.length, 2); + expect(result[0].emailAddress, 'invalid-email'); + expect(result[0].name, isNull); + expect(result[1].emailAddress, 'valid@example.com'); + }); + + test('removes duplicates within the list', () { + final addresses = [ + 'a@example.com', + 'A@EXAMPLE.COM', // duplicate in different case + 'b@example.com', + 'b@example.com', + ]; + final existingEmails = []; + + final result = addresses.toFilteredEmailAddressList(existingEmails); + + expect(result.map((e) => e.emailAddress), [ + 'a@example.com', + 'b@example.com', + ]); + }); + + test('removes addresses matching normalized existingEmails', () { + final addresses = [ + ' A@Example.Com ', + 'b@example.com', + ]; + final existingEmails = [ + EmailAddress('A', 'a@example.com'), + ]; + + final result = addresses.toFilteredEmailAddressList(existingEmails); + + expect(result.map((e) => e.emailAddress), ['b@example.com']); + }); + }); +} diff --git a/test/features/composer/presentation/extensions/list_named_address_extension_test.dart b/test/features/composer/presentation/extensions/list_named_address_extension_test.dart new file mode 100644 index 000000000..0e7b06aeb --- /dev/null +++ b/test/features/composer/presentation/extensions/list_named_address_extension_test.dart @@ -0,0 +1,76 @@ +import 'package:core/utils/mail/named_address.dart'; +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'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/list_named_address_extension.dart'; + +void main() { + group('ListNamedAddressExtension.toFilteredEmailAddressList', () { + test('filters out emails already in existingEmails list', () { + final namedList = [ + NamedAddress(name: 'A', address: 'a@example.com'), + NamedAddress(name: 'B', address: 'b@example.com'), + NamedAddress(name: 'C', address: 'c@example.com'), + ]; + + final existingEmails = [ + EmailAddress('B', 'b@example.com'), + ]; + + final result = namedList.toFilteredEmailAddressList(existingEmails); + + expect( + result.map((e) => e.emailAddress), + containsAll(['a@example.com', 'c@example.com']), + ); + expect( + result.map((e) => e.emailAddress), + isNot(contains('b@example.com')), + ); + }); + + test('removes duplicate emails within the same list', () { + final namedList = [ + NamedAddress(name: 'A1', address: 'a@example.com'), + NamedAddress(name: 'A2', address: 'A@Example.com'), + // Duplicate with different case + NamedAddress(name: 'B', address: 'b@example.com'), + ]; + + final result = namedList.toFilteredEmailAddressList([]); + + expect(result.length, 2); + expect( + result.map((e) => e.emailAddress), + containsAll(['a@example.com', 'b@example.com']), + ); + }); + + test('returns empty list if all emails are either existing or duplicates', + () { + final namedList = [ + NamedAddress(name: 'A', address: 'a@example.com'), + NamedAddress(name: 'A2', address: 'A@Example.com'), + ]; + + final existingEmails = [ + EmailAddress('Existing A', 'a@example.com'), + ]; + + final result = namedList.toFilteredEmailAddressList(existingEmails); + + expect(result, isEmpty); + }); + + test('handles emails with empty name fields', () { + final namedList = [ + NamedAddress(name: '', address: 'test@example.com'), + ]; + + final result = namedList.toFilteredEmailAddressList([]); + + expect(result.length, 1); + expect(result.first.emailAddress, 'test@example.com'); + }); + }); +}