mcp-email-validator 0.2.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.
- mcp_email_validator-0.2.0/LICENSE +21 -0
- mcp_email_validator-0.2.0/PKG-INFO +97 -0
- mcp_email_validator-0.2.0/README.md +82 -0
- mcp_email_validator-0.2.0/pyproject.toml +28 -0
- mcp_email_validator-0.2.0/pyproject.toml.orig +28 -0
- mcp_email_validator-0.2.0/src/mcp_email_validator/__init__.py +18 -0
- mcp_email_validator-0.2.0/src/mcp_email_validator/cli.py +38 -0
- mcp_email_validator-0.2.0/src/mcp_email_validator/mcp.py +5 -0
- mcp_email_validator-0.2.0/src/mcp_email_validator/tools.py +35 -0
- mcp_email_validator-0.2.0/src/mcp_email_validator/validation.py +219 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jayanth MKV
|
|
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,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-email-validator
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A safety-bounded MCP server for email syntax, DNS, and opt-in SMTP checks
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: dnspython>=2.9.0,<3
|
|
8
|
+
Requires-Dist: fastmcp>=4.1.4,<5
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Project-URL: Homepage, https://github.com/jayanth-mkv/agent-mcp-registry
|
|
11
|
+
Project-URL: Documentation, https://github.com/jayanth-mkv/agent-mcp-registry/tree/main/mcp-email-validator
|
|
12
|
+
Project-URL: Repository, https://github.com/jayanth-mkv/agent-mcp-registry
|
|
13
|
+
Project-URL: Issues, https://github.com/jayanth-mkv/agent-mcp-registry/issues
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Email Validator MCP
|
|
17
|
+
|
|
18
|
+
An MCP server for checking email syntax and DNS mail routes without sending a message. Optional SMTP probing provides a recipient acceptance signal; it does not prove ownership or guarantee delivery.
|
|
19
|
+
|
|
20
|
+
## Install a published release
|
|
21
|
+
|
|
22
|
+
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uvx --python 3.13 mcp-email-validator@0.2.0
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Published versions are listed on [PyPI](https://pypi.org/project/mcp-email-validator/). If the version is not available yet, use the GitHub command below. Publishing is managed through [GitHub Actions](https://github.com/jayanth-mkv/agent-mcp-registry/blob/main/docs/publishing.md).
|
|
29
|
+
|
|
30
|
+
## Run from GitHub without cloning
|
|
31
|
+
|
|
32
|
+
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/) and Git. uv downloads the selected package and manages Python 3.13 and its dependencies:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uvx --python 3.13 --from "git+https://github.com/jayanth-mkv/agent-mcp-registry.git@main#subdirectory=mcp-email-validator" mcp-email-validator
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Only this MCP and its runtime dependencies are installed. uv fetches the repository internally; you do not need a local checkout. To pin the source, replace `main` with a release tag or full commit SHA.
|
|
39
|
+
|
|
40
|
+
## Install from source
|
|
41
|
+
|
|
42
|
+
From this directory, with Python 3.13+ and [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv sync --locked --no-dev
|
|
46
|
+
uv run --locked --no-dev mcp-email-validator
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The default transport is stdio. Configure your MCP client using the [repository example](https://github.com/jayanth-mkv/agent-mcp-registry#connect-an-mcp-client). No API key is required; DNS checks need network access.
|
|
50
|
+
|
|
51
|
+
For Streamable HTTP:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv run --locked --no-dev mcp-email-validator --transport http --host 127.0.0.1 --port 8003
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Endpoint: `http://127.0.0.1:8003/mcp`. Legacy SSE is available with `--transport sse` at `/sse`. HTTP and SSE have no authentication; keep them on loopback unless protected separately.
|
|
58
|
+
|
|
59
|
+
## Tools
|
|
60
|
+
|
|
61
|
+
| Tool | Required argument | Optional arguments |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `validate_email` | `address: string` | `check_smtp=false`, `timeout_seconds=10` |
|
|
64
|
+
| `validate_email_batch` | `addresses: string[]` (up to 50) | `check_smtp=false`, `timeout_seconds=10` |
|
|
65
|
+
|
|
66
|
+
Both tools check conventional unquoted mailbox syntax and normalize the domain, then look up MX records. Domains without MX records may use A/AAAA fallback; a null MX means the domain does not accept mail. Invalid syntax skips DNS and SMTP.
|
|
67
|
+
|
|
68
|
+
Example tool arguments:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{ "address": "user@example.com", "check_smtp": false }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Results contain `normalized_address`, `syntax_valid`, `mail_route_status`, `mail_hosts`, `smtp_status`, and explanatory `notes`.
|
|
75
|
+
|
|
76
|
+
| Field | Possible values |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `mail_route_status` | `not_checked`, `found`, `fallback`, `not_found`, `lookup_error` |
|
|
79
|
+
| `smtp_status` | `not_requested`, `accepted`, `rejected`, `unavailable`, `error` |
|
|
80
|
+
|
|
81
|
+
## SMTP boundaries
|
|
82
|
+
|
|
83
|
+
SMTP probing is disabled unless `check_smtp=true`. It tries at most three mail hosts, connects to one public IP per host on port 25, and uses SMTP greeting, sender, and recipient commands. It never sends `DATA` or authenticates. DNS lookups and socket operations use a timeout clamped to 1–15 seconds; this is not a total request deadline, and batches run sequentially.
|
|
84
|
+
|
|
85
|
+
Use probing only for infrastructure and addresses you are authorized to assess. Catch-all domains, greylisting, sender policy, and anti-abuse controls can produce misleading results. Private and loopback IP addresses are not contacted. Address data is not written to an application database; DNS queries and optional SMTP probes are visible to the contacted infrastructure.
|
|
86
|
+
|
|
87
|
+
## Development
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv sync --locked
|
|
91
|
+
uv run --locked pytest -q
|
|
92
|
+
uv build
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Tests mock DNS/SMTP network operations and exercise the installed stdio server. Live recipient acceptance and delivery are separate checks.
|
|
96
|
+
|
|
97
|
+
Version 0.2.0. Licensed under the [MIT License](https://github.com/jayanth-mkv/agent-mcp-registry/blob/main/mcp-email-validator/LICENSE).
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Email Validator MCP
|
|
2
|
+
|
|
3
|
+
An MCP server for checking email syntax and DNS mail routes without sending a message. Optional SMTP probing provides a recipient acceptance signal; it does not prove ownership or guarantee delivery.
|
|
4
|
+
|
|
5
|
+
## Install a published release
|
|
6
|
+
|
|
7
|
+
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uvx --python 3.13 mcp-email-validator@0.2.0
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Published versions are listed on [PyPI](https://pypi.org/project/mcp-email-validator/). If the version is not available yet, use the GitHub command below. Publishing is managed through [GitHub Actions](https://github.com/jayanth-mkv/agent-mcp-registry/blob/main/docs/publishing.md).
|
|
14
|
+
|
|
15
|
+
## Run from GitHub without cloning
|
|
16
|
+
|
|
17
|
+
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/) and Git. uv downloads the selected package and manages Python 3.13 and its dependencies:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uvx --python 3.13 --from "git+https://github.com/jayanth-mkv/agent-mcp-registry.git@main#subdirectory=mcp-email-validator" mcp-email-validator
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Only this MCP and its runtime dependencies are installed. uv fetches the repository internally; you do not need a local checkout. To pin the source, replace `main` with a release tag or full commit SHA.
|
|
24
|
+
|
|
25
|
+
## Install from source
|
|
26
|
+
|
|
27
|
+
From this directory, with Python 3.13+ and [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv sync --locked --no-dev
|
|
31
|
+
uv run --locked --no-dev mcp-email-validator
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The default transport is stdio. Configure your MCP client using the [repository example](https://github.com/jayanth-mkv/agent-mcp-registry#connect-an-mcp-client). No API key is required; DNS checks need network access.
|
|
35
|
+
|
|
36
|
+
For Streamable HTTP:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv run --locked --no-dev mcp-email-validator --transport http --host 127.0.0.1 --port 8003
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Endpoint: `http://127.0.0.1:8003/mcp`. Legacy SSE is available with `--transport sse` at `/sse`. HTTP and SSE have no authentication; keep them on loopback unless protected separately.
|
|
43
|
+
|
|
44
|
+
## Tools
|
|
45
|
+
|
|
46
|
+
| Tool | Required argument | Optional arguments |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `validate_email` | `address: string` | `check_smtp=false`, `timeout_seconds=10` |
|
|
49
|
+
| `validate_email_batch` | `addresses: string[]` (up to 50) | `check_smtp=false`, `timeout_seconds=10` |
|
|
50
|
+
|
|
51
|
+
Both tools check conventional unquoted mailbox syntax and normalize the domain, then look up MX records. Domains without MX records may use A/AAAA fallback; a null MX means the domain does not accept mail. Invalid syntax skips DNS and SMTP.
|
|
52
|
+
|
|
53
|
+
Example tool arguments:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{ "address": "user@example.com", "check_smtp": false }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Results contain `normalized_address`, `syntax_valid`, `mail_route_status`, `mail_hosts`, `smtp_status`, and explanatory `notes`.
|
|
60
|
+
|
|
61
|
+
| Field | Possible values |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `mail_route_status` | `not_checked`, `found`, `fallback`, `not_found`, `lookup_error` |
|
|
64
|
+
| `smtp_status` | `not_requested`, `accepted`, `rejected`, `unavailable`, `error` |
|
|
65
|
+
|
|
66
|
+
## SMTP boundaries
|
|
67
|
+
|
|
68
|
+
SMTP probing is disabled unless `check_smtp=true`. It tries at most three mail hosts, connects to one public IP per host on port 25, and uses SMTP greeting, sender, and recipient commands. It never sends `DATA` or authenticates. DNS lookups and socket operations use a timeout clamped to 1–15 seconds; this is not a total request deadline, and batches run sequentially.
|
|
69
|
+
|
|
70
|
+
Use probing only for infrastructure and addresses you are authorized to assess. Catch-all domains, greylisting, sender policy, and anti-abuse controls can produce misleading results. Private and loopback IP addresses are not contacted. Address data is not written to an application database; DNS queries and optional SMTP probes are visible to the contacted infrastructure.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv sync --locked
|
|
76
|
+
uv run --locked pytest -q
|
|
77
|
+
uv build
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Tests mock DNS/SMTP network operations and exercise the installed stdio server. Live recipient acceptance and delivery are separate checks.
|
|
81
|
+
|
|
82
|
+
Version 0.2.0. Licensed under the [MIT License](https://github.com/jayanth-mkv/agent-mcp-registry/blob/main/mcp-email-validator/LICENSE).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-email-validator"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "A safety-bounded MCP server for email syntax, DNS, and opt-in SMTP checks"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"dnspython>=2.9.0,<3",
|
|
11
|
+
"fastmcp>=4.1.4,<5",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/jayanth-mkv/agent-mcp-registry"
|
|
16
|
+
Documentation = "https://github.com/jayanth-mkv/agent-mcp-registry/tree/main/mcp-email-validator"
|
|
17
|
+
Repository = "https://github.com/jayanth-mkv/agent-mcp-registry"
|
|
18
|
+
Issues = "https://github.com/jayanth-mkv/agent-mcp-registry/issues"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
mcp-email-validator = "mcp_email_validator.cli:main"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = ["pytest>=9.2.0,<10"]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["uv_build>=0.13.6,<0.14"]
|
|
28
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-email-validator"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "A safety-bounded MCP server for email syntax, DNS, and opt-in SMTP checks"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"dnspython>=2.9.0,<3",
|
|
11
|
+
"fastmcp>=4.1.4,<5",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/jayanth-mkv/agent-mcp-registry"
|
|
16
|
+
Documentation = "https://github.com/jayanth-mkv/agent-mcp-registry/tree/main/mcp-email-validator"
|
|
17
|
+
Repository = "https://github.com/jayanth-mkv/agent-mcp-registry"
|
|
18
|
+
Issues = "https://github.com/jayanth-mkv/agent-mcp-registry/issues"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
mcp-email-validator = "mcp_email_validator.cli:main"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = ["pytest>=9.2.0,<10"]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["uv_build>=0.13.6,<0.14"]
|
|
28
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Email Validator MCP server package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .mcp import mcp
|
|
6
|
+
from .tools import validate_email, validate_email_batch
|
|
7
|
+
|
|
8
|
+
__all__ = ["mcp", "run", "validate_email", "validate_email_batch"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(
|
|
12
|
+
host: str = "127.0.0.1", port: int = 8003, transport: str = "stdio"
|
|
13
|
+
) -> None:
|
|
14
|
+
"""Run the server with the requested local transport."""
|
|
15
|
+
if transport in {"sse", "http"}:
|
|
16
|
+
mcp.run(transport=transport, host=host, port=port)
|
|
17
|
+
else:
|
|
18
|
+
mcp.run(transport=transport)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Command-line entry point for Email Validator MCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from . import run
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
11
|
+
parser = argparse.ArgumentParser(description="Run the Email Validator MCP server.")
|
|
12
|
+
parser.add_argument(
|
|
13
|
+
"--transport",
|
|
14
|
+
choices=["stdio", "sse", "http"],
|
|
15
|
+
default="stdio",
|
|
16
|
+
help="MCP transport (default: stdio).",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--host",
|
|
20
|
+
default="127.0.0.1",
|
|
21
|
+
help="Host for HTTP or SSE transport (default: 127.0.0.1).",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--port",
|
|
25
|
+
type=int,
|
|
26
|
+
default=8003,
|
|
27
|
+
help="Port for HTTP or SSE transport (default: 8003).",
|
|
28
|
+
)
|
|
29
|
+
return parser
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> None:
|
|
33
|
+
args = build_parser().parse_args(argv)
|
|
34
|
+
run(host=args.host, port=args.port, transport=args.transport)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__": # pragma: no cover
|
|
38
|
+
main()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""MCP tool definitions for bounded email validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .mcp import mcp
|
|
8
|
+
from .validation import MAX_BATCH_SIZE, validate_address, validate_batch
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@mcp.tool
|
|
12
|
+
def validate_email(
|
|
13
|
+
address: str, check_smtp: bool = False, timeout_seconds: float = 10.0
|
|
14
|
+
) -> dict[str, Any]:
|
|
15
|
+
"""Check one email's syntax and mail route; SMTP probing is opt-in and sends no mail."""
|
|
16
|
+
return validate_address(
|
|
17
|
+
address, check_smtp=check_smtp, timeout_seconds=timeout_seconds
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@mcp.tool
|
|
22
|
+
def validate_email_batch(
|
|
23
|
+
addresses: list[str], check_smtp: bool = False, timeout_seconds: float = 10.0
|
|
24
|
+
) -> list[dict[str, Any]]:
|
|
25
|
+
"""Validate up to 50 addresses with optional, bounded SMTP recipient probes."""
|
|
26
|
+
if len(addresses) > MAX_BATCH_SIZE:
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
"error": f"Batch limit exceeded: submit at most {MAX_BATCH_SIZE} addresses.",
|
|
30
|
+
"smtp_status": "not_requested",
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
return validate_batch(
|
|
34
|
+
addresses, check_smtp=check_smtp, timeout_seconds=timeout_seconds
|
|
35
|
+
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Pure validation and deliberately bounded network-check helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import re
|
|
7
|
+
import smtplib
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import dns.exception
|
|
12
|
+
import dns.resolver
|
|
13
|
+
|
|
14
|
+
MAX_ADDRESS_LENGTH = 254
|
|
15
|
+
MAX_LOCAL_PART_LENGTH = 64
|
|
16
|
+
MAX_BATCH_SIZE = 50
|
|
17
|
+
MAX_SMTP_HOSTS = 3
|
|
18
|
+
MIN_TIMEOUT_SECONDS = 1.0
|
|
19
|
+
MAX_TIMEOUT_SECONDS = 15.0
|
|
20
|
+
LOCAL_PART_PATTERN = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _bounded_timeout(value: float) -> float:
|
|
24
|
+
"""Return a usable timeout without allowing callers to make probes linger."""
|
|
25
|
+
return max(MIN_TIMEOUT_SECONDS, min(float(value), MAX_TIMEOUT_SECONDS))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _normalise_address(address: str) -> tuple[str | None, str | None, str | None]:
|
|
29
|
+
"""Validate a conventional address and return normalized address and domain."""
|
|
30
|
+
candidate = address.strip()
|
|
31
|
+
if not candidate or len(candidate) > MAX_ADDRESS_LENGTH:
|
|
32
|
+
return None, None, "Address must be between 1 and 254 characters."
|
|
33
|
+
if candidate.count("@") != 1:
|
|
34
|
+
return None, None, "Address must contain one @ separator."
|
|
35
|
+
|
|
36
|
+
local_part, domain = candidate.rsplit("@", 1)
|
|
37
|
+
if not local_part or len(local_part) > MAX_LOCAL_PART_LENGTH:
|
|
38
|
+
return None, None, "Local part must be between 1 and 64 characters."
|
|
39
|
+
if not LOCAL_PART_PATTERN.fullmatch(local_part):
|
|
40
|
+
return None, None, "Local part contains unsupported characters."
|
|
41
|
+
if local_part.startswith(".") or local_part.endswith(".") or ".." in local_part:
|
|
42
|
+
return None, None, "Local part cannot begin, end, or repeat a dot."
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
ascii_domain = domain.encode("idna").decode("ascii").lower()
|
|
46
|
+
except UnicodeError:
|
|
47
|
+
return None, None, "Domain cannot be converted to IDNA."
|
|
48
|
+
if len(ascii_domain) > 253 or "." not in ascii_domain:
|
|
49
|
+
return None, None, "Domain must be a fully qualified hostname."
|
|
50
|
+
|
|
51
|
+
labels = ascii_domain.split(".")
|
|
52
|
+
if any(
|
|
53
|
+
not label
|
|
54
|
+
or len(label) > 63
|
|
55
|
+
or label.startswith("-")
|
|
56
|
+
or label.endswith("-")
|
|
57
|
+
or not all(character.isalnum() or character == "-" for character in label)
|
|
58
|
+
for label in labels
|
|
59
|
+
):
|
|
60
|
+
return None, None, "Domain contains an invalid hostname label."
|
|
61
|
+
|
|
62
|
+
return f"{local_part}@{ascii_domain}", ascii_domain, None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolver(timeout_seconds: float) -> dns.resolver.Resolver:
|
|
66
|
+
resolver = dns.resolver.Resolver(configure=True)
|
|
67
|
+
resolver.timeout = timeout_seconds
|
|
68
|
+
resolver.lifetime = timeout_seconds
|
|
69
|
+
return resolver
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_mail_hosts(domain: str, timeout_seconds: float) -> tuple[str, list[str], list[str]]:
|
|
73
|
+
"""Find MX hosts, falling back to the domain only when RFC fallback is possible."""
|
|
74
|
+
resolver = _resolver(timeout_seconds)
|
|
75
|
+
try:
|
|
76
|
+
answers = resolver.resolve(domain, "MX")
|
|
77
|
+
records = sorted(
|
|
78
|
+
(int(record.preference), str(record.exchange).rstrip(".")) for record in answers
|
|
79
|
+
)
|
|
80
|
+
if any(host == "" for _, host in records):
|
|
81
|
+
return "not_found", [], ["The domain publishes a null MX record and does not accept email."]
|
|
82
|
+
hosts = [host for _, host in records]
|
|
83
|
+
return "found", hosts, ["MX records were found for the domain."]
|
|
84
|
+
except dns.resolver.NoAnswer:
|
|
85
|
+
pass
|
|
86
|
+
except dns.resolver.NXDOMAIN:
|
|
87
|
+
return "not_found", [], ["The domain does not exist in DNS."]
|
|
88
|
+
except dns.exception.Timeout:
|
|
89
|
+
return "lookup_error", [], ["DNS lookup timed out before mail routes could be confirmed."]
|
|
90
|
+
except dns.exception.DNSException:
|
|
91
|
+
return "lookup_error", [], ["DNS lookup failed before mail routes could be confirmed."]
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
resolver.resolve(domain, "A")
|
|
95
|
+
return "fallback", [domain], ["No MX record; the domain has an A record and may use implicit MX fallback."]
|
|
96
|
+
except dns.resolver.NoAnswer:
|
|
97
|
+
pass
|
|
98
|
+
except dns.resolver.NXDOMAIN:
|
|
99
|
+
return "not_found", [], ["The domain does not exist in DNS."]
|
|
100
|
+
except dns.exception.Timeout:
|
|
101
|
+
return "lookup_error", [], ["DNS lookup timed out before mail routes could be confirmed."]
|
|
102
|
+
except dns.exception.DNSException:
|
|
103
|
+
return "lookup_error", [], ["DNS lookup failed before mail routes could be confirmed."]
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
resolver.resolve(domain, "AAAA")
|
|
107
|
+
return "fallback", [domain], ["No MX record; the domain has an AAAA record and may use implicit MX fallback."]
|
|
108
|
+
except dns.resolver.NoAnswer:
|
|
109
|
+
return "not_found", [], ["No MX, A, or AAAA mail route was found for the domain."]
|
|
110
|
+
except dns.resolver.NXDOMAIN:
|
|
111
|
+
return "not_found", [], ["The domain does not exist in DNS."]
|
|
112
|
+
except dns.exception.Timeout:
|
|
113
|
+
return "lookup_error", [], ["DNS lookup timed out before mail routes could be confirmed."]
|
|
114
|
+
except dns.exception.DNSException:
|
|
115
|
+
return "lookup_error", [], ["DNS lookup failed before mail routes could be confirmed."]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _public_ip_for_host(host: str, timeout_seconds: float) -> str | None:
|
|
119
|
+
"""Resolve one publicly routable address, avoiding private-network SMTP probes."""
|
|
120
|
+
resolver = _resolver(timeout_seconds)
|
|
121
|
+
for record_type in ("A", "AAAA"):
|
|
122
|
+
try:
|
|
123
|
+
answers = resolver.resolve(host, record_type)
|
|
124
|
+
except dns.resolver.NoAnswer:
|
|
125
|
+
continue
|
|
126
|
+
except dns.exception.DNSException:
|
|
127
|
+
return None
|
|
128
|
+
for answer in answers:
|
|
129
|
+
try:
|
|
130
|
+
candidate = ipaddress.ip_address(str(answer))
|
|
131
|
+
except ValueError:
|
|
132
|
+
continue
|
|
133
|
+
if candidate.is_global:
|
|
134
|
+
return str(candidate)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _probe_smtp(address: str, hosts: Iterable[str], timeout_seconds: float) -> tuple[str, list[str]]:
|
|
139
|
+
"""Attempt the smallest SMTP conversation needed for a recipient signal."""
|
|
140
|
+
attempted_hosts = 0
|
|
141
|
+
errors: list[str] = []
|
|
142
|
+
for host in list(hosts)[:MAX_SMTP_HOSTS]:
|
|
143
|
+
public_ip = _public_ip_for_host(host, timeout_seconds)
|
|
144
|
+
if not public_ip:
|
|
145
|
+
errors.append(f"{host}: no publicly routable address was available")
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
attempted_hosts += 1
|
|
149
|
+
try:
|
|
150
|
+
with smtplib.SMTP(host=public_ip, port=25, timeout=timeout_seconds) as client:
|
|
151
|
+
client.ehlo_or_helo_if_needed()
|
|
152
|
+
mail_code, _ = client.mail("mcp-email-validator@invalid.example")
|
|
153
|
+
if mail_code >= 400:
|
|
154
|
+
errors.append(f"{host}: server did not accept the probe sender")
|
|
155
|
+
continue
|
|
156
|
+
recipient_code, _ = client.rcpt(address)
|
|
157
|
+
except (OSError, smtplib.SMTPException):
|
|
158
|
+
errors.append(f"{host}: SMTP connection or protocol check failed")
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if 200 <= recipient_code < 300:
|
|
162
|
+
return "accepted", [f"A recipient command was accepted by {host}."]
|
|
163
|
+
if 500 <= recipient_code < 600:
|
|
164
|
+
return "rejected", [f"A recipient command was rejected by {host}."]
|
|
165
|
+
errors.append(f"{host}: SMTP server returned a temporary or indeterminate recipient response")
|
|
166
|
+
|
|
167
|
+
if not attempted_hosts:
|
|
168
|
+
return "unavailable", errors or ["No eligible public mail host was available for SMTP probing."]
|
|
169
|
+
return "error", errors or ["SMTP probing did not produce a recipient response."]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def validate_address(
|
|
173
|
+
address: str, *, check_smtp: bool = False, timeout_seconds: float = 10.0
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
"""Return bounded syntax, DNS, and optional SMTP facts for one address."""
|
|
176
|
+
timeout = _bounded_timeout(timeout_seconds)
|
|
177
|
+
normalized, domain, syntax_error = _normalise_address(address)
|
|
178
|
+
result: dict[str, Any] = {
|
|
179
|
+
"address": address,
|
|
180
|
+
"normalized_address": normalized,
|
|
181
|
+
"syntax_valid": syntax_error is None,
|
|
182
|
+
"domain": domain,
|
|
183
|
+
"mail_route_status": "not_checked",
|
|
184
|
+
"mail_hosts": [],
|
|
185
|
+
"smtp_status": "not_requested",
|
|
186
|
+
"notes": [],
|
|
187
|
+
}
|
|
188
|
+
if syntax_error:
|
|
189
|
+
result["notes"] = [syntax_error, "DNS and SMTP checks were skipped."]
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
route_status, hosts, route_notes = _resolve_mail_hosts(domain, timeout)
|
|
193
|
+
result["mail_route_status"] = route_status
|
|
194
|
+
result["mail_hosts"] = hosts
|
|
195
|
+
result["notes"] = route_notes
|
|
196
|
+
if not check_smtp:
|
|
197
|
+
result["notes"].append("SMTP probing was not requested.")
|
|
198
|
+
return result
|
|
199
|
+
if not hosts:
|
|
200
|
+
result["smtp_status"] = "unavailable"
|
|
201
|
+
result["notes"].append("SMTP probing was skipped because no mail host was available.")
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
smtp_status, smtp_notes = _probe_smtp(normalized, hosts, timeout)
|
|
205
|
+
result["smtp_status"] = smtp_status
|
|
206
|
+
result["notes"].extend(smtp_notes)
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def validate_batch(
|
|
211
|
+
addresses: list[str], *, check_smtp: bool = False, timeout_seconds: float = 10.0
|
|
212
|
+
) -> list[dict[str, Any]]:
|
|
213
|
+
"""Validate a deliberately small batch while preserving the caller's order."""
|
|
214
|
+
if len(addresses) > MAX_BATCH_SIZE:
|
|
215
|
+
raise ValueError(f"A batch can contain at most {MAX_BATCH_SIZE} addresses.")
|
|
216
|
+
return [
|
|
217
|
+
validate_address(address, check_smtp=check_smtp, timeout_seconds=timeout_seconds)
|
|
218
|
+
for address in addresses
|
|
219
|
+
]
|