mailcue 0.1.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.
mailcue/__init__.py ADDED
@@ -0,0 +1,110 @@
1
+ """MailCue Python SDK.
2
+
3
+ Drop-in client for the MailCue REST API. Both ``Mailcue`` (sync) and
4
+ ``AsyncMailcue`` (async) clients expose the same resource surface:
5
+ ``emails``, ``mailboxes``, ``domains``, ``aliases``, ``gpg``,
6
+ ``api_keys``, ``system``, and the SSE ``events`` stream.
7
+
8
+ Example:
9
+ >>> from mailcue import Mailcue
10
+ >>> client = Mailcue(api_key="mc_...")
11
+ >>> client.emails.send(
12
+ ... from_="hello@example.com",
13
+ ... to=["user@example.com"],
14
+ ... subject="Welcome",
15
+ ... html="<h1>Hi</h1>",
16
+ ... )
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from mailcue._version import __version__
22
+ from mailcue.client import AsyncMailcue, Mailcue
23
+ from mailcue.exceptions import (
24
+ AuthenticationError,
25
+ AuthorizationError,
26
+ ConflictError,
27
+ MailcueError,
28
+ NetworkError,
29
+ NotFoundError,
30
+ RateLimitError,
31
+ ServerError,
32
+ TimeoutError,
33
+ ValidationError,
34
+ )
35
+ from mailcue.transport import DEFAULT_BASE_URL
36
+ from mailcue.types import (
37
+ Alias,
38
+ AliasListResponse,
39
+ ApiKey,
40
+ AttachmentInfo,
41
+ BulkInjectResponse,
42
+ CreatedApiKey,
43
+ DnsCheckResponse,
44
+ DnsRecordInfo,
45
+ Domain,
46
+ DomainDetail,
47
+ DomainListResponse,
48
+ EmailDetail,
49
+ EmailListResponse,
50
+ EmailSummary,
51
+ Event,
52
+ FolderInfo,
53
+ GpgEmailInfo,
54
+ GpgKey,
55
+ GpgKeyExport,
56
+ GpgKeyListResponse,
57
+ HealthResponse,
58
+ KeyserverPublishResult,
59
+ Mailbox,
60
+ MailboxListResponse,
61
+ MailboxStats,
62
+ SendResult,
63
+ SignatureStatus,
64
+ TlsCertificateStatus,
65
+ )
66
+
67
+ __all__ = [
68
+ "DEFAULT_BASE_URL",
69
+ "Alias",
70
+ "AliasListResponse",
71
+ "ApiKey",
72
+ "AsyncMailcue",
73
+ "AttachmentInfo",
74
+ "AuthenticationError",
75
+ "AuthorizationError",
76
+ "BulkInjectResponse",
77
+ "ConflictError",
78
+ "CreatedApiKey",
79
+ "DnsCheckResponse",
80
+ "DnsRecordInfo",
81
+ "Domain",
82
+ "DomainDetail",
83
+ "DomainListResponse",
84
+ "EmailDetail",
85
+ "EmailListResponse",
86
+ "EmailSummary",
87
+ "Event",
88
+ "FolderInfo",
89
+ "GpgEmailInfo",
90
+ "GpgKey",
91
+ "GpgKeyExport",
92
+ "GpgKeyListResponse",
93
+ "HealthResponse",
94
+ "KeyserverPublishResult",
95
+ "Mailbox",
96
+ "MailboxListResponse",
97
+ "MailboxStats",
98
+ "Mailcue",
99
+ "MailcueError",
100
+ "NetworkError",
101
+ "NotFoundError",
102
+ "RateLimitError",
103
+ "SendResult",
104
+ "ServerError",
105
+ "SignatureStatus",
106
+ "TimeoutError",
107
+ "TlsCertificateStatus",
108
+ "ValidationError",
109
+ "__version__",
110
+ ]
mailcue/_version.py ADDED
@@ -0,0 +1,5 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
mailcue/auth.py ADDED
@@ -0,0 +1,50 @@
1
+ """Auth header builders.
2
+
3
+ MailCue accepts either an API key (``X-API-Key: mc_...``) or a JWT bearer
4
+ token (``Authorization: Bearer ...``). The transport calls ``headers()``
5
+ on the configured strategy for each request.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from typing import Dict
12
+
13
+
14
+ class AuthStrategy(ABC):
15
+ """Strategy that produces auth headers for outgoing requests."""
16
+
17
+ @abstractmethod
18
+ def headers(self) -> Dict[str, str]:
19
+ """Return the headers to merge into each request."""
20
+
21
+
22
+ class ApiKeyAuth(AuthStrategy):
23
+ """Authenticate using a MailCue API key."""
24
+
25
+ def __init__(self, api_key: str) -> None:
26
+ if not api_key:
27
+ raise ValueError("api_key must not be empty")
28
+ self._api_key = api_key
29
+
30
+ def headers(self) -> Dict[str, str]:
31
+ return {"X-API-Key": self._api_key}
32
+
33
+
34
+ class BearerAuth(AuthStrategy):
35
+ """Authenticate using a JWT bearer token."""
36
+
37
+ def __init__(self, token: str) -> None:
38
+ if not token:
39
+ raise ValueError("token must not be empty")
40
+ self._token = token
41
+
42
+ def headers(self) -> Dict[str, str]:
43
+ return {"Authorization": f"Bearer {self._token}"}
44
+
45
+
46
+ class NoAuth(AuthStrategy):
47
+ """No-op strategy used for unauthenticated endpoints (e.g. /health)."""
48
+
49
+ def headers(self) -> Dict[str, str]:
50
+ return {}
mailcue/client.py ADDED
@@ -0,0 +1,164 @@
1
+ """Top-level :class:`Mailcue` and :class:`AsyncMailcue` clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import httpx
8
+
9
+ from mailcue.auth import ApiKeyAuth, AuthStrategy, BearerAuth, NoAuth
10
+ from mailcue.events import AsyncSSEClient, SSEClient
11
+ from mailcue.resources.aliases import Aliases, AsyncAliases
12
+ from mailcue.resources.api_keys import ApiKeys, AsyncApiKeys
13
+ from mailcue.resources.domains import AsyncDomains, Domains
14
+ from mailcue.resources.emails import AsyncEmails, Emails
15
+ from mailcue.resources.gpg import AsyncGpg, Gpg
16
+ from mailcue.resources.mailboxes import AsyncMailboxes, Mailboxes
17
+ from mailcue.resources.system import AsyncSystem, System
18
+ from mailcue.transport import (
19
+ DEFAULT_BASE_URL,
20
+ AsyncTransport,
21
+ SyncTransport,
22
+ build_config,
23
+ )
24
+
25
+
26
+ def _resolve_auth(
27
+ api_key: Optional[str],
28
+ bearer_token: Optional[str],
29
+ ) -> AuthStrategy:
30
+ if api_key and bearer_token:
31
+ raise ValueError("Pass either api_key or bearer_token, not both")
32
+ if api_key:
33
+ return ApiKeyAuth(api_key)
34
+ if bearer_token:
35
+ return BearerAuth(bearer_token)
36
+ return NoAuth()
37
+
38
+
39
+ class Mailcue:
40
+ """Synchronous MailCue API client.
41
+
42
+ Example:
43
+ >>> from mailcue import Mailcue
44
+ >>> client = Mailcue(api_key="mc_...", base_url="https://mail.example.com")
45
+ >>> client.emails.send(
46
+ ... from_="hello@example.com",
47
+ ... to=["user@example.com"],
48
+ ... subject="Hi",
49
+ ... html="<h1>Hello</h1>",
50
+ ... )
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ api_key: Optional[str] = None,
57
+ bearer_token: Optional[str] = None,
58
+ base_url: Optional[str] = None,
59
+ timeout: float = 30.0,
60
+ max_retries: int = 3,
61
+ backoff_base: float = 0.5,
62
+ backoff_cap: float = 8.0,
63
+ verify: bool = True,
64
+ http_client: Optional[httpx.Client] = None,
65
+ ) -> None:
66
+ auth = _resolve_auth(api_key, bearer_token)
67
+ config = build_config(
68
+ base_url=base_url,
69
+ timeout=timeout,
70
+ max_retries=max_retries,
71
+ backoff_base=backoff_base,
72
+ backoff_cap=backoff_cap,
73
+ verify=verify,
74
+ )
75
+ self._transport = SyncTransport(config, auth, client=http_client)
76
+ self.emails = Emails(self._transport)
77
+ self.mailboxes = Mailboxes(self._transport)
78
+ self.domains = Domains(self._transport)
79
+ self.aliases = Aliases(self._transport)
80
+ self.gpg = Gpg(self._transport)
81
+ self.api_keys = ApiKeys(self._transport)
82
+ self.system = System(self._transport)
83
+ self.events = SSEClient(self._transport)
84
+
85
+ @property
86
+ def base_url(self) -> str:
87
+ return self._transport.base_url
88
+
89
+ def close(self) -> None:
90
+ """Close the underlying HTTP client (only if owned by this client)."""
91
+ self._transport.close()
92
+
93
+ def __enter__(self) -> "Mailcue":
94
+ return self
95
+
96
+ def __exit__(self, *_: object) -> None:
97
+ self.close()
98
+
99
+
100
+ class AsyncMailcue:
101
+ """Asynchronous MailCue API client.
102
+
103
+ Example:
104
+ >>> import asyncio
105
+ >>> from mailcue import AsyncMailcue
106
+ >>> async def main() -> None:
107
+ ... async with AsyncMailcue(api_key="mc_...") as client:
108
+ ... await client.emails.send(
109
+ ... from_="hello@example.com",
110
+ ... to=["user@example.com"],
111
+ ... subject="Hi",
112
+ ... html="<h1>Hello</h1>",
113
+ ... )
114
+ >>> asyncio.run(main())
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ *,
120
+ api_key: Optional[str] = None,
121
+ bearer_token: Optional[str] = None,
122
+ base_url: Optional[str] = None,
123
+ timeout: float = 30.0,
124
+ max_retries: int = 3,
125
+ backoff_base: float = 0.5,
126
+ backoff_cap: float = 8.0,
127
+ verify: bool = True,
128
+ http_client: Optional[httpx.AsyncClient] = None,
129
+ ) -> None:
130
+ auth = _resolve_auth(api_key, bearer_token)
131
+ config = build_config(
132
+ base_url=base_url,
133
+ timeout=timeout,
134
+ max_retries=max_retries,
135
+ backoff_base=backoff_base,
136
+ backoff_cap=backoff_cap,
137
+ verify=verify,
138
+ )
139
+ self._transport = AsyncTransport(config, auth, client=http_client)
140
+ self.emails = AsyncEmails(self._transport)
141
+ self.mailboxes = AsyncMailboxes(self._transport)
142
+ self.domains = AsyncDomains(self._transport)
143
+ self.aliases = AsyncAliases(self._transport)
144
+ self.gpg = AsyncGpg(self._transport)
145
+ self.api_keys = AsyncApiKeys(self._transport)
146
+ self.system = AsyncSystem(self._transport)
147
+ self.events = AsyncSSEClient(self._transport)
148
+
149
+ @property
150
+ def base_url(self) -> str:
151
+ return self._transport.base_url
152
+
153
+ async def aclose(self) -> None:
154
+ """Close the underlying HTTP client (only if owned by this client)."""
155
+ await self._transport.aclose()
156
+
157
+ async def __aenter__(self) -> "AsyncMailcue":
158
+ return self
159
+
160
+ async def __aexit__(self, *_: object) -> None:
161
+ await self.aclose()
162
+
163
+
164
+ __all__ = ["DEFAULT_BASE_URL", "AsyncMailcue", "Mailcue"]
mailcue/events.py ADDED
@@ -0,0 +1,200 @@
1
+ """Server-Sent Events client (sync + async) with auto-reconnect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import random
9
+ import time
10
+ from typing import AsyncIterator, Iterator, List, Optional
11
+
12
+ import httpx
13
+
14
+ from mailcue.exceptions import MailcueError, NetworkError
15
+ from mailcue.exceptions import TimeoutError as MailcueTimeoutError
16
+ from mailcue.transport import AsyncTransport, SyncTransport
17
+ from mailcue.types import Event
18
+
19
+ logger = logging.getLogger("mailcue.events")
20
+
21
+ _RECONNECT_BASE = 0.5
22
+ _RECONNECT_CAP = 30.0
23
+
24
+
25
+ def _parse_event(lines: List[str]) -> Optional[Event]:
26
+ """Parse one SSE block of lines into an :class:`Event`."""
27
+ event_type = "message"
28
+ data_lines: List[str] = []
29
+ event_id: Optional[str] = None
30
+ retry: Optional[int] = None
31
+ for line in lines:
32
+ if not line or line.startswith(":"):
33
+ continue
34
+ field, _, value = line.partition(":")
35
+ if value.startswith(" "):
36
+ value = value[1:]
37
+ if field == "event":
38
+ event_type = value
39
+ elif field == "data":
40
+ data_lines.append(value)
41
+ elif field == "id":
42
+ event_id = value
43
+ elif field == "retry":
44
+ try:
45
+ retry = int(value)
46
+ except ValueError:
47
+ retry = None
48
+ if not data_lines and event_type == "message":
49
+ return None
50
+ raw_data = "\n".join(data_lines)
51
+ payload: object = {}
52
+ if raw_data:
53
+ try:
54
+ payload = json.loads(raw_data)
55
+ except json.JSONDecodeError:
56
+ payload = {"raw": raw_data}
57
+ if not isinstance(payload, dict):
58
+ payload = {"value": payload}
59
+ return Event(event_type=event_type, data=payload, id=event_id, retry=retry)
60
+
61
+
62
+ def _reconnect_delay(attempt: int) -> float:
63
+ raw: float = min(_RECONNECT_CAP, _RECONNECT_BASE * float(2**attempt))
64
+ jitter: float = raw * 0.2
65
+ delay: float = raw + random.uniform(-jitter, jitter)
66
+ return max(0.0, delay)
67
+
68
+
69
+ class SSEClient:
70
+ """Synchronous SSE consumer with exponential-backoff reconnects."""
71
+
72
+ def __init__(
73
+ self,
74
+ transport: SyncTransport,
75
+ *,
76
+ path: str = "/events/stream",
77
+ reconnect: bool = True,
78
+ ) -> None:
79
+ self._transport = transport
80
+ self._path = path
81
+ self._reconnect = reconnect
82
+
83
+ def __iter__(self) -> Iterator[Event]:
84
+ return self.stream()
85
+
86
+ def stream(self) -> Iterator[Event]:
87
+ """Yield events forever (or until the server signals close).
88
+
89
+ Example:
90
+ >>> for event in client.events.stream():
91
+ ... print(event.event_type, event.data)
92
+ """
93
+ attempt = 0
94
+ while True:
95
+ try:
96
+ yield from self._iterate_once()
97
+ attempt = 0
98
+ if not self._reconnect:
99
+ return
100
+ except (NetworkError, MailcueTimeoutError, httpx.HTTPError) as exc:
101
+ if not self._reconnect:
102
+ raise
103
+ delay = _reconnect_delay(attempt)
104
+ logger.warning(
105
+ "SSE connection lost (%s); reconnecting in %.2fs",
106
+ exc,
107
+ delay,
108
+ )
109
+ time.sleep(delay)
110
+ attempt += 1
111
+
112
+ def _iterate_once(self) -> Iterator[Event]:
113
+ response = self._transport.open_stream(
114
+ "GET",
115
+ self._path,
116
+ headers={"Accept": "text/event-stream", "Cache-Control": "no-cache"},
117
+ timeout=None,
118
+ )
119
+ try:
120
+ buffer: List[str] = []
121
+ for line in response.iter_lines():
122
+ if line == "":
123
+ event = _parse_event(buffer)
124
+ buffer = []
125
+ if event is not None:
126
+ yield event
127
+ else:
128
+ buffer.append(line)
129
+ if buffer:
130
+ event = _parse_event(buffer)
131
+ if event is not None:
132
+ yield event
133
+ finally:
134
+ response.close()
135
+
136
+
137
+ class AsyncSSEClient:
138
+ """Asynchronous SSE consumer with exponential-backoff reconnects."""
139
+
140
+ def __init__(
141
+ self,
142
+ transport: AsyncTransport,
143
+ *,
144
+ path: str = "/events/stream",
145
+ reconnect: bool = True,
146
+ ) -> None:
147
+ self._transport = transport
148
+ self._path = path
149
+ self._reconnect = reconnect
150
+
151
+ def __aiter__(self) -> AsyncIterator[Event]:
152
+ return self.stream()
153
+
154
+ async def stream(self) -> AsyncIterator[Event]:
155
+ """Yield events forever (or until the server signals close)."""
156
+ attempt = 0
157
+ while True:
158
+ try:
159
+ async for event in self._iterate_once():
160
+ attempt = 0
161
+ yield event
162
+ if not self._reconnect:
163
+ return
164
+ except (NetworkError, MailcueTimeoutError, httpx.HTTPError) as exc:
165
+ if not self._reconnect:
166
+ raise
167
+ delay = _reconnect_delay(attempt)
168
+ logger.warning(
169
+ "SSE connection lost (%s); reconnecting in %.2fs",
170
+ exc,
171
+ delay,
172
+ )
173
+ await asyncio.sleep(delay)
174
+ attempt += 1
175
+ except MailcueError:
176
+ raise
177
+
178
+ async def _iterate_once(self) -> AsyncIterator[Event]:
179
+ response = await self._transport.open_stream(
180
+ "GET",
181
+ self._path,
182
+ headers={"Accept": "text/event-stream", "Cache-Control": "no-cache"},
183
+ timeout=None,
184
+ )
185
+ try:
186
+ buffer: List[str] = []
187
+ async for line in response.aiter_lines():
188
+ if line == "":
189
+ event = _parse_event(buffer)
190
+ buffer = []
191
+ if event is not None:
192
+ yield event
193
+ else:
194
+ buffer.append(line)
195
+ if buffer:
196
+ event = _parse_event(buffer)
197
+ if event is not None:
198
+ yield event
199
+ finally:
200
+ await response.aclose()
mailcue/exceptions.py ADDED
@@ -0,0 +1,81 @@
1
+ """Exception hierarchy for the MailCue SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+
8
+ class MailcueError(Exception):
9
+ """Base class for every error raised by the MailCue SDK."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status_code: Optional[int] = None,
16
+ detail: Any = None,
17
+ response_body: Any = None,
18
+ ) -> None:
19
+ super().__init__(message)
20
+ self.message = message
21
+ self.status_code = status_code
22
+ self.detail = detail
23
+ self.response_body = response_body
24
+
25
+ def __str__(self) -> str:
26
+ if self.status_code is not None:
27
+ return f"[{self.status_code}] {self.message}"
28
+ return self.message
29
+
30
+
31
+ class NetworkError(MailcueError):
32
+ """Connection failure, DNS error, or other transport-level network issue."""
33
+
34
+
35
+ class TimeoutError(MailcueError):
36
+ """Request exceeded the configured timeout."""
37
+
38
+
39
+ class AuthenticationError(MailcueError):
40
+ """HTTP 401: invalid or missing API key / bearer token."""
41
+
42
+
43
+ class AuthorizationError(AuthenticationError):
44
+ """HTTP 403: caller authenticated but lacks required privileges."""
45
+
46
+
47
+ class NotFoundError(MailcueError):
48
+ """HTTP 404: requested resource does not exist."""
49
+
50
+
51
+ class ConflictError(MailcueError):
52
+ """HTTP 409: request conflicts with current resource state."""
53
+
54
+
55
+ class ValidationError(MailcueError):
56
+ """HTTP 400 or 422: request failed server-side validation."""
57
+
58
+
59
+ class RateLimitError(MailcueError):
60
+ """HTTP 429: caller exceeded the rate limit."""
61
+
62
+ def __init__(
63
+ self,
64
+ message: str,
65
+ *,
66
+ retry_after: Optional[float] = None,
67
+ status_code: Optional[int] = 429,
68
+ detail: Any = None,
69
+ response_body: Any = None,
70
+ ) -> None:
71
+ super().__init__(
72
+ message,
73
+ status_code=status_code,
74
+ detail=detail,
75
+ response_body=response_body,
76
+ )
77
+ self.retry_after = retry_after
78
+
79
+
80
+ class ServerError(MailcueError):
81
+ """HTTP 5xx: MailCue server-side failure."""
mailcue/py.typed ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ """Resource modules grouping related API endpoints."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,19 @@
1
+ """Base classes for API resource bindings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mailcue.transport import AsyncTransport, SyncTransport
6
+
7
+
8
+ class SyncResource:
9
+ """Resource backed by a synchronous transport."""
10
+
11
+ def __init__(self, transport: SyncTransport) -> None:
12
+ self._transport = transport
13
+
14
+
15
+ class AsyncResource:
16
+ """Resource backed by an asynchronous transport."""
17
+
18
+ def __init__(self, transport: AsyncTransport) -> None:
19
+ self._transport = transport