From 1013a4a803352fbed3961f5b0bd00fa72ccf8ca5 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 9 Oct 2026 21:00:01 +0000 Subject: [PATCH 1/4] Add email verification inputs Add the verification_time and was_verification_successful inputs to the email object. They describe the most recent verification of the email address. Validation accepts a boolean for the result and an RFC 3339 date-time for the time. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 3 +++ README.rst | 4 +++- src/minfraud/validation.py | 2 ++ tests/data/full-transaction-request.json | 4 +++- tests/test_validation.py | 13 +++++++++++++ tests/test_webservice.py | 10 +++++++++- 6 files changed, 33 insertions(+), 3 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 139f21b1..315c2421 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -10,6 +10,9 @@ History ``phone_was_verification_successful``, and ``phone_verification_time`` inputs to the ``/billing`` and ``/shipping`` request objects. They describe the most recent verification of the phone number. +* Added the ``verification_time`` and ``was_verification_successful`` inputs + to the ``/email`` request object. They describe the most recent + verification of the email address. * The RFC 3339 check on ``/event/time`` and ``phone_verification_time`` now rejects more values that the web service rejects, such as February 30, hour 24, an offset of 24 hours or more, and a lowercase ``t`` or ``z``. diff --git a/README.rst b/README.rst index 961ec22f..bcc147f1 100644 --- a/README.rst +++ b/README.rst @@ -169,7 +169,9 @@ Score, Insights and Factors Example >>> }, >>> 'email': { >>> 'address': '977577b140bfb7c516e4746204fbdb01', - >>> 'domain': 'maxmind.com' + >>> 'domain': 'maxmind.com', + >>> 'verification_time': '2026-10-01T14:25:00Z', + >>> 'was_verification_successful': True, >>> }, >>> 'billing': { >>> 'first_name': 'Jane', diff --git a/src/minfraud/validation.py b/src/minfraud/validation.py index 59b5c399..821915df 100644 --- a/src/minfraud/validation.py +++ b/src/minfraud/validation.py @@ -393,6 +393,8 @@ def _uri(s: str) -> str: "email": { "address": _email_or_md5, "domain": _hostname, + "verification_time": _rfc3339_datetime, + "was_verification_successful": bool, }, "event": { "party": _event_party, diff --git a/tests/data/full-transaction-request.json b/tests/data/full-transaction-request.json index 985d3333..e9c58742 100644 --- a/tests/data/full-transaction-request.json +++ b/tests/data/full-transaction-request.json @@ -12,7 +12,9 @@ }, "email": { "address": "977577b140bfb7c516e4746204fbdb01", - "domain": "maxmind.com" + "domain": "maxmind.com", + "verification_time": "2026-10-01T14:25:00Z", + "was_verification_successful": true }, "billing": { "first_name": "First", diff --git a/tests/test_validation.py b/tests/test_validation.py index 32070854..d526cb71 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -371,6 +371,19 @@ def test_domain(self) -> None: for bad in ("bad ", " bad.com"): self.check_invalid_transaction({"email": {"domain": bad}}) + def test_verification_time(self) -> None: + for good in GOOD_TIMES: + self.check_transaction({"email": {"verification_time": good}}) + for bad in BAD_TIMES: + self.check_invalid_transaction({"email": {"verification_time": bad}}) + + def test_verification_time_not_string(self) -> None: + for bad in NON_STRING_TIMES: + self.check_invalid_transaction({"email": {"verification_time": bad}}) + + def test_was_verification_successful(self) -> None: + self.check_bool("email", "was_verification_successful") + class TestEvent(ValidationBase, unittest.TestCase): def test_party(self) -> None: diff --git a/tests/test_webservice.py b/tests/test_webservice.py index d9496422..f55f7b55 100644 --- a/tests/test_webservice.py +++ b/tests/test_webservice.py @@ -281,6 +281,8 @@ def test_200_with_email_hashing(self) -> None: "email": { "address": "977577b140bfb7c516e4746204fbdb01", "domain": "maxmind.com", + "verification_time": "2026-10-01T14:25:00Z", + "was_verification_successful": False, }, }, ).respond_with_data( @@ -292,7 +294,13 @@ def test_200_with_email_hashing(self) -> None: status=200, ) - request = {"email": {"address": "Test+ignore@maxmind.com"}} + request = { + "email": { + "address": "Test+ignore@maxmind.com", + "verification_time": "2026-10-01T14:25:00Z", + "was_verification_successful": False, + }, + } self.run_client(getattr(self.client, self.type)(request, hash_email=True)) # This was fixed in https://github.com/maxmind/minfraud-api-python/pull/78 From 463ab2c90afb6e96bf459274b2974278868d27c1 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 26 Oct 2026 18:47:13 +0000 Subject: [PATCH 2/4] Test that email hashing keeps verification inputs Co-Authored-By: Claude Opus 5.5 --- tests/test_request.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_request.py b/tests/test_request.py index a2d0e284..61ae8ab3 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -63,6 +63,24 @@ def test_maybe_hash_email(self) -> None: }, }, }, + { + "name": "verification inputs kept", + "input": { + "email": { + "address": "test@maxmind.com", + "verification_time": "2026-10-01T14:25:00Z", + "was_verification_successful": False, + }, + }, + "expected": { + "email": { + "address": "977577b140bfb7c516e4746204fbdb01", + "domain": "maxmind.com", + "verification_time": "2026-10-01T14:25:00Z", + "was_verification_successful": False, + }, + }, + }, { "name": "uppercase and alias", "input": {"email": {"address": "Test+ignored@MaxMind.com"}}, From 414516442b2561033e50e9c3e22c0d2cd74686d1 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 26 Oct 2026 18:47:13 +0000 Subject: [PATCH 3/4] Use a recent event time in examples The example event time was years before the example verification times, so the most recent verification came after the event. The web service also ignores an event time older than one year. Co-Authored-By: Claude Opus 5.5 --- README.rst | 2 +- tests/data/full-transaction-request.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index bcc147f1..e5c675ed 100644 --- a/README.rst +++ b/README.rst @@ -161,7 +161,7 @@ Score, Insights and Factors Example >>> 'shop_id': 's2123', >>> 'type': 'purchase', >>> 'transaction_id': 'txn3134133', - >>> 'time': '2014-04-12T23:20:50.052+00:00' + >>> 'time': '2026-10-05T23:20:50.052+00:00' >>> }, >>> 'account': { >>> 'user_id': '3132', diff --git a/tests/data/full-transaction-request.json b/tests/data/full-transaction-request.json index e9c58742..b5073f86 100644 --- a/tests/data/full-transaction-request.json +++ b/tests/data/full-transaction-request.json @@ -3,7 +3,7 @@ "party": "customer", "transaction_id": "txn3134133", "shop_id": "s2123", - "time": "2014-04-12T23:20:50.052+00:00", + "time": "2026-10-05T23:20:50.052+00:00", "type": "purchase" }, "account": { From 0b20a07ff8472093689f2dfd7f0d252e136fe608 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 26 Oct 2026 20:13:04 +0000 Subject: [PATCH 4/4] Share the RFC 3339 time validation tests The event, phone, and email verification time tests repeated the same loops. Use one check_rfc3339_time helper, next to check_bool, for all three fields. Co-Authored-By: Claude Opus 5.5 --- tests/test_validation.py | 37 +++++++++---------------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/tests/test_validation.py b/tests/test_validation.py index d526cb71..6b1c1e65 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -82,6 +82,12 @@ def check_bool(self, obj: str, key: str) -> None: for bad in ("", 0, "True"): self.check_invalid_transaction({obj: {key: bad}}) + def check_rfc3339_time(self, obj: str, key: str) -> None: + for good in GOOD_TIMES: + self.check_transaction({obj: {key: good}}) + for bad in BAD_TIMES + NON_STRING_TIMES: + self.check_invalid_transaction({obj: {key: bad}}) + def setup_report(self, report: dict[str, Any]) -> None: if "ip_address" not in report: report["ip_address"] = "1.2.3.4" @@ -186,18 +192,7 @@ def test_phone_was_verification_successful(self) -> None: self.check_bool(self.type, "phone_was_verification_successful") def test_phone_verification_time(self) -> None: - for good in GOOD_TIMES: - self.check_transaction({self.type: {"phone_verification_time": good}}) - for bad in BAD_TIMES: - self.check_invalid_transaction( - {self.type: {"phone_verification_time": bad}}, - ) - - def test_phone_verification_time_not_string(self) -> None: - for bad in NON_STRING_TIMES: - self.check_invalid_transaction( - {self.type: {"phone_verification_time": bad}}, - ) + self.check_rfc3339_time(self.type, "phone_verification_time") class TestBillingAddress(AddressBase): @@ -372,14 +367,7 @@ def test_domain(self) -> None: self.check_invalid_transaction({"email": {"domain": bad}}) def test_verification_time(self) -> None: - for good in GOOD_TIMES: - self.check_transaction({"email": {"verification_time": good}}) - for bad in BAD_TIMES: - self.check_invalid_transaction({"email": {"verification_time": bad}}) - - def test_verification_time_not_string(self) -> None: - for bad in NON_STRING_TIMES: - self.check_invalid_transaction({"email": {"verification_time": bad}}) + self.check_rfc3339_time("email", "verification_time") def test_was_verification_successful(self) -> None: self.check_bool("email", "was_verification_successful") @@ -399,14 +387,7 @@ def test_shop_id(self) -> None: self.check_transaction_str_type("event", "shop_id") def test_time(self) -> None: - for good in GOOD_TIMES: - self.check_transaction({"event": {"time": good}}) - for bad in BAD_TIMES: - self.check_invalid_transaction({"event": {"time": bad}}) - - def test_time_not_string(self) -> None: - for bad in NON_STRING_TIMES: - self.check_invalid_transaction({"event": {"time": bad}}) + self.check_rfc3339_time("event", "time") def test_time_impossible_date_message(self) -> None: transaction: dict[str, Any] = {"event": {"time": "2026-02-30T10:00:00Z"}}