herfy-email 0.1.0__tar.gz
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.
- herfy_email-0.1.0/PKG-INFO +81 -0
- herfy_email-0.1.0/README.md +58 -0
- herfy_email-0.1.0/herfy_email/__init__.py +17 -0
- herfy_email-0.1.0/herfy_email/client.py +247 -0
- herfy_email-0.1.0/herfy_email/exceptions.py +20 -0
- herfy_email-0.1.0/herfy_email/models.py +65 -0
- herfy_email-0.1.0/herfy_email/providers/__init__.py +17 -0
- herfy_email-0.1.0/herfy_email/providers/base.py +41 -0
- herfy_email-0.1.0/herfy_email/providers/mailgun.py +65 -0
- herfy_email-0.1.0/herfy_email/providers/sendgrid.py +61 -0
- herfy_email-0.1.0/herfy_email.egg-info/PKG-INFO +81 -0
- herfy_email-0.1.0/herfy_email.egg-info/SOURCES.txt +17 -0
- herfy_email-0.1.0/herfy_email.egg-info/dependency_links.txt +1 -0
- herfy_email-0.1.0/herfy_email.egg-info/requires.txt +17 -0
- herfy_email-0.1.0/herfy_email.egg-info/top_level.txt +1 -0
- herfy_email-0.1.0/pyproject.toml +36 -0
- herfy_email-0.1.0/setup.cfg +4 -0
- herfy_email-0.1.0/tests/test_client.py +97 -0
- herfy_email-0.1.0/tests/test_providers.py +24 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: herfy-email
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Provider-abstracted email SDK for Herfy applications — send via Control Center credentials
|
|
5
|
+
Author: Herfy Development Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: email,mailgun,sendgrid,herfy,control-center
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: httpx>=0.24.0
|
|
11
|
+
Provides-Extra: mailgun
|
|
12
|
+
Requires-Dist: mailgun>=1.0.0; extra == "mailgun"
|
|
13
|
+
Provides-Extra: sendgrid
|
|
14
|
+
Requires-Dist: sendgrid>=6.0.0; extra == "sendgrid"
|
|
15
|
+
Provides-Extra: all
|
|
16
|
+
Requires-Dist: mailgun>=1.0.0; extra == "all"
|
|
17
|
+
Requires-Dist: sendgrid>=6.0.0; extra == "all"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
21
|
+
Requires-Dist: respx>=0.20.0; extra == "dev"
|
|
22
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# herfy-email
|
|
25
|
+
|
|
26
|
+
Provider-abstracted email SDK for Herfy applications. Send transactional email using only your Control Center `client_id` and `client_secret` — no email provider credentials in app config.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install herfy-email[mailgun]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from herfy_email import HerfyEmailClient, AsyncHerfyEmailClient
|
|
38
|
+
|
|
39
|
+
# Sync
|
|
40
|
+
client = HerfyEmailClient.from_credentials(
|
|
41
|
+
client_id="app_pettycash",
|
|
42
|
+
client_secret="your_secret",
|
|
43
|
+
control_center_url="https://cc.herfy.com",
|
|
44
|
+
)
|
|
45
|
+
client.send(to="user@herfy.com", subject="Hello", html="<p>Hello</p>", text="Hello")
|
|
46
|
+
|
|
47
|
+
# Async (FastAPI)
|
|
48
|
+
client = AsyncHerfyEmailClient.from_credentials(
|
|
49
|
+
client_id="app_pettycash",
|
|
50
|
+
client_secret="your_secret",
|
|
51
|
+
control_center_url="https://cc.herfy.com",
|
|
52
|
+
)
|
|
53
|
+
await client.send(to="user@herfy.com", subject="Hello", html="<p>Hello</p>", text="Hello")
|
|
54
|
+
|
|
55
|
+
# From environment variables (CC_CLIENT_ID / AUTH_CLIENT_ID, CC_CLIENT_SECRET / AUTH_CLIENT_SECRET, CC_URL / CONTROL_CENTER_URL)
|
|
56
|
+
client = HerfyEmailClient.from_env()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## How it works
|
|
60
|
+
|
|
61
|
+
1. SDK authenticates with Control Center via `POST /api/app-auth/token` (cached, TTL 300s)
|
|
62
|
+
2. SDK fetches provider config from `GET /api/config/email` (cached for process lifetime)
|
|
63
|
+
3. SDK calls the configured provider (Mailgun, SendGrid, …) directly
|
|
64
|
+
|
|
65
|
+
Switching email providers requires only a Control Center env-var change — zero app changes.
|
|
66
|
+
|
|
67
|
+
## Provider support
|
|
68
|
+
|
|
69
|
+
| Provider | Extra |
|
|
70
|
+
|----------|-------|
|
|
71
|
+
| Mailgun | `pip install herfy-email[mailgun]` |
|
|
72
|
+
| SendGrid | `pip install herfy-email[sendgrid]` |
|
|
73
|
+
|
|
74
|
+
## Control Center configuration
|
|
75
|
+
|
|
76
|
+
```env
|
|
77
|
+
EMAIL_PROVIDER=mailgun
|
|
78
|
+
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxx
|
|
79
|
+
MAILGUN_DOMAIN=mg.herfy.com
|
|
80
|
+
MAILGUN_FROM_ADDRESS=noreply@herfy.com
|
|
81
|
+
```
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# herfy-email
|
|
2
|
+
|
|
3
|
+
Provider-abstracted email SDK for Herfy applications. Send transactional email using only your Control Center `client_id` and `client_secret` — no email provider credentials in app config.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install herfy-email[mailgun]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from herfy_email import HerfyEmailClient, AsyncHerfyEmailClient
|
|
15
|
+
|
|
16
|
+
# Sync
|
|
17
|
+
client = HerfyEmailClient.from_credentials(
|
|
18
|
+
client_id="app_pettycash",
|
|
19
|
+
client_secret="your_secret",
|
|
20
|
+
control_center_url="https://cc.herfy.com",
|
|
21
|
+
)
|
|
22
|
+
client.send(to="user@herfy.com", subject="Hello", html="<p>Hello</p>", text="Hello")
|
|
23
|
+
|
|
24
|
+
# Async (FastAPI)
|
|
25
|
+
client = AsyncHerfyEmailClient.from_credentials(
|
|
26
|
+
client_id="app_pettycash",
|
|
27
|
+
client_secret="your_secret",
|
|
28
|
+
control_center_url="https://cc.herfy.com",
|
|
29
|
+
)
|
|
30
|
+
await client.send(to="user@herfy.com", subject="Hello", html="<p>Hello</p>", text="Hello")
|
|
31
|
+
|
|
32
|
+
# From environment variables (CC_CLIENT_ID / AUTH_CLIENT_ID, CC_CLIENT_SECRET / AUTH_CLIENT_SECRET, CC_URL / CONTROL_CENTER_URL)
|
|
33
|
+
client = HerfyEmailClient.from_env()
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
1. SDK authenticates with Control Center via `POST /api/app-auth/token` (cached, TTL 300s)
|
|
39
|
+
2. SDK fetches provider config from `GET /api/config/email` (cached for process lifetime)
|
|
40
|
+
3. SDK calls the configured provider (Mailgun, SendGrid, …) directly
|
|
41
|
+
|
|
42
|
+
Switching email providers requires only a Control Center env-var change — zero app changes.
|
|
43
|
+
|
|
44
|
+
## Provider support
|
|
45
|
+
|
|
46
|
+
| Provider | Extra |
|
|
47
|
+
|----------|-------|
|
|
48
|
+
| Mailgun | `pip install herfy-email[mailgun]` |
|
|
49
|
+
| SendGrid | `pip install herfy-email[sendgrid]` |
|
|
50
|
+
|
|
51
|
+
## Control Center configuration
|
|
52
|
+
|
|
53
|
+
```env
|
|
54
|
+
EMAIL_PROVIDER=mailgun
|
|
55
|
+
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxx
|
|
56
|
+
MAILGUN_DOMAIN=mg.herfy.com
|
|
57
|
+
MAILGUN_FROM_ADDRESS=noreply@herfy.com
|
|
58
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .client import AsyncHerfyEmailClient, HerfyEmailClient
|
|
2
|
+
from .exceptions import AuthError, ConfigError, EmailError, SendError
|
|
3
|
+
from .models import EmailConfig, ProviderConfig, SendResult
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"HerfyEmailClient",
|
|
9
|
+
"AsyncHerfyEmailClient",
|
|
10
|
+
"EmailConfig",
|
|
11
|
+
"ProviderConfig",
|
|
12
|
+
"SendResult",
|
|
13
|
+
"EmailError",
|
|
14
|
+
"AuthError",
|
|
15
|
+
"ConfigError",
|
|
16
|
+
"SendError",
|
|
17
|
+
]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .exceptions import AuthError, ConfigError
|
|
10
|
+
from .models import EmailConfig, ProviderConfig, SendResult
|
|
11
|
+
from .providers import get_provider
|
|
12
|
+
from .providers.base import BaseEmailProvider
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HerfyEmailClient:
|
|
18
|
+
"""Synchronous email client using Control Center credentials."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
client_id: str,
|
|
23
|
+
client_secret: str,
|
|
24
|
+
control_center_url: str,
|
|
25
|
+
*,
|
|
26
|
+
token_cache_ttl: int = 240,
|
|
27
|
+
request_timeout: float = 15.0,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._cfg = EmailConfig(
|
|
30
|
+
control_center_url=control_center_url.rstrip("/"),
|
|
31
|
+
client_id=client_id,
|
|
32
|
+
client_secret=client_secret,
|
|
33
|
+
token_cache_ttl=token_cache_ttl,
|
|
34
|
+
request_timeout=request_timeout,
|
|
35
|
+
)
|
|
36
|
+
self._token: Optional[str] = None
|
|
37
|
+
self._token_at: float = 0.0
|
|
38
|
+
self._provider: Optional[BaseEmailProvider] = None
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_env(cls) -> "HerfyEmailClient":
|
|
42
|
+
cfg = EmailConfig.from_env()
|
|
43
|
+
return cls(
|
|
44
|
+
client_id=cfg.client_id,
|
|
45
|
+
client_secret=cfg.client_secret,
|
|
46
|
+
control_center_url=cfg.control_center_url,
|
|
47
|
+
token_cache_ttl=cfg.token_cache_ttl,
|
|
48
|
+
request_timeout=cfg.request_timeout,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_credentials(
|
|
53
|
+
cls,
|
|
54
|
+
*,
|
|
55
|
+
client_id: str,
|
|
56
|
+
client_secret: str,
|
|
57
|
+
control_center_url: str,
|
|
58
|
+
) -> "HerfyEmailClient":
|
|
59
|
+
return cls(
|
|
60
|
+
client_id=client_id,
|
|
61
|
+
client_secret=client_secret,
|
|
62
|
+
control_center_url=control_center_url,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _get_token(self) -> str:
|
|
66
|
+
if self._token and (time.monotonic() - self._token_at) < self._cfg.token_cache_ttl:
|
|
67
|
+
return self._token
|
|
68
|
+
url = f"{self._cfg.control_center_url}/oauth/token"
|
|
69
|
+
try:
|
|
70
|
+
resp = httpx.post(
|
|
71
|
+
url,
|
|
72
|
+
json={
|
|
73
|
+
"grant_type": "client_credentials",
|
|
74
|
+
"client_id": self._cfg.client_id,
|
|
75
|
+
"client_secret": self._cfg.client_secret,
|
|
76
|
+
},
|
|
77
|
+
timeout=self._cfg.request_timeout,
|
|
78
|
+
)
|
|
79
|
+
except httpx.RequestError as exc:
|
|
80
|
+
raise AuthError(f"Cannot reach Control Center at {url}: {exc}") from exc
|
|
81
|
+
if resp.status_code == 401:
|
|
82
|
+
raise AuthError("Invalid client_id or client_secret")
|
|
83
|
+
if resp.status_code != 200:
|
|
84
|
+
raise AuthError(f"Token request failed (HTTP {resp.status_code}): {resp.text[:200]}")
|
|
85
|
+
token = resp.json().get("access_token")
|
|
86
|
+
if not token:
|
|
87
|
+
raise AuthError("Control Center did not return an access_token")
|
|
88
|
+
self._token = str(token)
|
|
89
|
+
self._token_at = time.monotonic()
|
|
90
|
+
return self._token
|
|
91
|
+
|
|
92
|
+
def _get_provider(self) -> BaseEmailProvider:
|
|
93
|
+
if self._provider is not None:
|
|
94
|
+
return self._provider
|
|
95
|
+
url = f"{self._cfg.control_center_url}{self._cfg.email_config_path}"
|
|
96
|
+
try:
|
|
97
|
+
resp = httpx.get(
|
|
98
|
+
url,
|
|
99
|
+
headers={"Authorization": f"Bearer {self._get_token()}"},
|
|
100
|
+
timeout=self._cfg.request_timeout,
|
|
101
|
+
)
|
|
102
|
+
except httpx.RequestError as exc:
|
|
103
|
+
raise ConfigError(f"Cannot reach CC email config at {url}: {exc}") from exc
|
|
104
|
+
if resp.status_code == 401:
|
|
105
|
+
self._token = None
|
|
106
|
+
raise AuthError("Email config request rejected — token invalid")
|
|
107
|
+
if resp.status_code != 200:
|
|
108
|
+
raise ConfigError(
|
|
109
|
+
f"Email config request failed (HTTP {resp.status_code}): {resp.text[:200]}"
|
|
110
|
+
)
|
|
111
|
+
provider_cfg = ProviderConfig.from_api_response(resp.json())
|
|
112
|
+
self._provider = get_provider(provider_cfg)
|
|
113
|
+
return self._provider
|
|
114
|
+
|
|
115
|
+
def send(
|
|
116
|
+
self,
|
|
117
|
+
to: str,
|
|
118
|
+
subject: str,
|
|
119
|
+
html: str,
|
|
120
|
+
text: str,
|
|
121
|
+
from_address: Optional[str] = None,
|
|
122
|
+
) -> SendResult:
|
|
123
|
+
"""Send an email. Raises SendError on failure."""
|
|
124
|
+
return self._get_provider().send(to, subject, html, text, from_address)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class AsyncHerfyEmailClient:
|
|
128
|
+
"""Async email client using Control Center credentials (for FastAPI apps)."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
client_id: str,
|
|
133
|
+
client_secret: str,
|
|
134
|
+
control_center_url: str,
|
|
135
|
+
*,
|
|
136
|
+
token_cache_ttl: int = 240,
|
|
137
|
+
request_timeout: float = 15.0,
|
|
138
|
+
) -> None:
|
|
139
|
+
self._cfg = EmailConfig(
|
|
140
|
+
control_center_url=control_center_url.rstrip("/"),
|
|
141
|
+
client_id=client_id,
|
|
142
|
+
client_secret=client_secret,
|
|
143
|
+
token_cache_ttl=token_cache_ttl,
|
|
144
|
+
request_timeout=request_timeout,
|
|
145
|
+
)
|
|
146
|
+
self._token: Optional[str] = None
|
|
147
|
+
self._token_at: float = 0.0
|
|
148
|
+
self._provider: Optional[BaseEmailProvider] = None
|
|
149
|
+
self._http: Optional[httpx.AsyncClient] = None
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def from_env(cls) -> "AsyncHerfyEmailClient":
|
|
153
|
+
cfg = EmailConfig.from_env()
|
|
154
|
+
return cls(
|
|
155
|
+
client_id=cfg.client_id,
|
|
156
|
+
client_secret=cfg.client_secret,
|
|
157
|
+
control_center_url=cfg.control_center_url,
|
|
158
|
+
token_cache_ttl=cfg.token_cache_ttl,
|
|
159
|
+
request_timeout=cfg.request_timeout,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def from_credentials(
|
|
164
|
+
cls,
|
|
165
|
+
*,
|
|
166
|
+
client_id: str,
|
|
167
|
+
client_secret: str,
|
|
168
|
+
control_center_url: str,
|
|
169
|
+
) -> "AsyncHerfyEmailClient":
|
|
170
|
+
return cls(
|
|
171
|
+
client_id=client_id,
|
|
172
|
+
client_secret=client_secret,
|
|
173
|
+
control_center_url=control_center_url,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
async def _http_client(self) -> httpx.AsyncClient:
|
|
177
|
+
if self._http is None or self._http.is_closed:
|
|
178
|
+
self._http = httpx.AsyncClient()
|
|
179
|
+
return self._http
|
|
180
|
+
|
|
181
|
+
async def close(self) -> None:
|
|
182
|
+
if self._http and not self._http.is_closed:
|
|
183
|
+
await self._http.aclose()
|
|
184
|
+
|
|
185
|
+
async def _get_token(self) -> str:
|
|
186
|
+
if self._token and (time.monotonic() - self._token_at) < self._cfg.token_cache_ttl:
|
|
187
|
+
return self._token
|
|
188
|
+
client = await self._http_client()
|
|
189
|
+
url = f"{self._cfg.control_center_url}/oauth/token"
|
|
190
|
+
try:
|
|
191
|
+
resp = await client.post(
|
|
192
|
+
url,
|
|
193
|
+
json={
|
|
194
|
+
"grant_type": "client_credentials",
|
|
195
|
+
"client_id": self._cfg.client_id,
|
|
196
|
+
"client_secret": self._cfg.client_secret,
|
|
197
|
+
},
|
|
198
|
+
timeout=self._cfg.request_timeout,
|
|
199
|
+
)
|
|
200
|
+
except httpx.RequestError as exc:
|
|
201
|
+
raise AuthError(f"Cannot reach Control Center at {url}: {exc}") from exc
|
|
202
|
+
if resp.status_code == 401:
|
|
203
|
+
raise AuthError("Invalid client_id or client_secret")
|
|
204
|
+
if resp.status_code != 200:
|
|
205
|
+
raise AuthError(f"Token request failed (HTTP {resp.status_code}): {resp.text[:200]}")
|
|
206
|
+
token = resp.json().get("access_token")
|
|
207
|
+
if not token:
|
|
208
|
+
raise AuthError("Control Center did not return an access_token")
|
|
209
|
+
self._token = str(token)
|
|
210
|
+
self._token_at = time.monotonic()
|
|
211
|
+
return self._token
|
|
212
|
+
|
|
213
|
+
async def _get_provider(self) -> BaseEmailProvider:
|
|
214
|
+
if self._provider is not None:
|
|
215
|
+
return self._provider
|
|
216
|
+
client = await self._http_client()
|
|
217
|
+
url = f"{self._cfg.control_center_url}{self._cfg.email_config_path}"
|
|
218
|
+
try:
|
|
219
|
+
resp = await client.get(
|
|
220
|
+
url,
|
|
221
|
+
headers={"Authorization": f"Bearer {await self._get_token()}"},
|
|
222
|
+
timeout=self._cfg.request_timeout,
|
|
223
|
+
)
|
|
224
|
+
except httpx.RequestError as exc:
|
|
225
|
+
raise ConfigError(f"Cannot reach CC email config: {exc}") from exc
|
|
226
|
+
if resp.status_code == 401:
|
|
227
|
+
self._token = None
|
|
228
|
+
raise AuthError("Email config request rejected — token invalid")
|
|
229
|
+
if resp.status_code != 200:
|
|
230
|
+
raise ConfigError(
|
|
231
|
+
f"Email config request failed (HTTP {resp.status_code}): {resp.text[:200]}"
|
|
232
|
+
)
|
|
233
|
+
provider_cfg = ProviderConfig.from_api_response(resp.json())
|
|
234
|
+
self._provider = get_provider(provider_cfg)
|
|
235
|
+
return self._provider
|
|
236
|
+
|
|
237
|
+
async def send(
|
|
238
|
+
self,
|
|
239
|
+
to: str,
|
|
240
|
+
subject: str,
|
|
241
|
+
html: str,
|
|
242
|
+
text: str,
|
|
243
|
+
from_address: Optional[str] = None,
|
|
244
|
+
) -> SendResult:
|
|
245
|
+
"""Send an email asynchronously. Raises SendError on failure."""
|
|
246
|
+
provider = await self._get_provider()
|
|
247
|
+
return await provider.async_send(to, subject, html, text, from_address)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EmailError(Exception):
|
|
5
|
+
"""Base exception for herfy-email."""
|
|
6
|
+
def __init__(self, message: str, status_code: int = 0) -> None:
|
|
7
|
+
super().__init__(message)
|
|
8
|
+
self.status_code = status_code
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthError(EmailError):
|
|
12
|
+
"""CC token fetch failed or was rejected."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(EmailError):
|
|
16
|
+
"""CC email config endpoint returned an error or unknown provider."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SendError(EmailError):
|
|
20
|
+
"""Email provider rejected the send request."""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class EmailConfig:
|
|
10
|
+
"""Configuration for HerfyEmailClient."""
|
|
11
|
+
control_center_url: str
|
|
12
|
+
client_id: str
|
|
13
|
+
client_secret: str
|
|
14
|
+
email_config_path: str = "/api/config/email"
|
|
15
|
+
token_cache_ttl: int = 240
|
|
16
|
+
request_timeout: float = 15.0
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_env(cls) -> "EmailConfig":
|
|
20
|
+
url = os.environ.get("CONTROL_CENTER_URL", "")
|
|
21
|
+
cid = os.environ.get("AUTH_CLIENT_ID", "")
|
|
22
|
+
secret = os.environ.get("AUTH_CLIENT_SECRET", "")
|
|
23
|
+
if not url:
|
|
24
|
+
raise ValueError("CONTROL_CENTER_URL is required")
|
|
25
|
+
if not cid:
|
|
26
|
+
raise ValueError("AUTH_CLIENT_ID is required")
|
|
27
|
+
if not secret:
|
|
28
|
+
raise ValueError("AUTH_CLIENT_SECRET is required")
|
|
29
|
+
return cls(
|
|
30
|
+
control_center_url=url.rstrip("/"),
|
|
31
|
+
client_id=cid,
|
|
32
|
+
client_secret=secret,
|
|
33
|
+
token_cache_ttl=int(os.environ.get("EMAIL_TOKEN_CACHE_TTL", "240")),
|
|
34
|
+
request_timeout=float(os.environ.get("EMAIL_REQUEST_TIMEOUT", "15")),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ProviderConfig:
|
|
40
|
+
"""Parsed response from GET /api/config/email."""
|
|
41
|
+
provider: str
|
|
42
|
+
api_key: str
|
|
43
|
+
domain: str
|
|
44
|
+
from_address: str
|
|
45
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_api_response(cls, data: dict) -> "ProviderConfig":
|
|
49
|
+
config = data.get("config", {})
|
|
50
|
+
return cls(
|
|
51
|
+
provider=data["provider"],
|
|
52
|
+
api_key=config["api_key"],
|
|
53
|
+
domain=config.get("domain", ""),
|
|
54
|
+
from_address=config.get("from_address", "noreply@herfy.com"),
|
|
55
|
+
extra={k: v for k, v in config.items()
|
|
56
|
+
if k not in {"api_key", "domain", "from_address"}},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class SendResult:
|
|
62
|
+
success: bool
|
|
63
|
+
message_id: str = ""
|
|
64
|
+
provider: str = ""
|
|
65
|
+
raw: Any = None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..exceptions import ConfigError
|
|
4
|
+
from ..models import ProviderConfig
|
|
5
|
+
from .base import BaseEmailProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_provider(config: ProviderConfig) -> BaseEmailProvider:
|
|
9
|
+
"""Instantiate the correct provider from CC config."""
|
|
10
|
+
if config.provider == "mailgun":
|
|
11
|
+
from .mailgun import MailgunProvider
|
|
12
|
+
return MailgunProvider.from_config(config)
|
|
13
|
+
if config.provider == "sendgrid":
|
|
14
|
+
from .sendgrid import SendGridProvider
|
|
15
|
+
return SendGridProvider.from_config(config)
|
|
16
|
+
raise ConfigError(f"Unknown email provider: '{config.provider}'. "
|
|
17
|
+
f"Supported: mailgun, sendgrid")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..models import ProviderConfig, SendResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseEmailProvider(ABC):
|
|
10
|
+
"""Abstract base — one implementation per email provider."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def send(
|
|
14
|
+
self,
|
|
15
|
+
to: str,
|
|
16
|
+
subject: str,
|
|
17
|
+
html: str,
|
|
18
|
+
text: str,
|
|
19
|
+
from_address: Optional[str] = None,
|
|
20
|
+
) -> SendResult:
|
|
21
|
+
"""Send a single email. Raises SendError on failure."""
|
|
22
|
+
|
|
23
|
+
async def async_send(
|
|
24
|
+
self,
|
|
25
|
+
to: str,
|
|
26
|
+
subject: str,
|
|
27
|
+
html: str,
|
|
28
|
+
text: str,
|
|
29
|
+
from_address: Optional[str] = None,
|
|
30
|
+
) -> SendResult:
|
|
31
|
+
"""Async variant. Default implementation runs sync send in executor."""
|
|
32
|
+
import asyncio
|
|
33
|
+
loop = asyncio.get_running_loop()
|
|
34
|
+
return await loop.run_in_executor(
|
|
35
|
+
None, lambda: self.send(to, subject, html, text, from_address)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def from_config(cls, config: ProviderConfig) -> "BaseEmailProvider":
|
|
41
|
+
"""Construct from a ProviderConfig."""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..exceptions import ConfigError, SendError
|
|
7
|
+
from ..models import ProviderConfig, SendResult
|
|
8
|
+
from .base import BaseEmailProvider
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MailgunProvider(BaseEmailProvider):
|
|
14
|
+
"""Email provider backed by the Mailgun API."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, api_key: str, domain: str, from_address: str) -> None:
|
|
17
|
+
self._api_key = api_key
|
|
18
|
+
self._domain = domain
|
|
19
|
+
self._from_address = from_address
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_config(cls, config: ProviderConfig) -> "MailgunProvider":
|
|
23
|
+
if not config.domain.strip():
|
|
24
|
+
raise ConfigError("Mailgun provider requires 'domain' in CC email config")
|
|
25
|
+
return cls(
|
|
26
|
+
api_key=config.api_key,
|
|
27
|
+
domain=config.domain,
|
|
28
|
+
from_address=config.from_address,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def send(
|
|
32
|
+
self,
|
|
33
|
+
to: str,
|
|
34
|
+
subject: str,
|
|
35
|
+
html: str,
|
|
36
|
+
text: str,
|
|
37
|
+
from_address: Optional[str] = None,
|
|
38
|
+
) -> SendResult:
|
|
39
|
+
try:
|
|
40
|
+
import mailgun # type: ignore
|
|
41
|
+
except ImportError as exc:
|
|
42
|
+
raise ConfigError(
|
|
43
|
+
"mailgun package not installed. Run: pip install mailgun"
|
|
44
|
+
) from exc
|
|
45
|
+
|
|
46
|
+
sender = from_address or self._from_address
|
|
47
|
+
try:
|
|
48
|
+
client = mailgun.Client(auth=("api", self._api_key))
|
|
49
|
+
result = client.messages.create(
|
|
50
|
+
self._domain,
|
|
51
|
+
data={
|
|
52
|
+
"from": sender,
|
|
53
|
+
"to": [to],
|
|
54
|
+
"subject": subject,
|
|
55
|
+
"html": html,
|
|
56
|
+
"text": text,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
msg_id = getattr(result, "id", "") or ""
|
|
60
|
+
logger.info("Mailgun sent to %s, id=%s", to, msg_id)
|
|
61
|
+
return SendResult(success=True, message_id=msg_id, provider="mailgun", raw=result)
|
|
62
|
+
except ConfigError:
|
|
63
|
+
raise
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
raise SendError(f"Mailgun send failed: {exc}") from exc
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..exceptions import ConfigError, SendError
|
|
7
|
+
from ..models import ProviderConfig, SendResult
|
|
8
|
+
from .base import BaseEmailProvider
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SendGridProvider(BaseEmailProvider):
|
|
14
|
+
"""Email provider backed by the SendGrid API (stub — install sendgrid to activate)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, api_key: str, from_address: str) -> None:
|
|
17
|
+
self._api_key = api_key
|
|
18
|
+
self._from_address = from_address
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_config(cls, config: ProviderConfig) -> "SendGridProvider":
|
|
22
|
+
return cls(api_key=config.api_key, from_address=config.from_address)
|
|
23
|
+
|
|
24
|
+
def send(
|
|
25
|
+
self,
|
|
26
|
+
to: str,
|
|
27
|
+
subject: str,
|
|
28
|
+
html: str,
|
|
29
|
+
text: str,
|
|
30
|
+
from_address: Optional[str] = None,
|
|
31
|
+
) -> SendResult:
|
|
32
|
+
try:
|
|
33
|
+
import sendgrid as sg # type: ignore
|
|
34
|
+
from sendgrid.helpers.mail import Mail # type: ignore
|
|
35
|
+
except ImportError as exc:
|
|
36
|
+
raise ConfigError(
|
|
37
|
+
"sendgrid package not installed. Run: pip install sendgrid"
|
|
38
|
+
) from exc
|
|
39
|
+
|
|
40
|
+
sender = from_address or self._from_address
|
|
41
|
+
try:
|
|
42
|
+
client = sg.SendGridAPIClient(self._api_key)
|
|
43
|
+
message = Mail(
|
|
44
|
+
from_email=sender,
|
|
45
|
+
to_emails=to,
|
|
46
|
+
subject=subject,
|
|
47
|
+
html_content=html,
|
|
48
|
+
plain_text_content=text,
|
|
49
|
+
)
|
|
50
|
+
resp = client.send(message)
|
|
51
|
+
if resp.status_code >= 400:
|
|
52
|
+
raise SendError(f"SendGrid send failed: HTTP {resp.status_code}")
|
|
53
|
+
msg_id = str(resp.headers.get("X-Message-Id", ""))
|
|
54
|
+
logger.info("SendGrid sent to %s, id=%s", to, msg_id)
|
|
55
|
+
return SendResult(success=True, message_id=msg_id, provider="sendgrid", raw=resp)
|
|
56
|
+
except ConfigError:
|
|
57
|
+
raise
|
|
58
|
+
except SendError:
|
|
59
|
+
raise
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
raise SendError(f"SendGrid send failed: {exc}") from exc
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: herfy-email
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Provider-abstracted email SDK for Herfy applications — send via Control Center credentials
|
|
5
|
+
Author: Herfy Development Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: email,mailgun,sendgrid,herfy,control-center
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: httpx>=0.24.0
|
|
11
|
+
Provides-Extra: mailgun
|
|
12
|
+
Requires-Dist: mailgun>=1.0.0; extra == "mailgun"
|
|
13
|
+
Provides-Extra: sendgrid
|
|
14
|
+
Requires-Dist: sendgrid>=6.0.0; extra == "sendgrid"
|
|
15
|
+
Provides-Extra: all
|
|
16
|
+
Requires-Dist: mailgun>=1.0.0; extra == "all"
|
|
17
|
+
Requires-Dist: sendgrid>=6.0.0; extra == "all"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
21
|
+
Requires-Dist: respx>=0.20.0; extra == "dev"
|
|
22
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# herfy-email
|
|
25
|
+
|
|
26
|
+
Provider-abstracted email SDK for Herfy applications. Send transactional email using only your Control Center `client_id` and `client_secret` — no email provider credentials in app config.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install herfy-email[mailgun]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from herfy_email import HerfyEmailClient, AsyncHerfyEmailClient
|
|
38
|
+
|
|
39
|
+
# Sync
|
|
40
|
+
client = HerfyEmailClient.from_credentials(
|
|
41
|
+
client_id="app_pettycash",
|
|
42
|
+
client_secret="your_secret",
|
|
43
|
+
control_center_url="https://cc.herfy.com",
|
|
44
|
+
)
|
|
45
|
+
client.send(to="user@herfy.com", subject="Hello", html="<p>Hello</p>", text="Hello")
|
|
46
|
+
|
|
47
|
+
# Async (FastAPI)
|
|
48
|
+
client = AsyncHerfyEmailClient.from_credentials(
|
|
49
|
+
client_id="app_pettycash",
|
|
50
|
+
client_secret="your_secret",
|
|
51
|
+
control_center_url="https://cc.herfy.com",
|
|
52
|
+
)
|
|
53
|
+
await client.send(to="user@herfy.com", subject="Hello", html="<p>Hello</p>", text="Hello")
|
|
54
|
+
|
|
55
|
+
# From environment variables (CC_CLIENT_ID / AUTH_CLIENT_ID, CC_CLIENT_SECRET / AUTH_CLIENT_SECRET, CC_URL / CONTROL_CENTER_URL)
|
|
56
|
+
client = HerfyEmailClient.from_env()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## How it works
|
|
60
|
+
|
|
61
|
+
1. SDK authenticates with Control Center via `POST /api/app-auth/token` (cached, TTL 300s)
|
|
62
|
+
2. SDK fetches provider config from `GET /api/config/email` (cached for process lifetime)
|
|
63
|
+
3. SDK calls the configured provider (Mailgun, SendGrid, …) directly
|
|
64
|
+
|
|
65
|
+
Switching email providers requires only a Control Center env-var change — zero app changes.
|
|
66
|
+
|
|
67
|
+
## Provider support
|
|
68
|
+
|
|
69
|
+
| Provider | Extra |
|
|
70
|
+
|----------|-------|
|
|
71
|
+
| Mailgun | `pip install herfy-email[mailgun]` |
|
|
72
|
+
| SendGrid | `pip install herfy-email[sendgrid]` |
|
|
73
|
+
|
|
74
|
+
## Control Center configuration
|
|
75
|
+
|
|
76
|
+
```env
|
|
77
|
+
EMAIL_PROVIDER=mailgun
|
|
78
|
+
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxx
|
|
79
|
+
MAILGUN_DOMAIN=mg.herfy.com
|
|
80
|
+
MAILGUN_FROM_ADDRESS=noreply@herfy.com
|
|
81
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
herfy_email/__init__.py
|
|
4
|
+
herfy_email/client.py
|
|
5
|
+
herfy_email/exceptions.py
|
|
6
|
+
herfy_email/models.py
|
|
7
|
+
herfy_email.egg-info/PKG-INFO
|
|
8
|
+
herfy_email.egg-info/SOURCES.txt
|
|
9
|
+
herfy_email.egg-info/dependency_links.txt
|
|
10
|
+
herfy_email.egg-info/requires.txt
|
|
11
|
+
herfy_email.egg-info/top_level.txt
|
|
12
|
+
herfy_email/providers/__init__.py
|
|
13
|
+
herfy_email/providers/base.py
|
|
14
|
+
herfy_email/providers/mailgun.py
|
|
15
|
+
herfy_email/providers/sendgrid.py
|
|
16
|
+
tests/test_client.py
|
|
17
|
+
tests/test_providers.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
herfy_email
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=45", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "herfy-email"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Provider-abstracted email SDK for Herfy applications — send via Control Center credentials"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{name = "Herfy Development Team"}]
|
|
13
|
+
keywords = ["email", "mailgun", "sendgrid", "herfy", "control-center"]
|
|
14
|
+
|
|
15
|
+
dependencies = [
|
|
16
|
+
"httpx>=0.24.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
mailgun = ["mailgun>=1.0.0"]
|
|
21
|
+
sendgrid = ["sendgrid>=6.0.0"]
|
|
22
|
+
all = ["mailgun>=1.0.0", "sendgrid>=6.0.0"]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=7.0.0",
|
|
25
|
+
"pytest-asyncio>=0.21.0",
|
|
26
|
+
"respx>=0.20.0",
|
|
27
|
+
"black>=23.0.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["."]
|
|
32
|
+
include = ["herfy_email*"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
asyncio_mode = "auto"
|
|
36
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import respx
|
|
3
|
+
import httpx
|
|
4
|
+
from herfy_email.client import HerfyEmailClient
|
|
5
|
+
from herfy_email.exceptions import AuthError, ConfigError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CC_URL = "http://cc.test"
|
|
9
|
+
TOKEN_URL = f"{CC_URL}/oauth/token"
|
|
10
|
+
CONFIG_URL = f"{CC_URL}/api/config/email"
|
|
11
|
+
|
|
12
|
+
TOKEN_RESP = {"access_token": "tok123", "token_type": "bearer", "expires_in": 300}
|
|
13
|
+
CONFIG_RESP = {
|
|
14
|
+
"provider": "mailgun",
|
|
15
|
+
"version": 1,
|
|
16
|
+
"config": {
|
|
17
|
+
"api_key": "key-test",
|
|
18
|
+
"domain": "mg.herfy.com",
|
|
19
|
+
"from_address": "noreply@herfy.com",
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@respx.mock
|
|
25
|
+
def test_fetch_token_success():
|
|
26
|
+
respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json=TOKEN_RESP))
|
|
27
|
+
respx.get(CONFIG_URL).mock(return_value=httpx.Response(200, json=CONFIG_RESP))
|
|
28
|
+
|
|
29
|
+
client = HerfyEmailClient(
|
|
30
|
+
client_id="app_test",
|
|
31
|
+
client_secret="secret",
|
|
32
|
+
control_center_url=CC_URL,
|
|
33
|
+
)
|
|
34
|
+
token = client._get_token()
|
|
35
|
+
assert token == "tok123"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@respx.mock
|
|
39
|
+
def test_bad_credentials_raises_auth_error():
|
|
40
|
+
respx.post(TOKEN_URL).mock(return_value=httpx.Response(401, json={"detail": "bad"}))
|
|
41
|
+
|
|
42
|
+
client = HerfyEmailClient(
|
|
43
|
+
client_id="bad",
|
|
44
|
+
client_secret="wrong",
|
|
45
|
+
control_center_url=CC_URL,
|
|
46
|
+
)
|
|
47
|
+
with pytest.raises(AuthError):
|
|
48
|
+
client._get_token()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@respx.mock
|
|
52
|
+
def test_unknown_provider_raises_config_error():
|
|
53
|
+
respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json=TOKEN_RESP))
|
|
54
|
+
respx.get(CONFIG_URL).mock(return_value=httpx.Response(200, json={
|
|
55
|
+
"provider": "pigeon",
|
|
56
|
+
"version": 1,
|
|
57
|
+
"config": {"api_key": "x", "domain": "y", "from_address": "z"},
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
client = HerfyEmailClient(
|
|
61
|
+
client_id="app_test",
|
|
62
|
+
client_secret="secret",
|
|
63
|
+
control_center_url=CC_URL,
|
|
64
|
+
)
|
|
65
|
+
with pytest.raises(ConfigError, match="Unknown email provider"):
|
|
66
|
+
client._get_provider()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@respx.mock
|
|
70
|
+
def test_token_cached_not_refetched():
|
|
71
|
+
"""Second call to _get_token() should not make a second HTTP request."""
|
|
72
|
+
mock = respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json=TOKEN_RESP))
|
|
73
|
+
|
|
74
|
+
client = HerfyEmailClient(
|
|
75
|
+
client_id="app_test",
|
|
76
|
+
client_secret="secret",
|
|
77
|
+
control_center_url=CC_URL,
|
|
78
|
+
)
|
|
79
|
+
client._get_token()
|
|
80
|
+
client._get_token()
|
|
81
|
+
assert mock.call_count == 1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@respx.mock
|
|
85
|
+
def test_config_401_clears_token_cache():
|
|
86
|
+
"""A 401 on the config endpoint should clear the cached token."""
|
|
87
|
+
respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json=TOKEN_RESP))
|
|
88
|
+
respx.get(CONFIG_URL).mock(return_value=httpx.Response(401))
|
|
89
|
+
|
|
90
|
+
client = HerfyEmailClient(
|
|
91
|
+
client_id="app_test",
|
|
92
|
+
client_secret="secret",
|
|
93
|
+
control_center_url=CC_URL,
|
|
94
|
+
)
|
|
95
|
+
with pytest.raises(AuthError):
|
|
96
|
+
client._get_provider()
|
|
97
|
+
assert client._token is None
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from herfy_email.exceptions import ConfigError
|
|
3
|
+
from herfy_email.models import ProviderConfig
|
|
4
|
+
from herfy_email.providers import get_provider
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _cfg(provider: str) -> ProviderConfig:
|
|
8
|
+
return ProviderConfig(
|
|
9
|
+
provider=provider,
|
|
10
|
+
api_key="key-test",
|
|
11
|
+
domain="mg.herfy.com",
|
|
12
|
+
from_address="noreply@herfy.com",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_unknown_provider_raises():
|
|
17
|
+
with pytest.raises(ConfigError, match="Unknown email provider"):
|
|
18
|
+
get_provider(_cfg("unknown_provider"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_mailgun_provider_returned():
|
|
22
|
+
from herfy_email.providers.mailgun import MailgunProvider
|
|
23
|
+
p = get_provider(_cfg("mailgun"))
|
|
24
|
+
assert isinstance(p, MailgunProvider)
|