Skip to content

Commit e8a4000

Browse files
arntchibenwa
authored andcommitted
Add support for unicode adddresses as defined in RFC6532.
1 parent fdfe43c commit e8a4000

File tree

4 files changed

+140
-15
lines changed

4 files changed

+140
-15
lines changed

core/src/main/java/org/apache/james/core/Domain.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
package org.apache.james.core;
2121

2222
import java.io.Serializable;
23+
import java.net.IDN;
2324
import java.util.Locale;
2425
import java.util.Objects;
2526

@@ -54,9 +55,16 @@ public static Domain of(String domain) {
5455
Preconditions.checkArgument(domain.length() <= MAXIMUM_DOMAIN_LENGTH,
5556
"Domain name length should not exceed %s characters", MAXIMUM_DOMAIN_LENGTH);
5657

57-
String domainWithoutBrackets = removeBrackets(domain);
58+
String domainWithoutBrackets = IDN.toASCII(removeBrackets(domain), IDN.ALLOW_UNASSIGNED);
5859
Preconditions.checkArgument(PART_CHAR_MATCHER.matchesAllOf(domainWithoutBrackets),
59-
"Domain parts ASCII chars must be a-z A-Z 0-9 - or _ in %s", domain);
60+
"Domain parts ASCII chars must be a-z A-Z 0-9 - or _ in %s", domain);
61+
62+
if (domainWithoutBrackets.startsWith("xn--") ||
63+
domainWithoutBrackets.contains(".xn--")) {
64+
domainWithoutBrackets = IDN.toUnicode(domainWithoutBrackets);
65+
Preconditions.checkArgument(!domainWithoutBrackets.startsWith("xn--") &&
66+
!domainWithoutBrackets.contains(".xn--"));
67+
}
6068

6169
int pos = 0;
6270
int nextDot = domainWithoutBrackets.indexOf('.');

core/src/main/java/org/apache/james/core/MailAddress.java

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
package org.apache.james.core;
2121

22+
import java.net.IDN;
2223
import java.util.Locale;
2324
import java.util.Objects;
2425
import java.util.Optional;
@@ -418,7 +419,7 @@ public Optional<InternetAddress> toInternetAddress() {
418419
try {
419420
return Optional.of(new InternetAddress(toString()));
420421
} catch (AddressException ae) {
421-
LOGGER.warn("A valid address '{}' as per James criterial fails to parse as a jakarta.mail InternetAdrress", asString());
422+
LOGGER.warn("A valid address '{}' as per James criteria fails to parse as a jakarta.mail InternetAdrress", asString());
422423
return Optional.empty();
423424
}
424425
}
@@ -549,15 +550,15 @@ private int parseUnquotedLocalPart(StringBuilder lpSB, String address, int pos)
549550
//End of local-part
550551
break;
551552
} else {
552-
//<c> ::= any one of the 128 ASCII characters, but not any
553-
// <special> or <SP>
553+
//<c> ::= any printable ASCII character, or any non-ASCII
554+
// unicode codepoint, but not <special> or <SP>
554555
//<special> ::= "<" | ">" | "(" | ")" | "[" | "]" | "\" | "."
555556
// | "," | ";" | ":" | "@" """ | the control
556557
// characters (ASCII codes 0 through 31 inclusive and
557558
// 127)
558559
//<SP> ::= the space character (ASCII code 32)
559560
char c = address.charAt(pos);
560-
if (c <= 31 || c >= 127 || c == ' ') {
561+
if (c <= 31 || c == 127 || c == ' ') {
561562
throw new AddressException("Invalid character in local-part (user account) at position " +
562563
(pos + 1) + " in '" + address + "'", address, pos + 1);
563564
}
@@ -688,6 +689,7 @@ private int parseDomain(StringBuilder dSB, String address, int pos) throws Addre
688689
// in practice though, we should relax this as domain names can start
689690
// with digits as well as letters. So only check that doesn't start
690691
// or end with hyphen.
692+
boolean unicode = false;
691693
while (true) {
692694
if (pos >= address.length()) {
693695
break;
@@ -700,13 +702,31 @@ private int parseDomain(StringBuilder dSB, String address, int pos) throws Addre
700702
resultSB.append(ch);
701703
pos++;
702704
continue;
705+
} else if (ch >= 0x0080) {
706+
resultSB.append(ch);
707+
pos++;
708+
unicode = true;
709+
continue;
703710
}
704711
if (ch == '.') {
705712
break;
706713
}
707714
throw new AddressException("Invalid character at " + pos + " in '" + address + "'", address, pos);
708715
}
709716
String result = resultSB.toString();
717+
if (unicode) {
718+
try {
719+
result = IDN.toASCII(result, IDN.ALLOW_UNASSIGNED);
720+
} catch (IllegalArgumentException e) {
721+
throw new AddressException("Domain invalid according to IDNA", address);
722+
}
723+
}
724+
if (result.startsWith("xn--") || result.contains(".xn--")) {
725+
result = IDN.toUnicode(result);
726+
if (result.startsWith("xn--") || result.contains(".xn--")) {
727+
throw new AddressException("Domain invalid according to IDNA", address);
728+
}
729+
}
710730
if (result.startsWith("-") || result.endsWith("-")) {
711731
throw new AddressException("Domain name cannot begin or end with a hyphen \"-\" at position " +
712732
(pos + 1) + " in '" + address + "'", address, pos + 1);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/****************************************************************
2+
* Licensed to the Apache Software Foundation (ASF) under one *
3+
* or more contributor license agreements. See the NOTICE file *
4+
* distributed with this work for additional information *
5+
* regarding copyright ownership. The ASF licenses this file *
6+
* to you under the Apache License, Version 2.0 (the *
7+
* "License"); you may not use this file except in compliance *
8+
* with the License. You may obtain a copy of the License at *
9+
* *
10+
* http://www.apache.org/licenses/LICENSE-2.0 *
11+
* *
12+
* Unless required by applicable law or agreed to in writing, *
13+
* software distributed under the License is distributed on an *
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
15+
* KIND, either express or implied. See the License for the *
16+
* specific language governing permissions and limitations *
17+
* under the License. *
18+
****************************************************************/
19+
20+
package org.apache.james.core;
21+
22+
import static org.assertj.core.api.Assertions.assertThat;
23+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
24+
25+
import java.util.stream.Stream;
26+
27+
import org.junit.jupiter.api.Test;
28+
import org.junit.jupiter.params.ParameterizedTest;
29+
import org.junit.jupiter.params.provider.Arguments;
30+
import org.junit.jupiter.params.provider.MethodSource;
31+
32+
33+
class DomainTest {
34+
@Test
35+
void testPlainDomain() {
36+
Domain d1 = Domain.of("example.com");
37+
assertThat(d1.name().equals(d1.asString()));
38+
Domain d2 = Domain.of("Example.com");
39+
assertThat(d2.name()).isNotEqualTo(d2.asString());
40+
assertThat(d1.asString()).isEqualTo(d2.asString());
41+
}
42+
43+
@Test
44+
void testIPv4Domain() {
45+
Domain d1 = Domain.of("192.0.4.1");
46+
assertThat(d1.asString()).isEqualTo("192.0.4.1");
47+
}
48+
49+
@Test
50+
void testPunycodeIDN() {
51+
Domain d1 = Domain.of("xn--gr-zia.example");
52+
assertThat(d1.asString()).isEqualTo("grå.example");
53+
}
54+
55+
@Test
56+
void testDevanagariDomain() {
57+
Domain d1 = Domain.of("डाटामेल.भारत");
58+
assertThat(d1.asString()).isEqualTo(d1.name());
59+
}
60+
61+
private static Stream<Arguments> malformedDomains() {
62+
return Stream.of(
63+
"😊☺️.example", // emoji not permitted by IDNA
64+
"#.example", // really and truly not permitted
65+
"\uFEFF.example", // U+FEFF is the byte order mark
66+
"\u200C.example", // U+200C is a zero-width non-joiner
67+
"\u200Eibm.example" // U+200E is left-to-right
68+
)
69+
.map(Arguments::of);
70+
}
71+
72+
@ParameterizedTest
73+
@MethodSource("malformedDomains")
74+
void testMalformedDomains(String malformed) {
75+
assertThatThrownBy(() -> Domain.of(malformed))
76+
.as("rejecting malformed domain " + malformed)
77+
.isInstanceOf(IllegalArgumentException.class);
78+
}
79+
}
80+
81+

core/src/test/java/org/apache/james/core/MailAddressTest.java

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,15 @@
2222
import static org.assertj.core.api.Assertions.assertThat;
2323
import static org.assertj.core.api.Assertions.assertThatCode;
2424

25+
import java.util.Properties;
2526
import java.util.stream.Stream;
2627

28+
import jakarta.mail.Session;
2729
import jakarta.mail.internet.AddressException;
2830
import jakarta.mail.internet.InternetAddress;
2931

3032
import org.assertj.core.api.Assertions;
33+
import org.junit.jupiter.api.BeforeEach;
3134
import org.junit.jupiter.api.Test;
3235
import org.junit.jupiter.params.ParameterizedTest;
3336
import org.junit.jupiter.params.provider.Arguments;
@@ -55,6 +58,13 @@ private static Stream<Arguments> goodAddresses() {
5558
5659
5760
61+
"Loïc.Accentué@voilà.fr8",
62+
"pelé@exemple.com",
63+
"δοκιμή@παράδειγμα.δοκιμή",
64+
"我買@屋企.香港",
65+
"二ノ宮@黒川.日本",
66+
"медведь@с-балалайкой.рф",
67+
//"संपर्क@डाटामेल.भारत", fails in Jakarta, reason still unknown
5868
"user+mailbox/[email protected]",
5969
6070
"\"Abc@def\"@example.com",
@@ -96,40 +106,46 @@ private static Stream<Arguments> badAddresses() {
96106
"server-dev@[127.0.1.1.1]",
97107
"server-dev@[127.0.1.-1]",
98108
"test@dom+ain.com",
109+
99110
"\"a..b\"@domain.com", // jakarta.mail is unable to handle this so we better reject it
100111
"server-dev\\[email protected]", // jakarta.mail is unable to handle this so we better reject it
101112
102-
// According to wikipedia these addresses are valid but as jakarta.mail is unable
103-
// to work with them we shall rather reject them (note that this is not breaking retro-compatibility)
104-
"Loïc.Accentué@voilà.fr8",
105-
"pelé@exemple.com",
106-
"δοκιμή@παράδειγμα.δοκιμή",
107-
"我買@屋企.香港",
108-
"二ノ宮@黒川.日本",
109-
"медведь@с-балалайкой.рф",
110-
"संपर्क@डाटामेल.भारत",
113+
"sales@\u200Eibm.example", // U+200E is left-to-right
114+
// According to wikipedia this address is valid but as jakarta.mail is unable
115+
// to work with it we shall rather reject them (note that this is not breaking retro-compatibility)
111116
"mail.allow\\,[email protected]")
112117
.map(Arguments::of);
113118
}
114119

120+
@BeforeEach
121+
void setup() {
122+
Properties props = new Properties();
123+
props.setProperty("mail.mime.allowutf8", "true");
124+
Session s = Session.getDefaultInstance(props);
125+
assertThat(Boolean.parseBoolean(s.getProperties().getProperty("mail.mime.allowutf8", "false")));
126+
}
127+
115128
@ParameterizedTest
116129
@MethodSource("goodAddresses")
117130
void testGoodMailAddressString(String mailAddress) {
118131
assertThatCode(() -> new MailAddress(mailAddress))
132+
.as("parses " + mailAddress)
119133
.doesNotThrowAnyException();
120134
}
121135

122136
@ParameterizedTest
123137
@MethodSource("goodAddresses")
124138
void toInternetAddressShouldNoop(String mailAddress) throws Exception {
125139
assertThat(new MailAddress(mailAddress).toInternetAddress())
140+
.as("tries to parse " + mailAddress + " using jakarta.mail")
126141
.isNotEmpty();
127142
}
128143

129144
@ParameterizedTest
130145
@MethodSource("badAddresses")
131146
void testBadMailAddressString(String mailAddress) {
132147
Assertions.assertThatThrownBy(() -> new MailAddress(mailAddress))
148+
.as("fails to parse " + mailAddress)
133149
.isInstanceOf(AddressException.class);
134150
}
135151

0 commit comments

Comments
 (0)