3838import com .google .errorprone .annotations .Immutable ;
3939import com .google .mu .util .CharPredicate ;
4040import 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,
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
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>
93100@ Immutable
94101public 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 =
0 commit comments