Skip to content

Commit 83cb0b2

Browse files
authored
feat: sync sdk contact book support (#373)
1 parent 33acd09 commit 83cb0b2

11 files changed

Lines changed: 3434 additions & 2802 deletions

File tree

apps/docs/api-reference/openapi.json

Lines changed: 2682 additions & 2554 deletions
Large diffs are not rendered by default.

packages/python-sdk/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ It is published as `usesend` on PyPI.
100100
## Available Resources
101101

102102
- **Emails**: `client.emails.send()`, `client.emails.get()`
103-
- **Contacts**: `client.contacts.create()`, `client.contacts.get()`, `client.contacts.list()`
103+
- **ContactBooks**: `client.contact_books.list()`, `client.contact_books.create()`, `client.contact_books.get()`, `client.contact_books.update()`
104+
- **Contacts**: `client.contacts.create()`, `client.contacts.list()`, `client.contacts.get()`, `client.contacts.bulk_create()`, `client.contacts.bulk_delete()`
104105
- **Domains**: `client.domains.create()`, `client.domains.get()`, `client.domains.verify()`
105106
- **Campaigns**: `client.campaigns.create()`, `client.campaigns.get()`, `client.campaigns.schedule()`, `client.campaigns.pause()`, `client.campaigns.resume()`
106107

packages/python-sdk/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "usesend"
3-
version = "0.2.9"
3+
version = "0.2.10"
44
description = "Python SDK for the UseSend API"
55
authors = ["UseSend"]
66
license = "MIT"
@@ -14,6 +14,7 @@ requests = "^2.32.0"
1414
typing_extensions = ">=4.7"
1515

1616
[tool.poetry.group.dev.dependencies]
17+
pytest = "^8.3.5"
1718

1819
[build-system]
1920
requires = ["poetry-core"]
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
from typing import Any, Dict, List, Optional
2+
3+
from usesend import UseSend
4+
5+
6+
class MockResponse:
7+
def __init__(self, payload: Dict[str, Any], ok: bool = True, reason: str = "OK") -> None:
8+
self._payload = payload
9+
self.ok = ok
10+
self.reason = reason
11+
self.status_code = 200 if ok else 400
12+
13+
def json(self) -> Dict[str, Any]:
14+
return self._payload
15+
16+
17+
class MockSession:
18+
def __init__(self, responses: List[MockResponse]) -> None:
19+
self._responses = responses
20+
self.calls: List[Dict[str, Any]] = []
21+
22+
def request(
23+
self,
24+
method: str,
25+
url: str,
26+
headers: Optional[Dict[str, str]] = None,
27+
json: Optional[Any] = None,
28+
) -> MockResponse:
29+
self.calls.append(
30+
{
31+
"method": method,
32+
"url": url,
33+
"headers": headers,
34+
"json": json,
35+
}
36+
)
37+
return self._responses.pop(0)
38+
39+
40+
def test_contact_books_list_uses_expected_path_and_returns_data() -> None:
41+
session = MockSession(
42+
[
43+
MockResponse(
44+
[
45+
{
46+
"id": "cb_123",
47+
"name": "Newsletter Subscribers",
48+
"teamId": 1,
49+
"properties": {},
50+
"variables": ["company"],
51+
"emoji": "📙",
52+
"doubleOptInEnabled": True,
53+
"doubleOptInFrom": "Newsletter <hello@example.com>",
54+
"doubleOptInSubject": "Please confirm your subscription",
55+
"doubleOptInContent": "{}",
56+
"createdAt": "2026-03-01T00:00:00.000Z",
57+
"updatedAt": "2026-03-01T00:00:00.000Z",
58+
"_count": {"contacts": 12},
59+
}
60+
]
61+
)
62+
]
63+
)
64+
client = UseSend("us_test", session=session)
65+
66+
data, err = client.contact_books.list()
67+
68+
assert err is None
69+
assert data is not None
70+
assert data[0]["variables"] == ["company"]
71+
assert session.calls[0]["method"] == "GET"
72+
assert session.calls[0]["url"].endswith("/api/v1/contactBooks")
73+
74+
75+
def test_contact_books_alias_matches_js_style_client() -> None:
76+
session = MockSession([MockResponse({"id": "cb_123", "name": "Book"})])
77+
client = UseSend("us_test", session=session)
78+
79+
data, err = client.contactBooks.get("cb_123")
80+
81+
assert err is None
82+
assert data is not None
83+
assert data["id"] == "cb_123"
84+
assert session.calls[0]["url"].endswith("/api/v1/contactBooks/cb_123")
85+
86+
87+
def test_contacts_list_encodes_query_params() -> None:
88+
session = MockSession([MockResponse([])])
89+
client = UseSend("us_test", session=session)
90+
91+
data, err = client.contacts.list(
92+
"cb_123",
93+
emails="a@example.com,b@example.com",
94+
page=2,
95+
limit=50,
96+
ids="ct_1,ct_2",
97+
)
98+
99+
assert err is None
100+
assert data == []
101+
assert session.calls[0]["method"] == "GET"
102+
assert session.calls[0]["url"].endswith(
103+
"/api/v1/contactBooks/cb_123/contacts?emails=a%40example.com%2Cb%40example.com&page=2&limit=50&ids=ct_1%2Cct_2"
104+
)
105+
106+
107+
def test_contacts_bulk_methods_use_expected_payloads() -> None:
108+
session = MockSession(
109+
[
110+
MockResponse({"message": "Contacts imported", "count": 2}),
111+
MockResponse({"success": True, "count": 2}),
112+
]
113+
)
114+
client = UseSend("us_test", session=session)
115+
116+
create_data, create_err = client.contacts.bulk_create(
117+
"cb_123",
118+
[
119+
{"email": "a@example.com"},
120+
{"email": "b@example.com", "firstName": "B"},
121+
],
122+
)
123+
delete_data, delete_err = client.contacts.bulk_delete(
124+
"cb_123",
125+
{"contactIds": ["ct_1", "ct_2"]},
126+
)
127+
128+
assert create_err is None
129+
assert create_data == {"message": "Contacts imported", "count": 2}
130+
assert delete_err is None
131+
assert delete_data == {"success": True, "count": 2}
132+
assert session.calls[0]["method"] == "POST"
133+
assert session.calls[0]["json"] == [
134+
{"email": "a@example.com"},
135+
{"email": "b@example.com", "firstName": "B"},
136+
]
137+
assert session.calls[1]["method"] == "DELETE"
138+
assert session.calls[1]["json"] == {"contactIds": ["ct_1", "ct_2"]}

packages/python-sdk/usesend/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Python client for the UseSend API."""
22

33
from .usesend import UseSend, UseSendHTTPError
4+
from .contacts import Contacts # type: ignore
5+
from .contact_books import ContactBooks # type: ignore
46
from .domains import Domains # type: ignore
57
from .campaigns import Campaigns # type: ignore
68
from .webhooks import (
@@ -17,6 +19,8 @@
1719
"UseSend",
1820
"UseSendHTTPError",
1921
"types",
22+
"Contacts",
23+
"ContactBooks",
2024
"Domains",
2125
"Campaigns",
2226
"Webhooks",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Contact book resource client using TypedDict shapes (no Pydantic)."""
2+
from __future__ import annotations
3+
4+
from typing import Optional, Tuple, List
5+
6+
from .types import (
7+
APIError,
8+
ContactBook,
9+
ContactBookCreate,
10+
ContactBookCreateResponse,
11+
ContactBookDeleteResponse,
12+
ContactBookUpdate,
13+
ContactBookUpdateResponse,
14+
)
15+
16+
17+
class ContactBooks:
18+
"""Client for `/contactBooks` endpoints."""
19+
20+
def __init__(self, usesend: "UseSend") -> None:
21+
self.usesend = usesend
22+
23+
def list(self) -> Tuple[Optional[List[ContactBook]], Optional[APIError]]:
24+
data, err = self.usesend.get("/contactBooks")
25+
return (data, err) # type: ignore[return-value]
26+
27+
def create(
28+
self, payload: ContactBookCreate
29+
) -> Tuple[Optional[ContactBookCreateResponse], Optional[APIError]]:
30+
data, err = self.usesend.post("/contactBooks", payload)
31+
return (data, err) # type: ignore[return-value]
32+
33+
def get(self, contact_book_id: str) -> Tuple[Optional[ContactBook], Optional[APIError]]:
34+
data, err = self.usesend.get(f"/contactBooks/{contact_book_id}")
35+
return (data, err) # type: ignore[return-value]
36+
37+
def update(
38+
self, contact_book_id: str, payload: ContactBookUpdate
39+
) -> Tuple[Optional[ContactBookUpdateResponse], Optional[APIError]]:
40+
data, err = self.usesend.patch(f"/contactBooks/{contact_book_id}", payload)
41+
return (data, err) # type: ignore[return-value]
42+
43+
def delete(
44+
self, contact_book_id: str
45+
) -> Tuple[Optional[ContactBookDeleteResponse], Optional[APIError]]:
46+
data, err = self.usesend.delete(f"/contactBooks/{contact_book_id}")
47+
return (data, err) # type: ignore[return-value]
48+
49+
50+
from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position

packages/python-sdk/usesend/contacts.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22
from __future__ import annotations
33

44
from typing import Any, Dict, Optional, Tuple
5+
from urllib.parse import urlencode
56

67
from .types import (
78
APIError,
89
ContactDeleteResponse,
910
Contact,
11+
ContactBulkCreate,
12+
ContactBulkCreateResponse,
13+
ContactBulkDelete,
14+
ContactBulkDeleteResponse,
15+
ContactList,
1016
ContactUpdate,
1117
ContactUpdateResponse,
1218
ContactUpsert,
@@ -31,6 +37,32 @@ def create(
3137
)
3238
return (data, err) # type: ignore[return-value]
3339

40+
def list(
41+
self,
42+
book_id: str,
43+
*,
44+
emails: Optional[str] = None,
45+
page: Optional[int] = None,
46+
limit: Optional[int] = None,
47+
ids: Optional[str] = None,
48+
) -> Tuple[Optional[ContactList], Optional[APIError]]:
49+
query: Dict[str, Any] = {}
50+
if emails is not None:
51+
query["emails"] = emails
52+
if page is not None:
53+
query["page"] = page
54+
if limit is not None:
55+
query["limit"] = limit
56+
if ids is not None:
57+
query["ids"] = ids
58+
59+
path = f"/contactBooks/{book_id}/contacts"
60+
if query:
61+
path = f"{path}?{urlencode(query)}"
62+
63+
data, err = self.usesend.get(path)
64+
return (data, err) # type: ignore[return-value]
65+
3466
def get(
3567
self, book_id: str, contact_id: str
3668
) -> Tuple[Optional[Contact], Optional[APIError]]:
@@ -57,6 +89,24 @@ def upsert(
5789
)
5890
return (data, err) # type: ignore[return-value]
5991

92+
def bulk_create(
93+
self, book_id: str, payload: ContactBulkCreate
94+
) -> Tuple[Optional[ContactBulkCreateResponse], Optional[APIError]]:
95+
data, err = self.usesend.post(
96+
f"/contactBooks/{book_id}/contacts/bulk",
97+
payload,
98+
)
99+
return (data, err) # type: ignore[return-value]
100+
101+
def bulk_delete(
102+
self, book_id: str, payload: ContactBulkDelete
103+
) -> Tuple[Optional[ContactBulkDeleteResponse], Optional[APIError]]:
104+
data, err = self.usesend.delete(
105+
f"/contactBooks/{book_id}/contacts/bulk",
106+
payload,
107+
)
108+
return (data, err) # type: ignore[return-value]
109+
60110
def delete(
61111
self, *, book_id: str, contact_id: str
62112
) -> Tuple[Optional[ContactDeleteResponse], Optional[APIError]]:

packages/python-sdk/usesend/types.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,65 @@ class EmailCancelResponse(TypedDict, total=False):
271271
# ---------------------------------------------------------------------------
272272

273273

274+
class ContactBookCounts(TypedDict, total=False):
275+
contacts: int
276+
277+
278+
class ContactBook(TypedDict, total=False):
279+
id: str
280+
name: str
281+
teamId: float
282+
properties: Dict[str, str]
283+
variables: List[str]
284+
emoji: str
285+
doubleOptInEnabled: Optional[bool]
286+
doubleOptInFrom: Optional[str]
287+
doubleOptInSubject: Optional[str]
288+
doubleOptInContent: Optional[str]
289+
createdAt: str
290+
updatedAt: str
291+
_count: ContactBookCounts
292+
293+
294+
ContactBookList = List[ContactBook]
295+
296+
297+
class ContactBookCreate(TypedDict, total=False):
298+
name: str
299+
emoji: Optional[str]
300+
properties: Optional[Dict[str, str]]
301+
doubleOptInEnabled: Optional[bool]
302+
doubleOptInFrom: Optional[str]
303+
doubleOptInSubject: Optional[str]
304+
doubleOptInContent: Optional[str]
305+
variables: Optional[List[str]]
306+
307+
308+
class ContactBookCreateResponse(ContactBook, total=False):
309+
pass
310+
311+
312+
class ContactBookUpdate(TypedDict, total=False):
313+
name: Optional[str]
314+
emoji: Optional[str]
315+
properties: Optional[Dict[str, str]]
316+
doubleOptInEnabled: Optional[bool]
317+
doubleOptInFrom: Optional[str]
318+
doubleOptInSubject: Optional[str]
319+
doubleOptInContent: Optional[str]
320+
variables: Optional[List[str]]
321+
322+
323+
class ContactBookUpdateResponse(ContactBook, total=False):
324+
pass
325+
326+
327+
class ContactBookDeleteResponse(TypedDict):
328+
id: str
329+
success: bool
330+
message: str
331+
332+
274333
class ContactCreate(TypedDict, total=False):
275334
email: str
276335
firstName: Optional[str]
@@ -298,6 +357,23 @@ class ContactListItem(TypedDict, total=False):
298357
ContactList = List[ContactListItem]
299358

300359

360+
ContactBulkCreate = List[ContactCreate]
361+
362+
363+
class ContactBulkCreateResponse(TypedDict):
364+
message: str
365+
count: float
366+
367+
368+
class ContactBulkDelete(TypedDict):
369+
contactIds: List[str]
370+
371+
372+
class ContactBulkDeleteResponse(TypedDict):
373+
success: bool
374+
count: float
375+
376+
301377
class ContactUpdate(TypedDict, total=False):
302378
firstName: Optional[str]
303379
lastName: Optional[str]

0 commit comments

Comments
 (0)