emailsherlock-sdk 0.1.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.
@@ -0,0 +1,14 @@
1
+ name: ci
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python: ['3.8', '3.9', '3.11', '3.12']
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with: { python-version: '${{ matrix.python }}' }
13
+ - run: pip install pytest
14
+ - run: pytest -q
@@ -0,0 +1,20 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write # PyPI Trusted Publishing (OIDC)
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with: { python-version: '3.11' }
17
+ - run: pip install build pytest
18
+ - run: pytest -q
19
+ - run: python -m build
20
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EmailSherlock
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,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: emailsherlock-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the EmailSherlock email-verification API.
5
+ Project-URL: Homepage, https://emailsherlock.com
6
+ Project-URL: Documentation, https://emailsherlock.com/api/docs
7
+ Project-URL: Source, https://github.com/Emailsherlock1/python
8
+ Author: EmailSherlock
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: disposable,email,email-validation,email-verification,emailsherlock,mx,smtp
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Communications :: Email
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+
20
+ # emailsherlock-sdk
21
+
22
+ Official Python client for the [EmailSherlock](https://emailsherlock.com) email-verification API. Verify one address or a batch over HTTPS with an API key.
23
+
24
+ Zero third-party dependencies (uses only the standard library). Python 3.8+.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install emailsherlock-sdk
30
+ ```
31
+
32
+ The distribution is `emailsherlock-sdk`; the import is `emailsherlock`:
33
+
34
+ ```python
35
+ from emailsherlock import Emailsherlock
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```python
41
+ import os
42
+ from emailsherlock import Emailsherlock
43
+
44
+ # reads the key from the environment, never hard-code it
45
+ es = Emailsherlock(api_key=os.environ["ES_KEY"])
46
+
47
+ result = es.verify.single(email="jane@acme.com")
48
+
49
+ print(result.result) # "valid"
50
+ print(result.score) # 0.95
51
+ ```
52
+
53
+ Called with no argument, `Emailsherlock()` reads `ES_KEY` (or
54
+ `EMAILSHERLOCK_API_KEY`) from the environment.
55
+
56
+ ## Batch
57
+
58
+ Up to 100 addresses per call:
59
+
60
+ ```python
61
+ batch = es.verify.batch(emails=["jane@acme.com", "sales@acme.com"])
62
+
63
+ for item in batch.results:
64
+ if isinstance(item, VerifyResult):
65
+ print(item.email, item.result)
66
+ else: # BatchItemError
67
+ print(item.email, "failed:", item.error)
68
+ ```
69
+
70
+ ## The result object
71
+
72
+ `VerifyResult` mirrors the API JSON:
73
+
74
+ | attribute | type | meaning |
75
+ |--------------|-------|-----------------------------------------------------------------|
76
+ | `email` | str | the address you sent |
77
+ | `result` | str | `valid` · `invalid` · `catch_all` · `disposable` · `role` · `unknown` |
78
+ | `mx` | bool | the domain has reachable MX records |
79
+ | `disposable` | bool | throwaway / temporary-mail provider |
80
+ | `role` | bool | role address such as `info@` or `sales@` |
81
+ | `catch_all` | bool | host accepts mail for any local part |
82
+ | `score` | float | 0–1 confidence, higher is safer to send to |
83
+ | `freshness` | str | `fresh` · `cached_recent` · `cached_stale_refreshed` |
84
+
85
+ ## Credits and rate limits
86
+
87
+ After every call:
88
+
89
+ ```python
90
+ es.credits_remaining # e.g. 41
91
+ es.rate_limit # {"limit": 60, "remaining": 59, "reset": 1700000000}
92
+ ```
93
+
94
+ ## Errors
95
+
96
+ Every failure raises a subclass of `EmailsherlockError`:
97
+
98
+ | class | HTTP | when |
99
+ |-----------------------------|------|-----------------------------------------------|
100
+ | `AuthenticationError` | 401 | missing or invalid API key |
101
+ | `ForbiddenError` | 403 | key lacks the endpoint's scope (`required_scope`) |
102
+ | `InsufficientCreditsError` | 402 | not enough credits (`credits_required`, `credits_remaining`) |
103
+ | `RateLimitError` | 429 | rate limit hit (`retry_after`, `limit`, `remaining`, `reset`) |
104
+ | `ValidationError` | 400 / 422 | the request body was rejected |
105
+ | `ServiceUnavailableError` | 503 | verify engine unavailable (the credit is auto-refunded) |
106
+
107
+ ```python
108
+ from emailsherlock import RateLimitError
109
+
110
+ try:
111
+ es.verify.single(email="jane@acme.com")
112
+ except RateLimitError as err:
113
+ print(f"retry after {err.retry_after}s")
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT. Full API reference: https://emailsherlock.com/api/docs
@@ -0,0 +1,99 @@
1
+ # emailsherlock-sdk
2
+
3
+ Official Python client for the [EmailSherlock](https://emailsherlock.com) email-verification API. Verify one address or a batch over HTTPS with an API key.
4
+
5
+ Zero third-party dependencies (uses only the standard library). Python 3.8+.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install emailsherlock-sdk
11
+ ```
12
+
13
+ The distribution is `emailsherlock-sdk`; the import is `emailsherlock`:
14
+
15
+ ```python
16
+ from emailsherlock import Emailsherlock
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ import os
23
+ from emailsherlock import Emailsherlock
24
+
25
+ # reads the key from the environment, never hard-code it
26
+ es = Emailsherlock(api_key=os.environ["ES_KEY"])
27
+
28
+ result = es.verify.single(email="jane@acme.com")
29
+
30
+ print(result.result) # "valid"
31
+ print(result.score) # 0.95
32
+ ```
33
+
34
+ Called with no argument, `Emailsherlock()` reads `ES_KEY` (or
35
+ `EMAILSHERLOCK_API_KEY`) from the environment.
36
+
37
+ ## Batch
38
+
39
+ Up to 100 addresses per call:
40
+
41
+ ```python
42
+ batch = es.verify.batch(emails=["jane@acme.com", "sales@acme.com"])
43
+
44
+ for item in batch.results:
45
+ if isinstance(item, VerifyResult):
46
+ print(item.email, item.result)
47
+ else: # BatchItemError
48
+ print(item.email, "failed:", item.error)
49
+ ```
50
+
51
+ ## The result object
52
+
53
+ `VerifyResult` mirrors the API JSON:
54
+
55
+ | attribute | type | meaning |
56
+ |--------------|-------|-----------------------------------------------------------------|
57
+ | `email` | str | the address you sent |
58
+ | `result` | str | `valid` · `invalid` · `catch_all` · `disposable` · `role` · `unknown` |
59
+ | `mx` | bool | the domain has reachable MX records |
60
+ | `disposable` | bool | throwaway / temporary-mail provider |
61
+ | `role` | bool | role address such as `info@` or `sales@` |
62
+ | `catch_all` | bool | host accepts mail for any local part |
63
+ | `score` | float | 0–1 confidence, higher is safer to send to |
64
+ | `freshness` | str | `fresh` · `cached_recent` · `cached_stale_refreshed` |
65
+
66
+ ## Credits and rate limits
67
+
68
+ After every call:
69
+
70
+ ```python
71
+ es.credits_remaining # e.g. 41
72
+ es.rate_limit # {"limit": 60, "remaining": 59, "reset": 1700000000}
73
+ ```
74
+
75
+ ## Errors
76
+
77
+ Every failure raises a subclass of `EmailsherlockError`:
78
+
79
+ | class | HTTP | when |
80
+ |-----------------------------|------|-----------------------------------------------|
81
+ | `AuthenticationError` | 401 | missing or invalid API key |
82
+ | `ForbiddenError` | 403 | key lacks the endpoint's scope (`required_scope`) |
83
+ | `InsufficientCreditsError` | 402 | not enough credits (`credits_required`, `credits_remaining`) |
84
+ | `RateLimitError` | 429 | rate limit hit (`retry_after`, `limit`, `remaining`, `reset`) |
85
+ | `ValidationError` | 400 / 422 | the request body was rejected |
86
+ | `ServiceUnavailableError` | 503 | verify engine unavailable (the credit is auto-refunded) |
87
+
88
+ ```python
89
+ from emailsherlock import RateLimitError
90
+
91
+ try:
92
+ es.verify.single(email="jane@acme.com")
93
+ except RateLimitError as err:
94
+ print(f"retry after {err.retry_after}s")
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT. Full API reference: https://emailsherlock.com/api/docs
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "emailsherlock-sdk"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the EmailSherlock email-verification API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "EmailSherlock" }]
13
+ keywords = ["email", "email-verification", "email-validation", "emailsherlock", "mx", "smtp", "disposable"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Communications :: Email",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://emailsherlock.com"
25
+ Documentation = "https://emailsherlock.com/api/docs"
26
+ Source = "https://github.com/Emailsherlock1/python"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/emailsherlock"]
@@ -0,0 +1,28 @@
1
+ """Official Python client for the EmailSherlock email-verification API."""
2
+ from ._version import __version__
3
+ from .client import Emailsherlock
4
+ from .errors import (
5
+ AuthenticationError,
6
+ EmailsherlockError,
7
+ ForbiddenError,
8
+ InsufficientCreditsError,
9
+ RateLimitError,
10
+ ServiceUnavailableError,
11
+ ValidationError,
12
+ )
13
+ from .models import BatchItemError, BatchResponse, VerifyResult
14
+
15
+ __all__ = [
16
+ "__version__",
17
+ "Emailsherlock",
18
+ "VerifyResult",
19
+ "BatchResponse",
20
+ "BatchItemError",
21
+ "EmailsherlockError",
22
+ "AuthenticationError",
23
+ "ForbiddenError",
24
+ "InsufficientCreditsError",
25
+ "RateLimitError",
26
+ "ValidationError",
27
+ "ServiceUnavailableError",
28
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,133 @@
1
+ """HTTP client for the EmailSherlock verify API. Zero third-party dependencies."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from ._version import __version__
11
+ from .errors import EmailsherlockError, error_from_response
12
+ from .models import BatchResponse, VerifyResult
13
+
14
+ DEFAULT_BASE_URL = "https://api.emailsherlock.com"
15
+
16
+
17
+ def _num(value: Optional[str]) -> Optional[float]:
18
+ if value is None:
19
+ return None
20
+ try:
21
+ return float(value)
22
+ except (TypeError, ValueError):
23
+ return None
24
+
25
+
26
+ class _VerifyResource:
27
+ """Verify-endpoint methods, reached as ``client.verify``."""
28
+
29
+ def __init__(self, client: "Emailsherlock") -> None:
30
+ self._client = client
31
+
32
+ def single(self, email: str) -> VerifyResult:
33
+ """Verify a single address."""
34
+ data = self._client._request("/v1/verify/single", {"email": email})
35
+ return VerifyResult.from_dict(data)
36
+
37
+ def batch(self, emails: List[str]) -> BatchResponse:
38
+ """Verify up to 100 addresses in one call."""
39
+ data = self._client._request("/v1/verify/batch", {"emails": list(emails)})
40
+ return BatchResponse.from_dict(data)
41
+
42
+
43
+ class Emailsherlock:
44
+ """Client for the EmailSherlock verify API.
45
+
46
+ The API key is read from ``api_key`` or, if omitted, from the ``ES_KEY`` /
47
+ ``EMAILSHERLOCK_API_KEY`` environment variables.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ api_key: Optional[str] = None,
53
+ *,
54
+ base_url: str = DEFAULT_BASE_URL,
55
+ timeout: float = 30.0,
56
+ ) -> None:
57
+ key = api_key or os.environ.get("ES_KEY") or os.environ.get("EMAILSHERLOCK_API_KEY")
58
+ if not key:
59
+ raise EmailsherlockError(
60
+ "No API key provided. Pass api_key= or set ES_KEY.",
61
+ code="config_error",
62
+ )
63
+ self._api_key = key
64
+ self._base_url = base_url.rstrip("/")
65
+ self._timeout = timeout
66
+
67
+ #: credits left after the most recent request (X-Credits-Remaining)
68
+ self.credits_remaining: Optional[float] = None
69
+ #: rate-limit window after the most recent request (X-RateLimit-*)
70
+ self.rate_limit: Dict[str, Optional[float]] = {
71
+ "limit": None,
72
+ "remaining": None,
73
+ "reset": None,
74
+ }
75
+
76
+ self.verify = _VerifyResource(self)
77
+
78
+ def _request(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
79
+ url = self._base_url + path
80
+ body = json.dumps(payload).encode("utf-8")
81
+ request = urllib.request.Request(
82
+ url,
83
+ data=body,
84
+ method="POST",
85
+ headers={
86
+ "X-API-Key": self._api_key,
87
+ "Content-Type": "application/json",
88
+ "Accept": "application/json",
89
+ "User-Agent": "emailsherlock-python/{}".format(__version__),
90
+ },
91
+ )
92
+
93
+ try:
94
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
95
+ raw = response.read().decode("utf-8")
96
+ self._capture_meta(response.headers)
97
+ status = response.status
98
+ headers = response.headers
99
+ except urllib.error.HTTPError as exc:
100
+ raw = exc.read().decode("utf-8")
101
+ self._capture_meta(exc.headers)
102
+ parsed = self._parse(raw)
103
+ raise error_from_response(exc.code, parsed, exc.headers)
104
+ except urllib.error.URLError as exc:
105
+ raise EmailsherlockError(
106
+ "Request to {} failed: {}".format(path, exc.reason),
107
+ code="network_error",
108
+ )
109
+
110
+ parsed = self._parse(raw)
111
+ if status >= 400:
112
+ raise error_from_response(status, parsed, headers)
113
+ return parsed if isinstance(parsed, dict) else {}
114
+
115
+ def _capture_meta(self, headers: Any) -> None:
116
+ credits = headers.get("X-Credits-Remaining")
117
+ if credits is not None:
118
+ self.credits_remaining = _num(credits)
119
+ if headers.get("X-RateLimit-Limit") is not None:
120
+ self.rate_limit = {
121
+ "limit": _num(headers.get("X-RateLimit-Limit")),
122
+ "remaining": _num(headers.get("X-RateLimit-Remaining")),
123
+ "reset": _num(headers.get("X-RateLimit-Reset")),
124
+ }
125
+
126
+ @staticmethod
127
+ def _parse(raw: str) -> Optional[Dict[str, Any]]:
128
+ if not raw:
129
+ return None
130
+ try:
131
+ return json.loads(raw)
132
+ except json.JSONDecodeError:
133
+ return None
@@ -0,0 +1,125 @@
1
+ """Exception hierarchy. Every failure raises a subclass of EmailsherlockError."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, Optional
5
+
6
+
7
+ def _num(value: Optional[str]) -> Optional[float]:
8
+ if value is None:
9
+ return None
10
+ try:
11
+ return float(value)
12
+ except (TypeError, ValueError):
13
+ return None
14
+
15
+
16
+ class EmailsherlockError(Exception):
17
+ """Base class for every error this client raises."""
18
+
19
+ def __init__(
20
+ self,
21
+ message: str,
22
+ status: Optional[int] = None,
23
+ code: Optional[str] = None,
24
+ ) -> None:
25
+ super().__init__(message)
26
+ self.message = message
27
+ self.status = status
28
+ self.code = code
29
+
30
+
31
+ class AuthenticationError(EmailsherlockError):
32
+ """401 - missing or invalid API key."""
33
+
34
+
35
+ class ForbiddenError(EmailsherlockError):
36
+ """403 - the key lacks the scope this endpoint needs."""
37
+
38
+ def __init__(self, *args: Any, required_scope: Optional[str] = None, **kw: Any) -> None:
39
+ super().__init__(*args, **kw)
40
+ self.required_scope = required_scope
41
+
42
+
43
+ class InsufficientCreditsError(EmailsherlockError):
44
+ """402 - not enough credits on the wallet for this request."""
45
+
46
+ def __init__(
47
+ self,
48
+ *args: Any,
49
+ credits_required: Optional[float] = None,
50
+ credits_remaining: Optional[float] = None,
51
+ **kw: Any,
52
+ ) -> None:
53
+ super().__init__(*args, **kw)
54
+ self.credits_required = credits_required
55
+ self.credits_remaining = credits_remaining
56
+
57
+
58
+ class RateLimitError(EmailsherlockError):
59
+ """429 - per-key sliding-window rate limit hit."""
60
+
61
+ def __init__(
62
+ self,
63
+ *args: Any,
64
+ retry_after: Optional[float] = None,
65
+ limit: Optional[float] = None,
66
+ remaining: Optional[float] = None,
67
+ reset: Optional[float] = None,
68
+ **kw: Any,
69
+ ) -> None:
70
+ super().__init__(*args, **kw)
71
+ self.retry_after = retry_after
72
+ self.limit = limit
73
+ self.remaining = remaining
74
+ self.reset = reset
75
+
76
+
77
+ class ValidationError(EmailsherlockError):
78
+ """400 / 422 - the request body was rejected."""
79
+
80
+
81
+ class ServiceUnavailableError(EmailsherlockError):
82
+ """503 - the verify engine could not answer; the credit was auto-refunded."""
83
+
84
+
85
+ def error_from_response(
86
+ status: int,
87
+ body: Optional[Dict[str, Any]],
88
+ headers: Any,
89
+ ) -> EmailsherlockError:
90
+ envelope = (body or {}).get("error", {}) if isinstance(body, dict) else {}
91
+ code = envelope.get("code")
92
+ message = envelope.get("message") or "HTTP {}".format(status)
93
+
94
+ if status == 401:
95
+ return AuthenticationError(message, status=status, code=code)
96
+ if status == 403:
97
+ return ForbiddenError(
98
+ message,
99
+ status=status,
100
+ code=code,
101
+ required_scope=envelope.get("required_scope") or headers.get("X-Required-Scope"),
102
+ )
103
+ if status == 402:
104
+ return InsufficientCreditsError(
105
+ message,
106
+ status=status,
107
+ code=code,
108
+ credits_required=_num(headers.get("X-Credits-Required")),
109
+ credits_remaining=_num(headers.get("X-Credits-Remaining")),
110
+ )
111
+ if status == 429:
112
+ return RateLimitError(
113
+ message,
114
+ status=status,
115
+ code=code,
116
+ retry_after=_num(headers.get("Retry-After")),
117
+ limit=_num(headers.get("X-RateLimit-Limit")),
118
+ remaining=_num(headers.get("X-RateLimit-Remaining")),
119
+ reset=_num(headers.get("X-RateLimit-Reset")),
120
+ )
121
+ if status in (400, 422):
122
+ return ValidationError(message, status=status, code=code)
123
+ if status == 503:
124
+ return ServiceUnavailableError(message, status=status, code=code)
125
+ return EmailsherlockError(message, status=status, code=code)
@@ -0,0 +1,66 @@
1
+ """Typed result objects returned by the client."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Dict, List, Optional, Union
6
+
7
+
8
+ @dataclass
9
+ class VerifyResult:
10
+ """The result for one verified address. Mirrors the API JSON."""
11
+
12
+ email: str
13
+ result: str # valid | invalid | catch_all | disposable | role | unknown
14
+ mx: bool
15
+ disposable: bool
16
+ role: bool
17
+ catch_all: bool
18
+ score: float
19
+ freshness: str # fresh | cached_recent | cached_stale_refreshed
20
+ raw: Dict[str, Any] = field(default_factory=dict, repr=False)
21
+
22
+ @classmethod
23
+ def from_dict(cls, data: Dict[str, Any]) -> "VerifyResult":
24
+ return cls(
25
+ email=data.get("email", ""),
26
+ result=data.get("result", "unknown"),
27
+ mx=bool(data.get("mx", False)),
28
+ disposable=bool(data.get("disposable", False)),
29
+ role=bool(data.get("role", False)),
30
+ catch_all=bool(data.get("catch_all", False)),
31
+ score=float(data.get("score", 0.0)),
32
+ freshness=data.get("freshness", ""),
33
+ raw=data,
34
+ )
35
+
36
+
37
+ @dataclass
38
+ class BatchItemError:
39
+ """A per-address failure inside a batch response."""
40
+
41
+ email: Optional[str]
42
+ error: str # invalid_email | insufficient_credits | verify_unavailable
43
+
44
+ @classmethod
45
+ def from_dict(cls, data: Dict[str, Any]) -> "BatchItemError":
46
+ return cls(email=data.get("email"), error=data.get("error", "unknown"))
47
+
48
+
49
+ BatchItem = Union[VerifyResult, BatchItemError]
50
+
51
+
52
+ @dataclass
53
+ class BatchResponse:
54
+ """The response of a batch call: a list of results and/or per-item errors."""
55
+
56
+ results: List[BatchItem]
57
+
58
+ @classmethod
59
+ def from_dict(cls, data: Dict[str, Any]) -> "BatchResponse":
60
+ items: List[BatchItem] = []
61
+ for item in data.get("results", []):
62
+ if isinstance(item, dict) and "error" in item:
63
+ items.append(BatchItemError.from_dict(item))
64
+ else:
65
+ items.append(VerifyResult.from_dict(item))
66
+ return cls(results=items)
@@ -0,0 +1,125 @@
1
+ """Unit tests with a stubbed urlopen. No network."""
2
+ import io
3
+ import json
4
+ import sys
5
+ import urllib.error
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11
+
12
+ import emailsherlock
13
+ from emailsherlock import (
14
+ AuthenticationError,
15
+ Emailsherlock,
16
+ InsufficientCreditsError,
17
+ RateLimitError,
18
+ VerifyResult,
19
+ BatchItemError,
20
+ )
21
+
22
+
23
+ class FakeHeaders(dict):
24
+ def get(self, key, default=None):
25
+ for k, v in self.items():
26
+ if k.lower() == key.lower():
27
+ return v
28
+ return default
29
+
30
+
31
+ class FakeResponse:
32
+ def __init__(self, status, body, headers):
33
+ self.status = status
34
+ self._body = json.dumps(body).encode()
35
+ self.headers = FakeHeaders(headers)
36
+
37
+ def read(self):
38
+ return self._body
39
+
40
+ def __enter__(self):
41
+ return self
42
+
43
+ def __exit__(self, *a):
44
+ return False
45
+
46
+
47
+ def patch(monkeypatch, status, body, headers=None):
48
+ headers = headers or {}
49
+ if status >= 400:
50
+ def raiser(req, timeout=None):
51
+ raise urllib.error.HTTPError(
52
+ req.full_url, status, "err", FakeHeaders(headers),
53
+ io.BytesIO(json.dumps(body).encode()),
54
+ )
55
+ monkeypatch.setattr(emailsherlock.client.urllib.request, "urlopen", raiser)
56
+ else:
57
+ monkeypatch.setattr(
58
+ emailsherlock.client.urllib.request,
59
+ "urlopen",
60
+ lambda req, timeout=None: FakeResponse(status, body, headers),
61
+ )
62
+
63
+
64
+ def test_single_ok(monkeypatch):
65
+ patch(
66
+ monkeypatch, 200,
67
+ {"email": "jane@acme.com", "result": "valid", "mx": True, "disposable": False,
68
+ "role": False, "catch_all": False, "score": 0.95, "freshness": "fresh"},
69
+ {"X-Credits-Remaining": "41", "X-RateLimit-Limit": "60", "X-RateLimit-Remaining": "59", "X-RateLimit-Reset": "1700000000"},
70
+ )
71
+ es = Emailsherlock("k")
72
+ result = es.verify.single("jane@acme.com")
73
+ assert isinstance(result, VerifyResult)
74
+ assert result.result == "valid"
75
+ assert result.score == 0.95
76
+ assert es.credits_remaining == 41
77
+ assert es.rate_limit["limit"] == 60
78
+
79
+
80
+ def test_batch_mixed(monkeypatch):
81
+ patch(monkeypatch, 200, {"results": [
82
+ {"email": "jane@acme.com", "result": "valid", "mx": True, "disposable": False, "role": False, "catch_all": False, "score": 0.9, "freshness": "fresh"},
83
+ {"email": "nope@", "error": "invalid_email"},
84
+ ]})
85
+ es = Emailsherlock("k")
86
+ batch = es.verify.batch(["jane@acme.com", "nope@"])
87
+ assert len(batch.results) == 2
88
+ assert isinstance(batch.results[0], VerifyResult)
89
+ assert isinstance(batch.results[1], BatchItemError)
90
+ assert batch.results[1].error == "invalid_email"
91
+
92
+
93
+ def test_401(monkeypatch):
94
+ patch(monkeypatch, 401, {"error": {"code": "unauthorized", "message": "Invalid API key."}})
95
+ es = Emailsherlock("bad")
96
+ with pytest.raises(AuthenticationError) as exc:
97
+ es.verify.single("a@b.com")
98
+ assert exc.value.status == 401
99
+ assert exc.value.code == "unauthorized"
100
+
101
+
102
+ def test_402(monkeypatch):
103
+ patch(monkeypatch, 402, {"error": {"code": "insufficient_credits", "message": "Not enough."}},
104
+ {"X-Credits-Required": "1", "X-Credits-Remaining": "0"})
105
+ es = Emailsherlock("k")
106
+ with pytest.raises(InsufficientCreditsError) as exc:
107
+ es.verify.single("a@b.com")
108
+ assert exc.value.credits_required == 1
109
+ assert exc.value.credits_remaining == 0
110
+
111
+
112
+ def test_429(monkeypatch):
113
+ patch(monkeypatch, 429, {"error": {"code": "rate_limit_exceeded", "message": "Slow down."}},
114
+ {"Retry-After": "30"})
115
+ es = Emailsherlock("k")
116
+ with pytest.raises(RateLimitError) as exc:
117
+ es.verify.single("a@b.com")
118
+ assert exc.value.retry_after == 30
119
+
120
+
121
+ def test_missing_key(monkeypatch):
122
+ monkeypatch.delenv("ES_KEY", raising=False)
123
+ monkeypatch.delenv("EMAILSHERLOCK_API_KEY", raising=False)
124
+ with pytest.raises(emailsherlock.EmailsherlockError):
125
+ Emailsherlock()