diff --git a/core/lib/core.dart b/core/lib/core.dart index 52f26a87d..ec3d07de0 100644 --- a/core/lib/core.dart +++ b/core/lib/core.dart @@ -52,6 +52,7 @@ export 'utils/broadcast_channel/broadcast_channel.dart'; export 'utils/list_utils.dart'; export 'utils/mail/domain.dart'; export 'utils/mail/mail_address.dart'; +export 'utils/mail/named_address.dart'; export 'utils/application_manager.dart'; export 'utils/preview_eml_file_utils.dart'; export 'utils/logger/log_tracking.dart'; diff --git a/core/lib/utils/mail/named_address.dart b/core/lib/utils/mail/named_address.dart new file mode 100644 index 000000000..252ea8555 --- /dev/null +++ b/core/lib/utils/mail/named_address.dart @@ -0,0 +1,11 @@ +import 'package:equatable/equatable.dart'; + +class NamedAddress with EquatableMixin { + final String name; + final String address; + + NamedAddress({required this.name, required this.address}); + + @override + List get props => [name, address]; +} \ No newline at end of file diff --git a/core/lib/utils/string_convert.dart b/core/lib/utils/string_convert.dart index 6c980354b..2f3f9f42c 100644 --- a/core/lib/utils/string_convert.dart +++ b/core/lib/utils/string_convert.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:core/utils/app_logger.dart'; import 'package:core/domain/exceptions/string_exception.dart'; +import 'package:core/utils/mail/named_address.dart'; import 'package:html/parser.dart'; import 'package:http_parser/http_parser.dart'; @@ -137,4 +138,16 @@ class StringConvert { log('StringConvert::isTextTable:isMarkdown = $isMarkdown | isAscii = $isAsciiArt'); 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)!; + + return NamedAddress(name: name, address: address); + } + return null; + } } \ 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 2ac3f115c..756b33471 100644 --- a/core/test/utils/string_convert_test.dart +++ b/core/test/utils/string_convert_test.dart @@ -1,11 +1,12 @@ import 'dart:convert'; import 'package:core/domain/exceptions/string_exception.dart'; +import 'package:core/utils/mail/named_address.dart'; import 'package:core/utils/string_convert.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('StringConvert::decodeBase64ToString::test', () { + group('StringConvert.decodeBase64ToString', () { test('should decode a valid Base64 string to a normal string', () { // Arrange const base64Encoded = 'SGVsbG8gV29ybGQh'; @@ -41,7 +42,7 @@ void main() { }); }); - group('StringConvert::extractEmailAddress::', () { + group('StringConvert.extractEmailAddress', () { group('Basic Functionality', () { test('should not extract strings separated by spaces', () { const input = 'user1@example.com user2@example.com'; @@ -197,7 +198,7 @@ void main() { }); }); - group('string convert test:', () { + group('StringConvert.decodeFromBytes', () { const testText = 'Hello'; test( 'should use utf8 decoder ' @@ -284,7 +285,7 @@ void main() { }); }); - group('StringConvert::getMediaTypeFromBase64ImageTag::', () { + group('StringConvert.getMediaTypeFromBase64ImageTag', () { test('should return correct MediaType for valid JPEG base64 tag', () { const validJpegTag = 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='; final result = StringConvert.getMediaTypeFromBase64ImageTag(validJpegTag); @@ -331,7 +332,7 @@ void main() { }); }); - group('StringConvert::isTextTable::', () { + group('StringConvert.isTextTable', () { // --------------------------- // Test cases cho Markdown tables // --------------------------- @@ -516,4 +517,63 @@ void main() { expect(StringConvert.isTextTable(text), isTrue); }); }); + + group('StringConvert.parseNamedAddress', () { + test('Parses valid double-quoted input', () { + const input = '"John Doe" '; + final result = StringConvert.parseNamedAddress(input); + expect( + result, + NamedAddress(name: 'John Doe', address: 'john@example.com'), + ); + }); + + test('Parses valid single-quoted input', () { + const input = "'Jane Smith' "; + final result = StringConvert.parseNamedAddress(input); + expect( + result, + NamedAddress(name: 'Jane Smith', address: 'jane123@abc.com'), + ); + }); + + test('Keeps encoded values as-is (no decode)', () { + const input = '"John%20Doe" '; + final result = StringConvert.parseNamedAddress(input); + expect( + result, + NamedAddress(name: 'John%20Doe', address: 'john%40example.com'), + ); + }); + + test('Accepts whitespace between name and
', () { + const input = '"Name" '; + final result = StringConvert.parseNamedAddress(input); + expect(result, NamedAddress(name: 'Name', address: 'value')); + }); + + test('Returns null for empty name', () { + const input = '"" '; + final result = StringConvert.parseNamedAddress(input); + expect(result, isNull); + }); + + test('Returns null for empty address', () { + const input = '"Valid" <>'; + final result = StringConvert.parseNamedAddress(input); + expect(result, isNull); + }); + + test('Returns null for missing quotes', () { + const input = 'NoQuotes '; + final result = StringConvert.parseNamedAddress(input); + expect(result, isNull); + }); + + test('Returns null for mismatched quotes', () { + const input = "'Mismatch\" "; + final result = StringConvert.parseNamedAddress(input); + expect(result, isNull); + }); + }); } 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 5799f2b98..8ebd27866 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 @@ -4,9 +4,11 @@ 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'; extension AutoCreateTagForRecipientsExtension on ComposerController { @@ -40,25 +42,34 @@ extension AutoCreateTagForRecipientsExtension on ComposerController { }; void autoCreateEmailTagForType(PrefixEmailAddress type, String input) { - final addressSet = StringConvert.extractEmailAddress(input).toSet(); - if (addressSet.isEmpty) return; + final namedAddress = StringConvert.parseNamedAddress(input); + Set addressSet = {}; + + if (namedAddress == null) { + addressSet = StringConvert.extractEmailAddress(input).toSet(); + if (addressSet.isEmpty) return; + } final emailList = _emailLists[type]!; final keyEditor = _emailEditors[type]!; - final listEmailAddressRecord = addressSet - .map((address) => _generateEmailAddressFromString(address)) + 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(); - final emailSet = emailList.map((email) => email.email).toSet(); - - final newEmails = addressSet.difference(emailSet); - - if (newEmails.isEmpty) return; - - final listEmailAddress = listEmailAddressRecord - .where((emailRecord) => newEmails.contains(emailRecord.$1)) + listEmailAddress = listEmailAddressRecord + .where((emailRecord) => !emailSet.contains(emailRecord.$1)) .map((emailRecord) => emailRecord.$2) .toList(); + } + + if (listEmailAddress.isEmpty) return; emailList.addAll(listEmailAddress); diff --git a/lib/features/composer/presentation/extensions/named_address_extension.dart b/lib/features/composer/presentation/extensions/named_address_extension.dart new file mode 100644 index 000000000..f8216d224 --- /dev/null +++ b/lib/features/composer/presentation/extensions/named_address_extension.dart @@ -0,0 +1,7 @@ + +import 'package:core/utils/mail/named_address.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; + +extension NamedAddressExtension on NamedAddress { + EmailAddress toEmailAddress() => EmailAddress(name, address); +} \ 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 398a32423..b71484b69 100644 --- a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart @@ -18,6 +18,7 @@ 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/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'; @@ -557,6 +558,17 @@ 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)) { + stateSetter( + () => _currentListEmailAddress.add(namedAddress.toEmailAddress()), + ); + _updateListEmailAddressAction(); + } + return; + } + final listString = StringConvert.extractEmailAddress(value.trim()).toSet(); if (listString.isEmpty && !_isDuplicatedRecipient(value)) {