vaemail 1.0.0__py3-none-any.whl

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.
vaemail/__init__.py ADDED
@@ -0,0 +1,220 @@
1
+ """SDK Python de VaEmail.
2
+
3
+ Aucune dépendance : `urllib` suffit, et un paquet destiné à être installé par un
4
+ agent doit pouvoir s'installer sans rien tirer derrière lui. C'est le même parti
5
+ pris que le paquet npm.
6
+
7
+ from vaemail import VaEmail
8
+
9
+ client = VaEmail(api_key="...")
10
+ client.send({"to": "x@exemple.fr", "subject": "Bonjour", "html": "<p>Bonjour</p>"})
11
+ """
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
20
+
21
+ __all__ = ["VaEmail", "VaEmailError", "DEFAULT_BASE_URL"]
22
+ __version__ = "1.0.0"
23
+
24
+ DEFAULT_BASE_URL = "https://app.vaemail.fr"
25
+
26
+
27
+ class VaEmailError(Exception):
28
+ """Erreur rendue par l'API.
29
+
30
+ Porte ce qu'un appelant doit savoir pour décider : le code, l'action
31
+ corrective proposée, et si le même appel peut aboutir plus tard. Une
32
+ exception qui ne dit que « erreur 422 » oblige à lire la documentation ;
33
+ celle-ci porte la suite.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ message: str,
39
+ *,
40
+ code: str = "UNKNOWN",
41
+ status: int | None = None,
42
+ resolution: dict[str, Any] | None = None,
43
+ retryable: bool = False,
44
+ body: Any = None,
45
+ ) -> None:
46
+ super().__init__(message)
47
+ self.code = code
48
+ self.status = status
49
+ self.resolution = resolution or {}
50
+ self.retryable = retryable
51
+ self.body = body
52
+
53
+ def for_agent(self) -> str:
54
+ """Le motif, l'action corrective et s'il faut réessayer, en clair."""
55
+ lignes = [f"{self.code}: {self}"]
56
+
57
+ action = self.resolution.get("action")
58
+ if action:
59
+ endpoint = self.resolution.get("endpoint")
60
+ lignes.append(
61
+ f"Action corrective : {action}" + (f" ({endpoint})" if endpoint else "")
62
+ )
63
+
64
+ lignes.append(
65
+ "Le même appel peut aboutir plus tard."
66
+ if self.retryable
67
+ else "Réessayer à l'identique ne changera rien."
68
+ )
69
+
70
+ return "\n".join(lignes)
71
+
72
+
73
+ class VaEmail:
74
+ def __init__(
75
+ self,
76
+ api_key: str | None = None,
77
+ base_url: str = DEFAULT_BASE_URL,
78
+ *,
79
+ timeout: float = 30.0,
80
+ opener: Any = None,
81
+ ) -> None:
82
+ self.api_key = api_key
83
+ self.base_url = base_url.rstrip("/")
84
+ self.timeout = timeout
85
+ # Injectable pour les tests : on ne veut pas d'appel réseau dans une
86
+ # suite de tests, et on ne veut pas non plus d'une dépendance de test.
87
+ self._opener = opener or urllib.request.urlopen
88
+
89
+ # -- transport ---------------------------------------------------------
90
+
91
+ def _call(
92
+ self,
93
+ method: str,
94
+ path: str,
95
+ *,
96
+ body: dict[str, Any] | None = None,
97
+ query: dict[str, Any] | None = None,
98
+ idempotency_key: str | None = None,
99
+ ) -> Any:
100
+ url = self.base_url + path
101
+
102
+ propres = {k: v for k, v in (query or {}).items() if v not in (None, "")}
103
+ if propres:
104
+ url += "?" + urllib.parse.urlencode(propres)
105
+
106
+ entetes = {"Accept": "application/json"}
107
+ if self.api_key:
108
+ entetes["api-key"] = self.api_key
109
+ if idempotency_key:
110
+ entetes["Idempotency-Key"] = idempotency_key
111
+
112
+ donnees = None
113
+ if body is not None:
114
+ donnees = json.dumps(body).encode("utf-8")
115
+ entetes["Content-Type"] = "application/json"
116
+
117
+ requete = urllib.request.Request(url, data=donnees, headers=entetes, method=method)
118
+
119
+ try:
120
+ with self._opener(requete, timeout=self.timeout) as reponse:
121
+ brut = reponse.read().decode("utf-8") or "{}"
122
+ charge = json.loads(brut)
123
+ except urllib.error.HTTPError as erreur:
124
+ brut = erreur.read().decode("utf-8") or "{}"
125
+ try:
126
+ charge = json.loads(brut)
127
+ except json.JSONDecodeError:
128
+ charge = {}
129
+ raise self._erreur(charge, erreur.code) from None
130
+
131
+ # Une réponse 200 peut porter une erreur applicative : on la lève aussi,
132
+ # sinon l'appelant croit avoir réussi.
133
+ if isinstance(charge, dict) and charge.get("error"):
134
+ raise self._erreur(charge, 200)
135
+
136
+ return charge
137
+
138
+ @staticmethod
139
+ def _erreur(charge: Any, status: int) -> VaEmailError:
140
+ details = charge.get("error", {}) if isinstance(charge, dict) else {}
141
+
142
+ return VaEmailError(
143
+ details.get("message") or f"Erreur HTTP {status}.",
144
+ code=details.get("code", "UNKNOWN"),
145
+ status=status,
146
+ resolution=details.get("resolution"),
147
+ retryable=bool(details.get("retryable")),
148
+ body=charge,
149
+ )
150
+
151
+ # -- API ---------------------------------------------------------------
152
+
153
+ def capabilities(self) -> Any:
154
+ return self._call("GET", "/api/v1/capabilities")
155
+
156
+ def health(self) -> Any:
157
+ return self._call("GET", "/api/v1/health")
158
+
159
+ def send(self, message: dict[str, Any], idempotency_key: str | None = None) -> Any:
160
+ """Envoie un message. `idempotency_key` rend l'appel rejouable sans doublon 24 h."""
161
+ return self._call(
162
+ "POST", "/api/v1/transactional/send", body=message, idempotency_key=idempotency_key
163
+ )
164
+
165
+ def validate(self, message: dict[str, Any]) -> Any:
166
+ """Essai à blanc : mêmes contrôles que l'envoi, rien ne part."""
167
+ return self._call("POST", "/api/v1/messages/validate", body=message)
168
+
169
+ def get_message(self, message_id: str | int) -> Any:
170
+ return self._call("GET", f"/api/v1/messages/{urllib.parse.quote(str(message_id))}")
171
+
172
+ def list_messages(self, **filters: Any) -> Any:
173
+ return self._call("GET", "/api/v1/messages", query=filters)
174
+
175
+ def list_domains(self) -> Any:
176
+ return self._call("GET", "/api/v1/domains")
177
+
178
+ def add_domain(
179
+ self,
180
+ domain: str,
181
+ *,
182
+ selector: str | None = None,
183
+ dkim_tokens: list[str] | None = None,
184
+ idempotency_key: str | None = None,
185
+ ) -> Any:
186
+ """Déclare un domaine d'envoi.
187
+
188
+ `dkim_tokens` sont les trois valeurs données par SES à la création de
189
+ l'identité de domaine. Sans elles, la partie DKIM du guide DNS rendu est
190
+ un marqueur et le domaine ne peut pas être terminé sans intervention.
191
+ """
192
+ corps: dict[str, Any] = {"domain": domain}
193
+ if selector:
194
+ corps["dkim_selector"] = selector
195
+ if dkim_tokens:
196
+ corps["dkim_tokens"] = list(dkim_tokens)
197
+
198
+ return self._call("POST", "/api/v1/domains", body=corps, idempotency_key=idempotency_key)
199
+
200
+ def verify_domain(self, domain: str, selector: str | None = None) -> Any:
201
+ corps: dict[str, Any] = {"domain": domain}
202
+ if selector:
203
+ corps["dkim_selector"] = selector
204
+
205
+ return self._call("POST", "/api/v1/domains/verify", body=corps)
206
+
207
+ def dns_records(self, domain: str) -> Any:
208
+ return self._call("GET", f"/api/v1/domains/{urllib.parse.quote(domain)}/dns")
209
+
210
+ def diagnose_deliverability(self, domain: str) -> Any:
211
+ return self._call("GET", "/api/v1/deliverability/diagnose", query={"domain": domain})
212
+
213
+ def list_suppressions(self, **filters: Any) -> Any:
214
+ return self._call("GET", "/api/v1/suppressions", query=filters)
215
+
216
+ def usage(self) -> Any:
217
+ return self._call("GET", "/api/v1/usage")
218
+
219
+ def audit_logs(self, **filters: Any) -> Any:
220
+ return self._call("GET", "/api/v1/audit-logs", query=filters)
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: vaemail
3
+ Version: 1.0.0
4
+ Summary: Email infrastructure for AI agents and applications: send transactional email, authenticate sending domains, track delivery, diagnose deliverability.
5
+ Author: VaEmail (Agence SW)
6
+ License: MIT
7
+ Project-URL: Homepage, https://vaemail.fr/agents
8
+ Project-URL: Documentation, https://vaemail.fr/docs/api
9
+ Project-URL: Source, https://github.com/vaemail/vaemail-python
10
+ Keywords: email,email-api,transactional-email,ai-agent,agent,deliverability,sdk,vaemail,eu
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # vaemail — Python SDK
15
+
16
+ Email infrastructure for AI agents and applications. Send transactional email,
17
+ authenticate sending domains, track delivery, and diagnose deliverability.
18
+
19
+ No dependencies: the SDK uses only the standard library, so it installs without
20
+ pulling anything behind it.
21
+
22
+ ```bash
23
+ pip install vaemail
24
+ ```
25
+
26
+ ## Send an email
27
+
28
+ ```python
29
+ from vaemail import VaEmail
30
+
31
+ client = VaEmail(api_key="your-api-key")
32
+
33
+ client.send({
34
+ "to": "customer@example.com",
35
+ "subject": "Your order is on its way",
36
+ "html": "<p>Tracking number: 1Z999</p>",
37
+ }, idempotency_key="order-4711")
38
+ ```
39
+
40
+ The idempotency key makes the call safe to replay for 24 hours: retrying after a
41
+ timeout will not send a second email.
42
+
43
+ ## Try before sending
44
+
45
+ ```python
46
+ client.validate({"to": "customer@example.com", "subject": "Hi", "html": "<p>Hi</p>"})
47
+ ```
48
+
49
+ Same checks as a real send — key scope, quotas, sending domain, suppression
50
+ list — but nothing leaves. Useful when an agent builds a message on its own.
51
+
52
+ ## Authenticate a sending domain
53
+
54
+ ```python
55
+ result = client.add_domain("news.example.com", dkim_tokens=["aaa111", "bbb222", "ccc333"])
56
+
57
+ for record in result["data"]["dns_records"]:
58
+ if record["publishable"]:
59
+ print(record["type"], record["host"], "->", record["value"])
60
+ ```
61
+
62
+ The three DKIM tokens come from Amazon SES when the domain identity is created.
63
+ Every record says whether it is `publishable` as-is: a value that still depends
64
+ on something unknown is flagged instead of being handed over, so an agent never
65
+ publishes a record that would break the domain's authentication.
66
+
67
+ ## Errors say what to do next
68
+
69
+ ```python
70
+ from vaemail import VaEmailError
71
+
72
+ try:
73
+ client.send({"to": "customer@example.com"})
74
+ except VaEmailError as error:
75
+ print(error.code) # DOMAIN_NOT_VERIFIED
76
+ print(error.retryable) # False
77
+ print(error.for_agent()) # reason, corrective action, whether to retry
78
+ ```
79
+
80
+ ## Everything else
81
+
82
+ `capabilities()`, `health()`, `get_message()`, `list_messages()`,
83
+ `list_domains()`, `verify_domain()`, `dns_records()`,
84
+ `diagnose_deliverability()`, `list_suppressions()`, `usage()`, `audit_logs()`.
85
+
86
+ Full API reference: <https://vaemail.fr/docs/api>
87
+
88
+ ## Tests
89
+
90
+ ```bash
91
+ python -m unittest discover -s tests
92
+ ```
93
+
94
+ MIT licensed.
@@ -0,0 +1,5 @@
1
+ vaemail/__init__.py,sha256=A7j_a2K5QhrA9q8X76usIeUUfpzJ4ko3Rq0fordH8wk,7571
2
+ vaemail-1.0.0.dist-info/METADATA,sha256=T1tjIvNF_JKzqyD25p5letyjmc5-5G9ALxj5UB4XyXw,2834
3
+ vaemail-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ vaemail-1.0.0.dist-info/top_level.txt,sha256=8Lm4g5GDNRzHeUFHNkAnPjOAfC_E8uoaP8ZT6oJhnBM,8
5
+ vaemail-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ vaemail