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.
@@ -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,17 @@
1
+ httpx>=0.24.0
2
+
3
+ [all]
4
+ mailgun>=1.0.0
5
+ sendgrid>=6.0.0
6
+
7
+ [dev]
8
+ pytest>=7.0.0
9
+ pytest-asyncio>=0.21.0
10
+ respx>=0.20.0
11
+ black>=23.0.0
12
+
13
+ [mailgun]
14
+ mailgun>=1.0.0
15
+
16
+ [sendgrid]
17
+ sendgrid>=6.0.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)