kstlib 0.0.1a0__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Gmail API transport for async email delivery via OAuth2.
|
|
2
|
+
|
|
3
|
+
Send emails through the Gmail API using OAuth2 authentication. This transport
|
|
4
|
+
integrates with the kstlib.auth module for token management.
|
|
5
|
+
|
|
6
|
+
Requirements:
|
|
7
|
+
pip install httpx
|
|
8
|
+
|
|
9
|
+
OAuth2 Scopes:
|
|
10
|
+
https://www.googleapis.com/auth/gmail.send
|
|
11
|
+
|
|
12
|
+
API Documentation:
|
|
13
|
+
https://developers.google.com/gmail/api/reference/rest/v1/users.messages/send
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
Using a Token directly::
|
|
17
|
+
|
|
18
|
+
from kstlib.auth import Token
|
|
19
|
+
from kstlib.mail.transports import GmailTransport
|
|
20
|
+
|
|
21
|
+
token = Token(access_token="ya29.xxx", scope=["gmail.send"])
|
|
22
|
+
transport = GmailTransport(token=token)
|
|
23
|
+
await transport.send(message)
|
|
24
|
+
|
|
25
|
+
Using TokenStorage from kstlib.auth::
|
|
26
|
+
|
|
27
|
+
from kstlib.auth import OIDCProvider, AuthSession
|
|
28
|
+
from kstlib.mail.transports import GmailTransport
|
|
29
|
+
|
|
30
|
+
provider = OIDCProvider.from_config("google")
|
|
31
|
+
with AuthSession(provider) as session:
|
|
32
|
+
token = provider.get_token()
|
|
33
|
+
transport = GmailTransport(token=token)
|
|
34
|
+
await transport.send(message)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import base64
|
|
40
|
+
import logging
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from typing import TYPE_CHECKING
|
|
43
|
+
|
|
44
|
+
from kstlib.logging import TRACE_LEVEL
|
|
45
|
+
from kstlib.mail.exceptions import MailConfigurationError, MailTransportError
|
|
46
|
+
from kstlib.mail.transport import AsyncMailTransport, handle_http_error_response
|
|
47
|
+
from kstlib.utils.http_trace import create_trace_event_hooks
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from email.message import EmailMessage
|
|
51
|
+
|
|
52
|
+
import httpx
|
|
53
|
+
|
|
54
|
+
from kstlib.auth import Token
|
|
55
|
+
|
|
56
|
+
__all__ = ["GmailTransport"]
|
|
57
|
+
|
|
58
|
+
log = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
GMAIL_API_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class GmailResponse:
|
|
65
|
+
"""Response from Gmail API after sending an email.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
id: The immutable ID of the sent message.
|
|
69
|
+
thread_id: The ID of the thread the message belongs to.
|
|
70
|
+
label_ids: List of label IDs applied to this message.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
id: str
|
|
74
|
+
thread_id: str
|
|
75
|
+
label_ids: list[str]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class GmailTransport(AsyncMailTransport):
|
|
79
|
+
"""Async transport for sending emails via Gmail API.
|
|
80
|
+
|
|
81
|
+
Uses OAuth2 Bearer token authentication. The token must have the
|
|
82
|
+
'https://www.googleapis.com/auth/gmail.send' scope.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
token: OAuth2 token from kstlib.auth module.
|
|
86
|
+
base_url: API endpoint URL (default: Gmail send endpoint).
|
|
87
|
+
timeout: Request timeout in seconds (default: 30.0).
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
MailConfigurationError: If token is missing or invalid.
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
Basic send::
|
|
94
|
+
|
|
95
|
+
from kstlib.auth import Token
|
|
96
|
+
from kstlib.mail.transports import GmailTransport
|
|
97
|
+
|
|
98
|
+
token = Token(access_token="ya29.xxx")
|
|
99
|
+
transport = GmailTransport(token=token)
|
|
100
|
+
|
|
101
|
+
message = EmailMessage()
|
|
102
|
+
message["From"] = "sender@gmail.com"
|
|
103
|
+
message["To"] = "recipient@example.com"
|
|
104
|
+
message["Subject"] = "Hello"
|
|
105
|
+
message.set_content("Email body")
|
|
106
|
+
|
|
107
|
+
await transport.send(message)
|
|
108
|
+
|
|
109
|
+
With token refresh callback::
|
|
110
|
+
|
|
111
|
+
async def refresh_token() -> Token:
|
|
112
|
+
# Refresh logic using kstlib.auth
|
|
113
|
+
return new_token
|
|
114
|
+
|
|
115
|
+
transport = GmailTransport(
|
|
116
|
+
token=token,
|
|
117
|
+
on_token_refresh=refresh_token,
|
|
118
|
+
)
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
token: Token,
|
|
124
|
+
*,
|
|
125
|
+
base_url: str = GMAIL_API_URL,
|
|
126
|
+
timeout: float = 30.0,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Initialize the Gmail transport.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
token: OAuth2 token with gmail.send scope.
|
|
132
|
+
base_url: API endpoint URL.
|
|
133
|
+
timeout: Request timeout in seconds.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
MailConfigurationError: If token is None or has no access_token.
|
|
137
|
+
"""
|
|
138
|
+
if token is None:
|
|
139
|
+
raise MailConfigurationError("OAuth2 token is required for GmailTransport")
|
|
140
|
+
|
|
141
|
+
if not token.access_token:
|
|
142
|
+
raise MailConfigurationError("Token must have an access_token")
|
|
143
|
+
|
|
144
|
+
self._token = token
|
|
145
|
+
self._base_url = base_url
|
|
146
|
+
self._timeout = timeout
|
|
147
|
+
self._last_response: GmailResponse | None = None
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def token(self) -> Token:
|
|
151
|
+
"""Return the current OAuth2 token."""
|
|
152
|
+
return self._token
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def last_response(self) -> GmailResponse | None:
|
|
156
|
+
"""Return the response from the last successful send."""
|
|
157
|
+
return self._last_response
|
|
158
|
+
|
|
159
|
+
def update_token(self, token: Token) -> None:
|
|
160
|
+
"""Update the OAuth2 token (e.g., after refresh).
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
token: New OAuth2 token.
|
|
164
|
+
"""
|
|
165
|
+
self._token = token
|
|
166
|
+
|
|
167
|
+
async def send(self, message: EmailMessage) -> None:
|
|
168
|
+
"""Send an email via the Gmail API.
|
|
169
|
+
|
|
170
|
+
Encodes the EmailMessage in base64url format and posts it to the
|
|
171
|
+
Gmail API. The sender must match the authenticated user's email
|
|
172
|
+
address (or an alias).
|
|
173
|
+
|
|
174
|
+
When TRACE logging is enabled, detailed HTTP request/response
|
|
175
|
+
information is logged including headers and body.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
message: The email message to send.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
MailTransportError: If the API request fails.
|
|
182
|
+
MailConfigurationError: If httpx is not installed.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
import httpx
|
|
186
|
+
except ImportError as e:
|
|
187
|
+
raise MailConfigurationError("httpx is required for GmailTransport. Install with: pip install httpx") from e
|
|
188
|
+
|
|
189
|
+
# Check token expiration
|
|
190
|
+
if self._token.is_expired:
|
|
191
|
+
log.warning("OAuth2 token is expired, request may fail")
|
|
192
|
+
|
|
193
|
+
# Encode message as base64url
|
|
194
|
+
raw_message = self._encode_message(message)
|
|
195
|
+
payload = {"raw": raw_message}
|
|
196
|
+
|
|
197
|
+
headers = {
|
|
198
|
+
"Authorization": f"Bearer {self._token.access_token}",
|
|
199
|
+
"Content-Type": "application/json",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# Setup trace logging if enabled
|
|
203
|
+
event_hooks, trace_enabled = create_trace_event_hooks(log, TRACE_LEVEL)
|
|
204
|
+
if trace_enabled:
|
|
205
|
+
log.log(TRACE_LEVEL, "[Gmail] Sending email via Gmail API")
|
|
206
|
+
log.log(TRACE_LEVEL, "[Gmail] From: %s, To: %s", message.get("From"), message.get("To"))
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
async with httpx.AsyncClient(timeout=self._timeout, event_hooks=event_hooks) as client:
|
|
210
|
+
response = await client.post(
|
|
211
|
+
self._base_url,
|
|
212
|
+
json=payload,
|
|
213
|
+
headers=headers,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if response.status_code == 401:
|
|
217
|
+
raise MailTransportError("Gmail API authentication failed. Token may be expired or revoked.")
|
|
218
|
+
|
|
219
|
+
if response.status_code >= 400:
|
|
220
|
+
self._handle_error_response(response)
|
|
221
|
+
|
|
222
|
+
data = response.json()
|
|
223
|
+
self._last_response = GmailResponse(
|
|
224
|
+
id=data.get("id", ""),
|
|
225
|
+
thread_id=data.get("threadId", ""),
|
|
226
|
+
label_ids=data.get("labelIds", []),
|
|
227
|
+
)
|
|
228
|
+
log.debug("Email sent via Gmail API: %s", self._last_response.id)
|
|
229
|
+
if trace_enabled:
|
|
230
|
+
log.log(TRACE_LEVEL, "[Gmail] Message sent successfully, id=%s", self._last_response.id)
|
|
231
|
+
|
|
232
|
+
except httpx.TimeoutException as e:
|
|
233
|
+
if trace_enabled:
|
|
234
|
+
log.log(TRACE_LEVEL, "[Gmail] Timeout error: %s", e)
|
|
235
|
+
raise MailTransportError(f"Gmail API timeout: {e}") from e
|
|
236
|
+
except httpx.RequestError as e:
|
|
237
|
+
if trace_enabled:
|
|
238
|
+
log.log(TRACE_LEVEL, "[Gmail] Request error: %s", e)
|
|
239
|
+
raise MailTransportError(f"Gmail API request failed: {e}") from e
|
|
240
|
+
|
|
241
|
+
def _encode_message(self, message: EmailMessage) -> str:
|
|
242
|
+
"""Encode EmailMessage to base64url string.
|
|
243
|
+
|
|
244
|
+
Gmail API expects the raw RFC 2822 message encoded in base64url
|
|
245
|
+
(URL-safe base64 without padding).
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
message: The email message.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Base64url encoded message string.
|
|
252
|
+
"""
|
|
253
|
+
raw_bytes = message.as_bytes()
|
|
254
|
+
# base64url encoding (URL-safe, no padding)
|
|
255
|
+
encoded = base64.urlsafe_b64encode(raw_bytes).decode("ascii")
|
|
256
|
+
# Remove padding (Gmail API expects no padding)
|
|
257
|
+
return encoded.rstrip("=")
|
|
258
|
+
|
|
259
|
+
def _handle_error_response(self, response: httpx.Response) -> None:
|
|
260
|
+
"""Handle error response from Gmail API.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
response: The HTTP response.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
MailTransportError: Always raises with error details.
|
|
267
|
+
"""
|
|
268
|
+
handle_http_error_response(response, "Gmail", log, extract_code=True)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Resend.com API transport for async email delivery.
|
|
2
|
+
|
|
3
|
+
Resend is a modern email API for developers. This transport sends emails
|
|
4
|
+
via the Resend REST API using async HTTP requests.
|
|
5
|
+
|
|
6
|
+
Requirements:
|
|
7
|
+
pip install httpx
|
|
8
|
+
|
|
9
|
+
API Documentation:
|
|
10
|
+
https://resend.com/docs/api-reference/emails/send-email
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
Basic usage with API key::
|
|
14
|
+
|
|
15
|
+
from kstlib.mail.transports import ResendTransport
|
|
16
|
+
|
|
17
|
+
transport = ResendTransport(api_key="re_123456789")
|
|
18
|
+
|
|
19
|
+
# Use with MailBuilder
|
|
20
|
+
mail = MailBuilder(transport=transport)
|
|
21
|
+
await mail.sender("you@example.com").to("user@example.com").send_async()
|
|
22
|
+
|
|
23
|
+
With environment variable::
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
transport = ResendTransport(api_key=os.environ["RESEND_API_KEY"])
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import base64
|
|
32
|
+
import logging
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import TYPE_CHECKING, Any
|
|
35
|
+
|
|
36
|
+
from kstlib.logging import TRACE_LEVEL
|
|
37
|
+
from kstlib.mail.exceptions import MailConfigurationError, MailTransportError
|
|
38
|
+
from kstlib.mail.transport import AsyncMailTransport, handle_http_error_response
|
|
39
|
+
from kstlib.utils.http_trace import create_trace_event_hooks
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from email.message import EmailMessage
|
|
43
|
+
|
|
44
|
+
import httpx
|
|
45
|
+
|
|
46
|
+
__all__ = ["ResendTransport"]
|
|
47
|
+
|
|
48
|
+
log = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
RESEND_API_URL = "https://api.resend.com/emails"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class ResendResponse:
|
|
55
|
+
"""Response from Resend API after sending an email.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
id: The unique ID assigned to the sent email.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
id: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ResendTransport(AsyncMailTransport):
|
|
65
|
+
"""Async transport for sending emails via Resend.com API.
|
|
66
|
+
|
|
67
|
+
Resend provides a simple REST API for sending transactional emails.
|
|
68
|
+
This transport converts EmailMessage objects to Resend's JSON format
|
|
69
|
+
and sends them asynchronously using httpx.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
api_key: Resend API key (starts with 're_').
|
|
73
|
+
base_url: API base URL (default: https://api.resend.com/emails).
|
|
74
|
+
timeout: Request timeout in seconds (default: 30.0).
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
Basic send::
|
|
78
|
+
|
|
79
|
+
transport = ResendTransport(api_key="re_123456789")
|
|
80
|
+
|
|
81
|
+
message = EmailMessage()
|
|
82
|
+
message["From"] = "sender@example.com"
|
|
83
|
+
message["To"] = "recipient@example.com"
|
|
84
|
+
message["Subject"] = "Hello"
|
|
85
|
+
message.set_content("Plain text body")
|
|
86
|
+
|
|
87
|
+
await transport.send(message)
|
|
88
|
+
|
|
89
|
+
With HTML content::
|
|
90
|
+
|
|
91
|
+
message = EmailMessage()
|
|
92
|
+
message["From"] = "sender@example.com"
|
|
93
|
+
message["To"] = "recipient@example.com"
|
|
94
|
+
message["Subject"] = "Welcome"
|
|
95
|
+
message.set_content("Plain text fallback")
|
|
96
|
+
message.add_alternative("<h1>Welcome!</h1>", subtype="html")
|
|
97
|
+
|
|
98
|
+
await transport.send(message)
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
api_key: str,
|
|
104
|
+
*,
|
|
105
|
+
base_url: str = RESEND_API_URL,
|
|
106
|
+
timeout: float = 30.0,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Initialize the Resend transport.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
api_key: Resend API key.
|
|
112
|
+
base_url: API endpoint URL.
|
|
113
|
+
timeout: Request timeout in seconds.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
MailConfigurationError: If api_key is empty.
|
|
117
|
+
"""
|
|
118
|
+
if not api_key:
|
|
119
|
+
raise MailConfigurationError("Resend API key is required")
|
|
120
|
+
|
|
121
|
+
self._api_key = api_key
|
|
122
|
+
self._base_url = base_url
|
|
123
|
+
self._timeout = timeout
|
|
124
|
+
self._last_response: ResendResponse | None = None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def last_response(self) -> ResendResponse | None:
|
|
128
|
+
"""Return the response from the last successful send."""
|
|
129
|
+
return self._last_response
|
|
130
|
+
|
|
131
|
+
async def send(self, message: EmailMessage) -> None:
|
|
132
|
+
"""Send an email via the Resend API.
|
|
133
|
+
|
|
134
|
+
Converts the EmailMessage to Resend's JSON format and posts it
|
|
135
|
+
to the API. Supports plain text, HTML, and attachments.
|
|
136
|
+
|
|
137
|
+
When TRACE logging is enabled, detailed HTTP request/response
|
|
138
|
+
information is logged including headers and body.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
message: The email message to send.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
MailTransportError: If the API request fails.
|
|
145
|
+
MailConfigurationError: If required fields are missing.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
import httpx
|
|
149
|
+
except ImportError as e:
|
|
150
|
+
raise MailConfigurationError(
|
|
151
|
+
"httpx is required for ResendTransport. Install with: pip install httpx"
|
|
152
|
+
) from e
|
|
153
|
+
|
|
154
|
+
payload = self._build_payload(message)
|
|
155
|
+
|
|
156
|
+
headers = {
|
|
157
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Setup trace logging if enabled
|
|
162
|
+
event_hooks, trace_enabled = create_trace_event_hooks(log, TRACE_LEVEL)
|
|
163
|
+
if trace_enabled:
|
|
164
|
+
log.log(TRACE_LEVEL, "[Resend] Sending email via Resend API")
|
|
165
|
+
log.log(TRACE_LEVEL, "[Resend] From: %s, To: %s", message.get("From"), message.get("To"))
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
async with httpx.AsyncClient(timeout=self._timeout, event_hooks=event_hooks) as client:
|
|
169
|
+
response = await client.post(
|
|
170
|
+
self._base_url,
|
|
171
|
+
json=payload,
|
|
172
|
+
headers=headers,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if response.status_code >= 400:
|
|
176
|
+
self._handle_error_response(response)
|
|
177
|
+
|
|
178
|
+
data = response.json()
|
|
179
|
+
self._last_response = ResendResponse(id=data.get("id", ""))
|
|
180
|
+
log.debug("Email sent via Resend: %s", self._last_response.id)
|
|
181
|
+
if trace_enabled:
|
|
182
|
+
log.log(TRACE_LEVEL, "[Resend] Message sent successfully, id=%s", self._last_response.id)
|
|
183
|
+
|
|
184
|
+
except httpx.TimeoutException as e:
|
|
185
|
+
if trace_enabled:
|
|
186
|
+
log.log(TRACE_LEVEL, "[Resend] Timeout error: %s", e)
|
|
187
|
+
raise MailTransportError(f"Resend API timeout: {e}") from e
|
|
188
|
+
except httpx.RequestError as e:
|
|
189
|
+
if trace_enabled:
|
|
190
|
+
log.log(TRACE_LEVEL, "[Resend] Request error: %s", e)
|
|
191
|
+
raise MailTransportError(f"Resend API request failed: {e}") from e
|
|
192
|
+
|
|
193
|
+
def _build_payload(self, message: EmailMessage) -> dict[str, Any]:
|
|
194
|
+
"""Convert EmailMessage to Resend API payload.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
message: The email message.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Dict suitable for JSON serialization.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
MailConfigurationError: If required fields are missing.
|
|
204
|
+
"""
|
|
205
|
+
from_addr = message.get("From")
|
|
206
|
+
if not from_addr:
|
|
207
|
+
raise MailConfigurationError("Email must have a From address")
|
|
208
|
+
|
|
209
|
+
to_addrs = self._parse_recipients(message.get("To", ""))
|
|
210
|
+
if not to_addrs:
|
|
211
|
+
raise MailConfigurationError("Email must have at least one To address")
|
|
212
|
+
|
|
213
|
+
payload: dict[str, Any] = {
|
|
214
|
+
"from": from_addr,
|
|
215
|
+
"to": to_addrs,
|
|
216
|
+
"subject": message.get("Subject", ""),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# Add CC and BCC if present
|
|
220
|
+
cc_addrs = self._parse_recipients(message.get("Cc", ""))
|
|
221
|
+
if cc_addrs:
|
|
222
|
+
payload["cc"] = cc_addrs
|
|
223
|
+
|
|
224
|
+
bcc_addrs = self._parse_recipients(message.get("Bcc", ""))
|
|
225
|
+
if bcc_addrs:
|
|
226
|
+
payload["bcc"] = bcc_addrs
|
|
227
|
+
|
|
228
|
+
# Add Reply-To if present
|
|
229
|
+
reply_to = message.get("Reply-To")
|
|
230
|
+
if reply_to:
|
|
231
|
+
payload["reply_to"] = reply_to
|
|
232
|
+
|
|
233
|
+
# Extract body content
|
|
234
|
+
self._add_body_content(message, payload)
|
|
235
|
+
|
|
236
|
+
# Extract attachments
|
|
237
|
+
attachments = self._extract_attachments(message)
|
|
238
|
+
if attachments:
|
|
239
|
+
payload["attachments"] = attachments
|
|
240
|
+
|
|
241
|
+
return payload
|
|
242
|
+
|
|
243
|
+
def _parse_recipients(self, header_value: str) -> list[str]:
|
|
244
|
+
"""Parse comma-separated email addresses.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
header_value: The header value (e.g., "a@x.com, b@x.com").
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
List of email addresses.
|
|
251
|
+
"""
|
|
252
|
+
if not header_value:
|
|
253
|
+
return []
|
|
254
|
+
return [addr.strip() for addr in header_value.split(",") if addr.strip()]
|
|
255
|
+
|
|
256
|
+
def _add_body_content(self, message: EmailMessage, payload: dict[str, Any]) -> None:
|
|
257
|
+
"""Extract and add body content to payload.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
message: The email message.
|
|
261
|
+
payload: The API payload to update.
|
|
262
|
+
"""
|
|
263
|
+
# Get the message body - handle multipart
|
|
264
|
+
# Note: get_content() adds trailing newline, so we strip it
|
|
265
|
+
if message.is_multipart():
|
|
266
|
+
for part in message.walk():
|
|
267
|
+
content_type = part.get_content_type()
|
|
268
|
+
if content_type == "text/plain" and "text" not in payload:
|
|
269
|
+
content = part.get_content()
|
|
270
|
+
if isinstance(content, str):
|
|
271
|
+
payload["text"] = content.rstrip("\n")
|
|
272
|
+
elif content_type == "text/html" and "html" not in payload:
|
|
273
|
+
content = part.get_content()
|
|
274
|
+
if isinstance(content, str):
|
|
275
|
+
payload["html"] = content.rstrip("\n")
|
|
276
|
+
else:
|
|
277
|
+
# Simple message
|
|
278
|
+
content = message.get_content()
|
|
279
|
+
if isinstance(content, str):
|
|
280
|
+
text = content.rstrip("\n")
|
|
281
|
+
if message.get_content_type() == "text/html":
|
|
282
|
+
payload["html"] = text
|
|
283
|
+
else:
|
|
284
|
+
payload["text"] = text
|
|
285
|
+
|
|
286
|
+
def _extract_attachments(self, message: EmailMessage) -> list[dict[str, str]]:
|
|
287
|
+
"""Extract attachments from the email message.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
message: The email message.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of attachment dicts with filename and base64 content.
|
|
294
|
+
"""
|
|
295
|
+
attachments: list[dict[str, str]] = []
|
|
296
|
+
|
|
297
|
+
if not message.is_multipart():
|
|
298
|
+
return attachments
|
|
299
|
+
|
|
300
|
+
for part in message.walk():
|
|
301
|
+
content_disposition = part.get("Content-Disposition", "")
|
|
302
|
+
if "attachment" in content_disposition:
|
|
303
|
+
filename = part.get_filename() or "attachment"
|
|
304
|
+
content = part.get_payload(decode=True)
|
|
305
|
+
if isinstance(content, bytes):
|
|
306
|
+
attachments.append(
|
|
307
|
+
{
|
|
308
|
+
"filename": filename,
|
|
309
|
+
"content": base64.b64encode(content).decode("ascii"),
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return attachments
|
|
314
|
+
|
|
315
|
+
def _handle_error_response(self, response: httpx.Response) -> None:
|
|
316
|
+
"""Handle error response from Resend API.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
response: The HTTP response.
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
MailTransportError: Always raises with error details.
|
|
323
|
+
"""
|
|
324
|
+
handle_http_error_response(response, "Resend", log)
|