Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions resend/contacts/_contacts.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any, Dict, List, Optional, cast
from urllib.parse import quote

from typing_extensions import NotRequired, TypedDict

Expand Down Expand Up @@ -238,14 +239,15 @@ def update(cls, params: UpdateParams) -> UpdateContactResponse:
contact_identifier = (
params.get("email") if params.get("email") is not None else params.get("id")
)
encoded_contact_identifier = quote(cast(str, contact_identifier), safe="")
audience_id = params.get("audience_id")

if audience_id:
# Audience-specific contact (no properties support)
path = f"/audiences/{audience_id}/contacts/{contact_identifier}"
path = f"/audiences/{audience_id}/contacts/{encoded_contact_identifier}"
else:
# Global contact (supports properties)
path = f"/contacts/{contact_identifier}"
path = f"/contacts/{encoded_contact_identifier}"

resp = request.Request[Contacts.UpdateContactResponse](
path=path, params=cast(Dict[Any, Any], params), verb="patch"
Expand Down Expand Up @@ -316,12 +318,13 @@ def get(
if contact_identifier is None:
raise ValueError("id or email must be provided")

encoded_contact_identifier = quote(contact_identifier, safe="")
if audience_id:
# Audience-specific contact
path = f"/audiences/{audience_id}/contacts/{contact_identifier}"
path = f"/audiences/{audience_id}/contacts/{encoded_contact_identifier}"
else:
# Global contact
path = f"/contacts/{contact_identifier}"
path = f"/contacts/{encoded_contact_identifier}"

resp = request.Request[Contact](
path=path, params={}, verb="get"
Expand Down Expand Up @@ -353,12 +356,13 @@ def remove(
if contact_identifier is None:
raise ValueError("id or email must be provided")

encoded_contact_identifier = quote(contact_identifier, safe="")
if audience_id:
# Audience-specific contact
path = f"/audiences/{audience_id}/contacts/{contact_identifier}"
path = f"/audiences/{audience_id}/contacts/{encoded_contact_identifier}"
else:
# Global contact
path = f"/contacts/{contact_identifier}"
path = f"/contacts/{encoded_contact_identifier}"

resp = request.Request[Contacts.RemoveContactResponse](
path=path, params={}, verb="delete"
Expand Down Expand Up @@ -414,12 +418,13 @@ async def update_async(cls, params: UpdateParams) -> UpdateContactResponse:
contact_identifier = (
params.get("email") if params.get("email") is not None else params.get("id")
)
encoded_contact_identifier = quote(cast(str, contact_identifier), safe="")
audience_id = params.get("audience_id")

if audience_id:
path = f"/audiences/{audience_id}/contacts/{contact_identifier}"
path = f"/audiences/{audience_id}/contacts/{encoded_contact_identifier}"
else:
path = f"/contacts/{contact_identifier}"
path = f"/contacts/{encoded_contact_identifier}"

resp = await AsyncRequest[Contacts.UpdateContactResponse](
path=path, params=cast(Dict[Any, Any], params), verb="patch"
Expand Down Expand Up @@ -486,10 +491,11 @@ async def get_async(
if contact_identifier is None:
raise ValueError("id or email must be provided")

encoded_contact_identifier = quote(contact_identifier, safe="")
if audience_id:
path = f"/audiences/{audience_id}/contacts/{contact_identifier}"
path = f"/audiences/{audience_id}/contacts/{encoded_contact_identifier}"
else:
path = f"/contacts/{contact_identifier}"
path = f"/contacts/{encoded_contact_identifier}"

resp = await AsyncRequest[Contact](
path=path, params={}, verb="get"
Expand Down Expand Up @@ -520,10 +526,11 @@ async def remove_async(
if contact_identifier is None:
raise ValueError("id or email must be provided")

encoded_contact_identifier = quote(contact_identifier, safe="")
if audience_id:
path = f"/audiences/{audience_id}/contacts/{contact_identifier}"
path = f"/audiences/{audience_id}/contacts/{encoded_contact_identifier}"
else:
path = f"/contacts/{contact_identifier}"
path = f"/contacts/{encoded_contact_identifier}"

resp = await AsyncRequest[Contacts.RemoveContactResponse](
path=path, params={}, verb="delete"
Expand Down
13 changes: 9 additions & 4 deletions resend/contacts/_topics.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any, Dict, List, Optional, Union, cast
from urllib.parse import quote

from typing_extensions import NotRequired, TypedDict

Expand Down Expand Up @@ -140,7 +141,8 @@ def list(
if contact is None:
raise ValueError("contact_id or email must be provided")

base_path = f"/contacts/{contact}/topics"
encoded_contact = quote(contact, safe="")
base_path = f"/contacts/{encoded_contact}/topics"
query_params = cast(Dict[Any, Any], params) if params else None
path = PaginationHelper.build_paginated_path(base_path, query_params)
resp = request.Request[_ListResponse](
Expand Down Expand Up @@ -172,7 +174,8 @@ def update(cls, params: UpdateParams) -> UpdateResponse:
contact = (
params.get("id") if params.get("id") is not None else params.get("email")
)
path = f"/contacts/{contact}/topics"
encoded_contact = quote(cast(str, contact), safe="")
path = f"/contacts/{encoded_contact}/topics"

# Send the topics array directly as the request body (not wrapped in an object)
# The Request class accepts Union[Dict, List] as params
Expand Down Expand Up @@ -211,7 +214,8 @@ async def list_async(
if contact is None:
raise ValueError("contact_id or email must be provided")

base_path = f"/contacts/{contact}/topics"
encoded_contact = quote(contact, safe="")
base_path = f"/contacts/{encoded_contact}/topics"
query_params = cast(Dict[Any, Any], params) if params else None
path = PaginationHelper.build_paginated_path(base_path, query_params)
resp = await AsyncRequest[_ListResponse](
Expand Down Expand Up @@ -240,7 +244,8 @@ async def update_async(cls, params: UpdateParams) -> UpdateResponse:
contact = (
params.get("id") if params.get("id") is not None else params.get("email")
)
path = f"/contacts/{contact}/topics"
encoded_contact = quote(cast(str, contact), safe="")
path = f"/contacts/{encoded_contact}/topics"

request_body: Union[Dict[str, Any], List[Dict[str, Any]]] = cast(
List[Dict[str, Any]], params["topics"]
Expand Down
19 changes: 13 additions & 6 deletions resend/contacts/segments/_contact_segments.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any, Dict, List, Optional, cast
from urllib.parse import quote

from typing_extensions import NotRequired, TypedDict

Expand Down Expand Up @@ -185,7 +186,8 @@ def add(cls, params: AddParams) -> AddContactSegmentResponse:
raise ValueError("Either contact_id or email must be provided")

segment_id = params["segment_id"]
path = f"/contacts/{contact_identifier}/segments/{segment_id}"
encoded_contact_identifier = quote(contact_identifier, safe="")
path = f"/contacts/{encoded_contact_identifier}/segments/{segment_id}"
resp = request.Request[ContactSegments.AddContactSegmentResponse](
path=path, params=cast(Dict[Any, Any], params), verb="post"
).perform_with_content()
Expand All @@ -210,7 +212,8 @@ def remove(cls, params: RemoveParams) -> RemoveContactSegmentResponse:
raise ValueError("Either contact_id or email must be provided")

segment_id = params["segment_id"]
path = f"/contacts/{contact_identifier}/segments/{segment_id}"
encoded_contact_identifier = quote(contact_identifier, safe="")
path = f"/contacts/{encoded_contact_identifier}/segments/{segment_id}"
resp = request.Request[ContactSegments.RemoveContactSegmentResponse](
path=path, params={}, verb="delete"
).perform_with_content()
Expand All @@ -237,7 +240,8 @@ def list(
if not contact_identifier:
raise ValueError("Either contact_id or email must be provided")

base_path = f"/contacts/{contact_identifier}/segments"
encoded_contact_identifier = quote(contact_identifier, safe="")
base_path = f"/contacts/{encoded_contact_identifier}/segments"
query_params = cast(Dict[Any, Any], pagination) if pagination else None
path = PaginationHelper.build_paginated_path(base_path, query_params)
resp = request.Request[ContactSegments.ListContactSegmentsResponse](
Expand All @@ -264,7 +268,8 @@ async def add_async(cls, params: AddParams) -> AddContactSegmentResponse:
raise ValueError("Either contact_id or email must be provided")

segment_id = params["segment_id"]
path = f"/contacts/{contact_identifier}/segments/{segment_id}"
encoded_contact_identifier = quote(contact_identifier, safe="")
path = f"/contacts/{encoded_contact_identifier}/segments/{segment_id}"
resp = await AsyncRequest[ContactSegments.AddContactSegmentResponse](
path=path, params=cast(Dict[Any, Any], params), verb="post"
).perform_with_content()
Expand All @@ -289,7 +294,8 @@ async def remove_async(cls, params: RemoveParams) -> RemoveContactSegmentRespons
raise ValueError("Either contact_id or email must be provided")

segment_id = params["segment_id"]
path = f"/contacts/{contact_identifier}/segments/{segment_id}"
encoded_contact_identifier = quote(contact_identifier, safe="")
path = f"/contacts/{encoded_contact_identifier}/segments/{segment_id}"
resp = await AsyncRequest[ContactSegments.RemoveContactSegmentResponse](
path=path, params={}, verb="delete"
).perform_with_content()
Expand All @@ -316,7 +322,8 @@ async def list_async(
if not contact_identifier:
raise ValueError("Either contact_id or email must be provided")

base_path = f"/contacts/{contact_identifier}/segments"
encoded_contact_identifier = quote(contact_identifier, safe="")
base_path = f"/contacts/{encoded_contact_identifier}/segments"
query_params = cast(Dict[Any, Any], pagination) if pagination else None
path = PaginationHelper.build_paginated_path(base_path, query_params)
resp = await AsyncRequest[ContactSegments.ListContactSegmentsResponse](
Expand Down
40 changes: 40 additions & 0 deletions tests/contact_topics_async_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@ async def test_contact_topics_list_async_by_email(self) -> None:
assert response["has_more"] is False
assert response["data"][0]["id"] == "topic_123"

async def test_contact_topics_list_async_encodes_email_identifier(self) -> None:
self.set_mock_json(
{
"object": "list",
"has_more": False,
"data": [],
}
)

response: ContactsTopics.ListResponse = await resend.Contacts.Topics.list_async(
email="team/a?b@example.com"
)
assert response["has_more"] is False
assert (
self.mock.call_args.kwargs["url"]
== "https://api.resend.com/contacts/team%2Fa%3Fb%40example.com/topics"
)

async def test_contact_topics_list_async_raises_when_no_contact_identifier(
self,
) -> None:
Expand Down Expand Up @@ -113,6 +131,28 @@ async def test_contact_topics_update_async_by_email(self) -> None:
)
assert response["id"] == "cont_456"

async def test_contact_topics_update_async_encodes_email_identifier(self) -> None:
self.set_mock_json(
{
"id": "cont_456",
}
)

params: ContactsTopics.UpdateParams = {
"email": "team/a?b@example.com",
"topics": [
{"id": "topic_1", "subscription": "opt_in"},
],
}
response: ContactsTopics.UpdateResponse = (
await resend.Contacts.Topics.update_async(params)
)
assert response["id"] == "cont_456"
assert (
self.mock.call_args.kwargs["url"]
== "https://api.resend.com/contacts/team%2Fa%3Fb%40example.com/topics"
)

async def test_contact_topics_update_async_raises_when_no_contact_identifier(
self,
) -> None:
Expand Down
38 changes: 38 additions & 0 deletions tests/contact_topics_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ def test_contact_topics_list_by_email(self) -> None:
assert response["has_more"] is False
assert response["data"][0]["id"] == "topic_123"

def test_contact_topics_list_encodes_email_identifier(self) -> None:
self.set_mock_json(
{
"object": "list",
"has_more": False,
"data": [],
}
)

response: ContactsTopics.ListResponse = resend.Contacts.Topics.list(
email="team/a?b@example.com"
)
assert response["has_more"] is False
assert (
self.mock.call_args.kwargs["url"]
== "https://api.resend.com/contacts/team%2Fa%3Fb%40example.com/topics"
)

def test_contact_topics_list_with_pagination(self) -> None:
self.set_mock_json(
{
Expand Down Expand Up @@ -136,6 +154,26 @@ def test_contact_topics_update_by_email(self) -> None:
response: ContactsTopics.UpdateResponse = resend.Contacts.Topics.update(params)
assert response["id"] == "cont_456"

def test_contact_topics_update_encodes_email_identifier(self) -> None:
self.set_mock_json(
{
"id": "cont_456",
}
)

params: ContactsTopics.UpdateParams = {
"email": "team/a?b@example.com",
"topics": [
{"id": "topic_1", "subscription": "opt_in"},
],
}
response: ContactsTopics.UpdateResponse = resend.Contacts.Topics.update(params)
assert response["id"] == "cont_456"
assert (
self.mock.call_args.kwargs["url"]
== "https://api.resend.com/contacts/team%2Fa%3Fb%40example.com/topics"
)

def test_contact_topics_update_raises_when_no_contact_identifier(self) -> None:
resend.api_key = "re_123"

Expand Down
53 changes: 53 additions & 0 deletions tests/contacts_async_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,25 @@ async def test_contacts_update_async(self) -> None:
contact = await resend.Contacts.update_async(params)
assert contact["id"] == "479e3145-dd38-476b-932c-529ceb705947"

async def test_contacts_update_async_encodes_email_identifier(self) -> None:
self.set_mock_json(
{
"object": "contact",
"id": "global-contact-123",
}
)

params: resend.Contacts.UpdateParams = {
"email": "team/a?b@example.com",
"first_name": "Updated Global",
}
contact = await resend.Contacts.update_async(params)
assert contact["id"] == "global-contact-123"
assert (
self.mock.call_args.kwargs["url"]
== "https://api.resend.com/contacts/team%2Fa%3Fb%40example.com"
)

async def test_contacts_update_async_missing_required_params(self) -> None:
params: resend.Contacts.UpdateParams = {
"audience_id": "48c269ed-9873-4d60-bdd9-cd7e6fc0b9b8",
Expand Down Expand Up @@ -131,6 +150,24 @@ async def test_contacts_get_async_by_email(self) -> None:
assert contact["created_at"] == "2023-10-06T23:47:56.678Z"
assert contact["unsubscribed"] is False

async def test_contacts_get_async_encodes_email_identifier(self) -> None:
self.set_mock_json(
{
"object": "contact",
"id": "e169aa45-1ecf-4183-9955-b1499d5701d3",
"email": "team/a?b@example.com",
"created_at": "2023-10-06T23:47:56.678Z",
"unsubscribed": False,
}
)

contact = await resend.Contacts.get_async(email="team/a?b@example.com")
assert contact["email"] == "team/a?b@example.com"
assert (
self.mock.call_args.kwargs["url"]
== "https://api.resend.com/contacts/team%2Fa%3Fb%40example.com"
)

async def test_contacts_get_async_raises(self) -> None:
resend.api_key = "re_123"

Expand Down Expand Up @@ -193,6 +230,22 @@ async def test_contacts_remove_async_by_email(self) -> None:
assert rmed["id"] == "520784e2-887d-4c25-b53c-4ad46ad38100"
assert rmed["deleted"] is True

async def test_contacts_remove_async_encodes_email_identifier(self) -> None:
self.set_mock_json(
{
"object": "contact",
"id": "520784e2-887d-4c25-b53c-4ad46ad38100",
"deleted": True,
}
)

rmed = await resend.Contacts.remove_async(email="team/a?b@example.com")
assert rmed["deleted"] is True
assert (
self.mock.call_args.kwargs["url"]
== "https://api.resend.com/contacts/team%2Fa%3Fb%40example.com"
)

async def test_should_remove_contacts_async_by_email_raise_exception_when_no_content(
self,
) -> None:
Expand Down
Loading
Loading