mailtea 0.1.2__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.
mailtea/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Mailtea Python SDK — send, schedule, and manage email from your app or AI agent."""
2
+
3
+ from .client import Mailtea
4
+ from .errors import MailteaError
5
+
6
+ __all__ = ["Mailtea", "MailteaError"]
7
+ __version__ = "0.1.2"
mailtea/_resource.py ADDED
@@ -0,0 +1,54 @@
1
+ """Shared helpers for resource classes: payload merging, query strings, and
2
+ attribute-access response wrapping."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Dict, Optional
7
+ from urllib.parse import urlencode
8
+
9
+
10
+ def body(params: Optional[Dict[str, Any]], kwargs: Dict[str, Any]) -> Dict[str, Any]:
11
+ """Merge a positional wire-format dict with keyword arguments.
12
+
13
+ Every method accepts either style (or both — kwargs win on conflict)::
14
+
15
+ mailtea.emails.send({"from": "a@b.c", "subject": "Hi"})
16
+ mailtea.emails.send(from_="a@b.c", subject="Hi")
17
+
18
+ A single trailing underscore is stripped from kwarg names so Python
19
+ reserved words can be used (``from_`` becomes ``"from"`` on the wire).
20
+ """
21
+ merged = dict(params or {})
22
+ for key, value in kwargs.items():
23
+ merged[key[:-1] if key.endswith("_") else key] = value
24
+ return merged
25
+
26
+
27
+ def query(params: Optional[Dict[str, Any]]) -> str:
28
+ """Render a query string from a dict, dropping ``None`` values."""
29
+ if not params:
30
+ return ""
31
+ clean = {key: value for key, value in params.items() if value is not None}
32
+ return ("?" + urlencode(clean)) if clean else ""
33
+
34
+
35
+ class Resource(dict):
36
+ """An API response: a plain dict that also allows attribute access, so
37
+ both ``email["id"]`` and ``email.id`` work. Nested dicts/lists are wrapped
38
+ on access. Keys that collide with dict methods (``get``, ``items``, …)
39
+ are only reachable via ``[...]`` indexing.
40
+ """
41
+
42
+ def __getattr__(self, name: str) -> Any:
43
+ try:
44
+ return wrap(self[name])
45
+ except KeyError:
46
+ raise AttributeError(name) from None
47
+
48
+
49
+ def wrap(value: Any) -> Any:
50
+ if isinstance(value, dict):
51
+ return Resource(value)
52
+ if isinstance(value, list):
53
+ return [wrap(item) for item in value]
54
+ return value
mailtea/_transport.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.error
4
+ import urllib.request
5
+ from typing import Callable, Dict, Iterable, NamedTuple, Optional, Tuple
6
+
7
+
8
+ class HttpResponse(NamedTuple):
9
+ status: int
10
+ headers: Dict[str, str]
11
+ text: str
12
+
13
+
14
+ # (method, url, headers, body) -> HttpResponse. Injectable so tests need no network.
15
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes]], HttpResponse]
16
+
17
+
18
+ def _lower_headers(items: Iterable[Tuple[str, str]]) -> Dict[str, str]:
19
+ return {key.lower(): value for key, value in items}
20
+
21
+
22
+ def urllib_transport(
23
+ method: str, url: str, headers: Dict[str, str], body: Optional[bytes]
24
+ ) -> HttpResponse:
25
+ """Default transport built on the standard library — no third-party deps."""
26
+ request = urllib.request.Request(url=url, data=body, headers=headers, method=method)
27
+ try:
28
+ with urllib.request.urlopen(request) as response: # noqa: S310 (trusted base URL)
29
+ return HttpResponse(
30
+ response.status,
31
+ _lower_headers(response.headers.items()),
32
+ response.read().decode("utf-8"),
33
+ )
34
+ except urllib.error.HTTPError as error:
35
+ text = error.read().decode("utf-8") if error.fp is not None else ""
36
+ return HttpResponse(error.code, _lower_headers(error.headers.items()), text)
mailtea/api_keys.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from ._resource import body as _body
7
+
8
+ RequestFn = Callable[..., Any]
9
+
10
+
11
+ class ApiKeys:
12
+ """The ``api_keys`` resource. Access via ``mailtea.api_keys``.
13
+
14
+ Requires a token with ``settings:write``. A key can never be granted scopes
15
+ the calling token does not already hold.
16
+ """
17
+
18
+ def __init__(self, request: RequestFn) -> None:
19
+ self._request = request
20
+
21
+ def create(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
22
+ """Create an API key. The ``token`` is returned ONCE — store it securely.
23
+
24
+ Takes ``name``, optional ``permission`` (``"full_access"`` or
25
+ ``"sending_access"``), and optional ``domain_id``. Accepts a
26
+ wire-format dict, keyword arguments, or both.
27
+ """
28
+ return self._request("POST", "/v1/api-keys", _body(params, kwargs))
29
+
30
+ def list(self) -> Dict[str, Any]:
31
+ """List API keys (token values are never returned)."""
32
+ return self._request("GET", "/v1/api-keys")
33
+
34
+ def revoke(self, id: str) -> Any:
35
+ """Revoke (delete) an API key by id."""
36
+ return self._request("DELETE", "/v1/api-keys/" + quote(str(id), safe=""))
mailtea/client.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from typing import Any, Optional
6
+
7
+ from ._resource import wrap as _wrap
8
+ from ._transport import Transport, urllib_transport
9
+ from .api_keys import ApiKeys
10
+ from .contact_properties import ContactProperties
11
+ from .contacts import Contacts
12
+ from .domains import Domains
13
+ from .emails import Emails
14
+ from .errors import MailteaError
15
+ from .posts import Posts
16
+ from .segments import Segments
17
+ from .tags import Tags
18
+ from .webhooks import Webhooks
19
+
20
+ DEFAULT_BASE_URL = "https://api.mailtea.app"
21
+
22
+
23
+ class Mailtea:
24
+ """The Mailtea client.
25
+
26
+ >>> from mailtea import Mailtea
27
+ >>> mailtea = Mailtea(os.environ["MAILTEA_API_KEY"])
28
+ >>> sent = mailtea.emails.send(
29
+ ... from_="you@yourdomain.com",
30
+ ... to="recipient@example.com",
31
+ ... subject="Hello",
32
+ ... html="<p>Sent with Mailtea.</p>",
33
+ ... )
34
+ >>> sent.id # responses allow attribute and ["..."] access alike
35
+
36
+ Payloads may equally be passed as a single wire-format dict
37
+ (``send({"from": ..., "to": ...})``) — both styles hit the same endpoint.
38
+
39
+ The API key may be passed explicitly or read from ``MAILTEA_API_KEY``.
40
+ Self-hosting/local dev: pass ``base_url`` or set ``MAILTEA_API_BASE_URL``.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ api_key: Optional[str] = None,
46
+ *,
47
+ base_url: Optional[str] = None,
48
+ transport: Optional[Transport] = None,
49
+ ) -> None:
50
+ key = api_key or os.environ.get("MAILTEA_API_KEY")
51
+ if not key:
52
+ raise MailteaError(
53
+ "Missing Mailtea API key. Pass it to Mailtea(api_key) or set the "
54
+ "MAILTEA_API_KEY environment variable.",
55
+ code="missing_api_key",
56
+ )
57
+ self._api_key = key
58
+ self._base_url = (
59
+ base_url or os.environ.get("MAILTEA_API_BASE_URL") or DEFAULT_BASE_URL
60
+ ).rstrip("/")
61
+ self._transport: Transport = transport or urllib_transport
62
+
63
+ self.emails = Emails(self._request)
64
+ self.contacts = Contacts(self._request)
65
+ self.posts = Posts(self._request)
66
+ self.segments = Segments(self._request)
67
+ self.tags = Tags(self._request)
68
+ self.domains = Domains(self._request)
69
+ self.webhooks = Webhooks(self._request)
70
+ self.contact_properties = ContactProperties(self._request)
71
+ self.api_keys = ApiKeys(self._request)
72
+
73
+ def _request(self, method: str, path: str, body: Any = None) -> Any:
74
+ url = self._base_url + path
75
+ headers = {"Authorization": "Bearer " + self._api_key}
76
+ data: Optional[bytes] = None
77
+ if body is not None:
78
+ headers["Content-Type"] = "application/json"
79
+ data = json.dumps(body).encode("utf-8")
80
+
81
+ response = self._transport(method, url, headers, data)
82
+ request_id = response.headers.get("x-request-id")
83
+
84
+ if response.status >= 400:
85
+ message = "HTTP {0}".format(response.status)
86
+ details = None
87
+ try:
88
+ parsed = json.loads(response.text)
89
+ if isinstance(parsed, dict):
90
+ message = parsed.get("error") or message
91
+ details = parsed.get("details")
92
+ except ValueError:
93
+ pass # non-JSON body — keep the status-line message
94
+ raise MailteaError(
95
+ message, status=response.status, details=details, request_id=request_id
96
+ )
97
+
98
+ if response.status == 204 or not response.text:
99
+ return None
100
+ # Responses are dicts that also allow attribute access
101
+ # (email["id"] and email.id both work).
102
+ return _wrap(json.loads(response.text))
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from ._resource import body as _body, query as _query
7
+
8
+ RequestFn = Callable[..., Any]
9
+
10
+
11
+ class ContactProperties:
12
+ """The ``contact_properties`` resource (custom contact fields). Access via
13
+ ``mailtea.contact_properties``.
14
+
15
+ Definitions are team-scoped — there is no ``publication_id``. ``create``
16
+ takes ``key`` and ``type`` (``"string"`` or ``"number"``). Every method
17
+ accepts the payload as a wire-format dict, as keyword arguments, or both.
18
+ """
19
+
20
+ def __init__(self, request: RequestFn) -> None:
21
+ self._request = request
22
+
23
+ def create(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
24
+ return self._request("POST", "/v1/contact-properties", _body(params, kwargs))
25
+
26
+ def list(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
27
+ return self._request("GET", "/v1/contact-properties" + _query(_body(params, kwargs)))
28
+
29
+ def update(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
30
+ return self._request(
31
+ "PATCH", "/v1/contact-properties/" + quote(str(id), safe=""), _body(params, kwargs)
32
+ )
33
+
34
+ def delete(self, id: str) -> Dict[str, Any]:
35
+ return self._request(
36
+ "DELETE", "/v1/contact-properties/" + quote(str(id), safe="")
37
+ )
mailtea/contacts.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from ._resource import body as _body, query as _query
7
+
8
+ RequestFn = Callable[..., Any]
9
+
10
+
11
+ class Contacts:
12
+ """The ``contacts`` resource. Access via ``mailtea.contacts``.
13
+
14
+ Audience resources are scoped to a publication — pass ``publication_id``.
15
+ Every method accepts the payload as a wire-format dict, as keyword
16
+ arguments, or both.
17
+ """
18
+
19
+ def __init__(self, request: RequestFn) -> None:
20
+ self._request = request
21
+
22
+ def create(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
23
+ """Create a contact — or update it if the email already exists in the
24
+ publication (the endpoint upserts). :meth:`upsert` is the same call."""
25
+ return self._request("POST", "/v1/contacts", _body(params, kwargs))
26
+
27
+ def upsert(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
28
+ """Create the contact or update it in place — alias of :meth:`create`,
29
+ named for what ``POST /v1/contacts`` actually does."""
30
+ return self.create(params, **kwargs)
31
+
32
+ def list(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
33
+ return self._request("GET", "/v1/contacts" + _query(_body(params, kwargs)))
34
+
35
+ def get(self, id_or_email: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
36
+ return self._request(
37
+ "GET",
38
+ "/v1/contacts/" + quote(str(id_or_email), safe="") + _query(_body(params, kwargs)),
39
+ )
40
+
41
+ def update(self, id_or_email: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
42
+ merged = _body(params, kwargs)
43
+ return self._request(
44
+ "PATCH",
45
+ "/v1/contacts/"
46
+ + quote(str(id_or_email), safe="")
47
+ + _query({"publication_id": merged.get("publication_id")}),
48
+ merged,
49
+ )
50
+
51
+ def delete(self, id_or_email: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
52
+ return self._request(
53
+ "DELETE",
54
+ "/v1/contacts/" + quote(str(id_or_email), safe="") + _query(_body(params, kwargs)),
55
+ )
mailtea/domains.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from ._resource import body as _body, query as _query
7
+
8
+ RequestFn = Callable[..., Any]
9
+
10
+
11
+ class Domains:
12
+ """The ``domains`` resource (email/site sending domains). Access via
13
+ ``mailtea.domains``.
14
+
15
+ Scoped to a publication — pass ``publication_id``. Register a domain, add the
16
+ returned DNS ``records``, then :meth:`verify` it before sending from it.
17
+ Every method accepts the payload as a wire-format dict, as keyword
18
+ arguments, or both.
19
+ """
20
+
21
+ def __init__(self, request: RequestFn) -> None:
22
+ self._request = request
23
+ #: Tracking sub-domains (CNAME) under a domain.
24
+ self.tracking = TrackingDomains(request)
25
+
26
+ def create(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
27
+ """Register a domain. The response ``records`` lists the DNS records to add."""
28
+ return self._request("POST", "/v1/domains", _body(params, kwargs))
29
+
30
+ def list(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
31
+ return self._request("GET", "/v1/domains" + _query(_body(params, kwargs)))
32
+
33
+ def get(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
34
+ return self._request(
35
+ "GET", "/v1/domains/" + quote(str(id), safe="") + _query(_body(params, kwargs))
36
+ )
37
+
38
+ def verify(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
39
+ """Verify a domain via its DNS records; ``status`` becomes ``"verified"``."""
40
+ return self._request(
41
+ "POST",
42
+ "/v1/domains/" + quote(str(id), safe="") + "/verify" + _query(_body(params, kwargs)),
43
+ )
44
+
45
+ def update(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
46
+ merged = _body(params, kwargs)
47
+ return self._request(
48
+ "PATCH",
49
+ "/v1/domains/"
50
+ + quote(str(id), safe="")
51
+ + _query({"publication_id": merged.get("publication_id")}),
52
+ merged,
53
+ )
54
+
55
+ def delete(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
56
+ return self._request(
57
+ "DELETE", "/v1/domains/" + quote(str(id), safe="") + _query(_body(params, kwargs))
58
+ )
59
+
60
+
61
+ class TrackingDomains:
62
+ """Tracking sub-domains (CNAME) under a domain — used to serve open-pixel
63
+ and click-tracking links from your own domain. Access via
64
+ ``mailtea.domains.tracking``.
65
+ """
66
+
67
+ def __init__(self, request: RequestFn) -> None:
68
+ self._request = request
69
+
70
+ def create(self, domain_id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
71
+ """Add a tracking sub-domain. Takes ``publication_id`` and ``subdomain``.
72
+ The response ``records`` lists the CNAME to add."""
73
+ merged = _body(params, kwargs)
74
+ return self._request(
75
+ "POST",
76
+ "/v1/domains/"
77
+ + quote(str(domain_id), safe="")
78
+ + "/tracking-domains"
79
+ + _query({"publication_id": merged.get("publication_id")}),
80
+ {"subdomain": merged.get("subdomain")},
81
+ )
82
+
83
+ def list(self, domain_id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
84
+ return self._request(
85
+ "GET",
86
+ "/v1/domains/"
87
+ + quote(str(domain_id), safe="")
88
+ + "/tracking-domains"
89
+ + _query(_body(params, kwargs)),
90
+ )
91
+
92
+ def verify(
93
+ self,
94
+ domain_id: str,
95
+ tracking_domain_id: str,
96
+ params: Optional[Dict[str, Any]] = None,
97
+ **kwargs: Any,
98
+ ) -> Dict[str, Any]:
99
+ return self._request(
100
+ "POST",
101
+ "/v1/domains/"
102
+ + quote(str(domain_id), safe="")
103
+ + "/tracking-domains/"
104
+ + quote(str(tracking_domain_id), safe="")
105
+ + "/verify"
106
+ + _query(_body(params, kwargs)),
107
+ )
108
+
109
+ def delete(
110
+ self,
111
+ domain_id: str,
112
+ tracking_domain_id: str,
113
+ params: Optional[Dict[str, Any]] = None,
114
+ **kwargs: Any,
115
+ ) -> Dict[str, Any]:
116
+ return self._request(
117
+ "DELETE",
118
+ "/v1/domains/"
119
+ + quote(str(domain_id), safe="")
120
+ + "/tracking-domains/"
121
+ + quote(str(tracking_domain_id), safe="")
122
+ + _query(_body(params, kwargs)),
123
+ )
mailtea/emails.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, List, Optional, Union
4
+ from urllib.parse import quote
5
+
6
+ from ._resource import body as _body, query as _query
7
+
8
+ RequestFn = Callable[..., Any]
9
+ Recipients = Union[str, List[str]]
10
+
11
+
12
+ class Emails:
13
+ """The ``emails`` resource. Access via ``mailtea.emails``.
14
+
15
+ Every method accepts the payload as a wire-format dict, as keyword
16
+ arguments, or both (snake_case keys like ``reply_to``; use ``from_=`` as
17
+ the keyword form of ``"from"``).
18
+ """
19
+
20
+ def __init__(self, request: RequestFn) -> None:
21
+ self._request = request
22
+
23
+ def send(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
24
+ """Send a transactional email. Provide ``html``/``text`` OR a ``template``.
25
+
26
+ >>> mailtea.emails.send(from_="a@b.co", to="c@d.co", subject="Hi", html="<p>Hi</p>")
27
+
28
+ Returns ``{"id": ...}``.
29
+ """
30
+ return self._request("POST", "/v1/emails", _body(params, kwargs))
31
+
32
+ def batch(self, emails: List[Dict[str, Any]]) -> Dict[str, Any]:
33
+ """Send up to 100 emails in one request. Returns ``{"data": [{"id": ...}]}``."""
34
+ return self._request("POST", "/v1/emails/batch", emails)
35
+
36
+ def get(self, id: str) -> Dict[str, Any]:
37
+ """Retrieve an email with its delivery status and tracking counters.
38
+
39
+ Adds a friendly ``status`` alias of the raw ``last_event`` wire field.
40
+ """
41
+ email = self._request("GET", "/v1/emails/" + quote(str(id), safe=""))
42
+ if isinstance(email, dict) and email.get("status") is None:
43
+ email["status"] = email.get("last_event")
44
+ return email
45
+
46
+ def list(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
47
+ """List emails (most recent first). Optional filters: ``status``, ``tag_name``,
48
+ ``tag_value``, ``from_date``, ``to_date``, ``limit``, ``offset``."""
49
+ return self._request("GET", "/v1/emails" + _query(_body(params, kwargs)))
50
+
51
+ def update(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
52
+ """Update a scheduled email (currently only ``scheduled_at``)."""
53
+ return self._request(
54
+ "PATCH", "/v1/emails/" + quote(str(id), safe=""), _body(params, kwargs)
55
+ )
56
+
57
+ def reschedule(self, id: str, scheduled_at: str) -> Dict[str, Any]:
58
+ """Convenience wrapper over :meth:`update` for the reschedule case."""
59
+ return self.update(id, {"scheduled_at": scheduled_at})
60
+
61
+ def cancel(self, id: str) -> Dict[str, Any]:
62
+ """Cancel a scheduled email before it sends."""
63
+ return self._request("POST", "/v1/emails/" + quote(str(id), safe="") + "/cancel")
mailtea/errors.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class MailteaError(Exception):
7
+ """Raised when the Mailtea API returns an error or the client is misconfigured.
8
+
9
+ Attributes:
10
+ status: HTTP status code (0 for client-side errors).
11
+ code: machine-readable code for client-side errors (e.g. ``missing_api_key``).
12
+ details: validation issue list returned by the API, if any.
13
+ request_id: the API's ``x-request-id`` for support/debugging.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ *,
20
+ status: int = 0,
21
+ code: Optional[str] = None,
22
+ details: Any = None,
23
+ request_id: Optional[str] = None,
24
+ ) -> None:
25
+ super().__init__(message)
26
+ self.message = message
27
+ self.status = status
28
+ self.code = code
29
+ self.details = details
30
+ self.request_id = request_id
mailtea/posts.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from ._resource import body as _body
7
+
8
+ RequestFn = Callable[..., Any]
9
+
10
+
11
+ class Posts:
12
+ """The ``posts`` resource (newsletter posts/issues). Access via ``mailtea.posts``.
13
+
14
+ Every method accepts the payload as a wire-format dict, as keyword
15
+ arguments, or both (snake_case keys like ``reply_to``; use ``from_=`` as
16
+ the keyword form of ``"from"``).
17
+ """
18
+
19
+ def __init__(self, request: RequestFn) -> None:
20
+ self._request = request
21
+
22
+ def create(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
23
+ """Create a newsletter post (draft by default). Seed it from a published
24
+ server template with ``template_id`` + ``variables``, or pass inline
25
+ ``html``. Set ``send=True`` to deliver right after creating (or with
26
+ ``scheduled_at`` to schedule) — that requires the ``issues:send`` scope.
27
+
28
+ Returns ``{"id": ...}``.
29
+ """
30
+ return self._request("POST", "/v1/posts", _body(params, kwargs))
31
+
32
+ def send(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
33
+ """Send a draft post to the publication's audience — immediately, or at
34
+ ``scheduled_at`` (ISO 8601) if given. Requires the ``issues:send`` scope.
35
+ """
36
+ merged = _body(params, kwargs)
37
+ return self._request(
38
+ "POST", "/v1/posts/" + quote(str(id), safe="") + "/send", merged or None
39
+ )
40
+
41
+ def send_test(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
42
+ """Send a TEST copy of a post to specific recipients to check it before
43
+ subscribers see it. Renders the post exactly as a subscriber would receive
44
+ it and delivers a one-shot ``[TEST]`` email — it does NOT send to the
45
+ audience.
46
+
47
+ Takes ``recipients`` (up to 10), ``from`` (must use a verified domain),
48
+ and optional ``reply_to``. Returns ``{"sent_to": [...], "failed_to": [...]}``.
49
+ """
50
+ return self._request(
51
+ "POST", "/v1/posts/" + quote(str(id), safe="") + "/test", _body(params, kwargs)
52
+ )
mailtea/segments.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from ._resource import body as _body, query as _query
7
+
8
+ RequestFn = Callable[..., Any]
9
+
10
+
11
+ class Segments:
12
+ """The ``segments`` resource. Access via ``mailtea.segments``.
13
+
14
+ Audience segments are scoped to a publication — pass ``publication_id``.
15
+ Every method accepts the payload as a wire-format dict, as keyword
16
+ arguments, or both. To clear a nullable filter on update, pass ``None``
17
+ (e.g. ``status_filter=None``); omit the key to leave it unchanged.
18
+ """
19
+
20
+ def __init__(self, request: RequestFn) -> None:
21
+ self._request = request
22
+
23
+ def create(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
24
+ return self._request("POST", "/v1/segments", _body(params, kwargs))
25
+
26
+ def list(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
27
+ return self._request("GET", "/v1/segments" + _query(_body(params, kwargs)))
28
+
29
+ def get(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
30
+ return self._request(
31
+ "GET", "/v1/segments/" + quote(str(id), safe="") + _query(_body(params, kwargs))
32
+ )
33
+
34
+ def update(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
35
+ merged = _body(params, kwargs)
36
+ return self._request(
37
+ "PATCH",
38
+ "/v1/segments/"
39
+ + quote(str(id), safe="")
40
+ + _query({"publication_id": merged.get("publication_id")}),
41
+ merged,
42
+ )
43
+
44
+ def delete(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
45
+ return self._request(
46
+ "DELETE", "/v1/segments/" + quote(str(id), safe="") + _query(_body(params, kwargs))
47
+ )
mailtea/tags.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from ._resource import body as _body, query as _query
7
+
8
+ RequestFn = Callable[..., Any]
9
+
10
+
11
+ class Tags:
12
+ """The ``tags`` resource (tag definitions). Access via ``mailtea.tags``.
13
+
14
+ Tags are scoped to a publication — pass ``publication_id``. ``create``
15
+ requires ``default_subscription`` (``"opt_in"`` or ``"opt_out"``). This
16
+ manages tag definitions only; assigning tags to contacts is not yet exposed.
17
+ Every method accepts the payload as a wire-format dict, as keyword
18
+ arguments, or both.
19
+ """
20
+
21
+ def __init__(self, request: RequestFn) -> None:
22
+ self._request = request
23
+
24
+ def create(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
25
+ return self._request("POST", "/v1/tags", _body(params, kwargs))
26
+
27
+ def list(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
28
+ return self._request("GET", "/v1/tags" + _query(_body(params, kwargs)))
29
+
30
+ def get(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
31
+ return self._request(
32
+ "GET", "/v1/tags/" + quote(str(id), safe="") + _query(_body(params, kwargs))
33
+ )
34
+
35
+ def update(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
36
+ merged = _body(params, kwargs)
37
+ return self._request(
38
+ "PATCH",
39
+ "/v1/tags/"
40
+ + quote(str(id), safe="")
41
+ + _query({"publication_id": merged.get("publication_id")}),
42
+ merged,
43
+ )
44
+
45
+ def delete(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
46
+ return self._request(
47
+ "DELETE", "/v1/tags/" + quote(str(id), safe="") + _query(_body(params, kwargs))
48
+ )
mailtea/webhooks.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+ from urllib.parse import quote
5
+
6
+ from ._resource import body as _body, query as _query
7
+
8
+ RequestFn = Callable[..., Any]
9
+
10
+ _BASE = "/v1/webhooks/endpoints"
11
+
12
+
13
+ class Webhooks:
14
+ """The ``webhooks`` resource (outbound event subscriptions). Access via
15
+ ``mailtea.webhooks``.
16
+
17
+ Scoped to a publication — pass ``publication_id``. ``create`` returns the
18
+ ``signing_secret`` once; store it to verify payload signatures.
19
+ Every method accepts the payload as a wire-format dict, as keyword
20
+ arguments, or both.
21
+ """
22
+
23
+ def __init__(self, request: RequestFn) -> None:
24
+ self._request = request
25
+
26
+ def create(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
27
+ return self._request("POST", _BASE, _body(params, kwargs))
28
+
29
+ def list(self, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
30
+ return self._request("GET", _BASE + _query(_body(params, kwargs)))
31
+
32
+ def get(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
33
+ return self._request(
34
+ "GET", _BASE + "/" + quote(str(id), safe="") + _query(_body(params, kwargs))
35
+ )
36
+
37
+ def update(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
38
+ merged = _body(params, kwargs)
39
+ return self._request(
40
+ "PATCH",
41
+ _BASE
42
+ + "/"
43
+ + quote(str(id), safe="")
44
+ + _query({"publication_id": merged.get("publication_id")}),
45
+ merged,
46
+ )
47
+
48
+ def delete(self, id: str, params: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Dict[str, Any]:
49
+ return self._request(
50
+ "DELETE", _BASE + "/" + quote(str(id), safe="") + _query(_body(params, kwargs))
51
+ )
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: mailtea
3
+ Version: 0.1.2
4
+ Summary: Mailtea Python SDK — send, schedule, and manage email from your app or AI agent.
5
+ Project-URL: Homepage, https://mailtea.app
6
+ Project-URL: Documentation, https://docs.mailtea.app
7
+ Project-URL: Repository, https://github.com/mailtea-app/mailtea-python
8
+ Author: Mailtea
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agent,email,mailtea,sdk,transactional-email
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Communications :: Email
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+
18
+ # mailtea (Python)
19
+
20
+ The official Python SDK for [Mailtea](https://mailtea.app) — a thin, zero-dependency
21
+ wrapper over the [REST API](https://api.mailtea.app). Python 3.9+.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install mailtea
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ import os
33
+ from mailtea import Mailtea
34
+
35
+ mailtea = Mailtea(os.environ["MAILTEA_API_KEY"])
36
+
37
+ sent = mailtea.emails.send(
38
+ from_="you@yourdomain.com",
39
+ to="recipient@example.com",
40
+ subject="Hello from Mailtea",
41
+ html="<p>Your first email, sent with <strong>Mailtea</strong>.</p>",
42
+ )
43
+ print(sent.id)
44
+
45
+ email = mailtea.emails.get(sent.id)
46
+ print(email.status)
47
+ ```
48
+
49
+ The API key can be passed to `Mailtea(api_key)` or read from `MAILTEA_API_KEY`.
50
+ Self-hosting or local dev? pass `base_url=` or set `MAILTEA_API_BASE_URL`.
51
+
52
+ Every method equally accepts a single wire-format dict — handy when you already
53
+ hold the JSON payload:
54
+
55
+ ```python
56
+ sent = mailtea.emails.send({
57
+ "from": "you@yourdomain.com",
58
+ "to": "recipient@example.com",
59
+ "subject": "Hello from Mailtea",
60
+ "html": "<p>Your first email, sent with <strong>Mailtea</strong>.</p>",
61
+ })
62
+ print(sent["id"]) # responses support ["..."] and attribute access alike
63
+ ```
64
+
65
+ Keyword arguments use snake_case wire names; a trailing underscore escapes
66
+ Python reserved words (`from_=` → `"from"`).
67
+
68
+ ## API
69
+
70
+ | Method | Description |
71
+ | --- | --- |
72
+ | `emails.send(params)` | Send a transactional email → `{"id": ...}` |
73
+ | `emails.batch(emails)` | Send up to 100 emails → `{"data": [{"id": ...}]}` |
74
+ | `emails.get(id)` | Retrieve an email and its delivery status |
75
+ | `emails.list(params=None)` | List emails → `{"data", "total", "limit", "offset", "has_more"}` |
76
+ | `emails.update(id, params)` | Reschedule a scheduled email |
77
+ | `emails.reschedule(id, scheduled_at)` | Convenience wrapper over `update` |
78
+ | `emails.cancel(id)` | Cancel a scheduled email |
79
+ | `contacts.create / upsert / list / get / update / delete` | Manage audience contacts (`upsert` = `create`; the endpoint upserts) |
80
+ | `posts.create(...)` | Create a newsletter post (draft, or `send=True`) → `{"id": ...}` |
81
+ | `posts.send(id, scheduled_at=None)` | Send a draft post to the audience, now or scheduled |
82
+ | `posts.send_test(id, params)` | Send a `[TEST]` copy of a post → `{"sent_to", "failed_to"}` |
83
+ | `segments.create / list / get / update / delete` | Manage audience segments |
84
+ | `tags.create / list / get / update / delete` | Manage tag definitions |
85
+ | `domains.create / list / get / verify / update / delete` | Manage sending domains (add, read DNS records, verify) |
86
+ | `domains.tracking.create / list / verify / delete` | Manage CNAME tracking sub-domains under a domain |
87
+ | `webhooks.create / list / get / update / delete` | Manage outbound event subscriptions |
88
+ | `contact_properties.create / list / update / delete` | Manage custom contact fields (team-scoped) |
89
+ | `api_keys.create / list / revoke` | Manage API keys (`settings:write`) |
90
+
91
+ Payloads follow the REST wire format (`reply_to`, `scheduled_at`, …), passed as
92
+ keyword arguments or a plain dict. Errors raise `MailteaError` with `status`,
93
+ `details`, and `request_id`.
94
+
95
+ ## Develop
96
+
97
+ ```bash
98
+ cd sdks/python
99
+ python3 -m unittest discover -s tests -t .
100
+ ```
@@ -0,0 +1,18 @@
1
+ mailtea/__init__.py,sha256=cS4H6DPaM6sBvoEy3zYedMgxMXBhc7gGfQq-Z5w8bc8,212
2
+ mailtea/_resource.py,sha256=_QVaREujr5w1HtTvFaMlQAyqJUreZ6sHRiLE26nC4d4,1850
3
+ mailtea/_transport.py,sha256=FHXx6bsdNcgbPs1LRIaBfWRfTmzoyWBFfQQqSdcFOiM,1321
4
+ mailtea/api_keys.py,sha256=VTXPKFB8r4iAgcOUuxzIJAlUMJ11jrBRqp66pQIwpCk,1275
5
+ mailtea/client.py,sha256=L_PuxEwSbqAQhCzJ6nXKoyXoU2eCqYKBgthra6XXq98,3694
6
+ mailtea/contact_properties.py,sha256=WX1xJ7ylZMkgc1d1DKQ-Vhkzk5dANNG_awWDWcMDwsc,1450
7
+ mailtea/contacts.py,sha256=JFLk8DQ85T7PSaBfn_AORrR6AWaH4y_NYdQQ2XpfBek,2277
8
+ mailtea/domains.py,sha256=wM4X7Ve3kVw31wR7GvJsXjKydHHOGWiD7xvA7dpBGlI,4527
9
+ mailtea/emails.py,sha256=6trMltSTmPiI_o7VvRV-Kwbxv-n5fBleskI07vD7AC4,2720
10
+ mailtea/errors.py,sha256=DRvwEFoSuuRdP_Cb28GZYBpBKTYFWwUqbVLUh6Tb0fA,897
11
+ mailtea/posts.py,sha256=MMI-jk_05lRgpUbJZpMRrHkghYbXuHBQ5BNCZAmhASg,2227
12
+ mailtea/segments.py,sha256=EyMp6hlOaCfT_WmugLy91YiZ2SmNbUa53RBIbClMdWA,1873
13
+ mailtea/tags.py,sha256=zhG7w78scWVPgFSyKnIXzMeOXjxpBWBhG7hsnJ9qwIU,1891
14
+ mailtea/webhooks.py,sha256=rO6IM0ywZuQ0spKOw0ccy_lMsMtrDH4GhbJNJLz6wl0,1870
15
+ mailtea-0.1.2.dist-info/METADATA,sha256=carXUrSEQ6a-jmkFG7jxkuSX5LdvLNSkhZlcZdadjq4,3827
16
+ mailtea-0.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
17
+ mailtea-0.1.2.dist-info/licenses/LICENSE,sha256=mPPPljFBnTeFwc4OnW9TdnHIIjrqgDTtBsSVxC4McE4,1064
18
+ mailtea-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mailtea
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.