canitsend-client 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.
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: canitsend-client
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Client for the CanItSend API — SPF/DKIM/DMARC audit from live DNS, SPF flattening, and email validation.
|
|
5
|
+
Project-URL: Homepage, https://canitsend.com
|
|
6
|
+
Project-URL: Documentation, https://canitsend.com/docs
|
|
7
|
+
Project-URL: Changelog, https://canitsend.com/docs
|
|
8
|
+
Author: CanItSend
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: deliverability,dkim,dmarc,dns,email,email-validation,smtp,spf
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Communications :: Email
|
|
17
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# canitsend-client
|
|
24
|
+
|
|
25
|
+
Zero-dependency Python client for the [CanItSend API](https://canitsend.com). Two questions, one API.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install canitsend-client
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every endpoint needs an API key. The **free tier includes 100 audits/month** — instant and self-serve: https://canitsend.com/#pricing
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from canitsend import CanItSendClient
|
|
35
|
+
cis = CanItSendClient(api_key="snd_live_...")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## 1. Can my domain send?
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
a = cis.check("acme.com") # also accepts "you@acme.com" or a URL
|
|
42
|
+
print(a["grade"], a["score"]) # F 40
|
|
43
|
+
|
|
44
|
+
# Plain-language answer to "but my email works!"
|
|
45
|
+
print(a["explanation"]["headline"])
|
|
46
|
+
# -> "Your mail works — but you can't prove you sent it."
|
|
47
|
+
|
|
48
|
+
# The exact records to paste, chosen for YOUR detected mail provider
|
|
49
|
+
for r in a["suggestedRecords"]:
|
|
50
|
+
print(r["type"], r["host"], r["value"])
|
|
51
|
+
# -> TXT @ v=spf1 include:_spf.google.com ~all
|
|
52
|
+
# -> TXT _dmarc v=DMARC1; p=none; rua=mailto:dmarc@acme.com
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### The SPF 10-lookup limit
|
|
56
|
+
|
|
57
|
+
Over 10 DNS lookups means **PermError — SPF fails entirely**, not partially.
|
|
58
|
+
We count it correctly: `mx` costs **1**, not one per MX host. Most tools get this wrong.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
spf = cis.spf_check("acme.com")
|
|
62
|
+
print(spf["lookupCount"], "/ 10", "PermError!" if spf["lookupLimitExceeded"] else "")
|
|
63
|
+
|
|
64
|
+
print(cis.spf_flatten("acme.com")["flattened"]) # zero include-lookups
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 2. Should I send to this address?
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
v = cis.validate("jane@gmial.com")
|
|
71
|
+
print(v["verdict"]) # risky
|
|
72
|
+
print(v["didYouMean"]) # jane@gmail.com
|
|
73
|
+
|
|
74
|
+
cis.validate("test@mailinator.com") # risky - disposable
|
|
75
|
+
cis.validate("support@acme.com") # risky - role account
|
|
76
|
+
cis.validate("x@no-such.dev") # invalid - no MX, will bounce
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
> **What we don't claim:** we do **not** verify the mailbox exists and do **not**
|
|
80
|
+
> detect catch-alls. Both require an SMTP probe on port 25, which we deliberately
|
|
81
|
+
> do not run. `mailboxVerified` is always `False`. The strongest verdict is
|
|
82
|
+
> `deliverable_domain` — the *domain* accepts mail.
|
|
83
|
+
|
|
84
|
+
**Bulk** (Agency+): `audit_bulk([...])` up to 100 domains · `validate_bulk([...])` up to 1,000 addresses.
|
|
85
|
+
|
|
86
|
+
Errors raise `CanItSendError` with `.status` and `.problem` (RFC 9457).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# canitsend-client
|
|
2
|
+
|
|
3
|
+
Zero-dependency Python client for the [CanItSend API](https://canitsend.com). Two questions, one API.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install canitsend-client
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Every endpoint needs an API key. The **free tier includes 100 audits/month** — instant and self-serve: https://canitsend.com/#pricing
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from canitsend import CanItSendClient
|
|
13
|
+
cis = CanItSendClient(api_key="snd_live_...")
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 1. Can my domain send?
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
a = cis.check("acme.com") # also accepts "you@acme.com" or a URL
|
|
20
|
+
print(a["grade"], a["score"]) # F 40
|
|
21
|
+
|
|
22
|
+
# Plain-language answer to "but my email works!"
|
|
23
|
+
print(a["explanation"]["headline"])
|
|
24
|
+
# -> "Your mail works — but you can't prove you sent it."
|
|
25
|
+
|
|
26
|
+
# The exact records to paste, chosen for YOUR detected mail provider
|
|
27
|
+
for r in a["suggestedRecords"]:
|
|
28
|
+
print(r["type"], r["host"], r["value"])
|
|
29
|
+
# -> TXT @ v=spf1 include:_spf.google.com ~all
|
|
30
|
+
# -> TXT _dmarc v=DMARC1; p=none; rua=mailto:dmarc@acme.com
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### The SPF 10-lookup limit
|
|
34
|
+
|
|
35
|
+
Over 10 DNS lookups means **PermError — SPF fails entirely**, not partially.
|
|
36
|
+
We count it correctly: `mx` costs **1**, not one per MX host. Most tools get this wrong.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
spf = cis.spf_check("acme.com")
|
|
40
|
+
print(spf["lookupCount"], "/ 10", "PermError!" if spf["lookupLimitExceeded"] else "")
|
|
41
|
+
|
|
42
|
+
print(cis.spf_flatten("acme.com")["flattened"]) # zero include-lookups
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 2. Should I send to this address?
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
v = cis.validate("jane@gmial.com")
|
|
49
|
+
print(v["verdict"]) # risky
|
|
50
|
+
print(v["didYouMean"]) # jane@gmail.com
|
|
51
|
+
|
|
52
|
+
cis.validate("test@mailinator.com") # risky - disposable
|
|
53
|
+
cis.validate("support@acme.com") # risky - role account
|
|
54
|
+
cis.validate("x@no-such.dev") # invalid - no MX, will bounce
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
> **What we don't claim:** we do **not** verify the mailbox exists and do **not**
|
|
58
|
+
> detect catch-alls. Both require an SMTP probe on port 25, which we deliberately
|
|
59
|
+
> do not run. `mailboxVerified` is always `False`. The strongest verdict is
|
|
60
|
+
> `deliverable_domain` — the *domain* accepts mail.
|
|
61
|
+
|
|
62
|
+
**Bulk** (Agency+): `audit_bulk([...])` up to 100 domains · `validate_bulk([...])` up to 1,000 addresses.
|
|
63
|
+
|
|
64
|
+
Errors raise `CanItSendError` with `.status` and `.problem` (RFC 9457).
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Client for the CanItSend API.
|
|
2
|
+
|
|
3
|
+
Two questions, one API:
|
|
4
|
+
|
|
5
|
+
1. Can my domain send? -> check() / audit() / spf_flatten()
|
|
6
|
+
2. Should I send to this? -> validate() / validate_bulk()
|
|
7
|
+
|
|
8
|
+
Every data endpoint needs an API key. The free tier includes 100 audits/month:
|
|
9
|
+
https://canitsend.com/#pricing
|
|
10
|
+
|
|
11
|
+
Standard library only (urllib) — no dependencies.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE = "https://api.canitsend.com"
|
|
22
|
+
|
|
23
|
+
__all__ = ["CanItSendClient", "CanItSendError"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CanItSendError(Exception):
|
|
27
|
+
def __init__(self, status: int, problem: dict):
|
|
28
|
+
self.status = status
|
|
29
|
+
self.problem = problem
|
|
30
|
+
super().__init__(problem.get("detail") or problem.get("title") or f"HTTP {status}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CanItSendClient:
|
|
34
|
+
def __init__(self, api_key: Optional[str] = None, base_url: str = DEFAULT_BASE):
|
|
35
|
+
self.api_key = api_key
|
|
36
|
+
self.base_url = base_url.rstrip("/")
|
|
37
|
+
|
|
38
|
+
def _request(self, method: str, path: str,
|
|
39
|
+
params: Optional[dict] = None, body: Optional[dict] = None) -> Any:
|
|
40
|
+
url = self.base_url + path
|
|
41
|
+
if params:
|
|
42
|
+
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
|
43
|
+
if clean:
|
|
44
|
+
url += "?" + urllib.parse.urlencode(clean)
|
|
45
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
46
|
+
headers = {"accept": "application/json"}
|
|
47
|
+
if body is not None:
|
|
48
|
+
headers["content-type"] = "application/json"
|
|
49
|
+
if self.api_key:
|
|
50
|
+
headers["authorization"] = f"Bearer {self.api_key}"
|
|
51
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
52
|
+
try:
|
|
53
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
54
|
+
return json.loads(resp.read().decode())
|
|
55
|
+
except urllib.error.HTTPError as e:
|
|
56
|
+
try:
|
|
57
|
+
problem = json.loads(e.read().decode())
|
|
58
|
+
except Exception:
|
|
59
|
+
problem = {"title": e.reason}
|
|
60
|
+
raise CanItSendError(e.code, problem) from None
|
|
61
|
+
|
|
62
|
+
# ── 1. Can my domain send? ───────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def check(self, domain: str, selectors: Optional[list[str]] = None) -> dict:
|
|
65
|
+
"""Full audit: score, grade, explanation, and paste-in DNS records.
|
|
66
|
+
|
|
67
|
+
Accepts a domain, an email address, or a URL.
|
|
68
|
+
"""
|
|
69
|
+
return self._request("GET", "/v1/check",
|
|
70
|
+
{"domain": domain, "selectors": ",".join(selectors or [])})
|
|
71
|
+
|
|
72
|
+
def audit(self, domain: str, selectors: Optional[list[str]] = None) -> dict:
|
|
73
|
+
"""Same payload as check(), on the metered audit route."""
|
|
74
|
+
return self._request("GET", "/v1/audit",
|
|
75
|
+
{"domain": domain, "selectors": ",".join(selectors or [])})
|
|
76
|
+
|
|
77
|
+
def audit_bulk(self, domains: list[str]) -> dict:
|
|
78
|
+
"""Up to 100 domains. Agency plan and above."""
|
|
79
|
+
return self._request("POST", "/v1/audit/bulk", body={"domains": domains})
|
|
80
|
+
|
|
81
|
+
def spf_check(self, domain: str) -> dict:
|
|
82
|
+
return self._request("GET", "/v1/spf/check", {"domain": domain})
|
|
83
|
+
|
|
84
|
+
def spf_flatten(self, domain: str) -> dict:
|
|
85
|
+
"""Resolve every include: down to raw IPs — fixes the 10-lookup PermError."""
|
|
86
|
+
return self._request("GET", "/v1/spf/flatten", {"domain": domain})
|
|
87
|
+
|
|
88
|
+
def dmarc_parse(self, domain: str) -> dict:
|
|
89
|
+
return self._request("GET", "/v1/dmarc/parse", {"domain": domain})
|
|
90
|
+
|
|
91
|
+
def dkim_check(self, domain: str, selector: Optional[str] = None) -> dict:
|
|
92
|
+
return self._request("GET", "/v1/dkim/check", {"domain": domain, "selector": selector})
|
|
93
|
+
|
|
94
|
+
# ── 2. Should I send to this address? ────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def validate(self, email: str) -> dict:
|
|
97
|
+
"""Pre-send validation: syntax, MX, disposable, role account, typo hint.
|
|
98
|
+
|
|
99
|
+
Verdicts: "invalid" | "risky" | "deliverable_domain".
|
|
100
|
+
|
|
101
|
+
NOTE: does NOT verify the mailbox exists and does NOT detect catch-alls.
|
|
102
|
+
Both require an SMTP probe on port 25, which we deliberately do not run.
|
|
103
|
+
``mailboxVerified`` is always False.
|
|
104
|
+
"""
|
|
105
|
+
return self._request("POST", "/v1/validate", body={"email": email})
|
|
106
|
+
|
|
107
|
+
def validate_bulk(self, emails: list[str]) -> dict:
|
|
108
|
+
"""Up to 1,000 addresses. Agency plan and above."""
|
|
109
|
+
return self._request("POST", "/v1/validate/bulk", body={"emails": emails})
|
|
110
|
+
|
|
111
|
+
# ── Monitoring & account ─────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def list_monitors(self) -> dict:
|
|
114
|
+
return self._request("GET", "/v1/monitor")
|
|
115
|
+
|
|
116
|
+
def create_monitor(self, domain: str, webhook_url: Optional[str] = None,
|
|
117
|
+
frequency: str = "daily") -> dict:
|
|
118
|
+
return self._request("POST", "/v1/monitor",
|
|
119
|
+
body={"domain": domain, "webhookUrl": webhook_url,
|
|
120
|
+
"frequency": frequency})
|
|
121
|
+
|
|
122
|
+
def delete_monitor(self, monitor_id: str) -> dict:
|
|
123
|
+
return self._request("DELETE", f"/v1/monitor/{monitor_id}")
|
|
124
|
+
|
|
125
|
+
def usage(self) -> dict:
|
|
126
|
+
return self._request("GET", "/v1/usage")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "canitsend-client"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Client for the CanItSend API — SPF/DKIM/DMARC audit from live DNS, SPF flattening, and email validation."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
keywords = ["spf", "dkim", "dmarc", "email", "deliverability", "email-validation", "dns", "smtp"]
|
|
9
|
+
authors = [{ name = "CanItSend" }]
|
|
10
|
+
dependencies = []
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Topic :: Communications :: Email",
|
|
18
|
+
"Topic :: Internet :: Name Service (DNS)",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://canitsend.com"
|
|
25
|
+
Documentation = "https://canitsend.com/docs"
|
|
26
|
+
Changelog = "https://canitsend.com/docs"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["canitsend"]
|