Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ CHANGELOG
`DELIVERED_CODE`, `NETWORK`, and `OTHER`. Do not set
`phoneVerificationMethod` or `phoneWasVerificationSuccessful` if no
verification was attempted.
* Added `verificationTime` and `wasVerificationSuccessful` to the `Email`
request object. These describe the most recent verification of the email
address. Do not set `wasVerificationSuccessful` if no verification was
attempted.
* Fixed enum serialization, enum deserialization, and email address
hashing in locales that change case conversion, such as Turkish (`tr`) and
Azerbaijani (`az`). In those locales, some enum values serialized with a
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ Transaction request = new Transaction.Builder(
new Email.Builder()
.address("fraud@ster.com")
.domain("ster.com")
.verificationTime(ZonedDateTime.parse("2026-10-01T14:30:00Z"))
.wasVerificationSuccessful(true)
.build()
).event(
new Event.Builder()
Expand Down
48 changes: 48 additions & 0 deletions src/main/java/com/maxmind/minfraud/request/Email.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.Normalizer;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
Expand All @@ -22,6 +23,8 @@ public final class Email extends AbstractModel {
private final String address;
private final boolean hashAddress;
private final String domain;
private final ZonedDateTime verificationTime;
private final Boolean wasVerificationSuccessful;
private static final Map<String, String> typoDomains;
private static final Map<String, String> typoTlds;
private static final Map<String, String> equivalentDomains;
Expand Down Expand Up @@ -299,6 +302,8 @@ private Email(Email.Builder builder) {
address = builder.address;
hashAddress = builder.hashAddress;
domain = builder.domain;
verificationTime = builder.verificationTime;
wasVerificationSuccessful = builder.wasVerificationSuccessful;
}

/**
Expand All @@ -309,6 +314,8 @@ public static final class Builder {
private String address;
private boolean hashAddress;
private String domain;
private ZonedDateTime verificationTime;
private Boolean wasVerificationSuccessful;

/**
* The constructor for the builder.
Expand Down Expand Up @@ -384,6 +391,30 @@ public Email.Builder domain(String domain) {
return this;
}

/**
* @param time The date and time of the most recent attempt to verify the email address,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the email and phone time docs now use different wording. This says "the most recent attempt to verify the email address, if known" (here and on line 608). phoneVerificationTime in AbstractLocation.java (lines 198 and 323) says "the most recent verification of the phone number". The changelog bullets (lines 9 and 15) also say "most recent verification".

The two inputs have the same meaning: the time also applies when wasVerificationSuccessful is false. The "attempt" wording here is more accurate, and it matches the dev-site text. The phone inputs are also unreleased (4.5.0), so the phone Javadoc and the changelog can use the same wording now.

馃 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given it's about phone, we can leave it

* if known. The time must not be before 1970-01-01T00:00:00Z or more than
* two days in the future. The web service ignores a time outside this range
* and returns a warning.
* @return The builder object.
*/
public Email.Builder verificationTime(ZonedDateTime time) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: a ZonedDateTime whose UTC offset has non-zero seconds serializes in a form that the web service rejects.

Jackson writes the offset as 卤HH:MM:SS. The server's mmtime.ParseRFC3339 regex accepts only Z or 卤HH:MM. I ran this on the PR build:

  • ZonedDateTime.of(1971, 6, 1, 10, 0, 0, 0, ZoneId.of("Africa/Monrovia")) gives "verification_time":"1971-06-01T10:00:00-00:44:30".
  • An offset of ZoneOffset.ofHoursMinutesSeconds(5, 30, 15) gives "+05:30:15".

Both times are in the valid range. The server returns an INPUT_INVALID warning and ignores the value.

This is rare in practice. Liberia used -00:44:30 until 1972, and other zones with second offsets are from before 1970. The same problem exists in code that this PR did not add: AbstractLocation.phoneVerificationTime and Event.time.

Suggested fix: fix it once in Mapper for all ZonedDateTime fields, not per field. For example, convert the value to UTC when its offset has seconds. Add a test for it.

馃 Comment by Claude Opus 5.5.

@oschwald oschwald Oct 26, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reply by Claude Opus 5.5 on Greg's behalf.

We will leave this for a follow-up, STF-2206. Event.time and phoneVerificationTime have the same problem, so the fix belongs in Mapper, outside this PR.

verificationTime = time;
return this;
}

/**
* @param wasSuccessful Whether the most recent verification that the end user controls
* the email address succeeded, for example by confirming a link
* sent to the address. Do not set this if no verification was
* attempted.
* @return The builder object.
*/
public Email.Builder wasVerificationSuccessful(Boolean wasSuccessful) {
wasVerificationSuccessful = wasSuccessful;
return this;
}

/**
* @return An instance of {@code Email} created from the fields set on this builder.
*/
Expand Down Expand Up @@ -572,4 +603,21 @@ private static boolean isValidDomainLabel(String label) {
public String domain() {
return domain;
}

/**
* @return The date and time of the most recent attempt to verify the email address.
*/
@JsonProperty("verification_time")
public ZonedDateTime verificationTime() {
return verificationTime;
}

/**
* @return Whether the most recent verification that the end user controls the email
* address succeeded.
*/
@JsonProperty("was_verification_successful")
public Boolean wasVerificationSuccessful() {
return wasVerificationSuccessful;
}
}
46 changes: 46 additions & 0 deletions src/test/java/com/maxmind/minfraud/request/EmailTest.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.maxmind.minfraud.request;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

Expand All @@ -10,11 +11,14 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.skyscreamer.jsonassert.JSONAssert;

public class EmailTest {

Expand Down Expand Up @@ -318,4 +322,46 @@ void testInvalidDomains(String invalidDomain) {
() -> new Builder().domain(invalidDomain).build()
);
}

@Test
public void testVerificationTime() {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
var time = ZonedDateTime.now();
var email = new Builder().verificationTime(time).build();
assertEquals(time, email.verificationTime());
}

@Test
public void testWasVerificationSuccessful() {
var email = new Builder().wasVerificationSuccessful(false).build();
assertFalse(email.wasVerificationSuccessful());
}

@Test
public void testVerificationSerialization() throws Exception {
var email = new Builder()
.verificationTime(ZonedDateTime.parse("2026-10-01T14:30:00Z"))
.wasVerificationSuccessful(false)
.build();

var expectedJSON = "{verification_time:'2026-10-01T14:30:00Z',"
+ "was_verification_successful:false}";
JSONAssert.assertEquals(expectedJSON, email.toJson(), true);
}

@Test
public void testVerificationTimeSerializationWithRegionZone() throws Exception {
var email = new Builder()
.verificationTime(
ZonedDateTime.of(2026, 10, 1, 10, 30, 0, 0, ZoneId.of("America/New_York")))
.build();

JSONAssert.assertEquals(
"{verification_time:'2026-10-01T10:30:00-04:00'}", email.toJson(), true);
}

@Test
public void testVerificationOmittedWhenUnset() throws Exception {
var email = new Builder().build();
JSONAssert.assertEquals("{}", email.toJson(), true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ public static Transaction fullTransaction() throws Exception {
return makeTransaction(new Email.Builder()
.address("test@maxmind.com")
.domain("maxmind.com")
.verificationTime(ZonedDateTime.parse("2026-10-01T14:30:00Z"))
.wasVerificationSuccessful(true)
.build());
}

Expand All @@ -42,6 +44,8 @@ public static Transaction fullTransactionEmailMd5() throws Exception {
.address("test@maxmind.com")
.hashAddress()
.domain("maxmind.com")
.verificationTime(ZonedDateTime.parse("2026-10-01T14:30:00Z"))
.wasVerificationSuccessful(true)
.build());
}

Expand All @@ -62,7 +66,7 @@ private static Transaction makeTransaction(Email e) throws Exception {
.party(Event.Party.CUSTOMER)
.transactionId("txn3134133")
.shopId("s2123")
.time(ZonedDateTime.parse("2012-04-12T23:20:50.52Z"))
.time(ZonedDateTime.parse("2026-10-05T23:20:50.52Z"))
.type(Event.Type.PURCHASE)
.build()
)
Expand Down
6 changes: 4 additions & 2 deletions src/test/resources/test-data/full-request-email-md5.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"party": "customer",
"transaction_id": "txn3134133",
"shop_id": "s2123",
"time": "2012-04-12T23:20:50.52Z",
"time": "2026-10-05T23:20:50.52Z",
"type": "purchase"
},
"account": {
Expand All @@ -12,7 +12,9 @@
},
"email": {
"address": "977577b140bfb7c516e4746204fbdb01",
"domain": "maxmind.com"
"domain": "maxmind.com",
"verification_time": "2026-10-01T14:30:00Z",
"was_verification_successful": true
},
"billing": {
"first_name": "First",
Expand Down
6 changes: 4 additions & 2 deletions src/test/resources/test-data/full-request.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"party": "customer",
"transaction_id": "txn3134133",
"shop_id": "s2123",
"time": "2012-04-12T23:20:50.52Z",
"time": "2026-10-05T23:20:50.52Z",
"type": "purchase"
},
"account": {
Expand All @@ -12,7 +12,9 @@
},
"email": {
"address": "test@maxmind.com",
"domain": "maxmind.com"
"domain": "maxmind.com",
"verification_time": "2026-10-01T14:30:00Z",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: in this fixture, verification_time (2026-10-01) is 14 years after the event time (2012-04-12, line 6). The "most recent verification" then comes after the transaction that it describes. full-request-email-md5.json (line 16) and RequestTestHelper.java (lines 37 and 47, event time on line 69) have the same mismatch.

This is test data only, so it has no effect on behavior. The README is correct, because it uses new Date() for the event time. The phone verification times in these fixtures have the same problem. They are not new in this PR.

Suggested fix: use a verification time before the event time, or move the event time to a recent date.

馃 Comment by Claude Opus 5.5.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reply by Claude Opus 5.5 on Greg's behalf.

Fixed in 7b97801. The test event time is now 2026-10-05, after every verification time. This also fixes the phone times in the fixtures.

"was_verification_successful": true
},
"billing": {
"first_name": "First",
Expand Down
Loading