From f411a61a68a7196aa8c6eb95765625e2ebc273f1 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 9 Oct 2026 21:01:59 +0000 Subject: [PATCH 1/4] Add email verification inputs Add verification_time and was_verification_successful to withEmail(), as array keys or named arguments. These describe the most recent verification of the email address. As an array value, the success flag must be a boolean, even with input validation disabled. With input validation enabled, the time must be an RFC 3339 date-time. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ README.md | 4 +- src/MinFraud.php | 45 ++++++++-- tests/MaxMind/Test/MinFraudTest.php | 115 +++++++++++++++++++++++++- tests/data/minfraud/full-request.json | 4 +- 5 files changed, 160 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c7e24..c97e87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,11 @@ CHANGELOG `phoneWasVerificationSuccessful`, and `phoneVerificationTime` named arguments. Omit `phone_was_verification_successful` if no verification was attempted. +* Added the `verification_time` and `was_verification_successful` inputs to + the `/email` object. These describe the most recent verification of the + email address. Use them with `withEmail()` as array keys or as the + `verificationTime` and `wasVerificationSuccessful` named arguments. Omit + `was_verification_successful` if no verification was attempted. 3.7.0 (2026-07-21) ------------------ diff --git a/README.md b/README.md index 444ced1..d94d771 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,9 @@ $request = $mf->withDevice( usernameMd5: '4f9726678c438914fa04bdb8c1a24088' )->withEmail( address: 'test@maxmind.com', - domain: 'maxmind.com' + domain: 'maxmind.com', + verificationTime: '2026-10-01T14:30:00Z', + wasVerificationSuccessful: true )->withBilling( firstName: 'First', lastName: 'Last', diff --git a/src/MinFraud.php b/src/MinFraud.php index 2bf4287..b48522f 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -487,14 +487,26 @@ public function withAccount( * @link https://dev.maxmind.com/minfraud/api-documentation/requests/?lang=en#schema--request--email * minFraud email API docs * - * @param array $values An array of email data. The keys are the same as - * the JSON keys. You may use either this or the named - * arguments, but not both. - * @param string|null $address The email address used in the transaction. - * This field must be a valid email address. - * @param string|null $domain The domain of the email address used in the - * transaction. Do not include the `@` in this - * field. + * @param array $values An array of email data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $address The email address used in the transaction. + * This field must be a valid email address. + * @param string|null $domain The domain of the email address used in the + * transaction. Do not include the `@` in this + * field. + * @param string|null $verificationTime The date and time of the most recent + * attempt to verify the email address, if + * known, in RFC 3339 date-time format. The + * time must not be before + * 1970-01-01T00:00:00Z or more than two days + * in the future. + * @param bool|null $wasVerificationSuccessful Whether the most recent verification + * that the end user controls the email + * address succeeded, for example by + * confirming a link sent to the address. + * Omit this if no verification was + * attempted. * * @throws InvalidInputException if a value has the wrong type, or if * input validation is enabled and a value is @@ -507,6 +519,8 @@ public function withEmail( array $values = [], ?string $address = null, ?string $domain = null, + ?string $verificationTime = null, + ?bool $wasVerificationSuccessful = null, ): self { if (\count($values) !== 0) { if (\func_num_args() !== 1) { @@ -516,6 +530,12 @@ public function withEmail( } $address = $this->remove($values, 'address'); $domain = $this->remove($values, 'domain'); + $verificationTime = $this->remove($values, 'verification_time'); + $wasVerificationSuccessful = $this->remove( + $values, + 'was_verification_successful', + ['boolean'], + ); $this->verifyEmpty($values); } @@ -535,6 +555,15 @@ public function withEmail( $values['domain'] = $domain; } + if ($verificationTime !== null) { + $this->verifyRfc3339DateTime($verificationTime); + $values['verification_time'] = $verificationTime; + } + + if ($wasVerificationSuccessful !== null) { + $values['was_verification_successful'] = $wasVerificationSuccessful; + } + $new = clone $this; $new->content['email'] = $values; diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 5a2d1ae..e04d9d6 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -98,6 +98,7 @@ public function testWrongTypeWithoutValidation(string $method, string $field): v 'phone_was_verification_successful', 'was_3d_secure_successful', 'was_authorized', + 'was_verification_successful', ]; if (\in_array($field, $booleans, true)) { $values[] = 'true'; @@ -141,7 +142,7 @@ public static function allFields(): array 'withDevice' => [ 'accept_language', 'ip_address', 'session_age', 'session_id', 'tracking_token', 'user_agent', ], - 'withEmail' => ['address', 'domain'], + 'withEmail' => ['address', 'domain', 'verification_time', 'was_verification_successful'], 'withEvent' => ['party', 'shop_id', 'time', 'transaction_id', 'type'], 'withOrder' => [ 'affiliate_id', 'amount', 'currency', 'discount_code', 'has_gift_message', 'is_gift', @@ -342,7 +343,11 @@ public static function invalidValues(): array 'token' => '4111111111111111', ]], 'withDevice' => ['device', ['ip_address' => 'not an IP', 'session_age' => -1.5, 'session_id' => '']], - 'withEmail' => ['email', ['address' => 'not an email', 'domain' => 'not a domain']], + 'withEmail' => ['email', [ + 'address' => 'not an email', + 'domain' => 'not a domain', + 'verification_time' => 'not a time', + ]], 'withEvent' => ['event', ['party' => 'other', 'time' => 'not a time', 'type' => 'other']], 'withOrder' => ['order', ['amount' => -1.5, 'currency' => 'usd', 'referrer_uri' => 'not a URL']], 'withPayment' => ['payment', ['method' => 'other', 'processor' => 'other']], @@ -481,7 +486,9 @@ public function testFullInsightsRequestUsingNamedArgs(string $class, string $ser ) ->withEmail( address: '977577b140bfb7c516e4746204fbdb01', - domain: 'maxmind.com' + domain: 'maxmind.com', + verificationTime: '2026-10-01T14:30:00Z', + wasVerificationSuccessful: true ) ->withEvent( party: 'customer', @@ -1208,6 +1215,108 @@ public function testBadShippingPhoneWasVerificationSuccessful(): void )->withShipping(['phone_was_verification_successful' => 'true']); } + /** + * @dataProvider goodTimes + */ + public function testGoodEmailVerificationTimes(string $time): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withEmail(['verification_time' => $time]); + + $this->assertSame( + $time, + $result->jsonSerialize()['content']['email']['verification_time'], + ); + } + + /** + * @dataProvider badTimes + */ + public function testBadEmailVerificationTime(string $time): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('valid RFC 3339'); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withEmail(['verification_time' => $time]); + } + + public function testEmailWasVerificationSuccessfulFalse(): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withEmail(wasVerificationSuccessful: false); + + $this->assertFalse( + $result->jsonSerialize()['content']['email']['was_verification_successful'], + ); + } + + public function testBadEmailWasVerificationSuccessful(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage( + 'Expected was_verification_successful to be in [boolean] but was string', + ); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withEmail(['was_verification_successful' => 'true']); + } + + public function testEmailWasVerificationSuccessfulFalseArray(): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withEmail(['was_verification_successful' => false]); + + $this->assertFalse( + $result->jsonSerialize()['content']['email']['was_verification_successful'], + ); + } + + public function testEmailWasVerificationSuccessfulFalseWith(): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->with(['email' => ['was_verification_successful' => false]]); + + $this->assertFalse( + $result->jsonSerialize()['content']['email']['was_verification_successful'], + ); + } + + public function testEmailVerificationWithHashing(): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0, + ['hashEmail' => true], + )->withEmail([ + 'address' => 'test@gmail.com', + 'verification_time' => '2026-10-01T14:30:00Z', + 'was_verification_successful' => false, + ]); + + $this->assertSame( + [ + 'address' => '1aedb8d9dc4751e229a335e371db8058', + 'verification_time' => '2026-10-01T14:30:00Z', + 'was_verification_successful' => false, + 'domain' => 'gmail.com', + ], + $result->jsonSerialize()['content']['email'], + ); + } + /** * @dataProvider badIins */ diff --git a/tests/data/minfraud/full-request.json b/tests/data/minfraud/full-request.json index 837ddad..c199157 100644 --- a/tests/data/minfraud/full-request.json +++ b/tests/data/minfraud/full-request.json @@ -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", From 898a1e19013242f174a455fa3f625e3bfe6bd5a5 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 26 Oct 2026 18:46:37 +0000 Subject: [PATCH 2/4] Remove duplicate email verification test The with() method passes the email array to withEmail(), so this test ran the same code as testEmailWasVerificationSuccessfulFalseArray(). Co-Authored-By: Claude Opus 5.5 --- tests/MaxMind/Test/MinFraudTest.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index e04d9d6..a523ea8 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -1282,18 +1282,6 @@ public function testEmailWasVerificationSuccessfulFalseArray(): void ); } - public function testEmailWasVerificationSuccessfulFalseWith(): void - { - $result = $this->createMinFraudRequestWithFullResponse( - 'insights', - 0 - )->with(['email' => ['was_verification_successful' => false]]); - - $this->assertFalse( - $result->jsonSerialize()['content']['email']['was_verification_successful'], - ); - } - public function testEmailVerificationWithHashing(): void { $result = $this->createMinFraudRequestWithFullResponse( From f6695a7335122a725013a87bca5e28a21d281b50 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 26 Oct 2026 18:46:56 +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.md | 2 +- tests/MaxMind/Test/MinFraudTest.php | 2 +- tests/data/minfraud/full-request.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d94d771..56ba638 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ $request = $mf->withDevice( party: 'customer', transactionId: 'txn3134133', shopId: 's2123', - time: '2012-04-12T23:20:50+00:00', + time: '2026-10-05T23:20:50+00:00', type: 'purchase' )->withAccount( userId: 3132, diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index a523ea8..343c5d1 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -494,7 +494,7 @@ public function testFullInsightsRequestUsingNamedArgs(string $class, string $ser party: 'customer', transactionId: 'txn3134133', shopId: 's2123', - time: '2014-04-12T23:20:50+00:00', + time: '2026-10-05T23:20:50+00:00', type: 'purchase' ) ->withOrder( diff --git a/tests/data/minfraud/full-request.json b/tests/data/minfraud/full-request.json index c199157..6c7cbdf 100644 --- a/tests/data/minfraud/full-request.json +++ b/tests/data/minfraud/full-request.json @@ -3,7 +3,7 @@ "party": "customer", "transaction_id": "txn3134133", "shop_id": "s2123", - "time": "2014-04-12T23:20:50+00:00", + "time": "2026-10-05T23:20:50+00:00", "type": "purchase" }, "account": { From 7bcc4f0a1771eb190cacfc0b4d9204958f19191f Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 26 Oct 2026 20:13:39 +0000 Subject: [PATCH 4/4] Share the time validation tests with a data provider The billing phone, shipping phone, email, and event time tests repeated the same good and bad pairs. Use one test pair with a provider that crosses each field with goodTimes() and badTimes(). Co-Authored-By: Claude Opus 5.5 --- tests/MaxMind/Test/MinFraudTest.php | 159 +++++++++++----------------- 1 file changed, 60 insertions(+), 99 deletions(-) diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 343c5d1..13d1a14 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -1067,36 +1067,6 @@ public function testBadBillingPhoneVerificationMethod(): void )->withBilling(['phone_verification_method' => 'sms']); } - /** - * @dataProvider goodTimes - */ - public function testGoodBillingPhoneVerificationTimes(string $time): void - { - $result = $this->createMinFraudRequestWithFullResponse( - 'insights', - 0 - )->withBilling(['phone_verification_time' => $time]); - - $this->assertSame( - $time, - $result->jsonSerialize()['content']['billing']['phone_verification_time'], - ); - } - - /** - * @dataProvider badTimes - */ - public function testBadBillingPhoneVerificationTime(string $time): void - { - $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('valid RFC 3339'); - - $this->createMinFraudRequestWithFullResponse( - 'insights', - 0 - )->withBilling(['phone_verification_time' => $time]); - } - public function testBillingPhoneWasVerificationSuccessfulFalse(): void { $result = $this->createMinFraudRequestWithFullResponse( @@ -1149,36 +1119,6 @@ public function testBadShippingPhoneVerificationMethod(): void )->withShipping(['phone_verification_method' => 'sms']); } - /** - * @dataProvider goodTimes - */ - public function testGoodShippingPhoneVerificationTimes(string $time): void - { - $result = $this->createMinFraudRequestWithFullResponse( - 'insights', - 0 - )->withShipping(['phone_verification_time' => $time]); - - $this->assertSame( - $time, - $result->jsonSerialize()['content']['shipping']['phone_verification_time'], - ); - } - - /** - * @dataProvider badTimes - */ - public function testBadShippingPhoneVerificationTime(string $time): void - { - $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('valid RFC 3339'); - - $this->createMinFraudRequestWithFullResponse( - 'insights', - 0 - )->withShipping(['phone_verification_time' => $time]); - } - public function testShippingPhoneWasVerificationSuccessfulFalse(): void { $result = $this->createMinFraudRequestWithFullResponse( @@ -1215,36 +1155,6 @@ public function testBadShippingPhoneWasVerificationSuccessful(): void )->withShipping(['phone_was_verification_successful' => 'true']); } - /** - * @dataProvider goodTimes - */ - public function testGoodEmailVerificationTimes(string $time): void - { - $result = $this->createMinFraudRequestWithFullResponse( - 'insights', - 0 - )->withEmail(['verification_time' => $time]); - - $this->assertSame( - $time, - $result->jsonSerialize()['content']['email']['verification_time'], - ); - } - - /** - * @dataProvider badTimes - */ - public function testBadEmailVerificationTime(string $time): void - { - $this->expectException(InvalidInputException::class); - $this->expectExceptionMessage('valid RFC 3339'); - - $this->createMinFraudRequestWithFullResponse( - 'insights', - 0 - )->withEmail(['verification_time' => $time]); - } - public function testEmailWasVerificationSuccessfulFalse(): void { $result = $this->createMinFraudRequestWithFullResponse( @@ -1714,16 +1624,31 @@ public static function goodDomains(): array } /** - * @dataProvider goodTimes + * @dataProvider goodTimeInputs */ - public function testGoodEventTimes(string $time): void - { + public function testGoodTimes( + string $method, + string $section, + string $field, + string $time, + ): void { $result = $this->createMinFraudRequestWithFullResponse( 'insights', 0 - )->withEvent(['time' => $time]); + )->{$method}([$field => $time]); - $this->assertSame($time, $result->jsonSerialize()['content']['event']['time']); + $this->assertSame( + $time, + $result->jsonSerialize()['content'][$section][$field], + ); + } + + /** + * @return array> + */ + public static function goodTimeInputs(): array + { + return self::timeInputs(self::goodTimes()); } /** @@ -1754,17 +1679,29 @@ public static function goodTimes(): array } /** - * @dataProvider badTimes + * @dataProvider badTimeInputs */ - public function testBadEventTime(string $time): void - { + public function testBadTime( + string $method, + string $section, + string $field, + string $time, + ): void { $this->expectException(InvalidInputException::class); $this->expectExceptionMessage('valid RFC 3339'); $this->createMinFraudRequestWithFullResponse( 'insights', 0 - )->withEvent(['time' => $time]); + )->{$method}([$field => $time]); + } + + /** + * @return array> + */ + public static function badTimeInputs(): array + { + return self::timeInputs(self::badTimes()); } /** @@ -1803,6 +1740,30 @@ public static function badTimes(): array ]; } + /** + * @param array> $times + * + * @return array> + */ + private static function timeInputs(array $times): array + { + $fields = [ + ['withBilling', 'billing', 'phone_verification_time'], + ['withShipping', 'shipping', 'phone_verification_time'], + ['withEmail', 'email', 'verification_time'], + ['withEvent', 'event', 'time'], + ]; + + $tests = []; + foreach ($fields as [$method, $section, $field]) { + foreach ($times as [$time]) { + $tests["{$section} {$field} {$time}"] = [$method, $section, $field, $time]; + } + } + + return $tests; + } + /** * @dataProvider goodEventTypes */