reloop-email 0.2.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.
@@ -0,0 +1,12 @@
1
+ from .errors import ReloopApiError, ReloopError, ReloopNetworkError
2
+ from .reloop import Reloop
3
+
4
+ ReloopClient = Reloop
5
+
6
+ __all__ = [
7
+ "Reloop",
8
+ "ReloopClient",
9
+ "ReloopError",
10
+ "ReloopApiError",
11
+ "ReloopNetworkError",
12
+ ]
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ import httpx
6
+
7
+ from .errors import ReloopApiError, ReloopNetworkError
8
+
9
+
10
+ class HTTPClient:
11
+ """Internal HTTP transport for the Reloop SDK."""
12
+
13
+ def __init__(self, api_key: str, base_url: str = "https://reloop.sh") -> None:
14
+ if not api_key:
15
+ raise ValueError("Reloop SDK requires an api_key.")
16
+
17
+ self.api_key = api_key
18
+ self.base_url = base_url.rstrip("/")
19
+ self._http = httpx.Client(
20
+ base_url=self.base_url,
21
+ headers={
22
+ "x-api-key": self.api_key,
23
+ "Content-Type": "application/json",
24
+ "Accept": "application/json",
25
+ },
26
+ timeout=30.0,
27
+ )
28
+
29
+ def request(
30
+ self,
31
+ method: str,
32
+ path: str,
33
+ *,
34
+ params: Optional[dict[str, Any]] = None,
35
+ json: Optional[dict[str, Any]] = None,
36
+ ) -> dict[str, Any]:
37
+ try:
38
+ response = self._http.request(method, path, params=params, json=json)
39
+ except httpx.RequestError as exc:
40
+ raise ReloopNetworkError(f"Reloop network error: {exc}") from exc
41
+
42
+ if response.status_code >= 400:
43
+ body: Any = None
44
+ try:
45
+ body = response.json()
46
+ except ValueError:
47
+ body = response.text
48
+
49
+ message = response.reason_phrase
50
+ if isinstance(body, dict) and body.get("message"):
51
+ message = str(body["message"])
52
+
53
+ raise ReloopApiError(
54
+ f"Reloop API error ({response.status_code}): {message}",
55
+ status_code=response.status_code,
56
+ body=body,
57
+ )
58
+
59
+ if response.status_code == 204 or not response.content:
60
+ return {}
61
+
62
+ data = response.json()
63
+ if not isinstance(data, dict):
64
+ return {"data": data}
65
+ return data
66
+
67
+ def close(self) -> None:
68
+ self._http.close()
69
+
70
+ def __enter__(self) -> "HTTPClient":
71
+ return self
72
+
73
+ def __exit__(self, *args: object) -> None:
74
+ self.close()
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ REQUEST_KEY_MAP = {
7
+ "first_name": "firstName",
8
+ "last_name": "lastName",
9
+ "group_ids": "groupIds",
10
+ "group_id": "groupId",
11
+ "fallback_value": "fallbackValue",
12
+ "default_subscription": "defaultSubscription",
13
+ "channel_id": "channelId",
14
+ "property_name": "propertyName",
15
+ "property_type": "propertyType",
16
+ "contact_id": "contactId",
17
+ "rate_limit_enabled": "rateLimitEnabled",
18
+ "user_id": "userId",
19
+ }
20
+
21
+
22
+ def for_request(parameters: dict[str, Any]) -> dict[str, Any]:
23
+ normalized: dict[str, Any] = {}
24
+
25
+ for key, value in parameters.items():
26
+ if key == "unsubscribed":
27
+ if "status" not in parameters:
28
+ normalized["status"] = "unsubscribed" if value else "subscribed"
29
+ continue
30
+
31
+ api_key = REQUEST_KEY_MAP.get(key, _to_camel_case(key))
32
+ normalized[api_key] = _normalize_value(value, is_request=True)
33
+
34
+ return {key: value for key, value in normalized.items() if value is not None}
35
+
36
+
37
+ def for_query(options: dict[str, Any]) -> dict[str, Any]:
38
+ return for_request(options)
39
+
40
+
41
+ def for_snake_request(parameters: dict[str, Any]) -> dict[str, Any]:
42
+ """Pass parameters through without camelCase conversion (domain API)."""
43
+ return {key: value for key, value in parameters.items() if value is not None}
44
+
45
+
46
+ def for_response(data: dict[str, Any]) -> dict[str, Any]:
47
+ normalized: dict[str, Any] = {}
48
+
49
+ for key, value in data.items():
50
+ normalized[_to_snake_case(key)] = _normalize_value(value, is_request=False)
51
+
52
+ return normalized
53
+
54
+
55
+ def _normalize_value(value: Any, *, is_request: bool) -> Any:
56
+ if not isinstance(value, dict):
57
+ return value
58
+
59
+ if _is_list(value):
60
+ return [
61
+ _normalize_value(item, is_request=is_request) if isinstance(item, dict) else item
62
+ for item in value
63
+ ]
64
+
65
+ return for_request(value) if is_request else for_response(value)
66
+
67
+
68
+ def _is_list(value: dict[str, Any]) -> bool:
69
+ if not value:
70
+ return True
71
+ return list(value.keys()) == list(range(len(value)))
72
+
73
+
74
+ def _to_camel_case(key: str) -> str:
75
+ if key in REQUEST_KEY_MAP:
76
+ return REQUEST_KEY_MAP[key]
77
+ if "_" not in key:
78
+ return key
79
+ parts = key.split("_")
80
+ return parts[0] + "".join(part.capitalize() for part in parts[1:])
81
+
82
+
83
+ def _to_snake_case(key: str) -> str:
84
+ if "_" in key:
85
+ return key
86
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", key).lower()
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterator, Mapping
4
+
5
+
6
+ class Resource(Mapping[str, Any]):
7
+ """StripeObject-style resource with snake_case attribute access."""
8
+
9
+ _repr_attr = "id"
10
+
11
+ def __init__(self, **attributes: Any) -> None:
12
+ self._values = dict(attributes)
13
+ for key, value in attributes.items():
14
+ object.__setattr__(self, key, value)
15
+
16
+ @classmethod
17
+ def from_dict(cls, data: dict[str, Any]) -> Resource:
18
+ return cls(**data)
19
+
20
+ def __getitem__(self, key: str) -> Any:
21
+ return self._values[key]
22
+
23
+ def __iter__(self) -> Iterator[str]:
24
+ return iter(self._values)
25
+
26
+ def __len__(self) -> int:
27
+ return len(self._values)
28
+
29
+ def __repr__(self) -> str:
30
+ ident = getattr(self, self._repr_attr, None)
31
+ class_name = self.__class__.__name__
32
+ if ident is not None:
33
+ return f"<{class_name} {self._repr_attr}={ident!r}>"
34
+ return f"<{class_name}>"
35
+
36
+
37
+ class ApiKey(Resource):
38
+ pass
39
+
40
+
41
+ class ApiKeyList(Resource):
42
+ pass
43
+
44
+
45
+ class Contact(Resource):
46
+ pass
47
+
48
+
49
+ class ContactList(Resource):
50
+ pass
51
+
52
+
53
+ class ContactProperty(Resource):
54
+ pass
55
+
56
+
57
+ class PropertyList(Resource):
58
+ pass
59
+
60
+
61
+ class ContactGroup(Resource):
62
+ pass
63
+
64
+
65
+ class GroupList(Resource):
66
+ pass
67
+
68
+
69
+ class ContactChannel(Resource):
70
+ pass
71
+
72
+
73
+ class ChannelList(Resource):
74
+ pass
75
+
76
+
77
+ class DnsRecord(Resource):
78
+ pass
79
+
80
+
81
+ class Domain(Resource):
82
+ pass
83
+
84
+
85
+ class DomainList(Resource):
86
+ pass
87
+
88
+
89
+ class DomainStatus(Resource):
90
+ pass
91
+
92
+
93
+ class DomainNameservers(Resource):
94
+ pass
95
+
96
+
97
+ class ForwardDnsResponse(Resource):
98
+ pass
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Optional, TypeVar
4
+
5
+ from ._parameters import for_response
6
+ from ._resource import (
7
+ ApiKey,
8
+ ApiKeyList,
9
+ ChannelList,
10
+ Contact,
11
+ ContactChannel,
12
+ ContactGroup,
13
+ ContactList,
14
+ ContactProperty,
15
+ DnsRecord,
16
+ Domain,
17
+ DomainList,
18
+ DomainNameservers,
19
+ DomainStatus,
20
+ ForwardDnsResponse,
21
+ GroupList,
22
+ PropertyList,
23
+ Resource,
24
+ )
25
+
26
+ T = TypeVar("T", bound=Resource)
27
+
28
+
29
+ def _build(resource_cls: type[T], data: dict[str, Any]) -> T:
30
+ return resource_cls.from_dict(for_response(data))
31
+
32
+
33
+ def api_key(data: dict[str, Any]) -> ApiKey:
34
+ return _build(ApiKey, data)
35
+
36
+
37
+ def api_key_list(data: dict[str, Any]) -> ApiKeyList:
38
+ normalized = for_response(data)
39
+ if isinstance(normalized.get("api_keys"), list):
40
+ normalized["api_keys"] = [api_key(item) for item in normalized["api_keys"]]
41
+ return ApiKeyList.from_dict(normalized)
42
+
43
+
44
+ def contact(data: dict[str, Any]) -> Contact:
45
+ return _build(Contact, data)
46
+
47
+
48
+ def contact_list(data: dict[str, Any]) -> ContactList:
49
+ normalized = for_response(data)
50
+ if isinstance(normalized.get("contacts"), list):
51
+ normalized["contacts"] = [contact(item) for item in normalized["contacts"]]
52
+ return ContactList.from_dict(normalized)
53
+
54
+
55
+ def contact_property(data: dict[str, Any]) -> ContactProperty:
56
+ return _build(ContactProperty, data)
57
+
58
+
59
+ def property_list(data: dict[str, Any]) -> PropertyList:
60
+ normalized = for_response(data)
61
+ if isinstance(normalized.get("properties"), list):
62
+ normalized["properties"] = [
63
+ contact_property(item) for item in normalized["properties"]
64
+ ]
65
+ return PropertyList.from_dict(normalized)
66
+
67
+
68
+ def contact_group(data: dict[str, Any]) -> ContactGroup:
69
+ normalized = for_response(data)
70
+ if isinstance(normalized.get("contacts"), list):
71
+ normalized["contacts"] = [contact(item) for item in normalized["contacts"]]
72
+ return ContactGroup.from_dict(normalized)
73
+
74
+
75
+ def group_list(data: dict[str, Any]) -> GroupList:
76
+ normalized = for_response(data)
77
+ if isinstance(normalized.get("groups"), list):
78
+ normalized["groups"] = [contact_group(item) for item in normalized["groups"]]
79
+ return GroupList.from_dict(normalized)
80
+
81
+
82
+ def contact_channel(data: dict[str, Any]) -> ContactChannel:
83
+ normalized = for_response(data)
84
+ if isinstance(normalized.get("contact"), dict):
85
+ normalized["contact"] = contact(normalized["contact"])
86
+ return ContactChannel.from_dict(normalized)
87
+
88
+
89
+ def channel_list(data: dict[str, Any]) -> ChannelList:
90
+ normalized = for_response(data)
91
+ if isinstance(normalized.get("channels"), list):
92
+ normalized["channels"] = [
93
+ contact_channel(item) for item in normalized["channels"]
94
+ ]
95
+ return ChannelList.from_dict(normalized)
96
+
97
+
98
+ def dns_record(data: dict[str, Any]) -> DnsRecord:
99
+ return _build(DnsRecord, data)
100
+
101
+
102
+ def domain(data: dict[str, Any]) -> Domain:
103
+ normalized = for_response(data)
104
+ if isinstance(normalized.get("dns_records"), list):
105
+ normalized["dns_records"] = [
106
+ dns_record(item) for item in normalized["dns_records"]
107
+ ]
108
+ return Domain.from_dict(normalized)
109
+
110
+
111
+ def domain_list(data: dict[str, Any]) -> DomainList:
112
+ normalized = for_response(data)
113
+ if isinstance(normalized.get("domains"), list):
114
+ normalized["domains"] = [domain(item) for item in normalized["domains"]]
115
+ return DomainList.from_dict(normalized)
116
+
117
+
118
+ def domain_status(data: dict[str, Any]) -> DomainStatus:
119
+ return _build(DomainStatus, data)
120
+
121
+
122
+ def domain_nameservers(data: dict[str, Any]) -> DomainNameservers:
123
+ return _build(DomainNameservers, data)
124
+
125
+
126
+ def forward_dns_response(data: dict[str, Any]) -> ForwardDnsResponse:
127
+ return _build(ForwardDnsResponse, data)
reloop_email/client.py ADDED
@@ -0,0 +1,6 @@
1
+ from .reloop import Reloop
2
+
3
+ # Backward-compatible alias used in dashboard examples.
4
+ ReloopClient = Reloop
5
+
6
+ __all__ = ["Reloop", "ReloopClient"]
reloop_email/errors.py ADDED
@@ -0,0 +1,27 @@
1
+ from typing import Any, Optional
2
+
3
+
4
+ class ReloopError(Exception):
5
+ """Base exception for all Reloop SDK errors."""
6
+
7
+
8
+ class ReloopApiError(ReloopError):
9
+ """Raised when the Reloop API returns an error response."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status_code: Optional[int] = None,
16
+ body: Optional[Any] = None,
17
+ ) -> None:
18
+ super().__init__(message)
19
+ self.status_code = status_code
20
+ self.body = body
21
+
22
+ def __repr__(self) -> str:
23
+ return f"ReloopApiError(message={self.args[0]!r}, status_code={self.status_code!r})"
24
+
25
+
26
+ class ReloopNetworkError(ReloopError):
27
+ """Raised when a network error occurs while communicating with Reloop."""
reloop_email/reloop.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from ._http_client import HTTPClient
6
+ from .services.api_keys import ApiKeysService
7
+ from .services.contacts import ContactsService
8
+ from .services.domain import DomainService
9
+
10
+
11
+ class Reloop:
12
+ """Reloop Python SDK client."""
13
+
14
+ def __init__(
15
+ self,
16
+ api_key: str,
17
+ *,
18
+ base_url: str = "https://reloop.sh",
19
+ ) -> None:
20
+ self._http = HTTPClient(api_key, base_url)
21
+ self.api_keys = ApiKeysService(self._http)
22
+ self.contacts = ContactsService(self._http)
23
+ self.domain = DomainService(self._http)
24
+
25
+ @classmethod
26
+ def client(
27
+ cls,
28
+ api_key: str,
29
+ base_url: str = "https://reloop.sh",
30
+ ) -> "Reloop":
31
+ """Create a new Reloop client with the given API key."""
32
+ return cls(api_key, base_url=base_url)
33
+
34
+ def close(self) -> None:
35
+ self._http.close()
36
+
37
+ def __enter__(self) -> "Reloop":
38
+ return self
39
+
40
+ def __exit__(self, *args: object) -> None:
41
+ self.close()
@@ -0,0 +1,4 @@
1
+ from .api_keys import ApiKeysService
2
+ from .domain import DomainService
3
+
4
+ __all__ = ["ApiKeysService", "DomainService"]
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from .._http_client import HTTPClient
6
+ from .._parameters import for_query, for_request
7
+ from .._resource_factory import api_key, api_key_list
8
+
9
+
10
+ class ApiKeysService:
11
+ """Manage API keys."""
12
+
13
+ def __init__(self, client: HTTPClient) -> None:
14
+ self._client = client
15
+
16
+ def create(self, **parameters: Any) -> Any:
17
+ data = self._client.request(
18
+ "POST",
19
+ "/api/api-key/v1/",
20
+ json=for_request(parameters),
21
+ )
22
+ return api_key(data)
23
+
24
+ def list(self, **options: Any) -> Any:
25
+ data = self._client.request(
26
+ "GET",
27
+ "/api/api-key/v1/",
28
+ params=for_query(options) or None,
29
+ )
30
+ return api_key_list(data)
31
+
32
+ def get(self, api_key_id: str) -> Any:
33
+ data = self._client.request("GET", f"/api/api-key/v1/{api_key_id}")
34
+ return api_key(data)
35
+
36
+ def update(self, api_key_id: str, **parameters: Any) -> Any:
37
+ data = self._client.request(
38
+ "PATCH",
39
+ f"/api/api-key/v1/{api_key_id}",
40
+ json=for_request(parameters),
41
+ )
42
+ return api_key(data)
43
+
44
+ def delete(self, api_key_id: str) -> Any:
45
+ data = self._client.request("DELETE", f"/api/api-key/v1/{api_key_id}")
46
+ return api_key(data)
47
+
48
+ def rotate(self, api_key_id: str) -> Any:
49
+ data = self._client.request("POST", f"/api/api-key/v1/rotate/{api_key_id}")
50
+ return api_key(data)
51
+
52
+ def enable(self, api_key_id: str) -> Any:
53
+ data = self._client.request("POST", f"/api/api-key/v1/enable/{api_key_id}")
54
+ return api_key(data)
55
+
56
+ def disable(self, api_key_id: str) -> Any:
57
+ data = self._client.request("POST", f"/api/api-key/v1/disable/{api_key_id}")
58
+ return api_key(data)
59
+
60
+ def pause(self, api_key_id: str) -> Any:
61
+ """Pause an API key (alias for :meth:`disable`)."""
62
+ return self.disable(api_key_id)
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..._http_client import HTTPClient
6
+ from ..._parameters import for_query, for_request
7
+ from ..._resource_factory import (
8
+ contact,
9
+ contact_group,
10
+ contact_list,
11
+ contact_property,
12
+ group_list,
13
+ property_list,
14
+ )
15
+ from .channels import ContactChannelsService
16
+ from .groups import ContactGroupsService
17
+
18
+
19
+ class ContactsService:
20
+ """Manage contacts, properties, and groups."""
21
+
22
+ def __init__(self, client: HTTPClient) -> None:
23
+ self._client = client
24
+ self.groups = ContactGroupsService(client)
25
+ self.channels = ContactChannelsService(client)
26
+
27
+ def create(self, **parameters: Any) -> Any:
28
+ data = self._client.request(
29
+ "POST",
30
+ "/api/contacts/create",
31
+ json=for_request(parameters),
32
+ )
33
+ return contact(data)
34
+
35
+ def get(self, contact_id: str) -> Any:
36
+ data = self._client.request("GET", f"/api/contacts/retrieve/{contact_id}")
37
+ return contact(data)
38
+
39
+ def list(self, **options: Any) -> Any:
40
+ group_id = options.get("group_id") or options.get("groupId")
41
+
42
+ if group_id:
43
+ filtered = {
44
+ key: value
45
+ for key, value in options.items()
46
+ if key not in ("group_id", "groupId")
47
+ }
48
+ return self.groups.list_contacts(group_id, **filtered)
49
+
50
+ data = self._client.request(
51
+ "GET",
52
+ "/api/contacts/list",
53
+ params=for_query(options) or None,
54
+ )
55
+ return contact_list(data)
56
+
57
+ def update(self, contact_id: str, **parameters: Any) -> Any:
58
+ data = self._client.request(
59
+ "PATCH",
60
+ f"/api/contacts/{contact_id}",
61
+ json=for_request(parameters),
62
+ )
63
+ return contact(data)
64
+
65
+ def delete(self, contact_id: str) -> Any:
66
+ data = self._client.request("DELETE", f"/api/contacts/{contact_id}")
67
+ return contact(data)
68
+
69
+ def create_property(self, **parameters: Any) -> Any:
70
+ data = self._client.request(
71
+ "POST",
72
+ "/api/contacts/v1/properties/create",
73
+ json=for_request(parameters),
74
+ )
75
+ return contact_property(data)
76
+
77
+ def list_properties(self, **options: Any) -> Any:
78
+ data = self._client.request(
79
+ "GET",
80
+ "/api/contacts/v1/properties/list",
81
+ params=for_query(options) or None,
82
+ )
83
+ return property_list(data)
84
+
85
+ def update_property(self, property_id: str, **parameters: Any) -> Any:
86
+ data = self._client.request(
87
+ "PATCH",
88
+ f"/api/contacts/v1/properties/{property_id}",
89
+ json=for_request(parameters),
90
+ )
91
+ return contact_property(data)
92
+
93
+ def delete_property(self, property_id: str) -> Any:
94
+ data = self._client.request(
95
+ "DELETE",
96
+ f"/api/contacts/v1/properties/{property_id}",
97
+ )
98
+ return contact_property(data)
99
+
100
+ def create_group(self, **parameters: Any) -> Any:
101
+ data = self._client.request(
102
+ "POST",
103
+ "/api/contacts/v1/groups/create",
104
+ json=for_request(parameters),
105
+ )
106
+ return contact_group(data)
107
+
108
+ def list_groups(self, **options: Any) -> Any:
109
+ data = self._client.request(
110
+ "GET",
111
+ "/api/contacts/v1/groups/list",
112
+ params=for_query(options) or None,
113
+ )
114
+ return group_list(data)
115
+
116
+ def get_group(self, group_id: str) -> Any:
117
+ data = self._client.request("GET", f"/api/contacts/v1/groups/{group_id}")
118
+ return contact_group(data)
119
+
120
+ def update_group(self, group_id: str, **parameters: Any) -> Any:
121
+ data = self._client.request(
122
+ "PATCH",
123
+ f"/api/contacts/v1/groups/{group_id}",
124
+ json=for_request(parameters),
125
+ )
126
+ return contact_group(data)
127
+
128
+ def delete_group(self, group_id: str) -> Any:
129
+ data = self._client.request(
130
+ "DELETE",
131
+ f"/api/contacts/v1/groups/{group_id}",
132
+ )
133
+ return contact_group(data)
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..._http_client import HTTPClient
6
+ from ..._parameters import for_query, for_request
7
+ from ..._resource_factory import channel_list, contact_channel
8
+
9
+
10
+ class ContactChannelsService:
11
+ """Manage contact channels."""
12
+
13
+ def __init__(self, client: HTTPClient) -> None:
14
+ self._client = client
15
+
16
+ def create(self, **parameters: Any) -> Any:
17
+ data = self._client.request(
18
+ "POST",
19
+ "/api/contacts/v1/channels/create",
20
+ json=for_request(parameters),
21
+ )
22
+ return contact_channel(data)
23
+
24
+ def list(self, **options: Any) -> Any:
25
+ data = self._client.request(
26
+ "GET",
27
+ "/api/contacts/v1/channels/list",
28
+ params=for_query(options) or None,
29
+ )
30
+ return channel_list(data)
31
+
32
+ def get(self, channel_id: str) -> Any:
33
+ data = self._client.request("GET", f"/api/contacts/v1/channels/{channel_id}")
34
+ return contact_channel(data)
35
+
36
+ def update(self, channel_id: str, **parameters: Any) -> Any:
37
+ data = self._client.request(
38
+ "PATCH",
39
+ f"/api/contacts/v1/channels/{channel_id}",
40
+ json=for_request(parameters),
41
+ )
42
+ return contact_channel(data)
43
+
44
+ def delete(self, channel_id: str) -> Any:
45
+ data = self._client.request(
46
+ "DELETE",
47
+ f"/api/contacts/v1/channels/{channel_id}",
48
+ )
49
+ return contact_channel(data)
50
+
51
+ def add_contact(self, channel_id: str, **parameters: Any) -> Any:
52
+ data = self._client.request(
53
+ "POST",
54
+ f"/api/contacts/channel/{channel_id}",
55
+ json=for_request(parameters),
56
+ )
57
+ return contact_channel(data)
58
+
59
+ def update_subscription(self, channel_id: str, **parameters: Any) -> Any:
60
+ data = self._client.request(
61
+ "PATCH",
62
+ f"/api/contacts/channel/{channel_id}",
63
+ json=for_request(parameters),
64
+ )
65
+ return contact_channel(data)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..._http_client import HTTPClient
6
+ from ..._parameters import for_query, for_request
7
+ from ..._resource_factory import contact, contact_group
8
+
9
+
10
+ class ContactGroupsService:
11
+ """Manage contact group membership."""
12
+
13
+ def __init__(self, client: HTTPClient) -> None:
14
+ self._client = client
15
+
16
+ def add_contact(self, group_id: str, **parameters: Any) -> Any:
17
+ data = self._client.request(
18
+ "POST",
19
+ f"/api/contacts/group/{group_id}",
20
+ json=for_request(parameters),
21
+ )
22
+ return contact(data)
23
+
24
+ def remove_contact(self, group_id: str, **parameters: Any) -> Any:
25
+ data = self._client.request(
26
+ "DELETE",
27
+ f"/api/contacts/group/{group_id}",
28
+ json=for_request(parameters),
29
+ )
30
+ return contact(data)
31
+
32
+ def list_contacts(self, group_id: str, **options: Any) -> Any:
33
+ data = self._client.request(
34
+ "GET",
35
+ f"/api/contacts/v1/groups/{group_id}/contacts",
36
+ params=for_query(options) or None,
37
+ )
38
+ return contact_group(data)
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .._http_client import HTTPClient
6
+ from .._parameters import for_query, for_snake_request
7
+ from .._resource_factory import (
8
+ domain,
9
+ domain_list,
10
+ domain_nameservers,
11
+ domain_status,
12
+ forward_dns_response,
13
+ )
14
+
15
+
16
+ class DomainService:
17
+ """Manage sending and receiving domains."""
18
+
19
+ def __init__(self, client: HTTPClient) -> None:
20
+ self._client = client
21
+
22
+ def create(self, **parameters: Any) -> Any:
23
+ data = self._client.request(
24
+ "POST",
25
+ "/api/domain/v1/create",
26
+ json=for_snake_request(parameters),
27
+ )
28
+ return domain(data)
29
+
30
+ def list(self, **options: Any) -> Any:
31
+ data = self._client.request(
32
+ "GET",
33
+ "/api/domain/v1/list",
34
+ params=for_query(options) or None,
35
+ )
36
+ return domain_list(data)
37
+
38
+ def get(self, domain_id: str) -> Any:
39
+ data = self._client.request("GET", f"/api/domain/v1/{domain_id}")
40
+ return domain(data)
41
+
42
+ def get_nameservers(self, domain_id: str) -> Any:
43
+ data = self._client.request(
44
+ "GET",
45
+ f"/api/domain/v1/nameservers/{domain_id}",
46
+ )
47
+ return domain_nameservers(data)
48
+
49
+ def update(self, domain_id: str, **parameters: Any) -> Any:
50
+ data = self._client.request(
51
+ "PATCH",
52
+ f"/api/domain/v1/{domain_id}",
53
+ json=for_snake_request(parameters),
54
+ )
55
+ return domain(data)
56
+
57
+ def delete(self, domain_id: str) -> Any:
58
+ data = self._client.request("DELETE", f"/api/domain/v1/{domain_id}")
59
+ return domain(data)
60
+
61
+ def verify(self, domain_id: str) -> Any:
62
+ data = self._client.request("POST", f"/api/domain/v1/verify/{domain_id}")
63
+ return domain_status(data)
64
+
65
+ def forward_dns(self, domain_id: str, *, email: str) -> Any:
66
+ data = self._client.request(
67
+ "POST",
68
+ f"/api/domain/v1/verify/{domain_id}/forward-dns",
69
+ json={"email": email},
70
+ )
71
+ return forward_dns_response(data)
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: reloop-email
3
+ Version: 0.2.0
4
+ Summary: Reloop Python SDK
5
+ Project-URL: Homepage, https://reloop.sh
6
+ Project-URL: Repository, https://github.com/reloop-labs/reloop-python
7
+ Author: Reloop Labs
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: httpx>=0.24.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Reloop Python SDK
13
+
14
+ The official Python SDK for [Reloop](https://reloop.sh), modeled after the Stripe Python SDK with snake_case parameters and typed resource responses.
15
+
16
+ ## Requirements
17
+
18
+ - Python 3.9 or higher
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install reloop-email
24
+ ```
25
+
26
+ ## Getting Started
27
+
28
+ ```python
29
+ from reloop_email import Reloop
30
+
31
+ reloop = Reloop(api_key="re_123456789")
32
+ # or
33
+ reloop = Reloop.client("re_123456789")
34
+ ```
35
+
36
+ ## API Keys
37
+
38
+ ```python
39
+ reloop = Reloop(api_key="rl_123456789")
40
+
41
+ reloop.api_keys.list(page=1, limit=10)
42
+
43
+ reloop.api_keys.create(
44
+ name="Production key",
45
+ enabled=True,
46
+ rate_limit_enabled=True,
47
+ )
48
+
49
+ reloop.api_keys.get("key_123456789")
50
+ reloop.api_keys.update("key_123456789", name="Renamed key")
51
+ reloop.api_keys.rotate("key_123456789")
52
+ reloop.api_keys.disable("key_123456789")
53
+ reloop.api_keys.pause("key_123456789")
54
+ reloop.api_keys.enable("key_123456789")
55
+ reloop.api_keys.delete("key_123456789")
56
+ ```
57
+
58
+ ## Domains
59
+
60
+ Add, verify, and manage sending domains. Request parameters use snake_case; responses expose snake_case attributes.
61
+
62
+ ```python
63
+ reloop = Reloop(api_key="rl_123456789")
64
+
65
+ created = reloop.domain.create(
66
+ domain="send.example.com",
67
+ custom_return_path="inbound",
68
+ click_tracking=True,
69
+ open_tracking=True,
70
+ tls="opportunistic",
71
+ sending_email=True,
72
+ receiving_email=True,
73
+ )
74
+
75
+ domains = reloop.domain.list(page=1, limit=10, status="active")
76
+ one = reloop.domain.get("domain_123456789")
77
+
78
+ reloop.domain.update(
79
+ "domain_123456789",
80
+ click_tracking=False,
81
+ sending_email=True,
82
+ )
83
+
84
+ reloop.domain.verify("domain_123456789")
85
+
86
+ reloop.domain.forward_dns("domain_123456789", email="admin@example.com")
87
+
88
+ nameservers = reloop.domain.get_nameservers("domain_123456789")
89
+ print(nameservers.dns_provider, nameservers.nameservers)
90
+
91
+ reloop.domain.delete("domain_123456789")
92
+ ```
93
+
94
+ ## Contacts
95
+
96
+ Manage contacts, custom properties, groups, and channels. Methods accept snake_case keyword arguments and return resource objects with snake_case attributes.
97
+
98
+ ### Create a contact
99
+
100
+ ```python
101
+ reloop = Reloop(api_key="re_123456789")
102
+
103
+ contact = reloop.contacts.create(
104
+ email="steve.wozniak@gmail.com",
105
+ first_name="Steve",
106
+ last_name="Wozniak",
107
+ unsubscribed=False,
108
+ )
109
+
110
+ print(contact.email)
111
+ print(contact.first_name)
112
+ ```
113
+
114
+ ### List and update contacts
115
+
116
+ ```python
117
+ contacts = reloop.contacts.list(page=1, limit=10)
118
+ print(contacts.contacts, contacts.total)
119
+
120
+ reloop.contacts.update(
121
+ "cont_123456789",
122
+ first_name="Steve",
123
+ unsubscribed=False,
124
+ )
125
+ ```
126
+
127
+ ### Groups and channels
128
+
129
+ ```python
130
+ reloop.contacts.groups.add_contact(
131
+ "grp_123456789",
132
+ contact_id="cont_123456789",
133
+ )
134
+
135
+ reloop.contacts.channels.create(
136
+ name="Product Updates",
137
+ default_subscription="opt_in",
138
+ )
139
+ ```
140
+
141
+ ## Error Handling
142
+
143
+ ```python
144
+ from reloop_email import Reloop, ReloopApiError
145
+
146
+ reloop = Reloop(api_key="re_123456789")
147
+
148
+ try:
149
+ reloop.contacts.get("cont_invalid")
150
+ except ReloopApiError as error:
151
+ print(error.status_code, error.body)
152
+ ```
153
+
154
+ ## Context Manager
155
+
156
+ ```python
157
+ with Reloop(api_key="re_123456789") as reloop:
158
+ reloop.contacts.list(limit=10)
159
+ ```
@@ -0,0 +1,17 @@
1
+ reloop_email/__init__.py,sha256=tWlolF6kqj_B6zklRDp4GzPzPwLGuqkBqvQgEO56UhQ,234
2
+ reloop_email/_http_client.py,sha256=Y5MnGVwWp3HV0sIyrg2d_-8lDu_XfA0CLqpG-o1qMuE,2115
3
+ reloop_email/_parameters.py,sha256=KubqE0x9rejq7o7KfFY4iDiS75cWrJ6enJ2CwBHLY_I,2504
4
+ reloop_email/_resource.py,sha256=yfOKfIZkpb0KUG72tpVyYMOJXmcHBFrh9EeeVU4mt-U,1619
5
+ reloop_email/_resource_factory.py,sha256=vLSrejn0ukyDHUofxS5XfxJdapquC2r_qPSRxapK5V4,3812
6
+ reloop_email/client.py,sha256=D_xa3t1CqmfM0SLFgNfWwdA9zw9fjKLEpJNiBN4YcTM,144
7
+ reloop_email/errors.py,sha256=ajAg90kW_qw1vLeB1ct4eCJzJYjUI8ikxPQ0DWWRvrI,720
8
+ reloop_email/reloop.py,sha256=jDPTjw0_IGb2zK0ZBAL0arw9AdeL-N1AgoInNruTmYE,1047
9
+ reloop_email/services/__init__.py,sha256=xikGDamIqwyPBClEpPxXppdvc7VgxUDvZ-KoiNrFGJI,118
10
+ reloop_email/services/api_keys.py,sha256=TEXIqfM72OkefRoPzB78WFJTci2A_l9375_msJg36Cs,1969
11
+ reloop_email/services/domain.py,sha256=YimwEU-YS8wVE0E75gxPLeEqOVvfZb5MrPjJ1vqHIOg,2094
12
+ reloop_email/services/contacts/__init__.py,sha256=pAsZ4-g5IBQeCtG89D5xbGBsTL0gplSmNPj4-WRFrek,4110
13
+ reloop_email/services/contacts/channels.py,sha256=sNR2ZXG0VkaEZnecG6CluVAkPBbzlRu4wuQo5jFKgBQ,2030
14
+ reloop_email/services/contacts/groups.py,sha256=g9ABqZbAaDo-qDRf_Ws81U7OyYNuXwvYwB9QzRwerZI,1150
15
+ reloop_email-0.2.0.dist-info/METADATA,sha256=pfN0RcXvSXZcvbtfQnClF4FdwTuNNfqe_8Y-nJalfYk,3396
16
+ reloop_email-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
17
+ reloop_email-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any