mailtester-ninja 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mailtester_ninja-1.0.0/.github/workflows/ci.yml +20 -0
- mailtester_ninja-1.0.0/.github/workflows/publish.yml +34 -0
- mailtester_ninja-1.0.0/.gitignore +6 -0
- mailtester_ninja-1.0.0/LICENSE +21 -0
- mailtester_ninja-1.0.0/PKG-INFO +106 -0
- mailtester_ninja-1.0.0/README.md +76 -0
- mailtester_ninja-1.0.0/pyproject.toml +46 -0
- mailtester_ninja-1.0.0/src/mailtester_ninja/__init__.py +23 -0
- mailtester_ninja-1.0.0/src/mailtester_ninja/client.py +190 -0
- mailtester_ninja-1.0.0/src/mailtester_ninja/py.typed +0 -0
- mailtester_ninja-1.0.0/tests/test_client.py +132 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
pull_request:
|
|
6
|
+
permissions:
|
|
7
|
+
contents: read
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python }}
|
|
19
|
+
- run: pip install -e ".[test]"
|
|
20
|
+
- run: pytest -q
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags: ['v*']
|
|
5
|
+
workflow_dispatch:
|
|
6
|
+
permissions:
|
|
7
|
+
contents: read
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
- run: pip install -e ".[test]" build
|
|
17
|
+
- run: pytest -q
|
|
18
|
+
- run: python -m build
|
|
19
|
+
- uses: actions/upload-artifact@v4
|
|
20
|
+
with:
|
|
21
|
+
name: dist
|
|
22
|
+
path: dist/
|
|
23
|
+
publish:
|
|
24
|
+
needs: build
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
environment: pypi
|
|
27
|
+
permissions:
|
|
28
|
+
id-token: write
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/download-artifact@v4
|
|
31
|
+
with:
|
|
32
|
+
name: dist
|
|
33
|
+
path: dist/
|
|
34
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MailTester Ninja (Unlimited & Limited)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mailtester-ninja
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official client for the MailTester Ninja email verification API: check in real time if an email address is valid and deliverable.
|
|
5
|
+
Project-URL: Homepage, https://mailtester.ninja
|
|
6
|
+
Project-URL: Documentation, https://mailtester.ninja/api/
|
|
7
|
+
Project-URL: Source, https://github.com/mailtester-ninja/mailtester-python
|
|
8
|
+
Project-URL: Issues, https://github.com/mailtester-ninja/mailtester-python/issues
|
|
9
|
+
Project-URL: OpenAPI specification, https://mailtester.ninja/openapi.json
|
|
10
|
+
Author-email: MailTester Ninja <contact@mailtester.ninja>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: catch-all,disposable email,email,email checker,email validation,email verification,email verifier,mailtester,smtp
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Communications :: Email
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# MailTester Ninja for Python
|
|
32
|
+
|
|
33
|
+
Official client for the [MailTester Ninja](https://mailtester.ninja) email verification API. Check in real time whether an email address is valid and deliverable: each address is checked against the recipient's mail server, and nothing is stored.
|
|
34
|
+
|
|
35
|
+
- No dependencies (standard library only), typed, Python 3.9+
|
|
36
|
+
- Free to try: no signup with `key="free"`, or a [free API token](https://mailtester.ninja/free-email-verification-api/) for 50 checks a day
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install mailtester-ninja
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from mailtester_ninja import MailTester
|
|
46
|
+
|
|
47
|
+
mt = MailTester() # reads MAILTESTER_KEY, falls back to "free"
|
|
48
|
+
|
|
49
|
+
result = mt.verify("info@apple.com")
|
|
50
|
+
print(result.code, result.message) # ok Accepted
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Reading the verdict
|
|
54
|
+
|
|
55
|
+
Branch on `code`, show `message` to people.
|
|
56
|
+
|
|
57
|
+
| `code` | Meaning | Typical `message` |
|
|
58
|
+
|--------|---------|-------------------|
|
|
59
|
+
| `ok` | Valid and deliverable | Accepted |
|
|
60
|
+
| `ko` | Invalid, will bounce | Rejected, No MX, Disposable, Limited |
|
|
61
|
+
| `mb` | Uncertain: catch-all domain, or the server could not be probed | Catch-All, MX Error, Timeout, SPAM Block, Greylisted |
|
|
62
|
+
|
|
63
|
+
A catch-all domain is never reported as valid. Shortcuts: `result.is_valid`, `result.is_invalid`, `result.is_uncertain`, and `mt.is_deliverable(email)`.
|
|
64
|
+
|
|
65
|
+
## Verify a list
|
|
66
|
+
|
|
67
|
+
`verify_many` checks one address at a time and paces the calls for you: 30 seconds apart on the free tier, 1 second with a paid key (override with `delay`). Errors are reported per address, the loop keeps going.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
for email, result, error in mt.verify_many(["info@apple.com", "someone@example.com"]):
|
|
71
|
+
if error:
|
|
72
|
+
print(email, "no verdict:", error.message)
|
|
73
|
+
else:
|
|
74
|
+
print(email, result.code, result.message)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Errors
|
|
78
|
+
|
|
79
|
+
An error is never a verdict: do not drop an address because of it.
|
|
80
|
+
|
|
81
|
+
| Exception | When |
|
|
82
|
+
|-----------|------|
|
|
83
|
+
| `MailTesterKeyError` | The key is invalid or disabled. |
|
|
84
|
+
| `MailTesterRateLimitError` | A limit was reached (HTTP 429). `retry_after` is set on the free tier. |
|
|
85
|
+
| `MailTesterError` | Any other HTTP or network error (`status` when there is one). |
|
|
86
|
+
|
|
87
|
+
## Keys and limits
|
|
88
|
+
|
|
89
|
+
| Key | Daily checks | Pace |
|
|
90
|
+
|-----|--------------|------|
|
|
91
|
+
| `free` | 10 per IP | 1 request every 30 s |
|
|
92
|
+
| Free token `mtf_...` ([get one](https://mailtester.ninja/free-email-verification-api/)) | 50 per token | 1 request every 30 s |
|
|
93
|
+
| Paid key `sub_...` ([plans](https://mailtester.ninja/verify-emails-unlimited/)) | 100,000 to 200,000+ | daily limit / 8640 per 10 s (Pro 11, Ultimate 23) |
|
|
94
|
+
|
|
95
|
+
Options: `MailTester(key=None, base_url=..., timeout=60.0)`. The key defaults to the `MAILTESTER_KEY` environment variable, then to `free`.
|
|
96
|
+
|
|
97
|
+
## Links
|
|
98
|
+
|
|
99
|
+
- [API documentation](https://mailtester.ninja/api/) and [OpenAPI 3.1 specification](https://mailtester.ninja/openapi.json)
|
|
100
|
+
- [Integrations](https://mailtester.ninja/integrations/): n8n, Zapier, Make, Clay, Google Sheets
|
|
101
|
+
- [Code examples](https://github.com/mailtester-ninja/mailtester-api-examples) for other languages
|
|
102
|
+
- [JavaScript and TypeScript client](https://www.npmjs.com/package/@mailtester-ninja/client)
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# MailTester Ninja for Python
|
|
2
|
+
|
|
3
|
+
Official client for the [MailTester Ninja](https://mailtester.ninja) email verification API. Check in real time whether an email address is valid and deliverable: each address is checked against the recipient's mail server, and nothing is stored.
|
|
4
|
+
|
|
5
|
+
- No dependencies (standard library only), typed, Python 3.9+
|
|
6
|
+
- Free to try: no signup with `key="free"`, or a [free API token](https://mailtester.ninja/free-email-verification-api/) for 50 checks a day
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install mailtester-ninja
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quickstart
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from mailtester_ninja import MailTester
|
|
16
|
+
|
|
17
|
+
mt = MailTester() # reads MAILTESTER_KEY, falls back to "free"
|
|
18
|
+
|
|
19
|
+
result = mt.verify("info@apple.com")
|
|
20
|
+
print(result.code, result.message) # ok Accepted
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Reading the verdict
|
|
24
|
+
|
|
25
|
+
Branch on `code`, show `message` to people.
|
|
26
|
+
|
|
27
|
+
| `code` | Meaning | Typical `message` |
|
|
28
|
+
|--------|---------|-------------------|
|
|
29
|
+
| `ok` | Valid and deliverable | Accepted |
|
|
30
|
+
| `ko` | Invalid, will bounce | Rejected, No MX, Disposable, Limited |
|
|
31
|
+
| `mb` | Uncertain: catch-all domain, or the server could not be probed | Catch-All, MX Error, Timeout, SPAM Block, Greylisted |
|
|
32
|
+
|
|
33
|
+
A catch-all domain is never reported as valid. Shortcuts: `result.is_valid`, `result.is_invalid`, `result.is_uncertain`, and `mt.is_deliverable(email)`.
|
|
34
|
+
|
|
35
|
+
## Verify a list
|
|
36
|
+
|
|
37
|
+
`verify_many` checks one address at a time and paces the calls for you: 30 seconds apart on the free tier, 1 second with a paid key (override with `delay`). Errors are reported per address, the loop keeps going.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
for email, result, error in mt.verify_many(["info@apple.com", "someone@example.com"]):
|
|
41
|
+
if error:
|
|
42
|
+
print(email, "no verdict:", error.message)
|
|
43
|
+
else:
|
|
44
|
+
print(email, result.code, result.message)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Errors
|
|
48
|
+
|
|
49
|
+
An error is never a verdict: do not drop an address because of it.
|
|
50
|
+
|
|
51
|
+
| Exception | When |
|
|
52
|
+
|-----------|------|
|
|
53
|
+
| `MailTesterKeyError` | The key is invalid or disabled. |
|
|
54
|
+
| `MailTesterRateLimitError` | A limit was reached (HTTP 429). `retry_after` is set on the free tier. |
|
|
55
|
+
| `MailTesterError` | Any other HTTP or network error (`status` when there is one). |
|
|
56
|
+
|
|
57
|
+
## Keys and limits
|
|
58
|
+
|
|
59
|
+
| Key | Daily checks | Pace |
|
|
60
|
+
|-----|--------------|------|
|
|
61
|
+
| `free` | 10 per IP | 1 request every 30 s |
|
|
62
|
+
| Free token `mtf_...` ([get one](https://mailtester.ninja/free-email-verification-api/)) | 50 per token | 1 request every 30 s |
|
|
63
|
+
| Paid key `sub_...` ([plans](https://mailtester.ninja/verify-emails-unlimited/)) | 100,000 to 200,000+ | daily limit / 8640 per 10 s (Pro 11, Ultimate 23) |
|
|
64
|
+
|
|
65
|
+
Options: `MailTester(key=None, base_url=..., timeout=60.0)`. The key defaults to the `MAILTESTER_KEY` environment variable, then to `free`.
|
|
66
|
+
|
|
67
|
+
## Links
|
|
68
|
+
|
|
69
|
+
- [API documentation](https://mailtester.ninja/api/) and [OpenAPI 3.1 specification](https://mailtester.ninja/openapi.json)
|
|
70
|
+
- [Integrations](https://mailtester.ninja/integrations/): n8n, Zapier, Make, Clay, Google Sheets
|
|
71
|
+
- [Code examples](https://github.com/mailtester-ninja/mailtester-api-examples) for other languages
|
|
72
|
+
- [JavaScript and TypeScript client](https://www.npmjs.com/package/@mailtester-ninja/client)
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mailtester-ninja"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official client for the MailTester Ninja email verification API: check in real time if an email address is valid and deliverable."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "MailTester Ninja", email = "contact@mailtester.ninja" }]
|
|
14
|
+
keywords = ["email", "email verification", "email validation", "email verifier", "email checker", "smtp", "catch-all", "disposable email", "mailtester"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Communications :: Email",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
test = ["pytest>=7"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://mailtester.ninja"
|
|
36
|
+
Documentation = "https://mailtester.ninja/api/"
|
|
37
|
+
Source = "https://github.com/mailtester-ninja/mailtester-python"
|
|
38
|
+
Issues = "https://github.com/mailtester-ninja/mailtester-python/issues"
|
|
39
|
+
"OpenAPI specification" = "https://mailtester.ninja/openapi.json"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/mailtester_ninja"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Official Python client for the MailTester Ninja email verification API."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
FREE_KEY,
|
|
6
|
+
MailTester,
|
|
7
|
+
MailTesterError,
|
|
8
|
+
MailTesterKeyError,
|
|
9
|
+
MailTesterRateLimitError,
|
|
10
|
+
VerificationResult,
|
|
11
|
+
__version__,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DEFAULT_BASE_URL",
|
|
16
|
+
"FREE_KEY",
|
|
17
|
+
"MailTester",
|
|
18
|
+
"MailTesterError",
|
|
19
|
+
"MailTesterKeyError",
|
|
20
|
+
"MailTesterRateLimitError",
|
|
21
|
+
"VerificationResult",
|
|
22
|
+
"__version__",
|
|
23
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Official Python client for the MailTester Ninja email verification API.
|
|
2
|
+
|
|
3
|
+
https://mailtester.ninja/api/
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, Dict, Iterable, Iterator, Optional
|
|
17
|
+
|
|
18
|
+
__version__ = "1.0.0"
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://happy.mailtester.ninja"
|
|
21
|
+
FREE_KEY = "free" # keyless free tier: 10 checks a day per IP, one request every 30 seconds
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MailTesterError(Exception):
|
|
25
|
+
"""Any error returned by the API or the network. Never a verdict: do not drop the address on it."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, status: Optional[int] = None) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.message = message
|
|
30
|
+
self.status = status
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MailTesterKeyError(MailTesterError):
|
|
34
|
+
"""The key is invalid or disabled (``code`` "--")."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MailTesterRateLimitError(MailTesterError):
|
|
38
|
+
"""A limit was reached (HTTP 429). ``retry_after`` is set when the API says how long to wait."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str, retry_after: Optional[int] = None) -> None:
|
|
41
|
+
super().__init__(message, 429)
|
|
42
|
+
self.retry_after = retry_after
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class VerificationResult:
|
|
47
|
+
"""Verdict for one address.
|
|
48
|
+
|
|
49
|
+
``code``: "ok" valid and deliverable, "ko" invalid, "mb" uncertain (catch-all or not probeable).
|
|
50
|
+
``message``: human-readable label (Accepted, Rejected, Catch-All, No MX, Disposable, Timeout...).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
code: str
|
|
54
|
+
message: str
|
|
55
|
+
email: str
|
|
56
|
+
user: str = ""
|
|
57
|
+
domain: Optional[str] = None
|
|
58
|
+
mx: str = ""
|
|
59
|
+
connections: int = 0
|
|
60
|
+
limit: int = 0
|
|
61
|
+
rate: int = 0
|
|
62
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_valid(self) -> bool:
|
|
66
|
+
return self.code == "ok"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_invalid(self) -> bool:
|
|
70
|
+
return self.code == "ko"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_uncertain(self) -> bool:
|
|
74
|
+
return self.code == "mb"
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, data: Dict[str, Any]) -> "VerificationResult":
|
|
78
|
+
def as_int(value: Any) -> int:
|
|
79
|
+
try:
|
|
80
|
+
return int(value or 0)
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
return cls(
|
|
85
|
+
code=str(data.get("code", "")),
|
|
86
|
+
message=str(data.get("message", "")),
|
|
87
|
+
email=str(data.get("email", "")),
|
|
88
|
+
user=str(data.get("user") or ""),
|
|
89
|
+
domain=data.get("domain"),
|
|
90
|
+
mx=str(data.get("mx") or ""),
|
|
91
|
+
connections=as_int(data.get("connections")),
|
|
92
|
+
limit=as_int(data.get("limit")),
|
|
93
|
+
rate=as_int(data.get("rate")),
|
|
94
|
+
raw=dict(data),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _retry_after(message: str) -> Optional[int]:
|
|
99
|
+
match = re.search(r"retry in (\d+)\s*s", message, re.IGNORECASE)
|
|
100
|
+
return int(match.group(1)) if match else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MailTester:
|
|
104
|
+
"""Client for the MailTester Ninja API.
|
|
105
|
+
|
|
106
|
+
``key``: "free" (no signup), a free token "mtf_..." from
|
|
107
|
+
https://mailtester.ninja/free-email-verification-api/, or a subscription key "sub_...".
|
|
108
|
+
Defaults to the MAILTESTER_KEY environment variable, then to "free".
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, timeout: float = 60.0) -> None:
|
|
112
|
+
raw_key = key if key is not None else (os.environ.get("MAILTESTER_KEY") or FREE_KEY)
|
|
113
|
+
self.key = raw_key.strip().strip("{}")
|
|
114
|
+
self.base_url = base_url.rstrip("/")
|
|
115
|
+
self.timeout = timeout
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def is_free(self) -> bool:
|
|
119
|
+
"""True on the free tier ("free" or a free token), limited to one request every 30 seconds."""
|
|
120
|
+
return self.key == FREE_KEY or self.key.startswith("mtf_")
|
|
121
|
+
|
|
122
|
+
def _get(self, email: str) -> "tuple[int, str]":
|
|
123
|
+
query = urllib.parse.urlencode({"email": email.strip(), "key": self.key})
|
|
124
|
+
request = urllib.request.Request(
|
|
125
|
+
f"{self.base_url}/ninja?{query}",
|
|
126
|
+
headers={"Accept": "application/json", "User-Agent": f"mailtester-ninja-python/{__version__}"},
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
130
|
+
return response.status, response.read().decode("utf-8", "replace")
|
|
131
|
+
except urllib.error.HTTPError as err:
|
|
132
|
+
return err.code, err.read().decode("utf-8", "replace")
|
|
133
|
+
except (urllib.error.URLError, OSError) as err:
|
|
134
|
+
reason = getattr(err, "reason", err)
|
|
135
|
+
raise MailTesterError(f"Network error: {reason}") from err
|
|
136
|
+
|
|
137
|
+
def verify(self, email: str) -> VerificationResult:
|
|
138
|
+
"""Verify one address. Returns a verdict, raises a MailTesterError when there is none."""
|
|
139
|
+
status, text = self._get(email)
|
|
140
|
+
try:
|
|
141
|
+
body = json.loads(text)
|
|
142
|
+
except ValueError:
|
|
143
|
+
body = None
|
|
144
|
+
if not isinstance(body, dict):
|
|
145
|
+
body = None
|
|
146
|
+
message = str(body.get("message", "")) if body else text.strip()
|
|
147
|
+
if status == 429:
|
|
148
|
+
raise MailTesterRateLimitError(message, _retry_after(message))
|
|
149
|
+
if status >= 400:
|
|
150
|
+
raise MailTesterError(message or f"HTTP {status}", status)
|
|
151
|
+
if body is None:
|
|
152
|
+
raise MailTesterError("Unexpected response from the API", status)
|
|
153
|
+
if body.get("code") == "--":
|
|
154
|
+
raise MailTesterKeyError(message, status)
|
|
155
|
+
return VerificationResult.from_dict(body)
|
|
156
|
+
|
|
157
|
+
def is_deliverable(self, email: str) -> bool:
|
|
158
|
+
"""True only when the address is clearly valid and deliverable ("ok")."""
|
|
159
|
+
return self.verify(email).is_valid
|
|
160
|
+
|
|
161
|
+
def verify_many(
|
|
162
|
+
self, emails: Iterable[str], delay: Optional[float] = None, retry_on_limit: bool = True
|
|
163
|
+
) -> Iterator["tuple[str, Optional[VerificationResult], Optional[MailTesterError]]"]:
|
|
164
|
+
"""Verify a list one address at a time, pacing the calls to respect the rate limit.
|
|
165
|
+
|
|
166
|
+
Yields ``(email, result, None)`` or ``(email, None, error)`` for each non-empty address.
|
|
167
|
+
``delay`` defaults to 30 seconds on the free tier and 1 second with a paid key.
|
|
168
|
+
"""
|
|
169
|
+
pause = delay if delay is not None else (30.0 if self.is_free else 1.0)
|
|
170
|
+
first = True
|
|
171
|
+
for raw in emails:
|
|
172
|
+
email = str(raw).strip()
|
|
173
|
+
if not email:
|
|
174
|
+
continue
|
|
175
|
+
if not first and pause > 0:
|
|
176
|
+
time.sleep(pause)
|
|
177
|
+
first = False
|
|
178
|
+
try:
|
|
179
|
+
yield email, self.verify(email), None
|
|
180
|
+
except MailTesterRateLimitError as err:
|
|
181
|
+
if retry_on_limit and err.retry_after is not None:
|
|
182
|
+
time.sleep(err.retry_after + 1)
|
|
183
|
+
try:
|
|
184
|
+
yield email, self.verify(email), None
|
|
185
|
+
except MailTesterError as err2:
|
|
186
|
+
yield email, None, err2
|
|
187
|
+
else:
|
|
188
|
+
yield email, None, err
|
|
189
|
+
except MailTesterError as err:
|
|
190
|
+
yield email, None, err
|
|
File without changes
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import urllib.error
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from mailtester_ninja import (
|
|
9
|
+
MailTester,
|
|
10
|
+
MailTesterError,
|
|
11
|
+
MailTesterKeyError,
|
|
12
|
+
MailTesterRateLimitError,
|
|
13
|
+
VerificationResult,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
OK = {"code": "ok", "message": "Accepted", "domain": "apple.com", "mx": "mx-in.g.apple.com",
|
|
17
|
+
"connections": 1, "limit": 100000, "rate": 1, "email": "info@apple.com", "user": "Info"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fake(replies, calls=None):
|
|
21
|
+
replies = list(replies)
|
|
22
|
+
|
|
23
|
+
def _get(self, email):
|
|
24
|
+
if calls is not None:
|
|
25
|
+
calls.append(email)
|
|
26
|
+
return replies.pop(0) if len(replies) > 1 else replies[0]
|
|
27
|
+
return _get
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_verify_returns_result(monkeypatch):
|
|
31
|
+
monkeypatch.setattr(MailTester, "_get", fake([(200, json.dumps(OK))]))
|
|
32
|
+
r = MailTester("sub_1").verify("info@apple.com")
|
|
33
|
+
assert isinstance(r, VerificationResult)
|
|
34
|
+
assert r.code == "ok" and r.message == "Accepted" and r.is_valid
|
|
35
|
+
assert r.limit == 100000 and r.raw["mx"] == "mx-in.g.apple.com"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_key_handling(monkeypatch):
|
|
39
|
+
monkeypatch.delenv("MAILTESTER_KEY", raising=False)
|
|
40
|
+
assert MailTester().key == "free" and MailTester().is_free
|
|
41
|
+
assert MailTester("{sub_abc}").key == "sub_abc"
|
|
42
|
+
assert MailTester("mtf_x").is_free and not MailTester("sub_x").is_free
|
|
43
|
+
monkeypatch.setenv("MAILTESTER_KEY", "sub_env")
|
|
44
|
+
assert MailTester().key == "sub_env"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_request_url_and_headers(monkeypatch):
|
|
48
|
+
seen = {}
|
|
49
|
+
|
|
50
|
+
class Resp(io.BytesIO):
|
|
51
|
+
status = 200
|
|
52
|
+
def __enter__(self):
|
|
53
|
+
return self
|
|
54
|
+
def __exit__(self, *a):
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def urlopen(req, timeout=None):
|
|
58
|
+
seen["url"] = req.full_url
|
|
59
|
+
seen["accept"] = req.get_header("Accept")
|
|
60
|
+
return Resp(json.dumps(OK).encode())
|
|
61
|
+
|
|
62
|
+
monkeypatch.setattr("urllib.request.urlopen", urlopen)
|
|
63
|
+
MailTester("sub_1").verify(" info@apple.com ")
|
|
64
|
+
assert seen["url"] == "https://happy.mailtester.ninja/ninja?email=info%40apple.com&key=sub_1"
|
|
65
|
+
assert seen["accept"] == "application/json"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_http_error_body_is_read(monkeypatch):
|
|
69
|
+
def urlopen(req, timeout=None):
|
|
70
|
+
raise urllib.error.HTTPError(req.full_url, 403, "Forbidden", {}, io.BytesIO(b"Missing email param"))
|
|
71
|
+
monkeypatch.setattr("urllib.request.urlopen", urlopen)
|
|
72
|
+
with pytest.raises(MailTesterError) as e:
|
|
73
|
+
MailTester("sub_1").verify("")
|
|
74
|
+
assert e.value.status == 403 and e.value.message == "Missing email param"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_network_error(monkeypatch):
|
|
78
|
+
def urlopen(req, timeout=None):
|
|
79
|
+
raise urllib.error.URLError("unreachable")
|
|
80
|
+
monkeypatch.setattr("urllib.request.urlopen", urlopen)
|
|
81
|
+
with pytest.raises(MailTesterError, match="Network error"):
|
|
82
|
+
MailTester("sub_1").verify("a@b.com")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_invalid_key(monkeypatch):
|
|
86
|
+
body = dict(OK, code="--", message="Invalid Key", domain=None, limit=0)
|
|
87
|
+
monkeypatch.setattr(MailTester, "_get", fake([(200, json.dumps(body))]))
|
|
88
|
+
with pytest.raises(MailTesterKeyError):
|
|
89
|
+
MailTester("bad").verify("info@apple.com")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_free_limit(monkeypatch):
|
|
93
|
+
body = dict(OK, code="--", message="Free API: one request every 30 s, retry in 29 s. Unlimited speed: https://mailtester.ninja/verify-emails-unlimited/")
|
|
94
|
+
monkeypatch.setattr(MailTester, "_get", fake([(429, json.dumps(body))]))
|
|
95
|
+
with pytest.raises(MailTesterRateLimitError) as e:
|
|
96
|
+
MailTester("free").verify("info@apple.com")
|
|
97
|
+
assert e.value.retry_after == 29 and e.value.status == 429
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_paid_rate_limit_plain_text(monkeypatch):
|
|
101
|
+
monkeypatch.setattr(MailTester, "_get", fake([(429, "Too Many Requests - Your rate is now 25")]))
|
|
102
|
+
with pytest.raises(MailTesterRateLimitError) as e:
|
|
103
|
+
MailTester("sub_1").verify("a@b.com")
|
|
104
|
+
assert e.value.retry_after is None and "Too Many Requests" in e.value.message
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_is_deliverable(monkeypatch):
|
|
108
|
+
monkeypatch.setattr(MailTester, "_get", fake([(200, json.dumps(dict(OK, code="ko", message="Rejected")))]))
|
|
109
|
+
assert MailTester("sub_1").is_deliverable("a@b.com") is False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_verify_many_continues_after_errors(monkeypatch):
|
|
113
|
+
calls = []
|
|
114
|
+
replies = [(200, json.dumps(OK)), (403, "Missing email param"), (200, json.dumps(dict(OK, code="mb", message="Catch-All")))]
|
|
115
|
+
monkeypatch.setattr(MailTester, "_get", fake(replies, calls))
|
|
116
|
+
out = list(MailTester("sub_1").verify_many(["info@apple.com", " ", "x@y.com", "c@d.com"], delay=0))
|
|
117
|
+
assert [e for e, _, _ in out] == ["info@apple.com", "x@y.com", "c@d.com"]
|
|
118
|
+
assert out[0][1].code == "ok" and isinstance(out[1][2], MailTesterError) and out[2][1].message == "Catch-All"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_verify_many_retries_after_free_limit(monkeypatch):
|
|
122
|
+
limited = dict(OK, code="--", message="Free API: one request every 30 s, retry in 0 s.")
|
|
123
|
+
monkeypatch.setattr(MailTester, "_get", fake([(429, json.dumps(limited)), (200, json.dumps(OK))]))
|
|
124
|
+
monkeypatch.setattr("time.sleep", lambda s: None)
|
|
125
|
+
out = list(MailTester("free").verify_many(["info@apple.com"], delay=0))
|
|
126
|
+
assert len(out) == 1 and out[0][1].code == "ok"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.skipif(os.environ.get("MAILTESTER_LIVE") != "1", reason="live test: set MAILTESTER_LIVE=1")
|
|
130
|
+
def test_live_free_tier():
|
|
131
|
+
r = MailTester(os.environ.get("MAILTESTER_KEY") or "free").verify("info@apple.com")
|
|
132
|
+
assert r.code in ("ok", "ko", "mb")
|