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 +110 -0
- mailcue/_version.py +5 -0
- mailcue/auth.py +50 -0
- mailcue/client.py +164 -0
- mailcue/events.py +200 -0
- mailcue/exceptions.py +81 -0
- mailcue/py.typed +0 -0
- mailcue/resources/__init__.py +3 -0
- mailcue/resources/_base.py +19 -0
- mailcue/resources/aliases.py +114 -0
- mailcue/resources/api_keys.py +63 -0
- mailcue/resources/domains.py +87 -0
- mailcue/resources/emails.py +532 -0
- mailcue/resources/gpg.py +169 -0
- mailcue/resources/mailboxes.py +178 -0
- mailcue/resources/system.py +98 -0
- mailcue/transport.py +413 -0
- mailcue/types.py +275 -0
- mailcue-0.1.0.dist-info/METADATA +201 -0
- mailcue-0.1.0.dist-info/RECORD +22 -0
- mailcue-0.1.0.dist-info/WHEEL +4 -0
- mailcue-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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,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
|