Skip to content

Commit 4f3f061

Browse files
committed
Add allowGmailDots to EmailValidator
1 parent 2c3a98b commit 4f3f061

File tree

5 files changed

+161
-26
lines changed

5 files changed

+161
-26
lines changed

CHANGELOG.md

+17
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,23 @@ the `Email` object (similar to the `normalize()` method).
131131
"[email protected]" => "te*****@gm*****"
132132
```
133133

134+
135+
### Support for Leading and Trailing Dots in the Local-Part (GMail Allowed)
136+
137+
While technically disallowed under published RFCs, GMail considers email addresses that have
138+
local-parts that start with or end with a dot `.` character as valid. For example, GMail
139+
considers `[email protected]` valid, even though it is not actually valid according to RFC.
140+
141+
JMail now gives you the option to consider these addresses valid as well. You must use an
142+
`EmailValidator` with the `allowGmailDots` rule added to it to allow these addresses to pass validation.
143+
144+
```java
145+
EmailValidator validator = JMail.strictValidator()
146+
.allowGmailDots();
147+
148+
validator.isValid("[email protected]"); // returns true
149+
```
150+
134151
---
135152
## 1.6.3
136153

src/main/java/com/sanctionco/jmail/EmailValidator.java

+30-7
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,16 @@ public final class EmailValidator {
4848
= ValidationRules::requireAscii;
4949

5050
private final Map<Predicate<Email>, FailureReason> validationPredicates;
51+
private final boolean allowGmailDots;
5152

52-
EmailValidator(Map<Predicate<Email>, FailureReason> validationPredicates) {
53+
EmailValidator(Map<Predicate<Email>, FailureReason> validationPredicates,
54+
boolean allowGmailDots) {
5355
this.validationPredicates = Collections.unmodifiableMap(validationPredicates);
56+
this.allowGmailDots = allowGmailDots;
5457
}
5558

5659
EmailValidator() {
57-
this(new HashMap<>());
60+
this(new HashMap<>(), false);
5861
}
5962

6063
/**
@@ -77,7 +80,7 @@ public EmailValidator withRules(Map<Predicate<Email>, FailureReason> rules) {
7780
Map<Predicate<Email>, FailureReason> ruleMap = new HashMap<>(validationPredicates);
7881
ruleMap.putAll(rules);
7982

80-
return new EmailValidator(ruleMap);
83+
return new EmailValidator(ruleMap, allowGmailDots);
8184
}
8285

8386
/**
@@ -161,6 +164,23 @@ public EmailValidator withRule(Predicate<Email> rule, String failureReason) {
161164
return withRules(Collections.singletonMap(rule, new FailureReason(failureReason)));
162165
}
163166

167+
/**
168+
* <p>Create a new {@code EmailValidator} (with all rules from the current instance) that
169+
* allows email addresses to have a local-part that either starts with or ends with a dot
170+
* {@code .} character.</p>
171+
*
172+
* <p>While not allowed according to RFC, a leading or trailing dot character in the local-part
173+
* <strong>is allowed</strong> by GMail, hence the naming of this method.</p>
174+
*
175+
* <p>For example, {@code "[email protected]"} would be considered valid if you use the
176+
* {@code EmailValidator} returned by this method.</p>
177+
*
178+
* @return the new {@code EmailValidator} instance
179+
*/
180+
public EmailValidator allowGmailDots() {
181+
return new EmailValidator(this.validationPredicates, true);
182+
}
183+
164184
/**
165185
* <p>Create a new {@code EmailValidator} with all rules from the current instance and the
166186
* {@link ValidationRules#disallowIpDomain(Email)} rule.
@@ -341,7 +361,8 @@ public EmailValidator requireAscii() {
341361
* @return the result of the validation
342362
*/
343363
public boolean isValid(String email) {
344-
return JMail.tryParse(email)
364+
return JMail.validate(email, allowGmailDots)
365+
.getEmail()
345366
.filter(e -> !testPredicates(e).isPresent())
346367
.isPresent();
347368
}
@@ -383,7 +404,7 @@ public void enforceValid(String email) throws InvalidEmailException {
383404
* {@link Email} object if successful, or the {@link FailureReason} if not
384405
*/
385406
public EmailValidationResult validate(String email) {
386-
EmailValidationResult result = JMail.validate(email);
407+
EmailValidationResult result = JMail.validate(email, allowGmailDots);
387408

388409
// If failed basic validation, just return it
389410
if (!result.getEmail().isPresent()) return result;
@@ -404,14 +425,16 @@ public EmailValidationResult validate(String email) {
404425
* is invalid according to all registered validation rules
405426
*/
406427
public Optional<Email> tryParse(String email) {
407-
return JMail.tryParse(email).filter(e -> !testPredicates(e).isPresent());
428+
return JMail.validate(email, allowGmailDots).getEmail()
429+
.filter(e -> !testPredicates(e).isPresent());
408430
}
409431

410432
/**
411433
* Test the given email address against all configured validation predicates.
412434
*
413435
* @param email the email address to test
414-
* @return true if it passes the predicates, false otherwise
436+
* @return an Optional that is either empty if all predicates passed or filled with the proper
437+
* FailureReason if one failed
415438
*/
416439
private Optional<FailureReason> testPredicates(Email email) {
417440
return validationPredicates.entrySet().stream()

src/main/java/com/sanctionco/jmail/JMail.java

+44-19
Original file line numberDiff line numberDiff line change
@@ -93,20 +93,6 @@ public static void enforceValid(String email) throws InvalidEmailException {
9393
}
9494
}
9595

96-
/**
97-
* Determine if the given email address is valid, returning a new {@link EmailValidationResult}
98-
* object that contains details on the result of the validation. Use this method if you need to
99-
* see the {@link FailureReason} upon validation failure. See {@link #tryParse(String)}
100-
* for details on what is required of an email address within basic validation.
101-
*
102-
* @param email the email address to validate
103-
* @return a {@link EmailValidationResult} containing success or failure, along with the parsed
104-
* {@link Email} object if successful, or the {@link FailureReason} if not
105-
*/
106-
public static EmailValidationResult validate(String email) {
107-
return validateInternal(email);
108-
}
109-
11096
/**
11197
* Parse the given email address into a new {@link Email} object. This method does basic
11298
* validation on the input email address. This method does not claim to be 100%
@@ -123,18 +109,44 @@ public static EmailValidationResult validate(String email) {
123109
* is invalid
124110
*/
125111
public static Optional<Email> tryParse(String email) {
126-
EmailValidationResult result = validateInternal(email);
112+
EmailValidationResult result = validate(email);
127113

128114
return result.getEmail();
129115
}
130116

117+
/**
118+
* Determine if the given email address is valid, returning a new {@link EmailValidationResult}
119+
* object that contains details on the result of the validation. Use this method if you need to
120+
* see the {@link FailureReason} upon validation failure. See {@link #tryParse(String)}
121+
* for details on what is required of an email address within basic validation.
122+
*
123+
* @param email the email address to validate
124+
* @return a {@link EmailValidationResult} containing success or failure, along with the parsed
125+
* {@link Email} object if successful, or the {@link FailureReason} if not
126+
*/
127+
public static EmailValidationResult validate(String email) {
128+
return validateInternal(email, false);
129+
}
130+
131+
/**
132+
* Package-private validate method that exposes an additional option {@code allowGmailDots}.
133+
*
134+
* @param email the email address to parse and validate
135+
* @param allowGmailDots true if a leading or trailing dot in the local-part should be allowed
136+
* @return a {@link EmailValidationResult} containing success or failure, along with the parsed
137+
* {@link Email} object if successful, or the {@link FailureReason} if not
138+
*/
139+
static EmailValidationResult validate(String email, boolean allowGmailDots) {
140+
return validateInternal(email, allowGmailDots);
141+
}
142+
131143
/**
132144
* Internal parsing method.
133145
*
134146
* @param email the email address to parse
135147
* @return a new {@link Email} instance if valid, empty if invalid
136148
*/
137-
private static EmailValidationResult validateInternal(String email) {
149+
private static EmailValidationResult validateInternal(String email, boolean allowGmailDots) {
138150
// email cannot be null
139151
if (email == null) return EmailValidationResult.failure(FailureReason.NULL_ADDRESS);
140152

@@ -168,7 +180,11 @@ private static EmailValidationResult validateInternal(String email) {
168180
if (size > 320) return EmailValidationResult.failure(FailureReason.ADDRESS_TOO_LONG);
169181

170182
// email cannot start with '.'
171-
if (email.charAt(0) == '.') return EmailValidationResult.failure(FailureReason.STARTS_WITH_DOT);
183+
// email cannot start with '.'
184+
// unless we are configured to allow it (GMail doesn't care about a starting dot)
185+
if (email.charAt(0) == '.' && !allowGmailDots) {
186+
return EmailValidationResult.failure(FailureReason.STARTS_WITH_DOT);
187+
}
172188

173189
// email cannot end with '.'
174190
if (email.charAt(size - 1) == '.') {
@@ -223,7 +239,8 @@ private static EmailValidationResult validateInternal(String email) {
223239
return EmailValidationResult.failure(FailureReason.UNQUOTED_ANGLED_BRACKET);
224240
}
225241

226-
EmailValidationResult innerResult = validateInternal(email.substring(i + 1, size - 1));
242+
EmailValidationResult innerResult
243+
= validateInternal(email.substring(i + 1, size - 1), allowGmailDots);
227244

228245
// If the address passed validation, return success with the identifier included.
229246
// Otherwise, just return the failed internal result
@@ -512,7 +529,15 @@ private static EmailValidationResult validateInternal(String email) {
512529

513530
// Check that local-part does not end with '.'
514531
if (localPart.charAt(localPart.length() - 1) == '.') {
515-
return EmailValidationResult.failure(FailureReason.LOCAL_PART_ENDS_WITH_DOT);
532+
// unless we are configured to allow it (GMail doesn't care about a trailing dot)
533+
if (!allowGmailDots) {
534+
return EmailValidationResult.failure(FailureReason.LOCAL_PART_ENDS_WITH_DOT);
535+
}
536+
537+
// if we allow a trailing dot, just make sure it's not the only thing in the local-part
538+
if (localPartLen <= 1) {
539+
return EmailValidationResult.failure(FailureReason.LOCAL_PART_MISSING);
540+
}
516541
}
517542

518543
// Ensure the TLD is not empty or greater than 63 chars

src/test/java/com/sanctionco/jmail/EmailValidatorTest.java

+36
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,42 @@ void invalidatesCorrectlyWithMap(String email) {
307307
}
308308
}
309309

310+
@Nested
311+
class AllowGmailDots {
312+
@ParameterizedTest(name = "{0}")
313+
@ValueSource(strings = {
314+
315+
void rejectsInvalidDots(String email) {
316+
runInvalidTest(JMail.validator().allowGmailDots(),
317+
email, FailureReason.MULTIPLE_DOT_SEPARATORS);
318+
}
319+
320+
@ParameterizedTest(name = "{0}")
321+
@ValueSource(strings = {
322+
323+
void allowsGmailDots(String email) {
324+
runValidTest(JMail.validator().allowGmailDots(), email);
325+
}
326+
}
327+
328+
@Nested
329+
class DisallowIpDomainAllowGmailDotsCombination {
330+
@ParameterizedTest(name = "{0}")
331+
@ValueSource(strings = {
332+
"test@[1.2.3.4]", "test@[5.6.7.8]"})
333+
void rejects(String email) {
334+
runInvalidTest(JMail.validator().allowGmailDots().disallowIpDomain(),
335+
email, FailureReason.CONTAINS_IP_DOMAIN);
336+
}
337+
338+
@ParameterizedTest(name = "{0}")
339+
@ValueSource(strings = {
340+
341+
void allows(String email) {
342+
runValidTest(JMail.validator().allowGmailDots().disallowIpDomain(), email);
343+
}
344+
}
345+
310346
private static void runValidTest(EmailValidator validator, String email) {
311347
Condition<String> valid = new Condition<>(validator::isValid, "valid");
312348

src/test/java/com/sanctionco/jmail/JMailTest.java

+34
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import net.andreinc.mockneat.MockNeat;
99

1010
import org.assertj.core.api.Condition;
11+
import org.junit.jupiter.api.Nested;
1112
import org.junit.jupiter.api.Test;
1213
import org.junit.jupiter.params.ParameterizedTest;
1314
import org.junit.jupiter.params.provider.CsvFileSource;
@@ -17,6 +18,7 @@
1718
import static org.assertj.core.api.Assertions.assertThat;
1819
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
1920
import static org.assertj.core.api.Assertions.assertThatNoException;
21+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
2022

2123
class JMailTest {
2224
private final Condition<String> valid = new Condition<>(JMail::isValid, "valid");
@@ -207,4 +209,36 @@ void ensureIdentifiersAreParsed() {
207209
void isInvalidCanValidate() {
208210
assertThat(JMail.isInvalid("[email protected]")).isFalse();
209211
}
212+
213+
@Nested
214+
class AllowGmailDots {
215+
@ParameterizedTest(name = "{0}")
216+
@MethodSource({
217+
"com.sanctionco.jmail.helpers.AdditionalEmailProvider#provideInvalidEmails",
218+
"com.sanctionco.jmail.helpers.AdditionalEmailProvider#provideInvalidWhitespaceEmails",
219+
"com.sanctionco.jmail.helpers.AdditionalEmailProvider#provideInvalidControlEmails"})
220+
@CsvFileSource(resources = "/invalid-addresses.csv", delimiterString = " ;", numLinesToSkip = 1)
221+
void ensureFailuresWhenAllowGmailDotsIsTrue(String email) {
222+
// This test only works for addresses that will fail
223+
// even when we allow a starting or trailing dot in the local-part
224+
assumeTrue(email.charAt(0) != '.' && !email.contains(".@"));
225+
226+
assertThat(JMail.validate(email, true))
227+
.returns(true, EmailValidationResult::isFailure);
228+
}
229+
230+
@Test
231+
void ensureOnlyDotFailsWhenAllowGmailDotsIsTrue() {
232+
assertThat(JMail.validate("[email protected]", true))
233+
.returns(true, EmailValidationResult::isFailure)
234+
.returns(FailureReason.LOCAL_PART_MISSING, EmailValidationResult::getFailureReason);;
235+
}
236+
237+
@ParameterizedTest(name = "{0}")
238+
@ValueSource(strings = {"[email protected]", "[email protected]"})
239+
void ensureStartingAndTrailingDotsPass(String address) {
240+
assertThat(JMail.validate(address, true).getEmail())
241+
.isPresent().get().hasToString(address);
242+
}
243+
}
210244
}

0 commit comments

Comments
 (0)