Skip to content

Commit 6a9a5b5

Browse files
committed
v2 FailureReason
1 parent 6da2b68 commit 6a9a5b5

File tree

7 files changed

+408
-122
lines changed

7 files changed

+408
-122
lines changed

CHANGELOG.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,44 @@
22

33
## 2.0.0 (Upcoming)
44

5-
### Email Address Normalization Improvements
5+
### Breaking Changes
6+
7+
- The `jmail.normalize.strip.quotes` JVM system property no longer does anything.
8+
Use `NormalizationOptionsBuilder#stripQuotes()` instead.
9+
10+
11+
- `FailureReason` was switched from an enum to a class in order to support custom failure reasons, so you cannot
12+
use it in a `switch` statement.
13+
14+
15+
- `FailureReason.MISSING_TOP_LEVEL_DOMAIN` was changed to `FailureReason.MISSING_FINAL_DOMAIN_PART`.
16+
`MISSING_TOP_LEVEL_DOMAIN` was previously used for email addresses that failed validation because
17+
they ended the email address with a comment. This `FailureReason` was potentially misleading, for example
18+
enabling `requireTopLevelDomain()` on your `EmailValidator`.
19+
620

7-
#### Breaking changes
21+
- Email addresses that fail validation due to additional rules added to the `EmailValidator` (such as
22+
`disallowIpDomain()` or `requireValidMXRecord()`) no longer returns a generic `FailureReason.FAILED_CUSTOM_VALIDATION`
23+
in the `EmailValidationResult`. Instead, it returns a more specific `FailureReason` depending on the rule.
824

9-
- The `jmail.normalize.strip.quotes` JVM system property no longer does anything. Use `NormalizationOptionsBuilder#stripQuotes()` instead.
25+
### FailureReason Improvements
26+
27+
#### Changes to the FailureReason supplied for additional rules
28+
29+
The `FailureReason` returned in the `EmailValidationResult` is useful to understand why a specific
30+
email address failed validation. In v2.0.0, the `FailureReason` returned for email addresses that failed
31+
one of the additional validation rules added to your `EmailValidator` (such as `disallowIpDomain()` or
32+
`requireValidMXRecord()`) now return more specific and useful reasons.
33+
34+
#### Option to provide FailureReason for custom rules
35+
36+
Additionally, you can specify your own `FailureReason` for any custom validation rules that you add
37+
to your `EmailValidator`. Use the new `withRule(Predicate<Email>, FailureReason)` or
38+
`withRules(Map<Predicate<Email>, FailureReason>)` methods to specify the failure reason for each
39+
of your custom rules. If no failure reason is supplied, then the rule will default to the
40+
`FailureReason.FAILED_CUSTOM_VALIDATION` reason.
41+
42+
### Email Address Normalization Improvements
1043

1144
#### New Normalization Methods
1245

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

Lines changed: 117 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
import java.util.Arrays;
44
import java.util.Collection;
55
import java.util.Collections;
6-
import java.util.HashSet;
6+
import java.util.HashMap;
7+
import java.util.Map;
78
import java.util.Optional;
8-
import java.util.Set;
99
import java.util.StringJoiner;
1010
import java.util.function.Predicate;
1111
import java.util.stream.Collectors;
@@ -34,9 +34,9 @@ public final class EmailValidator {
3434
= ValidationRules::disallowIpDomain;
3535
private static final Predicate<Email> REQUIRE_TOP_LEVEL_DOMAIN_PREDICATE
3636
= ValidationRules::requireTopLevelDomain;
37-
private static final Predicate<Email> DISALLOW_EXPLICIT_SOURCE_ROUTING
37+
private static final Predicate<Email> DISALLOW_EXPLICIT_SOURCE_ROUTING_PREDICATE
3838
= ValidationRules::disallowExplicitSourceRouting;
39-
private static final Predicate<Email> DISALLOW_QUOTED_IDENTIFIERS
39+
private static final Predicate<Email> DISALLOW_QUOTED_IDENTIFIERS_PREDICATE
4040
= ValidationRules::disallowQuotedIdentifiers;
4141
private static final Predicate<Email> DISALLOW_RESERVED_DOMAINS_PREDICATE
4242
= ValidationRules::disallowReservedDomains;
@@ -47,14 +47,36 @@ public final class EmailValidator {
4747
private static final Predicate<Email> REQUIRE_ASCII_PREDICATE
4848
= ValidationRules::requireAscii;
4949

50-
private final Set<Predicate<Email>> validationPredicates;
50+
private final Map<Predicate<Email>, FailureReason> validationPredicates;
5151

52-
EmailValidator(Set<Predicate<Email>> validationPredicates) {
53-
this.validationPredicates = Collections.unmodifiableSet(validationPredicates);
52+
EmailValidator(Map<Predicate<Email>, FailureReason> validationPredicates) {
53+
this.validationPredicates = Collections.unmodifiableMap(validationPredicates);
5454
}
5555

5656
EmailValidator() {
57-
this(new HashSet<>());
57+
this(new HashMap<>());
58+
}
59+
60+
/**
61+
* Create a new {@code EmailValidator} with all rules from the current instance and the
62+
* additional provided custom validation rules.
63+
*
64+
* <p>Example usage:
65+
*
66+
* <pre>
67+
* validator.withRules(Map.of(
68+
* email -> email.domain().startsWith("test"), new FailureReason("MUST_START_WITH_TEST"),
69+
* email -> email.localPart.contains("hello"), new FailureReason("MUST_CONTAIN_HELLO"));
70+
* </pre>
71+
*
72+
* @param rules a collection of requirements that make a valid email address
73+
* @return the new {@code EmailValidator} instance
74+
*/
75+
public EmailValidator withRules(Map<Predicate<Email>, FailureReason> rules) {
76+
Map<Predicate<Email>, FailureReason> ruleMap = new HashMap<>(validationPredicates);
77+
ruleMap.putAll(rules);
78+
79+
return new EmailValidator(ruleMap);
5880
}
5981

6082
/**
@@ -73,10 +95,8 @@ public final class EmailValidator {
7395
* @return the new {@code EmailValidator} instance
7496
*/
7597
public EmailValidator withRules(Collection<Predicate<Email>> rules) {
76-
Set<Predicate<Email>> ruleSet = new HashSet<>(validationPredicates);
77-
ruleSet.addAll(rules);
78-
79-
return new EmailValidator(ruleSet);
98+
return withRules(rules.stream()
99+
.collect(Collectors.toMap(p -> p, p -> FailureReason.FAILED_CUSTOM_VALIDATION)));
80100
}
81101

82102
/**
@@ -94,79 +114,116 @@ public EmailValidator withRules(Collection<Predicate<Email>> rules) {
94114
* @return the new {@code EmailValidator} instance
95115
*/
96116
public EmailValidator withRule(Predicate<Email> rule) {
97-
return withRules(Collections.singletonList(rule));
117+
return withRules(Collections.singletonMap(rule, FailureReason.FAILED_CUSTOM_VALIDATION));
98118
}
99119

100120
/**
101-
* Create a new {@code EmailValidator} with all rules from the current instance and the
121+
* Create a new {@code EmailValidator} with all rules from the current instance and an
122+
* additional provided custom validation rule.
123+
*
124+
* <p>Example usage:
125+
*
126+
* <pre>
127+
* validator.withRule(
128+
* email -> email.domain().startsWith("test"),
129+
* new FailureReason("DOES_NOT_START_WITH_TEST"));
130+
* </pre>
131+
*
132+
* @param rule the requirement for a valid email address. This must be a {@link Predicate} that
133+
* accepts an {@link Email} object.
134+
* @param failureReason the {@link FailureReason} to return in the {@link EmailValidationResult}
135+
* if an email fails to pass this rule.
136+
* @return the new {@code EmailValidator} instance
137+
*/
138+
public EmailValidator withRule(Predicate<Email> rule, FailureReason failureReason) {
139+
return withRules(Collections.singletonMap(rule, failureReason));
140+
}
141+
142+
/**
143+
* <p>Create a new {@code EmailValidator} with all rules from the current instance and the
102144
* {@link ValidationRules#disallowIpDomain(Email)} rule.
103-
* Email addresses that have an IP address for a domain will fail validation.
145+
* Email addresses that have an IP address for a domain will fail validation with
146+
* {@link FailureReason#CONTAINS_IP_DOMAIN}.
104147
*
105148
* <p>For example, {@code "sample@[1.2.3.4]"} would be invalid.
106149
*
107150
* @return the new {@code EmailValidator} instance
108151
*/
109152
public EmailValidator disallowIpDomain() {
110-
return withRule(DISALLOW_IP_DOMAIN_PREDICATE);
153+
return withRule(
154+
DISALLOW_IP_DOMAIN_PREDICATE,
155+
FailureReason.CONTAINS_IP_DOMAIN);
111156
}
112157

113158
/**
114-
* Create a new {@code EmailValidator} with all rules from the current instance and the
159+
* <p>Create a new {@code EmailValidator} with all rules from the current instance and the
115160
* {@link ValidationRules#requireTopLevelDomain(Email)} rule.
116-
* Email addresses that do not have a top level domain will fail validation.
161+
* Email addresses that do not have a top level domain will fail validation
162+
* with {@link FailureReason#MISSING_TOP_LEVEL_DOMAIN}.
117163
*
118164
* <p>For example, {@code "sample@mailserver"} would be invalid.
119165
*
120166
* @return the new {@code EmailValidator} instance
121167
*/
122168
public EmailValidator requireTopLevelDomain() {
123-
return withRule(REQUIRE_TOP_LEVEL_DOMAIN_PREDICATE);
169+
return withRule(
170+
REQUIRE_TOP_LEVEL_DOMAIN_PREDICATE,
171+
FailureReason.MISSING_TOP_LEVEL_DOMAIN);
124172
}
125173

126174
/**
127175
* Create a new {@code EmailValidator} with all rules from the current instance and the
128176
* {@link ValidationRules#disallowExplicitSourceRouting(Email)} rule.
129-
* Email addresses that have explicit source routing will fail validation.
177+
* Email addresses that have explicit source routing will fail validation with
178+
* {@link FailureReason#CONTAINS_EXPLICIT_SOURCE_ROUTING}.
130179
*
131180
* <p>For example, {@code "@1st.relay,@2nd.relay:[email protected]"} would be invalid.
132181
*
133182
* @return the new {@code EmailValidator} instance
134183
*/
135184
public EmailValidator disallowExplicitSourceRouting() {
136-
return withRule(DISALLOW_EXPLICIT_SOURCE_ROUTING);
185+
return withRule(
186+
DISALLOW_EXPLICIT_SOURCE_ROUTING_PREDICATE,
187+
FailureReason.CONTAINS_EXPLICIT_SOURCE_ROUTING);
137188
}
138189

139190
/**
140191
* Create a new {@code EmailValidator} with all rules from the current instance and the
141192
* {@link ValidationRules#disallowQuotedIdentifiers(Email)} rule.
142-
* Email addresses that have quoted identifiers will fail validation.
193+
* Email addresses that have quoted identifiers will fail validation with
194+
* {@link FailureReason#CONTAINS_QUOTED_IDENTIFIER}.
143195
*
144196
* <p>For example, {@code "John Smith <[email protected]>"} would be invalid.
145197
*
146198
* @return the new {@code EmailValidator} instance
147199
*/
148200
public EmailValidator disallowQuotedIdentifiers() {
149-
return withRule(DISALLOW_QUOTED_IDENTIFIERS);
201+
return withRule(
202+
DISALLOW_QUOTED_IDENTIFIERS_PREDICATE,
203+
FailureReason.CONTAINS_QUOTED_IDENTIFIER);
150204
}
151205

152206
/**
153207
* Create a new {@code EmailValidator} with all rules from the current instance and the
154208
* {@link ValidationRules#disallowReservedDomains(Email)} rule.
155-
* Email addresses that have a reserved domain according to RFC 2606 will fail validation.
209+
* Email addresses that have a reserved domain according to RFC 2606 will fail validation
210+
* with {@link FailureReason#CONTAINS_RESERVED_DOMAIN}.
156211
*
157212
* <p>For example, {@code "[email protected]"} would be invalid.
158213
*
159214
* @return the new {@code EmailValidator} instance
160215
*/
161216
public EmailValidator disallowReservedDomains() {
162-
return withRule(DISALLOW_RESERVED_DOMAINS_PREDICATE);
217+
return withRule(
218+
DISALLOW_RESERVED_DOMAINS_PREDICATE,
219+
FailureReason.CONTAINS_RESERVED_DOMAIN);
163220
}
164221

165222
/**
166223
* Create a new {@code EmailValidator} with all rules from the current instance and the
167-
* {@link ValidationRules#requireOnlyTopLevelDomains(Email, Set)} rule.
224+
* {@link ValidationRules#requireOnlyTopLevelDomains(Email, java.util.Set)} rule.
168225
* Email addresses that have top level domains other than those provided will
169-
* fail validation.
226+
* fail validation with {@link FailureReason#INVALID_TOP_LEVEL_DOMAIN}.
170227
*
171228
* <p>For example, if you require only {@link TopLevelDomain#DOT_COM}, the email address
172229
* {@code "[email protected]"} would be invalid.
@@ -175,28 +232,33 @@ public EmailValidator disallowReservedDomains() {
175232
* @return the new {@code EmailValidator} instance
176233
*/
177234
public EmailValidator requireOnlyTopLevelDomains(TopLevelDomain... allowed) {
178-
return withRule(email -> ValidationRules.requireOnlyTopLevelDomains(
179-
email, Arrays.stream(allowed).collect(Collectors.toSet())));
235+
return withRule(
236+
email -> ValidationRules.requireOnlyTopLevelDomains(
237+
email, Arrays.stream(allowed).collect(Collectors.toSet())),
238+
FailureReason.INVALID_TOP_LEVEL_DOMAIN);
180239
}
181240

182241
/**
183242
* Create a new {@code EmailValidator} with all rules from the current instance and the
184243
* {@link ValidationRules#disallowObsoleteWhitespace(Email)} rule.
185244
* Email addresses that have obsolete folding whitespace according to RFC 2822 will fail
186-
* validation.
245+
* validation with {@link FailureReason#CONTAINS_OBSOLETE_WHITESPACE}.
187246
*
188247
* <p>For example, {@code "1234 @ local(blah) .com"} would be invalid.
189248
*
190249
* @return the new {@code EmailValidator} instance
191250
*/
192251
public EmailValidator disallowObsoleteWhitespace() {
193-
return withRule(DISALLOW_OBSOLETE_WHITESPACE_PREDICATE);
252+
return withRule(
253+
DISALLOW_OBSOLETE_WHITESPACE_PREDICATE,
254+
FailureReason.CONTAINS_OBSOLETE_WHITESPACE);
194255
}
195256

196257
/**
197258
* Create a new {@code EmailValidator} with all rules from the current instance and the
198259
* {@link ValidationRules#requireValidMXRecord(Email)} rule.
199-
* Email addresses that have a domain without a valid MX record will fail validation.
260+
* Email addresses that have a domain without a valid MX record will fail validation with
261+
* {@link FailureReason#INVALID_MX_RECORD}.
200262
*
201263
* <p><strong>NOTE: Adding this rule to your EmailValidator may increase
202264
* the amount of time it takes to validate email addresses, as the default initial timeout is
@@ -206,13 +268,16 @@ public EmailValidator disallowObsoleteWhitespace() {
206268
* @return the new {@code EmailValidator} instance
207269
*/
208270
public EmailValidator requireValidMXRecord() {
209-
return withRule(REQUIRE_VALID_MX_RECORD_PREDICATE);
271+
return withRule(
272+
REQUIRE_VALID_MX_RECORD_PREDICATE,
273+
FailureReason.INVALID_MX_RECORD);
210274
}
211275

212276
/**
213277
* Create a new {@code EmailValidator} with all rules from the current instance and the
214278
* {@link ValidationRules#requireValidMXRecord(Email, int, int)} rule.
215-
* Email addresses that have a domain without a valid MX record will fail validation.
279+
* Email addresses that have a domain without a valid MX record will fail validation with
280+
* {@link FailureReason#INVALID_MX_RECORD}.
216281
*
217282
* <p>This method allows you to customize the timeout and retries for performing DNS lookups.
218283
* The initial timeout is supplied in milliseconds, and the number of retries indicate how many
@@ -224,22 +289,25 @@ public EmailValidator requireValidMXRecord() {
224289
* @return the new {@code EmailValidator} instance
225290
*/
226291
public EmailValidator requireValidMXRecord(int initialTimeout, int numRetries) {
227-
return withRule(email ->
228-
ValidationRules.requireValidMXRecord(email, initialTimeout, numRetries));
292+
return withRule(
293+
email -> ValidationRules.requireValidMXRecord(email, initialTimeout, numRetries),
294+
FailureReason.INVALID_MX_RECORD);
229295
}
230296

231297
/**
232298
* Create a new {@code EmailValidator} with all rules from the current instance and the
233299
* {@link ValidationRules#requireAscii(Email)} rule.
234300
* Email addresses that contain characters other than those in the ASCII charset will fail
235-
* validation.
301+
* validation with {@link FailureReason#NON_ASCII_ADDRESS}
236302
*
237303
* <p>For example, {@code "jø[email protected]"} would be invalid.
238304
*
239305
* @return the new {@code EmailValidator} instance
240306
*/
241307
public EmailValidator requireAscii() {
242-
return withRule(REQUIRE_ASCII_PREDICATE);
308+
return withRule(
309+
REQUIRE_ASCII_PREDICATE,
310+
FailureReason.NON_ASCII_ADDRESS);
243311
}
244312

245313
/**
@@ -252,7 +320,7 @@ public EmailValidator requireAscii() {
252320
*/
253321
public boolean isValid(String email) {
254322
return JMail.tryParse(email)
255-
.filter(this::passesPredicates)
323+
.filter(e -> !testPredicates(e).isPresent())
256324
.isPresent();
257325
}
258326

@@ -298,12 +366,10 @@ public EmailValidationResult validate(String email) {
298366
// If failed basic validation, just return it
299367
if (!result.getEmail().isPresent()) return result;
300368

301-
// If the address fails custom validation, return failure
302-
if (!passesPredicates(result.getEmail().get())) {
303-
return EmailValidationResult.failure(FailureReason.FAILED_CUSTOM_VALIDATION);
304-
}
305-
306-
return result;
369+
// If the address fails custom validation, return failure, otherwise return the original result
370+
return testPredicates(result.getEmail().get())
371+
.map(EmailValidationResult::failure)
372+
.orElse(result);
307373
}
308374

309375
/**
@@ -316,7 +382,7 @@ public EmailValidationResult validate(String email) {
316382
* is invalid according to all registered validation rules
317383
*/
318384
public Optional<Email> tryParse(String email) {
319-
return JMail.tryParse(email).filter(this::passesPredicates);
385+
return JMail.tryParse(email).filter(e -> !testPredicates(e).isPresent());
320386
}
321387

322388
/**
@@ -325,9 +391,11 @@ public Optional<Email> tryParse(String email) {
325391
* @param email the email address to test
326392
* @return true if it passes the predicates, false otherwise
327393
*/
328-
private boolean passesPredicates(Email email) {
329-
return validationPredicates.stream()
330-
.allMatch(rule -> rule.test(email));
394+
private Optional<FailureReason> testPredicates(Email email) {
395+
return validationPredicates.entrySet().stream()
396+
.filter(entry -> !entry.getKey().test(email))
397+
.findFirst()
398+
.map(Map.Entry::getValue);
331399
}
332400

333401
@Override

0 commit comments

Comments
 (0)