diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7e6a0..51a316d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ nylas-python Changelog ====================== Unreleased ---------- +* Added Lists support (`Client.lists`, `/v3/lists`): list, create, find, update, and delete lists, plus `list_items`, `add_items`, and `remove_items` for `/v3/lists/{list_id}/items`, with typed request/response models in `nylas.models.lists` * Added Manage Domains (`Client.domains`, `/v3/admin/domains`): list, create, find, update, delete, `get_info`, and `verify` with models in `nylas.models.domains`; optional `ServiceAccountSigner` (`nylas.handler.service_account`) for service-account headers (`X-Nylas-Kid`, `X-Nylas-Nonce`, `X-Nylas-Timestamp`, `X-Nylas-Signature`) on each `Domains` method; new `cryptography` dependency, RSA signing, and `HttpClient` `serialized_json_body` so signed payloads match the wire body * Added Transactional Send: `Client.transactional_send.send()` for `POST /v3/domains/{domain_name}/messages/send`, with `TransactionalSendMessageRequest` and `TransactionalTemplate` models (JSON and multipart send behavior aligned with grant `messages.send`) * Added Policies support (`Client.policies`, `/v3/policies`): list, create, find, update, and delete, with typed request/response models in `nylas.models.policies` diff --git a/nylas/client.py b/nylas/client.py index 8bad36e..66ce84b 100644 --- a/nylas/client.py +++ b/nylas/client.py @@ -8,6 +8,7 @@ from nylas.resources.events import Events from nylas.resources.folders import Folders from nylas.resources.messages import Messages +from nylas.resources.lists import Lists from nylas.resources.threads import Threads from nylas.resources.transactional_send import TransactionalSend from nylas.resources.webhooks import Webhooks @@ -186,6 +187,16 @@ def messages(self) -> Messages: """ return Messages(self.http_client) + @property + def lists(self) -> Lists: + """ + Access the Lists API. + + Returns: + The Lists API. + """ + return Lists(self.http_client) + @property def threads(self) -> Threads: """ diff --git a/nylas/models/lists.py b/nylas/models/lists.py new file mode 100644 index 0000000..c4732e6 --- /dev/null +++ b/nylas/models/lists.py @@ -0,0 +1,69 @@ +from dataclasses import dataclass +from typing import List as TypingList, Literal, Optional + +from dataclasses_json import dataclass_json +from typing_extensions import NotRequired, TypedDict + +from nylas.models.list_query_params import ListQueryParams + +ListType = Literal["domain", "tld", "address"] + + +class ListListsQueryParams(ListQueryParams): + """Query parameters for listing lists.""" + + pass + + +class ListListItemsQueryParams(ListQueryParams): + """Query parameters for listing items in a list.""" + + pass + + +class CreateListRequest(TypedDict): + """Request body for creating a list.""" + + name: str + type: ListType + description: NotRequired[str] + + +class UpdateListRequest(TypedDict, total=False): + """Request body for updating a list.""" + + name: NotRequired[str] + description: NotRequired[str] + + +class UpdateListItemsRequest(TypedDict): + """Request body for adding/removing list items.""" + + items: TypingList[str] + + +@dataclass_json +@dataclass +class NylasList: + """A typed collection used in `in_list` rule conditions.""" + + id: Optional[str] = None + name: Optional[str] = None + description: Optional[str] = None + type: Optional[str] = None + items_count: Optional[int] = None + application_id: Optional[str] = None + organization_id: Optional[str] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None + + +@dataclass_json +@dataclass +class ListItem: + """A single value belonging to a Nylas list.""" + + id: Optional[str] = None + list_id: Optional[str] = None + value: Optional[str] = None + created_at: Optional[int] = None diff --git a/nylas/resources/lists.py b/nylas/resources/lists.py new file mode 100644 index 0000000..aa28cea --- /dev/null +++ b/nylas/resources/lists.py @@ -0,0 +1,126 @@ +from nylas.config import RequestOverrides +from nylas.handler.api_resources import ( + CreatableApiResource, + DestroyableApiResource, + FindableApiResource, + ListableApiResource, + UpdatableApiResource, +) +from nylas.models.lists import ( + CreateListRequest, + ListItem, + ListListItemsQueryParams, + ListListsQueryParams, + NylasList, + UpdateListItemsRequest, + UpdateListRequest, +) +from nylas.models.response import DeleteResponse, ListResponse, Response + + +class Lists( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """Nylas Lists API.""" + + def list( + self, + query_params: ListListsQueryParams = None, + overrides: RequestOverrides = None, + ) -> ListResponse[NylasList]: + """Return all lists for the application.""" + return super().list( + path="/v3/lists", + response_type=NylasList, + query_params=query_params, + overrides=overrides, + ) + + def create( + self, + request_body: CreateListRequest, + overrides: RequestOverrides = None, + ) -> Response[NylasList]: + """Create a new list.""" + return super().create( + path="/v3/lists", + request_body=request_body, + response_type=NylasList, + overrides=overrides, + ) + + def find(self, list_id: str, overrides: RequestOverrides = None) -> Response[NylasList]: + """Return a specific list by ID.""" + return super().find( + path=f"/v3/lists/{list_id}", + response_type=NylasList, + overrides=overrides, + ) + + def update( + self, + list_id: str, + request_body: UpdateListRequest, + overrides: RequestOverrides = None, + ) -> Response[NylasList]: + """Update a list by ID.""" + return super().update( + path=f"/v3/lists/{list_id}", + response_type=NylasList, + request_body=request_body, + method="PUT", + overrides=overrides, + ) + + def destroy(self, list_id: str, overrides: RequestOverrides = None) -> DeleteResponse: + """Delete a list by ID.""" + return super().destroy(path=f"/v3/lists/{list_id}", overrides=overrides) + + def list_items( + self, + list_id: str, + query_params: ListListItemsQueryParams = None, + overrides: RequestOverrides = None, + ) -> ListResponse[ListItem]: + """Return all items in a list.""" + return super().list( + path=f"/v3/lists/{list_id}/items", + response_type=ListItem, + query_params=query_params, + overrides=overrides, + ) + + def add_items( + self, + list_id: str, + request_body: UpdateListItemsRequest, + overrides: RequestOverrides = None, + ) -> Response[NylasList]: + """Add items to a list.""" + return super().create( + path=f"/v3/lists/{list_id}/items", + request_body=request_body, + response_type=NylasList, + overrides=overrides, + ) + + def remove_items( + self, + list_id: str, + request_body: UpdateListItemsRequest, + overrides: RequestOverrides = None, + ) -> Response[NylasList]: + """Remove items from a list.""" + json_response, headers = self._http_client._execute( + "DELETE", + f"/v3/lists/{list_id}/items", + None, + None, + request_body, + overrides=overrides, + ) + return Response.from_dict(json_response, NylasList, headers) diff --git a/tests/resources/test_lists.py b/tests/resources/test_lists.py new file mode 100644 index 0000000..168a3d0 --- /dev/null +++ b/tests/resources/test_lists.py @@ -0,0 +1,303 @@ +from unittest.mock import patch + +from nylas.models.lists import ListItem, NylasList +from nylas.resources.lists import Lists + + +class TestLists: + def test_list_deserialization(self): + list_json = { + "id": "list-123", + "name": "Blocked domains", + "description": "Known spam senders", + "type": "domain", + "items_count": 2, + "application_id": "app-123", + "organization_id": "org-123", + "created_at": 1712450952, + "updated_at": 1712451952, + } + + nylas_list = NylasList.from_dict(list_json) + + assert nylas_list.id == "list-123" + assert nylas_list.name == "Blocked domains" + assert nylas_list.description == "Known spam senders" + assert nylas_list.type == "domain" + assert nylas_list.items_count == 2 + assert nylas_list.application_id == "app-123" + assert nylas_list.organization_id == "org-123" + assert nylas_list.created_at == 1712450952 + assert nylas_list.updated_at == 1712451952 + + def test_list_item_deserialization(self): + item_json = { + "id": "item-123", + "list_id": "list-123", + "value": "spam-domain.com", + "created_at": 1712450952, + } + + item = ListItem.from_dict(item_json) + + assert item.id == "item-123" + assert item.list_id == "list-123" + assert item.value == "spam-domain.com" + assert item.created_at == 1712450952 + + def test_list_deserialization_with_minimal_fields(self): + nylas_list = NylasList.from_dict({"id": "list-123"}, infer_missing=True) + + assert nylas_list.id == "list-123" + assert nylas_list.name is None + assert nylas_list.description is None + assert nylas_list.type is None + assert nylas_list.items_count is None + + def test_list_item_deserialization_with_minimal_fields(self): + item = ListItem.from_dict({"id": "item-123"}, infer_missing=True) + + assert item.id == "item-123" + assert item.list_id is None + assert item.value is None + assert item.created_at is None + + def test_list_lists(self, http_client_list_response): + lists = Lists(http_client_list_response) + + lists.list() + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/lists", None, None, None, overrides=None + ) + + def test_list_lists_with_query_params(self, http_client_list_response): + lists = Lists(http_client_list_response) + + lists.list(query_params={"limit": 10, "page_token": "cursor-token"}) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/lists", + None, + {"limit": 10, "page_token": "cursor-token"}, + None, + overrides=None, + ) + + def test_create_list(self, http_client_response): + lists = Lists(http_client_response) + request_body = { + "name": "Blocked domains", + "description": "Known spam senders", + "type": "domain", + } + + lists.create(request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", "/v3/lists", None, None, request_body, overrides=None + ) + + def test_create_list_with_overrides(self, http_client_response): + lists = Lists(http_client_response) + request_body = {"name": "Allowed domains", "type": "domain"} + overrides = {"headers": {"X-Test": "value"}} + + lists.create(request_body=request_body, overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "POST", "/v3/lists", None, None, request_body, overrides=overrides + ) + + def test_find_list(self, http_client_response): + lists = Lists(http_client_response) + + lists.find(list_id="list-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/lists/list-123", None, None, None, overrides=None + ) + + def test_find_list_with_overrides(self, http_client_response): + lists = Lists(http_client_response) + overrides = {"headers": {"X-Test": "value"}} + + lists.find(list_id="list-123", overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/lists/list-123", None, None, None, overrides=overrides + ) + + def test_update_list(self, http_client_response): + lists = Lists(http_client_response) + request_body = {"name": "Updated blocked domains", "description": "Updated description"} + + lists.update(list_id="list-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "PUT", "/v3/lists/list-123", None, None, request_body, overrides=None + ) + + def test_update_list_with_overrides(self, http_client_response): + lists = Lists(http_client_response) + request_body = {"description": "Updated description"} + overrides = {"headers": {"X-Test": "value"}, "timeout": 42} + + lists.update(list_id="list-123", request_body=request_body, overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "PUT", "/v3/lists/list-123", None, None, request_body, overrides=overrides + ) + + def test_destroy_list(self, http_client_delete_response): + lists = Lists(http_client_delete_response) + + lists.destroy(list_id="list-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", "/v3/lists/list-123", None, None, None, overrides=None + ) + + def test_destroy_list_with_overrides(self, http_client_delete_response): + lists = Lists(http_client_delete_response) + overrides = {"headers": {"X-Test": "value"}} + + lists.destroy(list_id="list-123", overrides=overrides) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", "/v3/lists/list-123", None, None, None, overrides=overrides + ) + + def test_list_items(self, http_client_list_response): + lists = Lists(http_client_list_response) + + lists.list_items(list_id="list-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/lists/list-123/items", None, None, None, overrides=None + ) + + def test_list_items_with_query_params(self, http_client_list_response): + lists = Lists(http_client_list_response) + + lists.list_items(list_id="list-123", query_params={"limit": 50, "page_token": "next"}) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/lists/list-123/items", + None, + {"limit": 50, "page_token": "next"}, + None, + overrides=None, + ) + + def test_list_items_with_overrides(self, http_client_list_response): + lists = Lists(http_client_list_response) + overrides = {"headers": {"X-Test": "value"}} + + lists.list_items(list_id="list-123", overrides=overrides) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/lists/list-123/items", + None, + None, + None, + overrides=overrides, + ) + + def test_add_items(self, http_client_response): + lists = Lists(http_client_response) + request_body = {"items": ["spam-domain.com", "phishing-example.net"]} + + lists.add_items(list_id="list-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/lists/list-123/items", + None, + None, + request_body, + overrides=None, + ) + + def test_add_items_with_overrides(self, http_client_response): + lists = Lists(http_client_response) + request_body = {"items": ["trusted-domain.com"]} + overrides = {"headers": {"X-Test": "value"}} + + lists.add_items(list_id="list-123", request_body=request_body, overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/lists/list-123/items", + None, + None, + request_body, + overrides=overrides, + ) + + def test_remove_items(self, http_client_response): + lists = Lists(http_client_response) + request_body = {"items": ["spam-domain.com"]} + + lists.remove_items(list_id="list-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "DELETE", + "/v3/lists/list-123/items", + None, + None, + request_body, + overrides=None, + ) + + def test_remove_items_with_overrides(self, http_client_response): + lists = Lists(http_client_response) + request_body = {"items": ["spam-domain.com"]} + overrides = {"headers": {"X-Test": "value"}} + + lists.remove_items(list_id="list-123", request_body=request_body, overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "DELETE", + "/v3/lists/list-123/items", + None, + None, + request_body, + overrides=overrides, + ) + + def test_remove_items_deserializes_using_nylas_list(self, http_client): + lists = Lists(http_client) + request_body = {"items": ["spam-domain.com"]} + http_client._execute = lambda *args, **kwargs: ( + { + "request_id": "abc-123", + "data": { + "id": "list-123", + "name": "Blocked domains", + "type": "domain", + "items_count": 0, + }, + }, + {"X-Test-Header": "test"}, + ) + + with patch("nylas.resources.lists.Response.from_dict") as response_from_dict: + lists.remove_items(list_id="list-123", request_body=request_body) + + response_from_dict.assert_called_once_with( + { + "request_id": "abc-123", + "data": { + "id": "list-123", + "name": "Blocked domains", + "type": "domain", + "items_count": 0, + }, + }, + NylasList, + {"X-Test-Header": "test"}, + ) diff --git a/tests/test_client.py b/tests/test_client.py index b06769f..9b81f33 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,6 +11,7 @@ from nylas.resources.folders import Folders from nylas.resources.grants import Grants from nylas.resources.messages import Messages +from nylas.resources.lists import Lists from nylas.resources.policies import Policies from nylas.resources.rules import Rules from nylas.resources.threads import Threads @@ -91,6 +92,10 @@ def test_client_messages_property(self, client): assert client.messages is not None assert type(client.messages) is Messages + def test_client_lists_property(self, client): + assert client.lists is not None + assert type(client.lists) is Lists + def test_client_rules_property(self, client): assert client.rules is not None assert type(client.rules) is Rules