From 3ab673a0eb5cc637edabefa374a7ff91592b19d3 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 9 Oct 2026 20:58:31 +0000 Subject: [PATCH 1/6] Add email verification inputs Add the verification_time and was_verification_successful attributes to Email. They describe the most recent verification of the email address. With validation on, the result must be a boolean and the time must be an RFC 3339 date-time. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++ README.md | 6 ++- lib/minfraud/components/email.rb | 28 ++++++++++-- spec/components/email_spec.rb | 74 +++++++++++++++++++++++++++++++- 4 files changed, 104 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ada6a10..1ba90f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ attributes to `Minfraud::Components::Billing` and `Minfraud::Components::Shipping`. They describe the most recent verification of the billing or shipping phone number. +* Added the `verification_time` and `was_verification_successful` + attributes to `Minfraud::Components::Email`. They describe the most + recent verification of the email address. * Boolean inputs are now always sent as JSON booleans, and `false` is no longer dropped. Previously, every input set to `false` was omitted. Now only `nil` inputs are omitted. In particular: diff --git a/README.md b/README.md index 90c2cd8..d80f416 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,10 @@ assessment = Minfraud::Assessments.new( username_md5: '4f9726678c438914fa04bdb8c1a24088', }, email: { - address: 'test@maxmind.com', - domain: 'maxmind.com', + address: 'test@maxmind.com', + domain: 'maxmind.com', + verification_time: '2026-10-01T14:30:00Z', + was_verification_successful: true, }, billing: { first_name: 'First', diff --git a/lib/minfraud/components/email.rb b/lib/minfraud/components/email.rb index 726c33f..2f52c13 100644 --- a/lib/minfraud/components/email.rb +++ b/lib/minfraud/components/email.rb @@ -31,12 +31,32 @@ class Email < Base # @return [Boolean, nil] attr_accessor :hash_address + # The date and time of the most recent attempt to verify the email + # address, if known. The string must be in the RFC 3339 date-time + # format, e.g., "2012-04-12T23:20:50.52Z". 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. + # + # @see https://datatracker.ietf.org/doc/html/rfc3339 + # + # @return [String, nil] + attr_accessor :verification_time + + # 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 include this field if no verification was attempted. + # + # @return [Boolean, nil] + attr_accessor :was_verification_successful + # @param params [Hash] Hash of parameters. Each key/value should # correspond to one of the available attributes. def initialize(params = {}) - @address = params[:address] - @domain = params[:domain] - @hash_address = params[:hash_address] + @address = params[:address] + @domain = params[:domain] + @hash_address = params[:hash_address] + @verification_time = params[:verification_time] + @was_verification_successful = params[:was_verification_successful] validate end @@ -72,6 +92,8 @@ def validate validate_email('email', @address) validate_string('domain', 255, @domain) + validate_rfc3339('verification_time', @verification_time) + validate_boolean('was_verification_successful', @was_verification_successful) end def hash_email_address(address) diff --git a/spec/components/email_spec.rb b/spec/components/email_spec.rb index d1297e9..5aa0d84 100644 --- a/spec/components/email_spec.rb +++ b/spec/components/email_spec.rb @@ -3,9 +3,45 @@ require 'spec_helper' describe Minfraud::Components::Email do + describe '#to_json' do + [true, false].each do |successful| + it "sends was_verification_successful #{successful} as a JSON boolean" do + email = described_class.new( + verification_time: '2026-10-01T14:30:00Z', + was_verification_successful: successful, + ) + expect(email.to_json).to eq( + 'verification_time' => '2026-10-01T14:30:00Z', + 'was_verification_successful' => successful, + ) + end + end + + it 'sends the verification inputs with a hashed address' do + email = described_class.new( + address: 'test@maxmind.com', + domain: 'maxmind.com', + hash_address: true, + verification_time: '2026-10-01T14:30:00Z', + was_verification_successful: true, + ) + expect(email.to_json).to eq( + 'address' => '977577b140bfb7c516e4746204fbdb01', + 'domain' => 'maxmind.com', + 'verification_time' => '2026-10-01T14:30:00Z', + 'was_verification_successful' => true, + ) + end + end + describe 'validation' do - before do + around do |example| + previous = Minfraud.enable_validation + Minfraud.configure { |c| c.enable_validation = 1 } + example.run + ensure + Minfraud.configure { |c| c.enable_validation = previous } end it 'raises an exception for an invalid email address' do @@ -16,9 +52,43 @@ end.to raise_exception(Minfraud::InvalidInputError) end + it 'raises an exception for an invalid verification_time' do + expect do + described_class.new( + verification_time: '2026-10-01 14:30:00', + ) + end.to raise_exception(Minfraud::InvalidInputError) + end + + it 'raises an exception for a non-string verification_time' do + expect do + described_class.new( + verification_time: true, + ) + end.to raise_exception(Minfraud::InvalidInputError) + end + + it 'raises an exception for an invalid was_verification_successful' do + expect do + described_class.new( + was_verification_successful: 'true', + ) + end.to raise_exception(Minfraud::InvalidInputError) + end + + it 'raises an exception for a non-boolean was_verification_successful' do + expect do + described_class.new( + was_verification_successful: 1, + ) + end.to raise_exception(Minfraud::InvalidInputError) + end + it 'does not raise an exception for valid values' do described_class.new( - address: 'wstorey@maxmind.com', + address: 'wstorey@maxmind.com', + verification_time: '2026-10-01T14:30:00Z', + was_verification_successful: false, ) end From 2277b096e16b0b43871f7226850c1e700ae0048a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 23 Oct 2026 15:52:13 +0000 Subject: [PATCH 2/6] Share verification input specs between components The Email verification specs were copies of the phone specs in the addressable shared examples, with only the phone_ prefix removed. Move them into one shared example that takes the field prefix, and use it for Billing, Shipping, and Email. The phone specs now also check a non-boolean result and a boolean time. The phone specs still send phone_verification_method with the other valid values, and a separate spec checks that phone_verification_method is sent as a string. Co-Authored-By: Claude Opus 5.5 --- spec/components/email_spec.rb | 51 ++------------------- spec/spec_helper.rb | 1 + spec/support/addressable_examples.rb | 53 +++------------------- spec/support/verification_examples.rb | 65 +++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 95 deletions(-) create mode 100644 spec/support/verification_examples.rb diff --git a/spec/components/email_spec.rb b/spec/components/email_spec.rb index 5aa0d84..7e1986e 100644 --- a/spec/components/email_spec.rb +++ b/spec/components/email_spec.rb @@ -3,20 +3,9 @@ require 'spec_helper' describe Minfraud::Components::Email do - describe '#to_json' do - [true, false].each do |successful| - it "sends was_verification_successful #{successful} as a JSON boolean" do - email = described_class.new( - verification_time: '2026-10-01T14:30:00Z', - was_verification_successful: successful, - ) - expect(email.to_json).to eq( - 'verification_time' => '2026-10-01T14:30:00Z', - 'was_verification_successful' => successful, - ) - end - end + it_behaves_like 'a component with verification inputs', '' + describe '#to_json' do it 'sends the verification inputs with a hashed address' do email = described_class.new( address: 'test@maxmind.com', @@ -52,43 +41,9 @@ end.to raise_exception(Minfraud::InvalidInputError) end - it 'raises an exception for an invalid verification_time' do - expect do - described_class.new( - verification_time: '2026-10-01 14:30:00', - ) - end.to raise_exception(Minfraud::InvalidInputError) - end - - it 'raises an exception for a non-string verification_time' do - expect do - described_class.new( - verification_time: true, - ) - end.to raise_exception(Minfraud::InvalidInputError) - end - - it 'raises an exception for an invalid was_verification_successful' do - expect do - described_class.new( - was_verification_successful: 'true', - ) - end.to raise_exception(Minfraud::InvalidInputError) - end - - it 'raises an exception for a non-boolean was_verification_successful' do - expect do - described_class.new( - was_verification_successful: 1, - ) - end.to raise_exception(Minfraud::InvalidInputError) - end - it 'does not raise an exception for valid values' do described_class.new( - address: 'wstorey@maxmind.com', - verification_time: '2026-10-01T14:30:00Z', - was_verification_successful: false, + address: 'wstorey@maxmind.com', ) end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 04720c0..df2e2cf 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,3 +3,4 @@ $LOAD_PATH.unshift File.expand_path('../lib', __dir__) require 'minfraud' require_relative 'support/addressable_examples' +require_relative 'support/verification_examples' diff --git a/spec/support/addressable_examples.rb b/spec/support/addressable_examples.rb index eab45c2..5f75e0b 100644 --- a/spec/support/addressable_examples.rb +++ b/spec/support/addressable_examples.rb @@ -17,53 +17,12 @@ end.to raise_exception(Minfraud::NotEnumValueError) end - [true, false].each do |successful| - it "sends phone_was_verification_successful #{successful} as a JSON boolean" do - component = described_class.new( - phone_verification_method: 'network', - phone_verification_time: '2026-10-01T14:30:00Z', - phone_was_verification_successful: successful, - ) - expect(component.to_json).to eq( - 'phone_verification_method' => 'network', - 'phone_verification_time' => '2026-10-01T14:30:00Z', - 'phone_was_verification_successful' => successful, - ) - end + it 'sends phone_verification_method as a string' do + expect(described_class.new(phone_verification_method: :network).to_json).to eq( + 'phone_verification_method' => 'network', + ) end - context 'with validation' do - around do |example| - previous = Minfraud.enable_validation - - Minfraud.configure { |c| c.enable_validation = 1 } - example.run - ensure - Minfraud.configure { |c| c.enable_validation = previous } - end - - it 'raises an exception for an invalid phone_was_verification_successful' do - expect do - described_class.new( - phone_was_verification_successful: 'true', - ) - end.to raise_exception(Minfraud::InvalidInputError) - end - - it 'raises an exception for an invalid phone_verification_time' do - expect do - described_class.new( - phone_verification_time: '2026-10-01 14:30:00', - ) - end.to raise_exception(Minfraud::InvalidInputError) - end - - it 'does not raise an exception for valid phone verification values' do - described_class.new( - phone_verification_method: :delivered_code, - phone_verification_time: '2026-10-01T14:30:00Z', - phone_was_verification_successful: false, - ) - end - end + it_behaves_like 'a component with verification inputs', 'phone_', + phone_verification_method: :delivered_code end diff --git a/spec/support/verification_examples.rb b/spec/support/verification_examples.rb new file mode 100644 index 0000000..5b7da95 --- /dev/null +++ b/spec/support/verification_examples.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# The prefix is the text before verification_time and +# was_verification_successful in the field names, such as 'phone_'. +# other_valid holds more valid inputs to send with the verification values. +RSpec.shared_examples 'a component with verification inputs' do |prefix, other_valid = {}| + let(:time_field) { :"#{prefix}verification_time" } + let(:successful_field) { :"#{prefix}was_verification_successful" } + + [true, false].each do |successful| + it "sends #{prefix}was_verification_successful #{successful} as a JSON boolean" do + component = described_class.new( + time_field => '2026-10-01T14:30:00Z', + successful_field => successful, + ) + expect(component.to_json).to eq( + time_field.to_s => '2026-10-01T14:30:00Z', + successful_field.to_s => successful, + ) + end + end + + context 'with verification validation' do + around do |example| + previous = Minfraud.enable_validation + + Minfraud.configure { |c| c.enable_validation = 1 } + example.run + ensure + Minfraud.configure { |c| c.enable_validation = previous } + end + + it "raises an exception for an invalid #{prefix}verification_time" do + expect do + described_class.new(time_field => '2026-10-01 14:30:00') + end.to raise_exception(Minfraud::InvalidInputError) + end + + it "raises an exception for a boolean #{prefix}verification_time" do + expect do + described_class.new(time_field => true) + end.to raise_exception(Minfraud::InvalidInputError) + end + + it "raises an exception for an invalid #{prefix}was_verification_successful" do + expect do + described_class.new(successful_field => 'true') + end.to raise_exception(Minfraud::InvalidInputError) + end + + it "raises an exception for a non-boolean #{prefix}was_verification_successful" do + expect do + described_class.new(successful_field => 1) + end.to raise_exception(Minfraud::InvalidInputError) + end + + it 'does not raise an exception for valid verification values' do + described_class.new( + **other_valid, + time_field => '2026-10-01T14:30:00Z', + successful_field => false, + ) + end + end +end From 9b754710bde77dfcc6d03e0ac56af69903cddfd4 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 26 Oct 2026 18:47:23 +0000 Subject: [PATCH 3/6] Test a String phone_verification_method again The shared Billing and Shipping spec sent only a Symbol, so no spec covered the conversion from a String. Co-Authored-By: Claude Opus 5.5 --- spec/support/addressable_examples.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/support/addressable_examples.rb b/spec/support/addressable_examples.rb index 5f75e0b..3bba03e 100644 --- a/spec/support/addressable_examples.rb +++ b/spec/support/addressable_examples.rb @@ -18,7 +18,7 @@ end it 'sends phone_verification_method as a string' do - expect(described_class.new(phone_verification_method: :network).to_json).to eq( + expect(described_class.new(phone_verification_method: 'network').to_json).to eq( 'phone_verification_method' => 'network', ) end From f97e80099dbc7a28f2cacca9b6bca077e89846a2 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 26 Oct 2026 18:47:28 +0000 Subject: [PATCH 4/6] Remove other_valid from the verification specs The phone_verification_method value it sent added no coverage. The enum setter checks the value whether validation is on or off. Co-Authored-By: Claude Opus 5.5 --- spec/support/addressable_examples.rb | 3 +-- spec/support/verification_examples.rb | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/spec/support/addressable_examples.rb b/spec/support/addressable_examples.rb index 3bba03e..be2d0de 100644 --- a/spec/support/addressable_examples.rb +++ b/spec/support/addressable_examples.rb @@ -23,6 +23,5 @@ ) end - it_behaves_like 'a component with verification inputs', 'phone_', - phone_verification_method: :delivered_code + it_behaves_like 'a component with verification inputs', 'phone_' end diff --git a/spec/support/verification_examples.rb b/spec/support/verification_examples.rb index 5b7da95..90fc4db 100644 --- a/spec/support/verification_examples.rb +++ b/spec/support/verification_examples.rb @@ -2,8 +2,7 @@ # The prefix is the text before verification_time and # was_verification_successful in the field names, such as 'phone_'. -# other_valid holds more valid inputs to send with the verification values. -RSpec.shared_examples 'a component with verification inputs' do |prefix, other_valid = {}| +RSpec.shared_examples 'a component with verification inputs' do |prefix| let(:time_field) { :"#{prefix}verification_time" } let(:successful_field) { :"#{prefix}was_verification_successful" } @@ -56,7 +55,6 @@ it 'does not raise an exception for valid verification values' do described_class.new( - **other_valid, time_field => '2026-10-01T14:30:00Z', successful_field => false, ) From 58d0b124c0f82318c624a2bb0add02e5e83b78f4 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 26 Oct 2026 18:47:56 +0000 Subject: [PATCH 5/6] Check the error message for a boolean verification time A boolean also fails the RFC 3339 check, so the example passed even without the boolean check. Co-Authored-By: Claude Opus 5.5 --- spec/support/verification_examples.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/support/verification_examples.rb b/spec/support/verification_examples.rb index 90fc4db..1e7cead 100644 --- a/spec/support/verification_examples.rb +++ b/spec/support/verification_examples.rb @@ -38,7 +38,7 @@ it "raises an exception for a boolean #{prefix}verification_time" do expect do described_class.new(time_field => true) - end.to raise_exception(Minfraud::InvalidInputError) + end.to raise_exception(Minfraud::InvalidInputError, /must not be a boolean/) end it "raises an exception for an invalid #{prefix}was_verification_successful" do From 6a196051a8e1ae725e0d5a2e2e648a3ad2ec5b96 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 26 Oct 2026 18:48:04 +0000 Subject: [PATCH 6/6] 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 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d80f416..3e32869 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ assessment = Minfraud::Assessments.new( party: :customer, transaction_id: 'txn3134133', shop_id: 's2123', - time: '2012-04-12T23:20:50+00:00', + time: '2026-10-05T23:20:50+00:00', type: :purchase, }, account: {