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,9 @@
1
+ node_modules/
2
+ dist/
3
+ .wrangler/
4
+ data/kv-bulk.json
5
+ data/verified.json
6
+ data/discrepancies.json
7
+ *.log
8
+ .dev.vars
9
+ public/
@@ -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"]