kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.1.dist-info/METADATA +201 -0
  159. kstlib-1.0.1.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.1.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.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)