Skip to content

Commit

Permalink
TF-3189 composer now correctly encodes subaddresses
Browse files Browse the repository at this point in the history
  • Loading branch information
florentos17 committed Dec 3, 2024
1 parent 028e3d7 commit 2e44ad2
Show file tree
Hide file tree
Showing 6 changed files with 229 additions and 35 deletions.
128 changes: 128 additions & 0 deletions core/lib/utils/mail/mail_address.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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 = <int>[
// 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()}';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Object?> get props => [localPart, domain];
}
48 changes: 48 additions & 0 deletions core/test/utils/mail_address_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ void main() {
"[email protected]",
"user+mailbox/[email protected]",
"[email protected]",
"[email protected]",
"user+my [email protected]",
"user+Dossier d'été@domain.com",
"\"Abc@def\"@example.com",
"\"Fred Bloggs\"@example.com",
"\"Joe.\\Blow\"@example.com",
Expand Down Expand Up @@ -56,6 +59,10 @@ void main() {
"server-dev@#123.apache.org",
"server-dev@[127.0.1.1.1]",
"server-dev@[127.0.1.-1]",
"[email protected]",
"user+ @domain.com",
"user+#[email protected]",
"user+test-_.!~*'() @domain.com",
"\"a..b\"@domain.com", // jakarta.mail is unable to handle this so we better reject it
"server-dev\\[email protected]", // jakarta.mail is unable to handle this so we better reject it
"[email protected]",
Expand Down Expand Up @@ -165,5 +172,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 [email protected]");
expect(mailAddress.asEncodedString(), equals("user+my%[email protected]"));
});

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%[email protected]"));
});

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%[email protected]"));
});

test('getLocalPartDetails() should work', () {
final mailAddress = MailAddress.validateAddress("[email protected]");
expect(mailAddress.getLocalPartDetails(), equals("details"));
});

test('getLocalPartWithoutDetails() should work', () {
final mailAddress = MailAddress.validateAddress("[email protected]");
expect(mailAddress.getLocalPartWithoutDetails(), equals("user"));
});

test('stripDetails() should work', () {
final mailAddress = MailAddress.validateAddress("[email protected]");
expect(mailAddress.stripDetails().asString(), equals("[email protected]"));
});

test('stripDetails() should work with encoded local part', () {
final mailAddress = MailAddress.validateAddress("user+Dossier%20d%27%C3%A9t%C3%[email protected]");
expect(mailAddress.stripDetails().asString(), equals("[email protected]"));
});

test('stripDetails() should work when local part needs encoding', () {
final mailAddress = MailAddress.validateAddress("user+super [email protected]");
expect(mailAddress.stripDetails().asString(), equals("[email protected]"));
});

});
}
45 changes: 21 additions & 24 deletions lib/features/composer/presentation/composer_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
}
}

Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading

0 comments on commit 2e44ad2

Please sign in to comment.