diff --git a/CHANGELOG.md b/CHANGELOG.md index d402d461..28cdd9c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ CHANGELOG by providing `phoneVerificationMethod`, `phoneWasVerificationSuccessful`, and `phoneVerificationTime` to `Billing` or `Shipping`. The `PhoneVerificationMethod` enum lists the valid methods. +* Added the inputs `/email/verification_time` and + `/email/was_verification_successful`. They describe the most recent + verification of the email address. You may provide them by providing + `verificationTime` and `wasVerificationSuccessful` to `Email`. * `Event` now throws an `ArgumentError` if `time` is not a valid `Date`. Before, the client sent an invalid `Date` as `null` and did not report an error. diff --git a/README.md b/README.md index df35cfdf..5b33df41 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,8 @@ try { email: new minFraud.Email({ address: 'foo@bar.com', domain: 'bar.com', + verificationTime: new Date('2026-10-01T14:25:00Z'), + wasVerificationSuccessful: true, }), billing: new minFraud.Billing({ address: '123 Robot Ave.', diff --git a/src/request/email.spec.ts b/src/request/email.spec.ts index 4bcb76d5..26d41b8a 100644 --- a/src/request/email.spec.ts +++ b/src/request/email.spec.ts @@ -4,6 +4,63 @@ import crypto from 'node:crypto'; import Email from './email.js'; describe('Email()', () => { + it('stores the verification properties', () => { + const time = new Date('2026-10-01T14:25:00Z'); + const email = new Email({ + domain: 'example.com', + verificationTime: time, + wasVerificationSuccessful: false, + }); + + expect(email.domain).toBe('example.com'); + expect(email.verificationTime).toEqual(time); + expect(email.wasVerificationSuccessful).toBe(false); + }); + + it('copies `verificationTime`', () => { + const time = new Date('2026-10-01T14:25:00Z'); + const email = new Email({ verificationTime: time }); + + time.setTime(0); + + expect(email.verificationTime).toEqual(new Date('2026-10-01T14:25:00Z')); + }); + + it('accepts null verification properties', () => { + expect( + () => + new Email({ + verificationTime: null, + wasVerificationSuccessful: null, + } as unknown as ConstructorParameters[0]) + ).not.toThrow(); + }); + + it.each([ + ['an invalid Date', new Date('not a date')], + ['a string', '2026-10-01T14:25:00Z'], + ['a number', 1790000000000], + ])('throws an ArgumentError if verificationTime is %s', (_, value) => { + const test = () => + new Email({ + verificationTime: value as unknown as Date, + }); + expect(test).toThrow(ArgumentError); + expect(test).toThrow('verificationTime'); + }); + + it.each(['true', 1, 0])( + 'throws an ArgumentError for wasVerificationSuccessful %j', + (value) => { + const test = () => + new Email({ + wasVerificationSuccessful: value as unknown as boolean, + }); + expect(test).toThrow(ArgumentError); + expect(test).toThrow('wasVerificationSuccessful'); + } + ); + it('throws an error if email.address is not valid', () => { const email = () => new Email({ diff --git a/src/request/email.ts b/src/request/email.ts index 0f28ca1e..7e0f51ef 100644 --- a/src/request/email.ts +++ b/src/request/email.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import { domainToASCII } from 'node:url'; import { ArgumentError } from '../errors.js'; +import { assertValidDate } from '../utils.js'; // A fully-qualified domain name: two or more dot-separated labels of letters // (including non-ASCII), combining marks, digits, or hyphens, with no label @@ -51,6 +52,19 @@ interface EmailProps { * true, the address will instead be sent as an MD5 hash. */ hashAddress?: boolean; + /** + * The date and time of the most recent attempt to verify the email address, + * 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. + */ + verificationTime?: Date; + /** + * 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. + */ + wasVerificationSuccessful?: boolean; } /** @@ -64,6 +78,19 @@ export default class Email implements EmailProps { public address?: string; /** @inheritDoc EmailProps.domain */ public domain?: string; + /** + * The date and time of the most recent attempt to verify the email address, + * 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. + */ + public verificationTime?: Date; + /** + * 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. + */ + public wasVerificationSuccessful?: boolean; private static readonly typoDomains: { [key: string]: string } = { // gmail.com @@ -327,6 +354,19 @@ export default class Email implements EmailProps { throw new ArgumentError('`email.domain` is an invalid domain'); } + if (email.verificationTime != null) { + assertValidDate(email.verificationTime, 'email.verificationTime'); + } + + if ( + email.wasVerificationSuccessful != null && + typeof email.wasVerificationSuccessful !== 'boolean' + ) { + throw new ArgumentError( + '`email.wasVerificationSuccessful` must be a boolean' + ); + } + if (email.address) { if (email.hashAddress) { this.address = crypto.hash( @@ -344,6 +384,14 @@ export default class Email implements EmailProps { if (email.domain == null && email.address != null) { this.domain = email.address.substring(email.address.indexOf('@') + 1); } + + // Copy the Date so that a later change to the caller's object does not + // change the request. + if (email.verificationTime != null) { + this.verificationTime = new Date(email.verificationTime.getTime()); + } + + this.wasVerificationSuccessful = email.wasVerificationSuccessful; } private cleanEmailAddress(address: string) { diff --git a/src/request/transaction.spec.ts b/src/request/transaction.spec.ts index 06aca509..a3160f7a 100644 --- a/src/request/transaction.spec.ts +++ b/src/request/transaction.spec.ts @@ -250,6 +250,23 @@ describe('Transaction()', () => { expect(test.toString()).toContain('"email":{"domain":"foo.com"}'); }); + it('it handles email verification fields', () => { + const test = new Transaction({ + device: new Device({ + ipAddress: '1.1.1.1', + }), + email: new Email({ + verificationTime: new Date('2026-10-01T14:25:00Z'), + wasVerificationSuccessful: false, + }), + }); + + expect(JSON.parse(test.toString()).email).toEqual({ + verification_time: '2026-10-01T14:25:00.000Z', + was_verification_successful: false, + }); + }); + it('it handles optional billing field', () => { const test = new Transaction({ billing: new Billing({