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.
@@ -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,5 @@
1
+ """FastMCP application instance for the email validation server."""
2
+
3
+ from fastmcp import FastMCP
4
+
5
+ mcp = FastMCP(name="Email Validator", version="0.2.0")
@@ -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
+ ]