TF-3864 Support pasting multiple address with multiple format mixed
Signed-off-by: dab246 <tdvu@linagora.com>
This commit is contained in:
@@ -8,4 +8,7 @@ class NamedAddress with EquatableMixin {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [name, address];
|
List<Object?> get props => [name, address];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => name.isNotEmpty ? '$name <$address>' : address;
|
||||||
}
|
}
|
||||||
@@ -139,15 +139,61 @@ class StringConvert {
|
|||||||
return isMarkdown || isAsciiArt;
|
return isMarkdown || isAsciiArt;
|
||||||
}
|
}
|
||||||
|
|
||||||
static NamedAddress? parseNamedAddress(String input) {
|
static List<NamedAddress> extractNamedAddresses(String input) {
|
||||||
final regex = RegExp(r'''^(['"])(.+?)\1\s*<([^>\s]+)>$''');
|
try {
|
||||||
final match = regex.firstMatch(input.trim());
|
if (input.contains('%')) {
|
||||||
if (match != null) {
|
input = Uri.decodeComponent(input);
|
||||||
final name = match.group(2)!;
|
}
|
||||||
final address = match.group(3)!;
|
|
||||||
|
|
||||||
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 = <NamedAddress>[];
|
||||||
|
|
||||||
|
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<NamedAddress> _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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,62 +518,302 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('StringConvert.parseNamedAddress', () {
|
group('StringConvert.extractNamedAddresses', () {
|
||||||
test('Parses valid double-quoted input', () {
|
test('Extracts plain emails', () {
|
||||||
const input = '"John Doe" <john@example.com>';
|
const input = 'user1@example.com, user2@example.com; user3@example.com';
|
||||||
final result = StringConvert.parseNamedAddress(input);
|
final result = StringConvert.extractNamedAddresses(input);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
result,
|
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', () {
|
test('Extracts quoted name and email', () {
|
||||||
const input = "'Jane Smith' <jane123@abc.com>";
|
const input = '"John Doe" <john@example.com>, "Jane" <jane@example.com>';
|
||||||
final result = StringConvert.parseNamedAddress(input);
|
final result = StringConvert.extractNamedAddresses(input);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
result,
|
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)', () {
|
test('Extracts mixed quoted and plain emails', () {
|
||||||
const input = '"John%20Doe" <john%40example.com>';
|
const input =
|
||||||
final result = StringConvert.parseNamedAddress(input);
|
'"Alex" <alex@example.com>, user@example.com, "Sara" <sara@example.com>';
|
||||||
|
final result = StringConvert.extractNamedAddresses(input);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
result,
|
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 <address>', () {
|
test('Handles URL-encoded input', () {
|
||||||
const input = '"Name" <value>';
|
const input =
|
||||||
final result = StringConvert.parseNamedAddress(input);
|
'user1%40example.com%2C%20%22User%202%22%20%3Cuser2%40example.com%3E';
|
||||||
expect(result, NamedAddress(name: 'Name', address: 'value'));
|
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', () {
|
test('Handles base64 encoded input', () {
|
||||||
const input = '"" <something>';
|
const raw =
|
||||||
final result = StringConvert.parseNamedAddress(input);
|
'user1@example.com user2@example.com "Name Three" <user3@example.com>';
|
||||||
expect(result, isNull);
|
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', () {
|
test('Parses even invalid-looking emails without filtering', () {
|
||||||
const input = '"Valid" <>';
|
const input = 'user@example.com, , ; not-an-email, "Bad" <not-email>';
|
||||||
final result = StringConvert.parseNamedAddress(input);
|
final result = StringConvert.extractNamedAddresses(input);
|
||||||
expect(result, isNull);
|
|
||||||
|
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', () {
|
test('Extracts duplicated emails without filtering', () {
|
||||||
const input = 'NoQuotes <abc>';
|
const input =
|
||||||
final result = StringConvert.parseNamedAddress(input);
|
'"A" <user@example.com>, user@EXAMPLE.com, "B" <USER@example.com>';
|
||||||
expect(result, isNull);
|
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', () {
|
test('Handles single email', () {
|
||||||
const input = "'Mismatch\" <abc>";
|
const input = 'single@example.com';
|
||||||
final result = StringConvert.parseNamedAddress(input);
|
final result = StringConvert.extractNamedAddresses(input);
|
||||||
expect(result, isNull);
|
|
||||||
|
expect(
|
||||||
|
result,
|
||||||
|
equals([
|
||||||
|
NamedAddress(name: '', address: 'single@example.com'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Handles input with newlines and spacing', () {
|
||||||
|
const input = '''
|
||||||
|
"John" <john@example.com>
|
||||||
|
user2@example.com ;
|
||||||
|
"Jane" <jane@example.com>
|
||||||
|
''';
|
||||||
|
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" <ttnn@gmail.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'),
|
||||||
|
NamedAddress(name: 'ng van a', address: 'ttnn@gmail.com'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Parses multiple quoted named emails without delimiters', () {
|
||||||
|
const input =
|
||||||
|
'"ng van a" <ttnn@gmail.com> "ng van b" <ttnn1@gmail.com> "ng van c" <ttnn2@gmail.com> "ng van d" <ttnn3@gmail.com>';
|
||||||
|
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" <not-email> "Bad-2" <not-email-2>,
|
||||||
|
'Bad-3' <not-email-3>;
|
||||||
|
'useb' <userb@example.com>
|
||||||
|
''';
|
||||||
|
|
||||||
|
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;
|
||||||
|
<userA+foldeA@example.com>;
|
||||||
|
<userA+folder Hello@exmaple.com>;
|
||||||
|
<userA+folderA.subFolderA@exmaple.com>;
|
||||||
|
<user+folder Hello.subFolder Hello@exmaple.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'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-38
@@ -1,14 +1,12 @@
|
|||||||
|
import 'package:core/utils/app_logger.dart';
|
||||||
import 'package:core/utils/mail/mail_address.dart';
|
|
||||||
import 'package:core/utils/string_convert.dart';
|
import 'package:core/utils/string_convert.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:jmap_dart_client/jmap/mail/email/email_address.dart';
|
import 'package:jmap_dart_client/jmap/mail/email/email_address.dart';
|
||||||
import 'package:model/email/prefix_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: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/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/list_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_named_address_extension.dart';
|
||||||
|
|
||||||
extension AutoCreateTagForRecipientsExtension on ComposerController {
|
extension AutoCreateTagForRecipientsExtension on ComposerController {
|
||||||
|
|
||||||
@@ -42,36 +40,28 @@ extension AutoCreateTagForRecipientsExtension on ComposerController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
void autoCreateEmailTagForType(PrefixEmailAddress type, String input) {
|
void autoCreateEmailTagForType(PrefixEmailAddress type, String input) {
|
||||||
final namedAddress = StringConvert.parseNamedAddress(input);
|
final namedAddresses = StringConvert.extractNamedAddresses(input);
|
||||||
Set<String> addressSet = {};
|
List<EmailAddress> newListEmailAddress = [];
|
||||||
|
final existingEmailList = _emailLists[type]!;
|
||||||
|
|
||||||
if (namedAddress == null) {
|
if (namedAddresses.isNotEmpty) {
|
||||||
addressSet = StringConvert.extractEmailAddress(input).toSet();
|
final emailAddressListFromNamed = namedAddresses
|
||||||
if (addressSet.isEmpty) return;
|
.toFilteredEmailAddressList(existingEmailList);
|
||||||
|
log('$runtimeType::autoCreateEmailTagForType: Create email tag from named address list with length ${emailAddressListFromNamed.length}');
|
||||||
|
newListEmailAddress = emailAddressListFromNamed;
|
||||||
}
|
}
|
||||||
|
|
||||||
final emailList = _emailLists[type]!;
|
if (newListEmailAddress.isEmpty) {
|
||||||
final keyEditor = _emailEditors[type]!;
|
List<String> addresses = StringConvert.extractEmailAddress(input);
|
||||||
List<EmailAddress> listEmailAddress = [];
|
final emailAddressListFromAddress =
|
||||||
|
addresses.toFilteredEmailAddressList(existingEmailList);
|
||||||
final emailSet = emailList.map((email) => email.emailAddress).toSet();
|
log('$runtimeType::autoCreateEmailTagForType: Create email tag from address list with length ${emailAddressListFromAddress.length}');
|
||||||
|
newListEmailAddress = emailAddressListFromAddress;
|
||||||
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 (listEmailAddress.isEmpty) return;
|
if (newListEmailAddress.isEmpty) return;
|
||||||
|
|
||||||
emailList.addAll(listEmailAddress);
|
existingEmailList.addAll(newListEmailAddress);
|
||||||
|
|
||||||
if (!isInitialRecipient.value) {
|
if (!isInitialRecipient.value) {
|
||||||
isInitialRecipient.value = true;
|
isInitialRecipient.value = true;
|
||||||
@@ -80,19 +70,11 @@ extension AutoCreateTagForRecipientsExtension on ComposerController {
|
|||||||
|
|
||||||
updateStatusEmailSendButton();
|
updateStatusEmailSendButton();
|
||||||
|
|
||||||
|
final keyEditor = _emailEditors[type]!;
|
||||||
keyEditor.currentState?.resetTextField();
|
keyEditor.currentState?.resetTextField();
|
||||||
Future.delayed(
|
Future.delayed(
|
||||||
const Duration(milliseconds: 300),
|
const Duration(milliseconds: 300),
|
||||||
keyEditor.currentState?.closeSuggestionBox,
|
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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<String> {
|
||||||
|
List<EmailAddress> toFilteredEmailAddressList(
|
||||||
|
List<EmailAddress> existingEmails,
|
||||||
|
) {
|
||||||
|
final existingEmailSet = existingEmails.toNormalizedEmailSet();
|
||||||
|
|
||||||
|
final seen = <String>{};
|
||||||
|
final result = <EmailAddress>[];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NamedAddress> {
|
||||||
|
List<EmailAddress> toFilteredEmailAddressList(
|
||||||
|
List<EmailAddress> existingEmails,
|
||||||
|
) {
|
||||||
|
final existingEmailSet = existingEmails.toNormalizedEmailSet();
|
||||||
|
final seen = <String>{};
|
||||||
|
return where((named) {
|
||||||
|
final normalized = named.normalizedAddress;
|
||||||
|
return !existingEmailSet.contains(normalized) && seen.add(normalized);
|
||||||
|
}).map((named) => named.validateToEmailAddress()).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,25 @@
|
|||||||
|
import 'package:core/utils/mail/mail_address.dart';
|
||||||
import 'package:core/utils/mail/named_address.dart';
|
import 'package:core/utils/mail/named_address.dart';
|
||||||
import 'package:jmap_dart_client/jmap/mail/email/email_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 {
|
extension NamedAddressExtension on NamedAddress {
|
||||||
|
bool get isValid => name.isNotEmpty && address.isNotEmpty;
|
||||||
|
|
||||||
|
String get normalizedAddress => address.toLowerCase();
|
||||||
|
|
||||||
EmailAddress toEmailAddress() => EmailAddress(name, address);
|
EmailAddress toEmailAddress() => EmailAddress(name, address);
|
||||||
|
|
||||||
|
EmailAddress validateToEmailAddress() {
|
||||||
|
try {
|
||||||
|
if (isValid) {
|
||||||
|
return toEmailAddress();
|
||||||
|
} else {
|
||||||
|
final mailAddress = MailAddress.validateAddress(address);
|
||||||
|
return mailAddress.asEmailAddress();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
return toEmailAddress();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,8 @@ import 'package:model/email/prefix_email_address.dart';
|
|||||||
import 'package:model/extensions/email_address_extension.dart';
|
import 'package:model/extensions/email_address_extension.dart';
|
||||||
import 'package:model/mailbox/expand_mode.dart';
|
import 'package:model/mailbox/expand_mode.dart';
|
||||||
import 'package:super_tag_editor/tag_editor.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/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/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/draggable_email_address.dart';
|
||||||
@@ -558,34 +559,33 @@ class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
|
|||||||
) => _createMailTag(value, stateSetter);
|
) => _createMailTag(value, stateSetter);
|
||||||
|
|
||||||
void _createMailTag(String value, StateSetter stateSetter) {
|
void _createMailTag(String value, StateSetter stateSetter) {
|
||||||
final namedAddress = StringConvert.parseNamedAddress(value.trim());
|
final valueTrimmed = value.trim();
|
||||||
if (namedAddress != null) {
|
final namedAddresses = StringConvert.extractNamedAddresses(valueTrimmed);
|
||||||
if (!_isDuplicatedRecipient(namedAddress.address)) {
|
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(
|
stateSetter(
|
||||||
() => _currentListEmailAddress.add(namedAddress.toEmailAddress()),
|
() => _currentListEmailAddress.addAll(emailAddressListFromNamed),
|
||||||
);
|
);
|
||||||
_updateListEmailAddressAction();
|
_updateListEmailAddressAction();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final listString = StringConvert.extractEmailAddress(value.trim()).toSet();
|
List<String> 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);
|
_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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ extension SetEmailAddressExtension on Set<EmailAddress>? {
|
|||||||
extension ListEmailAddressExtension on List<EmailAddress> {
|
extension ListEmailAddressExtension on List<EmailAddress> {
|
||||||
Set<String> asSetAddress() => map((emailAddress) => emailAddress.emailAddress).toSet();
|
Set<String> asSetAddress() => map((emailAddress) => emailAddress.emailAddress).toSet();
|
||||||
|
|
||||||
|
Set<String> toNormalizedEmailSet() =>
|
||||||
|
map((e) => e.email?.toLowerCase()).whereType<String>().toSet();
|
||||||
|
|
||||||
List<EmailAddress> removeInvalidEmails(String username) {
|
List<EmailAddress> removeInvalidEmails(String username) {
|
||||||
final Set<String> seenEmails = {};
|
final Set<String> seenEmails = {};
|
||||||
return where((email) {
|
return where((email) {
|
||||||
|
|||||||
@@ -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 = <EmailAddress>[];
|
||||||
|
|
||||||
|
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 = <EmailAddress>[];
|
||||||
|
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user