mailglyph 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mailglyph/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ from .client import AsyncMailGlyph, MailGlyph
2
+ from .exceptions import (
3
+ ApiError,
4
+ AuthenticationError,
5
+ MailGlyphError,
6
+ NotFoundError,
7
+ RateLimitError,
8
+ ValidationError,
9
+ )
10
+ from .models import (
11
+ Campaign,
12
+ Contact,
13
+ FilterCondition,
14
+ FilterGroup,
15
+ Segment,
16
+ SegmentFilter,
17
+ SendEmailResult,
18
+ StaticSegmentMembersAddResult,
19
+ StaticSegmentMembersRemoveResult,
20
+ TrackEventResult,
21
+ VerifyEmailResult,
22
+ )
23
+
24
+ __version__ = "2.0.0"
25
+
26
+ __all__ = [
27
+ "ApiError",
28
+ "AsyncMailGlyph",
29
+ "AuthenticationError",
30
+ "Campaign",
31
+ "Contact",
32
+ "FilterCondition",
33
+ "FilterGroup",
34
+ "MailGlyph",
35
+ "MailGlyphError",
36
+ "NotFoundError",
37
+ "RateLimitError",
38
+ "Segment",
39
+ "SegmentFilter",
40
+ "SendEmailResult",
41
+ "StaticSegmentMembersAddResult",
42
+ "StaticSegmentMembersRemoveResult",
43
+ "TrackEventResult",
44
+ "ValidationError",
45
+ "VerifyEmailResult",
46
+ "__version__",
47
+ ]
mailglyph/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
mailglyph/client.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from .http_client import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, HttpClient
4
+ from .resources import (
5
+ AsyncCampaignsResource,
6
+ AsyncContactsResource,
7
+ AsyncEmailsResource,
8
+ AsyncEventsResource,
9
+ AsyncSegmentsResource,
10
+ CampaignsResource,
11
+ ContactsResource,
12
+ EmailsResource,
13
+ EventsResource,
14
+ SegmentsResource,
15
+ )
16
+
17
+
18
+ class MailGlyph:
19
+ def __init__(
20
+ self,
21
+ api_key: str,
22
+ *,
23
+ base_url: str = DEFAULT_BASE_URL,
24
+ timeout: float = DEFAULT_TIMEOUT,
25
+ max_retries: int = DEFAULT_MAX_RETRIES,
26
+ ) -> None:
27
+ self._http_client = HttpClient(
28
+ api_key,
29
+ base_url=base_url,
30
+ timeout=timeout,
31
+ max_retries=max_retries,
32
+ )
33
+ self.emails = EmailsResource(self._http_client)
34
+ self.events = EventsResource(self._http_client)
35
+ self.contacts = ContactsResource(self._http_client)
36
+ self.campaigns = CampaignsResource(self._http_client)
37
+ self.segments = SegmentsResource(self._http_client)
38
+
39
+ def close(self) -> None:
40
+ self._http_client.close()
41
+
42
+ def __enter__(self) -> MailGlyph:
43
+ return self
44
+
45
+ def __exit__(self, exc_type: object, exc: object, exc_tb: object) -> None:
46
+ self.close()
47
+
48
+
49
+ class AsyncMailGlyph:
50
+ def __init__(
51
+ self,
52
+ api_key: str,
53
+ *,
54
+ base_url: str = DEFAULT_BASE_URL,
55
+ timeout: float = DEFAULT_TIMEOUT,
56
+ max_retries: int = DEFAULT_MAX_RETRIES,
57
+ ) -> None:
58
+ self._http_client = HttpClient(
59
+ api_key,
60
+ base_url=base_url,
61
+ timeout=timeout,
62
+ max_retries=max_retries,
63
+ )
64
+ self.emails = AsyncEmailsResource(self._http_client)
65
+ self.events = AsyncEventsResource(self._http_client)
66
+ self.contacts = AsyncContactsResource(self._http_client)
67
+ self.campaigns = AsyncCampaignsResource(self._http_client)
68
+ self.segments = AsyncSegmentsResource(self._http_client)
69
+
70
+ async def close(self) -> None:
71
+ await self._http_client.aclose()
72
+
73
+ async def __aenter__(self) -> AsyncMailGlyph:
74
+ return self
75
+
76
+ async def __aexit__(self, exc_type: object, exc: object, exc_tb: object) -> None:
77
+ await self.close()
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class MailGlyphError(Exception):
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ *,
11
+ status_code: int | None = None,
12
+ payload: Any = None,
13
+ ) -> None:
14
+ super().__init__(message)
15
+ self.message = message
16
+ self.status_code = status_code
17
+ self.payload = payload
18
+
19
+
20
+ class AuthenticationError(MailGlyphError):
21
+ pass
22
+
23
+
24
+ class ValidationError(MailGlyphError):
25
+ pass
26
+
27
+
28
+ class NotFoundError(MailGlyphError):
29
+ pass
30
+
31
+
32
+ class RateLimitError(MailGlyphError):
33
+ def __init__(
34
+ self,
35
+ message: str,
36
+ *,
37
+ status_code: int | None = None,
38
+ payload: Any = None,
39
+ retry_after: float | None = None,
40
+ ) -> None:
41
+ super().__init__(message, status_code=status_code, payload=payload)
42
+ self.retry_after = retry_after
43
+
44
+
45
+ class ApiError(MailGlyphError):
46
+ pass
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import time
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from ._version import __version__
11
+ from .exceptions import (
12
+ ApiError,
13
+ AuthenticationError,
14
+ MailGlyphError,
15
+ NotFoundError,
16
+ RateLimitError,
17
+ ValidationError,
18
+ )
19
+
20
+ DEFAULT_BASE_URL = "https://api.mailglyph.com"
21
+ DEFAULT_TIMEOUT = 30.0
22
+ DEFAULT_MAX_RETRIES = 3
23
+
24
+
25
+ class HttpClient:
26
+ def __init__(
27
+ self,
28
+ api_key: str,
29
+ *,
30
+ base_url: str = DEFAULT_BASE_URL,
31
+ timeout: float = DEFAULT_TIMEOUT,
32
+ max_retries: int = DEFAULT_MAX_RETRIES,
33
+ ) -> None:
34
+ self._api_key = api_key
35
+ self._base_url = base_url.rstrip("/")
36
+ self._timeout = timeout
37
+ self._max_retries = max_retries
38
+ self._key_type = self._detect_key_type(api_key)
39
+ headers = {
40
+ "Authorization": f"Bearer {api_key}",
41
+ "Content-Type": "application/json",
42
+ "User-Agent": f"mailglyph-python/{__version__}",
43
+ }
44
+ self._sync_client = httpx.Client(
45
+ base_url=self._base_url, timeout=self._timeout, headers=headers
46
+ )
47
+ self._async_client = httpx.AsyncClient(
48
+ base_url=self._base_url,
49
+ timeout=self._timeout,
50
+ headers=headers,
51
+ )
52
+
53
+ @property
54
+ def key_type(self) -> str:
55
+ return self._key_type
56
+
57
+ @staticmethod
58
+ def _detect_key_type(api_key: str) -> str:
59
+ if api_key.startswith("sk_"):
60
+ return "sk"
61
+ if api_key.startswith("pk_"):
62
+ return "pk"
63
+ raise AuthenticationError("Invalid API key format. Expected key prefix 'sk_' or 'pk_'.")
64
+
65
+ def _enforce_key_restrictions(self, path: str) -> None:
66
+ if self._key_type == "pk" and path != "/v1/track":
67
+ raise AuthenticationError("Public API keys (pk_*) can only be used with /v1/track.")
68
+ if self._key_type == "sk" and path == "/v1/track":
69
+ raise AuthenticationError("Secret API keys (sk_*) cannot be used with /v1/track.")
70
+
71
+ def request(
72
+ self,
73
+ method: str,
74
+ path: str,
75
+ *,
76
+ params: dict[str, Any] | None = None,
77
+ json_body: dict[str, Any] | None = None,
78
+ ) -> Any:
79
+ self._enforce_key_restrictions(path)
80
+ return self._request_with_retries(method, path, params=params, json_body=json_body)
81
+
82
+ async def arequest(
83
+ self,
84
+ method: str,
85
+ path: str,
86
+ *,
87
+ params: dict[str, Any] | None = None,
88
+ json_body: dict[str, Any] | None = None,
89
+ ) -> Any:
90
+ self._enforce_key_restrictions(path)
91
+ return await self._arequest_with_retries(method, path, params=params, json_body=json_body)
92
+
93
+ def _request_with_retries(
94
+ self,
95
+ method: str,
96
+ path: str,
97
+ *,
98
+ params: dict[str, Any] | None,
99
+ json_body: dict[str, Any] | None,
100
+ ) -> Any:
101
+ for attempt in range(self._max_retries + 1):
102
+ try:
103
+ response = self._sync_client.request(method, path, params=params, json=json_body)
104
+ except httpx.TransportError as exc:
105
+ if attempt >= self._max_retries:
106
+ raise ApiError(f"Request failed: {exc}") from exc
107
+ time.sleep(self._retry_delay(attempt, None))
108
+ continue
109
+
110
+ if self._should_retry(response.status_code) and attempt < self._max_retries:
111
+ time.sleep(self._retry_delay(attempt, response.headers.get("Retry-After")))
112
+ continue
113
+
114
+ return self._parse_response(response)
115
+
116
+ raise ApiError("Request retries exhausted.")
117
+
118
+ async def _arequest_with_retries(
119
+ self,
120
+ method: str,
121
+ path: str,
122
+ *,
123
+ params: dict[str, Any] | None,
124
+ json_body: dict[str, Any] | None,
125
+ ) -> Any:
126
+ for attempt in range(self._max_retries + 1):
127
+ try:
128
+ response = await self._async_client.request(
129
+ method, path, params=params, json=json_body
130
+ )
131
+ except httpx.TransportError as exc:
132
+ if attempt >= self._max_retries:
133
+ raise ApiError(f"Request failed: {exc}") from exc
134
+ await asyncio.sleep(self._retry_delay(attempt, None))
135
+ continue
136
+
137
+ if self._should_retry(response.status_code) and attempt < self._max_retries:
138
+ await asyncio.sleep(self._retry_delay(attempt, response.headers.get("Retry-After")))
139
+ continue
140
+
141
+ return self._parse_response(response)
142
+
143
+ raise ApiError("Request retries exhausted.")
144
+
145
+ @staticmethod
146
+ def _should_retry(status_code: int) -> bool:
147
+ return status_code == 429 or 500 <= status_code <= 599
148
+
149
+ @staticmethod
150
+ def _retry_delay(attempt: int, retry_after: str | None) -> float:
151
+ if retry_after is not None:
152
+ try:
153
+ return max(float(retry_after), 0.0)
154
+ except ValueError:
155
+ pass
156
+ base = float(2**attempt)
157
+ jitter = random.uniform(0.0, 0.2)
158
+ return base + jitter
159
+
160
+ def _parse_response(self, response: httpx.Response) -> Any:
161
+ if response.status_code >= 400:
162
+ self._raise_for_status(response)
163
+
164
+ if response.status_code == 204:
165
+ return None
166
+
167
+ if not response.content:
168
+ return None
169
+
170
+ try:
171
+ return response.json()
172
+ except ValueError:
173
+ return response.text
174
+
175
+ def _raise_for_status(self, response: httpx.Response) -> None:
176
+ payload: Any
177
+ try:
178
+ payload = response.json()
179
+ except ValueError:
180
+ payload = None
181
+
182
+ message = self._extract_error_message(response.status_code, payload, response.text)
183
+ status_code = response.status_code
184
+
185
+ if status_code == 400:
186
+ raise ValidationError(message, status_code=status_code, payload=payload)
187
+ if status_code == 401:
188
+ raise AuthenticationError(message, status_code=status_code, payload=payload)
189
+ if status_code == 404:
190
+ raise NotFoundError(message, status_code=status_code, payload=payload)
191
+ if status_code == 429:
192
+ retry_after = response.headers.get("Retry-After")
193
+ retry_after_seconds: float | None = None
194
+ if retry_after is not None:
195
+ try:
196
+ retry_after_seconds = float(retry_after)
197
+ except ValueError:
198
+ retry_after_seconds = None
199
+ raise RateLimitError(
200
+ message,
201
+ status_code=status_code,
202
+ payload=payload,
203
+ retry_after=retry_after_seconds,
204
+ )
205
+ if 500 <= status_code <= 599:
206
+ raise ApiError(message, status_code=status_code, payload=payload)
207
+
208
+ raise MailGlyphError(message, status_code=status_code, payload=payload)
209
+
210
+ @staticmethod
211
+ def _extract_error_message(status_code: int, payload: Any, fallback: str) -> str:
212
+ if isinstance(payload, dict):
213
+ message = payload.get("message")
214
+ if isinstance(message, str) and message:
215
+ return message
216
+ error = payload.get("error")
217
+ if isinstance(error, str) and error:
218
+ return error
219
+ if fallback:
220
+ return fallback
221
+ return f"Request failed with status code {status_code}."
222
+
223
+ def close(self) -> None:
224
+ self._sync_client.close()
225
+
226
+ async def aclose(self) -> None:
227
+ await self._async_client.aclose()
mailglyph/models.py ADDED
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field
6
+
7
+
8
+ class MailGlyphModel(BaseModel):
9
+ model_config = ConfigDict(populate_by_name=True)
10
+
11
+
12
+ class ContactRef(MailGlyphModel):
13
+ id: str
14
+ email: str
15
+
16
+
17
+ class SendEmailItem(MailGlyphModel):
18
+ contact: ContactRef
19
+ email: str
20
+
21
+
22
+ class SendEmailResult(MailGlyphModel):
23
+ emails: list[SendEmailItem] = Field(default_factory=list)
24
+ timestamp: str | None = None
25
+
26
+
27
+ class VerifyEmailResult(MailGlyphModel):
28
+ email: str
29
+ valid: bool
30
+ is_disposable: bool = Field(alias="isDisposable")
31
+ is_alias: bool = Field(alias="isAlias")
32
+ is_typo: bool = Field(alias="isTypo")
33
+ is_plus_addressed: bool = Field(alias="isPlusAddressed")
34
+ is_random_input: bool = Field(alias="isRandomInput")
35
+ is_personal_email: bool = Field(alias="isPersonalEmail")
36
+ domain_exists: bool = Field(alias="domainExists")
37
+ has_website: bool = Field(alias="hasWebsite")
38
+ has_mx_records: bool = Field(alias="hasMxRecords")
39
+ suggested_email: str | None = Field(default=None, alias="suggestedEmail")
40
+ reasons: list[str] = Field(default_factory=list)
41
+
42
+
43
+ class TrackEventResult(MailGlyphModel):
44
+ contact: str
45
+ event: str
46
+ timestamp: str
47
+
48
+
49
+ class ContactMeta(MailGlyphModel):
50
+ is_new: bool | None = Field(default=None, alias="isNew")
51
+ is_update: bool | None = Field(default=None, alias="isUpdate")
52
+
53
+
54
+ class Contact(MailGlyphModel):
55
+ id: str
56
+ email: str
57
+ subscribed: bool
58
+ data: dict[str, Any] = Field(default_factory=dict)
59
+ created_at: str | None = Field(default=None, alias="createdAt")
60
+ updated_at: str | None = Field(default=None, alias="updatedAt")
61
+ meta: ContactMeta | None = Field(default=None, alias="_meta")
62
+
63
+
64
+ class Segment(MailGlyphModel):
65
+ id: str
66
+ name: str | None = None
67
+ description: str | None = None
68
+ condition: FilterCondition | None = None
69
+ track_membership: bool | None = Field(default=None, alias="trackMembership")
70
+ member_count: int | None = Field(default=None, alias="memberCount")
71
+ project_id: str | None = Field(default=None, alias="projectId")
72
+ created_at: str | None = Field(default=None, alias="createdAt")
73
+ updated_at: str | None = Field(default=None, alias="updatedAt")
74
+
75
+
76
+ class SegmentFilter(MailGlyphModel):
77
+ field: str
78
+ operator: str
79
+ value: Any | None = None
80
+ unit: str | None = None
81
+
82
+
83
+ class FilterGroup(MailGlyphModel):
84
+ filters: list[SegmentFilter] = Field(default_factory=list)
85
+ conditions: FilterCondition | None = None
86
+
87
+
88
+ class FilterCondition(MailGlyphModel):
89
+ logic: str
90
+ groups: list[FilterGroup] = Field(default_factory=list)
91
+
92
+
93
+ class Campaign(MailGlyphModel):
94
+ id: str
95
+ name: str | None = None
96
+ description: str | None = None
97
+ subject: str | None = None
98
+ body: str | None = None
99
+ from_email: str | None = Field(default=None, alias="from")
100
+ from_name: str | None = Field(default=None, alias="fromName")
101
+ reply_to: str | None = Field(default=None, alias="replyTo")
102
+ audience_type: str | None = Field(
103
+ default=None,
104
+ validation_alias=AliasChoices("audienceType", "type"),
105
+ serialization_alias="audienceType",
106
+ )
107
+ audience_condition: FilterCondition | None = Field(default=None, alias="audienceCondition")
108
+ segment_id: str | None = Field(default=None, alias="segmentId")
109
+ status: str | None = None
110
+ total_recipients: int | None = Field(default=None, alias="totalRecipients")
111
+ sent_count: int | None = Field(default=None, alias="sentCount")
112
+ delivered_count: int | None = Field(default=None, alias="deliveredCount")
113
+ opened_count: int | None = Field(default=None, alias="openedCount")
114
+ clicked_count: int | None = Field(default=None, alias="clickedCount")
115
+ bounced_count: int | None = Field(default=None, alias="bouncedCount")
116
+ scheduled_for: str | None = Field(
117
+ default=None,
118
+ validation_alias=AliasChoices("scheduledFor", "scheduledAt"),
119
+ serialization_alias="scheduledFor",
120
+ )
121
+ sent_at: str | None = Field(default=None, alias="sentAt")
122
+ created_at: str | None = Field(default=None, alias="createdAt")
123
+ updated_at: str | None = Field(default=None, alias="updatedAt")
124
+ segment: Segment | None = None
125
+
126
+
127
+ class ContactsPage(MailGlyphModel):
128
+ contacts: list[Contact] = Field(default_factory=list)
129
+ cursor: str | None = None
130
+ has_more: bool = Field(default=False, alias="hasMore")
131
+ total: int | None = None
132
+
133
+
134
+ class CampaignsPage(MailGlyphModel):
135
+ data: list[Campaign] = Field(default_factory=list)
136
+ page: int | None = None
137
+ page_size: int | None = Field(default=None, alias="pageSize")
138
+ total: int | None = None
139
+ total_pages: int | None = Field(default=None, alias="totalPages")
140
+
141
+ @property
142
+ def campaigns(self) -> list[Campaign]:
143
+ return self.data
144
+
145
+
146
+ class SegmentContactsPage(MailGlyphModel):
147
+ data: list[Contact] = Field(default_factory=list)
148
+ total: int | None = None
149
+ page: int | None = None
150
+ page_size: int | None = Field(default=None, alias="pageSize")
151
+ total_pages: int | None = Field(default=None, alias="totalPages")
152
+
153
+
154
+ class StaticSegmentMembersAddResult(MailGlyphModel):
155
+ added: int
156
+ not_found: list[str] = Field(default_factory=list, alias="notFound")
157
+
158
+
159
+ class StaticSegmentMembersRemoveResult(MailGlyphModel):
160
+ removed: int
161
+
162
+
163
+ FilterCondition.model_rebuild()
mailglyph/py.typed ADDED
File without changes
@@ -0,0 +1,18 @@
1
+ from .campaigns import AsyncCampaignsResource, CampaignsResource
2
+ from .contacts import AsyncContactsResource, ContactsResource
3
+ from .emails import AsyncEmailsResource, EmailsResource
4
+ from .events import AsyncEventsResource, EventsResource
5
+ from .segments import AsyncSegmentsResource, SegmentsResource
6
+
7
+ __all__ = [
8
+ "AsyncCampaignsResource",
9
+ "AsyncContactsResource",
10
+ "AsyncEmailsResource",
11
+ "AsyncEventsResource",
12
+ "AsyncSegmentsResource",
13
+ "CampaignsResource",
14
+ "ContactsResource",
15
+ "EmailsResource",
16
+ "EventsResource",
17
+ "SegmentsResource",
18
+ ]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def compact_dict(payload: dict[str, Any]) -> dict[str, Any]:
7
+ return {key: value for key, value in payload.items() if value is not None}
8
+
9
+
10
+ def unwrap_data(payload: Any) -> Any:
11
+ if isinstance(payload, dict) and "data" in payload:
12
+ return payload["data"]
13
+ return payload