Skip to content

Commit 8516ae0

Browse files
oschwaldclaude
andcommitted
Add email domain outputs support
Add support for new email domain outputs in minFraud Insights and Factors: - New EmailDomainVisit model with status, last_visited_on, and has_redirect - New EmailDomain fields: classification, risk, volume, and visit - Updated test fixtures and integration tests - Updated HISTORY.rst and CLAUDE.md documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 02bf9cf commit 8516ae0

7 files changed

Lines changed: 146 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ When adding email normalization rules to `request.py`:
318318

319319
Always update `HISTORY.rst` for user-facing changes.
320320

321-
**Important**: Do not add a date to changelog entries until release time. Version numbers are added but without dates.
321+
**Important**: Do not add a date to changelog entries until release time. A version number without a date indicates an unreleased version. Only add the date when the version is actually released.
322322

323323
Format:
324324
```rst

HISTORY.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ History
1717
transaction.
1818
* Added the input ``/payment/method``. This is the payment method associated
1919
with the transaction.
20+
* Added new ``EmailDomainVisit`` model class with ``status``,
21+
``last_visited_on``, and ``has_redirect`` attributes. This class provides
22+
information from automated visits to email domains.
23+
* Added new attributes to ``minfraud.models.EmailDomain``:
24+
* ``classification`` - A classification of the email domain (``business``,
25+
``education``, ``government``, or ``isp_email``).
26+
* ``risk`` - The risk associated with the domain (0.01 to 99).
27+
* ``volume`` - Activity on the email domain across the minFraud network,
28+
expressed in sightings per million (0.001 to 1,000,000).
29+
* ``visit`` - An ``EmailDomainVisit`` object containing information from
30+
an automated visit to the email domain.
2031

2132
3.1.0 (2025-05-23)
2233
++++++++++++++++++

src/minfraud/models.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,39 @@ def __init__(
285285
self.rule_label = rule_label
286286

287287

288+
class EmailDomainVisit(_Serializable):
289+
"""Information from an automated visit to the email domain."""
290+
291+
status: str | None
292+
"""A classification of the status of the domain based on an automated visit.
293+
This will be one of the following values: ``live``, ``dns_error``,
294+
``network_error``, ``http_error``, ``parked``, or ``pre_development``.
295+
Additional values may be added in the future."""
296+
297+
last_visited_on: str | None
298+
"""A date string (e.g. 2019-01-01) to identify the date when the automated
299+
visit to the domain was completed. This is expressed using the ISO 8601 date
300+
format YYYY-MM-DD."""
301+
302+
has_redirect: bool | None
303+
"""This is ``True`` if the domain in the request has redirects (configured
304+
to automatically send visitors to another URL). Otherwise the key is omitted.
305+
When ``True``, the ``status`` corresponds to the final redirected domain."""
306+
307+
def __init__(
308+
self,
309+
*,
310+
status: str | None = None,
311+
last_visited_on: str | None = None,
312+
has_redirect: bool | None = None,
313+
**_: Any,
314+
) -> None:
315+
"""Initialize an EmailDomainVisit instance."""
316+
self.status = status
317+
self.last_visited_on = last_visited_on
318+
self.has_redirect = has_redirect
319+
320+
288321
class EmailDomain(_Serializable):
289322
"""Information about the email domain passed in the request."""
290323

@@ -293,9 +326,42 @@ class EmailDomain(_Serializable):
293326
was first seen by MaxMind. This is expressed using the ISO 8601 date
294327
format."""
295328

296-
def __init__(self, *, first_seen: str | None = None, **_: Any) -> None:
329+
classification: str | None
330+
"""A classification of the domain. This will be one of the following values:
331+
``business``, ``education``, ``government``, or ``isp_email``. Additional
332+
values may be added in the future."""
333+
334+
risk: float | None
335+
"""This field contains the risk associated with the domain. The value ranges
336+
from 0.01 to 99. A higher score indicates higher risk."""
337+
338+
volume: float | None
339+
"""This field indicates the activity on an email domain across the minFraud
340+
network, expressed in sightings per million. The value ranges from 0.001 to
341+
1,000,000. Values are rounded to 2 significant figures."""
342+
343+
visit: EmailDomainVisit
344+
"""An object containing information from an automated visit to the email
345+
domain. This object may be populated after an automated visit has been
346+
completed. For newly sighted domains, the visit information may take some
347+
time to appear. Visit information is limited to low-volume domains only."""
348+
349+
def __init__(
350+
self,
351+
*,
352+
first_seen: str | None = None,
353+
classification: str | None = None,
354+
risk: float | None = None,
355+
volume: float | None = None,
356+
visit: dict[str, Any] | None = None,
357+
**_: Any,
358+
) -> None:
297359
"""Initialize an EmailDomain instance."""
298360
self.first_seen = first_seen
361+
self.classification = classification
362+
self.risk = risk
363+
self.volume = volume
364+
self.visit = EmailDomainVisit(**(visit or {}))
299365

300366

301367
class Email(_Serializable):

tests/data/factors-response.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,15 @@
150150
},
151151
"email": {
152152
"domain": {
153-
"first_seen": "2014-02-23"
153+
"first_seen": "2014-02-23",
154+
"classification": "business",
155+
"risk": 1.23,
156+
"volume": 37000,
157+
"visit": {
158+
"status": "live",
159+
"last_visited_on": "2024-01-15",
160+
"has_redirect": false
161+
}
154162
},
155163
"first_seen": "2017-01-02",
156164
"is_disposable": true,

tests/data/insights-response.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,15 @@
150150
},
151151
"email": {
152152
"domain": {
153-
"first_seen": "2014-02-23"
153+
"first_seen": "2014-02-23",
154+
"classification": "business",
155+
"risk": 1.23,
156+
"volume": 37000,
157+
"visit": {
158+
"status": "live",
159+
"last_visited_on": "2024-01-15",
160+
"has_redirect": false
161+
}
154162
},
155163
"first_seen": "2017-01-02",
156164
"is_disposable": true,

tests/test_models.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Disposition,
1111
Email,
1212
EmailDomain,
13+
EmailDomainVisit,
1314
Factors,
1415
GeoIP2Location,
1516
Insights,
@@ -108,6 +109,18 @@ def test_disposition(self) -> None:
108109
self.assertEqual("default", disposition.reason)
109110
self.assertEqual("custom rule label", disposition.rule_label)
110111

112+
def test_email_domain_visit(self) -> None:
113+
last_visited_on = "2024-01-15"
114+
visit = EmailDomainVisit(
115+
status="live",
116+
last_visited_on=last_visited_on,
117+
has_redirect=True,
118+
)
119+
120+
self.assertEqual("live", visit.status)
121+
self.assertEqual(last_visited_on, visit.last_visited_on)
122+
self.assertEqual(True, visit.has_redirect)
123+
111124
def test_email(self) -> None:
112125
first_seen = "2016-01-01"
113126
email = Email(
@@ -124,11 +137,26 @@ def test_email(self) -> None:
124137

125138
def test_email_domain(self) -> None:
126139
first_seen = "2016-01-01"
140+
last_visited_on = "2024-01-15"
127141
domain = EmailDomain(
128142
first_seen=first_seen,
143+
classification="business",
144+
risk=1.23,
145+
volume=37000,
146+
visit={
147+
"status": "live",
148+
"last_visited_on": last_visited_on,
149+
"has_redirect": False,
150+
},
129151
)
130152

131153
self.assertEqual(first_seen, domain.first_seen)
154+
self.assertEqual("business", domain.classification)
155+
self.assertEqual(1.23, domain.risk)
156+
self.assertEqual(37000, domain.volume)
157+
self.assertEqual("live", domain.visit.status)
158+
self.assertEqual(last_visited_on, domain.visit.last_visited_on)
159+
self.assertEqual(False, domain.visit.has_redirect)
132160

133161
def test_geoip2_location(self) -> None:
134162
time = "2015-04-19T12:59:23-01:00"
@@ -357,7 +385,20 @@ def factors_response(self) -> dict[str, Any]:
357385
"type": "debit",
358386
},
359387
"device": {"id": "b643d445-18b2-4b9d-bad4-c9c4366e402a"},
360-
"email": {"domain": {"first_seen": "2014-02-23"}, "is_free": True},
388+
"email": {
389+
"domain": {
390+
"first_seen": "2014-02-23",
391+
"classification": "business",
392+
"risk": 1.23,
393+
"volume": 37000,
394+
"visit": {
395+
"status": "live",
396+
"last_visited_on": "2024-01-15",
397+
"has_redirect": False,
398+
},
399+
},
400+
"is_free": True,
401+
},
361402
"shipping_address": {"is_in_ip_country": True},
362403
"shipping_phone": {"is_voip": True},
363404
"billing_address": {"is_in_ip_country": True},
@@ -412,6 +453,12 @@ def check_insights_data(self, insights: Insights | Factors, uuid: str) -> None:
412453
self.assertEqual("reject", insights.disposition.action)
413454
self.assertEqual(True, insights.email.is_free)
414455
self.assertEqual("2014-02-23", insights.email.domain.first_seen)
456+
self.assertEqual("business", insights.email.domain.classification)
457+
self.assertEqual(1.23, insights.email.domain.risk)
458+
self.assertEqual(37000, insights.email.domain.volume)
459+
self.assertEqual("live", insights.email.domain.visit.status)
460+
self.assertEqual("2024-01-15", insights.email.domain.visit.last_visited_on)
461+
self.assertEqual(False, insights.email.domain.visit.has_redirect)
415462
self.assertEqual(True, insights.shipping_phone.is_voip)
416463
self.assertEqual(True, insights.shipping_address.is_in_ip_country)
417464
self.assertEqual(True, insights.billing_address.is_in_ip_country)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)