Skip to content

Commit 47b842d

Browse files
florentos17hoangdat
authored andcommitted
TF-3189 composer now correctly encodes subaddresses
1 parent 20c4720 commit 47b842d

File tree

6 files changed

+229
-35
lines changed

6 files changed

+229
-35
lines changed

core/lib/utils/mail/mail_address.dart

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:convert';
12
import 'package:core/domain/exceptions/address_exception.dart';
23
import 'package:core/utils/app_logger.dart';
34
import 'package:core/utils/mail/domain.dart';
@@ -23,8 +24,15 @@ class MailAddress with EquatableMixin {
2324
final String localPart;
2425
final Domain domain;
2526

27+
static const String subaddressingLocalPartDelimiter = '+';
28+
2629
MailAddress({required this.localPart, required this.domain});
2730

31+
MailAddress.fromParts({required String localPartWithoutDetails, required String localPartDetails, required this.domain}) : localPart =
32+
localPartDetails.isEmpty
33+
? localPartWithoutDetails
34+
: '$localPartWithoutDetails$subaddressingLocalPartDelimiter$localPartDetails';
35+
2836
factory MailAddress.validateAddress(String address) {
2937
log('MailAddress::validate: Address = $address');
3038
String localPart;
@@ -137,6 +145,89 @@ class MailAddress with EquatableMixin {
137145
return localPart;
138146
}
139147

148+
String? getLocalPartDetails() {
149+
int separatorPosition = localPart.indexOf(subaddressingLocalPartDelimiter);
150+
if (separatorPosition <= 0) {
151+
return null;
152+
}
153+
return localPart.substring(separatorPosition + subaddressingLocalPartDelimiter.length);
154+
}
155+
156+
String getLocalPartWithoutDetails() {
157+
int separatorPosition = localPart.indexOf(subaddressingLocalPartDelimiter);
158+
if (separatorPosition <= 0) {
159+
return localPart;
160+
}
161+
return localPart.substring(0, separatorPosition);
162+
}
163+
164+
MailAddress stripDetails() {
165+
return MailAddress(localPart: getLocalPartWithoutDetails(), domain: domain);
166+
}
167+
168+
// cannot use Uri.encodeComponent because it is meant to be compliant with RFC2396
169+
// eg `-_.!~*'()` are not encoded, but we want `!*'()` to be
170+
static final _needsNoEncoding = RegExp(r'^[a-zA-Z0-9._~-]+$');
171+
172+
// this table is adapted from `_unreserved2396Table` found at
173+
// https://github.com/dart-lang/sdk/blob/58f9beb6d4ec9e93430454bb96c0b8f068d0b0bc/sdk/lib/core/uri.dart#L3382
174+
static const _customUnreservedTable = <int>[
175+
// LSB MSB
176+
// | |
177+
0x0000, // 0x00 - 0x0f 0000000000000000
178+
0x0000, // 0x10 - 0x1f 0000000000000000
179+
// -.
180+
0x6000, // 0x20 - 0x2f 0000000000000110
181+
// 0123456789
182+
0x03ff, // 0x30 - 0x3f 1111111111000000
183+
// ABCDEFGHIJKLMNO
184+
0xfffe, // 0x40 - 0x4f 0111111111111111
185+
// PQRSTUVWXYZ _
186+
0x87ff, // 0x50 - 0x5f 1111111111100001
187+
// abcdefghijklmno
188+
0xfffe, // 0x60 - 0x6f 0111111111111111
189+
// pqrstuvwxyz ~
190+
0x47ff, // 0x70 - 0x7f 1111111111100010
191+
];
192+
193+
// this method is adapted from `_uriEncode()` found at:
194+
// https://github.com/dart-lang/sdk/blob/bb8db16297e6b9994b08ecae6ee1dd45a0be587e/sdk/lib/_internal/wasm/lib/uri_patch.dart#L49
195+
static String customUriEncode(String text) {
196+
if (_needsNoEncoding.hasMatch(text)) {
197+
return text;
198+
}
199+
200+
// Encode the string into bytes then generate an ASCII only string
201+
// by percent encoding selected bytes.
202+
StringBuffer result = StringBuffer('');
203+
var bytes = utf8.encode(text);
204+
for (int byte in bytes) {
205+
if (byte < 128 &&
206+
((_customUnreservedTable[byte >> 4] & (1 << (byte & 0x0f))) != 0)) {
207+
result.writeCharCode(byte);
208+
} else {
209+
const String hexDigits = '0123456789ABCDEF';
210+
result.write('%');
211+
result.write(hexDigits[(byte >> 4) & 0x0f]);
212+
result.write(hexDigits[byte & 0x0f]);
213+
}
214+
}
215+
return result.toString();
216+
}
217+
218+
String asEncodedString() {
219+
String? localPartDetails = getLocalPartDetails();
220+
if(localPartDetails == null) {
221+
return asString();
222+
} else {
223+
return MailAddress.fromParts(
224+
localPartWithoutDetails: getLocalPartWithoutDetails(),
225+
localPartDetails: customUriEncode(localPartDetails),
226+
domain: domain
227+
).asString();
228+
}
229+
}
230+
140231
@override
141232
String toString() {
142233
return '$localPart@${domain.asString()}';
@@ -323,6 +414,10 @@ class MailAddress with EquatableMixin {
323414
lpSB.write('.');
324415
pos++;
325416
lastCharDot = true;
417+
} else if (postChar == subaddressingLocalPartDelimiter) {
418+
// Start of local part details, jump to the `@`
419+
lpSB.write(subaddressingLocalPartDelimiter);
420+
pos = _parseLocalPartDetails(lpSB, address, pos+1);
326421
} else if (postChar == '@') {
327422
// End of local-part
328423
break;
@@ -416,6 +511,39 @@ class MailAddress with EquatableMixin {
416511
return pos;
417512
}
418513

514+
static int _parseLocalPartDetails(StringBuffer localPartSB, String address, int pos) {
515+
StringBuffer localPartDetailsSB = StringBuffer();
516+
517+
while (true) {
518+
if (pos >= address.length) {
519+
break;
520+
}
521+
var postChar = address[pos];
522+
if (postChar == '@') {
523+
// End of local-part-details
524+
break;
525+
} else {
526+
localPartDetailsSB.write(postChar);
527+
pos++;
528+
}
529+
}
530+
531+
String localPartDetails = localPartDetailsSB.toString();
532+
if (localPartDetails.isEmpty || localPartDetails.trim().isEmpty) {
533+
throw AddressException("target mailbox name should not be empty");
534+
}
535+
if (localPartDetails.startsWith('#')) {
536+
throw AddressException("target mailbox name should not start with #");
537+
}
538+
final forbiddenChars = RegExp(r'[*\r\n]');
539+
if (forbiddenChars.hasMatch(localPartDetails)) {
540+
throw AddressException("target mailbox name should not contain special characters");
541+
}
542+
543+
localPartSB.write(localPartDetails);
544+
return pos;
545+
}
546+
419547
@override
420548
List<Object?> get props => [localPart, domain];
421549
}

core/test/utils/mail_address_test.dart

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ void main() {
2020
2121
"user+mailbox/[email protected]",
2222
23+
24+
"user+my [email protected]",
25+
"user+Dossier d'été@domain.com",
2326
"\"Abc@def\"@example.com",
2427
"\"Fred Bloggs\"@example.com",
2528
"\"Joe.\\Blow\"@example.com",
@@ -56,6 +59,10 @@ void main() {
5659
"server-dev@#123.apache.org",
5760
"server-dev@[127.0.1.1.1]",
5861
"server-dev@[127.0.1.-1]",
62+
63+
"user+ @domain.com",
64+
65+
"user+test-_.!~*'() @domain.com",
5966
"\"a..b\"@domain.com", // jakarta.mail is unable to handle this so we better reject it
6067
"server-dev\\[email protected]", // jakarta.mail is unable to handle this so we better reject it
6168
@@ -165,5 +172,46 @@ void main() {
165172
final mailAddress = MailAddress.validateAddress(GOOD_ADDRESS);
166173
expect(mailAddress.toString(), equals(GOOD_ADDRESS));
167174
});
175+
176+
test('MailAddress.encodeLocalPartDetails() should work with characters to encode', () {
177+
final mailAddress = MailAddress.validateAddress("user+my [email protected]");
178+
expect(mailAddress.asEncodedString(), equals("user+my%[email protected]"));
179+
});
180+
181+
test('MailAddress.encodeLocalPartDetails() should work with many characters to encode', () {
182+
final mailAddress = MailAddress.validateAddress("user+Dossier d'été@domain.com");
183+
expect(mailAddress.asEncodedString(), equals("user+Dossier%20d%27%C3%A9t%C3%[email protected]"));
184+
});
185+
186+
test('MailAddress.encodeLocalPartDetails() should encode the rights characters', () {
187+
final mailAddress = MailAddress.validateAddress("user+test-_.!~'() @domain.com");
188+
expect(mailAddress.asEncodedString(), equals("user+test-_.%21~%27%28%29%[email protected]"));
189+
});
190+
191+
test('getLocalPartDetails() should work', () {
192+
final mailAddress = MailAddress.validateAddress("[email protected]");
193+
expect(mailAddress.getLocalPartDetails(), equals("details"));
194+
});
195+
196+
test('getLocalPartWithoutDetails() should work', () {
197+
final mailAddress = MailAddress.validateAddress("[email protected]");
198+
expect(mailAddress.getLocalPartWithoutDetails(), equals("user"));
199+
});
200+
201+
test('stripDetails() should work', () {
202+
final mailAddress = MailAddress.validateAddress("[email protected]");
203+
expect(mailAddress.stripDetails().asString(), equals("[email protected]"));
204+
});
205+
206+
test('stripDetails() should work with encoded local part', () {
207+
final mailAddress = MailAddress.validateAddress("user+Dossier%20d%27%C3%A9t%C3%[email protected]");
208+
expect(mailAddress.stripDetails().asString(), equals("[email protected]"));
209+
});
210+
211+
test('stripDetails() should work when local part needs encoding', () {
212+
final mailAddress = MailAddress.validateAddress("user+super [email protected]");
213+
expect(mailAddress.stripDetails().asString(), equals("[email protected]"));
214+
});
215+
168216
});
169217
}

lib/features/composer/presentation/composer_controller.dart

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import 'package:tmail_ui_user/features/composer/presentation/controller/rich_tex
5858
import 'package:tmail_ui_user/features/composer/presentation/extensions/email_action_type_extension.dart';
5959
import 'package:tmail_ui_user/features/composer/presentation/extensions/list_identities_extension.dart';
6060
import 'package:tmail_ui_user/features/composer/presentation/extensions/list_shared_media_file_extension.dart';
61+
import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart';
6162
import 'package:tmail_ui_user/features/composer/presentation/mixin/drag_drog_file_mixin.dart';
6263
import 'package:tmail_ui_user/features/composer/presentation/model/create_email_request.dart';
6364
import 'package:tmail_ui_user/features/composer/presentation/model/draggable_email_address.dart';
@@ -1600,16 +1601,16 @@ class ComposerController extends BaseController
16001601
final inputReplyToEmail = replyToEmailAddressController.text;
16011602

16021603
if (inputToEmail.isNotEmpty) {
1603-
_autoCreateToEmailTag(inputToEmail);
1604+
_autoCreateToEmailTag(MailAddress.validateAddress(inputToEmail));
16041605
}
16051606
if (inputCcEmail.isNotEmpty) {
1606-
_autoCreateCcEmailTag(inputCcEmail);
1607+
_autoCreateCcEmailTag(MailAddress.validateAddress(inputCcEmail));
16071608
}
16081609
if (inputBccEmail.isNotEmpty) {
1609-
_autoCreateBccEmailTag(inputBccEmail);
1610+
_autoCreateBccEmailTag(MailAddress.validateAddress(inputBccEmail));
16101611
}
16111612
if (inputReplyToEmail.isNotEmpty) {
1612-
_autoCreateReplyToEmailTag(inputReplyToEmail);
1613+
_autoCreateReplyToEmailTag(MailAddress.validateAddress(inputReplyToEmail));
16131614
}
16141615
}
16151616

@@ -1620,10 +1621,9 @@ class ComposerController extends BaseController
16201621
.contains(inputEmail);
16211622
}
16221623

1623-
void _autoCreateToEmailTag(String inputEmail) {
1624-
if (!_isDuplicatedRecipient(inputEmail, listToEmailAddress)) {
1625-
final emailAddress = EmailAddress(null, inputEmail);
1626-
listToEmailAddress.add(emailAddress);
1624+
void _autoCreateToEmailTag(MailAddress inputMailAddress) {
1625+
if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listToEmailAddress)) {
1626+
listToEmailAddress.add(inputMailAddress.asEmailAddress());
16271627
isInitialRecipient.value = true;
16281628
isInitialRecipient.refresh();
16291629
_updateStatusEmailSendButton();
@@ -1635,10 +1635,9 @@ class ComposerController extends BaseController
16351635
});
16361636
}
16371637

1638-
void _autoCreateCcEmailTag(String inputEmail) {
1639-
if (!_isDuplicatedRecipient(inputEmail, listCcEmailAddress)) {
1640-
final emailAddress = EmailAddress(null, inputEmail);
1641-
listCcEmailAddress.add(emailAddress);
1638+
void _autoCreateCcEmailTag(MailAddress inputMailAddress) {
1639+
if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listCcEmailAddress)) {
1640+
listCcEmailAddress.add(inputMailAddress.asEmailAddress());
16421641
isInitialRecipient.value = true;
16431642
isInitialRecipient.refresh();
16441643
_updateStatusEmailSendButton();
@@ -1649,10 +1648,9 @@ class ComposerController extends BaseController
16491648
});
16501649
}
16511650

1652-
void _autoCreateBccEmailTag(String inputEmail) {
1653-
if (!_isDuplicatedRecipient(inputEmail, listBccEmailAddress)) {
1654-
final emailAddress = EmailAddress(null, inputEmail);
1655-
listBccEmailAddress.add(emailAddress);
1651+
void _autoCreateBccEmailTag(MailAddress inputMailAddress) {
1652+
if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listBccEmailAddress)) {
1653+
listBccEmailAddress.add(inputMailAddress.asEmailAddress());
16561654
isInitialRecipient.value = true;
16571655
isInitialRecipient.refresh();
16581656
_updateStatusEmailSendButton();
@@ -1663,10 +1661,9 @@ class ComposerController extends BaseController
16631661
});
16641662
}
16651663

1666-
void _autoCreateReplyToEmailTag(String inputEmail) {
1667-
if (!_isDuplicatedRecipient(inputEmail, listReplyToEmailAddress)) {
1668-
final emailAddress = EmailAddress(null, inputEmail);
1669-
listReplyToEmailAddress.add(emailAddress);
1664+
void _autoCreateReplyToEmailTag(MailAddress inputMailAddress) {
1665+
if (!_isDuplicatedRecipient(inputMailAddress.asEncodedString(), listReplyToEmailAddress)) {
1666+
listReplyToEmailAddress.add(inputMailAddress.asEmailAddress());
16701667
isInitialRecipient.value = true;
16711668
isInitialRecipient.refresh();
16721669
_updateStatusEmailSendButton();
@@ -1745,28 +1742,28 @@ class ComposerController extends BaseController
17451742
toAddressExpandMode.value = ExpandMode.COLLAPSE;
17461743
final inputToEmail = toEmailAddressController.text;
17471744
if (inputToEmail.isNotEmpty) {
1748-
_autoCreateToEmailTag(inputToEmail);
1745+
_autoCreateToEmailTag(MailAddress.validateAddress(inputToEmail));
17491746
}
17501747
break;
17511748
case PrefixEmailAddress.cc:
17521749
ccAddressExpandMode.value = ExpandMode.COLLAPSE;
17531750
final inputCcEmail = ccEmailAddressController.text;
17541751
if (inputCcEmail.isNotEmpty) {
1755-
_autoCreateCcEmailTag(inputCcEmail);
1752+
_autoCreateCcEmailTag(MailAddress.validateAddress(inputCcEmail));
17561753
}
17571754
break;
17581755
case PrefixEmailAddress.bcc:
17591756
bccAddressExpandMode.value = ExpandMode.COLLAPSE;
17601757
final inputBccEmail = bccEmailAddressController.text;
17611758
if (inputBccEmail.isNotEmpty) {
1762-
_autoCreateBccEmailTag(inputBccEmail);
1759+
_autoCreateBccEmailTag(MailAddress.validateAddress(inputBccEmail));
17631760
}
17641761
break;
17651762
case PrefixEmailAddress.replyTo:
17661763
replyToAddressExpandMode.value = ExpandMode.COLLAPSE;
17671764
final inputReplyToEmail = replyToEmailAddressController.text;
17681765
if (inputReplyToEmail.isNotEmpty) {
1769-
_autoCreateReplyToEmailTag(inputReplyToEmail);
1766+
_autoCreateReplyToEmailTag(MailAddress.validateAddress(inputReplyToEmail));
17701767
}
17711768
break;
17721769
default:
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import 'package:jmap_dart_client/jmap/mail/email/email_address.dart';
2+
import 'package:core/core.dart';
3+
4+
extension MailAddressExtension on MailAddress {
5+
String? get getDisplayName {
6+
String? localPartDetails = getLocalPartDetails();
7+
if(localPartDetails == null) {
8+
return null;
9+
} else {
10+
return '${getLocalPartWithoutDetails()} [${getLocalPartDetails()}]';
11+
}
12+
}
13+
14+
EmailAddress asEmailAddress() {
15+
return EmailAddress(getDisplayName, asEncodedString());
16+
}
17+
}

0 commit comments

Comments
 (0)