Skip to content

Commit 039f29b

Browse files
committed
support quoted local part
1 parent a840c86 commit 039f29b

2 files changed

Lines changed: 117 additions & 5 deletions

File tree

dot-parse/src/main/java/com/google/common/labs/email/EmailAddress.java

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import com.google.errorprone.annotations.Immutable;
3939
import com.google.mu.util.CharPredicate;
4040
import com.google.mu.util.StringFormat;
41+
import com.google.mu.util.Substring;
4142

4243
/**
4344
* Represents an email address according to RFC 5322, designed as a modern,
@@ -54,6 +55,13 @@
5455
* <ul>
5556
* <li><b>Address Specification (addr-spec):</b> Supports the standard
5657
* {@code local-part@domain} format (RFC 5322 §3.4.1).</li>
58+
* <li><b>Quoted Local-Parts:</b> Fully supports double-quoted local-parts
59+
* (RFC 5322 §3.4.1), with backslash-escaped characters. In order to provide a clean,
60+
* canonical representation, enclosing quotes are automatically stripped and backslash
61+
* escapes are unescaped when stored in the {@code localPart} property (e.g.,
62+
* {@code "john doe" -> "john doe"}). While serializing via {@link #address()} or
63+
* {@link #toString()}, appropriate quotes and escapes are dynamically and safely
64+
* re-introduced if necessary to maintain syntactical validity under RFC 5322 (since v10.3).</li>
5765
* <li><b>Name-Addr:</b> Fully supports {@code "display-name" <addr-spec>}
5866
* syntax (RFC 5322 §3.4).</li>
5967
* <li><b>Quoted-Strings:</b> Complies with RFC 5322 §3.2.4, supporting
@@ -80,7 +88,6 @@
8088
* header injection risks, the following RFC 5322 edge cases are excluded:</p>
8189
*
8290
* <ul>
83-
* <li><b>Quoted Local-Parts:</b> (e.g., {@code "john doe"@domain}) - Deprecated in practice.
8491
* <li><b>Comments (CFWS):</b> (e.g., {@code name(comment) <addr>}) - De facto obsolete.
8592
* <li><b>Domain Literals:</b> (e.g., {@code user@[192.168.1.1]}) - IP routing is rarely supported.
8693
* </ul>
@@ -93,6 +100,8 @@
93100
@Immutable
94101
public record EmailAddress(Optional<String> displayName, String localPart, String domain) {
95102
private static final StringFormat WITH_DISPLAY_NAME = new StringFormat("\"{name}\" <{address}>");
103+
private static final CharPredicate UNQUOTED_CHARS =
104+
((CharPredicate) Character::isLetterOrDigit).or("!#$%&'*+-/=?^_`{|}~.").precomputeForAscii();
96105

97106
/**
98107
* The parser for email address, according to RFC 5322, and supporting BMP characters.
@@ -108,7 +117,10 @@ public record EmailAddress(Optional<String> displayName, String localPart, Strin
108117
* Prefer using the {@link #of} factory method. You can call {@link #withDisplayName}
109118
* to optionally attach a display name.
110119
*/
111-
public EmailAddress{
120+
public EmailAddress {
121+
if (localPart.startsWith("\"") && localPart.endsWith("\"") && localPart.length() >= 2) {
122+
localPart = unescape(localPart.substring(1, localPart.length() - 1));
123+
}
112124
checkArgument(!localPart.isEmpty(), "local-part cannot be empty");
113125
checkArgument(!domain.isEmpty(), "domain cannot be empty");
114126
requireNonNull(displayName);
@@ -135,7 +147,22 @@ public static EmailAddress of(String localPart, String domain) {
135147

136148
/** Returns the {@code addr-spec}, in the form of {@code user@mycompany.com}. */
137149
public String address() {
138-
return localPart + '@' + domain;
150+
return showLocalPart() + '@' + domain;
151+
}
152+
153+
private String showLocalPart() {
154+
return localPart.isEmpty()
155+
|| localPart.startsWith(".")
156+
|| localPart.endsWith(".")
157+
|| localPart.contains("..")
158+
|| !UNQUOTED_CHARS.matchesAllOf(localPart)
159+
? '"' + escape(localPart) + '"'
160+
: localPart;
161+
}
162+
163+
private static String unescape(String text) {
164+
return Substring.first(java.util.regex.Pattern.compile("\\\\.")).repeatedly()
165+
.replaceAllFrom(text, e -> e.subSequence(1, e.length()));
139166
}
140167

141168
/**
@@ -186,9 +213,11 @@ public static List<EmailAddress> parseAddressList(String addressList) {
186213
private static Parser<EmailAddress> makeParser() {
187214
CharPredicate letterOrDigit = Character::isLetterOrDigit;
188215
CharPredicate isoControl = Character::isISOControl;
189-
Parser<String> localPart =
216+
Parser<String> localPart = anyOf(
217+
Parser.quotedByWithEscapes(
218+
'"', '"', chars(1).suchThat(isoControl::matchesNoneOf, "escapable char")),
190219
consecutive(letterOrDigit.or("!#$%&'*+-/=?^_`{|}~").precomputeForAscii(), "local part")
191-
.atLeastOnceDelimitedBy(".", joining("."));
220+
.atLeastOnceDelimitedBy(".", joining(".")));
192221
Parser<String> domain =
193222
consecutive(letterOrDigit.or("-.").precomputeForAscii(), "domain label chars");
194223
Parser<EmailAddress> address =

dot-parse/src/test/java/com/google/common/labs/email/EmailAddressTest.java

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,89 @@ public void testEmailAddressParsing_simple(@TestParameter ParseStrategy parser)
8686
parser.assertParsesTo("test@example.com", EmailAddress.of("test", "example.com"));
8787
}
8888

89+
@Test
90+
public void testEmailAddressParsing_quotedLocalPart_simple(@TestParameter ParseStrategy parser) {
91+
assume().that(parser).isEqualTo(ParseStrategy.COMBINATOR);
92+
EmailAddress address = parser.parse("\"john doe\"@example.com");
93+
assertThat(address.localPart()).isEqualTo("john doe");
94+
assertThat(address.address()).isEqualTo("\"john doe\"@example.com");
95+
}
96+
97+
@Test
98+
public void testEmailAddressParsing_quotedLocalPart_withEscapedQuotes(@TestParameter ParseStrategy parser) {
99+
assume().that(parser).isEqualTo(ParseStrategy.COMBINATOR);
100+
EmailAddress address = parser.parse("\"john\\\"doe\"@example.com");
101+
assertThat(address.localPart()).isEqualTo("john\"doe");
102+
assertThat(address.address()).isEqualTo("\"john\\\"doe\"@example.com");
103+
}
104+
105+
@Test
106+
public void testEmailAddressParsing_quotedLocalPart_withEscapedBackslash(@TestParameter ParseStrategy parser) {
107+
assume().that(parser).isEqualTo(ParseStrategy.COMBINATOR);
108+
EmailAddress address = parser.parse("\"john\\\\doe\"@example.com");
109+
assertThat(address.localPart()).isEqualTo("john\\doe");
110+
assertThat(address.address()).isEqualTo("\"john\\\\doe\"@example.com");
111+
}
112+
113+
@Test
114+
public void testEmailAddressParsing_quotedLocalPart_withOtherEscapedChars(@TestParameter ParseStrategy parser) {
115+
assume().that(parser).isEqualTo(ParseStrategy.COMBINATOR);
116+
EmailAddress address = parser.parse("\"john\\=doe\"@example.com");
117+
assertThat(address.localPart()).isEqualTo("john=doe");
118+
assertThat(address.address()).isEqualTo("john=doe@example.com");
119+
}
120+
121+
@Test
122+
public void testEmailAddressParsing_quotedLocalPart_withDisplayName(@TestParameter ParseStrategy parser) {
123+
assume().that(parser).isEqualTo(ParseStrategy.COMBINATOR);
124+
EmailAddress address = parser.parse("John Doe <\"john doe\"@example.com>");
125+
assertThat(address.localPart()).isEqualTo("john doe");
126+
assertThat(address.address()).isEqualTo("\"john doe\"@example.com");
127+
assertThat(address.displayName()).hasValue("John Doe");
128+
}
129+
130+
@Test
131+
public void testEmailAddressOf_localPartNeedsQuoting() {
132+
EmailAddress address = EmailAddress.of("john\"doe", "example.com");
133+
assertThat(address.localPart()).isEqualTo("john\"doe");
134+
assertThat(address.toString()).isEqualTo("\"john\\\"doe\"@example.com");
135+
}
136+
137+
@Test
138+
public void testEmailAddressOf_localPartNeedsQuoting_withDisplayName() {
139+
EmailAddress address = EmailAddress.of("john doe", "example.com").withDisplayName("John Doe");
140+
assertThat(address.localPart()).isEqualTo("john doe");
141+
assertThat(address.toString()).isEqualTo("\"John Doe\" <\"john doe\"@example.com>");
142+
}
143+
144+
@Test
145+
public void testEmailAddressOf_localPartStartsWithDot() {
146+
EmailAddress address = EmailAddress.of(".john.doe", "example.com");
147+
assertThat(address.localPart()).isEqualTo(".john.doe");
148+
assertThat(address.toString()).isEqualTo("\".john.doe\"@example.com");
149+
}
150+
151+
@Test
152+
public void testEmailAddressOf_localPartEndsWithDot() {
153+
EmailAddress address = EmailAddress.of("john.doe.", "example.com");
154+
assertThat(address.localPart()).isEqualTo("john.doe.");
155+
assertThat(address.toString()).isEqualTo("\"john.doe.\"@example.com");
156+
}
157+
158+
@Test
159+
public void testEmailAddressOf_localPartContainsDoubleDot() {
160+
EmailAddress address = EmailAddress.of("john..doe", "example.com");
161+
assertThat(address.localPart()).isEqualTo("john..doe");
162+
assertThat(address.toString()).isEqualTo("\"john..doe\"@example.com");
163+
}
164+
165+
@Test
166+
public void testEmailAddressOf_localPartContainsSingleDotInMiddle() {
167+
EmailAddress address = EmailAddress.of("john.doe", "example.com");
168+
assertThat(address.localPart()).isEqualTo("john.doe");
169+
assertThat(address.toString()).isEqualTo("john.doe@example.com");
170+
}
171+
89172
@Test
90173
public void testEmailAddressParsing_complex(@TestParameter ParseStrategy parser) {
91174
parser.assertParsesTo(

0 commit comments

Comments
 (0)