vaemail 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.
- vaemail-1.0.0/PKG-INFO +94 -0
- vaemail-1.0.0/README.md +81 -0
- vaemail-1.0.0/pyproject.toml +22 -0
- vaemail-1.0.0/setup.cfg +4 -0
- vaemail-1.0.0/tests/test_sdk.py +107 -0
- vaemail-1.0.0/vaemail/__init__.py +220 -0
- vaemail-1.0.0/vaemail.egg-info/PKG-INFO +94 -0
- vaemail-1.0.0/vaemail.egg-info/SOURCES.txt +8 -0
- vaemail-1.0.0/vaemail.egg-info/dependency_links.txt +1 -0
- vaemail-1.0.0/vaemail.egg-info/top_level.txt +1 -0
vaemail-1.0.0/PKG-INFO
ADDED
|
@@ -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.
|
vaemail-1.0.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# vaemail — Python SDK
|
|
2
|
+
|
|
3
|
+
Email infrastructure for AI agents and applications. Send transactional email,
|
|
4
|
+
authenticate sending domains, track delivery, and diagnose deliverability.
|
|
5
|
+
|
|
6
|
+
No dependencies: the SDK uses only the standard library, so it installs without
|
|
7
|
+
pulling anything behind it.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install vaemail
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Send an email
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from vaemail import VaEmail
|
|
17
|
+
|
|
18
|
+
client = VaEmail(api_key="your-api-key")
|
|
19
|
+
|
|
20
|
+
client.send({
|
|
21
|
+
"to": "customer@example.com",
|
|
22
|
+
"subject": "Your order is on its way",
|
|
23
|
+
"html": "<p>Tracking number: 1Z999</p>",
|
|
24
|
+
}, idempotency_key="order-4711")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The idempotency key makes the call safe to replay for 24 hours: retrying after a
|
|
28
|
+
timeout will not send a second email.
|
|
29
|
+
|
|
30
|
+
## Try before sending
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
client.validate({"to": "customer@example.com", "subject": "Hi", "html": "<p>Hi</p>"})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Same checks as a real send — key scope, quotas, sending domain, suppression
|
|
37
|
+
list — but nothing leaves. Useful when an agent builds a message on its own.
|
|
38
|
+
|
|
39
|
+
## Authenticate a sending domain
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
result = client.add_domain("news.example.com", dkim_tokens=["aaa111", "bbb222", "ccc333"])
|
|
43
|
+
|
|
44
|
+
for record in result["data"]["dns_records"]:
|
|
45
|
+
if record["publishable"]:
|
|
46
|
+
print(record["type"], record["host"], "->", record["value"])
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The three DKIM tokens come from Amazon SES when the domain identity is created.
|
|
50
|
+
Every record says whether it is `publishable` as-is: a value that still depends
|
|
51
|
+
on something unknown is flagged instead of being handed over, so an agent never
|
|
52
|
+
publishes a record that would break the domain's authentication.
|
|
53
|
+
|
|
54
|
+
## Errors say what to do next
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from vaemail import VaEmailError
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
client.send({"to": "customer@example.com"})
|
|
61
|
+
except VaEmailError as error:
|
|
62
|
+
print(error.code) # DOMAIN_NOT_VERIFIED
|
|
63
|
+
print(error.retryable) # False
|
|
64
|
+
print(error.for_agent()) # reason, corrective action, whether to retry
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Everything else
|
|
68
|
+
|
|
69
|
+
`capabilities()`, `health()`, `get_message()`, `list_messages()`,
|
|
70
|
+
`list_domains()`, `verify_domain()`, `dns_records()`,
|
|
71
|
+
`diagnose_deliverability()`, `list_suppressions()`, `usage()`, `audit_logs()`.
|
|
72
|
+
|
|
73
|
+
Full API reference: <https://vaemail.fr/docs/api>
|
|
74
|
+
|
|
75
|
+
## Tests
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python -m unittest discover -s tests
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
MIT licensed.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vaemail"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Email infrastructure for AI agents and applications: send transactional email, authenticate sending domains, track delivery, diagnose deliverability."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "VaEmail (Agence SW)" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["email", "email-api", "transactional-email", "ai-agent", "agent", "deliverability", "sdk", "vaemail", "eu"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://vaemail.fr/agents"
|
|
18
|
+
Documentation = "https://vaemail.fr/docs/api"
|
|
19
|
+
Source = "https://github.com/vaemail/vaemail-python"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
packages = ["vaemail"]
|
vaemail-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Ce qui compte est ce qui part sur le réseau : on l'observe avec un faux opener.
|
|
2
|
+
|
|
3
|
+
Pas de dépendance de test non plus (unittest de la bibliothèque standard), pour
|
|
4
|
+
que `python -m unittest` suffise partout.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
import unittest
|
|
11
|
+
import urllib.error
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
15
|
+
|
|
16
|
+
from vaemail import DEFAULT_BASE_URL, VaEmail, VaEmailError # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FausseReponse(io.BytesIO):
|
|
20
|
+
def __enter__(self):
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
def __exit__(self, *_):
|
|
24
|
+
self.close()
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def espion(charge=None, statut=200):
|
|
29
|
+
appels = []
|
|
30
|
+
|
|
31
|
+
def opener(requete, timeout=None):
|
|
32
|
+
appels.append(requete)
|
|
33
|
+
corps = json.dumps(charge if charge is not None else {"success": True, "data": {}})
|
|
34
|
+
if statut >= 400:
|
|
35
|
+
raise urllib.error.HTTPError(
|
|
36
|
+
requete.full_url, statut, "erreur", {}, io.BytesIO(corps.encode())
|
|
37
|
+
)
|
|
38
|
+
return FausseReponse(corps.encode())
|
|
39
|
+
|
|
40
|
+
return appels, opener
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestSdk(unittest.TestCase):
|
|
44
|
+
def test_la_cle_part_en_entete_et_la_base_est_la_production(self):
|
|
45
|
+
appels, opener = espion()
|
|
46
|
+
VaEmail(api_key="cle-test", opener=opener).capabilities()
|
|
47
|
+
|
|
48
|
+
self.assertEqual(appels[0].full_url, f"{DEFAULT_BASE_URL}/api/v1/capabilities")
|
|
49
|
+
self.assertEqual(appels[0].get_header("Api-key"), "cle-test")
|
|
50
|
+
|
|
51
|
+
def test_les_jetons_dkim_sont_transmis(self):
|
|
52
|
+
appels, opener = espion()
|
|
53
|
+
VaEmail(api_key="k", opener=opener).add_domain(
|
|
54
|
+
"news.exemple.fr", dkim_tokens=["aaa111", "bbb222", "ccc333"]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
corps = json.loads(appels[0].data)
|
|
58
|
+
self.assertEqual(corps["dkim_tokens"], ["aaa111", "bbb222", "ccc333"])
|
|
59
|
+
self.assertNotIn("dkim_selector", corps)
|
|
60
|
+
|
|
61
|
+
def test_la_cle_d_idempotence_part_en_entete(self):
|
|
62
|
+
appels, opener = espion()
|
|
63
|
+
VaEmail(api_key="k", opener=opener).send({"to": "x@exemple.fr"}, "commande-42")
|
|
64
|
+
|
|
65
|
+
self.assertEqual(appels[0].get_header("Idempotency-key"), "commande-42")
|
|
66
|
+
|
|
67
|
+
def test_les_filtres_vides_ne_polluent_pas_l_url(self):
|
|
68
|
+
appels, opener = espion()
|
|
69
|
+
VaEmail(api_key="k", opener=opener).list_messages(status="sent", limit=None)
|
|
70
|
+
|
|
71
|
+
self.assertIn("status=sent", appels[0].full_url)
|
|
72
|
+
self.assertNotIn("limit", appels[0].full_url)
|
|
73
|
+
|
|
74
|
+
def test_une_erreur_dit_quoi_faire_et_si_l_appel_peut_etre_rejoue(self):
|
|
75
|
+
_, opener = espion(
|
|
76
|
+
{
|
|
77
|
+
"error": {
|
|
78
|
+
"code": "DOMAIN_NOT_VERIFIED",
|
|
79
|
+
"message": "Domaine non authentifié.",
|
|
80
|
+
"resolution": {"action": "verify_domain", "endpoint": "/api/v1/domains/verify"},
|
|
81
|
+
"retryable": False,
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
422,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
with self.assertRaises(VaEmailError) as capture:
|
|
88
|
+
VaEmail(api_key="k", opener=opener).send({"to": "x@exemple.fr"})
|
|
89
|
+
|
|
90
|
+
erreur = capture.exception
|
|
91
|
+
self.assertEqual(erreur.code, "DOMAIN_NOT_VERIFIED")
|
|
92
|
+
self.assertEqual(erreur.status, 422)
|
|
93
|
+
self.assertFalse(erreur.retryable)
|
|
94
|
+
self.assertIn("verify_domain", erreur.for_agent())
|
|
95
|
+
|
|
96
|
+
def test_une_erreur_applicative_renvoyee_en_200_est_levee_quand_meme(self):
|
|
97
|
+
_, opener = espion({"error": {"code": "QUOTA_EXCEEDED", "retryable": True}}, 200)
|
|
98
|
+
|
|
99
|
+
with self.assertRaises(VaEmailError) as capture:
|
|
100
|
+
VaEmail(api_key="k", opener=opener).usage()
|
|
101
|
+
|
|
102
|
+
self.assertEqual(capture.exception.code, "QUOTA_EXCEEDED")
|
|
103
|
+
self.assertTrue(capture.exception.retryable)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
unittest.main()
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vaemail
|