coffeemail 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coffeemail/__init__.py +106 -0
- coffeemail/async_client.py +52 -0
- coffeemail/client.py +50 -0
- coffeemail/core/crypto.py +19 -0
- coffeemail/core/errors.py +88 -0
- coffeemail/core/http.py +243 -0
- coffeemail/core/i18n.py +33 -0
- coffeemail/core/types.py +50 -0
- coffeemail/models/__init__.py +89 -0
- coffeemail/models/audiences.py +50 -0
- coffeemail/models/broadcasts.py +27 -0
- coffeemail/models/domains.py +43 -0
- coffeemail/models/emails.py +96 -0
- coffeemail/models/stats.py +16 -0
- coffeemail/models/suppressions.py +29 -0
- coffeemail/models/templates.py +31 -0
- coffeemail/models/webhooks.py +47 -0
- coffeemail/py.typed +1 -0
- coffeemail/resources/__init__.py +29 -0
- coffeemail/resources/audiences.py +307 -0
- coffeemail/resources/broadcasts.py +154 -0
- coffeemail/resources/domains.py +99 -0
- coffeemail/resources/emails.py +243 -0
- coffeemail/resources/stats.py +49 -0
- coffeemail/resources/suppressions.py +108 -0
- coffeemail/resources/templates.py +257 -0
- coffeemail/resources/webhooks.py +182 -0
- coffeemail-0.1.0.dist-info/METADATA +179 -0
- coffeemail-0.1.0.dist-info/RECORD +30 -0
- coffeemail-0.1.0.dist-info/WHEEL +4 -0
coffeemail/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from coffeemail.async_client import AsyncCoffeeMail
|
|
2
|
+
from coffeemail.client import CoffeeMail
|
|
3
|
+
from coffeemail.core.errors import (
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
CoffeeMailError,
|
|
6
|
+
ConflictError,
|
|
7
|
+
NetworkError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PermissionDeniedError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ServerError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
from coffeemail.core.types import ApiKeyIntrospection, CoffeeMailResponse
|
|
15
|
+
from coffeemail.models import (
|
|
16
|
+
AudienceDetail,
|
|
17
|
+
BatchSendEmailResult,
|
|
18
|
+
BroadcastDetail,
|
|
19
|
+
BroadcastStatus,
|
|
20
|
+
BulkAddContactsResponse,
|
|
21
|
+
CheckSuppressionResponse,
|
|
22
|
+
ContactDetail,
|
|
23
|
+
CreateDomainResponse,
|
|
24
|
+
CreatedWebhookDetail,
|
|
25
|
+
DnsRecord,
|
|
26
|
+
DomainDetail,
|
|
27
|
+
DomainStatus,
|
|
28
|
+
EmailAttachment,
|
|
29
|
+
EmailDetail,
|
|
30
|
+
EmailParticipant,
|
|
31
|
+
EmailStats,
|
|
32
|
+
EmailStatus,
|
|
33
|
+
EmailTag,
|
|
34
|
+
ListAudiencesResponse,
|
|
35
|
+
ListBroadcastsResponse,
|
|
36
|
+
ListContactsResponse,
|
|
37
|
+
ListDomainsResponse,
|
|
38
|
+
ListEmailsResponse,
|
|
39
|
+
ListSuppressionsResponse,
|
|
40
|
+
ListTemplatesResponse,
|
|
41
|
+
ListWebhooksResponse,
|
|
42
|
+
PreviewTemplateResponse,
|
|
43
|
+
RotateWebhookSecretResult,
|
|
44
|
+
SendEmailPayload,
|
|
45
|
+
SendEmailResponse,
|
|
46
|
+
SuppressionDetail,
|
|
47
|
+
SuppressionReason,
|
|
48
|
+
TemplateDetail,
|
|
49
|
+
TestWebhookResult,
|
|
50
|
+
WebhookDetail,
|
|
51
|
+
WebhookEvent,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__version__ = "0.1.0"
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"CoffeeMail",
|
|
58
|
+
"AsyncCoffeeMail",
|
|
59
|
+
"CoffeeMailResponse",
|
|
60
|
+
"ApiKeyIntrospection",
|
|
61
|
+
"CoffeeMailError",
|
|
62
|
+
"AuthenticationError",
|
|
63
|
+
"PermissionDeniedError",
|
|
64
|
+
"NotFoundError",
|
|
65
|
+
"ValidationError",
|
|
66
|
+
"ConflictError",
|
|
67
|
+
"RateLimitError",
|
|
68
|
+
"ServerError",
|
|
69
|
+
"NetworkError",
|
|
70
|
+
"BatchSendEmailResult",
|
|
71
|
+
"EmailAttachment",
|
|
72
|
+
"EmailDetail",
|
|
73
|
+
"EmailParticipant",
|
|
74
|
+
"EmailStatus",
|
|
75
|
+
"EmailTag",
|
|
76
|
+
"ListEmailsResponse",
|
|
77
|
+
"SendEmailPayload",
|
|
78
|
+
"SendEmailResponse",
|
|
79
|
+
"CreateDomainResponse",
|
|
80
|
+
"DnsRecord",
|
|
81
|
+
"DomainDetail",
|
|
82
|
+
"DomainStatus",
|
|
83
|
+
"ListDomainsResponse",
|
|
84
|
+
"ListTemplatesResponse",
|
|
85
|
+
"PreviewTemplateResponse",
|
|
86
|
+
"TemplateDetail",
|
|
87
|
+
"AudienceDetail",
|
|
88
|
+
"BulkAddContactsResponse",
|
|
89
|
+
"ContactDetail",
|
|
90
|
+
"ListAudiencesResponse",
|
|
91
|
+
"ListContactsResponse",
|
|
92
|
+
"BroadcastDetail",
|
|
93
|
+
"BroadcastStatus",
|
|
94
|
+
"ListBroadcastsResponse",
|
|
95
|
+
"CheckSuppressionResponse",
|
|
96
|
+
"ListSuppressionsResponse",
|
|
97
|
+
"SuppressionDetail",
|
|
98
|
+
"SuppressionReason",
|
|
99
|
+
"CreatedWebhookDetail",
|
|
100
|
+
"ListWebhooksResponse",
|
|
101
|
+
"RotateWebhookSecretResult",
|
|
102
|
+
"TestWebhookResult",
|
|
103
|
+
"WebhookDetail",
|
|
104
|
+
"WebhookEvent",
|
|
105
|
+
"EmailStats",
|
|
106
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from coffeemail.core.http import DEFAULT_TIMEOUT_SECONDS, AsyncHttpTransport
|
|
6
|
+
from coffeemail.core.i18n import Locale
|
|
7
|
+
from coffeemail.core.types import ApiKeyIntrospection, CoffeeMailResponse
|
|
8
|
+
from coffeemail.resources.audiences import AsyncAudiences
|
|
9
|
+
from coffeemail.resources.broadcasts import AsyncBroadcasts
|
|
10
|
+
from coffeemail.resources.domains import AsyncDomains
|
|
11
|
+
from coffeemail.resources.emails import AsyncEmails
|
|
12
|
+
from coffeemail.resources.stats import AsyncStats
|
|
13
|
+
from coffeemail.resources.suppressions import AsyncSuppressions
|
|
14
|
+
from coffeemail.resources.templates import AsyncTemplates
|
|
15
|
+
from coffeemail.resources.webhooks import AsyncWebhooks
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AsyncCoffeeMail:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api_key: str | None = None,
|
|
22
|
+
*,
|
|
23
|
+
base_url: str | None = None,
|
|
24
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
25
|
+
locale: Locale = "pt-BR",
|
|
26
|
+
http_client: httpx.AsyncClient | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
resolved_key = api_key or os.getenv("COFFEEMAIL_API_KEY")
|
|
29
|
+
self._transport = AsyncHttpTransport(
|
|
30
|
+
api_key=resolved_key,
|
|
31
|
+
base_url=base_url,
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
locale=locale,
|
|
34
|
+
client=http_client,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.emails = AsyncEmails(self._transport)
|
|
38
|
+
self.domains = AsyncDomains(self._transport)
|
|
39
|
+
self.templates = AsyncTemplates(self._transport)
|
|
40
|
+
self.audiences = AsyncAudiences(self._transport)
|
|
41
|
+
self.broadcasts = AsyncBroadcasts(self._transport)
|
|
42
|
+
self.suppressions = AsyncSuppressions(self._transport)
|
|
43
|
+
self.webhooks = AsyncWebhooks(self._transport)
|
|
44
|
+
self.stats = AsyncStats(self._transport)
|
|
45
|
+
|
|
46
|
+
async def introspect(
|
|
47
|
+
self, force_refresh: bool = False
|
|
48
|
+
) -> CoffeeMailResponse[ApiKeyIntrospection]:
|
|
49
|
+
return await self._transport.introspect(force_refresh=force_refresh)
|
|
50
|
+
|
|
51
|
+
def invalidate_introspection_cache(self) -> None:
|
|
52
|
+
self._transport.invalidate_introspection_cache()
|
coffeemail/client.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from coffeemail.core.http import DEFAULT_TIMEOUT_SECONDS, HttpTransport
|
|
6
|
+
from coffeemail.core.i18n import Locale
|
|
7
|
+
from coffeemail.core.types import ApiKeyIntrospection, CoffeeMailResponse
|
|
8
|
+
from coffeemail.resources.audiences import Audiences
|
|
9
|
+
from coffeemail.resources.broadcasts import Broadcasts
|
|
10
|
+
from coffeemail.resources.domains import Domains
|
|
11
|
+
from coffeemail.resources.emails import Emails
|
|
12
|
+
from coffeemail.resources.stats import Stats
|
|
13
|
+
from coffeemail.resources.suppressions import Suppressions
|
|
14
|
+
from coffeemail.resources.templates import Templates
|
|
15
|
+
from coffeemail.resources.webhooks import Webhooks
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CoffeeMail:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api_key: str | None = None,
|
|
22
|
+
*,
|
|
23
|
+
base_url: str | None = None,
|
|
24
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
25
|
+
locale: Locale = "pt-BR",
|
|
26
|
+
http_client: httpx.Client | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
resolved_key = api_key or os.getenv("COFFEEMAIL_API_KEY")
|
|
29
|
+
self._transport = HttpTransport(
|
|
30
|
+
api_key=resolved_key,
|
|
31
|
+
base_url=base_url,
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
locale=locale,
|
|
34
|
+
client=http_client,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.emails = Emails(self._transport)
|
|
38
|
+
self.domains = Domains(self._transport)
|
|
39
|
+
self.templates = Templates(self._transport)
|
|
40
|
+
self.audiences = Audiences(self._transport)
|
|
41
|
+
self.broadcasts = Broadcasts(self._transport)
|
|
42
|
+
self.suppressions = Suppressions(self._transport)
|
|
43
|
+
self.webhooks = Webhooks(self._transport)
|
|
44
|
+
self.stats = Stats(self._transport)
|
|
45
|
+
|
|
46
|
+
def introspect(self, force_refresh: bool = False) -> CoffeeMailResponse[ApiKeyIntrospection]:
|
|
47
|
+
return self._transport.introspect(force_refresh=force_refresh)
|
|
48
|
+
|
|
49
|
+
def invalidate_introspection_cache(self) -> None:
|
|
50
|
+
self._transport.invalidate_introspection_cache()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def verify_webhook_signature(
|
|
6
|
+
payload: str | bytes,
|
|
7
|
+
signature: str,
|
|
8
|
+
secret: str,
|
|
9
|
+
) -> bool:
|
|
10
|
+
if not payload or not signature or not secret:
|
|
11
|
+
return False
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
raw_bytes = payload.encode("utf-8") if isinstance(payload, str) else payload
|
|
15
|
+
secret_bytes = secret.encode("utf-8")
|
|
16
|
+
computed = hmac.new(secret_bytes, raw_bytes, hashlib.sha256).hexdigest()
|
|
17
|
+
return hmac.compare_digest(computed.lower(), signature.strip().lower())
|
|
18
|
+
except Exception:
|
|
19
|
+
return False
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
class CoffeeMailError(Exception):
|
|
2
|
+
def __init__(
|
|
3
|
+
self,
|
|
4
|
+
message: str,
|
|
5
|
+
status_code: int | None = None,
|
|
6
|
+
code: str | None = None,
|
|
7
|
+
details: dict[str, object] | None = None,
|
|
8
|
+
) -> None:
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
self.message = message
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
self.code = code
|
|
13
|
+
self.details = details or {}
|
|
14
|
+
|
|
15
|
+
def __str__(self) -> str:
|
|
16
|
+
if self.code:
|
|
17
|
+
return f"[{self.code}] {self.message}"
|
|
18
|
+
return self.message
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AuthenticationError(CoffeeMailError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PermissionDeniedError(CoffeeMailError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NotFoundError(CoffeeMailError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ValidationError(CoffeeMailError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConflictError(CoffeeMailError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RateLimitError(CoffeeMailError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ServerError(CoffeeMailError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NetworkError(CoffeeMailError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_error_from_response(
|
|
54
|
+
status_code: int,
|
|
55
|
+
body: dict[str, object] | None = None,
|
|
56
|
+
default_message: str | None = None,
|
|
57
|
+
) -> CoffeeMailError:
|
|
58
|
+
data = body or {}
|
|
59
|
+
message = str(
|
|
60
|
+
data.get("message") or data.get("error") or default_message or f"HTTP Error {status_code}"
|
|
61
|
+
)
|
|
62
|
+
code = data.get("code")
|
|
63
|
+
str_code = str(code) if code else None
|
|
64
|
+
details = data.get("details")
|
|
65
|
+
details_dict = details if isinstance(details, dict) else {}
|
|
66
|
+
|
|
67
|
+
if status_code == 401:
|
|
68
|
+
return AuthenticationError(
|
|
69
|
+
message, status_code=status_code, code=str_code, details=details_dict
|
|
70
|
+
)
|
|
71
|
+
if status_code == 403:
|
|
72
|
+
return PermissionDeniedError(
|
|
73
|
+
message, status_code=status_code, code=str_code, details=details_dict
|
|
74
|
+
)
|
|
75
|
+
if status_code == 404:
|
|
76
|
+
return NotFoundError(message, status_code=status_code, code=str_code, details=details_dict)
|
|
77
|
+
if status_code in (400, 422):
|
|
78
|
+
return ValidationError(
|
|
79
|
+
message, status_code=status_code, code=str_code, details=details_dict
|
|
80
|
+
)
|
|
81
|
+
if status_code == 409:
|
|
82
|
+
return ConflictError(message, status_code=status_code, code=str_code, details=details_dict)
|
|
83
|
+
if status_code == 429:
|
|
84
|
+
return RateLimitError(message, status_code=status_code, code=str_code, details=details_dict)
|
|
85
|
+
if status_code >= 500:
|
|
86
|
+
return ServerError(message, status_code=status_code, code=str_code, details=details_dict)
|
|
87
|
+
|
|
88
|
+
return CoffeeMailError(message, status_code=status_code, code=str_code, details=details_dict)
|
coffeemail/core/http.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from coffeemail.core.errors import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
NetworkError,
|
|
10
|
+
create_error_from_response,
|
|
11
|
+
)
|
|
12
|
+
from coffeemail.core.i18n import Locale, get_message
|
|
13
|
+
from coffeemail.core.types import ApiKeyIntrospection, CoffeeMailResponse
|
|
14
|
+
|
|
15
|
+
SDK_VERSION = "0.1.0"
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.coffeemail.com.br"
|
|
17
|
+
DEFAULT_TIMEOUT_SECONDS = 10.0
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseTransport:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
api_key: str | None = None,
|
|
26
|
+
base_url: str | None = None,
|
|
27
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
28
|
+
locale: Locale = "pt-BR",
|
|
29
|
+
) -> None:
|
|
30
|
+
clean_key = (api_key or "").strip()
|
|
31
|
+
if not clean_key:
|
|
32
|
+
raise AuthenticationError(get_message("missing_api_key", locale))
|
|
33
|
+
|
|
34
|
+
self.api_key = clean_key
|
|
35
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
self.locale = locale
|
|
38
|
+
|
|
39
|
+
def get_headers(self, custom_headers: dict[str, str] | None = None) -> dict[str, str]:
|
|
40
|
+
headers = {
|
|
41
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
"Accept": "application/json",
|
|
44
|
+
"Accept-Language": self.locale,
|
|
45
|
+
"User-Agent": f"coffeemail-python/{SDK_VERSION}",
|
|
46
|
+
}
|
|
47
|
+
if custom_headers:
|
|
48
|
+
headers.update(custom_headers)
|
|
49
|
+
return headers
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HttpTransport(BaseTransport):
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
api_key: str | None = None,
|
|
56
|
+
base_url: str | None = None,
|
|
57
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
58
|
+
locale: Locale = "pt-BR",
|
|
59
|
+
client: httpx.Client | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(api_key, base_url, timeout, locale)
|
|
62
|
+
self.client = client or httpx.Client(timeout=self.timeout)
|
|
63
|
+
self._cached_introspection: ApiKeyIntrospection | None = None
|
|
64
|
+
self._introspection_expires_at: float = 0.0
|
|
65
|
+
|
|
66
|
+
def request(
|
|
67
|
+
self,
|
|
68
|
+
method: str,
|
|
69
|
+
path: str,
|
|
70
|
+
params: Mapping[str, object] | None = None,
|
|
71
|
+
json_data: Mapping[str, object] | None = None,
|
|
72
|
+
headers: dict[str, str] | None = None,
|
|
73
|
+
) -> CoffeeMailResponse[dict[str, object]]:
|
|
74
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
75
|
+
req_headers = self.get_headers(headers)
|
|
76
|
+
clean_params = (
|
|
77
|
+
{k: str(v) for k, v in params.items() if v is not None} if params is not None else None
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
response = self.client.request(
|
|
82
|
+
method=method,
|
|
83
|
+
url=url,
|
|
84
|
+
params=clean_params,
|
|
85
|
+
json=json_data,
|
|
86
|
+
headers=req_headers,
|
|
87
|
+
)
|
|
88
|
+
except httpx.TimeoutException as exc:
|
|
89
|
+
err = NetworkError(
|
|
90
|
+
get_message("timeout_error", self.locale), details={"original_error": str(exc)}
|
|
91
|
+
)
|
|
92
|
+
return CoffeeMailResponse(data=None, error=err, status_code=None)
|
|
93
|
+
except httpx.RequestError as exc:
|
|
94
|
+
err = NetworkError(
|
|
95
|
+
get_message("network_error", self.locale), details={"original_error": str(exc)}
|
|
96
|
+
)
|
|
97
|
+
return CoffeeMailResponse(data=None, error=err, status_code=None)
|
|
98
|
+
|
|
99
|
+
if not response.is_success:
|
|
100
|
+
body: dict[str, object] = {"message": response.text}
|
|
101
|
+
try:
|
|
102
|
+
parsed_body = response.json()
|
|
103
|
+
if isinstance(parsed_body, dict):
|
|
104
|
+
body = parsed_body
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
error = create_error_from_response(response.status_code, body)
|
|
108
|
+
return CoffeeMailResponse(data=None, error=error, status_code=response.status_code)
|
|
109
|
+
|
|
110
|
+
data: dict[str, object] = {}
|
|
111
|
+
if response.content:
|
|
112
|
+
try:
|
|
113
|
+
parsed_data = response.json()
|
|
114
|
+
if isinstance(parsed_data, dict):
|
|
115
|
+
data = parsed_data
|
|
116
|
+
except Exception:
|
|
117
|
+
data = {}
|
|
118
|
+
|
|
119
|
+
return CoffeeMailResponse(data=data, error=None, status_code=response.status_code)
|
|
120
|
+
|
|
121
|
+
def introspect(self, force_refresh: bool = False) -> CoffeeMailResponse[ApiKeyIntrospection]:
|
|
122
|
+
now = time.time()
|
|
123
|
+
if (
|
|
124
|
+
not force_refresh
|
|
125
|
+
and self._cached_introspection
|
|
126
|
+
and now < self._introspection_expires_at
|
|
127
|
+
):
|
|
128
|
+
return CoffeeMailResponse(data=self._cached_introspection, error=None, status_code=200)
|
|
129
|
+
|
|
130
|
+
raw_response = self.request("GET", "/v1/product/auth/me/api-key")
|
|
131
|
+
if raw_response.error or raw_response.data is None:
|
|
132
|
+
return CoffeeMailResponse(
|
|
133
|
+
data=None, error=raw_response.error, status_code=raw_response.status_code
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
introspection = ApiKeyIntrospection.model_validate(raw_response.data)
|
|
137
|
+
self._cached_introspection = introspection
|
|
138
|
+
self._introspection_expires_at = now + 300.0
|
|
139
|
+
return CoffeeMailResponse(
|
|
140
|
+
data=introspection, error=None, status_code=raw_response.status_code
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def invalidate_introspection_cache(self) -> None:
|
|
144
|
+
self._cached_introspection = None
|
|
145
|
+
self._introspection_expires_at = 0.0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class AsyncHttpTransport(BaseTransport):
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
api_key: str | None = None,
|
|
152
|
+
base_url: str | None = None,
|
|
153
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
154
|
+
locale: Locale = "pt-BR",
|
|
155
|
+
client: httpx.AsyncClient | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
super().__init__(api_key, base_url, timeout, locale)
|
|
158
|
+
self.client = client or httpx.AsyncClient(timeout=self.timeout)
|
|
159
|
+
self._cached_introspection: ApiKeyIntrospection | None = None
|
|
160
|
+
self._introspection_expires_at: float = 0.0
|
|
161
|
+
|
|
162
|
+
async def request(
|
|
163
|
+
self,
|
|
164
|
+
method: str,
|
|
165
|
+
path: str,
|
|
166
|
+
params: Mapping[str, object] | None = None,
|
|
167
|
+
json_data: Mapping[str, object] | None = None,
|
|
168
|
+
headers: dict[str, str] | None = None,
|
|
169
|
+
) -> CoffeeMailResponse[dict[str, object]]:
|
|
170
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
171
|
+
req_headers = self.get_headers(headers)
|
|
172
|
+
clean_params = (
|
|
173
|
+
{k: str(v) for k, v in params.items() if v is not None} if params is not None else None
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
response = await self.client.request(
|
|
178
|
+
method=method,
|
|
179
|
+
url=url,
|
|
180
|
+
params=clean_params,
|
|
181
|
+
json=json_data,
|
|
182
|
+
headers=req_headers,
|
|
183
|
+
)
|
|
184
|
+
except httpx.TimeoutException as exc:
|
|
185
|
+
err = NetworkError(
|
|
186
|
+
get_message("timeout_error", self.locale), details={"original_error": str(exc)}
|
|
187
|
+
)
|
|
188
|
+
return CoffeeMailResponse(data=None, error=err, status_code=None)
|
|
189
|
+
except httpx.RequestError as exc:
|
|
190
|
+
err = NetworkError(
|
|
191
|
+
get_message("network_error", self.locale), details={"original_error": str(exc)}
|
|
192
|
+
)
|
|
193
|
+
return CoffeeMailResponse(data=None, error=err, status_code=None)
|
|
194
|
+
|
|
195
|
+
if not response.is_success:
|
|
196
|
+
body: dict[str, object] = {"message": response.text}
|
|
197
|
+
try:
|
|
198
|
+
parsed_body = response.json()
|
|
199
|
+
if isinstance(parsed_body, dict):
|
|
200
|
+
body = parsed_body
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
error = create_error_from_response(response.status_code, body)
|
|
204
|
+
return CoffeeMailResponse(data=None, error=error, status_code=response.status_code)
|
|
205
|
+
|
|
206
|
+
data: dict[str, object] = {}
|
|
207
|
+
if response.content:
|
|
208
|
+
try:
|
|
209
|
+
parsed_data = response.json()
|
|
210
|
+
if isinstance(parsed_data, dict):
|
|
211
|
+
data = parsed_data
|
|
212
|
+
except Exception:
|
|
213
|
+
data = {}
|
|
214
|
+
|
|
215
|
+
return CoffeeMailResponse(data=data, error=None, status_code=response.status_code)
|
|
216
|
+
|
|
217
|
+
async def introspect(
|
|
218
|
+
self, force_refresh: bool = False
|
|
219
|
+
) -> CoffeeMailResponse[ApiKeyIntrospection]:
|
|
220
|
+
now = time.time()
|
|
221
|
+
if (
|
|
222
|
+
not force_refresh
|
|
223
|
+
and self._cached_introspection
|
|
224
|
+
and now < self._introspection_expires_at
|
|
225
|
+
):
|
|
226
|
+
return CoffeeMailResponse(data=self._cached_introspection, error=None, status_code=200)
|
|
227
|
+
|
|
228
|
+
raw_response = await self.request("GET", "/v1/product/auth/me/api-key")
|
|
229
|
+
if raw_response.error or raw_response.data is None:
|
|
230
|
+
return CoffeeMailResponse(
|
|
231
|
+
data=None, error=raw_response.error, status_code=raw_response.status_code
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
introspection = ApiKeyIntrospection.model_validate(raw_response.data)
|
|
235
|
+
self._cached_introspection = introspection
|
|
236
|
+
self._introspection_expires_at = now + 300.0
|
|
237
|
+
return CoffeeMailResponse(
|
|
238
|
+
data=introspection, error=None, status_code=raw_response.status_code
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def invalidate_introspection_cache(self) -> None:
|
|
242
|
+
self._cached_introspection = None
|
|
243
|
+
self._introspection_expires_at = 0.0
|
coffeemail/core/i18n.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
Locale = Literal["pt-BR", "en"]
|
|
4
|
+
|
|
5
|
+
MESSAGES: dict[str, dict[Locale, str]] = {
|
|
6
|
+
"missing_api_key": {
|
|
7
|
+
"pt-BR": "Chave de API ausente. Forneça a chave no construtor ou defina a variável COFFEEMAIL_API_KEY.",
|
|
8
|
+
"en": "Missing API key. Pass it to the constructor or define COFFEEMAIL_API_KEY environment variable.",
|
|
9
|
+
},
|
|
10
|
+
"timeout_error": {
|
|
11
|
+
"pt-BR": "Tempo limite de conexão esgotado ao contatar o servidor da CoffeeMail.",
|
|
12
|
+
"en": "Connection timed out while contacting CoffeeMail server.",
|
|
13
|
+
},
|
|
14
|
+
"network_error": {
|
|
15
|
+
"pt-BR": "Falha na comunicação de rede com o servidor da CoffeeMail.",
|
|
16
|
+
"en": "Network communication failure with CoffeeMail server.",
|
|
17
|
+
},
|
|
18
|
+
"invalid_webhook_signature": {
|
|
19
|
+
"pt-BR": "Assinatura de webhook inválida ou corrompida.",
|
|
20
|
+
"en": "Invalid or corrupted webhook signature.",
|
|
21
|
+
},
|
|
22
|
+
"webhook_timestamp_expired": {
|
|
23
|
+
"pt-BR": "Timestamp do webhook expirou em relação à tolerância configurada.",
|
|
24
|
+
"en": "Webhook timestamp expired according to configured tolerance.",
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_message(key: str, locale: Locale = "pt-BR") -> str:
|
|
30
|
+
entry = MESSAGES.get(key)
|
|
31
|
+
if not entry:
|
|
32
|
+
return key
|
|
33
|
+
return entry.get(locale, entry.get("pt-BR", key))
|
coffeemail/core/types.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from typing import Generic, TypeVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
from coffeemail.core.errors import CoffeeMailError
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CoffeeMailResponse(Generic[T]):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
data: T | None = None,
|
|
15
|
+
error: CoffeeMailError | None = None,
|
|
16
|
+
status_code: int | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
self.data = data
|
|
19
|
+
self.error = error
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_success(self) -> bool:
|
|
24
|
+
return self.error is None
|
|
25
|
+
|
|
26
|
+
def unwrap(self) -> T:
|
|
27
|
+
if self.error is not None:
|
|
28
|
+
raise self.error
|
|
29
|
+
if self.data is None:
|
|
30
|
+
raise ValueError("Resposta sem dados disponíveis.")
|
|
31
|
+
return self.data
|
|
32
|
+
|
|
33
|
+
def __iter__(self) -> Iterator[object | None]:
|
|
34
|
+
yield self.data
|
|
35
|
+
yield self.error
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
return f"CoffeeMailResponse(data={self.data!r}, error={self.error!r}, status_code={self.status_code})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ApiKeyIntrospection(BaseModel):
|
|
42
|
+
id: str
|
|
43
|
+
name: str
|
|
44
|
+
environment: str
|
|
45
|
+
scopes: list[str] = Field(default_factory=list)
|
|
46
|
+
rate_limit: dict[str, object] = Field(default_factory=dict, alias="rateLimit")
|
|
47
|
+
created_at: str = Field(alias="createdAt")
|
|
48
|
+
expires_at: str | None = Field(default=None, alias="expiresAt")
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict(populate_by_name=True)
|