From 8ed44e71714c6de86a5844be2089f25415376745 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 25 Nov 2021 10:38:51 -0500 Subject: [PATCH 001/186] [73524] Metadata Support for Calendars, Accounts, and Messages (#177) --- CHANGELOG.md | 1 + nylas/client/restful_models.py | 14 ++++++++++++-- tests/conftest.py | 15 +++++++++++++++ tests/test_accounts.py | 9 +++++++++ tests/test_messages.py | 8 ++++++++ 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54483845..e20b550d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Unreleased (dev) ---------------- * Add support for Event notifications * Add support for Component CRUD +* Add metadata support for `Calendar`, `Message` and `Account` * Improve error details returned from the API v5.2.0 diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 9e53382e..1a60c4fd 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -184,6 +184,7 @@ class Message(NylasAPIObject): "to", "unread", "starred", + "metadata", "_folder", "_labels", "headers", @@ -602,7 +603,15 @@ def get_picture(self): class Calendar(NylasAPIObject): - attrs = ["id", "account_id", "name", "description", "read_only", "object"] + attrs = [ + "id", + "account_id", + "name", + "description", + "metadata", + "read_only", + "object", + ] collection_name = "calendars" def __init__(self, api): @@ -773,6 +782,7 @@ class Account(NylasAPIObject): "provider", "sync_state", "trial", + "metadata", ] collection_name = "accounts" @@ -781,7 +791,7 @@ def __init__(self, api): NylasAPIObject.__init__(self, Account, api) def as_json(self): - dct = NylasAPIObject.as_json(self) + dct = {"metadata": self.metadata} return dct def upgrade(self): diff --git a/tests/conftest.py b/tests/conftest.py index 62500efd..3e9fb7ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -173,6 +173,13 @@ def list_callback(request): return (200, {}, json.dumps([])) return (200, {}, json.dumps(accounts)) + def update_callback(request): + response = accounts[0] + payload = json.loads(request.body) + if payload["metadata"]: + response["metadata"] = payload["metadata"] + return 200, {}, json.dumps(response) + url_re = "{base}(/a/{client_id})?/accounts/?".format( base=api_url, client_id=client_id ) @@ -182,6 +189,12 @@ def list_callback(request): content_type="application/json", callback=list_callback, ) + mocked_responses.add_callback( + responses.PUT, + re.compile(url_re), + content_type="application/json", + callback=update_callback, + ) @pytest.fixture @@ -391,6 +404,8 @@ def request_callback(request): for l in payload["labels"] ] base_msg["labels"] = labels + if "metadata" in payload: + base_msg["metadata"] = payload["metadata"] return (200, {}, json.dumps(base_msg)) endpoint = re.compile(api_url + "/messages/1234") diff --git a/tests/test_accounts.py b/tests/test_accounts.py index 0957a905..882e3147 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -87,3 +87,12 @@ def test_account_access(api_client): account3 = api_client.accounts.first() assert isinstance(account3, APIAccount) assert account1.as_json() == account2.as_json() == account3.as_json() + + +@pytest.mark.usefixtures("mock_accounts") +def test_account_metadata(api_client_with_client_id, monkeypatch): + monkeypatch.setattr(api_client_with_client_id, "is_opensource_api", lambda: False) + account1 = api_client_with_client_id.accounts[0] + account1["metadata"] = {"test": "value"} + account1.save() + assert account1["metadata"] == {"test": "value"} diff --git a/tests/test_messages.py b/tests/test_messages.py index 8fc3ce9c..c5481b00 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -144,3 +144,11 @@ def test_filter_messages_ts(mocked_responses, api_client): request = mocked_responses.calls[0].request url = URLObject(request.url) assert url.query_dict["received_before"] == "1275350400" + + +@pytest.mark.usefixtures("mock_message", "mock_messages") +def test_message_metadata(mocked_responses, api_client): + message = api_client.messages.first() + message["metadata"] = {"test": "value"} + message.save() + assert message.metadata == {"test": "value"} From f412d738f0a93534233e7492260a52db0f958d83 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 26 Nov 2021 12:46:17 -0500 Subject: [PATCH 002/186] [69624] Scheduler API Support (#173) This PR enables SDK support for the Scheduler API. --- CHANGELOG.md | 1 + nylas/client/client.py | 51 +++- nylas/client/restful_models.py | 43 +++ nylas/client/scheduler_models.py | 59 ++++ .../scheduler_restful_model_collection.py | 65 ++++ tests/conftest.py | 264 ++++++++++++++++ tests/test_scheduler.py | 287 ++++++++++++++++++ 7 files changed, 756 insertions(+), 14 deletions(-) create mode 100644 nylas/client/scheduler_models.py create mode 100644 nylas/client/scheduler_restful_model_collection.py create mode 100644 tests/test_scheduler.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e20b550d..e8c75fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased (dev) ---------------- +* Add support for Scheduler API * Add support for Event notifications * Add support for Component CRUD * Add metadata support for `Calendar`, `Message` and `Account` diff --git a/nylas/client/client.py b/nylas/client/client.py index d7d68065..8f970ff7 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -1,4 +1,5 @@ from __future__ import print_function + import sys from os import environ from base64 import b64encode @@ -31,6 +32,9 @@ Component, ) from nylas.client.neural_api_models import Neural +from nylas.client.scheduler_restful_model_collection import ( + SchedulerRestfulModelCollection, +) from nylas.utils import timestamp_from_dt, create_request_body DEBUG = environ.get("NYLAS_CLIENT_DEBUG") @@ -421,6 +425,10 @@ def room_resources(self): def calendars(self): return RestfulModelCollection(Calendar, self) + @property + def scheduler(self): + return SchedulerRestfulModelCollection(self) + @property def components(self): return RestfulModelCollection(Component, self) @@ -466,11 +474,13 @@ def _get_resources(self, cls, extra=None, **filters): return [cls.create(self, **x) for x in results if x is not None] def _get_resource_raw( - self, cls, id, extra=None, headers=None, stream=False, **filters + self, cls, id, extra=None, headers=None, stream=False, path=None, **filters ): """Get an individual REST resource""" + if path is None: + path = cls.collection_name postfix = "/{}".format(extra) if extra else "" - path = "/{}".format(cls.collection_name) if cls.collection_name else "" + path = "/{}".format(path) if path else "" id = "/{}".format(id) if id else "" if not cls.api_root: url = "{server}{path}{id}{postfix}".format( @@ -578,16 +588,20 @@ def _delete_resource(self, cls, id, data=None, **kwargs): else: _validate(session.delete(url)) - def _update_resource(self, cls, id, data, **kwargs): + def _put_resource(self, cls, id, data, extra=None, path=None, **kwargs): + if path is None: + path = cls.collection_name name = "{prefix}{path}".format( prefix="/{}/{}".format(cls.api_root, self.client_id) if cls.api_root else "", - path="/{}".format(cls.collection_name) if cls.collection_name else "", + path="/{}".format(path) if path else "", ) + + postfix = "/{}".format(extra) if extra else "" url = ( URLObject(self.api_server) - .with_path("{name}/{id}".format(name=name, id=id)) + .with_path("{name}/{id}{postfix}".format(name=name, id=id, postfix=postfix)) .set_query_params(**kwargs) ) @@ -596,17 +610,20 @@ def _update_resource(self, cls, id, data, **kwargs): converted_data = create_request_body(data, cls.datetime_attrs) response = session.put(url, json=converted_data) - result = _validate(response).json() - return cls.create(self, **result) + result = _validate(response) + return result.json() - def _call_resource_method(self, cls, id, method_name, data): - """POST a dictionary to an API method, - for example /a/.../accounts/id/upgrade""" + def _update_resource(self, cls, id, data, **kwargs): + result = self._put_resource(cls, id, data, kwargs) + return cls.create(self, **result) - path = "/{}".format(cls.collection_name) if cls.collection_name else "" + def _post_resource(self, cls, id, method_name, data, path=None): + if path is None: + path = cls.collection_name + path = "/{}".format(path) if path else "" if not cls.api_root: - url_path = "/{name}/{id}/{method}".format( - name=cls.collection_name, id=id, method=method_name + url_path = "{name}/{id}/{method}".format( + name=path, id=id, method=method_name ) else: # Management method. @@ -624,7 +641,13 @@ def _call_resource_method(self, cls, id, method_name, data): session = self._get_http_session(cls.api_root) response = session.post(url, json=converted_data) - result = _validate(response).json() + return _validate(response).json() + + def _call_resource_method(self, cls, id, method_name, data): + """POST a dictionary to an API method, + for example /a/.../accounts/id/upgrade""" + + result = self._post_resource(cls, id, method_name, data) return cls.create(self, **result) def _request_neural_resource(self, cls, data, path=None, method="PUT"): diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 1a60c4fd..13aaf454 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -715,6 +715,49 @@ def __init__(self, api): NylasAPIObject.__init__(self, RoomResource, api) +class Scheduler(NylasAPIObject): + attrs = [ + "id", + "access_tokens", + "app_client_id", + "app_organization_id", + "config", + "edit_token", + "name", + "slug", + ] + date_attrs = { + "created_at": "created_at", + "modified_at": "modified_at", + } + collection_name = "manage/pages" + + def __init__(self, api): + NylasAPIObject.__init__(self, Scheduler, api) + + def get_available_calendars(self): + if not self.id: + raise ValueError("Cannot get calendars for a page without an ID") + + response = self.api._get_resource_raw(Scheduler, self.id, extra="calendars") + response_body = response.json() + for body in response_body: + for i in range(len(body["calendars"])): + body["calendars"][i] = Calendar.create(self.api, **body["calendars"][i]) + + return response_body + + def upload_image(self, content_type, object_name): + if not self.id: + raise ValueError("Cannot upload an image to a page without an ID") + + data = {"contentType": content_type, "objectName": object_name} + response = self.api._put_resource( + Scheduler, self.id, data, extra="upload-image" + ) + return response + + class Component(NylasAPIObject): attrs = [ "id", diff --git a/nylas/client/scheduler_models.py b/nylas/client/scheduler_models.py new file mode 100644 index 00000000..14a47470 --- /dev/null +++ b/nylas/client/scheduler_models.py @@ -0,0 +1,59 @@ +from nylas.client.restful_models import RestfulModel + + +class SchedulerTimeSlot(RestfulModel): + attrs = ["account_id", "calendar_id", "host_name", "emails"] + datetime_attrs = {"start": "start", "end": "end"} + + def __init__(self, api): + RestfulModel.__init__(self, SchedulerTimeSlot, api) + + +class SchedulerBookingConfirmation(RestfulModel): + attrs = [ + "id", + "account_id", + "additional_field_values", + "calendar_event_id", + "calendar_id", + "edit_hash", + "is_confirmed", + "location", + "recipient_email", + "recipient_locale", + "recipient_name", + "recipient_tz", + "title", + ] + datetime_attrs = {"start_time": "start_time", "end_time": "end_time"} + + def __init__(self, api): + RestfulModel.__init__(self, SchedulerBookingConfirmation, api) + + +class SchedulerBookingRequest(RestfulModel): + attrs = [ + "additional_values", + "additional_emails", + "email", + "locale", + "name", + "page_hostname", + "replaces_booking_hash", + "timezone", + "slot", + ] + + def __init__(self, api): + RestfulModel.__init__(self, SchedulerBookingRequest, api) + + def as_json(self): + dct = RestfulModel.as_json(self) + if "additional_values" not in dct or dct["additional_values"] is None: + dct["additional_values"] = {} + if "additional_emails" not in dct or dct["additional_emails"] is None: + dct["additional_emails"] = [] + if "slot" in dct and isinstance(dct["slot"], SchedulerTimeSlot): + dct["slot"] = dct["slot"].as_json() + + return dct diff --git a/nylas/client/scheduler_restful_model_collection.py b/nylas/client/scheduler_restful_model_collection.py new file mode 100644 index 00000000..4e5071a6 --- /dev/null +++ b/nylas/client/scheduler_restful_model_collection.py @@ -0,0 +1,65 @@ +import copy + +from nylas.client.restful_model_collection import RestfulModelCollection +from nylas.client.restful_models import Scheduler +from nylas.client.scheduler_models import ( + SchedulerTimeSlot, + SchedulerBookingConfirmation, +) + + +class SchedulerRestfulModelCollection(RestfulModelCollection): + def __init__(self, api): + # Make a copy of the API as we need to change the base url for Scheduler calls + scheduler_api = copy.copy(api) + scheduler_api.api_server = "https://api.schedule.nylas.com" + RestfulModelCollection.__init__(self, Scheduler, scheduler_api) + + def get_google_availability(self): + return self._execute_provider_availability("google") + + def get_office_365_availability(self): + return self._execute_provider_availability("o365") + + def get_page_slug(self, slug): + page_response = self.api._get_resource_raw( + self.model_class, slug, extra="info", path="schedule" + ).json() + return Scheduler.create(self.api, **page_response) + + def get_available_time_slots(self, slug): + response = self.api._get_resource_raw( + self.model_class, slug, extra="timeslots", path="schedule" + ).json() + return [ + SchedulerTimeSlot.create(self.api, **x) for x in response if x is not None + ] + + def book_time_slot(self, slug, timeslot): + response = self.api._post_resource( + self.model_class, slug, "timeslots", timeslot.as_json(), path="schedule" + ) + return SchedulerBookingConfirmation.create(self.api, **response) + + def cancel_booking(self, slug, edit_hash, reason): + return self.api._post_resource( + self.model_class, + slug, + "{}/cancel".format(edit_hash), + {"reason": reason}, + path="schedule", + ) + + def confirm_booking(self, slug, edit_hash): + booking_response = self.api._post_resource( + self.model_class, slug, "{}/confirm".format(edit_hash), {}, path="schedule" + ) + return SchedulerBookingConfirmation.create(self.api, **booking_response) + + def _execute_provider_availability(self, provider): + return self.api._get_resource_raw( + self.model_class, + None, + extra="availability/{}".format(provider), + path="schedule", + ).json() diff --git a/tests/conftest.py b/tests/conftest.py index 3e9fb7ca..8191bfe0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -813,6 +813,28 @@ def callback(_request): ) +@pytest.fixture +def mock_scheduler_create_response(mocked_responses, api_url, message_body): + def callback(_request): + try: + payload = json.loads(_request.body) + except ValueError: + return 400, {}, "" + + payload["id"] = "cv4ei7syx10uvsxbs21ccsezf" + return 200, {}, json.dumps(payload) + + mocked_responses.add_callback( + responses.POST, "https://api.schedule.nylas.com/manage/pages", callback=callback + ) + + mocked_responses.add( + responses.PUT, + "https://api.schedule.nylas.com/manage/pages/cv4ei7syx10uvsxbs21ccsezf", + body=json.dumps(message_body), + ) + + @pytest.fixture def mock_event_create_response_with_limits(mocked_responses, api_url, message_body): def callback(request): @@ -1305,6 +1327,248 @@ def list_callback(request): ) +@pytest.fixture +def mock_schedulers(mocked_responses, api_url): + scheduler_list = [ + { + "app_client_id": "test-client-id", + "app_organization_id": 12345, + "config": { + "appearance": { + "color": "#0068D3", + "company_name": "", + "logo": "", + "show_autoschedule": "true", + "show_nylas_branding": "false", + "show_timezone_options": "true", + "show_week_view": "true", + "submit_text": "Submit", + }, + "locale": "en", + "reminders": [], + "timezone": "America/Los_Angeles", + }, + "created_at": "2021-10-22", + "edit_token": "test-edit-token-1", + "id": 90210, + "modified_at": "2021-10-22", + "name": "test-1", + "slug": "test1", + }, + { + "app_client_id": "test-client-id", + "app_organization_id": 12345, + "config": { + "calendar_ids": { + "test-calendar-id": { + "availability": ["availability-id"], + "booking": "booking-id", + } + }, + "event": { + "capacity": -1, + "duration": 45, + "location": "Location TBD", + "title": "test-event", + }, + "locale": "en", + "reminders": [], + "timezone": "America/Los_Angeles", + }, + "created_at": "2021-10-22", + "edit_token": "test-edit-token-2", + "id": 90211, + "modified_at": "2021-10-22", + "name": "test-2", + "slug": "test2", + }, + ] + + def list_callback(arg=None): + return 200, {}, json.dumps(scheduler_list) + + def return_one_callback(arg=None): + return 200, {}, json.dumps(scheduler_list[0]) + + info_endpoint = re.compile("https://api.schedule.nylas.com/schedule/.*/info") + + mocked_responses.add_callback( + responses.GET, + "https://api.schedule.nylas.com/manage/pages", + content_type="application/json", + callback=list_callback, + ) + + mocked_responses.add_callback( + responses.GET, + info_endpoint, + content_type="application/json", + callback=return_one_callback, + ) + + +@pytest.fixture +def mock_scheduler_get_available_calendars(mocked_responses, api_url): + calendars = [ + { + "calendars": [ + {"id": "calendar-id", "name": "Emailed events", "read_only": "true"}, + ], + "email": "swag@nylas.com", + "id": "scheduler-id", + "name": "Python Tester", + } + ] + + def list_callback(arg=None): + return 200, {}, json.dumps(calendars) + + calendars_url = "https://api.schedule.nylas.com/manage/pages/{id}/calendars".format( + id="cv4ei7syx10uvsxbs21ccsezf" + ) + + mocked_responses.add_callback( + responses.GET, + calendars_url, + content_type="application/json", + callback=list_callback, + ) + + +@pytest.fixture +def mock_scheduler_upload_image(mocked_responses, api_url): + upload = { + "filename": "test.png", + "originalFilename": "test.png", + "publicUrl": "https://public.nylas.com/test.png", + "signedUrl": "https://signed.nylas.com/test.png", + } + + def list_callback(arg=None): + return 200, {}, json.dumps(upload) + + calendars_url = ( + "https://api.schedule.nylas.com/manage/pages/{id}/upload-image".format( + id="cv4ei7syx10uvsxbs21ccsezf" + ) + ) + + mocked_responses.add_callback( + responses.PUT, + calendars_url, + content_type="application/json", + callback=list_callback, + ) + + +@pytest.fixture +def mock_scheduler_provider_availability(mocked_responses, api_url): + response = { + "busy": [ + { + "end": 1636731958, + "start": 1636728347, + }, + ], + "email": "test@example.com", + "name": "John Doe", + } + + def callback(arg=None): + return 200, {}, json.dumps(response) + + provider_url = re.compile( + "https://api.schedule.nylas.com/schedule/availability/(google|o365)" + ) + + mocked_responses.add_callback( + responses.GET, + provider_url, + callback=callback, + ) + + +@pytest.fixture +def mock_scheduler_timeslots(mocked_responses, api_url): + scheduler_time_slots = [ + { + "account_id": "test-account-id", + "calendar_id": "test-calendar-id", + "emails": ["test@example.com"], + "end": 1636731958, + "host_name": "www.hostname.com", + "start": 1636728347, + }, + ] + + booking_confirmation = { + "account_id": "test-account-id", + "additional_field_values": { + "test": "yes", + }, + "calendar_event_id": "test-event-id", + "calendar_id": "test-calendar-id", + "edit_hash": "test-edit-hash", + "end_time": 1636731958, + "id": 123, + "is_confirmed": False, + "location": "Earth", + "recipient_email": "recipient@example.com", + "recipient_locale": "en_US", + "recipient_name": "Recipient Doe", + "recipient_tz": "America/New_York", + "start_time": 1636728347, + "title": "Test Booking", + } + + cancel_payload = { + "success": True, + } + + def list_timeslots(arg=None): + return 200, {}, json.dumps(scheduler_time_slots) + + def book_timeslot(arg=None): + return 200, {}, json.dumps(booking_confirmation) + + def confirm_booking(arg=None): + booking_confirmation["is_confirmed"] = True + return 200, {}, json.dumps(booking_confirmation) + + def cancel_booking(arg=None): + return 200, {}, json.dumps(cancel_payload) + + timeslots_url = re.compile("https://api.schedule.nylas.com/schedule/.*/timeslots") + + confirm_url = re.compile("https://api.schedule.nylas.com/schedule/.*/.*/confirm") + + cancel_url = re.compile("https://api.schedule.nylas.com/schedule/.*/.*/cancel") + + mocked_responses.add_callback( + responses.GET, + timeslots_url, + callback=list_timeslots, + ) + + mocked_responses.add_callback( + responses.POST, + timeslots_url, + callback=book_timeslot, + ) + + mocked_responses.add_callback( + responses.POST, + confirm_url, + callback=confirm_booking, + ) + + mocked_responses.add_callback( + responses.POST, + cancel_url, + callback=cancel_booking, + ) + + @pytest.fixture def mock_components(mocked_responses, api_url): components = [ diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 00000000..77be03ff --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,287 @@ +import json +from datetime import datetime +import pytest +import responses + +from nylas.client.restful_models import Scheduler, Calendar +from nylas.client.scheduler_models import SchedulerTimeSlot, SchedulerBookingRequest + + +def blank_scheduler_page(api_client): + scheduler = api_client.scheduler.create() + scheduler.access_tokens = ["test-access-token"] + scheduler.name = "Python SDK Example" + scheduler.slug = "py_example_1" + return scheduler + + +def test_scheduler_endpoint(api_client): + scheduler = api_client.scheduler + assert scheduler.api.api_server == "https://api.schedule.nylas.com" + + +@pytest.mark.usefixtures("mock_schedulers") +def test_scheduler(api_client): + scheduler = api_client.scheduler.first() + assert isinstance(scheduler, Scheduler) + assert scheduler.id == 90210 + assert scheduler.app_client_id == "test-client-id" + assert scheduler.app_organization_id == 12345 + assert len(scheduler.config) == 4 + assert isinstance(scheduler.config, dict) + assert scheduler.config["locale"] == "en" + assert len(scheduler.config["reminders"]) == 0 + assert scheduler.config["timezone"] == "America/Los_Angeles" + assert scheduler.edit_token == "test-edit-token-1" + assert scheduler.name == "test-1" + assert scheduler.slug == "test1" + assert scheduler.created_at == datetime.strptime("2021-10-22", "%Y-%m-%d").date() + assert scheduler.modified_at == datetime.strptime("2021-10-22", "%Y-%m-%d").date() + + +@pytest.mark.usefixtures("mock_scheduler_create_response") +def test_create_scheduler(api_client): + scheduler = blank_scheduler_page(api_client) + scheduler.save() + assert scheduler.id == "cv4ei7syx10uvsxbs21ccsezf" + + +@pytest.mark.usefixtures("mock_scheduler_create_response") +def test_modify_scheduler(api_client): + scheduler = blank_scheduler_page(api_client) + scheduler.id = "cv4ei7syx10uvsxbs21ccsezf" + scheduler.name = "Updated Name" + scheduler.save() + assert scheduler.name == "Updated Name" + + +@pytest.mark.usefixtures("mock_scheduler_get_available_calendars") +def test_scheduler_get_available_calendars(api_client): + scheduler = blank_scheduler_page(api_client) + scheduler.id = "cv4ei7syx10uvsxbs21ccsezf" + calendars = scheduler.get_available_calendars() + assert len(calendars) == 1 + calendar = calendars[0] + assert len(calendar["calendars"]) == 1 + assert isinstance(calendar["calendars"][0], Calendar) + assert calendar["calendars"][0].id == "calendar-id" + assert calendar["calendars"][0].name == "Emailed events" + assert calendar["calendars"][0].read_only + assert calendar["email"] == "swag@nylas.com" + assert calendar["id"] == "scheduler-id" + assert calendar["name"] == "Python Tester" + + +@pytest.mark.usefixtures("mock_scheduler_get_available_calendars") +def test_scheduler_get_available_calendars_no_id_throws_error(api_client): + scheduler = blank_scheduler_page(api_client) + with pytest.raises(ValueError): + scheduler.get_available_calendars() + + +@pytest.mark.usefixtures("mock_scheduler_upload_image") +def test_scheduler_upload_image(api_client): + scheduler = blank_scheduler_page(api_client) + scheduler.id = "cv4ei7syx10uvsxbs21ccsezf" + upload = scheduler.upload_image("image/png", "test.png") + assert upload["filename"] == "test.png" + assert upload["originalFilename"] == "test.png" + assert upload["publicUrl"] == "https://public.nylas.com/test.png" + assert upload["signedUrl"] == "https://signed.nylas.com/test.png" + + +@pytest.mark.usefixtures("mock_scheduler_get_available_calendars") +def test_scheduler_get_available_calendars_no_id_throws_error(api_client): + scheduler = blank_scheduler_page(api_client) + with pytest.raises(ValueError): + scheduler.upload_image("image/png", "test.png") + + +@pytest.mark.usefixtures("mock_scheduler_provider_availability") +def test_scheduler_get_google_availability(mocked_responses, api_client): + api_client.scheduler.get_google_availability() + request = mocked_responses.calls[0].request + assert request.url == "https://api.schedule.nylas.com/schedule/availability/google" + assert request.method == responses.GET + + +@pytest.mark.usefixtures("mock_scheduler_provider_availability") +def test_scheduler_get_o365_availability(mocked_responses, api_client): + api_client.scheduler.get_office_365_availability() + request = mocked_responses.calls[0].request + assert request.url == "https://api.schedule.nylas.com/schedule/availability/o365" + assert request.method == responses.GET + + +@pytest.mark.usefixtures("mock_schedulers") +def test_scheduler_get_page_slug(mocked_responses, api_client): + scheduler = api_client.scheduler.get_page_slug("test1") + request = mocked_responses.calls[0].request + assert request.url == "https://api.schedule.nylas.com/schedule/test1/info" + assert request.method == responses.GET + assert isinstance(scheduler, Scheduler) + assert scheduler.id == 90210 + assert scheduler.app_client_id == "test-client-id" + assert scheduler.app_organization_id == 12345 + assert len(scheduler.config) == 4 + assert isinstance(scheduler.config, dict) + assert scheduler.config["locale"] == "en" + assert len(scheduler.config["reminders"]) == 0 + assert scheduler.config["timezone"] == "America/Los_Angeles" + assert scheduler.edit_token == "test-edit-token-1" + assert scheduler.name == "test-1" + assert scheduler.slug == "test1" + assert scheduler.created_at == datetime.strptime("2021-10-22", "%Y-%m-%d").date() + assert scheduler.modified_at == datetime.strptime("2021-10-22", "%Y-%m-%d").date() + + +@pytest.mark.usefixtures("mock_scheduler_timeslots") +def test_scheduler_get_available_time_slots(mocked_responses, api_client): + scheduler = blank_scheduler_page(api_client) + timeslots = api_client.scheduler.get_available_time_slots(scheduler.slug) + request = mocked_responses.calls[0].request + assert ( + request.url == "https://api.schedule.nylas.com/schedule/py_example_1/timeslots" + ) + assert request.method == responses.GET + assert len(timeslots) == 1 + assert timeslots[0] + assert timeslots[0].account_id == "test-account-id" + assert timeslots[0].calendar_id == "test-calendar-id" + assert timeslots[0].emails[0] == "test@example.com" + assert timeslots[0].host_name == "www.hostname.com" + assert timeslots[0].end == datetime.utcfromtimestamp(1636731958) + assert timeslots[0].start == datetime.utcfromtimestamp(1636728347) + + +@pytest.mark.usefixtures("mock_scheduler_timeslots") +def test_scheduler_get_available_time_slots(mocked_responses, api_client): + scheduler = blank_scheduler_page(api_client) + timeslots = api_client.scheduler.get_available_time_slots(scheduler.slug) + request = mocked_responses.calls[0].request + assert ( + request.url == "https://api.schedule.nylas.com/schedule/py_example_1/timeslots" + ) + assert request.method == responses.GET + assert len(timeslots) == 1 + assert timeslots[0] + assert timeslots[0].account_id == "test-account-id" + assert timeslots[0].calendar_id == "test-calendar-id" + assert timeslots[0].emails[0] == "test@example.com" + assert timeslots[0].host_name == "www.hostname.com" + assert timeslots[0].end == datetime.utcfromtimestamp(1636731958) + assert timeslots[0].start == datetime.utcfromtimestamp(1636728347) + + +@pytest.mark.usefixtures("mock_scheduler_timeslots") +def test_scheduler_book_time_slot(mocked_responses, api_client): + scheduler = blank_scheduler_page(api_client) + slot = SchedulerTimeSlot.create(api_client) + slot.account_id = "test-account-id" + slot.calendar_id = "test-calendar-id" + slot.emails = ["recipient@example.com"] + slot.host_name = "www.nylas.com" + slot.start = datetime.utcfromtimestamp(1636728347) + slot.end = datetime.utcfromtimestamp(1636731958) + timeslot_to_book = SchedulerBookingRequest.create(api_client) + timeslot_to_book.additional_values = { + "test": "yes", + } + timeslot_to_book.email = "recipient@example.com" + timeslot_to_book.locale = "en_US" + timeslot_to_book.name = "Recipient Doe" + timeslot_to_book.timezone = "America/New_York" + timeslot_to_book.slot = slot + booking_response = api_client.scheduler.book_time_slot( + scheduler.slug, timeslot_to_book + ) + request = mocked_responses.calls[0].request + assert ( + request.url == "https://api.schedule.nylas.com/schedule/py_example_1/timeslots" + ) + assert request.method == responses.POST + assert json.loads(request.body) == { + "additional_emails": [], + "additional_values": { + "test": "yes", + }, + "email": "recipient@example.com", + "locale": "en_US", + "name": "Recipient Doe", + "timezone": "America/New_York", + "slot": { + "account_id": "test-account-id", + "calendar_id": "test-calendar-id", + "emails": ["recipient@example.com"], + "host_name": "www.nylas.com", + "start": 1636728347, + "end": 1636731958, + }, + } + assert booking_response.account_id == "test-account-id" + assert booking_response.calendar_id == "test-calendar-id" + assert booking_response.additional_field_values == { + "test": "yes", + } + assert booking_response.calendar_event_id == "test-event-id" + assert booking_response.calendar_id == "test-calendar-id" + assert booking_response.calendar_event_id == "test-event-id" + assert booking_response.edit_hash == "test-edit-hash" + assert booking_response.id == 123 + assert booking_response.is_confirmed is False + assert booking_response.location == "Earth" + assert booking_response.title == "Test Booking" + assert booking_response.recipient_email == "recipient@example.com" + assert booking_response.recipient_locale == "en_US" + assert booking_response.recipient_name == "Recipient Doe" + assert booking_response.recipient_tz == "America/New_York" + assert booking_response.end_time == datetime.utcfromtimestamp(1636731958) + assert booking_response.start_time == datetime.utcfromtimestamp(1636728347) + + +@pytest.mark.usefixtures("mock_scheduler_timeslots") +def test_scheduler_confirm_booking(mocked_responses, api_client): + scheduler = blank_scheduler_page(api_client) + booking_confirmation = api_client.scheduler.confirm_booking( + scheduler.slug, "test-edit-hash" + ) + request = mocked_responses.calls[0].request + assert ( + request.url + == "https://api.schedule.nylas.com/schedule/py_example_1/test-edit-hash/confirm" + ) + assert request.method == responses.POST + assert booking_confirmation.account_id == "test-account-id" + assert booking_confirmation.calendar_id == "test-calendar-id" + assert booking_confirmation.additional_field_values == { + "test": "yes", + } + assert booking_confirmation.calendar_event_id == "test-event-id" + assert booking_confirmation.calendar_id == "test-calendar-id" + assert booking_confirmation.calendar_event_id == "test-event-id" + assert booking_confirmation.edit_hash == "test-edit-hash" + assert booking_confirmation.id == 123 + assert booking_confirmation.is_confirmed is True + assert booking_confirmation.location == "Earth" + assert booking_confirmation.title == "Test Booking" + assert booking_confirmation.recipient_email == "recipient@example.com" + assert booking_confirmation.recipient_locale == "en_US" + assert booking_confirmation.recipient_name == "Recipient Doe" + assert booking_confirmation.recipient_tz == "America/New_York" + assert booking_confirmation.end_time == datetime.utcfromtimestamp(1636731958) + assert booking_confirmation.start_time == datetime.utcfromtimestamp(1636728347) + + +@pytest.mark.usefixtures("mock_scheduler_timeslots") +def test_scheduler_cancel_booking(mocked_responses, api_client): + scheduler = blank_scheduler_page(api_client) + timeslots = api_client.scheduler.cancel_booking( + scheduler.slug, "test-edit-hash", "It was a test." + ) + request = mocked_responses.calls[0].request + assert ( + request.url + == "https://api.schedule.nylas.com/schedule/py_example_1/test-edit-hash/cancel" + ) + assert request.method == responses.POST + assert json.loads(request.body) == {"reason": "It was a test."} From 11981da59a69264c72a51a145d44dccdf100d36d Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 26 Nov 2021 13:47:48 -0500 Subject: [PATCH 003/186] v5.3.0 Release (#178) Release new nylas v5.3.0 --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d65a0389..be75864a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.2.0 +current_version = 5.3.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c75fd0..bc11afba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased (dev) +v5.3.0 ---------------- * Add support for Scheduler API * Add support for Event notifications diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index cd742bfa..8265c1e9 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.2.0" +__VERSION__ = "5.3.0" From b411986d37a0cb3cd4d44f29e3348764ca6b720e Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 16 Dec 2021 12:44:59 -0500 Subject: [PATCH 004/186] [74538] Add is_primary field for Calendar (#179) The SDK was missing the is_primary field for Calendar, this PR adds the field in. --- CHANGELOG.md | 4 ++++ nylas/client/restful_models.py | 1 + 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc11afba..4cb76106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased (dev) +---------------- +* Add `is_primary` field to Calendar + v5.3.0 ---------------- * Add support for Scheduler API diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 13aaf454..73904121 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -610,6 +610,7 @@ class Calendar(NylasAPIObject): "description", "metadata", "read_only", + "is_primary", "object", ] collection_name = "calendars" From 79256da6fbc00b07c428baa18a76c55b9353fc96 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 16 Dec 2021 12:58:48 -0500 Subject: [PATCH 005/186] [75826] Job Status support (#181) This PR adds support for job statuses within supported models, as well as the /job-statuses endpoint. --- CHANGELOG.md | 1 + nylas/client/client.py | 5 ++++ nylas/client/restful_models.py | 32 +++++++++++++++++++++++-- tests/conftest.py | 33 ++++++++++++++++++++++++++ tests/test_job_status.py | 43 ++++++++++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 tests/test_job_status.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb76106..28db99f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased (dev) ---------------- +* Add job status support * Add `is_primary` field to Calendar v5.3.0 diff --git a/nylas/client/client.py b/nylas/client/client.py index 8f970ff7..f35cf2ce 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -30,6 +30,7 @@ Label, Draft, Component, + JobStatus, ) from nylas.client.neural_api_models import Neural from nylas.client.scheduler_restful_model_collection import ( @@ -425,6 +426,10 @@ def room_resources(self): def calendars(self): return RestfulModelCollection(Calendar, self) + @property + def job_statuses(self): + return RestfulModelCollection(JobStatus, self) + @property def scheduler(self): return SchedulerRestfulModelCollection(self) diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 73904121..202386ee 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -58,6 +58,7 @@ def create(cls, api, **kwargs): object_type and object_type != cls_object_type and object_type != "account" + and cls_object_type != "jobstatus" and not _is_subclass(cls, object_type) ): # We were given a specific object type and we're trying to @@ -65,6 +66,8 @@ def create(cls, api, **kwargs): # and labels API.) # We need a special case for accounts because the /accounts API # is different between the open source and hosted API. + # And a special case for job status because the object refers to + # the type of objects' job status return obj = cls(api) # pylint: disable=no-value-for-parameter obj.cls = cls @@ -181,6 +184,7 @@ class Message(NylasAPIObject): "starred", "subject", "thread_id", + "job_status_id", "to", "unread", "starred", @@ -284,7 +288,7 @@ def raw(self): class Folder(NylasAPIObject): - attrs = ["id", "display_name", "name", "object", "account_id"] + attrs = ["id", "display_name", "name", "object", "account_id", "job_status_id"] collection_name = "folders" def __init__(self, api): @@ -300,7 +304,7 @@ def messages(self): class Label(NylasAPIObject): - attrs = ["id", "display_name", "name", "object", "account_id"] + attrs = ["id", "display_name", "name", "object", "account_id", "job_status_id"] collection_name = "labels" def __init__(self, api): @@ -465,6 +469,7 @@ class Draft(Message): "subject", "thread_id", "to", + "job_status_id", "unread", "version", "file_ids", @@ -572,6 +577,7 @@ class Contact(NylasAPIObject): "nickname", "company_name", "job_title", + "job_status_id", "manager_name", "office_location", "notes", @@ -608,6 +614,7 @@ class Calendar(NylasAPIObject): "account_id", "name", "description", + "job_status_id", "metadata", "read_only", "is_primary", @@ -639,6 +646,7 @@ class Event(NylasAPIObject): "recurrence", "status", "master_event_id", + "job_status_id", "owner", "original_start_time", "object", @@ -716,6 +724,26 @@ def __init__(self, api): NylasAPIObject.__init__(self, RoomResource, api) +class JobStatus(NylasAPIObject): + attrs = [ + "id", + "account_id", + "job_status_id", + "action", + "object", + "status", + "original_data", + ] + datetime_attrs = {"created_at": "created_at"} + collection_name = "job-statuses" + + def __init__(self, api): + NylasAPIObject.__init__(self, JobStatus, api) + + def is_successful(self): + return self.status == "successful" + + class Scheduler(NylasAPIObject): attrs = [ "id", diff --git a/tests/conftest.py b/tests/conftest.py index 8191bfe0..817de783 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1630,6 +1630,39 @@ def mock_resources(mocked_responses, api_url): ) +@pytest.fixture +def mock_job_statuses(mocked_responses, api_url): + job_status = [ + { + "account_id": "test_account_id", + "action": "save_draft", + "created_at": 1622846160, + "id": "test_id", + "job_status_id": "test_job_status_id", + "object": "message", + "status": "successful", + }, + { + "account_id": "test_account_id", + "action": "update_event", + "created_at": 1622846160, + "id": "test_id_2", + "job_status_id": "test_job_status_id_2", + "object": "event", + "status": "successful", + }, + ] + + endpoint = re.compile(api_url + "/job-statuses") + mocked_responses.add( + responses.GET, + endpoint, + body=json.dumps(job_status), + status=200, + content_type="application/json", + ) + + @pytest.fixture def mock_account_management(mocked_responses, api_url, account_id, client_id): account = { diff --git a/tests/test_job_status.py b/tests/test_job_status.py new file mode 100644 index 00000000..f3c826a6 --- /dev/null +++ b/tests/test_job_status.py @@ -0,0 +1,43 @@ +from datetime import datetime + +import pytest +from nylas.client.restful_models import JobStatus + + +@pytest.mark.usefixtures("mock_job_statuses") +def test_first_job_status(api_client): + job_status = api_client.job_statuses.first() + assert isinstance(job_status, JobStatus) + + +@pytest.mark.usefixtures("mock_job_statuses") +def test_all_job_status(api_client): + job_statuses = api_client.job_statuses.all() + assert len(job_statuses) == 2 + for job_status in job_statuses: + assert isinstance(job_status, JobStatus) + + +@pytest.mark.usefixtures("mock_job_statuses") +def test_job_status(api_client): + job_status = api_client.job_statuses.first() + assert job_status["account_id"] == "test_account_id" + assert job_status["action"] == "save_draft" + assert job_status["id"] == "test_id" + assert job_status["job_status_id"] == "test_job_status_id" + assert job_status["object"] == "message" + assert job_status["status"] == "successful" + assert job_status["created_at"] == datetime(2021, 6, 4, 22, 36) + + +@pytest.mark.usefixtures("mock_job_statuses") +def test_job_status_is_successful(api_client): + job_status = api_client.job_statuses.first() + assert job_status.is_successful() is True + + +@pytest.mark.usefixtures("mock_job_statuses") +def test_job_status_is_successful_false(api_client): + job_status = api_client.job_statuses.first() + job_status.status = "failed" + assert job_status.is_successful() is False From ba5f07ccff2b20605f478a675087f3320b96f86f Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 16 Dec 2021 13:13:33 -0500 Subject: [PATCH 006/186] [75449] Fix bug where updating an Event results in an API error (#180) A recent API update caused updating an event using the SDK to return an error because the SDK sends a Participant's status on an Event update call. This PR omits this field when converting the Event object to a JSON. --- CHANGELOG.md | 1 + nylas/client/restful_models.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28db99f8..8ac4ed59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Unreleased (dev) ---------------- * Add job status support * Add `is_primary` field to Calendar +* Fix bug where updating an Event results in an API error v5.3.0 ---------------- diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 202386ee..73cf7821 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -672,6 +672,12 @@ def as_json(self): dct["when"] = dct["when"].copy() dct["when"].pop("object", None) + if dct.get("participants") and isinstance(dct.get("participants"), list): + # The status of a participant cannot be updated and, if the key is + # included, it will return an error from the API + for participant in dct.get("participants"): + participant.pop("status", None) + return dct def rsvp(self, status, comment=None): From 2ba18b84ad0b6f3cb6829cedb4411d09ec388e22 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 16 Dec 2021 14:06:11 -0500 Subject: [PATCH 007/186] v5.4.0 Release (#182) New nylas v5.4.0 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index be75864a..fdb651ba 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.3.0 +current_version = 5.4.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac4ed59..b8658a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased (dev) +v5.4.0 ---------------- * Add job status support * Add `is_primary` field to Calendar diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 8265c1e9..9835fa92 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.3.0" +__VERSION__ = "5.4.0" From c068cce13def2e0027e233620ca0de7917b22eeb Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 23 Dec 2021 13:14:40 -0500 Subject: [PATCH 008/186] [76796] Improved Application Detail support (#183) This PR adds support for 3 Application-related endpoints. The Python SDK now is able to get and update application details as well as delete an application account. --- CHANGELOG.md | 4 +++ nylas/client/client.py | 34 ++++++++++++++++++---- nylas/client/restful_models.py | 3 -- tests/conftest.py | 52 ++++++++++++++++++++++++++++++++++ tests/test_accounts.py | 42 ++++++++++++++++++++++----- 5 files changed, 119 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8658a76..6592aa2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Improved support for Application Details + v5.4.0 ---------------- * Add job status support diff --git a/nylas/client/client.py b/nylas/client/client.py index f35cf2ce..30f29bba 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -90,13 +90,10 @@ def __init__( self.authorize_url = api_server + "/oauth/authorize" self.access_token_url = api_server + "/oauth/token" self.revoke_url = api_server + "/oauth/revoke" - self.revoke_all_url = ( - api_server + "/a/{client_id}/accounts/{account_id}/revoke-all" - ) + self.application_url = api_server + "/a/{client_id}" + self.revoke_all_url = self.application_url + "/accounts/{account_id}/revoke-all" self.ip_addresses_url = api_server + "/a/{client_id}/ip_addresses" - self.token_info_url = ( - api_server + "/a/{client_id}/accounts/{account_id}/token-info" - ) + self.token_info_url = self.application_url + "/accounts/{account_id}/token-info" self.client_secret = client_secret self.client_id = client_id @@ -198,6 +195,31 @@ def is_opensource_api(self): return False + def application_details(self): + application_details_url = self.application_url.format(client_id=self.client_id) + resp = self.admin_session.get(application_details_url) + _validate(resp).json() + return resp.json() + + def update_application_details( + self, application_name=None, icon_url=None, redirect_uris=None + ): + application_details_url = self.application_url.format(client_id=self.client_id) + data = {} + if application_name is not None: + data["application_name"] = application_name + if icon_url is not None: + data["icon_url"] = icon_url + if redirect_uris is not None: + data["redirect_uris"] = redirect_uris + + headers = {"Content-Type": "application/json"} + headers.update(self.admin_session.headers) + resp = self.admin_session.put( + application_details_url, json=data, headers=headers + ) + return _validate(resp).json() + def revoke_token(self): resp = self.session.post(self.revoke_url) _validate(resp) diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 73cf7821..f0bed95e 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -878,9 +878,6 @@ def upgrade(self): def downgrade(self): return self.api._call_resource_method(self, self.account_id, "downgrade", None) - def delete(self): - raise NotImplementedError - class APIAccount(NylasAPIObject): attrs = [ diff --git a/tests/conftest.py b/tests/conftest.py index 817de783..174ccb62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -180,6 +180,10 @@ def update_callback(request): response["metadata"] = payload["metadata"] return 200, {}, json.dumps(response) + def delete_callback(request): + response = {"success": True} + return 200, {}, json.dumps(response) + url_re = "{base}(/a/{client_id})?/accounts/?".format( base=api_url, client_id=client_id ) @@ -195,6 +199,12 @@ def update_callback(request): content_type="application/json", callback=update_callback, ) + mocked_responses.add_callback( + responses.DELETE, + re.compile(url_re), + content_type="application/json", + callback=delete_callback, + ) @pytest.fixture @@ -322,10 +332,18 @@ def request_callback(request): folder.update(payload) return (200, {}, json.dumps(folder)) + def delete_callback(request): + payload = {"successful": True} + return 200, {}, json.dumps(payload) + mocked_responses.add_callback( responses.PUT, url, content_type="application/json", callback=request_callback ) + mocked_responses.add_callback( + responses.DELETE, url, content_type="application/json", callback=delete_callback + ) + @pytest.fixture def mock_messages(mocked_responses, api_url, account_id): @@ -1715,6 +1733,40 @@ def mock_revoke_all_tokens(mocked_responses, api_url, account_id, client_id): ) +@pytest.fixture +def mock_application_details(mocked_responses, api_url, client_id): + application_details_url = "{base}/a/{client_id}".format( + base=api_url, client_id=client_id + ) + + def modify_endpoint(request): + return 200, {}, json.dumps(json.loads(request.body)) + + mocked_responses.add( + responses.GET, + application_details_url, + content_type="application/json", + status=200, + body=json.dumps( + { + "application_name": "My New App Name", + "icon_url": "http://localhost:5555/icon.png", + "redirect_uris": [ + "http://localhost:5555/login_callback", + "localhost", + "https://customerA.myapplication.com/login_callback", + ], + } + ), + ) + mocked_responses.add_callback( + responses.PUT, + application_details_url, + content_type="application/json", + callback=modify_endpoint, + ) + + @pytest.fixture def mock_ip_addresses(mocked_responses, api_url, client_id): ip_addresses_url = "{base}/a/{client_id}/ip_addresses".format( diff --git a/tests/test_accounts.py b/tests/test_accounts.py index 882e3147..1a96400b 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -55,13 +55,6 @@ def test_account_upgrade(api_client, client_id): assert account.billing_state == "paid" -def test_account_delete(api_client, monkeypatch): - monkeypatch.setattr(api_client, "is_opensource_api", lambda: False) - account = api_client.accounts.create() - with pytest.raises(NotImplementedError): - account.delete() - - @pytest.mark.usefixtures("mock_revoke_all_tokens", "mock_account") def test_revoke_all_tokens(api_client_with_client_id): assert api_client_with_client_id.access_token is not None @@ -96,3 +89,38 @@ def test_account_metadata(api_client_with_client_id, monkeypatch): account1["metadata"] = {"test": "value"} account1.save() assert account1["metadata"] == {"test": "value"} + + +@pytest.mark.usefixtures("mock_accounts") +def test_application_account_delete(api_client_with_client_id, monkeypatch): + monkeypatch.setattr(api_client_with_client_id, "is_opensource_api", lambda: False) + account1 = api_client_with_client_id.accounts[0] + api_client_with_client_id.accounts.delete(account1.id) + + +@pytest.mark.usefixtures("mock_application_details") +def test_application_details(api_client_with_client_id, monkeypatch): + monkeypatch.setattr(api_client_with_client_id, "is_opensource_api", lambda: False) + app_data = api_client_with_client_id.application_details() + assert app_data["application_name"] == "My New App Name" + assert app_data["icon_url"] == "http://localhost:5555/icon.png" + assert app_data["redirect_uris"] == [ + "http://localhost:5555/login_callback", + "localhost", + "https://customerA.myapplication.com/login_callback", + ] + + +@pytest.mark.usefixtures("mock_application_details") +def test_update_application_details(api_client_with_client_id, monkeypatch): + monkeypatch.setattr(api_client_with_client_id, "is_opensource_api", lambda: False) + updated_data = api_client_with_client_id.update_application_details( + application_name="New Name", + icon_url="https://myurl.com/icon.png", + redirect_uris=["https://redirect.com"], + ) + assert updated_data["application_name"] == "New Name" + assert updated_data["icon_url"] == "https://myurl.com/icon.png" + assert updated_data["redirect_uris"] == [ + "https://redirect.com", + ] From 6f466789e8e7961ae352e1878e43a3481a65116d Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 23 Dec 2021 13:18:07 -0500 Subject: [PATCH 009/186] [77068] Fix passing `kwargs` to `_update_resource` (#184) When passing keyword arguments to a method that utilizes _update_resource, such as Event.save(), the keyword arguments were incorrectly being bubbled up resulting in them not formatting in the URL as URL parameters. --- CHANGELOG.md | 1 + nylas/client/client.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6592aa2d..bb86e55f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased ---------------- +* Fix issue where keyword arguments calling `_update_resource` were not correctly resolving to URL params * Improved support for Application Details v5.4.0 diff --git a/nylas/client/client.py b/nylas/client/client.py index 30f29bba..2c1220a8 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -641,7 +641,7 @@ def _put_resource(self, cls, id, data, extra=None, path=None, **kwargs): return result.json() def _update_resource(self, cls, id, data, **kwargs): - result = self._put_resource(cls, id, data, kwargs) + result = self._put_resource(cls, id, data, **kwargs) return cls.create(self, **result) def _post_resource(self, cls, id, method_name, data, path=None): From a6524b6a1dfc93d9ce16102ba6b38603c90862f3 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 23 Dec 2021 13:48:59 -0500 Subject: [PATCH 010/186] v5.4.1 Release (#185) New nylas v5.4.1 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index fdb651ba..f9bc9a2b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.4.0 +current_version = 5.4.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index bb86e55f..a15dd0a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v5.4.1 ---------------- * Fix issue where keyword arguments calling `_update_resource` were not correctly resolving to URL params * Improved support for Application Details diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 9835fa92..32d94066 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.4.0" +__VERSION__ = "5.4.1" From 8eafc58646eeb22959614a12a90fb78839626771 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 13 Jan 2022 15:52:22 -0500 Subject: [PATCH 011/186] [77881] Add missing `source` field in `Contact` class (#187) This PR adds a missing field to the Contact class. --- CHANGELOG.md | 4 ++++ nylas/client/restful_models.py | 1 + tests/conftest.py | 1 + tests/test_contacts.py | 1 + 4 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a15dd0a4..69317c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add missing `source` field in `Contact` class + v5.4.1 ---------------- * Fix issue where keyword arguments calling `_update_resource` were not correctly resolving to URL params diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index f0bed95e..0fcde8d7 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -580,6 +580,7 @@ class Contact(NylasAPIObject): "job_status_id", "manager_name", "office_location", + "source", "notes", "picture_url", ] diff --git a/tests/conftest.py b/tests/conftest.py index 174ccb62..2107b98a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1193,6 +1193,7 @@ def mock_contact(mocked_responses, account_id, api_url): "job_title": "QA Tester", "manager_name": "George", "office_location": "Over the Rainbow", + "source": "inbox", "notes": "This is a note", "picture_url": "{base}/contacts/{id}/picture".format( base=api_url, id="9hga75n6mdvq4zgcmhcn7hpys" diff --git a/tests/test_contacts.py b/tests/test_contacts.py index 1798df3a..66f613b5 100644 --- a/tests/test_contacts.py +++ b/tests/test_contacts.py @@ -20,6 +20,7 @@ def test_get_contact(api_client): assert contact.given_name == "Given" assert contact.surname == "Sur" assert contact.birthday == date(1964, 10, 5) + assert contact.source == "inbox" @pytest.mark.usefixtures("mock_contacts") From 877805e2b39a951ef915c70488349182e2822ce1 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 13 Jan 2022 17:24:01 -0500 Subject: [PATCH 012/186] v5.4.2 Release (#188) New nylas v5.4.2 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f9bc9a2b..9eeab9a4 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.4.1 +current_version = 5.4.2 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 69317c91..51f7de96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v5.4.2 ---------------- * Add missing `source` field in `Contact` class diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 32d94066..8cdfdd06 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.4.1" +__VERSION__ = "5.4.2" From 45e7bda0ce0cc72f1e58137c3ab557ad5fd1879c Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Mon, 17 Jan 2022 14:07:57 -0500 Subject: [PATCH 013/186] [76804] Event to ICS Support (#186) This PR enables support for generating an ICS file from an Event. --- CHANGELOG.md | 1 + nylas/client/client.py | 10 ++--- nylas/client/restful_models.py | 45 ++++++++++++++++++++ tests/conftest.py | 7 ++++ tests/test_events.py | 75 ++++++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f7de96..391f1a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog v5.4.2 ---------------- +* Add support for `Event` to ICS * Add missing `source` field in `Contact` class v5.4.1 diff --git a/nylas/client/client.py b/nylas/client/client.py index 2c1220a8..37a745e2 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -648,18 +648,18 @@ def _post_resource(self, cls, id, method_name, data, path=None): if path is None: path = cls.collection_name path = "/{}".format(path) if path else "" + id = "/{}".format(id) if id else "" + method = "/{}".format(method_name) if method_name else "" if not cls.api_root: - url_path = "{name}/{id}/{method}".format( - name=path, id=id, method=method_name - ) + url_path = "{name}{id}{method}".format(name=path, id=id, method=method) else: # Management method. - url_path = "/{prefix}/{client_id}{path}/{id}/{method}".format( + url_path = "/{prefix}/{client_id}{path}{id}{method}".format( prefix=cls.api_root, client_id=self.client_id, path=path, id=id, - method=method_name, + method=method, ) url = URLObject(self.api_server).with_path(url_path) diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 0fcde8d7..99a05464 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -701,6 +701,51 @@ def rsvp(self, status, comment=None): result = response.json() return Event.create(self, **result) + def generate_ics(self, ical_uid=None, method=None, prodid=None): + """ + Generate an ICS file server-side, from an Event + + Args: + ical_uid (str): Unique identifier used events across calendaring systems + method (str): Description of invitation and response methods for attendees + prodid (str): Company-specific unique product identifier + + Returns: + str: String for writing directly into an ICS file + + Raises: + ValueError: If the event does not have calendar_id or when set + RuntimeError: If the server returns an object without an ics string + """ + if not self.calendar_id or not self.when: + raise ValueError( + "Cannot generate an ICS file for an event without a Calendar ID or when set" + ) + + payload = {} + ics_options = {} + if self.id: + payload["event_id"] = self.id + else: + payload = self.as_json() + + if ical_uid: + ics_options["ical_uid"] = ical_uid + if method: + ics_options["method"] = method + if prodid: + ics_options["prodid"] = prodid + + if ics_options: + payload["ics_options"] = ics_options + + response = self.api._post_resource(Event, None, "to-ics", payload) + if "ics" in response: + return response["ics"] + raise RuntimeError( + "Unexpected response from the API server. Returned 200 but no 'ics' string found." + ) + def save(self, **kwargs): if ( self.conferencing diff --git a/tests/conftest.py b/tests/conftest.py index 2107b98a..25b0bba8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -831,6 +831,13 @@ def callback(_request): ) +@pytest.fixture +def mock_event_generate_ics(mocked_responses, api_url, message_body): + mocked_responses.add( + responses.POST, api_url + "/events/to-ics", body=json.dumps({"ics": ""}) + ) + + @pytest.fixture def mock_scheduler_create_response(mocked_responses, api_url, message_body): def callback(_request): diff --git a/tests/test_events.py b/tests/test_events.py index 10955430..248cc81e 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -545,3 +545,78 @@ def test_event_notifications(mocked_responses, api_client): assert event.notifications[0]["minutes_before_event"] == 60 assert event.notifications[0]["subject"] == "Test Event Notification" assert event.notifications[0]["body"] == "Reminding you about our meeting." + + +@pytest.mark.usefixtures("mock_event_create_response", "mock_event_generate_ics") +def test_generate_ics_existing_event(mocked_responses, api_client): + event = blank_event(api_client) + event.save() + ics = event.generate_ics() + ics_request = mocked_responses.calls[1].request + assert len(mocked_responses.calls) == 2 + assert event.id == "cv4ei7syx10uvsxbs21ccsezf" + assert ics_request.path_url == "/events/to-ics" + assert ics_request.method == "POST" + assert json.loads(ics_request.body) == {"event_id": "cv4ei7syx10uvsxbs21ccsezf"} + + +@pytest.mark.usefixtures("mock_event_create_response", "mock_event_generate_ics") +def test_generate_ics_no_event_id(mocked_responses, api_client): + event = blank_event(api_client) + ics = event.generate_ics() + ics_request = mocked_responses.calls[0].request + assert len(mocked_responses.calls) == 1 + assert event.id is None + assert ics_request.path_url == "/events/to-ics" + assert ics_request.method == "POST" + assert json.loads(ics_request.body) == { + "calendar_id": "calendar_id", + "title": "Paris-Brest", + "when": {"end_time": 1409594400, "start_time": 1409594400}, + } + + +@pytest.mark.usefixtures("mock_event_create_response", "mock_event_generate_ics") +def test_generate_ics_options(mocked_responses, api_client): + event = blank_event(api_client) + event.save() + ics = event.generate_ics( + ical_uid="test_uuid", method="request", prodid="test_prodid" + ) + ics_request = mocked_responses.calls[1].request + assert len(mocked_responses.calls) == 2 + assert event.id == "cv4ei7syx10uvsxbs21ccsezf" + assert ics_request.path_url == "/events/to-ics" + assert ics_request.method == "POST" + assert json.loads(ics_request.body) == { + "event_id": "cv4ei7syx10uvsxbs21ccsezf", + "ics_options": { + "ical_uid": "test_uuid", + "method": "request", + "prodid": "test_prodid", + }, + } + + +@pytest.mark.usefixtures("mock_event_create_response", "mock_event_generate_ics") +def test_generate_ics_no_calendar_id_throws(mocked_responses, api_client): + event = blank_event(api_client) + del event.calendar_id + with pytest.raises(ValueError) as exc: + event.generate_ics() + + assert str(exc.value) == ( + "Cannot generate an ICS file for an event without a Calendar ID or when set" + ) + + +@pytest.mark.usefixtures("mock_event_create_response", "mock_event_generate_ics") +def test_generate_ics_no_when_throws(mocked_responses, api_client): + event = blank_event(api_client) + del event.when + with pytest.raises(ValueError) as exc: + event.generate_ics() + + assert str(exc.value) == ( + "Cannot generate an ICS file for an event without a Calendar ID or when set" + ) From 5f7e0a553cdafc439100e4839fe2a8f486ec40ac Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Mon, 31 Jan 2022 18:13:18 -0500 Subject: [PATCH 014/186] [79420] Enable full payload response for exchanging the token for code (#190) This PR introduces a new method, send_authorization that will call the /oauth/token endpoint and returns the full payload that includes the access token within it. --- CHANGELOG.md | 4 ++++ nylas/client/client.py | 17 +++++++++++++++-- nylas/client/errors.py | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 391f1a60..bd245d33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Enable full payload response for exchanging the token for code + v5.4.2 ---------------- * Add support for `Event` to ICS diff --git a/nylas/client/client.py b/nylas/client/client.py index 37a745e2..bc72e808 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -169,7 +169,16 @@ def authentication_url( url = URLObject(self.authorize_url).add_query_params(args.items()) return str(url) - def token_for_code(self, code): + def send_authorization(self, code): + """ + Exchanges an authorization code for an access token. + + Args: + code (str): The authorization code returned from authenticating the user + + Returns: + dict: The response from the API containing the access token + """ args = { "client_id": self.client_id, "client_secret": self.client_secret, @@ -186,7 +195,11 @@ def token_for_code(self, code): self.access_token_url, data=urlencode(args), headers=headers ).json() - self.access_token = resp[u"access_token"] + self.access_token = resp["access_token"] + return resp + + def token_for_code(self, code): + self.send_authorization(code) return self.access_token def is_opensource_api(self): diff --git a/nylas/client/errors.py b/nylas/client/errors.py index 49448116..b3c31ab5 100644 --- a/nylas/client/errors.py +++ b/nylas/client/errors.py @@ -33,7 +33,7 @@ class NylasApiError(HTTPError): def __init__(self, response): try: response_json = json.loads(response.text) - error_message = u"%s %s. Reason: %s. Nylas Error Type: %s" % ( + error_message = "%s %s. Reason: %s. Nylas Error Type: %s" % ( response.status_code, response.reason, response_json["message"], From 19f8e87b6e0e1a9cd5972aff990c463fef5fcece Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 1 Feb 2022 13:56:39 -0500 Subject: [PATCH 015/186] v5.5.0 Release (#191) New nylas v5.5.0 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 4 ++-- nylas/_client_sdk_version.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 9eeab9a4..d348f822 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.4.2 +current_version = 5.5.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index bd245d33..7cea8f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,13 @@ nylas-python Changelog ====================== -Unreleased +v5.5.0 ---------------- +* Add support for `Event` to ICS * Enable full payload response for exchanging the token for code v5.4.2 ---------------- -* Add support for `Event` to ICS * Add missing `source` field in `Contact` class v5.4.1 diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 8cdfdd06..8f019229 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.4.2" +__VERSION__ = "5.5.0" From f4e8737025514440c545e9385cd9e0346ba96582 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 10 Feb 2022 10:33:01 -0500 Subject: [PATCH 016/186] [80871] Add validation for `send_authorization` (#195) This PR forces the /oath/token endpoint to return a JSON type and adds validation in case the API returns a non 200 response code and returns a properly formatted error code. --- CHANGELOG.md | 4 ++++ nylas/client/client.py | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cea8f57..f42e92e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add validation for `send_authorization` + v5.5.0 ---------------- * Add support for `Event` to ICS diff --git a/nylas/client/client.py b/nylas/client/client.py index bc72e808..06bbb2b3 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -188,14 +188,15 @@ def send_authorization(self, code): headers = { "Content-type": "application/x-www-form-urlencoded", - "Accept": "text/plain", + "Accept": "application/json", } resp = requests.post( self.access_token_url, data=urlencode(args), headers=headers - ).json() + ) + results = _validate(resp).json() - self.access_token = resp["access_token"] + self.access_token = results["access_token"] return resp def token_for_code(self, code): From f5588f46b1e5a1e4f96b1b14e733f719f9e72693 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 10 Feb 2022 10:35:37 -0500 Subject: [PATCH 017/186] [80305] Fix `native-authentication-gmail` example app (#196) client_id and client_secret should be present for make_google_blueprint to succeed --- CHANGELOG.md | 1 + examples/native-authentication-gmail/server.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f42e92e7..a7071798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Add validation for `send_authorization` +* Fix `native-authentication-gmail` example app v5.5.0 ---------------- diff --git a/examples/native-authentication-gmail/server.py b/examples/native-authentication-gmail/server.py index d79e6593..d1d49a73 100644 --- a/examples/native-authentication-gmail/server.py +++ b/examples/native-authentication-gmail/server.py @@ -66,6 +66,8 @@ # Use Flask-Dance to automatically set up the OAuth endpoints for Google. # For more information, check out the documentation: http://flask-dance.rtfd.org google_bp = make_google_blueprint( + client_id=app.config["GOOGLE_OAUTH_CLIENT_ID"], + client_secret=app.config["GOOGLE_OAUTH_CLIENT_SECRET"], scope=[ "openid", "https://www.googleapis.com/auth/userinfo.email", From 05a4a91d304c11bb064b97c6d1c42d9b3f13d25b Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 10 Feb 2022 12:58:58 -0500 Subject: [PATCH 018/186] v5.5.1 Release (#199) New nylas v5.5.1 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d348f822..f527020a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.5.0 +current_version = 5.5.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index a7071798..e6b17ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v5.5.1 ---------------- * Add validation for `send_authorization` * Fix `native-authentication-gmail` example app diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 8f019229..e85dcca9 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.5.0" +__VERSION__ = "5.5.1" From 5f2bbdda07537b63f71f0c3d535e0c1a8bd085a4 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 10 Feb 2022 15:36:33 -0500 Subject: [PATCH 019/186] [80777] Omit `None` values from resulting `as_json()` object (#197) This PR removes None values from appearing in the resulting conversion from as_json(). The same logic applies to availability-related functions. --- CHANGELOG.md | 4 ++++ nylas/client/client.py | 11 ++++++++--- nylas/client/restful_models.py | 6 ++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b17ee2..c066d2b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Omit `None` values from resulting `as_json()` object + v5.5.1 ---------------- * Add validation for `send_authorization` diff --git a/nylas/client/client.py b/nylas/client/client.py index 06bbb2b3..3d59f50e 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -352,11 +352,14 @@ def availability( "interval_minutes": interval_minutes, "start_time": start_time, "end_time": end_time, - "buffer": buffer, - "round_robin": round_robin, "free_busy": free_busy or [], "open_hours": open_hours or [], } + if buffer is not None: + data["buffer"] = buffer + if round_robin is not None: + data["round_robin"] = round_robin + resp = self.session.post(url, json=data) _validate(resp) return resp.json() @@ -404,10 +407,12 @@ def consecutive_availability( "interval_minutes": interval_minutes, "start_time": start_time, "end_time": end_time, - "buffer": buffer, "free_busy": free_busy or [], "open_hours": open_hours or [], } + if buffer is not None: + data["buffer"] = buffer + resp = self.session.post(url, json=data) _validate(resp) return resp.json() diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 99a05464..80018338 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -113,9 +113,11 @@ def as_json(self): continue if hasattr(self, attr): if attr in reserved_keywords: - dct[attr] = getattr(self, "{}_".format(attr)) + attr_value = getattr(self, "{}_".format(attr)) else: - dct[attr] = getattr(self, attr) + attr_value = getattr(self, attr) + if attr_value is not None: + dct[attr] = attr_value for date_attr, iso_attr in self.cls.date_attrs.items(): if date_attr in self.read_only_attrs: continue From 8984727446198992542d9e97de215274dbb629db Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 10 Feb 2022 16:12:11 -0500 Subject: [PATCH 020/186] Enable Nylas API v2.4 Support (#198) This PR enables Nylas API v2.4 support. --- CHANGELOG.md | 1 + nylas/client/client.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c066d2b3..79320bdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Omit `None` values from resulting `as_json()` object +* Enable Nylas API v2.4 support v5.5.1 ---------------- diff --git a/nylas/client/client.py b/nylas/client/client.py index 3d59f50e..92ce55cc 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -40,7 +40,7 @@ DEBUG = environ.get("NYLAS_CLIENT_DEBUG") API_SERVER = "https://api.nylas.com" -SUPPORTED_API_VERSION = "2.2" +SUPPORTED_API_VERSION = "2.4" def _validate(response): From fdd625ee0e6d857f907cfa3a297eb3f28933bc96 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 15 Feb 2022 10:20:44 -0500 Subject: [PATCH 021/186] [76806] Add Webhook support (#200) This PR adds support for Webhooks. --- CHANGELOG.md | 1 + nylas/client/client.py | 5 ++ nylas/client/restful_models.py | 69 ++++++++++++++++++++++- setup.py | 1 + tests/conftest.py | 100 +++++++++++++++++++++++++++++++++ tests/test_webhooks.py | 80 ++++++++++++++++++++++++++ 6 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 tests/test_webhooks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 79320bdd..a4f57cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased ---------------- +* Add Webhook support * Omit `None` values from resulting `as_json()` object * Enable Nylas API v2.4 support diff --git a/nylas/client/client.py b/nylas/client/client.py index 92ce55cc..c19a7b03 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -31,6 +31,7 @@ Draft, Component, JobStatus, + Webhook, ) from nylas.client.neural_api_models import Neural from nylas.client.scheduler_restful_model_collection import ( @@ -479,6 +480,10 @@ def scheduler(self): def components(self): return RestfulModelCollection(Component, self) + @property + def webhooks(self): + return RestfulModelCollection(Webhook, self) + @property def neural(self): return Neural(self) diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 80018338..77b1b04b 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -1,5 +1,6 @@ from datetime import datetime from collections import defaultdict +from enum import Enum from six import StringIO from nylas.client.restful_model_collection import RestfulModelCollection @@ -866,7 +867,7 @@ class Component(NylasAPIObject): api_root = "component" def __init__(self, api): - NylasAPIObject.__init__(self, RoomResource, api) + NylasAPIObject.__init__(self, Component, api) def as_json(self): dct = NylasAPIObject.as_json(self) @@ -876,6 +877,72 @@ def as_json(self): return dct +class Webhook(NylasAPIObject): + attrs = ( + "id", + "callback_url", + "state", + "triggers", + "application_id", + "version", + ) + read_only_attrs = {"id", "application_id", "version"} + + collection_name = "webhooks" + api_root = "a" + + def __init__(self, api): + NylasAPIObject.__init__(self, Webhook, api) + + def as_json(self): + dct = {} + # Only 'state' can get updated + if self.id: + dct["state"] = self.state + else: + dct = NylasAPIObject.as_json(self) + return dct + + class Trigger(str, Enum): + """ + This is an Enum representing all the possible webhook triggers + + see more: https://developer.nylas.com/docs/developer-tools/webhooks/available-webhooks + """ + + ACCOUNT_CONNECTED = "account.connected" + ACCOUNT_RUNNING = "account.running" + ACCOUNT_STOPPED = "account.stopped" + ACCOUNT_INVALID = "account.invalid" + ACCOUNT_SYNC_ERROR = "account.sync_error" + MESSAGE_CREATED = "message.created" + MESSAGE_OPENED = "message.opened" + MESSAGE_UPDATED = "message.updated" + MESSAGE_LINK_CLICKED = "message.link_clicked" + THREAD_REPLIED = "thread.replied" + CONTACT_CREATED = "contact.created" + CONTACT_UPDATED = "contact.updated" + CONTACT_DELETED = "contact.deleted" + CALENDAR_CREATED = "calendar.created" + CALENDAR_UPDATED = "calendar.updated" + CALENDAR_DELETED = "calendar.deleted" + EVENT_CREATED = "event.created" + EVENT_UPDATED = "event.updated" + EVENT_DELETED = "event.deleted" + JOB_SUCCESSFUL = "job.successful" + JOB_FAILED = "job.failed" + + class State(str, Enum): + """ + This is an Enum representing all the possible webhook states + + see more: https://developer.nylas.com/docs/developer-tools/webhooks/#enable-and-disable-webhooks + """ + + ACTIVE = "active" + INACTIVE = "inactive" + + class Namespace(NylasAPIObject): attrs = [ "account", diff --git a/setup.py b/setup.py index 6beac07f..e5f50308 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,7 @@ "requests[security]>=2.4.2", "six>=1.4.1", "urlobject", + "enum34>=1.1.10", ] TEST_DEPENDENCIES = [ "pytest", diff --git a/tests/conftest.py b/tests/conftest.py index 25b0bba8..ba778811 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1623,6 +1623,106 @@ def list_callback(arg=None): ) +@pytest.fixture +def mock_create_webhook(mocked_responses, api_url, client_id): + webhook = {"application_id": "application-id", "id": "webhook-id", "version": "1.0"} + + def callback(request): + try: + payload = json.loads(request.body) + except ValueError: + return 400, {}, "" + + if ( + "callback_url" not in payload + and ("triggers" not in payload and type(payload["triggers"]) is not list) + and "state" not in payload + ): + return 400, {}, "" + + webhook["callback_url"] = payload["callback_url"] + webhook["triggers"] = payload["triggers"] + webhook["state"] = payload["state"] + + return 200, {}, json.dumps(webhook) + + endpoint = "{base}/a/{client_id}/webhooks".format(base=api_url, client_id=client_id) + mocked_responses.add_callback( + responses.POST, + endpoint, + callback=callback, + content_type="application/json", + ) + + +@pytest.fixture +def mock_webhooks(mocked_responses, api_url, client_id): + webhook = { + "application_id": "application-id", + "callback_url": "https://your-server.com/webhook", + "id": "webhook-id", + "state": "active", + "triggers": ["message.created"], + "version": "2.0", + } + + def list_callback(request): + return 200, {}, json.dumps([webhook]) + + def single_callback(request): + webhook["id"] = get_id_from_url(request.url) + return 200, {}, json.dumps(webhook) + + def update_callback(request): + try: + payload = json.loads(request.body) + except ValueError: + return 400, {}, "" + + if "state" in payload: + webhook["state"] = payload["state"] + webhook["id"] = get_id_from_url(request.url) + return 200, {}, json.dumps(webhook) + + def delete_callback(request): + return 200, {}, json.dumps({"success": True}) + + def get_id_from_url(url): + path = URLObject(url).path + return path.rsplit("/", 1)[-1] + + endpoint_single = re.compile( + "{base}/a/{client_id}/webhooks/*".format(base=api_url, client_id=client_id) + ) + endpoint_list = "{base}/a/{client_id}/webhooks".format( + base=api_url, client_id=client_id + ) + mocked_responses.add_callback( + responses.GET, + endpoint_list, + content_type="application/json", + callback=list_callback, + ) + mocked_responses.add_callback( + responses.GET, + endpoint_single, + content_type="application/json", + callback=single_callback, + ) + mocked_responses.add_callback( + responses.PUT, + endpoint_single, + content_type="application/json", + callback=update_callback, + ) + mocked_responses.add_callback( + responses.DELETE, + endpoint_single, + content_type="application/json", + callback=delete_callback, + ) + + @pytest.fixture def mock_resources(mocked_responses, api_url): resources = [ diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 00000000..b4ee595a --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,80 @@ +import json + +import pytest +from urlobject import URLObject + +from nylas.client.restful_models import Webhook + + +@pytest.mark.usefixtures("mock_webhooks") +def test_webhooks(mocked_responses, api_client_with_client_id): + webhook = api_client_with_client_id.webhooks.first() + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/a/fake-client-id/webhooks" + assert request.method == "GET" + assert isinstance(webhook, Webhook) + assert webhook.id == "webhook-id" + assert webhook.application_id == "application-id" + assert webhook.callback_url == "https://your-server.com/webhook" + assert webhook.state == "active" + assert webhook.triggers == ["message.created"] + assert webhook.version == "2.0" + + +@pytest.mark.usefixtures("mock_webhooks") +def test_single_webhook(mocked_responses, api_client_with_client_id): + webhook = api_client_with_client_id.webhooks.get("abc123") + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/a/fake-client-id/webhooks/abc123" + assert request.method == "GET" + assert isinstance(webhook, Webhook) + assert webhook.id == "abc123" + + +@pytest.mark.usefixtures("mock_webhooks") +def test_update_webhook(mocked_responses, api_client_with_client_id): + webhook = api_client_with_client_id.webhooks.get("abc123") + webhook.state = Webhook.State.INACTIVE + webhook.save() + assert len(mocked_responses.calls) == 2 + request = mocked_responses.calls[1].request + assert URLObject(request.url).path == "/a/fake-client-id/webhooks/abc123" + assert request.method == "PUT" + assert json.loads(request.body) == {"state": "inactive"} + assert isinstance(webhook, Webhook) + assert webhook.id == "abc123" + assert webhook.state == "inactive" + + +@pytest.mark.usefixtures("mock_webhooks") +def test_delete_webhook(mocked_responses, api_client_with_client_id): + api_client_with_client_id.webhooks.delete("abc123") + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/a/fake-client-id/webhooks/abc123" + assert request.method == "DELETE" + + +@pytest.mark.usefixtures("mock_create_webhook") +def test_create_webhook(mocked_responses, api_client_with_client_id): + webhook = api_client_with_client_id.webhooks.create() + webhook.callback_url = "https://your-server.com/webhook" + webhook.triggers = [Webhook.Trigger.MESSAGE_CREATED] + webhook.state = Webhook.State.ACTIVE + webhook.application_id = "should-not-send" + webhook.version = "should-not-send" + webhook.save() + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/a/fake-client-id/webhooks" + assert request.method == "POST" + assert json.loads(request.body) == { + "callback_url": "https://your-server.com/webhook", + "triggers": ["message.created"], + "state": "active", + } + assert isinstance(webhook, Webhook) + assert webhook.id == "webhook-id" + assert webhook.application_id == "application-id" + assert webhook.callback_url == "https://your-server.com/webhook" + assert webhook.state == "active" + assert webhook.triggers == ["message.created"] + assert webhook.version == "1.0" From 5627648e21da6c93736cbd65a2f3cdf580d1aaaf Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 15 Feb 2022 10:22:59 -0500 Subject: [PATCH 022/186] [76809] Add Delta support (#201) This PR adds support for Deltas. --- CHANGELOG.md | 1 + nylas/client/client.py | 19 ++- nylas/client/delta_collection.py | 195 +++++++++++++++++++++++++++++++ nylas/client/delta_models.py | 73 ++++++++++++ nylas/client/restful_models.py | 5 +- tests/conftest.py | 188 +++++++++++++++++++++++++++++ tests/test_delta.py | 158 +++++++++++++++++++++++++ 7 files changed, 636 insertions(+), 3 deletions(-) create mode 100644 nylas/client/delta_collection.py create mode 100644 nylas/client/delta_models.py create mode 100644 tests/test_delta.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f57cdb..2fcd4854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased ---------------- +* Add Delta support * Add Webhook support * Omit `None` values from resulting `as_json()` object * Enable Nylas API v2.4 support diff --git a/nylas/client/client.py b/nylas/client/client.py index c19a7b03..8d49ce17 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -13,6 +13,7 @@ import six from six.moves.urllib.parse import urlencode from nylas._client_sdk_version import __VERSION__ +from nylas.client.delta_collection import DeltaCollection from nylas.client.errors import MessageRejectedError, NylasApiError from nylas.client.restful_model_collection import RestfulModelCollection from nylas.client.restful_models import ( @@ -480,6 +481,10 @@ def scheduler(self): def components(self): return RestfulModelCollection(Component, self) + @property + def deltas(self): + return DeltaCollection(self) + @property def webhooks(self): return RestfulModelCollection(Webhook, self) @@ -525,7 +530,15 @@ def _get_resources(self, cls, extra=None, **filters): return [cls.create(self, **x) for x in results if x is not None] def _get_resource_raw( - self, cls, id, extra=None, headers=None, stream=False, path=None, **filters + self, + cls, + id, + extra=None, + headers=None, + stream=False, + path=None, + stream_timeout=None, + **filters ): """Get an individual REST resource""" if path is None: @@ -554,7 +567,9 @@ def _get_resource_raw( headers = headers or {} headers.update(session.headers) - response = session.get(url, headers=headers, stream=stream) + response = session.get( + url, headers=headers, stream=stream, timeout=stream_timeout + ) return _validate(response) def _get_resource(self, cls, id, **filters): diff --git a/nylas/client/delta_collection.py b/nylas/client/delta_collection.py new file mode 100644 index 00000000..f01c29ad --- /dev/null +++ b/nylas/client/delta_collection.py @@ -0,0 +1,195 @@ +import json + +from requests import ReadTimeout + +from nylas.client.delta_models import Delta, Deltas + + +class DeltaCollection: + path = "delta" + + def __init__(self, api): + self.api = api + + def latest_cursor(self): + """ + Returns the latest delta cursor + + Returns: + str: The latest cursor + + Raises: + RuntimeError: If the server returns an object without a cursor + """ + + response = self.api._post_resource( + Delta, "latest_cursor", None, None, path=self.path + ) + if "cursor" not in response: + raise RuntimeError( + "Unexpected response from the API server. Returned 200 but no 'cursor' string found." + ) + + return response["cursor"] + + def since(self, cursor, view=None, include_types=None, excluded_types=None): + """ + Get a list of delta cursors since a specified cursor + + Args: + cursor (str): The first cursor to request from + view (str): Value representing if delta expands thread and message objects. + include_types (list[str] | str): The objects to exclusively include in the returned deltas. Note you cannot set both included and excluded types. + excluded_types (list[str] | str): The objects to exclude in the returned deltas. Note you cannot set both included and excluded types. + + Returns: + Deltas: The API response containing the list of deltas + + Raises: + ValueError: If both include_types and excluded_types are set + """ + + include_types, excluded_types = _validate_types(include_types, excluded_types) + response = self.api._get_resource_raw( + Delta, + None, + path=self.path, + cursor=cursor, + view=view, + include_types=include_types, + excluded_types=excluded_types, + ).json() + return Deltas.create(self.api, **response) + + def stream( + self, + cursor, + callback=None, + timeout=None, + view=None, + include_types=None, + excluded_types=None, + ): + """ + Stream deltas + + Args: + cursor (str): The cursor to stream from + callback: A callable function to invoke on each delta received. No callback is set by default. + timeout (int): The number of seconds to stream for before timing out. No timeout is set by default. + view (str): Value representing if delta expands thread and message objects. + include_types (list[str] | str): The objects to exclusively include in the returned deltas. Note you cannot set both included and excluded types. + excluded_types (list[str] | str): The objects to exclude in the returned deltas. Note you cannot set both included and excluded types. + + Returns: + list[Delta]: The list of streamed deltas + + Raises: + ValueError: If both include_types and excluded_types are set + """ + + deltas = [] + include_types, excluded_types = _validate_types(include_types, excluded_types) + emit_deltas = False + if callback and callable(callback): + emit_deltas = True + + try: + response = self.api._get_resource_raw( + Delta, + "streaming", + stream=True, + path=self.path, + stream_timeout=timeout, + cursor=cursor, + view=view, + include_types=include_types, + excluded_types=excluded_types, + ) + for raw_rsp in response.iter_lines(): + if raw_rsp: + response_json = json.loads(raw_rsp) + delta = Delta.create(self.api, **response_json) + deltas.append(delta) + if emit_deltas: + callback(delta) + except ReadTimeout: + pass + + return deltas + + def longpoll( + self, + cursor, + timeout, + callback=None, + view=None, + include_types=None, + excluded_types=None, + ): + """ + Long-poll for deltas + + Args: + cursor (str): The cursor to poll from + timeout (int): The number of seconds to poll for before timing out + callback: A callable function to invoke on each delta received. No callback is set by default. + view (str): Value representing if delta expands thread and message objects. + include_types (list[str] | str): The objects to exclusively include in the returned deltas. Note you cannot set both included and excluded types. + excluded_types (list[str] | str): The objects to exclude in the returned deltas. Note you cannot set both included and excluded types. + + Returns: + Deltas: The API response containing the list of deltas + + Raises: + ValueError: If both include_types and excluded_types are set + """ + + delta = {} + include_types, excluded_types = _validate_types(include_types, excluded_types) + emit_deltas = False + if callback and callable(callback): + emit_deltas = True + + buffer = bytearray() + response = self.api._get_resource_raw( + Delta, + "longpoll", + stream=True, + path=self.path, + timeout=timeout, + cursor=cursor, + view=view, + include_types=include_types, + excluded_types=excluded_types, + ) + for raw_rsp in response.iter_lines(): + if raw_rsp: + buffer.extend(raw_rsp) + try: + buffer_json = json.loads(buffer.decode()) + delta = Deltas.create(self.api, **buffer_json) + if emit_deltas: + callback(delta) + except ValueError: + continue + + return delta + + +# Helper functions for validating type inputs +def _validate_types(include_types, excluded_types): + if include_types and excluded_types: + raise ValueError("You cannot set both include_types and excluded_types") + + return _join_types(include_types), _join_types(excluded_types) + + +def _join_types(types): + if types: + if isinstance(types, str): + return types + try: + return ",".join(types) + except TypeError: + return None diff --git a/nylas/client/delta_models.py b/nylas/client/delta_models.py new file mode 100644 index 00000000..c5b104a8 --- /dev/null +++ b/nylas/client/delta_models.py @@ -0,0 +1,73 @@ +from nylas.client.restful_models import ( + RestfulModel, + NylasAPIObject, + Contact, + File, + Message, + Draft, + Thread, + Event, + Folder, + Label, +) + + +class Deltas(RestfulModel): + attrs = ( + "cursor_start", + "cursor_end", + "_deltas", + ) + read_only_attrs = tuple(attrs) + + def __init__(self, api): + RestfulModel.__init__(self, Deltas, api) + + @property + def deltas(self): + """ + Instantiate a Delta object from the API response + + Returns: + list[Delta]: List of Delta instantiated objects + """ + if self._deltas: + deltas = [] + for delta in self._deltas: + deltas.append(Delta.create(self.api, **delta)) + return deltas + + +class Delta(RestfulModel): + attrs = ( + "id", + "cursor", + "event", + "object", + "_attributes", + ) + read_only_attrs = tuple(attrs) + class_mapping = { + "contact": Contact, + "file": File, + "message": Message, + "draft": Draft, + "thread": Thread, + "event": Event, + "folder": Folder, + "label": Label, + } + + def __init__(self, api): + RestfulModel.__init__(self, Delta, api) + + @property + def attributes(self): + """ + Instantiate the object provided in the Delta + + Returns: + NylasAPIObject: The object of NylasAPIObject type represented in the Delta + """ + if self._attributes and self.object and self.object in self.class_mapping: + return self.class_mapping[self.object].create(self.api, **self._attributes) diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 77b1b04b..4c7b1e7a 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -55,11 +55,14 @@ def __init__(self, cls, api): def create(cls, api, **kwargs): object_type = kwargs.get("object") cls_object_type = getattr(cls, "object_type", cls.__name__.lower()) + # These are classes that should bypass the check below because they + # often represent other types (e.g. a delta's object type might be event) + class_check_whitelist = ["jobstatus", "delta"] if ( object_type and object_type != cls_object_type and object_type != "account" - and cls_object_type != "jobstatus" + and cls_object_type not in class_check_whitelist and not _is_subclass(cls, object_type) ): # We were given a specific object type and we're trying to diff --git a/tests/conftest.py b/tests/conftest.py index ba778811..ce8554d2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2241,3 +2241,191 @@ def file_callback(request): content_type="application/json", callback=file_callback, ) + + +@pytest.fixture +def mock_deltas_since(mocked_responses, api_url): + deltas = { + "cursor_start": "start_cursor", + "cursor_end": "end_cursor", + "deltas": [ + { + "attributes": { + "account_id": "aid-5678", + "given_name": "First", + "surname": "Last", + "id": "id-1234", + "object": "contact", + }, + "cursor": "contact_cursor", + "event": "create", + "id": "delta-1", + "object": "contact", + }, + { + "attributes": { + "account_id": "aid-5678", + "content_type": "text/plain", + "filename": "sample.txt", + "id": "id-1234", + "object": "file", + "size": 123, + }, + "cursor": "file_cursor", + "event": "create", + "id": "delta-2", + "object": "file", + }, + { + "attributes": { + "account_id": "aid-5678", + "to": [{"email": "foo", "name": "bar"}], + "subject": "foo", + "id": "id-1234", + "object": "message", + }, + "cursor": "message_cursor", + "event": "create", + "id": "delta-3", + "object": "message", + }, + { + "attributes": { + "account_id": "aid-5678", + "to": [{"email": "foo", "name": "bar"}], + "subject": "foo", + "id": "id-1234", + "object": "draft", + }, + "cursor": "draft_cursor", + "event": "create", + "id": "delta-4", + "object": "draft", + }, + { + "attributes": { + "account_id": "aid-5678", + "subject": "Subject", + "id": "id-1234", + "object": "thread", + }, + "cursor": "thread_cursor", + "event": "create", + "id": "delta-5", + "object": "thread", + }, + { + "attributes": { + "id": "id-1234", + "title": "test event", + "when": {"time": 1409594400, "object": "time"}, + "participants": [ + { + "name": "foo", + "email": "bar", + "status": "noreply", + "comment": "This is a comment", + "phone_number": "416-000-0000", + }, + ], + "ical_uid": "id-5678", + "master_event_id": "master-1234", + "original_start_time": 1409592400, + }, + "cursor": "event_cursor", + "event": "create", + "id": "delta-6", + "object": "event", + }, + { + "attributes": { + "account_id": "aid-5678", + "id": "id-1234", + "object": "folder", + "name": "inbox", + "display_name": "name", + }, + "cursor": "folder_cursor", + "event": "create", + "id": "delta-7", + "object": "folder", + }, + { + "attributes": { + "account_id": "aid-5678", + "id": "id-1234", + "object": "label", + "name": "inbox", + }, + "cursor": "label_cursor", + "event": "create", + "id": "delta-8", + "object": "label", + }, + ], + } + + def callback(request): + return 200, {}, json.dumps(deltas) + + mocked_responses.add_callback( + responses.GET, + "{base}/delta".format(base=api_url), + callback=callback, + content_type="application/json", + ) + + +@pytest.fixture +def mock_delta_cursor(mocked_responses, api_url): + def callback(request): + return 200, {}, json.dumps({"cursor": "cursor"}) + + mocked_responses.add_callback( + responses.POST, + "{base}/delta/latest_cursor".format(base=api_url), + callback=callback, + content_type="application/json", + ) + + +@pytest.fixture +def mock_delta_stream(mocked_responses, api_url): + delta = { + "attributes": { + "account_id": "aid-5678", + "given_name": "First", + "surname": "Last", + "id": "id-1234", + "object": "contact", + }, + "cursor": "contact_cursor", + "event": "create", + "id": "delta-1", + "object": "contact", + } + + def stream_callback(request): + return 200, {}, json.dumps(delta) + + def longpoll_callback(request): + response = { + "cursor_start": "start_cursor", + "cursor_end": "end_cursor", + "deltas": [delta], + } + return 200, {}, json.dumps(response) + + mocked_responses.add_callback( + responses.GET, + "{base}/delta/streaming".format(base=api_url), + callback=stream_callback, + content_type="application/json", + ) + + mocked_responses.add_callback( + responses.GET, + "{base}/delta/longpoll".format(base=api_url), + callback=longpoll_callback, + content_type="application/json", + ) diff --git a/tests/test_delta.py b/tests/test_delta.py new file mode 100644 index 00000000..1ce4648a --- /dev/null +++ b/tests/test_delta.py @@ -0,0 +1,158 @@ +import pytest +from urlobject import URLObject + +from nylas.client.delta_models import Deltas, Delta +from nylas.client.restful_models import ( + Contact, + File, + Message, + Draft, + Thread, + Event, + Folder, + Label, +) + + +@pytest.mark.usefixtures("mock_deltas_since") +def test_deltas_since(mocked_responses, api_client): + deltas = api_client.deltas.since("cursor") + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/delta" + assert URLObject(request.url).query_dict == {"cursor": "cursor"} + assert request.method == "GET" + assert isinstance(deltas, Deltas) + assert deltas.cursor_start == "start_cursor" + assert deltas.cursor_end == "end_cursor" + assert len(deltas.deltas) == 8 + assert isinstance(deltas.deltas[0].attributes, Contact) + assert deltas.deltas[0].cursor == "contact_cursor" + assert deltas.deltas[0].event == "create" + assert deltas.deltas[0].id == "delta-1" + assert deltas.deltas[0].object == "contact" + assert isinstance(deltas.deltas[1].attributes, File) + assert deltas.deltas[1].cursor == "file_cursor" + assert deltas.deltas[1].event == "create" + assert deltas.deltas[1].id == "delta-2" + assert deltas.deltas[1].object == "file" + assert isinstance(deltas.deltas[2].attributes, Message) + assert deltas.deltas[2].cursor == "message_cursor" + assert deltas.deltas[2].event == "create" + assert deltas.deltas[2].id == "delta-3" + assert deltas.deltas[2].object == "message" + assert isinstance(deltas.deltas[3].attributes, Draft) + assert deltas.deltas[3].cursor == "draft_cursor" + assert deltas.deltas[3].event == "create" + assert deltas.deltas[3].id == "delta-4" + assert deltas.deltas[3].object == "draft" + assert isinstance(deltas.deltas[4].attributes, Thread) + assert deltas.deltas[4].cursor == "thread_cursor" + assert deltas.deltas[4].event == "create" + assert deltas.deltas[4].id == "delta-5" + assert deltas.deltas[4].object == "thread" + assert isinstance(deltas.deltas[5].attributes, Event) + assert deltas.deltas[5].cursor == "event_cursor" + assert deltas.deltas[5].event == "create" + assert deltas.deltas[5].id == "delta-6" + assert deltas.deltas[5].object == "event" + assert isinstance(deltas.deltas[6].attributes, Folder) + assert deltas.deltas[6].cursor == "folder_cursor" + assert deltas.deltas[6].event == "create" + assert deltas.deltas[6].id == "delta-7" + assert deltas.deltas[6].object == "folder" + assert isinstance(deltas.deltas[7].attributes, Label) + assert deltas.deltas[7].cursor == "label_cursor" + assert deltas.deltas[7].event == "create" + assert deltas.deltas[7].id == "delta-8" + assert deltas.deltas[7].object == "label" + + +@pytest.mark.usefixtures("mock_delta_cursor") +def test_delta_cursor(mocked_responses, api_client): + cursor = api_client.deltas.latest_cursor() + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/delta/latest_cursor" + assert request.method == "POST" + assert cursor == "cursor" + + +def evaluate_contact_delta(delta): + assert isinstance(delta, Delta) + assert isinstance(delta.attributes, Contact) + assert delta.cursor == "contact_cursor" + assert delta.event == "create" + assert delta.id == "delta-1" + assert delta.object == "contact" + + +@pytest.mark.usefixtures("mock_delta_stream") +def test_delta_streaming(mocked_responses, api_client): + streaming = api_client.deltas.stream("cursor") + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/delta/streaming" + assert URLObject(request.url).query_dict == {"cursor": "cursor"} + assert request.method == "GET" + assert len(streaming) == 1 + evaluate_contact_delta(streaming[0]) + + +@pytest.mark.usefixtures("mock_delta_stream") +def test_delta_longpoll(mocked_responses, api_client): + longpoll = api_client.deltas.longpoll("cursor", 30) + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/delta/longpoll" + assert URLObject(request.url).query_dict == {"cursor": "cursor", "timeout": "30"} + assert request.method == "GET" + assert isinstance(longpoll, Deltas) + assert longpoll.cursor_start == "start_cursor" + assert longpoll.cursor_end == "end_cursor" + assert len(longpoll.deltas) == 1 + evaluate_contact_delta(longpoll.deltas[0]) + + +@pytest.mark.usefixtures("mock_delta_stream") +def test_delta_callback(mocked_responses, api_client): + api_client.deltas.stream("cursor", callback=evaluate_contact_delta) + + +@pytest.mark.usefixtures("mock_delta_stream") +def test_delta_optional_params(mocked_responses, api_client): + api_client.deltas.longpoll( + "cursor", 30, view="expanded", include_types=["event", "file"] + ) + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/delta/longpoll" + assert URLObject(request.url).query_dict == { + "cursor": "cursor", + "timeout": "30", + "view": "expanded", + "include_types": "event,file", + } + assert request.method == "GET" + + +@pytest.mark.usefixtures("mock_delta_stream") +def test_delta_type_string(mocked_responses, api_client): + api_client.deltas.longpoll("cursor", 30, view="expanded", excluded_types="event") + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/delta/longpoll" + assert URLObject(request.url).query_dict == { + "cursor": "cursor", + "timeout": "30", + "view": "expanded", + "excluded_types": "event", + } + assert request.method == "GET" + + +@pytest.mark.usefixtures("mock_delta_stream") +def test_delta_set_both_types_raise_error(api_client): + with pytest.raises(ValueError) as excinfo: + api_client.deltas.longpoll( + "cursor", + 30, + view="expanded", + excluded_types="event", + include_types="file", + ) + assert "You cannot set both include_types and excluded_types" in str(excinfo) From b06b92de297a609b2b32ea08b1c5db9a88f0d083 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 15 Feb 2022 10:34:39 -0500 Subject: [PATCH 023/186] v5.6.0 Release (#202) New nylas v5.6.0 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f527020a..b3bec7ae 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.5.1 +current_version = 5.6.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fcd4854..20a27478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v5.6.0 ---------------- * Add Delta support * Add Webhook support diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index e85dcca9..b31f89c6 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.5.1" +__VERSION__ = "5.6.0" From 87a9d1101379972f9245df96edc274516118630f Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Mon, 7 Mar 2022 12:42:29 -0500 Subject: [PATCH 024/186] [TDSK-108] Outbox Support (#203) This PR brings support to the new outbox beta. --- CHANGELOG.md | 4 + nylas/client/client.py | 71 +++++++++----- nylas/client/outbox_models.py | 178 ++++++++++++++++++++++++++++++++++ tests/conftest.py | 84 ++++++++++++++++ tests/test_outbox.py | 126 ++++++++++++++++++++++++ 5 files changed, 440 insertions(+), 23 deletions(-) create mode 100644 nylas/client/outbox_models.py create mode 100644 tests/test_outbox.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a27478..1eea632c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add Outbox support + v5.6.0 ---------------- * Add Delta support diff --git a/nylas/client/client.py b/nylas/client/client.py index 8d49ce17..959c3aa4 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -8,13 +8,13 @@ from itertools import chain import requests -from requests import HTTPError from urlobject import URLObject import six from six.moves.urllib.parse import urlencode from nylas._client_sdk_version import __VERSION__ from nylas.client.delta_collection import DeltaCollection from nylas.client.errors import MessageRejectedError, NylasApiError +from nylas.client.outbox_models import Outbox from nylas.client.restful_model_collection import RestfulModelCollection from nylas.client.restful_models import ( Calendar, @@ -493,6 +493,10 @@ def webhooks(self): def neural(self): return Neural(self) + @property + def outbox(self): + return Outbox(self) + ########################################################## # Private functions used by Restful Model Collection # ########################################################## @@ -532,7 +536,7 @@ def _get_resources(self, cls, extra=None, **filters): def _get_resource_raw( self, cls, - id, + resource_id, extra=None, headers=None, stream=False, @@ -545,10 +549,10 @@ def _get_resource_raw( path = cls.collection_name postfix = "/{}".format(extra) if extra else "" path = "/{}".format(path) if path else "" - id = "/{}".format(id) if id else "" + resource_id = "/{}".format(resource_id) if resource_id else "" if not cls.api_root: url = "{server}{path}{id}{postfix}".format( - server=self.api_server, path=path, id=id, postfix=postfix + server=self.api_server, path=path, id=resource_id, postfix=postfix ) else: url = "{server}/{prefix}/{client_id}{path}{id}{postfix}".format( @@ -556,7 +560,7 @@ def _get_resource_raw( prefix=cls.api_root, client_id=self.client_id, path=path, - id=id, + id=resource_id, postfix=postfix, ) @@ -572,16 +576,16 @@ def _get_resource_raw( ) return _validate(response) - def _get_resource(self, cls, id, **filters): - response = self._get_resource_raw(cls, id, **filters) + def _get_resource(self, cls, resource_id, **filters): + response = self._get_resource_raw(cls, resource_id, **filters) result = response.json() if isinstance(result, list): result = result[0] return cls.create(self, **result) - def _get_resource_data(self, cls, id, extra=None, headers=None, **filters): + def _get_resource_data(self, cls, resource_id, extra=None, headers=None, **filters): response = self._get_resource_raw( - cls, id, extra=extra, headers=headers, **filters + cls, resource_id, extra=extra, headers=headers, **filters ) return response.content @@ -636,7 +640,7 @@ def _create_resources(self, cls, data): results = _validate(response).json() return [cls.create(self, **x) for x in results] - def _delete_resource(self, cls, id, data=None, **kwargs): + def _delete_resource(self, cls, resource_id, data=None, **kwargs): name = "{prefix}{path}".format( prefix="/{}/{}".format(cls.api_root, self.client_id) if cls.api_root @@ -645,7 +649,7 @@ def _delete_resource(self, cls, id, data=None, **kwargs): ) url = ( URLObject(self.api_server) - .with_path("{name}/{id}".format(name=name, id=id)) + .with_path("{name}/{id}".format(name=name, id=resource_id)) .set_query_params(**kwargs) ) session = self._get_http_session(cls.api_root) @@ -654,7 +658,9 @@ def _delete_resource(self, cls, id, data=None, **kwargs): else: _validate(session.delete(url)) - def _put_resource(self, cls, id, data, extra=None, path=None, **kwargs): + def _setup_update_resource( + self, cls, resource_id, data, extra=None, path=None, **kwargs + ): if path is None: path = cls.collection_name name = "{prefix}{path}".format( @@ -667,37 +673,56 @@ def _put_resource(self, cls, id, data, extra=None, path=None, **kwargs): postfix = "/{}".format(extra) if extra else "" url = ( URLObject(self.api_server) - .with_path("{name}/{id}{postfix}".format(name=name, id=id, postfix=postfix)) + .with_path( + "{name}/{id}{postfix}".format( + name=name, id=resource_id, postfix=postfix + ) + ) .set_query_params(**kwargs) ) + converted_data = create_request_body(data, cls.datetime_attrs) - session = self._get_http_session(cls.api_root) + return url, self._get_http_session(cls.api_root), converted_data - converted_data = create_request_body(data, cls.datetime_attrs) + def _patch_resource(self, cls, resource_id, data, extra=None, path=None, **kwargs): + url, session, converted_data = self._setup_update_resource( + cls, resource_id, data, extra=extra, path=path, **kwargs + ) + response = session.patch(url, json=converted_data) + + result = _validate(response) + return result.json() + + def _put_resource(self, cls, resource_id, data, extra=None, path=None, **kwargs): + url, session, converted_data = self._setup_update_resource( + cls, resource_id, data, extra=extra, path=path, **kwargs + ) response = session.put(url, json=converted_data) result = _validate(response) return result.json() - def _update_resource(self, cls, id, data, **kwargs): - result = self._put_resource(cls, id, data, **kwargs) + def _update_resource(self, cls, resource_id, data, **kwargs): + result = self._put_resource(cls, resource_id, data, **kwargs) return cls.create(self, **result) - def _post_resource(self, cls, id, method_name, data, path=None): + def _post_resource(self, cls, resource_id, method_name, data, path=None): if path is None: path = cls.collection_name path = "/{}".format(path) if path else "" - id = "/{}".format(id) if id else "" + resource_id = "/{}".format(resource_id) if resource_id else "" method = "/{}".format(method_name) if method_name else "" if not cls.api_root: - url_path = "{name}{id}{method}".format(name=path, id=id, method=method) + url_path = "{name}{id}{method}".format( + name=path, id=resource_id, method=method + ) else: # Management method. url_path = "/{prefix}/{client_id}{path}{id}{method}".format( prefix=cls.api_root, client_id=self.client_id, path=path, - id=id, + id=resource_id, method=method, ) @@ -709,11 +734,11 @@ def _post_resource(self, cls, id, method_name, data, path=None): return _validate(response).json() - def _call_resource_method(self, cls, id, method_name, data): + def _call_resource_method(self, cls, resource_id, method_name, data): """POST a dictionary to an API method, for example /a/.../accounts/id/upgrade""" - result = self._post_resource(cls, id, method_name, data) + result = self._post_resource(cls, resource_id, method_name, data) return cls.create(self, **result) def _request_neural_resource(self, cls, data, path=None, method="PUT"): diff --git a/nylas/client/outbox_models.py b/nylas/client/outbox_models.py new file mode 100644 index 00000000..f5731ca3 --- /dev/null +++ b/nylas/client/outbox_models.py @@ -0,0 +1,178 @@ +from datetime import datetime + +from nylas.client.restful_models import RestfulModel, Draft +from nylas.utils import timestamp_from_dt + + +class OutboxMessage(RestfulModel): + attrs = Draft.attrs + [ + "send_at", + "retry_limit_datetime", + "original_send_at", + ] + datetime_attrs = { + "send_at": "send_at", + "retry_limit_datetime": "retry_limit_datetime", + "original_send_at": "original_send_at", + } + read_only_attrs = {"send_at", "retry_limit_datetime", "original_send_at"} + collection_name = "v2/outbox" + + def __init__(self, api): + RestfulModel.__init__(self, OutboxMessage, api) + + +class OutboxJobStatus(RestfulModel): + attrs = [ + "account_id", + "job_status_id", + "status", + "original_data", + ] + collection_name = "v2/outbox" + + def __init__(self, api): + RestfulModel.__init__(self, OutboxJobStatus, api) + + +class SendGridVerifiedStatus(RestfulModel): + attrs = [ + "domain_verified", + "sender_verified", + ] + collection_name = "v2/outbox/onboard" + + def __init__(self, api): + RestfulModel.__init__(self, SendGridVerifiedStatus, api) + + +class Outbox: + def __init__(self, api): + self.api = api + + def send(self, draft, send_at, retry_limit_datetime=None): + """ + Send a message via Outbox + + Args: + draft (Draft | OutboxMessage): The message to send + send_at (datetime | int): The date and time to send the message. If set to 0, Outbox will send this message immediately. + retry_limit_datetime (datetime | int): Optional date and time to stop retry attempts for a message. + + Returns: + OutboxJobStatus: The Outbox message job status + + Raises: + ValueError: If the date and times provided are not valid + """ + draft_json = draft.as_json() + send_at, retry_limit_datetime = self._validate_and_format_datetime( + send_at, retry_limit_datetime + ) + + draft_json["send_at"] = send_at + if retry_limit_datetime is not None: + draft_json["retry_limit_datetime"] = retry_limit_datetime + + return self.api._create_resource(OutboxJobStatus, draft_json) + + def update( + self, job_status_id, draft=None, send_at=None, retry_limit_datetime=None + ): + """ + Update a scheduled Outbox message + + Args: + job_status_id (str): The ID of the outbox job status + draft (Draft | OutboxMessage): The message object with updated values + send_at (datetime | int): The date and time to send the message. If set to 0, Outbox will send this message immediately. + retry_limit_datetime (datetime | int): Optional date and time to stop retry attempts for a message. + + Returns: + OutboxJobStatus: The updated Outbox message job status + + Raises: + ValueError: If the date and times provided are not valid + """ + payload = {} + if draft: + payload = draft.as_json() + send_at, retry_limit_datetime = self._validate_and_format_datetime( + send_at, retry_limit_datetime + ) + + if send_at is not None: + payload["send_at"] = send_at + if retry_limit_datetime is not None: + payload["retry_limit_datetime"] = retry_limit_datetime + + response = self.api._patch_resource(OutboxJobStatus, job_status_id, payload) + return OutboxJobStatus.create(self.api, **response) + + def delete(self, job_status_id): + """ + Delete a scheduled Outbox message + + Args: + job_status_id (str): The ID of the outbox job status to delete + """ + + self.api._delete_resource(OutboxJobStatus, job_status_id) + + def send_grid_verification_status(self): + """ + SendGrid - Check Authentication and Verification Status + + Returns: + SendGridVerifiedStatus: The status of the domain authentication and the single sender verification for SendGrid integrations + + Raises: + RuntimeError: If the server returns an object without results + """ + response = self.api._get_resource_raw( + SendGridVerifiedStatus, None, extra="verified_status" + ) + response_body = response.json() + if "results" not in response_body: + raise RuntimeError( + "Unexpected response from the API server. Returned 200 but no 'ics' string found." + ) + return SendGridVerifiedStatus.create(self.api, **response_body["results"]) + + def delete_send_grid_sub_user(self, email_address): + """ + SendGrid - Delete SendGrid Subuser and UAS Grant + + Args: + email_address (str): Email address for SendGrid subuser to delete + """ + payload = {"email": email_address} + + self.api._delete_resource(SendGridVerifiedStatus, "subuser", data=payload) + + def _validate_and_format_datetime(self, send_at, retry_limit_datetime): + send_at_epoch = ( + timestamp_from_dt(send_at) if isinstance(send_at, datetime) else send_at + ) + retry_limit_datetime_epoch = ( + timestamp_from_dt(retry_limit_datetime) + if isinstance(retry_limit_datetime, datetime) + else retry_limit_datetime + ) + now_epoch = timestamp_from_dt(datetime.today()) + + if send_at_epoch and send_at_epoch != 0 and send_at_epoch < now_epoch: + raise ValueError( + "Cannot set message to be sent at a time before the current time." + ) + + if retry_limit_datetime_epoch and retry_limit_datetime_epoch != 0: + current_send_at = ( + send_at_epoch if send_at_epoch and send_at_epoch != 0 else now_epoch + ) + if retry_limit_datetime_epoch < current_send_at: + raise ValueError( + "Cannot set message to stop retrying before time to send at." + ) + + return send_at_epoch, retry_limit_datetime_epoch diff --git a/tests/conftest.py b/tests/conftest.py index ce8554d2..1fbac973 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2429,3 +2429,87 @@ def longpoll_callback(request): callback=longpoll_callback, content_type="application/json", ) + + +@pytest.fixture +def mock_outbox(mocked_responses, api_url): + outbox_job_status = { + "job_status_id": "job-status-id", + "status": "pending", + "original_data": { + "subject": "With Love, from Nylas", + "to": [{"name": "Me", "email": "test@email.com"}], + "body": "This email was sent using the Nylas email API. Visit https://nylas.com for details.", + }, + "account_id": "account-id", + } + + def return_job_status(request): + response = outbox_job_status + payload = json.loads(request.body) + if "send_at" in payload: + response["original_data"]["send_at"] = payload["send_at"] + response["original_data"]["original_send_at"] = payload["send_at"] + response["original_data"]["retry_limit_datetime"] = payload["send_at"] + if "retry_limit_datetime" in payload: + response["original_data"]["retry_limit_datetime"] = payload[ + "retry_limit_datetime" + ] + return 200, {}, json.dumps(response) + + def delete_callback(request): + return 200, {}, "" + + outbox_endpoint = "{base}/v2/outbox".format(base=api_url) + endpoint_single = re.compile("{outbox_url}/*".format(outbox_url=outbox_endpoint)) + + mocked_responses.add_callback( + responses.POST, + outbox_endpoint, + callback=return_job_status, + content_type="application/json", + ) + + mocked_responses.add_callback( + responses.PATCH, + endpoint_single, + callback=return_job_status, + content_type="application/json", + ) + + mocked_responses.add_callback( + responses.DELETE, + endpoint_single, + callback=delete_callback, + content_type="application/json", + ) + + +@pytest.fixture +def mock_outbox_send_grid(mocked_responses, api_url): + send_grid_verification = { + "results": {"domain_verified": True, "sender_verified": True} + } + + def return_status(request): + return 200, {}, json.dumps(send_grid_verification) + + def delete_callback(request): + return 200, {}, "" + + verification_url = "{base}/v2/outbox/onboard/verified_status".format(base=api_url) + delete_url = "{base}/v2/outbox/onboard/subuser".format(base=api_url) + + mocked_responses.add_callback( + responses.GET, + verification_url, + callback=return_status, + content_type="application/json", + ) + + mocked_responses.add_callback( + responses.DELETE, + delete_url, + callback=delete_callback, + content_type="application/json", + ) diff --git a/tests/test_outbox.py b/tests/test_outbox.py new file mode 100644 index 00000000..3adff8f0 --- /dev/null +++ b/tests/test_outbox.py @@ -0,0 +1,126 @@ +from datetime import datetime, timedelta +import json + +import pytest +from urlobject import URLObject + +from nylas.utils import timestamp_from_dt + + +@pytest.mark.usefixtures("mock_outbox") +def test_outbox_send(mocked_responses, api_client): + draft, tomorrow, day_after = prepare_outbox_request(api_client) + + job_status = api_client.outbox.send(draft, tomorrow, retry_limit_datetime=day_after) + + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/v2/outbox" + assert request.method == "POST" + body = json.loads(request.body) + evaluate_message(body, tomorrow, day_after) + assert job_status["job_status_id"] == "job-status-id" + assert job_status["status"] == "pending" + assert job_status["account_id"] == "account-id" + original_data = job_status["original_data"] + evaluate_message(original_data, tomorrow, day_after) + assert original_data["original_send_at"] == timestamp_from_dt(tomorrow) + + +@pytest.mark.usefixtures("mock_outbox") +def test_outbox_update(mocked_responses, api_client): + draft, tomorrow, day_after = prepare_outbox_request(api_client) + + api_client.outbox.update( + "job-status-id", draft=draft, send_at=tomorrow, retry_limit_datetime=day_after + ) + + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/v2/outbox/job-status-id" + assert request.method == "PATCH" + body = json.loads(request.body) + evaluate_message(body, tomorrow, day_after) + + +@pytest.mark.usefixtures("mock_outbox") +def test_outbox_send_at_before_today_should_raise(mocked_responses, api_client): + with pytest.raises(ValueError) as excinfo: + api_client.outbox._validate_and_format_datetime(636309514, None) + assert "Cannot set message to be sent at a time before the current time." in str( + excinfo + ) + + +@pytest.mark.usefixtures("mock_outbox") +def test_outbox_retry_limit_datetime_before_send_at_should_raise( + mocked_responses, api_client +): + tomorrow = datetime.today() + timedelta(days=1) + day_after = tomorrow + timedelta(days=1) + with pytest.raises(ValueError) as excinfo: + api_client.outbox._validate_and_format_datetime( + send_at=day_after, retry_limit_datetime=tomorrow + ) + assert "Cannot set message to stop retrying before time to send at." in str(excinfo) + + +@pytest.mark.usefixtures("mock_outbox") +def test_outbox_retry_limit_datetime_before_today_should_raise( + mocked_responses, api_client +): + with pytest.raises(ValueError) as excinfo: + api_client.outbox._validate_and_format_datetime(None, 636309514) + assert "Cannot set message to stop retrying before time to send at." in str(excinfo) + + +@pytest.mark.usefixtures("mock_outbox") +def test_outbox_delete(mocked_responses, api_client): + api_client.outbox.delete("job-status-id") + + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/v2/outbox/job-status-id" + assert request.method == "DELETE" + + +@pytest.mark.usefixtures("mock_outbox_send_grid") +def test_outbox_send_grid_verification(mocked_responses, api_client): + verification_status = api_client.outbox.send_grid_verification_status() + + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/v2/outbox/onboard/verified_status" + assert request.method == "GET" + assert verification_status.domain_verified is True + assert verification_status.sender_verified is True + + +@pytest.mark.usefixtures("mock_outbox_send_grid") +def test_outbox_send_grid_verification(mocked_responses, api_client): + api_client.outbox.delete_send_grid_sub_user("test@email.com") + + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/v2/outbox/onboard/subuser" + assert request.method == "DELETE" + + +# Test helpers + + +def prepare_outbox_request(api_client): + draft = api_client.drafts.create() + draft.subject = "With Love, from Nylas" + draft.to = [{"email": "test@email.com", "name": "Me"}] + draft.body = "This email was sent using the Nylas email API. Visit https://nylas.com for details." + tomorrow = datetime.today() + timedelta(days=1) + day_after = tomorrow + timedelta(days=1) + + return draft, tomorrow, day_after + + +def evaluate_message(message, send_at, retry_limit_datetime): + assert message["to"] == [{"email": "test@email.com", "name": "Me"}] + assert message["subject"] == "With Love, from Nylas" + assert ( + message["body"] + == "This email was sent using the Nylas email API. Visit https://nylas.com for details." + ) + assert message["send_at"] == timestamp_from_dt(send_at) + assert message["retry_limit_datetime"] == timestamp_from_dt(retry_limit_datetime) From 4d396e896ca27e191bfdae1c582c86784a0784ff Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 25 Mar 2022 21:17:43 -0400 Subject: [PATCH 025/186] Add missing metadata field in Draft (#206) --- CHANGELOG.md | 1 + nylas/client/restful_models.py | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eea632c..288699a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Add Outbox support +* Fix `Draft` not sending metadata v5.6.0 ---------------- diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 4c7b1e7a..ace3bdf9 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -484,6 +484,7 @@ class Draft(Message): "starred", "snippet", "tracking", + "metadata", ] datetime_attrs = {"last_modified_at": "date"} collection_name = "drafts" From 65b814df3becd9bc8dbcc361db8d5d70e2f4e8d2 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Mon, 28 Mar 2022 13:25:51 -0400 Subject: [PATCH 026/186] Enable Nylas API v2.5 support (#204) Enable Nylas API v2.5 for the Python SDK --- CHANGELOG.md | 1 + nylas/client/client.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 288699a0..2e256db0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Add Outbox support +* Enable Nylas API v2.5 support * Fix `Draft` not sending metadata v5.6.0 diff --git a/nylas/client/client.py b/nylas/client/client.py index 959c3aa4..255924fd 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -42,7 +42,7 @@ DEBUG = environ.get("NYLAS_CLIENT_DEBUG") API_SERVER = "https://api.nylas.com" -SUPPORTED_API_VERSION = "2.4" +SUPPORTED_API_VERSION = "2.5" def _validate(response): From 0c6f13b4084793ea0034b2348232115e41b3f7fe Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Wed, 30 Mar 2022 11:51:18 -0400 Subject: [PATCH 027/186] Add support for `limit` and `offset` for message/thread search (#208) This PR adds limit and offset as parameters for message/thread search. --- CHANGELOG.md | 1 + nylas/client/restful_model_collection.py | 6 +++++- tests/conftest.py | 2 +- tests/test_search.py | 7 +++++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e256db0..6bcc747f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Add Outbox support +* Add support for `limit` and `offset` for message/thread search * Enable Nylas API v2.5 support * Fix `Draft` not sending metadata diff --git a/nylas/client/restful_model_collection.py b/nylas/client/restful_model_collection.py index 596c2a68..8fc9cf41 100644 --- a/nylas/client/restful_model_collection.py +++ b/nylas/client/restful_model_collection.py @@ -96,7 +96,7 @@ def create(self, **kwargs): def delete(self, id, data=None, **kwargs): return self.api._delete_resource(self.model_class, id, data=data, **kwargs) - def search(self, q): # pylint: disable=invalid-name + def search(self, q, limit=None, offset=None): # pylint: disable=invalid-name from nylas.client.restful_models import ( Message, Thread, @@ -104,6 +104,10 @@ def search(self, q): # pylint: disable=invalid-name if self.model_class is Thread or self.model_class is Message: kwargs = {"q": q} + if limit is not None: + kwargs["limit"] = limit + if offset is not None: + kwargs["offset"] = offset return self.api._get_resources(self.model_class, extra="search", **kwargs) else: raise Exception("Searching is only allowed on Thread and Message models") diff --git a/tests/conftest.py b/tests/conftest.py index 1fbac973..51086136 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1023,7 +1023,7 @@ def mock_message_search_response(mocked_responses, api_url): mocked_responses.add( responses.GET, - api_url + "/messages/search?q=Pinot", + re.compile(api_url + "/messages/search\?q=Pinot.*"), body=response_body, status=200, content_type="application/json", diff --git a/tests/test_search.py b/tests/test_search.py index bdac4b32..52aa733d 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -16,6 +16,13 @@ def test_search_messages(api_client): assert "Pinot" in messages[1].snippet +@pytest.mark.usefixtures("mock_message_search_response") +def test_search_messages_with_limit_offset(mocked_responses, api_client): + api_client.messages.search("Pinot", limit=10, offset=0) + request = mocked_responses.calls[0].request + assert request.path_url == "/messages/search?q=Pinot&limit=10&offset=0" + + @pytest.mark.usefixtures("mock_message_search_response") def test_search_drafts(api_client): with pytest.raises(Exception): From 7d93620e4003fb20b4b7a76c2923f14aa621a5be Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 31 Mar 2022 13:56:17 -0400 Subject: [PATCH 028/186] [TSDK-263] Add new authentication method field to Account (#209) This PR adds support for new authentication_method field in Account. --- CHANGELOG.md | 1 + nylas/client/restful_models.py | 1 + tests/conftest.py | 1 + 3 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bcc747f..50ad5667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Unreleased ---------------- * Add Outbox support * Add support for `limit` and `offset` for message/thread search +* Add `authentication_type` field to `Account` * Enable Nylas API v2.5 support * Fix `Draft` not sending metadata diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index ace3bdf9..124141f0 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -978,6 +978,7 @@ class Account(NylasAPIObject): "namespace_id", "provider", "sync_state", + "authentication_type", "trial", "metadata", ] diff --git a/tests/conftest.py b/tests/conftest.py index 51086136..3bdbe97c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1800,6 +1800,7 @@ def mock_account_management(mocked_responses, api_url, account_id, client_id): "provider": "gmail", "organization_unit": "label", "billing_state": "paid", + "authentication_type": "password", } paid_response = json.dumps(account) account["billing_state"] = "cancelled" From b7a7373aa3561d8b7772bf5029cdfdae8dd7b963 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 31 Mar 2022 16:58:06 -0400 Subject: [PATCH 029/186] [TSDK-242] Add support for new (beta) Integrations authentication (#207) This PR adds support for new beta authentication, specifically: Integration API, Grants API, Hosted Authentication --- CHANGELOG.md | 1 + nylas/client/authentication_models.py | 330 ++++++++++++++++++++++++++ nylas/client/client.py | 151 +++++++----- nylas/client/neural_api_models.py | 3 +- nylas/client/restful_models.py | 24 +- nylas/utils.py | 23 ++ tests/conftest.py | 184 ++++++++++++++ tests/test_authentication.py | 238 +++++++++++++++++++ tests/test_client.py | 24 +- 9 files changed, 898 insertions(+), 80 deletions(-) create mode 100644 nylas/client/authentication_models.py create mode 100644 tests/test_authentication.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ad5667..6e7a4421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Add Outbox support +* Add support for new (beta) Integrations authentication (Integrations API, Grants API, Hosted Authentication for Integrations) * Add support for `limit` and `offset` for message/thread search * Add `authentication_type` field to `Account` * Enable Nylas API v2.5 support diff --git a/nylas/client/authentication_models.py b/nylas/client/authentication_models.py new file mode 100644 index 00000000..7574b117 --- /dev/null +++ b/nylas/client/authentication_models.py @@ -0,0 +1,330 @@ +from copy import copy + +from nylas.client.restful_model_collection import RestfulModelCollection, CHUNK_SIZE +from nylas.client.restful_models import NylasAPIObject +from nylas.utils import AuthMethod +from enum import Enum + + +class Integration(NylasAPIObject): + attrs = ( + "name", + "provider", + "expires_in", + "settings", + "redirect_uris", + "scope", + "id", + ) + read_only_attrs = {"provider", "id"} + auth_method = AuthMethod.BASIC_CLIENT_ID_AND_SECRET + collection_name = "connect/integrations" + + def __init__(self, api): + NylasAPIObject.__init__(self, Integration, api) + self.settings = {} + self.scope = [] + + def set_client_id(self, client_id): + """ + Set the client ID of the OAuth provider + + Args: + client_id (str): Client ID of the OAuth provider + """ + self.settings["client_id"] = client_id + + def set_client_secret(self, client_secret): + """ + Set the client secret of the OAuth provider + + Args: + client_secret (str): Client secret of the OAuth provider + """ + self.settings["client_secret"] = client_secret + + @classmethod + def create(cls, api, **kwargs): + if "data" in kwargs: + kwargs = kwargs.get("data") + obj = super(Integration, cls).create(api, **kwargs) + if "provider" in kwargs: + obj["id"] = kwargs.get("provider") + + return obj + + def as_json(self): + dct = super(Integration, self).as_json() + if not self.id: + if isinstance(self.provider, Authentication.Provider): + dct["provider"] = self.provider.value + else: + dct["provider"] = self.provider + + return dct + + def _update_resource(self, **kwargs): + provider = self.id or self.provider + return self.api._patch_resource(self.cls, provider, self.as_json(), **kwargs) + + +class Grant(NylasAPIObject): + attrs = ( + "id", + "provider", + "state", + "email", + "ip", + "grant_status", + "user_agent", + "created_at", + "updated_at", + "settings", + "metadata", + "scope", + ) + read_only_attrs = { + "id", + "email", + "ip", + "grant_status", + "user_agent", + "created_at", + "updated_at", + } + auth_method = AuthMethod.BASIC_CLIENT_ID_AND_SECRET + collection_name = "connect/grants" + + def __init__(self, api): + NylasAPIObject.__init__(self, Grant, api) + self.settings = {} + self.metadata = {} + self.scope = [] + + @classmethod + def create(cls, api, **kwargs): + if "data" in kwargs: + kwargs = kwargs.get("data") + obj = super(Grant, cls).create(api, **kwargs) + return obj + + def as_json(self): + dct = super(Grant, self).as_json() + # provider and state can not be updated + if self.id: + del dct["provider"] + del dct["state"] + else: + if isinstance(self.provider, Authentication.Provider): + dct["provider"] = self.provider.value + else: + dct["provider"] = self.provider + + return dct + + def _update_resource(self, **kwargs): + return self.api._patch_resource(self.cls, self.id, self.as_json(), **kwargs) + + +class Authentication(object): + def __init__(self, api): + self._app_name = "beta" + self._region = Authentication.Region.US + # Make a copy of the API as we need to change the base url for Integration calls + self.api = copy(api) + self._set_integrations_api_url() + + @property + def app_name(self): + return self._app_name + + @app_name.setter + def app_name(self, value): + """ + Set the name of the application to prefix the URL for all integration calls for this instance + + Args: + value (str): The name of the application + """ + self._app_name = value + self._set_integrations_api_url() + + @property + def region(self): + return self._region + + @region.setter + def region(self, value): + """ + Set the region to prefix the URL for all integration calls for this instance + + Args: + value (Integration.Region): The region + """ + self._region = value + self._set_integrations_api_url() + + @property + def integrations(self): + """ + Integrations API for integrating a provider to the Nylas application + + Returns: + IntegrationRestfulModelCollection: The Integration API configured with the app_name and region + """ + return IntegrationRestfulModelCollection(self.api) + + @property + def grants(self): + """ + Native Authentication for the integrated provider + + Returns: + GrantRestfulModelCollection: The Grants API configured with the app_name and region + """ + return GrantRestfulModelCollection(self.api) + + def hosted_authentication( + self, + provider, + redirect_uri, + grant_id=None, + login_hint=None, + state=None, + expires_in=None, + settings=None, + metadata=None, + scope=None, + ): + """ + Hosted Authentication for the integrated provider + + Args: + provider (Authentication.Provider): OAuth provider + redirect_uri (str): The URI for the final redirect + grant_id (str): Existing Grant ID to trigger a re-authentication + login_hint (str): Hint to simplify the login flow + state (str): State value to return after authentication flow is completed + expires_in (int): How long this request (and the attached login) ID will remain valid before the link expires + settings (dict[str, str]): Settings required by provider + metadata (dict[str, any]): Metadata to store as part of the grant + scope (list[str]): OAuth provider-specific scopes + + Returns: + dict[str, any]: The login information + """ + request = {"provider": provider, "redirect_uri": redirect_uri} + if grant_id: + request["grant_id"] = grant_id + if login_hint: + request["login_hint"] = login_hint + if state: + request["state"] = state + if expires_in: + request["expires_in"] = expires_in + if settings: + request["settings"] = settings + if metadata: + request["metadata"] = metadata + if scope: + request["scope"] = scope + + response = self.api._post_resource(Grant, "auth", None, request, path="connect") + if "data" in response: + response = response["data"] + + return response + + def _set_integrations_api_url(self): + self.api.api_server = "https://{app_name}.{region}.nylas.com".format( + app_name=self.app_name, region=self.region.value + ) + + class Region(str, Enum): + """ + This is an Enum the regions supported by the Integrations API + """ + + US = "us" + EU = "eu" + + class Provider(str, Enum): + """ + This is an Enum representing all the available providers for integrations + """ + + GOOGLE = "google" + MICROSOFT = "microsoft" + IMAP = "imap" + ZOOM = "zoom" + + +class AuthenticationRestfulModelCollection(RestfulModelCollection): + def __init__(self, model_class, api): + RestfulModelCollection.__init__(self, model_class, api) + + def _get_model_collection(self, offset=0, limit=CHUNK_SIZE): + filters = copy(self.filters) + filters["offset"] = offset + if not filters.get("limit"): + filters["limit"] = limit + + response = self.api._get_resource_raw(self.model_class, None, **filters).json() + if "data" not in response or response["data"] is None: + return [] + + return [ + self.model_class.create(self, **x) + for x in response["data"] + if x is not None + ] + + +class IntegrationRestfulModelCollection(AuthenticationRestfulModelCollection): + def __init__(self, api): + AuthenticationRestfulModelCollection.__init__(self, Integration, api) + + def get(self, provider): + """ + Get an existing integration for a provider + + Args: + provider (Authentication.Provider): The provider + + Returns: + Integration: The existing integration + """ + return super(IntegrationRestfulModelCollection, self).get(provider.value) + + def delete(self, provider, data=None, **kwargs): + """ + Deletes an existing integration for a provider + + Args: + provider (Authentication.Provider): The provider + """ + super(IntegrationRestfulModelCollection, self).delete( + provider.value, data=data, **kwargs + ) + + +class GrantRestfulModelCollection(AuthenticationRestfulModelCollection): + def __init__(self, api): + AuthenticationRestfulModelCollection.__init__(self, Grant, api) + + def on_demand_sync(self, grant_id, sync_from=None): + """ + Trigger a grant sync on demand + + Args: + grant_id (str): The grant ID to sync + sync_from (int): Epoch timestamp when the sync starts from + + Returns: + Grant: The grant after triggering the sync + """ + path = "sync" + if sync_from: + path = path + "?sync_from={}".format(sync_from) + response = self.api._post_resource(Grant, grant_id, path, data=None) + return self.model_class.create(self, **response) diff --git a/nylas/client/client.py b/nylas/client/client.py index 255924fd..cdccfc18 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -38,7 +38,8 @@ from nylas.client.scheduler_restful_model_collection import ( SchedulerRestfulModelCollection, ) -from nylas.utils import timestamp_from_dt, create_request_body +from nylas.client.authentication_models import Authentication +from nylas.utils import timestamp_from_dt, create_request_body, AuthMethod, HttpMethod DEBUG = environ.get("NYLAS_CLIENT_DEBUG") API_SERVER = "https://api.nylas.com" @@ -121,17 +122,13 @@ def __init__( self.admin_session = requests.Session() if client_secret is not None: - b64_client_secret = b64encode((client_secret + ":").encode("utf8")) - authorization = "Basic {secret}".format( - secret=b64_client_secret.decode("utf8") - ) self.admin_session.headers = { - "Authorization": authorization, "X-Nylas-API-Wrapper": "python", "X-Nylas-Client-Id": self.client_id, "Nylas-API-Version": self.api_version, "User-Agent": version_header, } + self.admin_session.headers.update(self._add_auth_header(AuthMethod.BASIC)) super(APIClient, self).__init__() @property @@ -141,12 +138,6 @@ def access_token(self): @access_token.setter def access_token(self, value): self._access_token = value - if value: - authorization = "Bearer {token}".format(token=value) - self.session.headers["Authorization"] = authorization - else: - if "Authorization" in self.session.headers: - del self.session.headers["Authorization"] def authentication_url( self, @@ -193,8 +184,11 @@ def send_authorization(self, code): "Accept": "application/json", } - resp = requests.post( - self.access_token_url, data=urlencode(args), headers=headers + resp = self._request( + HttpMethod.POST, + self.access_token_url, + headers=headers, + data=urlencode(args), ) results = _validate(resp).json() @@ -268,9 +262,10 @@ def token_info(self): token_info_url = self.token_info_url.format( client_id=self.client_id, account_id=self.account.id ) - self.admin_session.headers["Content-Type"] = "application/json" + headers = {"Content-Type": "application/json"} + headers.update(self.admin_session.headers) resp = self.admin_session.post( - token_info_url, json={"access_token": self.access_token} + token_info_url, headers=headers, json={"access_token": self.access_token} ) _validate(resp).json() return resp.json() @@ -292,7 +287,7 @@ def free_busy(self, emails, start_at, end_at): "start_time": start_time, "end_time": end_time, } - resp = self.session.post(url, json=data) + resp = self._request(HttpMethod.POST, url, json=data) _validate(resp) return resp.json() @@ -362,7 +357,7 @@ def availability( if round_robin is not None: data["round_robin"] = round_robin - resp = self.session.post(url, json=data) + resp = self._request(HttpMethod.POST, url, json=data) _validate(resp) return resp.json() @@ -415,7 +410,7 @@ def consecutive_availability( if buffer is not None: data["buffer"] = buffer - resp = self.session.post(url, json=data) + resp = self._request(HttpMethod.POST, url, json=data) _validate(resp) return resp.json() @@ -497,6 +492,10 @@ def neural(self): def outbox(self): return Outbox(self) + @property + def authentication(self): + return Authentication(self) + ########################################################## # Private functions used by Restful Model Collection # ########################################################## @@ -529,7 +528,7 @@ def _get_resources(self, cls, extra=None, **filters): converted_data = create_request_body(filters, cls.datetime_filter_attrs) url = str(URLObject(url).add_query_params(converted_data.items())) - response = self._get_http_session(cls.api_root).get(url) + response = self._request(HttpMethod.GET, url, cls=cls) results = _validate(response).json() return [cls.create(self, **x) for x in results if x is not None] @@ -567,12 +566,13 @@ def _get_resource_raw( converted_data = create_request_body(filters, cls.datetime_filter_attrs) url = str(URLObject(url).add_query_params(converted_data.items())) - session = self._get_http_session(cls.api_root) - - headers = headers or {} - headers.update(session.headers) - response = session.get( - url, headers=headers, stream=stream, timeout=stream_timeout + response = self._request( + HttpMethod.GET, + url, + cls=cls, + headers=headers, + stream=stream, + timeout=stream_timeout, ) return _validate(response) @@ -602,15 +602,14 @@ def _create_resource(self, cls, data, **kwargs): .set_query_params(**kwargs) ) - session = self._get_http_session(cls.api_root) - if cls == File: - response = session.post(url, files=data) + response = self._request(HttpMethod.POST, url, cls=cls, files=data) else: converted_data = create_request_body(data, cls.datetime_attrs) headers = {"Content-Type": "application/json"} - headers.update(session.headers) - response = session.post(url, json=converted_data, headers=headers) + response = self._request( + HttpMethod.POST, url, cls=cls, headers=headers, json=converted_data + ) result = _validate(response).json() if cls.collection_name == "send": @@ -625,17 +624,17 @@ def _create_resources(self, cls, data): path="/{}".format(cls.collection_name) if cls.collection_name else "", ) url = URLObject(self.api_server).with_path("{name}".format(name=name)) - session = self._get_http_session(cls.api_root) if cls == File: - response = session.post(url, files=data) + response = self._request(HttpMethod.POST, url, cls=cls, files=data) else: converted_data = [ create_request_body(datum, cls.datetime_attrs) for datum in data ] headers = {"Content-Type": "application/json"} - headers.update(session.headers) - response = session.post(url, json=converted_data, headers=headers) + response = self._request( + HttpMethod.POST, url, cls=cls, headers=headers, json=converted_data + ) results = _validate(response).json() return [cls.create(self, **x) for x in results] @@ -652,14 +651,13 @@ def _delete_resource(self, cls, resource_id, data=None, **kwargs): .with_path("{name}/{id}".format(name=name, id=resource_id)) .set_query_params(**kwargs) ) - session = self._get_http_session(cls.api_root) if data: - _validate(session.delete(url, json=data)) + _validate(self._request(HttpMethod.DELETE, url, cls=cls, json=data)) else: - _validate(session.delete(url)) + _validate(self._request(HttpMethod.DELETE, url, cls=cls)) - def _setup_update_resource( - self, cls, resource_id, data, extra=None, path=None, **kwargs + def _request_update_resource( + self, method, cls, resource_id, data, extra=None, path=None, **kwargs ): if path is None: path = cls.collection_name @@ -682,25 +680,20 @@ def _setup_update_resource( ) converted_data = create_request_body(data, cls.datetime_attrs) - return url, self._get_http_session(cls.api_root), converted_data - - def _patch_resource(self, cls, resource_id, data, extra=None, path=None, **kwargs): - url, session, converted_data = self._setup_update_resource( - cls, resource_id, data, extra=extra, path=path, **kwargs - ) - response = session.patch(url, json=converted_data) + response = self._request(method, url, cls=cls, json=converted_data) result = _validate(response) return result.json() - def _put_resource(self, cls, resource_id, data, extra=None, path=None, **kwargs): - url, session, converted_data = self._setup_update_resource( - cls, resource_id, data, extra=extra, path=path, **kwargs + def _patch_resource(self, cls, resource_id, data, extra=None, path=None, **kwargs): + return self._request_update_resource( + HttpMethod.PATCH, cls, resource_id, data, extra=extra, path=path, **kwargs ) - response = session.put(url, json=converted_data) - result = _validate(response) - return result.json() + def _put_resource(self, cls, resource_id, data, extra=None, path=None, **kwargs): + return self._request_update_resource( + HttpMethod.PUT, cls, resource_id, data, extra=extra, path=path, **kwargs + ) def _update_resource(self, cls, resource_id, data, **kwargs): result = self._put_resource(cls, resource_id, data, **kwargs) @@ -729,9 +722,7 @@ def _post_resource(self, cls, resource_id, method_name, data, path=None): url = URLObject(self.api_server).with_path(url_path) converted_data = create_request_body(data, cls.datetime_attrs) - session = self._get_http_session(cls.api_root) - response = session.post(url, json=converted_data) - + response = self._request(HttpMethod.POST, url, cls=cls, json=converted_data) return _validate(response).json() def _call_resource_method(self, cls, resource_id, method_name, data): @@ -741,15 +732,15 @@ def _call_resource_method(self, cls, resource_id, method_name, data): result = self._post_resource(cls, resource_id, method_name, data) return cls.create(self, **result) - def _request_neural_resource(self, cls, data, path=None, method="PUT"): + def _request_neural_resource(self, cls, data, path=None, method=None): if path is None: path = cls.collection_name + if method is None: + method = HttpMethod.PUT url = URLObject(self.api_server).with_path("/neural/{name}".format(name=path)) - session = self._get_http_session(cls.api_root) - converted_data = create_request_body(data, cls.datetime_attrs) - response = session.request(method, url, json=converted_data) + response = self._request(method, url, cls=cls, json=converted_data) result = _validate(response).json() if isinstance(result, list): @@ -776,3 +767,43 @@ def _validate_open_hours(self, emails, open_hours, free_busy): raise ValueError( "Open Hours cannot contain an email not present in the main email list or the free busy email list." ) + + def _request(self, method, url, cls=None, headers=None, **kwargs): + api_root = None + auth_method = None + if cls: + api_root = cls.api_root + auth_method = cls.auth_method + + session = self._get_http_session(api_root) + headers = headers or {} + headers.update(session.headers) + headers.update(self._add_auth_header(auth_method)) + return session.request(method.name, url, headers=headers, **kwargs) + + def _add_auth_header(self, auth_method): + authorization = None + if auth_method is AuthMethod.BEARER: + authorization = ( + "Bearer {token}".format(token=self.access_token) + if self.access_token + else None + ) + elif auth_method is AuthMethod.BASIC_CLIENT_ID_AND_SECRET: + if self.client_id and self.client_secret: + credential = "{client_id}:{client_secret}".format( + client_id=self.client_id, client_secret=self.client_secret + ) + authorization = "Basic {credential}".format( + credential=b64encode(credential.encode("utf8")) + ) + else: + if self.client_secret: + b64_client_secret = b64encode( + ("{}:".format(self.client_secret)).encode("utf8") + ) + authorization = "Basic {secret}".format( + secret=b64_client_secret.decode("utf8") + ) + + return {"Authorization": authorization} if authorization else {} diff --git a/nylas/client/neural_api_models.py b/nylas/client/neural_api_models.py index 0911ef46..a0710631 100644 --- a/nylas/client/neural_api_models.py +++ b/nylas/client/neural_api_models.py @@ -1,4 +1,5 @@ from nylas.client.restful_models import RestfulModel, Message, File, Contact +from nylas.utils import HttpMethod import re @@ -132,7 +133,7 @@ def __init__(self, api): def recategorize(self, category): data = {"message_id": self.id, "category": category} self.api._request_neural_resource( - NeuralCategorizer, data, "categorize/feedback", "POST" + NeuralCategorizer, data, "categorize/feedback", method=HttpMethod.POST ) data = {"message_id": self.id} response = self.api._request_neural_resource(NeuralCategorizer, data) diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 124141f0..cce4d727 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -5,7 +5,7 @@ from six import StringIO from nylas.client.restful_model_collection import RestfulModelCollection from nylas.client.errors import FileUploadError, UnSyncedError, NylasApiError -from nylas.utils import timestamp_from_dt +from nylas.utils import timestamp_from_dt, AuthMethod # pylint: disable=attribute-defined-outside-init @@ -35,6 +35,7 @@ class RestfulModel(dict): datetime_filter_attrs = {} typed_dict_attrs = {} read_only_attrs = {} + auth_method = AuthMethod.BEARER # The Nylas API holds most objects for an account directly under '/', # but some of them are under '/a' (mostly the account-management # and billing code). api_root is a tiny metaprogramming hack to let @@ -158,17 +159,22 @@ def child_collection(self, cls, **filters): def save(self, **kwargs): if self.id: - new_obj = self.api._update_resource( - self.cls, self.id, self.as_json(), **kwargs - ) + new_obj = self._update_resource(**kwargs) else: - new_obj = self.api._create_resource(self.cls, self.as_json(), **kwargs) - for attr in self.cls.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) + new_obj = self._create_resource(**kwargs) + self._update_values(new_obj) def update(self): - new_obj = self.api._update_resource(self.cls, self.id, self.as_json()) + new_obj = self._update_resource() + self._update_values(new_obj) + + def _create_resource(self, **kwargs): + return self.api._create_resource(self.cls, self.as_json(), **kwargs) + + def _update_resource(self, **kwargs): + return self.api._update_resource(self.cls, self.id, self.as_json(), **kwargs) + + def _update_values(self, new_obj): for attr in self.cls.attrs: if hasattr(new_obj, attr): setattr(self, attr, getattr(new_obj, attr)) diff --git a/nylas/utils.py b/nylas/utils.py index fa74eae5..cfeb9ff0 100644 --- a/nylas/utils.py +++ b/nylas/utils.py @@ -1,5 +1,6 @@ from __future__ import division from datetime import datetime, timedelta +from enum import Enum def timestamp_from_dt(dt, epoch=datetime(1970, 1, 1)): @@ -50,3 +51,25 @@ def convert_metadata_pairs_to_array(data): metadata_pair.append(key + ":" + value) return metadata_pair + + +class AuthMethod(str, Enum): + """ + This is an Enum representing all the different authentication methods that the Nylas APIs support + """ + + BEARER = 1 + BASIC = 2 + BASIC_CLIENT_ID_AND_SECRET = 3 + + +class HttpMethod(str, Enum): + """ + This is an Enum representing all the HTTP Methods that the Nylas APIs support + """ + + GET = 1 + PUT = 2 + POST = 3 + PATCH = 4 + DELETE = 5 diff --git a/tests/conftest.py b/tests/conftest.py index 3bdbe97c..58897346 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2514,3 +2514,187 @@ def delete_callback(request): callback=delete_callback, content_type="application/json", ) + + +@pytest.fixture +def mock_integrations(mocked_responses, client_id): + integration = { + "name": "Nylas Playground", + "provider": "zoom", + "settings": { + "client_id": "test_client_id", + "client_secret": "test_client_secret", + }, + "redirect_uris": ["https://www.nylas.com"], + "expires_in": 12000, + } + + def list_callback(request): + response = {"data": [integration], "limit": 10, "offset": 0} + return 200, {}, json.dumps(response) + + def single_callback(request): + integration["provider"] = get_id_from_url(request.url) + response = {"data": integration} + return 200, {}, json.dumps(response) + + def update_callback(request): + try: + payload = json.loads(request.body) + except ValueError: + return 400, {}, "" + + response = {"success": True, "data": payload} + return 200, {}, json.dumps(response) + + def delete_callback(request): + return 200, {}, json.dumps({"success": True}) + + def get_id_from_url(url): + path = URLObject(url).path + return path.rsplit("/", 1)[-1] + + endpoint_post = re.compile("https://.*nylas.com/connect/integrations") + endpoint_single = re.compile("https://.*nylas.com/connect/integrations/.*") + endpoint_list = re.compile("https://.*nylas.com/connect/integrations\?.*") + mocked_responses.add_callback( + responses.GET, + endpoint_list, + content_type="application/json", + callback=list_callback, + ) + mocked_responses.add_callback( + responses.GET, + endpoint_single, + content_type="application/json", + callback=single_callback, + ) + mocked_responses.add_callback( + responses.POST, + endpoint_post, + content_type="application/json", + callback=update_callback, + ) + mocked_responses.add_callback( + responses.PATCH, + endpoint_single, + content_type="application/json", + callback=update_callback, + ) + mocked_responses.add_callback( + responses.DELETE, + endpoint_single, + content_type="application/json", + callback=delete_callback, + ) + + +@pytest.fixture +def mock_grants(mocked_responses, client_id): + grant = { + "id": "grant-id", + "provider": "zoom", + "grant_status": "valid", + "email": "email@example.com", + "metadata": {"isAdmin": True}, + "scope": ["meeting:write"], + "user_agent": "string", + "ip": "string", + "state": "my-state", + "created_at": 1617817109, + "updated_at": 1617817109, + } + + def list_callback(request): + response = {"data": [grant], "limit": 10, "offset": 0} + return 200, {}, json.dumps(response) + + def single_callback(request): + response = {"data": grant} + return 200, {}, json.dumps(response) + + def update_callback(request): + try: + payload = json.loads(request.body) + except ValueError: + return 400, {}, "" + + response = {"success": True, "data": payload} + return 200, {}, json.dumps(response) + + def delete_callback(request): + return 200, {}, json.dumps({"success": True}) + + def on_demand_sync(request): + return 200, {}, json.dumps(grant) + + endpoint_post = re.compile("https://.*nylas.com/connect/grants") + endpoint_single = re.compile("https://.*nylas.com/connect/grants/.*") + endpoint_list = re.compile("https://.*nylas.com/connect/grants\?.*") + endpoint_sync = re.compile("https://.*nylas.com/connect/grants/.*/sync.*") + mocked_responses.add_callback( + responses.POST, + endpoint_sync, + content_type="application/json", + callback=on_demand_sync, + ) + mocked_responses.add_callback( + responses.GET, + endpoint_list, + content_type="application/json", + callback=list_callback, + ) + mocked_responses.add_callback( + responses.GET, + endpoint_single, + content_type="application/json", + callback=single_callback, + ) + mocked_responses.add_callback( + responses.POST, + endpoint_post, + content_type="application/json", + callback=update_callback, + ) + mocked_responses.add_callback( + responses.PATCH, + endpoint_single, + content_type="application/json", + callback=update_callback, + ) + mocked_responses.add_callback( + responses.DELETE, + endpoint_single, + content_type="application/json", + callback=delete_callback, + ) + + +@pytest.fixture +def mock_authentication_hosted_auth(mocked_responses, client_id): + api_response = { + "success": True, + "data": { + "url": "https://accounts.nylas.com/connect/login?id=uas-hosted-id", + "id": "uas-hosted-id", + "expires_at": 0, + "request": {}, + }, + } + + def hosted_auth_response(request): + try: + payload = json.loads(request.body) + except ValueError: + return 400, {}, "" + + api_response["data"]["request"] = payload + return 200, {}, json.dumps(api_response) + + endpoint = re.compile("https://.*nylas.com/connect/auth") + mocked_responses.add_callback( + responses.POST, + endpoint, + content_type="application/json", + callback=hosted_auth_response, + ) diff --git a/tests/test_authentication.py b/tests/test_authentication.py new file mode 100644 index 00000000..023c368f --- /dev/null +++ b/tests/test_authentication.py @@ -0,0 +1,238 @@ +import json + +import pytest +from urlobject import URLObject + +from nylas.client.authentication_models import Authentication, Integration, Grant + + +@pytest.mark.usefixtures("mock_integrations") +def test_authentication_api_url(mocked_responses, api_client): + authentication = api_client.authentication + integrations = authentication.integrations + integrations.first() + request = mocked_responses.calls[0].request + assert URLObject(request.url).hostname == "beta.us.nylas.com" + authentication.app_name = "test_app" + integrations.first() + request = mocked_responses.calls[1].request + assert URLObject(request.url).hostname == "test_app.us.nylas.com" + authentication.region = Authentication.Region.EU + integrations.first() + request = mocked_responses.calls[2].request + assert URLObject(request.url).hostname == "test_app.eu.nylas.com" + + +@pytest.mark.usefixtures("mock_integrations") +def test_integration(mocked_responses, api_client): + integration = api_client.authentication.integrations.first() + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/connect/integrations" + assert request.method == "GET" + assert isinstance(integration, Integration) + assert integration.name == "Nylas Playground" + assert integration.id == "zoom" + assert integration.provider == "zoom" + assert integration.settings["client_id"] == "test_client_id" + assert integration.settings["client_secret"] == "test_client_secret" + assert integration.redirect_uris[0] == "https://www.nylas.com" + assert integration.expires_in == 12000 + + +@pytest.mark.usefixtures("mock_integrations") +def test_single_integration(mocked_responses, api_client): + integration = api_client.authentication.integrations.get( + Authentication.Provider.ZOOM + ) + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/connect/integrations/zoom" + assert request.method == "GET" + assert isinstance(integration, Integration) + assert integration.id == "zoom" + assert integration.provider == "zoom" + + +@pytest.mark.usefixtures("mock_integrations") +def test_update_integration(mocked_responses, api_client): + integration = api_client.authentication.integrations.get( + Authentication.Provider.ZOOM + ) + integration.name = "Updated Integration Name" + integration.save() + assert len(mocked_responses.calls) == 2 + request = mocked_responses.calls[1].request + assert URLObject(request.url).path == "/connect/integrations/zoom" + assert request.method == "PATCH" + assert json.loads(request.body) == { + "name": "Updated Integration Name", + "settings": { + "client_id": "test_client_id", + "client_secret": "test_client_secret", + }, + "redirect_uris": ["https://www.nylas.com"], + "expires_in": 12000, + "scope": [], + } + assert isinstance(integration, Integration) + assert integration.id == "zoom" + assert integration.provider == "zoom" + assert integration.name == "Updated Integration Name" + + +@pytest.mark.usefixtures("mock_integrations") +def test_delete_integration(mocked_responses, api_client): + api_client.authentication.integrations.delete(Authentication.Provider.ZOOM) + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/connect/integrations/zoom" + assert request.method == "DELETE" + + +@pytest.mark.usefixtures("mock_integrations") +def test_create_integration(mocked_responses, api_client): + integration = api_client.authentication.integrations.create() + integration.name = "Nylas Playground" + integration.provider = Authentication.Provider.ZOOM + integration.settings["client_id"] = "test_client_id" + integration.settings["client_secret"] = "test_client_secret" + integration.redirect_uris = ["https://www.nylas.com"] + integration.expires_in = 12000 + integration.scope = ["test.scope"] + integration.save() + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/connect/integrations" + assert request.method == "POST" + assert json.loads(request.body) == { + "name": "Nylas Playground", + "provider": "zoom", + "settings": { + "client_id": "test_client_id", + "client_secret": "test_client_secret", + }, + "redirect_uris": ["https://www.nylas.com"], + "expires_in": 12000, + "scope": ["test.scope"], + } + assert isinstance(integration, Integration) + assert integration.name == "Nylas Playground" + assert integration.id == "zoom" + assert integration.provider == "zoom" + assert integration.settings["client_id"] == "test_client_id" + assert integration.settings["client_secret"] == "test_client_secret" + assert integration.redirect_uris[0] == "https://www.nylas.com" + assert integration.expires_in == 12000 + assert integration.scope == ["test.scope"] + + +@pytest.mark.usefixtures("mock_grants") +def test_grant(mocked_responses, api_client): + grant = api_client.authentication.grants.first() + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/connect/grants" + assert request.method == "GET" + assert isinstance(grant, Grant) + assert grant.id == "grant-id" + assert grant.provider == "zoom" + assert grant.grant_status == "valid" + assert grant.email == "email@example.com" + assert grant.metadata == {"isAdmin": True} + assert grant.scope[0] == "meeting:write" + assert grant.user_agent == "string" + assert grant.ip == "string" + assert grant.created_at == 1617817109 + assert grant.updated_at == 1617817109 + + +@pytest.mark.usefixtures("mock_grants") +def test_single_grant(mocked_responses, api_client): + grant = api_client.authentication.grants.get("grant-id") + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/connect/grants/grant-id" + assert request.method == "GET" + assert isinstance(grant, Grant) + assert grant.id == "grant-id" + + +@pytest.mark.usefixtures("mock_grants") +def test_update_grant(mocked_responses, api_client): + grant = api_client.authentication.grants.get("grant-id") + grant.settings = {"refresh_token": "test_token"} + grant.save() + assert len(mocked_responses.calls) == 2 + request = mocked_responses.calls[1].request + assert URLObject(request.url).path == "/connect/grants/grant-id" + assert request.method == "PATCH" + assert json.loads(request.body) == { + "settings": {"refresh_token": "test_token"}, + "metadata": {"isAdmin": True}, + "scope": ["meeting:write"], + } + assert isinstance(grant, Grant) + assert grant.id == "grant-id" + assert grant.settings == {"refresh_token": "test_token"} + + +@pytest.mark.usefixtures("mock_grants") +def test_delete_grant(mocked_responses, api_client): + api_client.authentication.grants.delete("grant-id") + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/connect/grants/grant-id" + assert request.method == "DELETE" + + +@pytest.mark.usefixtures("mock_grants") +def test_create_grant(mocked_responses, api_client): + grant = api_client.authentication.grants.create() + grant.provider = Authentication.Provider.ZOOM + grant.settings = {"refresh_token": "test-refresh-token"} + grant.metadata = {"isAdmin": True} + grant.scope = ["meeting:write"] + grant.save() + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/connect/grants" + assert request.method == "POST" + assert json.loads(request.body) == { + "provider": "zoom", + "settings": {"refresh_token": "test-refresh-token"}, + "scope": ["meeting:write"], + "metadata": {"isAdmin": True}, + } + assert isinstance(grant, Grant) + + +@pytest.mark.usefixtures("mock_grants") +def test_grant_on_demand_sync(mocked_responses, api_client): + grant = api_client.authentication.grants.on_demand_sync("grant-id", sync_from=12000) + request = mocked_responses.calls[0].request + assert request.path_url == "/connect/grants/grant-id/sync?sync_from=12000" + assert request.method == "POST" + assert request.body is None + assert isinstance(grant, Grant) + + +@pytest.mark.usefixtures("mock_authentication_hosted_auth") +def test_grant_authentication_hosted_auth(mocked_responses, api_client): + api_client.authentication.hosted_authentication( + provider=Authentication.Provider.ZOOM, + redirect_uri="https://myapp.com/callback-handler", + grant_id="test-grant-id", + login_hint="example@email.com", + state="test-state", + expires_in=60, + settings={"refresh_token": "test-refresh-token"}, + metadata={"isAdmin": True}, + scope=["meeting:write"], + ) + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/connect/auth" + assert request.method == "POST" + assert json.loads(request.body) == { + "provider": "zoom", + "redirect_uri": "https://myapp.com/callback-handler", + "grant_id": "test-grant-id", + "login_hint": "example@email.com", + "state": "test-state", + "expires_in": 60, + "settings": {"refresh_token": "test-refresh-token"}, + "scope": ["meeting:write"], + "metadata": {"isAdmin": True}, + } diff --git a/tests/test_client.py b/tests/test_client.py index f383f1b4..1235b11e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -30,16 +30,20 @@ def test_custom_client(): ) -def test_client_access_token(): - client = APIClient(access_token="foo") - assert client.access_token == "foo" - assert client.session.headers["Authorization"] == "Bearer foo" - client.access_token = "bar" - assert client.access_token == "bar" - assert client.session.headers["Authorization"] == "Bearer bar" - client.access_token = None - assert client.access_token is None - assert "Authorization" not in client.session.headers +@pytest.mark.usefixtures("mock_resources") +def test_client_access_token(api_client, mocked_responses): + api_client.access_token = "foo" + assert api_client.access_token == "foo" + api_client.room_resources.first() + assert mocked_responses.calls[0].request.headers["Authorization"] == "Bearer foo" + api_client.access_token = "bar" + api_client.room_resources.first() + assert api_client.access_token == "bar" + assert mocked_responses.calls[1].request.headers["Authorization"] == "Bearer bar" + api_client.access_token = None + api_client.room_resources.first() + assert api_client.access_token is None + assert "Authorization" not in api_client.session.headers def test_client_headers(): From 388b423e1c4ea7f8fb60045440f7af25334caee4 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 31 Mar 2022 17:13:20 -0400 Subject: [PATCH 030/186] v5.7.0 Release (#210) New nylas v5.7.0 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b3bec7ae..15261d31 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.6.0 +current_version = 5.7.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e7a4421..edf30eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v5.7.0 ---------------- * Add Outbox support * Add support for new (beta) Integrations authentication (Integrations API, Grants API, Hosted Authentication for Integrations) diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index b31f89c6..6341c31b 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.6.0" +__VERSION__ = "5.7.0" From 1bf2a2546a47db69774cbe0e351189d4024eb5d1 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Wed, 13 Apr 2022 16:08:16 -0400 Subject: [PATCH 031/186] Added count method (#213) This PR adds support for the count view, returning the number of objects in the collection being queried. --- .gitignore | 3 +++ CHANGELOG.md | 4 ++++ nylas/client/restful_model_collection.py | 13 +++++++++++++ tests/test_client.py | 13 +++++++++++++ 4 files changed, 33 insertions(+) diff --git a/.gitignore b/.gitignore index 66731b8d..ade358a5 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ local/ pip-selfcheck.json tests/output +local.env +examples/test.py +.env diff --git a/CHANGELOG.md b/CHANGELOG.md index edf30eb7..cdbaa738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add support for getting the number of queried objects (count view) + v5.7.0 ---------------- * Add Outbox support diff --git a/nylas/client/restful_model_collection.py b/nylas/client/restful_model_collection.py index 8fc9cf41..3c45965a 100644 --- a/nylas/client/restful_model_collection.py +++ b/nylas/client/restful_model_collection.py @@ -63,6 +63,19 @@ def all(self, limit=float("infinity")): limit = self.filters["limit"] return self._range(self.filters["offset"], limit) + def count(self): + """ + Get the number of objects in the collection being queried + + Returns: + int: The number of objects in the collection being queried + """ + self.filters["view"] = "count" + response = self.api._get_resource_raw( + self.model_class, resource_id=None, **self.filters + ).json() + return response["count"] + def where(self, filter=None, **filters): # Some API parameters like "from" and "in" also are # Python reserved keywords. To work around this, we rename diff --git a/tests/test_client.py b/tests/test_client.py index 1235b11e..9d3ba21c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -276,3 +276,16 @@ def callback(request): contacts = list(api_client.contacts.where(limit=75)) assert len(contacts) == 75 + + +def test_count(mocked_responses, api_client, api_url): + count_data = {"count": 721} + mocked_responses.add( + responses.GET, + api_url + "/contacts", + content_type="application/json", + body=json.dumps(count_data), + ) + + contact_count = api_client.contacts.count() + assert contact_count == 721 From 4e5913782a060a328912c981ed536b98a4526d48 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Wed, 13 Apr 2022 17:12:33 -0400 Subject: [PATCH 032/186] [TSDK-287] Fix Calendar availability functions not using the correct authentication method (#214) The Calendar availability functions were using an incorrect authentication method when requesting to the API --- CHANGELOG.md | 1 + nylas/client/client.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdbaa738..9e7aa4a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Add support for getting the number of queried objects (count view) +* Fix Calendar availability functions not using the correct authentication method v5.7.0 ---------------- diff --git a/nylas/client/client.py b/nylas/client/client.py index cdccfc18..c0067740 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -287,7 +287,7 @@ def free_busy(self, emails, start_at, end_at): "start_time": start_time, "end_time": end_time, } - resp = self._request(HttpMethod.POST, url, json=data) + resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) _validate(resp) return resp.json() @@ -357,7 +357,7 @@ def availability( if round_robin is not None: data["round_robin"] = round_robin - resp = self._request(HttpMethod.POST, url, json=data) + resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) _validate(resp) return resp.json() @@ -410,7 +410,7 @@ def consecutive_availability( if buffer is not None: data["buffer"] = buffer - resp = self._request(HttpMethod.POST, url, json=data) + resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) _validate(resp) return resp.json() From 93bc034f9a891d69c2e6cf3b6ef92d86b2822089 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 14 Apr 2022 17:26:36 -0400 Subject: [PATCH 033/186] [TSDK-286] Improve usage of read only fields in models (#215) This PR improves our usage of read only fields, adding a general set of read only fields used by all API models, and read only fields for specific API models. --- nylas/client/restful_models.py | 30 ++++++++++++++++++++++++++++-- tests/test_events.py | 27 +++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index cce4d727..e8994d50 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -151,6 +151,8 @@ def as_json(self): class NylasAPIObject(RestfulModel): + read_only_attrs = {"id", "account_id", "object", "job_status_id"} + def __init__(self, cls, api): RestfulModel.__init__(self, cls, api) @@ -638,6 +640,7 @@ class Calendar(NylasAPIObject): def __init__(self, api): NylasAPIObject.__init__(self, Calendar, api) + self.read_only_attrs.update({"is_primary", "read_only"}) @property def events(self): @@ -674,6 +677,16 @@ class Event(NylasAPIObject): def __init__(self, api): NylasAPIObject.__init__(self, Event, api) + self.read_only_attrs.update( + { + "ical_uid", + "message_id", + "owner", + "status", + "master_event_id", + "original_start_time", + } + ) def as_json(self): dct = NylasAPIObject.as_json(self) @@ -804,6 +817,13 @@ class JobStatus(NylasAPIObject): def __init__(self, api): NylasAPIObject.__init__(self, JobStatus, api) + self.read_only_attrs.update( + { + "action", + "status", + "original_data", + } + ) def is_successful(self): return self.status == "successful" @@ -871,13 +891,19 @@ class Component(NylasAPIObject): "created_at": "created_at", "updated_at": "updated_at", } - read_only_attrs = {"id", "public_application_id", "created_at", "updated_at"} collection_name = None api_root = "component" def __init__(self, api): NylasAPIObject.__init__(self, Component, api) + self.read_only_attrs.update( + { + "public_application_id", + "created_at", + "updated_at", + } + ) def as_json(self): dct = NylasAPIObject.as_json(self) @@ -896,13 +922,13 @@ class Webhook(NylasAPIObject): "application_id", "version", ) - read_only_attrs = {"id", "application_id", "version"} collection_name = "webhooks" api_root = "a" def __init__(self, api): NylasAPIObject.__init__(self, Webhook, api) + self.read_only_attrs.update({"application_id", "version"}) def as_json(self): dct = {} diff --git a/tests/test_events.py b/tests/test_events.py index 248cc81e..a85a754a 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -2,7 +2,6 @@ from datetime import datetime, timedelta import pytest from urlobject import URLObject -from requests import RequestException from nylas.client.restful_models import Event @@ -15,15 +14,39 @@ def blank_event(api_client): @pytest.mark.usefixtures("mock_event_create_response") -def test_event_crud(api_client): +def test_event_crud(mocked_responses, api_client): event1 = blank_event(api_client) + event1.object = "should not send" + event1.account_id = "should not send" + event1.job_status_id = "should not send" + event1.ical_uid = "should not send" + event1.message_id = "should not send" + event1.owner = "should not send" + event1.status = "should not send" + event1.master_event_id = "should not send" + event1.original_start_time = "should not send" event1.save() + request = mocked_responses.calls[0].request + body = json.loads(request.body) assert event1.id == "cv4ei7syx10uvsxbs21ccsezf" + assert "title" in body + assert "object" not in body + assert "account_id" not in body + assert "job_status_id" not in body + assert "ical_uid" not in body + assert "message_id" not in body + assert "owner" not in body + assert "status" not in body + assert "master_event_id" not in body + assert "original_start_time" not in body event1.title = "blah" event1.save() + request = mocked_responses.calls[1].request + body = json.loads(request.body) assert event1.title == "loaded from JSON" assert event1.get("ignored") is None + assert "id" not in body @pytest.mark.usefixtures("mock_event_create_notify_response") From 444efedc88e0ed1c6eb38e8bf2e995b51a6ad381 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 14 Apr 2022 17:41:37 -0400 Subject: [PATCH 034/186] v5.8.0 Release (#216) New nylas v5.8.0 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 3 ++- nylas/_client_sdk_version.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 15261d31..9bd14f2b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.7.0 +current_version = 5.8.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e7aa4a3..129302c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ nylas-python Changelog ====================== -Unreleased +v5.8.0 ---------------- * Add support for getting the number of queried objects (count view) +* Improve usage of read only fields in models * Fix Calendar availability functions not using the correct authentication method v5.7.0 diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 6341c31b..d042b73a 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.7.0" +__VERSION__ = "5.8.0" From 6acd150c62baf530141a7ef563abd4d6705fcf13 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 10 May 2022 16:31:37 -0400 Subject: [PATCH 035/186] [TSDK-298] Support collective and group events (#217) This PR bring support to collective and group events. --- CHANGELOG.md | 4 +++ nylas/client/client.py | 3 +++ nylas/client/restful_models.py | 17 ++++++++++++- tests/test_events.py | 45 ++++++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 129302c6..69ef260c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add support for collective and group events + v5.8.0 ---------------- * Add support for getting the number of queried objects (count view) diff --git a/nylas/client/client.py b/nylas/client/client.py index c0067740..f6a0768a 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -316,6 +316,7 @@ def availability( interval, start_at, end_at, + event_collection_id=None, buffer=None, round_robin=None, free_busy=None, @@ -356,6 +357,8 @@ def availability( data["buffer"] = buffer if round_robin is not None: data["round_robin"] = round_robin + if event_collection_id is not None: + data["event_collection_id"] = event_collection_id resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) _validate(resp) diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index e8994d50..412530e5 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -671,6 +671,9 @@ class Event(NylasAPIObject): "ical_uid", "metadata", "notifications", + "event_collection_id", + "capacity", + "round_robin_order", ] datetime_attrs = {"original_start_at": "original_start_time"} collection_name = "events" @@ -772,7 +775,7 @@ def generate_ics(self, ical_uid=None, method=None, prodid=None): "Unexpected response from the API server. Returned 200 but no 'ics' string found." ) - def save(self, **kwargs): + def validate(self): if ( self.conferencing and "details" in self.conferencing @@ -781,6 +784,18 @@ def save(self, **kwargs): raise ValueError( "Cannot set both 'details' and 'autocreate' in conferencing object." ) + if ( + self.capacity + and self.capacity != -1 + and self.participants + and len(self.participants) > self.capacity + ): + raise ValueError( + "The number of participants in the event exceeds the set capacity." + ) + + def save(self, **kwargs): + self.validate() super(Event, self).save(**kwargs) diff --git a/tests/test_events.py b/tests/test_events.py index a85a754a..10cf2afc 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -138,6 +138,51 @@ def test_event_conferencing_details_autocreate_error(mocked_responses, api_clien ) +@pytest.mark.usefixtures("mock_event_create_response") +def test_event_error_if_participants_more_than_capacity(mocked_responses, api_client): + event = blank_event(api_client) + event.capacity = 1 + event.participants = [ + {"email": "person1@email.com"}, + {"email": "person2@email.com"}, + ] + with pytest.raises(ValueError) as excinfo: + event.save() + assert "The number of participants in the event exceeds the set capacity." in str( + excinfo + ) + + +@pytest.mark.usefixtures("mock_event_create_response") +def test_event_no_error_if_capacity_negative_one(mocked_responses, api_client): + event = blank_event(api_client) + event.capacity = -1 + event.participants = [ + {"email": "person1@email.com"}, + {"email": "person2@email.com"}, + ] + event.save() + + +@pytest.mark.usefixtures("mock_event_create_response") +def test_event_no_error_if_participants_less_than_eql_capacity( + mocked_responses, api_client +): + event = blank_event(api_client) + event.capacity = 2 + event.participants = [ + {"email": "person1@email.com"}, + {"email": "person2@email.com"}, + ] + event.save() + event.capacity = 3 + event.participants = [ + {"email": "person1@email.com"}, + {"email": "person2@email.com"}, + ] + event.save() + + @pytest.mark.usefixtures("mock_calendars", "mock_events") def test_calendar_events(api_client): calendar = api_client.calendars.first() From 931e457e8b32c14012e78d9371469636944245d0 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 10 May 2022 18:04:06 -0400 Subject: [PATCH 036/186] v5.9.0 Release (#218) New nylas v5.9.0 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 9bd14f2b..0a97d919 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.8.0 +current_version = 5.9.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ef260c..45a83d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v5.9.0 ---------------- * Add support for collective and group events diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index d042b73a..09bfb060 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.8.0" +__VERSION__ = "5.9.0" From 0a7576e5d8d90b1bb4cb300d5d9e30266121faa2 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Mon, 6 Jun 2022 17:55:22 -0400 Subject: [PATCH 037/186] Add option to include readonly params in `as_json` (#222) Allows to convert object to JSON ignoring API rules for read only params --- CHANGELOG.md | 4 ++++ nylas/client/restful_models.py | 10 +++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a83d45..7c801c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add option to include read only params in `as_json` + v5.9.0 ---------------- * Add support for collective and group events diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 412530e5..214c7ad3 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -106,7 +106,7 @@ def create(cls, api, **kwargs): return obj - def as_json(self): + def as_json(self, enforce_read_only=True): dct = {} # Some API parameters like "from" and "in" also are # Python reserved keywords. To work around this, we rename @@ -114,7 +114,7 @@ def as_json(self): # their correct form though. reserved_keywords = ["from", "in"] for attr in self.cls.attrs: - if attr in self.read_only_attrs: + if attr in self.read_only_attrs and enforce_read_only is True: continue if hasattr(self, attr): if attr in reserved_keywords: @@ -124,17 +124,17 @@ def as_json(self): if attr_value is not None: dct[attr] = attr_value for date_attr, iso_attr in self.cls.date_attrs.items(): - if date_attr in self.read_only_attrs: + if date_attr in self.read_only_attrs and enforce_read_only is True: continue if self.get(date_attr): dct[iso_attr] = self[date_attr].strftime("%Y-%m-%d") for dt_attr, ts_attr in self.cls.datetime_attrs.items(): - if dt_attr in self.read_only_attrs: + if dt_attr in self.read_only_attrs and enforce_read_only is True: continue if self.get(dt_attr): dct[ts_attr] = timestamp_from_dt(self[dt_attr]) for attr, value_attr in self.cls.typed_dict_attrs.items(): - if attr in self.read_only_attrs: + if attr in self.read_only_attrs and enforce_read_only is True: continue typed_dict = getattr(self, attr) if value_attr: From ea8645e913fb736b1ca57bafe43857f99f7f267c Mon Sep 17 00:00:00 2001 From: Erikka <19295862+erikkai@users.noreply.github.com> Date: Mon, 6 Jun 2022 15:01:19 -0700 Subject: [PATCH 038/186] Change config file to match new Flask rules (#221) In Flask 2.1 onwards the method used to set up the config file is no longer available. So it will break. I replaced it with the config option where you load from a file. Then I set up config.json to be config.py so that it will work. --- examples/hosted-oauth/config.json | 5 ----- examples/hosted-oauth/config.py | 3 +++ examples/hosted-oauth/server.py | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) delete mode 100644 examples/hosted-oauth/config.json create mode 100644 examples/hosted-oauth/config.py diff --git a/examples/hosted-oauth/config.json b/examples/hosted-oauth/config.json deleted file mode 100644 index 9b54e3df..00000000 --- a/examples/hosted-oauth/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "SECRET_KEY": "replace me with a random string", - "NYLAS_OAUTH_CLIENT_ID": "replace me with the client ID from Nylas", - "NYLAS_OAUTH_CLIENT_SECRET": "replace me with the client secret from Nylas" -} diff --git a/examples/hosted-oauth/config.py b/examples/hosted-oauth/config.py new file mode 100644 index 00000000..12da98a2 --- /dev/null +++ b/examples/hosted-oauth/config.py @@ -0,0 +1,3 @@ +SECRET_KEY = ("replace me with a random string",) +NYLAS_OAUTH_CLIENT_ID = ("replace me with the client ID from Nylas",) +NYLAS_OAUTH_CLIENT_SECRET = "replace me with the client secret from Nylas" diff --git a/examples/hosted-oauth/server.py b/examples/hosted-oauth/server.py index 72caed31..cd640531 100644 --- a/examples/hosted-oauth/server.py +++ b/examples/hosted-oauth/server.py @@ -40,7 +40,7 @@ # For more information, check out the documentation: http://flask.pocoo.org # Create a Flask app, and load the configuration file. app = Flask(__name__) -app.config.from_json("config.json") +app.config.from_pyfile("config.py") # Check for dummy configuration values. # If you are building your own application based on this example, From 07d1ca1418d24cbc254178ecc6e4842ce59fa1e2 Mon Sep 17 00:00:00 2001 From: Michael Gisi Date: Tue, 21 Jun 2022 10:18:57 -0400 Subject: [PATCH 039/186] use http basic auth for /oauth/revoke endpoint (#223) Current implementation will always return a 401. Use basic auth per the Nylas docs: https://developer.nylas.com/docs/api#post/oauth/revoke --- nylas/client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nylas/client/client.py b/nylas/client/client.py index f6a0768a..b5b92f6b 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -231,7 +231,7 @@ def update_application_details( return _validate(resp).json() def revoke_token(self): - resp = self.session.post(self.revoke_url) + resp = requests.post(self.revoke_url, auth=(self.access_token, None)) _validate(resp) self.auth_token = None self.access_token = None From c362eda2fb600d0e7e159fde922fce92324df5be Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 21 Jun 2022 11:17:24 -0400 Subject: [PATCH 040/186] v5.9.1 Release (#224) New nylas v5.9.1 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 4 +++- nylas/_client_sdk_version.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0a97d919..42587ac9 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.9.0 +current_version = 5.9.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c801c0e..344a52db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,11 @@ nylas-python Changelog ====================== -Unreleased +v5.9.1 ---------------- * Add option to include read only params in `as_json` +* Change config file in `hosted-oauth` example to match new Flask rules +* Fix unauthorized error for `revoke_token` v5.9.0 ---------------- diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 09bfb060..e3648def 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.9.0" +__VERSION__ = "5.9.1" From a43da620e03aef257dffea263a81ac4ba7bc762b Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 28 Jun 2022 14:44:36 -0400 Subject: [PATCH 041/186] Add enforce_read_only to children as_json (#225) In the last release we added enforce_read_only to the as_json function (#222) but only on the top level one. Some models overrided this function but was missing this parameter, so this adds it in. --- CHANGELOG.md | 4 ++++ nylas/client/authentication_models.py | 14 ++++++++---- nylas/client/restful_models.py | 32 +++++++++++++++------------ nylas/client/scheduler_models.py | 2 +- 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 344a52db..0eb35af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add `enforce_read_only` parameter to overriding `as_json` functions + v5.9.1 ---------------- * Add option to include read only params in `as_json` diff --git a/nylas/client/authentication_models.py b/nylas/client/authentication_models.py index 7574b117..f90ebd9f 100644 --- a/nylas/client/authentication_models.py +++ b/nylas/client/authentication_models.py @@ -53,8 +53,11 @@ def create(cls, api, **kwargs): return obj - def as_json(self): - dct = super(Integration, self).as_json() + def as_json(self, enforce_read_only=True): + dct = super(Integration, self).as_json(enforce_read_only) + if enforce_read_only is False: + return dct + if not self.id: if isinstance(self.provider, Authentication.Provider): dct["provider"] = self.provider.value @@ -108,8 +111,11 @@ def create(cls, api, **kwargs): obj = super(Grant, cls).create(api, **kwargs) return obj - def as_json(self): - dct = super(Grant, self).as_json() + def as_json(self, enforce_read_only=True): + dct = super(Grant, self).as_json(enforce_read_only) + if enforce_read_only is False: + return dct + # provider and state can not be updated if self.id: del dct["provider"] diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 214c7ad3..94172a84 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -691,8 +691,11 @@ def __init__(self, api): } ) - def as_json(self): - dct = NylasAPIObject.as_json(self) + def as_json(self, enforce_read_only=True): + dct = NylasAPIObject.as_json(self, enforce_read_only) + if enforce_read_only is False: + return dct + # Filter some parameters we got from the API if dct.get("when"): # Currently, the event (self) and the dict (dct) share the same @@ -920,8 +923,11 @@ def __init__(self, api): } ) - def as_json(self): - dct = NylasAPIObject.as_json(self) + def as_json(self, enforce_read_only=True): + dct = NylasAPIObject.as_json(self, enforce_read_only) + if enforce_read_only is False: + return dct + # "type" cannot be modified after created if self.id: dct.pop("type") @@ -945,13 +951,13 @@ def __init__(self, api): NylasAPIObject.__init__(self, Webhook, api) self.read_only_attrs.update({"application_id", "version"}) - def as_json(self): + def as_json(self, enforce_read_only=True): dct = {} # Only 'state' can get updated - if self.id: + if self.id and enforce_read_only is True: dct["state"] = self.state else: - dct = NylasAPIObject.as_json(self) + dct = NylasAPIObject.as_json(self, enforce_read_only) return dct class Trigger(str, Enum): @@ -1035,9 +1041,11 @@ class Account(NylasAPIObject): def __init__(self, api): NylasAPIObject.__init__(self, Account, api) - def as_json(self): - dct = {"metadata": self.metadata} - return dct + def as_json(self, enforce_read_only=True): + if enforce_read_only is False: + return NylasAPIObject.as_json(self, enforce_read_only) + else: + return {"metadata": self.metadata} def upgrade(self): return self.api._call_resource_method(self, self.account_id, "upgrade", None) @@ -1064,10 +1072,6 @@ class APIAccount(NylasAPIObject): def __init__(self, api): NylasAPIObject.__init__(self, APIAccount, api) - def as_json(self): - dct = NylasAPIObject.as_json(self) - return dct - class SingletonAccount(APIAccount): # This is an APIAccount that lives under /account. diff --git a/nylas/client/scheduler_models.py b/nylas/client/scheduler_models.py index 14a47470..0d19e5d5 100644 --- a/nylas/client/scheduler_models.py +++ b/nylas/client/scheduler_models.py @@ -47,7 +47,7 @@ class SchedulerBookingRequest(RestfulModel): def __init__(self, api): RestfulModel.__init__(self, SchedulerBookingRequest, api) - def as_json(self): + def as_json(self, enforce_read_only=True): dct = RestfulModel.as_json(self) if "additional_values" not in dct or dct["additional_values"] is None: dct["additional_values"] = {} From d227f5b71e03cbffdc56cfdc7ee092e11e17ba32 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 30 Jun 2022 15:44:23 -0400 Subject: [PATCH 042/186] v5.9.2 Release (#226) New nylas v5.9.2 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 42587ac9..82290803 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.9.1 +current_version = 5.9.2 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb35af5..f85b84d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v5.9.2 ---------------- * Add `enforce_read_only` parameter to overriding `as_json` functions diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index e3648def..c87baea1 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.9.1" +__VERSION__ = "5.9.2" From 9ddcc1f554d9f68324d0219f9dd420db62f50cdd Mon Sep 17 00:00:00 2001 From: Erikka <19295862+erikkai@users.noreply.github.com> Date: Mon, 11 Jul 2022 22:16:17 -0700 Subject: [PATCH 043/186] Change to config.py Match new config.py file instead of config.json. --- examples/hosted-oauth/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/hosted-oauth/README.md b/examples/hosted-oauth/README.md index 6bc04d12..a53b74e7 100644 --- a/examples/hosted-oauth/README.md +++ b/examples/hosted-oauth/README.md @@ -21,9 +21,9 @@ You should see your client ID and client secret on the dashboard, once you've logged in on the [Nylas Developer](https://developer.nylas.com/) website. -## Update the `config.json` File +## Update the `config.py` File -Open the `config.json` file in this directory, and replace the example +Open the `config.py` file in this directory, and replace the example client ID and client secret with the real values that you got from the Nylas Developer dashboard. You'll also need to replace the example secret key with any random string of letters and numbers: a keyboard mash will do. From 655d01373e361b09dde35e506191a55668ac82fa Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 26 Jul 2022 09:57:22 -0400 Subject: [PATCH 044/186] Support new `calendars` field in free-busy/availability queries (#228) In the free busy, availability, and consecutive availability endpoints of the Nylas Calendar API, there's a new field (calendar) that can be substituted for emails in the event that someone wants to check for availability/free-busy of other calendars within the same organization. --- CHANGELOG.md | 4 ++ nylas/client/client.py | 23 +++++++- tests/test_events.py | 130 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f85b84d6..b11715c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add support for `calendar` field in free-busy, availability, and consecutive availability queries + v5.9.2 ---------------- * Add `enforce_read_only` parameter to overriding `as_json` functions diff --git a/nylas/client/client.py b/nylas/client/client.py index b5b92f6b..9bc2f458 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -72,6 +72,13 @@ def _validate(response): return response +def _validate_availability_query(query): + if (query.get("emails", None) is None or len(query["emails"]) == 0) and ( + query.get("calendars", None) is None or len(query["calendars"]) == 0 + ): + raise ValueError("Must set either 'emails' or 'calendars' in the query.") + + class APIClient(json.JSONEncoder): """API client for the Nylas API.""" @@ -270,7 +277,7 @@ def token_info(self): _validate(resp).json() return resp.json() - def free_busy(self, emails, start_at, end_at): + def free_busy(self, emails, start_at, end_at, calendars=None): if isinstance(emails, six.string_types): emails = [emails] if isinstance(start_at, datetime): @@ -287,6 +294,10 @@ def free_busy(self, emails, start_at, end_at): "start_time": start_time, "end_time": end_time, } + if calendars is not None and len(calendars) > 0: + data["calendars"] = calendars + + _validate_availability_query(data) resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) _validate(resp) return resp.json() @@ -321,6 +332,7 @@ def availability( round_robin=None, free_busy=None, open_hours=None, + calendars=None, ): if isinstance(emails, six.string_types): emails = [emails] @@ -359,7 +371,10 @@ def availability( data["round_robin"] = round_robin if event_collection_id is not None: data["event_collection_id"] = event_collection_id + if calendars is not None and len(calendars) > 0: + data["calendars"] = calendars + _validate_availability_query(data) resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) _validate(resp) return resp.json() @@ -374,10 +389,11 @@ def consecutive_availability( buffer=None, free_busy=None, open_hours=None, + calendars=None, ): if isinstance(emails, six.string_types): emails = [[emails]] - elif isinstance(emails[0], list) is False: + elif len(emails) > 0 and isinstance(emails[0], list) is False: raise ValueError("'emails' must be a list of lists.") if isinstance(duration, timedelta): duration_minutes = int(duration.total_seconds() // 60) @@ -412,7 +428,10 @@ def consecutive_availability( } if buffer is not None: data["buffer"] = buffer + if calendars is not None and len(calendars) > 0: + data["calendars"] = calendars + _validate_availability_query(data) resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) _validate(resp) return resp.json() diff --git a/tests/test_events.py b/tests/test_events.py index 10cf2afc..3de943ec 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -292,6 +292,41 @@ def test_free_busy_single_email(mocked_responses, api_client): assert data["end_time"] == 951868800 +@pytest.mark.usefixtures("mock_free_busy") +def test_free_busy_with_calendars(mocked_responses, api_client): + email = "ben@bitdiddle.com" + start_at = datetime(2000, 1, 1) + end_at = datetime(2000, 3, 1) + calendars = [ + { + "account_id": "test_account_id", + "calendar_ids": ["example_calendar_a", "example_calendar_b"], + } + ] + api_client.free_busy([email], start_at, end_at, calendars) + + request = mocked_responses.calls[-1].request + assert URLObject(request.url).path == "/calendars/free-busy" + data = json.loads(request.body) + assert data["emails"] == [email] + assert data["start_time"] == 946684800 + assert data["end_time"] == 951868800 + assert len(data["calendars"]) == 1 + assert data["calendars"][0] == { + "account_id": "test_account_id", + "calendar_ids": ["example_calendar_a", "example_calendar_b"], + } + + +@pytest.mark.usefixtures("mock_free_busy") +def test_free_busy_without_emails_or_calendar(mocked_responses, api_client): + start_at = datetime(2000, 1, 1) + end_at = datetime(2000, 3, 1) + with pytest.raises(ValueError) as excinfo: + api_client.free_busy([], start_at, end_at) + assert "Must set either 'emails' or 'calendars' in the query." in str(excinfo) + + @pytest.mark.usefixtures("mock_availability") def test_availability_datetime(mocked_responses, api_client): emails = ["one@example.com", "two@example.com", "three@example.com"] @@ -415,6 +450,56 @@ def test_availability_with_free_busy(mocked_responses, api_client): assert data["free_busy"] == free_busy +@pytest.mark.usefixtures("mock_availability") +def test_availability_with_calendars(mocked_responses, api_client): + emails = [ + "one@example.com", + "two@example.com", + "three@example.com", + "visitor@external.net", + ] + duration = 48 + interval = timedelta(minutes=18) + start_at = datetime(2020, 1, 1) + end_at = datetime(2020, 1, 2) + calendars = [ + { + "account_id": "test_account_id", + "calendar_ids": ["example_calendar_a", "example_calendar_b"], + } + ] + api_client.availability( + emails, duration, interval, start_at, end_at, calendars=calendars + ) + + request = mocked_responses.calls[-1].request + assert URLObject(request.url).path == "/calendars/availability" + data = json.loads(request.body) + assert data["emails"] == emails + assert data["duration_minutes"] == 48 + assert isinstance(data["duration_minutes"], int) + assert data["interval_minutes"] == 18 + assert isinstance(data["interval_minutes"], int) + assert data["start_time"] == 1577836800 + assert data["end_time"] == 1577923200 + assert len(data["calendars"]) == 1 + assert data["calendars"][0] == { + "account_id": "test_account_id", + "calendar_ids": ["example_calendar_a", "example_calendar_b"], + } + + +@pytest.mark.usefixtures("mock_availability") +def test_availability_without_emails_or_calendar(mocked_responses, api_client): + duration = 48 + interval = timedelta(minutes=18) + start_at = datetime(2000, 1, 1) + end_at = datetime(2000, 3, 1) + with pytest.raises(ValueError) as excinfo: + api_client.availability([], duration, interval, start_at, end_at) + assert "Must set either 'emails' or 'calendars' in the query." in str(excinfo) + + @pytest.mark.usefixtures("mock_availability") def test_consecutive_availability(mocked_responses, api_client): emails = [["one@example.com"], ["two@example.com", "three@example.com"]] @@ -521,6 +606,51 @@ def test_consecutive_availability_free_busy(mocked_responses, api_client): assert data["open_hours"][0]["end"] == "14:00" +@pytest.mark.usefixtures("mock_availability") +def test_consecutive_availability_with_calendars(mocked_responses, api_client): + emails = [["one@example.com"], ["two@example.com", "three@example.com"]] + duration = timedelta(minutes=30) + interval = timedelta(hours=1, minutes=30) + start_at = datetime(2020, 1, 1) + end_at = datetime(2020, 1, 2) + calendars = [ + { + "account_id": "test_account_id", + "calendar_ids": ["example_calendar_a", "example_calendar_b"], + } + ] + api_client.consecutive_availability( + emails, duration, interval, start_at, end_at, calendars=calendars + ) + + request = mocked_responses.calls[-1].request + assert URLObject(request.url).path == "/calendars/availability/consecutive" + data = json.loads(request.body) + assert data["emails"] == emails + assert data["duration_minutes"] == 30 + assert isinstance(data["duration_minutes"], int) + assert data["interval_minutes"] == 90 + assert isinstance(data["interval_minutes"], int) + assert data["start_time"] == 1577836800 + assert data["end_time"] == 1577923200 + assert len(data["calendars"]) == 1 + assert data["calendars"][0] == { + "account_id": "test_account_id", + "calendar_ids": ["example_calendar_a", "example_calendar_b"], + } + + +@pytest.mark.usefixtures("mock_availability") +def test_availability_without_emails_or_calendar(mocked_responses, api_client): + duration = 48 + interval = timedelta(minutes=18) + start_at = datetime(2000, 1, 1) + end_at = datetime(2000, 3, 1) + with pytest.raises(ValueError) as excinfo: + api_client.consecutive_availability([], duration, interval, start_at, end_at) + assert "Must set either 'emails' or 'calendars' in the query." in str(excinfo) + + @pytest.mark.usefixtures("mock_availability") def test_consecutive_availability_invalid_open_hours_email( mocked_responses, api_client From b22488027ae3f6adf5b9248610d5c54e830d0d36 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 26 Jul 2022 13:35:36 -0400 Subject: [PATCH 045/186] Add `metadata` field to `JobStatus` (#227) This PR adds metadata field to JobStatus --- CHANGELOG.md | 1 + nylas/client/restful_models.py | 1 + tests/conftest.py | 1 + tests/test_job_status.py | 1 + 4 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b11715c8..33158d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased ---------------- +* Add `metadata` field to `JobStatus` * Add support for `calendar` field in free-busy, availability, and consecutive availability queries v5.9.2 diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 94172a84..0b72a725 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -829,6 +829,7 @@ class JobStatus(NylasAPIObject): "object", "status", "original_data", + "metadata", ] datetime_attrs = {"created_at": "created_at"} collection_name = "job-statuses" diff --git a/tests/conftest.py b/tests/conftest.py index 58897346..17e3c69c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1767,6 +1767,7 @@ def mock_job_statuses(mocked_responses, api_url): "job_status_id": "test_job_status_id", "object": "message", "status": "successful", + "metadata": {"message_id": "nylas_message_id"}, }, { "account_id": "test_account_id", diff --git a/tests/test_job_status.py b/tests/test_job_status.py index f3c826a6..f5a89c58 100644 --- a/tests/test_job_status.py +++ b/tests/test_job_status.py @@ -28,6 +28,7 @@ def test_job_status(api_client): assert job_status["object"] == "message" assert job_status["status"] == "successful" assert job_status["created_at"] == datetime(2021, 6, 4, 22, 36) + assert job_status["metadata"] == {"message_id": "nylas_message_id"} @pytest.mark.usefixtures("mock_job_statuses") From d89b662570d695359a3d862e27f74bb500acc024 Mon Sep 17 00:00:00 2001 From: kraju3 <35513942+kraju3@users.noreply.github.com> Date: Thu, 28 Jul 2022 15:10:40 -0500 Subject: [PATCH 046/186] TSDK-366 Hosted Authentication params added (#229) Hosted Authentication URL parameters were missing redirect_on_error and provider value. I have added them and created two tests for them as well --- nylas/client/client.py | 12 +++++++++ tests/test_client.py | 56 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/nylas/client/client.py b/nylas/client/client.py index 9bc2f458..6f7ab174 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -152,6 +152,8 @@ def authentication_url( login_hint="", state="", scopes=("email", "calendar", "contacts"), + provider="", + redirect_on_error=None, ): args = { "redirect_uri": redirect_uri, @@ -165,6 +167,16 @@ def authentication_url( if isinstance(scopes, str): scopes = [scopes] args["scopes"] = ",".join(scopes) + if provider and provider in [ + "icloud", + "gmail", + "office365", + "exchange", + "imap", + ]: + args["provider"] = provider + if redirect_on_error is not None and isinstance(redirect_on_error, bool): + args["redirect_on_error"] = "true" if redirect_on_error is True else "false" url = URLObject(self.authorize_url).add_query_params(args.items()) return str(url) diff --git a/tests/test_client.py b/tests/test_client.py index 9d3ba21c..1eb6f4b8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -143,6 +143,62 @@ def test_client_authentication_url_scopes_none(api_client, api_url): assert urls_equal(expected, actual) +def test_client_authentication_url_optional_params(api_client, api_url): + expected = ( + URLObject(api_url) + .with_path("/oauth/authorize") + .set_query_params( + [ + ("login_hint", ""), + ("state", ""), + ("redirect_uri", "/redirect"), + ("response_type", "code"), + ("client_id", "None"), + ("scopes", "email"), + ("provider", "gmail"), + ("redirect_on_error", "false"), + ] + ) + ) + actual = URLObject( + api_client.authentication_url( + "/redirect", scopes="email", provider="gmail", redirect_on_error=False + ) + ) + assert urls_equal(expected, actual) + + +def test_client_authentication_url_invalid_param_values(api_client, api_url): + expected = ( + URLObject(api_url) + .with_path("/oauth/authorize") + .set_query_params( + [ + ("login_hint", ""), + ("state", ""), + ("redirect_uri", "/redirect"), + ("response_type", "code"), + ("client_id", "None"), + ("scopes", "email"), + ] + ) + ) + actual = URLObject( + api_client.authentication_url("/redirect", scopes="email", provider="Google") + ) + assert urls_equal(expected, actual) + + expected2 = expected.set_query_param("provider", "gmail") + + actual2 = URLObject( + api_client.authentication_url( + "/redirect", scopes="email", provider="gmail", redirect_on_error="true" + ) + ) + + assert urls_equal(expected2, actual2) + + def test_client_token_for_code(mocked_responses, api_client, api_url): endpoint = re.compile(api_url + "/oauth/token") response_body = json.dumps({"access_token": "hooray"}) From 9ff95a0890fe618009e8062136181f031caadec6 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 29 Jul 2022 16:45:33 -0400 Subject: [PATCH 047/186] V5.10.0 Release (#230) New `nylas` v5.10.0 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 3 ++- nylas/_client_sdk_version.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 82290803..74ed0a41 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.9.2 +current_version = 5.10.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 33158d19..fe221dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ nylas-python Changelog ====================== -Unreleased +v5.10.0 ---------------- * Add `metadata` field to `JobStatus` +* Add missing hosted authentication parameters * Add support for `calendar` field in free-busy, availability, and consecutive availability queries v5.9.2 diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index c87baea1..753aaa2a 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.9.2" +__VERSION__ = "5.10.0" From f766d11b8f9e24d695eb85129978a49d61a6b00b Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 2 Sep 2022 16:52:02 -0400 Subject: [PATCH 048/186] Fix Codecov repo integration (#231) Fix and re-enable the codecov integration. --- .github/workflows/clubhouse.yml | 61 --------------------------------- .github/workflows/test.yml | 2 +- README.md | 47 ++++++++++++++++--------- 3 files changed, 32 insertions(+), 78 deletions(-) delete mode 100644 .github/workflows/clubhouse.yml diff --git a/.github/workflows/clubhouse.yml b/.github/workflows/clubhouse.yml deleted file mode 100644 index 9ca4e01f..00000000 --- a/.github/workflows/clubhouse.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Linked Clubhouse Story -on: - pull_request: - types: [opened, closed, labeled] - -jobs: - clubhouse: - runs-on: ubuntu-latest - if: ${{ github.event.pull_request.draft == false }} - steps: - - uses: singingwolfboy/create-linked-clubhouse-story@v1.7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - clubhouse-token: ${{ secrets.CLUBHOUSE_TOKEN }} - project-name: Python SDK - opened-state-name: Reviewing - merged-state-name: Done - closed-state-name: Won't Pursue - ignored-users: dependabot - label-iteration-group-map: | - { - "SDK": { - "groupId": "6093fae3-f1eb-424f-a84f-cf8ff98167db", - "excludeName": "Backlog" - } - } - user-map: | - { - "AaronDDM": "5f622398-b918-4e0a-8f87-495cbfb63682", - "BahramKouhestani: "5fa31422-7ab3-46c6-89ef-fbde9120a640", - "bengotow": "5d659cde-7f97-4da0-88c5-50c5c0c0053e", - "BenLloydPearson": "5d260325-a60e-4a03-99ee-3b614b1cedfd", - "benjaminwhtan": "5d89014d-8fda-49a1-8f8c-ce8c72cd1b96", - "billwjo": "5d2372c8-2f19-4409-a86c-97462c626d10", - "chorrell": "5f0dd62e-bd28-4eac-aec6-526c7a96b19c", - "danielliu": "607ddce2-652a-48a5-ae5f-0d42811ede81", - "davidting": "5e0e4200-3c79-4755-aab9-a4a16f8ddcf1", - "dominicj-nylas": "5e947e4e-d40e-477e-85d0-21f2b323c069", - "dtom90": "604fa1a5-f5ce-4c4e-908a-3a87c15db98d", - "aiirwiick": "60ab71f2-b85d-4317-9018-df3e48e89dab", - "sammywen": "607da402-9bee-4ca4-80d5-b8f833840732", - "jesmarcannao": "5e441a1c-a7b5-40c8-8634-ef9f0bcce5c1", - "jhatch28": "5d0a9c4e-48a5-419b-a9b4-68fa672b96c9", - "jieunsharonkim": "5d03a446-3f70-4747-8bfd-af099463b4a4", - "jonafato": "5e9f1493-a9b5-436f-9c8b-c933631bbf8a", - "jseller": "604625a6-1d18-428e-a825-09233513387a", - "khamidou": "5d0ab326-3910-4ae8-b2f2-37bf2465d296", - "kdoby": "5d769b41-2f29-42a0-ac47-73ad93682db4", - "lkaileh": "5d23983d-49b8-4481-a947-87ff2bc56066", - "Gerdie": "5e1d52c6-7e82-4015-ab26-a59c357cfd4a", - "maxwell-schwartz": "5d48497f-0016-46f1-9a3c-149806c3599a", - "mypile": "60465c2b-1a8b-42dc-a467-d6d084d8f4f8", - "pfista": "5d0d0771-c540-4206-94b1-47e9fbef88b0", - "pengfeiye": "5d1533b0-fa0e-4557-a6fc-80e97923d817", - "peterdemarzio": "5e138a88-26cf-4814-b9fe-0c1f0ddcb8c0", - "philrenaud": "5e9f7a55-876b-453f-b137-b8677727fb81", - "spang": "5d13f026-00a6-4a9c-ba80-0048f43427f4", - "yusra-ahmed": "5d237850-e39b-4e75-8c52-89003602a59a", - "nylas-marcus": "5fc55708-44b9-41e8-aec0-004c3af90e69", - "mrashed-dev": "609964df-eb6c-4d74-8799-e33b7d503a09" - } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 621a14c5..55c043cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: - name: Upload coverage to Codecov if: ${{ always() }} - uses: codecov/codecov-action@v1.0.7 + uses: codecov/codecov-action@v3 black: runs-on: ubuntu-latest diff --git a/README.md b/README.md index e2f9bad7..68a86eaf 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,11 @@ -# Nylas Python SDK ![Build Status](https://github.com/nylas/nylas-python/workflows/Test/badge.svg) [![Code Coverage](https://codecov.io/gh/nylas/nylas-python/branch/main/graph/badge.svg)](https://codecov.io/gh/nylas/nylas-python) + + Aimeos logo + + +# Nylas Python SDK + +[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/nylas/nylas-python/Test)](https://github.com/nylas/nylas-python/actions/workflows/test.yml) +[![codecov](https://codecov.io/gh/nylas/nylas-python/branch/main/graph/badge.svg?token=HyxGAn5bJR)](https://codecov.io/gh/nylas/nylas-python) This is the GitHub repository for the Nylas Python SDK and this repo is primarily for anyone who wants to make contributions to the SDK or install it from source. If you are looking to use Python to access the Nylas Email, Calendar, or Contacts API you should refer to our official [Python SDK Quickstart Guide](https://docs.nylas.com/docs/quickstart-python). @@ -13,18 +20,22 @@ Here are some resources to help you get started: If you have a question about the Nylas Communications Platform, please reach out to support@nylas.com to get help. -# Install +## โš™๏ธ Install The Nylas Python SDK is available via pip: -`pip install nylas` +```bash +pip install nylas +``` To install the SDK from source, clone this repo and run the install script. - git clone https://github.com/nylas/nylas-python.git && cd nylas-python - python setup.py install +```bash +git clone https://github.com/nylas/nylas-python.git && cd nylas-python +python setup.py install +``` -# Usage +## โšก๏ธ Usage To use this SDK, you first need to [sign up for a free Nylas developer account](https://nylas.com/register). @@ -33,26 +44,30 @@ Then, follow our guide to [setup your first app and get your API access keys](ht Next, in your python script, import the `APIClient` class from the `nylas` package, and create a new instance of this class, passing the variables you gathered when you got your developer API keys. In the following example, replace `CLIENT_ID`, `CLIENT_SECRET`, and `ACCESS_TOKEN` with your values. - from nylas import APIClient +```python +from nylas import APIClient - nylas = APIClient( - CLIENT_ID, - CLIENT_SECRET, - ACCESS_TOKEN - ) +nylas = APIClient( + CLIENT_ID, + CLIENT_SECRET, + ACCESS_TOKEN +) +``` Now, you can use `nylas` to access full email, calendar, and contacts functionality. For example, here is how you would print the subject line for the most recent email message to the console. - message = nylas.messages.first() - print(message.subject) +```python +message = nylas.messages.first() +print(message.subject) +``` To learn more about how to use the Nylas Python SDK, please refer to our [Python SDK QuickStart Guide](https://docs.nylas.com/docs/quickstart-python) and our [Python tutorials](https://docs.nylas.com/docs/tutorials). -# Contributing +## ๐Ÿ’™ Contributing Please refer to [Contributing](Contributing.md) for information about how to make contributions to this project. We welcome questions, bug reports, and pull requests. -# License +## ๐Ÿ“ License This project is licensed under the terms of the MIT license. Please refer to [LICENSE](LICENSE) for the full terms. From 36c2ead337594387592c1f171b99e5880b0e2d3b Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Wed, 21 Sep 2022 10:37:28 -0400 Subject: [PATCH 049/186] [TSDK-483] Fix authentication for integrations (#233) In Python 2.7 the 'b' prefix ahead of a string is ignored but in Python 3 it indicates a byte literal versus a string. So on Python 3 apps implementing our SDK they would encounter an error as the Nylas API is expecting a string instead of a byte string. Now we ensure that it gets decoded back into a string to ensure it works on Python 3. --- nylas/client/client.py | 6 +++--- tests/test_client.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/nylas/client/client.py b/nylas/client/client.py index 6f7ab174..ab69f229 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -817,19 +817,19 @@ def _request(self, method, url, cls=None, headers=None, **kwargs): def _add_auth_header(self, auth_method): authorization = None - if auth_method is AuthMethod.BEARER: + if auth_method == AuthMethod.BEARER: authorization = ( "Bearer {token}".format(token=self.access_token) if self.access_token else None ) - elif auth_method is AuthMethod.BASIC_CLIENT_ID_AND_SECRET: + elif auth_method == AuthMethod.BASIC_CLIENT_ID_AND_SECRET: if self.client_id and self.client_secret: credential = "{client_id}:{client_secret}".format( client_id=self.client_id, client_secret=self.client_secret ) authorization = "Basic {credential}".format( - credential=b64encode(credential.encode("utf8")) + credential=b64encode(credential.encode("utf8")).decode("utf8") ) else: if self.client_secret: diff --git a/tests/test_client.py b/tests/test_client.py index 1eb6f4b8..82fed5a9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,6 +6,7 @@ import responses from nylas.client import APIClient from nylas.client.restful_models import Contact +from nylas.utils import AuthMethod def urls_equal(url1, url2): @@ -345,3 +346,22 @@ def test_count(mocked_responses, api_client, api_url): contact_count = api_client.contacts.count() assert contact_count == 721 + + +def test_add_auth_header_bearer(api_client): + api_client.access_token = "access_token" + auth_header = api_client._add_auth_header(AuthMethod.BEARER) + assert auth_header == {"Authorization": "Bearer access_token"} + + +def test_add_auth_header_basic_client_id_and_secret(api_client): + api_client.client_id = "client_id" + api_client.client_secret = "client_secret" + auth_header = api_client._add_auth_header(AuthMethod.BASIC_CLIENT_ID_AND_SECRET) + assert auth_header == {"Authorization": "Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ="} + + +def test_add_auth_header_basic(api_client): + api_client.client_secret = "client_secret" + auth_header = api_client._add_auth_header(AuthMethod.BASIC) + assert auth_header == {"Authorization": "Basic Y2xpZW50X3NlY3JldDo="} From 394f0a42a245436067ee83cbac3e77b1307d79bd Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 22 Sep 2022 15:33:06 -0400 Subject: [PATCH 050/186] v5.10.1 Release (#234) New nylas v5.10.1 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 4 ++++ nylas/_client_sdk_version.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 74ed0a41..7c3c8d3a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.10.0 +current_version = 5.10.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index fe221dab..953b24d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +v5.10.1 +---------------- +* Fix authentication for integrations + v5.10.0 ---------------- * Add `metadata` field to `JobStatus` diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 753aaa2a..aa3c0ca2 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.10.0" +__VERSION__ = "5.10.1" From 02b5324baba9868e55c1aff36e5336fe0eb8b70f Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Wed, 5 Oct 2022 15:34:47 -0400 Subject: [PATCH 051/186] add enhanced events edge case (#236) --- nylas/client/authentication_models.py | 14 ++++++++++++++ tests/test_authentication.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/nylas/client/authentication_models.py b/nylas/client/authentication_models.py index f90ebd9f..3e634906 100644 --- a/nylas/client/authentication_models.py +++ b/nylas/client/authentication_models.py @@ -246,6 +246,20 @@ def _set_integrations_api_url(self): app_name=self.app_name, region=self.region.value ) + def _hosted_authentication_enhanced_events( + self, provider, redirect_uri, account_id + ): + request = { + "provider": provider, + "redirect_uri": redirect_uri, + "account_id": account_id, + } + response = self.api._post_resource(Grant, "auth", None, request, path="connect") + if "data" in response: + response = response["data"] + + return response + class Region(str, Enum): """ This is an Enum the regions supported by the Integrations API diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 023c368f..536d779f 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -236,3 +236,20 @@ def test_grant_authentication_hosted_auth(mocked_responses, api_client): "scope": ["meeting:write"], "metadata": {"isAdmin": True}, } + + +@pytest.mark.usefixtures("mock_authentication_hosted_auth") +def test_grant_authentication_hosted_auth_enhanced_events(mocked_responses, api_client): + api_client.authentication._hosted_authentication_enhanced_events( + provider=Authentication.Provider.ZOOM, + redirect_uri="https://myapp.com/callback-handler", + account_id="test-account-id", + ) + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/connect/auth" + assert request.method == "POST" + assert json.loads(request.body) == { + "provider": "zoom", + "redirect_uri": "https://myapp.com/callback-handler", + "account_id": "test-account-id", + } From 7e02e89703fddf422e23c00adb6bfd5adf76d4c0 Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Tue, 11 Oct 2022 17:13:04 -0400 Subject: [PATCH 052/186] Remove sys.exit for PEP 517 compatibility (#235) Nylas is having some issues being installed by poetry 1.2. Poetry uses the PEP 517 standard for installing dependencies. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e5f50308..e9cfb844 100644 --- a/setup.py +++ b/setup.py @@ -118,4 +118,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + main() From ee4809287a0a68fa05909682c42b9296834c56fc Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 11 Oct 2022 17:23:04 -0400 Subject: [PATCH 053/186] v5.10.2 Release (#237) New nylas v5.10.2 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 4 ++++ nylas/_client_sdk_version.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 7c3c8d3a..56c08032 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.10.1 +current_version = 5.10.2 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 953b24d0..3312b54d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +v5.10.2 +---------------- +* Update package setup to be compatible with PEP 517 + v5.10.1 ---------------- * Fix authentication for integrations diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index aa3c0ca2..d500dad3 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.10.1" +__VERSION__ = "5.10.2" From b14b15812f09c97ff0584e7624b774cdb3382205 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Mon, 24 Oct 2022 15:05:38 -0400 Subject: [PATCH 054/186] Add support for calendar colors (#238) Add support for the new colors field in the Calendar model. Please note that this field is read only, and for Microsoft calendars only. --- CHANGELOG.md | 4 ++++ nylas/client/restful_models.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3312b54d..5c1c48c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add support for calendar colors (for Microsoft calendars) + v5.10.2 ---------------- * Update package setup to be compatible with PEP 517 diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 0b72a725..c55bb833 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -630,6 +630,7 @@ class Calendar(NylasAPIObject): "account_id", "name", "description", + "hex_color", "job_status_id", "metadata", "read_only", @@ -640,7 +641,7 @@ class Calendar(NylasAPIObject): def __init__(self, api): NylasAPIObject.__init__(self, Calendar, api) - self.read_only_attrs.update({"is_primary", "read_only"}) + self.read_only_attrs.update({"is_primary", "read_only", "hex_color"}) @property def events(self): From c70a76764ecc8498a2726e1bcb57d313d2b28611 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 18 Nov 2022 17:50:32 -0500 Subject: [PATCH 055/186] Add visibility field in Event (#240) Add the visibility to the Event class. --- CHANGELOG.md | 1 + nylas/client/restful_models.py | 1 + tests/test_events.py | 2 ++ 3 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1c48c8..713c4eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Add support for calendar colors (for Microsoft calendars) +* Add support for visibility field in Event v5.10.2 ---------------- diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index c55bb833..a9a7a185 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -675,6 +675,7 @@ class Event(NylasAPIObject): "event_collection_id", "capacity", "round_robin_order", + "visibility", ] datetime_attrs = {"original_start_at": "original_start_time"} collection_name = "events" diff --git a/tests/test_events.py b/tests/test_events.py index 3de943ec..6c347db9 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -25,10 +25,12 @@ def test_event_crud(mocked_responses, api_client): event1.status = "should not send" event1.master_event_id = "should not send" event1.original_start_time = "should not send" + event1.visibility = "private" event1.save() request = mocked_responses.calls[0].request body = json.loads(request.body) assert event1.id == "cv4ei7syx10uvsxbs21ccsezf" + assert event1.visibility == "private" assert "title" in body assert "object" not in body assert "account_id" not in body From 5a7ca7d86f23f09eeb31352c838a9d868d154f44 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 18 Nov 2022 17:51:14 -0500 Subject: [PATCH 056/186] Add new custom error for 429 errors (#239) Add support for rate limit 429 headers from the API --- CHANGELOG.md | 1 + nylas/client/client.py | 4 +++- nylas/client/errors.py | 18 ++++++++++++++++++ tests/test_send_error_handling.py | 22 +++++++++++++++++++--- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 713c4eec..6c667421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Add support for calendar colors (for Microsoft calendars) +* Add support for rate limit errors * Add support for visibility field in Event v5.10.2 diff --git a/nylas/client/client.py b/nylas/client/client.py index ab69f229..851a8cdf 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -13,7 +13,7 @@ from six.moves.urllib.parse import urlencode from nylas._client_sdk_version import __VERSION__ from nylas.client.delta_collection import DeltaCollection -from nylas.client.errors import MessageRejectedError, NylasApiError +from nylas.client.errors import MessageRejectedError, NylasApiError, RateLimitError from nylas.client.outbox_models import Outbox from nylas.client.restful_model_collection import RestfulModelCollection from nylas.client.restful_models import ( @@ -66,6 +66,8 @@ def _validate(response): # we will handle it separate and handle a _different_ exception # so that users don't think they need to pay. raise MessageRejectedError(response) + elif response.status_code == 429: + raise RateLimitError(response) elif response.status_code >= 400: raise NylasApiError(response) diff --git a/nylas/client/errors.py b/nylas/client/errors.py index b3c31ab5..b8d7ef24 100644 --- a/nylas/client/errors.py +++ b/nylas/client/errors.py @@ -42,3 +42,21 @@ def __init__(self, response): super(NylasApiError, self).__init__(error_message, response=response) except (ValueError, KeyError): super(NylasApiError, self).__init__(response.text, response=response) + + +class RateLimitError(NylasApiError): + """ + Error class for 429 rate limit errors + This class provides details about the rate limit returned from the server + """ + + RATE_LIMIT_LIMIT_HEADER = "X-RateLimit-Limit" + RATE_LIMIT_RESET_HEADER = "X-RateLimit-Reset" + + def __init__(self, response): + try: + self.rate_limit = int(response.headers[self.RATE_LIMIT_LIMIT_HEADER]) + self.rate_limit_reset = int(response.headers[self.RATE_LIMIT_RESET_HEADER]) + super(RateLimitError, self).__init__(response) + except (ValueError, KeyError): + super(RateLimitError, self).__init__(response) diff --git a/tests/test_send_error_handling.py b/tests/test_send_error_handling.py index c3c0ec74..c89aa484 100644 --- a/tests/test_send_error_handling.py +++ b/tests/test_send_error_handling.py @@ -4,11 +4,11 @@ import responses import six from requests import RequestException -from nylas.client.errors import MessageRejectedError +from nylas.client.errors import MessageRejectedError, RateLimitError def mock_sending_error( - http_code, message, mocked_responses, api_url, server_error=None + http_code, message, mocked_responses, api_url, server_error=None, headers=None ): send_endpoint = re.compile(api_url + "/send") response_body = {"type": "api_error", "message": message} @@ -27,6 +27,7 @@ def mock_sending_error( content_type="application/json", status=http_code, body=response_body, + headers=headers, ) @@ -41,10 +42,25 @@ def test_handle_message_rejected(mocked_responses, api_client, api_url): @pytest.mark.usefixtures("mock_account", "mock_save_draft") def test_handle_quota_exceeded(mocked_responses, api_client, api_url): + draft = api_client.drafts.create() + error_message = "Daily sending quota exceeded" + error_headers = {"X-RateLimit-Limit": "500", "X-RateLimit-Reset": "10"} + mock_sending_error( + 429, error_message, mocked_responses, api_url=api_url, headers=error_headers + ) + with pytest.raises(RateLimitError) as exc: + draft.send() + assert "Too Many Requests" in str(exc.value) + assert exc.value.rate_limit == 500 + assert exc.value.rate_limit_reset == 10 + + +@pytest.mark.usefixtures("mock_account", "mock_save_draft") +def test_handle_quota_exceeded_no_headers(mocked_responses, api_client, api_url): draft = api_client.drafts.create() error_message = "Daily sending quota exceeded" mock_sending_error(429, error_message, mocked_responses, api_url=api_url) - with pytest.raises(RequestException) as exc: + with pytest.raises(RateLimitError) as exc: draft.send() assert "Too Many Requests" in str(exc.value) From a1e3a73fa49c46d1acb4cbfcec6ee4fc282a36d2 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 18 Nov 2022 17:59:58 -0500 Subject: [PATCH 057/186] v5.11.0 Release (#241) New nylas v5.11.0 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 56c08032..4420246e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.10.2 +current_version = 5.11.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c667421..63de846c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v5.11.0 ---------------- * Add support for calendar colors (for Microsoft calendars) * Add support for rate limit errors diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index d500dad3..5b4f3bd8 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.10.2" +__VERSION__ = "5.11.0" From 27227be4ad5e1427cdc9a9ddb785ebe6bc05f846 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Mon, 12 Dec 2022 10:33:48 -0500 Subject: [PATCH 058/186] Add support for sending raw MIME messages (#243) This PR adds support for sending raw MIME messages. --- CHANGELOG.md | 4 ++++ nylas/client/client.py | 6 ++++++ nylas/client/restful_models.py | 12 ++++++++++++ tests/conftest.py | 36 ++++++++++++++++++++++++++++++++++ tests/test_drafts.py | 21 ++++++++++++++++++++ 5 files changed, 79 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63de846c..07fe0d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add support for sending raw MIME messages + v5.11.0 ---------------- * Add support for calendar colors (for Microsoft calendars) diff --git a/nylas/client/client.py b/nylas/client/client.py index 851a8cdf..6168f3cc 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -33,6 +33,7 @@ Component, JobStatus, Webhook, + Send, ) from nylas.client.neural_api_models import Neural from nylas.client.scheduler_restful_model_collection import ( @@ -640,6 +641,11 @@ def _create_resource(self, cls, data, **kwargs): if cls == File: response = self._request(HttpMethod.POST, url, cls=cls, files=data) + elif cls == Send and type(data) is not dict: + headers = {"Content-Type": "message/rfc822"} + response = self._request( + HttpMethod.POST, url, cls=cls, headers=headers, data=data + ) else: converted_data = create_request_body(data, cls.datetime_attrs) headers = {"Content-Type": "application/json"} diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index a9a7a185..7dcd9645 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -514,6 +514,18 @@ def detach(self, file): if file.id in self.file_ids: self.file_ids.remove(file.id) + def send_raw(self, mime_message): + """ + Send a raw MIME message + + Args: + mime_message (str): The raw MIME message to send + + Returns: + Message: The sent message + """ + return self.api._create_resource(Send, mime_message) + def send(self): if not self.id: data = self.as_json() diff --git a/tests/conftest.py b/tests/conftest.py index 17e3c69c..9a2ba7de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -738,6 +738,42 @@ def callback(request): ) +@pytest.fixture +def mock_draft_raw_response(mocked_responses, api_url): + body = { + "bcc": [], + "body": "", + "cc": [], + "date": 1438684486, + "events": [], + "files": [], + "folder": None, + "from": [{"email": "benb@nylas.com"}], + "id": "2h111aefv8pzwzfykrn7hercj", + "namespace_id": "384uhp3aj8l7rpmv9s2y2rukn", + "object": "draft", + "reply_to": [], + "reply_to_message_id": None, + "snippet": "", + "starred": False, + "subject": "Stay polish, stay hungary", + "thread_id": "clm33kapdxkposgltof845v9s", + "to": [{"email": "helena@nylas.com", "name": "Helena Handbasket"}], + "unread": False, + "version": 0, + } + + def callback(request): + return 200, {}, json.dumps(body) + + mocked_responses.add_callback( + responses.POST, + api_url + "/send", + callback=callback, + content_type="application/json", + ) + + @pytest.fixture def mock_draft_send_unsaved_response(mocked_responses, api_url): def callback(request): diff --git a/tests/test_drafts.py b/tests/test_drafts.py index 7b18a09f..9e2ba953 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -86,6 +86,27 @@ def test_send_draft_with_tracking(mocked_responses, api_client): assert send_payload["tracking"] == tracking +@pytest.mark.usefixtures("mock_draft_raw_response") +def test_send_draft_raw_mime(mocked_responses, api_client): + raw_mime = """MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Subject: With Love, From Nylas +From: You +To: My Nylas Friend + +This email was sent via raw MIME using the Nylas email API. Visit https://nylas.com for details. +""" + draft = api_client.drafts.create() + draft.send_raw(raw_mime) + + send_payload = mocked_responses.calls[-1].request + assert type(send_payload.body) == str + assert send_payload.body == raw_mime + assert send_payload.path_url == "/send" + assert send_payload.method == "POST" + assert send_payload.headers["Content-Type"] == "message/rfc822" + + @pytest.mark.usefixtures("mock_files") def test_draft_attachment(api_client): draft = api_client.drafts.create() From d844c963135430adb5775d2d88170fae093a0892 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 16 Dec 2022 12:26:30 -0500 Subject: [PATCH 059/186] v5.12.0 Release (#244) New nylas v5.12.0 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 4420246e..434bcdd2 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.11.0 +current_version = 5.12.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 07fe0d76..7d9c31ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v5.12.0 ---------------- * Add support for sending raw MIME messages diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 5b4f3bd8..38b84e8c 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.11.0" +__VERSION__ = "5.12.0" From ba0d2f87264b1274992d08637a64ea4bc2f8ef2a Mon Sep 17 00:00:00 2001 From: Matt Hooks Date: Thu, 5 Jan 2023 11:55:12 -0500 Subject: [PATCH 060/186] Only install enum34 on python34 and below. (#247) Small patch to limit `enum34` to installs on Python3.4 or less. Py34 is roughly chosen here due to the relative time of release (w.r.t. the last updates to `enum34`), and to avoid changing deps on any early-adopter py34 projects. --- setup.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e9cfb844..1f399d8e 100644 --- a/setup.py +++ b/setup.py @@ -15,8 +15,11 @@ "requests[security]>=2.4.2", "six>=1.4.1", "urlobject", - "enum34>=1.1.10", ] + +if sys.version_info[:2] <= (3, 4): + RUN_DEPENDENCIES.append("enum34>=1.1.10") + TEST_DEPENDENCIES = [ "pytest", "pytest-cov", From 10cf2bd1cf0ee0601e485ea995bcd5ae236ebd70 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 6 Jan 2023 17:27:38 -0500 Subject: [PATCH 061/186] v5.12.1 Release (#248) New nylas v5.12.1 release --- .bumpversion.cfg | 2 +- CHANGELOG.md | 4 ++++ nylas/_client_sdk_version.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 434bcdd2..17ded378 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.12.0 +current_version = 5.12.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d9c31ef..0d54e99b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +v5.12.1 +---------------- +* Only install enum34 on python34 and below + v5.12.0 ---------------- * Add support for sending raw MIME messages diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 38b84e8c..f3c10d71 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.12.0" +__VERSION__ = "5.12.1" From 18854040c678b47b0c120904b72784ebd8f601cf Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Wed, 1 Feb 2023 12:36:27 -0500 Subject: [PATCH 062/186] Local webhook development support (#252) This PR enables support for local webhook development. When implementing this feature in your app, the SDK will create a tunnel connection to a websocket server and registers it as a webhook callback to your Nylas account. --- .github/workflows/test.yml | 2 +- nylas/config.py | 13 +++ nylas/services/__init__.py | 0 nylas/services/tunnel.py | 96 ++++++++++++++++++++ setup.py | 6 ++ tests/test_tunnel.py | 181 +++++++++++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 nylas/config.py create mode 100644 nylas/services/__init__.py create mode 100644 nylas/services/tunnel.py create mode 100644 tests/test_tunnel.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55c043cd..ab21f452 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,4 +47,4 @@ jobs: run: pip install black - name: Run black - run: black --check . + run: black --check --extend-exclude="/examples" . diff --git a/nylas/config.py b/nylas/config.py new file mode 100644 index 00000000..e7cab9fd --- /dev/null +++ b/nylas/config.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class Region(str, Enum): + """ + Enum representing the regions supported by the Nylas API + """ + + US = "us" + IRELAND = "ireland" + + +DEFAULT_REGION = Region.US diff --git a/nylas/services/__init__.py b/nylas/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nylas/services/tunnel.py b/nylas/services/tunnel.py new file mode 100644 index 00000000..356ad0d4 --- /dev/null +++ b/nylas/services/tunnel.py @@ -0,0 +1,96 @@ +import uuid + +import websocket +import json +from threading import Thread + +from nylas.client import APIClient +from nylas.client.restful_models import Webhook +from nylas.config import DEFAULT_REGION + + +def open_webhook_tunnel(api, config): + """ + Open a webhook tunnel and register it with the Nylas API + 1. Creates a UUID + 2. Opens a websocket connection to Nylas' webhook forwarding service, + with the UUID as a header + 3. Creates a new webhook pointed at the forwarding service with the UUID as the path + + When an event is received by the forwarding service, it will push directly to this websocket + connection + + Args: + api (APIClient): The configured Nylas API client + config (dict[str, any]): Configuration for the webhook tunnel, including callback functions, region, + and events to subscribe to + """ + + ws = _build_webhook_tunnel(api, config) + ws_run = Thread(target=_run_webhook_tunnel, args=(ws,)) + ws_run.start() + + +def _run_webhook_tunnel(ws): + ws.run_forever() + + +def _register_webhook(api, callback_domain, tunnel_id, triggers): + webhook = api.webhooks.create() + webhook.callback_url = "https://{}/{}".format(callback_domain, tunnel_id) + webhook.triggers = triggers + webhook.state = Webhook.State.ACTIVE.value + webhook.save() + + +def _build_webhook_tunnel(api, config): + ws_domain = "wss://tunnel.nylas.com" + callback_domain = "cb.nylas.com" + # This UUID will map our websocket to a webhook in the forwarding server + tunnel_id = str(uuid.uuid4()) + + region = config.get("region", DEFAULT_REGION) + triggers = config.get("triggers", [e.value for e in Webhook.Trigger]) + + usr_on_message = config.get("on_message", None) + on_open = config.get("on_open", None) + on_error = config.get("on_error", None) + on_close = config.get("on_close", None) + on_ping = config.get("on_ping", None) + on_pong = config.get("on_pong", None) + on_cont_message = config.get("on_cont_message", None) + on_data = config.get("on_data", None) + + def on_message(wsapp, message): + deltas = _parse_deltas(message) + for delta in deltas: + usr_on_message(delta) + + ws = websocket.WebSocketApp( + ws_domain, + header={ + "Client-Id": api.client_id, + "Client-Secret": api.client_secret, + "Tunnel-Id": tunnel_id, + "Region": region.value, + }, + on_open=on_open, + on_message=on_message if usr_on_message else None, + on_error=on_error, + on_close=on_close, + on_ping=on_ping, + on_pong=on_pong, + on_cont_message=on_cont_message, + on_data=on_data, + ) + + # Register the webhook to the Nylas application + _register_webhook(api, callback_domain, tunnel_id, triggers) + + return ws + + +def _parse_deltas(message): + parsed_message = json.loads(message) + parsed_body = json.loads(parsed_message["body"]) + return parsed_body["deltas"] diff --git a/setup.py b/setup.py index 1f399d8e..bc42cfad 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,7 @@ "requests[security]>=2.4.2", "six>=1.4.1", "urlobject", + "websocket-client==0.59.0", ] if sys.version_info[:2] <= (3, 4): @@ -24,10 +25,15 @@ "pytest", "pytest-cov", "pytest-timeout", + "pytest-mock", "responses==0.10.5", "twine", "pytz", ] + +if sys.version_info < (3, 3): + TEST_DEPENDENCIES.append("mock") + RELEASE_DEPENDENCIES = ["bumpversion>=0.5.0", "twine>=3.4.2"] diff --git a/tests/test_tunnel.py b/tests/test_tunnel.py new file mode 100644 index 00000000..5b2d0e91 --- /dev/null +++ b/tests/test_tunnel.py @@ -0,0 +1,181 @@ +import json +import sys + +if sys.version_info >= (3, 3): + from unittest.mock import Mock +else: + from mock import Mock + +import pytest +from urlobject import URLObject + +from nylas.config import Region +from nylas.services import tunnel +from nylas.client.restful_models import Webhook + + +@pytest.mark.usefixtures("mock_create_webhook") +def test_build_webhook_tunnel(mocker, api_client_with_client_id): + mocker.patch("websocket.WebSocketApp", mock_websocket) + mocker.patch("uuid.uuid4", return_value="uuid") + ws = tunnel._build_webhook_tunnel( + api_client_with_client_id, + { + "region": Region.IRELAND, + "triggers": [Webhook.Trigger.MESSAGE_CREATED], + "on_open": on_open, + "on_error": on_error, + "on_close": on_close, + "on_ping": on_ping, + "on_pong": on_pong, + "on_cont_message": on_cont_message, + "on_data": on_data, + }, + ) + assert ws["domain"] == "wss://tunnel.nylas.com" + assert ws["header"] == { + "Client-Id": "fake-client-id", + "Client-Secret": "nyl4n4ut", + "Tunnel-Id": "uuid", + "Region": "ireland", + } + assert ws["on_open"] == on_open + assert ws["on_error"] == on_error + assert ws["on_close"] == on_close + assert ws["on_ping"] == on_ping + assert ws["on_pong"] == on_pong + assert ws["on_cont_message"] == on_cont_message + assert ws["on_data"] == on_data + + +@pytest.mark.usefixtures("mock_create_webhook") +def test_build_webhook_tunnel_defaults(mocker, api_client_with_client_id): + mocker.patch("websocket.WebSocketApp", mock_websocket) + mocker.patch("uuid.uuid4", return_value="uuid") + ws = tunnel._build_webhook_tunnel(api_client_with_client_id, {}) + assert ws["domain"] == "wss://tunnel.nylas.com" + assert ws["header"] == { + "Client-Id": "fake-client-id", + "Client-Secret": "nyl4n4ut", + "Tunnel-Id": "uuid", + "Region": "us", + } + assert ws["on_open"] is None + assert ws["on_message"] is None + assert ws["on_error"] is None + assert ws["on_close"] is None + assert ws["on_ping"] is None + assert ws["on_pong"] is None + assert ws["on_cont_message"] is None + assert ws["on_data"] is None + + +@pytest.mark.usefixtures("mock_create_webhook") +def test_register_webhook(mocked_responses, api_client_with_client_id): + tunnel._register_webhook( + api_client_with_client_id, + "domain.com", + "tunnel_id", + [Webhook.Trigger.MESSAGE_CREATED], + ) + request = mocked_responses.calls[0].request + assert URLObject(request.url).path == "/a/fake-client-id/webhooks" + assert request.method == "POST" + assert json.loads(request.body) == { + "callback_url": "https://domain.com/tunnel_id", + "triggers": ["message.created"], + "state": "active", + } + + +@pytest.mark.usefixtures("mock_create_webhook") +def test_open_webhook_tunnel(mocker, api_client_with_client_id): + mock_build = Mock() + mock_run = Mock() + mocker.patch("nylas.services.tunnel._build_webhook_tunnel", mock_build) + mocker.patch("nylas.services.tunnel._run_webhook_tunnel", mock_run) + + tunnel.open_webhook_tunnel(api_client_with_client_id, {"region": Region.IRELAND}) + + mock_build_calls = mock_build.call_args_list + assert len(mock_build_calls) == 1 + assert len(mock_build_calls[0].args) == 2 + assert mock_build_calls[0].args == ( + api_client_with_client_id, + {"region": Region.IRELAND}, + ) + + mock_run_calls = mock_run.call_args_list + assert len(mock_run_calls) == 1 + + +def test_run_webhook_tunnel(): + mock = Mock() + tunnel._run_webhook_tunnel(mock) + mock_method_calls = mock.method_calls + assert len(mock_method_calls) == 1 + assert mock_method_calls[0][0] == "run_forever" + + +def test_parse_deltas(): + message = '{"body": "{\\"deltas\\": [{\\"date\\": 1675098465, \\"object\\": \\"message\\", \\"type\\": \\"message.created\\", \\"object_data\\": {\\"namespace_id\\": \\"namespace_123\\", \\"account_id\\": \\"account_123\\", \\"object\\": \\"message\\", \\"attributes\\": {\\"thread_id\\": \\"thread_123\\", \\"received_date\\": 1675098459}, \\"id\\": \\"123\\", \\"metadata\\": null}}]}"}' + deltas = tunnel._parse_deltas(message) + assert len(deltas) == 1 + delta = deltas[0] + assert delta["date"] == 1675098465 + assert delta["object"] == "message" + assert delta["type"] == Webhook.Trigger.MESSAGE_CREATED + assert delta["object_data"] is not None + + +# ============================================================================ +# Mock functions for websocket callback +# ============================================================================ + + +# This function mocks websocket implementation and returns a list of params as a dict +def mock_websocket( + domain, + header, + on_open, + on_message, + on_error, + on_close, + on_ping, + on_pong, + on_cont_message, + on_data, +): + return locals() + + +def on_message(): + print("on_message") + + +def on_open(): + print("on_open") + + +def on_error(): + print("on_error") + + +def on_close(): + print("on_close") + + +def on_ping(): + print("on_ping") + + +def on_pong(): + print("on_pong") + + +def on_cont_message(): + print("on_cont_message") + + +def on_data(): + print("on_data") From e6c68be27acb6c8e76c4a652203704d03d740c9d Mon Sep 17 00:00:00 2001 From: Matt Hooks Date: Wed, 1 Feb 2023 13:02:56 -0500 Subject: [PATCH 063/186] Use PEP508 syntax for conditional dependency. (#250) Co-authored-by: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> --- setup.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index bc42cfad..0328f734 100644 --- a/setup.py +++ b/setup.py @@ -15,12 +15,10 @@ "requests[security]>=2.4.2", "six>=1.4.1", "urlobject", + "enum34>=1.1.10; python_version<='3.4'", "websocket-client==0.59.0", ] -if sys.version_info[:2] <= (3, 4): - RUN_DEPENDENCIES.append("enum34>=1.1.10") - TEST_DEPENDENCIES = [ "pytest", "pytest-cov", @@ -29,11 +27,9 @@ "responses==0.10.5", "twine", "pytz", + "mock; python_version<'3.3'", ] -if sys.version_info < (3, 3): - TEST_DEPENDENCIES.append("mock") - RELEASE_DEPENDENCIES = ["bumpversion>=0.5.0", "twine>=3.4.2"] From 1e529d963a2f826fd9bfa22af83403ef56215bad Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Wed, 1 Feb 2023 13:27:00 -0500 Subject: [PATCH 064/186] v5.13.0 Release (#253) This new Nylas Python SDK release provides the following additions: * Add local webhook development support (#252) * Use PEP508 syntax for conditional dependencies (#250) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 5 +++++ nylas/_client_sdk_version.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 17ded378..fc0aee40 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.12.1 +current_version = 5.13.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d54e99b..0401d223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ nylas-python Changelog ====================== +v5.13.0 +---------------- +* Add local webhook development support +* Use PEP508 syntax for conditional dependencies + v5.12.1 ---------------- * Only install enum34 on python34 and below diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index f3c10d71..270ffe2c 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.12.1" +__VERSION__ = "5.13.0" From da09bdf5d528870006569fb2c4af45e2adf48f17 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Wed, 1 Feb 2023 14:17:35 -0500 Subject: [PATCH 065/186] Minor fixes for v5.13.1 (#254) This PR provides a few key fixes: * fix `send_authorization` not returning the correct dict * fix expanded threads not inflating the messages objects properly * fix class attributes with leading underscores not serializing as expected --- nylas/client/client.py | 11 ++++++++++- nylas/client/restful_models.py | 5 +++++ tests/conftest.py | 16 ++++++++++++++++ tests/test_threads.py | 8 ++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/nylas/client/client.py b/nylas/client/client.py index 6168f3cc..1672d723 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -215,9 +215,18 @@ def send_authorization(self, code): results = _validate(resp).json() self.access_token = results["access_token"] - return resp + return results def token_for_code(self, code): + """ + Exchange an authorization code for an access token + + Args: + code (str): One-time authorization code from Nylas + + Returns: + str: The access token + """ self.send_authorization(code) return self.access_token diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 7dcd9645..04f04dd4 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -122,6 +122,8 @@ def as_json(self, enforce_read_only=True): else: attr_value = getattr(self, attr) if attr_value is not None: + if attr.startswith("_"): + attr = attr[1:] dct[attr] = attr_value for date_attr, iso_attr in self.cls.date_attrs.items(): if date_attr in self.read_only_attrs and enforce_read_only is True: @@ -353,6 +355,7 @@ class Thread(NylasAPIObject): "version", "_folders", "_labels", + "_messages", "received_recent_date", "has_attachments", ] @@ -375,6 +378,8 @@ def __init__(self, api): @property def messages(self): + if self._messages: + return [Message.create(self.api, **f) for f in self._messages] return self.child_collection(Message, thread_id=self.id) @property diff --git a/tests/conftest.py b/tests/conftest.py index 9a2ba7de..8d71d683 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -545,6 +545,22 @@ def mock_labelled_thread(mocked_responses, api_url, account_id): "object": "label", }, ], + "messages": [ + { + "account_id": account_id, + "date": 1675274530, + "id": "222", + "labels": [ + { + "display_name": "Trash", + "id": "trash-id", + "name": "trash", + }, + ], + "object": "message", + "thread_id": "111", + } + ], "first_message_timestamp": 1451703845, "last_message_timestamp": 1483326245, "last_message_received_timestamp": 1483326245, diff --git a/tests/test_threads.py b/tests/test_threads.py index acfa260f..99bb0b82 100644 --- a/tests/test_threads.py +++ b/tests/test_threads.py @@ -71,6 +71,14 @@ def test_thread_messages(api_client): assert all(isinstance(message, Message) for message in thread.messages) +@pytest.mark.usefixtures("mock_labelled_thread") +def test_thread_messages_from_expanded_thread(api_client): + thread = api_client.threads.get(111) + assert len(thread.messages) == 1 + message = thread.messages[0] + assert isinstance(message, Message) + + @pytest.mark.usefixtures("mock_threads", "mock_drafts") def test_thread_drafts(api_client): thread = api_client.threads.first() From 246d0b264c4a1de82ed640aa4984355853eb1acf Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Wed, 1 Feb 2023 14:26:33 -0500 Subject: [PATCH 066/186] v5.13.1 Release (#255) This new Nylas Python SDK provides the following fixes: * fix `send_authorization` not returning the correct dict (#254) * fix expanded threads not inflating the messages objects properly (#254) * fix class attributes with leading underscores not serializing as expected (#254) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 6 ++++++ nylas/_client_sdk_version.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index fc0aee40..68c1f2fe 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.13.0 +current_version = 5.13.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 0401d223..2a0cf815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ nylas-python Changelog ====================== +v5.13.1 +---------------- +* Fix `send_authorization` not returning the correct dict +* Fix expanded threads not inflating the messages objects properly +* Fix class attributes with leading underscores not serializing as expected + v5.13.0 ---------------- * Add local webhook development support diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 270ffe2c..5cc75c51 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.13.0" +__VERSION__ = "5.13.1" From 5ed1367b91a32d21ceb918846962e2217012afe7 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Mon, 3 Apr 2023 15:28:11 -0400 Subject: [PATCH 067/186] Add support for verifying webhook signatures (#257) This PR adds support for verifying the webhook signature of inbound webhook notifications. --- CHANGELOG.md | 4 ++++ nylas/client/restful_models.py | 20 ++++++++++++++++++++ tests/test_webhooks.py | 18 ++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0cf815..3543aa39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add support for verifying webhook signatures + v5.13.1 ---------------- * Fix `send_authorization` not returning the correct dict diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 04f04dd4..69d95d96 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -1,3 +1,5 @@ +import hashlib +import hmac from datetime import datetime from collections import defaultdict from enum import Enum @@ -980,6 +982,24 @@ def as_json(self, enforce_read_only=True): dct = NylasAPIObject.as_json(self, enforce_read_only) return dct + @staticmethod + def verify_webhook_signature(nylas_signature, raw_body, client_secret): + """ + Verify incoming webhook signature came from Nylas + + Args: + nylas_signature (str): The signature to verify + raw_body (bytes | bytearray): The raw body from the payload + client_secret (str): Client secret of the app receiving the webhook + + Returns: + bool: True if the webhook signature was verified from Nylas + """ + digest = hmac.new( + str.encode(client_secret), msg=raw_body, digestmod=hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(digest, nylas_signature) + class Trigger(str, Enum): """ This is an Enum representing all the possible webhook triggers diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index b4ee595a..97e29f2e 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -78,3 +78,21 @@ def test_create_webhook(mocked_responses, api_client_with_client_id): assert webhook.state == "active" assert webhook.triggers == ["message.created"] assert webhook.version == "1.0" + + +def test_verify_webhook_signature(): + is_verified = Webhook.verify_webhook_signature( + "ddc02f921a4835e310f249dc09770c3fea2cb6fe949adc1887d7adc04a581e1c", + str.encode("test123"), + "myClientSecret", + ) + assert is_verified is True + + +def test_verify_webhook_signature_bad_signature(): + is_verified = Webhook.verify_webhook_signature( + "ddc02f921a4835e310f249dc09770c3fea2cb6fe949adc1887d7adc04a581e1c", + str.encode("test1234"), + "myClientSecret", + ) + assert is_verified is False From 53ca4e91ced2adb81c393959f753309c1599b091 Mon Sep 17 00:00:00 2001 From: kraju3 <35513942+kraju3@users.noreply.github.com> Date: Mon, 3 Apr 2023 17:18:45 -0500 Subject: [PATCH 068/186] Adding optional parameter for token-info endpoint (#256) Allow token_info parameter to pass in an account_id so the call to GET /accounts doesn't throw 401. This is how the actual endpoint behaves. --------- Co-authored-by: Kiran Raju Co-authored-by: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> --- nylas/client/client.py | 14 ++++++++++---- tests/conftest.py | 4 +--- tests/test_accounts.py | 10 ++++++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/nylas/client/client.py b/nylas/client/client.py index 1672d723..9a480517 100644 --- a/nylas/client/client.py +++ b/nylas/client/client.py @@ -289,10 +289,16 @@ def ip_addresses(self): _validate(resp).json() return resp.json() - def token_info(self): - token_info_url = self.token_info_url.format( - client_id=self.client_id, account_id=self.account.id - ) + def token_info(self, account_id=None): + token_info_url = "" + if account_id is not None: + token_info_url = self.token_info_url.format( + client_id=self.client_id, account_id=account_id + ) + else: + token_info_url = self.token_info_url.format( + client_id=self.client_id, account_id=self.account.id + ) headers = {"Content-Type": "application/json"} headers.update(self.admin_session.headers) resp = self.admin_session.post( diff --git a/tests/conftest.py b/tests/conftest.py index 8d71d683..4eeeadda 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1955,9 +1955,7 @@ def mock_ip_addresses(mocked_responses, api_url, client_id): @pytest.fixture def mock_token_info(mocked_responses, api_url, account_id, client_id): - token_info_url = "{base}/a/{client_id}/accounts/{id}/token-info".format( - base=api_url, id=account_id, client_id=client_id - ) + token_info_url = re.compile(api_url + "/a/.*/accounts/.*/token-info") mocked_responses.add( responses.POST, token_info_url, diff --git a/tests/test_accounts.py b/tests/test_accounts.py index 1a96400b..06ad6cba 100644 --- a/tests/test_accounts.py +++ b/tests/test_accounts.py @@ -38,6 +38,16 @@ def test_token_info(api_client_with_client_id): assert "scopes" in result +@pytest.mark.usefixtures("mock_token_info", "mock_account") +def test_token_info_with_account_id(api_client_with_client_id): + result = api_client_with_client_id.token_info( + account_id="anvkhwelkfdoehdflhdjkfhe1" + ) + assert isinstance(result, dict) + assert "updated_at" in result + assert "scopes" in result + + @pytest.mark.usefixtures("mock_account") def test_account_datetime(api_client): account = api_client.account From 06a9dff0b238835adc9a66ece0e07cd3676d72d6 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 4 Apr 2023 15:24:59 -0400 Subject: [PATCH 069/186] v5.14.0 Release (#258) This new Nylas Python SDK --- .bumpversion.cfg | 2 +- CHANGELOG.md | 1 + nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 68c1f2fe..be080970 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.13.1 +current_version = 5.14.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 3543aa39..fbfb17f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Add support for verifying webhook signatures +* Add optional parameter for token-info endpoint v5.13.1 ---------------- diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 5cc75c51..a01e3948 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.13.1" +__VERSION__ = "5.14.0" From 45ff5bd15182f3c1e2e8eefff139e5e1988a305a Mon Sep 17 00:00:00 2001 From: thedzy Date: Thu, 20 Jul 2023 11:18:53 -0400 Subject: [PATCH 070/186] Bug, cannot do a record count before an iteration (#263) * Bug, cannot do a record count before an iteration Create a copy of the filer to prevent other functions for using the "count" view when making calls * try to fix github action --------- Co-authored-by: Shane Young Co-authored-by: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- nylas/client/restful_model_collection.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ab21f452..1337be5d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v2 - name: Setup python - uses: actions/setup-python@v2 + uses: MatteoH2O1999/setup-python@v1.4.1 with: python-version: ${{ matrix.python-version }} diff --git a/nylas/client/restful_model_collection.py b/nylas/client/restful_model_collection.py index 3c45965a..1ccb9289 100644 --- a/nylas/client/restful_model_collection.py +++ b/nylas/client/restful_model_collection.py @@ -70,7 +70,8 @@ def count(self): Returns: int: The number of objects in the collection being queried """ - self.filters["view"] = "count" + filters = self.filters.copy() + filters["view"] = "count" response = self.api._get_resource_raw( self.model_class, resource_id=None, **self.filters ).json() From bb4f85e7e0bc5cff364d061c7d99f884359f551b Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Mon, 24 Jul 2023 13:03:03 -0400 Subject: [PATCH 071/186] Fix unable to set participant status on create event (#264) * update test to what the participant status rule is * only remove participant if it's an update call * Update CHANGELOG.md --- CHANGELOG.md | 5 +++++ nylas/client/restful_models.py | 6 +++++- tests/test_events.py | 10 +++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbfb17f4..6e0b8e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ nylas-python Changelog Unreleased ---------------- +* Fix error when trying to iterate on list after calling count +* Fix error when setting participant status on create event + +v5.14.0 +---------------- * Add support for verifying webhook signatures * Add optional parameter for token-info endpoint diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py index 69d95d96..b6539e78 100644 --- a/nylas/client/restful_models.py +++ b/nylas/client/restful_models.py @@ -726,7 +726,11 @@ def as_json(self, enforce_read_only=True): dct["when"] = dct["when"].copy() dct["when"].pop("object", None) - if dct.get("participants") and isinstance(dct.get("participants"), list): + if ( + dct.get("participants") + and isinstance(dct.get("participants"), list) + and self.id + ): # The status of a participant cannot be updated and, if the key is # included, it will return an error from the API for participant in dct.get("participants"): diff --git a/tests/test_events.py b/tests/test_events.py index 6c347db9..068ba995 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -26,11 +26,15 @@ def test_event_crud(mocked_responses, api_client): event1.master_event_id = "should not send" event1.original_start_time = "should not send" event1.visibility = "private" + event1.participants = [ + {"email": "person1@email.com", "status": "yes"}, + ] event1.save() request = mocked_responses.calls[0].request body = json.loads(request.body) assert event1.id == "cv4ei7syx10uvsxbs21ccsezf" - assert event1.visibility == "private" + assert body["participants"][0]["status"] == "yes" + assert body["visibility"] == "private" assert "title" in body assert "object" not in body assert "account_id" not in body @@ -43,9 +47,13 @@ def test_event_crud(mocked_responses, api_client): assert "original_start_time" not in body event1.title = "blah" + assert "participants" in event1 + event1["participants"][0]["status"] = "no" event1.save() request = mocked_responses.calls[1].request body = json.loads(request.body) + assert body["title"] == "blah" + assert "status" not in body["participants"][0] assert event1.title == "loaded from JSON" assert event1.get("ignored") is None assert "id" not in body From 06e48c349e4295bdf868e6763dc30279334642fe Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 1 Aug 2023 20:55:30 -0400 Subject: [PATCH 072/186] v5.14.1 Release (#265) new Nylas Python SDK --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index be080970..c796aed1 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.14.0 +current_version = 5.14.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0b8e1b..8427d09c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v5.14.1 ---------------- * Fix error when trying to iterate on list after calling count * Fix error when setting participant status on create event diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index a01e3948..53988afb 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.14.0" +__VERSION__ = "5.14.1" From f2bb44ec3f465ab93deadfc0249972eaa7b5df53 Mon Sep 17 00:00:00 2001 From: Ram <553578+relaxedtomato@users.noreply.github.com> Date: Thu, 28 Sep 2023 17:56:53 -0400 Subject: [PATCH 073/186] Update README for Hacktoberfest 2023 (#276) add blurb on hacktoberfest guidelines --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 68a86eaf..f10e035d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ To learn more about how to use the Nylas Python SDK, please refer to our [Python Please refer to [Contributing](Contributing.md) for information about how to make contributions to this project. We welcome questions, bug reports, and pull requests. +Taking part in Hacktoberfest 2023 (i.e. issue is tagged with `hacktoberfest`)? Read our [Nylas Hacktoberfest 2023 contribution guidelines](https://github.com/nylas-samples/nylas-hacktoberfest-2023/blob/main/readme.md). + ## ๐Ÿ“ License This project is licensed under the terms of the MIT license. Please refer to [LICENSE](LICENSE) for the full terms. From bea26008747f5b5a77c76e4a73dfc8bbacf5958c Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 29 Dec 2023 10:37:10 -0500 Subject: [PATCH 074/186] Add support for view parameter in `threads.search()` (#318) This PR adds support for the view parameter in thread searching. Closes #287. --- CHANGELOG.md | 4 ++++ nylas/client/restful_model_collection.py | 6 +++++- tests/conftest.py | 2 +- tests/test_search.py | 14 ++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8427d09c..a9c651aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add support for `view` parameter in `Threads.search()` + v5.14.1 ---------------- * Fix error when trying to iterate on list after calling count diff --git a/nylas/client/restful_model_collection.py b/nylas/client/restful_model_collection.py index 1ccb9289..408e4e09 100644 --- a/nylas/client/restful_model_collection.py +++ b/nylas/client/restful_model_collection.py @@ -110,7 +110,9 @@ def create(self, **kwargs): def delete(self, id, data=None, **kwargs): return self.api._delete_resource(self.model_class, id, data=data, **kwargs) - def search(self, q, limit=None, offset=None): # pylint: disable=invalid-name + def search( + self, q, limit=None, offset=None, view=None + ): # pylint: disable=invalid-name from nylas.client.restful_models import ( Message, Thread, @@ -122,6 +124,8 @@ def search(self, q, limit=None, offset=None): # pylint: disable=invalid-name kwargs["limit"] = limit if offset is not None: kwargs["offset"] = offset + if view is not None and self.model_class is Thread: + kwargs["view"] = view return self.api._get_resources(self.model_class, extra="search", **kwargs) else: raise Exception("Searching is only allowed on Thread and Message models") diff --git a/tests/conftest.py b/tests/conftest.py index 4eeeadda..8ead8d4c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1005,7 +1005,7 @@ def mock_thread_search_response(mocked_responses, api_url): mocked_responses.add( responses.GET, - api_url + "/threads/search?q=Helena", + re.compile(api_url + "/threads/search\?q=Helena.*"), body=response_body, status=200, content_type="application/json", diff --git a/tests/test_search.py b/tests/test_search.py index 52aa733d..367d3881 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -23,6 +23,20 @@ def test_search_messages_with_limit_offset(mocked_responses, api_client): assert request.path_url == "/messages/search?q=Pinot&limit=10&offset=0" +@pytest.mark.usefixtures("mock_message_search_response") +def test_search_messages_with_view_should_not_appear(mocked_responses, api_client): + api_client.messages.search("Pinot", view="expanded") + request = mocked_responses.calls[0].request + assert request.path_url == "/messages/search?q=Pinot" + + +@pytest.mark.usefixtures("mock_thread_search_response") +def test_search_messages_with_view_should_appear(mocked_responses, api_client): + api_client.threads.search("Helena", view="expanded") + request = mocked_responses.calls[0].request + assert request.path_url == "/threads/search?q=Helena&view=expanded" + + @pytest.mark.usefixtures("mock_message_search_response") def test_search_drafts(api_client): with pytest.raises(Exception): From 247a70fa1ea11dea9caa65773089d468f8875eee Mon Sep 17 00:00:00 2001 From: Max Muoto Date: Wed, 3 Jan 2024 10:39:18 -0600 Subject: [PATCH 075/186] Fix invalid escape sequence (#327) When running Python 3.12, the invalid escape sequence here leads to a `SyntaxWarning`. --- nylas/client/neural_api_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nylas/client/neural_api_models.py b/nylas/client/neural_api_models.py index a0710631..e4aa8ce2 100644 --- a/nylas/client/neural_api_models.py +++ b/nylas/client/neural_api_models.py @@ -165,7 +165,7 @@ def __init__(self, api): RestfulModel.__init__(self, NeuralCleanConversation, api) def extract_images(self): - pattern = "[\(']cid:(.*?)[\)']" + pattern = r"[\(']cid:(.*?)[\)']" file_ids = re.findall(pattern, self.conversation) files = [] for match in file_ids: From d71acbe1e3d3ba0446cebf82531821f5140309c9 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 6 Feb 2024 02:19:28 +0400 Subject: [PATCH 076/186] v6.0.0 (#341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description This PR is a culmination of all the PRs that contained v6-related development, with formal support for the Nylas API v3. This release is essentially the same as the v6.0.0b9 release found on pypi. Please refer to the readme for a quick start guide on using the new SDK, as well as links to the upgrade doc as well as the SDK reference docs. # Changelog * **BREAKING CHANGE**: Python SDK v6 supports the Nylas API v3 exclusively, dropping support for any endpoints that are not available in v3 * **BREAKING CHANGE**: Drop support for Python < v3.8 * **BREAKING CHANGE**: Dropped the use of 'Collections' in favor of 'Resources' * **BREAKING CHANGE**: Removed all REST calls from models and moved them directly into resources * **BREAKING CHANGE**: Models no longer inherit from `dict` but instead either are a `dataclass` or inherit from `TypedDict` * **BREAKING CHANGE**: Renamed the SDK entrypoint from `APIClient` to `Client` * **REMOVED**: Local Webhook development support is removed due to incompatibility * Rewrote the majority of SDK to be more intuitive, explicit, and efficient * Created models for all API resources and endpoints, for all HTTP methods to reduce confusion on which fields are available for each endpoint * Created error classes for the different API errors as well as SDK-specific errors ====================================================================================== * Python SDK Rewrite for API v3 (with Auth, Calendars, and Event support) (#262) This PR re-writes the Python SDK to be less complex, more human readable, and more intuitive to use. The rewrite also enables support for API v3. The following changes have been made: - Full rewrite of the SDK, including the HTTP Client - Dropped support for Python 2.7 and any Python 3.x < 3.8 - Updated all the dependencies to the latest versions - Support for communicating with API v3 and parsing the response schemas - Full support for all auth-related methods for API v3 - Full CRUD support for Calendars and Events APIs in API v3 * Add models and typing to resources (#266) This PR adds models for create and update models and adds typing to all the resources. Also the following were done: - Renamed the model package to models - Cleaned up dependencies - Add availability and webhook support - Add generics support - Change our mixin strategy * Refactor Auth classes (#267) This PR aligns the Python SDK to the other SDKs regarding Auth resource functions and models. * Refactor Error Classes (#268) This PR ensures the Python SDK has all the models and handling logic required for the different errors that may arise. * Set up SDK Reference generation (#269) This PR configures mkdocs to generate an SDK reference for the Python SDK. * Python SDK v6 Documentation (#270) This PR finalizes all the in-code documentation, updates the README.md file and adds an UPGRADE.md file. * v6.0.0 beta 1 Release (#271) This PR sets up for v6.0.0 beta 1 release. * Fix API contract for Events (#272) Fixes to the Event object * Add None init for Optional fields (#273) This PR addresses a bug that causes Event deserialization to break. This is because we should init all Optional fields to None. * v6.0.0 beta 2 Release * Fix bug when deserializing Union types (#275) This PR adds in deserialization logic for the polyformic fields. * Fix bug when deserializing delete response (#277) For `.destroy()` method we are not using the correct model to deserialize the response to. We just discard it and use `Response` which results in a KeyError. * v6.0.0 beta 3 Release (#278) * Improve quickstart examples & documentation overview (#281) This is everything that tripped me up getting started and would have helped me orient more quickly without having to go and dig around in the code overly much. * Add support for free-busy endpoint (#279) * Add support for free-busy endpoint Mostly cribbed off the availability support, though radically stripped down as free-busy is a great deal simpler. * debugging * just work * fix docs * fix doc * rename Error to FreeBusyError * Change auth build query to point to scope instead of scopes (#283) Co-authored-by: Kiran Raju * Fix KeyError when building Auth URL (#284) We were not properly checking the existence of an entry in a dict. * Add Connector support (#292) This PR adds support for the connector endpoints. * Nylas Credentials API v3 (#293) New PR for Credentials API support. Introduced a new class for UpdateRequest and fixed other issues with typing * v6.0.0 Beta 4 Release (#294) * Update CHANGELOG.md * Bump version: 6.0.0b3 โ†’ 6.0.0b4 * Changes to calendars and grants models and resources to not fail on decoding. (#300) Co-authored-by: kiran.raju@nylas.com * Fix creating a grant/custom auth (#299) This PR fixes creating a grant by pointing it to the custom auth endpoint. * v6.0.0 beta 5 release (#302) * Update CHANGELOG.md * Bump version: 6.0.0b4 โ†’ 6.0.0b5 * document how to enable requests DEBUG logs (#304) * remove unused imports (#303) * Add support for Read, Update, and Delete for Messages (#305) RUD support for Messages API * Making timezone value and timestamps for Grant optional due to no support in our API's (#306) * Document events.find() too for comprehensive quickstart (#307) * v6.0.0 beta 6 Release (#308) * Update CHANGELOG.md * Bump version: 6.0.0b5 โ†’ 6.0.0b6 * Make "To" Optional (#309) When sending an email without a To (using bc or bcc), fetching breaks. * Add Message Send, Drafts, Threads, and Smart Compose APIs support (#310) This PR builds on #305. Adds the remaining message-related endpoints and models. This PR also brings forward a few breaking changes that alter behaviour to #305: - In `ListMessageQueryParams` anything that was a list type in the API (to, bcc, etc.) are now List type as well. We then convert these arrays to comma delimited strings fit for the API. - `Attachments` have moved to `models/attachments.py` as we needed a common space for both `Message` and `Draft` objects to pull from. * Folders API support (#311) This PR adds support for the folders API. * Attachments API Support (#312) This PR adds support for the Attachments API. * v6.0.0 beta 7 Release (#314) Changelog: * Add Message Send, Drafts, Threads, and Smart Compose APIs support (#310) * Add support for folders API (#311) * Add support for attachments API (#312) * Fix required field for the `Message` model (#309) * Fix message send/draft create/modify not working properly (#315) This PR fixes the serialization of outgoing message requests using the multipart formatter. We've also added in a helper function to help with formatting files to attach to a message. * Fix required fields in Thread (#322) Fix some optional fields to be able to return a thread * Move `Grants` to `NylasClient` and custom authentication to `Auth` (#324) * Move custom auth out of grants to auth * move grants entry point out of auth to nylasclient * Update CHANGELOG.md * Fix issue with multipart attachments throwing KeyError (#319) * fix attaching files via multipart * Update CHANGELOG.md * Fix inaccuracies with Event models (#317) * Fix inaccuracies with Event models * fix calendar typo * timestamps should be int * Add contact objects * Add Contact CRUD support * Add contact group support * Fixes for contacts api Found some bugs on AV-1465-3-0-ga-python-sdk-contacts-api so this PR should address them as I have fully tested them. * Contacts API support (#325) This PR adds support for the Contacts API. * Fix incorrect PKCE code challenge generation (#330) This PR fixes the PKCE code challenge generation; the correct method the API wants is for us to base64 encode the hex string as opposed to base64 encoding resulting hashed bytearray directly. Closes #329. Thanks to @wobeng for reporting and providing the correct code. Co-authored-by: Welby O. <11966846+wobeng@users.noreply.github.com> * v6.0.0 beta 8 Release (#331) * linting * Bump version: 6.0.0b7 โ†’ 6.0.0b8 * Update CHANGELOG.md * Update client.py to add Drafts (#333) Added the call to Drafts endpoint * Fix drafts API entrypoint (#334) * Add support for sending drafts (#336) This PR adds support for sending drafts. * Update events.py to add capacity (#335) Add capacity to the events response * Changed client_secret to optional for token exchange methods; defaults to API Key now (#337) client_secret is not required if the API Key used for the SDK and the clientId belong to the same application. * Python testing framework + fixes (#323) This PR adds the full testing suite for Python. This also includes the following fixes: * Changed references to `callback_url` to `webhook_url` to match API * Fix deserialization issue with `Connector` model * Fix serialization of query parameters * Fix typos in folders, threads, code exchange, smart compose, webhook and attachment models * Fix types in reminder and messages models * Fix message/draft deserialization in thread model * Standardized casing for enums * Fix fields for creating drafts and sending messages (#338) Fix fields for creating drafts and sending messages. * Add pylint step and address linting issues (#339) This PR runs pytest for the first time and fixes all offences (after disabling certain rules), and adds a step to lint to the CI steps. * v6.0.0 beta 9 Release (#340) # Changelog * Add support for sending drafts * Changed `client_secret` to optional for token exchange methods; defaults to API Key now * Changed references to `callback_url` to `webhook_url` to match API * Fix deserialization issue with `Connector` model * Fix serialization of query parameters * Fix typos in folders, threads, code exchange, smart compose, webhook and attachment models * Fix types in reminder and messages models * Fix message/draft deserialization in thread model * Standardized casing for enums * Update CHANGELOG.md * scrub references to the beta * Bump version: 6.0.0b9 โ†’ 6.0.0 --------- Co-authored-by: Christine Spang Co-authored-by: Christine Spang Co-authored-by: kraju3 <35513942+kraju3@users.noreply.github.com> Co-authored-by: Kiran Raju Co-authored-by: kiran.raju@nylas.com Co-authored-by: Blag Co-authored-by: Welby O. <11966846+wobeng@users.noreply.github.com> --- .arcconfig | 3 - .bumpversion.cfg | 2 +- .coveragerc | 1 + .github/workflows/sdk-reference.yml | 43 + .github/workflows/test.yml | 20 +- .gitignore | 6 +- .pylintrc | 31 + CHANGELOG.md | 20 +- README.md | 100 +- UPGRADE.md | 178 ++ examples/hosted-oauth/README.md | 86 - examples/hosted-oauth/config.py | 3 - examples/hosted-oauth/requirements.txt | 3 - examples/hosted-oauth/server.py | 127 - .../templates/after_authorized.html | 17 - examples/hosted-oauth/templates/base.html | 14 - .../templates/before_authorized.html | 29 - .../native-authentication-exchange/README.md | 52 - .../config.json | 5 - .../requirements.txt | 3 - .../native-authentication-exchange/server.py | 170 - .../templates/base.html | 14 - .../templates/index.html | 27 - .../templates/missing_token.html | 5 - .../templates/success.html | 16 - .../native-authentication-gmail/README.md | 108 - .../native-authentication-gmail/config.json | 7 - .../requirements.txt | 3 - .../native-authentication-gmail/server.py | 234 -- .../templates/after_connected.html | 20 - .../templates/after_google.html | 11 - .../templates/base.html | 14 - .../templates/before_google.html | 34 - examples/webhooks/README.md | 125 - examples/webhooks/config.json | 7 - examples/webhooks/requirements.txt | 3 - examples/webhooks/server.py | 201 -- examples/webhooks/templates/base.html | 14 - examples/webhooks/templates/index.html | 19 - mkdocs.yml | 23 + nylas/__init__.py | 7 +- nylas/_client_sdk_version.py | 2 +- nylas/client.py | 171 + nylas/client/__init__.py | 5 - nylas/client/authentication_models.py | 350 --- nylas/client/client.py | 866 ----- nylas/client/delta_collection.py | 195 -- nylas/client/delta_models.py | 73 - nylas/client/errors.py | 62 - nylas/client/neural_api_models.py | 184 -- nylas/client/outbox_models.py | 178 -- nylas/client/restful_model_collection.py | 175 -- nylas/client/restful_models.py | 1122 ------- nylas/client/scheduler_models.py | 59 - .../scheduler_restful_model_collection.py | 65 - nylas/config.py | 16 +- nylas/{services => handler}/__init__.py | 0 nylas/handler/api_resources.py | 74 + nylas/handler/http_client.py | 169 + {tests => nylas/models}/__init__.py | 0 nylas/models/application_details.py | 83 + nylas/models/attachments.py | 68 + nylas/models/auth.py | 198 ++ nylas/models/availability.py | 140 + nylas/models/calendars.py | 96 + nylas/models/connectors.py | 157 + nylas/models/contacts.py | 387 +++ nylas/models/credentials.py | 111 + nylas/models/drafts.py | 154 + nylas/models/errors.py | 163 + nylas/models/events.py | 792 +++++ nylas/models/folders.py | 72 + nylas/models/free_busy.py | 71 + nylas/models/grants.py | 102 + nylas/models/list_query_params.py | 16 + nylas/models/messages.py | 213 ++ nylas/models/redirect_uri.py | 98 + nylas/models/response.py | 129 + nylas/models/smart_compose.py | 28 + nylas/models/threads.py | 143 + nylas/models/webhooks.py | 143 + nylas/resources/__init__.py | 0 nylas/resources/applications.py | 36 + nylas/resources/attachments.py | 104 + nylas/resources/auth.py | 258 ++ nylas/resources/calendars.py | 165 + nylas/resources/connectors.py | 123 + nylas/resources/contacts.py | 149 + nylas/resources/credentials.py | 127 + nylas/resources/drafts.py | 147 + nylas/resources/events.py | 179 ++ nylas/resources/folders.py | 111 + nylas/resources/grants.py | 89 + nylas/resources/messages.py | 206 ++ nylas/resources/redirect_uris.py | 105 + nylas/resources/resource.py | 8 + nylas/resources/smart_compose.py | 55 + nylas/resources/threads.py | 94 + nylas/resources/webhooks.py | 148 + nylas/services/tunnel.py | 96 - nylas/utils.py | 75 - nylas/utils/__init__.py | 0 nylas/utils/file_utils.py | 58 + scripts/generate-docs.py | 45 + setup.cfg | 2 - setup.py | 67 +- tests/.gitignore | 1 - tests/conftest.py | 2792 +---------------- tests/handler/test_api_resources.py | 152 + tests/handler/test_http_client.py | 247 ++ tests/resources/test_applications.py | 90 + tests/resources/test_attachments.py | 81 + tests/resources/test_auth.py | 377 +++ tests/resources/test_calendars.py | 183 ++ tests/resources/test_connectors.py | 111 + tests/resources/test_contacts.py | 196 ++ tests/resources/test_credentials.py | 97 + tests/resources/test_drafts.py | 168 + tests/resources/test_events.py | 230 ++ tests/resources/test_folders.py | 118 + tests/resources/test_grants.py | 88 + tests/resources/test_messages.py | 212 ++ tests/resources/test_redirect_uris.py | 116 + tests/resources/test_smart_compose.py | 35 + tests/resources/test_threads.py | 185 ++ tests/resources/test_webhooks.py | 142 + tests/test_accounts.py | 136 - tests/test_authentication.py | 255 -- tests/test_client.py | 419 +-- tests/test_components.py | 79 - tests/test_contacts.py | 175 -- tests/test_delta.py | 158 - tests/test_drafts.py | 155 - tests/test_events.py | 830 ----- tests/test_files.py | 83 - tests/test_filter.py | 73 - tests/test_folders.py | 27 - tests/test_job_status.py | 44 - tests/test_labels.py | 32 - tests/test_messages.py | 154 - tests/test_neural.py | 183 -- tests/test_outbox.py | 126 - tests/test_resources.py | 28 - tests/test_scheduler.py | 287 -- tests/test_search.py | 43 - tests/test_send_error_handling.py | 101 - tests/test_threads.py | 159 - tests/test_tunnel.py | 181 -- tests/test_webhooks.py | 98 - tests/utils/test_file_utils.py | 72 + tox.ini | 7 - 151 files changed, 9486 insertions(+), 11422 deletions(-) delete mode 100644 .arcconfig create mode 100644 .github/workflows/sdk-reference.yml create mode 100644 .pylintrc create mode 100644 UPGRADE.md delete mode 100644 examples/hosted-oauth/README.md delete mode 100644 examples/hosted-oauth/config.py delete mode 100644 examples/hosted-oauth/requirements.txt delete mode 100644 examples/hosted-oauth/server.py delete mode 100644 examples/hosted-oauth/templates/after_authorized.html delete mode 100644 examples/hosted-oauth/templates/base.html delete mode 100644 examples/hosted-oauth/templates/before_authorized.html delete mode 100644 examples/native-authentication-exchange/README.md delete mode 100644 examples/native-authentication-exchange/config.json delete mode 100644 examples/native-authentication-exchange/requirements.txt delete mode 100644 examples/native-authentication-exchange/server.py delete mode 100644 examples/native-authentication-exchange/templates/base.html delete mode 100644 examples/native-authentication-exchange/templates/index.html delete mode 100644 examples/native-authentication-exchange/templates/missing_token.html delete mode 100644 examples/native-authentication-exchange/templates/success.html delete mode 100644 examples/native-authentication-gmail/README.md delete mode 100644 examples/native-authentication-gmail/config.json delete mode 100644 examples/native-authentication-gmail/requirements.txt delete mode 100644 examples/native-authentication-gmail/server.py delete mode 100644 examples/native-authentication-gmail/templates/after_connected.html delete mode 100644 examples/native-authentication-gmail/templates/after_google.html delete mode 100644 examples/native-authentication-gmail/templates/base.html delete mode 100644 examples/native-authentication-gmail/templates/before_google.html delete mode 100644 examples/webhooks/README.md delete mode 100644 examples/webhooks/config.json delete mode 100644 examples/webhooks/requirements.txt delete mode 100755 examples/webhooks/server.py delete mode 100644 examples/webhooks/templates/base.html delete mode 100644 examples/webhooks/templates/index.html create mode 100644 mkdocs.yml create mode 100644 nylas/client.py delete mode 100644 nylas/client/__init__.py delete mode 100644 nylas/client/authentication_models.py delete mode 100644 nylas/client/client.py delete mode 100644 nylas/client/delta_collection.py delete mode 100644 nylas/client/delta_models.py delete mode 100644 nylas/client/errors.py delete mode 100644 nylas/client/neural_api_models.py delete mode 100644 nylas/client/outbox_models.py delete mode 100644 nylas/client/restful_model_collection.py delete mode 100644 nylas/client/restful_models.py delete mode 100644 nylas/client/scheduler_models.py delete mode 100644 nylas/client/scheduler_restful_model_collection.py rename nylas/{services => handler}/__init__.py (100%) create mode 100644 nylas/handler/api_resources.py create mode 100644 nylas/handler/http_client.py rename {tests => nylas/models}/__init__.py (100%) create mode 100644 nylas/models/application_details.py create mode 100644 nylas/models/attachments.py create mode 100644 nylas/models/auth.py create mode 100644 nylas/models/availability.py create mode 100644 nylas/models/calendars.py create mode 100644 nylas/models/connectors.py create mode 100644 nylas/models/contacts.py create mode 100644 nylas/models/credentials.py create mode 100644 nylas/models/drafts.py create mode 100644 nylas/models/errors.py create mode 100644 nylas/models/events.py create mode 100644 nylas/models/folders.py create mode 100644 nylas/models/free_busy.py create mode 100644 nylas/models/grants.py create mode 100644 nylas/models/list_query_params.py create mode 100644 nylas/models/messages.py create mode 100644 nylas/models/redirect_uri.py create mode 100644 nylas/models/response.py create mode 100644 nylas/models/smart_compose.py create mode 100644 nylas/models/threads.py create mode 100644 nylas/models/webhooks.py create mode 100644 nylas/resources/__init__.py create mode 100644 nylas/resources/applications.py create mode 100644 nylas/resources/attachments.py create mode 100644 nylas/resources/auth.py create mode 100644 nylas/resources/calendars.py create mode 100644 nylas/resources/connectors.py create mode 100644 nylas/resources/contacts.py create mode 100644 nylas/resources/credentials.py create mode 100644 nylas/resources/drafts.py create mode 100644 nylas/resources/events.py create mode 100644 nylas/resources/folders.py create mode 100644 nylas/resources/grants.py create mode 100644 nylas/resources/messages.py create mode 100644 nylas/resources/redirect_uris.py create mode 100644 nylas/resources/resource.py create mode 100644 nylas/resources/smart_compose.py create mode 100644 nylas/resources/threads.py create mode 100644 nylas/resources/webhooks.py delete mode 100644 nylas/services/tunnel.py delete mode 100644 nylas/utils.py create mode 100644 nylas/utils/__init__.py create mode 100644 nylas/utils/file_utils.py create mode 100644 scripts/generate-docs.py delete mode 100644 setup.cfg delete mode 100644 tests/.gitignore create mode 100644 tests/handler/test_api_resources.py create mode 100644 tests/handler/test_http_client.py create mode 100644 tests/resources/test_applications.py create mode 100644 tests/resources/test_attachments.py create mode 100644 tests/resources/test_auth.py create mode 100644 tests/resources/test_calendars.py create mode 100644 tests/resources/test_connectors.py create mode 100644 tests/resources/test_contacts.py create mode 100644 tests/resources/test_credentials.py create mode 100644 tests/resources/test_drafts.py create mode 100644 tests/resources/test_events.py create mode 100644 tests/resources/test_folders.py create mode 100644 tests/resources/test_grants.py create mode 100644 tests/resources/test_messages.py create mode 100644 tests/resources/test_redirect_uris.py create mode 100644 tests/resources/test_smart_compose.py create mode 100644 tests/resources/test_threads.py create mode 100644 tests/resources/test_webhooks.py delete mode 100644 tests/test_accounts.py delete mode 100644 tests/test_authentication.py delete mode 100644 tests/test_components.py delete mode 100644 tests/test_contacts.py delete mode 100644 tests/test_delta.py delete mode 100644 tests/test_drafts.py delete mode 100644 tests/test_events.py delete mode 100644 tests/test_files.py delete mode 100644 tests/test_filter.py delete mode 100644 tests/test_folders.py delete mode 100644 tests/test_job_status.py delete mode 100644 tests/test_labels.py delete mode 100644 tests/test_messages.py delete mode 100644 tests/test_neural.py delete mode 100644 tests/test_outbox.py delete mode 100644 tests/test_resources.py delete mode 100644 tests/test_scheduler.py delete mode 100644 tests/test_search.py delete mode 100644 tests/test_send_error_handling.py delete mode 100644 tests/test_threads.py delete mode 100644 tests/test_tunnel.py delete mode 100644 tests/test_webhooks.py create mode 100644 tests/utils/test_file_utils.py delete mode 100644 tox.ini diff --git a/.arcconfig b/.arcconfig deleted file mode 100644 index 0df938ee..00000000 --- a/.arcconfig +++ /dev/null @@ -1,3 +0,0 @@ -{ - "conduit_uri" : "https://phab.nylas.com/" -} diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c796aed1..0808f5a9 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.14.1 +current_version = 6.0.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/.coveragerc b/.coveragerc index 8460a941..01586ee0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,3 @@ [run] source = nylas +omit = tests/* diff --git a/.github/workflows/sdk-reference.yml b/.github/workflows/sdk-reference.yml new file mode 100644 index 00000000..bf16e9ff --- /dev/null +++ b/.github/workflows/sdk-reference.yml @@ -0,0 +1,43 @@ +name: sdk-reference + +on: + push: + branches: + - main + pull_request: + +jobs: + docs: + runs-on: ubuntu-latest + environment: + name: sdk-reference + url: ${{ steps.deploy.outputs.url }} + steps: + - uses: actions/checkout@v2 + - name: Setup Nodejs + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install dependencies and build + run: pip install .[docs] + - name: Build docs + run: python setup.py build-docs + - name: Set env BRANCH + run: echo "BRANCH=$(echo $GITHUB_REF | cut -d'/' -f 3)" >> $GITHUB_ENV + - name: Set env CLOUDFLARE_BRANCH + run: | + if [[ $BRANCH == 'main' && $GITHUB_EVENT_NAME == 'push' ]]; then + echo "CLOUDFLARE_BRANCH=main" >> "$GITHUB_ENV" + else + echo "CLOUDFLARE_BRANCH=$BRANCH" >> "$GITHUB_ENV" + fi + - name: Publish to Cloudflare Pages + uses: cloudflare/pages-action@v1 + id: deploy + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: nylas-python-sdk-reference + directory: site + wranglerVersion: "3" + branch: ${{ env.CLOUDFLARE_BRANCH }} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1337be5d..f7763d52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,12 +14,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["2.x", "3.x"] + python-version: ["3.8", "3.x"] name: Python ${{ matrix.python-version }} steps: - uses: actions/checkout@v2 - name: Setup python - uses: MatteoH2O1999/setup-python@v1.4.1 + uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} @@ -35,16 +35,22 @@ jobs: black: runs-on: ubuntu-latest - name: Black + name: Pylint and Black steps: - uses: actions/checkout@v2 - name: Setup python uses: actions/setup-python@v2 with: - python-version: "3.x" + python-version: "3.8" + + - name: Install dependencies + run: pip install . + + - name: Install pylint and black + run: pip install pylint black - - name: Install black - run: pip install black + - name: Run pylint + run: pylint nylas - name: Run black - run: black --check --extend-exclude="/examples" . + run: black . diff --git a/.gitignore b/.gitignore index ade358a5..1e7c9032 100644 --- a/.gitignore +++ b/.gitignore @@ -63,5 +63,9 @@ local/ pip-selfcheck.json tests/output local.env -examples/test.py +test.py .env + +# Documentation +site +docs diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..2db5495e --- /dev/null +++ b/.pylintrc @@ -0,0 +1,31 @@ +[FORMAT] +good-names=id,k,v,cc,to,ip +max-line-length=120 + +[MESSAGES CONTROL] +disable= + missing-module-docstring, + arguments-differ, + protected-access, + duplicate-code, + too-many-instance-attributes, + unnecessary-pass, + too-many-arguments, + too-few-public-methods, + +[TYPECHECK] + +generated-members= + Message.from_dict, + Draft.from_dict, + Time.from_dict, + Timespan.from_dict, + Date.from_dict, + Datespan.from_dict, + Details.from_dict, + Autocreate.from_dict, + RequestIdOnlyResponse.from_dict, + TokenInfoResponse.from_dict, + CodeExchangeResponse.from_dict, + NylasApiErrorResponse.from_dict, + NylasOAuthErrorResponse.from_dict, diff --git a/CHANGELOG.md b/CHANGELOG.md index a9c651aa..618c2b84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,25 @@ nylas-python Changelog ====================== -Unreleased +v6.0.0 +---------------- +* **BREAKING CHANGE**: Python SDK v6 supports the Nylas API v3 exclusively, dropping support for any endpoints that are not available in v3 +* **BREAKING CHANGE**: Drop support for Python < v3.8 +* **BREAKING CHANGE**: Dropped the use of 'Collections' in favor of 'Resources' +* **BREAKING CHANGE**: Removed all REST calls from models and moved them directly into resources +* **BREAKING CHANGE**: Models no longer inherit from `dict` but instead either are a `dataclass` or inherit from `TypedDict` +* **BREAKING CHANGE**: Renamed the SDK entrypoint from `APIClient` to `Client` +* **REMOVED**: Local Webhook development support is removed due to incompatibility +* Rewrote the majority of SDK to be more intuitive, explicit, and efficient +* Created models for all API resources and endpoints, for all HTTP methods to reduce confusion on which fields are available for each endpoint +* Created error classes for the different API errors as well as SDK-specific errors + +v5.14.1 +---------------- +* Fix error when trying to iterate on list after calling count +* Fix error when setting participant status on create event + +v5.14.0 ---------------- * Add support for `view` parameter in `Threads.search()` diff --git a/README.md b/README.md index f10e035d..f9d33072 100644 --- a/README.md +++ b/README.md @@ -4,31 +4,34 @@ # Nylas Python SDK -[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/nylas/nylas-python/Test)](https://github.com/nylas/nylas-python/actions/workflows/test.yml) +[![PyPI - Version](https://img.shields.io/pypi/v/nylas)](https://pypi.org/project/nylas/) [![codecov](https://codecov.io/gh/nylas/nylas-python/branch/main/graph/badge.svg?token=HyxGAn5bJR)](https://codecov.io/gh/nylas/nylas-python) -This is the GitHub repository for the Nylas Python SDK and this repo is primarily for anyone who wants to make contributions to the SDK or install it from source. If you are looking to use Python to access the Nylas Email, Calendar, or Contacts API you should refer to our official [Python SDK Quickstart Guide](https://docs.nylas.com/docs/quickstart-python). +This is the GitHub repository for the Nylas Python SDK. The repo is primarily for anyone who wants to install the SDK from source or make contributions to it. -The Nylas Communications Platform provides REST APIs for [Email](https://docs.nylas.com/docs/quickstart-email), [Calendar](https://docs.nylas.com/docs/quickstart-calendar), and [Contacts](https://docs.nylas.com/docs/quickstart-contacts), and the Python SDK is the quickest way to build your integration using Python +If you're looking to use Python to access the Nylas Email, Calendar, or Contacts APIs, see our [Python SDK Quickstart guide](https://docs.nylas.com/docs/quickstart-python). + +The Nylas platform provides REST APIs for [Email](https://docs.nylas.com/docs/quickstart-email), [Calendar](https://docs.nylas.com/docs/quickstart-calendar), and [Contacts](https://docs.nylas.com/docs/quickstart-contacts), and the Python SDK is the quickest way to build your integration using Python. Here are some resources to help you get started: -- [Nylas SDK Tutorials](https://docs.nylas.com/docs/tutorials) -- [Get Started with the Nylas Communications Platform](https://docs.nylas.com/docs/getting-started) -- [Sign up for your Nylas developer account.](https://nylas.com/register) -- [Nylas API Reference](https://docs.nylas.com/reference) +- [Sign up for a free Nylas account](https://dashboard.nylas.com/register). +- Follow the [Nylas API v3 Quickstart guide](https://developer.nylas.com/docs/v3-beta/v3-quickstart/). +- Browse the [Nylas SDK reference docs](https://nylas-python-sdk-reference.pages.dev/). +- Browse the [Nylas API reference docs](https://developer.nylas.com/docs/api/). +- See our code samples in the [Nylas Samples repo](https://github.com/orgs/nylas-samples/repositories?q=&type=all&language=python). -If you have a question about the Nylas Communications Platform, please reach out to support@nylas.com to get help. +If you have any questions about the Nylas platform, please reach out to support@nylas.com. ## โš™๏ธ Install The Nylas Python SDK is available via pip: ```bash -pip install nylas +pip install nylas --pre ``` -To install the SDK from source, clone this repo and run the install script. +To install the SDK from source, clone this repo and run the install script: ```bash git clone https://github.com/nylas/nylas-python.git && cd nylas-python @@ -37,38 +40,83 @@ python setup.py install ## โšก๏ธ Usage -To use this SDK, you first need to [sign up for a free Nylas developer account](https://nylas.com/register). +Before you use the Nylas Python SDK, you must first [create a Nylas account](https://dashboard.nylas.com/register). Then, follow our [API v3 Quickstart guide](https://developer.nylas.com/docs/v3-beta/v3-quickstart/) to set up your first app and get your API keys. + +For code samples and example applications, take a look at our [Python repos in the Nylas Samples collection](https://github.com/orgs/nylas-samples/repositories?q=&type=all&language=python). -Then, follow our guide to [setup your first app and get your API access keys](https://docs.nylas.com/docs/get-your-developer-api-keys). +### ๐Ÿš€ Make your first request -Next, in your python script, import the `APIClient` class from the `nylas` package, and create a new instance of this class, passing the variables you gathered when you got your developer API keys. In the following example, replace `CLIENT_ID`, `CLIENT_SECRET`, and `ACCESS_TOKEN` with your values. +After you've installed and set up the Nylas Python SDK, you can make your first API request. To do so, use the `Client` class from the `nylas` package. +The SDK is organized into different resources, each of which has methods to make requests to the Nylas API. Each resource is available through the `Client` object that you configured with your API key. For example, you can use this code to get a list of Calendars: ```python -from nylas import APIClient +from nylas import Client -nylas = APIClient( - CLIENT_ID, - CLIENT_SECRET, - ACCESS_TOKEN +nylas = Client( + api_key="API_KEY", ) -``` -Now, you can use `nylas` to access full email, calendar, and contacts functionality. For example, here is how you would print the subject line for the most recent email message to the console. +calendars, request_id, next_cursor = nylas.calendars.list("GRANT_ID") + +event, request_id = nylas.events.create( + identifier="GRANT_ID", + request_body={ + "title": "test title", + "description": "test description", + "when": { + "start_time": start_unix_timestamp, + "end_time": end_unix_timestamp, + } + }, + query_params={"calendar_id": "primary", "notify_participants": True}, + ) +) +event, request_id = nylas.events.find( + identifier="GRANT_ID", + event_id=event.id, + query_params={ + "calendar_id": "primary", + }, +) + +nylas.events.destroy("GRANT_ID", event.id, {"calendar_id": "primary"}) -```python -message = nylas.messages.first() -print(message.subject) ``` -To learn more about how to use the Nylas Python SDK, please refer to our [Python SDK QuickStart Guide](https://docs.nylas.com/docs/quickstart-python) and our [Python tutorials](https://docs.nylas.com/docs/tutorials). +## ๐Ÿ“š Documentation + +This SDK makes heavy use of [Python 3 dataclasses](https://realpython.com/python-data-classes/) to define the REST resources and request/response schemas of the Nylas APIs. The Client object is a wrapper around all of these resources and is used to interact with the corresponding APIs. Basic CRUD operations are handled by the `create()`, `find()`, `list()`, `update()`, and `destroy()` methods on each resource. Resources may also have other methods which are all detailed in the [reference guide for the Python SDK](https://nylas-python-sdk-reference.pages.dev/). In the code reference, start at `client`, and then `resources` will give more info on available API call methods. `models` is the place to find schemas for requests, responses, and all Nylas object types. + +While most resources are accessed via the top-level Client object, note that `auth` contains the sub-resource `grants` as well as a collection of other auth-related API calls. -## ๐Ÿ’™ Contributing +You'll want to catch `nylas.models.errors.NylasAPIError` to handle errors. + +Have fun!! + +## โœจ Upgrade from v5.x + +See [UPGRADE.md](UPGRADE.md) for instructions on upgrading from v5.x to v6.x. + +## ๐Ÿ’™ Contribute Please refer to [Contributing](Contributing.md) for information about how to make contributions to this project. We welcome questions, bug reports, and pull requests. -Taking part in Hacktoberfest 2023 (i.e. issue is tagged with `hacktoberfest`)? Read our [Nylas Hacktoberfest 2023 contribution guidelines](https://github.com/nylas-samples/nylas-hacktoberfest-2023/blob/main/readme.md). +## ๐Ÿ› ๏ธ Debugging + +It can sometimes be helpful to turn on request logging during development. Adding the following snippet to your code that calls the SDK should get you sorted: + +``` +import logging +import requests + +# Set up logging to print out HTTP request information +logging.basicConfig(level=logging.DEBUG) +requests_log = logging.getLogger("requests.packages.urllib3") +requests_log.setLevel(logging.DEBUG) +requests_log.propagate = True +``` ## ๐Ÿ“ License diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 00000000..4283f36e --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,178 @@ +# Upgrade to the Nylas Python SDK v6.0 + +The Nylas Python SDK has been rewritten to prepare for the upcoming release of the Nylas API v3. The changes make the SDK more idiomatic and easier to use. We've also included [function and model documentation](https://nylas-python-sdk-reference.pages.dev/), so you can easily find the implementation details that you need. + +This guide will help you upgrade your environment to use the new SDK. + +## Initial setup + +To upgrade to the new Python SDK, you must update your dependencies to use the new version. You can do this by installing the newest version of the SDK using pip: + +```bash +pip install nylas --pre +``` + +**Note**: The minimum Python version is now the lowest supported LTS: Python v3.8. + +The first step to using the new SDK is to initialize a new `nylas` instance. You can do this by passing your API key to the constructor: + +```python +from nylas import Client + +nylas = Client( + api_key="API_KEY", +) +``` + +Note that the SDK's entry point has changed to `Client`. + +From here, you can use the Nylas `Client` instance to make API requests by accessing the different resources configured with your API key. + +## Models + +Models have completely changed in the new version of the Nylas Python SDK. First, the SDK now includes a specific model for each request and response to/from the Nylas API. Let's take a Calendar object, for example. In the previous version of the SDK, there was only one `Calendar` object representing a Calendar in three states: + +- It is to be created. +- It is to be updated. +- It is to be retrieved. + +This meant that all models had to be configured with _all_ possible fields that could be used in any of these scenarios, making the object very large and difficult to anticipate as a developer. + +The new SDK has split the `Calendar` model into three separate models, one for each of the previous scenarios: + +- `Calendar`: Retrieve a Calendar. +- `CreateCalendarRequest`: Create a Calendar. +- `UpdateCalendarRequest`: Update a Calendar. + +Because the new version of the SDK drops support for Python versions lower than v3.8, our models now take advantage of some new Python features. For the models that represent response objects, we now use [dataclasses](https://docs.python.org/3/library/dataclasses.html) to make them more readable, easier to use, and to provide some type hinting and in-IDE hinting. Response objects also implement [the `dataclasses-json` library](https://pypi.org/project/dataclasses-json/), which provides utility functions such as `to_dict()` and `to_json()` that allow you to use your data in a variety of formats. + +For models that represent request objects, we're using [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict) to provide a seamless guided experience to building objects for outgoing requests. Both sets of classes are fully typed as well, ensuring that you have all the information you need to make a successful API request. + +## Make requests to the Nylas API + +To make requests to the Nylas API, you use the `nylas` instance that you configured earlier. + +The Python SDK is organized into different resources corresponding to each of the Nylas APIs. Each resource includes all of the available methods to make requests to its respective API. For example, you can use this code to get a list of Calendars: + +```python +from nylas import Client + +nylas = Client( + api_key="API_KEY", +) + +response = nylas.calendars.list(identifier="GRANT_ID") +``` + +This may look very similar to how you would get a list of Calendars in previous versions of the SDK, but there are some key differences that we'll cover in the following sections. + +### Response objects + +The Nylas API v3 has standard response objects for all requests, with the exception of OAuth endpoints. There are generally two main types of response objects: + +- `Response`: Used for requests that return a single object, such as requests to retrieve a single Calendar. This returns a parameterized object of the type that you requested (for example, `Calendar`) and a string representing the request ID. +- `ListResponse`: Used for requests that return a list of objects, such as requests to retrieve a _list_ of Calendars. This returns a list of parameterized objects of the type that you requested (for example, `Calendar`), a string representing the request ID, and a string representing the token of the next page for paginating the request. + +Both classes also support destructuring. This means you can use code like this to manipulate the data: + +```python +from nylas import Client + +nylas = Client( + api_key="API_KEY", +) + +response = nylas.calendars.list(identifier="GRANT_ID") +calendars = response.data # The list of calendars + +# Or + +calendars, request_id = nylas.calendars.list(identifier="CALENDAR_ID") # The list of calendars and the request ID +``` + +### Pagination + +The Nylas API v3 uses a new way to paginate responses by returning a `next_cursor` parameter in `ListResponse` objects. The `next_cursor` points to the next page, if one exists. + +Currently, the Nylas Python SDK doesn't support pagination out of the box, but this is something we're looking to add in the future. Instead, you can use `next_cursor` to make a request to the next page: + +```python +from nylas import Client + +nylas = Client( + api_key="API_KEY", +) + +response = nylas.calendars.list(identifier="GRANT_ID") +all_calendars = list(response) + +while response.next_cursor: + response = nylas.calendars.list(identifier="GRANT_ID", query_params={"page_token": response.next_cursor}) + all_calendars.extend(response) +``` + +### Error objects + +Similar to response objects, the Nylas API v3 has standard error objects for all requests, with the exception of OAuth endpoints. There are two superclass error classes: + +- `AbstractNylasApiError`: Used for errors returned by the Nylas API. +- `AbstractNylasSdkError`: Used for errors returned by the Python SDK. + +The `AbstractNylasApiError` superclass includes two subclasses: + +- `NylasOAuthError`: Used for Nylas API errors returned from OAuth endpoints. +- `NylasApiError`: Used for all other Nylas API errors. + +The Python SDK extracts error details from the response and stores them in the error object, along with the request ID and HTTP status code. + +Currently, there is only one type of `AbstractNylasSdkError` that we return: the `NylasSdkTimeoutError`, which is thrown when a request times out. + +## Authentication + +The Nylas Python SDK's authentication methods reflect [those available in the Nylas API v3](https://developer.nylas.com/docs/developer-guide/v3-authentication/). + +While you can only create and manage your application's connectors (formerly called "integrations") in the Dashboard, you can manage almost everything else directly from the Python SDK. This includes managing Grants, redirect URIs, OAuth tokens, and authenticating your users. + +There are two main methods to focus on when authenticating users to your app: + +- `Auth#url_for_oath2`: Returns the URL that you should direct your users to in order to authenticate them with OAuth 2.0. +- `Auth#exchange_code_for_token`: Exchanges the code Nylas returns from the authentication redirect for an access token from the OAuth provider. Nylas' response to this request returns both the access token and information about the new Grant. + +Note that you don't need to use the `grant_id` to make requests. Instead, you can use the authenticated email address associated with the Grant as the identifier. If you prefer to use the `grant_id`, you can extract it from the `CodeExchangeResponse`. + +This code demonstrates how to authenticate a user into a Nylas app: + +```python +from nylas import Client + +nylas = Client( + api_key="API_KEY", +) + +# Build the URL for authentication +auth_url = nylas.auth.url_for_oauth2({ + "client_id": "CLIENT_ID", + "redirect_uri": "abc", + "login_hint": "example@email.com" +}) + +# Write code here to redirect the user to the url and parse the code +... + +# Exchange the code for an access token + +code_exchange_response = nylas.auth.exchange_code_for_token({ + "client_id": "CLIENT_ID", + "client_secret": "CLIENT_SECRET", + "code": "CODE", + "redirect_uri": "abc" +}) + +# Now you can either use the email address that was authenticated or the grant ID in the response as the identifier + +response_with_email = nylas.calendars.list(identifier="example@email.com") + +# Or + +response_with_grant = nylas.calendars.list(identifier=code_exchange_response.grant_id) +``` diff --git a/examples/hosted-oauth/README.md b/examples/hosted-oauth/README.md deleted file mode 100644 index a53b74e7..00000000 --- a/examples/hosted-oauth/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# Example: Hosted OAuth - -This is an example project that demonstrates how to connect to Nylas via -OAuth, using [Nylas' hosted OAuth flow](https://docs.nylas.com/reference#oauth). - -This example uses the [Flask](http://flask.pocoo.org/) web framework to make -a small website, and uses the [Flask-Dance](http://flask-dance.rtfd.org/) -extension to handle the tricky bits of implementing the -[OAuth protocol](https://oauth.net/). -Once the OAuth communication is in place, this example website will contact -the Nylas API to learn some basic information about the current user, -such as the user's name and email address. It will display that information -on the page, just to prove that it can fetch it correctly. - -In order to successfully run this example, you need to do the following things: - -## Get a client ID & client secret from Nylas - -To do this, make a [Nylas Developer](https://developer.nylas.com/) account. -You should see your client ID and client secret on the dashboard, -once you've logged in on the -[Nylas Developer](https://developer.nylas.com/) website. - -## Update the `config.py` File - -Open the `config.py` file in this directory, and replace the example -client ID and client secret with the real values that you got from the Nylas -Developer dashboard. You'll also need to replace the example secret key with -any random string of letters and numbers: a keyboard mash will do. - -## Set Up HTTPS - -The OAuth protocol requires that all communication occur via the secure HTTPS -connections, rather than insecure HTTP connections. There are several ways -to set up HTTPS on your computer, but perhaps the simplest is to use -[ngrok](https://ngrok.com), a tool that lets you create a secure tunnel -from the ngrok website to your computer. Install it from the website, and -then run the following command: - -``` -ngrok http 5000 -``` - -Notice that ngrok will show you two "forwarding" URLs, which may look something -like `http://ed90abe7.ngrok.io` and `https://ed90abe7.ngrok.io`. (The hash -subdomain will be different for you.) You'll be using the second URL, which -starts with `https`. - -Alternatively, you can set the `OAUTHLIB_INSECURE_TRANSPORT` environment -variable in your shell, to disable the HTTPS check. That way, you'll be -able to use `localhost` to refer to your app, instead of an ngrok URL. -However, be aware that you won't be able to do this when you deploy -your app to production, so it's usually a better idea to set up HTTPS properly. - -## Set the Nylas Callback URL - -Once you have a HTTPS URL that points to your computer, you'll need to tell -Nylas about it. On the [Nylas Dashboard](https://dashboard.nylas.com) -click on the Application Dropdown Menu on the left, then "View all Applications". -From there, select "Edit" for the app you'd like to use and select the -"Application Callbacks" tab. Paste your HTTPS URL into the text field, and add -`/login/nylas/authorized` after it. For example, if your HTTPS URL is -`https://ad172180.ngrok.io`, then you would put `https://ad172180.ngrok.io/login/nylas/authorized` -into the text field in the "Application Callbacks" tab. - -Then click the "Add Callback" button to save. - -## Install the Dependencies - -This project depends on a few third-party Python modules, like Flask. -These dependencies are listed in the `requirements.txt` file in this directory. -To install them, use the `pip` tool, like this: - -``` -pip install -r requirements.txt -``` - -## Run the Example - -Finally, run the example project like this: - -``` -python server.py -``` - -Once the server is running, visit the ngrok URL in your browser to test it out! diff --git a/examples/hosted-oauth/config.py b/examples/hosted-oauth/config.py deleted file mode 100644 index 12da98a2..00000000 --- a/examples/hosted-oauth/config.py +++ /dev/null @@ -1,3 +0,0 @@ -SECRET_KEY = ("replace me with a random string",) -NYLAS_OAUTH_CLIENT_ID = ("replace me with the client ID from Nylas",) -NYLAS_OAUTH_CLIENT_SECRET = "replace me with the client secret from Nylas" diff --git a/examples/hosted-oauth/requirements.txt b/examples/hosted-oauth/requirements.txt deleted file mode 100644 index e87c213b..00000000 --- a/examples/hosted-oauth/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Flask>=0.11 -Flask-Dance>=0.11.1 -requests diff --git a/examples/hosted-oauth/server.py b/examples/hosted-oauth/server.py deleted file mode 100644 index cd640531..00000000 --- a/examples/hosted-oauth/server.py +++ /dev/null @@ -1,127 +0,0 @@ -# Imports from the Python standard library -from __future__ import print_function -import os -import sys -import textwrap - -# Imports from third-party modules that this project depends on -try: - import requests - from flask import Flask, render_template - from werkzeug.middleware.proxy_fix import ProxyFix - from flask_dance.contrib.nylas import make_nylas_blueprint, nylas -except ImportError: - message = textwrap.dedent( - """ - You need to install the dependencies for this project. - To do so, run this command: - - pip install -r requirements.txt - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -try: - from nylas import APIClient -except ImportError: - message = textwrap.dedent( - """ - You need to install the Nylas SDK for this project. - To do so, run this command: - - pip install nylas - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -# This example uses Flask, a micro web framework written in Python. -# For more information, check out the documentation: http://flask.pocoo.org -# Create a Flask app, and load the configuration file. -app = Flask(__name__) -app.config.from_pyfile("config.py") - -# Check for dummy configuration values. -# If you are building your own application based on this example, -# you can remove this check from your code. -cfg_needs_replacing = [ - key - for key, value in app.config.items() - if isinstance(value, str) and value.startswith("replace me") -] -if cfg_needs_replacing: - message = textwrap.dedent( - """ - This example will only work if you replace the fake configuration - values in `config.json` with real configuration values. - The following config values need to be replaced: - {keys} - Consult the README.md file in this directory for more information. - """ - ).format(keys=", ".join(cfg_needs_replacing)) - print(message, file=sys.stderr) - sys.exit(1) - -# Use Flask-Dance to automatically set up the OAuth endpoints for Nylas. -# For more information, check out the documentation: http://flask-dance.rtfd.org -nylas_bp = make_nylas_blueprint() -app.register_blueprint(nylas_bp, url_prefix="/login") - -# Teach Flask how to find out that it's behind an ngrok proxy -app.wsgi_app = ProxyFix(app.wsgi_app) - -# Define what Flask should do when someone visits the root URL of this website. -@app.route("/") -def index(): - # If the user has already connected to Nylas via OAuth, - # `nylas.authorized` will be True. Otherwise, it will be False. - if not nylas.authorized: - # OAuth requires HTTPS. The template will display a handy warning, - # unless we've overridden the check. - return render_template( - "before_authorized.html", - insecure_override=os.environ.get("OAUTHLIB_INSECURE_TRANSPORT"), - ) - - # If we've gotten to this point, then the user has already connected - # to Nylas via OAuth. Let's set up the SDK client with the OAuth token: - client = APIClient( - client_id=app.config["NYLAS_OAUTH_CLIENT_ID"], - client_secret=app.config["NYLAS_OAUTH_CLIENT_SECRET"], - access_token=nylas.access_token, - ) - - # We'll use the Nylas client to fetch information from Nylas - # about the current user, and pass that to the template. - account = client.account - return render_template("after_authorized.html", account=account) - - -def ngrok_url(): - """ - If ngrok is running, it exposes an API on port 4040. We can use that - to figure out what URL it has assigned, and suggest that to the user. - https://ngrok.com/docs#list-tunnels - """ - try: - ngrok_resp = requests.get("http://localhost:4040/api/tunnels") - except requests.ConnectionError: - # I guess ngrok isn't running. - return None - ngrok_data = ngrok_resp.json() - secure_urls = [ - tunnel["public_url"] - for tunnel in ngrok_data["tunnels"] - if tunnel["proto"] == "https" - ] - return secure_urls[0] - - -# When this file is executed, run the Flask web server. -if __name__ == "__main__": - url = ngrok_url() - if url: - print(" * Visit {url} to view this Nylas example".format(url=url)) - - app.run() diff --git a/examples/hosted-oauth/templates/after_authorized.html b/examples/hosted-oauth/templates/after_authorized.html deleted file mode 100644 index 04e010a0..00000000 --- a/examples/hosted-oauth/templates/after_authorized.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

You've successfully connected to Nylas via hosted OAuth! Here's some - information that I got from the Nylas API, to prove it:

- - - {% for key, value in account.items() %} - - - - - {% endfor %} -
{{ key }}{{ value }}
- -

If you want to test this OAuth flow again, clear your browser cookies - or open a new browser in incognito mode.

-{% endblock %} diff --git a/examples/hosted-oauth/templates/base.html b/examples/hosted-oauth/templates/base.html deleted file mode 100644 index e3f8e610..00000000 --- a/examples/hosted-oauth/templates/base.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Nylas Hosted OAuth Example - - - -
-

Nylas Hosted OAuth Example

- {% block body %}{% endblock %} -
- - diff --git a/examples/hosted-oauth/templates/before_authorized.html b/examples/hosted-oauth/templates/before_authorized.html deleted file mode 100644 index 9a7215ba..00000000 --- a/examples/hosted-oauth/templates/before_authorized.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} -{% block body %} -{% if not insecure_override %} - -{% endif %} - -

Thanks for giving Nylas a try! Next, you need to click this button - to connect to Nylas via hosted OAuth.

-Connect to Nylas - - -{% endblock %} diff --git a/examples/native-authentication-exchange/README.md b/examples/native-authentication-exchange/README.md deleted file mode 100644 index 09ef1d0d..00000000 --- a/examples/native-authentication-exchange/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Example: Native Authentication (Exchange) - -This is an example project that demonstrates how to connect to Nylas using the -[Native Authentication](https://docs.nylas.com/reference#native-authentication-1) -flow. Note that different email providers have different native authentication -processes; this example project *only* works with Microsoft Exchange. - -This example uses the [Flask](http://flask.pocoo.org/) web framework to make -a small website, and uses the [Flask-WTF](https://flask-wtf.readthedocs.io/) -extension to implement an HTML form so the user can type in their -Exchange account information. -Once the native authentication has been set up, this example website will contact -the Nylas API to learn some basic information about the current user, -such as the user's name and email address. It will display that information -on the page, just to prove that it can fetch it correctly. - -In order to successfully run this example, you need to do the following things: - -## Get a client ID & client secret from Nylas - -To do this, make a [Nylas Developer](https://developer.nylas.com/) account. -You should see your client ID and client secret on the dashboard, -once you've logged in on the -[Nylas Developer](https://developer.nylas.com/) website. - -## Update the `config.json` File - -Open the `config.json` file in this directory, and replace the example -values with the real values. This is where you'll need the client ID and -client secret fron Nylas. You'll also need to replace the example secret key with -any random string of letters and numbers: a keyboard mash will do. - -## Install the Dependencies - -This project depends on a few third-party Python modules, like Flask. -These dependencies are listed in the `requirements.txt` file in this directory. -To install them, use the `pip` tool, like this: - -``` -pip install -r requirements.txt -``` - -## Run the Example - -Finally, run the example project like this: - -``` -python server.py -``` - -Once the server is running, visit `http://127.0.0.1:5000/` in your browser -to test it out! diff --git a/examples/native-authentication-exchange/config.json b/examples/native-authentication-exchange/config.json deleted file mode 100644 index 9b54e3df..00000000 --- a/examples/native-authentication-exchange/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "SECRET_KEY": "replace me with a random string", - "NYLAS_OAUTH_CLIENT_ID": "replace me with the client ID from Nylas", - "NYLAS_OAUTH_CLIENT_SECRET": "replace me with the client secret from Nylas" -} diff --git a/examples/native-authentication-exchange/requirements.txt b/examples/native-authentication-exchange/requirements.txt deleted file mode 100644 index f03e8299..00000000 --- a/examples/native-authentication-exchange/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Flask>=0.11 -Flask-WTF -requests diff --git a/examples/native-authentication-exchange/server.py b/examples/native-authentication-exchange/server.py deleted file mode 100644 index 3fefd926..00000000 --- a/examples/native-authentication-exchange/server.py +++ /dev/null @@ -1,170 +0,0 @@ -# Imports from the Python standard library -from __future__ import print_function -import os -import sys -import textwrap - -# Imports from third-party modules that this project depends on -try: - import requests - from flask import Flask, render_template, session, redirect, url_for - from flask_wtf import FlaskForm - from wtforms.fields import StringField, PasswordField - from wtforms.fields.html5 import EmailField - from wtforms.validators import DataRequired -except ImportError: - message = textwrap.dedent( - """ - You need to install the dependencies for this project. - To do so, run this command: - - pip install -r requirements.txt - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -try: - from nylas import APIClient -except ImportError: - message = textwrap.dedent( - """ - You need to install the Nylas SDK for this project. - To do so, run this command: - - pip install nylas - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -# This example uses Flask, a micro web framework written in Python. -# For more information, check out the documentation: http://flask.pocoo.org -# Create a Flask app, and load the configuration file. -app = Flask(__name__) -app.config.from_json("config.json") - -# Check for dummy configuration values. -# If you are building your own application based on this example, -# you can remove this check from your code. -cfg_needs_replacing = [ - key - for key, value in app.config.items() - if isinstance(value, str) and value.startswith("replace me") -] -if cfg_needs_replacing: - message = textwrap.dedent( - """ - This example will only work if you replace the fake configuration - values in `config.json` with real configuration values. - The following config values need to be replaced: - {keys} - Consult the README.md file in this directory for more information. - """ - ).format(keys=", ".join(cfg_needs_replacing)) - print(message, file=sys.stderr) - sys.exit(1) - - -class ExchangeCredentialsForm(FlaskForm): - name = StringField("Name", validators=[DataRequired()]) - email = EmailField("Email Address", validators=[DataRequired()]) - password = PasswordField("Password", validators=[DataRequired()]) - server_host = StringField("Server Host", render_kw={"placeholder": "(optional)"}) - - -class APIError(Exception): - pass - - -# Define what Flask should do when someone visits the root URL of this website. -@app.route("/", methods=("GET", "POST")) -def index(): - form = ExchangeCredentialsForm() - api_error = None - if form.validate_on_submit(): - try: - return pass_creds_to_nylas( - name=form.name.data, - email=form.email.data, - password=form.password.data, - server_host=form.server_host.data, - ) - except APIError as err: - api_error = err.args[0] - return render_template("index.html", form=form, api_error=api_error) - - -def pass_creds_to_nylas(name, email, password, server_host=None): - """ - Passes Exchange credentials to Nylas, to set up native authentication. - """ - # Start the connection process by looking up all the information that - # Nylas needs in order to connect, and sending it to the authorize API. - nylas_authorize_data = { - "client_id": app.config["NYLAS_OAUTH_CLIENT_ID"], - "name": name, - "email_address": email, - "provider": "exchange", - "settings": {"username": email, "password": password}, - } - if server_host: - nylas_authorize_data["settings"]["eas_server_host"] = server_host - - nylas_authorize_resp = requests.post( - "https://api.nylas.com/connect/authorize", json=nylas_authorize_data - ) - if not nylas_authorize_resp.ok: - message = nylas_authorize_resp.json()["message"] - raise APIError(message) - - nylas_code = nylas_authorize_resp.json()["code"] - - # Now that we've got the `code` from the authorize response, - # pass it to the token response to complete the connection. - nylas_token_data = { - "client_id": app.config["NYLAS_OAUTH_CLIENT_ID"], - "client_secret": app.config["NYLAS_OAUTH_CLIENT_SECRET"], - "code": nylas_code, - } - nylas_token_resp = requests.post( - "https://api.nylas.com/connect/token", json=nylas_token_data - ) - if not nylas_token_resp.ok: - message = nylas_token_resp.json()["message"] - raise APIError(message) - - nylas_access_token = nylas_token_resp.json()["access_token"] - - # Great, we've connected the Exchange account to Nylas! - # In the process, Nylas gave us an OAuth access token, which we'll need - # in order to make API requests to Nylas in the future. - # We'll save that access token in the Flask session, so we can pick it up - # later and use it when we need it. - session["nylas_access_token"] = nylas_access_token - - # We're all done here. Redirect the user back to the success page, - # which will pick up the access token we just saved. - return redirect(url_for("success")) - - -@app.route("/success") -def success(): - if "nylas_access_token" not in session: - return render_template("missing_token.html") - - client = APIClient( - client_id=app.config["NYLAS_OAUTH_CLIENT_ID"], - client_secret=app.config["NYLAS_OAUTH_CLIENT_SECRET"], - access_token=session["nylas_access_token"], - ) - - # We'll use the Nylas client to fetch information from Nylas - # about the current user, and pass that to the template. - account = client.account - return render_template("success.html", account=account) - - -# When this file is executed, run the Flask web server. -if __name__ == "__main__": - app.run() diff --git a/examples/native-authentication-exchange/templates/base.html b/examples/native-authentication-exchange/templates/base.html deleted file mode 100644 index b0683cbb..00000000 --- a/examples/native-authentication-exchange/templates/base.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Nylas Native Authentication: Exchange - - - -
-

Nylas Native Authentication: Exchange

- {% block body %}{% endblock %} -
- - diff --git a/examples/native-authentication-exchange/templates/index.html b/examples/native-authentication-exchange/templates/index.html deleted file mode 100644 index 20a653b2..00000000 --- a/examples/native-authentication-exchange/templates/index.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

Thanks for giving Nylas a try! - This example is set up to work with Exchange, so it will only work if you - have an Exchange account.

- -
-

Please enter the credentials for your Microsoft Exchange account.

- {{ form.hidden_tag() }} - - {% if api_error %} -
{{ api_error }}
- {% endif %} - - {% for field in form if field.widget.input_type != 'hidden' %} -
- {{ field.label }} - {{ field(class_='form-control') }} - {% if field.errors %} - {% for error in field.errors %}{{ error }}{% if not loop.last %}
{% endif %}{% endfor %}
- {% endif %} -
- {% endfor %} - - -
-{% endblock %} diff --git a/examples/native-authentication-exchange/templates/missing_token.html b/examples/native-authentication-exchange/templates/missing_token.html deleted file mode 100644 index 7b194ca5..00000000 --- a/examples/native-authentication-exchange/templates/missing_token.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

Missing Access Token

-

You don't seem to have an OAuth access token for Nylas. Try going back to the home page to make one.

-{% endblock %} diff --git a/examples/native-authentication-exchange/templates/success.html b/examples/native-authentication-exchange/templates/success.html deleted file mode 100644 index b9a78636..00000000 --- a/examples/native-authentication-exchange/templates/success.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

Done!

-

You've successfully connected to Nylas via native authentication! - Here's some information that I got from the Nylas API, to prove it:

- - - {% for key, value in account.items() %} - - - - - {% endfor %} -
{{ key }}{{ value }}
- -{% endblock %} diff --git a/examples/native-authentication-gmail/README.md b/examples/native-authentication-gmail/README.md deleted file mode 100644 index 2de2cbff..00000000 --- a/examples/native-authentication-gmail/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# Example: Native Authentication (Gmail) - -This is an example project that demonstrates how to connect to Nylas using the -[Native Authentication](https://docs.nylas.com/reference#native-authentication-1) -flow. Note that different email providers have different native authentication -processes; this example project *only* works with Gmail. - -This example uses the [Flask](http://flask.pocoo.org/) web framework to make -a small website, and uses the [Flask-Dance](http://flask-dance.rtfd.org/) -extension to handle the tricky bits of implementing the -[OAuth protocol](https://oauth.net/). -Once the native authentication has been set up, this example website will contact -the Nylas API to learn some basic information about the current user, -such as the user's name and email address. It will display that information -on the page, just to prove that it can fetch it correctly. - -In order to successfully run this example, you need to do the following things: - -## Get a client ID & client secret from Nylas - -To do this, make a [Nylas Developer](https://developer.nylas.com/) account. -You should see your client ID and client secret on the dashboard, -once you've logged in on the -[Nylas Developer](https://developer.nylas.com/) website. - -## Get a client ID & client secret from Google - -To do this, go to the -[Google Developers Console](https://console.developers.google.com) -and create a project. Then go to the "Library" section and enable the -following APIs: "Gmail API", "Contacts API", "Google Calendar API". -Then go to the "Credentials" section and create a new OAuth client ID. -Select "Web application" for the application type, and click the "Create" -button. - -Check out the -[Google OAuth Setup Guide](https://support.nylas.com/hc/en-us/articles/222176307) -on the Nylas support website, for more information. - -## Update the `config.json` File - -Open the `config.json` file in this directory, and replace the example -values with the real values. You'll need the client ID and client secret -from Nylas, and the client ID and client secret from Google. - -You'll also need to replace the example secret key with -any random string of letters and numbers: a keyboard mash will do. - -## Set Up HTTPS - -The OAuth protocol requires that all communication occur via the secure HTTPS -connections, rather than insecure HTTP connections. There are several ways -to set up HTTPS on your computer, but perhaps the simplest is to use -[ngrok](https://ngrok.com), a tool that lets you create a secure tunnel -from the ngrok website to your computer. Install it from the website, and -then run the following command: - -``` -ngrok http 5000 -``` - -Notice that ngrok will show you two "forwarding" URLs, which may look something -like `http://ed90abe7.ngrok.io` and `https://ed90abe7.ngrok.io`. (The hash -subdomain will be different for you.) You'll be using the second URL, which -starts with `https`. - -Alternatively, you can set the `OAUTHLIB_INSECURE_TRANSPORT` environment -variable in your shell, to disable the HTTPS check. That way, you'll be -able to use `localhost` to refer to your app, instead of an ngrok URL. -However, be aware that you won't be able to do this when you deploy -your app to production, so it's usually a better idea to set up HTTPS properly. - -## Set the Authorized Redirect URI for Google - -Once you have a HTTPS URL that points to your computer, you'll need to tell -Google about it. On the -[Google Developer Console](https://console.developers.google.com), -click on the "Credentials" section, find the OAuth client that you -already created, and click on the "edit" button on the right side. -There is a section called "Authorized redirect URIs"; this is where -you need to tell Google about your HTTPS URL. -Paste your HTTPS URL into text field, and add `/login/google/authorized` -after it. For example, if your HTTPS URL is `https://ad172180.ngrok.io`, then -you would put `https://ad172180.ngrok.io/login/google/authorized` into -the "Authorized redirect URIs" text field. - -Then click the "Done" button to save. Even after you save, it usually takes -Google about 5 minutes to update everything behind the scenes. - -## Install the Dependencies - -This project depends on a few third-party Python modules, like Flask. -These dependencies are listed in the `requirements.txt` file in this directory. -To install them, use the `pip` tool, like this: - -``` -pip install -r requirements.txt -``` - -## Run the Example - -Finally, run the example project like this: - -``` -python server.py -``` - -Once the server is running, visit the ngrok URL in your browser to test it out! diff --git a/examples/native-authentication-gmail/config.json b/examples/native-authentication-gmail/config.json deleted file mode 100644 index f997e0fe..00000000 --- a/examples/native-authentication-gmail/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "SECRET_KEY": "replace me with a random string", - "NYLAS_OAUTH_CLIENT_ID": "replace me with the client ID from Nylas", - "NYLAS_OAUTH_CLIENT_SECRET": "replace me with the client secret from Nylas", - "GOOGLE_OAUTH_CLIENT_ID": "replace me with the client ID from Google", - "GOOGLE_OAUTH_CLIENT_SECRET": "replace me with the client secret from Google" -} diff --git a/examples/native-authentication-gmail/requirements.txt b/examples/native-authentication-gmail/requirements.txt deleted file mode 100644 index e87c213b..00000000 --- a/examples/native-authentication-gmail/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Flask>=0.11 -Flask-Dance>=0.11.1 -requests diff --git a/examples/native-authentication-gmail/server.py b/examples/native-authentication-gmail/server.py deleted file mode 100644 index d1d49a73..00000000 --- a/examples/native-authentication-gmail/server.py +++ /dev/null @@ -1,234 +0,0 @@ -# Imports from the Python standard library -from __future__ import print_function -import os -import sys -import textwrap - -# Imports from third-party modules that this project depends on -try: - import requests - from flask import Flask, render_template, session, redirect, url_for - from werkzeug.middleware.proxy_fix import ProxyFix - from flask_dance.contrib.google import make_google_blueprint, google -except ImportError: - message = textwrap.dedent( - """ - You need to install the dependencies for this project. - To do so, run this command: - - pip install -r requirements.txt - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -try: - from nylas import APIClient -except ImportError: - message = textwrap.dedent( - """ - You need to install the Nylas SDK for this project. - To do so, run this command: - - pip install nylas - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -# This example uses Flask, a micro web framework written in Python. -# For more information, check out the documentation: http://flask.pocoo.org -# Create a Flask app, and load the configuration file. -app = Flask(__name__) -app.config.from_json("config.json") - -# Check for dummy configuration values. -# If you are building your own application based on this example, -# you can remove this check from your code. -cfg_needs_replacing = [ - key - for key, value in app.config.items() - if isinstance(value, str) and value.startswith("replace me") -] -if cfg_needs_replacing: - message = textwrap.dedent( - """ - This example will only work if you replace the fake configuration - values in `config.json` with real configuration values. - The following config values need to be replaced: - {keys} - Consult the README.md file in this directory for more information. - """ - ).format(keys=", ".join(cfg_needs_replacing)) - print(message, file=sys.stderr) - sys.exit(1) - -# Use Flask-Dance to automatically set up the OAuth endpoints for Google. -# For more information, check out the documentation: http://flask-dance.rtfd.org -google_bp = make_google_blueprint( - client_id=app.config["GOOGLE_OAUTH_CLIENT_ID"], - client_secret=app.config["GOOGLE_OAUTH_CLIENT_SECRET"], - scope=[ - "openid", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - "https://mail.google.com/", - "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/contacts", - ], - offline=True, # this allows you to get a refresh token from Google - redirect_to="after_google", - # If you get a "missing Google refresh token" error, uncomment this line: - # reprompt_consent=True, - # That `reprompt_consent` argument will force Google to re-ask the user - # every single time if they want to connect with your application. - # Google will only send the refresh token if the user has explicitly - # given consent. -) -app.register_blueprint(google_bp, url_prefix="/login") - -# Teach Flask how to find out that it's behind an ngrok proxy -app.wsgi_app = ProxyFix(app.wsgi_app) - -# Define what Flask should do when someone visits the root URL of this website. -@app.route("/") -def index(): - # If the user has already connected to Google via OAuth, - # `google.authorized` will be True. We also need to be sure that - # we have a refresh token from Google. If we don't have both of those, - # that indicates that we haven't correctly connected with Google. - if not (google.authorized and "refresh_token" in google.token): - # Google requires HTTPS. The template will display a handy warning, - # unless we've overridden the check. - return render_template( - "before_google.html", - insecure_override=os.environ.get("OAUTHLIB_INSECURE_TRANSPORT"), - ) - - if "nylas_access_token" not in session: - # The user has already connected to Google via OAuth, - # but hasn't yet passed those credentials to Nylas. - # We'll redirect the user to the right place to make that happen. - return redirect(url_for("after_google")) - - # If we've gotten to this point, then the user has already connected - # to both Google and Nylas. - # Let's set up the SDK client with the OAuth token: - client = APIClient( - client_id=app.config["NYLAS_OAUTH_CLIENT_ID"], - client_secret=app.config["NYLAS_OAUTH_CLIENT_SECRET"], - access_token=session["nylas_access_token"], - ) - - # We'll use the Nylas client to fetch information from Nylas - # about the current user, and pass that to the template. - account = client.account - return render_template("after_connected.html", account=account, client=client) - - -@app.route("/google/success") -def after_google(): - """ - This just renders a confirmation page, to let the user know that - they've successfully connected to Google and need to move on to the - next step: passing those authentication credentials to Nylas. - """ - return render_template("after_google.html") - - -@app.route("/nylas/connect") -def pass_creds_to_nylas(): - """ - This view loads the credentials from Google and passes them to Nylas, - to set up native authentication. - """ - # If you haven't already connected with Google, this won't work. - if not google.authorized: - return "Error: not yet connected with Google!", 400 - - if "refresh_token" not in google.token: - # We're missing the refresh token from Google, and the only way to get - # a new one is to force reauthentication. That's annoying. - return ( - ( - "Error: missing Google refresh token. " - "Uncomment the `reprompt_consent` line in the code to fix this." - ), - 500, - ) - - # Look up the user's name and email address from Google. - google_resp = google.get("/oauth2/v2/userinfo?fields=name,email") - assert google_resp.ok, "Received failure response from Google userinfo API" - google_userinfo = google_resp.json() - - # Start the connection process by looking up all the information that - # Nylas needs in order to connect, and sending it to the authorize API. - nylas_authorize_data = { - "client_id": app.config["NYLAS_OAUTH_CLIENT_ID"], - "name": google_userinfo["name"], - "email_address": google_userinfo["email"], - "provider": "gmail", - "settings": { - "google_client_id": app.config["GOOGLE_OAUTH_CLIENT_ID"], - "google_client_secret": app.config["GOOGLE_OAUTH_CLIENT_SECRET"], - "google_refresh_token": google.token["refresh_token"], - }, - } - nylas_authorize_resp = requests.post( - "https://api.nylas.com/connect/authorize", json=nylas_authorize_data - ) - assert nylas_authorize_resp.ok, "Received failure response from Nylas authorize API" - nylas_code = nylas_authorize_resp.json()["code"] - - # Now that we've got the `code` from the authorize response, - # pass it to the token response to complete the connection. - nylas_token_data = { - "client_id": app.config["NYLAS_OAUTH_CLIENT_ID"], - "client_secret": app.config["NYLAS_OAUTH_CLIENT_SECRET"], - "code": nylas_code, - } - nylas_token_resp = requests.post( - "https://api.nylas.com/connect/token", json=nylas_token_data - ) - assert nylas_token_resp.ok, "Received failure response from Nylas token API" - nylas_access_token = nylas_token_resp.json()["access_token"] - - # Great, we've connected Google to Nylas! In the process, Nylas gave us - # an OAuth access token, which we'll need in order to make API requests - # to Nylas in the future. We'll save that access token in the Flask session, - # so we can pick it up later and use it when we need it. - session["nylas_access_token"] = nylas_access_token - - # We're all done here. Redirect the user back to the home page, - # which will pick up the access token we just saved. - return redirect(url_for("index")) - - -def ngrok_url(): - """ - If ngrok is running, it exposes an API on port 4040. We can use that - to figure out what URL it has assigned, and suggest that to the user. - https://ngrok.com/docs#list-tunnels - """ - try: - ngrok_resp = requests.get("http://localhost:4040/api/tunnels") - except requests.ConnectionError: - # I guess ngrok isn't running. - return None - ngrok_data = ngrok_resp.json() - secure_urls = [ - tunnel["public_url"] - for tunnel in ngrok_data["tunnels"] - if tunnel["proto"] == "https" - ] - return secure_urls[0] - - -# When this file is executed, run the Flask web server. -if __name__ == "__main__": - url = ngrok_url() - if url: - print(" * Visit {url} to view this Nylas example".format(url=url)) - - app.run() diff --git a/examples/native-authentication-gmail/templates/after_connected.html b/examples/native-authentication-gmail/templates/after_connected.html deleted file mode 100644 index 23efec8e..00000000 --- a/examples/native-authentication-gmail/templates/after_connected.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

Done!

-

You've successfully connected to Nylas via native authentication! - Here's some information that I got from the Nylas API, to prove it:

- - - {% for key, value in account.items() %} - - - - - {% endfor %} - - - - -
{{ key }}{{ value }}
Access Token:{{ client.access_token }}
- -{% endblock %} diff --git a/examples/native-authentication-gmail/templates/after_google.html b/examples/native-authentication-gmail/templates/after_google.html deleted file mode 100644 index 89a8fc09..00000000 --- a/examples/native-authentication-gmail/templates/after_google.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

Step 2: Pass Credentials to Nylas

- -

Great, you've succesfully connected with Google! The next step is to - hand those authentication credentials over to Nylas, so that Nylas - can connect with Google on your behalf. - Please click this link to do that.

-Pass Credentials to Nylas - -{% endblock %} diff --git a/examples/native-authentication-gmail/templates/base.html b/examples/native-authentication-gmail/templates/base.html deleted file mode 100644 index 9a26d102..00000000 --- a/examples/native-authentication-gmail/templates/base.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Nylas Native Authentication: Gmail - - - -
-

Nylas Native Authentication: Gmail

- {% block body %}{% endblock %} -
- - diff --git a/examples/native-authentication-gmail/templates/before_google.html b/examples/native-authentication-gmail/templates/before_google.html deleted file mode 100644 index 07a59634..00000000 --- a/examples/native-authentication-gmail/templates/before_google.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends "base.html" %} -{% block body %} -{% if not insecure_override %} - -{% endif %} - -

Step 1: Connect with Google

- -

Thanks for giving Nylas a try! Native Authentication is a two-step process, - where the first step is connecting with the native email provider. - This example is set up to work with Google, so it will only work if you - have a Gmail account.

-

First, please click this button to connect with Google.

-Connect with Google - - -{% endblock %} diff --git a/examples/webhooks/README.md b/examples/webhooks/README.md deleted file mode 100644 index ac9483a1..00000000 --- a/examples/webhooks/README.md +++ /dev/null @@ -1,125 +0,0 @@ -# Example: Webhooks - -This is an example project that demonstrates how to use -[the webhooks feature on Nylas](https://docs.nylas.com/reference#webhooks). -When you run the app and set up a webhook with Nylas, it will print out -some information every time you receive a webhook notification from Nylas. - -In order to successfully run this example, you need to do the following things: - -## Install and run redis - -[Redis](https://redis.io/) is an in-memory data store. This example uses it -as a message broker for the Celery task queue. You'll need to have it running -on your local computer in order to use the task queue. - -If you're using macOS, you can install redis from [Homebrew](https://brew.sh/), -like this: - -``` -brew install redis -brew services start redis -``` - -If you're unable to install and run redis, you can still run this example -without the task queue -- keep reading. - -## Get a client ID & client secret from Nylas - -To do this, make a [Nylas Developer](https://developer.nylas.com/) account. -You should see your client ID and client secret on the dashboard, -once you've logged in on the -[Nylas Developer](https://developer.nylas.com/) website. - -## Update the `config.json` File - -Open the `config.json` file in this directory, and replace the example -client ID and client secret with the real values that you got from the Nylas -Developer dashboard. You'll also need to replace the example secret key with -any random string of letters and numbers: a keyboard mash will do. - -The config file also has two options related to Celery, which you probably -don't need to modify. `CELERY_BROKER_URL` should point to your running redis -server: if you've got it running on your local computer, you're all set. -However, if you haven't managed to get redis running on your computer, you -can change `CELERY_TASK_ALWAYS_EAGER` to `true`. This will disable the task -queue, and cause all Celery tasks to be run immediately rather than queuing -them for later. - -## Set Up HTTPS - -Nylas requires that all webhooks be delivered to the secure HTTPS endpoints, -rather than insecure HTTP endpoints. There are several ways -to set up HTTPS on your computer, but perhaps the simplest is to use -[ngrok](https://ngrok.com), a tool that lets you create a secure tunnel -from the ngrok website to your computer. Install it from the website, and -then run the following command: - -``` -ngrok http 5000 -``` - -Notice that ngrok will show you two "forwarding" URLs, which may look something -like `http://ed90abe7.ngrok.io` and `https://ed90abe7.ngrok.io`. (The hash -subdomain will be different for you.) You'll be using the second URL, which -starts with `https`. - -## Install the Dependencies - -This project depends on a few third-party Python modules. -These dependencies are listed in the `requirements.txt` file in this directory. -To install them, use the `pip` tool, like this: - -``` -pip install -r requirements.txt -``` - -## Run the Celery worker (if you're using redis) - -The Celery worker will continuously check the task queue to see if there are -any new tasks to be run, and it will run any tasks that it finds. Without at -least one worker running, tasks on the task queue will sit there unfinished -forever. To run a celery worker, pass the `--worker` argument to the `server.py` -script, like this: - -``` -python server.py --worker -``` - -Note that if you're not using redis, you don't need to run a Celery worker, -because the tasks will be run immediately rather than put on the task queue. - -## Run the Example - -While the Celery worker is running, open a new terminal window and run the -Flask web server, like this: - -``` -python server.py -``` - -You should see the ngrok URL in the console, and the web server will start -on port 5000. - -## Set the Nylas Callback URL - -Now that your webhook is all set up and running, you need to tell -Nylas about it. On the [Nylas Developer](https://developer.nylas.com) console, -click on the "Webhooks" tab on the left side, then click the "Add Webhook" -button. -Paste your HTTPS URL into text field, and add `/webhook` -after it. For example, if your HTTPS URL is `https://ad172180.ngrok.io`, then -you would put `https://ad172180.ngrok.io/webhook` into the text field. - -Then click the "Create Webhook" button to save. - -## Trigger events and see webhook notifications! - -Send an email on an account that's connected to Nylas. In a minute or two, -you'll get a webhook notification with information about the event that just -happened! - -If you're using redis, you should see the information about the event in the -terminal window where your Celery worker is running. If you're not using -redis, you should see the information about the event in the terminal window -where your Flask web server is running. diff --git a/examples/webhooks/config.json b/examples/webhooks/config.json deleted file mode 100644 index 04e9a645..00000000 --- a/examples/webhooks/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "SECRET_KEY": "replace me with a random string", - "NYLAS_OAUTH_CLIENT_ID": "replace me with the client ID from Nylas", - "NYLAS_OAUTH_CLIENT_SECRET": "replace me with the client secret from Nylas", - "CELERY_BROKER_URL": "redis://localhost", - "CELERY_TASK_ALWAYS_EAGER": false -} diff --git a/examples/webhooks/requirements.txt b/examples/webhooks/requirements.txt deleted file mode 100644 index 38e90172..00000000 --- a/examples/webhooks/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Flask>=0.11 -celery[redis]>=4.0.0 -requests diff --git a/examples/webhooks/server.py b/examples/webhooks/server.py deleted file mode 100755 index db1deb95..00000000 --- a/examples/webhooks/server.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python - -# Imports from the Python standard library -from __future__ import print_function -import os -import sys -import datetime -import textwrap -import hmac -import hashlib - -# Imports from third-party modules that this project depends on -try: - import requests - from flask import Flask, request, render_template - from werkzeug.middleware.proxy_fix import ProxyFix - from celery import Celery -except ImportError: - message = textwrap.dedent( - """ - You need to install the dependencies for this project. - To do so, run this command: - - pip install -r requirements.txt - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -# This example uses Flask, a micro web framework written in Python. -# For more information, check out the documentation: http://flask.pocoo.org -# Create a Flask app, and load the configuration file. -app = Flask(__name__) -app.config.from_json("config.json") - -# Check for dummy configuration values. -# If you are building your own application based on this example, -# you can remove this check from your code. -cfg_needs_replacing = [ - key - for key, value in app.config.items() - if isinstance(value, str) and value.startswith("replace me") -] -if cfg_needs_replacing: - message = textwrap.dedent( - """ - This example will only work if you replace the fake configuration - values in `config.json` with real configuration values. - The following config values need to be replaced: - {keys} - Consult the README.md file in this directory for more information. - """ - ).format(keys=", ".join(cfg_needs_replacing)) - print(message, file=sys.stderr) - sys.exit(1) - -# Teach Flask how to find out that it's behind an ngrok proxy -app.wsgi_app = ProxyFix(app.wsgi_app) - -# This example also uses Celery, a task queue framework written in Python. -# For more information, check out the documentation: http://docs.celeryproject.org -# Create a Celery instance, and load its configuration from Flask. -celery = Celery(app.import_name) -celery.config_from_object(app.config, namespace="CELERY") - - -@app.route("/webhook", methods=["GET", "POST"]) -def webhook(): - """ - When the Flask server gets a request at the `/webhook` URL, it will run - this function. Most of the time, that request will be a genuine webhook - notification from Nylas. However, it's possible that the request could - be a fake notification from someone else, trying to fool our app. This - function needs to verify that the webhook is genuine! - """ - # When you first tell Nylas about your webhook, it will test that webhook - # URL with a GET request to make sure that it responds correctly. - # We just need to return the `challenge` parameter to indicate that this - # is a valid webhook URL. - if request.method == "GET" and "challenge" in request.args: - print(" * Nylas connected to the webhook!") - return request.args["challenge"] - - # Alright, this is a POST request, which means it's a webhook notification. - # The question is, is it genuine or fake? Check the signature to find out. - is_genuine = verify_signature( - message=request.data, - key=app.config["NYLAS_OAUTH_CLIENT_SECRET"].encode("utf8"), - signature=request.headers.get("X-Nylas-Signature"), - ) - if not is_genuine: - return "Signature verification failed!", 401 - - # Alright, we have a genuine webhook notification from Nylas! - # Let's find out what it says... - data = request.get_json() - for delta in data["deltas"]: - # Processing the data might take awhile, or it might fail. - # As a result, instead of processing it right now, we'll push a task - # onto the Celery task queue, to handle it later. That way, - # we've got the data saved, and we can return a response to the - # Nylas webhook notification right now. - process_delta.delay(delta) - - # Now that all the `process_delta` tasks have been queued, we can - # return an HTTP response to Nylas, to let them know that we processed - # the webhook notification successfully. - return "Deltas have been queued", 200 - - -def verify_signature(message, key, signature): - """ - This function will verify the authenticity of a digital signature. - For security purposes, Nylas includes a digital signature in the headers - of every webhook notification, so that clients can verify that the - webhook request came from Nylas and no one else. The signing key - is your OAuth client secret, which only you and Nylas know. - """ - digest = hmac.new(key, msg=message, digestmod=hashlib.sha256).hexdigest() - return hmac.compare_digest(digest, signature) - - -@celery.task -def process_delta(delta): - """ - This is the part of the code where you would process the information - from the webhook notification. Each delta is one change that happened, - and might require fetching message IDs, updating your database, - and so on. - - However, because this is just an example project, we'll just print - out information about the notification, so you can see what - information is being sent. - """ - kwargs = { - "type": delta["type"], - "date": datetime.datetime.utcfromtimestamp(delta["date"]), - "object_id": delta["object_data"]["id"], - } - print(" * {type} at {date} with ID {object_id}".format(**kwargs)) - - -@app.route("/") -def index(): - """ - This makes sure that when you visit the root of the website, - you get a webpage rather than a 404 error. - """ - return render_template("index.html", ngrok_url=ngrok_url()) - - -def ngrok_url(): - """ - If ngrok is running, it exposes an API on port 4040. We can use that - to figure out what URL it has assigned, and suggest that to the user. - https://ngrok.com/docs#list-tunnels - """ - try: - ngrok_resp = requests.get("http://localhost:4040/api/tunnels") - except requests.ConnectionError: - # I guess ngrok isn't running. - return None - ngrok_data = ngrok_resp.json() - secure_urls = [ - tunnel["public_url"] - for tunnel in ngrok_data["tunnels"] - if tunnel["proto"] == "https" - ] - return secure_urls[0] - - -# When this file is executed, this block of code will run. -if __name__ == "__main__": - if len(sys.argv) > 1 and sys.argv[1] == "--worker": - # Run the celery worker, *instead* of running the Flask web server. - celery.worker_main(sys.argv[1:]) - sys.exit() - - # If we get here, we're going to try to run the Flask web server. - url = ngrok_url() - if not url: - print( - "Looks like ngrok isn't running! Start it by running " - "`ngrok http 5000` in a different terminal window, " - "and then try running this example again.", - file=sys.stderr, - ) - sys.exit(1) - - print(" * Webhook URL: {url}/webhook".format(url=url)) - - if app.config.get("CELERY_TASK_ALWAYS_EAGER"): - print(" * Celery tasks will be run synchronously. No worker needed.") - elif len(celery.control.inspect().stats().keys()) < 2: - print( - " * You need to run at least one Celery worker, otherwise " - "the webhook notifications will never be processed.\n" - " To do so, run `{arg0} --worker` in a different " - "terminal window.".format(arg0=sys.argv[0]) - ) - app.run() diff --git a/examples/webhooks/templates/base.html b/examples/webhooks/templates/base.html deleted file mode 100644 index 3d6ac8a3..00000000 --- a/examples/webhooks/templates/base.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Nylas Webhook Example - - - -
-

Nylas Webhook Example

- {% block body %}{% endblock %} -
- - diff --git a/examples/webhooks/templates/index.html b/examples/webhooks/templates/index.html deleted file mode 100644 index 5069360f..00000000 --- a/examples/webhooks/templates/index.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} -{% block body %} -

This example doesn't have anything to see in the browser. Set up your - webhook on the - Nylas Developer console, - and then watch your terminal to see the webhook notifications come in. -

- -

Your webhook URL is: - {{ ngrok_url }}{{ url_for("webhook") }} -

- -

Once you've received at least one webhook notification from Nylas, - you might want to check out the - ngrok web interface. - That will allow you to see more information about the webhook notification, - and replay it for testing purposes if you want. -

-{% endblock %} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..6e8dfce4 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,23 @@ +site_name: Nylas Python SDK Reference +theme: + name: 'material' + +nav: + - Getting Started: index.md + - Code Reference: reference/ + - Contributing: contributing.md + - License: license.md + +# Add plugins +plugins: + - search + - mkdocstrings: + default_handler: python + handlers: + python: + paths: [ nylas ] + - gen-files: + scripts: + - scripts/generate-docs.py + - literate-nav: + nav_file: SUMMARY.md diff --git a/nylas/__init__.py b/nylas/__init__.py index da9b836f..2befcf83 100644 --- a/nylas/__init__.py +++ b/nylas/__init__.py @@ -1,6 +1,3 @@ -from pkgutil import extend_path -from .client.client import APIClient +from nylas.client import Client -# Allow out-of-tree submodules. -__path__ = extend_path(__path__, __name__) -__all__ = ["APIClient"] +__all__ = ["Client"] diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 53988afb..8171cf01 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.14.1" +__VERSION__ = "6.0.0" diff --git a/nylas/client.py b/nylas/client.py new file mode 100644 index 00000000..6ce8e9b5 --- /dev/null +++ b/nylas/client.py @@ -0,0 +1,171 @@ +from nylas.config import DEFAULT_SERVER_URL +from nylas.handler.http_client import HttpClient +from nylas.resources.applications import Applications +from nylas.resources.attachments import Attachments +from nylas.resources.auth import Auth +from nylas.resources.calendars import Calendars +from nylas.resources.connectors import Connectors +from nylas.resources.events import Events +from nylas.resources.folders import Folders +from nylas.resources.messages import Messages +from nylas.resources.threads import Threads +from nylas.resources.webhooks import Webhooks +from nylas.resources.contacts import Contacts +from nylas.resources.drafts import Drafts +from nylas.resources.grants import Grants + + +class Client: + """ + API client for the Nylas API. + + Attributes: + api_key: The Nylas API key to use for authentication + api_uri: The URL to use for communicating with the Nylas API + http_client: The HTTP client to use for requests to the Nylas API + """ + + def __init__( + self, api_key: str, api_uri: str = DEFAULT_SERVER_URL, timeout: int = 30 + ): + """ + Initialize the Nylas API client. + + Args: + api_key: The Nylas API key to use for authentication + api_uri: The URL to use for communicating with the Nylas API + timeout: The timeout for requests to the Nylas API, in seconds + """ + self.api_key = api_key + self.api_uri = api_uri + self.http_client = HttpClient(self.api_uri, self.api_key, timeout) + + @property + def auth(self) -> Auth: + """ + Access the Auth API. + + Returns: + The Auth API. + """ + return Auth(self.http_client) + + @property + def applications(self) -> Applications: + """ + Access the Applications API. + + Returns: + The Applications API. + """ + return Applications(self.http_client) + + @property + def attachments(self) -> Attachments: + """ + Access the Attachments API. + + Returns: + The Attachments API. + """ + return Attachments(self.http_client) + + @property + def connectors(self) -> Connectors: + """ + Access the Connectors API. + + Returns: + The Connectors API. + """ + return Connectors(self.http_client) + + @property + def calendars(self) -> Calendars: + """ + Access the Calendars API. + + Returns: + The Calendars API. + """ + return Calendars(self.http_client) + + @property + def contacts(self) -> Contacts: + """ + Access the Contacts API. + + Returns: + The Contacts API. + """ + return Contacts(self.http_client) + + @property + def drafts(self) -> Drafts: + """ + Access the Drafts API. + + Returns: + The Drafts API. + """ + return Drafts(self.http_client) + + @property + def events(self) -> Events: + """ + Access the Events API. + + Returns: + The Events API. + """ + return Events(self.http_client) + + @property + def folders(self) -> Folders: + """ + Access the Folders API. + + Returns: + The Folders API. + """ + return Folders(self.http_client) + + @property + def grants(self) -> Grants: + """ + Access the Grants API. + + Returns: + The Grants API. + """ + return Grants(self.http_client) + + @property + def messages(self) -> Messages: + """ + Access the Messages API. + + Returns: + The Messages API. + """ + return Messages(self.http_client) + + @property + def threads(self) -> Threads: + """ + Access the Threads API. + + Returns: + The Threads API. + """ + return Threads(self.http_client) + + @property + def webhooks(self) -> Webhooks: + """ + Access the Webhooks API. + + Returns: + The Webhooks API. + """ + return Webhooks(self.http_client) diff --git a/nylas/client/__init__.py b/nylas/client/__init__.py deleted file mode 100644 index 57d8f800..00000000 --- a/nylas/client/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from pkgutil import extend_path -from .client import APIClient - -__path__ = extend_path(__path__, __name__) -__all__ = ["APIClient"] diff --git a/nylas/client/authentication_models.py b/nylas/client/authentication_models.py deleted file mode 100644 index 3e634906..00000000 --- a/nylas/client/authentication_models.py +++ /dev/null @@ -1,350 +0,0 @@ -from copy import copy - -from nylas.client.restful_model_collection import RestfulModelCollection, CHUNK_SIZE -from nylas.client.restful_models import NylasAPIObject -from nylas.utils import AuthMethod -from enum import Enum - - -class Integration(NylasAPIObject): - attrs = ( - "name", - "provider", - "expires_in", - "settings", - "redirect_uris", - "scope", - "id", - ) - read_only_attrs = {"provider", "id"} - auth_method = AuthMethod.BASIC_CLIENT_ID_AND_SECRET - collection_name = "connect/integrations" - - def __init__(self, api): - NylasAPIObject.__init__(self, Integration, api) - self.settings = {} - self.scope = [] - - def set_client_id(self, client_id): - """ - Set the client ID of the OAuth provider - - Args: - client_id (str): Client ID of the OAuth provider - """ - self.settings["client_id"] = client_id - - def set_client_secret(self, client_secret): - """ - Set the client secret of the OAuth provider - - Args: - client_secret (str): Client secret of the OAuth provider - """ - self.settings["client_secret"] = client_secret - - @classmethod - def create(cls, api, **kwargs): - if "data" in kwargs: - kwargs = kwargs.get("data") - obj = super(Integration, cls).create(api, **kwargs) - if "provider" in kwargs: - obj["id"] = kwargs.get("provider") - - return obj - - def as_json(self, enforce_read_only=True): - dct = super(Integration, self).as_json(enforce_read_only) - if enforce_read_only is False: - return dct - - if not self.id: - if isinstance(self.provider, Authentication.Provider): - dct["provider"] = self.provider.value - else: - dct["provider"] = self.provider - - return dct - - def _update_resource(self, **kwargs): - provider = self.id or self.provider - return self.api._patch_resource(self.cls, provider, self.as_json(), **kwargs) - - -class Grant(NylasAPIObject): - attrs = ( - "id", - "provider", - "state", - "email", - "ip", - "grant_status", - "user_agent", - "created_at", - "updated_at", - "settings", - "metadata", - "scope", - ) - read_only_attrs = { - "id", - "email", - "ip", - "grant_status", - "user_agent", - "created_at", - "updated_at", - } - auth_method = AuthMethod.BASIC_CLIENT_ID_AND_SECRET - collection_name = "connect/grants" - - def __init__(self, api): - NylasAPIObject.__init__(self, Grant, api) - self.settings = {} - self.metadata = {} - self.scope = [] - - @classmethod - def create(cls, api, **kwargs): - if "data" in kwargs: - kwargs = kwargs.get("data") - obj = super(Grant, cls).create(api, **kwargs) - return obj - - def as_json(self, enforce_read_only=True): - dct = super(Grant, self).as_json(enforce_read_only) - if enforce_read_only is False: - return dct - - # provider and state can not be updated - if self.id: - del dct["provider"] - del dct["state"] - else: - if isinstance(self.provider, Authentication.Provider): - dct["provider"] = self.provider.value - else: - dct["provider"] = self.provider - - return dct - - def _update_resource(self, **kwargs): - return self.api._patch_resource(self.cls, self.id, self.as_json(), **kwargs) - - -class Authentication(object): - def __init__(self, api): - self._app_name = "beta" - self._region = Authentication.Region.US - # Make a copy of the API as we need to change the base url for Integration calls - self.api = copy(api) - self._set_integrations_api_url() - - @property - def app_name(self): - return self._app_name - - @app_name.setter - def app_name(self, value): - """ - Set the name of the application to prefix the URL for all integration calls for this instance - - Args: - value (str): The name of the application - """ - self._app_name = value - self._set_integrations_api_url() - - @property - def region(self): - return self._region - - @region.setter - def region(self, value): - """ - Set the region to prefix the URL for all integration calls for this instance - - Args: - value (Integration.Region): The region - """ - self._region = value - self._set_integrations_api_url() - - @property - def integrations(self): - """ - Integrations API for integrating a provider to the Nylas application - - Returns: - IntegrationRestfulModelCollection: The Integration API configured with the app_name and region - """ - return IntegrationRestfulModelCollection(self.api) - - @property - def grants(self): - """ - Native Authentication for the integrated provider - - Returns: - GrantRestfulModelCollection: The Grants API configured with the app_name and region - """ - return GrantRestfulModelCollection(self.api) - - def hosted_authentication( - self, - provider, - redirect_uri, - grant_id=None, - login_hint=None, - state=None, - expires_in=None, - settings=None, - metadata=None, - scope=None, - ): - """ - Hosted Authentication for the integrated provider - - Args: - provider (Authentication.Provider): OAuth provider - redirect_uri (str): The URI for the final redirect - grant_id (str): Existing Grant ID to trigger a re-authentication - login_hint (str): Hint to simplify the login flow - state (str): State value to return after authentication flow is completed - expires_in (int): How long this request (and the attached login) ID will remain valid before the link expires - settings (dict[str, str]): Settings required by provider - metadata (dict[str, any]): Metadata to store as part of the grant - scope (list[str]): OAuth provider-specific scopes - - Returns: - dict[str, any]: The login information - """ - request = {"provider": provider, "redirect_uri": redirect_uri} - if grant_id: - request["grant_id"] = grant_id - if login_hint: - request["login_hint"] = login_hint - if state: - request["state"] = state - if expires_in: - request["expires_in"] = expires_in - if settings: - request["settings"] = settings - if metadata: - request["metadata"] = metadata - if scope: - request["scope"] = scope - - response = self.api._post_resource(Grant, "auth", None, request, path="connect") - if "data" in response: - response = response["data"] - - return response - - def _set_integrations_api_url(self): - self.api.api_server = "https://{app_name}.{region}.nylas.com".format( - app_name=self.app_name, region=self.region.value - ) - - def _hosted_authentication_enhanced_events( - self, provider, redirect_uri, account_id - ): - request = { - "provider": provider, - "redirect_uri": redirect_uri, - "account_id": account_id, - } - response = self.api._post_resource(Grant, "auth", None, request, path="connect") - if "data" in response: - response = response["data"] - - return response - - class Region(str, Enum): - """ - This is an Enum the regions supported by the Integrations API - """ - - US = "us" - EU = "eu" - - class Provider(str, Enum): - """ - This is an Enum representing all the available providers for integrations - """ - - GOOGLE = "google" - MICROSOFT = "microsoft" - IMAP = "imap" - ZOOM = "zoom" - - -class AuthenticationRestfulModelCollection(RestfulModelCollection): - def __init__(self, model_class, api): - RestfulModelCollection.__init__(self, model_class, api) - - def _get_model_collection(self, offset=0, limit=CHUNK_SIZE): - filters = copy(self.filters) - filters["offset"] = offset - if not filters.get("limit"): - filters["limit"] = limit - - response = self.api._get_resource_raw(self.model_class, None, **filters).json() - if "data" not in response or response["data"] is None: - return [] - - return [ - self.model_class.create(self, **x) - for x in response["data"] - if x is not None - ] - - -class IntegrationRestfulModelCollection(AuthenticationRestfulModelCollection): - def __init__(self, api): - AuthenticationRestfulModelCollection.__init__(self, Integration, api) - - def get(self, provider): - """ - Get an existing integration for a provider - - Args: - provider (Authentication.Provider): The provider - - Returns: - Integration: The existing integration - """ - return super(IntegrationRestfulModelCollection, self).get(provider.value) - - def delete(self, provider, data=None, **kwargs): - """ - Deletes an existing integration for a provider - - Args: - provider (Authentication.Provider): The provider - """ - super(IntegrationRestfulModelCollection, self).delete( - provider.value, data=data, **kwargs - ) - - -class GrantRestfulModelCollection(AuthenticationRestfulModelCollection): - def __init__(self, api): - AuthenticationRestfulModelCollection.__init__(self, Grant, api) - - def on_demand_sync(self, grant_id, sync_from=None): - """ - Trigger a grant sync on demand - - Args: - grant_id (str): The grant ID to sync - sync_from (int): Epoch timestamp when the sync starts from - - Returns: - Grant: The grant after triggering the sync - """ - path = "sync" - if sync_from: - path = path + "?sync_from={}".format(sync_from) - response = self.api._post_resource(Grant, grant_id, path, data=None) - return self.model_class.create(self, **response) diff --git a/nylas/client/client.py b/nylas/client/client.py deleted file mode 100644 index 9a480517..00000000 --- a/nylas/client/client.py +++ /dev/null @@ -1,866 +0,0 @@ -from __future__ import print_function - -import sys -from os import environ -from base64 import b64encode -import json -from datetime import datetime, timedelta -from itertools import chain - -import requests -from urlobject import URLObject -import six -from six.moves.urllib.parse import urlencode -from nylas._client_sdk_version import __VERSION__ -from nylas.client.delta_collection import DeltaCollection -from nylas.client.errors import MessageRejectedError, NylasApiError, RateLimitError -from nylas.client.outbox_models import Outbox -from nylas.client.restful_model_collection import RestfulModelCollection -from nylas.client.restful_models import ( - Calendar, - Contact, - Event, - RoomResource, - Message, - Thread, - File, - Account, - APIAccount, - SingletonAccount, - Folder, - Label, - Draft, - Component, - JobStatus, - Webhook, - Send, -) -from nylas.client.neural_api_models import Neural -from nylas.client.scheduler_restful_model_collection import ( - SchedulerRestfulModelCollection, -) -from nylas.client.authentication_models import Authentication -from nylas.utils import timestamp_from_dt, create_request_body, AuthMethod, HttpMethod - -DEBUG = environ.get("NYLAS_CLIENT_DEBUG") -API_SERVER = "https://api.nylas.com" -SUPPORTED_API_VERSION = "2.5" - - -def _validate(response): - if DEBUG: # pragma: no cover - print( - "{method} {url} ({body}) => {status}: {text}".format( - method=response.request.method, - url=response.request.url, - body=response.request.body, - status=response.status_code, - text=response.text, - ) - ) - - if response.status_code == 402: - # HTTP status code 402 normally means "Payment Required", - # but when Nylas uses that status code, it means something different. - # Usually it indicates an upstream error on the provider. - # We let Requests handle most HTTP errors, but for this one, - # we will handle it separate and handle a _different_ exception - # so that users don't think they need to pay. - raise MessageRejectedError(response) - elif response.status_code == 429: - raise RateLimitError(response) - elif response.status_code >= 400: - raise NylasApiError(response) - - return response - - -def _validate_availability_query(query): - if (query.get("emails", None) is None or len(query["emails"]) == 0) and ( - query.get("calendars", None) is None or len(query["calendars"]) == 0 - ): - raise ValueError("Must set either 'emails' or 'calendars' in the query.") - - -class APIClient(json.JSONEncoder): - """API client for the Nylas API.""" - - def __init__( - self, - client_id=environ.get("NYLAS_CLIENT_ID"), - client_secret=environ.get("NYLAS_CLIENT_SECRET"), - access_token=environ.get("NYLAS_ACCESS_TOKEN"), - api_server=API_SERVER, - api_version=SUPPORTED_API_VERSION, - ): - if not api_server.startswith("https://"): - raise Exception( - "When overriding the Nylas API server address, you" - " must include https://" - ) - self.api_server = api_server - self.api_version = api_version - self.authorize_url = api_server + "/oauth/authorize" - self.access_token_url = api_server + "/oauth/token" - self.revoke_url = api_server + "/oauth/revoke" - self.application_url = api_server + "/a/{client_id}" - self.revoke_all_url = self.application_url + "/accounts/{account_id}/revoke-all" - self.ip_addresses_url = api_server + "/a/{client_id}/ip_addresses" - self.token_info_url = self.application_url + "/accounts/{account_id}/token-info" - - self.client_secret = client_secret - self.client_id = client_id - - self.session = requests.Session() - self.version = __VERSION__ - major, minor, revision, _, __ = sys.version_info - version_header = "Nylas Python SDK {} - {}.{}.{}".format( - self.version, major, minor, revision - ) - self.session.headers = { - "X-Nylas-API-Wrapper": "python", - "X-Nylas-Client-Id": self.client_id, - "Nylas-API-Version": self.api_version, - "User-Agent": version_header, - } - self._access_token = None - self.access_token = access_token - self.auth_token = None - - # Requests to the /a/ namespace don't use an auth token but - # the client_secret. Set up a specific session for this. - self.admin_session = requests.Session() - - if client_secret is not None: - self.admin_session.headers = { - "X-Nylas-API-Wrapper": "python", - "X-Nylas-Client-Id": self.client_id, - "Nylas-API-Version": self.api_version, - "User-Agent": version_header, - } - self.admin_session.headers.update(self._add_auth_header(AuthMethod.BASIC)) - super(APIClient, self).__init__() - - @property - def access_token(self): - return self._access_token - - @access_token.setter - def access_token(self, value): - self._access_token = value - - def authentication_url( - self, - redirect_uri, - login_hint="", - state="", - scopes=("email", "calendar", "contacts"), - provider="", - redirect_on_error=None, - ): - args = { - "redirect_uri": redirect_uri, - "client_id": self.client_id or "None", # 'None' for back-compat - "response_type": "code", - "login_hint": login_hint, - "state": state, - } - - if scopes: - if isinstance(scopes, str): - scopes = [scopes] - args["scopes"] = ",".join(scopes) - if provider and provider in [ - "icloud", - "gmail", - "office365", - "exchange", - "imap", - ]: - args["provider"] = provider - if redirect_on_error is not None and isinstance(redirect_on_error, bool): - args["redirect_on_error"] = "true" if redirect_on_error is True else "false" - - url = URLObject(self.authorize_url).add_query_params(args.items()) - return str(url) - - def send_authorization(self, code): - """ - Exchanges an authorization code for an access token. - - Args: - code (str): The authorization code returned from authenticating the user - - Returns: - dict: The response from the API containing the access token - """ - args = { - "client_id": self.client_id, - "client_secret": self.client_secret, - "grant_type": "authorization_code", - "code": code, - } - - headers = { - "Content-type": "application/x-www-form-urlencoded", - "Accept": "application/json", - } - - resp = self._request( - HttpMethod.POST, - self.access_token_url, - headers=headers, - data=urlencode(args), - ) - results = _validate(resp).json() - - self.access_token = results["access_token"] - return results - - def token_for_code(self, code): - """ - Exchange an authorization code for an access token - - Args: - code (str): One-time authorization code from Nylas - - Returns: - str: The access token - """ - self.send_authorization(code) - return self.access_token - - def is_opensource_api(self): - if self.client_id is None and self.client_secret is None: - return True - - return False - - def application_details(self): - application_details_url = self.application_url.format(client_id=self.client_id) - resp = self.admin_session.get(application_details_url) - _validate(resp).json() - return resp.json() - - def update_application_details( - self, application_name=None, icon_url=None, redirect_uris=None - ): - application_details_url = self.application_url.format(client_id=self.client_id) - data = {} - if application_name is not None: - data["application_name"] = application_name - if icon_url is not None: - data["icon_url"] = icon_url - if redirect_uris is not None: - data["redirect_uris"] = redirect_uris - - headers = {"Content-Type": "application/json"} - headers.update(self.admin_session.headers) - resp = self.admin_session.put( - application_details_url, json=data, headers=headers - ) - return _validate(resp).json() - - def revoke_token(self): - resp = requests.post(self.revoke_url, auth=(self.access_token, None)) - _validate(resp) - self.auth_token = None - self.access_token = None - - def revoke_all_tokens(self, keep_access_token=None): - revoke_all_url = self.revoke_all_url.format( - client_id=self.client_id, account_id=self.account.id - ) - data = {} - if keep_access_token is not None: - data["keep_access_token"] = keep_access_token - - headers = {"Content-Type": "application/json"} - headers.update(self.admin_session.headers) - resp = self.admin_session.post(revoke_all_url, json=data, headers=headers) - _validate(resp).json() - if keep_access_token != self.access_token: - self.auth_token = None - self.access_token = None - - def ip_addresses(self): - ip_addresses_url = self.ip_addresses_url.format(client_id=self.client_id) - resp = self.admin_session.get(ip_addresses_url) - _validate(resp).json() - return resp.json() - - def token_info(self, account_id=None): - token_info_url = "" - if account_id is not None: - token_info_url = self.token_info_url.format( - client_id=self.client_id, account_id=account_id - ) - else: - token_info_url = self.token_info_url.format( - client_id=self.client_id, account_id=self.account.id - ) - headers = {"Content-Type": "application/json"} - headers.update(self.admin_session.headers) - resp = self.admin_session.post( - token_info_url, headers=headers, json={"access_token": self.access_token} - ) - _validate(resp).json() - return resp.json() - - def free_busy(self, emails, start_at, end_at, calendars=None): - if isinstance(emails, six.string_types): - emails = [emails] - if isinstance(start_at, datetime): - start_time = timestamp_from_dt(start_at) - else: - start_time = start_at - if isinstance(end_at, datetime): - end_time = timestamp_from_dt(end_at) - else: - end_time = end_at - url = "{api_server}/calendars/free-busy".format(api_server=self.api_server) - data = { - "emails": emails, - "start_time": start_time, - "end_time": end_time, - } - if calendars is not None and len(calendars) > 0: - data["calendars"] = calendars - - _validate_availability_query(data) - resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) - _validate(resp) - return resp.json() - - def open_hours(self, emails, days, timezone, start, end): - if isinstance(emails, six.string_types): - emails = [emails] - if isinstance(days, int): - days = [days] - if isinstance(start, datetime): - start = "{hour}:{minute}".format(hour=start.hour, minute=start.minute) - if isinstance(start, datetime): - end = "{hour}:{minute}".format(hour=end.hour, minute=end.minute) - return { - "emails": emails, - "days": days, - "timezone": timezone, - "start": start, - "end": end, - "object_type": "open_hours", - } - - def availability( - self, - emails, - duration, - interval, - start_at, - end_at, - event_collection_id=None, - buffer=None, - round_robin=None, - free_busy=None, - open_hours=None, - calendars=None, - ): - if isinstance(emails, six.string_types): - emails = [emails] - if isinstance(duration, timedelta): - duration_minutes = int(duration.total_seconds() // 60) - else: - duration_minutes = int(duration) - if isinstance(interval, timedelta): - interval_minutes = int(interval.total_seconds() // 60) - else: - interval_minutes = int(interval) - if isinstance(start_at, datetime): - start_time = timestamp_from_dt(start_at) - else: - start_time = start_at - if isinstance(end_at, datetime): - end_time = timestamp_from_dt(end_at) - else: - end_time = end_at - if open_hours is not None: - self._validate_open_hours(emails, open_hours, free_busy) - - url = "{api_server}/calendars/availability".format(api_server=self.api_server) - data = { - "emails": emails, - "duration_minutes": duration_minutes, - "interval_minutes": interval_minutes, - "start_time": start_time, - "end_time": end_time, - "free_busy": free_busy or [], - "open_hours": open_hours or [], - } - if buffer is not None: - data["buffer"] = buffer - if round_robin is not None: - data["round_robin"] = round_robin - if event_collection_id is not None: - data["event_collection_id"] = event_collection_id - if calendars is not None and len(calendars) > 0: - data["calendars"] = calendars - - _validate_availability_query(data) - resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) - _validate(resp) - return resp.json() - - def consecutive_availability( - self, - emails, - duration, - interval, - start_at, - end_at, - buffer=None, - free_busy=None, - open_hours=None, - calendars=None, - ): - if isinstance(emails, six.string_types): - emails = [[emails]] - elif len(emails) > 0 and isinstance(emails[0], list) is False: - raise ValueError("'emails' must be a list of lists.") - if isinstance(duration, timedelta): - duration_minutes = int(duration.total_seconds() // 60) - else: - duration_minutes = int(duration) - if isinstance(interval, timedelta): - interval_minutes = int(interval.total_seconds() // 60) - else: - interval_minutes = int(interval) - if isinstance(start_at, datetime): - start_time = timestamp_from_dt(start_at) - else: - start_time = start_at - if isinstance(end_at, datetime): - end_time = timestamp_from_dt(end_at) - else: - end_time = end_at - if open_hours is not None: - self._validate_open_hours(emails, open_hours, free_busy) - - url = "{api_server}/calendars/availability/consecutive".format( - api_server=self.api_server - ) - data = { - "emails": emails, - "duration_minutes": duration_minutes, - "interval_minutes": interval_minutes, - "start_time": start_time, - "end_time": end_time, - "free_busy": free_busy or [], - "open_hours": open_hours or [], - } - if buffer is not None: - data["buffer"] = buffer - if calendars is not None and len(calendars) > 0: - data["calendars"] = calendars - - _validate_availability_query(data) - resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) - _validate(resp) - return resp.json() - - @property - def account(self): - return self._get_resource(SingletonAccount, "") - - @property - def accounts(self): - if self.is_opensource_api(): - return RestfulModelCollection(APIAccount, self) - return RestfulModelCollection(Account, self) - - @property - def threads(self): - return RestfulModelCollection(Thread, self) - - @property - def folders(self): - return RestfulModelCollection(Folder, self) - - @property - def labels(self): - return RestfulModelCollection(Label, self) - - @property - def messages(self): - return RestfulModelCollection(Message, self) - - @property - def files(self): - return RestfulModelCollection(File, self) - - @property - def drafts(self): - return RestfulModelCollection(Draft, self) - - @property - def contacts(self): - return RestfulModelCollection(Contact, self) - - @property - def events(self): - return RestfulModelCollection(Event, self) - - @property - def room_resources(self): - return RestfulModelCollection(RoomResource, self) - - @property - def calendars(self): - return RestfulModelCollection(Calendar, self) - - @property - def job_statuses(self): - return RestfulModelCollection(JobStatus, self) - - @property - def scheduler(self): - return SchedulerRestfulModelCollection(self) - - @property - def components(self): - return RestfulModelCollection(Component, self) - - @property - def deltas(self): - return DeltaCollection(self) - - @property - def webhooks(self): - return RestfulModelCollection(Webhook, self) - - @property - def neural(self): - return Neural(self) - - @property - def outbox(self): - return Outbox(self) - - @property - def authentication(self): - return Authentication(self) - - ########################################################## - # Private functions used by Restful Model Collection # - ########################################################## - - def _get_http_session(self, api_root): - # Is this a request for a resource under the accounts/billing/admin - # namespace (/a)? If the latter, pass the client_secret - # instead of the secret_token - if api_root: - return self.admin_session - return self.session - - def _get_resources(self, cls, extra=None, **filters): - # FIXME @karim: remove this interim code when we've got rid - # of the old accounts API. - postfix = "/{}".format(extra) if extra else "" - path = "/{}".format(cls.collection_name) if cls.collection_name else "" - if not cls.api_root: - url = "{server}{path}{postfix}".format( - server=self.api_server, path=path, postfix=postfix - ) - else: - url = "{server}/{prefix}/{client_id}{path}{postfix}".format( - server=self.api_server, - prefix=cls.api_root, - client_id=self.client_id, - path=path, - postfix=postfix, - ) - - converted_data = create_request_body(filters, cls.datetime_filter_attrs) - url = str(URLObject(url).add_query_params(converted_data.items())) - response = self._request(HttpMethod.GET, url, cls=cls) - results = _validate(response).json() - return [cls.create(self, **x) for x in results if x is not None] - - def _get_resource_raw( - self, - cls, - resource_id, - extra=None, - headers=None, - stream=False, - path=None, - stream_timeout=None, - **filters - ): - """Get an individual REST resource""" - if path is None: - path = cls.collection_name - postfix = "/{}".format(extra) if extra else "" - path = "/{}".format(path) if path else "" - resource_id = "/{}".format(resource_id) if resource_id else "" - if not cls.api_root: - url = "{server}{path}{id}{postfix}".format( - server=self.api_server, path=path, id=resource_id, postfix=postfix - ) - else: - url = "{server}/{prefix}/{client_id}{path}{id}{postfix}".format( - server=self.api_server, - prefix=cls.api_root, - client_id=self.client_id, - path=path, - id=resource_id, - postfix=postfix, - ) - - converted_data = create_request_body(filters, cls.datetime_filter_attrs) - url = str(URLObject(url).add_query_params(converted_data.items())) - - response = self._request( - HttpMethod.GET, - url, - cls=cls, - headers=headers, - stream=stream, - timeout=stream_timeout, - ) - return _validate(response) - - def _get_resource(self, cls, resource_id, **filters): - response = self._get_resource_raw(cls, resource_id, **filters) - result = response.json() - if isinstance(result, list): - result = result[0] - return cls.create(self, **result) - - def _get_resource_data(self, cls, resource_id, extra=None, headers=None, **filters): - response = self._get_resource_raw( - cls, resource_id, extra=extra, headers=headers, **filters - ) - return response.content - - def _create_resource(self, cls, data, **kwargs): - name = "{prefix}{path}".format( - prefix="/{}/{}".format(cls.api_root, self.client_id) - if cls.api_root - else "", - path="/{}".format(cls.collection_name) if cls.collection_name else "", - ) - url = ( - URLObject(self.api_server) - .with_path("{name}".format(name=name)) - .set_query_params(**kwargs) - ) - - if cls == File: - response = self._request(HttpMethod.POST, url, cls=cls, files=data) - elif cls == Send and type(data) is not dict: - headers = {"Content-Type": "message/rfc822"} - response = self._request( - HttpMethod.POST, url, cls=cls, headers=headers, data=data - ) - else: - converted_data = create_request_body(data, cls.datetime_attrs) - headers = {"Content-Type": "application/json"} - response = self._request( - HttpMethod.POST, url, cls=cls, headers=headers, json=converted_data - ) - - result = _validate(response).json() - if cls.collection_name == "send": - return result - return cls.create(self, **result) - - def _create_resources(self, cls, data): - name = "{prefix}{path}".format( - prefix="/{}/{}".format(cls.api_root, self.client_id) - if cls.api_root - else "", - path="/{}".format(cls.collection_name) if cls.collection_name else "", - ) - url = URLObject(self.api_server).with_path("{name}".format(name=name)) - - if cls == File: - response = self._request(HttpMethod.POST, url, cls=cls, files=data) - else: - converted_data = [ - create_request_body(datum, cls.datetime_attrs) for datum in data - ] - headers = {"Content-Type": "application/json"} - response = self._request( - HttpMethod.POST, url, cls=cls, headers=headers, json=converted_data - ) - - results = _validate(response).json() - return [cls.create(self, **x) for x in results] - - def _delete_resource(self, cls, resource_id, data=None, **kwargs): - name = "{prefix}{path}".format( - prefix="/{}/{}".format(cls.api_root, self.client_id) - if cls.api_root - else "", - path="/{}".format(cls.collection_name) if cls.collection_name else "", - ) - url = ( - URLObject(self.api_server) - .with_path("{name}/{id}".format(name=name, id=resource_id)) - .set_query_params(**kwargs) - ) - if data: - _validate(self._request(HttpMethod.DELETE, url, cls=cls, json=data)) - else: - _validate(self._request(HttpMethod.DELETE, url, cls=cls)) - - def _request_update_resource( - self, method, cls, resource_id, data, extra=None, path=None, **kwargs - ): - if path is None: - path = cls.collection_name - name = "{prefix}{path}".format( - prefix="/{}/{}".format(cls.api_root, self.client_id) - if cls.api_root - else "", - path="/{}".format(path) if path else "", - ) - - postfix = "/{}".format(extra) if extra else "" - url = ( - URLObject(self.api_server) - .with_path( - "{name}/{id}{postfix}".format( - name=name, id=resource_id, postfix=postfix - ) - ) - .set_query_params(**kwargs) - ) - converted_data = create_request_body(data, cls.datetime_attrs) - - response = self._request(method, url, cls=cls, json=converted_data) - - result = _validate(response) - return result.json() - - def _patch_resource(self, cls, resource_id, data, extra=None, path=None, **kwargs): - return self._request_update_resource( - HttpMethod.PATCH, cls, resource_id, data, extra=extra, path=path, **kwargs - ) - - def _put_resource(self, cls, resource_id, data, extra=None, path=None, **kwargs): - return self._request_update_resource( - HttpMethod.PUT, cls, resource_id, data, extra=extra, path=path, **kwargs - ) - - def _update_resource(self, cls, resource_id, data, **kwargs): - result = self._put_resource(cls, resource_id, data, **kwargs) - return cls.create(self, **result) - - def _post_resource(self, cls, resource_id, method_name, data, path=None): - if path is None: - path = cls.collection_name - path = "/{}".format(path) if path else "" - resource_id = "/{}".format(resource_id) if resource_id else "" - method = "/{}".format(method_name) if method_name else "" - if not cls.api_root: - url_path = "{name}{id}{method}".format( - name=path, id=resource_id, method=method - ) - else: - # Management method. - url_path = "/{prefix}/{client_id}{path}{id}{method}".format( - prefix=cls.api_root, - client_id=self.client_id, - path=path, - id=resource_id, - method=method, - ) - - url = URLObject(self.api_server).with_path(url_path) - converted_data = create_request_body(data, cls.datetime_attrs) - - response = self._request(HttpMethod.POST, url, cls=cls, json=converted_data) - return _validate(response).json() - - def _call_resource_method(self, cls, resource_id, method_name, data): - """POST a dictionary to an API method, - for example /a/.../accounts/id/upgrade""" - - result = self._post_resource(cls, resource_id, method_name, data) - return cls.create(self, **result) - - def _request_neural_resource(self, cls, data, path=None, method=None): - if path is None: - path = cls.collection_name - if method is None: - method = HttpMethod.PUT - url = URLObject(self.api_server).with_path("/neural/{name}".format(name=path)) - - converted_data = create_request_body(data, cls.datetime_attrs) - response = self._request(method, url, cls=cls, json=converted_data) - - result = _validate(response).json() - if isinstance(result, list): - object_list = [] - for obj in result: - object_list.append(cls.create(self, **obj)) - return object_list - - return cls.create(self, **result) - - def _validate_open_hours(self, emails, open_hours, free_busy): - if isinstance(open_hours, list) is False: - raise ValueError("'open_hours' must be an array.") - open_hours_emails = list( - chain.from_iterable([oh["emails"] for oh in open_hours]) - ) - free_busy_emails = ( - [fb["email"] for fb in free_busy] if free_busy is not None else [] - ) - if isinstance(emails[0], list) is True: - emails = list(chain.from_iterable(emails)) - for email in open_hours_emails: - if (email in emails) is False and (email in free_busy_emails) is False: - raise ValueError( - "Open Hours cannot contain an email not present in the main email list or the free busy email list." - ) - - def _request(self, method, url, cls=None, headers=None, **kwargs): - api_root = None - auth_method = None - if cls: - api_root = cls.api_root - auth_method = cls.auth_method - - session = self._get_http_session(api_root) - headers = headers or {} - headers.update(session.headers) - headers.update(self._add_auth_header(auth_method)) - return session.request(method.name, url, headers=headers, **kwargs) - - def _add_auth_header(self, auth_method): - authorization = None - if auth_method == AuthMethod.BEARER: - authorization = ( - "Bearer {token}".format(token=self.access_token) - if self.access_token - else None - ) - elif auth_method == AuthMethod.BASIC_CLIENT_ID_AND_SECRET: - if self.client_id and self.client_secret: - credential = "{client_id}:{client_secret}".format( - client_id=self.client_id, client_secret=self.client_secret - ) - authorization = "Basic {credential}".format( - credential=b64encode(credential.encode("utf8")).decode("utf8") - ) - else: - if self.client_secret: - b64_client_secret = b64encode( - ("{}:".format(self.client_secret)).encode("utf8") - ) - authorization = "Basic {secret}".format( - secret=b64_client_secret.decode("utf8") - ) - - return {"Authorization": authorization} if authorization else {} diff --git a/nylas/client/delta_collection.py b/nylas/client/delta_collection.py deleted file mode 100644 index f01c29ad..00000000 --- a/nylas/client/delta_collection.py +++ /dev/null @@ -1,195 +0,0 @@ -import json - -from requests import ReadTimeout - -from nylas.client.delta_models import Delta, Deltas - - -class DeltaCollection: - path = "delta" - - def __init__(self, api): - self.api = api - - def latest_cursor(self): - """ - Returns the latest delta cursor - - Returns: - str: The latest cursor - - Raises: - RuntimeError: If the server returns an object without a cursor - """ - - response = self.api._post_resource( - Delta, "latest_cursor", None, None, path=self.path - ) - if "cursor" not in response: - raise RuntimeError( - "Unexpected response from the API server. Returned 200 but no 'cursor' string found." - ) - - return response["cursor"] - - def since(self, cursor, view=None, include_types=None, excluded_types=None): - """ - Get a list of delta cursors since a specified cursor - - Args: - cursor (str): The first cursor to request from - view (str): Value representing if delta expands thread and message objects. - include_types (list[str] | str): The objects to exclusively include in the returned deltas. Note you cannot set both included and excluded types. - excluded_types (list[str] | str): The objects to exclude in the returned deltas. Note you cannot set both included and excluded types. - - Returns: - Deltas: The API response containing the list of deltas - - Raises: - ValueError: If both include_types and excluded_types are set - """ - - include_types, excluded_types = _validate_types(include_types, excluded_types) - response = self.api._get_resource_raw( - Delta, - None, - path=self.path, - cursor=cursor, - view=view, - include_types=include_types, - excluded_types=excluded_types, - ).json() - return Deltas.create(self.api, **response) - - def stream( - self, - cursor, - callback=None, - timeout=None, - view=None, - include_types=None, - excluded_types=None, - ): - """ - Stream deltas - - Args: - cursor (str): The cursor to stream from - callback: A callable function to invoke on each delta received. No callback is set by default. - timeout (int): The number of seconds to stream for before timing out. No timeout is set by default. - view (str): Value representing if delta expands thread and message objects. - include_types (list[str] | str): The objects to exclusively include in the returned deltas. Note you cannot set both included and excluded types. - excluded_types (list[str] | str): The objects to exclude in the returned deltas. Note you cannot set both included and excluded types. - - Returns: - list[Delta]: The list of streamed deltas - - Raises: - ValueError: If both include_types and excluded_types are set - """ - - deltas = [] - include_types, excluded_types = _validate_types(include_types, excluded_types) - emit_deltas = False - if callback and callable(callback): - emit_deltas = True - - try: - response = self.api._get_resource_raw( - Delta, - "streaming", - stream=True, - path=self.path, - stream_timeout=timeout, - cursor=cursor, - view=view, - include_types=include_types, - excluded_types=excluded_types, - ) - for raw_rsp in response.iter_lines(): - if raw_rsp: - response_json = json.loads(raw_rsp) - delta = Delta.create(self.api, **response_json) - deltas.append(delta) - if emit_deltas: - callback(delta) - except ReadTimeout: - pass - - return deltas - - def longpoll( - self, - cursor, - timeout, - callback=None, - view=None, - include_types=None, - excluded_types=None, - ): - """ - Long-poll for deltas - - Args: - cursor (str): The cursor to poll from - timeout (int): The number of seconds to poll for before timing out - callback: A callable function to invoke on each delta received. No callback is set by default. - view (str): Value representing if delta expands thread and message objects. - include_types (list[str] | str): The objects to exclusively include in the returned deltas. Note you cannot set both included and excluded types. - excluded_types (list[str] | str): The objects to exclude in the returned deltas. Note you cannot set both included and excluded types. - - Returns: - Deltas: The API response containing the list of deltas - - Raises: - ValueError: If both include_types and excluded_types are set - """ - - delta = {} - include_types, excluded_types = _validate_types(include_types, excluded_types) - emit_deltas = False - if callback and callable(callback): - emit_deltas = True - - buffer = bytearray() - response = self.api._get_resource_raw( - Delta, - "longpoll", - stream=True, - path=self.path, - timeout=timeout, - cursor=cursor, - view=view, - include_types=include_types, - excluded_types=excluded_types, - ) - for raw_rsp in response.iter_lines(): - if raw_rsp: - buffer.extend(raw_rsp) - try: - buffer_json = json.loads(buffer.decode()) - delta = Deltas.create(self.api, **buffer_json) - if emit_deltas: - callback(delta) - except ValueError: - continue - - return delta - - -# Helper functions for validating type inputs -def _validate_types(include_types, excluded_types): - if include_types and excluded_types: - raise ValueError("You cannot set both include_types and excluded_types") - - return _join_types(include_types), _join_types(excluded_types) - - -def _join_types(types): - if types: - if isinstance(types, str): - return types - try: - return ",".join(types) - except TypeError: - return None diff --git a/nylas/client/delta_models.py b/nylas/client/delta_models.py deleted file mode 100644 index c5b104a8..00000000 --- a/nylas/client/delta_models.py +++ /dev/null @@ -1,73 +0,0 @@ -from nylas.client.restful_models import ( - RestfulModel, - NylasAPIObject, - Contact, - File, - Message, - Draft, - Thread, - Event, - Folder, - Label, -) - - -class Deltas(RestfulModel): - attrs = ( - "cursor_start", - "cursor_end", - "_deltas", - ) - read_only_attrs = tuple(attrs) - - def __init__(self, api): - RestfulModel.__init__(self, Deltas, api) - - @property - def deltas(self): - """ - Instantiate a Delta object from the API response - - Returns: - list[Delta]: List of Delta instantiated objects - """ - if self._deltas: - deltas = [] - for delta in self._deltas: - deltas.append(Delta.create(self.api, **delta)) - return deltas - - -class Delta(RestfulModel): - attrs = ( - "id", - "cursor", - "event", - "object", - "_attributes", - ) - read_only_attrs = tuple(attrs) - class_mapping = { - "contact": Contact, - "file": File, - "message": Message, - "draft": Draft, - "thread": Thread, - "event": Event, - "folder": Folder, - "label": Label, - } - - def __init__(self, api): - RestfulModel.__init__(self, Delta, api) - - @property - def attributes(self): - """ - Instantiate the object provided in the Delta - - Returns: - NylasAPIObject: The object of NylasAPIObject type represented in the Delta - """ - if self._attributes and self.object and self.object in self.class_mapping: - return self.class_mapping[self.object].create(self.api, **self._attributes) diff --git a/nylas/client/errors.py b/nylas/client/errors.py deleted file mode 100644 index b8d7ef24..00000000 --- a/nylas/client/errors.py +++ /dev/null @@ -1,62 +0,0 @@ -import json - -from requests import HTTPError - - -class NylasError(Exception): - pass - - -class MessageRejectedError(NylasError): - pass - - -class FileUploadError(NylasError): - pass - - -class UnSyncedError(NylasError): - """ - HTTP Code 202 - The request was valid but the resource wasn't ready. Retry the request with exponential backoff. - """ - - pass - - -class NylasApiError(HTTPError): - """ - Error class for Nylas API Errors - This class provides more information to the user sent from the server, if present - """ - - def __init__(self, response): - try: - response_json = json.loads(response.text) - error_message = "%s %s. Reason: %s. Nylas Error Type: %s" % ( - response.status_code, - response.reason, - response_json["message"], - response_json["type"], - ) - super(NylasApiError, self).__init__(error_message, response=response) - except (ValueError, KeyError): - super(NylasApiError, self).__init__(response.text, response=response) - - -class RateLimitError(NylasApiError): - """ - Error class for 429 rate limit errors - This class provides details about the rate limit returned from the server - """ - - RATE_LIMIT_LIMIT_HEADER = "X-RateLimit-Limit" - RATE_LIMIT_RESET_HEADER = "X-RateLimit-Reset" - - def __init__(self, response): - try: - self.rate_limit = int(response.headers[self.RATE_LIMIT_LIMIT_HEADER]) - self.rate_limit_reset = int(response.headers[self.RATE_LIMIT_RESET_HEADER]) - super(RateLimitError, self).__init__(response) - except (ValueError, KeyError): - super(RateLimitError, self).__init__(response) diff --git a/nylas/client/neural_api_models.py b/nylas/client/neural_api_models.py deleted file mode 100644 index e4aa8ce2..00000000 --- a/nylas/client/neural_api_models.py +++ /dev/null @@ -1,184 +0,0 @@ -from nylas.client.restful_models import RestfulModel, Message, File, Contact -from nylas.utils import HttpMethod -import re - - -def _add_options_to_body(body, options): - options_dict = options.__dict__ - # Only append set options to body to prevent a 400 error - options_filtered = {k: v for k, v in options_dict.items() if v is not None} - return body.update(options_filtered) - - -class Neural(RestfulModel): - def __init__(self, api): - RestfulModel.__init__(self, Neural, api) - - def sentiment_analysis_message(self, message_ids): - body = {"message_id": message_ids} - return self.api._request_neural_resource(NeuralSentimentAnalysis, body) - - def sentiment_analysis_text(self, text): - body = {"text": text} - return self.api._request_neural_resource(NeuralSentimentAnalysis, body) - - def extract_signature(self, message_ids, parse_contacts=None, options=None): - body = {"message_id": message_ids} - if parse_contacts is not None and isinstance(parse_contacts, bool): - body["parse_contacts"] = parse_contacts - if options is not None and isinstance(options, NeuralMessageOptions): - _add_options_to_body(body, options) - signatures = self.api._request_neural_resource(NeuralSignatureExtraction, body) - if parse_contacts is not False: - for sig in signatures: - sig.contacts = NeuralSignatureContact.create(self.api, **sig.contacts) - return signatures - - def ocr_request(self, file_id, pages=None): - body = {"file_id": file_id} - if pages is not None and isinstance(pages, list): - body["pages"] = pages - return self.api._request_neural_resource(NeuralOcr, body) - - def categorize(self, message_ids): - body = {"message_id": message_ids} - categorized = self.api._request_neural_resource(NeuralCategorizer, body) - for message in categorized: - message.categorizer = Categorize.create(self.api, **message.categorizer) - return categorized - - def clean_conversation(self, message_ids, options=None): - body = {"message_id": message_ids} - if options is not None and isinstance(options, NeuralMessageOptions): - _add_options_to_body(body, options) - return self.api._request_neural_resource(NeuralCleanConversation, body) - - -class NeuralMessageOptions: - def __init__( - self, - ignore_links=None, - ignore_images=None, - ignore_tables=None, - remove_conclusion_phrases=None, - images_as_markdowns=None, - ): - self.ignore_links = ignore_links - self.ignore_images = ignore_images - self.ignore_tables = ignore_tables - self.remove_conclusion_phrases = remove_conclusion_phrases - self.images_as_markdowns = images_as_markdowns - - -class NeuralSentimentAnalysis(RestfulModel): - attrs = [ - "account_id", - "sentiment", - "sentiment_score", - "processed_length", - "text", - ] - collection_name = "sentiment" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralSentimentAnalysis, api) - - -class NeuralSignatureExtraction(Message): - attrs = Message.attrs + ["signature", "model_version", "contacts"] - collection_name = "signature" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralSignatureExtraction, api) - - -class NeuralSignatureContact(RestfulModel): - attrs = ["job_titles", "links", "phone_numbers", "emails", "names"] - collection_name = "signature_contact" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralSignatureContact, api) - - def to_contact_object(self): - contact = {} - if self.names is not None: - contact["given_name"] = self.names[0]["first_name"] - contact["surname"] = self.names[0]["last_name"] - if self.job_titles is not None: - contact["job_title"] = self.job_titles[0] - if self.emails is not None: - contact["emails"] = [] - for email in self.emails: - contact["emails"].append({"type": "personal", "email": email}) - if self.phone_numbers is not None: - contact["phone_numbers"] = [] - for number in self.phone_numbers: - contact["phone_numbers"].append({"type": "mobile", "number": number}) - if self.links is not None: - contact["web_pages"] = [] - for url in self.links: - description = url["description"] if url["description"] else "homepage" - contact["web_pages"].append({"type": description, "url": url["url"]}) - - return Contact.create(self.api, **contact) - - -class NeuralCategorizer(Message): - attrs = Message.attrs + ["categorizer"] - collection_name = "categorize" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralCategorizer, api) - - def recategorize(self, category): - data = {"message_id": self.id, "category": category} - self.api._request_neural_resource( - NeuralCategorizer, data, "categorize/feedback", method=HttpMethod.POST - ) - data = {"message_id": self.id} - response = self.api._request_neural_resource(NeuralCategorizer, data) - categorize = response[0] - if categorize.categorizer: - categorize.categorizer = Categorize.create( - self.api, **categorize.categorizer - ) - return categorize - - -class Categorize(RestfulModel): - attrs = ["category", "categorized_at", "model_version", "subcategories"] - datetime_attrs = {"categorized_at": "categorized_at"} - collection_name = "category" - - def __init__(self, api): - RestfulModel.__init__(self, Categorize, api) - - -class NeuralCleanConversation(Message): - attrs = Message.attrs + [ - "conversation", - "model_version", - ] - collection_name = "conversation" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralCleanConversation, api) - - def extract_images(self): - pattern = r"[\(']cid:(.*?)[\)']" - file_ids = re.findall(pattern, self.conversation) - files = [] - for match in file_ids: - files.append(self.api.files.get(match)) - return files - - -class NeuralOcr(File): - attrs = File.attrs + [ - "ocr", - "processed_pages", - ] - collection_name = "ocr" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralOcr, api) diff --git a/nylas/client/outbox_models.py b/nylas/client/outbox_models.py deleted file mode 100644 index f5731ca3..00000000 --- a/nylas/client/outbox_models.py +++ /dev/null @@ -1,178 +0,0 @@ -from datetime import datetime - -from nylas.client.restful_models import RestfulModel, Draft -from nylas.utils import timestamp_from_dt - - -class OutboxMessage(RestfulModel): - attrs = Draft.attrs + [ - "send_at", - "retry_limit_datetime", - "original_send_at", - ] - datetime_attrs = { - "send_at": "send_at", - "retry_limit_datetime": "retry_limit_datetime", - "original_send_at": "original_send_at", - } - read_only_attrs = {"send_at", "retry_limit_datetime", "original_send_at"} - collection_name = "v2/outbox" - - def __init__(self, api): - RestfulModel.__init__(self, OutboxMessage, api) - - -class OutboxJobStatus(RestfulModel): - attrs = [ - "account_id", - "job_status_id", - "status", - "original_data", - ] - collection_name = "v2/outbox" - - def __init__(self, api): - RestfulModel.__init__(self, OutboxJobStatus, api) - - -class SendGridVerifiedStatus(RestfulModel): - attrs = [ - "domain_verified", - "sender_verified", - ] - collection_name = "v2/outbox/onboard" - - def __init__(self, api): - RestfulModel.__init__(self, SendGridVerifiedStatus, api) - - -class Outbox: - def __init__(self, api): - self.api = api - - def send(self, draft, send_at, retry_limit_datetime=None): - """ - Send a message via Outbox - - Args: - draft (Draft | OutboxMessage): The message to send - send_at (datetime | int): The date and time to send the message. If set to 0, Outbox will send this message immediately. - retry_limit_datetime (datetime | int): Optional date and time to stop retry attempts for a message. - - Returns: - OutboxJobStatus: The Outbox message job status - - Raises: - ValueError: If the date and times provided are not valid - """ - draft_json = draft.as_json() - send_at, retry_limit_datetime = self._validate_and_format_datetime( - send_at, retry_limit_datetime - ) - - draft_json["send_at"] = send_at - if retry_limit_datetime is not None: - draft_json["retry_limit_datetime"] = retry_limit_datetime - - return self.api._create_resource(OutboxJobStatus, draft_json) - - def update( - self, job_status_id, draft=None, send_at=None, retry_limit_datetime=None - ): - """ - Update a scheduled Outbox message - - Args: - job_status_id (str): The ID of the outbox job status - draft (Draft | OutboxMessage): The message object with updated values - send_at (datetime | int): The date and time to send the message. If set to 0, Outbox will send this message immediately. - retry_limit_datetime (datetime | int): Optional date and time to stop retry attempts for a message. - - Returns: - OutboxJobStatus: The updated Outbox message job status - - Raises: - ValueError: If the date and times provided are not valid - """ - payload = {} - if draft: - payload = draft.as_json() - send_at, retry_limit_datetime = self._validate_and_format_datetime( - send_at, retry_limit_datetime - ) - - if send_at is not None: - payload["send_at"] = send_at - if retry_limit_datetime is not None: - payload["retry_limit_datetime"] = retry_limit_datetime - - response = self.api._patch_resource(OutboxJobStatus, job_status_id, payload) - return OutboxJobStatus.create(self.api, **response) - - def delete(self, job_status_id): - """ - Delete a scheduled Outbox message - - Args: - job_status_id (str): The ID of the outbox job status to delete - """ - - self.api._delete_resource(OutboxJobStatus, job_status_id) - - def send_grid_verification_status(self): - """ - SendGrid - Check Authentication and Verification Status - - Returns: - SendGridVerifiedStatus: The status of the domain authentication and the single sender verification for SendGrid integrations - - Raises: - RuntimeError: If the server returns an object without results - """ - response = self.api._get_resource_raw( - SendGridVerifiedStatus, None, extra="verified_status" - ) - response_body = response.json() - if "results" not in response_body: - raise RuntimeError( - "Unexpected response from the API server. Returned 200 but no 'ics' string found." - ) - return SendGridVerifiedStatus.create(self.api, **response_body["results"]) - - def delete_send_grid_sub_user(self, email_address): - """ - SendGrid - Delete SendGrid Subuser and UAS Grant - - Args: - email_address (str): Email address for SendGrid subuser to delete - """ - payload = {"email": email_address} - - self.api._delete_resource(SendGridVerifiedStatus, "subuser", data=payload) - - def _validate_and_format_datetime(self, send_at, retry_limit_datetime): - send_at_epoch = ( - timestamp_from_dt(send_at) if isinstance(send_at, datetime) else send_at - ) - retry_limit_datetime_epoch = ( - timestamp_from_dt(retry_limit_datetime) - if isinstance(retry_limit_datetime, datetime) - else retry_limit_datetime - ) - now_epoch = timestamp_from_dt(datetime.today()) - - if send_at_epoch and send_at_epoch != 0 and send_at_epoch < now_epoch: - raise ValueError( - "Cannot set message to be sent at a time before the current time." - ) - - if retry_limit_datetime_epoch and retry_limit_datetime_epoch != 0: - current_send_at = ( - send_at_epoch if send_at_epoch and send_at_epoch != 0 else now_epoch - ) - if retry_limit_datetime_epoch < current_send_at: - raise ValueError( - "Cannot set message to stop retrying before time to send at." - ) - - return send_at_epoch, retry_limit_datetime_epoch diff --git a/nylas/client/restful_model_collection.py b/nylas/client/restful_model_collection.py deleted file mode 100644 index 408e4e09..00000000 --- a/nylas/client/restful_model_collection.py +++ /dev/null @@ -1,175 +0,0 @@ -from copy import copy -from nylas.utils import convert_metadata_pairs_to_array - -CHUNK_SIZE = 50 - - -class RestfulModelCollection(object): - def __init__(self, cls, api, filter=None, offset=0, **filters): - if filter: - filters.update(filter) - from nylas.client import APIClient - - if not isinstance(api, APIClient): - raise Exception("Provided api was not an APIClient.") - - filters.setdefault("offset", offset) - - self.model_class = cls - self.filters = filters - self.api = api - - def __iter__(self): - return self.values() - - def values(self): - limit = self.filters.get("limit") - offset = self.filters["offset"] - fetched = 0 - # Currently, the Nylas API handles pagination poorly: API responses do not expose - # any information about pagination, so the client does not know whether there is - # another page of data or not. For example, if the client sends an API request - # without a limit specified, and the response contains 100 items, how can it tell - # if there are 100 items in total, or if there more items to fetch on the next page? - # It can't! The only way to know is to ask for the next page (by repeating the API - # request with `offset=100`), and see if you get more items or not. - # If it does not receive more items, it can assume that it has retrieved all the data. - while True: - if limit: - if fetched >= limit: - break - - req_limit = min(CHUNK_SIZE, limit - fetched) - else: - req_limit = CHUNK_SIZE - - models = self._get_model_collection(offset + fetched, req_limit) - if not models: - break - - for model in models: - yield model - - fetched += len(models) - - def first(self): - results = self._get_model_collection(0, 1) - if results: - return results[0] - return None - - def all(self, limit=float("infinity")): - if "limit" in self.filters and self.filters["limit"] is not None: - limit = self.filters["limit"] - return self._range(self.filters["offset"], limit) - - def count(self): - """ - Get the number of objects in the collection being queried - - Returns: - int: The number of objects in the collection being queried - """ - filters = self.filters.copy() - filters["view"] = "count" - response = self.api._get_resource_raw( - self.model_class, resource_id=None, **self.filters - ).json() - return response["count"] - - def where(self, filter=None, **filters): - # Some API parameters like "from" and "in" also are - # Python reserved keywords. To work around this, we rename - # them to "from_" and "in_". The API still needs them in - # their correct form though. - reserved_keywords = ["from", "in"] - for keyword in reserved_keywords: - escaped_keyword = "{}_".format(keyword) - if escaped_keyword in filters: - filters[keyword] = filters.get(escaped_keyword) - del filters[escaped_keyword] - - if filter: - filters.update(filter) - filters.setdefault("offset", 0) - - if "metadata_pair" in filters: - pairs = convert_metadata_pairs_to_array(filters["metadata_pair"]) - filters["metadata_pair"] = pairs - - collection = copy(self) - collection.filters = filters - return collection - - def get(self, id): - return self._get_model(id) - - def create(self, **kwargs): - return self.model_class.create(self.api, **kwargs) - - def delete(self, id, data=None, **kwargs): - return self.api._delete_resource(self.model_class, id, data=data, **kwargs) - - def search( - self, q, limit=None, offset=None, view=None - ): # pylint: disable=invalid-name - from nylas.client.restful_models import ( - Message, - Thread, - ) # pylint: disable=cyclic-import - - if self.model_class is Thread or self.model_class is Message: - kwargs = {"q": q} - if limit is not None: - kwargs["limit"] = limit - if offset is not None: - kwargs["offset"] = offset - if view is not None and self.model_class is Thread: - kwargs["view"] = view - return self.api._get_resources(self.model_class, extra="search", **kwargs) - else: - raise Exception("Searching is only allowed on Thread and Message models") - - def __getitem__(self, key): - if isinstance(key, slice): - if key.step is not None: - raise ValueError( - "'step' not supported for slicing " - "RestfulModelCollection objects " - "(e.g. messages[::step])" - ) - elif key.start < 0 or key.stop < 0: - raise ValueError("slice indices must be positive") - elif key.stop - key.start < 0: - raise ValueError( - "ending slice index cannot be less than " "starting index" - ) - return self._range(key.start, key.stop - key.start) - else: - return self._get_model_collection(key, 1)[0] - - # Private functions - - def _get_model_collection(self, offset=0, limit=CHUNK_SIZE): - filters = copy(self.filters) - filters["offset"] = offset - if not filters.get("limit"): - filters["limit"] = limit - - return self.api._get_resources(self.model_class, **filters) - - def _get_model(self, id): - return self.api._get_resource(self.model_class, id, **self.filters) - - def _range(self, offset=0, limit=CHUNK_SIZE): - accumulated = [] - while len(accumulated) < limit: - to_fetch = min(limit - len(accumulated), CHUNK_SIZE) - results = self._get_model_collection(offset + len(accumulated), to_fetch) - accumulated.extend(results) - - # done if we run out of data to fetch - if not results or len(results) < to_fetch: - break - - return accumulated diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py deleted file mode 100644 index b6539e78..00000000 --- a/nylas/client/restful_models.py +++ /dev/null @@ -1,1122 +0,0 @@ -import hashlib -import hmac -from datetime import datetime -from collections import defaultdict -from enum import Enum - -from six import StringIO -from nylas.client.restful_model_collection import RestfulModelCollection -from nylas.client.errors import FileUploadError, UnSyncedError, NylasApiError -from nylas.utils import timestamp_from_dt, AuthMethod - -# pylint: disable=attribute-defined-outside-init - - -def typed_dict_attr(items, attr_name=None): - if attr_name: - pairs = [(item["type"], item[attr_name]) for item in items] - else: - pairs = [(item["type"], item) for item in items] - dct = defaultdict(list) - for key, value in pairs: - dct[key].append(value) - return dct - - -def _is_subclass(cls, parent): - for base in cls.__bases__: - if base.__name__.lower() == parent: - return True - return False - - -class RestfulModel(dict): - attrs = [] - date_attrs = {} - datetime_attrs = {} - datetime_filter_attrs = {} - typed_dict_attrs = {} - read_only_attrs = {} - auth_method = AuthMethod.BEARER - # The Nylas API holds most objects for an account directly under '/', - # but some of them are under '/a' (mostly the account-management - # and billing code). api_root is a tiny metaprogramming hack to let - # us use the same code for both. - api_root = None - - def __init__(self, cls, api): - self.id = None - self.cls = cls - self.api = api - super(RestfulModel, self).__init__() - - __setattr__ = dict.__setitem__ - __delattr__ = dict.__delitem__ - __getattr__ = dict.get - - @classmethod - def create(cls, api, **kwargs): - object_type = kwargs.get("object") - cls_object_type = getattr(cls, "object_type", cls.__name__.lower()) - # These are classes that should bypass the check below because they - # often represent other types (e.g. a delta's object type might be event) - class_check_whitelist = ["jobstatus", "delta"] - if ( - object_type - and object_type != cls_object_type - and object_type != "account" - and cls_object_type not in class_check_whitelist - and not _is_subclass(cls, object_type) - ): - # We were given a specific object type and we're trying to - # instantiate something different; abort. (Relevant for folders - # and labels API.) - # We need a special case for accounts because the /accounts API - # is different between the open source and hosted API. - # And a special case for job status because the object refers to - # the type of objects' job status - return - obj = cls(api) # pylint: disable=no-value-for-parameter - obj.cls = cls - for attr in cls.attrs: - # Support attributes we want to override with properties where - # the property names overlap with the JSON names (e.g. folders) - attr_name = attr - if attr_name.startswith("_"): - attr = attr_name[1:] - if attr in kwargs: - obj[attr_name] = kwargs[attr] - if attr_name == "from": - obj["from_"] = kwargs[attr] - for date_attr, iso_attr in cls.date_attrs.items(): - if kwargs.get(iso_attr): - obj[date_attr] = datetime.strptime(kwargs[iso_attr], "%Y-%m-%d").date() - for dt_attr, ts_attr in cls.datetime_attrs.items(): - if kwargs.get(ts_attr): - try: - obj[dt_attr] = datetime.utcfromtimestamp(kwargs[ts_attr]) - except TypeError: - # If the datetime format is in the format of ISO8601 - obj[dt_attr] = datetime.strptime( - kwargs[ts_attr], "%Y-%m-%dT%H:%M:%S.%fZ" - ) - for attr, value_attr_name in cls.typed_dict_attrs.items(): - obj[attr] = typed_dict_attr(kwargs.get(attr, []), attr_name=value_attr_name) - - if "id" not in kwargs: - obj["id"] = None - - return obj - - def as_json(self, enforce_read_only=True): - dct = {} - # Some API parameters like "from" and "in" also are - # Python reserved keywords. To work around this, we rename - # them to "from_" and "in_". The API still needs them in - # their correct form though. - reserved_keywords = ["from", "in"] - for attr in self.cls.attrs: - if attr in self.read_only_attrs and enforce_read_only is True: - continue - if hasattr(self, attr): - if attr in reserved_keywords: - attr_value = getattr(self, "{}_".format(attr)) - else: - attr_value = getattr(self, attr) - if attr_value is not None: - if attr.startswith("_"): - attr = attr[1:] - dct[attr] = attr_value - for date_attr, iso_attr in self.cls.date_attrs.items(): - if date_attr in self.read_only_attrs and enforce_read_only is True: - continue - if self.get(date_attr): - dct[iso_attr] = self[date_attr].strftime("%Y-%m-%d") - for dt_attr, ts_attr in self.cls.datetime_attrs.items(): - if dt_attr in self.read_only_attrs and enforce_read_only is True: - continue - if self.get(dt_attr): - dct[ts_attr] = timestamp_from_dt(self[dt_attr]) - for attr, value_attr in self.cls.typed_dict_attrs.items(): - if attr in self.read_only_attrs and enforce_read_only is True: - continue - typed_dict = getattr(self, attr) - if value_attr: - dct[attr] = [] - for key, values in typed_dict.items(): - for value in values: - dct[attr].append({"type": key, value_attr: value}) - else: - dct[attr] = [] - for values in typed_dict.values(): - for value in values: - dct[attr].append(value) - return dct - - -class NylasAPIObject(RestfulModel): - read_only_attrs = {"id", "account_id", "object", "job_status_id"} - - def __init__(self, cls, api): - RestfulModel.__init__(self, cls, api) - - def child_collection(self, cls, **filters): - return RestfulModelCollection(cls, self.api, **filters) - - def save(self, **kwargs): - if self.id: - new_obj = self._update_resource(**kwargs) - else: - new_obj = self._create_resource(**kwargs) - self._update_values(new_obj) - - def update(self): - new_obj = self._update_resource() - self._update_values(new_obj) - - def _create_resource(self, **kwargs): - return self.api._create_resource(self.cls, self.as_json(), **kwargs) - - def _update_resource(self, **kwargs): - return self.api._update_resource(self.cls, self.id, self.as_json(), **kwargs) - - def _update_values(self, new_obj): - for attr in self.cls.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - - -class Message(NylasAPIObject): - attrs = [ - "bcc", - "body", - "cc", - "date", - "events", - "files", - "from", - "id", - "account_id", - "object", - "snippet", - "starred", - "subject", - "thread_id", - "job_status_id", - "to", - "unread", - "starred", - "metadata", - "_folder", - "_labels", - "headers", - "reply_to", - ] - datetime_attrs = {"received_at": "date"} - datetime_filter_attrs = { - "received_before": "received_before", - "received_after": "received_after", - } - collection_name = "messages" - - def __init__(self, api): - NylasAPIObject.__init__(self, Message, api) - - @property - def attachments(self): - return self.child_collection(File, message_id=self.id) - - @property - def folder(self): - # Instantiate a Folder object from the API response - if self._folder: - return Folder.create(self.api, **self._folder) - - @property - def labels(self): - if self._labels: - return [Label.create(self.api, **l) for l in self._labels] - return [] - - def update_folder(self, folder_id): - update = {"folder": folder_id} - new_obj = self.api._update_resource(self.cls, self.id, update) - for attr in self.cls.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - return self.folder - - def update_labels(self, label_ids=None): - label_ids = label_ids or [] - update = {"labels": label_ids} - new_obj = self.api._update_resource(self.cls, self.id, update) - for attr in self.cls.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - return self.labels - - def add_labels(self, label_ids=None): - label_ids = label_ids or [] - labels = [l.id for l in self.labels] - labels = list(set(labels).union(set(label_ids))) - return self.update_labels(labels) - - def add_label(self, label_id): - return self.add_labels([label_id]) - - def remove_labels(self, label_ids=None): - label_ids = label_ids or [] - labels = [l.id for l in self.labels] - labels = list(set(labels) - set(label_ids)) - return self.update_labels(labels) - - def remove_label(self, label_id): - return self.remove_labels([label_id]) - - def mark_as_seen(self): - self.mark_as_read() - - def mark_as_read(self): - update = {"unread": False} - self.api._update_resource(self.cls, self.id, update) - self.unread = False - - def mark_as_unread(self): - update = {"unread": True} - self.api._update_resource(self.cls, self.id, update) - self.unread = True - - def star(self): - update = {"starred": True} - self.api._update_resource(self.cls, self.id, update) - self.starred = True - - def unstar(self): - update = {"starred": False} - self.api._update_resource(self.cls, self.id, update) - self.starred = False - - @property - def raw(self): - headers = {"Accept": "message/rfc822"} - response = self.api._get_resource_raw(Message, self.id, headers=headers) - if response.status_code == 202: - raise UnSyncedError(response.content) - return response.content - - -class Folder(NylasAPIObject): - attrs = ["id", "display_name", "name", "object", "account_id", "job_status_id"] - collection_name = "folders" - - def __init__(self, api): - NylasAPIObject.__init__(self, Folder, api) - - @property - def threads(self): - return self.child_collection(Thread, folder_id=self.id) - - @property - def messages(self): - return self.child_collection(Message, folder_id=self.id) - - -class Label(NylasAPIObject): - attrs = ["id", "display_name", "name", "object", "account_id", "job_status_id"] - collection_name = "labels" - - def __init__(self, api): - NylasAPIObject.__init__(self, Label, api) - - @property - def threads(self): - return self.child_collection(Thread, label_id=self.id) - - @property - def messages(self): - return self.child_collection(Message, label_id=self.id) - - -class Thread(NylasAPIObject): - attrs = [ - "draft_ids", - "id", - "message_ids", - "account_id", - "object", - "participants", - "snippet", - "subject", - "subject_date", - "last_message_timestamp", - "first_message_timestamp", - "last_message_received_timestamp", - "last_message_sent_timestamp", - "unread", - "starred", - "version", - "_folders", - "_labels", - "_messages", - "received_recent_date", - "has_attachments", - ] - datetime_attrs = { - "first_message_at": "first_message_timestamp", - "last_message_at": "last_message_timestamp", - "last_message_received_at": "last_message_received_timestamp", - "last_message_sent_at": "last_message_sent_timestamp", - } - datetime_filter_attrs = { - "last_message_before": "last_message_before", - "last_message_after": "last_message_after", - "started_before": "started_before", - "started_after": "started_after", - } - collection_name = "threads" - - def __init__(self, api): - NylasAPIObject.__init__(self, Thread, api) - - @property - def messages(self): - if self._messages: - return [Message.create(self.api, **f) for f in self._messages] - return self.child_collection(Message, thread_id=self.id) - - @property - def drafts(self): - return self.child_collection(Draft, thread_id=self.id) - - @property - def folders(self): - if self._folders: - return [Folder.create(self.api, **f) for f in self._folders] - return [] - - @property - def labels(self): - if self._labels: - return [Label.create(self.api, **l) for l in self._labels] - return [] - - def update_folder(self, folder_id): - update = {"folder": folder_id} - new_obj = self.api._update_resource(self.cls, self.id, update) - for attr in self.cls.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - return self.folder - - def update_labels(self, label_ids=None): - label_ids = label_ids or [] - update = {"labels": label_ids} - new_obj = self.api._update_resource(self.cls, self.id, update) - for attr in self.cls.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - return self.labels - - def add_labels(self, label_ids=None): - label_ids = label_ids or [] - labels = [l.id for l in self.labels] - labels = list(set(labels).union(set(label_ids))) - return self.update_labels(labels) - - def add_label(self, label_id): - return self.add_labels([label_id]) - - def remove_labels(self, label_ids=None): - label_ids = label_ids or [] - labels = [l.id for l in self.labels] - labels = list(set(labels) - set(label_ids)) - return self.update_labels(labels) - - def remove_label(self, label_id): - return self.remove_labels([label_id]) - - def mark_as_seen(self): - self.mark_as_read() - - def mark_as_read(self): - update = {"unread": False} - self.api._update_resource(self.cls, self.id, update) - self.unread = False - - def mark_as_unread(self): - update = {"unread": True} - self.api._update_resource(self.cls, self.id, update) - self.unread = True - - def star(self): - update = {"starred": True} - self.api._update_resource(self.cls, self.id, update) - self.starred = True - - def unstar(self): - update = {"starred": False} - self.api._update_resource(self.cls, self.id, update) - self.starred = False - - def create_reply(self): - draft = self.drafts.create() - draft.thread_id = self.id - draft.subject = self.subject - return draft - - -# This is a dummy class that allows us to use the create_resource function -# and pass in a 'Send' object that will translate into a 'send' endpoint. -class Send(Message): - collection_name = "send" - - def __init__(self, api): # pylint: disable=super-init-not-called - NylasAPIObject.__init__( - self, Send, api - ) # pylint: disable=non-parent-init-called - - -class Draft(Message): - attrs = [ - "bcc", - "cc", - "body", - "date", - "files", - "from", - "id", - "account_id", - "object", - "subject", - "thread_id", - "to", - "job_status_id", - "unread", - "version", - "file_ids", - "reply_to_message_id", - "reply_to", - "starred", - "snippet", - "tracking", - "metadata", - ] - datetime_attrs = {"last_modified_at": "date"} - collection_name = "drafts" - - def __init__(self, api, thread_id=None): # pylint: disable=unused-argument - Message.__init__(self, api) - NylasAPIObject.__init__( - self, Thread, api - ) # pylint: disable=non-parent-init-called - self.file_ids = [] - - def attach(self, file): - if not file.id: - file.save() - - self.file_ids.append(file.id) - - def detach(self, file): - if file.id in self.file_ids: - self.file_ids.remove(file.id) - - def send_raw(self, mime_message): - """ - Send a raw MIME message - - Args: - mime_message (str): The raw MIME message to send - - Returns: - Message: The sent message - """ - return self.api._create_resource(Send, mime_message) - - def send(self): - if not self.id: - data = self.as_json() - else: - data = {"draft_id": self.id} - if hasattr(self, "version"): - data["version"] = self.version - if hasattr(self, "tracking") and self.tracking is not None: - data["tracking"] = self.tracking - - msg = self.api._create_resource(Send, data) - if msg: - return msg - - def delete(self): - if self.id and self.version is not None: - data = {"version": self.version} - self.api._delete_resource(self.cls, self.id, data=data) - - -class File(NylasAPIObject): - attrs = [ - "content_type", - "filename", - "id", - "content_id", - "account_id", - "object", - "size", - "message_ids", - ] - collection_name = "files" - - def save(self): # pylint: disable=arguments-differ - stream = getattr(self, "stream", None) - if not stream: - data = getattr(self, "data", None) - if data: - stream = StringIO(data) - - if not stream: - message = ( - "File object not properly formatted, " - "must provide either a stream or data." - ) - raise FileUploadError(message) - - file_info = (self.filename, stream, self.content_type, {}) # upload headers - - new_obj = self.api._create_resources(File, {"file": file_info}) - new_obj = new_obj[0] - for attr in self.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - - def download(self): - if not self.id: - message = "Can't download a file that hasn't been uploaded." - raise FileUploadError(message) - - return self.api._get_resource_data(File, self.id, extra="download") - - def __init__(self, api): - NylasAPIObject.__init__(self, File, api) - - -class Contact(NylasAPIObject): - attrs = [ - "id", - "object", - "account_id", - "given_name", - "middle_name", - "surname", - "suffix", - "nickname", - "company_name", - "job_title", - "job_status_id", - "manager_name", - "office_location", - "source", - "notes", - "picture_url", - ] - date_attrs = {"birthday": "birthday"} - typed_dict_attrs = { - "emails": "email", - "im_addresses": "im_address", - "physical_addresses": None, - "phone_numbers": "number", - "web_pages": "url", - } - collection_name = "contacts" - - def __init__(self, api): - NylasAPIObject.__init__(self, Contact, api) - - def get_picture(self): - if not self.get("picture_url", None): - return None - - response = self.api._get_resource_raw( - Contact, self.id, extra="picture", stream=True - ) - if response.status_code >= 400: - raise NylasApiError(response) - return response.raw - - -class Calendar(NylasAPIObject): - attrs = [ - "id", - "account_id", - "name", - "description", - "hex_color", - "job_status_id", - "metadata", - "read_only", - "is_primary", - "object", - ] - collection_name = "calendars" - - def __init__(self, api): - NylasAPIObject.__init__(self, Calendar, api) - self.read_only_attrs.update({"is_primary", "read_only", "hex_color"}) - - @property - def events(self): - return self.child_collection(Event, calendar_id=self.id) - - -class Event(NylasAPIObject): - attrs = [ - "id", - "account_id", - "title", - "description", - "conferencing", - "location", - "read_only", - "when", - "busy", - "participants", - "calendar_id", - "recurrence", - "status", - "master_event_id", - "job_status_id", - "owner", - "original_start_time", - "object", - "message_id", - "ical_uid", - "metadata", - "notifications", - "event_collection_id", - "capacity", - "round_robin_order", - "visibility", - ] - datetime_attrs = {"original_start_at": "original_start_time"} - collection_name = "events" - - def __init__(self, api): - NylasAPIObject.__init__(self, Event, api) - self.read_only_attrs.update( - { - "ical_uid", - "message_id", - "owner", - "status", - "master_event_id", - "original_start_time", - } - ) - - def as_json(self, enforce_read_only=True): - dct = NylasAPIObject.as_json(self, enforce_read_only) - if enforce_read_only is False: - return dct - - # Filter some parameters we got from the API - if dct.get("when"): - # Currently, the event (self) and the dict (dct) share the same - # reference to the `'when'` dict. We need to clone the dict so - # that when we remove the object key, the original event's - # `'when'` reference is unmodified. - dct["when"] = dct["when"].copy() - dct["when"].pop("object", None) - - if ( - dct.get("participants") - and isinstance(dct.get("participants"), list) - and self.id - ): - # The status of a participant cannot be updated and, if the key is - # included, it will return an error from the API - for participant in dct.get("participants"): - participant.pop("status", None) - - return dct - - def rsvp(self, status, comment=None): - if not self.message_id: - raise ValueError( - "This event was not imported from an iCalendar invite, and so it is not possible to RSVP via Nylas" - ) - if status not in {"yes", "no", "maybe"}: - raise ValueError("invalid status: {status}".format(status=status)) - - url = "{api_server}/send-rsvp".format(api_server=self.api.api_server) - data = { - "event_id": self.id, - "status": status, - "comment": comment, - } - response = self.api.session.post(url, json=data) - if response.status_code >= 400: - raise NylasApiError(response) - result = response.json() - return Event.create(self, **result) - - def generate_ics(self, ical_uid=None, method=None, prodid=None): - """ - Generate an ICS file server-side, from an Event - - Args: - ical_uid (str): Unique identifier used events across calendaring systems - method (str): Description of invitation and response methods for attendees - prodid (str): Company-specific unique product identifier - - Returns: - str: String for writing directly into an ICS file - - Raises: - ValueError: If the event does not have calendar_id or when set - RuntimeError: If the server returns an object without an ics string - """ - if not self.calendar_id or not self.when: - raise ValueError( - "Cannot generate an ICS file for an event without a Calendar ID or when set" - ) - - payload = {} - ics_options = {} - if self.id: - payload["event_id"] = self.id - else: - payload = self.as_json() - - if ical_uid: - ics_options["ical_uid"] = ical_uid - if method: - ics_options["method"] = method - if prodid: - ics_options["prodid"] = prodid - - if ics_options: - payload["ics_options"] = ics_options - - response = self.api._post_resource(Event, None, "to-ics", payload) - if "ics" in response: - return response["ics"] - raise RuntimeError( - "Unexpected response from the API server. Returned 200 but no 'ics' string found." - ) - - def validate(self): - if ( - self.conferencing - and "details" in self.conferencing - and "autocreate" in self.conferencing - ): - raise ValueError( - "Cannot set both 'details' and 'autocreate' in conferencing object." - ) - if ( - self.capacity - and self.capacity != -1 - and self.participants - and len(self.participants) > self.capacity - ): - raise ValueError( - "The number of participants in the event exceeds the set capacity." - ) - - def save(self, **kwargs): - self.validate() - - super(Event, self).save(**kwargs) - - -class RoomResource(NylasAPIObject): - attrs = [ - "object", - "email", - "name", - "capacity", - "building", - "floor_name", - "floor_number", - ] - object_type = "room_resource" - collection_name = "resources" - - def __init__(self, api): - NylasAPIObject.__init__(self, RoomResource, api) - - -class JobStatus(NylasAPIObject): - attrs = [ - "id", - "account_id", - "job_status_id", - "action", - "object", - "status", - "original_data", - "metadata", - ] - datetime_attrs = {"created_at": "created_at"} - collection_name = "job-statuses" - - def __init__(self, api): - NylasAPIObject.__init__(self, JobStatus, api) - self.read_only_attrs.update( - { - "action", - "status", - "original_data", - } - ) - - def is_successful(self): - return self.status == "successful" - - -class Scheduler(NylasAPIObject): - attrs = [ - "id", - "access_tokens", - "app_client_id", - "app_organization_id", - "config", - "edit_token", - "name", - "slug", - ] - date_attrs = { - "created_at": "created_at", - "modified_at": "modified_at", - } - collection_name = "manage/pages" - - def __init__(self, api): - NylasAPIObject.__init__(self, Scheduler, api) - - def get_available_calendars(self): - if not self.id: - raise ValueError("Cannot get calendars for a page without an ID") - - response = self.api._get_resource_raw(Scheduler, self.id, extra="calendars") - response_body = response.json() - for body in response_body: - for i in range(len(body["calendars"])): - body["calendars"][i] = Calendar.create(self.api, **body["calendars"][i]) - - return response_body - - def upload_image(self, content_type, object_name): - if not self.id: - raise ValueError("Cannot upload an image to a page without an ID") - - data = {"contentType": content_type, "objectName": object_name} - response = self.api._put_resource( - Scheduler, self.id, data, extra="upload-image" - ) - return response - - -class Component(NylasAPIObject): - attrs = [ - "id", - "account_id", - "name", - "type", - "action", - "active", - "settings", - "public_account_id", - "public_token_id", - "public_application_id", - "access_token", - "allowed_domains", - ] - datetime_attrs = { - "created_at": "created_at", - "updated_at": "updated_at", - } - - collection_name = None - api_root = "component" - - def __init__(self, api): - NylasAPIObject.__init__(self, Component, api) - self.read_only_attrs.update( - { - "public_application_id", - "created_at", - "updated_at", - } - ) - - def as_json(self, enforce_read_only=True): - dct = NylasAPIObject.as_json(self, enforce_read_only) - if enforce_read_only is False: - return dct - - # "type" cannot be modified after created - if self.id: - dct.pop("type") - return dct - - -class Webhook(NylasAPIObject): - attrs = ( - "id", - "callback_url", - "state", - "triggers", - "application_id", - "version", - ) - - collection_name = "webhooks" - api_root = "a" - - def __init__(self, api): - NylasAPIObject.__init__(self, Webhook, api) - self.read_only_attrs.update({"application_id", "version"}) - - def as_json(self, enforce_read_only=True): - dct = {} - # Only 'state' can get updated - if self.id and enforce_read_only is True: - dct["state"] = self.state - else: - dct = NylasAPIObject.as_json(self, enforce_read_only) - return dct - - @staticmethod - def verify_webhook_signature(nylas_signature, raw_body, client_secret): - """ - Verify incoming webhook signature came from Nylas - - Args: - nylas_signature (str): The signature to verify - raw_body (bytes | bytearray): The raw body from the payload - client_secret (str): Client secret of the app receiving the webhook - - Returns: - bool: True if the webhook signature was verified from Nylas - """ - digest = hmac.new( - str.encode(client_secret), msg=raw_body, digestmod=hashlib.sha256 - ).hexdigest() - return hmac.compare_digest(digest, nylas_signature) - - class Trigger(str, Enum): - """ - This is an Enum representing all the possible webhook triggers - - see more: https://developer.nylas.com/docs/developer-tools/webhooks/available-webhooks - """ - - ACCOUNT_CONNECTED = "account.connected" - ACCOUNT_RUNNING = "account.running" - ACCOUNT_STOPPED = "account.stopped" - ACCOUNT_INVALID = "account.invalid" - ACCOUNT_SYNC_ERROR = "account.sync_error" - MESSAGE_CREATED = "message.created" - MESSAGE_OPENED = "message.opened" - MESSAGE_UPDATED = "message.updated" - MESSAGE_LINK_CLICKED = "message.link_clicked" - THREAD_REPLIED = "thread.replied" - CONTACT_CREATED = "contact.created" - CONTACT_UPDATED = "contact.updated" - CONTACT_DELETED = "contact.deleted" - CALENDAR_CREATED = "calendar.created" - CALENDAR_UPDATED = "calendar.updated" - CALENDAR_DELETED = "calendar.deleted" - EVENT_CREATED = "event.created" - EVENT_UPDATED = "event.updated" - EVENT_DELETED = "event.deleted" - JOB_SUCCESSFUL = "job.successful" - JOB_FAILED = "job.failed" - - class State(str, Enum): - """ - This is an Enum representing all the possible webhook states - - see more: https://developer.nylas.com/docs/developer-tools/webhooks/#enable-and-disable-webhooks - """ - - ACTIVE = "active" - INACTIVE = "inactive" - - -class Namespace(NylasAPIObject): - attrs = [ - "account", - "email_address", - "id", - "account_id", - "object", - "provider", - "name", - "organization_unit", - ] - collection_name = "n" - - def __init__(self, api): - NylasAPIObject.__init__(self, Namespace, api) - - def child_collection(self, cls, **filters): - return RestfulModelCollection(cls, self.api, self.id, **filters) - - -class Account(NylasAPIObject): - api_root = "a" - - attrs = [ - "account_id", - "billing_state", - "email", - "id", - "namespace_id", - "provider", - "sync_state", - "authentication_type", - "trial", - "metadata", - ] - - collection_name = "accounts" - - def __init__(self, api): - NylasAPIObject.__init__(self, Account, api) - - def as_json(self, enforce_read_only=True): - if enforce_read_only is False: - return NylasAPIObject.as_json(self, enforce_read_only) - else: - return {"metadata": self.metadata} - - def upgrade(self): - return self.api._call_resource_method(self, self.account_id, "upgrade", None) - - def downgrade(self): - return self.api._call_resource_method(self, self.account_id, "downgrade", None) - - -class APIAccount(NylasAPIObject): - attrs = [ - "account_id", - "email_address", - "id", - "name", - "object", - "organization_unit", - "provider", - "sync_state", - ] - datetime_attrs = {"linked_at": "linked_at"} - - collection_name = "accounts" - - def __init__(self, api): - NylasAPIObject.__init__(self, APIAccount, api) - - -class SingletonAccount(APIAccount): - # This is an APIAccount that lives under /account. - collection_name = "account" diff --git a/nylas/client/scheduler_models.py b/nylas/client/scheduler_models.py deleted file mode 100644 index 0d19e5d5..00000000 --- a/nylas/client/scheduler_models.py +++ /dev/null @@ -1,59 +0,0 @@ -from nylas.client.restful_models import RestfulModel - - -class SchedulerTimeSlot(RestfulModel): - attrs = ["account_id", "calendar_id", "host_name", "emails"] - datetime_attrs = {"start": "start", "end": "end"} - - def __init__(self, api): - RestfulModel.__init__(self, SchedulerTimeSlot, api) - - -class SchedulerBookingConfirmation(RestfulModel): - attrs = [ - "id", - "account_id", - "additional_field_values", - "calendar_event_id", - "calendar_id", - "edit_hash", - "is_confirmed", - "location", - "recipient_email", - "recipient_locale", - "recipient_name", - "recipient_tz", - "title", - ] - datetime_attrs = {"start_time": "start_time", "end_time": "end_time"} - - def __init__(self, api): - RestfulModel.__init__(self, SchedulerBookingConfirmation, api) - - -class SchedulerBookingRequest(RestfulModel): - attrs = [ - "additional_values", - "additional_emails", - "email", - "locale", - "name", - "page_hostname", - "replaces_booking_hash", - "timezone", - "slot", - ] - - def __init__(self, api): - RestfulModel.__init__(self, SchedulerBookingRequest, api) - - def as_json(self, enforce_read_only=True): - dct = RestfulModel.as_json(self) - if "additional_values" not in dct or dct["additional_values"] is None: - dct["additional_values"] = {} - if "additional_emails" not in dct or dct["additional_emails"] is None: - dct["additional_emails"] = [] - if "slot" in dct and isinstance(dct["slot"], SchedulerTimeSlot): - dct["slot"] = dct["slot"].as_json() - - return dct diff --git a/nylas/client/scheduler_restful_model_collection.py b/nylas/client/scheduler_restful_model_collection.py deleted file mode 100644 index 4e5071a6..00000000 --- a/nylas/client/scheduler_restful_model_collection.py +++ /dev/null @@ -1,65 +0,0 @@ -import copy - -from nylas.client.restful_model_collection import RestfulModelCollection -from nylas.client.restful_models import Scheduler -from nylas.client.scheduler_models import ( - SchedulerTimeSlot, - SchedulerBookingConfirmation, -) - - -class SchedulerRestfulModelCollection(RestfulModelCollection): - def __init__(self, api): - # Make a copy of the API as we need to change the base url for Scheduler calls - scheduler_api = copy.copy(api) - scheduler_api.api_server = "https://api.schedule.nylas.com" - RestfulModelCollection.__init__(self, Scheduler, scheduler_api) - - def get_google_availability(self): - return self._execute_provider_availability("google") - - def get_office_365_availability(self): - return self._execute_provider_availability("o365") - - def get_page_slug(self, slug): - page_response = self.api._get_resource_raw( - self.model_class, slug, extra="info", path="schedule" - ).json() - return Scheduler.create(self.api, **page_response) - - def get_available_time_slots(self, slug): - response = self.api._get_resource_raw( - self.model_class, slug, extra="timeslots", path="schedule" - ).json() - return [ - SchedulerTimeSlot.create(self.api, **x) for x in response if x is not None - ] - - def book_time_slot(self, slug, timeslot): - response = self.api._post_resource( - self.model_class, slug, "timeslots", timeslot.as_json(), path="schedule" - ) - return SchedulerBookingConfirmation.create(self.api, **response) - - def cancel_booking(self, slug, edit_hash, reason): - return self.api._post_resource( - self.model_class, - slug, - "{}/cancel".format(edit_hash), - {"reason": reason}, - path="schedule", - ) - - def confirm_booking(self, slug, edit_hash): - booking_response = self.api._post_resource( - self.model_class, slug, "{}/confirm".format(edit_hash), {}, path="schedule" - ) - return SchedulerBookingConfirmation.create(self.api, **booking_response) - - def _execute_provider_availability(self, provider): - return self.api._get_resource_raw( - self.model_class, - None, - extra="availability/{}".format(provider), - path="schedule", - ).json() diff --git a/nylas/config.py b/nylas/config.py index e7cab9fd..4a888623 100644 --- a/nylas/config.py +++ b/nylas/config.py @@ -7,7 +7,21 @@ class Region(str, Enum): """ US = "us" - IRELAND = "ireland" + EU = "eu" DEFAULT_REGION = Region.US +""" The default Nylas API region. """ + +REGION_CONFIG = { + Region.US: { + "nylasApiUrl": "https://api.us.nylas.com", + }, + Region.EU: { + "nylasApiUrl": "https://api.eu.nylas.com", + }, +} +""" The available preset configuration values for each Nylas API region. """ + +DEFAULT_SERVER_URL = REGION_CONFIG[DEFAULT_REGION]["nylasApiUrl"] +""" The default Nylas API URL. """ diff --git a/nylas/services/__init__.py b/nylas/handler/__init__.py similarity index 100% rename from nylas/services/__init__.py rename to nylas/handler/__init__.py diff --git a/nylas/handler/api_resources.py b/nylas/handler/api_resources.py new file mode 100644 index 00000000..469b4667 --- /dev/null +++ b/nylas/handler/api_resources.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from nylas.models.response import Response, ListResponse, DeleteResponse +from nylas.resources.resource import Resource + +# pylint: disable=too-few-public-methods,missing-class-docstring,missing-function-docstring + + +class ListableApiResource(Resource): + def list( + self, path, response_type, headers=None, query_params=None, request_body=None + ) -> ListResponse: + response_json = self._http_client._execute( + "GET", path, headers, query_params, request_body + ) + + return ListResponse.from_dict(response_json, response_type) + + +class FindableApiResource(Resource): + def find( + self, path, response_type, headers=None, query_params=None, request_body=None + ) -> Response: + response_json = self._http_client._execute( + "GET", path, headers, query_params, request_body + ) + + return Response.from_dict(response_json, response_type) + + +class CreatableApiResource(Resource): + def create( + self, path, response_type, headers=None, query_params=None, request_body=None + ) -> Response: + response_json = self._http_client._execute( + "POST", path, headers, query_params, request_body + ) + + return Response.from_dict(response_json, response_type) + + +class UpdatableApiResource(Resource): + def update( + self, + path, + response_type, + headers=None, + query_params=None, + request_body=None, + method="PUT", + ): + response_json = self._http_client._execute( + method, path, headers, query_params, request_body + ) + + return Response.from_dict(response_json, response_type) + + +class DestroyableApiResource(Resource): + def destroy( + self, + path, + response_type=None, + headers=None, + query_params=None, + request_body=None, + ): + if response_type is None: + response_type = DeleteResponse + + response_json = self._http_client._execute( + "DELETE", path, headers, query_params, request_body + ) + return response_type.from_dict(response_json) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py new file mode 100644 index 00000000..b4c2fcce --- /dev/null +++ b/nylas/handler/http_client.py @@ -0,0 +1,169 @@ +import sys +from typing import Union +from urllib.parse import urlparse, quote + +import requests +from requests import Response + +from nylas._client_sdk_version import __VERSION__ +from nylas.models.errors import ( + NylasApiError, + NylasApiErrorResponse, + NylasSdkTimeoutError, + NylasOAuthError, + NylasOAuthErrorResponse, + NylasApiErrorResponseData, +) + + +def _validate_response(response: Response) -> dict: + json = response.json() + if response.status_code >= 400: + parsed_url = urlparse(response.url) + try: + if ( + "connect/token" in parsed_url.path + or "connect/revoke" in parsed_url.path + ): + parsed_error = NylasOAuthErrorResponse.from_dict(json) + raise NylasOAuthError(parsed_error, response.status_code) + + parsed_error = NylasApiErrorResponse.from_dict(json) + raise NylasApiError(parsed_error, response.status_code) + except (KeyError, TypeError) as exc: + request_id = json.get("request_id", None) + raise NylasApiError( + NylasApiErrorResponse( + request_id, + NylasApiErrorResponseData( + type="unknown", + message=json, + ), + ), + status_code=response.status_code, + ) from exc + + return json + + +def _build_query_params(base_url: str, query_params: dict = None) -> str: + query_param_parts = [] + for key, value in query_params.items(): + if isinstance(value, list): + for item in value: + query_param_parts.append(f"{key}={quote(str(item))}") + elif isinstance(value, dict): + for k, v in value.items(): + query_param_parts.append(f"{key}={k}:{quote(str(v))}") + else: + query_param_parts.append(f"{key}={quote(str(value))}") + + query_string = "&".join(query_param_parts) + return f"{base_url}?{query_string}" + + +# pylint: disable=too-few-public-methods +class HttpClient: + """HTTP client for the Nylas API.""" + + def __init__(self, api_server, api_key, timeout): + self.api_server = api_server + self.api_key = api_key + self.timeout = timeout + self.session = requests.Session() + + def _execute( + self, + method, + path, + headers=None, + query_params=None, + request_body=None, + data=None, + ) -> dict: + request = self._build_request( + method, path, headers, query_params, request_body, data + ) + try: + response = self.session.request( + request["method"], + request["url"], + headers=request["headers"], + json=request_body, + timeout=self.timeout, + data=data, + ) + except requests.exceptions.Timeout as exc: + raise NylasSdkTimeoutError( + url=request["url"], timeout=self.timeout + ) from exc + + return _validate_response(response) + + def _execute_download_request( + self, + path, + headers=None, + query_params=None, + stream=False, + ) -> Union[bytes, Response]: + request = self._build_request("GET", path, headers, query_params) + try: + response = self.session.request( + request["method"], + request["url"], + headers=request["headers"], + timeout=self.timeout, + stream=stream, + ) + + # If we stream an iterator for streaming the content, otherwise return the entire byte array + if stream: + return response + + return response.content if response.content else None + except requests.exceptions.Timeout as exc: + raise NylasSdkTimeoutError( + url=request["url"], timeout=self.timeout + ) from exc + + def _build_request( + self, + method: str, + path: str, + headers: dict = None, + query_params: dict = None, + request_body=None, + data=None, + ) -> dict: + base_url = f"{self.api_server}{path}" + url = _build_query_params(base_url, query_params) if query_params else base_url + headers = self._build_headers(headers, request_body, data) + + return { + "method": method, + "url": url, + "headers": headers, + } + + def _build_headers( + self, extra_headers: dict = None, response_body=None, data=None + ) -> dict: + if extra_headers is None: + extra_headers = {} + + major, minor, revision, _, __ = sys.version_info + user_agent_header = ( + f"Nylas Python SDK {__VERSION__} - {major}.{minor}.{revision}" + ) + headers = { + "X-Nylas-API-Wrapper": "python", + "User-Agent": user_agent_header, + "Authorization": f"Bearer {self.api_key}", + } + if data is not None and data.content_type is not None: + headers["Content-type"] = data.content_type + elif response_body is not None: + headers["Content-type"] = "application/json" + + return {**headers, **extra_headers} diff --git a/tests/__init__.py b/nylas/models/__init__.py similarity index 100% rename from tests/__init__.py rename to nylas/models/__init__.py diff --git a/nylas/models/application_details.py b/nylas/models/application_details.py new file mode 100644 index 00000000..daf79493 --- /dev/null +++ b/nylas/models/application_details.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from typing import Literal, Optional, List + +from dataclasses_json import dataclass_json + +from nylas.models.redirect_uri import RedirectUri + +Region = Literal["us", "eu"] +""" Literal representing the available Nylas API regions. """ + +Environment = Literal["production", "staging"] +""" Literal representing the different Nylas API environments. """ + + +@dataclass_json +@dataclass +class Branding: + """ + Class representation of branding details for the application. + + Attributes: + name: Name of the application. + icon_url: URL pointing to the application icon. + website_url: Application/publisher website URL. + description: Description of the application. + """ + + name: str + icon_url: Optional[str] = None + website_url: Optional[str] = None + description: Optional[str] = None + + +@dataclass_json +@dataclass +class HostedAuthentication: + """ + Class representation of hosted authentication branding details. + + Attributes: + background_image_url: URL pointing to the background image. + alignment: Alignment of the background image. + color_primary: Primary color of the hosted authentication page. + color_secondary: Secondary color of the hosted authentication page. + title: Title of the hosted authentication page. + subtitle: Subtitle for the hosted authentication page. + background_color: Background color of the hosted authentication page. + spacing: CSS spacing attribute in px. + """ + + background_image_url: str + alignment: Optional[str] = None + color_primary: Optional[str] = None + color_secondary: Optional[str] = None + title: Optional[str] = None + subtitle: Optional[str] = None + background_color: Optional[str] = None + spacing: Optional[int] = None + + +@dataclass_json +@dataclass +class ApplicationDetails: + """ + Class representation of a Nylas application details object. + + Attributes: + application_id: Public application ID. + organization_id: ID representing the organization. + region: Region identifier. + environment: Environment identifier. + branding: Branding details for the application. + hosted_authentication: Hosted authentication branding details. + callback_uris: List of redirect URIs. + """ + + application_id: str + organization_id: str + region: Region + environment: Environment + branding: Branding + hosted_authentication: Optional[HostedAuthentication] = None + callback_uris: List[RedirectUri] = None diff --git a/nylas/models/attachments.py b/nylas/models/attachments.py new file mode 100644 index 00000000..59c2d52b --- /dev/null +++ b/nylas/models/attachments.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass +from typing import Optional, Union, BinaryIO + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + + +@dataclass_json +@dataclass +class Attachment: + """ + An attachment on a message. + + Attributes: + id: Globally unique object identifier. + grant_id: The grant ID of the attachment. + size: Size of the attachment in bytes. + filename: Name of the attachment. + content_type: MIME type of the attachment. + content_id: The content ID of the attachment. + content_disposition: The content disposition of the attachment. + is_inline: Whether the attachment is inline. + """ + + id: str + grant_id: Optional[str] = None + filename: Optional[str] = None + content_type: Optional[str] = None + size: Optional[int] = None + content_id: Optional[str] = None + content_disposition: Optional[str] = None + is_inline: Optional[bool] = None + + +class CreateAttachmentRequest(TypedDict): + """ + A request to create an attachment. + + You can use `attach_file_request_builder()` to build this request. + + Attributes: + filename: Name of the attachment. + content_type: MIME type of the attachment. + content: Either a Base64 encoded content of the attachment or a pointer to a file. + size: Size of the attachment in bytes. + content_id: The content ID of the attachment. + content_disposition: The content disposition of the attachment. + is_inline: Whether the attachment is inline. + """ + + filename: str + content_type: str + content: Union[str, BinaryIO] + size: int + content_id: NotRequired[str] + content_disposition: NotRequired[str] + is_inline: NotRequired[bool] + + +class FindAttachmentQueryParams(TypedDict): + """ + Interface of the query parameters for finding an attachment. + + Attributes: + message_id: Message ID to find the attachment in. + """ + + message_id: str diff --git a/nylas/models/auth.py b/nylas/models/auth.py new file mode 100644 index 00000000..7aa45260 --- /dev/null +++ b/nylas/models/auth.py @@ -0,0 +1,198 @@ +from dataclasses import dataclass +from typing import Optional, List, Literal + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +AccessType = Literal["online", "offline"] +""" Literal for the access type of the authentication URL. """ + +Provider = Literal["google", "imap", "microsoft", "virtual-calendar"] +""" Literal for the different authentication providers. """ + +Prompt = Literal[ + "select_provider", "detect", "select_provider,detect", "detect,select_provider" +] +""" Literal for the different supported OAuth prompts. """ + + +class URLForAuthenticationConfig(TypedDict): + """ + Configuration for generating a URL for OAuth 2.0 authentication. + + Attributes: + client_id: The client ID of your application. + redirect_uri: Redirect URI of the integration. + provider: The integration provider type that you already had set up with Nylas for this application. + If not set, the user is directed to the Hosted Login screen and prompted to select a provider. + access_type: If the exchange token should return a refresh token too. + Not suitable for client side or JavaScript apps. + prompt: The prompt parameter is used to force the consent screen to be displayed even if the user + has already given consent to your application. + scope: A space-delimited list of scopes that identify the resources that your application + could access on the user's behalf. + If no scope is given, all of the default integration's scopes are used. + include_grant_scopes: If set to true, the scopes granted to the application will be included in the response. + state: Optional state to be returned after authentication + login_hint: Prefill the login name (usually email) during authorization flow. + If a Grant for the provided email already exists, a Grant's re-auth will automatically be initiated. + """ + + client_id: str + redirect_uri: str + provider: NotRequired[Provider] + access_type: NotRequired[AccessType] + prompt: NotRequired[Prompt] + scope: NotRequired[List[str]] + include_grant_scopes: NotRequired[bool] + state: NotRequired[str] + login_hint: NotRequired[str] + + +class URLForAdminConsentConfig(URLForAuthenticationConfig): + """ + Configuration for generating a URL for admin consent authentication for Microsoft. + + Attributes: + credential_id: The credential ID for the Microsoft account + """ + + credential_id: str + + +class CodeExchangeRequest(TypedDict): + """ + Interface of a Nylas code exchange request + + Attributes: + redirect_uri: Should match the same redirect URI that was used for getting the code during the initial + authorization request. + code: OAuth 2.0 code fetched from the previous step. + client_id: Client ID of the application. + client_secret: Client secret of the application. If not provided, the API Key will be used instead. + code_verifier: The original plain text code verifier (code_challenge) used in the initial + authorization request (PKCE). + """ + + redirect_uri: str + code: str + client_id: str + client_secret: NotRequired[str] + code_verifier: NotRequired[str] + + +class TokenExchangeRequest(TypedDict): + """ + Interface of a Nylas token exchange request + + Attributes: + redirect_uri: Should match the same redirect URI that was used for getting the code during the initial + authorization request. + refresh_token: Token to refresh/request your short-lived access token + client_id: Client ID of the application. + client_secret: Client secret of the application. If not provided, the API Key will be used instead. + """ + + redirect_uri: str + refresh_token: str + client_id: str + client_secret: NotRequired[str] + + +@dataclass_json +@dataclass +class CodeExchangeResponse: + """ + Class representation of a Nylas code exchange response. + + Attributes: + access_token: Supports exchanging the Nylas code for an access token, or refreshing an access token. + grant_id: ID representing the new Grant. + scope: List of scopes associated with the token. + expires_in: The remaining lifetime of the access token, in seconds. + refresh_token: Returned only if the code is requested using "access_type=offline". + id_token: A JWT that contains identity information about the user. Digitally signed by Nylas. + token_type: Always "Bearer". + """ + + access_token: str + grant_id: str + scope: str + expires_in: int + refresh_token: Optional[str] = None + id_token: Optional[str] = None + token_type: Optional[str] = None + + +@dataclass_json +@dataclass +class TokenInfoResponse: + """ + Class representation of a Nylas token information response. + + Attributes: + iss: The issuer of the token. + aud: The token's audience. + iat: The time that the token was issued. + exp: The time that the token expires. + sub: The token's subject. + email: The email address of the Grant belonging to the user's token. + """ + + iss: str + aud: str + iat: int + exp: int + sub: Optional[str] = None + email: Optional[str] = None + + +@dataclass_json +@dataclass +class PkceAuthUrl: + """ + Class representing the object containing the OAuth 2.0 URL and the hashed secret. + + Attributes: + secret: Server-side challenge used in the OAuth 2.0 flow. + secret_hash: SHA-256 hash of the secret. + url: The URL for hosted authentication. + """ + + secret: str + secret_hash: str + url: str + + +class ProviderDetectParams(TypedDict): + """ + Interface representing the object used to set parameters for detecting a provider. + + Attributes: + email: Email address to detect the provider for. + client_id: Client ID of the Nylas application. + all_provider_types: Search by all providers regardless of created integrations. If unset, defaults to false. + """ + + email: str + client_id: str + all_provider_types: NotRequired[bool] + + +@dataclass_json +@dataclass +class ProviderDetectResponse: + """ + Interface representing the Nylas provider detect response. + + Attributes: + email_address: Email provided for autodetection + detected: Whether the provider was detected + provider: Detected provider + type: Provider type (if IMAP provider detected displays the IMAP provider) + """ + + email_address: str + detected: bool + provider: Optional[str] = None + type: Optional[str] = None diff --git a/nylas/models/availability.py b/nylas/models/availability.py new file mode 100644 index 00000000..4d491afb --- /dev/null +++ b/nylas/models/availability.py @@ -0,0 +1,140 @@ +from dataclasses import dataclass, field +from typing import List, Literal + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +AvailabilityMethod = Literal["max-fairness", "max-availability"] +""" Literal representing the method used to determine availability for a meeting. """ + + +@dataclass_json +@dataclass +class TimeSlot: + """ + Interface for a Nylas availability time slot + + Attributes: + emails: The emails of the participants who are available for the time slot. + start_time: Unix timestamp for the start of the slot. + end_time: Unix timestamp for the end of the slot. + """ + + emails: List[str] + start_time: int + end_time: int + + +@dataclass_json +@dataclass +class GetAvailabilityResponse: + """ + Interface for a Nylas get availability response + + Attributes: + order: This property is only populated for round-robin events. + It will contain the order in which the accounts would be next in line to attend the proposed meeting. + time_slots: The available time slots where a new meeting can be created for the requested preferences. + """ + + time_slots: List[TimeSlot] + order: List[str] = field(default_factory=list) + + +class MeetingBuffer(TypedDict): + """ + Interface for the meeting buffer object within an availability request. + + Attributes: + before: The amount of buffer time in increments of 5 minutes to add before existing meetings. + Defaults to 0. + after: The amount of buffer time in increments of 5 minutes to add after existing meetings. + Defaults to 0. + """ + + before: int + after: int + + +class OpenHours(TypedDict): + """ + Interface of a participant's open hours. + + Attributes: + days: The days of the week that the open hour settings will be applied to. + Sunday corresponds to 0 and Saturday corresponds to 6. + timezone: IANA time zone database formatted string (e.g. America/New_York). + start: Start time in 24-hour time format. Leading 0's are left off. + end: End time in 24-hour time format. Leading 0's are left off. + extdates: A list of dates that will be excluded from the open hours. + Dates should be formatted as YYYY-MM-DD. + """ + + days: List[int] + timezone: str + start: str + end: str + exdates: NotRequired[List[str]] + + +class AvailabilityRules(TypedDict): + """ + Interface for the availability rules for a Nylas calendar. + + Attributes: + availability_method: The method used to determine availability for a meeting. + buffer: The buffer to add to the start and end of a meeting. + default_open_hours: A default set of open hours to apply to all participants. + You can overwrite these open hours for individual participants by specifying open_hours on + the participant object. + round_robin_event_id: The ID on events that Nylas considers when calculating the order of + round-robin participants. + This is used for both max-fairness and max-availability methods. + """ + + availability_method: NotRequired[AvailabilityMethod] + buffer: NotRequired[MeetingBuffer] + default_open_hours: NotRequired[List[OpenHours]] + round_robin_event_id: NotRequired[str] + + +class AvailabilityParticipant(TypedDict): + """ + Interface of participant details to check availability for. + + Attributes: + email: The email address of the participant. + calendar_ids: An optional list of the calendar IDs associated with each participant's email address. + If not provided, Nylas uses the primary calendar ID. + open_hours: Open hours for this participant. The endpoint searches for free time slots during these open hours. + """ + + email: str + calendar_ids: NotRequired[List[str]] + open_hours: NotRequired[List[OpenHours]] + + +class GetAvailabilityRequest(TypedDict): + """ + Interface for a Nylas get availability request + + Attributes: + start_time: Unix timestamp for the start time to check availability for. + end_time: Unix timestamp for the end time to check availability for. + participants: Participant details to check availability for. + duration_minutes: The total number of minutes the event should last. + interval_minutes: Nylas checks from the nearest interval of the passed start time. + For example, to schedule 30-minute meetings with 15 minutes between them. + If you have a meeting starting at 9:59, the API returns times starting at 10:00. 10:00-10:30, 10:15-10:45. + round_to_30_minutes: When set to true, the availability time slots will start at 30 minutes past or on the hour. + For example, a free slot starting at 16:10 is considered available only from 16:30. + availability_rules: The rules to apply when checking availability. + """ + + start_time: int + end_time: int + participants: List[AvailabilityParticipant] + duration_minutes: int + interval_minutes: NotRequired[int] + round_to_30_minutes: NotRequired[bool] + availability_rules: NotRequired[AvailabilityRules] diff --git a/nylas/models/calendars.py b/nylas/models/calendars.py new file mode 100644 index 00000000..9b3468a3 --- /dev/null +++ b/nylas/models/calendars.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass +from typing import Dict, Any, Optional + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +from nylas.models.list_query_params import ListQueryParams + + +@dataclass_json +@dataclass +class Calendar: + """ + Class representation of a Nylas Calendar object. + + Attributes: + id: Globally unique object identifier. + grant_id: Grant ID representing the user's account. + name: Name of the Calendar. + timezone: IANA time zone database-formatted string (for example, "America/New_York"). + This value is only supported for Google and Virtual Calendars. + read_only: If the event participants are able to edit the Event. + is_owned_by_user: If the Calendar is owned by the user account. + object: The type of object. + description: Description of the Calendar. + location: Geographic location of the Calendar as free-form text. + hex_color: The background color of the calendar in the hexadecimal format (for example, "#0099EE"). + If not defined, the default color is used. + hex_foreground_color: The background color of the calendar in the hexadecimal format (for example, "#0099EE"). + If not defined, the default color is used (Google only). + is_primary: If the Calendar is the account's primary calendar. + metadata: A list of key-value pairs storing additional data. + """ + + id: str + grant_id: str + name: str + read_only: bool + is_owned_by_user: bool + object: str = "calendar" + timezone: Optional[str] = None + description: Optional[str] = None + location: Optional[str] = None + hex_color: Optional[str] = None + hex_foreground_color: Optional[str] = None + is_primary: Optional[bool] = None + metadata: Optional[Dict[str, Any]] = None + + +class ListCalendarsQueryParams(ListQueryParams): + """ + Interface of the query parameters for listing calendars. + + Attributes: + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + metadata_pair: Pass in your metadata key-value pair to search for metadata. + """ + + metadata_pair: NotRequired[Dict[str, str]] + + +class CreateCalendarRequest(TypedDict): + """ + Interface of a Nylas create calendar request + + Attributes: + name: Name of the Calendar. + description: Description of the calendar. + location: Geographic location of the calendar as free-form text. + timezone: IANA time zone database formatted string (e.g. America/New_York). + metadata: A list of key-value pairs storing additional data. + """ + + name: str + description: NotRequired[str] + location: NotRequired[str] + timezone: NotRequired[str] + metadata: NotRequired[Dict[str, str]] + + +class UpdateCalendarRequest(CreateCalendarRequest): + """ + Interface of a Nylas update calendar request + + Attributes: + hexColor: The background color of the calendar in the hexadecimal format (e.g. #0099EE). + Empty indicates default color. + hexForegroundColor: The background color of the calendar in the hexadecimal format (e.g. #0099EE). + Empty indicates default color. (Google only) + """ + + hexColor: NotRequired[str] + hexForegroundColor: NotRequired[str] diff --git a/nylas/models/connectors.py b/nylas/models/connectors.py new file mode 100644 index 00000000..6ffd5ef0 --- /dev/null +++ b/nylas/models/connectors.py @@ -0,0 +1,157 @@ +from dataclasses import dataclass +from typing import Dict, Any, List, Optional, Union +from typing_extensions import TypedDict, NotRequired + +from dataclasses_json import dataclass_json + +from nylas.models.auth import Provider +from nylas.models.list_query_params import ListQueryParams + + +@dataclass_json +@dataclass +class Connector: + """ + Interface representing the Nylas connector response. + + Attributes: + provider: The provider type + settings: Optional settings from provider + scope: Default scopes for the connector + """ + + provider: Provider + settings: Optional[Dict[str, Any]] = None + scope: Optional[List[str]] = None + + +class BaseCreateConnectorRequest(TypedDict): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + provider: The provider type + """ + + provider: Provider + + +class GoogleCreateConnectorSettings(TypedDict): + """ + Interface representing a Google connector creation request. + + Attributes: + client_id: The Google Client ID + client_secret: The Google Client Secret + topic_name: The Google Pub/Sub topic name + """ + + client_id: str + client_secret: str + topic_name: NotRequired[str] + + +class MicrosoftCreateConnectorSettings(TypedDict): + """ + Interface representing a Microsoft connector creation request. + + Attributes: + client_id: The Google Client ID + client_secret: The Google Client Secret + tenant: The Microsoft tenant ID + """ + + client_id: str + client_secret: str + tenant: NotRequired[str] + + +class GoogleCreateConnectorRequest(BaseCreateConnectorRequest): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + provider (Provider): The provider type, should be Google + settings: The Google OAuth provider credentials and settings + scope: The Google OAuth scopes + """ + + settings: GoogleCreateConnectorSettings + scope: NotRequired[List[str]] + + +class MicrosoftCreateConnectorRequest(BaseCreateConnectorRequest): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + name (str): Custom name of the connector + provider (Provider): The provider type, should be Google + settings: The Microsoft OAuth provider credentials and settings + scope: The Microsoft OAuth scopes + """ + + settings: MicrosoftCreateConnectorSettings + scope: NotRequired[List[str]] + + +class ImapCreateConnectorRequest(BaseCreateConnectorRequest): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + name (str): Custom name of the connector + provider (Provider): The provider type, should be IMAP + """ + + pass + + +class VirtualCalendarsCreateConnectorRequest(BaseCreateConnectorRequest): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + name (str): Custom name of the connector + provider (Provider): The provider type + """ + + pass + + +CreateConnectorRequest = Union[ + GoogleCreateConnectorRequest, + MicrosoftCreateConnectorRequest, + ImapCreateConnectorRequest, + VirtualCalendarsCreateConnectorRequest, +] +""" The type of the Nylas connector creation request. """ + + +class UpdateConnectorRequest(TypedDict): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + name: Custom name of the connector + settings: The OAuth provider credentials and settings + scope: The OAuth scopes + """ + + name: NotRequired[str] + settings: NotRequired[Dict[str, Any]] + scope: NotRequired[List[str]] + + +class ListConnectorQueryParams(ListQueryParams): + """ + Interface of the query parameters for listing connectors. + + Attributes: + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + """ + + pass diff --git a/nylas/models/contacts.py b/nylas/models/contacts.py new file mode 100644 index 00000000..507c0592 --- /dev/null +++ b/nylas/models/contacts.py @@ -0,0 +1,387 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional, List +from typing_extensions import TypedDict, NotRequired + +from dataclasses_json import dataclass_json + +from nylas.models.list_query_params import ListQueryParams + + +class SourceType(str, Enum): + """Enum representing the different types of sources for a contact.""" + + ADDRESS_BOOK = "address_book" + INBOX = "inbox" + DOMAIN = "domain" + + +@dataclass_json +@dataclass +class PhoneNumber: + """ + A phone number for a contact. + + Attributes: + number: The phone number. + type: The type of phone number. + """ + + number: Optional[str] = None + type: Optional[str] = None + + +@dataclass_json +@dataclass +class PhysicalAddress: + """ + A physical address for a contact. + + Attributes: + format: The format of the address. + street_address: The street address of the contact. + city: The city of the contact. + postal_code: The postal code of the contact. + state: The state of the contact. + country: The country of the contact. + type: The type of address. + """ + + format: Optional[str] = None + street_address: Optional[str] = None + city: Optional[str] = None + postal_code: Optional[str] = None + state: Optional[str] = None + country: Optional[str] = None + type: Optional[str] = None + + +@dataclass_json +@dataclass +class WebPage: + """ + A web page for a contact. + + Attributes: + url: The URL of the web page. + type: The type of web page. + """ + + url: Optional[str] = None + type: Optional[str] = None + + +@dataclass_json +@dataclass +class ContactEmail: + """ + An email address for a contact. + + Attributes: + email: The email address. + type: The type of email address. + """ + + email: Optional[str] = None + type: Optional[str] = None + + +@dataclass_json +@dataclass +class ContactGroupId: + """ + A contact group ID for a contact. + + Attributes: + id: The contact group ID. + """ + + id: str + + +@dataclass_json +@dataclass +class InstantMessagingAddress: + """ + An instant messaging address for a contact. + + Attributes: + im_address: The instant messaging address. + type: The type of instant messaging address. + """ + + im_address: Optional[str] = None + type: Optional[str] = None + + +@dataclass_json +@dataclass +class Contact: + """ + Class representation of a Nylas contact object. + + Attributes: + id: Globally unique object identifier. + grant_id: Grant ID representing the user's account. + object: The type of object. + birthday: The contact's birthday. + company_name: The contact's company name. + display_name: The contact's display name. + emails: The contact's email addresses. + im_addresses: The contact's instant messaging addresses. + given_name: The contact's given name. + job_title: The contact's job title. + manager_name: The contact's manager name. + middle_name: The contact's middle name. + nickname: The contact's nickname. + notes: The contact's notes. + office_location: The contact's office location. + picture_url: The contact's picture URL. + picture: The contact's picture. + suffix: The contact's suffix. + surname: The contact's surname. + source: The contact's source. + phone_numbers: The contact's phone numbers. + physical_addresses: The contact's physical addresses. + web_pages: The contact's web pages. + groups: The contact's groups. + """ + + id: str + grant_id: str + object: str = "contact" + birthday: Optional[str] = None + company_name: Optional[str] = None + display_name: Optional[str] = None + emails: Optional[List[ContactEmail]] = None + im_addresses: Optional[List[InstantMessagingAddress]] = None + given_name: Optional[str] = None + job_title: Optional[str] = None + manager_name: Optional[str] = None + middle_name: Optional[str] = None + nickname: Optional[str] = None + notes: Optional[str] = None + office_location: Optional[str] = None + picture_url: Optional[str] = None + picture: Optional[str] = None + suffix: Optional[str] = None + surname: Optional[str] = None + source: Optional[SourceType] = None + phone_numbers: Optional[List[PhoneNumber]] = None + physical_addresses: Optional[List[PhysicalAddress]] = None + web_pages: Optional[List[WebPage]] = None + groups: Optional[List[ContactGroupId]] = None + + +class WriteablePhoneNumber(TypedDict): + """ + A phone number for a contact. + + Attributes: + number: The phone number. + type: The type of phone number. + """ + + number: NotRequired[str] + type: NotRequired[str] + + +class WriteablePhysicalAddress(TypedDict): + """ + A physical address for a contact. + + Attributes: + format: The format of the address. + street_address: The street address of the contact. + city: The city of the contact. + postal_code: The postal code of the contact. + state: The state of the contact. + country: The country of the contact. + type: The type of address. + """ + + format: NotRequired[str] + street_address: NotRequired[str] + city: NotRequired[str] + postal_code: NotRequired[str] + state: NotRequired[str] + country: NotRequired[str] + type: NotRequired[str] + + +class WriteableWebPage(TypedDict): + """ + A web page for a contact. + + Attributes: + url: The URL of the web page. + type: The type of web page. + """ + + url: NotRequired[str] + type: NotRequired[str] + + +class WriteableContactEmail(TypedDict): + """ + An email address for a contact. + + Attributes: + email: The email address. + type: The type of email address. + """ + + email: NotRequired[str] + type: NotRequired[str] + + +class WriteableContactGroupId(TypedDict): + """ + A contact group ID for a contact. + + Attributes: + id: The contact group ID. + """ + + id: str + + +class WriteableInstantMessagingAddress(TypedDict): + """ + An instant messaging address for a contact. + + Attributes: + im_address: The instant messaging address. + type: The type of instant messaging address. + """ + + im_address: NotRequired[str] + type: NotRequired[str] + + +class CreateContactRequest(TypedDict): + """ + Interface for creating a Nylas contact. + + Attributes: + birthday: The contact's birthday. + company_name: The contact's company name. + display_name: The contact's display name. + emails: The contact's email addresses. + im_addresses: The contact's instant messaging addresses. + given_name: The contact's given name. + job_title: The contact's job title. + manager_name: The contact's manager name. + middle_name: The contact's middle name. + nickname: The contact's nickname. + notes: The contact's notes. + office_location: The contact's office location. + picture_url: The contact's picture URL. + picture: The contact's picture. + suffix: The contact's suffix. + surname: The contact's surname. + source: The contact's source. + phone_numbers: The contact's phone numbers. + physical_addresses: The contact's physical addresses. + web_pages: The contact's web pages. + groups: The contact's groups. + """ + + birthday: NotRequired[str] + company_name: NotRequired[str] + display_name: NotRequired[str] + emails: NotRequired[List[WriteableContactEmail]] + im_addresses: NotRequired[List[WriteableInstantMessagingAddress]] + given_name: NotRequired[str] + job_title: NotRequired[str] + manager_name: NotRequired[str] + middle_name: NotRequired[str] + nickname: NotRequired[str] + notes: NotRequired[str] + office_location: NotRequired[str] + picture_url: NotRequired[str] + picture: NotRequired[str] + suffix: NotRequired[str] + surname: NotRequired[str] + source: NotRequired[SourceType] + phone_numbers: NotRequired[List[WriteablePhoneNumber]] + physical_addresses: NotRequired[List[WriteablePhysicalAddress]] + web_pages: NotRequired[List[WriteableWebPage]] + groups: NotRequired[List[WriteableContactGroupId]] + + +UpdateContactRequest = CreateContactRequest +"""Interface for updating a Nylas contact.""" + + +class ListContactsQueryParams(ListQueryParams): + """ + Interface of the query parameters for listing calendars. + + Attributes: + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + email: Returns the contacts matching the exact contact's email. + phone_number: Returns the contacts matching the contact's exact phone number + source: Returns the contacts matching from the address book or auto-generated contacts from emails. + For example of contacts only from the address book: /contacts?source=address_bookor + for only autogenerated contacts:/contacts?source=inbox` + group: Returns the contacts belonging to the Contact Group matching this ID + recurse: When set to true, returns the contacts also within the specified Contact Group subgroups, + if the group parameter is set. + """ + + email: NotRequired[str] + phone_number: NotRequired[str] + source: NotRequired[SourceType] + group: NotRequired[str] + recurse: NotRequired[bool] + + +class FindContactQueryParams(TypedDict): + """ + The available query parameters for finding a contact. + + Attributes: + profile_picture: If true and picture_url is present, the response includes a Base64 binary data blob that + you can use to view information as an image file. + """ + + profile_picture: NotRequired[bool] + + +class GroupType(str, Enum): + """Enum representing the different types of contact groups.""" + + USER = "user" + SYSTEM = "system" + OTHER = "other" + + +@dataclass_json +@dataclass +class ContactGroup: + """ + Class representation of a Nylas contact group object. + + Attributes: + id: Globally unique object identifier. + grant_id: Grant ID representing the user's account. + object: The type of object. + group_type: The type of contact group. + name: The name of the contact group. + path: The path of the contact group. + """ + + id: str + grant_id: str + object: str = "contact_group" + group_type: Optional[GroupType] = None + name: Optional[str] = None + path: Optional[str] = None + + +ListContactGroupsQueryParams = ListQueryParams +"""The available query parameters for listing contact groups.""" diff --git a/nylas/models/credentials.py b/nylas/models/credentials.py new file mode 100644 index 00000000..39dd65ff --- /dev/null +++ b/nylas/models/credentials.py @@ -0,0 +1,111 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Literal, Union + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, Protocol, NotRequired + +CredentialType = Literal["adminconsent", "serviceaccount", "connector"] +"""The alias for the different types of credentials that can be created.""" + + +@dataclass_json +@dataclass +class Credential: + """ + Interface representing a Nylas Credential object. + Attributes + id: Globally unique object identifier; + name: Name of the credential + credential_type: The type of credential + hashed_data: Hashed value of the credential that you created + created_at: Timestamp of when the credential was created + updated_at: Timestamp of when the credential was updated; + """ + + id: str + name: str + credential_type: Optional[CredentialType] = None + hashed_data: Optional[str] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None + + +class MicrosoftAdminConsentSettings(Protocol): + """ + Interface representing the data required to create a Microsoft Admin Consent credential. + + Attributes: + client_id: The client ID of the Azure AD application + client_secret: The client secret of the Azure AD application + """ + + client_id: str + client_secret: str + + +class GoogleServiceAccountCredential(Protocol): + """ + Interface representing the data required to create a Google Service Account credential. + + Attributes: + private_key_id: The private key ID of the service account + private_key: The private key of the service account + client_email: The client email of the service account + """ + + private_key_id: str + private_key: str + client_email: str + + +CredentialData = Union[ + MicrosoftAdminConsentSettings, GoogleServiceAccountCredential, Dict[str, any] +] +"""The alias for the different types of credential data that can be used to create a credential.""" + + +class CredentialRequest(TypedDict): + """ + Interface representing a request to create a credential. + + Attributes: + name: Name of the credential + credential_type: Type of credential you want to create. + credential_data: The data required to successfully create the credential object + """ + + name: Optional[str] + credential_type: CredentialType + credential_data: CredentialData + + +class UpdateCredentialRequest(TypedDict): + """ + Interface representing a request to update a credential. + + Attributes: + name: Name of the credential + credential_data: The data required to successfully create the credential object + """ + + name: Optional[str] + credential_data: Optional[CredentialData] + + +class ListCredentialQueryParams(TypedDict): + """ + Interface representing the query parameters for credentials . + + Attributes: + offset: Offset results + sort_by: Sort entries by field name + order_by: Order results by the specified field. + Currently only start is supported. + limit: The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + """ + + limit: NotRequired[int] + offset: NotRequired[int] + order_by: NotRequired[str] + sort_by: NotRequired[str] diff --git a/nylas/models/drafts.py b/nylas/models/drafts.py new file mode 100644 index 00000000..18900fa5 --- /dev/null +++ b/nylas/models/drafts.py @@ -0,0 +1,154 @@ +from dataclasses import dataclass +from typing import List, get_type_hints + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +from nylas.models.attachments import CreateAttachmentRequest +from nylas.models.events import EmailName +from nylas.models.list_query_params import ListQueryParams +from nylas.models.messages import Message + + +@dataclass_json +@dataclass +class Draft(Message): + """ + A Draft object. + + Attributes: + id (str): Globally unique object identifier. + grant_id (str): The grant that this message belongs to. + from_ (List[EmailName]): The sender of the message. + date (int): The date the message was received. + object: The type of object. + thread_id (Optional[str]): The thread that this message belongs to. + subject (Optional[str]): The subject of the message. + to (Optional[List[EmailName]]): The recipients of the message. + cc (Optional[List[EmailName]]): The CC recipients of the message. + bcc (Optional[List[EmailName]]): The BCC recipients of the message. + reply_to (Optional[List[EmailName]]): The reply-to recipients of the message. + unread (Optional[bool]): Whether the message is unread. + starred (Optional[bool]): Whether the message is starred. + snippet (Optional[str]): A snippet of the message body. + body (Optional[str]): The body of the message. + attachments (Optional[List[Attachment]]): The attachments on the message. + folders (Optional[List[str]]): The folders that the message is in. + created_at (Optional[int]): Unix timestamp of when the message was created. + """ + + object: str = "draft" + + +class TrackingOptions(TypedDict): + """ + The different tracking options for when a message is sent. + + Attributes: + label: The label to apply to tracked messages. + links: Whether to track links. + opens: Whether to track opens. + thread_replies: Whether to track thread replies. + """ + + label: NotRequired[str] + links: NotRequired[bool] + opens: NotRequired[bool] + thread_replies: NotRequired[bool] + + +class CreateDraftRequest(TypedDict): + """ + A request to create a draft. + + Attributes: + subject: The subject of the message. + to: The recipients of the message. + cc: The CC recipients of the message. + bcc: The BCC recipients of the message. + reply_to: The reply-to recipients of the message. + starred: Whether the message is starred. + body: The body of the message. + attachments: The attachments on the message. + send_at: Unix timestamp to send the message at. + reply_to_message_id: The ID of the message that you are replying to. + tracking_options: Options for tracking opens, links, and thread replies. + """ + + body: NotRequired[str] + subject: NotRequired[str] + to: NotRequired[List[EmailName]] + bcc: NotRequired[List[EmailName]] + cc: NotRequired[List[EmailName]] + reply_to: NotRequired[List[EmailName]] + attachments: NotRequired[List[CreateAttachmentRequest]] + starred: NotRequired[bool] + send_at: NotRequired[int] + reply_to_message_id: NotRequired[str] + tracking_options: NotRequired[TrackingOptions] + + +UpdateDraftRequest = CreateDraftRequest +""" A request to update a draft. """ + + +# Need to use Functional typed dicts because "from" and "in" are Python +# keywords, and can't be declared using the declarative syntax +ListDraftsQueryParams = TypedDict( + "ListDraftsQueryParams", + { + **get_type_hints(ListQueryParams), + "subject": NotRequired[str], + "any_email": NotRequired[List[str]], + "from": NotRequired[List[str]], + "to": NotRequired[List[str]], + "cc": NotRequired[List[str]], + "bcc": NotRequired[List[str]], + "in": NotRequired[List[str]], + "unread": NotRequired[bool], + "starred": NotRequired[bool], + "thread_id": NotRequired[str], + "has_attachment": NotRequired[bool], + }, +) +""" +Query parameters for listing drafts. + +Attributes: + subject: Return messages with matching subject. + any_email: Return messages that have been sent or received by this comma-separated list of email addresses. + from: Return messages sent from this email address. + to: Return messages sent to this email address. + cc: Return messages cc'd to this email address. + bcc: Return messages bcc'd to this email address. + in: Return messages in this specific folder or label, specified by ID. + unread: Filter messages by unread status. + starred: Filter messages by starred status. + has_attachment: Filter messages by whether they have an attachment. + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. +""" + + +class SendMessageRequest(CreateDraftRequest): + """ + A request to send a message. + + Attributes: + subject (NotRequired[str]): The subject of the message. + to (NotRequired[List[EmailName]]): The recipients of the message. + cc (NotRequired[List[EmailName]]): The CC recipients of the message. + bcc (NotRequired[List[EmailName]]): The BCC recipients of the message. + reply_to (NotRequired[List[EmailName]]): The reply-to recipients of the message. + starred (NotRequired[bool]): Whether the message is starred. + body (NotRequired[str]): The body of the message. + attachments (NotRequired[List[CreateAttachmentRequest]]): The attachments on the message. + send_at (NotRequired[int]): Unix timestamp to send the message at. + reply_to_message_id (NotRequired[str]): The ID of the message that you are replying to. + tracking_options (NotRequired[TrackingOptions]): Options for tracking opens, links, and thread replies. + use_draft: Whether or not to use draft support. This is primarily used when dealing with large attachments. + """ + + use_draft: NotRequired[bool] diff --git a/nylas/models/errors.py b/nylas/models/errors.py new file mode 100644 index 00000000..d59b61ba --- /dev/null +++ b/nylas/models/errors.py @@ -0,0 +1,163 @@ +from dataclasses import dataclass +from typing import Optional + +from dataclasses_json import dataclass_json + + +class AbstractNylasApiError(Exception): + """ + Base class for all Nylas API errors. + + Attributes: + request_id: The unique identifier of the request. + status_code: The HTTP status code of the error response. + """ + + def __init__( + self, + message: str, + request_id: Optional[str] = None, + status_code: Optional[int] = None, + ): + """ + Args: + request_id: The unique identifier of the request. + status_code: The HTTP status code of the error response. + message: The error message. + """ + self.request_id: str = request_id + self.status_code: int = status_code + super().__init__(message) + + +class AbstractNylasSdkError(Exception): + """ + Base class for all Nylas SDK errors. + """ + + pass + + +@dataclass_json +@dataclass +class NylasApiErrorResponseData: + """ + Interface representing the error data within the response object. + + Attributes: + type: The type of error. + message: The error message. + provider_error: The provider error if there is one. + """ + + type: str + message: str + provider_error: Optional[dict] = None + + +@dataclass_json +@dataclass +class NylasApiErrorResponse: + """ + Interface representing the error response from the Nylas API. + + Attributes: + request_id: The unique identifier of the request. + error: The error data. + """ + + request_id: str + error: NylasApiErrorResponseData + + +@dataclass_json +@dataclass +class NylasOAuthErrorResponse: + """ + Interface representing an OAuth error returned by the Nylas API. + + Attributes: + error: Error type. + error_code: Error code used for referencing the docs, logs, and data stream. + error_description: Human readable error description. + error_uri: URL to the related documentation and troubleshooting regarding this error. + """ + + error: str + error_code: int + error_description: str + error_uri: str + + +class NylasApiError(AbstractNylasApiError): + """ + Class representation of a general Nylas API error. + + Attributes: + type: Error type. + provider_error: Provider Error. + """ + + def __init__( + self, + api_error: NylasApiErrorResponse, + status_code: Optional[int] = None, + ): + """ + Args: + api_error: The error details from the API. + status_code: The HTTP status code of the error response. + """ + super().__init__(api_error.error.message, api_error.request_id, status_code) + self.type: str = api_error.error.type + self.provider_error: Optional[dict] = api_error.error.provider_error + + +class NylasOAuthError(AbstractNylasApiError): + """ + Class representation of an OAuth error returned by the Nylas API. + + Attributes: + error: Error type. + error_code: Error code used for referencing the docs, logs, and data stream. + error_description: Human readable error description. + error_uri: URL to the related documentation and troubleshooting regarding this error. + """ + + def __init__( + self, + oauth_error: NylasOAuthErrorResponse, + status_code: Optional[int] = None, + ): + """ + Args: + oauth_error: The error details from the API. + status_code: The HTTP status code of the error response. + """ + super().__init__(oauth_error.error_description, status_code) + self.error: str = oauth_error.error + self.error_code: int = oauth_error.error_code + self.error_description: str = oauth_error.error_description + self.error_uri: str = oauth_error.error_uri + + +class NylasSdkTimeoutError(AbstractNylasSdkError): + """ + Error thrown when the Nylas SDK times out before receiving a response from the server. + + Attributes: + url: The URL that timed out. + timeout: The timeout value set in the Nylas SDK, in seconds. + """ + + def __init__(self, url: str, timeout: int): + """ + Args: + url: The URL that timed out. + timeout: The timeout value set in the Nylas SDK, in seconds. + """ + super().__init__( + "Nylas SDK timed out before receiving a response from the server." + ) + self.url: str = url + self.timeout: int = timeout diff --git a/nylas/models/events.py b/nylas/models/events.py new file mode 100644 index 00000000..873a51c8 --- /dev/null +++ b/nylas/models/events.py @@ -0,0 +1,792 @@ +from dataclasses import dataclass, field +from typing import Dict, Any, List, Optional, Union, Literal + +from dataclasses_json import dataclass_json, config +from typing_extensions import TypedDict, NotRequired + +from nylas.models.list_query_params import ListQueryParams + +Status = Literal["confirmed", "tentative", "cancelled"] +""" Literal representing the status of an Event. """ + +Visibility = Literal["default", "public", "private"] +""" Literal representation of visibility of the Event. """ + +ParticipantStatus = Literal["noreply", "yes", "no", "maybe"] +""" Literal representing the status of an Event participant. """ + +SendRsvpStatus = Literal["yes", "no", "maybe"] +""" Literal representing the status of an RSVP. """ + + +@dataclass_json +@dataclass +class Participant: + """ + Interface representing an Event participant. + + Attributes: + email: Participant's email address. + name: Participant's name. + status: Participant's status. + comment: Comment by the participant. + phone_number: Participant's phone number. + """ + + email: str + status: ParticipantStatus + name: Optional[str] = None + comment: Optional[str] = None + phone_number: Optional[str] = None + + +class EmailName(TypedDict): + """ + Interface representing an email address and optional name. + + Attributes: + email: Email address. + name: Full name. + """ + + email: str + name: NotRequired[str] + + +@dataclass_json +@dataclass +class Time: + """ + Class representation of a specific point in time. + A meeting at 2pm would be represented as a time subobject. + + Attributes: + time: A UNIX timestamp representing the time of occurrence. + timezone: If timezone is present, then the value for time will be read with timezone. + Timezone using IANA formatted string. (e.g. "America/New_York") + """ + + time: int + timezone: Optional[str] = None + object: str = "time" + + +@dataclass_json +@dataclass +class Timespan: + """ + Class representation of a time span with start and end times. + An hour lunch meeting would be represented as timespan subobjects. + + Attributes: + start_time: The Event's start time. + end_time: The Event's end time. + start_timezone: The timezone of the start time, represented by an IANA-formatted string + (for example, "America/New_York"). + end_timezone: The timezone of the end time, represented by an IANA-formatted string + (for example, "America/New_York"). + """ + + start_time: int + end_time: int + start_timezone: Optional[str] = None + end_timezone: Optional[str] = None + object: str = "timespan" + + +@dataclass_json +@dataclass +class Date: + """ + Class representation of an entire day spans without specific times. + Your birthday and holidays would be represented as date subobjects. + + Attributes: + date: Date of occurrence in ISO 8601 format. + """ + + date: str + object: str = "date" + + +@dataclass_json +@dataclass +class Datespan: + """ + Class representation of a specific dates without clock-based start or end times. + A business quarter or academic semester would be represented as datespan subobjects. + + Attributes: + start_date: The start date in ISO 8601 format. + end_date: The end date in ISO 8601 format. + """ + + start_date: str + end_date: str + object: str = "datespan" + + +When = Union[Time, Timespan, Date, Datespan] +""" Union type representing the different types of Event time configurations. """ + + +def _decode_when(when: dict) -> When: + """ + Decode a when object into a When object. + + Args: + when: The when object to decode. + + Returns: + The decoded When object. + """ + if "object" not in when: + raise ValueError("Invalid when object, no 'object' field found.") + + if when["object"] == "time": + return Time.from_dict(when) + + if when["object"] == "timespan": + return Timespan.from_dict(when) + + if when["object"] == "date": + return Date.from_dict(when) + + if when["object"] == "datespan": + return Datespan.from_dict(when) + + raise ValueError( + f"Invalid when object, unknown 'object' field found: {when['object']}" + ) + + +ConferencingProvider = Literal[ + "Google Meet", "Zoom Meeting", "Microsoft Teams", "GoToMeeting", "WebEx" +] +""" Literal for the different conferencing providers. """ + + +@dataclass_json +@dataclass +class DetailsConfig: + """ + Class representation of a conferencing details config object + + Attributes: + meeting_code: The conferencing meeting code. Used for Zoom. + password: The conferencing meeting password. Used for Zoom. + url: The conferencing meeting url. + pin: The conferencing meeting pin. Used for Google Meet. + phone: The conferencing meeting phone numbers. Used for Google Meet. + """ + + meeting_code: Optional[str] = None + password: Optional[str] = None + url: Optional[str] = None + pin: Optional[str] = None + phone: Optional[List[str]] = None + + +@dataclass_json +@dataclass +class Details: + """ + Class representation of a conferencing details object + + Attributes: + provider: The conferencing provider + details: The conferencing details + """ + + provider: ConferencingProvider + details: Dict[str, Any] + + +@dataclass_json +@dataclass +class Autocreate: + """ + Class representation of a conferencing autocreate object + + Attributes: + provider: The conferencing provider + autocreate: Empty dict to indicate an intention to autocreate a video link. + Additional provider settings may be included in autocreate.settings, but Nylas does not validate these. + """ + + provider: ConferencingProvider + autocreate: Dict[str, Any] + + +Conferencing = Union[Details, Autocreate] +""" Union type representing the different types of conferencing configurations. """ + + +def _decode_conferencing(conferencing: dict) -> Union[Conferencing, None]: + """ + Decode a when object into a When object. + + Args: + when: The when object to decode. + + Returns: + The decoded When object. + """ + if not conferencing: + return None + + if "details" in conferencing: + return Details.from_dict(conferencing) + + if "autocreate" in conferencing: + return Autocreate.from_dict(conferencing) + + raise ValueError(f"Invalid conferencing object, unknown type found: {conferencing}") + + +@dataclass_json +@dataclass +class ReminderOverride: + """ + Class representation of a reminder override object. + + Attributes: + reminder_minutes: The user's preferred Event reminder time, in minutes. + Reminder minutes are in the following format: "[20]". + reminder_method: The user's preferred method for Event reminders (Google only). + """ + + reminder_minutes: Optional[int] = None + reminder_method: Optional[str] = None + + +@dataclass_json +@dataclass +class Reminders: + """ + Class representation of a reminder object. + + Attributes: + use_default: Whether to use the default reminder settings for the calendar. + overrides: A list of reminders for the event if use_default is set to false. + If left empty or omitted while use_default is set to false, the event will have no reminders. + """ + + use_default: bool + overrides: Optional[List[ReminderOverride]] = None + + +@dataclass_json +@dataclass +class Event: + """ + Class representation of a Nylas Event object. + + Attributes: + id: Globally unique object identifier. + grant_id: Grant ID representing the user's account. + calendar_id: The Event's Calendar ID. + busy: Whether to show this Event's time block as available on shared or public calendars. + read_only: If the Event's participants are able to edit the Event. + created_at: Unix timestamp representing the Event's creation time. + updated_at: Unix timestamp representing the time when the Event was last updated. + participants: List of participants invited to the Event. Participants may be people, rooms, or resources. + when: Representation of an Event's time and duration. + conferencing: Representation of an Event's conferencing details. + object: The type of object. + description: The Event's description. + location: The Event's location (for example, a physical address or a meeting room). + ical_uid: Unique ID for iCalendar standard, allowing you to identify events across calendaring systems. + Recurring events may share the same value. Can be "null" for events synced before the year 2020. + title: The Event's title. + html_link: A link to the Event in the provider's UI. + hide_participants: Whether participants of the Event should be hidden. + metadata: List of key-value pairs storing additional data. + creator: The user who created the Event. + organizer: The organizer of the Event. + recurrence: A list of RRULE and EXDATE strings. + reminders: List of reminders for the Event. + status: The Event's status. + visibility: The Event's visibility (private or public). + capacity: Sets the maximum number of participants that may attend the event. + """ + + id: str + grant_id: str + calendar_id: str + busy: bool + created_at: int + updated_at: int + participants: List[Participant] + visibility: Visibility + when: When = field(metadata=config(decoder=_decode_when)) + conferencing: Optional[Conferencing] = field( + default=None, metadata=config(decoder=_decode_conferencing) + ) + object: str = "event" + read_only: Optional[bool] = None + description: Optional[str] = None + location: Optional[str] = None + ical_uid: Optional[str] = None + title: Optional[str] = None + html_link: Optional[str] = None + hide_participants: Optional[bool] = None + metadata: Optional[Dict[str, Any]] = None + creator: Optional[EmailName] = None + organizer: Optional[EmailName] = None + recurrence: Optional[List[str]] = None + reminders: Optional[Reminders] = None + status: Optional[Status] = None + capacity: Optional[int] = None + + +class CreateParticipant(TypedDict): + """ + Interface representing a participant for event creation. + + Attributes: + email: Participant's email address. + name: Participant's name. + comment: Comment by the participant. + phone_number: Participant's phone number. + """ + + email: str + name: NotRequired[str] + comment: NotRequired[str] + phone_number: NotRequired[str] + + +class UpdateParticipant(TypedDict): + """ + Interface representing a participant for updating an event. + + Attributes: + email: Participant's email address. + name: Participant's name. + comment: Comment by the participant. + phoneNumber: Participant's phone number. + """ + + email: NotRequired[str] + name: NotRequired[str] + comment: NotRequired[str] + phoneNumber: NotRequired[str] + + +class WritableDetailsConfig(TypedDict): + """ + Interface representing a writable conferencing details config object + + Attributes: + meeting_code: The conferencing meeting code. Used for Zoom. + password: The conferencing meeting password. Used for Zoom. + url: The conferencing meeting url. + pin: The conferencing meeting pin. Used for Google Meet. + phone: The conferencing meeting phone numbers. Used for Google Meet. + """ + + meeting_code: NotRequired[str] + password: NotRequired[str] + url: NotRequired[str] + pin: NotRequired[str] + phone: NotRequired[List[str]] + + +class WriteableReminderOverride(TypedDict): + """ + Interface representing a writable reminder override object. + + Attributes: + reminder_minutes: The user's preferred Event reminder time, in minutes. + Reminder minutes are in the following format: "[20]". + reminder_method: The user's preferred method for Event reminders (Google only). + """ + + reminder_minutes: NotRequired[int] + reminder_method: NotRequired[str] + + +class CreateReminders(TypedDict): + """ + Interface representing a reminder object for event creation. + + Attributes: + use_default: Whether to use the default reminder settings for the calendar. + overrides: A list of reminders for the event if use_default is set to false. + If left empty or omitted while use_default is set to false, the event will have no reminders. + """ + + use_default: bool + overrides: NotRequired[List[WriteableReminderOverride]] + + +class UpdateReminders(TypedDict): + """ + Interface representing a reminder object for updating an event. + + Attributes: + use_default: Whether to use the default reminder settings for the calendar. + overrides: A list of reminders for the event if use_default is set to false. + If left empty or omitted while use_default is set to false, the event will have no reminders. + """ + + use_default: NotRequired[bool] + overrides: NotRequired[List[WriteableReminderOverride]] + + +class CreateDetails(TypedDict): + """ + Interface representing a conferencing details object for event creation + + Attributes: + provider: The conferencing provider + details: The conferencing details + """ + + provider: ConferencingProvider + details: WritableDetailsConfig + + +class UpdateDetails(TypedDict): + """ + Interface representing a conferencing details object for updating an event + + Attributes: + provider: The conferencing provider + details: The conferencing details + """ + + provider: NotRequired[ConferencingProvider] + details: NotRequired[WritableDetailsConfig] + + +class CreateAutocreate(TypedDict): + """ + Interface representing a conferencing autocreate object for event creation + + Attributes: + provider: The conferencing provider + autocreate: Empty dict to indicate an intention to autocreate a video link. + Additional provider settings may be included in autocreate.settings, but Nylas does not validate these. + """ + + provider: ConferencingProvider + autocreate: Dict[str, Any] + + +class UpdateAutocreate(TypedDict): + """ + Interface representing a conferencing autocreate object for event creation + + Attributes: + provider: The conferencing provider + autocreate: Empty dict to indicate an intention to autocreate a video link. + Additional provider settings may be included in autocreate.settings, but Nylas does not validate these. + """ + + provider: NotRequired[ConferencingProvider] + autocreate: NotRequired[Dict[str, Any]] + + +CreateConferencing = Union[CreateDetails, CreateAutocreate] +""" Union type representing the different types of conferencing configurations for Event creation. """ + +UpdateConferencing = Union[UpdateDetails, UpdateAutocreate] +""" Union type representing the different types of conferencing configurations for updating an Event.""" + + +# When +class CreateTime(TypedDict): + """ + Interface representing a specific point in time for event creation. + A meeting at 2pm would be represented as a time subobject. + + Attributes: + time: A UNIX timestamp representing the time of occurrence. + timezone: If timezone is present, then the value for time will be read with timezone. + Timezone using IANA formatted string. (e.g. "America/New_York") + """ + + time: int + timezone: NotRequired[str] + + +class UpdateTime(TypedDict): + """ + Interface representing a specific point in time for updating an event. + A meeting at 2pm would be represented as a time subobject. + + Attributes: + time: A UNIX timestamp representing the time of occurrence. + timezone: If timezone is present, then the value for time will be read with timezone. + Timezone using IANA formatted string. (e.g. "America/New_York") + """ + + time: NotRequired[int] + timezone: NotRequired[str] + + +class CreateTimespan(TypedDict): + """ + Interface representing a time span with start and end times for event creation. + An hour lunch meeting would be represented as timespan subobjects. + + Attributes: + start_time: The start time of the event. + end_time: The end time of the event. + start_timezone: The timezone of the start time. Timezone using IANA formatted string. (e.g. "America/New_York") + end_timezone: The timezone of the end time. Timezone using IANA formatted string. (e.g. "America/New_York") + """ + + start_time: int + end_time: int + start_timezone: NotRequired[str] + end_timezone: NotRequired[str] + + +class UpdateTimespan(TypedDict): + """ + Interface representing a time span with start and end times for updating an event. + An hour lunch meeting would be represented as timespan subobjects. + + Attributes: + start_time: The start time of the event. + end_time: The end time of the event. + start_timezone: The timezone of the start time. Timezone using IANA formatted string. (e.g. "America/New_York") + end_timezone: The timezone of the end time. Timezone using IANA formatted string. (e.g. "America/New_York") + """ + + start_time: NotRequired[int] + end_time: NotRequired[int] + start_timezone: NotRequired[str] + end_timezone: NotRequired[str] + + +class CreateDate(TypedDict): + """ + Interface representing an entire day spans without specific times for event creation. + Your birthday and holidays would be represented as date subobjects. + + Attributes: + date: Date of occurrence in ISO 8601 format. + """ + + date: str + + +class UpdateDate(TypedDict): + """ + Interface representing an entire day spans without specific times for updating an event. + Your birthday and holidays would be represented as date subobjects. + + Attributes: + date: Date of occurrence in ISO 8601 format. + """ + + date: NotRequired[str] + + +class CreateDatespan(TypedDict): + """ + Interface representing a specific dates without clock-based start or end times for event creation. + A business quarter or academic semester would be represented as datespan subobjects. + + Attributes: + start_date: The start date in ISO 8601 format. + end_date: The end date in ISO 8601 format. + """ + + start_date: str + end_date: str + + +class UpdateDatespan(TypedDict): + """ + Interface representing a specific dates without clock-based start or end times for updating an event. + A business quarter or academic semester would be represented as datespan subobjects. + + Attributes: + start_date: The start date in ISO 8601 format. + end_date: The end date in ISO 8601 format. + """ + + start_date: NotRequired[str] + end_date: NotRequired[str] + + +CreateWhen = Union[CreateTime, CreateTimespan, CreateDate, CreateDatespan] +""" Union type representing the different types of event time configurations for Event creation. """ + +UpdateWhen = Union[UpdateTime, UpdateTimespan, UpdateDate, UpdateDatespan] +""" Union type representing the different types of event time configurations for updating an Event.""" + + +class CreateEventRequest(TypedDict): + """ + Interface representing a request to create an event. + + Attributes: + when: When the event occurs. + title: The title of the event. + busy: Whether the event is busy or free. + description: The description of the event. + location: The location of the event. + conferencing: The conferencing details of the event. + reminders: A list of reminders to send for the event. + If left empty or omitted, the event uses the provider defaults. + metadata: Metadata associated with the event. + participants: The participants of the event. + recurrence: The recurrence rules of the event. + visibility: The visibility of the event. + capacity: The capacity of the event. + hide_participants: Whether to hide participants of the event. + """ + + when: CreateWhen + title: NotRequired[str] + busy: NotRequired[bool] + description: NotRequired[str] + location: NotRequired[str] + conferencing: NotRequired[CreateConferencing] + reminders: NotRequired[CreateReminders] + metadata: NotRequired[Dict[str, Any]] + participants: NotRequired[List[CreateParticipant]] + recurrence: NotRequired[List[str]] + visibility: NotRequired[Visibility] + capacity: NotRequired[int] + hide_participants: NotRequired[bool] + + +class UpdateEventRequest(TypedDict): + """ + Interface representing a request to update an event. + + Attributes: + when: When the event occurs. + title: The title of the event. + busy: Whether the event is busy or free. + description: The description of the event. + location: The location of the event. + conferencing: The conferencing details of the event. + reminders: A list of reminders to send for the event. + metadata: Metadata associated with the event. + participants: The participants of the event. + recurrence: The recurrence rules of the event. + visibility: The visibility of the event. + capacity: The capacity of the event. + hide_participants: Whether to hide participants of the event. + """ + + when: NotRequired[UpdateWhen] + title: NotRequired[str] + busy: NotRequired[bool] + description: NotRequired[str] + location: NotRequired[str] + conferencing: NotRequired[UpdateConferencing] + reminders: NotRequired[UpdateReminders] + metadata: NotRequired[Dict[str, Any]] + participants: NotRequired[List[UpdateParticipant]] + recurrence: NotRequired[List[str]] + visibility: NotRequired[Visibility] + capacity: NotRequired[int] + hide_participants: NotRequired[bool] + + +class ListEventQueryParams(ListQueryParams): + """ + Interface representing the query parameters for listing events. + + Attributes: + show_cancelled: Return events that have a status of cancelled. + If an event is recurring, then it returns no matter the value set. + Different providers have different semantics for cancelled events. + calendar_id: Specify calendar ID of the event. "primary" is a supported value + indicating the user's primary calendar. + title: Return events matching the specified title. + description: Return events matching the specified description. + location: Return events matching the specified location. + start: Return events starting after the specified unix timestamp. + Defaults to the current timestamp. Not respected by metadata filtering. + end: Return events ending before the specified unix timestamp. + Defaults to a month from now. Not respected by metadata filtering. + metadata_pair: Pass in your metadata key and value pair to search for metadata. + expand_recurring: If true, the response will include an event for each occurrence of a recurring event within + the requested time range. + If false, only a single primary event will be returned for each recurring event. + Cannot be used when filtering on metadata. Defaults to false. + busy: Returns events with a busy status of true. + order_by: Order results by the specified field. + Currently only start is supported. + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + """ + + calendar_id: str + show_cancelled: NotRequired[bool] + title: NotRequired[str] + description: NotRequired[str] + location: NotRequired[str] + start: NotRequired[int] + end: NotRequired[int] + metadata_pair: NotRequired[Dict[str, Any]] + expand_recurring: NotRequired[bool] + busy: NotRequired[bool] + order_by: NotRequired[str] + + +class CreateEventQueryParams(TypedDict): + """ + Interface representing of the query parameters for creating an event. + + Attributes: + calendar_id: The ID of the calendar to create the event in. + notify_participants: Email notifications containing the calendar event is sent to all event participants. + """ + + calendar_id: str + notify_participants: NotRequired[bool] + + +class FindEventQueryParams(TypedDict): + """ + Interface representing of the query parameters for finding an event. + + Attributes: + calendar_id: Calendar ID to find the event in. + "primary" is a supported value indicating the user's primary calendar. + """ + + calendar_id: str + + +UpdateEventQueryParams = CreateEventQueryParams +""" Interface representing of the query parameters for updating an Event. """ + +DestroyEventQueryParams = CreateEventQueryParams +""" Interface representing of the query parameters for destroying an Event. """ + + +class SendRsvpQueryParams(TypedDict): + """ + Interface representing of the query parameters for an event. + + Attributes: + calendar_id: Calendar ID to find the event in. + "primary" is a supported value indicating the user's primary calendar. + """ + + calendar_id: str + + +class SendRsvpRequest(TypedDict): + """ + Interface representing a request to send an RSVP. + + Attributes: + status: The status of the RSVP. + """ + + status: SendRsvpStatus diff --git a/nylas/models/folders.py b/nylas/models/folders.py new file mode 100644 index 00000000..3b160a37 --- /dev/null +++ b/nylas/models/folders.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass +from typing import Optional + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + + +@dataclass_json +@dataclass +class Folder: + """ + Class representing a Nylas folder. + + Attributes: + id: A globally unique object identifier. + grant_id: A Grant ID of the Nylas account. + name: Folder name + object: The type of object. + parent_id: ID of the parent folder. (Microsoft only) + background_color: Folder background color. (Google only) + text_color: Folder text color. (Google only) + system_folder: Indicates if the folder is user created or system created. (Google Only) + child_count: The number of immediate child folders in the current folder. (Microsoft only) + unread_count: The number of unread items inside of a folder. + total_count: The number of items inside of a folder. + """ + + id: str + grant_id: str + name: str + object: str = "folder" + parent_id: Optional[str] = None + background_color: Optional[str] = None + text_color: Optional[str] = None + system_folder: Optional[bool] = None + child_count: Optional[int] = None + unread_count: Optional[int] = None + total_count: Optional[int] = None + + +class CreateFolderRequest(TypedDict): + """ + Class representation of the Nylas folder creation request. + + Attributes: + name: The name of the folder. + parent_id: The parent ID of the folder. (Microsoft only) + background_color: The background color of the folder. (Google only) + text_color: The text color of the folder. (Google only) + """ + + name: str + parent_id: NotRequired[str] + background_color: NotRequired[str] + text_color: NotRequired[str] + + +class UpdateFolderRequest(TypedDict): + """ + Class representation of the Nylas folder update request. + + Attributes: + name: The name of the folder. + parent_id: The parent ID of the folder. (Microsoft only) + background_color: The background color of the folder. (Google only) + text_color: The text color of the folder. (Google only) + """ + + name: NotRequired[str] + parent_id: NotRequired[str] + background_color: NotRequired[str] + text_color: NotRequired[str] diff --git a/nylas/models/free_busy.py b/nylas/models/free_busy.py new file mode 100644 index 00000000..680ddd2e --- /dev/null +++ b/nylas/models/free_busy.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass +from typing import List, Union + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict + + +@dataclass_json +@dataclass +class FreeBusyError: + """ + Interface for a Nylas free/busy call error + + Attributes: + email: The email address of the participant who had an error. + error: The provider's error message. + """ + + email: str + error: str + + +@dataclass_json +@dataclass +class TimeSlot: + """ + Interface for a Nylas free/busy time slot + + Attributes: + start_time: Unix timestamp for the start of the slot. + end_time: Unix timestamp for the end of the slot. + status: The status of the slot. Typically "busy" + """ + + start_time: int + end_time: int + status: str + + +@dataclass_json +@dataclass +class FreeBusy: + """ + Interface for an individual Nylas free/busy response + + Attributes: + email: The email address of the participant. + time_slots: List of time slots for the participant. + """ + + email: str + time_slots: List[TimeSlot] + + +GetFreeBusyResponse = List[Union[FreeBusy, FreeBusyError]] +""" Interface for a Nylas get free/busy response """ + + +class GetFreeBusyRequest(TypedDict): + """ + Interface for a Nylas get free/busy request + + Attributes: + start_time: Unix timestamp for the start time to check free/busy for. + end_time: Unix timestamp for the end time to check free/busy for. + emails: List of email addresses to check free/busy for. + """ + + start_time: int + end_time: int + emails: List[str] diff --git a/nylas/models/grants.py b/nylas/models/grants.py new file mode 100644 index 00000000..e9be3ff8 --- /dev/null +++ b/nylas/models/grants.py @@ -0,0 +1,102 @@ +from dataclasses import dataclass, field +from typing import List, Any, Dict, Optional + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +from nylas.models.auth import Provider + + +@dataclass_json +@dataclass +class Grant: + """ + Interface representing a Nylas Grant object. + + Attributes: + id: Globally unique object identifier. + provider: OAuth provider that the user authenticated with. + scope: Scopes specified for the grant. + created_at: Unix timestamp when the grant was created. + grant_status: Status of the grant, if it is still valid or if the user needs to re-authenticate. + email: Email address associated with the grant. + user_agent: End user's client user agent. + ip: End user's client IP address. + state: Initial state that was sent as part of the OAuth request. + updated_at: Unix timestamp when the grant was updated. + provider_user_id: Provider's ID for the user this grant is associated with. + settings: Settings required by the provider that were sent as part of the OAuth request. + """ + + id: str + provider: str + scope: List[str] = field(default_factory=list) + grant_status: Optional[str] = None + email: Optional[str] = None + user_agent: Optional[str] = None + ip: Optional[str] = None + state: Optional[str] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None + provider_user_id: Optional[str] = None + settings: Optional[Dict[str, Any]] = None + + +class CreateGrantRequest(TypedDict): + """ + Interface representing a request to create a grant. + + Attributes: + provider: OAuth provider. + settings: Settings required by provider. + state: Optional state value to return to developer's website after authentication flow is completed. + scope: Optional list of scopes to request. If not specified it will use the integration default scopes. + """ + + provider: Provider + settings: Dict[str, Any] + state: NotRequired[str] + scope: NotRequired[List[str]] + + +class UpdateGrantRequest(TypedDict): + """ + Interface representing a request to update a grant. + + Attributes: + settings: Settings required by provider. + scope: List of integration scopes for the grant. + """ + + settings: NotRequired[Dict[str, Any]] + scope: NotRequired[List[str]] + + +class ListGrantsQueryParams(TypedDict): + """ + Interface representing the query parameters for listing grants. + + Attributes: + limit: The maximum number of objects to return. + This field defaults to 10. The maximum allowed value is 200. + offset: Offset grant results by this number. + sortBy: Sort entries by field name + orderBy: Specify ascending or descending order. + since: Scope grants from a specific point in time by Unix timestamp. + before: Scope grants to a specific point in time by Unix timestamp. + email: Filtering your query based on grant email address (if applicable) + grantStatus: Filtering your query based on grant email status (if applicable) + ip: Filtering your query based on grant IP address + provider: Filtering your query based on OAuth provider + """ + + limit: NotRequired[int] + offset: NotRequired[int] + sortBy: NotRequired[str] + orderBy: NotRequired[str] + since: NotRequired[int] + before: NotRequired[int] + email: NotRequired[str] + grantStatus: NotRequired[str] + ip: NotRequired[str] + provider: NotRequired[Provider] diff --git a/nylas/models/list_query_params.py b/nylas/models/list_query_params.py new file mode 100644 index 00000000..e865d9cc --- /dev/null +++ b/nylas/models/list_query_params.py @@ -0,0 +1,16 @@ +from typing_extensions import TypedDict, NotRequired + + +class ListQueryParams(TypedDict): + """ + Interface of the query parameters for listing resources. + + Attributes: + limit: The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token: An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + """ + + limit: NotRequired[int] + page_token: NotRequired[str] diff --git a/nylas/models/messages.py b/nylas/models/messages.py new file mode 100644 index 00000000..bc7f5313 --- /dev/null +++ b/nylas/models/messages.py @@ -0,0 +1,213 @@ +from dataclasses import dataclass, field +from typing import List, Literal, Optional, Dict, Any +from dataclasses_json import dataclass_json, config +from typing_extensions import TypedDict, NotRequired, get_type_hints + +from nylas.models.attachments import Attachment +from nylas.models.list_query_params import ListQueryParams +from nylas.models.events import EmailName + + +Fields = Literal["standard", "include_headers"] +""" Literal representing which headers to include with a message. """ + + +@dataclass_json +@dataclass +class MessageHeader: + """ + A message header. + + Attributes: + name: The header name. + value: The header value. + """ + + name: str + value: str + + +@dataclass_json +@dataclass +class Message: + """ + A Message object. + + Attributes: + id: Globally unique object identifier. + grant_id: The grant that this message belongs to. + thread_id: The thread that this message belongs to. + subject: The subject of the message. + from_: The sender of the message. + object: The type of object. + to: The recipients of the message. + cc: The CC recipients of the message. + bcc: The BCC recipients of the message. + reply_to: The reply-to recipients of the message. + date: The date the message was received. + unread: Whether the message is unread. + starred: Whether the message is starred. + snippet: A snippet of the message body. + body: The body of the message. + attachments: The attachments on the message. + folders: The folders that the message is in. + headers: The headers of the message. + created_at: Unix timestamp of when the message was created. + """ + + grant_id: str + from_: List[EmailName] = field(metadata=config(field_name="from")) + object: str = "message" + id: Optional[str] = None + body: Optional[str] = None + thread_id: Optional[str] = None + subject: Optional[str] = None + snippet: Optional[str] = None + to: Optional[List[EmailName]] = None + bcc: Optional[List[EmailName]] = None + cc: Optional[List[EmailName]] = None + reply_to: Optional[List[EmailName]] = None + attachments: Optional[List[Attachment]] = None + folders: Optional[List[str]] = None + headers: Optional[List[MessageHeader]] = None + unread: Optional[bool] = None + starred: Optional[bool] = None + created_at: Optional[int] = None + date: Optional[int] = None + + +# Need to use Functional typed dicts because "from" and "in" are Python +# keywords, and can't be declared using the declarative syntax +ListMessagesQueryParams = TypedDict( + "ListMessagesQueryParams", + { + **get_type_hints(ListQueryParams), # Inherit fields from ListQueryParams + "subject": NotRequired[str], + "any_email": NotRequired[List[str]], + "from": NotRequired[List[str]], + "to": NotRequired[List[str]], + "cc": NotRequired[List[str]], + "bcc": NotRequired[List[str]], + "in": NotRequired[List[str]], + "unread": NotRequired[bool], + "starred": NotRequired[bool], + "thread_id": NotRequired[str], + "received_before": NotRequired[int], + "received_after": NotRequired[int], + "has_attachment": NotRequired[bool], + "fields": NotRequired[Fields], + "search_query_native": NotRequired[str], + }, +) +""" +Query parameters for listing messages. + +Attributes: + subject: Return messages with matching subject. + any_email: Return messages that have been sent or received by this comma-separated list of email addresses. + from: Return messages sent from this email address. + to: Return messages sent to this email address. + cc: Return messages cc'd to this email address. + bcc: Return messages bcc'd to this email address. + in: Return messages in this specific folder or label, specified by ID. + unread: Filter messages by unread status. + starred: Filter messages by starred status. + thread_id: Filter messages by thread_id. + received_before: Return messages with received dates before received_before. + received_after: Return messages with received dates after received_after. + has_attachment: Filter messages by whether they have an attachment. + fields: Specify "include_headers" to include headers in the response. "standard" is the default. + search_query_native: A native provider search query for Google or Microsoft. + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. +""" + + +class FindMessageQueryParams(TypedDict): + + """ + Query parameters for finding a message. + + Attributes: + fields: Specify "include_headers" to include headers in the response. "standard" is the default. + """ + + fields: NotRequired[Fields] + + +class UpdateMessageRequest(TypedDict): + + """ + Request payload for updating a message. + + Attributes: + starred: The message's starred status + unread: The message's unread status + folders: The message's folders + metadata: A list of key-value pairs storing additional data + """ + + unread: NotRequired[bool] + starred: NotRequired[bool] + folders: NotRequired[List[str]] + metadata: NotRequired[Dict[str, Any]] + + +@dataclass_json +@dataclass +class ScheduledMessageStatus: + """ + The status of a scheduled message. + + Attributes: + code: The status code the describes the state of the scheduled message. + description: A description of the status of the scheduled message. + """ + + code: str + description: str + + +@dataclass_json +@dataclass +class ScheduledMessage: + """ + A scheduled message. + + Attributes: + schedule_id: The unique identifier for the scheduled message. + status: The status of the scheduled message. + close_time: The time the message was sent or failed to send, in epoch time. + """ + + schedule_id: int + status: ScheduledMessageStatus + close_time: Optional[int] = None + + +@dataclass_json +@dataclass +class ScheduledMessagesList: + """ + A list of scheduled messages. + + Attributes: + schedules: The list of scheduled messages. + """ + + schedules: List[ScheduledMessage] + + +@dataclass_json +@dataclass +class StopScheduledMessageResponse: + """ + The response from stopping a scheduled message. + + Attributes: + message: A message describing the result of the request. + """ + + message: str diff --git a/nylas/models/redirect_uri.py b/nylas/models/redirect_uri.py new file mode 100644 index 00000000..21894027 --- /dev/null +++ b/nylas/models/redirect_uri.py @@ -0,0 +1,98 @@ +from dataclasses import dataclass +from typing import Optional + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + + +@dataclass_json +@dataclass +class RedirectUriSettings: + """ + Configuration settings for a Redirect URI object. + + Attributes: + origin: Related to JS platform. + bundle_id: Related to iOS platform. + app_store_id: Related to iOS platform. + team_id: Related to iOS platform. + package_name: Related to Android platform. + sha1_certificate_fingerprint: Related to Android platform. + """ + + origin: Optional[str] = None + bundle_id: Optional[str] = None + app_store_id: Optional[str] = None + team_id: Optional[str] = None + package_name: Optional[str] = None + sha1_certificate_fingerprint: Optional[str] = None + + +@dataclass_json +@dataclass +class RedirectUri: + """ + Class representing a Redirect URI object. + + Attributes: + id: Globally unique object identifier. + url: Redirect URL. + platform: Platform identifier. + settings: Configuration settings. + """ + + id: str + url: str + platform: str + settings: Optional[RedirectUriSettings] = None + + +class WritableRedirectUriSettings(TypedDict): + """ + Class representing redirect uri settings to be provided for a create/update call. + + Attributes: + origin: Optional origin for the redirect uri. + bundle_id: Optional bundle id for the redirect uri. + app_store_id: Optional app store id for the redirect uri. + team_id: Optional team id for the redirect uri. + package_name: Optional package name for the redirect uri. + sha1_certificate_fingerprint: Optional sha1 certificate fingerprint for the redirect uri. + """ + + origin: NotRequired[str] + bundle_id: NotRequired[str] + app_store_id: NotRequired[str] + team_id: NotRequired[str] + package_name: NotRequired[str] + sha1_certificate_fingerprint: NotRequired[str] + + +class CreateRedirectUriRequest(TypedDict): + """ + Class representing a request to create a redirect uri. + + Attributes: + url: Redirect URL. + platform: Platform identifier. + settings: Optional settings for the redirect uri. + """ + + url: str + platform: str + settings: NotRequired[WritableRedirectUriSettings] + + +class UpdateRedirectUriRequest(TypedDict): + """ + Class representing a request to update a redirect uri. + + Attributes: + url: Redirect URL. + platform: Platform identifier. + settings: Optional settings for the redirect uri. + """ + + url: NotRequired[str] + platform: NotRequired[str] + settings: NotRequired[WritableRedirectUriSettings] diff --git a/nylas/models/response.py b/nylas/models/response.py new file mode 100644 index 00000000..d00798c5 --- /dev/null +++ b/nylas/models/response.py @@ -0,0 +1,129 @@ +from dataclasses import dataclass +from typing import TypeVar, Generic, Optional, List + +from dataclasses_json import DataClassJsonMixin, dataclass_json + +T = TypeVar("T", bound=DataClassJsonMixin) + + +class Response(tuple, Generic[T]): + """ + Response object returned from the Nylas API. + + Attributes: + data: The requested data object. + request_id: The request ID. + """ + + data: T + request_id: str + + def __new__(cls, data: T, request_id: str): + """ + Initialize the response object. + + Args: + data: The requested data object. + request_id: The request ID. + """ + # Initialize the tuple for destructuring support + instance = super().__new__(cls, (data, request_id)) + + instance.data = data + instance.request_id = request_id + + return instance + + @classmethod + def from_dict(cls, resp: dict, generic_type): + """ + Convert a dictionary to a response object. + + Args: + resp: The dictionary to convert. + generic_type: The type to deserialize the data object into. + """ + + return cls( + data=generic_type.from_dict(resp["data"]), + request_id=resp["request_id"], + ) + + +class ListResponse(tuple, Generic[T]): + """ + List response object returned from the Nylas API. + + Attributes: + data: The list of requested data objects. + request_id: The request ID. + next_cursor: The cursor to use to get the next page of data. + """ + + data: List[T] + request_id: str + next_cursor: Optional[str] = None + + def __new__(cls, data: List[T], request_id: str, next_cursor: Optional[str] = None): + """ + Initialize the response object. + + Args: + data: The list of requested data objects. + request_id: The request ID. + next_cursor: The cursor to use to get the next page of data. + """ + # Initialize the tuple for destructuring support + instance = super().__new__(cls, (data, request_id, next_cursor)) + + instance.data = data + instance.request_id = request_id + instance.next_cursor = next_cursor + + return instance + + @classmethod + def from_dict(cls, resp: dict, generic_type): + """ + Convert a dictionary to a response object. + + Args: + resp: The dictionary to convert. + generic_type: The type to deserialize the data objects into. + """ + + converted_data = [] + for item in resp["data"]: + converted_data.append(generic_type.from_dict(item)) + + return cls( + data=converted_data, + request_id=resp["request_id"], + next_cursor=resp.get("next_cursor", None), + ) + + +@dataclass_json +@dataclass +class DeleteResponse: + """ + Delete response object returned from the Nylas API. + + Attributes: + request_id: The request ID returned from the API. + """ + + request_id: str + + +@dataclass_json +@dataclass +class RequestIdOnlyResponse: + """ + Response object returned from the Nylas API that only contains a request ID. + + Attributes: + request_id: The request ID returned from the API. + """ + + request_id: str diff --git a/nylas/models/smart_compose.py b/nylas/models/smart_compose.py new file mode 100644 index 00000000..05fa2a3a --- /dev/null +++ b/nylas/models/smart_compose.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from typing import TypedDict + +from dataclasses_json import dataclass_json + + +class ComposeMessageRequest(TypedDict): + """ + A request to compose a message. + + Attributes: + prompt: The prompt that smart compose will use to generate a message suggestion. + """ + + prompt: str + + +@dataclass_json +@dataclass +class ComposeMessageResponse: + """ + A response from composing a message. + + Attributes: + suggestion: The message suggestion generated by smart compose. + """ + + suggestion: str diff --git a/nylas/models/threads.py b/nylas/models/threads.py new file mode 100644 index 00000000..1485d3b0 --- /dev/null +++ b/nylas/models/threads.py @@ -0,0 +1,143 @@ +from dataclasses import dataclass, field +from typing import List, Optional, get_type_hints, Union +from typing_extensions import TypedDict, NotRequired + +from dataclasses_json import dataclass_json, config + +from nylas.models.drafts import Draft +from nylas.models.events import EmailName +from nylas.models.list_query_params import ListQueryParams + +from nylas.models.messages import Message + + +def _decode_draft_or_message(json: dict) -> Union[Message, Draft]: + """ + Decode a message/draft object into a python object. + + Args: + json: The message/draft object to decode. + + Returns: + The decoded message/draft object. + """ + if "object" not in json: + raise ValueError("Invalid when object, no 'object' field found.") + + if json["object"] == "draft": + return Draft.from_dict(json) + + if json["object"] == "message": + return Message.from_dict(json) + + raise ValueError(f"Invalid object, unknown 'object' field found: {json['object']}") + + +@dataclass_json +@dataclass +class Thread: + """ + A Thread object. + + Attributes: + id: Globally unique object identifier. + grant_id: The grant that this thread belongs to. + latest_draft_or_message: The latest draft or message in the thread. + has_attachment: Whether the thread has an attachment. + has_drafts: Whether the thread has drafts. + starred: A boolean indicating whether the thread is starred or not + unread: A boolean indicating whether the thread is read or not. + earliest_message_date: Unix timestamp of the earliest or first message in the thread. + latest_message_received_date: Unix timestamp of the most recent message received in the thread. + latest_message_sent_date: Unix timestamp of the most recent message sent in the thread. + participant: An array of participants in the thread. + message_ids: An array of message IDs in the thread. + draft_ids: An array of draft IDs in the thread. + folders: An array of folder IDs the thread appears in. + object: The type of object. + snippet: A short snippet of the last received message/draft body. + This is the first 100 characters of the message body, with any HTML tags removed. + subject: The subject of the thread. + """ + + id: str + grant_id: str + has_drafts: bool + starred: bool + unread: bool + earliest_message_date: int + message_ids: List[str] + folders: List[str] + latest_draft_or_message: Union[Message, Draft] = field( + metadata=config(decoder=_decode_draft_or_message) + ) + object: str = "thread" + latest_message_received_date: Optional[int] = None + draft_ids: Optional[List[str]] = None + snippet: Optional[str] = None + subject: Optional[str] = None + participants: Optional[List[EmailName]] = None + latest_message_sent_date: Optional[int] = None + has_attachments: Optional[bool] = None + + +class UpdateThreadRequest(TypedDict): + """ + A request to update a thread. + + Attributes: + starred: Sets all messages in the thread as starred or unstarred. + unread: Sets all messages in the thread as read or unread. + folders: The IDs of the folders to apply, overwriting all previous folders for all messages in the thread. + """ + + starred: NotRequired[bool] + unread: NotRequired[bool] + folders: NotRequired[List[str]] + + +# Need to use Functional typed dicts because "from" and "in" are Python +# keywords, and can't be declared using the declarative syntax +ListThreadsQueryParams = TypedDict( + "ListThreadsQueryParams", + { + **get_type_hints(ListQueryParams), # Inherit fields from ListQueryParams + "subject": NotRequired[str], + "any_email": NotRequired[str], + "from": NotRequired[str], + "to": NotRequired[str], + "cc": NotRequired[str], + "bcc": NotRequired[str], + "in": NotRequired[str], + "unread": NotRequired[bool], + "starred": NotRequired[bool], + "thread_id": NotRequired[str], + "latest_message_before": NotRequired[int], + "latest_message_after": NotRequired[int], + "has_attachment": NotRequired[bool], + "search_query_native": NotRequired[str], + }, +) +""" +Query parameters for listing threads. + +Attributes: + subject: Return threads with matching subject. + any_email: Return threads that have been sent or received by this comma-separated list of email addresses. + from: Return threads sent from this email address. + to: Return threads sent to this email address. + cc: Return threads cc'd to this email address. + bcc: Return threads bcc'd to this email address. + in: Return threads in this specific folder or label, specified by ID. + unread: Filter threads by unread status. + starred: Filter threads by starred status. + thread_id: Filter threads by thread_id. + latest_message_before: Return threads whose most recent message was received before this Unix timestamp. + latest_message_after: Return threads whose most recent message was received after this Unix timestamp. + has_attachment: Filter threads by whether they have an attachment. + search_query_native: A native provider search query for Google or Microsoft. + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. +""" diff --git a/nylas/models/webhooks.py b/nylas/models/webhooks.py new file mode 100644 index 00000000..9ff6e3b0 --- /dev/null +++ b/nylas/models/webhooks.py @@ -0,0 +1,143 @@ +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional, Literal + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +WebhookStatus = Literal["active", "failing", "failed", "pause"] +""" Literals representing the possible webhook statuses. """ + + +class WebhookTriggers(str, Enum): + """Enum representing the available webhook triggers.""" + + CALENDAR_CREATED = "calendar.created" + CALENDAR_UPDATED = "calendar.updated" + CALENDAR_DELETED = "calendar.deleted" + EVENT_CREATED = "event.created" + EVENT_UPDATED = "event.updated" + EVENT_DELETED = "event.deleted" + GRANT_CREATED = "grant.created" + GRANT_UPDATED = "grant.updated" + GRANT_DELETED = "grant.deleted" + GRANT_EXPIRED = "grant.expired" + MESSAGE_SEND_SUCCESS = "message.send_success" + MESSAGE_SEND_FAILED = "message.send_failed" + + +@dataclass_json +@dataclass +class Webhook: + """ + Class representing a Nylas webhook. + + Attributes: + id: Globally unique object identifier. + trigger_types: The event that triggers the webhook. + webhook_url: The URL to send webhooks to. + status: The status of the new destination. + notification_email_addresses: The email addresses that Nylas notifies when a webhook is down for a while. + status_updated_at: The time when the status field was last updated, represented as a Unix timestamp in seconds. + created_at: The time when the status field was created, represented as a Unix timestamp in seconds. + updated_at: The time when the status field was last updated, represented as a Unix timestamp in seconds. + description: A human-readable description of the webhook destination. + """ + + id: str + trigger_types: List[WebhookTriggers] + webhook_url: str + status: WebhookStatus + notification_email_addresses: List[str] + status_updated_at: int + created_at: int + updated_at: int + description: Optional[str] = None + + +class WebhookWithSecret(Webhook): + """ + Class representing a Nylas webhook with secret. + + Attributes: + webhook_secret: A secret value used to encode the X-Nylas-Signature header on webhook requests. + """ + + webhook_secret: str + + +@dataclass_json +@dataclass +class WebhookDeleteData: + """ + Class representing the object enclosing the webhook deletion status. + + Attributes: + status: The status of the webhook deletion. + """ + + status: str + + +@dataclass_json +@dataclass +class WebhookDeleteResponse: + """ + Class representing a Nylas webhook delete response. + + Attributes: + request_id: The request's ID. + data: Object containing the webhook deletion status. + """ + + request_id: str + data: Optional[WebhookDeleteData] = None + + +@dataclass_json +@dataclass +class WebhookIpAddressesResponse: + """ + Class representing the response for getting a list of webhook IP addresses. + + Attributes: + ip_addresses: The IP addresses that Nylas send your webhook from. + updated_at: Unix timestamp representing the time when Nylas last updated the list of IP addresses. + """ + + ip_addresses: List[str] + updated_at: int + + +class CreateWebhookRequest(TypedDict): + """ + Class representation of a Nylas create webhook request. + + Attributes: + trigger_types: List of events that triggers the webhook. + webhook_url: The url to send webhooks to. + description: A human-readable description of the webhook destination. + notification_email_addresses: The email addresses that Nylas notifies when a webhook is down for a while. + """ + + trigger_types: List[WebhookTriggers] + webhook_url: str + description: NotRequired[str] + notification_email_addresses: NotRequired[List[str]] + + +class UpdateWebhookRequest(TypedDict): + """ + Class representation of a Nylas update webhook request. + + Attributes: + trigger_types: List of events that triggers the webhook. + webhook_url: The url to send webhooks to. + description: A human-readable description of the webhook destination. + notification_email_addresses: The email addresses that Nylas notifies when a webhook is down for a while. + """ + + trigger_types: NotRequired[List[WebhookTriggers]] + webhook_url: NotRequired[str] + description: NotRequired[str] + notification_email_addresses: NotRequired[List[str]] diff --git a/nylas/resources/__init__.py b/nylas/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nylas/resources/applications.py b/nylas/resources/applications.py new file mode 100644 index 00000000..7f127276 --- /dev/null +++ b/nylas/resources/applications.py @@ -0,0 +1,36 @@ +from nylas.models.application_details import ApplicationDetails +from nylas.models.response import Response +from nylas.resources.redirect_uris import RedirectUris +from nylas.resources.resource import Resource + + +class Applications(Resource): + """ + Nylas Applications API + + The Nylas Applications API allows you to get information about your Nylas application. + You can also manage the redirect URIs associated with your application. + """ + + @property + def redirect_uris(self) -> RedirectUris: + """ + Manage Redirect URIs for your Nylas Application. + + Returns: + RedirectUris: The redirect URIs associated with your Nylas Application. + """ + return RedirectUris(self._http_client) + + def info(self) -> Response[ApplicationDetails]: + """ + Get the application information. + + Returns: + Response: The application information. + """ + + json_response = self._http_client._execute( + method="GET", path="/v3/applications" + ) + return Response.from_dict(json_response, ApplicationDetails) diff --git a/nylas/resources/attachments.py b/nylas/resources/attachments.py new file mode 100644 index 00000000..034307fb --- /dev/null +++ b/nylas/resources/attachments.py @@ -0,0 +1,104 @@ +from requests import Response + +from nylas.handler.api_resources import ( + FindableApiResource, +) +from nylas.models.attachments import Attachment, FindAttachmentQueryParams +from nylas.models.response import Response as NylasResponse + + +class Attachments( + FindableApiResource, +): + """ + Nylas Attachments API + + The Nylas Attachments API allows you to get metadata ot, and download attachments from messages. + """ + + def find( + self, + identifier: str, + attachment_id: str, + query_params: FindAttachmentQueryParams, + ) -> NylasResponse[Attachment]: + """ + Return metadata of an attachment. + + Args: + identifier: The identifier of the Grant to act upon. + attachment_id: The id of the attachment to retrieve. + query_params: The query parameters to include in the request. + + Returns: + The attachment metadata. + """ + return super().find( + path=f"/v3/grants/{identifier}/attachments/{attachment_id}", + response_type=Attachment, + query_params=query_params, + ) + + def download( + self, + identifier: str, + attachment_id: str, + query_params: FindAttachmentQueryParams, + ) -> Response: + """ + Download the attachment data. + + This function returns a raw response object to allow you the ability + to stream the file contents. The response object should be closed + after use to ensure the connection is closed. + + Args: + identifier: The identifier of the Grant to act upon. + attachment_id: The id of the attachment to download. + query_params: The query parameters to include in the request. + + Returns: + The Response object containing the file data. + + Example: + Here is an example of how to use this function when streaming: + + ```python + response = execute_request_raw_response(url, method, stream=True) + try: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + # Process each chunk + pass + finally: + response.close() # Ensure the response is closed + ``` + """ + return self._http_client._execute_download_request( + path=f"/v3/grants/{identifier}/attachments/{attachment_id}/download", + query_params=query_params, + stream=True, + ) + + def download_bytes( + self, + identifier: str, + attachment_id: str, + query_params: FindAttachmentQueryParams, + ) -> bytes: + """ + Download the attachment as a byte array. + + Args: + identifier: The identifier of the Grant to act upon. + attachment_id: The id of the attachment to download. + query_params: The query parameters to include in the request. + + Returns: + The raw file data. + """ + return self._http_client._execute_download_request( + path=f"/v3/grants/{identifier}/attachments/{attachment_id}/download", + query_params=query_params, + stream=False, + ) diff --git a/nylas/resources/auth.py b/nylas/resources/auth.py new file mode 100644 index 00000000..f5a55191 --- /dev/null +++ b/nylas/resources/auth.py @@ -0,0 +1,258 @@ +import base64 +import hashlib +import uuid + +from nylas.handler.http_client import _build_query_params +from nylas.models.grants import CreateGrantRequest, Grant + +from nylas.models.auth import ( + CodeExchangeResponse, + PkceAuthUrl, + TokenInfoResponse, + CodeExchangeRequest, + TokenExchangeRequest, + ProviderDetectResponse, + ProviderDetectParams, + URLForAuthenticationConfig, + URLForAdminConsentConfig, +) +from nylas.models.response import Response +from nylas.resources.resource import Resource + + +def _hash_pkce_secret(secret: str) -> str: + sha256_hash = hashlib.sha256(secret.encode()).hexdigest() + return base64.b64encode(sha256_hash.encode()).decode().rstrip("=") + + +def _build_query(config: dict) -> dict: + config["response_type"] = "code" + + if "access_type" not in config: + config["access_type"] = "online" + + if "scope" in config: + config["scope"] = " ".join(config["scope"]) + + return config + + +def _build_query_with_pkce(config: dict, secret_hash: str) -> dict: + params = _build_query(config) + + params["code_challenge"] = secret_hash + params["code_challenge_method"] = "s256" + + return params + + +def _build_query_with_admin_consent(config: dict) -> dict: + params = _build_query(config) + + params["response_type"] = "adminconsent" + + if "credential_id" in config: + params["credential_id"] = config["credential_id"] + + return params + + +class Auth(Resource): + """ + A collection of authentication related API endpoints + + These endpoints allow for various functionality related to authentication. + """ + + def url_for_oauth2(self, config: URLForAuthenticationConfig) -> str: + """ + Build the URL for authenticating users to your application via Hosted Authentication. + + Args: + config: The configuration for building the URL. + + Returns: + The URL for hosted authentication. + """ + query = _build_query(config) + + return self._url_auth_builder(query) + + def exchange_code_for_token( + self, request: CodeExchangeRequest + ) -> CodeExchangeResponse: + """ + Exchange an authorization code for an access token. + + Args: + request: The request parameters for the code exchange + + Returns: + Information about the Nylas application + """ + if "client_secret" not in request: + request["client_secret"] = self._http_client.api_key + + request_body = dict(request) + request_body["grant_type"] = "authorization_code" + + return self._get_token(request_body) + + def custom_authentication( + self, request_body: CreateGrantRequest + ) -> Response[Grant]: + """ + Create a Grant via Custom Authentication. + + Args: + request_body: The values to create the Grant with. + + Returns: + The created Grant. + """ + + json_response = self._http_client._execute( + method="POST", + path="/v3/connect/custom", + request_body=request_body, + ) + return Response.from_dict(json_response, Grant) + + def refresh_access_token( + self, request: TokenExchangeRequest + ) -> CodeExchangeResponse: + """ + Refresh an access token. + + Args: + request: The refresh token request. + + Returns: + The response containing the new access token. + """ + if "client_secret" not in request: + request["client_secret"] = self._http_client.api_key + + request_body = dict(request) + request_body["grant_type"] = "refresh_token" + + return self._get_token(request_body) + + def id_token_info(self, id_token: str) -> TokenInfoResponse: + """ + Get info about an ID token. + + Args: + id_token: The ID token to query. + + Returns: + The API response with the token information. + """ + + query_params = { + "id_token": id_token, + } + + return self._get_token_info(query_params) + + def validate_access_token(self, access_token: str) -> TokenInfoResponse: + """ + Get info about an access token. + + Args: + access_token: The access token to query. + + Returns: + The API response with the token information. + """ + + query_params = { + "access_token": access_token, + } + + return self._get_token_info(query_params) + + def url_for_oauth2_pkce(self, config: URLForAuthenticationConfig) -> PkceAuthUrl: + """ + Build the URL for authenticating users to your application via Hosted Authentication with PKCE. + + IMPORTANT: YOU WILL NEED TO STORE THE 'secret' returned to use it inside the CodeExchange flow + + Args: + config: The configuration for the authentication request. + + Returns: + The URL for hosted authentication with secret & hashed secret. + """ + secret = str(uuid.uuid4()) + secret_hash = _hash_pkce_secret(secret) + query = _build_query_with_pkce(config, secret_hash) + + return PkceAuthUrl(secret, secret_hash, self._url_auth_builder(query)) + + def url_for_admin_consent(self, config: URLForAdminConsentConfig) -> str: + """Build the URL for admin consent authentication for Microsoft. + + Args: + config: The configuration for the authentication request. + + Returns: + The URL for hosted authentication. + """ + config_with_provider = {"provider": "microsoft", **config} + query = _build_query_with_admin_consent(config_with_provider) + + return self._url_auth_builder(query) + + def revoke(self, token: str) -> True: + """Revoke a single access token. + + Args: + token: The access token to revoke. + + Returns: + True: If the token was revoked successfully. + """ + self._http_client._execute( + method="POST", + path="/v3/connect/revoke", + query_params={"token": token}, + ) + + return True + + def detect_provider( + self, params: ProviderDetectParams + ) -> Response[ProviderDetectResponse]: + """ + Detect provider from email address. + + Args: + params: The parameters to include in the request + + Returns: + The detected provider, if found. + """ + + json_response = self._http_client._execute( + method="POST", + path="/v3/providers/detect", + query_params=params, + ) + return Response.from_dict(json_response, ProviderDetectResponse) + + def _url_auth_builder(self, query: dict) -> str: + base = f"{self._http_client.api_server}/v3/connect/auth" + return _build_query_params(base, query) + + def _get_token(self, request_body: dict) -> CodeExchangeResponse: + json_response = self._http_client._execute( + method="POST", path="/v3/connect/token", request_body=request_body + ) + return CodeExchangeResponse.from_dict(json_response) + + def _get_token_info(self, query_params: dict) -> TokenInfoResponse: + json_response = self._http_client._execute( + method="GET", path="/v3/connect/tokeninfo", query_params=query_params + ) + return TokenInfoResponse.from_dict(json_response) diff --git a/nylas/resources/calendars.py b/nylas/resources/calendars.py new file mode 100644 index 00000000..af276614 --- /dev/null +++ b/nylas/resources/calendars.py @@ -0,0 +1,165 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.availability import GetAvailabilityResponse, GetAvailabilityRequest +from nylas.models.free_busy import GetFreeBusyResponse, GetFreeBusyRequest +from nylas.models.calendars import ( + Calendar, + CreateCalendarRequest, + UpdateCalendarRequest, + ListCalendarsQueryParams, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class Calendars( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Calendar API + + The Nylas calendar API allows you to create new calendars or manage existing ones, as well as getting + free/busy information for a calendar and getting availability for a calendar. + + A calendar can be accessed by one, or several people, and can contain events. + """ + + def list( + self, identifier: str, query_params: ListCalendarsQueryParams = None + ) -> ListResponse[Calendar]: + """ + Return all Calendars. + + Args: + identifier: The identifier of the Grant to act upon. + query_params: The query parameters to include in the request. + + Returns: + The list of Calendars. + """ + + return super().list( + path=f"/v3/grants/{identifier}/calendars", + query_params=query_params, + response_type=Calendar, + ) + + def find(self, identifier: str, calendar_id: str) -> Response[Calendar]: + """ + Return a Calendar. + + Args: + identifier: The identifier of the Grant to act upon. + calendar_id: The ID of the Calendar to retrieve. + Use "primary" to refer to the primary Calendar associated with the Grant. + + Returns: + The Calendar. + """ + return super().find( + path=f"/v3/grants/{identifier}/calendars/{calendar_id}", + response_type=Calendar, + ) + + def create( + self, identifier: str, request_body: CreateCalendarRequest + ) -> Response[Calendar]: + """ + Create a Calendar. + + Args: + identifier: The identifier of the Grant to act upon. + request_body: The values to create the Calendar with. + + Returns: + The created Calendar. + """ + return super().create( + path=f"/v3/grants/{identifier}/calendars", + response_type=Calendar, + request_body=request_body, + ) + + def update( + self, identifier: str, calendar_id: str, request_body: UpdateCalendarRequest + ) -> Response[Calendar]: + """ + Update a Calendar. + + Args: + identifier: The identifier of the Grant to act upon. + calendar_id: The ID of the Calendar to update. + Use "primary" to refer to the primary Calendar associated with the Grant. + request_body: The values to update the Calendar with. + + Returns: + The updated Calendar. + """ + return super().update( + path=f"/v3/grants/{identifier}/calendars/{calendar_id}", + response_type=Calendar, + request_body=request_body, + ) + + def destroy(self, identifier: str, calendar_id: str) -> DeleteResponse: + """ + Delete a Calendar. + + Args: + identifier: The identifier of the Grant to act upon. + calendar_id: The ID of the Calendar to delete. + Use "primary" to refer to the primary Calendar associated with the Grant. + + Returns: + The deletion response. + """ + return super().destroy(path=f"/v3/grants/{identifier}/calendars/{calendar_id}") + + def get_availability( + self, request_body: GetAvailabilityRequest + ) -> Response[GetAvailabilityResponse]: + """ + Get availability for a Calendar. + + Args: + request_body: The request body to send to the API. + + Returns: + Response: The availability response from the API. + """ + json_response = self._http_client._execute( + method="POST", + path="/v3/calendars/availability", + request_body=request_body, + ) + + return Response.from_dict(json_response, GetAvailabilityResponse) + + def get_free_busy( + self, identifier: str, request_body: GetFreeBusyRequest + ) -> Response[GetFreeBusyResponse]: + """ + Get free/busy info for a Calendar. + + Args: + identifier: The grant ID or email account to get free/busy for. + request_body: The request body to send to the API. + + Returns: + Response: The free/busy response from the API. + """ + json_response = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/calendars/free-busy", + request_body=request_body, + ) + + return Response.from_dict(json_response, GetFreeBusyResponse) diff --git a/nylas/resources/connectors.py b/nylas/resources/connectors.py new file mode 100644 index 00000000..72bacc70 --- /dev/null +++ b/nylas/resources/connectors.py @@ -0,0 +1,123 @@ +from nylas.resources.credentials import Credentials + +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.auth import Provider +from nylas.models.connectors import ( + ListConnectorQueryParams, + Connector, + CreateConnectorRequest, + UpdateConnectorRequest, +) +from nylas.models.response import ListResponse, Response, DeleteResponse + + +class Connectors( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Connectors API + + The Nylas Connectors API allows you to create new connectors or manage existing ones. + In Nylas, a connector (formerly called an "integration") stores information that allows your Nylas application + to connect to a third party services + """ + + @property + def credentials(self) -> Credentials: + """ + Access the Credentials API. + + Returns: + The Credentials API. + """ + return Credentials(self._http_client) + + def list( + self, query_params: ListConnectorQueryParams = None + ) -> ListResponse[Connector]: + """ + Return all Connectors. + + Args: + query_params: The query parameters to include in the request. + + Returns: + The list of Connectors. + """ + + return super().list( + path="/v3/connectors", response_type=Connector, query_params=query_params + ) + + def find(self, provider: Provider) -> Response[Connector]: + """ + Return a connector associated with the provider. + + Args: + provider: The provider associated to the connector to retrieve. + + Returns: + The Connector. + """ + return super().find( + path=f"/v3/connectors/{provider}", + response_type=Connector, + ) + + def create(self, request_body: CreateConnectorRequest) -> Response[Connector]: + """ + Create a connector. + + Args: + request_body: The values to create the connector with. + + Returns: + The created connector. + """ + return super().create( + path="/v3/connectors", + request_body=request_body, + response_type=Connector, + ) + + def update( + self, provider: Provider, request_body: UpdateConnectorRequest + ) -> Response[Connector]: + """ + Create a connector. + + Args: + provider: The provider associated to the connector to update. + request_body: The values to update the connector with. + + Returns: + The created connector. + """ + return super().update( + path=f"/v3/connectors/{provider}", + request_body=request_body, + response_type=Connector, + method="PATCH", + ) + + def destroy(self, provider: Provider) -> DeleteResponse: + """ + Delete a connector. + + Args: + provider: The provider associated to the connector to delete. + + Returns: + The deleted connector. + """ + return super().destroy(path=f"/v3/connectors/{provider}") diff --git a/nylas/resources/contacts.py b/nylas/resources/contacts.py new file mode 100644 index 00000000..6e11411e --- /dev/null +++ b/nylas/resources/contacts.py @@ -0,0 +1,149 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.contacts import ( + Contact, + CreateContactRequest, + UpdateContactRequest, + ListContactsQueryParams, + FindContactQueryParams, + ListContactGroupsQueryParams, + ContactGroup, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class Contacts( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Contacts API + + The Contacts API allows you to manage contacts and contact groups for a user. + """ + + def list( + self, identifier: str, query_params: ListContactsQueryParams = None + ) -> ListResponse[Contact]: + """ + Return all Contacts. + + Attributes: + identifier: The identifier of the Grant to act upon. + query_params: The query parameters to include in the request. + + Returns: + The list of contacts. + """ + + return super().list( + path=f"/v3/grants/{identifier}/contacts", + query_params=query_params, + response_type=Contact, + ) + + def find( + self, + identifier: str, + contact_id: str, + query_params: FindContactQueryParams = None, + ) -> Response[Contact]: + """ + Return a Contact. + + Attributes: + identifier: The identifier of the Grant to act upon. + contact_id: The ID of the contact to retrieve. + query_params: The query parameters to include in the request. + + Returns: + The contact. + """ + return super().find( + path=f"/v3/grants/{identifier}/contacts/{contact_id}", + response_type=Contact, + query_params=query_params, + ) + + def create( + self, identifier: str, request_body: CreateContactRequest + ) -> Response[Contact]: + """ + Create a Contact. + + Attributes: + identifier: The identifier of the Grant to act upon. + request_body: The values to create the Contact with. + + Returns: + The created contact. + """ + return super().create( + path=f"/v3/grants/{identifier}/contacts", + response_type=Contact, + request_body=request_body, + ) + + def update( + self, identifier: str, contact_id: str, request_body: UpdateContactRequest + ) -> Response[Contact]: + """ + Update a Contact. + + Attributes: + identifier: The identifier of the Grant to act upon. + contact_id: The ID of the Contact to update. + Use "primary" to refer to the primary Contact associated with the Grant. + request_body: The values to update the Contact with. + + Returns: + The updated contact. + """ + return super().update( + path=f"/v3/grants/{identifier}/contacts/{contact_id}", + response_type=Contact, + request_body=request_body, + ) + + def destroy(self, identifier: str, contact_id: str) -> DeleteResponse: + """ + Delete a Contact. + + Attributes: + identifier: The identifier of the Grant to act upon. + contact_id: The ID of the Contact to delete. + Use "primary" to refer to the primary Contact associated with the Grant. + + Returns: + The deletion response. + """ + return super().destroy(path=f"/v3/grants/{identifier}/contacts/{contact_id}") + + def list_groups( + self, identifier: str, query_params: ListContactGroupsQueryParams = None + ) -> ListResponse[ContactGroup]: + """ + Return all contact groups. + + Attributes: + identifier: The identifier of the Grant to act upon. + query_params: The query parameters to include in the request. + + Returns: + The list of contact groups. + """ + json_response = self._http_client._execute( + method="GET", + path=f"/v3/grants/{identifier}/contacts/groups", + query_params=query_params, + ) + + return ListResponse.from_dict(json_response, ContactGroup) diff --git a/nylas/resources/credentials.py b/nylas/resources/credentials.py new file mode 100644 index 00000000..52e6797a --- /dev/null +++ b/nylas/resources/credentials.py @@ -0,0 +1,127 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.auth import Provider +from nylas.models.credentials import ( + Credential, + CredentialRequest, + ListCredentialQueryParams, + UpdateCredentialRequest, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class Credentials( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Credentials API + + + A Nylas connector credential is a special type of record that securely stores information + that allows you to connect using an administrator account + """ + + def list( + self, provider: Provider, query_params: ListCredentialQueryParams = None + ) -> ListResponse[Credential]: + """ + Return all credentials for a particular provider. + + Args: + provider: The provider. + query_params: The query parameters to include in the request. + + Returns: + The list of credentials. + """ + + return super().list( + path=f"/v3/connectors/{provider}/creds", + response_type=Credential, + query_params=query_params, + ) + + def find(self, provider: Provider, credential_id: str) -> Response[Credential]: + """ + Return a credential. + + Args: + provider: The provider of the credential. + credential_id: The ID of the credential to retrieve. + + Returns: + The Credential. + """ + + return super().find( + path=f"/v3/connectors/{provider}/creds/{credential_id}", + response_type=Credential, + ) + + def create( + self, provider: Provider, request_body: CredentialRequest + ) -> Response[Credential]: + """ + Create a credential for a particular provider. + + Args: + provider: The provider. + request_body: The values to create the Credential with. + + Returns: + The created Credential. + """ + + return super().create( + path=f"/v3/connectors/{provider}/creds", + response_type=Credential, + request_body=request_body, + ) + + def update( + self, + provider: Provider, + credential_id: str, + request_body: UpdateCredentialRequest, + ) -> Response[Credential]: + """ + Update a credential. + + Args: + provider: The provider. + credential_id: The ID of the credential to update. + request_body: The values to update the credential with. + + Returns: + The updated credential. + """ + + return super().update( + path=f"/v3/connectors/{provider}/creds/{credential_id}", + response_type=Credential, + request_body=request_body, + method="PATCH", + ) + + def destroy(self, provider: Provider, credential_id: str) -> DeleteResponse: + """ + Delete a credential. + + Args: + provider: the provider for the grant + credential_id: The ID of the credential to delete. + + Returns: + The deletion response. + """ + + return super().destroy(path=f"/v3/connectors/{provider}/creds/{credential_id}") diff --git a/nylas/resources/drafts.py b/nylas/resources/drafts.py new file mode 100644 index 00000000..439d581d --- /dev/null +++ b/nylas/resources/drafts.py @@ -0,0 +1,147 @@ +from typing import Optional + +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, + CreatableApiResource, +) +from nylas.models.drafts import ( + ListDraftsQueryParams, + Draft, + UpdateDraftRequest, + CreateDraftRequest, +) +from nylas.models.messages import Message +from nylas.models.response import ListResponse, Response, DeleteResponse +from nylas.utils.file_utils import _build_form_request + + +class Drafts( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Draft API + + The Drafts API allows you to create, read, update, and delete drafts and send them as messages. + """ + + def list( + self, identifier: str, query_params: Optional[ListDraftsQueryParams] = None + ) -> ListResponse[Draft]: + """ + Return all Drafts. + + Args: + identifier: The identifier of the grant to get drafts for. + query_params: The query parameters to filter drafts by. + + Returns: + A list of Drafts. + """ + return super().list( + path=f"/v3/grants/{identifier}/drafts", + response_type=Draft, + query_params=query_params, + ) + + def find( + self, + identifier: str, + draft_id: str, + ) -> Response[Draft]: + """ + Return a Draft. + + Args: + identifier: The identifier of the grant to get the draft for. + draft_id: The identifier of the draft to get. + + Returns: + The requested Draft. + """ + return super().find( + path=f"/v3/grants/{identifier}/drafts/{draft_id}", + response_type=Draft, + ) + + def create( + self, identifier: str, request_body: CreateDraftRequest + ) -> Response[Draft]: + """ + Create a Draft. + + Args: + identifier: The identifier of the grant to send the message for. + request_body: The request body to create a draft with. + + Returns: + The newly created Draft. + """ + json_response = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/drafts", + data=_build_form_request(request_body), + ) + + return Response.from_dict(json_response, Draft) + + def update( + self, + identifier: str, + draft_id: str, + request_body: UpdateDraftRequest, + ) -> Response[Draft]: + """ + Update a Draft. + + Args: + identifier: The identifier of the grant to update the draft for. + draft_id: The identifier of the draft to update. + request_body: The request body to update the draft with. + + Returns: + The updated Draft. + """ + json_response = self._http_client._execute( + method="PUT", + path=f"/v3/grants/{identifier}/drafts/{draft_id}", + data=_build_form_request(request_body), + ) + + return Response.from_dict(json_response, Draft) + + def destroy(self, identifier: str, draft_id: str) -> DeleteResponse: + """ + Delete a Draft. + + Args: + identifier: The identifier of the grant to delete the draft for. + draft_id: The identifier of the draft to delete. + + Returns: + The deletion response. + """ + return super().destroy( + path=f"/v3/grants/{identifier}/drafts/{draft_id}", + ) + + def send(self, identifier: str, draft_id: str) -> Response[Message]: + """ + Send a Draft. + + Args: + identifier: The identifier of the grant to send the draft for. + draft_id: The identifier of the draft to send. + """ + json_response = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/drafts/{draft_id}", + ) + + return Response.from_dict(json_response, Message) diff --git a/nylas/resources/events.py b/nylas/resources/events.py new file mode 100644 index 00000000..fe23650d --- /dev/null +++ b/nylas/resources/events.py @@ -0,0 +1,179 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.events import ( + Event, + UpdateEventRequest, + CreateEventRequest, + FindEventQueryParams, + ListEventQueryParams, + CreateEventQueryParams, + UpdateEventQueryParams, + DestroyEventQueryParams, + SendRsvpQueryParams, + SendRsvpRequest, +) +from nylas.models.response import ( + Response, + ListResponse, + DeleteResponse, + RequestIdOnlyResponse, +) + + +class Events( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Events API + + The Events API allows you to find, create, update, and delete events on any calendar on your Nylas account. + """ + + def list( + self, identifier: str, query_params: ListEventQueryParams + ) -> ListResponse[Event]: + """ + Return all Events. + + Args: + identifier: The identifier of the Grant to act upon. + query_params: The query parameters to include in the request. + + Returns: + The list of Events. + """ + + return super().list( + path=f"/v3/grants/{identifier}/events", + response_type=Event, + query_params=query_params, + ) + + def find( + self, identifier: str, event_id: str, query_params: FindEventQueryParams + ) -> Response[Event]: + """ + Return an Event. + + Args: + identifier: The identifier of the Grant to act upon. + event_id: The ID of the Event to retrieve. + query_params: The query parameters to include in the request. + + Returns: + The Event. + """ + + return super().find( + path=f"/v3/grants/{identifier}/events/{event_id}", + response_type=Event, + query_params=query_params, + ) + + def create( + self, + identifier: str, + request_body: CreateEventRequest, + query_params: CreateEventQueryParams, + ) -> Response[Event]: + """ + Create an Event. + + Args: + identifier: The identifier of the Grant to act upon. + request_body: The values to create the Event with. + query_params: The query parameters to include in the request. + + Returns: + The created Event. + """ + + return super().create( + path=f"/v3/grants/{identifier}/events", + response_type=Event, + request_body=request_body, + query_params=query_params, + ) + + def update( + self, + identifier: str, + event_id: str, + request_body: UpdateEventRequest, + query_params: UpdateEventQueryParams, + ) -> Response[Event]: + """ + Update an Event. + + Args: + identifier: The identifier of the Grant to act upon. + event_id: The ID of the Event to update. + request_body: The values to update the Event with. + query_params: The query parameters to include in the request. + + Returns: + The updated Event. + """ + + return super().update( + path=f"/v3/grants/{identifier}/events/{event_id}", + response_type=Event, + request_body=request_body, + query_params=query_params, + ) + + def destroy( + self, identifier: str, event_id: str, query_params: DestroyEventQueryParams + ) -> DeleteResponse: + """ + Delete an Event. + + Args: + identifier: The identifier of the Grant to act upon. + event_id: The ID of the Event to delete. + query_params: The query parameters to include in the request. + + Returns: + The deletion response. + """ + + return super().destroy( + path=f"/v3/grants/{identifier}/events/{event_id}", + query_params=query_params, + ) + + def send_rsvp( + self, + identifier: str, + event_id: str, + request_body: SendRsvpRequest, + query_params: SendRsvpQueryParams, + ) -> RequestIdOnlyResponse: + """Send RSVP for an event. + + Args: + identifier: The grant ID or email account to send RSVP for. + event_id: The event ID to send RSVP for. + query_params: The query parameters to send to the API. + request_body: The request body to send to the API. + + Returns: + Response: The RSVP response from the API. + """ + json_response = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/events/{event_id}/send-rsvp", + query_params=query_params, + request_body=request_body, + ) + + return RequestIdOnlyResponse.from_dict(json_response) diff --git a/nylas/resources/folders.py b/nylas/resources/folders.py new file mode 100644 index 00000000..9517a64a --- /dev/null +++ b/nylas/resources/folders.py @@ -0,0 +1,111 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.folders import ( + Folder, + CreateFolderRequest, + UpdateFolderRequest, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class Folders( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Folders API + + The Nylas folders API allows you to create new folders or manage existing ones. + """ + + def list(self, identifier: str) -> ListResponse[Folder]: + """ + Return all Folders. + + Args: + identifier: The identifier of the Grant to act upon. + + Returns: + The list of Folders. + """ + + return super().list( + path=f"/v3/grants/{identifier}/folders", + response_type=Folder, + ) + + def find(self, identifier: str, folder_id: str) -> Response[Folder]: + """ + Return a Folder. + + Args: + identifier: The identifier of the Grant to act upon. + folder_id: The ID of the Folder to retrieve. + + Returns: + The Folder. + """ + return super().find( + path=f"/v3/grants/{identifier}/folders/{folder_id}", + response_type=Folder, + ) + + def create( + self, identifier: str, request_body: CreateFolderRequest + ) -> Response[Folder]: + """ + Create a Folder. + + Args: + identifier: The identifier of the Grant to act upon. + request_body: The values to create the Folder with. + + Returns: + The created Folder. + """ + return super().create( + path=f"/v3/grants/{identifier}/folders", + response_type=Folder, + request_body=request_body, + ) + + def update( + self, identifier: str, folder_id: str, request_body: UpdateFolderRequest + ) -> Response[Folder]: + """ + Update a Folder. + + Args: + identifier: The identifier of the Grant to act upon. + folder_id: The ID of the Folder to update. + request_body: The values to update the Folder with. + + Returns: + The updated Folder. + """ + return super().update( + path=f"/v3/grants/{identifier}/folders/{folder_id}", + response_type=Folder, + request_body=request_body, + ) + + def destroy(self, identifier: str, folder_id: str) -> DeleteResponse: + """ + Delete a Folder. + + Args: + identifier: The identifier of the Grant to act upon. + folder_id: The ID of the Folder to delete. + + Returns: + The deletion response. + """ + return super().destroy(path=f"/v3/grants/{identifier}/folders/{folder_id}") diff --git a/nylas/resources/grants.py b/nylas/resources/grants.py new file mode 100644 index 00000000..6571fce2 --- /dev/null +++ b/nylas/resources/grants.py @@ -0,0 +1,89 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.grants import ( + Grant, + ListGrantsQueryParams, + UpdateGrantRequest, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class Grants( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Grants API + + The Grants API allows you to find and manage existing grants for your Nylas application. + + Grants represent a specific set of permissions ("scopes") that a specific end user granted Nylas + for a specific service provider + """ + + def list(self, query_params: ListGrantsQueryParams = None) -> ListResponse[Grant]: + """ + Return all Grants. + + Args: + query_params: The query parameters to include in the request. + + Returns: + A list of Grants. + """ + + return super().list( + path="/v3/grants", response_type=Grant, query_params=query_params + ) + + def find(self, grant_id: str) -> Response[Grant]: + """ + Return a Grant. + + Args: + grant_id: The ID of the Grant to retrieve. + + Returns: + The Grant. + """ + + return super().find(path=f"/v3/grants/{grant_id}", response_type=Grant) + + def update( + self, grant_id: str, request_body: UpdateGrantRequest + ) -> Response[Grant]: + """ + Update a Grant. + + Args: + grant_id: The ID of the Grant to update. + request_body: The values to update the Grant with. + + Returns: + The updated Grant. + """ + + return super().update( + path=f"/v3/grants/{grant_id}", + response_type=Grant, + request_body=request_body, + ) + + def destroy(self, grant_id: str) -> DeleteResponse: + """ + Delete a Grant. + + Args: + grant_id: The ID of the Grant to delete. + + Returns: + The deletion response. + """ + + return super().destroy(path=f"/v3/grants/{grant_id}") diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py new file mode 100644 index 00000000..a0fc0c86 --- /dev/null +++ b/nylas/resources/messages.py @@ -0,0 +1,206 @@ +from typing import Optional + +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.drafts import SendMessageRequest +from nylas.models.messages import ( + Message, + ListMessagesQueryParams, + FindMessageQueryParams, + UpdateMessageRequest, + ScheduledMessagesList, + ScheduledMessage, + StopScheduledMessageResponse, +) +from nylas.models.response import Response, ListResponse, DeleteResponse +from nylas.resources.smart_compose import SmartCompose +from nylas.utils.file_utils import _build_form_request + + +class Messages( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Messages API + + The messages API allows you to send, find, update, and delete messages. + You can also use the messages API to schedule messages to be sent at a later time. + The Smart Compose API, allowing you to generate email content using machine learning, is also available. + """ + + @property + def smart_compose(self) -> SmartCompose: + """ + Access the Smart Compose collection of endpoints. + + Returns: + The Smart Compose collection of endpoints. + """ + return SmartCompose(self._http_client) + + def list( + self, identifier: str, query_params: Optional[ListMessagesQueryParams] = None + ) -> ListResponse[Message]: + """ + Return all Messages. + + Args: + identifier: The identifier of the grant to get messages for. + query_params: The query parameters to filter messages by. + + Returns: + A list of Messages. + """ + return super().list( + path=f"/v3/grants/{identifier}/messages", + response_type=Message, + query_params=query_params, + ) + + def find( + self, + identifier: str, + message_id: str, + query_params: Optional[FindMessageQueryParams] = None, + ) -> Response[Message]: + """ + Return a Message. + + Args: + identifier: The identifier of the grant to get the message for. + message_id: The identifier of the message to get. + query_params: The query parameters to include in the request. + + Returns: + The requested Message. + """ + return super().find( + path=f"/v3/grants/{identifier}/messages/{message_id}", + response_type=Message, + query_params=query_params, + ) + + def update( + self, + identifier: str, + message_id: str, + request_body: UpdateMessageRequest, + ) -> Response[Message]: + """ + Update a Message. + + Args: + identifier: The identifier of the grant to update the message for. + message_id: The identifier of the message to update. + request_body: The request body to update the message with. + + Returns: + The updated Message. + """ + return super().update( + path=f"/v3/grants/{identifier}/messages/{message_id}", + response_type=Message, + request_body=request_body, + ) + + def destroy(self, identifier: str, message_id: str) -> DeleteResponse: + """ + Delete a Message. + + Args: + identifier: The identifier of the grant to delete the message for. + message_id: The identifier of the message to delete. + + Returns: + The deletion response. + """ + return super().destroy( + path=f"/v3/grants/{identifier}/messages/{message_id}", + ) + + def send( + self, identifier: str, request_body: SendMessageRequest + ) -> Response[Message]: + """ + Send a Message. + + Args: + identifier: The identifier of the grant to send the message for. + request_body: The request body to send the message with. + + Returns: + The sent message. + """ + json_response = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/messages/send", + data=_build_form_request(request_body), + ) + + return Response.from_dict(json_response, Message) + + def list_scheduled_messages( + self, identifier: str + ) -> Response[ScheduledMessagesList]: + """ + Retrieve your scheduled messages. + + Args: + identifier: The identifier of the grant to delete the message for. + + Returns: + Response: The list of scheduled messages. + """ + json_response = self._http_client._execute( + method="GET", + path=f"/v3/grants/{identifier}/messages/schedules", + ) + + return Response.from_dict(json_response, ScheduledMessagesList) + + def find_scheduled_message( + self, identifier: str, schedule_id: str + ) -> Response[ScheduledMessage]: + """ + Retrieve your scheduled messages. + + Args: + identifier: The identifier of the grant to delete the message for. + schedule_id: The id of the scheduled message to retrieve. + + Returns: + Response: The scheduled message. + """ + json_response = self._http_client._execute( + method="GET", + path=f"/v3/grants/{identifier}/messages/schedules/{schedule_id}", + ) + + return Response.from_dict(json_response, ScheduledMessage) + + def stop_scheduled_message( + self, identifier: str, schedule_id: str + ) -> Response[StopScheduledMessageResponse]: + """ + Stop a scheduled message. + + Args: + identifier: The identifier of the grant to delete the message for. + schedule_id: The id of the scheduled message to stop. + + Returns: + Response: The confirmation of the stopped scheduled message. + """ + json_response = self._http_client._execute( + method="DELETE", + path=f"/v3/grants/{identifier}/messages/schedules/{schedule_id}", + ) + + return Response.from_dict(json_response, StopScheduledMessageResponse) diff --git a/nylas/resources/redirect_uris.py b/nylas/resources/redirect_uris.py new file mode 100644 index 00000000..82f47117 --- /dev/null +++ b/nylas/resources/redirect_uris.py @@ -0,0 +1,105 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.redirect_uri import ( + RedirectUri, + CreateRedirectUriRequest, + UpdateRedirectUriRequest, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class RedirectUris( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Manage Redirect URIs for your Nylas Application. + + These endpoints allow you to create, update, and delete Redirect URIs for your Nylas Application. + """ + + def list(self) -> ListResponse[RedirectUri]: + """ + Return all Redirect URIs. + + Returns: + The list of Redirect URIs. + """ + + return super().list( + path="/v3/applications/redirect-uris", response_type=RedirectUri + ) + + def find(self, redirect_uri_id: str) -> Response[RedirectUri]: + """ + Return a Redirect URI. + + Args: + redirect_uri_id: The ID of the Redirect URI to retrieve. + + Returns: + The Redirect URI. + """ + + return super().find( + path=f"/v3/applications/redirect-uris/{redirect_uri_id}", + response_type=RedirectUri, + ) + + def create(self, request_body: CreateRedirectUriRequest) -> Response[RedirectUri]: + """ + Create a Redirect URI. + + Args: + request_body: The values to create the Redirect URI with. + + Returns: + The created Redirect URI. + """ + + return super().create( + path="/v3/applications/redirect-uris", + request_body=request_body, + response_type=RedirectUri, + ) + + def update( + self, redirect_uri_id: str, request_body: UpdateRedirectUriRequest + ) -> Response[RedirectUri]: + """ + Update a Redirect URI. + + Args: + redirect_uri_id: The ID of the Redirect URI to update. + request_body: The values to update the Redirect URI with. + + Returns: + The updated Redirect URI. + """ + + return super().update( + path=f"/v3/applications/redirect-uris/{redirect_uri_id}", + request_body=request_body, + response_type=RedirectUri, + ) + + def destroy(self, redirect_uri_id: str) -> DeleteResponse: + """ + Delete a Redirect URI. + + Args: + redirect_uri_id: The ID of the Redirect URI to delete. + + Returns: + The deletion response. + """ + + return super().destroy(path=f"/v3/applications/redirect-uris/{redirect_uri_id}") diff --git a/nylas/resources/resource.py b/nylas/resources/resource.py new file mode 100644 index 00000000..5880fef7 --- /dev/null +++ b/nylas/resources/resource.py @@ -0,0 +1,8 @@ +from nylas.handler.http_client import HttpClient + + +class Resource: + """Base class for all Nylas API resources.""" + + def __init__(self, http_client: HttpClient): + self._http_client = http_client diff --git a/nylas/resources/smart_compose.py b/nylas/resources/smart_compose.py new file mode 100644 index 00000000..c1f6ba7b --- /dev/null +++ b/nylas/resources/smart_compose.py @@ -0,0 +1,55 @@ +from nylas.models.response import Response + +from nylas.models.smart_compose import ComposeMessageRequest, ComposeMessageResponse +from nylas.resources.resource import Resource + + +class SmartCompose(Resource): + """ + A collection of Smart Compose related API endpoints. + + These endpoints allow for the generation of message suggestions. + """ + + def compose_message( + self, identifier: str, request_body: ComposeMessageRequest + ) -> Response[ComposeMessageResponse]: + """ + Compose a message. + + Args: + identifier: The identifier of the grant to generate a message suggestion for. + request_body: The prompt that smart compose will use to generate a message suggestion. + + Returns: + The generated message. + """ + res = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/messages/smart-compose", + request_body=request_body, + ) + + return Response.from_dict(res, ComposeMessageResponse) + + def compose_message_reply( + self, identifier: str, message_id: str, request_body: ComposeMessageRequest + ) -> ComposeMessageResponse: + """ + Compose a message reply. + + Args: + identifier: The identifier of the grant to generate a message suggestion for. + message_id: The id of the message to reply to. + request_body: The prompt that smart compose will use to generate a message reply suggestion. + + Returns: + The generated message reply. + """ + res = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/messages/{message_id}/smart-compose", + request_body=request_body, + ) + + return Response.from_dict(res, ComposeMessageResponse) diff --git a/nylas/resources/threads.py b/nylas/resources/threads.py new file mode 100644 index 00000000..2ff49adc --- /dev/null +++ b/nylas/resources/threads.py @@ -0,0 +1,94 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.response import ListResponse, Response, DeleteResponse +from nylas.models.threads import ListThreadsQueryParams, Thread, UpdateThreadRequest + + +class Threads( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Threads API + + The threads API allows you to find, update, and delete threads. + """ + + def list( + self, identifier: str, query_params: ListThreadsQueryParams = None + ) -> ListResponse[Thread]: + """ + Return all Threads. + + Args: + identifier: The identifier of the grant to get threads for. + query_params: The query parameters to filter threads by. + + Returns: + A list of Threads. + """ + return super().list( + path=f"/v3/grants/{identifier}/threads", + response_type=Thread, + query_params=query_params, + ) + + def find(self, identifier: str, thread_id: str) -> Response[Thread]: + """ + Return a Thread. + + Args: + identifier: The identifier of the grant to get the thread for. + thread_id: The identifier of the thread to get. + + Returns: + The requested Thread. + """ + return super().find( + path=f"/v3/grants/{identifier}/threads/{thread_id}", + response_type=Thread, + ) + + def update( + self, + identifier: str, + thread_id: str, + request_body: UpdateThreadRequest, + ) -> Response[Thread]: + """ + Update a Thread. + + Args: + identifier: The identifier of the grant to update the thread for. + thread_id: The identifier of the thread to update. + request_body: The request body to update the thread with. + + Returns: + The updated Thread. + """ + return super().update( + path=f"/v3/grants/{identifier}/threads/{thread_id}", + response_type=Thread, + request_body=request_body, + ) + + def destroy(self, identifier: str, thread_id: str) -> DeleteResponse: + """ + Delete a Thread. + + Args: + identifier: The identifier of the grant to delete the thread for. + thread_id: The identifier of the thread to delete. + + Returns: + The deletion response. + """ + return super().destroy( + path=f"/v3/grants/{identifier}/threads/{thread_id}", + ) diff --git a/nylas/resources/webhooks.py b/nylas/resources/webhooks.py new file mode 100644 index 00000000..0d1fb621 --- /dev/null +++ b/nylas/resources/webhooks.py @@ -0,0 +1,148 @@ +import urllib.parse + +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.response import Response, ListResponse +from nylas.models.webhooks import ( + Webhook, + WebhookWithSecret, + WebhookDeleteResponse, + WebhookIpAddressesResponse, + CreateWebhookRequest, + UpdateWebhookRequest, +) + + +class Webhooks( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Webhooks API + + The Nylas webhooks API allows you to manage webhook destinations for your Nylas application. + """ + + def list(self) -> ListResponse[Webhook]: + """ + List all webhook destinations + + Returns: + The list of webhook destinations + """ + return super().list(path="/v3/webhooks", response_type=Webhook) + + def find(self, webhook_id: str) -> Response[Webhook]: + """ + Get a webhook destination + + Parameters: + webhook_id: The ID of the webhook destination to get + + Returns: + The webhook destination + """ + return super().find(path=f"/v3/webhooks/{webhook_id}", response_type=Webhook) + + def create(self, request_body: CreateWebhookRequest) -> Response[WebhookWithSecret]: + """ + Create a webhook destination + + Parameters: + request_body: The request body to create the webhook destination + + Returns: + The created webhook destination + """ + return super().create( + path="/v3/webhooks", + request_body=request_body, + response_type=WebhookWithSecret, + ) + + def update( + self, webhook_id: str, request_body: UpdateWebhookRequest + ) -> Response[Webhook]: + """ + Update a webhook destination + + Parameters: + webhook_id: The ID of the webhook destination to update + request_body: The request body to update the webhook destination + + Returns: + The updated webhook destination + """ + return super().update( + path=f"/v3/webhooks/{webhook_id}", + request_body=request_body, + response_type=Webhook, + ) + + def destroy(self, webhook_id: str) -> WebhookDeleteResponse: + """ + Delete a webhook destination + + Parameters: + webhook_id: The ID of the webhook destination to delete + + Returns: + The response from deleting the webhook destination + """ + return super().destroy( + path=f"/v3/webhooks/{webhook_id}", response_type=WebhookDeleteResponse + ) + + def rotate_secret(self, webhook_id: str) -> Response[WebhookWithSecret]: + """ + Update the webhook secret value for a destination + + Parameters: + webhook_id: The ID of the webhook destination to update + + Returns: + The updated webhook destination + """ + res = self._http_client._execute( + method="PUT", + path=f"/v3/webhooks/{webhook_id}/rotate-secret", + request_body={}, + ) + return Response.from_dict(res, WebhookWithSecret) + + def ip_addresses(self) -> Response[WebhookIpAddressesResponse]: + """ + Get the current list of IP addresses that Nylas sends webhooks from + + Returns: + The list of IP addresses that Nylas sends webhooks from + """ + res = self._http_client._execute(method="GET", path="/v3/webhooks/ip-addresses") + return Response.from_dict(res, WebhookIpAddressesResponse) + + +def extract_challenge_parameter(url: str) -> str: + """ + Extract the challenge parameter from a URL + + Parameters: + url: The URL sent by Nylas containing the challenge parameter + + Returns: + The challenge parameter + """ + url_object = urllib.parse.urlparse(url) + query = urllib.parse.parse_qs(url_object.query) + challenge_parameter = query.get("challenge") + if not challenge_parameter: + raise ValueError("Invalid URL or no challenge parameter found.") + + return challenge_parameter[0] diff --git a/nylas/services/tunnel.py b/nylas/services/tunnel.py deleted file mode 100644 index 356ad0d4..00000000 --- a/nylas/services/tunnel.py +++ /dev/null @@ -1,96 +0,0 @@ -import uuid - -import websocket -import json -from threading import Thread - -from nylas.client import APIClient -from nylas.client.restful_models import Webhook -from nylas.config import DEFAULT_REGION - - -def open_webhook_tunnel(api, config): - """ - Open a webhook tunnel and register it with the Nylas API - 1. Creates a UUID - 2. Opens a websocket connection to Nylas' webhook forwarding service, - with the UUID as a header - 3. Creates a new webhook pointed at the forwarding service with the UUID as the path - - When an event is received by the forwarding service, it will push directly to this websocket - connection - - Args: - api (APIClient): The configured Nylas API client - config (dict[str, any]): Configuration for the webhook tunnel, including callback functions, region, - and events to subscribe to - """ - - ws = _build_webhook_tunnel(api, config) - ws_run = Thread(target=_run_webhook_tunnel, args=(ws,)) - ws_run.start() - - -def _run_webhook_tunnel(ws): - ws.run_forever() - - -def _register_webhook(api, callback_domain, tunnel_id, triggers): - webhook = api.webhooks.create() - webhook.callback_url = "https://{}/{}".format(callback_domain, tunnel_id) - webhook.triggers = triggers - webhook.state = Webhook.State.ACTIVE.value - webhook.save() - - -def _build_webhook_tunnel(api, config): - ws_domain = "wss://tunnel.nylas.com" - callback_domain = "cb.nylas.com" - # This UUID will map our websocket to a webhook in the forwarding server - tunnel_id = str(uuid.uuid4()) - - region = config.get("region", DEFAULT_REGION) - triggers = config.get("triggers", [e.value for e in Webhook.Trigger]) - - usr_on_message = config.get("on_message", None) - on_open = config.get("on_open", None) - on_error = config.get("on_error", None) - on_close = config.get("on_close", None) - on_ping = config.get("on_ping", None) - on_pong = config.get("on_pong", None) - on_cont_message = config.get("on_cont_message", None) - on_data = config.get("on_data", None) - - def on_message(wsapp, message): - deltas = _parse_deltas(message) - for delta in deltas: - usr_on_message(delta) - - ws = websocket.WebSocketApp( - ws_domain, - header={ - "Client-Id": api.client_id, - "Client-Secret": api.client_secret, - "Tunnel-Id": tunnel_id, - "Region": region.value, - }, - on_open=on_open, - on_message=on_message if usr_on_message else None, - on_error=on_error, - on_close=on_close, - on_ping=on_ping, - on_pong=on_pong, - on_cont_message=on_cont_message, - on_data=on_data, - ) - - # Register the webhook to the Nylas application - _register_webhook(api, callback_domain, tunnel_id, triggers) - - return ws - - -def _parse_deltas(message): - parsed_message = json.loads(message) - parsed_body = json.loads(parsed_message["body"]) - return parsed_body["deltas"] diff --git a/nylas/utils.py b/nylas/utils.py deleted file mode 100644 index cfeb9ff0..00000000 --- a/nylas/utils.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import division -from datetime import datetime, timedelta -from enum import Enum - - -def timestamp_from_dt(dt, epoch=datetime(1970, 1, 1)): - """ - Convert a datetime to a timestamp. - https://stackoverflow.com/a/8778548/141395 - """ - # For offset-aware datetime objects, convert them first before performing delta - if dt.tzinfo is not None and dt.utcoffset() is not None: - dt = dt.replace(tzinfo=None) - dt.utcoffset() - - delta = dt - epoch - - return int(delta.total_seconds() / timedelta(seconds=1).total_seconds()) - - -def create_request_body(data, datetime_attrs): - """ - Given a dictionary of data, and a dictionary of datetime attributes, - return a new dictionary that is suitable for a request. It converts - any datetime attributes that may be present to their timestamped - equivalent, and it filters out any attributes set to "None". - """ - if not data: - return data - - new_data = {} - for key, value in data.items(): - if key in datetime_attrs and isinstance(value, datetime): - new_key = datetime_attrs[key] - new_data[new_key] = timestamp_from_dt(value) - elif value is not None: - new_data[key] = value - - return new_data - - -def convert_metadata_pairs_to_array(data): - """ - Given a dictionary of metadata pairs, convert it to key-value pairs - in the format the Nylas API expects: "events?metadata_pair=:" - """ - if not data: - return data - - metadata_pair = [] - for key, value in data.items(): - metadata_pair.append(key + ":" + value) - - return metadata_pair - - -class AuthMethod(str, Enum): - """ - This is an Enum representing all the different authentication methods that the Nylas APIs support - """ - - BEARER = 1 - BASIC = 2 - BASIC_CLIENT_ID_AND_SECRET = 3 - - -class HttpMethod(str, Enum): - """ - This is an Enum representing all the HTTP Methods that the Nylas APIs support - """ - - GET = 1 - PUT = 2 - POST = 3 - PATCH = 4 - DELETE = 5 diff --git a/nylas/utils/__init__.py b/nylas/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nylas/utils/file_utils.py b/nylas/utils/file_utils.py new file mode 100644 index 00000000..da4b5faf --- /dev/null +++ b/nylas/utils/file_utils.py @@ -0,0 +1,58 @@ +import json +import mimetypes +import os +from pathlib import Path + +from requests_toolbelt import MultipartEncoder + +from nylas.models.attachments import CreateAttachmentRequest + + +def attach_file_request_builder(file_path) -> CreateAttachmentRequest: + """ + Build a request to attach a file. + + Attributes: + file_path: The path to the file to attach. + + Returns: + A properly-formatted request to attach the file. + """ + path = Path(file_path) + filename = path.name + size = os.path.getsize(file_path) + content_type = mimetypes.guess_type(file_path)[0] + file_stream = open(file_path, "rb") # pylint: disable=consider-using-with + + return { + "filename": filename, + "content_type": content_type if content_type else "application/octet-stream", + "content": file_stream, + "size": size, + } + + +def _build_form_request(request_body: dict) -> MultipartEncoder: + """ + Build a form-data request. + + Attributes: + request_body: The request body to send. + + Returns: + The multipart/form-data request. + """ + attachments = request_body.get("attachments", []) + request_body.pop("attachments", None) + message_payload = json.dumps(request_body) + + # Create the multipart/form-data encoder + fields = {"message": ("", message_payload, "application/json")} + for index, attachment in enumerate(attachments): + fields[f"file{index}"] = ( + attachment["filename"], + attachment["content"], + attachment["content_type"], + ) + + return MultipartEncoder(fields=fields) diff --git a/scripts/generate-docs.py b/scripts/generate-docs.py new file mode 100644 index 00000000..26a31a30 --- /dev/null +++ b/scripts/generate-docs.py @@ -0,0 +1,45 @@ +"""Generate the code reference pages and navigation.""" + +from pathlib import Path +import mkdocs_gen_files + +# Set files to exclude from the docs +excluded_files = [ + "__init__", + "_client_sdk_version", + "handler/__init__", + "handler/api_resources", + "handler/http_client", + "models/__init__", + "resources/__init__", + "utils/__init__", +] + +# Prepare Navigation +nav = mkdocs_gen_files.Nav() + +# Traverse through SDK source files to generate markdown docs for them +for path in sorted(Path("nylas").rglob("*.py")): + # Calculate paths + module_path = path.relative_to("nylas").with_suffix("") + doc_path = path.relative_to("nylas").with_suffix(".md") + full_doc_path = Path("reference", doc_path) + + # Skip excluded files + if str(module_path) in excluded_files: + continue + + # Add file to navigation + parts = tuple(module_path.parts) + nav[parts] = doc_path.as_posix() + + # Generate markdown docs + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + ident = ".".join(parts) + fd.write(f"::: {ident}") + + mkdocs_gen_files.set_edit_path(full_doc_path, path) + +# Write navigation to SUMMARY.md +with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 37e7f81a..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[tool:pytest] -timeout = 10 diff --git a/setup.py b/setup.py index 0328f734..fc555b39 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,5 @@ +import os +import shutil import sys import re import subprocess @@ -11,26 +13,27 @@ r'^__VERSION__\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE ).group(1) +with open("README.md", "r", encoding="utf-8") as f: + README = f.read() + RUN_DEPENDENCIES = [ - "requests[security]>=2.4.2", - "six>=1.4.1", - "urlobject", - "enum34>=1.1.10; python_version<='3.4'", - "websocket-client==0.59.0", + "requests[security]>=2.31.0", + "requests-toolbelt>=1.0.0", + "dataclasses-json>=0.5.9", + "typing_extensions>=4.7.1", ] -TEST_DEPENDENCIES = [ - "pytest", - "pytest-cov", - "pytest-timeout", - "pytest-mock", - "responses==0.10.5", - "twine", - "pytz", - "mock; python_version<'3.3'", +TEST_DEPENDENCIES = ["pytest>=7.4.0", "pytest-cov>=4.1.0", "setuptools>=69.0.3"] + +DOCS_DEPENDENCIES = [ + "mkdocs>=1.5.2", + "mkdocstrings[python]>=0.22.0", + "mkdocs-material>=9.2.6", + "mkdocs-gen-files>=0.5.0", + "mkdocs-literate-nav>=0.6.0", ] -RELEASE_DEPENDENCIES = ["bumpversion>=0.5.0", "twine>=3.4.2"] +RELEASE_DEPENDENCIES = ["bumpversion>=0.6.0", "twine>=4.0.2"] class PyTest(TestCommand): @@ -76,6 +79,19 @@ def main(): except FileNotFoundError as e: print("Error encountered: {}.\n\n".format(e)) sys.exit() + elif sys.argv[1] == "build-docs": + if not os.path.exists("docs"): + os.makedirs("docs") + try: + # Copy the README and other markdowns to the docs folder + shutil.copy("README.md", "docs/index.md") + shutil.copy("Contributing.md", "docs/contributing.md") + shutil.copy("LICENSE", "docs/license.md") + + subprocess.check_output(["mkdocs", "build"]) + except FileNotFoundError as e: + print("Error encountered: {}.\n\n".format(e)) + sys.exit() elif sys.argv[1] == "release": if len(sys.argv) < 3: type_ = "patch" @@ -95,30 +111,25 @@ def main(): setup( name="nylas", version=VERSION, + python_requires=">=3.8", packages=find_packages(), install_requires=RUN_DEPENDENCIES, dependency_links=[], tests_require=TEST_DEPENDENCIES, - extras_require={"test": TEST_DEPENDENCIES, "release": RELEASE_DEPENDENCIES}, + extras_require={ + "test": TEST_DEPENDENCIES, + "docs": DOCS_DEPENDENCIES, + "release": RELEASE_DEPENDENCIES, + }, cmdclass={"test": PyTest}, author="Nylas Team", author_email="support@nylas.com", - description="Python bindings for Nylas, the next-generation email platform.", + description="Python bindings for the Nylas API platform.", license="MIT", keywords="inbox app appserver email nylas contacts calendar", url="https://github.com/nylas/nylas-python", long_description_content_type="text/markdown", - long_description=""" -# Nylas REST API Python bindings -![Build Status](https://github.com/nylas/nylas-python/workflows/Test/badge.svg) -[![Code Coverage](https://codecov.io/gh/nylas/nylas-python/branch/main/graph/badge.svg)](https://codecov.io/gh/nylas/nylas-python) - -Python bindings for the Nylas REST API. https://www.nylas.com/docs - -The Nylas APIs power applications with email, calendar, and contacts CRUD and bi-directional sync from any inbox in the world. - -Nylas is compatible with 100% of email service providers, so you only have to integrate once. -No more headaches building unique integrations against archaic and outdated IMAP and SMTP protocols.""", + long_description=README, ) diff --git a/tests/.gitignore b/tests/.gitignore deleted file mode 100644 index f9b48b34..00000000 --- a/tests/.gitignore +++ /dev/null @@ -1 +0,0 @@ -credentials.py diff --git a/tests/conftest.py b/tests/conftest.py index 8ead8d4c..87391582 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,2751 +1,131 @@ -import os -import re -import json -import copy -import cgi -import random -import string -import pytest -import responses -from urlobject import URLObject -from nylas import APIClient - -# pylint: disable=redefined-outer-name,too-many-lines - -#### HANDLING PAGINATION #### -# Currently, the Nylas API handles pagination poorly: API responses do not expose -# any information about pagination, so the client does not know whether there is -# another page of data or not. For example, if the client sends an API request -# without a limit specified, and the response contains 100 items, how can it tell -# if there are 100 items in total, or if there more items to fetch on the next page? -# It can't! The only way to know is to ask for the next page (by repeating the API -# request with `offset=100`), and see if you get more items or not. -# If it does not receive more items, it can assume that it has retrieved all the data. -# -# This file contains mocks for several API endpoints, including "list" endpoints -# like `/messages` and `/events`. The mocks for these list endpoints must be smart -# enough to check for an `offset` query param, and return an empty list if the -# client requests more data than the first page. If the mock does not -# check for this `offset` query param, and returns the same mock data over and over, -# any SDK method that tries to fetch *all* of a certain type of data -# (like `client.messages.all()`) will never complete. - - -def generate_id(size=25, chars=string.ascii_letters + string.digits): - return "".join(random.choice(chars) for _ in range(size)) - - -@pytest.fixture -def message_body(): - return { - "busy": True, - "calendar_id": "94rssh7bd3rmsxsp19kiocxze", - "description": None, - "id": "cv4ei7syx10uvsxbs21ccsezf", - "location": "1 Infinite loop, Cupertino", - "message_id": None, - "namespace_id": "384uhp3aj8l7rpmv9s2y2rukn", - "object": "event", - "owner": None, - "participants": [], - "read_only": False, - "status": "confirmed", - "title": "The rain song", - "when": { - "end_time": 1441056790, - "object": "timespan", - "start_time": 1441053190, - }, - } - - -@pytest.fixture -def access_token(): - return "l3m0n_w4ter" - - -@pytest.fixture -def account_id(): - return "4ennivvrcgsqytgybfk912dto" - - -@pytest.fixture -def api_url(): - return "https://localhost:2222" - - -@pytest.fixture -def client_id(): - return "fake-client-id" - - -@pytest.fixture -def client_secret(): - return "nyl4n4ut" - - -@pytest.fixture -def api_client(api_url): - return APIClient( - client_id=None, client_secret=None, access_token=None, api_server=api_url - ) - - -@pytest.fixture -def api_client_with_client_id(access_token, api_url, client_id, client_secret): - return APIClient( - client_id=client_id, - client_secret=client_secret, - access_token=access_token, - api_server=api_url, - ) - - -@pytest.fixture -def mocked_responses(): - rmock = responses.RequestsMock(assert_all_requests_are_fired=False) - with rmock: - yield rmock - - -@pytest.fixture -def mock_save_draft(mocked_responses, api_url): - save_endpoint = re.compile(api_url + "/drafts") - response_body = json.dumps( - {"id": "4dl0ni6vxomazo73r5oydo16k", "version": "4dw0ni6txomazo33r5ozdo16j"} - ) - mocked_responses.add( - responses.POST, - save_endpoint, - content_type="application/json", - status=200, - body=response_body, - match_querystring=True, - ) - - -@pytest.fixture -def mock_account(mocked_responses, api_url, account_id): - response_body = json.dumps( - { - "account_id": account_id, - "email_address": "ben.bitdiddle1861@gmail.com", - "id": account_id, - "name": "Ben Bitdiddle", - "object": "account", - "provider": "gmail", - "organization_unit": "label", - "billing_state": "paid", - "linked_at": 1500920299, - "sync_state": "running", - } - ) - mocked_responses.add( - responses.GET, - re.compile(api_url + "/account(?!s)/?"), - content_type="application/json", - status=200, - body=response_body, - ) - - -@pytest.fixture -def mock_accounts(mocked_responses, api_url, account_id, client_id): - accounts = [ - { - "account_id": account_id, - "email_address": "ben.bitdiddle1861@gmail.com", - "id": account_id, - "name": "Ben Bitdiddle", - "object": "account", - "provider": "gmail", - "organization_unit": "label", - "billing_state": "paid", - "linked_at": 1500920299, - "sync_state": "running", - } - ] - - def list_callback(request): - url = URLObject(request.url) - offset = int(url.query_dict.get("offset") or 0) - if offset: - return (200, {}, json.dumps([])) - return (200, {}, json.dumps(accounts)) - - def update_callback(request): - response = accounts[0] - payload = json.loads(request.body) - if payload["metadata"]: - response["metadata"] = payload["metadata"] - return 200, {}, json.dumps(response) - - def delete_callback(request): - response = {"success": True} - return 200, {}, json.dumps(response) - - url_re = "{base}(/a/{client_id})?/accounts/?".format( - base=api_url, client_id=client_id - ) - mocked_responses.add_callback( - responses.GET, - re.compile(url_re), - content_type="application/json", - callback=list_callback, - ) - mocked_responses.add_callback( - responses.PUT, - re.compile(url_re), - content_type="application/json", - callback=update_callback, - ) - mocked_responses.add_callback( - responses.DELETE, - re.compile(url_re), - content_type="application/json", - callback=delete_callback, - ) - - -@pytest.fixture -def mock_folder_account(mocked_responses, api_url, account_id): - response_body = json.dumps( - { - "email_address": "ben.bitdiddle1861@office365.com", - "id": account_id, - "name": "Ben Bitdiddle", - "account_id": account_id, - "object": "account", - "provider": "eas", - "organization_unit": "folder", - } - ) - mocked_responses.add( - responses.GET, - api_url + "/account", - content_type="application/json", - status=200, - body=response_body, - match_querystring=True, - ) - - -@pytest.fixture -def mock_labels(mocked_responses, api_url, account_id): - labels = [ - { - "display_name": "Important", - "id": "anuep8pe5ugmxrucchrzba2o8", - "name": "important", - "account_id": account_id, - "object": "label", - }, - { - "display_name": "Trash", - "id": "f1xgowbgcehk235xiy3c3ek42", - "name": "trash", - "account_id": account_id, - "object": "label", - }, - { - "display_name": "Sent Mail", - "id": "ah14wp5fvypvjjnplh7nxgb4h", - "name": "sent", - "account_id": account_id, - "object": "label", - }, - { - "display_name": "All Mail", - "id": "ah14wp5fvypvjjnplh7nxgb4h", - "name": "all", - "account_id": account_id, - "object": "label", - }, - { - "display_name": "Inbox", - "id": "dc11kl3s9lj4760g6zb36spms", - "name": "inbox", - "account_id": account_id, - "object": "label", - }, - ] - - def list_callback(request): - url = URLObject(request.url) - offset = int(url.query_dict.get("offset") or 0) - if offset: - return (200, {}, json.dumps([])) - return (200, {}, json.dumps(labels)) - - endpoint = re.compile(api_url + "/labels.*") - mocked_responses.add_callback( - responses.GET, - endpoint, - content_type="application/json", - callback=list_callback, - ) - - -@pytest.fixture -def mock_label(mocked_responses, api_url, account_id): - response_body = json.dumps( - { - "display_name": "Important", - "id": "anuep8pe5ugmxrucchrzba2o8", - "name": "important", - "account_id": account_id, - "object": "label", - } - ) - url = api_url + "/labels/anuep8pe5ugmxrucchrzba2o8" - mocked_responses.add( - responses.GET, - url, - content_type="application/json", - status=200, - body=response_body, - ) - - -@pytest.fixture -def mock_folder(mocked_responses, api_url, account_id): - folder = { - "display_name": "My Folder", - "id": "anuep8pe5ug3xrupchwzba2o8", - "name": None, - "account_id": account_id, - "object": "folder", - } - response_body = json.dumps(folder) - url = api_url + "/folders/anuep8pe5ug3xrupchwzba2o8" - mocked_responses.add( - responses.GET, - url, - content_type="application/json", - status=200, - body=response_body, - ) - - def request_callback(request): - payload = json.loads(request.body) - if "display_name" in payload: - folder.update(payload) - return (200, {}, json.dumps(folder)) - - def delete_callback(request): - payload = {"successful": True} - return 200, {}, json.dumps(payload) - - mocked_responses.add_callback( - responses.PUT, url, content_type="application/json", callback=request_callback - ) - - mocked_responses.add_callback( - responses.DELETE, url, content_type="application/json", callback=delete_callback - ) - - -@pytest.fixture -def mock_messages(mocked_responses, api_url, account_id): - messages = [ - { - "id": "1234", - "to": [{"email": "foo@yahoo.com", "name": "Foo"}], - "from": [{"email": "bar@gmail.com", "name": "Bar"}], - "subject": "Test Message", - "account_id": account_id, - "object": "message", - "labels": [{"name": "inbox", "display_name": "Inbox", "id": "abcd"}], - "starred": False, - "unread": True, - "date": 1265077342, - }, - { - "id": "1238", - "to": [{"email": "foo2@yahoo.com", "name": "Foo Two"}], - "from": [{"email": "bar2@gmail.com", "name": "Bar Two"}], - "subject": "Test Message 2", - "account_id": account_id, - "object": "message", - "labels": [{"name": "inbox", "display_name": "Inbox", "id": "abcd"}], - "starred": False, - "unread": True, - "date": 1265085342, - }, - { - "id": "12", - "to": [{"email": "foo3@yahoo.com", "name": "Foo Three"}], - "from": [{"email": "bar3@gmail.com", "name": "Bar Three"}], - "subject": "Test Message 3", - "account_id": account_id, - "object": "message", - "labels": [{"name": "archive", "display_name": "Archive", "id": "gone"}], - "starred": False, - "unread": False, - "date": 1265093842, - }, - ] - - def list_callback(request): - url = URLObject(request.url) - offset = int(url.query_dict.get("offset") or 0) - if offset: - return (200, {}, json.dumps([])) - return (200, {}, json.dumps(messages)) - - endpoint = re.compile(api_url + "/messages") - mocked_responses.add_callback( - responses.GET, endpoint, content_type="application/json", callback=list_callback - ) - - -@pytest.fixture -def mock_message(mocked_responses, api_url, account_id): - base_msg = { - "id": "1234", - "to": [{"email": "foo@yahoo.com", "name": "Foo"}], - "from": [{"email": "bar@gmail.com", "name": "Bar"}], - "subject": "Test Message", - "account_id": account_id, - "object": "message", - "labels": [{"name": "inbox", "display_name": "Inbox", "id": "abcd"}], - "starred": False, - "unread": True, - } - response_body = json.dumps(base_msg) - - def request_callback(request): - payload = json.loads(request.body) - if "labels" in payload: - labels = [ - {"name": "test", "display_name": "test", "id": l} - for l in payload["labels"] - ] - base_msg["labels"] = labels - if "metadata" in payload: - base_msg["metadata"] = payload["metadata"] - return (200, {}, json.dumps(base_msg)) - - endpoint = re.compile(api_url + "/messages/1234") - mocked_responses.add( - responses.GET, - endpoint, - content_type="application/json", - status=200, - body=response_body, - ) - mocked_responses.add_callback( - responses.PUT, - endpoint, - content_type="application/json", - callback=request_callback, - ) - mocked_responses.add( - responses.DELETE, endpoint, content_type="application/json", status=200, body="" - ) - - -@pytest.fixture -def mock_threads(mocked_responses, api_url, account_id): - threads = [ - { - "id": "5678", - "subject": "Test Thread", - "account_id": account_id, - "object": "thread", - "folders": [{"name": "inbox", "display_name": "Inbox", "id": "abcd"}], - "starred": True, - "unread": False, - "first_message_timestamp": 1451703845, - "last_message_timestamp": 1483326245, - "last_message_received_timestamp": 1483326245, - "last_message_sent_timestamp": 1483232461, - } - ] - - def list_callback(request): - url = URLObject(request.url) - offset = int(url.query_dict.get("offset") or 0) - if offset: - return (200, {}, json.dumps([])) - return (200, {}, json.dumps(threads)) - - endpoint = re.compile(api_url + "/threads") - mocked_responses.add_callback( - responses.GET, - endpoint, - content_type="application/json", - callback=list_callback, - ) - - -@pytest.fixture -def mock_thread(mocked_responses, api_url, account_id): - base_thrd = { - "id": "5678", - "subject": "Test Thread", - "account_id": account_id, - "object": "thread", - "folders": [{"name": "inbox", "display_name": "Inbox", "id": "abcd"}], - "starred": True, - "unread": False, - "first_message_timestamp": 1451703845, - "last_message_timestamp": 1483326245, - "last_message_received_timestamp": 1483326245, - "last_message_sent_timestamp": 1483232461, - } - response_body = json.dumps(base_thrd) - - def request_callback(request): - payload = json.loads(request.body) - if "folder" in payload: - folder = {"name": "test", "display_name": "test", "id": payload["folder"]} - base_thrd["folders"] = [folder] - return (200, {}, json.dumps(base_thrd)) - - endpoint = re.compile(api_url + "/threads/5678") - mocked_responses.add( - responses.GET, - endpoint, - content_type="application/json", - status=200, - body=response_body, - ) - mocked_responses.add_callback( - responses.PUT, - endpoint, - content_type="application/json", - callback=request_callback, - ) - - -@pytest.fixture -def mock_labelled_thread(mocked_responses, api_url, account_id): - base_thread = { - "id": "111", - "subject": "Labelled Thread", - "account_id": account_id, - "object": "thread", - "folders": [{"name": "inbox", "display_name": "Inbox", "id": "abcd"}], - "starred": True, - "unread": False, - "labels": [ - { - "display_name": "Important", - "id": "anuep8pe5ugmxrucchrzba2o8", - "name": "important", - "account_id": account_id, - "object": "label", - }, - { - "display_name": "Existing", - "id": "dfslhgy3rlijfhlsujnchefs3", - "name": "existing", - "account_id": account_id, - "object": "label", - }, - ], - "messages": [ - { - "account_id": account_id, - "date": 1675274530, - "id": "222", - "labels": [ - { - "display_name": "Trash", - "id": "trash-id", - "name": "trash", - }, - ], - "object": "message", - "thread_id": "111", - } - ], - "first_message_timestamp": 1451703845, - "last_message_timestamp": 1483326245, - "last_message_received_timestamp": 1483326245, - "last_message_sent_timestamp": 1483232461, - } - response_body = json.dumps(base_thread) - - def request_callback(request): - payload = json.loads(request.body) - if "labels" in payload: - existing_labels = {label["id"]: label for label in base_thread["labels"]} - new_labels = [] - for label_id in payload["labels"]: - if label_id in existing_labels: - new_labels.append(existing_labels[label_id]) - else: - new_labels.append( - { - "name": "updated", - "display_name": "Updated", - "id": label_id, - "account_id": account_id, - "object": "label", - } - ) - copied = copy.copy(base_thread) - copied["labels"] = new_labels - return (200, {}, json.dumps(copied)) - - endpoint = re.compile(api_url + "/threads/111") - mocked_responses.add( - responses.GET, - endpoint, - content_type="application/json", - status=200, - body=response_body, - ) - mocked_responses.add_callback( - responses.PUT, - endpoint, - content_type="application/json", - callback=request_callback, - ) - - -@pytest.fixture -def mock_drafts(mocked_responses, api_url): - drafts = [ - { - "bcc": [], - "body": "Cheers mate!", - "cc": [], - "date": 1438684486, - "events": [], - "files": [], - "folder": None, - "from": [], - "id": "2h111aefv8pzwzfykrn7hercj", - "namespace_id": "384uhp3aj8l7rpmv9s2y2rukn", - "object": "draft", - "reply_to": [], - "reply_to_message_id": None, - "snippet": "", - "starred": False, - "subject": "Here's an attachment", - "thread_id": "clm33kapdxkposgltof845v9s", - "to": [{"email": "helena@nylas.com", "name": "Helena Handbasket"}], - "unread": False, - "version": 0, - } - ] - - def list_callback(request): - url = URLObject(request.url) - offset = int(url.query_dict.get("offset") or 0) - if offset: - return (200, {}, json.dumps([])) - return (200, {}, json.dumps(drafts)) - - mocked_responses.add_callback( - responses.GET, - api_url + "/drafts", - content_type="application/json", - callback=list_callback, - ) - - -@pytest.fixture -def mock_draft_saved_response(mocked_responses, api_url): - draft_json = { - "bcc": [], - "body": "Cheers mate!", - "cc": [], - "date": 1438684486, - "events": [], - "files": [], - "folder": None, - "from": [], - "id": "2h111aefv8pzwzfykrn7hercj", - "namespace_id": "384uhp3aj8l7rpmv9s2y2rukn", - "object": "draft", - "reply_to": [], - "reply_to_message_id": None, - "snippet": "", - "starred": False, - "subject": "Here's an attachment", - "thread_id": "clm33kapdxkposgltof845v9s", - "to": [{"email": "helena@nylas.com", "name": "Helena Handbasket"}], - "unread": False, - "version": 0, - } - - def create_callback(_request): - return (200, {}, json.dumps(draft_json)) - - def update_callback(request): - try: - payload = json.loads(request.body) - except ValueError: - return (200, {}, json.dumps(draft_json)) - - stripped_payload = {key: value for key, value in payload.items() if value} - updated_draft_json = copy.copy(draft_json) - updated_draft_json.update(stripped_payload) - updated_draft_json["version"] += 1 - return (200, {}, json.dumps(updated_draft_json)) - - mocked_responses.add_callback( - responses.POST, - api_url + "/drafts", - content_type="application/json", - callback=create_callback, - ) - - mocked_responses.add_callback( - responses.PUT, - api_url + "/drafts/2h111aefv8pzwzfykrn7hercj", - content_type="application/json", - callback=update_callback, - ) - - -@pytest.fixture -def mock_draft_deleted_response(mocked_responses, api_url): - mocked_responses.add( - responses.DELETE, - api_url + "/drafts/2h111aefv8pzwzfykrn7hercj", - content_type="application/json", - status=200, - body="", - ) - - -@pytest.fixture -def mock_draft_sent_response(mocked_responses, api_url): - body = { - "bcc": [], - "body": "", - "cc": [], - "date": 1438684486, - "events": [], - "files": [], - "folder": None, - "from": [{"email": "benb@nylas.com"}], - "id": "2h111aefv8pzwzfykrn7hercj", - "namespace_id": "384uhp3aj8l7rpmv9s2y2rukn", - "object": "draft", - "reply_to": [], - "reply_to_message_id": None, - "snippet": "", - "starred": False, - "subject": "Stay polish, stay hungary", - "thread_id": "clm33kapdxkposgltof845v9s", - "to": [{"email": "helena@nylas.com", "name": "Helena Handbasket"}], - "unread": False, - "version": 0, - } - - values = [(400, {}, "Couldn't send email"), (200, {}, json.dumps(body))] - - def callback(request): - payload = json.loads(request.body) - assert payload["draft_id"] == "2h111aefv8pzwzfykrn7hercj" - return values.pop() - - mocked_responses.add_callback( - responses.POST, - api_url + "/send", - callback=callback, - content_type="application/json", - ) - - -@pytest.fixture -def mock_draft_raw_response(mocked_responses, api_url): - body = { - "bcc": [], - "body": "", - "cc": [], - "date": 1438684486, - "events": [], - "files": [], - "folder": None, - "from": [{"email": "benb@nylas.com"}], - "id": "2h111aefv8pzwzfykrn7hercj", - "namespace_id": "384uhp3aj8l7rpmv9s2y2rukn", - "object": "draft", - "reply_to": [], - "reply_to_message_id": None, - "snippet": "", - "starred": False, - "subject": "Stay polish, stay hungary", - "thread_id": "clm33kapdxkposgltof845v9s", - "to": [{"email": "helena@nylas.com", "name": "Helena Handbasket"}], - "unread": False, - "version": 0, - } - - def callback(request): - return 200, {}, json.dumps(body) - - mocked_responses.add_callback( - responses.POST, - api_url + "/send", - callback=callback, - content_type="application/json", - ) - - -@pytest.fixture -def mock_draft_send_unsaved_response(mocked_responses, api_url): - def callback(request): - payload = json.loads(request.body) - payload["draft_id"] = "2h111aefv8pzwzfykrn7hercj" - return 200, {}, json.dumps(payload) - - mocked_responses.add_callback( - responses.POST, - api_url + "/send", - callback=callback, - content_type="application/json", - ) - - -@pytest.fixture -def mock_files(mocked_responses, api_url, account_id): - files_content = {"3qfe4k3siosfjtjpfdnon8zbn": b"Hello, World!"} - files_metadata = { - "3qfe4k3siosfjtjpfdnon8zbn": { - "id": "3qfe4k3siosfjtjpfdnon8zbn", - "content_type": "text/plain", - "filename": "hello.txt", - "account_id": account_id, - "object": "file", - "size": len(files_content["3qfe4k3siosfjtjpfdnon8zbn"]), - } - } - mocked_responses.add( - responses.GET, - api_url + "/files", - body=json.dumps(list(files_metadata.values())), - ) - for file_id in files_content: - mocked_responses.add( - responses.POST, - "{base}/files/{file_id}".format(base=api_url, file_id=file_id), - body=json.dumps(files_metadata[file_id]), - ) - mocked_responses.add( - responses.GET, - "{base}/files/{file_id}/download".format(base=api_url, file_id=file_id), - body=files_content[file_id], - ) - - def create_callback(request): - uploaded_lines = request.body.decode("utf8").splitlines() - content_disposition = uploaded_lines[1] - _, params = cgi.parse_header(content_disposition) - filename = params.get("filename", None) - content = "".join(uploaded_lines[3:-1]) - size = len(content.encode("utf8")) - - body = [ - { - "id": generate_id(), - "content_type": "text/plain", - "filename": filename, - "account_id": account_id, - "object": "file", - "size": size, - } - ] - return (200, {}, json.dumps(body)) - - mocked_responses.add_callback( - responses.POST, api_url + "/files", callback=create_callback - ) - - -@pytest.fixture -def mock_event_create_response(mocked_responses, api_url, message_body): - def callback(_request): - try: - payload = json.loads(_request.body) - except ValueError: - return 400, {}, "" - - payload["id"] = "cv4ei7syx10uvsxbs21ccsezf" - return 200, {}, json.dumps(payload) - - mocked_responses.add_callback( - responses.POST, api_url + "/events", callback=callback - ) - - put_body = {"title": "loaded from JSON", "ignored": "ignored"} - mocked_responses.add( - responses.PUT, - api_url + "/events/cv4ei7syx10uvsxbs21ccsezf", - body=json.dumps(put_body), - ) - - -@pytest.fixture -def mock_event_generate_ics(mocked_responses, api_url, message_body): - mocked_responses.add( - responses.POST, api_url + "/events/to-ics", body=json.dumps({"ics": ""}) - ) - - -@pytest.fixture -def mock_scheduler_create_response(mocked_responses, api_url, message_body): - def callback(_request): - try: - payload = json.loads(_request.body) - except ValueError: - return 400, {}, "" - - payload["id"] = "cv4ei7syx10uvsxbs21ccsezf" - return 200, {}, json.dumps(payload) - - mocked_responses.add_callback( - responses.POST, "https://api.schedule.nylas.com/manage/pages", callback=callback - ) - - mocked_responses.add( - responses.PUT, - "https://api.schedule.nylas.com/manage/pages/cv4ei7syx10uvsxbs21ccsezf", - body=json.dumps(message_body), - ) - - -@pytest.fixture -def mock_event_create_response_with_limits(mocked_responses, api_url, message_body): - def callback(request): - url = URLObject(request.url) - limit = int(url.query_dict.get("limit") or 50) - body = [message_body for _ in range(0, limit)] - return 200, {}, json.dumps(body) - - mocked_responses.add_callback(responses.GET, api_url + "/events", callback=callback) - - -@pytest.fixture -def mock_event_create_notify_response(mocked_responses, api_url, message_body): - mocked_responses.add( - responses.POST, - api_url + "/events?notify_participants=true&other_param=1", - body=json.dumps(message_body), - ) - - -@pytest.fixture -def mock_send_rsvp(mocked_responses, api_url, message_body): - mocked_responses.add( - responses.POST, - re.compile(api_url + "/send-rsvp"), - body=json.dumps(message_body), - ) - - -@pytest.fixture -def mock_components_create_response(mocked_responses, api_url, message_body): - def callback(_request): - try: - payload = json.loads(_request.body) - except ValueError: - return 400, {}, "" - - payload["id"] = "cv4ei7syx10uvsxbs21ccsezf" - return 200, {}, json.dumps(payload) - - mocked_responses.add_callback( - responses.POST, re.compile(api_url + "/component/*"), callback=callback - ) - - mocked_responses.add( - responses.PUT, - re.compile(api_url + "/component/*"), - body=json.dumps(message_body), - ) - - -@pytest.fixture -def mock_thread_search_response(mocked_responses, api_url): - snippet = ( - "Hey Helena, Looking forward to getting together for dinner on Friday. " - "What can I bring? I have a couple bottles of wine or could put together" - ) - response_body = json.dumps( - [ - { - "id": "evh5uy0shhpm5d0le89goor17", - "object": "thread", - "account_id": "awa6ltos76vz5hvphkp8k17nt", - "subject": "Dinner Party on Friday", - "unread": False, - "starred": False, - "last_message_timestamp": 1398229259, - "last_message_received_timestamp": 1398229259, - "first_message_timestamp": 1298229259, - "participants": [ - {"name": "Ben Bitdiddle", "email": "ben.bitdiddle@gmail.com"} - ], - "snippet": snippet, - "folders": [ - { - "name": "inbox", - "display_name": "INBOX", - "id": "f0idlvozkrpj3ihxze7obpivh", - } - ], - "message_ids": [ - "251r594smznew6yhiocht2v29", - "7upzl8ss738iz8xf48lm84q3e", - "ah5wuphj3t83j260jqucm9a28", - ], - "draft_ids": ["251r594smznew6yhi12312saq"], - "version": 2, - } - ] - ) - - mocked_responses.add( - responses.GET, - re.compile(api_url + "/threads/search\?q=Helena.*"), - body=response_body, - status=200, - content_type="application/json", - match_querystring=True, - ) - - -@pytest.fixture -def mock_message_search_response(mocked_responses, api_url): - snippet = ( - "Sounds good--that bottle of Pinot should go well with the meal. " - "I'll also bring a surprise for dessert. :) " - "Do you have ice cream? Looking fo" - ) - response_body = json.dumps( - [ - { - "id": "84umizq7c4jtrew491brpa6iu", - "object": "message", - "account_id": "14e5bn96uizyuhidhcw5rfrb0", - "thread_id": "5vryyrki4fqt7am31uso27t3f", - "subject": "Re: Dinner on Friday?", - "from": [{"name": "Ben Bitdiddle", "email": "ben.bitdiddle@gmail.com"}], - "to": [{"name": "Bill Rogers", "email": "wbrogers@mit.edu"}], - "cc": [], - "bcc": [], - "reply_to": [], - "date": 1370084645, - "unread": True, - "starred": False, - "folder": { - "name": "inbox", - "display_name": "INBOX", - "id": "f0idlvozkrpj3ihxze7obpivh", - }, - "snippet": snippet, - "body": "....", - "files": [], - "events": [], - }, - { - "id": "84umizq7asdf3aw491brpa6iu", - "object": "message", - "account_id": "14e5bakdsfljskidhcw5rfrb0", - "thread_id": "5vryyralskdjfwlj1uso27t3f", - "subject": "Re: Dinner on Friday?", - "from": [{"name": "Ben Bitdiddle", "email": "ben.bitdiddle@gmail.com"}], - "to": [{"name": "Bill Rogers", "email": "wbrogers@mit.edu"}], - "cc": [], - "bcc": [], - "reply_to": [], - "date": 1370084645, - "unread": True, - "starred": False, - "folder": { - "name": "inbox", - "display_name": "INBOX", - "id": "f0idlvozkrpj3ihxze7obpivh", - }, - "snippet": snippet, - "body": "....", - "files": [], - "events": [], - }, - ] - ) - - mocked_responses.add( - responses.GET, - re.compile(api_url + "/messages/search\?q=Pinot.*"), - body=response_body, - status=200, - content_type="application/json", - match_querystring=True, - ) - - -@pytest.fixture -def mock_calendars(mocked_responses, api_url): - calendars = [ - { - "id": "8765", - "events": [ - { - "title": "Pool party", - "location": "Local Community Pool", - "participants": ["Alice", "Bob", "Claire", "Dot"], - } - ], - } - ] - - def list_callback(request): - url = URLObject(request.url) - offset = int(url.query_dict.get("offset") or 0) - if offset: - return (200, {}, json.dumps([])) - return (200, {}, json.dumps(calendars)) - - endpoint = re.compile(api_url + "/calendars") - mocked_responses.add_callback( - responses.GET, - endpoint, - content_type="application/json", - callback=list_callback, - ) - - -@pytest.fixture -def mock_contacts(mocked_responses, account_id, api_url): - contact1 = { - "id": "5x6b54whvcz1j22ggiyorhk9v", - "object": "contact", - "account_id": account_id, - "given_name": "Charlie", - "middle_name": None, - "surname": "Bucket", - "birthday": "1964-10-05", - "suffix": None, - "nickname": None, - "company_name": None, - "job_title": "Student", - "manager_name": None, - "office_location": None, - "notes": None, - "picture_url": "{base}/contacts/{id}/picture".format( - base=api_url, id="5x6b54whvcz1j22ggiyorhk9v" - ), - "emails": [{"email": "charlie@gmail.com", "type": None}], - "im_addresses": [], - "physical_addresses": [], - "phone_numbers": [], - "web_pages": [], - } - contact2 = { - "id": "4zqkfw8k1d12h0k784ipeh498", - "object": "contact", - "account_id": account_id, - "given_name": "William", - "middle_name": "J", - "surname": "Wonka", - "birthday": "1955-02-28", - "suffix": None, - "nickname": None, - "company_name": None, - "job_title": "Chocolate Artist", - "manager_name": None, - "office_location": "Willy Wonka Factory", - "notes": None, - "picture_url": None, - "emails": [{"email": "scrumptious@wonka.com", "type": None}], - "im_addresses": [], - "physical_addresses": [], - "phone_numbers": [], - "web_pages": [{"type": "work", "url": "http://www.wonka.com"}], - } - contact3 = { - "id": "9fn1aoi2i00qv6h1zpag6b26w", - "object": "contact", - "account_id": account_id, - "given_name": "Oompa", - "middle_name": None, - "surname": "Loompa", - "birthday": None, - "suffix": None, - "nickname": None, - "company_name": None, - "job_title": None, - "manager_name": None, - "office_location": "Willy Wonka Factory", - "notes": None, - "picture_url": None, - "emails": [], - "im_addresses": [], - "physical_addresses": [], - "phone_numbers": [], - "web_pages": [], - } - contacts = [contact1, contact2, contact3] - - def list_callback(request): - url = URLObject(request.url) - offset = int(url.query_dict.get("offset") or 0) - if offset: - return (200, {}, json.dumps([])) - return (200, {}, json.dumps(contacts)) - - def create_callback(request): - payload = json.loads(request.body) - payload["id"] = generate_id() - return (200, {}, json.dumps(payload)) - - for contact in contacts: - mocked_responses.add( - responses.GET, - re.compile(api_url + "/contacts/" + contact["id"]), - content_type="application/json", - status=200, - body=json.dumps(contact), - ) - if contact.get("picture_url"): - mocked_responses.add( - responses.GET, - contact["picture_url"], - content_type="image/jpeg", - status=200, - body=os.urandom(50), - stream=True, - ) - else: - mocked_responses.add( - responses.GET, - "{base}/contacts/{id}/picture".format(base=api_url, id=contact["id"]), - status=404, - body="", - ) - mocked_responses.add_callback( - responses.GET, - re.compile(api_url + "/contacts"), - content_type="application/json", - callback=list_callback, - ) - mocked_responses.add_callback( - responses.POST, - api_url + "/contacts", - content_type="application/json", - callback=create_callback, - ) - - -@pytest.fixture -def mock_contact(mocked_responses, account_id, api_url): - contact = { - "id": "9hga75n6mdvq4zgcmhcn7hpys", - "object": "contact", - "account_id": account_id, - "given_name": "Given", - "middle_name": "Middle", - "surname": "Sur", - "birthday": "1964-10-05", - "suffix": "Jr", - "nickname": "Testy", - "company_name": "Test Data Inc", - "job_title": "QA Tester", - "manager_name": "George", - "office_location": "Over the Rainbow", - "source": "inbox", - "notes": "This is a note", - "picture_url": "{base}/contacts/{id}/picture".format( - base=api_url, id="9hga75n6mdvq4zgcmhcn7hpys" - ), - "emails": [ - {"type": "first", "email": "one@example.com"}, - {"type": "second", "email": "two@example.com"}, - {"type": "primary", "email": "abc@example.com"}, - {"type": "primary", "email": "xyz@example.com"}, - {"type": None, "email": "unknown@example.com"}, - ], - "im_addresses": [ - {"type": "aim", "im_address": "SmarterChild"}, - {"type": "gtalk", "im_address": "fake@gmail.com"}, - {"type": "gtalk", "im_address": "fake2@gmail.com"}, - ], - "physical_addresses": [ - { - "type": "home", - "format": "structured", - "street_address": "123 Awesome Street", - "postal_code": "99989", - "state": "CA", - "country": "America", - } - ], - "phone_numbers": [ - {"type": "home", "number": "555-555-5555"}, - {"type": "mobile", "number": "555-555-5555"}, - {"type": "mobile", "number": "987654321"}, - ], - "web_pages": [ - {"type": "profile", "url": "http://www.facebook.com/abc"}, - {"type": "profile", "url": "http://www.twitter.com/abc"}, - {"type": None, "url": "http://example.com"}, - ], - } - - def update_callback(request): - try: - payload = json.loads(request.body) - except ValueError: - return (200, {}, json.dumps(contact)) - - stripped_payload = {key: value for key, value in payload.items() if value} - updated_contact_json = copy.copy(contact) - updated_contact_json.update(stripped_payload) - return (200, {}, json.dumps(updated_contact_json)) - - mocked_responses.add( - responses.GET, - "{base}/contacts/{id}".format(base=api_url, id=contact["id"]), - content_type="application/json", - status=200, - body=json.dumps(contact), - ) - mocked_responses.add( - responses.GET, - contact["picture_url"], - content_type="image/jpeg", - status=200, - body=os.urandom(50), - stream=True, - ) - - mocked_responses.add_callback( - responses.PUT, - "{base}/contacts/{id}".format(base=api_url, id=contact["id"]), - content_type="application/json", - callback=update_callback, - ) - - -@pytest.fixture -def mock_events(mocked_responses, api_url): - events = [ - { - "id": "1234abcd5678", - "message_id": "evh5uy0shhpm5d0le89goor17", - "ical_uid": "19960401T080045Z-4000F192713-0052@example.com", - "title": "Pool party", - "location": "Local Community Pool", - "participants": [ - { - "comment": None, - "email": "kelly@nylas.com", - "name": "Kelly Nylanaut", - "status": "noreply", - }, - { - "comment": None, - "email": "sarah@nylas.com", - "name": "Sarah Nylanaut", - "status": "no", - }, - ], - "metadata": {}, - }, - { - "id": "9876543cba", - "message_id": None, - "ical_uid": None, - "title": "Event Without Message", - "description": "This event does not have a corresponding message ID.", - "metadata": {}, - }, - { - "id": "1231241zxc", - "message_id": None, - "ical_uid": None, - "title": "Event With Metadata", - "description": "This event uses metadata to store custom values.", - "metadata": {"platform": "python", "event_type": "meeting"}, - }, - ] - - def list_callback(request): - url = URLObject(request.url) - offset = int(url.query_dict.get("offset") or 0) - metadata_key = url.query_multi_dict.get("metadata_key") - metadata_value = url.query_multi_dict.get("metadata_value") - metadata_pair = url.query_multi_dict.get("metadata_pair") - - if offset: - return (200, {}, json.dumps([])) - if metadata_key or metadata_value or metadata_pair: - results = [] - for event in events: - if ( - metadata_key - and set(metadata_key) & set(event["metadata"]) - or metadata_value - and set(metadata_value) & set(event["metadata"].values()) - ): - results.append(event) - elif metadata_pair: - for pair in metadata_pair: - key_value = pair.split(":") - if ( - key_value[0] in event["metadata"] - and event["metadata"][key_value[0]] == key_value[1] - ): - results.append(event) - return (200, {}, json.dumps(results)) - return (200, {}, json.dumps(events)) - - endpoint = re.compile(api_url + "/events") - mocked_responses.add_callback( - responses.GET, endpoint, content_type="application/json", callback=list_callback - ) - - -@pytest.fixture -def mock_schedulers(mocked_responses, api_url): - scheduler_list = [ - { - "app_client_id": "test-client-id", - "app_organization_id": 12345, - "config": { - "appearance": { - "color": "#0068D3", - "company_name": "", - "logo": "", - "show_autoschedule": "true", - "show_nylas_branding": "false", - "show_timezone_options": "true", - "show_week_view": "true", - "submit_text": "Submit", - }, - "locale": "en", - "reminders": [], - "timezone": "America/Los_Angeles", - }, - "created_at": "2021-10-22", - "edit_token": "test-edit-token-1", - "id": 90210, - "modified_at": "2021-10-22", - "name": "test-1", - "slug": "test1", - }, - { - "app_client_id": "test-client-id", - "app_organization_id": 12345, - "config": { - "calendar_ids": { - "test-calendar-id": { - "availability": ["availability-id"], - "booking": "booking-id", - } - }, - "event": { - "capacity": -1, - "duration": 45, - "location": "Location TBD", - "title": "test-event", - }, - "locale": "en", - "reminders": [], - "timezone": "America/Los_Angeles", - }, - "created_at": "2021-10-22", - "edit_token": "test-edit-token-2", - "id": 90211, - "modified_at": "2021-10-22", - "name": "test-2", - "slug": "test2", - }, - ] - - def list_callback(arg=None): - return 200, {}, json.dumps(scheduler_list) - - def return_one_callback(arg=None): - return 200, {}, json.dumps(scheduler_list[0]) - - info_endpoint = re.compile("https://api.schedule.nylas.com/schedule/.*/info") - - mocked_responses.add_callback( - responses.GET, - "https://api.schedule.nylas.com/manage/pages", - content_type="application/json", - callback=list_callback, - ) - - mocked_responses.add_callback( - responses.GET, - info_endpoint, - content_type="application/json", - callback=return_one_callback, - ) - - -@pytest.fixture -def mock_scheduler_get_available_calendars(mocked_responses, api_url): - calendars = [ - { - "calendars": [ - {"id": "calendar-id", "name": "Emailed events", "read_only": "true"}, - ], - "email": "swag@nylas.com", - "id": "scheduler-id", - "name": "Python Tester", - } - ] - - def list_callback(arg=None): - return 200, {}, json.dumps(calendars) - - calendars_url = "https://api.schedule.nylas.com/manage/pages/{id}/calendars".format( - id="cv4ei7syx10uvsxbs21ccsezf" - ) - - mocked_responses.add_callback( - responses.GET, - calendars_url, - content_type="application/json", - callback=list_callback, - ) - - -@pytest.fixture -def mock_scheduler_upload_image(mocked_responses, api_url): - upload = { - "filename": "test.png", - "originalFilename": "test.png", - "publicUrl": "https://public.nylas.com/test.png", - "signedUrl": "https://signed.nylas.com/test.png", - } - - def list_callback(arg=None): - return 200, {}, json.dumps(upload) - - calendars_url = ( - "https://api.schedule.nylas.com/manage/pages/{id}/upload-image".format( - id="cv4ei7syx10uvsxbs21ccsezf" - ) - ) - - mocked_responses.add_callback( - responses.PUT, - calendars_url, - content_type="application/json", - callback=list_callback, - ) - - -@pytest.fixture -def mock_scheduler_provider_availability(mocked_responses, api_url): - response = { - "busy": [ - { - "end": 1636731958, - "start": 1636728347, - }, - ], - "email": "test@example.com", - "name": "John Doe", - } - - def callback(arg=None): - return 200, {}, json.dumps(response) - - provider_url = re.compile( - "https://api.schedule.nylas.com/schedule/availability/(google|o365)" - ) - - mocked_responses.add_callback( - responses.GET, - provider_url, - callback=callback, - ) - - -@pytest.fixture -def mock_scheduler_timeslots(mocked_responses, api_url): - scheduler_time_slots = [ - { - "account_id": "test-account-id", - "calendar_id": "test-calendar-id", - "emails": ["test@example.com"], - "end": 1636731958, - "host_name": "www.hostname.com", - "start": 1636728347, - }, - ] - - booking_confirmation = { - "account_id": "test-account-id", - "additional_field_values": { - "test": "yes", - }, - "calendar_event_id": "test-event-id", - "calendar_id": "test-calendar-id", - "edit_hash": "test-edit-hash", - "end_time": 1636731958, - "id": 123, - "is_confirmed": False, - "location": "Earth", - "recipient_email": "recipient@example.com", - "recipient_locale": "en_US", - "recipient_name": "Recipient Doe", - "recipient_tz": "America/New_York", - "start_time": 1636728347, - "title": "Test Booking", - } - - cancel_payload = { - "success": True, - } - - def list_timeslots(arg=None): - return 200, {}, json.dumps(scheduler_time_slots) - - def book_timeslot(arg=None): - return 200, {}, json.dumps(booking_confirmation) - - def confirm_booking(arg=None): - booking_confirmation["is_confirmed"] = True - return 200, {}, json.dumps(booking_confirmation) - - def cancel_booking(arg=None): - return 200, {}, json.dumps(cancel_payload) - - timeslots_url = re.compile("https://api.schedule.nylas.com/schedule/.*/timeslots") - - confirm_url = re.compile("https://api.schedule.nylas.com/schedule/.*/.*/confirm") - - cancel_url = re.compile("https://api.schedule.nylas.com/schedule/.*/.*/cancel") - - mocked_responses.add_callback( - responses.GET, - timeslots_url, - callback=list_timeslots, - ) - - mocked_responses.add_callback( - responses.POST, - timeslots_url, - callback=book_timeslot, - ) - - mocked_responses.add_callback( - responses.POST, - confirm_url, - callback=confirm_booking, - ) - - mocked_responses.add_callback( - responses.POST, - cancel_url, - callback=cancel_booking, - ) - - -@pytest.fixture -def mock_components(mocked_responses, api_url): - components = [ - { - "active": True, - "settings": {}, - "allowed_domains": [], - "id": "component-id", - "name": "PyTest Component", - "public_account_id": "account-id", - "public_application_id": "application-id", - "type": "agenda", - "created_at": "2021-10-22T18:02:10.000Z", - "updated_at": "2021-10-22T18:02:10.000Z", - "accessed_at": None, - "public_token_id": "token-id", - }, - ] - - def list_callback(arg=None): - return 200, {}, json.dumps(components) - - endpoint = re.compile(api_url + "/component/*") - mocked_responses.add_callback( - responses.GET, endpoint, content_type="application/json", callback=list_callback - ) - - -@pytest.fixture -def mock_create_webhook(mocked_responses, api_url, client_id): - webhook = {"application_id": "application-id", "id": "webhook-id", "version": "1.0"} - - def callback(request): - try: - payload = json.loads(request.body) - except ValueError: - return 400, {}, "" - - if ( - "callback_url" not in payload - and ("triggers" not in payload and type(payload["triggers"]) is not list) - and "state" not in payload - ): - return 400, {}, "" - - webhook["callback_url"] = payload["callback_url"] - webhook["triggers"] = payload["triggers"] - webhook["state"] = payload["state"] - - return 200, {}, json.dumps(webhook) - - endpoint = "{base}/a/{client_id}/webhooks".format(base=api_url, client_id=client_id) - mocked_responses.add_callback( - responses.POST, - endpoint, - callback=callback, - content_type="application/json", - ) - - -@pytest.fixture -def mock_webhooks(mocked_responses, api_url, client_id): - webhook = { - "application_id": "application-id", - "callback_url": "https://your-server.com/webhook", - "id": "webhook-id", - "state": "active", - "triggers": ["message.created"], - "version": "2.0", - } - - def list_callback(request): - return 200, {}, json.dumps([webhook]) - - def single_callback(request): - webhook["id"] = get_id_from_url(request.url) - return 200, {}, json.dumps(webhook) - - def update_callback(request): - try: - payload = json.loads(request.body) - except ValueError: - return 400, {}, "" - - if "state" in payload: - webhook["state"] = payload["state"] - webhook["id"] = get_id_from_url(request.url) - return 200, {}, json.dumps(webhook) - - def delete_callback(request): - return 200, {}, json.dumps({"success": True}) - - def get_id_from_url(url): - path = URLObject(url).path - return path.rsplit("/", 1)[-1] - - endpoint_single = re.compile( - "{base}/a/{client_id}/webhooks/*".format(base=api_url, client_id=client_id) - ) - endpoint_list = "{base}/a/{client_id}/webhooks".format( - base=api_url, client_id=client_id - ) - mocked_responses.add_callback( - responses.GET, - endpoint_list, - content_type="application/json", - callback=list_callback, - ) - mocked_responses.add_callback( - responses.GET, - endpoint_single, - content_type="application/json", - callback=single_callback, - ) - mocked_responses.add_callback( - responses.PUT, - endpoint_single, - content_type="application/json", - callback=update_callback, - ) - mocked_responses.add_callback( - responses.DELETE, - endpoint_single, - content_type="application/json", - callback=delete_callback, - ) - +from unittest.mock import patch, Mock -@pytest.fixture -def mock_resources(mocked_responses, api_url): - resources = [ - { - "object": "room_resource", - "email": "training-room-1A@google.com", - "name": "Google Training Room", - "building": "San Francisco", - "capacity": "10", - "floor_name": "7", - "floor_number": None, - }, - { - "object": "room_resource", - "email": "training-room@outlook.com", - "name": "Microsoft Training Room", - "building": "Seattle", - "capacity": "5", - "floor_name": "Office", - "floor_number": "2", - }, - ] - - endpoint = re.compile(api_url + "/resources") - mocked_responses.add( - responses.GET, - endpoint, - body=json.dumps(resources), - status=200, - content_type="application/json", - ) - - -@pytest.fixture -def mock_job_statuses(mocked_responses, api_url): - job_status = [ - { - "account_id": "test_account_id", - "action": "save_draft", - "created_at": 1622846160, - "id": "test_id", - "job_status_id": "test_job_status_id", - "object": "message", - "status": "successful", - "metadata": {"message_id": "nylas_message_id"}, - }, - { - "account_id": "test_account_id", - "action": "update_event", - "created_at": 1622846160, - "id": "test_id_2", - "job_status_id": "test_job_status_id_2", - "object": "event", - "status": "successful", - }, - ] - - endpoint = re.compile(api_url + "/job-statuses") - mocked_responses.add( - responses.GET, - endpoint, - body=json.dumps(job_status), - status=200, - content_type="application/json", - ) - - -@pytest.fixture -def mock_account_management(mocked_responses, api_url, account_id, client_id): - account = { - "account_id": account_id, - "email_address": "ben.bitdiddle1861@gmail.com", - "id": account_id, - "name": "Ben Bitdiddle", - "object": "account", - "provider": "gmail", - "organization_unit": "label", - "billing_state": "paid", - "authentication_type": "password", - } - paid_response = json.dumps(account) - account["billing_state"] = "cancelled" - cancelled_response = json.dumps(account) - - upgrade_url = "{base}/a/{client_id}/accounts/{id}/upgrade".format( - base=api_url, id=account_id, client_id=client_id - ) - downgrade_url = "{base}/a/{client_id}/accounts/{id}/downgrade".format( - base=api_url, id=account_id, client_id=client_id - ) - mocked_responses.add( - responses.POST, - upgrade_url, - content_type="application/json", - status=200, - body=paid_response, - ) - mocked_responses.add( - responses.POST, - downgrade_url, - content_type="application/json", - status=200, - body=cancelled_response, - ) +import pytest +import requests +from nylas.models.response import Response, ListResponse +from nylas.handler.http_client import HttpClient -@pytest.fixture -def mock_revoke_all_tokens(mocked_responses, api_url, account_id, client_id): - revoke_all_url = "{base}/a/{client_id}/accounts/{id}/revoke-all".format( - base=api_url, id=account_id, client_id=client_id - ) - mocked_responses.add( - responses.POST, - revoke_all_url, - content_type="application/json", - status=200, - body=json.dumps({"success": True}), - ) +from nylas import Client @pytest.fixture -def mock_application_details(mocked_responses, api_url, client_id): - application_details_url = "{base}/a/{client_id}".format( - base=api_url, client_id=client_id - ) - - def modify_endpoint(request): - return 200, {}, json.dumps(json.loads(request.body)) - - mocked_responses.add( - responses.GET, - application_details_url, - content_type="application/json", - status=200, - body=json.dumps( - { - "application_name": "My New App Name", - "icon_url": "http://localhost:5555/icon.png", - "redirect_uris": [ - "http://localhost:5555/login_callback", - "localhost", - "https://customerA.myapplication.com/login_callback", - ], - } - ), - ) - mocked_responses.add_callback( - responses.PUT, - application_details_url, - content_type="application/json", - callback=modify_endpoint, +def client(): + return Client( + api_key="test-key", ) @pytest.fixture -def mock_ip_addresses(mocked_responses, api_url, client_id): - ip_addresses_url = "{base}/a/{client_id}/ip_addresses".format( - base=api_url, client_id=client_id - ) - mocked_responses.add( - responses.GET, - ip_addresses_url, - content_type="application/json", - status=200, - body=json.dumps( - { - "ip_addresses": [ - "39.45.235.23", - "23.10.341.123", - "12.56.256.654", - "67.20.987.231", - ], - "updated_at": 1552072984, - } - ), +def http_client(): + return HttpClient( + api_server="https://test.nylas.com", + api_key="test-key", + timeout=30, ) @pytest.fixture -def mock_token_info(mocked_responses, api_url, account_id, client_id): - token_info_url = re.compile(api_url + "/a/.*/accounts/.*/token-info") - mocked_responses.add( - responses.POST, - token_info_url, - content_type="application/json", - status=200, - body=json.dumps( - { - "created_at": 1563496685, - "scopes": "calendar,email,contacts", - "state": "valid", - "updated_at": 1563496685, - } - ), - ) +def patched_version_and_sys(): + with patch("sys.version_info", (1, 2, 3, "final", 5)), patch( + "nylas.handler.http_client.__VERSION__", "2.0.0" + ): + yield @pytest.fixture -def mock_free_busy(mocked_responses, api_url): - free_busy_url = "{base}/calendars/free-busy".format(base=api_url) - - def free_busy_callback(request): - payload = json.loads(request.body) - email = payload["emails"][0] - resp_data = [ - { - "object": "free_busy", - "email": email, - "time_slots": [ - { - "object": "time_slot", - "status": "busy", - "start_time": 1409594400, - "end_time": 1409598000, - }, - { - "object": "time_slot", - "status": "busy", - "start_time": 1409598000, - "end_time": 1409599000, - }, - ], - } - ] - return 200, {}, json.dumps(resp_data) +def patched_session_request(): + mock_response = Mock() + mock_response.content = b"mock data" + mock_response.json.return_value = {"foo": "bar"} + mock_response.status_code = 200 - mocked_responses.add_callback( - responses.POST, - free_busy_url, - content_type="application/json", - callback=free_busy_callback, - ) + with patch("requests.Session.request", return_value=mock_response) as mock_request: + yield mock_request @pytest.fixture -def mock_availability(mocked_responses, api_url): - availability_url = "{base}/calendars/availability".format(base=api_url) - - def availability_callback(request): - payload = json.loads(request.body) - resp_data = { - "object": "availability", - "time_slots": [ - { - "object": "time_slot", - "status": "free", - "start_time": 1409594400, - "end_time": 1409598000, - }, - { - "object": "time_slot", - "status": "free", - "start_time": 1409598000, - "end_time": 1409599000, - }, - ], - } - - return 200, {}, json.dumps(resp_data) - - mocked_responses.add_callback( - responses.POST, - availability_url, - content_type="application/json", - callback=availability_callback, - ) - - mocked_responses.add_callback( - responses.POST, - "{url}/consecutive".format(url=availability_url), - content_type="application/json", - callback=availability_callback, - ) +def mock_session_timeout(): + with patch("requests.Session.request", side_effect=requests.exceptions.Timeout): + yield @pytest.fixture -def mock_sentiment_analysis(mocked_responses, api_url, account_id): - sentiment_url = "{base}/neural/sentiment".format(base=api_url) - - def sentiment_callback(request): - payload = json.loads(request.body) - if "message_id" in payload: - response = [ +def http_client_list_response(): + with patch( + "nylas.models.response.ListResponse.from_dict", + return_value=ListResponse([], "bar"), + ): + mock_http_client = Mock() + mock_http_client._execute.return_value = { + "request_id": "abc-123", + "data": [ { - "account_id": account_id, - "processed_length": 11, - "sentiment": "NEUTRAL", - "sentiment_score": 0.30000001192092896, - "text": "hello world", + "id": "calendar-123", + "grant_id": "grant-123", + "name": "Mock Calendar", + "read_only": False, + "is_owned_by_user": True, + "object": "calendar", } - ] - else: - response = { - "account_id": account_id, - "processed_length": len(payload["text"]), - "sentiment": "NEUTRAL", - "sentiment_score": 0.30000001192092896, - "text": payload["text"], - } - - return 200, {}, json.dumps(response) - - mocked_responses.add_callback( - responses.PUT, - sentiment_url, - content_type="application/json", - callback=sentiment_callback, - ) - - -@pytest.fixture -def mock_extract_signature(mocked_responses, api_url, account_id): - signature_url = "{base}/neural/signature".format(base=api_url) - - def signature_callback(request): - payload = json.loads(request.body) - response = { - "account_id": account_id, - "body": "This is the body
Nylas Swag
Software Engineer
123-456-8901
swag@nylas.com
https://example.com/link.html", - "signature": "Nylas Swag\n\nSoftware Engineer\n\n123-456-8901\n\nswag@nylas.com", - "date": 1624029503, - "from": [ - { - "email": "swag@nylas.com", - "name": "Nylas Swag", - }, ], - "id": "abc123", - "model_version": "0.0.1", - "object": "message", - "provider_name": "gmail", - "subject": "Subject", - "to": [ - { - "email": "me@nylas.com", - "name": "me", - }, - ], - } - if "parse_contacts" not in payload or payload["parse_contacts"] is True: - response["contacts"] = { - "job_titles": ["Software Engineer"], - "links": [ - { - "description": "string", - "url": "https://example.com/link.html", - }, - ], - "phone_numbers": ["123-456-8901"], - "emails": ["swag@nylas.com"], - "names": [ - { - "first_name": "Nylas", - "last_name": "Swag", - }, - ], - } - - return 200, {}, json.dumps([response]) - - mocked_responses.add_callback( - responses.PUT, - signature_url, - content_type="application/json", - callback=signature_callback, - ) - - -@pytest.fixture -def mock_categorize(mocked_responses, api_url, account_id): - categorize_url = "{base}/neural/categorize".format(base=api_url) - - def categorize_callback(request): - response = { - "account_id": account_id, - "body": "This is a body", - "categorizer": { - "categorized_at": 1627076720, - "category": "feed", - "model_version": "6194f733", - "subcategories": ["ooo"], - }, - "date": 1624029503, - "from": [ - { - "email": "swag@nylas.com", - "name": "Nylas Swag", - }, - ], - "id": "abc123", - "object": "message", - "provider_name": "gmail", - "subject": "Subject", - "to": [ - { - "email": "me@nylas.com", - "name": "me", - }, - ], - } - - return 200, {}, json.dumps([response]) - - def recategorize_callback(request): - response = { - "account_id": account_id, - "category": "conversation", - "is_primary_label": "true", - "message_id": "abc123", - "recategorized_at": "2021-07-17T00:04:22.006193", - "recategorized_from": { - "category": "feed", - "model_version": "6194f733", - "subcategories": ["ooo"], - }, - "subcategories": ["ooo"], - } - - return 200, {}, json.dumps(response) - - mocked_responses.add_callback( - responses.PUT, - categorize_url, - content_type="application/json", - callback=categorize_callback, - ) - - mocked_responses.add_callback( - responses.POST, - "{}/feedback".format(categorize_url), - content_type="application/json", - callback=recategorize_callback, - ) - - -@pytest.fixture -def mock_ocr_request(mocked_responses, api_url, account_id): - ocr_url = "{base}/neural/ocr".format(base=api_url) - - def ocr_callback(request): - response = { - "account_id": account_id, - "content_type": "application/pdf", - "filename": "sample.pdf", - "id": "abc123", - "object": "file", - "ocr": ["This is page 1", "This is page 2"], - "processed_pages": 2, - "size": 20, - } - - return 200, {}, json.dumps(response) - - mocked_responses.add_callback( - responses.PUT, - ocr_url, - content_type="application/json", - callback=ocr_callback, - ) - - -@pytest.fixture -def mock_clean_conversation(mocked_responses, api_url, account_id): - conversation_url = "{base}/neural/conversation".format(base=api_url) - file_url = "{base}/files/1781777f666586677621".format(base=api_url) - - def conversation_callback(request): - response = { - "account_id": account_id, - "body": " This is the body", - "conversation": " This is the conversation", - "date": 1624029503, - "from": [ - { - "email": "swag@nylas.com", - "name": "Nylas Swag", - }, - ], - "id": "abc123", - "model_version": "0.0.1", - "object": "message", - "provider_name": "gmail", - "subject": "Subject", - "to": [ - { - "email": "me@nylas.com", - "name": "me", - }, - ], - } - - return 200, {}, json.dumps([response]) - - def file_callback(request): - response = { - "id": "1781777f666586677621", - "content_type": "image/png", - "filename": "hello.png", - "account_id": account_id, - "object": "file", - "size": 123, } - - return 200, {}, json.dumps(response) - - mocked_responses.add_callback( - responses.PUT, - conversation_url, - content_type="application/json", - callback=conversation_callback, - ) - - mocked_responses.add_callback( - responses.GET, - file_url, - content_type="application/json", - callback=file_callback, - ) + yield mock_http_client @pytest.fixture -def mock_deltas_since(mocked_responses, api_url): - deltas = { - "cursor_start": "start_cursor", - "cursor_end": "end_cursor", - "deltas": [ - { - "attributes": { - "account_id": "aid-5678", - "given_name": "First", - "surname": "Last", - "id": "id-1234", - "object": "contact", - }, - "cursor": "contact_cursor", - "event": "create", - "id": "delta-1", - "object": "contact", - }, - { - "attributes": { - "account_id": "aid-5678", - "content_type": "text/plain", - "filename": "sample.txt", - "id": "id-1234", - "object": "file", - "size": 123, - }, - "cursor": "file_cursor", - "event": "create", - "id": "delta-2", - "object": "file", - }, - { - "attributes": { - "account_id": "aid-5678", - "to": [{"email": "foo", "name": "bar"}], - "subject": "foo", - "id": "id-1234", - "object": "message", - }, - "cursor": "message_cursor", - "event": "create", - "id": "delta-3", - "object": "message", - }, - { - "attributes": { - "account_id": "aid-5678", - "to": [{"email": "foo", "name": "bar"}], - "subject": "foo", - "id": "id-1234", - "object": "draft", - }, - "cursor": "draft_cursor", - "event": "create", - "id": "delta-4", - "object": "draft", - }, - { - "attributes": { - "account_id": "aid-5678", - "subject": "Subject", - "id": "id-1234", - "object": "thread", - }, - "cursor": "thread_cursor", - "event": "create", - "id": "delta-5", - "object": "thread", - }, - { - "attributes": { - "id": "id-1234", - "title": "test event", - "when": {"time": 1409594400, "object": "time"}, - "participants": [ - { - "name": "foo", - "email": "bar", - "status": "noreply", - "comment": "This is a comment", - "phone_number": "416-000-0000", - }, - ], - "ical_uid": "id-5678", - "master_event_id": "master-1234", - "original_start_time": 1409592400, - }, - "cursor": "event_cursor", - "event": "create", - "id": "delta-6", - "object": "event", - }, - { - "attributes": { - "account_id": "aid-5678", - "id": "id-1234", - "object": "folder", - "name": "inbox", - "display_name": "name", - }, - "cursor": "folder_cursor", - "event": "create", - "id": "delta-7", - "object": "folder", - }, - { - "attributes": { - "account_id": "aid-5678", - "id": "id-1234", - "object": "label", - "name": "inbox", - }, - "cursor": "label_cursor", - "event": "create", - "id": "delta-8", - "object": "label", +def http_client_response(): + with patch( + "nylas.models.response.Response.from_dict", return_value=Response({}, "bar") + ): + mock_http_client = Mock() + mock_http_client._execute.return_value = { + "request_id": "abc-123", + "data": { + "id": "calendar-123", + "grant_id": "grant-123", + "name": "Mock Calendar", + "read_only": False, + "is_owned_by_user": True, + "object": "calendar", }, - ], - } - - def callback(request): - return 200, {}, json.dumps(deltas) - - mocked_responses.add_callback( - responses.GET, - "{base}/delta".format(base=api_url), - callback=callback, - content_type="application/json", - ) - - -@pytest.fixture -def mock_delta_cursor(mocked_responses, api_url): - def callback(request): - return 200, {}, json.dumps({"cursor": "cursor"}) - - mocked_responses.add_callback( - responses.POST, - "{base}/delta/latest_cursor".format(base=api_url), - callback=callback, - content_type="application/json", - ) - - -@pytest.fixture -def mock_delta_stream(mocked_responses, api_url): - delta = { - "attributes": { - "account_id": "aid-5678", - "given_name": "First", - "surname": "Last", - "id": "id-1234", - "object": "contact", - }, - "cursor": "contact_cursor", - "event": "create", - "id": "delta-1", - "object": "contact", - } - - def stream_callback(request): - return 200, {}, json.dumps(delta) - - def longpoll_callback(request): - response = { - "cursor_start": "start_cursor", - "cursor_end": "end_cursor", - "deltas": [delta], } - return 200, {}, json.dumps(response) - - mocked_responses.add_callback( - responses.GET, - "{base}/delta/streaming".format(base=api_url), - callback=stream_callback, - content_type="application/json", - ) - - mocked_responses.add_callback( - responses.GET, - "{base}/delta/longpoll".format(base=api_url), - callback=longpoll_callback, - content_type="application/json", - ) - - -@pytest.fixture -def mock_outbox(mocked_responses, api_url): - outbox_job_status = { - "job_status_id": "job-status-id", - "status": "pending", - "original_data": { - "subject": "With Love, from Nylas", - "to": [{"name": "Me", "email": "test@email.com"}], - "body": "This email was sent using the Nylas email API. Visit https://nylas.com for details.", - }, - "account_id": "account-id", - } - - def return_job_status(request): - response = outbox_job_status - payload = json.loads(request.body) - if "send_at" in payload: - response["original_data"]["send_at"] = payload["send_at"] - response["original_data"]["original_send_at"] = payload["send_at"] - response["original_data"]["retry_limit_datetime"] = payload["send_at"] - if "retry_limit_datetime" in payload: - response["original_data"]["retry_limit_datetime"] = payload[ - "retry_limit_datetime" - ] - return 200, {}, json.dumps(response) - - def delete_callback(request): - return 200, {}, "" - - outbox_endpoint = "{base}/v2/outbox".format(base=api_url) - endpoint_single = re.compile("{outbox_url}/*".format(outbox_url=outbox_endpoint)) - - mocked_responses.add_callback( - responses.POST, - outbox_endpoint, - callback=return_job_status, - content_type="application/json", - ) - - mocked_responses.add_callback( - responses.PATCH, - endpoint_single, - callback=return_job_status, - content_type="application/json", - ) - - mocked_responses.add_callback( - responses.DELETE, - endpoint_single, - callback=delete_callback, - content_type="application/json", - ) - - -@pytest.fixture -def mock_outbox_send_grid(mocked_responses, api_url): - send_grid_verification = { - "results": {"domain_verified": True, "sender_verified": True} - } - - def return_status(request): - return 200, {}, json.dumps(send_grid_verification) - - def delete_callback(request): - return 200, {}, "" - - verification_url = "{base}/v2/outbox/onboard/verified_status".format(base=api_url) - delete_url = "{base}/v2/outbox/onboard/subuser".format(base=api_url) - - mocked_responses.add_callback( - responses.GET, - verification_url, - callback=return_status, - content_type="application/json", - ) - - mocked_responses.add_callback( - responses.DELETE, - delete_url, - callback=delete_callback, - content_type="application/json", - ) + yield mock_http_client @pytest.fixture -def mock_integrations(mocked_responses, client_id): - integration = { - "name": "Nylas Playground", - "provider": "zoom", - "settings": { - "client_id": "test_client_id", - "client_secret": "test_client_secret", - }, - "redirect_uris": ["https://www.nylas.com"], - "expires_in": 12000, +def http_client_delete_response(): + mock_http_client = Mock() + mock_http_client._execute.return_value = { + "request_id": "abc-123", } - - def list_callback(request): - response = {"data": [integration], "limit": 10, "offset": 0} - return 200, {}, json.dumps(response) - - def single_callback(request): - integration["provider"] = get_id_from_url(request.url) - response = {"data": integration} - return 200, {}, json.dumps(response) - - def update_callback(request): - try: - payload = json.loads(request.body) - except ValueError: - return 400, {}, "" - - response = {"success": True, "data": payload} - return 200, {}, json.dumps(response) - - def delete_callback(request): - return 200, {}, json.dumps({"success": True}) - - def get_id_from_url(url): - path = URLObject(url).path - return path.rsplit("/", 1)[-1] - - endpoint_post = re.compile("https://.*nylas.com/connect/integrations") - endpoint_single = re.compile("https://.*nylas.com/connect/integrations/.*") - endpoint_list = re.compile("https://.*nylas.com/connect/integrations\?.*") - mocked_responses.add_callback( - responses.GET, - endpoint_list, - content_type="application/json", - callback=list_callback, - ) - mocked_responses.add_callback( - responses.GET, - endpoint_single, - content_type="application/json", - callback=single_callback, - ) - mocked_responses.add_callback( - responses.POST, - endpoint_post, - content_type="application/json", - callback=update_callback, - ) - mocked_responses.add_callback( - responses.PATCH, - endpoint_single, - content_type="application/json", - callback=update_callback, - ) - mocked_responses.add_callback( - responses.DELETE, - endpoint_single, - content_type="application/json", - callback=delete_callback, - ) + return mock_http_client @pytest.fixture -def mock_grants(mocked_responses, client_id): - grant = { - "id": "grant-id", - "provider": "zoom", - "grant_status": "valid", - "email": "email@example.com", - "metadata": {"isAdmin": True}, - "scope": ["meeting:write"], - "user_agent": "string", - "ip": "string", - "state": "my-state", - "created_at": 1617817109, - "updated_at": 1617817109, +def http_client_token_exchange(): + mock_http_client = Mock() + mock_http_client._execute.return_value = { + "access_token": "nylas_access_token", + "expires_in": 3600, + "id_token": "jwt_token", + "refresh_token": "nylas_refresh_token", + "scope": "https://www.googleapis.com/auth/gmail.readonly profile", + "token_type": "Bearer", + "grant_id": "grant_123", } - - def list_callback(request): - response = {"data": [grant], "limit": 10, "offset": 0} - return 200, {}, json.dumps(response) - - def single_callback(request): - response = {"data": grant} - return 200, {}, json.dumps(response) - - def update_callback(request): - try: - payload = json.loads(request.body) - except ValueError: - return 400, {}, "" - - response = {"success": True, "data": payload} - return 200, {}, json.dumps(response) - - def delete_callback(request): - return 200, {}, json.dumps({"success": True}) - - def on_demand_sync(request): - return 200, {}, json.dumps(grant) - - endpoint_post = re.compile("https://.*nylas.com/connect/grants") - endpoint_single = re.compile("https://.*nylas.com/connect/grants/.*") - endpoint_list = re.compile("https://.*nylas.com/connect/grants\?.*") - endpoint_sync = re.compile("https://.*nylas.com/connect/grants/.*/sync.*") - mocked_responses.add_callback( - responses.POST, - endpoint_sync, - content_type="application/json", - callback=on_demand_sync, - ) - mocked_responses.add_callback( - responses.GET, - endpoint_list, - content_type="application/json", - callback=list_callback, - ) - mocked_responses.add_callback( - responses.GET, - endpoint_single, - content_type="application/json", - callback=single_callback, - ) - mocked_responses.add_callback( - responses.POST, - endpoint_post, - content_type="application/json", - callback=update_callback, - ) - mocked_responses.add_callback( - responses.PATCH, - endpoint_single, - content_type="application/json", - callback=update_callback, - ) - mocked_responses.add_callback( - responses.DELETE, - endpoint_single, - content_type="application/json", - callback=delete_callback, - ) + return mock_http_client @pytest.fixture -def mock_authentication_hosted_auth(mocked_responses, client_id): - api_response = { - "success": True, - "data": { - "url": "https://accounts.nylas.com/connect/login?id=uas-hosted-id", - "id": "uas-hosted-id", - "expires_at": 0, - "request": {}, - }, +def http_client_token_info(): + mock_http_client = Mock() + mock_http_client._execute.return_value = { + "iss": "https://nylas.com", + "aud": "http://localhost:3030", + "sub": "Jaf84d88-ยฃ274-46cc-bbc9-aed7dac061c7", + "email": "user@example.com", + "iat": 1692094848, + "exp": 1692095173, } - - def hosted_auth_response(request): - try: - payload = json.loads(request.body) - except ValueError: - return 400, {}, "" - - api_response["data"]["request"] = payload - return 200, {}, json.dumps(api_response) - - endpoint = re.compile("https://.*nylas.com/connect/auth") - mocked_responses.add_callback( - responses.POST, - endpoint, - content_type="application/json", - callback=hosted_auth_response, - ) + return mock_http_client diff --git a/tests/handler/test_api_resources.py b/tests/handler/test_api_resources.py new file mode 100644 index 00000000..eb0a760c --- /dev/null +++ b/tests/handler/test_api_resources.py @@ -0,0 +1,152 @@ +from unittest.mock import patch, Mock + +import pytest +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) + +from nylas.handler.http_client import ( + HttpClient, +) +from nylas.models.calendars import Calendar +from nylas.models.response import ( + ListResponse, + Response, + DeleteResponse, + RequestIdOnlyResponse, +) + + +class MockResource( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + pass + + +class TestApiResource: + def test_list_resource(self, http_client_list_response): + resource = MockResource(http_client_list_response) + + response = resource.list( + path="/foo", + response_type=Calendar, + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert type(response) is ListResponse + http_client_list_response._execute.assert_called_once_with( + "GET", + "/foo", + {"test": "header"}, + {"query": "param"}, + {"foo": "bar"}, + ) + + def test_find_resource(self, http_client_response): + resource = MockResource(http_client_response) + + response = resource.find( + path="/foo", + response_type=Calendar, + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert type(response) is Response + http_client_response._execute.assert_called_once_with( + "GET", + "/foo", + {"test": "header"}, + {"query": "param"}, + {"foo": "bar"}, + ) + + def test_create_resource(self, http_client_response): + resource = MockResource(http_client_response) + + response = resource.create( + path="/foo", + response_type=Calendar, + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert type(response) is Response + http_client_response._execute.assert_called_once_with( + "POST", + "/foo", + {"test": "header"}, + {"query": "param"}, + {"foo": "bar"}, + ) + + def test_update_resource(self, http_client_response): + resource = MockResource(http_client_response) + + response = resource.update( + path="/foo", + response_type=Calendar, + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert type(response) is Response + http_client_response._execute.assert_called_once_with( + "PUT", + "/foo", + {"test": "header"}, + {"query": "param"}, + {"foo": "bar"}, + ) + + def test_destroy_resource(self, http_client_delete_response): + resource = MockResource(http_client_delete_response) + + response = resource.destroy( + path="/foo", + response_type=RequestIdOnlyResponse, + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert type(response) is RequestIdOnlyResponse + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/foo", + {"test": "header"}, + {"query": "param"}, + {"foo": "bar"}, + ) + + def test_destroy_resource_default_type(self, http_client_delete_response): + resource = MockResource(http_client_delete_response) + + response = resource.destroy( + path="/foo", + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert type(response) is DeleteResponse + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/foo", + {"test": "header"}, + {"query": "param"}, + {"foo": "bar"}, + ) diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py new file mode 100644 index 00000000..62a44d8a --- /dev/null +++ b/tests/handler/test_http_client.py @@ -0,0 +1,247 @@ +from unittest.mock import Mock + +import pytest + +from nylas.handler.http_client import ( + HttpClient, + _build_query_params, + _validate_response, +) +from nylas.models.errors import NylasApiError, NylasOAuthError + + +class TestData: + def __init__(self, content_type=None): + self.content_type = content_type + + +class TestHttpClient: + def test_http_client_init(self): + http_client = HttpClient( + api_server="https://test.nylas.com", + api_key="test-key", + timeout=60, + ) + + assert http_client.api_server == "https://test.nylas.com" + assert http_client.api_key == "test-key" + assert http_client.timeout == 60 + + def test_build_headers_default(self, http_client, patched_version_and_sys): + headers = http_client._build_headers() + + assert headers == { + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + } + + def test_build_headers_extra_headers(self, http_client, patched_version_and_sys): + headers = http_client._build_headers( + extra_headers={ + "foo": "bar", + "X-Test": "test", + } + ) + + assert headers == { + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + "foo": "bar", + "X-Test": "test", + } + + def test_build_headers_json_body(self, http_client, patched_version_and_sys): + headers = http_client._build_headers( + response_body={ + "foo": "bar", + } + ) + + assert headers == { + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + "Content-type": "application/json", + } + + def test_build_headers_form_body(self, http_client, patched_version_and_sys): + headers = http_client._build_headers( + response_body={ + "foo": "bar", + }, + data=TestData(content_type="application/x-www-form-urlencoded"), + ) + + assert headers == { + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + "Content-type": "application/x-www-form-urlencoded", + } + + def test_build_request_default(self, http_client, patched_version_and_sys): + request = http_client._build_request( + method="GET", + path="/foo", + ) + + assert request == { + "method": "GET", + "url": "https://test.nylas.com/foo", + "headers": { + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + }, + } + + def test_build_query_params(self, patched_version_and_sys): + url = _build_query_params( + base_url="https://test.nylas.com/foo", + query_params={ + "foo": "bar", + "list": ["a", "b", "c"], + "map": {"key1": "value1", "key2": "value2"}, + }, + ) + + assert ( + url + == "https://test.nylas.com/foo?foo=bar&list=a&list=b&list=c&map=key1:value1&map=key2:value2" + ) + + def test_execute_download_request(self, http_client, patched_session_request): + response = http_client._execute_download_request( + path="/foo", + ) + assert response == b"mock data" + + def test_execute_download_request_with_stream( + self, http_client, patched_session_request + ): + response = http_client._execute_download_request( + path="/foo", + stream=True, + ) + assert isinstance(response, Mock) is True + assert response.content == b"mock data" + + def test_execute_download_request_timeout(self, http_client, mock_session_timeout): + with pytest.raises(Exception) as e: + http_client._execute_download_request( + path="/foo", + ) + assert ( + str(e.value) + == "Nylas SDK timed out before receiving a response from the server." + ) + + def test_validate_response(self): + response = Mock() + response.status_code = 200 + response.json.return_value = {"foo": "bar"} + response.url = "https://test.nylas.com/foo" + + validation = _validate_response(response) + assert validation == {"foo": "bar"} + + def test_validate_response_400_error(self): + response = Mock() + response.status_code = 400 + response.json.return_value = { + "request_id": "123", + "error": { + "type": "api_error", + "message": "The request is invalid.", + "provider_error": {"foo": "bar"}, + }, + } + response.url = "https://test.nylas.com/foo" + + with pytest.raises(Exception) as e: + _validate_response(response) + assert e.type == NylasApiError + assert str(e.value) == "The request is invalid." + assert e.value.type == "api_error" + assert e.value.request_id == "123" + assert e.value.status_code == 400 + assert e.value.provider_error == {"foo": "bar"} + + def test_validate_response_auth_error(self): + response = Mock() + response.status_code = 401 + response.json.return_value = { + "error": "invalid_request", + "error_description": "The request is invalid.", + "error_uri": "https://docs.nylas.com/reference#authentication-errors", + "error_code": 100241, + } + response.url = "https://test.nylas.com/connect/token" + + with pytest.raises(Exception) as e: + _validate_response(response) + assert e.type == NylasOAuthError + assert str(e.value) == "The request is invalid." + assert e.value.error == "invalid_request" + assert e.value.error_code == 100241 + assert e.value.error_description == "The request is invalid." + + def test_validate_response_400_keyerror(self): + response = Mock() + response.status_code = 400 + response.json.return_value = { + "request_id": "123", + "foo": "bar", + } + response.url = "https://test.nylas.com/foo" + + with pytest.raises(Exception) as e: + _validate_response(response) + assert e.type == NylasApiError + assert str(e.value) == "{'request_id': '123', 'foo': 'bar'}" + assert e.value.type == "unknown" + assert e.value.request_id == "123" + assert e.value.status_code == 400 + + def test_execute( + self, http_client, patched_version_and_sys, patched_session_request + ): + response = http_client._execute( + method="GET", + path="/foo", + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert response == {"foo": "bar"} + patched_session_request.assert_called_once_with( + "GET", + "https://test.nylas.com/foo?query=param", + headers={ + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + "Content-type": "application/json", + "test": "header", + }, + json={"foo": "bar"}, + timeout=30, + data=None, + ) + + def test_execute_timeout(self, http_client, mock_session_timeout): + with pytest.raises(Exception) as e: + http_client._execute( + method="GET", + path="/foo", + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + assert ( + str(e.value) + == "Nylas SDK timed out before receiving a response from the server." + ) diff --git a/tests/resources/test_applications.py b/tests/resources/test_applications.py new file mode 100644 index 00000000..9b47eca7 --- /dev/null +++ b/tests/resources/test_applications.py @@ -0,0 +1,90 @@ +from unittest.mock import Mock + +from nylas.models.application_details import ApplicationDetails + +from nylas.resources.redirect_uris import RedirectUris + +from nylas.resources.applications import Applications + + +class TestApplications: + def test_redirect_uris_property(self, http_client): + applications = Applications(http_client) + assert isinstance(applications.redirect_uris, RedirectUris) + + def test_info(self): + mock_http_client = Mock() + mock_http_client._execute.return_value = { + "request_id": "req-123", + "data": { + "application_id": "ad410018-d306-43f9-8361-fa5d7b2172e0", + "organization_id": "f5db4482-dbbe-4b32-b347-61c260d803ce", + "region": "us", + "environment": "production", + "branding": { + "name": "My application", + "icon_url": "https://my-app.com/my-icon.png", + "website_url": "https://my-app.com", + "description": "Online banking application.", + }, + "hosted_authentication": { + "background_image_url": "https://my-app.com/bg.jpg", + "alignment": "left", + "color_primary": "#dc0000", + "color_secondary": "#000056", + "title": "string", + "subtitle": "string", + "background_color": "#003400", + "spacing": 5, + }, + "callback_uris": [ + { + "id": "0556d035-6cb6-4262-a035-6b77e11cf8fc", + "url": "string", + "platform": "web", + "settings": { + "origin": "string", + "bundle_id": "string", + "package_name": "string", + "sha1_certificate_fingerprint": "string", + }, + } + ], + }, + } + app = Applications(mock_http_client) + + res = app.info() + + mock_http_client._execute.assert_called_once_with( + method="GET", path="/v3/applications" + ) + assert type(res.data) == ApplicationDetails + assert res.data.application_id == "ad410018-d306-43f9-8361-fa5d7b2172e0" + assert res.data.organization_id == "f5db4482-dbbe-4b32-b347-61c260d803ce" + assert res.data.region == "us" + assert res.data.environment == "production" + assert res.data.branding.name == "My application" + assert res.data.branding.icon_url == "https://my-app.com/my-icon.png" + assert res.data.branding.website_url == "https://my-app.com" + assert res.data.branding.description == "Online banking application." + assert ( + res.data.hosted_authentication.background_image_url + == "https://my-app.com/bg.jpg" + ) + assert res.data.hosted_authentication.alignment == "left" + assert res.data.hosted_authentication.color_primary == "#dc0000" + assert res.data.hosted_authentication.color_secondary == "#000056" + assert res.data.hosted_authentication.title == "string" + assert res.data.hosted_authentication.subtitle == "string" + assert res.data.hosted_authentication.background_color == "#003400" + assert res.data.hosted_authentication.spacing == 5 + assert res.data.callback_uris[0].id == "0556d035-6cb6-4262-a035-6b77e11cf8fc" + assert res.data.callback_uris[0].url == "string" + assert res.data.callback_uris[0].platform == "web" + assert res.data.callback_uris[0].settings.origin == "string" + assert res.data.callback_uris[0].settings.bundle_id == "string" + assert res.data.callback_uris[0].settings.package_name == "string" + assert ( + res.data.callback_uris[0].settings.sha1_certificate_fingerprint == "string" + ) diff --git a/tests/resources/test_attachments.py b/tests/resources/test_attachments.py new file mode 100644 index 00000000..ab62d2c8 --- /dev/null +++ b/tests/resources/test_attachments.py @@ -0,0 +1,81 @@ +from unittest.mock import Mock + +from nylas.models.attachments import Attachment, FindAttachmentQueryParams +from nylas.resources.attachments import Attachments + + +class TestAttachments: + def test_attachment_deserialization(self, http_client): + attach_json = { + "content_type": "image/png", + "filename": "pic.png", + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "185e56cb50e12e82", + "is_inline": True, + "size": 13068, + "content_id": "", + } + + attachment = Attachment.from_dict(attach_json) + + assert attachment.content_type == "image/png" + assert attachment.filename == "pic.png" + assert attachment.grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert attachment.id == "185e56cb50e12e82" + assert attachment.is_inline is True + assert attachment.size == 13068 + assert attachment.content_id == "" + + def test_find_attachment(self, http_client_response): + attachments = Attachments(http_client_response) + query_params = FindAttachmentQueryParams(message_id="message-123") + + attachments.find( + identifier="abc-123", + attachment_id="attachment-123", + query_params=query_params, + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/attachments/attachment-123", + None, + query_params, + None, + ) + + def test_download_attachment(self): + mock_http_client = Mock() + mock_http_client._execute_download_request.return_value = b"mock data" + attachments = Attachments(mock_http_client) + query_params = FindAttachmentQueryParams(message_id="message-123") + + attachments.download( + identifier="abc-123", + attachment_id="attachment-123", + query_params=query_params, + ) + + mock_http_client._execute_download_request.assert_called_once_with( + path="/v3/grants/abc-123/attachments/attachment-123/download", + query_params=query_params, + stream=True, + ) + + def test_download_bytes(self): + mock_http_client = Mock() + mock_http_client._execute_download_request.return_value = b"mock data" + attachments = Attachments(mock_http_client) + query_params = FindAttachmentQueryParams(message_id="message-123") + + attachments.download_bytes( + identifier="abc-123", + attachment_id="attachment-123", + query_params=query_params, + ) + + mock_http_client._execute_download_request.assert_called_once_with( + path="/v3/grants/abc-123/attachments/attachment-123/download", + query_params=query_params, + stream=False, + ) diff --git a/tests/resources/test_auth.py b/tests/resources/test_auth.py new file mode 100644 index 00000000..fbe57fa1 --- /dev/null +++ b/tests/resources/test_auth.py @@ -0,0 +1,377 @@ +from unittest import mock +from unittest.mock import Mock, patch + +from nylas.models.auth import ( + CodeExchangeResponse, + TokenInfoResponse, + ProviderDetectResponse, +) +from nylas.models.grants import Grant + +from nylas.resources.auth import ( + _hash_pkce_secret, + _build_query, + _build_query_with_pkce, + _build_query_with_admin_consent, + Auth, +) + + +class TestAuth: + def test_hash_pkce_secret(self): + assert ( + _hash_pkce_secret("nylas") + == "ZTk2YmY2Njg2YTNjMzUxMGU5ZTkyN2RiNzA2OWNiMWNiYTliOTliMDIyZjQ5NDgzYTZjZTMyNzA4MDllNjhhMg" + ) + + def test_build_query(self): + config = { + "foo": "bar", + "scope": ["email", "calendar"], + } + + assert _build_query(config) == { + "foo": "bar", + "response_type": "code", + "access_type": "online", + "scope": "email calendar", + } + + def test_build_query_with_pkce(self): + config = { + "foo": "bar", + "scope": ["email", "calendar"], + } + + assert _build_query_with_pkce(config, "secret-hash-123") == { + "foo": "bar", + "response_type": "code", + "access_type": "online", + "scope": "email calendar", + "code_challenge": "secret-hash-123", + "code_challenge_method": "s256", + } + + def test_build_query_with_admin_consent(self): + config = { + "foo": "bar", + "scope": ["email", "calendar"], + "credential_id": "credential-id-123", + } + + assert _build_query_with_admin_consent(config) == { + "foo": "bar", + "response_type": "adminconsent", + "access_type": "online", + "scope": "email calendar", + "credential_id": "credential-id-123", + } + + def test_url_auth_builder(self, http_client): + auth = Auth(http_client) + + assert ( + auth._url_auth_builder({"foo": "bar"}) + == "https://test.nylas.com/v3/connect/auth?foo=bar" + ) + + def test_get_token(self, http_client_token_exchange): + auth = Auth(http_client_token_exchange) + req = { + "redirect_uri": "https://example.com", + "code": "code", + "client_id": "client_id", + "client_secret": "client_secret", + } + + res = auth._get_token(req) + + http_client_token_exchange._execute.assert_called_once_with( + method="POST", + path="/v3/connect/token", + request_body=req, + ) + assert type(res) is CodeExchangeResponse + assert res.access_token == "nylas_access_token" + assert res.expires_in == 3600 + assert res.id_token == "jwt_token" + assert res.refresh_token == "nylas_refresh_token" + assert res.scope == "https://www.googleapis.com/auth/gmail.readonly profile" + assert res.token_type == "Bearer" + assert res.grant_id == "grant_123" + + def test_get_token_info(self, http_client_token_info): + auth = Auth(http_client_token_info) + req = { + "foo": "bar", + } + + res = auth._get_token_info(req) + + http_client_token_info._execute.assert_called_once_with( + method="GET", + path="/v3/connect/tokeninfo", + query_params=req, + ) + assert type(res) is TokenInfoResponse + assert res.iss == "https://nylas.com" + assert res.aud == "http://localhost:3030" + assert res.sub == "Jaf84d88-ยฃ274-46cc-bbc9-aed7dac061c7" + assert res.email == "user@example.com" + assert res.iat == 1692094848 + assert res.exp == 1692095173 + + def test_url_for_oauth2(self, http_client): + auth = Auth(http_client) + config = { + "client_id": "abc-123", + "redirect_uri": "https://example.com/oauth/callback", + "scope": ["email.read_only", "calendar", "contacts"], + "login_hint": "test@gmail.com", + "provider": "google", + "prompt": "select_provider,detect", + "state": "abc-123-state", + } + + url = auth.url_for_oauth2(config) + + assert ( + url + == "https://test.nylas.com/v3/connect/auth?client_id=abc-123&redirect_uri=https%3A//example.com/oauth/callback&scope=email.read_only%20calendar%20contacts&login_hint=test%40gmail.com&provider=google&prompt=select_provider%2Cdetect&state=abc-123-state&response_type=code&access_type=online" + ) + + def test_exchange_code_for_token(self, http_client_token_exchange): + auth = Auth(http_client_token_exchange) + config = { + "client_id": "abc-123", + "client_secret": "secret", + "code": "code", + "redirect_uri": "https://example.com/oauth/callback", + } + + auth.exchange_code_for_token(config) + + http_client_token_exchange._execute.assert_called_once_with( + method="POST", + path="/v3/connect/token", + request_body={ + "client_id": "abc-123", + "client_secret": "secret", + "code": "code", + "redirect_uri": "https://example.com/oauth/callback", + "grant_type": "authorization_code", + }, + ) + + def test_exchange_code_for_token_no_secret(self, http_client_token_exchange): + http_client_token_exchange.api_key = "nylas-api-key" + auth = Auth(http_client_token_exchange) + config = { + "client_id": "abc-123", + "code": "code", + "redirect_uri": "https://example.com/oauth/callback", + } + + auth.exchange_code_for_token(config) + + http_client_token_exchange._execute.assert_called_once_with( + method="POST", + path="/v3/connect/token", + request_body={ + "client_id": "abc-123", + "code": "code", + "redirect_uri": "https://example.com/oauth/callback", + "client_secret": "nylas-api-key", + "grant_type": "authorization_code", + }, + ) + + def test_custom_authentication(self): + mock_http_client = Mock() + mock_http_client._execute.return_value = { + "request_id": "abc-123", + "data": { + "id": "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47", + "provider": "google", + "grant_status": "valid", + "email": "email@example.com", + "scope": ["Mail.Read", "User.Read", "offline_access"], + "user_agent": "string", + "ip": "string", + "state": "my-state", + "created_at": 1617817109, + "updated_at": 1617817109, + }, + } + auth = Auth(mock_http_client) + + res = auth.custom_authentication( + {"provider": "google", "settings": {"foo": "bar"}} + ) + + mock_http_client._execute.assert_called_once_with( + method="POST", + path="/v3/connect/custom", + request_body={"provider": "google", "settings": {"foo": "bar"}}, + ) + assert type(res.data) is Grant + assert res.data.id == "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47" + assert res.data.provider == "google" + assert res.data.grant_status == "valid" + assert res.data.email == "email@example.com" + assert res.data.scope == ["Mail.Read", "User.Read", "offline_access"] + assert res.data.user_agent == "string" + assert res.data.ip == "string" + assert res.data.state == "my-state" + assert res.data.created_at == 1617817109 + assert res.data.updated_at == 1617817109 + + def test_refresh_access_token(self, http_client_token_exchange): + auth = Auth(http_client_token_exchange) + config = { + "redirect_uri": "https://example.com/oauth/callback", + "refresh_token": "refresh-12345", + "client_id": "abc-123", + "client_secret": "secret", + } + + auth.refresh_access_token(config) + + http_client_token_exchange._execute.assert_called_once_with( + method="POST", + path="/v3/connect/token", + request_body={ + "redirect_uri": "https://example.com/oauth/callback", + "refresh_token": "refresh-12345", + "client_id": "abc-123", + "client_secret": "secret", + "grant_type": "refresh_token", + }, + ) + + def test_refresh_access_token_no_secret(self, http_client_token_exchange): + http_client_token_exchange.api_key = "nylas-api-key" + auth = Auth(http_client_token_exchange) + config = { + "redirect_uri": "https://example.com/oauth/callback", + "refresh_token": "refresh-12345", + "client_id": "abc-123", + } + + auth.refresh_access_token(config) + + http_client_token_exchange._execute.assert_called_once_with( + method="POST", + path="/v3/connect/token", + request_body={ + "redirect_uri": "https://example.com/oauth/callback", + "refresh_token": "refresh-12345", + "client_id": "abc-123", + "client_secret": "nylas-api-key", + "grant_type": "refresh_token", + }, + ) + + def test_id_token_info(self, http_client_token_info): + auth = Auth(http_client_token_info) + + auth.id_token_info("id-123") + + http_client_token_info._execute.assert_called_once_with( + method="GET", + path="/v3/connect/tokeninfo", + query_params={"id_token": "id-123"}, + ) + + def test_validate_access_token(self, http_client_token_info): + auth = Auth(http_client_token_info) + + auth.validate_access_token("id-123") + + http_client_token_info._execute.assert_called_once_with( + method="GET", + path="/v3/connect/tokeninfo", + query_params={"access_token": "id-123"}, + ) + + @mock.patch("uuid.uuid4") + def test_url_for_oauth2_pkce(self, mock_uuid4, http_client): + mock_uuid4.return_value = "nylas" + auth = Auth(http_client) + config = { + "client_id": "abc-123", + "redirect_uri": "https://example.com/oauth/callback", + "scope": ["email.read_only", "calendar", "contacts"], + "login_hint": "test@gmail.com", + "provider": "google", + "prompt": "select_provider,detect", + "state": "abc-123-state", + } + + result = auth.url_for_oauth2_pkce(config) + + assert ( + result.url + == "https://test.nylas.com/v3/connect/auth?client_id=abc-123&redirect_uri=https%3A//example.com/oauth/callback&scope=email.read_only%20calendar%20contacts&login_hint=test%40gmail.com&provider=google&prompt=select_provider%2Cdetect&state=abc-123-state&response_type=code&access_type=online&code_challenge=ZTk2YmY2Njg2YTNjMzUxMGU5ZTkyN2RiNzA2OWNiMWNiYTliOTliMDIyZjQ5NDgzYTZjZTMyNzA4MDllNjhhMg&code_challenge_method=s256" + ) + assert result.secret == "nylas" + assert ( + result.secret_hash + == "ZTk2YmY2Njg2YTNjMzUxMGU5ZTkyN2RiNzA2OWNiMWNiYTliOTliMDIyZjQ5NDgzYTZjZTMyNzA4MDllNjhhMg" + ) + + def test_url_for_admin_consent(self, http_client): + auth = Auth(http_client) + config = { + "credential_id": "cred-123", + "client_id": "abc-123", + "redirect_uri": "https://example.com/oauth/callback", + "scope": ["email.read_only", "calendar", "contacts"], + "login_hint": "test@gmail.com", + "prompt": "select_provider,detect", + "state": "abc-123-state", + } + + url = auth.url_for_admin_consent(config) + + assert ( + url + == "https://test.nylas.com/v3/connect/auth?provider=microsoft&credential_id=cred-123&client_id=abc-123&redirect_uri=https%3A//example.com/oauth/callback&scope=email.read_only%20calendar%20contacts&login_hint=test%40gmail.com&prompt=select_provider%2Cdetect&state=abc-123-state&response_type=adminconsent&access_type=online" + ) + + def test_revoke(self, http_client_response): + auth = Auth(http_client_response) + + res = auth.revoke("access_token") + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/connect/revoke", + query_params={"token": "access_token"}, + ) + assert res is True + + def test_detect_provider(self): + mock_http_client = Mock() + mock_http_client._execute.return_value = { + "request_id": "abc-123", + "data": { + "email_address": "test@gmail.com", + "detected": True, + "provider": "google", + "type": "string", + }, + } + auth = Auth(mock_http_client) + req = { + "email": "test@gmail.com", + "client_id": "client-123", + "all_provider_types": True, + } + + res = auth.detect_provider(req) + + mock_http_client._execute.assert_called_once_with( + method="POST", path="/v3/providers/detect", query_params=req + ) + assert type(res.data) == ProviderDetectResponse diff --git a/tests/resources/test_calendars.py b/tests/resources/test_calendars.py new file mode 100644 index 00000000..65b40221 --- /dev/null +++ b/tests/resources/test_calendars.py @@ -0,0 +1,183 @@ +from nylas.resources.calendars import Calendars + +from nylas.models.calendars import Calendar + + +class TestCalendar: + def test_calendar_deserialization(self): + calendar_json = { + "grant_id": "abc-123-grant-id", + "description": "Description of my new calendar", + "hex_color": "#039BE5", + "hex_foreground_color": "#039BE5", + "id": "5d3qmne77v32r8l4phyuksl2x", + "is_owned_by_user": True, + "is_primary": True, + "location": "Los Angeles, CA", + "metadata": {"your-key": "value"}, + "name": "My New Calendar", + "object": "calendar", + "read_only": False, + "timezone": "America/Los_Angeles", + } + + cal = Calendar.from_dict(calendar_json) + + assert cal.grant_id == "abc-123-grant-id" + assert cal.description == "Description of my new calendar" + assert cal.hex_color == "#039BE5" + assert cal.hex_foreground_color == "#039BE5" + assert cal.id == "5d3qmne77v32r8l4phyuksl2x" + assert cal.is_owned_by_user is True + assert cal.is_primary is True + assert cal.location == "Los Angeles, CA" + assert cal.metadata == {"your-key": "value"} + assert cal.name == "My New Calendar" + assert cal.object == "calendar" + assert cal.read_only is False + assert cal.timezone == "America/Los_Angeles" + + def test_list_calendars(self, http_client_list_response): + calendars = Calendars(http_client_list_response) + + calendars.list(identifier="abc-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/calendars", None, None, None + ) + + def test_list_calendars_with_query_params(self, http_client_list_response): + calendars = Calendars(http_client_list_response) + + calendars.list(identifier="abc-123", query_params={"limit": 20}) + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/calendars", None, {"limit": 20}, None + ) + + def test_find_calendar(self, http_client_response): + calendars = Calendars(http_client_response) + + calendars.find(identifier="abc-123", calendar_id="calendar-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/calendars/calendar-123", None, None, None + ) + + def test_create_calendar(self, http_client_response): + calendars = Calendars(http_client_response) + request_body = { + "name": "My New Calendar", + "description": "Description of my new calendar", + "location": "Los Angeles, CA", + "timezone": "America/Los_Angeles", + "metadata": {"your-key": "value"}, + } + + calendars.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/calendars", + None, + None, + request_body, + ) + + def test_update_calendar(self, http_client_response): + calendars = Calendars(http_client_response) + request_body = { + "name": "My Updated Calendar", + "description": "Description of my updated calendar", + "location": "Los Angeles, CA", + "timezone": "America/Los_Angeles", + "metadata": {"your-key": "value"}, + } + + calendars.update( + identifier="abc-123", calendar_id="calendar-123", request_body=request_body + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/calendars/calendar-123", + None, + None, + request_body, + ) + + def test_destroy_calendar(self, http_client_delete_response): + calendars = Calendars(http_client_delete_response) + + calendars.destroy(identifier="abc-123", calendar_id="calendar-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/calendars/calendar-123", + None, + None, + None, + ) + + def test_get_availability(self, http_client_response): + calendars = Calendars(http_client_response) + request_body = { + "start_time": 1614556800, + "end_time": 1614643200, + "participants": [ + { + "email": "test@gmail.com", + "calendar_ids": ["calendar-123"], + "open_hours": [ + { + "days": [0], + "timezone": "America/Los_Angeles", + "start": "09:00", + "end": "17:00", + "exdates": ["2021-03-01"], + } + ], + } + ], + "duration_minutes": 60, + "interval_minutes": 30, + "round_to_30_minutes": True, + "availability_rules": { + "availability_method": "max-availability", + "buffer": {"before": 10, "after": 10}, + "default_open_hours": [ + { + "days": [0], + "timezone": "America/Los_Angeles", + "start": "09:00", + "end": "17:00", + "exdates": ["2021-03-01"], + } + ], + "round_robin_event_id": "event-123", + }, + } + + calendars.get_availability(request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/calendars/availability", + request_body=request_body, + ) + + def test_get_free_busy(self, http_client_response): + calendars = Calendars(http_client_response) + request_body = { + "start_time": 1614556800, + "end_time": 1614643200, + "emails": ["test@gmail.com"], + } + + calendars.get_free_busy(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/calendars/free-busy", + request_body=request_body, + ) diff --git a/tests/resources/test_connectors.py b/tests/resources/test_connectors.py new file mode 100644 index 00000000..2aa62338 --- /dev/null +++ b/tests/resources/test_connectors.py @@ -0,0 +1,111 @@ +from nylas.models.connectors import Connector +from nylas.resources.connectors import Connectors +from nylas.resources.credentials import Credentials + + +class TestConnectors: + def test_credentials_property(self, http_client): + connectors = Connectors(http_client) + assert isinstance(connectors.credentials, Credentials) + + def test_connector_deserialization(self, http_client): + connector_json = { + "provider": "google", + "settings": {"topic_name": "abc123"}, + "scope": [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ], + } + + connector = Connector.from_dict(connector_json) + + assert connector.provider == "google" + assert connector.settings["topic_name"] == "abc123" + assert connector.scope == [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ] + + def test_list_connectors(self, http_client_list_response): + connectors = Connectors(http_client_list_response) + + connectors.list() + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/connectors", None, None, None + ) + + def test_find_connector(self, http_client_response): + connectors = Connectors(http_client_response) + + connectors.find("google") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/connectors/google", None, None, None + ) + + def test_create_connector(self, http_client_response): + connectors = Connectors(http_client_response) + request_body = { + "provider": "google", + "settings": { + "client_id": "string", + "client_secret": "string", + "topic_name": "string", + }, + "scope": [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ], + } + + connectors.create(request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/connectors", + None, + None, + request_body, + ) + + def test_update_connector(self, http_client_response): + connectors = Connectors(http_client_response) + request_body = { + "settings": { + "client_id": "string", + "client_secret": "string", + "topic_name": "string", + }, + "scope": [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ], + } + + connectors.update( + provider="google", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PATCH", + "/v3/connectors/google", + None, + None, + request_body, + ) + + def test_destroy_connector(self, http_client_delete_response): + connectors = Connectors(http_client_delete_response) + + connectors.destroy("google") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/connectors/google", + None, + None, + None, + ) diff --git a/tests/resources/test_contacts.py b/tests/resources/test_contacts.py new file mode 100644 index 00000000..d107fc30 --- /dev/null +++ b/tests/resources/test_contacts.py @@ -0,0 +1,196 @@ +from nylas.resources.contacts import Contacts + +from nylas.models.contacts import ( + Contact, + ContactEmail, + ContactGroupId, + InstantMessagingAddress, + PhoneNumber, + PhysicalAddress, + WebPage, +) + + +class TestContact: + def test_contact_deserialization(self): + contact_json = { + "birthday": "1960-12-31", + "company_name": "Nylas", + "emails": [{"type": "work", "email": "john-work@example.com"}], + "given_name": "John", + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "groups": [{"id": "starred"}], + "id": "5d3qmne77v32r8l4phyuksl2x", + "im_addresses": [{"type": "other", "im_address": "myjabberaddress"}], + "job_title": "Software Engineer", + "manager_name": "Bill", + "middle_name": "Jacob", + "nickname": "JD", + "notes": "Loves ramen", + "object": "contact", + "office_location": "123 Main Street", + "phone_numbers": [{"type": "work", "number": "+1-555-555-5555"}], + "physical_addresses": [ + { + "type": "work", + "street_address": "123 Main Street", + "postal_code": 94107, + "state": "CA", + "country": "US", + "city": "San Francisco", + } + ], + "picture_url": "https://example.com/picture.jpg", + "suffix": "Jr.", + "surname": "Doe", + "web_pages": [ + {"type": "work", "url": "http://www.linkedin.com/in/johndoe"} + ], + } + + contact = Contact.from_dict(contact_json) + + assert contact.birthday == "1960-12-31" + assert contact.company_name == "Nylas" + assert contact.emails == [ + ContactEmail(email="john-work@example.com", type="work") + ] + assert contact.given_name == "John" + assert contact.grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert contact.groups == [ContactGroupId(id="starred")] + assert contact.id == "5d3qmne77v32r8l4phyuksl2x" + assert contact.im_addresses == [ + InstantMessagingAddress(type="other", im_address="myjabberaddress") + ] + assert contact.job_title == "Software Engineer" + assert contact.manager_name == "Bill" + assert contact.middle_name == "Jacob" + assert contact.nickname == "JD" + assert contact.notes == "Loves ramen" + assert contact.object == "contact" + assert contact.office_location == "123 Main Street" + assert contact.phone_numbers == [ + PhoneNumber(type="work", number="+1-555-555-5555") + ] + assert contact.physical_addresses == [ + PhysicalAddress( + type="work", + street_address="123 Main Street", + postal_code="94107", + state="CA", + country="US", + city="San Francisco", + ) + ] + assert contact.picture_url == "https://example.com/picture.jpg" + assert contact.suffix == "Jr." + assert contact.surname == "Doe" + assert contact.web_pages == [ + WebPage(type="work", url="http://www.linkedin.com/in/johndoe") + ] + + def test_list_contacts(self, http_client_list_response): + contacts = Contacts(http_client_list_response) + + contacts.list(identifier="abc-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/contacts", None, None, None + ) + + def test_list_contacts_with_query_params(self, http_client_list_response): + contacts = Contacts(http_client_list_response) + + contacts.list(identifier="abc-123", query_params={"limit": 20}) + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/contacts", None, {"limit": 20}, None + ) + + def test_find_contact(self, http_client_response): + contacts = Contacts(http_client_response) + + contacts.find(identifier="abc-123", contact_id="contact-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/contacts/contact-123", None, None, None + ) + + def test_find_contact_with_query_params(self, http_client_response): + contacts = Contacts(http_client_response) + + contacts.find( + identifier="abc-123", + contact_id="contact-123", + query_params={"profile_picture": True}, + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/contacts/contact-123", + None, + {"profile_picture": True}, + None, + ) + + def test_create_contact(self, http_client_response): + contacts = Contacts(http_client_response) + request_body = { + "given_name": "John", + "surname": "Doe", + "company_name": "Nylas", + } + + contacts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/contacts", + None, + None, + request_body, + ) + + def test_update_contact(self, http_client_response): + contacts = Contacts(http_client_response) + request_body = { + "given_name": "John", + "surname": "Doe", + "company_name": "Nylas", + } + + contacts.update( + identifier="abc-123", contact_id="contact-123", request_body=request_body + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/contacts/contact-123", + None, + None, + request_body, + ) + + def test_destroy_contact(self, http_client_delete_response): + contacts = Contacts(http_client_delete_response) + + contacts.destroy(identifier="abc-123", contact_id="contact-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/contacts/contact-123", + None, + None, + None, + ) + + def test_list_groups(self, http_client_list_response): + contacts = Contacts(http_client_list_response) + + contacts.list_groups(identifier="abc-123", query_params={"limit": 20}) + + http_client_list_response._execute.assert_called_once_with( + method="GET", + path="/v3/grants/abc-123/contacts/groups", + query_params={"limit": 20}, + ) diff --git a/tests/resources/test_credentials.py b/tests/resources/test_credentials.py new file mode 100644 index 00000000..c2eb6a94 --- /dev/null +++ b/tests/resources/test_credentials.py @@ -0,0 +1,97 @@ +from nylas.models.credentials import Credential +from nylas.resources.credentials import Credentials + + +class TestCredentials: + def test_credential_deserialization(self, http_client): + credential_json = { + "id": "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47", + "name": "My first Google credential", + "created_at": 1617817109, + "updated_at": 1617817109, + } + + credential = Credential.from_dict(credential_json) + + assert credential.id == "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47" + assert credential.name == "My first Google credential" + assert credential.created_at == 1617817109 + assert credential.updated_at == 1617817109 + + def test_list_credentials(self, http_client_list_response): + credentials = Credentials(http_client_list_response) + + credentials.list("google") + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/connectors/google/creds", None, None, None + ) + + def test_find_credential(self, http_client_response): + credentials = Credentials(http_client_response) + + credentials.find("google", "abc-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/connectors/google/creds/abc-123", None, None, None + ) + + def test_create_credential(self, http_client_response): + credentials = Credentials(http_client_response) + request_body = { + "name": "My first Google credential", + "credential_type": "serviceaccount", + "credential_data": { + "private_key_id": "string", + "private_key": "string", + "client_email": "string", + }, + } + + credentials.create("google", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/connectors/google/creds", + None, + None, + request_body, + ) + + def test_update_credential(self, http_client_response): + credentials = Credentials(http_client_response) + request_body = { + "name": "My first Google credential", + "credential_data": { + "private_key_id": "string", + "private_key": "string", + "client_email": "string", + }, + } + + credentials.update( + provider="google", + credential_id="abc-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PATCH", + "/v3/connectors/google/creds/abc-123", + None, + None, + request_body, + ) + + def test_destroy_credential(self, http_client_delete_response): + credentials = Credentials(http_client_delete_response) + + credentials.destroy("google", "abc-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/connectors/google/creds/abc-123", + None, + None, + None, + ) diff --git a/tests/resources/test_drafts.py b/tests/resources/test_drafts.py new file mode 100644 index 00000000..fa03fea6 --- /dev/null +++ b/tests/resources/test_drafts.py @@ -0,0 +1,168 @@ +from unittest.mock import patch, Mock + +from nylas.models.drafts import Draft +from nylas.resources.drafts import Drafts + + +class TestDraft: + def test_draft_deserialization(self): + draft_json = { + "body": "Hello, I just sent a message using Nylas!", + "cc": [{"email": "arya.stark@example.com"}], + "attachments": [ + { + "content_type": "text/calendar", + "id": "4kj2jrcoj9ve5j9yxqz5cuv98", + "size": 1708, + } + ], + "folders": ["8l6c4d11y1p4dm4fxj52whyr9", "d9zkcr2tljpu3m4qpj7l2hbr0"], + "from": [{"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}], + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "5d3qmne77v32r8l4phyuksl2x", + "object": "draft", + "reply_to": [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ], + "snippet": "Hello, I just sent a message using Nylas!", + "starred": True, + "subject": "Hello from Nylas!", + "thread_id": "1t8tv3890q4vgmwq6pmdwm8qgsaer", + "to": [{"name": "Jon Snow", "email": "j.snow@example.com"}], + "date": 1705084742, + "created_at": 1705084926, + } + + draft = Draft.from_dict(draft_json) + + assert draft.body == "Hello, I just sent a message using Nylas!" + assert draft.cc == [{"email": "arya.stark@example.com"}] + assert len(draft.attachments) == 1 + assert draft.attachments[0].content_type == "text/calendar" + assert draft.attachments[0].id == "4kj2jrcoj9ve5j9yxqz5cuv98" + assert draft.attachments[0].size == 1708 + assert draft.folders == [ + "8l6c4d11y1p4dm4fxj52whyr9", + "d9zkcr2tljpu3m4qpj7l2hbr0", + ] + assert draft.from_ == [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ] + assert draft.grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert draft.id == "5d3qmne77v32r8l4phyuksl2x" + assert draft.object == "draft" + assert draft.reply_to == [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ] + assert draft.snippet == "Hello, I just sent a message using Nylas!" + assert draft.starred is True + assert draft.subject == "Hello from Nylas!" + assert draft.thread_id == "1t8tv3890q4vgmwq6pmdwm8qgsaer" + assert draft.to == [{"name": "Jon Snow", "email": "j.snow@example.com"}] + assert draft.date == 1705084742 + assert draft.created_at == 1705084926 + + def test_list_drafts(self, http_client_list_response): + drafts = Drafts(http_client_list_response) + + drafts.list(identifier="abc-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/drafts", None, None, None + ) + + def test_list_drafts_with_query_params(self, http_client_list_response): + drafts = Drafts(http_client_list_response) + + drafts.list( + identifier="abc-123", + query_params={ + "subject": "Hello from Nylas!", + }, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/drafts", + None, + { + "subject": "Hello from Nylas!", + }, + None, + ) + + def test_find_draft(self, http_client_response): + drafts = Drafts(http_client_response) + + drafts.find(identifier="abc-123", draft_id="draft-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/drafts/draft-123", None, None, None + ) + + def test_create_draft(self, http_client_response): + drafts = Drafts(http_client_response) + mock_encoder = Mock() + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + } + + with patch( + "nylas.resources.drafts._build_form_request", return_value=mock_encoder + ): + drafts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/drafts", + data=mock_encoder, + ) + + def test_update_draft(self, http_client_response): + drafts = Drafts(http_client_response) + mock_encoder = Mock() + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + } + + with patch( + "nylas.resources.drafts._build_form_request", return_value=mock_encoder + ): + drafts.update( + identifier="abc-123", draft_id="draft-123", request_body=request_body + ) + + http_client_response._execute.assert_called_once_with( + method="PUT", + path="/v3/grants/abc-123/drafts/draft-123", + data=mock_encoder, + ) + + def test_destroy_draft(self, http_client_delete_response): + drafts = Drafts(http_client_delete_response) + + drafts.destroy(identifier="abc-123", draft_id="draft-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/drafts/draft-123", + None, + None, + None, + ) + + def test_send_draft(self, http_client_response): + drafts = Drafts(http_client_response) + + drafts.send(identifier="abc-123", draft_id="draft-123") + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/drafts/draft-123", + ) diff --git a/tests/resources/test_events.py b/tests/resources/test_events.py new file mode 100644 index 00000000..0f426a74 --- /dev/null +++ b/tests/resources/test_events.py @@ -0,0 +1,230 @@ +from nylas.resources.events import Events + +from nylas.models.events import Event + + +class TestEvent: + def test_event_deserialization(self): + event_json = { + "busy": True, + "calendar_id": "7d93zl2palhxqdy6e5qinsakt", + "conferencing": { + "provider": "Zoom Meeting", + "details": { + "meeting_code": "code-123456", + "password": "password-123456", + "url": "https://zoom.us/j/1234567890?pwd=1234567890", + }, + }, + "created_at": 1661874192, + "description": "Description of my new calendar", + "hide_participants": False, + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "html_link": "https://www.google.com/calendar/event?eid=bTMzcGJrNW4yYjk4bjk3OWE4Ef3feD2VuM29fMjAyMjA2MjdUMjIwMDAwWiBoYWxsYUBueWxhcy5jb20", + "id": "5d3qmne77v32r8l4phyuksl2x", + "location": "Roller Rink", + "metadata": {"your_key": "your_value"}, + "object": "event", + "organizer": {"email": "organizer@example.com", "name": ""}, + "participants": [ + { + "comment": "Aristotle", + "email": "aristotle@example.com", + "name": "Aristotle", + "phone_number": "+1 23456778", + "status": "maybe", + } + ], + "read_only": False, + "reminders": { + "use_default": False, + "overrides": [{"reminder_minutes": 10, "reminder_method": "email"}], + }, + "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=MO", "EXDATE:20211011T000000Z"], + "status": "confirmed", + "title": "Birthday Party", + "updated_at": 1661874192, + "visibility": "private", + "when": { + "start_time": 1661874192, + "end_time": 1661877792, + "start_timezone": "America/New_York", + "end_timezone": "America/New_York", + "object": "timespan", + }, + } + + event = Event.from_dict(event_json) + + assert event.busy is True + assert event.calendar_id == "7d93zl2palhxqdy6e5qinsakt" + assert event.conferencing.provider == "Zoom Meeting" + assert event.conferencing.details["meeting_code"] == "code-123456" + assert event.conferencing.details["password"] == "password-123456" + assert ( + event.conferencing.details["url"] + == "https://zoom.us/j/1234567890?pwd=1234567890" + ) + assert event.created_at == 1661874192 + assert event.description == "Description of my new calendar" + assert event.hide_participants is False + assert event.grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert ( + event.html_link + == "https://www.google.com/calendar/event?eid=bTMzcGJrNW4yYjk4bjk3OWE4Ef3feD2VuM29fMjAyMjA2MjdUMjIwMDAwWiBoYWxsYUBueWxhcy5jb20" + ) + assert event.id == "5d3qmne77v32r8l4phyuksl2x" + assert event.location == "Roller Rink" + assert event.metadata == {"your_key": "your_value"} + assert event.object == "event" + assert event.participants[0].comment == "Aristotle" + assert event.participants[0].email == "aristotle@example.com" + assert event.participants[0].name == "Aristotle" + assert event.participants[0].phone_number == "+1 23456778" + assert event.participants[0].status == "maybe" + assert event.read_only is False + assert event.reminders.use_default is False + assert event.reminders.overrides[0].reminder_minutes == 10 + assert event.reminders.overrides[0].reminder_method == "email" + assert event.recurrence[0] == "RRULE:FREQ=WEEKLY;BYDAY=MO" + assert event.recurrence[1] == "EXDATE:20211011T000000Z" + assert event.status == "confirmed" + assert event.title == "Birthday Party" + assert event.updated_at == 1661874192 + assert event.visibility == "private" + assert event.when.start_time == 1661874192 + assert event.when.end_time == 1661877792 + assert event.when.start_timezone == "America/New_York" + assert event.when.end_timezone == "America/New_York" + assert event.when.object == "timespan" + + def test_list_events(self, http_client_list_response): + events = Events(http_client_list_response) + + events.list( + identifier="abc-123", + query_params={ + "calendar_id": "abc-123", + "limit": 20, + }, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/events", + None, + { + "calendar_id": "abc-123", + "limit": 20, + }, + None, + ) + + def test_find_event(self, http_client_response): + events = Events(http_client_response) + + events.find( + identifier="abc-123", + event_id="event-123", + query_params={"calendar_id": "abc-123"}, + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/events/event-123", + None, + {"calendar_id": "abc-123"}, + None, + ) + + def test_create_event(self, http_client_response): + events = Events(http_client_response) + request_body = { + "when": { + "start_time": 1661874192, + "end_time": 1661877792, + "start_timezone": "America/New_York", + "end_timezone": "America/New_York", + }, + "description": "Description of my new event", + "location": "Los Angeles, CA", + "metadata": {"your-key": "value"}, + } + + events.create( + identifier="abc-123", + request_body=request_body, + query_params={"calendar_id": "abc-123"}, + ) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/events", + None, + {"calendar_id": "abc-123"}, + request_body, + ) + + def test_update_event(self, http_client_response): + events = Events(http_client_response) + request_body = { + "when": { + "start_time": 1661874192, + "end_time": 1661877792, + "start_timezone": "America/New_York", + "end_timezone": "America/New_York", + }, + "description": "Updated description of my event", + "location": "Los Angeles, CA", + "metadata": {"your-key": "value"}, + } + + events.update( + identifier="abc-123", + event_id="event-123", + request_body=request_body, + query_params={"calendar_id": "abc-123"}, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/events/event-123", + None, + {"calendar_id": "abc-123"}, + request_body, + ) + + def test_destroy_event(self, http_client_delete_response): + events = Events(http_client_delete_response) + + events.destroy( + identifier="abc-123", + event_id="event-123", + query_params={"calendar_id": "abc-123"}, + ) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/events/event-123", + None, + {"calendar_id": "abc-123"}, + None, + ) + + def test_send_rsvp(self, http_client_response): + events = Events(http_client_response) + request_body = {"status": "yes"} + + events.send_rsvp( + identifier="abc-123", + event_id="event-123", + request_body=request_body, + query_params={"calendar_id": "abc-123"}, + ) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/events/event-123/send-rsvp", + request_body=request_body, + query_params={"calendar_id": "abc-123"}, + ) diff --git a/tests/resources/test_folders.py b/tests/resources/test_folders.py new file mode 100644 index 00000000..8b32c8c2 --- /dev/null +++ b/tests/resources/test_folders.py @@ -0,0 +1,118 @@ +from nylas.resources.folders import Folders + +from nylas.models.folders import Folder + + +class TestFolder: + def test_folder_deserialization(self): + folder_json = { + "id": "SENT", + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "name": "SENT", + "system_folder": True, + "object": "folder", + "unread_count": 0, + "child_count": 0, + "parent_id": "ascsf21412", + "background_color": "#039BE5", + "text_color": "#039BE5", + "total_count": 0, + } + + folder = Folder.from_dict(folder_json) + + assert folder.id == "SENT" + assert folder.grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert folder.name == "SENT" + assert folder.system_folder is True + assert folder.object == "folder" + assert folder.unread_count == 0 + assert folder.child_count == 0 + assert folder.parent_id == "ascsf21412" + assert folder.background_color == "#039BE5" + assert folder.text_color == "#039BE5" + assert folder.total_count == 0 + + def test_list_folders(self, http_client_list_response): + folders = Folders(http_client_list_response) + + folders.list(identifier="abc-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/folders", + None, + None, + None, + ) + + def test_find_folder(self, http_client_response): + folders = Folders(http_client_response) + + folders.find(identifier="abc-123", folder_id="folder-123") + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/folders/folder-123", + None, + None, + None, + ) + + def test_create_folder(self, http_client_response): + folders = Folders(http_client_response) + request_body = { + "name": "My New Folder", + "parent_id": "parent-folder-id", + "background_color": "#039BE5", + "text_color": "#039BE5", + } + + folders.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/folders", + None, + None, + request_body, + ) + + def test_update_folder(self, http_client_response): + folders = Folders(http_client_response) + request_body = { + "name": "My New Folder", + "parent_id": "parent-folder-id", + "background_color": "#039BE5", + "text_color": "#039BE5", + } + + folders.update( + identifier="abc-123", + folder_id="folder-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/folders/folder-123", + None, + None, + request_body, + ) + + def test_destroy_folder(self, http_client_delete_response): + folders = Folders(http_client_delete_response) + + folders.destroy( + identifier="abc-123", + folder_id="folder-123", + ) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/folders/folder-123", + None, + None, + None, + ) diff --git a/tests/resources/test_grants.py b/tests/resources/test_grants.py new file mode 100644 index 00000000..d850c13c --- /dev/null +++ b/tests/resources/test_grants.py @@ -0,0 +1,88 @@ +from nylas.models.grants import Grant +from nylas.resources.grants import Grants + + +class TestGrants: + def test_grant_deserialization(self, http_client): + grant_json = { + "id": "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47", + "provider": "google", + "grant_status": "valid", + "email": "email@example.com", + "scope": ["Mail.Read", "User.Read", "offline_access"], + "user_agent": "string", + "ip": "string", + "state": "my-state", + "created_at": 1617817109, + "updated_at": 1617817109, + } + + grant = Grant.from_dict(grant_json) + + assert grant.id == "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47" + assert grant.provider == "google" + assert grant.grant_status == "valid" + assert grant.email == "email@example.com" + assert grant.scope == ["Mail.Read", "User.Read", "offline_access"] + assert grant.user_agent == "string" + assert grant.ip == "string" + assert grant.state == "my-state" + assert grant.created_at == 1617817109 + assert grant.updated_at == 1617817109 + + def test_list_grants(self, http_client_list_response): + grants = Grants(http_client_list_response) + + grants.list() + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants", None, None, None + ) + + def test_find_grant(self, http_client_response): + grants = Grants(http_client_response) + + grants.find("grant-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/grants/grant-123", None, None, None + ) + + def test_update_grant(self, http_client_response): + grants = Grants(http_client_response) + request_body = { + "settings": { + "client_id": "string", + "client_secret": "string", + }, + "scope": [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ], + } + + grants.update( + grant_id="grant-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/grant-123", + None, + None, + request_body, + ) + + def test_destroy_grant(self, http_client_delete_response): + grants = Grants(http_client_delete_response) + + grants.destroy("grant-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/grant-123", + None, + None, + None, + ) diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py new file mode 100644 index 00000000..deb7feec --- /dev/null +++ b/tests/resources/test_messages.py @@ -0,0 +1,212 @@ +from unittest.mock import patch, Mock + +from nylas.models.messages import Message +from nylas.resources.messages import Messages +from nylas.resources.smart_compose import SmartCompose + + +class TestMessage: + def test_smart_compose_property(self, http_client_response): + messages = Messages(http_client_response) + + assert type(messages.smart_compose) is SmartCompose + + def test_message_deserialization(self): + message_json = { + "body": "Hello, I just sent a message using Nylas!", + "cc": [{"name": "Arya Stark", "email": "arya.stark@example.com"}], + "date": 1635355739, + "attachments": [ + { + "content_type": "text/calendar", + "id": "4kj2jrcoj9ve5j9yxqz5cuv98", + "size": 1708, + } + ], + "folders": ["8l6c4d11y1p4dm4fxj52whyr9", "d9zkcr2tljpu3m4qpj7l2hbr0"], + "from": [{"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}], + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "5d3qmne77v32r8l4phyuksl2x", + "object": "message", + "reply_to": [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ], + "snippet": "Hello, I just sent a message using Nylas!", + "starred": True, + "subject": "Hello from Nylas!", + "thread_id": "1t8tv3890q4vgmwq6pmdwm8qgsaer", + "to": [{"name": "Jon Snow", "email": "j.snow@example.com"}], + "unread": True, + } + + message = Message.from_dict(message_json) + + assert message.body == "Hello, I just sent a message using Nylas!" + assert message.cc == [{"name": "Arya Stark", "email": "arya.stark@example.com"}] + assert message.date == 1635355739 + assert message.attachments[0].content_type == "text/calendar" + assert message.attachments[0].id == "4kj2jrcoj9ve5j9yxqz5cuv98" + assert message.attachments[0].size == 1708 + assert message.folders[0] == "8l6c4d11y1p4dm4fxj52whyr9" + assert message.folders[1] == "d9zkcr2tljpu3m4qpj7l2hbr0" + assert message.from_ == [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ] + assert message.grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert message.id == "5d3qmne77v32r8l4phyuksl2x" + assert message.object == "message" + assert message.reply_to == [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ] + assert message.snippet == "Hello, I just sent a message using Nylas!" + assert message.starred is True + assert message.subject == "Hello from Nylas!" + assert message.thread_id == "1t8tv3890q4vgmwq6pmdwm8qgsaer" + assert message.to == [{"name": "Jon Snow", "email": "j.snow@example.com"}] + assert message.unread is True + + def test_list_messages(self, http_client_list_response): + messages = Messages(http_client_list_response) + + messages.list(identifier="abc-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/messages", None, None, None + ) + + def test_list_messages_with_query_params(self, http_client_list_response): + messages = Messages(http_client_list_response) + + messages.list( + identifier="abc-123", + query_params={ + "subject": "Hello from Nylas!", + }, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages", + None, + { + "subject": "Hello from Nylas!", + }, + None, + ) + + def test_find_message(self, http_client_response): + messages = Messages(http_client_response) + + messages.find(identifier="abc-123", message_id="message-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/messages/message-123", None, None, None + ) + + def test_find_message_with_query_params(self, http_client_response): + messages = Messages(http_client_response) + + messages.find( + identifier="abc-123", + message_id="message-123", + query_params={"fields": "standard"}, + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/message-123", + None, + {"fields": "standard"}, + None, + ) + + def test_update_message(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "starred": True, + "unread": False, + "folders": ["folder-123"], + "metadata": {"foo": "bar"}, + } + + messages.update( + identifier="abc-123", + message_id="message-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/messages/message-123", + None, + None, + request_body, + ) + + def test_destroy_message(self, http_client_delete_response): + messages = Messages(http_client_delete_response) + + messages.destroy(identifier="abc-123", message_id="message-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/messages/message-123", + None, + None, + None, + ) + + def test_send_message(self, http_client_response): + messages = Messages(http_client_response) + mock_encoder = Mock() + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + } + + with patch( + "nylas.resources.messages._build_form_request", return_value=mock_encoder + ): + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + data=mock_encoder, + ) + + def test_list_scheduled_messages(self, http_client_response): + messages = Messages(http_client_response) + + messages.list_scheduled_messages(identifier="abc-123") + + http_client_response._execute.assert_called_once_with( + method="GET", + path="/v3/grants/abc-123/messages/schedules", + ) + + def test_find_scheduled_message(self, http_client_response): + messages = Messages(http_client_response) + + messages.find_scheduled_message( + identifier="abc-123", schedule_id="schedule-123" + ) + + http_client_response._execute.assert_called_once_with( + method="GET", + path="/v3/grants/abc-123/messages/schedules/schedule-123", + ) + + def test_stop_scheduled_message(self, http_client_response): + messages = Messages(http_client_response) + + messages.stop_scheduled_message( + identifier="abc-123", schedule_id="schedule-123" + ) + + http_client_response._execute.assert_called_once_with( + method="DELETE", + path="/v3/grants/abc-123/messages/schedules/schedule-123", + ) diff --git a/tests/resources/test_redirect_uris.py b/tests/resources/test_redirect_uris.py new file mode 100644 index 00000000..fa7febc2 --- /dev/null +++ b/tests/resources/test_redirect_uris.py @@ -0,0 +1,116 @@ +from nylas.resources.redirect_uris import RedirectUris + +from nylas.models.redirect_uri import RedirectUri + + +class TestRedirectUri: + def test_redirect_uri_deserialization(self): + redirect_uri_json = { + "id": "0556d035-6cb6-4262-a035-6b77e11cf8fc", + "url": "http://localhost/abc", + "platform": "web", + "settings": { + "origin": "string", + "bundle_id": "string", + "app_store_id": "string", + "team_id": "string", + "package_name": "string", + "sha1_certificate_fingerprint": "string", + }, + } + + redirect_uri = RedirectUri.from_dict(redirect_uri_json) + + assert redirect_uri.id == "0556d035-6cb6-4262-a035-6b77e11cf8fc" + assert redirect_uri.url == "http://localhost/abc" + assert redirect_uri.platform == "web" + assert redirect_uri.settings.origin == "string" + assert redirect_uri.settings.bundle_id == "string" + assert redirect_uri.settings.app_store_id == "string" + assert redirect_uri.settings.team_id == "string" + assert redirect_uri.settings.package_name == "string" + assert redirect_uri.settings.sha1_certificate_fingerprint == "string" + + def test_list_redirect_uris(self, http_client_list_response): + redirect_uris = RedirectUris(http_client_list_response) + + redirect_uris.list() + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/applications/redirect-uris", None, None, None + ) + + def test_find_redirect_uri(self, http_client_response): + redirect_uris = RedirectUris(http_client_response) + + redirect_uris.find(redirect_uri_id="redirect_uri-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/applications/redirect-uris/redirect_uri-123", None, None, None + ) + + def test_create_redirect_uri(self, http_client_response): + redirect_uris = RedirectUris(http_client_response) + request_body = { + "url": "http://localhost/abc", + "platform": "web", + "settings": { + "origin": "string", + "bundle_id": "string", + "app_store_id": "string", + "team_id": "string", + "package_name": "string", + "sha1_certificate_fingerprint": "string", + }, + } + + redirect_uris.create(request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/applications/redirect-uris", + None, + None, + request_body, + ) + + def test_update_redirect_uri(self, http_client_response): + redirect_uris = RedirectUris(http_client_response) + request_body = { + "url": "http://localhost/abc", + "platform": "web", + "settings": { + "origin": "string", + "bundle_id": "string", + "app_store_id": "string", + "team_id": "string", + "package_name": "string", + "sha1_certificate_fingerprint": "string", + }, + } + + redirect_uris.update( + redirect_uri_id="redirect_uri-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/applications/redirect-uris/redirect_uri-123", + None, + None, + request_body, + ) + + def test_destroy_redirect_uri(self, http_client_delete_response): + redirect_uris = RedirectUris(http_client_delete_response) + + redirect_uris.destroy(redirect_uri_id="redirect_uri-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/applications/redirect-uris/redirect_uri-123", + None, + None, + None, + ) diff --git a/tests/resources/test_smart_compose.py b/tests/resources/test_smart_compose.py new file mode 100644 index 00000000..a04e7dbe --- /dev/null +++ b/tests/resources/test_smart_compose.py @@ -0,0 +1,35 @@ +from nylas.models.smart_compose import ComposeMessageResponse +from nylas.resources.smart_compose import SmartCompose + + +class TestSmartCompose: + def test_smart_compose_deserialization(self, http_client): + smart_compose_json = {"suggestion": "Hello world"} + + smart_compose = ComposeMessageResponse.from_dict(smart_compose_json) + + assert smart_compose.suggestion == "Hello world" + + def test_compose_message(self, http_client_response): + smart_compose = SmartCompose(http_client_response) + request_body = {"prompt": "Hello world"} + + smart_compose.compose_message("grant-123", request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/grant-123/messages/smart-compose", + request_body=request_body, + ) + + def test_compose_message_reply(self, http_client_response): + smart_compose = SmartCompose(http_client_response) + request_body = {"prompt": "Hello world"} + + smart_compose.compose_message_reply("grant-123", "message-123", request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/grant-123/messages/message-123/smart-compose", + request_body=request_body, + ) diff --git a/tests/resources/test_threads.py b/tests/resources/test_threads.py new file mode 100644 index 00000000..5a0e24da --- /dev/null +++ b/tests/resources/test_threads.py @@ -0,0 +1,185 @@ +from nylas.models.attachments import Attachment +from nylas.models.events import EmailName +from nylas.resources.threads import Threads + +from nylas.models.threads import Thread + + +class TestThread: + def test_thread_deserialization(self): + thread_json = { + "grant_id": "ca8f1733-6063-40cc-a2e3-ec7274abef11", + "id": "7ml84jdmfnw20sq59f30hirhe", + "object": "thread", + "has_attachments": False, + "has_drafts": False, + "earliest_message_date": 1634149514, + "latest_message_received_date": 1634832749, + "latest_message_sent_date": 1635174399, + "participants": [ + {"email": "daenerys.t@example.com", "name": "Daenerys Targaryen"} + ], + "snippet": "jnlnnn --Sent with Nylas", + "starred": False, + "subject": "Dinner Wednesday?", + "unread": False, + "message_ids": ["njeb79kFFzli09", "998abue3mGH4sk"], + "draft_ids": ["a809kmmoW90Dx"], + "folders": ["8l6c4d11y1p4dm4fxj52whyr9", "d9zkcr2tljpu3m4qpj7l2hbr0"], + "latest_draft_or_message": { + "body": "Hello, I just sent a message using Nylas!", + "cc": [{"name": "Arya Stark", "email": "arya.stark@example.com"}], + "date": 1635355739, + "attachments": [ + { + "content_type": "text/calendar", + "id": "4kj2jrcoj9ve5j9yxqz5cuv98", + "size": 1708, + } + ], + "folders": ["8l6c4d11y1p4dm4fxj52whyr9", "d9zkcr2tljpu3m4qpj7l2hbr0"], + "from": [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ], + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "njeb79kFFzli09", + "object": "message", + "reply_to": [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ], + "snippet": "Hello, I just sent a message using Nylas!", + "starred": True, + "subject": "Hello from Nylas!", + "thread_id": "1t8tv3890q4vgmwq6pmdwm8qgsaer", + "to": [{"name": "Jon Snow", "email": "j.snow@example.com"}], + "unread": True, + }, + } + + thread = Thread.from_dict(thread_json) + + assert thread.grant_id == "ca8f1733-6063-40cc-a2e3-ec7274abef11" + assert thread.id == "7ml84jdmfnw20sq59f30hirhe" + assert thread.object == "thread" + assert thread.has_attachments is False + assert thread.has_drafts is False + assert thread.earliest_message_date == 1634149514 + assert thread.latest_message_received_date == 1634832749 + assert thread.latest_message_sent_date == 1635174399 + assert thread.participants == [ + EmailName(name="Daenerys Targaryen", email="daenerys.t@example.com") + ] + assert thread.snippet == "jnlnnn --Sent with Nylas" + assert thread.starred is False + assert thread.subject == "Dinner Wednesday?" + assert thread.unread is False + assert thread.message_ids == ["njeb79kFFzli09", "998abue3mGH4sk"] + assert thread.draft_ids == ["a809kmmoW90Dx"] + assert thread.folders == [ + "8l6c4d11y1p4dm4fxj52whyr9", + "d9zkcr2tljpu3m4qpj7l2hbr0", + ] + assert ( + thread.latest_draft_or_message.body + == "Hello, I just sent a message using Nylas!" + ) + assert thread.latest_draft_or_message.cc == [ + EmailName(name="Arya Stark", email="arya.stark@example.com") + ] + assert thread.latest_draft_or_message.date == 1635355739 + assert thread.latest_draft_or_message.attachments == [ + Attachment( + content_type="text/calendar", + id="4kj2jrcoj9ve5j9yxqz5cuv98", + size=1708, + ), + ] + assert thread.latest_draft_or_message.folders == [ + "8l6c4d11y1p4dm4fxj52whyr9", + "d9zkcr2tljpu3m4qpj7l2hbr0", + ] + assert thread.latest_draft_or_message.from_ == [ + EmailName(name="Daenerys Targaryen", email="daenerys.t@example.com") + ] + assert ( + thread.latest_draft_or_message.grant_id + == "41009df5-bf11-4c97-aa18-b285b5f2e386" + ) + assert thread.latest_draft_or_message.id == "njeb79kFFzli09" + assert thread.latest_draft_or_message.object == "message" + assert thread.latest_draft_or_message.reply_to == [ + EmailName(name="Daenerys Targaryen", email="daenerys.t@example.com") + ] + assert ( + thread.latest_draft_or_message.snippet + == "Hello, I just sent a message using Nylas!" + ) + assert thread.latest_draft_or_message.starred is True + assert thread.latest_draft_or_message.subject == "Hello from Nylas!" + assert ( + thread.latest_draft_or_message.thread_id == "1t8tv3890q4vgmwq6pmdwm8qgsaer" + ) + assert thread.latest_draft_or_message.to == [ + EmailName(name="Jon Snow", email="j.snow@example.com") + ] + assert thread.latest_draft_or_message.unread is True + + def test_list_threads(self, http_client_list_response): + threads = Threads(http_client_list_response) + + threads.list(identifier="abc-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/threads", None, None, None + ) + + def test_list_threads_with_query_params(self, http_client_list_response): + threads = Threads(http_client_list_response) + + threads.list(identifier="abc-123", query_params={"to": "abc@gmail.com"}) + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/threads", None, {"to": "abc@gmail.com"}, None + ) + + def test_find_thread(self, http_client_response): + threads = Threads(http_client_response) + + threads.find(identifier="abc-123", thread_id="thread-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/threads/thread-123", None, None, None + ) + + def test_update_thread(self, http_client_response): + threads = Threads(http_client_response) + request_body = { + "starred": True, + "unread": False, + "folders": ["folder-123"], + } + + threads.update( + identifier="abc-123", thread_id="thread-123", request_body=request_body + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/threads/thread-123", + None, + None, + request_body, + ) + + def test_destroy_thread(self, http_client_delete_response): + threads = Threads(http_client_delete_response) + + threads.destroy(identifier="abc-123", thread_id="thread-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/threads/thread-123", + None, + None, + None, + ) diff --git a/tests/resources/test_webhooks.py b/tests/resources/test_webhooks.py new file mode 100644 index 00000000..19d57b78 --- /dev/null +++ b/tests/resources/test_webhooks.py @@ -0,0 +1,142 @@ +import pytest + +from nylas.models.webhooks import Webhook, WebhookTriggers +from nylas.resources.webhooks import Webhooks, extract_challenge_parameter + + +class TestWebhooks: + def test_webhook_deserialization(self, http_client): + webhook_json = { + "id": "UMWjAjMeWQ4D8gYF2moonK4486", + "description": "Production webhook destination", + "trigger_types": ["calendar.created"], + "webhook_url": "https://example.com/webhooks", + "status": "active", + "notification_email_addresses": ["jane@example.com", "joe@example.com"], + "status_updated_at": 1234567890, + "created_at": 1234567890, + "updated_at": 1234567890, + } + + webhook = Webhook.from_dict(webhook_json) + + assert webhook.id == "UMWjAjMeWQ4D8gYF2moonK4486" + assert webhook.description == "Production webhook destination" + assert webhook.trigger_types == ["calendar.created"] + assert webhook.webhook_url == "https://example.com/webhooks" + assert webhook.status == "active" + assert webhook.notification_email_addresses == [ + "jane@example.com", + "joe@example.com", + ] + assert webhook.status_updated_at == 1234567890 + assert webhook.created_at == 1234567890 + assert webhook.updated_at == 1234567890 + + def test_list_webhooks(self, http_client_list_response): + webhooks = Webhooks(http_client_list_response) + + webhooks.list() + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/webhooks", None, None, None + ) + + def test_find_webhook(self, http_client_response): + webhooks = Webhooks(http_client_response) + + webhooks.find("webhook-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/webhooks/webhook-123", None, None, None + ) + + def test_create_webhook(self, http_client_response): + webhooks = Webhooks(http_client_response) + request_body = { + "trigger_types": [WebhookTriggers.EVENT_CREATED], + "webhook_url": "https://example.com/webhooks", + "description": "Production webhook destination", + "notification_email_addresses": ["jane@test.com"], + } + + webhooks.create(request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/webhooks", + None, + None, + request_body, + ) + + def test_update_webhook(self, http_client_response): + webhooks = Webhooks(http_client_response) + request_body = { + "trigger_types": [WebhookTriggers.EVENT_CREATED], + "webhook_url": "https://example.com/webhooks", + "description": "Production webhook destination", + "notification_email_addresses": ["jane@test.com"], + } + + webhooks.update( + webhook_id="webhook-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/webhooks/webhook-123", + None, + None, + request_body, + ) + + def test_destroy_webhook(self, http_client_delete_response): + webhooks = Webhooks(http_client_delete_response) + + webhooks.destroy("webhook-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/webhooks/webhook-123", + None, + None, + None, + ) + + def test_rotate_secret(self, http_client_response): + webhooks = Webhooks(http_client_response) + + webhooks.rotate_secret("webhook-123") + + http_client_response._execute.assert_called_once_with( + method="PUT", + path="/v3/webhooks/webhook-123/rotate-secret", + request_body={}, + ) + + def test_ip_addresses(self, http_client_response): + webhooks = Webhooks(http_client_response) + + webhooks.ip_addresses() + + http_client_response._execute.assert_called_once_with( + method="GET", + path="/v3/webhooks/ip-addresses", + ) + + def test_extract_challenge_parameter(self, http_client): + url = "https://example.com/webhooks?challenge=abc123" + + challenge = extract_challenge_parameter(url) + + assert challenge == "abc123" + + def test_extract_challenge_parameter_no_challenge(self, http_client): + url = "https://example.com/webhooks" + + with pytest.raises(ValueError) as e: + extract_challenge_parameter(url) + + assert str(e.value) == "Invalid URL or no challenge parameter found." diff --git a/tests/test_accounts.py b/tests/test_accounts.py deleted file mode 100644 index 06ad6cba..00000000 --- a/tests/test_accounts.py +++ /dev/null @@ -1,136 +0,0 @@ -from datetime import datetime -import pytest -from nylas.client.restful_models import Account, APIAccount, SingletonAccount - - -def test_create_account(api_client, monkeypatch): - monkeypatch.setattr(api_client, "is_opensource_api", lambda: False) - account = api_client.accounts.create() - assert isinstance(account, Account) - - -def test_create_apiaccount(api_client, monkeypatch): - monkeypatch.setattr(api_client, "is_opensource_api", lambda: True) - account = api_client.accounts.create() - assert isinstance(account, APIAccount) - - -def test_account_json(api_client, monkeypatch): - monkeypatch.setattr(api_client, "is_opensource_api", lambda: False) - account = api_client.accounts.create() - result = account.as_json() - assert isinstance(result, dict) - - -@pytest.mark.usefixtures("mock_ip_addresses") -def test_ip_addresses(api_client_with_client_id): - result = api_client_with_client_id.ip_addresses() - assert isinstance(result, dict) - assert "updated_at" in result - assert "ip_addresses" in result - - -@pytest.mark.usefixtures("mock_token_info", "mock_account") -def test_token_info(api_client_with_client_id): - result = api_client_with_client_id.token_info() - assert isinstance(result, dict) - assert "updated_at" in result - assert "scopes" in result - - -@pytest.mark.usefixtures("mock_token_info", "mock_account") -def test_token_info_with_account_id(api_client_with_client_id): - result = api_client_with_client_id.token_info( - account_id="anvkhwelkfdoehdflhdjkfhe1" - ) - assert isinstance(result, dict) - assert "updated_at" in result - assert "scopes" in result - - -@pytest.mark.usefixtures("mock_account") -def test_account_datetime(api_client): - account = api_client.account - assert account.linked_at == datetime(2017, 7, 24, 18, 18, 19) - - -@pytest.mark.usefixtures("mock_accounts", "mock_account_management") -def test_account_upgrade(api_client, client_id): - api_client.client_id = client_id - account = api_client.accounts.first() - assert account.billing_state == "paid" - account = account.downgrade() - assert account.billing_state == "cancelled" - account = account.upgrade() - assert account.billing_state == "paid" - - -@pytest.mark.usefixtures("mock_revoke_all_tokens", "mock_account") -def test_revoke_all_tokens(api_client_with_client_id): - assert api_client_with_client_id.access_token is not None - api_client_with_client_id.revoke_all_tokens() - assert api_client_with_client_id.access_token is None - - -@pytest.mark.usefixtures("mock_revoke_all_tokens", "mock_account") -def test_revoke_all_tokens_with_keep_access_token( - api_client_with_client_id, access_token -): - assert api_client_with_client_id.access_token == access_token - api_client_with_client_id.revoke_all_tokens(keep_access_token=access_token) - assert api_client_with_client_id.access_token == access_token - - -@pytest.mark.usefixtures("mock_accounts", "mock_account") -def test_account_access(api_client): - account1 = api_client.account - assert isinstance(account1, SingletonAccount) - account2 = api_client.accounts[0] - assert isinstance(account2, APIAccount) - account3 = api_client.accounts.first() - assert isinstance(account3, APIAccount) - assert account1.as_json() == account2.as_json() == account3.as_json() - - -@pytest.mark.usefixtures("mock_accounts") -def test_account_metadata(api_client_with_client_id, monkeypatch): - monkeypatch.setattr(api_client_with_client_id, "is_opensource_api", lambda: False) - account1 = api_client_with_client_id.accounts[0] - account1["metadata"] = {"test": "value"} - account1.save() - assert account1["metadata"] == {"test": "value"} - - -@pytest.mark.usefixtures("mock_accounts") -def test_application_account_delete(api_client_with_client_id, monkeypatch): - monkeypatch.setattr(api_client_with_client_id, "is_opensource_api", lambda: False) - account1 = api_client_with_client_id.accounts[0] - api_client_with_client_id.accounts.delete(account1.id) - - -@pytest.mark.usefixtures("mock_application_details") -def test_application_details(api_client_with_client_id, monkeypatch): - monkeypatch.setattr(api_client_with_client_id, "is_opensource_api", lambda: False) - app_data = api_client_with_client_id.application_details() - assert app_data["application_name"] == "My New App Name" - assert app_data["icon_url"] == "http://localhost:5555/icon.png" - assert app_data["redirect_uris"] == [ - "http://localhost:5555/login_callback", - "localhost", - "https://customerA.myapplication.com/login_callback", - ] - - -@pytest.mark.usefixtures("mock_application_details") -def test_update_application_details(api_client_with_client_id, monkeypatch): - monkeypatch.setattr(api_client_with_client_id, "is_opensource_api", lambda: False) - updated_data = api_client_with_client_id.update_application_details( - application_name="New Name", - icon_url="https://myurl.com/icon.png", - redirect_uris=["https://redirect.com"], - ) - assert updated_data["application_name"] == "New Name" - assert updated_data["icon_url"] == "https://myurl.com/icon.png" - assert updated_data["redirect_uris"] == [ - "https://redirect.com", - ] diff --git a/tests/test_authentication.py b/tests/test_authentication.py deleted file mode 100644 index 536d779f..00000000 --- a/tests/test_authentication.py +++ /dev/null @@ -1,255 +0,0 @@ -import json - -import pytest -from urlobject import URLObject - -from nylas.client.authentication_models import Authentication, Integration, Grant - - -@pytest.mark.usefixtures("mock_integrations") -def test_authentication_api_url(mocked_responses, api_client): - authentication = api_client.authentication - integrations = authentication.integrations - integrations.first() - request = mocked_responses.calls[0].request - assert URLObject(request.url).hostname == "beta.us.nylas.com" - authentication.app_name = "test_app" - integrations.first() - request = mocked_responses.calls[1].request - assert URLObject(request.url).hostname == "test_app.us.nylas.com" - authentication.region = Authentication.Region.EU - integrations.first() - request = mocked_responses.calls[2].request - assert URLObject(request.url).hostname == "test_app.eu.nylas.com" - - -@pytest.mark.usefixtures("mock_integrations") -def test_integration(mocked_responses, api_client): - integration = api_client.authentication.integrations.first() - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/connect/integrations" - assert request.method == "GET" - assert isinstance(integration, Integration) - assert integration.name == "Nylas Playground" - assert integration.id == "zoom" - assert integration.provider == "zoom" - assert integration.settings["client_id"] == "test_client_id" - assert integration.settings["client_secret"] == "test_client_secret" - assert integration.redirect_uris[0] == "https://www.nylas.com" - assert integration.expires_in == 12000 - - -@pytest.mark.usefixtures("mock_integrations") -def test_single_integration(mocked_responses, api_client): - integration = api_client.authentication.integrations.get( - Authentication.Provider.ZOOM - ) - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/connect/integrations/zoom" - assert request.method == "GET" - assert isinstance(integration, Integration) - assert integration.id == "zoom" - assert integration.provider == "zoom" - - -@pytest.mark.usefixtures("mock_integrations") -def test_update_integration(mocked_responses, api_client): - integration = api_client.authentication.integrations.get( - Authentication.Provider.ZOOM - ) - integration.name = "Updated Integration Name" - integration.save() - assert len(mocked_responses.calls) == 2 - request = mocked_responses.calls[1].request - assert URLObject(request.url).path == "/connect/integrations/zoom" - assert request.method == "PATCH" - assert json.loads(request.body) == { - "name": "Updated Integration Name", - "settings": { - "client_id": "test_client_id", - "client_secret": "test_client_secret", - }, - "redirect_uris": ["https://www.nylas.com"], - "expires_in": 12000, - "scope": [], - } - assert isinstance(integration, Integration) - assert integration.id == "zoom" - assert integration.provider == "zoom" - assert integration.name == "Updated Integration Name" - - -@pytest.mark.usefixtures("mock_integrations") -def test_delete_integration(mocked_responses, api_client): - api_client.authentication.integrations.delete(Authentication.Provider.ZOOM) - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/connect/integrations/zoom" - assert request.method == "DELETE" - - -@pytest.mark.usefixtures("mock_integrations") -def test_create_integration(mocked_responses, api_client): - integration = api_client.authentication.integrations.create() - integration.name = "Nylas Playground" - integration.provider = Authentication.Provider.ZOOM - integration.settings["client_id"] = "test_client_id" - integration.settings["client_secret"] = "test_client_secret" - integration.redirect_uris = ["https://www.nylas.com"] - integration.expires_in = 12000 - integration.scope = ["test.scope"] - integration.save() - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/connect/integrations" - assert request.method == "POST" - assert json.loads(request.body) == { - "name": "Nylas Playground", - "provider": "zoom", - "settings": { - "client_id": "test_client_id", - "client_secret": "test_client_secret", - }, - "redirect_uris": ["https://www.nylas.com"], - "expires_in": 12000, - "scope": ["test.scope"], - } - assert isinstance(integration, Integration) - assert integration.name == "Nylas Playground" - assert integration.id == "zoom" - assert integration.provider == "zoom" - assert integration.settings["client_id"] == "test_client_id" - assert integration.settings["client_secret"] == "test_client_secret" - assert integration.redirect_uris[0] == "https://www.nylas.com" - assert integration.expires_in == 12000 - assert integration.scope == ["test.scope"] - - -@pytest.mark.usefixtures("mock_grants") -def test_grant(mocked_responses, api_client): - grant = api_client.authentication.grants.first() - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/connect/grants" - assert request.method == "GET" - assert isinstance(grant, Grant) - assert grant.id == "grant-id" - assert grant.provider == "zoom" - assert grant.grant_status == "valid" - assert grant.email == "email@example.com" - assert grant.metadata == {"isAdmin": True} - assert grant.scope[0] == "meeting:write" - assert grant.user_agent == "string" - assert grant.ip == "string" - assert grant.created_at == 1617817109 - assert grant.updated_at == 1617817109 - - -@pytest.mark.usefixtures("mock_grants") -def test_single_grant(mocked_responses, api_client): - grant = api_client.authentication.grants.get("grant-id") - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/connect/grants/grant-id" - assert request.method == "GET" - assert isinstance(grant, Grant) - assert grant.id == "grant-id" - - -@pytest.mark.usefixtures("mock_grants") -def test_update_grant(mocked_responses, api_client): - grant = api_client.authentication.grants.get("grant-id") - grant.settings = {"refresh_token": "test_token"} - grant.save() - assert len(mocked_responses.calls) == 2 - request = mocked_responses.calls[1].request - assert URLObject(request.url).path == "/connect/grants/grant-id" - assert request.method == "PATCH" - assert json.loads(request.body) == { - "settings": {"refresh_token": "test_token"}, - "metadata": {"isAdmin": True}, - "scope": ["meeting:write"], - } - assert isinstance(grant, Grant) - assert grant.id == "grant-id" - assert grant.settings == {"refresh_token": "test_token"} - - -@pytest.mark.usefixtures("mock_grants") -def test_delete_grant(mocked_responses, api_client): - api_client.authentication.grants.delete("grant-id") - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/connect/grants/grant-id" - assert request.method == "DELETE" - - -@pytest.mark.usefixtures("mock_grants") -def test_create_grant(mocked_responses, api_client): - grant = api_client.authentication.grants.create() - grant.provider = Authentication.Provider.ZOOM - grant.settings = {"refresh_token": "test-refresh-token"} - grant.metadata = {"isAdmin": True} - grant.scope = ["meeting:write"] - grant.save() - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/connect/grants" - assert request.method == "POST" - assert json.loads(request.body) == { - "provider": "zoom", - "settings": {"refresh_token": "test-refresh-token"}, - "scope": ["meeting:write"], - "metadata": {"isAdmin": True}, - } - assert isinstance(grant, Grant) - - -@pytest.mark.usefixtures("mock_grants") -def test_grant_on_demand_sync(mocked_responses, api_client): - grant = api_client.authentication.grants.on_demand_sync("grant-id", sync_from=12000) - request = mocked_responses.calls[0].request - assert request.path_url == "/connect/grants/grant-id/sync?sync_from=12000" - assert request.method == "POST" - assert request.body is None - assert isinstance(grant, Grant) - - -@pytest.mark.usefixtures("mock_authentication_hosted_auth") -def test_grant_authentication_hosted_auth(mocked_responses, api_client): - api_client.authentication.hosted_authentication( - provider=Authentication.Provider.ZOOM, - redirect_uri="https://myapp.com/callback-handler", - grant_id="test-grant-id", - login_hint="example@email.com", - state="test-state", - expires_in=60, - settings={"refresh_token": "test-refresh-token"}, - metadata={"isAdmin": True}, - scope=["meeting:write"], - ) - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/connect/auth" - assert request.method == "POST" - assert json.loads(request.body) == { - "provider": "zoom", - "redirect_uri": "https://myapp.com/callback-handler", - "grant_id": "test-grant-id", - "login_hint": "example@email.com", - "state": "test-state", - "expires_in": 60, - "settings": {"refresh_token": "test-refresh-token"}, - "scope": ["meeting:write"], - "metadata": {"isAdmin": True}, - } - - -@pytest.mark.usefixtures("mock_authentication_hosted_auth") -def test_grant_authentication_hosted_auth_enhanced_events(mocked_responses, api_client): - api_client.authentication._hosted_authentication_enhanced_events( - provider=Authentication.Provider.ZOOM, - redirect_uri="https://myapp.com/callback-handler", - account_id="test-account-id", - ) - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/connect/auth" - assert request.method == "POST" - assert json.loads(request.body) == { - "provider": "zoom", - "redirect_uri": "https://myapp.com/callback-handler", - "account_id": "test-account-id", - } diff --git a/tests/test_client.py b/tests/test_client.py index 82fed5a9..139874d8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,367 +1,88 @@ -import re -import json -from six.moves.urllib.parse import parse_qs # pylint: disable=relative-import -import pytest -from urlobject import URLObject -import responses -from nylas.client import APIClient -from nylas.client.restful_models import Contact -from nylas.utils import AuthMethod - - -def urls_equal(url1, url2): - """ - Compare two URLObjects, without regard to the order of their query strings. - """ - return ( - url1.without_query() == url2.without_query() - and url1.query_dict == url2.query_dict - ) - - -def test_custom_client(): - # Can specify API server - custom = APIClient(api_server="https://example.com") - assert custom.api_server == "https://example.com" - # Must be a valid URL - with pytest.raises(Exception) as exc: - APIClient(api_server="invalid") - assert exc.value.args[0] == ( - "When overriding the Nylas API server address, " "you must include https://" - ) - - -@pytest.mark.usefixtures("mock_resources") -def test_client_access_token(api_client, mocked_responses): - api_client.access_token = "foo" - assert api_client.access_token == "foo" - api_client.room_resources.first() - assert mocked_responses.calls[0].request.headers["Authorization"] == "Bearer foo" - api_client.access_token = "bar" - api_client.room_resources.first() - assert api_client.access_token == "bar" - assert mocked_responses.calls[1].request.headers["Authorization"] == "Bearer bar" - api_client.access_token = None - api_client.room_resources.first() - assert api_client.access_token is None - assert "Authorization" not in api_client.session.headers - - -def test_client_headers(): - client = APIClient(client_id="whee", client_secret="foo") - headers = client.session.headers - assert headers["X-Nylas-API-Wrapper"] == "python" - assert headers["X-Nylas-Client-Id"] == "whee" - assert "Nylas-API-Version" in headers - assert "Nylas Python SDK" in headers["User-Agent"] - assert "Authorization" not in headers - - -def test_client_admin_headers(): - client = APIClient(client_id="bounce", client_secret="foo") - headers = client.admin_session.headers - assert headers["Authorization"] == "Basic Zm9vOg==" - assert headers["X-Nylas-API-Wrapper"] == "python" - assert headers["X-Nylas-Client-Id"] == "bounce" - assert "Nylas-API-Version" in headers - assert "Nylas Python SDK" in headers["User-Agent"] - - -def test_custom_api_version(): - # Can specify API server - custom = APIClient(api_version="500") - assert custom.api_version == "500" - - -def test_client_authentication_url(api_client, api_url): - expected = ( - URLObject(api_url) - .with_path("/oauth/authorize") - .set_query_params( - [ - ("login_hint", ""), - ("state", ""), - ("redirect_uri", "/redirect"), - ("response_type", "code"), - ("client_id", "None"), - ("scopes", "email,calendar,contacts"), - ] - ) - ) - actual = URLObject(api_client.authentication_url("/redirect")) - assert urls_equal(expected, actual) - - actual2 = URLObject(api_client.authentication_url("/redirect", login_hint="hint")) - expected2 = expected.set_query_param("login_hint", "hint") - assert urls_equal(expected2, actual2) - - actual3 = URLObject(api_client.authentication_url("/redirect", state="confusion")) - expected3 = expected.set_query_param("state", "confusion") - assert urls_equal(expected3, actual3) - - -def test_client_authentication_url_custom_scopes(api_client, api_url): - expected = ( - URLObject(api_url) - .with_path("/oauth/authorize") - .set_query_params( - [ - ("login_hint", ""), - ("state", ""), - ("redirect_uri", "/redirect"), - ("response_type", "code"), - ("client_id", "None"), - ("scopes", "email"), - ] +from nylas import Client +from nylas.resources.applications import Applications +from nylas.resources.attachments import Attachments +from nylas.resources.auth import Auth +from nylas.resources.calendars import Calendars +from nylas.resources.connectors import Connectors +from nylas.resources.contacts import Contacts +from nylas.resources.drafts import Drafts +from nylas.resources.events import Events +from nylas.resources.folders import Folders +from nylas.resources.grants import Grants +from nylas.resources.messages import Messages +from nylas.resources.threads import Threads +from nylas.resources.webhooks import Webhooks + + +class TestClient: + def test_client_init(self): + client = Client( + api_key="test-key", + api_uri="https://test.nylas.com", + timeout=60, ) - ) - actual = URLObject(api_client.authentication_url("/redirect", scopes="email")) - assert urls_equal(expected, actual) - actual2 = URLObject( - api_client.authentication_url("/redirect", scopes=["calendar", "contacts"]) - ) - expected2 = expected.set_query_param("scopes", "calendar,contacts") - assert urls_equal(expected2, actual2) + assert client.api_key == "test-key" + assert client.api_uri == "https://test.nylas.com" + assert client.http_client.timeout == 60 - -def test_client_authentication_url_scopes_none(api_client, api_url): - expected = ( - URLObject(api_url) - .with_path("/oauth/authorize") - .set_query_params( - [ - ("login_hint", ""), - ("state", ""), - ("redirect_uri", "/redirect"), - ("response_type", "code"), - ("client_id", "None"), - # no scopes parameter - ] - ) - ) - actual = URLObject(api_client.authentication_url("/redirect", scopes=None)) - assert urls_equal(expected, actual) - - -def test_client_authentication_url_optional_params(api_client, api_url): - expected = ( - URLObject(api_url) - .with_path("/oauth/authorize") - .set_query_params( - [ - ("login_hint", ""), - ("state", ""), - ("redirect_uri", "/redirect"), - ("response_type", "code"), - ("client_id", "None"), - ("scopes", "email"), - ("provider", "gmail"), - ("redirect_on_error", "false"), - ] - ) - ) - actual = URLObject( - api_client.authentication_url( - "/redirect", scopes="email", provider="gmail", redirect_on_error=False + def test_client_init_defaults(self): + client = Client( + api_key="test-key", ) - ) - assert urls_equal(expected, actual) - - -def test_client_authentication_url_invalid_param_values(api_client, api_url): - expected = ( - URLObject(api_url) - .with_path("/oauth/authorize") - .set_query_params( - [ - ("login_hint", ""), - ("state", ""), - ("redirect_uri", "/redirect"), - ("response_type", "code"), - ("client_id", "None"), - ("scopes", "email"), - ] - ) - ) - actual = URLObject( - api_client.authentication_url("/redirect", scopes="email", provider="Google") - ) - assert urls_equal(expected, actual) - - expected2 = expected.set_query_param("provider", "gmail") - - actual2 = URLObject( - api_client.authentication_url( - "/redirect", scopes="email", provider="gmail", redirect_on_error="true" - ) - ) - - assert urls_equal(expected2, actual2) - - -def test_client_token_for_code(mocked_responses, api_client, api_url): - endpoint = re.compile(api_url + "/oauth/token") - response_body = json.dumps({"access_token": "hooray"}) - mocked_responses.add( - responses.POST, - endpoint, - content_type="application/json", - status=200, - body=response_body, - ) - - assert api_client.token_for_code("foo") == "hooray" - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - body = parse_qs(request.body) - assert body["grant_type"] == ["authorization_code"] - assert body["code"] == ["foo"] - - -def test_client_opensource_api(api_client): - # pylint: disable=singleton-comparison - assert api_client.is_opensource_api() == True - api_client.client_id = "foo" - api_client.client_secret = "super-sekrit" - assert api_client.is_opensource_api() == False - api_client.client_id = api_client.client_secret = None - assert api_client.is_opensource_api() == True - - -def test_client_revoke_token(mocked_responses, api_client, api_url): - endpoint = re.compile(api_url + "/oauth/revoke") - mocked_responses.add(responses.POST, endpoint, status=200, body="") - - api_client.auth_token = "foo" - api_client.access_token = "bar" - api_client.revoke_token() - assert api_client.auth_token is None - assert api_client.access_token is None - assert len(mocked_responses.calls) == 1 - - -def test_create_resources(mocked_responses, api_client, api_url): - contacts_data = [ - {"id": 1, "name": "first", "email": "first@example.com"}, - {"id": 2, "name": "second", "email": "second@example.com"}, - ] - mocked_responses.add( - responses.POST, - api_url + "/contacts", - content_type="application/json", - status=200, - body=json.dumps(contacts_data), - ) - - post_data = list(contacts_data) # make a copy - for contact in post_data: - del contact["id"] - - contacts = api_client._create_resources(Contact, post_data) - assert len(contacts) == 2 - assert all(isinstance(contact, Contact) for contact in contacts) - assert len(mocked_responses.calls) == 1 - - -def test_call_resource_method(mocked_responses, api_client, api_url): - contact_data = {"id": 1, "name": "first", "email": "first@example.com"} - mocked_responses.add( - responses.POST, - api_url + "/contacts/1/remove_duplicates", - content_type="application/json", - status=200, - body=json.dumps(contact_data), - ) - - contact = api_client._call_resource_method(Contact, 1, "remove_duplicates", {}) - assert isinstance(contact, Contact) - assert len(mocked_responses.calls) == 1 - - -def test_201_response(mocked_responses, api_client, api_url): - contact_data = {"id": 1, "given_name": "Charlie", "surname": "Bucket"} - mocked_responses.add( - responses.POST, - api_url + "/contacts", - content_type="application/json", - status=201, # This HTTP status still indicates success, - # even though it's not 200. - body=json.dumps(contact_data), - ) - contact = api_client.contacts.create() - contact.save() - assert len(mocked_responses.calls) == 1 - -def test_301_response(mocked_responses, api_client, api_url): - contact_data = {"id": 1, "given_name": "Charlie", "surname": "Bucket"} - mocked_responses.add( - responses.GET, - api_url + "/contacts/first", - status=301, - headers={"Location": api_url + "/contacts/1"}, - ) - mocked_responses.add( - responses.GET, - api_url + "/contacts/1", - content_type="application/json", - status=200, - body=json.dumps(contact_data), - ) - contact = api_client.contacts.get("first") - assert contact["id"] == 1 - assert contact["given_name"] == "Charlie" - assert contact["surname"] == "Bucket" - assert len(mocked_responses.calls) == 2 + assert client.api_key == "test-key" + assert client.api_uri == "https://api.us.nylas.com" + assert client.http_client.timeout == 30 + def test_client_auth_property(self, client): + assert client.auth is not None + assert type(client.auth) is Auth -def test_pagination(mocked_responses, api_client, api_url): - def callback(request): - url = URLObject(request.url) - limit = int(url.query_dict.get("limit") or 50) - offset = int(url.query_dict.get("offset") or 0) - fake_data = [{"id": i} for i in range(offset, limit + offset)] - return (200, {}, json.dumps(fake_data)) + def test_client_applications_property(self, client): + assert client.applications is not None + assert type(client.applications) is Applications - mocked_responses.add_callback( - responses.GET, - "{base}/contacts".format(base=api_url), - content_type="application/json", - callback=callback, - ) + def test_client_attachments_property(self, client): + assert client.attachments is not None + assert type(client.attachments) is Attachments - contacts = list(api_client.contacts.where(limit=75)) - assert len(contacts) == 75 + def test_client_calendars_property(self, client): + assert client.calendars is not None + assert type(client.calendars) is Calendars + def test_client_contacts_property(self, client): + assert client.contacts is not None + assert type(client.contacts) is Contacts -def test_count(mocked_responses, api_client, api_url): - count_data = {"count": 721} - mocked_responses.add( - responses.GET, - api_url + "/contacts", - content_type="application/json", - body=json.dumps(count_data), - ) + def test_client_connectors_property(self, client): + assert client.connectors is not None + assert type(client.connectors) is Connectors - contact_count = api_client.contacts.count() - assert contact_count == 721 + def test_client_drafts_property(self, client): + assert client.drafts is not None + assert type(client.drafts) is Drafts + def test_client_events_property(self, client): + assert client.events is not None + assert type(client.events) is Events -def test_add_auth_header_bearer(api_client): - api_client.access_token = "access_token" - auth_header = api_client._add_auth_header(AuthMethod.BEARER) - assert auth_header == {"Authorization": "Bearer access_token"} + def test_client_folders_property(self, client): + assert client.folders is not None + assert type(client.folders) is Folders + def test_client_grants_property(self, client): + assert client.grants is not None + assert type(client.grants) is Grants -def test_add_auth_header_basic_client_id_and_secret(api_client): - api_client.client_id = "client_id" - api_client.client_secret = "client_secret" - auth_header = api_client._add_auth_header(AuthMethod.BASIC_CLIENT_ID_AND_SECRET) - assert auth_header == {"Authorization": "Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ="} + def test_client_messages_property(self, client): + assert client.messages is not None + assert type(client.messages) is Messages + def test_client_threads_property(self, client): + assert client.threads is not None + assert type(client.threads) is Threads -def test_add_auth_header_basic(api_client): - api_client.client_secret = "client_secret" - auth_header = api_client._add_auth_header(AuthMethod.BASIC) - assert auth_header == {"Authorization": "Basic Y2xpZW50X3NlY3JldDo="} + def test_client_webhooks_property(self, client): + assert client.webhooks is not None + assert type(client.webhooks) is Webhooks diff --git a/tests/test_components.py b/tests/test_components.py deleted file mode 100644 index a53511b4..00000000 --- a/tests/test_components.py +++ /dev/null @@ -1,79 +0,0 @@ -from datetime import datetime - -import pytest - -from nylas.client.restful_models import Component - - -def blank_component(api_client): - component = api_client.components.create() - component.name = "Python Component Test" - component.type = "agenda" - component.public_account_id = "test-account-id" - component.access_token = "test-access-token" - return component - - -@pytest.mark.usefixtures("mock_components") -def test_components(api_client): - component = api_client.components.first() - assert isinstance(component, Component) - assert component.id == "component-id" - assert component.active is True - assert component.name == "PyTest Component" - assert component.public_account_id == "account-id" - assert component.public_application_id == "application-id" - assert component.type == "agenda" - assert component.created_at == datetime.strptime( - "2021-10-22T18:02:10.000Z", "%Y-%m-%dT%H:%M:%S.%fZ" - ) - assert component.updated_at == datetime.strptime( - "2021-10-22T18:02:10.000Z", "%Y-%m-%dT%H:%M:%S.%fZ" - ) - assert component.public_token_id == "token-id" - - -@pytest.mark.usefixtures("mock_components") -def test_components(api_client): - component = api_client.components.first() - assert isinstance(component, Component) - assert component.id == "component-id" - assert component.active is True - assert component.name == "PyTest Component" - assert component.public_account_id == "account-id" - assert component.public_application_id == "application-id" - assert component.type == "agenda" - assert component.created_at == datetime.strptime( - "2021-10-22T18:02:10.000Z", "%Y-%m-%dT%H:%M:%S.%fZ" - ) - assert component.updated_at == datetime.strptime( - "2021-10-22T18:02:10.000Z", "%Y-%m-%dT%H:%M:%S.%fZ" - ) - assert component.public_token_id == "token-id" - - -@pytest.mark.usefixtures("mock_components_create_response") -def test_create_components(api_client): - component = blank_component(api_client) - component.save() - assert component.id == "cv4ei7syx10uvsxbs21ccsezf" - - -@pytest.mark.usefixtures("mock_components_create_response") -def test_modify_components(api_client): - component = blank_component(api_client) - component.id = "cv4ei7syx10uvsxbs21ccsezf" - component.name = "Updated Name" - component.save() - assert component.name == "Updated Name" - - -@pytest.mark.usefixtures("mock_components_create_response") -def test_components_as_json_read_only(api_client): - component = blank_component(api_client) - component.id = "test-id" - json = component.as_json() - assert "id" not in json - assert "public_application_id" not in json - assert "created_at" not in json - assert "updated_at" not in json diff --git a/tests/test_contacts.py b/tests/test_contacts.py deleted file mode 100644 index 66f613b5..00000000 --- a/tests/test_contacts.py +++ /dev/null @@ -1,175 +0,0 @@ -import json -from datetime import date -import pytest -from six import binary_type -from nylas.client.restful_models import Contact - - -@pytest.mark.usefixtures("mock_contacts") -def test_list_contacts(api_client): - contacts = list(api_client.contacts) - assert len(contacts) == 3 - assert all(isinstance(x, Contact) for x in contacts) - - -@pytest.mark.usefixtures("mock_contact") -def test_get_contact(api_client): - contact = api_client.contacts.get("9hga75n6mdvq4zgcmhcn7hpys") - assert contact is not None - assert isinstance(contact, Contact) - assert contact.given_name == "Given" - assert contact.surname == "Sur" - assert contact.birthday == date(1964, 10, 5) - assert contact.source == "inbox" - - -@pytest.mark.usefixtures("mock_contacts") -def test_create_contact(api_client, mocked_responses): - contact = api_client.contacts.create() - contact.given_name = "Monkey" - contact.surname = "Business" - assert not mocked_responses.calls - contact.save() - assert len(mocked_responses.calls) == 1 - assert contact.id is not None - assert contact.given_name == "Monkey" - assert contact.surname == "Business" - - -@pytest.mark.usefixtures("mock_contact") -def test_update_contact(api_client, mocked_responses): - contact = api_client.contacts.get("9hga75n6mdvq4zgcmhcn7hpys") - assert len(mocked_responses.calls) == 1 - assert contact.job_title == "QA Tester" - contact.job_title = "Factory Owner" - contact.office_location = "Willy Wonka Factory" - contact.save() - assert len(mocked_responses.calls) == 2 - assert contact.id == "9hga75n6mdvq4zgcmhcn7hpys" - assert contact.job_title == "Factory Owner" - assert contact.office_location == "Willy Wonka Factory" - - -@pytest.mark.usefixtures("mock_contact") -def test_contact_picture(api_client, mocked_responses): - contact = api_client.contacts.get("9hga75n6mdvq4zgcmhcn7hpys") - assert len(mocked_responses.calls) == 1 - assert contact.picture_url - picture = contact.get_picture() - assert len(mocked_responses.calls) == 2 - picture_call = mocked_responses.calls[1] - assert contact.picture_url == picture_call.request.url - assert picture.headers["Content-Type"] == "image/jpeg" - content = picture.read() - assert isinstance(content, binary_type) - - -@pytest.mark.usefixtures("mock_contacts") -def test_contact_no_picture(api_client, mocked_responses): - contact = api_client.contacts.get("4zqkfw8k1d12h0k784ipeh498") - assert len(mocked_responses.calls) == 1 - assert not contact.picture_url - picture = contact.get_picture() - assert len(mocked_responses.calls) == 1 - assert not picture - - -@pytest.mark.usefixtures("mock_contact") -def test_contact_emails(api_client): - contact = api_client.contacts.get("9hga75n6mdvq4zgcmhcn7hpys") - assert isinstance(contact.emails, dict) - assert contact.emails["first"] == ["one@example.com"] - assert contact.emails["second"] == ["two@example.com"] - assert contact.emails["primary"] == ["abc@example.com", "xyz@example.com"] - assert contact.emails[None] == ["unknown@example.com"] - assert "absent" not in contact.emails - - -@pytest.mark.usefixtures("mock_contact") -def test_contact_im_addresses(api_client): - contact = api_client.contacts.get("9hga75n6mdvq4zgcmhcn7hpys") - assert isinstance(contact.im_addresses, dict) - assert contact.im_addresses["aim"] == ["SmarterChild"] - assert contact.im_addresses["gtalk"] == ["fake@gmail.com", "fake2@gmail.com"] - assert "absent" not in contact.im_addresses - - -@pytest.mark.usefixtures("mock_contact") -def test_contact_physical_addresses(api_client): - contact = api_client.contacts.get("9hga75n6mdvq4zgcmhcn7hpys") - assert isinstance(contact.physical_addresses, dict) - addr = contact.physical_addresses["home"][0] - assert isinstance(addr, dict) - assert addr["format"] == "structured" - assert addr["street_address"] == "123 Awesome Street" - assert "absent" not in contact.physical_addresses - - -@pytest.mark.usefixtures("mock_contact") -def test_contact_phone_numbers(api_client): - contact = api_client.contacts.get("9hga75n6mdvq4zgcmhcn7hpys") - assert isinstance(contact.phone_numbers, dict) - assert contact.phone_numbers["home"] == ["555-555-5555"] - assert contact.phone_numbers["mobile"] == ["555-555-5555", "987654321"] - assert "absent" not in contact.phone_numbers - - -@pytest.mark.usefixtures("mock_contact") -def test_contact_web_pages(api_client): - contact = api_client.contacts.get("9hga75n6mdvq4zgcmhcn7hpys") - assert isinstance(contact.web_pages, dict) - profiles = ["http://www.facebook.com/abc", "http://www.twitter.com/abc"] - assert contact.web_pages["profile"] == profiles - assert contact.web_pages[None] == ["http://example.com"] - assert "absent" not in contact.web_pages - - -@pytest.mark.usefixtures("mock_contact") -def test_update_contact_special_values(api_client, mocked_responses): - contact = api_client.contacts.get("9hga75n6mdvq4zgcmhcn7hpys") - assert len(mocked_responses.calls) == 1 - contact.birthday = date(1999, 3, 6) - contact.emails["absent"].append("absent@fake.com") - contact.im_addresses["absent"].append("absent-im") - contact.physical_addresses["absent"].append( - { - "type": "absent", - "format": "structured", - "street_address": "123 Absent Street", - } - ) - contact.phone_numbers["absent"].append("222-333-4444") - contact.web_pages["absent"].append("http://absent.com/me") - contact.save() - assert len(mocked_responses.calls) == 2 - assert contact.id == "9hga75n6mdvq4zgcmhcn7hpys" - assert contact.emails["absent"] == ["absent@fake.com"] - assert contact.im_addresses["absent"] == ["absent-im"] - assert contact.physical_addresses["absent"] == [ - { - "type": "absent", - "format": "structured", - "street_address": "123 Absent Street", - } - ] - assert contact.phone_numbers["absent"] == ["222-333-4444"] - assert contact.web_pages["absent"] == ["http://absent.com/me"] - - request = mocked_responses.calls[-1].request - req_body = json.loads(request.body) - birthday = "1999-03-06" - email_address = {"type": "absent", "email": "absent@fake.com"} - im_address = {"type": "absent", "im_address": "absent-im"} - physical_address = { - "type": "absent", - "format": "structured", - "street_address": "123 Absent Street", - } - phone_number = {"type": "absent", "number": "222-333-4444"} - web_page = {"type": "absent", "url": "http://absent.com/me"} - assert req_body["birthday"] == birthday - assert email_address in req_body["emails"] - assert im_address in req_body["im_addresses"] - assert physical_address in req_body["physical_addresses"] - assert phone_number in req_body["phone_numbers"] - assert web_page in req_body["web_pages"] diff --git a/tests/test_delta.py b/tests/test_delta.py deleted file mode 100644 index 1ce4648a..00000000 --- a/tests/test_delta.py +++ /dev/null @@ -1,158 +0,0 @@ -import pytest -from urlobject import URLObject - -from nylas.client.delta_models import Deltas, Delta -from nylas.client.restful_models import ( - Contact, - File, - Message, - Draft, - Thread, - Event, - Folder, - Label, -) - - -@pytest.mark.usefixtures("mock_deltas_since") -def test_deltas_since(mocked_responses, api_client): - deltas = api_client.deltas.since("cursor") - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/delta" - assert URLObject(request.url).query_dict == {"cursor": "cursor"} - assert request.method == "GET" - assert isinstance(deltas, Deltas) - assert deltas.cursor_start == "start_cursor" - assert deltas.cursor_end == "end_cursor" - assert len(deltas.deltas) == 8 - assert isinstance(deltas.deltas[0].attributes, Contact) - assert deltas.deltas[0].cursor == "contact_cursor" - assert deltas.deltas[0].event == "create" - assert deltas.deltas[0].id == "delta-1" - assert deltas.deltas[0].object == "contact" - assert isinstance(deltas.deltas[1].attributes, File) - assert deltas.deltas[1].cursor == "file_cursor" - assert deltas.deltas[1].event == "create" - assert deltas.deltas[1].id == "delta-2" - assert deltas.deltas[1].object == "file" - assert isinstance(deltas.deltas[2].attributes, Message) - assert deltas.deltas[2].cursor == "message_cursor" - assert deltas.deltas[2].event == "create" - assert deltas.deltas[2].id == "delta-3" - assert deltas.deltas[2].object == "message" - assert isinstance(deltas.deltas[3].attributes, Draft) - assert deltas.deltas[3].cursor == "draft_cursor" - assert deltas.deltas[3].event == "create" - assert deltas.deltas[3].id == "delta-4" - assert deltas.deltas[3].object == "draft" - assert isinstance(deltas.deltas[4].attributes, Thread) - assert deltas.deltas[4].cursor == "thread_cursor" - assert deltas.deltas[4].event == "create" - assert deltas.deltas[4].id == "delta-5" - assert deltas.deltas[4].object == "thread" - assert isinstance(deltas.deltas[5].attributes, Event) - assert deltas.deltas[5].cursor == "event_cursor" - assert deltas.deltas[5].event == "create" - assert deltas.deltas[5].id == "delta-6" - assert deltas.deltas[5].object == "event" - assert isinstance(deltas.deltas[6].attributes, Folder) - assert deltas.deltas[6].cursor == "folder_cursor" - assert deltas.deltas[6].event == "create" - assert deltas.deltas[6].id == "delta-7" - assert deltas.deltas[6].object == "folder" - assert isinstance(deltas.deltas[7].attributes, Label) - assert deltas.deltas[7].cursor == "label_cursor" - assert deltas.deltas[7].event == "create" - assert deltas.deltas[7].id == "delta-8" - assert deltas.deltas[7].object == "label" - - -@pytest.mark.usefixtures("mock_delta_cursor") -def test_delta_cursor(mocked_responses, api_client): - cursor = api_client.deltas.latest_cursor() - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/delta/latest_cursor" - assert request.method == "POST" - assert cursor == "cursor" - - -def evaluate_contact_delta(delta): - assert isinstance(delta, Delta) - assert isinstance(delta.attributes, Contact) - assert delta.cursor == "contact_cursor" - assert delta.event == "create" - assert delta.id == "delta-1" - assert delta.object == "contact" - - -@pytest.mark.usefixtures("mock_delta_stream") -def test_delta_streaming(mocked_responses, api_client): - streaming = api_client.deltas.stream("cursor") - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/delta/streaming" - assert URLObject(request.url).query_dict == {"cursor": "cursor"} - assert request.method == "GET" - assert len(streaming) == 1 - evaluate_contact_delta(streaming[0]) - - -@pytest.mark.usefixtures("mock_delta_stream") -def test_delta_longpoll(mocked_responses, api_client): - longpoll = api_client.deltas.longpoll("cursor", 30) - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/delta/longpoll" - assert URLObject(request.url).query_dict == {"cursor": "cursor", "timeout": "30"} - assert request.method == "GET" - assert isinstance(longpoll, Deltas) - assert longpoll.cursor_start == "start_cursor" - assert longpoll.cursor_end == "end_cursor" - assert len(longpoll.deltas) == 1 - evaluate_contact_delta(longpoll.deltas[0]) - - -@pytest.mark.usefixtures("mock_delta_stream") -def test_delta_callback(mocked_responses, api_client): - api_client.deltas.stream("cursor", callback=evaluate_contact_delta) - - -@pytest.mark.usefixtures("mock_delta_stream") -def test_delta_optional_params(mocked_responses, api_client): - api_client.deltas.longpoll( - "cursor", 30, view="expanded", include_types=["event", "file"] - ) - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/delta/longpoll" - assert URLObject(request.url).query_dict == { - "cursor": "cursor", - "timeout": "30", - "view": "expanded", - "include_types": "event,file", - } - assert request.method == "GET" - - -@pytest.mark.usefixtures("mock_delta_stream") -def test_delta_type_string(mocked_responses, api_client): - api_client.deltas.longpoll("cursor", 30, view="expanded", excluded_types="event") - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/delta/longpoll" - assert URLObject(request.url).query_dict == { - "cursor": "cursor", - "timeout": "30", - "view": "expanded", - "excluded_types": "event", - } - assert request.method == "GET" - - -@pytest.mark.usefixtures("mock_delta_stream") -def test_delta_set_both_types_raise_error(api_client): - with pytest.raises(ValueError) as excinfo: - api_client.deltas.longpoll( - "cursor", - 30, - view="expanded", - excluded_types="event", - include_types="file", - ) - assert "You cannot set both include_types and excluded_types" in str(excinfo) diff --git a/tests/test_drafts.py b/tests/test_drafts.py deleted file mode 100644 index 9e2ba953..00000000 --- a/tests/test_drafts.py +++ /dev/null @@ -1,155 +0,0 @@ -import json -from datetime import datetime - -import pytest -from requests import RequestException -from nylas.utils import timestamp_from_dt - -# pylint: disable=len-as-condition - - -@pytest.mark.usefixtures("mock_drafts") -def test_draft_attrs(api_client): - draft = api_client.drafts.first() - expected_modified = datetime(2015, 8, 4, 10, 34, 46) - assert draft.last_modified_at == expected_modified - assert draft.date == timestamp_from_dt(expected_modified) - - -@pytest.mark.usefixtures("mock_draft_saved_response", "mock_draft_sent_response") -def test_save_send_draft(api_client): - draft = api_client.drafts.create() - draft.to = [{"name": "My Friend", "email": "my.friend@example.com"}] - draft.subject = "Here's an attachment" - draft.body = "Cheers mate!" - draft.save() - - draft.subject = "Stay polish, stay hungary" - draft.save() - assert draft.subject == "Stay polish, stay hungary" - - msg = draft.send() - assert msg["thread_id"] == "clm33kapdxkposgltof845v9s" - - # Second time should throw an error - with pytest.raises(RequestException): - draft.send() - - -def test_draft_as_json(api_client): - draft = api_client.drafts.create() - draft.to = [{"name": "My Friend", "email": "my.friend@example.com"}] - draft.subject = "Here's an attachment" - draft.body = "Cheers mate!" - draft.from_ = [{"name": "Me", "email": "me@example.com"}] - msg = draft.as_json() - assert msg["to"] == [{"name": "My Friend", "email": "my.friend@example.com"}] - assert msg["subject"] == "Here's an attachment" - assert msg["body"] == "Cheers mate!" - assert msg["from"] == [{"name": "Me", "email": "me@example.com"}] - - -@pytest.mark.usefixtures("mock_draft_saved_response", "mock_draft_sent_response") -def test_save_send_draft_with_tracking(mocked_responses, api_client): - tracking = { - "links": "true", - "opens": "true", - "thread_replies": "true", - "payload": "new-payload", - } - draft = api_client.drafts.create() - draft.to = [{"name": "My Friend", "email": "my.friend@example.com"}] - draft.subject = "Here's an attachment" - draft.body = "Cheers mate!" - draft.save() - - draft.tracking = tracking - draft.save() - assert draft.tracking == tracking - - draft.send() - send_payload = json.loads(mocked_responses.calls[-1].request.body) - assert send_payload["tracking"] == tracking - - -@pytest.mark.usefixtures("mock_draft_send_unsaved_response") -def test_send_draft_with_tracking(mocked_responses, api_client): - tracking = {"opens": "true", "payload": "payload"} - draft = api_client.drafts.create() - draft.to = [{"name": "My Friend", "email": "my.friend@example.com"}] - draft.subject = "Newsletter" - draft.body = "Our latest sale!" - draft.tracking = tracking - draft.send() - - send_payload = json.loads(mocked_responses.calls[-1].request.body) - assert send_payload["tracking"] == tracking - - -@pytest.mark.usefixtures("mock_draft_raw_response") -def test_send_draft_raw_mime(mocked_responses, api_client): - raw_mime = """MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Subject: With Love, From Nylas -From: You -To: My Nylas Friend - -This email was sent via raw MIME using the Nylas email API. Visit https://nylas.com for details. -""" - draft = api_client.drafts.create() - draft.send_raw(raw_mime) - - send_payload = mocked_responses.calls[-1].request - assert type(send_payload.body) == str - assert send_payload.body == raw_mime - assert send_payload.path_url == "/send" - assert send_payload.method == "POST" - assert send_payload.headers["Content-Type"] == "message/rfc822" - - -@pytest.mark.usefixtures("mock_files") -def test_draft_attachment(api_client): - draft = api_client.drafts.create() - attachment = api_client.files.create() - attachment.filename = "dummy" - attachment.data = "data" - - assert len(draft.file_ids) == 0 - draft.attach(attachment) - assert len(draft.file_ids) == 1 - assert attachment.id in draft.file_ids - - unattached = api_client.files.create() - unattached.filename = "unattached" - unattached.data = "foo" - draft.detach(unattached) - assert len(draft.file_ids) == 1 - assert attachment.id in draft.file_ids - assert unattached.id not in draft.file_ids - - draft.detach(attachment) - assert len(draft.file_ids) == 0 - - -@pytest.mark.usefixtures("mock_draft_saved_response", "mock_draft_deleted_response") -def test_delete_draft(api_client): - draft = api_client.drafts.create() - # Unsaved draft shouldn't throw an error on .delete(), but won't actually - # delete anything. - draft.delete() - # Now save the draft... - draft.save() - # ... and delete it for real. - draft.delete() - - -@pytest.mark.usefixtures("mock_draft_saved_response") -def test_draft_version(api_client): - draft = api_client.drafts.create() - assert "version" not in draft - draft.save() - assert draft["version"] == 0 - draft.update() - assert draft["version"] == 1 - draft.update() - assert draft["version"] == 2 diff --git a/tests/test_events.py b/tests/test_events.py deleted file mode 100644 index 068ba995..00000000 --- a/tests/test_events.py +++ /dev/null @@ -1,830 +0,0 @@ -import json -from datetime import datetime, timedelta -import pytest -from urlobject import URLObject -from nylas.client.restful_models import Event - - -def blank_event(api_client): - event = api_client.events.create() - event.title = "Paris-Brest" - event.calendar_id = "calendar_id" - event.when = {"start_time": 1409594400, "end_time": 1409594400} - return event - - -@pytest.mark.usefixtures("mock_event_create_response") -def test_event_crud(mocked_responses, api_client): - event1 = blank_event(api_client) - event1.object = "should not send" - event1.account_id = "should not send" - event1.job_status_id = "should not send" - event1.ical_uid = "should not send" - event1.message_id = "should not send" - event1.owner = "should not send" - event1.status = "should not send" - event1.master_event_id = "should not send" - event1.original_start_time = "should not send" - event1.visibility = "private" - event1.participants = [ - {"email": "person1@email.com", "status": "yes"}, - ] - event1.save() - request = mocked_responses.calls[0].request - body = json.loads(request.body) - assert event1.id == "cv4ei7syx10uvsxbs21ccsezf" - assert body["participants"][0]["status"] == "yes" - assert body["visibility"] == "private" - assert "title" in body - assert "object" not in body - assert "account_id" not in body - assert "job_status_id" not in body - assert "ical_uid" not in body - assert "message_id" not in body - assert "owner" not in body - assert "status" not in body - assert "master_event_id" not in body - assert "original_start_time" not in body - - event1.title = "blah" - assert "participants" in event1 - event1["participants"][0]["status"] = "no" - event1.save() - request = mocked_responses.calls[1].request - body = json.loads(request.body) - assert body["title"] == "blah" - assert "status" not in body["participants"][0] - assert event1.title == "loaded from JSON" - assert event1.get("ignored") is None - assert "id" not in body - - -@pytest.mark.usefixtures("mock_event_create_notify_response") -def test_event_notify(mocked_responses, api_client): - event1 = blank_event(api_client) - event1.save(notify_participants="true", other_param="1") - assert event1.id == "cv4ei7syx10uvsxbs21ccsezf" - - url = mocked_responses.calls[-1].request.url - query = URLObject(url).query_dict - assert query["notify_participants"] == "true" - assert query["other_param"] == "1" - - -@pytest.mark.usefixtures("mock_event_create_response") -def test_event_conferencing_details(mocked_responses, api_client): - event = blank_event(api_client) - event.conferencing = { - "provider": "Zoom Meeting", - "details": { - "url": "https://us02web.zoom.us/j/****************", - "meeting_code": "213", - "password": "xyz", - "phone": ["+11234567890"], - }, - } - event.save() - assert event.id == "cv4ei7syx10uvsxbs21ccsezf" - assert event.conferencing["provider"] == "Zoom Meeting" - assert ( - event.conferencing["details"]["url"] - == "https://us02web.zoom.us/j/****************" - ) - assert event.conferencing["details"]["meeting_code"] == "213" - assert event.conferencing["details"]["password"] == "xyz" - assert event.conferencing["details"]["phone"] == ["+11234567890"] - - body = json.loads(mocked_responses.calls[-1].request.body) - assert body["conferencing"]["provider"] == "Zoom Meeting" - assert ( - body["conferencing"]["details"]["url"] - == "https://us02web.zoom.us/j/****************" - ) - assert body["conferencing"]["details"]["meeting_code"] == "213" - assert body["conferencing"]["details"]["password"] == "xyz" - assert body["conferencing"]["details"]["phone"] == ["+11234567890"] - - -@pytest.mark.usefixtures("mock_event_create_response") -def test_event_conferencing_autocreate(mocked_responses, api_client): - event = blank_event(api_client) - event.conferencing = { - "provider": "Zoom Meeting", - "autocreate": { - "settings": {}, - }, - } - event.save() - assert event.id == "cv4ei7syx10uvsxbs21ccsezf" - assert event.conferencing["provider"] == "Zoom Meeting" - assert event.conferencing["autocreate"]["settings"] == {} - - body = json.loads(mocked_responses.calls[-1].request.body) - assert body["conferencing"]["provider"] == "Zoom Meeting" - assert event.conferencing["autocreate"]["settings"] == {} - - -@pytest.mark.usefixtures("mock_event_create_response") -def test_event_conferencing_details_autocreate_error(mocked_responses, api_client): - event = blank_event(api_client) - event.conferencing = { - "provider": "Zoom Meeting", - "details": { - "url": "https://us02web.zoom.us/j/****************", - "meeting_code": "213", - "password": "xyz", - "phone": ["+11234567890"], - }, - "autocreate": { - "settings": { - "password": "1234", - }, - }, - } - with pytest.raises(ValueError) as excinfo: - event.save() - assert "Cannot set both 'details' and 'autocreate' in conferencing object." in str( - excinfo - ) - - -@pytest.mark.usefixtures("mock_event_create_response") -def test_event_error_if_participants_more_than_capacity(mocked_responses, api_client): - event = blank_event(api_client) - event.capacity = 1 - event.participants = [ - {"email": "person1@email.com"}, - {"email": "person2@email.com"}, - ] - with pytest.raises(ValueError) as excinfo: - event.save() - assert "The number of participants in the event exceeds the set capacity." in str( - excinfo - ) - - -@pytest.mark.usefixtures("mock_event_create_response") -def test_event_no_error_if_capacity_negative_one(mocked_responses, api_client): - event = blank_event(api_client) - event.capacity = -1 - event.participants = [ - {"email": "person1@email.com"}, - {"email": "person2@email.com"}, - ] - event.save() - - -@pytest.mark.usefixtures("mock_event_create_response") -def test_event_no_error_if_participants_less_than_eql_capacity( - mocked_responses, api_client -): - event = blank_event(api_client) - event.capacity = 2 - event.participants = [ - {"email": "person1@email.com"}, - {"email": "person2@email.com"}, - ] - event.save() - event.capacity = 3 - event.participants = [ - {"email": "person1@email.com"}, - {"email": "person2@email.com"}, - ] - event.save() - - -@pytest.mark.usefixtures("mock_calendars", "mock_events") -def test_calendar_events(api_client): - calendar = api_client.calendars.first() - assert calendar.events - assert all(isinstance(event, Event) for event in calendar.events) - - -@pytest.mark.usefixtures("mock_events", "mock_send_rsvp") -def test_event(mocked_responses, api_client): - event = api_client.events.first() - event.rsvp("yes") - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/send-rsvp" - data = json.loads(request.body) - assert data["event_id"] == event.id - assert data["status"] == "yes" - assert data["comment"] == None - - -@pytest.mark.usefixtures("mock_events", "mock_send_rsvp") -def test_event_rsvp_with_comment(mocked_responses, api_client): - event = api_client.events.first() - event.rsvp("no", "I have a conflict") - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/send-rsvp" - data = json.loads(request.body) - assert data["event_id"] == event.id - assert data["status"] == "no" - assert data["comment"] == "I have a conflict" - - -@pytest.mark.usefixtures("mock_events") -def test_event_rsvp_invalid(api_client): - event = api_client.events.first() - with pytest.raises(ValueError) as excinfo: - event.rsvp("purple") - assert "invalid status" in str(excinfo) - - -@pytest.mark.usefixtures("mock_events") -def test_event_rsvp_no_message(api_client): - event = api_client.events.all()[1] - with pytest.raises(ValueError) as excinfo: - event.rsvp("yes") - assert "This event was not imported from an iCalendar invite" in str(excinfo) - - -@pytest.mark.usefixtures("mock_free_busy") -def test_free_busy_datetime(mocked_responses, api_client): - email = "fake@example.com" - start_at = datetime(2020, 1, 1) - end_at = datetime(2020, 1, 2) - free_busy = api_client.free_busy([email], start_at, end_at) - - assert isinstance(free_busy, list) - assert isinstance(free_busy[0], dict) - assert free_busy[0]["email"] == "fake@example.com" - assert "time_slots" in free_busy[0] - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/free-busy" - data = json.loads(request.body) - assert data["emails"] == [email] - assert data["start_time"] == 1577836800 - assert data["end_time"] == 1577923200 - - -@pytest.mark.usefixtures("mock_free_busy") -def test_free_busy_timestamp(mocked_responses, api_client): - email = "ron@example.com" - start_time = 1580511600 - end_time = 1580598000 - free_busy = api_client.free_busy([email], start_time, end_time) - - assert isinstance(free_busy, list) - assert isinstance(free_busy[0], dict) - assert free_busy[0]["email"] == "ron@example.com" - assert "time_slots" in free_busy[0] - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/free-busy" - data = json.loads(request.body) - assert data["emails"] == [email] - assert data["start_time"] == 1580511600 - assert data["end_time"] == 1580598000 - - -@pytest.mark.usefixtures("mock_free_busy") -def test_free_busy_single_email(mocked_responses, api_client): - email = "ben@bitdiddle.com" - start_at = datetime(2000, 1, 1) - end_at = datetime(2000, 3, 1) - free_busy = api_client.free_busy(email, start_at, end_at) - - assert isinstance(free_busy, list) - assert isinstance(free_busy[0], dict) - assert free_busy[0]["email"] == "ben@bitdiddle.com" - assert "time_slots" in free_busy[0] - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/free-busy" - data = json.loads(request.body) - assert data["emails"] == [email] - assert data["start_time"] == 946684800 - assert data["end_time"] == 951868800 - - -@pytest.mark.usefixtures("mock_free_busy") -def test_free_busy_with_calendars(mocked_responses, api_client): - email = "ben@bitdiddle.com" - start_at = datetime(2000, 1, 1) - end_at = datetime(2000, 3, 1) - calendars = [ - { - "account_id": "test_account_id", - "calendar_ids": ["example_calendar_a", "example_calendar_b"], - } - ] - api_client.free_busy([email], start_at, end_at, calendars) - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/free-busy" - data = json.loads(request.body) - assert data["emails"] == [email] - assert data["start_time"] == 946684800 - assert data["end_time"] == 951868800 - assert len(data["calendars"]) == 1 - assert data["calendars"][0] == { - "account_id": "test_account_id", - "calendar_ids": ["example_calendar_a", "example_calendar_b"], - } - - -@pytest.mark.usefixtures("mock_free_busy") -def test_free_busy_without_emails_or_calendar(mocked_responses, api_client): - start_at = datetime(2000, 1, 1) - end_at = datetime(2000, 3, 1) - with pytest.raises(ValueError) as excinfo: - api_client.free_busy([], start_at, end_at) - assert "Must set either 'emails' or 'calendars' in the query." in str(excinfo) - - -@pytest.mark.usefixtures("mock_availability") -def test_availability_datetime(mocked_responses, api_client): - emails = ["one@example.com", "two@example.com", "three@example.com"] - duration = timedelta(minutes=30) - interval = timedelta(hours=1, minutes=30) - start_at = datetime(2020, 1, 1) - end_at = datetime(2020, 1, 2) - availability = api_client.availability(emails, duration, interval, start_at, end_at) - - assert isinstance(availability, dict) - assert "time_slots" in availability - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/availability" - data = json.loads(request.body) - assert data["emails"] == emails - assert data["duration_minutes"] == 30 - assert isinstance(data["duration_minutes"], int) - assert data["interval_minutes"] == 90 - assert isinstance(data["interval_minutes"], int) - assert data["start_time"] == 1577836800 - assert data["end_time"] == 1577923200 - assert data["free_busy"] == [] - - -@pytest.mark.usefixtures("mock_availability") -def test_availability_timestamp(mocked_responses, api_client): - emails = ["one@example.com", "two@example.com", "three@example.com"] - duration = 30 - interval = 60 - start_time = 1580511600 - end_time = 1580598000 - availability = api_client.availability( - emails, duration, interval, start_time, end_time - ) - - assert isinstance(availability, dict) - assert "time_slots" in availability - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/availability" - data = json.loads(request.body) - assert data["emails"] == emails - assert data["duration_minutes"] == 30 - assert isinstance(data["duration_minutes"], int) - assert data["interval_minutes"] == 60 - assert isinstance(data["interval_minutes"], int) - assert data["start_time"] == 1580511600 - assert data["end_time"] == 1580598000 - assert data["free_busy"] == [] - - -@pytest.mark.usefixtures("mock_availability") -def test_availability_single_email(mocked_responses, api_client): - email = "ben@bitdiddle.com" - duration = timedelta(minutes=60) - interval = 5 - start_at = datetime(2000, 1, 1) - end_at = datetime(2000, 3, 1) - availability = api_client.availability(email, duration, interval, start_at, end_at) - - assert isinstance(availability, dict) - assert "time_slots" in availability - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/availability" - data = json.loads(request.body) - assert data["emails"] == [email] - assert data["duration_minutes"] == 60 - assert isinstance(data["duration_minutes"], int) - assert data["interval_minutes"] == 5 - assert isinstance(data["interval_minutes"], int) - assert data["start_time"] == 946684800 - assert data["end_time"] == 951868800 - assert data["free_busy"] == [] - - -@pytest.mark.usefixtures("mock_availability") -def test_availability_with_free_busy(mocked_responses, api_client): - emails = [ - "one@example.com", - "two@example.com", - "three@example.com", - "visitor@external.net", - ] - duration = 48 - interval = timedelta(minutes=18) - start_at = datetime(2020, 1, 1) - end_at = datetime(2020, 1, 2) - free_busy = [ - { - "email": "visitor@external.net", - "time_slots": [ - { - "object": "time_slot", - "status": "busy", - "start_time": 1584377898, - "end_time": 1584379800, - } - ], - "object": "free_busy", - } - ] - availability = api_client.availability( - emails, duration, interval, start_at, end_at, free_busy=free_busy - ) - - assert isinstance(availability, dict) - assert "time_slots" in availability - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/availability" - data = json.loads(request.body) - assert data["emails"] == emails - assert data["duration_minutes"] == 48 - assert isinstance(data["duration_minutes"], int) - assert data["interval_minutes"] == 18 - assert isinstance(data["interval_minutes"], int) - assert data["start_time"] == 1577836800 - assert data["end_time"] == 1577923200 - assert data["free_busy"] == free_busy - - -@pytest.mark.usefixtures("mock_availability") -def test_availability_with_calendars(mocked_responses, api_client): - emails = [ - "one@example.com", - "two@example.com", - "three@example.com", - "visitor@external.net", - ] - duration = 48 - interval = timedelta(minutes=18) - start_at = datetime(2020, 1, 1) - end_at = datetime(2020, 1, 2) - calendars = [ - { - "account_id": "test_account_id", - "calendar_ids": ["example_calendar_a", "example_calendar_b"], - } - ] - api_client.availability( - emails, duration, interval, start_at, end_at, calendars=calendars - ) - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/availability" - data = json.loads(request.body) - assert data["emails"] == emails - assert data["duration_minutes"] == 48 - assert isinstance(data["duration_minutes"], int) - assert data["interval_minutes"] == 18 - assert isinstance(data["interval_minutes"], int) - assert data["start_time"] == 1577836800 - assert data["end_time"] == 1577923200 - assert len(data["calendars"]) == 1 - assert data["calendars"][0] == { - "account_id": "test_account_id", - "calendar_ids": ["example_calendar_a", "example_calendar_b"], - } - - -@pytest.mark.usefixtures("mock_availability") -def test_availability_without_emails_or_calendar(mocked_responses, api_client): - duration = 48 - interval = timedelta(minutes=18) - start_at = datetime(2000, 1, 1) - end_at = datetime(2000, 3, 1) - with pytest.raises(ValueError) as excinfo: - api_client.availability([], duration, interval, start_at, end_at) - assert "Must set either 'emails' or 'calendars' in the query." in str(excinfo) - - -@pytest.mark.usefixtures("mock_availability") -def test_consecutive_availability(mocked_responses, api_client): - emails = [["one@example.com"], ["two@example.com", "three@example.com"]] - duration = timedelta(minutes=30) - interval = timedelta(hours=1, minutes=30) - start_at = datetime(2020, 1, 1) - end_at = datetime(2020, 1, 2) - open_hours = api_client.open_hours( - ["one@example.com", "two@example.com", "three@example.com"], - [0], - "America/Chicago", - "10:00", - "14:00", - ) - api_client.consecutive_availability( - emails, duration, interval, start_at, end_at, open_hours=[open_hours] - ) - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/availability/consecutive" - data = json.loads(request.body) - assert data["emails"] == emails - assert data["duration_minutes"] == 30 - assert isinstance(data["duration_minutes"], int) - assert data["interval_minutes"] == 90 - assert isinstance(data["interval_minutes"], int) - assert data["start_time"] == 1577836800 - assert data["end_time"] == 1577923200 - assert data["free_busy"] == [] - assert data["open_hours"][0]["emails"] == [ - "one@example.com", - "two@example.com", - "three@example.com", - ] - assert data["open_hours"][0]["days"] == [0] - assert data["open_hours"][0]["timezone"] == "America/Chicago" - assert data["open_hours"][0]["start"] == "10:00" - assert data["open_hours"][0]["end"] == "14:00" - - -@pytest.mark.usefixtures("mock_availability") -def test_consecutive_availability_free_busy(mocked_responses, api_client): - emails = [["one@example.com"], ["two@example.com", "three@example.com"]] - duration = timedelta(minutes=30) - interval = timedelta(hours=1, minutes=30) - start_at = datetime(2020, 1, 1) - end_at = datetime(2020, 1, 2) - open_hours = api_client.open_hours( - [ - "one@example.com", - "two@example.com", - "three@example.com", - "visitor@external.net", - ], - [0], - "America/Chicago", - "10:00", - "14:00", - ) - free_busy = [ - { - "email": "visitor@external.net", - "time_slots": [ - { - "object": "time_slot", - "status": "busy", - "start_time": 1584377898, - "end_time": 1584379800, - } - ], - "object": "free_busy", - } - ] - api_client.consecutive_availability( - emails, - duration, - interval, - start_at, - end_at, - free_busy=free_busy, - open_hours=[open_hours], - ) - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/availability/consecutive" - data = json.loads(request.body) - assert data["emails"] == emails - assert data["duration_minutes"] == 30 - assert isinstance(data["duration_minutes"], int) - assert data["interval_minutes"] == 90 - assert isinstance(data["interval_minutes"], int) - assert data["start_time"] == 1577836800 - assert data["end_time"] == 1577923200 - assert data["free_busy"] == free_busy - assert data["open_hours"][0]["emails"] == [ - "one@example.com", - "two@example.com", - "three@example.com", - "visitor@external.net", - ] - assert data["open_hours"][0]["days"] == [0] - assert data["open_hours"][0]["timezone"] == "America/Chicago" - assert data["open_hours"][0]["start"] == "10:00" - assert data["open_hours"][0]["end"] == "14:00" - - -@pytest.mark.usefixtures("mock_availability") -def test_consecutive_availability_with_calendars(mocked_responses, api_client): - emails = [["one@example.com"], ["two@example.com", "three@example.com"]] - duration = timedelta(minutes=30) - interval = timedelta(hours=1, minutes=30) - start_at = datetime(2020, 1, 1) - end_at = datetime(2020, 1, 2) - calendars = [ - { - "account_id": "test_account_id", - "calendar_ids": ["example_calendar_a", "example_calendar_b"], - } - ] - api_client.consecutive_availability( - emails, duration, interval, start_at, end_at, calendars=calendars - ) - - request = mocked_responses.calls[-1].request - assert URLObject(request.url).path == "/calendars/availability/consecutive" - data = json.loads(request.body) - assert data["emails"] == emails - assert data["duration_minutes"] == 30 - assert isinstance(data["duration_minutes"], int) - assert data["interval_minutes"] == 90 - assert isinstance(data["interval_minutes"], int) - assert data["start_time"] == 1577836800 - assert data["end_time"] == 1577923200 - assert len(data["calendars"]) == 1 - assert data["calendars"][0] == { - "account_id": "test_account_id", - "calendar_ids": ["example_calendar_a", "example_calendar_b"], - } - - -@pytest.mark.usefixtures("mock_availability") -def test_availability_without_emails_or_calendar(mocked_responses, api_client): - duration = 48 - interval = timedelta(minutes=18) - start_at = datetime(2000, 1, 1) - end_at = datetime(2000, 3, 1) - with pytest.raises(ValueError) as excinfo: - api_client.consecutive_availability([], duration, interval, start_at, end_at) - assert "Must set either 'emails' or 'calendars' in the query." in str(excinfo) - - -@pytest.mark.usefixtures("mock_availability") -def test_consecutive_availability_invalid_open_hours_email( - mocked_responses, api_client -): - emails = [["one@example.com"], ["two@example.com", "three@example.com"]] - duration = timedelta(minutes=30) - interval = timedelta(hours=1, minutes=30) - start_at = datetime(2020, 1, 1) - end_at = datetime(2020, 1, 2) - open_hours = api_client.open_hours( - [ - "one@example.com", - "two@example.com", - "three@example.com", - "visitor@external.net", - "four@example.com", - ], - [0], - "America/Chicago", - "10:00", - "14:00", - ) - free_busy = [ - { - "email": "visitor@external.net", - "time_slots": [ - { - "object": "time_slot", - "status": "busy", - "start_time": 1584377898, - "end_time": 1584379800, - } - ], - "object": "free_busy", - } - ] - with pytest.raises(ValueError): - api_client.consecutive_availability( - emails, - duration, - interval, - start_at, - end_at, - free_busy=free_busy, - open_hours=[open_hours], - ) - - -@pytest.mark.usefixtures("mock_events") -def test_metadata_filtering(api_client): - events_filtered_by_key = api_client.events.where(metadata_key="platform") - assert len(events_filtered_by_key.all()) > 0 - for event in events_filtered_by_key: - assert "platform" in event["metadata"] - - events_filtered_by_value = api_client.events.where( - metadata_value=["meeting", "java"] - ) - assert len(events_filtered_by_value.all()) > 0 - for event in events_filtered_by_value: - assert event["metadata"]["event_type"] == "meeting" - - events_filtered_by_pair = api_client.events.where( - metadata_pair={"platform": "python", "bla": "blablabla"} - ) - assert len(events_filtered_by_pair.all()) > 0 - for event in events_filtered_by_pair: - assert "platform" in event["metadata"] - assert event["metadata"]["platform"] == "python" - - non_existant_event = api_client.events.where(metadata_pair={"bla": "blablabla"}) - assert len(non_existant_event.all()) == 0 - - -@pytest.mark.usefixtures("mock_event_create_response") -def test_event_notifications(mocked_responses, api_client): - event = blank_event(api_client) - event.notifications = [ - { - "type": "email", - "minutes_before_event": 60, - "subject": "Test Event Notification", - "body": "Reminding you about our meeting.", - } - ] - event.save() - assert event.id == "cv4ei7syx10uvsxbs21ccsezf" - assert len(event.notifications) == 1 - assert event.notifications[0]["type"] == "email" - assert event.notifications[0]["minutes_before_event"] == 60 - assert event.notifications[0]["subject"] == "Test Event Notification" - assert event.notifications[0]["body"] == "Reminding you about our meeting." - - -@pytest.mark.usefixtures("mock_event_create_response", "mock_event_generate_ics") -def test_generate_ics_existing_event(mocked_responses, api_client): - event = blank_event(api_client) - event.save() - ics = event.generate_ics() - ics_request = mocked_responses.calls[1].request - assert len(mocked_responses.calls) == 2 - assert event.id == "cv4ei7syx10uvsxbs21ccsezf" - assert ics_request.path_url == "/events/to-ics" - assert ics_request.method == "POST" - assert json.loads(ics_request.body) == {"event_id": "cv4ei7syx10uvsxbs21ccsezf"} - - -@pytest.mark.usefixtures("mock_event_create_response", "mock_event_generate_ics") -def test_generate_ics_no_event_id(mocked_responses, api_client): - event = blank_event(api_client) - ics = event.generate_ics() - ics_request = mocked_responses.calls[0].request - assert len(mocked_responses.calls) == 1 - assert event.id is None - assert ics_request.path_url == "/events/to-ics" - assert ics_request.method == "POST" - assert json.loads(ics_request.body) == { - "calendar_id": "calendar_id", - "title": "Paris-Brest", - "when": {"end_time": 1409594400, "start_time": 1409594400}, - } - - -@pytest.mark.usefixtures("mock_event_create_response", "mock_event_generate_ics") -def test_generate_ics_options(mocked_responses, api_client): - event = blank_event(api_client) - event.save() - ics = event.generate_ics( - ical_uid="test_uuid", method="request", prodid="test_prodid" - ) - ics_request = mocked_responses.calls[1].request - assert len(mocked_responses.calls) == 2 - assert event.id == "cv4ei7syx10uvsxbs21ccsezf" - assert ics_request.path_url == "/events/to-ics" - assert ics_request.method == "POST" - assert json.loads(ics_request.body) == { - "event_id": "cv4ei7syx10uvsxbs21ccsezf", - "ics_options": { - "ical_uid": "test_uuid", - "method": "request", - "prodid": "test_prodid", - }, - } - - -@pytest.mark.usefixtures("mock_event_create_response", "mock_event_generate_ics") -def test_generate_ics_no_calendar_id_throws(mocked_responses, api_client): - event = blank_event(api_client) - del event.calendar_id - with pytest.raises(ValueError) as exc: - event.generate_ics() - - assert str(exc.value) == ( - "Cannot generate an ICS file for an event without a Calendar ID or when set" - ) - - -@pytest.mark.usefixtures("mock_event_create_response", "mock_event_generate_ics") -def test_generate_ics_no_when_throws(mocked_responses, api_client): - event = blank_event(api_client) - del event.when - with pytest.raises(ValueError) as exc: - event.generate_ics() - - assert str(exc.value) == ( - "Cannot generate an ICS file for an event without a Calendar ID or when set" - ) diff --git a/tests/test_files.py b/tests/test_files.py deleted file mode 100644 index 65449358..00000000 --- a/tests/test_files.py +++ /dev/null @@ -1,83 +0,0 @@ -import cgi -from io import BytesIO -import pytest -from nylas.client.errors import FileUploadError - - -@pytest.mark.usefixtures("mock_files") -def test_file_upload_data(api_client, mocked_responses): - data = "Hello, World!" - - myfile = api_client.files.create() - myfile.filename = "hello.txt" - myfile.data = data - - assert not mocked_responses.calls - myfile.save() - assert len(mocked_responses.calls) == 1 - - assert myfile.filename == "hello.txt" - assert myfile.size == 13 - - upload_body = mocked_responses.calls[0].request.body - upload_lines = upload_body.decode("utf8").splitlines() - - content_disposition = upload_lines[1] - _, params = cgi.parse_header(content_disposition) - assert params["filename"] == "hello.txt" - assert "Hello, World!" in upload_lines - - -@pytest.mark.usefixtures("mock_files") -def test_file_upload_stream(api_client, mocked_responses): - stream = BytesIO(b"Hello, World!") - stream.name = "wacky.txt" - - myfile = api_client.files.create() - myfile.filename = "hello.txt" - myfile.stream = stream - assert not mocked_responses.calls - myfile.save() - assert len(mocked_responses.calls) == 1 - - assert myfile.filename == "hello.txt" - assert myfile.size == 13 - - upload_body = mocked_responses.calls[0].request.body - upload_lines = upload_body.decode("utf8").splitlines() - - content_disposition = upload_lines[1] - _, params = cgi.parse_header(content_disposition) - assert params["filename"] == "hello.txt" - assert "Hello, World!" in upload_lines - - -@pytest.mark.usefixtures("mock_files") -def test_file_download(api_client, mocked_responses): - assert not mocked_responses.calls - myfile = api_client.files.first() - assert len(mocked_responses.calls) == 1 - data = myfile.download().decode() - assert len(mocked_responses.calls) == 2 - assert data == "Hello, World!" - - -def test_file_invalid_upload(api_client): - myfile = api_client.files.create() - with pytest.raises(FileUploadError) as exc: - myfile.save() - - assert str(exc.value) == ( - "File object not properly formatted, " "must provide either a stream or data." - ) - - -def test_file_upload_errors(api_client): - myfile = api_client.files.create() - myfile.filename = "test.txt" - myfile.data = "Hello World." - - with pytest.raises(FileUploadError) as exc: - myfile.download() - - assert str(exc.value) == ("Can't download a file that " "hasn't been uploaded.") diff --git a/tests/test_filter.py b/tests/test_filter.py deleted file mode 100644 index 95169251..00000000 --- a/tests/test_filter.py +++ /dev/null @@ -1,73 +0,0 @@ -import json -import random - -import pytest -import responses -from urlobject import URLObject - - -def test_no_filter(mocked_responses, api_client, api_url, message_body): - message_body_list_50 = [message_body for _ in range(1, 51)] - message_body_list_22 = [message_body for _ in range(1, 23)] - - values = [ - (200, {}, json.dumps(message_body_list_22)), - (200, {}, json.dumps(message_body_list_50)), - ] - - def callback(_request): - return values.pop() - - mocked_responses.add_callback(responses.GET, api_url + "/events", callback=callback) - - events = api_client.events.all() - assert len(events) == 72 - assert events[0].id == "cv4ei7syx10uvsxbs21ccsezf" - - -def test_two_filters(mocked_responses, api_client, api_url): - mocked_responses.add(responses.GET, api_url + "/events", body="[]") - events = api_client.events.where(param1="a", param2="b").all() - assert len(events) == 0 # pylint: disable=len-as-condition - url = mocked_responses.calls[-1].request.url - query = URLObject(url).query_dict - assert query["param1"] == "a" - assert query["param2"] == "b" - - -@pytest.mark.usefixtures("mock_event_create_response_with_limits") -def test_limit_filter(mocked_responses, api_client, api_url, message_body): - events = api_client.events.where(limit=51).all() - assert len(events) == 51 # pylint: disable=len-as-condition - url = mocked_responses.calls[-1].request.url - query = URLObject(url).query_dict - assert query["limit"] == "51" - - -def test_no_offset(mocked_responses, api_client, api_url): - mocked_responses.add(responses.GET, api_url + r"/events", body="[]") - list(api_client.events.where({"in": "Nylas"}).values()) - url = mocked_responses.calls[-1].request.url - query = URLObject(url).query_dict - assert query["in"] == "Nylas" - assert query["offset"] == "0" - - -def test_zero_offset(mocked_responses, api_client, api_url): - mocked_responses.add(responses.GET, api_url + "/events", body="[]") - list(api_client.events.where({"in": "Nylas", "offset": 0}).values()) - url = mocked_responses.calls[-1].request.url - query = URLObject(url).query_dict - assert query["in"] == "Nylas" - assert query["offset"] == "0" - - -def test_non_zero_offset(mocked_responses, api_client, api_url): - offset = random.randint(1, 1000) - mocked_responses.add(responses.GET, api_url + "/events", body="[]") - - list(api_client.events.where({"in": "Nylas", "offset": offset}).values()) - url = mocked_responses.calls[-1].request.url - query = URLObject(url).query_dict - assert query["in"] == "Nylas" - assert query["offset"] == str(offset) diff --git a/tests/test_folders.py b/tests/test_folders.py deleted file mode 100644 index 46512f4c..00000000 --- a/tests/test_folders.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest -from nylas.client.restful_models import Folder, Thread, Message - - -@pytest.mark.usefixtures("mock_folder") -def test_get_change_folder(api_client): - folder = api_client.folders.get("anuep8pe5ug3xrupchwzba2o8") - assert folder is not None - assert isinstance(folder, Folder) - assert folder.display_name == "My Folder" - folder.display_name = "My New Folder" - folder.save() - assert folder.display_name == "My New Folder" - - -@pytest.mark.usefixtures("mock_folder", "mock_threads") -def test_folder_threads(api_client): - folder = api_client.folders.get("anuep8pe5ug3xrupchwzba2o8") - assert folder.threads - assert all(isinstance(thread, Thread) for thread in folder.threads) - - -@pytest.mark.usefixtures("mock_folder", "mock_messages") -def test_folder_messages(api_client): - folder = api_client.folders.get("anuep8pe5ug3xrupchwzba2o8") - assert folder.messages - assert all(isinstance(message, Message) for message in folder.messages) diff --git a/tests/test_job_status.py b/tests/test_job_status.py deleted file mode 100644 index f5a89c58..00000000 --- a/tests/test_job_status.py +++ /dev/null @@ -1,44 +0,0 @@ -from datetime import datetime - -import pytest -from nylas.client.restful_models import JobStatus - - -@pytest.mark.usefixtures("mock_job_statuses") -def test_first_job_status(api_client): - job_status = api_client.job_statuses.first() - assert isinstance(job_status, JobStatus) - - -@pytest.mark.usefixtures("mock_job_statuses") -def test_all_job_status(api_client): - job_statuses = api_client.job_statuses.all() - assert len(job_statuses) == 2 - for job_status in job_statuses: - assert isinstance(job_status, JobStatus) - - -@pytest.mark.usefixtures("mock_job_statuses") -def test_job_status(api_client): - job_status = api_client.job_statuses.first() - assert job_status["account_id"] == "test_account_id" - assert job_status["action"] == "save_draft" - assert job_status["id"] == "test_id" - assert job_status["job_status_id"] == "test_job_status_id" - assert job_status["object"] == "message" - assert job_status["status"] == "successful" - assert job_status["created_at"] == datetime(2021, 6, 4, 22, 36) - assert job_status["metadata"] == {"message_id": "nylas_message_id"} - - -@pytest.mark.usefixtures("mock_job_statuses") -def test_job_status_is_successful(api_client): - job_status = api_client.job_statuses.first() - assert job_status.is_successful() is True - - -@pytest.mark.usefixtures("mock_job_statuses") -def test_job_status_is_successful_false(api_client): - job_status = api_client.job_statuses.first() - job_status.status = "failed" - assert job_status.is_successful() is False diff --git a/tests/test_labels.py b/tests/test_labels.py deleted file mode 100644 index a2c150cc..00000000 --- a/tests/test_labels.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest -from nylas.client.restful_models import Label, Thread, Message - - -@pytest.mark.usefixtures("mock_labels") -def test_list_labels(api_client): - labels = api_client.labels - labels = [l for l in labels] - assert len(labels) == 5 - assert all(isinstance(x, Label) for x in labels) - - -@pytest.mark.usefixtures("mock_label") -def test_get_label(api_client): - label = api_client.labels.get("anuep8pe5ugmxrucchrzba2o8") - assert label is not None - assert isinstance(label, Label) - assert label.display_name == "Important" - - -@pytest.mark.usefixtures("mock_label", "mock_threads") -def test_label_threads(api_client): - label = api_client.labels.get("anuep8pe5ugmxrucchrzba2o8") - assert label.threads - assert all(isinstance(thread, Thread) for thread in label.threads) - - -@pytest.mark.usefixtures("mock_label", "mock_messages") -def test_label_messages(api_client): - label = api_client.labels.get("anuep8pe5ugmxrucchrzba2o8") - assert label.messages - assert all(isinstance(message, Message) for message in label.messages) diff --git a/tests/test_messages.py b/tests/test_messages.py deleted file mode 100644 index c5481b00..00000000 --- a/tests/test_messages.py +++ /dev/null @@ -1,154 +0,0 @@ -from datetime import datetime -import json - -import six -import pytest -from urlobject import URLObject -from nylas.client.restful_models import Message -from nylas.utils import timestamp_from_dt -import pytz - - -@pytest.mark.usefixtures("mock_messages") -def test_messages(api_client): - message = api_client.messages.first() - assert len(message.labels) == 1 - assert message.labels[0].display_name == "Inbox" - assert message.folder is None - assert message.unread - assert not message.starred - - -@pytest.mark.usefixtures("mock_messages") -def test_message_attrs(api_client): - message = api_client.messages.first() - expected_received = datetime(2010, 2, 2, 2, 22, 22) - assert message.received_at == expected_received - assert message.date == timestamp_from_dt(expected_received) - - -@pytest.mark.usefixtures("mock_account", "mock_messages", "mock_message") -def test_message_stars(api_client): - message = api_client.messages.first() - assert message.starred is False - message.star() - assert message.starred is True - message.unstar() - assert message.starred is False - - -@pytest.mark.usefixtures("mock_account", "mock_messages", "mock_message") -def test_message_read(api_client): - message = api_client.messages.first() - assert message.unread is True - message.mark_as_read() - assert message.unread is False - message.mark_as_unread() - assert message.unread is True - # mark_as_seen() is a synonym for mark_as_read() - message.mark_as_seen() - assert message.unread is False - - -@pytest.mark.usefixtures("mock_account", "mock_messages", "mock_message") -def test_message_labels(api_client): - message = api_client.messages.first() - message.add_label("fghj") - msg_labels = [l.id for l in message.labels] - assert "abcd" in msg_labels - assert "fghj" in msg_labels - message.remove_label("fghj") - msg_labels = [l.id for l in message.labels] - assert "abcd" in msg_labels - assert "fghj" not in msg_labels - - # Test that folders don't do anything when labels are in effect - message.update_folder("zxcv") - assert message.folder is None - - -@pytest.mark.usefixtures("mock_account", "mock_message", "mock_messages") -def test_message_raw(api_client, account_id): - message = api_client.messages.first() - raw = message.raw - assert isinstance(raw, six.binary_type) - parsed = json.loads(raw) - assert parsed == { - "object": "message", - "to": [{"email": "foo@yahoo.com", "name": "Foo"}], - "from": [{"email": "bar@gmail.com", "name": "Bar"}], - "account_id": account_id, - "labels": [{"display_name": "Inbox", "name": "inbox", "id": "abcd"}], - "starred": False, - "unread": True, - "id": "1234", - "subject": "Test Message", - } - - -@pytest.mark.usefixtures("mock_message") -def test_message_delete_by_id(mocked_responses, api_client): - api_client.messages.delete(1234, forceful=True) - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - url = URLObject(request.url) - assert url.query_dict["forceful"] == "True" - - -@pytest.mark.usefixtures("mock_message") -def test_message_resolution(mocked_responses, api_client, account_id): - message = api_client.messages.get(1234) - assert message.object == "message" - assert message.to == [{"email": "foo@yahoo.com", "name": "Foo"}] - assert message.from_ == [{"email": "bar@gmail.com", "name": "Bar"}] - assert message["from"] == [{"email": "bar@gmail.com", "name": "Bar"}] - assert message.account_id == account_id - assert message._labels == [{"display_name": "Inbox", "name": "inbox", "id": "abcd"}] - assert message.id == "1234" - assert message.subject == "Test Message" - assert message.starred is False - assert message.unread is True - - -@pytest.mark.usefixtures("mock_messages") -def test_slice_messages(api_client): - messages = api_client.messages[0:2] - assert len(messages) == 3 - assert all(isinstance(message, Message) for message in messages) - - -@pytest.mark.usefixtures("mock_messages") -def test_filter_messages_dt(mocked_responses, api_client): - api_client.messages.where(received_before=datetime(2010, 6, 1)).all() - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - url = URLObject(request.url) - assert url.query_dict["received_before"] == "1275350400" - - -@pytest.mark.usefixtures("mock_messages") -def test_filter_messages_dt_with_timezone(mocked_responses, api_client): - api_client.messages.where( - received_before=datetime(2010, 6, 1, tzinfo=pytz.utc) - ).all() - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - url = URLObject(request.url) - assert url.query_dict["received_before"] == "1275350400" - - -@pytest.mark.usefixtures("mock_messages") -def test_filter_messages_ts(mocked_responses, api_client): - api_client.messages.where(received_before=1275350400).all() - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - url = URLObject(request.url) - assert url.query_dict["received_before"] == "1275350400" - - -@pytest.mark.usefixtures("mock_message", "mock_messages") -def test_message_metadata(mocked_responses, api_client): - message = api_client.messages.first() - message["metadata"] = {"test": "value"} - message.save() - assert message.metadata == {"test": "value"} diff --git a/tests/test_neural.py b/tests/test_neural.py deleted file mode 100644 index d9035cb3..00000000 --- a/tests/test_neural.py +++ /dev/null @@ -1,183 +0,0 @@ -import json -from datetime import datetime - -import pytest -from nylas.client.restful_models import File - -from nylas.client.neural_api_models import ( - NeuralSignatureContact, - NeuralMessageOptions, - Categorize, - NeuralCategorizer, -) - - -@pytest.mark.usefixtures("mock_sentiment_analysis") -def test_sentiment_analysis_message(mocked_responses, api_client, account_id): - analysis_response = api_client.neural.sentiment_analysis_message(["message_id"]) - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - assert json.loads(request.body) == {"message_id": ["message_id"]} - assert len(analysis_response) == 1 - analysis = analysis_response[0] - assert analysis.account_id == account_id - assert analysis.processed_length == 11 - assert analysis.sentiment == "NEUTRAL" - assert analysis.sentiment_score == 0.30000001192092896 - assert analysis.text == "hello world" - - -@pytest.mark.usefixtures("mock_sentiment_analysis") -def test_sentiment_analysis_text(mocked_responses, api_client, account_id): - analysis = api_client.neural.sentiment_analysis_text("hello world") - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - assert json.loads(request.body) == {"text": "hello world"} - assert analysis.account_id == account_id - assert analysis.processed_length == 11 - assert analysis.sentiment == "NEUTRAL" - assert analysis.sentiment_score == 0.30000001192092896 - assert analysis.text == "hello world" - - -@pytest.mark.usefixtures("mock_extract_signature") -def test_extract_signature(mocked_responses, api_client): - signature_response = api_client.neural.extract_signature(["abc123"]) - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - assert json.loads(request.body) == {"message_id": ["abc123"]} - assert len(signature_response) == 1 - signature = signature_response[0] - assert ( - signature.signature - == "Nylas Swag\n\nSoftware Engineer\n\n123-456-8901\n\nswag@nylas.com" - ) - assert signature.model_version == "0.0.1" - assert isinstance(signature.contacts, NeuralSignatureContact) - contact = signature.contacts - assert contact.job_titles == ["Software Engineer"] - assert contact.links == [ - { - "description": "string", - "url": "https://example.com/link.html", - } - ] - assert contact.phone_numbers == ["123-456-8901"] - assert contact.emails == ["swag@nylas.com"] - assert contact.names == [ - { - "first_name": "Nylas", - "last_name": "Swag", - } - ] - - -@pytest.mark.usefixtures("mock_extract_signature") -def test_extract_signature_options(mocked_responses, api_client): - options = NeuralMessageOptions(False, False, False, False, False) - api_client.neural.extract_signature(["abc123"], False, options) - request = mocked_responses.calls[0].request - assert json.loads(request.body) == { - "message_id": ["abc123"], - "parse_contacts": False, - "ignore_links": False, - "ignore_images": False, - "ignore_tables": False, - "remove_conclusion_phrases": False, - "images_as_markdowns": False, - } - - -@pytest.mark.usefixtures("mock_extract_signature") -def test_signature_convert_contact(mocked_responses, api_client): - signature = api_client.neural.extract_signature(["abc123"]) - contact = signature[0].contacts.to_contact_object() - assert contact.given_name == "Nylas" - assert contact.surname == "Swag" - assert contact.job_title == "Software Engineer" - assert len(contact.emails) == 1 - assert contact.emails["personal"] == ["swag@nylas.com"] - assert len(contact.phone_numbers) == 1 - assert contact.phone_numbers["mobile"] == ["123-456-8901"] - assert len(contact.web_pages) == 1 - assert contact.web_pages["string"] == ["https://example.com/link.html"] - - -@pytest.mark.usefixtures("mock_categorize") -def test_categorize(mocked_responses, api_client): - response = api_client.neural.categorize(["abc123"]) - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - assert json.loads(request.body) == {"message_id": ["abc123"]} - assert len(response) == 1 - assert isinstance(response[0].categorizer, Categorize) - categorize = response[0].categorizer - assert categorize.category == "feed" - assert categorize.model_version == "6194f733" - assert categorize.subcategories == ["ooo"] - assert categorize.categorized_at == datetime.utcfromtimestamp(1627076720) - - -@pytest.mark.usefixtures("mock_categorize") -def test_recategorize(mocked_responses, api_client): - categorize = api_client.neural.categorize("abc123") - recategorize = categorize[0].recategorize("conversation") - assert len(mocked_responses.calls) == 3 - request = mocked_responses.calls[1].request - assert json.loads(request.body) == { - "message_id": "abc123", - "category": "conversation", - } - assert isinstance(recategorize, NeuralCategorizer) - - -@pytest.mark.usefixtures("mock_ocr_request") -def test_ocr_request(mocked_responses, api_client): - ocr = api_client.neural.ocr_request("abc123", [2, 3]) - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - assert json.loads(request.body) == {"file_id": "abc123", "pages": [2, 3]} - assert len(ocr.ocr) == 2 - assert ocr.ocr[0] == "This is page 1" - assert ocr.ocr[1] == "This is page 2" - assert ocr.processed_pages == 2 - - -@pytest.mark.usefixtures("mock_clean_conversation") -def test_clean_conversation(mocked_responses, api_client): - convo_response = api_client.neural.clean_conversation(["abc123"]) - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - assert json.loads(request.body) == {"message_id": ["abc123"]} - assert len(convo_response) == 1 - convo = convo_response[0] - assert ( - convo.conversation - == " This is the conversation" - ) - assert convo.model_version == "0.0.1" - - -@pytest.mark.usefixtures("mock_clean_conversation") -def test_clean_conversation_options(mocked_responses, api_client): - options = NeuralMessageOptions(False, False, False, False, False) - api_client.neural.clean_conversation(["abc123"], options) - request = mocked_responses.calls[0].request - assert json.loads(request.body) == { - "message_id": ["abc123"], - "ignore_links": False, - "ignore_images": False, - "ignore_tables": False, - "remove_conclusion_phrases": False, - "images_as_markdowns": False, - } - - -@pytest.mark.usefixtures("mock_clean_conversation") -def test_clean_conversation_extract_images(mocked_responses, api_client): - convo = api_client.neural.clean_conversation(["abc123"]) - extracted_files = convo[0].extract_images() - assert len(mocked_responses.calls) == 2 - assert len(extracted_files) == 1 - assert isinstance(extracted_files[0], File) is True - assert extracted_files[0].id == "1781777f666586677621" diff --git a/tests/test_outbox.py b/tests/test_outbox.py deleted file mode 100644 index 3adff8f0..00000000 --- a/tests/test_outbox.py +++ /dev/null @@ -1,126 +0,0 @@ -from datetime import datetime, timedelta -import json - -import pytest -from urlobject import URLObject - -from nylas.utils import timestamp_from_dt - - -@pytest.mark.usefixtures("mock_outbox") -def test_outbox_send(mocked_responses, api_client): - draft, tomorrow, day_after = prepare_outbox_request(api_client) - - job_status = api_client.outbox.send(draft, tomorrow, retry_limit_datetime=day_after) - - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/v2/outbox" - assert request.method == "POST" - body = json.loads(request.body) - evaluate_message(body, tomorrow, day_after) - assert job_status["job_status_id"] == "job-status-id" - assert job_status["status"] == "pending" - assert job_status["account_id"] == "account-id" - original_data = job_status["original_data"] - evaluate_message(original_data, tomorrow, day_after) - assert original_data["original_send_at"] == timestamp_from_dt(tomorrow) - - -@pytest.mark.usefixtures("mock_outbox") -def test_outbox_update(mocked_responses, api_client): - draft, tomorrow, day_after = prepare_outbox_request(api_client) - - api_client.outbox.update( - "job-status-id", draft=draft, send_at=tomorrow, retry_limit_datetime=day_after - ) - - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/v2/outbox/job-status-id" - assert request.method == "PATCH" - body = json.loads(request.body) - evaluate_message(body, tomorrow, day_after) - - -@pytest.mark.usefixtures("mock_outbox") -def test_outbox_send_at_before_today_should_raise(mocked_responses, api_client): - with pytest.raises(ValueError) as excinfo: - api_client.outbox._validate_and_format_datetime(636309514, None) - assert "Cannot set message to be sent at a time before the current time." in str( - excinfo - ) - - -@pytest.mark.usefixtures("mock_outbox") -def test_outbox_retry_limit_datetime_before_send_at_should_raise( - mocked_responses, api_client -): - tomorrow = datetime.today() + timedelta(days=1) - day_after = tomorrow + timedelta(days=1) - with pytest.raises(ValueError) as excinfo: - api_client.outbox._validate_and_format_datetime( - send_at=day_after, retry_limit_datetime=tomorrow - ) - assert "Cannot set message to stop retrying before time to send at." in str(excinfo) - - -@pytest.mark.usefixtures("mock_outbox") -def test_outbox_retry_limit_datetime_before_today_should_raise( - mocked_responses, api_client -): - with pytest.raises(ValueError) as excinfo: - api_client.outbox._validate_and_format_datetime(None, 636309514) - assert "Cannot set message to stop retrying before time to send at." in str(excinfo) - - -@pytest.mark.usefixtures("mock_outbox") -def test_outbox_delete(mocked_responses, api_client): - api_client.outbox.delete("job-status-id") - - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/v2/outbox/job-status-id" - assert request.method == "DELETE" - - -@pytest.mark.usefixtures("mock_outbox_send_grid") -def test_outbox_send_grid_verification(mocked_responses, api_client): - verification_status = api_client.outbox.send_grid_verification_status() - - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/v2/outbox/onboard/verified_status" - assert request.method == "GET" - assert verification_status.domain_verified is True - assert verification_status.sender_verified is True - - -@pytest.mark.usefixtures("mock_outbox_send_grid") -def test_outbox_send_grid_verification(mocked_responses, api_client): - api_client.outbox.delete_send_grid_sub_user("test@email.com") - - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/v2/outbox/onboard/subuser" - assert request.method == "DELETE" - - -# Test helpers - - -def prepare_outbox_request(api_client): - draft = api_client.drafts.create() - draft.subject = "With Love, from Nylas" - draft.to = [{"email": "test@email.com", "name": "Me"}] - draft.body = "This email was sent using the Nylas email API. Visit https://nylas.com for details." - tomorrow = datetime.today() + timedelta(days=1) - day_after = tomorrow + timedelta(days=1) - - return draft, tomorrow, day_after - - -def evaluate_message(message, send_at, retry_limit_datetime): - assert message["to"] == [{"email": "test@email.com", "name": "Me"}] - assert message["subject"] == "With Love, from Nylas" - assert ( - message["body"] - == "This email was sent using the Nylas email API. Visit https://nylas.com for details." - ) - assert message["send_at"] == timestamp_from_dt(send_at) - assert message["retry_limit_datetime"] == timestamp_from_dt(retry_limit_datetime) diff --git a/tests/test_resources.py b/tests/test_resources.py deleted file mode 100644 index 36c7860c..00000000 --- a/tests/test_resources.py +++ /dev/null @@ -1,28 +0,0 @@ -import pytest -from nylas.client.restful_models import RoomResource - - -@pytest.mark.usefixtures("mock_resources") -def test_first_resource(api_client): - resource = api_client.room_resources.first() - assert isinstance(resource, RoomResource) - - -@pytest.mark.usefixtures("mock_resources") -def test_all_resources(api_client): - resources = api_client.room_resources.all() - assert len(resources) == 2 - for resource in resources: - assert isinstance(resource, RoomResource) - - -@pytest.mark.usefixtures("mock_resources") -def test_resource(api_client): - resource = api_client.room_resources.first() - assert resource["object"] == "room_resource" - assert resource["email"] == "training-room-1A@google.com" - assert resource["name"] == "Google Training Room" - assert resource["building"] == "San Francisco" - assert resource["capacity"] == "10" - assert resource["floor_name"] == "7" - assert resource["floor_number"] is None diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py deleted file mode 100644 index 77be03ff..00000000 --- a/tests/test_scheduler.py +++ /dev/null @@ -1,287 +0,0 @@ -import json -from datetime import datetime -import pytest -import responses - -from nylas.client.restful_models import Scheduler, Calendar -from nylas.client.scheduler_models import SchedulerTimeSlot, SchedulerBookingRequest - - -def blank_scheduler_page(api_client): - scheduler = api_client.scheduler.create() - scheduler.access_tokens = ["test-access-token"] - scheduler.name = "Python SDK Example" - scheduler.slug = "py_example_1" - return scheduler - - -def test_scheduler_endpoint(api_client): - scheduler = api_client.scheduler - assert scheduler.api.api_server == "https://api.schedule.nylas.com" - - -@pytest.mark.usefixtures("mock_schedulers") -def test_scheduler(api_client): - scheduler = api_client.scheduler.first() - assert isinstance(scheduler, Scheduler) - assert scheduler.id == 90210 - assert scheduler.app_client_id == "test-client-id" - assert scheduler.app_organization_id == 12345 - assert len(scheduler.config) == 4 - assert isinstance(scheduler.config, dict) - assert scheduler.config["locale"] == "en" - assert len(scheduler.config["reminders"]) == 0 - assert scheduler.config["timezone"] == "America/Los_Angeles" - assert scheduler.edit_token == "test-edit-token-1" - assert scheduler.name == "test-1" - assert scheduler.slug == "test1" - assert scheduler.created_at == datetime.strptime("2021-10-22", "%Y-%m-%d").date() - assert scheduler.modified_at == datetime.strptime("2021-10-22", "%Y-%m-%d").date() - - -@pytest.mark.usefixtures("mock_scheduler_create_response") -def test_create_scheduler(api_client): - scheduler = blank_scheduler_page(api_client) - scheduler.save() - assert scheduler.id == "cv4ei7syx10uvsxbs21ccsezf" - - -@pytest.mark.usefixtures("mock_scheduler_create_response") -def test_modify_scheduler(api_client): - scheduler = blank_scheduler_page(api_client) - scheduler.id = "cv4ei7syx10uvsxbs21ccsezf" - scheduler.name = "Updated Name" - scheduler.save() - assert scheduler.name == "Updated Name" - - -@pytest.mark.usefixtures("mock_scheduler_get_available_calendars") -def test_scheduler_get_available_calendars(api_client): - scheduler = blank_scheduler_page(api_client) - scheduler.id = "cv4ei7syx10uvsxbs21ccsezf" - calendars = scheduler.get_available_calendars() - assert len(calendars) == 1 - calendar = calendars[0] - assert len(calendar["calendars"]) == 1 - assert isinstance(calendar["calendars"][0], Calendar) - assert calendar["calendars"][0].id == "calendar-id" - assert calendar["calendars"][0].name == "Emailed events" - assert calendar["calendars"][0].read_only - assert calendar["email"] == "swag@nylas.com" - assert calendar["id"] == "scheduler-id" - assert calendar["name"] == "Python Tester" - - -@pytest.mark.usefixtures("mock_scheduler_get_available_calendars") -def test_scheduler_get_available_calendars_no_id_throws_error(api_client): - scheduler = blank_scheduler_page(api_client) - with pytest.raises(ValueError): - scheduler.get_available_calendars() - - -@pytest.mark.usefixtures("mock_scheduler_upload_image") -def test_scheduler_upload_image(api_client): - scheduler = blank_scheduler_page(api_client) - scheduler.id = "cv4ei7syx10uvsxbs21ccsezf" - upload = scheduler.upload_image("image/png", "test.png") - assert upload["filename"] == "test.png" - assert upload["originalFilename"] == "test.png" - assert upload["publicUrl"] == "https://public.nylas.com/test.png" - assert upload["signedUrl"] == "https://signed.nylas.com/test.png" - - -@pytest.mark.usefixtures("mock_scheduler_get_available_calendars") -def test_scheduler_get_available_calendars_no_id_throws_error(api_client): - scheduler = blank_scheduler_page(api_client) - with pytest.raises(ValueError): - scheduler.upload_image("image/png", "test.png") - - -@pytest.mark.usefixtures("mock_scheduler_provider_availability") -def test_scheduler_get_google_availability(mocked_responses, api_client): - api_client.scheduler.get_google_availability() - request = mocked_responses.calls[0].request - assert request.url == "https://api.schedule.nylas.com/schedule/availability/google" - assert request.method == responses.GET - - -@pytest.mark.usefixtures("mock_scheduler_provider_availability") -def test_scheduler_get_o365_availability(mocked_responses, api_client): - api_client.scheduler.get_office_365_availability() - request = mocked_responses.calls[0].request - assert request.url == "https://api.schedule.nylas.com/schedule/availability/o365" - assert request.method == responses.GET - - -@pytest.mark.usefixtures("mock_schedulers") -def test_scheduler_get_page_slug(mocked_responses, api_client): - scheduler = api_client.scheduler.get_page_slug("test1") - request = mocked_responses.calls[0].request - assert request.url == "https://api.schedule.nylas.com/schedule/test1/info" - assert request.method == responses.GET - assert isinstance(scheduler, Scheduler) - assert scheduler.id == 90210 - assert scheduler.app_client_id == "test-client-id" - assert scheduler.app_organization_id == 12345 - assert len(scheduler.config) == 4 - assert isinstance(scheduler.config, dict) - assert scheduler.config["locale"] == "en" - assert len(scheduler.config["reminders"]) == 0 - assert scheduler.config["timezone"] == "America/Los_Angeles" - assert scheduler.edit_token == "test-edit-token-1" - assert scheduler.name == "test-1" - assert scheduler.slug == "test1" - assert scheduler.created_at == datetime.strptime("2021-10-22", "%Y-%m-%d").date() - assert scheduler.modified_at == datetime.strptime("2021-10-22", "%Y-%m-%d").date() - - -@pytest.mark.usefixtures("mock_scheduler_timeslots") -def test_scheduler_get_available_time_slots(mocked_responses, api_client): - scheduler = blank_scheduler_page(api_client) - timeslots = api_client.scheduler.get_available_time_slots(scheduler.slug) - request = mocked_responses.calls[0].request - assert ( - request.url == "https://api.schedule.nylas.com/schedule/py_example_1/timeslots" - ) - assert request.method == responses.GET - assert len(timeslots) == 1 - assert timeslots[0] - assert timeslots[0].account_id == "test-account-id" - assert timeslots[0].calendar_id == "test-calendar-id" - assert timeslots[0].emails[0] == "test@example.com" - assert timeslots[0].host_name == "www.hostname.com" - assert timeslots[0].end == datetime.utcfromtimestamp(1636731958) - assert timeslots[0].start == datetime.utcfromtimestamp(1636728347) - - -@pytest.mark.usefixtures("mock_scheduler_timeslots") -def test_scheduler_get_available_time_slots(mocked_responses, api_client): - scheduler = blank_scheduler_page(api_client) - timeslots = api_client.scheduler.get_available_time_slots(scheduler.slug) - request = mocked_responses.calls[0].request - assert ( - request.url == "https://api.schedule.nylas.com/schedule/py_example_1/timeslots" - ) - assert request.method == responses.GET - assert len(timeslots) == 1 - assert timeslots[0] - assert timeslots[0].account_id == "test-account-id" - assert timeslots[0].calendar_id == "test-calendar-id" - assert timeslots[0].emails[0] == "test@example.com" - assert timeslots[0].host_name == "www.hostname.com" - assert timeslots[0].end == datetime.utcfromtimestamp(1636731958) - assert timeslots[0].start == datetime.utcfromtimestamp(1636728347) - - -@pytest.mark.usefixtures("mock_scheduler_timeslots") -def test_scheduler_book_time_slot(mocked_responses, api_client): - scheduler = blank_scheduler_page(api_client) - slot = SchedulerTimeSlot.create(api_client) - slot.account_id = "test-account-id" - slot.calendar_id = "test-calendar-id" - slot.emails = ["recipient@example.com"] - slot.host_name = "www.nylas.com" - slot.start = datetime.utcfromtimestamp(1636728347) - slot.end = datetime.utcfromtimestamp(1636731958) - timeslot_to_book = SchedulerBookingRequest.create(api_client) - timeslot_to_book.additional_values = { - "test": "yes", - } - timeslot_to_book.email = "recipient@example.com" - timeslot_to_book.locale = "en_US" - timeslot_to_book.name = "Recipient Doe" - timeslot_to_book.timezone = "America/New_York" - timeslot_to_book.slot = slot - booking_response = api_client.scheduler.book_time_slot( - scheduler.slug, timeslot_to_book - ) - request = mocked_responses.calls[0].request - assert ( - request.url == "https://api.schedule.nylas.com/schedule/py_example_1/timeslots" - ) - assert request.method == responses.POST - assert json.loads(request.body) == { - "additional_emails": [], - "additional_values": { - "test": "yes", - }, - "email": "recipient@example.com", - "locale": "en_US", - "name": "Recipient Doe", - "timezone": "America/New_York", - "slot": { - "account_id": "test-account-id", - "calendar_id": "test-calendar-id", - "emails": ["recipient@example.com"], - "host_name": "www.nylas.com", - "start": 1636728347, - "end": 1636731958, - }, - } - assert booking_response.account_id == "test-account-id" - assert booking_response.calendar_id == "test-calendar-id" - assert booking_response.additional_field_values == { - "test": "yes", - } - assert booking_response.calendar_event_id == "test-event-id" - assert booking_response.calendar_id == "test-calendar-id" - assert booking_response.calendar_event_id == "test-event-id" - assert booking_response.edit_hash == "test-edit-hash" - assert booking_response.id == 123 - assert booking_response.is_confirmed is False - assert booking_response.location == "Earth" - assert booking_response.title == "Test Booking" - assert booking_response.recipient_email == "recipient@example.com" - assert booking_response.recipient_locale == "en_US" - assert booking_response.recipient_name == "Recipient Doe" - assert booking_response.recipient_tz == "America/New_York" - assert booking_response.end_time == datetime.utcfromtimestamp(1636731958) - assert booking_response.start_time == datetime.utcfromtimestamp(1636728347) - - -@pytest.mark.usefixtures("mock_scheduler_timeslots") -def test_scheduler_confirm_booking(mocked_responses, api_client): - scheduler = blank_scheduler_page(api_client) - booking_confirmation = api_client.scheduler.confirm_booking( - scheduler.slug, "test-edit-hash" - ) - request = mocked_responses.calls[0].request - assert ( - request.url - == "https://api.schedule.nylas.com/schedule/py_example_1/test-edit-hash/confirm" - ) - assert request.method == responses.POST - assert booking_confirmation.account_id == "test-account-id" - assert booking_confirmation.calendar_id == "test-calendar-id" - assert booking_confirmation.additional_field_values == { - "test": "yes", - } - assert booking_confirmation.calendar_event_id == "test-event-id" - assert booking_confirmation.calendar_id == "test-calendar-id" - assert booking_confirmation.calendar_event_id == "test-event-id" - assert booking_confirmation.edit_hash == "test-edit-hash" - assert booking_confirmation.id == 123 - assert booking_confirmation.is_confirmed is True - assert booking_confirmation.location == "Earth" - assert booking_confirmation.title == "Test Booking" - assert booking_confirmation.recipient_email == "recipient@example.com" - assert booking_confirmation.recipient_locale == "en_US" - assert booking_confirmation.recipient_name == "Recipient Doe" - assert booking_confirmation.recipient_tz == "America/New_York" - assert booking_confirmation.end_time == datetime.utcfromtimestamp(1636731958) - assert booking_confirmation.start_time == datetime.utcfromtimestamp(1636728347) - - -@pytest.mark.usefixtures("mock_scheduler_timeslots") -def test_scheduler_cancel_booking(mocked_responses, api_client): - scheduler = blank_scheduler_page(api_client) - timeslots = api_client.scheduler.cancel_booking( - scheduler.slug, "test-edit-hash", "It was a test." - ) - request = mocked_responses.calls[0].request - assert ( - request.url - == "https://api.schedule.nylas.com/schedule/py_example_1/test-edit-hash/cancel" - ) - assert request.method == responses.POST - assert json.loads(request.body) == {"reason": "It was a test."} diff --git a/tests/test_search.py b/tests/test_search.py deleted file mode 100644 index 367d3881..00000000 --- a/tests/test_search.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest - - -@pytest.mark.usefixtures("mock_thread_search_response") -def test_search_threads(api_client): - threads = api_client.threads.search("Helena") - assert len(threads) == 1 - assert "Helena" in threads[0].snippet - - -@pytest.mark.usefixtures("mock_message_search_response") -def test_search_messages(api_client): - messages = api_client.messages.search("Pinot") - assert len(messages) == 2 - assert "Pinot" in messages[0].snippet - assert "Pinot" in messages[1].snippet - - -@pytest.mark.usefixtures("mock_message_search_response") -def test_search_messages_with_limit_offset(mocked_responses, api_client): - api_client.messages.search("Pinot", limit=10, offset=0) - request = mocked_responses.calls[0].request - assert request.path_url == "/messages/search?q=Pinot&limit=10&offset=0" - - -@pytest.mark.usefixtures("mock_message_search_response") -def test_search_messages_with_view_should_not_appear(mocked_responses, api_client): - api_client.messages.search("Pinot", view="expanded") - request = mocked_responses.calls[0].request - assert request.path_url == "/messages/search?q=Pinot" - - -@pytest.mark.usefixtures("mock_thread_search_response") -def test_search_messages_with_view_should_appear(mocked_responses, api_client): - api_client.threads.search("Helena", view="expanded") - request = mocked_responses.calls[0].request - assert request.path_url == "/threads/search?q=Helena&view=expanded" - - -@pytest.mark.usefixtures("mock_message_search_response") -def test_search_drafts(api_client): - with pytest.raises(Exception): - api_client.drafts.search("Pinot") diff --git a/tests/test_send_error_handling.py b/tests/test_send_error_handling.py deleted file mode 100644 index c89aa484..00000000 --- a/tests/test_send_error_handling.py +++ /dev/null @@ -1,101 +0,0 @@ -import json -import re -import pytest -import responses -import six -from requests import RequestException -from nylas.client.errors import MessageRejectedError, RateLimitError - - -def mock_sending_error( - http_code, message, mocked_responses, api_url, server_error=None, headers=None -): - send_endpoint = re.compile(api_url + "/send") - response_body = {"type": "api_error", "message": message} - - if six.PY2 and http_code == 429: - # Python 2 `httplib` doesn't know about status code 429 - six.moves.http_client.responses[429] = "Too Many Requests" - - if server_error is not None: - response_body["server_error"] = server_error - - response_body = json.dumps(response_body) - mocked_responses.add( - responses.POST, - send_endpoint, - content_type="application/json", - status=http_code, - body=response_body, - headers=headers, - ) - - -@pytest.mark.usefixtures("mock_account", "mock_save_draft") -def test_handle_message_rejected(mocked_responses, api_client, api_url): - draft = api_client.drafts.create() - error_message = "Sending to all recipients failed" - mock_sending_error(402, error_message, mocked_responses, api_url=api_url) - with pytest.raises(MessageRejectedError): - draft.send() - - -@pytest.mark.usefixtures("mock_account", "mock_save_draft") -def test_handle_quota_exceeded(mocked_responses, api_client, api_url): - draft = api_client.drafts.create() - error_message = "Daily sending quota exceeded" - error_headers = {"X-RateLimit-Limit": "500", "X-RateLimit-Reset": "10"} - mock_sending_error( - 429, error_message, mocked_responses, api_url=api_url, headers=error_headers - ) - with pytest.raises(RateLimitError) as exc: - draft.send() - assert "Too Many Requests" in str(exc.value) - assert exc.value.rate_limit == 500 - assert exc.value.rate_limit_reset == 10 - - -@pytest.mark.usefixtures("mock_account", "mock_save_draft") -def test_handle_quota_exceeded_no_headers(mocked_responses, api_client, api_url): - draft = api_client.drafts.create() - error_message = "Daily sending quota exceeded" - mock_sending_error(429, error_message, mocked_responses, api_url=api_url) - with pytest.raises(RateLimitError) as exc: - draft.send() - assert "Too Many Requests" in str(exc.value) - - -@pytest.mark.usefixtures("mock_account", "mock_save_draft") -def test_handle_service_unavailable(mocked_responses, api_client, api_url): - draft = api_client.drafts.create() - error_message = "The server unexpectedly closed the connection" - mock_sending_error(503, error_message, mocked_responses, api_url=api_url) - with pytest.raises(RequestException) as exc: - draft.send() - assert "Service Unavailable" in str(exc.value) - - -@pytest.mark.usefixtures("mock_account", "mock_save_draft") -def test_returns_server_error(mocked_responses, api_client, api_url): - draft = api_client.drafts.create() - error_message = "The server unexpectedly closed the connection" - reason = "Rejected potential SPAM" - mock_sending_error( - 503, error_message, mocked_responses, api_url=api_url, server_error=reason - ) - with pytest.raises(RequestException) as exc: - draft.send() - - assert "Service Unavailable" in str(exc.value) - - -@pytest.mark.usefixtures("mock_account", "mock_save_draft") -def test_doesnt_return_server_error_if_not_defined( - mocked_responses, api_client, api_url -): - draft = api_client.drafts.create() - error_message = "The server unexpectedly closed the connection" - mock_sending_error(503, error_message, mocked_responses, api_url=api_url) - with pytest.raises(RequestException) as exc: - draft.send() - assert "Service Unavailable" in str(exc.value) diff --git a/tests/test_threads.py b/tests/test_threads.py deleted file mode 100644 index 99bb0b82..00000000 --- a/tests/test_threads.py +++ /dev/null @@ -1,159 +0,0 @@ -from datetime import datetime - -import pytest -from urlobject import URLObject -from nylas.client.restful_models import Message, Draft, Label -from nylas.utils import timestamp_from_dt - - -@pytest.mark.usefixtures("mock_threads") -def test_thread_attrs(api_client): - thread = api_client.threads.first() - expected_first = datetime(2016, 1, 2, 3, 4, 5) - expected_last = datetime(2017, 1, 2, 3, 4, 5) - expected_last_received = datetime(2017, 1, 2, 3, 4, 5) - expected_last_sent = datetime(2017, 1, 1, 1, 1, 1) - - assert thread.first_message_timestamp == timestamp_from_dt(expected_first) - assert thread.first_message_at == expected_first - assert thread.last_message_timestamp == timestamp_from_dt(expected_last) - assert thread.last_message_at == expected_last - assert thread.last_message_received_timestamp == timestamp_from_dt( - expected_last_received - ) - assert thread.last_message_received_at == expected_last_received - assert thread.last_message_sent_timestamp == timestamp_from_dt(expected_last_sent) - assert thread.last_message_sent_at == expected_last_sent - - -def test_update_thread_attrs(api_client): - thread = api_client.threads.create() - first = datetime(2017, 2, 3, 10, 0, 0) - second = datetime(2016, 10, 5, 14, 30, 0) - # timestamps and datetimes are handled totally separately - thread.last_message_at = first - thread.last_message_timestamp = timestamp_from_dt(second) - assert thread.last_message_at == first - assert thread.last_message_timestamp == timestamp_from_dt(second) - # but datetimes overwrite timestamps when serializing to JSON - assert thread.as_json()["last_message_timestamp"] == timestamp_from_dt(first) - - -@pytest.mark.usefixtures("mock_threads") -def test_thread_folder(api_client): - thread = api_client.threads.first() - assert len(thread.labels) == 0 # pylint: disable=len-as-condition - assert len(thread.folders) == 1 - assert thread.folders[0].display_name == "Inbox" - assert not thread.unread - assert thread.starred - - -@pytest.mark.usefixtures("mock_folder_account", "mock_threads", "mock_thread") -def test_thread_change(api_client): - thread = api_client.threads.first() - - assert thread.starred - thread.unstar() - assert not thread.starred - thread.star() - assert thread.starred - - thread.update_folder("qwer") - assert len(thread.folders) == 1 - assert thread.folders[0].id == "qwer" - - -@pytest.mark.usefixtures("mock_threads", "mock_messages") -def test_thread_messages(api_client): - thread = api_client.threads.first() - assert thread.messages - assert all(isinstance(message, Message) for message in thread.messages) - - -@pytest.mark.usefixtures("mock_labelled_thread") -def test_thread_messages_from_expanded_thread(api_client): - thread = api_client.threads.get(111) - assert len(thread.messages) == 1 - message = thread.messages[0] - assert isinstance(message, Message) - - -@pytest.mark.usefixtures("mock_threads", "mock_drafts") -def test_thread_drafts(api_client): - thread = api_client.threads.first() - assert thread.drafts - assert all(isinstance(draft, Draft) for draft in thread.drafts) - - -@pytest.mark.usefixtures("mock_labelled_thread", "mock_labels") -def test_thread_label(api_client): - thread = api_client.threads.get(111) - assert len(thread.labels) == 2 - assert all(isinstance(label, Label) for label in thread.labels) - - returned = thread.add_label("fake1") - assert len(thread.labels) == 3 - assert thread.labels == returned - - returned = thread.remove_label("fake1") - assert len(thread.labels) == 2 # pylint: disable=len-as-condition - assert thread.labels == returned - - -@pytest.mark.usefixtures("mock_labelled_thread", "mock_labels") -def test_thread_labels(api_client): - thread = api_client.threads.get(111) - assert len(thread.labels) == 2 - assert all(isinstance(label, Label) for label in thread.labels) - - returned = thread.add_labels(["fake1", "fake2"]) - assert len(thread.labels) == 4 - assert thread.labels == returned - - label_ids = [l.id for l in thread.labels] - returned = thread.remove_labels(label_ids) - assert len(thread.labels) == 0 # pylint: disable=len-as-condition - assert thread.labels == returned - - -@pytest.mark.usefixtures("mock_threads", "mock_thread") -def test_thread_read(api_client): - thread = api_client.threads.first() - assert thread.unread is False - thread.mark_as_unread() - assert thread.unread is True - thread.mark_as_read() - assert thread.unread is False - # mark_as_seen() is a synonym for mark_as_read() - thread.mark_as_unread() - assert thread.unread is True - thread.mark_as_seen() - assert thread.unread is False - - -@pytest.mark.usefixtures("mock_threads") -def test_thread_reply(api_client): - thread = api_client.threads.first() - draft = thread.create_reply() - assert isinstance(draft, Draft) - assert draft.thread_id == thread.id - assert draft.subject == thread.subject - - -@pytest.mark.usefixtures("mock_threads") -def test_filter_threads_dt(mocked_responses, api_client): - api_client.threads.where(started_before=datetime(2010, 6, 1)).all() - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - url = URLObject(request.url) - assert url.query_dict["started_before"] == "1275350400" - - -@pytest.mark.usefixtures("mock_threads") -def test_filter_threads_ts(mocked_responses, api_client): - api_client.threads.where(started_before=1275350400).all() - assert len(mocked_responses.calls) == 1 - request = mocked_responses.calls[0].request - url = URLObject(request.url) - assert url.query_dict["started_before"] == "1275350400" diff --git a/tests/test_tunnel.py b/tests/test_tunnel.py deleted file mode 100644 index 5b2d0e91..00000000 --- a/tests/test_tunnel.py +++ /dev/null @@ -1,181 +0,0 @@ -import json -import sys - -if sys.version_info >= (3, 3): - from unittest.mock import Mock -else: - from mock import Mock - -import pytest -from urlobject import URLObject - -from nylas.config import Region -from nylas.services import tunnel -from nylas.client.restful_models import Webhook - - -@pytest.mark.usefixtures("mock_create_webhook") -def test_build_webhook_tunnel(mocker, api_client_with_client_id): - mocker.patch("websocket.WebSocketApp", mock_websocket) - mocker.patch("uuid.uuid4", return_value="uuid") - ws = tunnel._build_webhook_tunnel( - api_client_with_client_id, - { - "region": Region.IRELAND, - "triggers": [Webhook.Trigger.MESSAGE_CREATED], - "on_open": on_open, - "on_error": on_error, - "on_close": on_close, - "on_ping": on_ping, - "on_pong": on_pong, - "on_cont_message": on_cont_message, - "on_data": on_data, - }, - ) - assert ws["domain"] == "wss://tunnel.nylas.com" - assert ws["header"] == { - "Client-Id": "fake-client-id", - "Client-Secret": "nyl4n4ut", - "Tunnel-Id": "uuid", - "Region": "ireland", - } - assert ws["on_open"] == on_open - assert ws["on_error"] == on_error - assert ws["on_close"] == on_close - assert ws["on_ping"] == on_ping - assert ws["on_pong"] == on_pong - assert ws["on_cont_message"] == on_cont_message - assert ws["on_data"] == on_data - - -@pytest.mark.usefixtures("mock_create_webhook") -def test_build_webhook_tunnel_defaults(mocker, api_client_with_client_id): - mocker.patch("websocket.WebSocketApp", mock_websocket) - mocker.patch("uuid.uuid4", return_value="uuid") - ws = tunnel._build_webhook_tunnel(api_client_with_client_id, {}) - assert ws["domain"] == "wss://tunnel.nylas.com" - assert ws["header"] == { - "Client-Id": "fake-client-id", - "Client-Secret": "nyl4n4ut", - "Tunnel-Id": "uuid", - "Region": "us", - } - assert ws["on_open"] is None - assert ws["on_message"] is None - assert ws["on_error"] is None - assert ws["on_close"] is None - assert ws["on_ping"] is None - assert ws["on_pong"] is None - assert ws["on_cont_message"] is None - assert ws["on_data"] is None - - -@pytest.mark.usefixtures("mock_create_webhook") -def test_register_webhook(mocked_responses, api_client_with_client_id): - tunnel._register_webhook( - api_client_with_client_id, - "domain.com", - "tunnel_id", - [Webhook.Trigger.MESSAGE_CREATED], - ) - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/a/fake-client-id/webhooks" - assert request.method == "POST" - assert json.loads(request.body) == { - "callback_url": "https://domain.com/tunnel_id", - "triggers": ["message.created"], - "state": "active", - } - - -@pytest.mark.usefixtures("mock_create_webhook") -def test_open_webhook_tunnel(mocker, api_client_with_client_id): - mock_build = Mock() - mock_run = Mock() - mocker.patch("nylas.services.tunnel._build_webhook_tunnel", mock_build) - mocker.patch("nylas.services.tunnel._run_webhook_tunnel", mock_run) - - tunnel.open_webhook_tunnel(api_client_with_client_id, {"region": Region.IRELAND}) - - mock_build_calls = mock_build.call_args_list - assert len(mock_build_calls) == 1 - assert len(mock_build_calls[0].args) == 2 - assert mock_build_calls[0].args == ( - api_client_with_client_id, - {"region": Region.IRELAND}, - ) - - mock_run_calls = mock_run.call_args_list - assert len(mock_run_calls) == 1 - - -def test_run_webhook_tunnel(): - mock = Mock() - tunnel._run_webhook_tunnel(mock) - mock_method_calls = mock.method_calls - assert len(mock_method_calls) == 1 - assert mock_method_calls[0][0] == "run_forever" - - -def test_parse_deltas(): - message = '{"body": "{\\"deltas\\": [{\\"date\\": 1675098465, \\"object\\": \\"message\\", \\"type\\": \\"message.created\\", \\"object_data\\": {\\"namespace_id\\": \\"namespace_123\\", \\"account_id\\": \\"account_123\\", \\"object\\": \\"message\\", \\"attributes\\": {\\"thread_id\\": \\"thread_123\\", \\"received_date\\": 1675098459}, \\"id\\": \\"123\\", \\"metadata\\": null}}]}"}' - deltas = tunnel._parse_deltas(message) - assert len(deltas) == 1 - delta = deltas[0] - assert delta["date"] == 1675098465 - assert delta["object"] == "message" - assert delta["type"] == Webhook.Trigger.MESSAGE_CREATED - assert delta["object_data"] is not None - - -# ============================================================================ -# Mock functions for websocket callback -# ============================================================================ - - -# This function mocks websocket implementation and returns a list of params as a dict -def mock_websocket( - domain, - header, - on_open, - on_message, - on_error, - on_close, - on_ping, - on_pong, - on_cont_message, - on_data, -): - return locals() - - -def on_message(): - print("on_message") - - -def on_open(): - print("on_open") - - -def on_error(): - print("on_error") - - -def on_close(): - print("on_close") - - -def on_ping(): - print("on_ping") - - -def on_pong(): - print("on_pong") - - -def on_cont_message(): - print("on_cont_message") - - -def on_data(): - print("on_data") diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py deleted file mode 100644 index 97e29f2e..00000000 --- a/tests/test_webhooks.py +++ /dev/null @@ -1,98 +0,0 @@ -import json - -import pytest -from urlobject import URLObject - -from nylas.client.restful_models import Webhook - - -@pytest.mark.usefixtures("mock_webhooks") -def test_webhooks(mocked_responses, api_client_with_client_id): - webhook = api_client_with_client_id.webhooks.first() - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/a/fake-client-id/webhooks" - assert request.method == "GET" - assert isinstance(webhook, Webhook) - assert webhook.id == "webhook-id" - assert webhook.application_id == "application-id" - assert webhook.callback_url == "https://your-server.com/webhook" - assert webhook.state == "active" - assert webhook.triggers == ["message.created"] - assert webhook.version == "2.0" - - -@pytest.mark.usefixtures("mock_webhooks") -def test_single_webhook(mocked_responses, api_client_with_client_id): - webhook = api_client_with_client_id.webhooks.get("abc123") - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/a/fake-client-id/webhooks/abc123" - assert request.method == "GET" - assert isinstance(webhook, Webhook) - assert webhook.id == "abc123" - - -@pytest.mark.usefixtures("mock_webhooks") -def test_update_webhook(mocked_responses, api_client_with_client_id): - webhook = api_client_with_client_id.webhooks.get("abc123") - webhook.state = Webhook.State.INACTIVE - webhook.save() - assert len(mocked_responses.calls) == 2 - request = mocked_responses.calls[1].request - assert URLObject(request.url).path == "/a/fake-client-id/webhooks/abc123" - assert request.method == "PUT" - assert json.loads(request.body) == {"state": "inactive"} - assert isinstance(webhook, Webhook) - assert webhook.id == "abc123" - assert webhook.state == "inactive" - - -@pytest.mark.usefixtures("mock_webhooks") -def test_delete_webhook(mocked_responses, api_client_with_client_id): - api_client_with_client_id.webhooks.delete("abc123") - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/a/fake-client-id/webhooks/abc123" - assert request.method == "DELETE" - - -@pytest.mark.usefixtures("mock_create_webhook") -def test_create_webhook(mocked_responses, api_client_with_client_id): - webhook = api_client_with_client_id.webhooks.create() - webhook.callback_url = "https://your-server.com/webhook" - webhook.triggers = [Webhook.Trigger.MESSAGE_CREATED] - webhook.state = Webhook.State.ACTIVE - webhook.application_id = "should-not-send" - webhook.version = "should-not-send" - webhook.save() - request = mocked_responses.calls[0].request - assert URLObject(request.url).path == "/a/fake-client-id/webhooks" - assert request.method == "POST" - assert json.loads(request.body) == { - "callback_url": "https://your-server.com/webhook", - "triggers": ["message.created"], - "state": "active", - } - assert isinstance(webhook, Webhook) - assert webhook.id == "webhook-id" - assert webhook.application_id == "application-id" - assert webhook.callback_url == "https://your-server.com/webhook" - assert webhook.state == "active" - assert webhook.triggers == ["message.created"] - assert webhook.version == "1.0" - - -def test_verify_webhook_signature(): - is_verified = Webhook.verify_webhook_signature( - "ddc02f921a4835e310f249dc09770c3fea2cb6fe949adc1887d7adc04a581e1c", - str.encode("test123"), - "myClientSecret", - ) - assert is_verified is True - - -def test_verify_webhook_signature_bad_signature(): - is_verified = Webhook.verify_webhook_signature( - "ddc02f921a4835e310f249dc09770c3fea2cb6fe949adc1887d7adc04a581e1c", - str.encode("test1234"), - "myClientSecret", - ) - assert is_verified is False diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py new file mode 100644 index 00000000..2058541a --- /dev/null +++ b/tests/utils/test_file_utils.py @@ -0,0 +1,72 @@ +from unittest.mock import patch, mock_open + +from nylas.utils.file_utils import attach_file_request_builder, _build_form_request + + +class TestFileUtils: + def test_attach_file_request_builder(self): + file_path = "tests/data/attachment.txt" + file_size = 1234 + content_type = "text/plain" + mocked_open = mock_open(read_data="test data") + + with patch("os.path.getsize", return_value=file_size): + with patch("mimetypes.guess_type", return_value=(content_type, None)): + with patch("builtins.open", mocked_open): + attach_file_request = attach_file_request_builder(file_path) + + assert attach_file_request["filename"] == "attachment.txt" + assert attach_file_request["content_type"] == content_type + assert attach_file_request["size"] == file_size + mocked_open.assert_called_once_with(file_path, "rb") + + def test_build_form_request(self): + request_body = { + "to": [{"email": "test@gmail.com"}], + "subject": "test subject", + "body": "test body", + "attachments": [ + { + "filename": "attachment.txt", + "content_type": "text/plain", + "content": b"test data", + "size": 1234, + } + ], + } + + request = _build_form_request(request_body) + + assert len(request.fields) == 2 + assert "message" in request.fields + assert "file0" in request.fields + assert len(request.fields["message"]) == 3 + assert request.fields["message"][0] == "" + assert ( + request.fields["message"][1] + == '{"to": [{"email": "test@gmail.com"}], "subject": "test subject", "body": "test body"}' + ) + assert request.fields["message"][2] == "application/json" + assert len(request.fields["file0"]) == 3 + assert request.fields["file0"][0] == "attachment.txt" + assert request.fields["file0"][1] == b"test data" + assert request.fields["file0"][2] == "text/plain" + + def test_build_form_request_no_attachments(self): + request_body = { + "to": [{"email": "test@gmail.com"}], + "subject": "test subject", + "body": "test body", + } + + request = _build_form_request(request_body) + + assert len(request.fields) == 1 + assert "message" in request.fields + assert len(request.fields["message"]) == 3 + assert request.fields["message"][0] == "" + assert ( + request.fields["message"][1] + == '{"to": [{"email": "test@gmail.com"}], "subject": "test subject", "body": "test body"}' + ) + assert request.fields["message"][2] == "application/json" diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 80e89c02..00000000 --- a/tox.ini +++ /dev/null @@ -1,7 +0,0 @@ -[tox] -envlist = py27,pypy,py34 - -[testenv] -commands = - pip install -e .[test] - pytest From facd82f7813439bf86431805abc9a82e3cded973 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 8 Feb 2024 20:23:14 +0400 Subject: [PATCH 077/186] Fix KeyError when using tokeninfo functions (#342) Calling `Auth.id_token_info()` and `Auth.validate_access_token()` returned a KeyError as the SDK was not handling the Response object properly. --- CHANGELOG.md | 4 ++++ nylas/resources/auth.py | 8 ++++---- tests/conftest.py | 15 +++++++++------ tests/resources/test_auth.py | 14 +++++++------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 618c2b84..85705aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Fix deserialization error when getting token info or verifying access token + v6.0.0 ---------------- * **BREAKING CHANGE**: Python SDK v6 supports the Nylas API v3 exclusively, dropping support for any endpoints that are not available in v3 diff --git a/nylas/resources/auth.py b/nylas/resources/auth.py index f5a55191..883a6ba5 100644 --- a/nylas/resources/auth.py +++ b/nylas/resources/auth.py @@ -138,7 +138,7 @@ def refresh_access_token( return self._get_token(request_body) - def id_token_info(self, id_token: str) -> TokenInfoResponse: + def id_token_info(self, id_token: str) -> Response[TokenInfoResponse]: """ Get info about an ID token. @@ -155,7 +155,7 @@ def id_token_info(self, id_token: str) -> TokenInfoResponse: return self._get_token_info(query_params) - def validate_access_token(self, access_token: str) -> TokenInfoResponse: + def validate_access_token(self, access_token: str) -> Response[TokenInfoResponse]: """ Get info about an access token. @@ -251,8 +251,8 @@ def _get_token(self, request_body: dict) -> CodeExchangeResponse: ) return CodeExchangeResponse.from_dict(json_response) - def _get_token_info(self, query_params: dict) -> TokenInfoResponse: + def _get_token_info(self, query_params: dict) -> Response[TokenInfoResponse]: json_response = self._http_client._execute( method="GET", path="/v3/connect/tokeninfo", query_params=query_params ) - return TokenInfoResponse.from_dict(json_response) + return Response.from_dict(json_response, TokenInfoResponse) diff --git a/tests/conftest.py b/tests/conftest.py index 87391582..b89203d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -121,11 +121,14 @@ def http_client_token_exchange(): def http_client_token_info(): mock_http_client = Mock() mock_http_client._execute.return_value = { - "iss": "https://nylas.com", - "aud": "http://localhost:3030", - "sub": "Jaf84d88-ยฃ274-46cc-bbc9-aed7dac061c7", - "email": "user@example.com", - "iat": 1692094848, - "exp": 1692095173, + "request_id": "abc-123", + "data": { + "iss": "https://nylas.com", + "aud": "http://localhost:3030", + "sub": "Jaf84d88-ยฃ274-46cc-bbc9-aed7dac061c7", + "email": "user@example.com", + "iat": 1692094848, + "exp": 1692095173, + }, } return mock_http_client diff --git a/tests/resources/test_auth.py b/tests/resources/test_auth.py index fbe57fa1..07587919 100644 --- a/tests/resources/test_auth.py +++ b/tests/resources/test_auth.py @@ -113,13 +113,13 @@ def test_get_token_info(self, http_client_token_info): path="/v3/connect/tokeninfo", query_params=req, ) - assert type(res) is TokenInfoResponse - assert res.iss == "https://nylas.com" - assert res.aud == "http://localhost:3030" - assert res.sub == "Jaf84d88-ยฃ274-46cc-bbc9-aed7dac061c7" - assert res.email == "user@example.com" - assert res.iat == 1692094848 - assert res.exp == 1692095173 + assert type(res.data) is TokenInfoResponse + assert res.data.iss == "https://nylas.com" + assert res.data.aud == "http://localhost:3030" + assert res.data.sub == "Jaf84d88-ยฃ274-46cc-bbc9-aed7dac061c7" + assert res.data.email == "user@example.com" + assert res.data.iat == 1692094848 + assert res.data.exp == 1692095173 def test_url_for_oauth2(self, http_client): auth = Auth(http_client) From 85eb04ba9266dac7faf47d3ac6bca287520eeeb4 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Thu, 8 Feb 2024 21:34:56 +0400 Subject: [PATCH 078/186] Fix schema issue in the `Event` model (#343) This PR makes created_at and updated_at optional in the SDK to reflect the API. --- CHANGELOG.md | 1 + nylas/models/events.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85705aad..f5f3269b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Fix deserialization error when getting token info or verifying access token +* Fix schema issue in the `Event` model v6.0.0 ---------------- diff --git a/nylas/models/events.py b/nylas/models/events.py index 873a51c8..3228a9ae 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -315,8 +315,6 @@ class Event: grant_id: str calendar_id: str busy: bool - created_at: int - updated_at: int participants: List[Participant] visibility: Visibility when: When = field(metadata=config(decoder=_decode_when)) @@ -338,6 +336,8 @@ class Event: reminders: Optional[Reminders] = None status: Optional[Status] = None capacity: Optional[int] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None class CreateParticipant(TypedDict): From 0c8f031a25cbf1fa915bb61394353a5ada4bc6d1 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 9 Feb 2024 19:43:58 +0400 Subject: [PATCH 079/186] Add email field in CodeExchangeResponse model (#344) This PR adds in missing email field in the CodeExchangeResponse model. --- CHANGELOG.md | 2 +- nylas/models/auth.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f3269b..166a7e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Fix deserialization error when getting token info or verifying access token -* Fix schema issue in the `Event` model +* Fix schemas issue in the `Event` and `CodeExchangeResponse` models v6.0.0 ---------------- diff --git a/nylas/models/auth.py b/nylas/models/auth.py index 7aa45260..db76c052 100644 --- a/nylas/models/auth.py +++ b/nylas/models/auth.py @@ -110,6 +110,7 @@ class CodeExchangeResponse: grant_id: ID representing the new Grant. scope: List of scopes associated with the token. expires_in: The remaining lifetime of the access token, in seconds. + email: Email address of the grant that is created. refresh_token: Returned only if the code is requested using "access_type=offline". id_token: A JWT that contains identity information about the user. Digitally signed by Nylas. token_type: Always "Bearer". @@ -119,6 +120,7 @@ class CodeExchangeResponse: grant_id: str scope: str expires_in: int + email: Optional[str] = None refresh_token: Optional[str] = None id_token: Optional[str] = None token_type: Optional[str] = None From 7252bb9035811c407c6526e76ae6a1ff124797cc Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 9 Feb 2024 20:53:06 +0400 Subject: [PATCH 080/186] v6.0.1 Release (#345) # Changelog * Fix deserialization error when getting token info or verifying access token (#342) * Fix schemas issue in the `Event` and `CodeExchangeResponse` models (#343, #344) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0808f5a9..035c41e8 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.0.0 +current_version = 6.0.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 166a7e4c..64f6985f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.0.1 ---------------- * Fix deserialization error when getting token info or verifying access token * Fix schemas issue in the `Event` and `CodeExchangeResponse` models diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 8171cf01..1931fab7 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.0.0" +__VERSION__ = "6.0.1" From 357ce0468a6617f4452575bbe6188a1f8ae601e2 Mon Sep 17 00:00:00 2001 From: Blag Date: Wed, 14 Feb 2024 10:06:13 -0500 Subject: [PATCH 081/186] Missing Webhook Secret on Webhooks.create response (#346) Added webhook_secret as part of the Webhook response --- nylas/models/webhooks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nylas/models/webhooks.py b/nylas/models/webhooks.py index 9ff6e3b0..4586576d 100644 --- a/nylas/models/webhooks.py +++ b/nylas/models/webhooks.py @@ -54,7 +54,8 @@ class Webhook: updated_at: int description: Optional[str] = None - +@dataclass_json +@dataclass class WebhookWithSecret(Webhook): """ Class representing a Nylas webhook with secret. @@ -63,7 +64,7 @@ class WebhookWithSecret(Webhook): webhook_secret: A secret value used to encode the X-Nylas-Signature header on webhook requests. """ - webhook_secret: str + webhook_secret: str = "" @dataclass_json From 5564fdbbc57db90f9d76f7b81607765f9a9f6cdc Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 23 Feb 2024 16:40:36 -0500 Subject: [PATCH 082/186] v6.0.1 Changes to fields (#348) * TW-2561 Add support for round_to field * TW-2675 Added support for attributes field * TW-2678 Added support for icloud auth provider * TW-2666 remove client ID --- CHANGELOG.md | 7 +++++++ nylas/models/auth.py | 4 +--- nylas/models/availability.py | 6 ++++++ nylas/models/folders.py | 5 +++++ tests/resources/test_auth.py | 1 - tests/resources/test_folders.py | 2 ++ 6 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f6985f..790777b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Added support for `round_to` field in availability response +* Added support for `attributes` field in folder model +* Added support for icloud as an auth provider +* Removed `client_id` from `detect_provider()` + v6.0.1 ---------------- * Fix deserialization error when getting token info or verifying access token diff --git a/nylas/models/auth.py b/nylas/models/auth.py index db76c052..bb6274c7 100644 --- a/nylas/models/auth.py +++ b/nylas/models/auth.py @@ -7,7 +7,7 @@ AccessType = Literal["online", "offline"] """ Literal for the access type of the authentication URL. """ -Provider = Literal["google", "imap", "microsoft", "virtual-calendar"] +Provider = Literal["google", "imap", "microsoft", "icloud", "virtual-calendar"] """ Literal for the different authentication providers. """ Prompt = Literal[ @@ -172,12 +172,10 @@ class ProviderDetectParams(TypedDict): Attributes: email: Email address to detect the provider for. - client_id: Client ID of the Nylas application. all_provider_types: Search by all providers regardless of created integrations. If unset, defaults to false. """ email: str - client_id: str all_provider_types: NotRequired[bool] diff --git a/nylas/models/availability.py b/nylas/models/availability.py index 4d491afb..005977d3 100644 --- a/nylas/models/availability.py +++ b/nylas/models/availability.py @@ -128,7 +128,12 @@ class GetAvailabilityRequest(TypedDict): If you have a meeting starting at 9:59, the API returns times starting at 10:00. 10:00-10:30, 10:15-10:45. round_to_30_minutes: When set to true, the availability time slots will start at 30 minutes past or on the hour. For example, a free slot starting at 16:10 is considered available only from 16:30. + Note: This field is deprecated, use round_to instead. availability_rules: The rules to apply when checking availability. + round_to: The number of minutes to round the time slots to. + This allows for rounding to any multiple of 5 minutes, up to a maximum of 60 minutes. + The default value is set to 15 minutes. + When this variable is assigned a value, it overrides the behavior of the roundTo30Minutes flag,if it was set """ start_time: int @@ -138,3 +143,4 @@ class GetAvailabilityRequest(TypedDict): interval_minutes: NotRequired[int] round_to_30_minutes: NotRequired[bool] availability_rules: NotRequired[AvailabilityRules] + round_to: NotRequired[int] diff --git a/nylas/models/folders.py b/nylas/models/folders.py index 3b160a37..06a68b64 100644 --- a/nylas/models/folders.py +++ b/nylas/models/folders.py @@ -23,6 +23,10 @@ class Folder: child_count: The number of immediate child folders in the current folder. (Microsoft only) unread_count: The number of unread items inside of a folder. total_count: The number of items inside of a folder. + attributes: Common attribute descriptors shared by system folders across providers. + For example, Sent email folders have the `["\\Sent"]` attribute. + For IMAP grants, IMAP providers provide the attributes. + For Google and Microsoft Graph, Nylas matches system folders to a set of common attributes. """ id: str @@ -36,6 +40,7 @@ class Folder: child_count: Optional[int] = None unread_count: Optional[int] = None total_count: Optional[int] = None + attributes: Optional[str] = None class CreateFolderRequest(TypedDict): diff --git a/tests/resources/test_auth.py b/tests/resources/test_auth.py index 07587919..d9f0eae8 100644 --- a/tests/resources/test_auth.py +++ b/tests/resources/test_auth.py @@ -365,7 +365,6 @@ def test_detect_provider(self): auth = Auth(mock_http_client) req = { "email": "test@gmail.com", - "client_id": "client-123", "all_provider_types": True, } diff --git a/tests/resources/test_folders.py b/tests/resources/test_folders.py index 8b32c8c2..5dfed482 100644 --- a/tests/resources/test_folders.py +++ b/tests/resources/test_folders.py @@ -17,6 +17,7 @@ def test_folder_deserialization(self): "background_color": "#039BE5", "text_color": "#039BE5", "total_count": 0, + "attributes": ["\\Sent"], } folder = Folder.from_dict(folder_json) @@ -32,6 +33,7 @@ def test_folder_deserialization(self): assert folder.background_color == "#039BE5" assert folder.text_color == "#039BE5" assert folder.total_count == 0 + assert folder.attributes == "['\\\\Sent']" def test_list_folders(self, http_client_list_response): folders = Folders(http_client_list_response) From 707697df74d1feafdd6670051bee38439c38ef87 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 27 Feb 2024 16:55:53 -0500 Subject: [PATCH 083/186] Fix responses not deserializing correctly (#350) This PR fixes the errors encountered when using the free busy and scheduled messages endpoints. Closes #347. --- .pylintrc | 3 ++ CHANGELOG.md | 1 + nylas/models/messages.py | 15 +------- nylas/resources/calendars.py | 21 ++++++++++-- nylas/resources/messages.py | 12 ++++--- tests/conftest.py | 57 +++++++++++++++++++++++++++++++ tests/resources/test_calendars.py | 25 +++++++++++--- tests/resources/test_messages.py | 17 ++++++--- 8 files changed, 122 insertions(+), 29 deletions(-) diff --git a/.pylintrc b/.pylintrc index 2db5495e..f789fc61 100644 --- a/.pylintrc +++ b/.pylintrc @@ -29,3 +29,6 @@ generated-members= CodeExchangeResponse.from_dict, NylasApiErrorResponse.from_dict, NylasOAuthErrorResponse.from_dict, + FreeBusyError.from_dict, + FreeBusy.from_dict, + ScheduledMessage.from_dict, diff --git a/CHANGELOG.md b/CHANGELOG.md index 790777b1..8d55e9e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Unreleased * Added support for `round_to` field in availability response * Added support for `attributes` field in folder model * Added support for icloud as an auth provider +* Fixed issue with free busy and scheduled message responses not being deserialized correctly * Removed `client_id` from `detect_provider()` v6.0.1 diff --git a/nylas/models/messages.py b/nylas/models/messages.py index bc7f5313..3e1c82a9 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -182,24 +182,11 @@ class ScheduledMessage: close_time: The time the message was sent or failed to send, in epoch time. """ - schedule_id: int + schedule_id: str status: ScheduledMessageStatus close_time: Optional[int] = None -@dataclass_json -@dataclass -class ScheduledMessagesList: - """ - A list of scheduled messages. - - Attributes: - schedules: The list of scheduled messages. - """ - - schedules: List[ScheduledMessage] - - @dataclass_json @dataclass class StopScheduledMessageResponse: diff --git a/nylas/resources/calendars.py b/nylas/resources/calendars.py index af276614..f74a3c43 100644 --- a/nylas/resources/calendars.py +++ b/nylas/resources/calendars.py @@ -1,3 +1,5 @@ +from typing import List + from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -6,7 +8,12 @@ DestroyableApiResource, ) from nylas.models.availability import GetAvailabilityResponse, GetAvailabilityRequest -from nylas.models.free_busy import GetFreeBusyResponse, GetFreeBusyRequest +from nylas.models.free_busy import ( + GetFreeBusyResponse, + GetFreeBusyRequest, + FreeBusyError, + FreeBusy, +) from nylas.models.calendars import ( Calendar, CreateCalendarRequest, @@ -145,7 +152,7 @@ def get_availability( def get_free_busy( self, identifier: str, request_body: GetFreeBusyRequest - ) -> Response[GetFreeBusyResponse]: + ) -> Response[List[GetFreeBusyResponse]]: """ Get free/busy info for a Calendar. @@ -162,4 +169,12 @@ def get_free_busy( request_body=request_body, ) - return Response.from_dict(json_response, GetFreeBusyResponse) + data = [] + request_id = json_response["request_id"] + for item in json_response["data"]: + if item.get("object") == "error": + data.append(FreeBusyError.from_dict(item)) + else: + data.append(FreeBusy.from_dict(item)) + + return Response(data, request_id) diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index a0fc0c86..e6279579 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, List from nylas.handler.api_resources import ( ListableApiResource, @@ -12,7 +12,6 @@ ListMessagesQueryParams, FindMessageQueryParams, UpdateMessageRequest, - ScheduledMessagesList, ScheduledMessage, StopScheduledMessageResponse, ) @@ -148,7 +147,7 @@ def send( def list_scheduled_messages( self, identifier: str - ) -> Response[ScheduledMessagesList]: + ) -> Response[List[ScheduledMessage]]: """ Retrieve your scheduled messages. @@ -163,7 +162,12 @@ def list_scheduled_messages( path=f"/v3/grants/{identifier}/messages/schedules", ) - return Response.from_dict(json_response, ScheduledMessagesList) + data = [] + request_id = json_response["request_id"] + for item in json_response["data"]: + data.append(ScheduledMessage.from_dict(item)) + + return Response(data, request_id) def find_scheduled_message( self, identifier: str, schedule_id: str diff --git a/tests/conftest.py b/tests/conftest.py index b89203d4..41542ea7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -132,3 +132,60 @@ def http_client_token_info(): }, } return mock_http_client + + +@pytest.fixture +def http_client_free_busy(): + mock_http_client = Mock() + mock_http_client._execute.return_value = { + "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5", + "data": [ + { + "email": "user1@example.com", + "time_slots": [ + { + "start_time": 1690898400, + "end_time": 1690902000, + "status": "busy", + "object": "time_slot", + }, + { + "start_time": 1691064000, + "end_time": 1691067600, + "status": "busy", + "object": "time_slot", + }, + ], + "object": "free_busy", + }, + { + "email": "user2@example.com", + "error": "Unable to resolve e-mail address user2@example.com to an Active Directory object.", + "object": "error", + }, + ], + } + return mock_http_client + + +@pytest.fixture +def http_client_list_scheduled_messages(): + mock_http_client = Mock() + mock_http_client._execute.return_value = { + "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5", + "data": [ + { + "schedule_id": "8cd56334-6d95-432c-86d1-c5dab0ce98be", + "status": { + "code": "pending", + "description": "schedule send awaiting send at time", + }, + }, + { + "schedule_id": "rb856334-6d95-432c-86d1-c5dab0ce98be", + "status": {"code": "sucess", "description": "schedule send succeeded"}, + "close_time": 1690579819, + }, + ], + } + return mock_http_client diff --git a/tests/resources/test_calendars.py b/tests/resources/test_calendars.py index 65b40221..c7571850 100644 --- a/tests/resources/test_calendars.py +++ b/tests/resources/test_calendars.py @@ -166,18 +166,35 @@ def test_get_availability(self, http_client_response): request_body=request_body, ) - def test_get_free_busy(self, http_client_response): - calendars = Calendars(http_client_response) + def test_get_free_busy(self, http_client_free_busy): + calendars = Calendars(http_client_free_busy) request_body = { "start_time": 1614556800, "end_time": 1614643200, "emails": ["test@gmail.com"], } - calendars.get_free_busy(identifier="abc-123", request_body=request_body) + response = calendars.get_free_busy( + identifier="abc-123", request_body=request_body + ) - http_client_response._execute.assert_called_once_with( + http_client_free_busy._execute.assert_called_once_with( method="POST", path="/v3/grants/abc-123/calendars/free-busy", request_body=request_body, ) + assert len(response.data) == 2 + assert response.request_id == "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5" + assert response.data[0].email == "user1@example.com" + assert len(response.data[0].time_slots) == 2 + assert response.data[0].time_slots[0].start_time == 1690898400 + assert response.data[0].time_slots[0].end_time == 1690902000 + assert response.data[0].time_slots[0].status == "busy" + assert response.data[0].time_slots[1].start_time == 1691064000 + assert response.data[0].time_slots[1].end_time == 1691067600 + assert response.data[0].time_slots[1].status == "busy" + assert response.data[1].email == "user2@example.com" + assert ( + response.data[1].error + == "Unable to resolve e-mail address user2@example.com to an Active Directory object." + ) diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index deb7feec..10477314 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -177,15 +177,24 @@ def test_send_message(self, http_client_response): data=mock_encoder, ) - def test_list_scheduled_messages(self, http_client_response): - messages = Messages(http_client_response) + def test_list_scheduled_messages(self, http_client_list_scheduled_messages): + messages = Messages(http_client_list_scheduled_messages) - messages.list_scheduled_messages(identifier="abc-123") + res = messages.list_scheduled_messages(identifier="abc-123") - http_client_response._execute.assert_called_once_with( + http_client_list_scheduled_messages._execute.assert_called_once_with( method="GET", path="/v3/grants/abc-123/messages/schedules", ) + assert res.request_id == "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5" + assert len(res.data) == 2 + assert res.data[0].schedule_id == "8cd56334-6d95-432c-86d1-c5dab0ce98be" + assert res.data[0].status.code == "pending" + assert res.data[0].status.description == "schedule send awaiting send at time" + assert res.data[1].schedule_id == "rb856334-6d95-432c-86d1-c5dab0ce98be" + assert res.data[1].status.code == "sucess" + assert res.data[1].status.description == "schedule send succeeded" + assert res.data[1].close_time == 1690579819 def test_find_scheduled_message(self, http_client_response): messages = Messages(http_client_response) From 4a597e3098b96ea2781daefd4a073d204fd091cf Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 27 Feb 2024 17:44:55 -0500 Subject: [PATCH 084/186] v6.1.0 Release (#351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update CHANGELOG.md * Bump version: 6.0.1 โ†’ 6.1.0 --- .bumpversion.cfg | 2 +- CHANGELOG.md | 3 ++- nylas/_client_sdk_version.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 035c41e8..1bc4442f 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.0.1 +current_version = 6.1.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d55e9e3..7396839d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ nylas-python Changelog ====================== -Unreleased +v6.1.0 ---------------- * Added support for `round_to` field in availability response * Added support for `attributes` field in folder model * Added support for icloud as an auth provider +* Fixed webhook secret not returning on creation of webhook * Fixed issue with free busy and scheduled message responses not being deserialized correctly * Removed `client_id` from `detect_provider()` diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 1931fab7..3a182a66 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.0.1" +__VERSION__ = "6.1.0" From ce786a7450d35e336a44cb0b5fe8f5dced784d62 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Mon, 4 Mar 2024 11:00:52 -0500 Subject: [PATCH 085/186] Change default timeout to match API (90 seconds) (#353) The API's default timeout is 90 seconds, so it's best we match that. We have updated the default value to match this, user can still override the default. --- CHANGELOG.md | 4 ++++ nylas/client.py | 2 +- tests/test_client.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7396839d..87662ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Change default timeout to match API (90 seconds) + v6.1.0 ---------------- * Added support for `round_to` field in availability response diff --git a/nylas/client.py b/nylas/client.py index 6ce8e9b5..00b5ad8c 100644 --- a/nylas/client.py +++ b/nylas/client.py @@ -26,7 +26,7 @@ class Client: """ def __init__( - self, api_key: str, api_uri: str = DEFAULT_SERVER_URL, timeout: int = 30 + self, api_key: str, api_uri: str = DEFAULT_SERVER_URL, timeout: int = 90 ): """ Initialize the Nylas API client. diff --git a/tests/test_client.py b/tests/test_client.py index 139874d8..3fd84b67 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -33,7 +33,7 @@ def test_client_init_defaults(self): assert client.api_key == "test-key" assert client.api_uri == "https://api.us.nylas.com" - assert client.http_client.timeout == 30 + assert client.http_client.timeout == 90 def test_client_auth_property(self, client): assert client.auth is not None From 7c6ca443c2b887e18b3c729eddc5cf1a3d87d1f9 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 5 Mar 2024 17:10:02 -0500 Subject: [PATCH 086/186] Improved message sending and draft create/update performance (#352) This PR improves message send/draft create/update performance by always defaulting to application/json instead of multipart. Multipart will only be used for when a request contains a total attachments size of 3mb or higher. --- CHANGELOG.md | 1 + nylas/models/webhooks.py | 1 + nylas/resources/drafts.py | 50 +++++++++++---- nylas/resources/messages.py | 21 ++++++- nylas/utils/file_utils.py | 4 ++ tests/resources/test_drafts.py | 104 +++++++++++++++++++++++++++++++ tests/resources/test_messages.py | 53 ++++++++++++++++ 7 files changed, 220 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87662ff2..87c2321d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased ---------------- +* Improved message sending and draft create/update performance * Change default timeout to match API (90 seconds) v6.1.0 diff --git a/nylas/models/webhooks.py b/nylas/models/webhooks.py index 4586576d..dc49a284 100644 --- a/nylas/models/webhooks.py +++ b/nylas/models/webhooks.py @@ -54,6 +54,7 @@ class Webhook: updated_at: int description: Optional[str] = None + @dataclass_json @dataclass class WebhookWithSecret(Webhook): diff --git a/nylas/resources/drafts.py b/nylas/resources/drafts.py index 439d581d..c9ec54b2 100644 --- a/nylas/resources/drafts.py +++ b/nylas/resources/drafts.py @@ -15,7 +15,7 @@ ) from nylas.models.messages import Message from nylas.models.response import ListResponse, Response, DeleteResponse -from nylas.utils.file_utils import _build_form_request +from nylas.utils.file_utils import _build_form_request, MAXIMUM_JSON_ATTACHMENT_SIZE class Drafts( @@ -83,13 +83,27 @@ def create( Returns: The newly created Draft. """ - json_response = self._http_client._execute( - method="POST", - path=f"/v3/grants/{identifier}/drafts", - data=_build_form_request(request_body), + path = f"/v3/grants/{identifier}/drafts" + + # Use form data only if the attachment size is greater than 3mb + attachment_size = sum( + attachment.get("size", 0) + for attachment in request_body.get("attachments", []) ) + if attachment_size >= MAXIMUM_JSON_ATTACHMENT_SIZE: + json_response = self._http_client._execute( + method="POST", + path=path, + data=_build_form_request(request_body), + ) - return Response.from_dict(json_response, Draft) + return Response.from_dict(json_response, Draft) + + return super().create( + path=path, + response_type=Draft, + request_body=request_body, + ) def update( self, @@ -108,13 +122,27 @@ def update( Returns: The updated Draft. """ - json_response = self._http_client._execute( - method="PUT", - path=f"/v3/grants/{identifier}/drafts/{draft_id}", - data=_build_form_request(request_body), + path = f"/v3/grants/{identifier}/drafts/{draft_id}" + + # Use form data only if the attachment size is greater than 3mb + attachment_size = sum( + attachment.get("size", 0) + for attachment in request_body.get("attachments", []) ) + if attachment_size >= MAXIMUM_JSON_ATTACHMENT_SIZE: + json_response = self._http_client._execute( + method="PUT", + path=path, + data=_build_form_request(request_body), + ) - return Response.from_dict(json_response, Draft) + return Response.from_dict(json_response, Draft) + + return super().update( + path=path, + response_type=Draft, + request_body=request_body, + ) def destroy(self, identifier: str, draft_id: str) -> DeleteResponse: """ diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index e6279579..b37dabed 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -17,7 +17,7 @@ ) from nylas.models.response import Response, ListResponse, DeleteResponse from nylas.resources.smart_compose import SmartCompose -from nylas.utils.file_utils import _build_form_request +from nylas.utils.file_utils import _build_form_request, MAXIMUM_JSON_ATTACHMENT_SIZE class Messages( @@ -137,10 +137,25 @@ def send( Returns: The sent message. """ + path = f"/v3/grants/{identifier}/messages/send" + form_data = None + json_body = None + + # Use form data only if the attachment size is greater than 3mb + attachment_size = sum( + attachment.get("size", 0) + for attachment in request_body.get("attachments", []) + ) + if attachment_size >= MAXIMUM_JSON_ATTACHMENT_SIZE: + form_data = _build_form_request(request_body) + else: + json_body = request_body + json_response = self._http_client._execute( method="POST", - path=f"/v3/grants/{identifier}/messages/send", - data=_build_form_request(request_body), + path=path, + request_body=json_body, + data=form_data, ) return Response.from_dict(json_response, Message) diff --git a/nylas/utils/file_utils.py b/nylas/utils/file_utils.py index da4b5faf..09faf2e6 100644 --- a/nylas/utils/file_utils.py +++ b/nylas/utils/file_utils.py @@ -8,6 +8,10 @@ from nylas.models.attachments import CreateAttachmentRequest +MAXIMUM_JSON_ATTACHMENT_SIZE = 3 * 1024 * 1024 +"""The maximum size of an attachment that can be sent using json.""" + + def attach_file_request_builder(file_path) -> CreateAttachmentRequest: """ Build a request to attach a file. diff --git a/tests/resources/test_drafts.py b/tests/resources/test_drafts.py index fa03fea6..8c2fdf59 100644 --- a/tests/resources/test_drafts.py +++ b/tests/resources/test_drafts.py @@ -101,6 +101,44 @@ def test_find_draft(self, http_client_response): ) def test_create_draft(self, http_client_response): + drafts = Drafts(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + } + + drafts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", "/v3/grants/abc-123/drafts", None, None, request_body + ) + + def test_create_draft_small_attachment(self, http_client_response): + drafts = Drafts(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3, + }, + ], + } + + drafts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", "/v3/grants/abc-123/drafts", None, None, request_body + ) + + def test_create_draft_large_attachment(self, http_client_response): drafts = Drafts(http_client_response) mock_encoder = Mock() request_body = { @@ -108,6 +146,14 @@ def test_create_draft(self, http_client_response): "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], "body": "This is the body of my draft message.", + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3 * 1024 * 1024, + }, + ], } with patch( @@ -122,6 +168,56 @@ def test_create_draft(self, http_client_response): ) def test_update_draft(self, http_client_response): + drafts = Drafts(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + } + + drafts.update( + identifier="abc-123", draft_id="draft-123", request_body=request_body + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/drafts/draft-123", + None, + None, + request_body, + ) + + def test_update_draft_small_attachment(self, http_client_response): + drafts = Drafts(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3, + }, + ], + } + + drafts.update( + identifier="abc-123", draft_id="draft-123", request_body=request_body + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/drafts/draft-123", + None, + None, + request_body, + ) + + def test_update_draft_large_attachment(self, http_client_response): drafts = Drafts(http_client_response) mock_encoder = Mock() request_body = { @@ -129,6 +225,14 @@ def test_update_draft(self, http_client_response): "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], "body": "This is the body of my draft message.", + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3 * 1024 * 1024, + }, + ], } with patch( diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 10477314..d345a444 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -157,6 +157,50 @@ def test_destroy_message(self, http_client_delete_response): ) def test_send_message(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + ) + + def test_send_message_small_attachment(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3, + }, + ], + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + ) + + def test_send_message_large_attachment(self, http_client_response): messages = Messages(http_client_response) mock_encoder = Mock() request_body = { @@ -164,6 +208,14 @@ def test_send_message(self, http_client_response): "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], "body": "This is the body of my draft message.", + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3 * 1024 * 1024, + }, + ], } with patch( @@ -174,6 +226,7 @@ def test_send_message(self, http_client_response): http_client_response._execute.assert_called_once_with( method="POST", path="/v3/grants/abc-123/messages/send", + request_body=None, data=mock_encoder, ) From b5ca36179685efe9f1a7a7e9097c8090861827db Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 5 Mar 2024 17:48:47 -0500 Subject: [PATCH 087/186] v6.1.1 Release (#356) # Description * Improved message sending and draft create/update performance (#352, #349) * Change default timeout to match API (90 seconds) (#353) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 1bc4442f..4423062b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.1.0 +current_version = 6.1.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c2321d..14b8ae25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.1.1 ---------------- * Improved message sending and draft create/update performance * Change default timeout to match API (90 seconds) diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 3a182a66..eb1a8dd8 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.1.0" +__VERSION__ = "6.1.1" From 9d70ed452af972976add5b9a0637d5ea9c6b0fbd Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 3 May 2024 12:22:36 -0400 Subject: [PATCH 088/186] Add clean conversation support (#361) This PR adds support for the /messages/clean endpoint. --- CHANGELOG.md | 4 +++ nylas/models/messages.py | 53 ++++++++++++++++++++++++++++++++ nylas/resources/messages.py | 23 ++++++++++++++ tests/conftest.py | 29 +++++++++++++++++ tests/resources/test_messages.py | 34 ++++++++++++++++++++ 5 files changed, 143 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b8ae25..30638355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Add clean messages support + v6.1.1 ---------------- * Improved message sending and draft create/update performance diff --git a/nylas/models/messages.py b/nylas/models/messages.py index 3e1c82a9..58f917ce 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -198,3 +198,56 @@ class StopScheduledMessageResponse: """ message: str + + +class CleanMessagesRequest(TypedDict): + """ + Request to clean a list of messages. + + Attributes: + message_id: IDs of the email messages to clean. + ignore_links: If true, removes link-related tags () from the email message while keeping the text. + ignore_images: If true, removes images from the email message. + images_as_markdown: If true, converts images in the email message to Markdown. + ignore_tables: If true, removes table-related tags (, ) from the email message while + keeping rows. + remove_conclusion_phrases: If true, removes phrases such as "Best" and "Regards" in the email message signature. + """ + + message_id: List[str] + ignore_links: NotRequired[bool] + ignore_images: NotRequired[bool] + images_as_markdown: NotRequired[bool] + ignore_tables: NotRequired[bool] + remove_conclusion_phrases: NotRequired[bool] + + +@dataclass_json +@dataclass +class CleanMessagesResponse(Message): + """ + Message object with the cleaned HTML message body. + + Attributes: + id (str): Globally unique object identifier. + grant_id (str): The grant that this message belongs to. + from_ (List[EmailName]): The sender of the message. + date (int): The date the message was received. + object: The type of object. + thread_id (Optional[str]): The thread that this message belongs to. + subject (Optional[str]): The subject of the message. + to (Optional[List[EmailName]]): The recipients of the message. + cc (Optional[List[EmailName]]): The CC recipients of the message. + bcc (Optional[List[EmailName]]): The BCC recipients of the message. + reply_to (Optional[List[EmailName]]): The reply-to recipients of the message. + unread (Optional[bool]): Whether the message is unread. + starred (Optional[bool]): Whether the message is starred. + snippet (Optional[str]): A snippet of the message body. + body (Optional[str]): The body of the message. + attachments (Optional[List[Attachment]]): The attachments on the message. + folders (Optional[List[str]]): The folders that the message is in. + created_at (Optional[int]): Unix timestamp of when the message was created. + conversation (str): The cleaned HTML message body. + """ + + conversation: str = "" diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index b37dabed..ed19c106 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -14,6 +14,8 @@ UpdateMessageRequest, ScheduledMessage, StopScheduledMessageResponse, + CleanMessagesRequest, + CleanMessagesResponse, ) from nylas.models.response import Response, ListResponse, DeleteResponse from nylas.resources.smart_compose import SmartCompose @@ -223,3 +225,24 @@ def stop_scheduled_message( ) return Response.from_dict(json_response, StopScheduledMessageResponse) + + def clean_messages( + self, identifier: str, request_body: CleanMessagesRequest + ) -> ListResponse[CleanMessagesResponse]: + """ + Remove extra information from a list of messages. + + Args: + identifier: The identifier of the grant to clean the message for. + request_body: The values to clean the message with. + + Returns: + The list of cleaned messages. + """ + json_resposne = self._http_client._execute( + method="PUT", + path=f"/v3/grants/{identifier}/messages/clean", + request_body=request_body, + ) + + return ListResponse.from_dict(json_resposne, CleanMessagesResponse) diff --git a/tests/conftest.py b/tests/conftest.py index 41542ea7..6fba23db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -189,3 +189,32 @@ def http_client_list_scheduled_messages(): ], } return mock_http_client + + +@pytest.fixture +def http_client_clean_messages(): + mock_http_client = Mock() + mock_http_client._execute.return_value = { + "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5", + "data": [ + { + "body": "Hello, I just sent a message using Nylas!", + "from": [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ], + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "message-1", + "object": "message", + "conversation": "cleaned example", + }, + { + "body": "Hello, this is a test message!", + "from": [{"name": "Michael Scott", "email": "m.scott@email.com"}], + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "message-2", + "object": "message", + "conversation": "another example", + }, + ], + } + return mock_http_client diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index d345a444..9a1be5bb 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -272,3 +272,37 @@ def test_stop_scheduled_message(self, http_client_response): method="DELETE", path="/v3/grants/abc-123/messages/schedules/schedule-123", ) + + def test_clean_messages(self, http_client_clean_messages): + messages = Messages(http_client_clean_messages) + request_body = { + "message_id": ["message-1", "message-2"], + "ignore_images": True, + "ignore_links": True, + "ignore_tables": True, + "images_as_markdown": True, + "remove_conclusion_phrases": True, + } + + response = messages.clean_messages( + identifier="abc-123", + request_body=request_body, + ) + + http_client_clean_messages._execute.assert_called_once_with( + method="PUT", + path="/v3/grants/abc-123/messages/clean", + request_body=request_body, + ) + + # Assert the conversation field, and the typical message fields serialize properly + assert len(response.data) == 2 + assert response.data[0].body == "Hello, I just sent a message using Nylas!" + assert response.data[0].from_ == [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ] + assert response.data[0].object == "message" + assert response.data[0].id == "message-1" + assert response.data[0].grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert response.data[0].conversation == "cleaned example" + assert response.data[1].conversation == "another example" From 873363fd9c58613ad06f798fcd1b68525708bd29 Mon Sep 17 00:00:00 2001 From: Blag Date: Fri, 3 May 2024 12:26:23 -0400 Subject: [PATCH 089/186] Python SDK - Add missing Webhook triggers (#357) Added message_bouce_detected, message_created, message_updated, message_opened, message_link_clicked, thread.replied --- nylas/models/webhooks.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nylas/models/webhooks.py b/nylas/models/webhooks.py index dc49a284..c4239f3a 100644 --- a/nylas/models/webhooks.py +++ b/nylas/models/webhooks.py @@ -24,6 +24,12 @@ class WebhookTriggers(str, Enum): GRANT_EXPIRED = "grant.expired" MESSAGE_SEND_SUCCESS = "message.send_success" MESSAGE_SEND_FAILED = "message.send_failed" + MESSAGE_BOUNCE_DETECTED = "message.bounce_detected" + MESSAGE_CREATED = "message.created" + MESSAGE_UPDATED = "message.updated" + MESSAGE_OPENED = "message.opened" + MESSAGE_LINK_CLICKED = "message.link_clicked" + THREAD_REPLIED = "thread.replied" @dataclass_json From 763e911e5bdd232172277f5d9a805ee6543cf317 Mon Sep 17 00:00:00 2001 From: Blag Date: Fri, 3 May 2024 12:28:17 -0400 Subject: [PATCH 090/186] Update events.py (#358) Make visibility optional to include iCloud events --- nylas/models/events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nylas/models/events.py b/nylas/models/events.py index 3228a9ae..9ae3ce6c 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -316,12 +316,12 @@ class Event: calendar_id: str busy: bool participants: List[Participant] - visibility: Visibility when: When = field(metadata=config(decoder=_decode_when)) conferencing: Optional[Conferencing] = field( default=None, metadata=config(decoder=_decode_conferencing) ) object: str = "event" + visibility: Optional[Visibility] = None read_only: Optional[bool] = None description: Optional[str] = None location: Optional[str] = None From 39710f873a0510f9931486fc703f69c137f87db3 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 3 May 2024 12:38:24 -0400 Subject: [PATCH 091/186] Add new fields; provider for code exchange and custom_headers for drafts/messages (#360) This PR adds two new fields: - `provider` to `CodeExchangeResponse` - `custom_headers` to `CreateDraftRequest`, `UpdateDraftRequest`, and `SendMessageRequest` --- CHANGELOG.md | 6 +++++- nylas/models/auth.py | 2 ++ nylas/models/drafts.py | 16 ++++++++++++++++ tests/conftest.py | 1 + tests/resources/test_auth.py | 1 + 5 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30638355..91d2926a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,11 @@ nylas-python Changelog Unreleased ---------------- -* Add clean messages support +* Added support for adding custom headers to outgoing requests +* Added support for `provider` field in code exchange response +* Added clean messages support +* Added additional webhook triggers +* Made event visibility optional to support iCloud events v6.1.1 ---------------- diff --git a/nylas/models/auth.py b/nylas/models/auth.py index bb6274c7..3b57ac36 100644 --- a/nylas/models/auth.py +++ b/nylas/models/auth.py @@ -114,6 +114,7 @@ class CodeExchangeResponse: refresh_token: Returned only if the code is requested using "access_type=offline". id_token: A JWT that contains identity information about the user. Digitally signed by Nylas. token_type: Always "Bearer". + provider: The provider that the code was exchanged with. """ access_token: str @@ -124,6 +125,7 @@ class CodeExchangeResponse: refresh_token: Optional[str] = None id_token: Optional[str] = None token_type: Optional[str] = None + provider: Optional[Provider] = None @dataclass_json diff --git a/nylas/models/drafts.py b/nylas/models/drafts.py index 18900fa5..3f77aaed 100644 --- a/nylas/models/drafts.py +++ b/nylas/models/drafts.py @@ -57,6 +57,19 @@ class TrackingOptions(TypedDict): thread_replies: NotRequired[bool] +class CustomHeader(TypedDict): + """ + A key-value pair representing a header that can be added to drafts and outgoing messages. + + Attributes: + name: The name of the custom header. + value: The value of the custom header. + """ + + name: str + value: str + + class CreateDraftRequest(TypedDict): """ A request to create a draft. @@ -73,6 +86,7 @@ class CreateDraftRequest(TypedDict): send_at: Unix timestamp to send the message at. reply_to_message_id: The ID of the message that you are replying to. tracking_options: Options for tracking opens, links, and thread replies. + custom_headers: Custom headers to add to the message. """ body: NotRequired[str] @@ -86,6 +100,7 @@ class CreateDraftRequest(TypedDict): send_at: NotRequired[int] reply_to_message_id: NotRequired[str] tracking_options: NotRequired[TrackingOptions] + custom_headers: NotRequired[List[CustomHeader]] UpdateDraftRequest = CreateDraftRequest @@ -148,6 +163,7 @@ class SendMessageRequest(CreateDraftRequest): send_at (NotRequired[int]): Unix timestamp to send the message at. reply_to_message_id (NotRequired[str]): The ID of the message that you are replying to. tracking_options (NotRequired[TrackingOptions]): Options for tracking opens, links, and thread replies. + custom_headers(NotRequired[List[CustomHeader]]): Custom headers to add to the message. use_draft: Whether or not to use draft support. This is primarily used when dealing with large attachments. """ diff --git a/tests/conftest.py b/tests/conftest.py index 6fba23db..390dc54f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -113,6 +113,7 @@ def http_client_token_exchange(): "scope": "https://www.googleapis.com/auth/gmail.readonly profile", "token_type": "Bearer", "grant_id": "grant_123", + "provider": "google", } return mock_http_client diff --git a/tests/resources/test_auth.py b/tests/resources/test_auth.py index d9f0eae8..07796158 100644 --- a/tests/resources/test_auth.py +++ b/tests/resources/test_auth.py @@ -99,6 +99,7 @@ def test_get_token(self, http_client_token_exchange): assert res.scope == "https://www.googleapis.com/auth/gmail.readonly profile" assert res.token_type == "Bearer" assert res.grant_id == "grant_123" + assert res.provider == "google" def test_get_token_info(self, http_client_token_info): auth = Auth(http_client_token_info) From a4f3bad110137d4aff9e1bef32eb59ef55dd0646 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Mon, 6 May 2024 09:00:14 -0400 Subject: [PATCH 092/186] Fixed issue where attachments < 3mb were not being encoded correctly (#362) Fixed issue where attachments < 3mb were not being encoded correctly --- CHANGELOG.md | 1 + nylas/resources/drafts.py | 17 ++++++++++++++++- nylas/resources/messages.py | 14 +++++++++++++- nylas/utils/file_utils.py | 17 +++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91d2926a..299747eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Unreleased * Added clean messages support * Added additional webhook triggers * Made event visibility optional to support iCloud events +* Fixed issue where attachments < 3mb were not being encoded correctly v6.1.1 ---------------- diff --git a/nylas/resources/drafts.py b/nylas/resources/drafts.py index c9ec54b2..01c423e4 100644 --- a/nylas/resources/drafts.py +++ b/nylas/resources/drafts.py @@ -1,3 +1,4 @@ +import io from typing import Optional from nylas.handler.api_resources import ( @@ -15,7 +16,11 @@ ) from nylas.models.messages import Message from nylas.models.response import ListResponse, Response, DeleteResponse -from nylas.utils.file_utils import _build_form_request, MAXIMUM_JSON_ATTACHMENT_SIZE +from nylas.utils.file_utils import ( + _build_form_request, + MAXIMUM_JSON_ATTACHMENT_SIZE, + encode_stream_to_base64, +) class Drafts( @@ -99,6 +104,11 @@ def create( return Response.from_dict(json_response, Draft) + # Encode the content of the attachments to base64 + for attachment in request_body.get("attachments", []): + if issubclass(type(attachment["content"]), io.IOBase): + attachment["content"] = encode_stream_to_base64(attachment["content"]) + return super().create( path=path, response_type=Draft, @@ -138,6 +148,11 @@ def update( return Response.from_dict(json_response, Draft) + # Encode the content of the attachments to base64 + for attachment in request_body.get("attachments", []): + if issubclass(type(attachment["content"]), io.IOBase): + attachment["content"] = encode_stream_to_base64(attachment["content"]) + return super().update( path=path, response_type=Draft, diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index ed19c106..d08dfc2b 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -1,3 +1,4 @@ +import io from typing import Optional, List from nylas.handler.api_resources import ( @@ -19,7 +20,11 @@ ) from nylas.models.response import Response, ListResponse, DeleteResponse from nylas.resources.smart_compose import SmartCompose -from nylas.utils.file_utils import _build_form_request, MAXIMUM_JSON_ATTACHMENT_SIZE +from nylas.utils.file_utils import ( + _build_form_request, + MAXIMUM_JSON_ATTACHMENT_SIZE, + encode_stream_to_base64, +) class Messages( @@ -151,6 +156,13 @@ def send( if attachment_size >= MAXIMUM_JSON_ATTACHMENT_SIZE: form_data = _build_form_request(request_body) else: + # Encode the content of the attachments to base64 + for attachment in request_body.get("attachments", []): + if issubclass(type(attachment["content"]), io.IOBase): + attachment["content"] = encode_stream_to_base64( + attachment["content"] + ) + json_body = request_body json_response = self._http_client._execute( diff --git a/nylas/utils/file_utils.py b/nylas/utils/file_utils.py index 09faf2e6..69dd7dc3 100644 --- a/nylas/utils/file_utils.py +++ b/nylas/utils/file_utils.py @@ -1,7 +1,9 @@ +import base64 import json import mimetypes import os from pathlib import Path +from typing import BinaryIO from requests_toolbelt import MultipartEncoder @@ -36,6 +38,21 @@ def attach_file_request_builder(file_path) -> CreateAttachmentRequest: } +def encode_stream_to_base64(binary_stream: BinaryIO) -> str: + """ + Encode the content of a binary stream to a base64 string. + + Attributes: + binary_stream: The binary stream to encode. + + Returns: + The base64 encoded content of the binary stream. + """ + binary_stream.seek(0) + binary_content = binary_stream.read() + return base64.b64encode(binary_content).decode("utf-8") + + def _build_form_request(request_body: dict) -> MultipartEncoder: """ Build a form-data request. From 2656703470e74e95cf84237576e6af64d24bd1d9 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Wed, 15 May 2024 12:29:46 -0400 Subject: [PATCH 093/186] Added support for overriding various fields of outgoing requests (#363) This PR adds support for adding a new `RequestOverride` object which can be built with fields that can override: * API URI * API Key * Timeout * and, adding additional headers for outgoing requests. --- CHANGELOG.md | 1 + nylas/config.py | 20 ++++++ nylas/handler/api_resources.py | 36 +++++++--- nylas/handler/http_client.py | 50 ++++++++++---- nylas/resources/applications.py | 8 ++- nylas/resources/attachments.py | 10 +++ nylas/resources/auth.py | 55 +++++++++++---- nylas/resources/calendars.py | 49 +++++++++++--- nylas/resources/connectors.py | 37 ++++++++-- nylas/resources/contacts.py | 45 ++++++++++-- nylas/resources/credentials.py | 39 +++++++++-- nylas/resources/drafts.py | 41 +++++++++-- nylas/resources/events.py | 33 ++++++++- nylas/resources/folders.py | 45 ++++++++++-- nylas/resources/grants.py | 36 ++++++++-- nylas/resources/messages.py | 47 ++++++++++--- nylas/resources/redirect_uris.py | 39 +++++++++-- nylas/resources/smart_compose.py | 16 ++++- nylas/resources/threads.py | 24 +++++-- nylas/resources/webhooks.py | 56 ++++++++++++--- tests/handler/test_api_resources.py | 10 +++ tests/handler/test_http_client.py | 98 +++++++++++++++++++++++++++ tests/resources/test_applications.py | 2 +- tests/resources/test_attachments.py | 5 ++ tests/resources/test_auth.py | 27 ++++---- tests/resources/test_calendars.py | 21 +++++- tests/resources/test_connectors.py | 22 ++---- tests/resources/test_contacts.py | 21 +++++- tests/resources/test_credentials.py | 12 +++- tests/resources/test_drafts.py | 32 +++++++-- tests/resources/test_events.py | 6 ++ tests/resources/test_folders.py | 10 +-- tests/resources/test_grants.py | 16 ++--- tests/resources/test_messages.py | 22 ++++-- tests/resources/test_redirect_uris.py | 12 +++- tests/resources/test_smart_compose.py | 2 + tests/resources/test_threads.py | 18 ++++- tests/resources/test_webhooks.py | 26 ++----- 38 files changed, 842 insertions(+), 207 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 299747eb..8e23b198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Added support for adding custom headers to outgoing requests +* Added support for overriding various fields of outgoing requests * Added support for `provider` field in code exchange response * Added clean messages support * Added additional webhook triggers diff --git a/nylas/config.py b/nylas/config.py index 4a888623..ee625e02 100644 --- a/nylas/config.py +++ b/nylas/config.py @@ -1,4 +1,7 @@ from enum import Enum +from typing import TypedDict + +from typing_extensions import NotRequired class Region(str, Enum): @@ -10,6 +13,23 @@ class Region(str, Enum): EU = "eu" +class RequestOverrides(TypedDict): + """ + Overrides to use for an outgoing request to the Nylas API + + Attributes: + api_key: The API key to use for the request. + api_uri: The API URI to use for the request. + timeout: The timeout to use for the request. + headers: Additional headers to include in the request. + """ + + api_key: NotRequired[str] + api_uri: NotRequired[str] + timeout: NotRequired[int] + headers: NotRequired[dict] + + DEFAULT_REGION = Region.US """ The default Nylas API region. """ diff --git a/nylas/handler/api_resources.py b/nylas/handler/api_resources.py index 469b4667..ca38bce2 100644 --- a/nylas/handler/api_resources.py +++ b/nylas/handler/api_resources.py @@ -8,10 +8,16 @@ class ListableApiResource(Resource): def list( - self, path, response_type, headers=None, query_params=None, request_body=None + self, + path, + response_type, + headers=None, + query_params=None, + request_body=None, + overrides=None, ) -> ListResponse: response_json = self._http_client._execute( - "GET", path, headers, query_params, request_body + "GET", path, headers, query_params, request_body, overrides=overrides ) return ListResponse.from_dict(response_json, response_type) @@ -19,10 +25,16 @@ def list( class FindableApiResource(Resource): def find( - self, path, response_type, headers=None, query_params=None, request_body=None + self, + path, + response_type, + headers=None, + query_params=None, + request_body=None, + overrides=None, ) -> Response: response_json = self._http_client._execute( - "GET", path, headers, query_params, request_body + "GET", path, headers, query_params, request_body, overrides=overrides ) return Response.from_dict(response_json, response_type) @@ -30,10 +42,16 @@ def find( class CreatableApiResource(Resource): def create( - self, path, response_type, headers=None, query_params=None, request_body=None + self, + path, + response_type, + headers=None, + query_params=None, + request_body=None, + overrides=None, ) -> Response: response_json = self._http_client._execute( - "POST", path, headers, query_params, request_body + "POST", path, headers, query_params, request_body, overrides=overrides ) return Response.from_dict(response_json, response_type) @@ -48,9 +66,10 @@ def update( query_params=None, request_body=None, method="PUT", + overrides=None, ): response_json = self._http_client._execute( - method, path, headers, query_params, request_body + method, path, headers, query_params, request_body, overrides=overrides ) return Response.from_dict(response_json, response_type) @@ -64,11 +83,12 @@ def destroy( headers=None, query_params=None, request_body=None, + overrides=None, ): if response_type is None: response_type = DeleteResponse response_json = self._http_client._execute( - "DELETE", path, headers, query_params, request_body + "DELETE", path, headers, query_params, request_body, overrides=overrides ) return response_type.from_dict(response_json) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index b4c2fcce..2d9ace51 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -80,23 +80,26 @@ def _execute( query_params=None, request_body=None, data=None, + overrides=None, ) -> dict: request = self._build_request( - method, path, headers, query_params, request_body, data + method, path, headers, query_params, request_body, data, overrides ) + + timeout = self.timeout + if overrides and overrides.get("timeout"): + timeout = overrides["timeout"] try: response = self.session.request( request["method"], request["url"], headers=request["headers"], json=request_body, - timeout=self.timeout, + timeout=timeout, data=data, ) except requests.exceptions.Timeout as exc: - raise NylasSdkTimeoutError( - url=request["url"], timeout=self.timeout - ) from exc + raise NylasSdkTimeoutError(url=request["url"], timeout=timeout) from exc return _validate_response(response) @@ -106,14 +109,19 @@ def _execute_download_request( headers=None, query_params=None, stream=False, + overrides=None, ) -> Union[bytes, Response]: - request = self._build_request("GET", path, headers, query_params) + request = self._build_request("GET", path, headers, query_params, overrides) + + timeout = self.timeout + if overrides and overrides.get("timeout"): + timeout = overrides["timeout"] try: response = self.session.request( request["method"], request["url"], headers=request["headers"], - timeout=self.timeout, + timeout=timeout, stream=stream, ) @@ -123,9 +131,7 @@ def _execute_download_request( return response.content if response.content else None except requests.exceptions.Timeout as exc: - raise NylasSdkTimeoutError( - url=request["url"], timeout=self.timeout - ) from exc + raise NylasSdkTimeoutError(url=request["url"], timeout=timeout) from exc def _build_request( self, @@ -135,10 +141,15 @@ def _build_request( query_params: dict = None, request_body=None, data=None, + overrides=None, ) -> dict: - base_url = f"{self.api_server}{path}" + api_server = self.api_server + if overrides and overrides.get("api_uri"): + api_server = overrides["api_uri"] + + base_url = f"{api_server}{path}" url = _build_query_params(base_url, query_params) if query_params else base_url - headers = self._build_headers(headers, request_body, data) + headers = self._build_headers(headers, request_body, data, overrides) return { "method": method, @@ -147,8 +158,12 @@ def _build_request( } def _build_headers( - self, extra_headers: dict = None, response_body=None, data=None + self, extra_headers: dict = None, response_body=None, data=None, overrides=None ) -> dict: + override_headers = {} + if overrides and overrides.get("headers"): + override_headers = overrides["headers"] + if extra_headers is None: extra_headers = {} @@ -156,14 +171,19 @@ def _build_headers( user_agent_header = ( f"Nylas Python SDK {__VERSION__} - {major}.{minor}.{revision}" ) + + api_key = self.api_key + if overrides and overrides.get("api_key"): + api_key = overrides["api_key"] + headers = { "X-Nylas-API-Wrapper": "python", "User-Agent": user_agent_header, - "Authorization": f"Bearer {self.api_key}", + "Authorization": f"Bearer {api_key}", } if data is not None and data.content_type is not None: headers["Content-type"] = data.content_type elif response_body is not None: headers["Content-type"] = "application/json" - return {**headers, **extra_headers} + return {**headers, **extra_headers, **override_headers} diff --git a/nylas/resources/applications.py b/nylas/resources/applications.py index 7f127276..9510cf32 100644 --- a/nylas/resources/applications.py +++ b/nylas/resources/applications.py @@ -1,3 +1,4 @@ +from nylas.config import RequestOverrides from nylas.models.application_details import ApplicationDetails from nylas.models.response import Response from nylas.resources.redirect_uris import RedirectUris @@ -22,15 +23,18 @@ def redirect_uris(self) -> RedirectUris: """ return RedirectUris(self._http_client) - def info(self) -> Response[ApplicationDetails]: + def info(self, overrides: RequestOverrides = None) -> Response[ApplicationDetails]: """ Get the application information. + Args: + overrides: The query parameters to include in the request. + Returns: Response: The application information. """ json_response = self._http_client._execute( - method="GET", path="/v3/applications" + method="GET", path="/v3/applications", overrides=overrides ) return Response.from_dict(json_response, ApplicationDetails) diff --git a/nylas/resources/attachments.py b/nylas/resources/attachments.py index 034307fb..eafb70ce 100644 --- a/nylas/resources/attachments.py +++ b/nylas/resources/attachments.py @@ -1,5 +1,6 @@ from requests import Response +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( FindableApiResource, ) @@ -21,6 +22,7 @@ def find( identifier: str, attachment_id: str, query_params: FindAttachmentQueryParams, + overrides: RequestOverrides = None, ) -> NylasResponse[Attachment]: """ Return metadata of an attachment. @@ -29,6 +31,7 @@ def find( identifier: The identifier of the Grant to act upon. attachment_id: The id of the attachment to retrieve. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The attachment metadata. @@ -37,6 +40,7 @@ def find( path=f"/v3/grants/{identifier}/attachments/{attachment_id}", response_type=Attachment, query_params=query_params, + overrides=overrides, ) def download( @@ -44,6 +48,7 @@ def download( identifier: str, attachment_id: str, query_params: FindAttachmentQueryParams, + overrides: RequestOverrides = None, ) -> Response: """ Download the attachment data. @@ -56,6 +61,7 @@ def download( identifier: The identifier of the Grant to act upon. attachment_id: The id of the attachment to download. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The Response object containing the file data. @@ -78,6 +84,7 @@ def download( path=f"/v3/grants/{identifier}/attachments/{attachment_id}/download", query_params=query_params, stream=True, + overrides=overrides, ) def download_bytes( @@ -85,6 +92,7 @@ def download_bytes( identifier: str, attachment_id: str, query_params: FindAttachmentQueryParams, + overrides: RequestOverrides = None, ) -> bytes: """ Download the attachment as a byte array. @@ -93,6 +101,7 @@ def download_bytes( identifier: The identifier of the Grant to act upon. attachment_id: The id of the attachment to download. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The raw file data. @@ -101,4 +110,5 @@ def download_bytes( path=f"/v3/grants/{identifier}/attachments/{attachment_id}/download", query_params=query_params, stream=False, + overrides=overrides, ) diff --git a/nylas/resources/auth.py b/nylas/resources/auth.py index 883a6ba5..cccc35f1 100644 --- a/nylas/resources/auth.py +++ b/nylas/resources/auth.py @@ -2,6 +2,7 @@ import hashlib import uuid +from nylas.config import RequestOverrides from nylas.handler.http_client import _build_query_params from nylas.models.grants import CreateGrantRequest, Grant @@ -79,13 +80,14 @@ def url_for_oauth2(self, config: URLForAuthenticationConfig) -> str: return self._url_auth_builder(query) def exchange_code_for_token( - self, request: CodeExchangeRequest + self, request: CodeExchangeRequest, overrides: RequestOverrides = None ) -> CodeExchangeResponse: """ Exchange an authorization code for an access token. Args: request: The request parameters for the code exchange + overrides: The request overrides to use for the request. Returns: Information about the Nylas application @@ -96,16 +98,17 @@ def exchange_code_for_token( request_body = dict(request) request_body["grant_type"] = "authorization_code" - return self._get_token(request_body) + return self._get_token(request_body, overrides) def custom_authentication( - self, request_body: CreateGrantRequest + self, request_body: CreateGrantRequest, overrides: RequestOverrides = None ) -> Response[Grant]: """ Create a Grant via Custom Authentication. Args: request_body: The values to create the Grant with. + overrides: The request overrides to use for the request. Returns: The created Grant. @@ -115,17 +118,19 @@ def custom_authentication( method="POST", path="/v3/connect/custom", request_body=request_body, + overrides=overrides, ) return Response.from_dict(json_response, Grant) def refresh_access_token( - self, request: TokenExchangeRequest + self, request: TokenExchangeRequest, overrides: RequestOverrides = None ) -> CodeExchangeResponse: """ Refresh an access token. Args: request: The refresh token request. + overrides: The request overrides to use for the request. Returns: The response containing the new access token. @@ -136,14 +141,17 @@ def refresh_access_token( request_body = dict(request) request_body["grant_type"] = "refresh_token" - return self._get_token(request_body) + return self._get_token(request_body, overrides) - def id_token_info(self, id_token: str) -> Response[TokenInfoResponse]: + def id_token_info( + self, id_token: str, overrides: RequestOverrides = None + ) -> Response[TokenInfoResponse]: """ Get info about an ID token. Args: id_token: The ID token to query. + overrides: The request overrides to use for the request. Returns: The API response with the token information. @@ -153,14 +161,17 @@ def id_token_info(self, id_token: str) -> Response[TokenInfoResponse]: "id_token": id_token, } - return self._get_token_info(query_params) + return self._get_token_info(query_params, overrides) - def validate_access_token(self, access_token: str) -> Response[TokenInfoResponse]: + def validate_access_token( + self, access_token: str, overrides: RequestOverrides = None + ) -> Response[TokenInfoResponse]: """ Get info about an access token. Args: access_token: The access token to query. + overrides: The request overrides to use for the request. Returns: The API response with the token information. @@ -170,7 +181,7 @@ def validate_access_token(self, access_token: str) -> Response[TokenInfoResponse "access_token": access_token, } - return self._get_token_info(query_params) + return self._get_token_info(query_params, overrides) def url_for_oauth2_pkce(self, config: URLForAuthenticationConfig) -> PkceAuthUrl: """ @@ -204,11 +215,12 @@ def url_for_admin_consent(self, config: URLForAdminConsentConfig) -> str: return self._url_auth_builder(query) - def revoke(self, token: str) -> True: + def revoke(self, token: str, overrides: RequestOverrides = None) -> True: """Revoke a single access token. Args: token: The access token to revoke. + overrides: The request overrides to use for the request. Returns: True: If the token was revoked successfully. @@ -217,18 +229,20 @@ def revoke(self, token: str) -> True: method="POST", path="/v3/connect/revoke", query_params={"token": token}, + overrides=overrides, ) return True def detect_provider( - self, params: ProviderDetectParams + self, params: ProviderDetectParams, overrides: RequestOverrides = None ) -> Response[ProviderDetectResponse]: """ Detect provider from email address. Args: params: The parameters to include in the request + overrides: The request overrides to use for the request. Returns: The detected provider, if found. @@ -238,6 +252,7 @@ def detect_provider( method="POST", path="/v3/providers/detect", query_params=params, + overrides=overrides, ) return Response.from_dict(json_response, ProviderDetectResponse) @@ -245,14 +260,24 @@ def _url_auth_builder(self, query: dict) -> str: base = f"{self._http_client.api_server}/v3/connect/auth" return _build_query_params(base, query) - def _get_token(self, request_body: dict) -> CodeExchangeResponse: + def _get_token( + self, request_body: dict, overrides: RequestOverrides + ) -> CodeExchangeResponse: json_response = self._http_client._execute( - method="POST", path="/v3/connect/token", request_body=request_body + method="POST", + path="/v3/connect/token", + request_body=request_body, + overrides=overrides, ) return CodeExchangeResponse.from_dict(json_response) - def _get_token_info(self, query_params: dict) -> Response[TokenInfoResponse]: + def _get_token_info( + self, query_params: dict, overrides: RequestOverrides + ) -> Response[TokenInfoResponse]: json_response = self._http_client._execute( - method="GET", path="/v3/connect/tokeninfo", query_params=query_params + method="GET", + path="/v3/connect/tokeninfo", + query_params=query_params, + overrides=overrides, ) return Response.from_dict(json_response, TokenInfoResponse) diff --git a/nylas/resources/calendars.py b/nylas/resources/calendars.py index f74a3c43..145da03a 100644 --- a/nylas/resources/calendars.py +++ b/nylas/resources/calendars.py @@ -1,5 +1,6 @@ from typing import List +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -40,7 +41,10 @@ class Calendars( """ def list( - self, identifier: str, query_params: ListCalendarsQueryParams = None + self, + identifier: str, + query_params: ListCalendarsQueryParams = None, + overrides: RequestOverrides = None, ) -> ListResponse[Calendar]: """ Return all Calendars. @@ -48,6 +52,7 @@ def list( Args: identifier: The identifier of the Grant to act upon. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The list of Calendars. @@ -57,9 +62,12 @@ def list( path=f"/v3/grants/{identifier}/calendars", query_params=query_params, response_type=Calendar, + overrides=overrides, ) - def find(self, identifier: str, calendar_id: str) -> Response[Calendar]: + def find( + self, identifier: str, calendar_id: str, overrides: RequestOverrides = None + ) -> Response[Calendar]: """ Return a Calendar. @@ -67,6 +75,7 @@ def find(self, identifier: str, calendar_id: str) -> Response[Calendar]: identifier: The identifier of the Grant to act upon. calendar_id: The ID of the Calendar to retrieve. Use "primary" to refer to the primary Calendar associated with the Grant. + overrides: The request overrides to use for the request. Returns: The Calendar. @@ -74,10 +83,14 @@ def find(self, identifier: str, calendar_id: str) -> Response[Calendar]: return super().find( path=f"/v3/grants/{identifier}/calendars/{calendar_id}", response_type=Calendar, + overrides=overrides, ) def create( - self, identifier: str, request_body: CreateCalendarRequest + self, + identifier: str, + request_body: CreateCalendarRequest, + overrides: RequestOverrides = None, ) -> Response[Calendar]: """ Create a Calendar. @@ -85,6 +98,7 @@ def create( Args: identifier: The identifier of the Grant to act upon. request_body: The values to create the Calendar with. + overrides: The request overrides to use for the request. Returns: The created Calendar. @@ -93,10 +107,15 @@ def create( path=f"/v3/grants/{identifier}/calendars", response_type=Calendar, request_body=request_body, + overrides=overrides, ) def update( - self, identifier: str, calendar_id: str, request_body: UpdateCalendarRequest + self, + identifier: str, + calendar_id: str, + request_body: UpdateCalendarRequest, + overrides: RequestOverrides = None, ) -> Response[Calendar]: """ Update a Calendar. @@ -106,6 +125,7 @@ def update( calendar_id: The ID of the Calendar to update. Use "primary" to refer to the primary Calendar associated with the Grant. request_body: The values to update the Calendar with. + overrides: The request overrides to use for the request. Returns: The updated Calendar. @@ -114,9 +134,12 @@ def update( path=f"/v3/grants/{identifier}/calendars/{calendar_id}", response_type=Calendar, request_body=request_body, + overrides=overrides, ) - def destroy(self, identifier: str, calendar_id: str) -> DeleteResponse: + def destroy( + self, identifier: str, calendar_id: str, overrides: RequestOverrides = None + ) -> DeleteResponse: """ Delete a Calendar. @@ -124,20 +147,24 @@ def destroy(self, identifier: str, calendar_id: str) -> DeleteResponse: identifier: The identifier of the Grant to act upon. calendar_id: The ID of the Calendar to delete. Use "primary" to refer to the primary Calendar associated with the Grant. + overrides: The request overrides to use for the request. Returns: The deletion response. """ - return super().destroy(path=f"/v3/grants/{identifier}/calendars/{calendar_id}") + return super().destroy( + path=f"/v3/grants/{identifier}/calendars/{calendar_id}", overrides=overrides + ) def get_availability( - self, request_body: GetAvailabilityRequest + self, request_body: GetAvailabilityRequest, overrides: RequestOverrides = None ) -> Response[GetAvailabilityResponse]: """ Get availability for a Calendar. Args: request_body: The request body to send to the API. + overrides: The request overrides to use for the request. Returns: Response: The availability response from the API. @@ -146,12 +173,16 @@ def get_availability( method="POST", path="/v3/calendars/availability", request_body=request_body, + overrides=overrides, ) return Response.from_dict(json_response, GetAvailabilityResponse) def get_free_busy( - self, identifier: str, request_body: GetFreeBusyRequest + self, + identifier: str, + request_body: GetFreeBusyRequest, + overrides: RequestOverrides = None, ) -> Response[List[GetFreeBusyResponse]]: """ Get free/busy info for a Calendar. @@ -159,6 +190,7 @@ def get_free_busy( Args: identifier: The grant ID or email account to get free/busy for. request_body: The request body to send to the API. + overrides: The request overrides to use for the request. Returns: Response: The free/busy response from the API. @@ -167,6 +199,7 @@ def get_free_busy( method="POST", path=f"/v3/grants/{identifier}/calendars/free-busy", request_body=request_body, + overrides=overrides, ) data = [] diff --git a/nylas/resources/connectors.py b/nylas/resources/connectors.py index 72bacc70..7ce217b4 100644 --- a/nylas/resources/connectors.py +++ b/nylas/resources/connectors.py @@ -1,3 +1,4 @@ +from nylas.config import RequestOverrides from nylas.resources.credentials import Credentials from nylas.handler.api_resources import ( @@ -43,28 +44,37 @@ def credentials(self) -> Credentials: return Credentials(self._http_client) def list( - self, query_params: ListConnectorQueryParams = None + self, + query_params: ListConnectorQueryParams = None, + overrides: RequestOverrides = None, ) -> ListResponse[Connector]: """ Return all Connectors. Args: query_params: The query parameters to include in the request. + overrides: The request overrides to use. Returns: The list of Connectors. """ return super().list( - path="/v3/connectors", response_type=Connector, query_params=query_params + path="/v3/connectors", + response_type=Connector, + query_params=query_params, + overrides=overrides, ) - def find(self, provider: Provider) -> Response[Connector]: + def find( + self, provider: Provider, overrides: RequestOverrides = None + ) -> Response[Connector]: """ Return a connector associated with the provider. Args: provider: The provider associated to the connector to retrieve. + overrides: The request overrides to use. Returns: The Connector. @@ -72,14 +82,18 @@ def find(self, provider: Provider) -> Response[Connector]: return super().find( path=f"/v3/connectors/{provider}", response_type=Connector, + overrides=overrides, ) - def create(self, request_body: CreateConnectorRequest) -> Response[Connector]: + def create( + self, request_body: CreateConnectorRequest, overrides: RequestOverrides = None + ) -> Response[Connector]: """ Create a connector. Args: request_body: The values to create the connector with. + overrides: The request overrides to use. Returns: The created connector. @@ -88,10 +102,14 @@ def create(self, request_body: CreateConnectorRequest) -> Response[Connector]: path="/v3/connectors", request_body=request_body, response_type=Connector, + overrides=overrides, ) def update( - self, provider: Provider, request_body: UpdateConnectorRequest + self, + provider: Provider, + request_body: UpdateConnectorRequest, + overrides: RequestOverrides = None, ) -> Response[Connector]: """ Create a connector. @@ -99,6 +117,7 @@ def update( Args: provider: The provider associated to the connector to update. request_body: The values to update the connector with. + overrides: The request overrides to use. Returns: The created connector. @@ -108,16 +127,20 @@ def update( request_body=request_body, response_type=Connector, method="PATCH", + overrides=overrides, ) - def destroy(self, provider: Provider) -> DeleteResponse: + def destroy( + self, provider: Provider, overrides: RequestOverrides = None + ) -> DeleteResponse: """ Delete a connector. Args: provider: The provider associated to the connector to delete. + overrides: The request overrides to use. Returns: The deleted connector. """ - return super().destroy(path=f"/v3/connectors/{provider}") + return super().destroy(path=f"/v3/connectors/{provider}", overrides=overrides) diff --git a/nylas/resources/contacts.py b/nylas/resources/contacts.py index 6e11411e..66c45557 100644 --- a/nylas/resources/contacts.py +++ b/nylas/resources/contacts.py @@ -1,3 +1,4 @@ +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -31,7 +32,10 @@ class Contacts( """ def list( - self, identifier: str, query_params: ListContactsQueryParams = None + self, + identifier: str, + query_params: ListContactsQueryParams = None, + overrides: RequestOverrides = None, ) -> ListResponse[Contact]: """ Return all Contacts. @@ -39,6 +43,7 @@ def list( Attributes: identifier: The identifier of the Grant to act upon. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The list of contacts. @@ -48,6 +53,7 @@ def list( path=f"/v3/grants/{identifier}/contacts", query_params=query_params, response_type=Contact, + overrides=overrides, ) def find( @@ -55,6 +61,7 @@ def find( identifier: str, contact_id: str, query_params: FindContactQueryParams = None, + overrides: RequestOverrides = None, ) -> Response[Contact]: """ Return a Contact. @@ -63,6 +70,7 @@ def find( identifier: The identifier of the Grant to act upon. contact_id: The ID of the contact to retrieve. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The contact. @@ -71,10 +79,14 @@ def find( path=f"/v3/grants/{identifier}/contacts/{contact_id}", response_type=Contact, query_params=query_params, + overrides=overrides, ) def create( - self, identifier: str, request_body: CreateContactRequest + self, + identifier: str, + request_body: CreateContactRequest, + overrides: RequestOverrides = None, ) -> Response[Contact]: """ Create a Contact. @@ -82,6 +94,7 @@ def create( Attributes: identifier: The identifier of the Grant to act upon. request_body: The values to create the Contact with. + overrides: The request overrides to use for the request. Returns: The created contact. @@ -90,10 +103,15 @@ def create( path=f"/v3/grants/{identifier}/contacts", response_type=Contact, request_body=request_body, + overrides=overrides, ) def update( - self, identifier: str, contact_id: str, request_body: UpdateContactRequest + self, + identifier: str, + contact_id: str, + request_body: UpdateContactRequest, + overrides: RequestOverrides = None, ) -> Response[Contact]: """ Update a Contact. @@ -103,6 +121,7 @@ def update( contact_id: The ID of the Contact to update. Use "primary" to refer to the primary Contact associated with the Grant. request_body: The values to update the Contact with. + overrides: The request overrides to use for the request. Returns: The updated contact. @@ -111,9 +130,15 @@ def update( path=f"/v3/grants/{identifier}/contacts/{contact_id}", response_type=Contact, request_body=request_body, + overrides=overrides, ) - def destroy(self, identifier: str, contact_id: str) -> DeleteResponse: + def destroy( + self, + identifier: str, + contact_id: str, + overrides: RequestOverrides = None, + ) -> DeleteResponse: """ Delete a Contact. @@ -121,14 +146,20 @@ def destroy(self, identifier: str, contact_id: str) -> DeleteResponse: identifier: The identifier of the Grant to act upon. contact_id: The ID of the Contact to delete. Use "primary" to refer to the primary Contact associated with the Grant. + overrides: The request overrides to use for the request. Returns: The deletion response. """ - return super().destroy(path=f"/v3/grants/{identifier}/contacts/{contact_id}") + return super().destroy( + path=f"/v3/grants/{identifier}/contacts/{contact_id}", overrides=overrides + ) def list_groups( - self, identifier: str, query_params: ListContactGroupsQueryParams = None + self, + identifier: str, + query_params: ListContactGroupsQueryParams = None, + overrides: RequestOverrides = None, ) -> ListResponse[ContactGroup]: """ Return all contact groups. @@ -136,6 +167,7 @@ def list_groups( Attributes: identifier: The identifier of the Grant to act upon. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The list of contact groups. @@ -144,6 +176,7 @@ def list_groups( method="GET", path=f"/v3/grants/{identifier}/contacts/groups", query_params=query_params, + overrides=overrides, ) return ListResponse.from_dict(json_response, ContactGroup) diff --git a/nylas/resources/credentials.py b/nylas/resources/credentials.py index 52e6797a..336cb06c 100644 --- a/nylas/resources/credentials.py +++ b/nylas/resources/credentials.py @@ -1,3 +1,4 @@ +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -31,7 +32,10 @@ class Credentials( """ def list( - self, provider: Provider, query_params: ListCredentialQueryParams = None + self, + provider: Provider, + query_params: ListCredentialQueryParams = None, + overrides: RequestOverrides = None, ) -> ListResponse[Credential]: """ Return all credentials for a particular provider. @@ -39,6 +43,7 @@ def list( Args: provider: The provider. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The list of credentials. @@ -48,15 +53,22 @@ def list( path=f"/v3/connectors/{provider}/creds", response_type=Credential, query_params=query_params, + overrides=overrides, ) - def find(self, provider: Provider, credential_id: str) -> Response[Credential]: + def find( + self, + provider: Provider, + credential_id: str, + overrides: RequestOverrides = None, + ) -> Response[Credential]: """ Return a credential. Args: provider: The provider of the credential. credential_id: The ID of the credential to retrieve. + overrides: The request overrides to use for the request. Returns: The Credential. @@ -65,10 +77,14 @@ def find(self, provider: Provider, credential_id: str) -> Response[Credential]: return super().find( path=f"/v3/connectors/{provider}/creds/{credential_id}", response_type=Credential, + overrides=overrides, ) def create( - self, provider: Provider, request_body: CredentialRequest + self, + provider: Provider, + request_body: CredentialRequest, + overrides: RequestOverrides = None, ) -> Response[Credential]: """ Create a credential for a particular provider. @@ -76,6 +92,7 @@ def create( Args: provider: The provider. request_body: The values to create the Credential with. + overrides: The request overrides to use for the request. Returns: The created Credential. @@ -85,6 +102,7 @@ def create( path=f"/v3/connectors/{provider}/creds", response_type=Credential, request_body=request_body, + overrides=overrides, ) def update( @@ -92,6 +110,7 @@ def update( provider: Provider, credential_id: str, request_body: UpdateCredentialRequest, + overrides: RequestOverrides = None, ) -> Response[Credential]: """ Update a credential. @@ -100,6 +119,7 @@ def update( provider: The provider. credential_id: The ID of the credential to update. request_body: The values to update the credential with. + overrides: The request overrides to use for the request. Returns: The updated credential. @@ -110,18 +130,27 @@ def update( response_type=Credential, request_body=request_body, method="PATCH", + overrides=overrides, ) - def destroy(self, provider: Provider, credential_id: str) -> DeleteResponse: + def destroy( + self, + provider: Provider, + credential_id: str, + overrides: RequestOverrides = None, + ) -> DeleteResponse: """ Delete a credential. Args: provider: the provider for the grant credential_id: The ID of the credential to delete. + overrides: The request overrides to use for the request. Returns: The deletion response. """ - return super().destroy(path=f"/v3/connectors/{provider}/creds/{credential_id}") + return super().destroy( + path=f"/v3/connectors/{provider}/creds/{credential_id}", overrides=overrides + ) diff --git a/nylas/resources/drafts.py b/nylas/resources/drafts.py index 01c423e4..1a18546e 100644 --- a/nylas/resources/drafts.py +++ b/nylas/resources/drafts.py @@ -1,6 +1,7 @@ import io from typing import Optional +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -37,7 +38,10 @@ class Drafts( """ def list( - self, identifier: str, query_params: Optional[ListDraftsQueryParams] = None + self, + identifier: str, + query_params: Optional[ListDraftsQueryParams] = None, + overrides: RequestOverrides = None, ) -> ListResponse[Draft]: """ Return all Drafts. @@ -45,6 +49,7 @@ def list( Args: identifier: The identifier of the grant to get drafts for. query_params: The query parameters to filter drafts by. + overrides: The request overrides to use for the request. Returns: A list of Drafts. @@ -53,12 +58,14 @@ def list( path=f"/v3/grants/{identifier}/drafts", response_type=Draft, query_params=query_params, + overrides=overrides, ) def find( self, identifier: str, draft_id: str, + overrides: RequestOverrides = None, ) -> Response[Draft]: """ Return a Draft. @@ -66,6 +73,7 @@ def find( Args: identifier: The identifier of the grant to get the draft for. draft_id: The identifier of the draft to get. + overrides: The request overrides to use for the request. Returns: The requested Draft. @@ -73,10 +81,14 @@ def find( return super().find( path=f"/v3/grants/{identifier}/drafts/{draft_id}", response_type=Draft, + overrides=overrides, ) def create( - self, identifier: str, request_body: CreateDraftRequest + self, + identifier: str, + request_body: CreateDraftRequest, + overrides: RequestOverrides = None, ) -> Response[Draft]: """ Create a Draft. @@ -84,6 +96,7 @@ def create( Args: identifier: The identifier of the grant to send the message for. request_body: The request body to create a draft with. + overrides: The request overrides to use for the request. Returns: The newly created Draft. @@ -100,6 +113,7 @@ def create( method="POST", path=path, data=_build_form_request(request_body), + overrides=overrides, ) return Response.from_dict(json_response, Draft) @@ -113,6 +127,7 @@ def create( path=path, response_type=Draft, request_body=request_body, + overrides=overrides, ) def update( @@ -120,6 +135,7 @@ def update( identifier: str, draft_id: str, request_body: UpdateDraftRequest, + overrides: RequestOverrides = None, ) -> Response[Draft]: """ Update a Draft. @@ -128,6 +144,7 @@ def update( identifier: The identifier of the grant to update the draft for. draft_id: The identifier of the draft to update. request_body: The request body to update the draft with. + overrides: The request overrides to use for the request. Returns: The updated Draft. @@ -144,6 +161,7 @@ def update( method="PUT", path=path, data=_build_form_request(request_body), + overrides=overrides, ) return Response.from_dict(json_response, Draft) @@ -157,34 +175,49 @@ def update( path=path, response_type=Draft, request_body=request_body, + overrides=overrides, ) - def destroy(self, identifier: str, draft_id: str) -> DeleteResponse: + def destroy( + self, + identifier: str, + draft_id: str, + overrides: RequestOverrides = None, + ) -> DeleteResponse: """ Delete a Draft. Args: identifier: The identifier of the grant to delete the draft for. draft_id: The identifier of the draft to delete. + overrides: The request overrides to use for the request. Returns: The deletion response. """ return super().destroy( path=f"/v3/grants/{identifier}/drafts/{draft_id}", + overrides=overrides, ) - def send(self, identifier: str, draft_id: str) -> Response[Message]: + def send( + self, + identifier: str, + draft_id: str, + overrides: RequestOverrides = None, + ) -> Response[Message]: """ Send a Draft. Args: identifier: The identifier of the grant to send the draft for. draft_id: The identifier of the draft to send. + overrides: The request overrides to use for the request. """ json_response = self._http_client._execute( method="POST", path=f"/v3/grants/{identifier}/drafts/{draft_id}", + overrides=overrides, ) return Response.from_dict(json_response, Message) diff --git a/nylas/resources/events.py b/nylas/resources/events.py index fe23650d..416e8529 100644 --- a/nylas/resources/events.py +++ b/nylas/resources/events.py @@ -1,3 +1,4 @@ +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -39,7 +40,10 @@ class Events( """ def list( - self, identifier: str, query_params: ListEventQueryParams + self, + identifier: str, + query_params: ListEventQueryParams, + overrides: RequestOverrides = None, ) -> ListResponse[Event]: """ Return all Events. @@ -47,6 +51,7 @@ def list( Args: identifier: The identifier of the Grant to act upon. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The list of Events. @@ -56,10 +61,15 @@ def list( path=f"/v3/grants/{identifier}/events", response_type=Event, query_params=query_params, + overrides=overrides, ) def find( - self, identifier: str, event_id: str, query_params: FindEventQueryParams + self, + identifier: str, + event_id: str, + query_params: FindEventQueryParams, + overrides: RequestOverrides = None, ) -> Response[Event]: """ Return an Event. @@ -68,6 +78,7 @@ def find( identifier: The identifier of the Grant to act upon. event_id: The ID of the Event to retrieve. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The Event. @@ -77,6 +88,7 @@ def find( path=f"/v3/grants/{identifier}/events/{event_id}", response_type=Event, query_params=query_params, + overrides=overrides, ) def create( @@ -84,6 +96,7 @@ def create( identifier: str, request_body: CreateEventRequest, query_params: CreateEventQueryParams, + overrides: RequestOverrides = None, ) -> Response[Event]: """ Create an Event. @@ -92,6 +105,7 @@ def create( identifier: The identifier of the Grant to act upon. request_body: The values to create the Event with. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The created Event. @@ -102,6 +116,7 @@ def create( response_type=Event, request_body=request_body, query_params=query_params, + overrides=overrides, ) def update( @@ -110,6 +125,7 @@ def update( event_id: str, request_body: UpdateEventRequest, query_params: UpdateEventQueryParams, + overrides: RequestOverrides = None, ) -> Response[Event]: """ Update an Event. @@ -119,6 +135,7 @@ def update( event_id: The ID of the Event to update. request_body: The values to update the Event with. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The updated Event. @@ -129,10 +146,15 @@ def update( response_type=Event, request_body=request_body, query_params=query_params, + overrides=overrides, ) def destroy( - self, identifier: str, event_id: str, query_params: DestroyEventQueryParams + self, + identifier: str, + event_id: str, + query_params: DestroyEventQueryParams, + overrides: RequestOverrides = None, ) -> DeleteResponse: """ Delete an Event. @@ -141,6 +163,7 @@ def destroy( identifier: The identifier of the Grant to act upon. event_id: The ID of the Event to delete. query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. Returns: The deletion response. @@ -149,6 +172,7 @@ def destroy( return super().destroy( path=f"/v3/grants/{identifier}/events/{event_id}", query_params=query_params, + overrides=overrides, ) def send_rsvp( @@ -157,6 +181,7 @@ def send_rsvp( event_id: str, request_body: SendRsvpRequest, query_params: SendRsvpQueryParams, + overrides: RequestOverrides = None, ) -> RequestIdOnlyResponse: """Send RSVP for an event. @@ -165,6 +190,7 @@ def send_rsvp( event_id: The event ID to send RSVP for. query_params: The query parameters to send to the API. request_body: The request body to send to the API. + overrides: The request overrides to use for the request. Returns: Response: The RSVP response from the API. @@ -174,6 +200,7 @@ def send_rsvp( path=f"/v3/grants/{identifier}/events/{event_id}/send-rsvp", query_params=query_params, request_body=request_body, + overrides=overrides, ) return RequestIdOnlyResponse.from_dict(json_response) diff --git a/nylas/resources/folders.py b/nylas/resources/folders.py index 9517a64a..885056ff 100644 --- a/nylas/resources/folders.py +++ b/nylas/resources/folders.py @@ -1,3 +1,4 @@ +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -26,12 +27,17 @@ class Folders( The Nylas folders API allows you to create new folders or manage existing ones. """ - def list(self, identifier: str) -> ListResponse[Folder]: + def list( + self, + identifier: str, + overrides: RequestOverrides = None, + ) -> ListResponse[Folder]: """ Return all Folders. Args: identifier: The identifier of the Grant to act upon. + overrides: The request overrides to use. Returns: The list of Folders. @@ -40,15 +46,22 @@ def list(self, identifier: str) -> ListResponse[Folder]: return super().list( path=f"/v3/grants/{identifier}/folders", response_type=Folder, + overrides=overrides, ) - def find(self, identifier: str, folder_id: str) -> Response[Folder]: + def find( + self, + identifier: str, + folder_id: str, + overrides: RequestOverrides = None, + ) -> Response[Folder]: """ Return a Folder. Args: identifier: The identifier of the Grant to act upon. folder_id: The ID of the Folder to retrieve. + overrides: The request overrides to use. Returns: The Folder. @@ -56,10 +69,14 @@ def find(self, identifier: str, folder_id: str) -> Response[Folder]: return super().find( path=f"/v3/grants/{identifier}/folders/{folder_id}", response_type=Folder, + overrides=overrides, ) def create( - self, identifier: str, request_body: CreateFolderRequest + self, + identifier: str, + request_body: CreateFolderRequest, + overrides: RequestOverrides = None, ) -> Response[Folder]: """ Create a Folder. @@ -67,6 +84,7 @@ def create( Args: identifier: The identifier of the Grant to act upon. request_body: The values to create the Folder with. + overrides: The request overrides to use. Returns: The created Folder. @@ -75,10 +93,15 @@ def create( path=f"/v3/grants/{identifier}/folders", response_type=Folder, request_body=request_body, + overrides=overrides, ) def update( - self, identifier: str, folder_id: str, request_body: UpdateFolderRequest + self, + identifier: str, + folder_id: str, + request_body: UpdateFolderRequest, + overrides: RequestOverrides = None, ) -> Response[Folder]: """ Update a Folder. @@ -87,6 +110,7 @@ def update( identifier: The identifier of the Grant to act upon. folder_id: The ID of the Folder to update. request_body: The values to update the Folder with. + overrides: The request overrides to use. Returns: The updated Folder. @@ -95,17 +119,26 @@ def update( path=f"/v3/grants/{identifier}/folders/{folder_id}", response_type=Folder, request_body=request_body, + overrides=overrides, ) - def destroy(self, identifier: str, folder_id: str) -> DeleteResponse: + def destroy( + self, + identifier: str, + folder_id: str, + overrides: RequestOverrides = None, + ) -> DeleteResponse: """ Delete a Folder. Args: identifier: The identifier of the Grant to act upon. folder_id: The ID of the Folder to delete. + overrides: The request overrides to use. Returns: The deletion response. """ - return super().destroy(path=f"/v3/grants/{identifier}/folders/{folder_id}") + return super().destroy( + path=f"/v3/grants/{identifier}/folders/{folder_id}", overrides=overrides + ) diff --git a/nylas/resources/grants.py b/nylas/resources/grants.py index 6571fce2..5f6e0266 100644 --- a/nylas/resources/grants.py +++ b/nylas/resources/grants.py @@ -1,3 +1,4 @@ +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -27,36 +28,52 @@ class Grants( for a specific service provider """ - def list(self, query_params: ListGrantsQueryParams = None) -> ListResponse[Grant]: + def list( + self, + query_params: ListGrantsQueryParams = None, + overrides: RequestOverrides = None, + ) -> ListResponse[Grant]: """ Return all Grants. Args: query_params: The query parameters to include in the request. + overrides: The request overrides to use. Returns: A list of Grants. """ return super().list( - path="/v3/grants", response_type=Grant, query_params=query_params + path="/v3/grants", + response_type=Grant, + query_params=query_params, + overrides=overrides, ) - def find(self, grant_id: str) -> Response[Grant]: + def find( + self, grant_id: str, overrides: RequestOverrides = None + ) -> Response[Grant]: """ Return a Grant. Args: grant_id: The ID of the Grant to retrieve. + overrides: The request overrides to use. Returns: The Grant. """ - return super().find(path=f"/v3/grants/{grant_id}", response_type=Grant) + return super().find( + path=f"/v3/grants/{grant_id}", response_type=Grant, overrides=overrides + ) def update( - self, grant_id: str, request_body: UpdateGrantRequest + self, + grant_id: str, + request_body: UpdateGrantRequest, + overrides: RequestOverrides = None, ) -> Response[Grant]: """ Update a Grant. @@ -64,6 +81,7 @@ def update( Args: grant_id: The ID of the Grant to update. request_body: The values to update the Grant with. + overrides: The request overrides to use. Returns: The updated Grant. @@ -73,17 +91,21 @@ def update( path=f"/v3/grants/{grant_id}", response_type=Grant, request_body=request_body, + overrides=overrides, ) - def destroy(self, grant_id: str) -> DeleteResponse: + def destroy( + self, grant_id: str, overrides: RequestOverrides = None + ) -> DeleteResponse: """ Delete a Grant. Args: grant_id: The ID of the Grant to delete. + overrides: The request overrides to use. Returns: The deletion response. """ - return super().destroy(path=f"/v3/grants/{grant_id}") + return super().destroy(path=f"/v3/grants/{grant_id}", overrides=overrides) diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index d08dfc2b..6e518598 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -1,6 +1,7 @@ import io from typing import Optional, List +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -52,7 +53,10 @@ def smart_compose(self) -> SmartCompose: return SmartCompose(self._http_client) def list( - self, identifier: str, query_params: Optional[ListMessagesQueryParams] = None + self, + identifier: str, + query_params: Optional[ListMessagesQueryParams] = None, + overrides: RequestOverrides = None, ) -> ListResponse[Message]: """ Return all Messages. @@ -60,6 +64,7 @@ def list( Args: identifier: The identifier of the grant to get messages for. query_params: The query parameters to filter messages by. + overrides: The request overrides to apply to the request. Returns: A list of Messages. @@ -68,6 +73,7 @@ def list( path=f"/v3/grants/{identifier}/messages", response_type=Message, query_params=query_params, + overrides=overrides, ) def find( @@ -75,6 +81,7 @@ def find( identifier: str, message_id: str, query_params: Optional[FindMessageQueryParams] = None, + overrides: RequestOverrides = None, ) -> Response[Message]: """ Return a Message. @@ -83,6 +90,7 @@ def find( identifier: The identifier of the grant to get the message for. message_id: The identifier of the message to get. query_params: The query parameters to include in the request. + overrides: The request overrides to apply to the request. Returns: The requested Message. @@ -91,6 +99,7 @@ def find( path=f"/v3/grants/{identifier}/messages/{message_id}", response_type=Message, query_params=query_params, + overrides=overrides, ) def update( @@ -98,6 +107,7 @@ def update( identifier: str, message_id: str, request_body: UpdateMessageRequest, + overrides: RequestOverrides = None, ) -> Response[Message]: """ Update a Message. @@ -106,6 +116,7 @@ def update( identifier: The identifier of the grant to update the message for. message_id: The identifier of the message to update. request_body: The request body to update the message with. + overrides: The request overrides to apply to the request. Returns: The updated Message. @@ -114,25 +125,32 @@ def update( path=f"/v3/grants/{identifier}/messages/{message_id}", response_type=Message, request_body=request_body, + overrides=overrides, ) - def destroy(self, identifier: str, message_id: str) -> DeleteResponse: + def destroy( + self, identifier: str, message_id: str, overrides: RequestOverrides = None + ) -> DeleteResponse: """ Delete a Message. Args: identifier: The identifier of the grant to delete the message for. message_id: The identifier of the message to delete. + overrides: The request overrides to apply to the request. Returns: The deletion response. """ return super().destroy( - path=f"/v3/grants/{identifier}/messages/{message_id}", + path=f"/v3/grants/{identifier}/messages/{message_id}", overrides=overrides ) def send( - self, identifier: str, request_body: SendMessageRequest + self, + identifier: str, + request_body: SendMessageRequest, + overrides: RequestOverrides = None, ) -> Response[Message]: """ Send a Message. @@ -140,6 +158,7 @@ def send( Args: identifier: The identifier of the grant to send the message for. request_body: The request body to send the message with. + overrides: The request overrides to apply to the request. Returns: The sent message. @@ -170,18 +189,20 @@ def send( path=path, request_body=json_body, data=form_data, + overrides=overrides, ) return Response.from_dict(json_response, Message) def list_scheduled_messages( - self, identifier: str + self, identifier: str, overrides: RequestOverrides = None ) -> Response[List[ScheduledMessage]]: """ Retrieve your scheduled messages. Args: identifier: The identifier of the grant to delete the message for. + overrides: The request overrides to apply to the request. Returns: Response: The list of scheduled messages. @@ -189,6 +210,7 @@ def list_scheduled_messages( json_response = self._http_client._execute( method="GET", path=f"/v3/grants/{identifier}/messages/schedules", + overrides=overrides, ) data = [] @@ -199,7 +221,7 @@ def list_scheduled_messages( return Response(data, request_id) def find_scheduled_message( - self, identifier: str, schedule_id: str + self, identifier: str, schedule_id: str, overrides: RequestOverrides = None ) -> Response[ScheduledMessage]: """ Retrieve your scheduled messages. @@ -207,6 +229,7 @@ def find_scheduled_message( Args: identifier: The identifier of the grant to delete the message for. schedule_id: The id of the scheduled message to retrieve. + overrides: The request overrides to apply to the request. Returns: Response: The scheduled message. @@ -214,12 +237,13 @@ def find_scheduled_message( json_response = self._http_client._execute( method="GET", path=f"/v3/grants/{identifier}/messages/schedules/{schedule_id}", + overrides=overrides, ) return Response.from_dict(json_response, ScheduledMessage) def stop_scheduled_message( - self, identifier: str, schedule_id: str + self, identifier: str, schedule_id: str, overrides: RequestOverrides = None ) -> Response[StopScheduledMessageResponse]: """ Stop a scheduled message. @@ -227,6 +251,7 @@ def stop_scheduled_message( Args: identifier: The identifier of the grant to delete the message for. schedule_id: The id of the scheduled message to stop. + overrides: The request overrides to apply to the request. Returns: Response: The confirmation of the stopped scheduled message. @@ -234,12 +259,16 @@ def stop_scheduled_message( json_response = self._http_client._execute( method="DELETE", path=f"/v3/grants/{identifier}/messages/schedules/{schedule_id}", + overrides=overrides, ) return Response.from_dict(json_response, StopScheduledMessageResponse) def clean_messages( - self, identifier: str, request_body: CleanMessagesRequest + self, + identifier: str, + request_body: CleanMessagesRequest, + overrides: RequestOverrides = None, ) -> ListResponse[CleanMessagesResponse]: """ Remove extra information from a list of messages. @@ -247,6 +276,7 @@ def clean_messages( Args: identifier: The identifier of the grant to clean the message for. request_body: The values to clean the message with. + overrides: The request overrides to apply to the request. Returns: The list of cleaned messages. @@ -255,6 +285,7 @@ def clean_messages( method="PUT", path=f"/v3/grants/{identifier}/messages/clean", request_body=request_body, + overrides=overrides, ) return ListResponse.from_dict(json_resposne, CleanMessagesResponse) diff --git a/nylas/resources/redirect_uris.py b/nylas/resources/redirect_uris.py index 82f47117..b2831afc 100644 --- a/nylas/resources/redirect_uris.py +++ b/nylas/resources/redirect_uris.py @@ -1,3 +1,4 @@ +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -26,24 +27,32 @@ class RedirectUris( These endpoints allow you to create, update, and delete Redirect URIs for your Nylas Application. """ - def list(self) -> ListResponse[RedirectUri]: + def list(self, overrides: RequestOverrides = None) -> ListResponse[RedirectUri]: """ Return all Redirect URIs. + Args: + overrides: The request overrides to apply to the request. + Returns: The list of Redirect URIs. """ return super().list( - path="/v3/applications/redirect-uris", response_type=RedirectUri + path="/v3/applications/redirect-uris", + response_type=RedirectUri, + overrides=overrides, ) - def find(self, redirect_uri_id: str) -> Response[RedirectUri]: + def find( + self, redirect_uri_id: str, overrides: RequestOverrides = None + ) -> Response[RedirectUri]: """ Return a Redirect URI. Args: redirect_uri_id: The ID of the Redirect URI to retrieve. + overrides: The request overrides to apply to the request. Returns: The Redirect URI. @@ -52,14 +61,18 @@ def find(self, redirect_uri_id: str) -> Response[RedirectUri]: return super().find( path=f"/v3/applications/redirect-uris/{redirect_uri_id}", response_type=RedirectUri, + overrides=overrides, ) - def create(self, request_body: CreateRedirectUriRequest) -> Response[RedirectUri]: + def create( + self, request_body: CreateRedirectUriRequest, overrides: RequestOverrides = None + ) -> Response[RedirectUri]: """ Create a Redirect URI. Args: request_body: The values to create the Redirect URI with. + overrides: The request overrides to apply to the request. Returns: The created Redirect URI. @@ -69,10 +82,14 @@ def create(self, request_body: CreateRedirectUriRequest) -> Response[RedirectUri path="/v3/applications/redirect-uris", request_body=request_body, response_type=RedirectUri, + overrides=overrides, ) def update( - self, redirect_uri_id: str, request_body: UpdateRedirectUriRequest + self, + redirect_uri_id: str, + request_body: UpdateRedirectUriRequest, + overrides: RequestOverrides = None, ) -> Response[RedirectUri]: """ Update a Redirect URI. @@ -80,6 +97,7 @@ def update( Args: redirect_uri_id: The ID of the Redirect URI to update. request_body: The values to update the Redirect URI with. + overrides: The request overrides to apply to the request. Returns: The updated Redirect URI. @@ -89,17 +107,24 @@ def update( path=f"/v3/applications/redirect-uris/{redirect_uri_id}", request_body=request_body, response_type=RedirectUri, + overrides=overrides, ) - def destroy(self, redirect_uri_id: str) -> DeleteResponse: + def destroy( + self, redirect_uri_id: str, overrides: RequestOverrides = None + ) -> DeleteResponse: """ Delete a Redirect URI. Args: redirect_uri_id: The ID of the Redirect URI to delete. + overrides: The request overrides to apply to the request. Returns: The deletion response. """ - return super().destroy(path=f"/v3/applications/redirect-uris/{redirect_uri_id}") + return super().destroy( + path=f"/v3/applications/redirect-uris/{redirect_uri_id}", + overrides=overrides, + ) diff --git a/nylas/resources/smart_compose.py b/nylas/resources/smart_compose.py index c1f6ba7b..9f54135b 100644 --- a/nylas/resources/smart_compose.py +++ b/nylas/resources/smart_compose.py @@ -1,3 +1,4 @@ +from nylas.config import RequestOverrides from nylas.models.response import Response from nylas.models.smart_compose import ComposeMessageRequest, ComposeMessageResponse @@ -12,7 +13,10 @@ class SmartCompose(Resource): """ def compose_message( - self, identifier: str, request_body: ComposeMessageRequest + self, + identifier: str, + request_body: ComposeMessageRequest, + overrides: RequestOverrides = None, ) -> Response[ComposeMessageResponse]: """ Compose a message. @@ -20,6 +24,7 @@ def compose_message( Args: identifier: The identifier of the grant to generate a message suggestion for. request_body: The prompt that smart compose will use to generate a message suggestion. + overrides: The request overrides to apply to the request. Returns: The generated message. @@ -28,12 +33,17 @@ def compose_message( method="POST", path=f"/v3/grants/{identifier}/messages/smart-compose", request_body=request_body, + overrides=overrides, ) return Response.from_dict(res, ComposeMessageResponse) def compose_message_reply( - self, identifier: str, message_id: str, request_body: ComposeMessageRequest + self, + identifier: str, + message_id: str, + request_body: ComposeMessageRequest, + overrides: RequestOverrides = None, ) -> ComposeMessageResponse: """ Compose a message reply. @@ -42,6 +52,7 @@ def compose_message_reply( identifier: The identifier of the grant to generate a message suggestion for. message_id: The id of the message to reply to. request_body: The prompt that smart compose will use to generate a message reply suggestion. + overrides: The request overrides to apply to the request. Returns: The generated message reply. @@ -50,6 +61,7 @@ def compose_message_reply( method="POST", path=f"/v3/grants/{identifier}/messages/{message_id}/smart-compose", request_body=request_body, + overrides=overrides, ) return Response.from_dict(res, ComposeMessageResponse) diff --git a/nylas/resources/threads.py b/nylas/resources/threads.py index 2ff49adc..712199a1 100644 --- a/nylas/resources/threads.py +++ b/nylas/resources/threads.py @@ -1,3 +1,4 @@ +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -21,7 +22,10 @@ class Threads( """ def list( - self, identifier: str, query_params: ListThreadsQueryParams = None + self, + identifier: str, + query_params: ListThreadsQueryParams = None, + overrides: RequestOverrides = None, ) -> ListResponse[Thread]: """ Return all Threads. @@ -29,6 +33,7 @@ def list( Args: identifier: The identifier of the grant to get threads for. query_params: The query parameters to filter threads by. + overrides: The request overrides to apply to the request. Returns: A list of Threads. @@ -37,15 +42,19 @@ def list( path=f"/v3/grants/{identifier}/threads", response_type=Thread, query_params=query_params, + overrides=overrides, ) - def find(self, identifier: str, thread_id: str) -> Response[Thread]: + def find( + self, identifier: str, thread_id: str, overrides: RequestOverrides = None + ) -> Response[Thread]: """ Return a Thread. Args: identifier: The identifier of the grant to get the thread for. thread_id: The identifier of the thread to get. + overrides: The request overrides to apply to the request. Returns: The requested Thread. @@ -53,6 +62,7 @@ def find(self, identifier: str, thread_id: str) -> Response[Thread]: return super().find( path=f"/v3/grants/{identifier}/threads/{thread_id}", response_type=Thread, + overrides=overrides, ) def update( @@ -60,6 +70,7 @@ def update( identifier: str, thread_id: str, request_body: UpdateThreadRequest, + overrides: RequestOverrides = None, ) -> Response[Thread]: """ Update a Thread. @@ -68,6 +79,7 @@ def update( identifier: The identifier of the grant to update the thread for. thread_id: The identifier of the thread to update. request_body: The request body to update the thread with. + overrides: The request overrides to apply to the request. Returns: The updated Thread. @@ -76,19 +88,23 @@ def update( path=f"/v3/grants/{identifier}/threads/{thread_id}", response_type=Thread, request_body=request_body, + overrides=overrides, ) - def destroy(self, identifier: str, thread_id: str) -> DeleteResponse: + def destroy( + self, identifier: str, thread_id: str, overrides: RequestOverrides = None + ) -> DeleteResponse: """ Delete a Thread. Args: identifier: The identifier of the grant to delete the thread for. thread_id: The identifier of the thread to delete. + overrides: The request overrides to apply to the request. Returns: The deletion response. """ return super().destroy( - path=f"/v3/grants/{identifier}/threads/{thread_id}", + path=f"/v3/grants/{identifier}/threads/{thread_id}", overrides=overrides ) diff --git a/nylas/resources/webhooks.py b/nylas/resources/webhooks.py index 0d1fb621..b649e5ba 100644 --- a/nylas/resources/webhooks.py +++ b/nylas/resources/webhooks.py @@ -1,5 +1,6 @@ import urllib.parse +from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, FindableApiResource, @@ -31,33 +32,46 @@ class Webhooks( The Nylas webhooks API allows you to manage webhook destinations for your Nylas application. """ - def list(self) -> ListResponse[Webhook]: + def list(self, overrides: RequestOverrides = None) -> ListResponse[Webhook]: """ List all webhook destinations + Args: + overrides: The request overrides to apply to the request + Returns: The list of webhook destinations """ return super().list(path="/v3/webhooks", response_type=Webhook) - def find(self, webhook_id: str) -> Response[Webhook]: + def find( + self, webhook_id: str, overrides: RequestOverrides = None + ) -> Response[Webhook]: """ Get a webhook destination Parameters: webhook_id: The ID of the webhook destination to get + overrides: The request overrides to apply to the request Returns: The webhook destination """ - return super().find(path=f"/v3/webhooks/{webhook_id}", response_type=Webhook) + return super().find( + path=f"/v3/webhooks/{webhook_id}", + response_type=Webhook, + overrides=overrides, + ) - def create(self, request_body: CreateWebhookRequest) -> Response[WebhookWithSecret]: + def create( + self, request_body: CreateWebhookRequest, overrides: RequestOverrides = None + ) -> Response[WebhookWithSecret]: """ Create a webhook destination Parameters: request_body: The request body to create the webhook destination + overrides: The request overrides to apply to the request Returns: The created webhook destination @@ -66,10 +80,14 @@ def create(self, request_body: CreateWebhookRequest) -> Response[WebhookWithSecr path="/v3/webhooks", request_body=request_body, response_type=WebhookWithSecret, + overrides=overrides, ) def update( - self, webhook_id: str, request_body: UpdateWebhookRequest + self, + webhook_id: str, + request_body: UpdateWebhookRequest, + overrides: RequestOverrides = None, ) -> Response[Webhook]: """ Update a webhook destination @@ -77,6 +95,7 @@ def update( Parameters: webhook_id: The ID of the webhook destination to update request_body: The request body to update the webhook destination + overrides: The request overrides to apply to the request Returns: The updated webhook destination @@ -85,28 +104,37 @@ def update( path=f"/v3/webhooks/{webhook_id}", request_body=request_body, response_type=Webhook, + overrides=overrides, ) - def destroy(self, webhook_id: str) -> WebhookDeleteResponse: + def destroy( + self, webhook_id: str, overrides: RequestOverrides = None + ) -> WebhookDeleteResponse: """ Delete a webhook destination Parameters: webhook_id: The ID of the webhook destination to delete + overrides: The request overrides to apply to the request Returns: The response from deleting the webhook destination """ return super().destroy( - path=f"/v3/webhooks/{webhook_id}", response_type=WebhookDeleteResponse + path=f"/v3/webhooks/{webhook_id}", + response_type=WebhookDeleteResponse, + overrides=overrides, ) - def rotate_secret(self, webhook_id: str) -> Response[WebhookWithSecret]: + def rotate_secret( + self, webhook_id: str, overrides: RequestOverrides = None + ) -> Response[WebhookWithSecret]: """ Update the webhook secret value for a destination Parameters: webhook_id: The ID of the webhook destination to update + overrides: The request overrides to apply to the request Returns: The updated webhook destination @@ -115,17 +143,25 @@ def rotate_secret(self, webhook_id: str) -> Response[WebhookWithSecret]: method="PUT", path=f"/v3/webhooks/{webhook_id}/rotate-secret", request_body={}, + overrides=overrides, ) return Response.from_dict(res, WebhookWithSecret) - def ip_addresses(self) -> Response[WebhookIpAddressesResponse]: + def ip_addresses( + self, overrides: RequestOverrides = None + ) -> Response[WebhookIpAddressesResponse]: """ Get the current list of IP addresses that Nylas sends webhooks from + Args: + overrides: The request overrides to apply to the request + Returns: The list of IP addresses that Nylas sends webhooks from """ - res = self._http_client._execute(method="GET", path="/v3/webhooks/ip-addresses") + res = self._http_client._execute( + method="GET", path="/v3/webhooks/ip-addresses", overrides=overrides + ) return Response.from_dict(res, WebhookIpAddressesResponse) diff --git a/tests/handler/test_api_resources.py b/tests/handler/test_api_resources.py index eb0a760c..e40aadf6 100644 --- a/tests/handler/test_api_resources.py +++ b/tests/handler/test_api_resources.py @@ -50,6 +50,7 @@ def test_list_resource(self, http_client_list_response): {"test": "header"}, {"query": "param"}, {"foo": "bar"}, + overrides=None, ) def test_find_resource(self, http_client_response): @@ -70,6 +71,7 @@ def test_find_resource(self, http_client_response): {"test": "header"}, {"query": "param"}, {"foo": "bar"}, + overrides=None, ) def test_create_resource(self, http_client_response): @@ -81,6 +83,7 @@ def test_create_resource(self, http_client_response): headers={"test": "header"}, query_params={"query": "param"}, request_body={"foo": "bar"}, + overrides=None, ) assert type(response) is Response @@ -90,6 +93,7 @@ def test_create_resource(self, http_client_response): {"test": "header"}, {"query": "param"}, {"foo": "bar"}, + overrides=None, ) def test_update_resource(self, http_client_response): @@ -101,6 +105,7 @@ def test_update_resource(self, http_client_response): headers={"test": "header"}, query_params={"query": "param"}, request_body={"foo": "bar"}, + overrides=None, ) assert type(response) is Response @@ -110,6 +115,7 @@ def test_update_resource(self, http_client_response): {"test": "header"}, {"query": "param"}, {"foo": "bar"}, + overrides=None, ) def test_destroy_resource(self, http_client_delete_response): @@ -121,6 +127,7 @@ def test_destroy_resource(self, http_client_delete_response): headers={"test": "header"}, query_params={"query": "param"}, request_body={"foo": "bar"}, + overrides=None, ) assert type(response) is RequestIdOnlyResponse @@ -130,6 +137,7 @@ def test_destroy_resource(self, http_client_delete_response): {"test": "header"}, {"query": "param"}, {"foo": "bar"}, + overrides=None, ) def test_destroy_resource_default_type(self, http_client_delete_response): @@ -140,6 +148,7 @@ def test_destroy_resource_default_type(self, http_client_delete_response): headers={"test": "header"}, query_params={"query": "param"}, request_body={"foo": "bar"}, + overrides=None, ) assert type(response) is DeleteResponse @@ -149,4 +158,5 @@ def test_destroy_resource_default_type(self, http_client_delete_response): {"test": "header"}, {"query": "param"}, {"foo": "bar"}, + overrides=None, ) diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index 62a44d8a..dbcf6c13 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -81,6 +81,37 @@ def test_build_headers_form_body(self, http_client, patched_version_and_sys): "Content-type": "application/x-www-form-urlencoded", } + def test_build_headers_override_headers(self, http_client, patched_version_and_sys): + headers = http_client._build_headers( + overrides={ + "headers": { + "foo": "bar", + "X-Test": "test", + } + } + ) + + assert headers == { + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + "foo": "bar", + "X-Test": "test", + } + + def test_build_headers_override_api_key(self, http_client, patched_version_and_sys): + headers = http_client._build_headers( + overrides={ + "api_key": "test-key-override", + } + ) + + assert headers == { + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key-override", + } + def test_build_request_default(self, http_client, patched_version_and_sys): request = http_client._build_request( method="GET", @@ -97,6 +128,25 @@ def test_build_request_default(self, http_client, patched_version_and_sys): }, } + def test_build_request_override_api_uri(self, http_client, patched_version_and_sys): + request = http_client._build_request( + method="GET", + path="/foo", + overrides={ + "api_uri": "https://override.nylas.com", + }, + ) + + assert request == { + "method": "GET", + "url": "https://override.nylas.com/foo", + "headers": { + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + }, + } + def test_build_query_params(self, patched_version_and_sys): url = _build_query_params( base_url="https://test.nylas.com/foo", @@ -138,6 +188,26 @@ def test_execute_download_request_timeout(self, http_client, mock_session_timeou == "Nylas SDK timed out before receiving a response from the server." ) + def test_execute_download_request_override_timeout( + self, http_client, patched_version_and_sys, patched_session_request + ): + response = http_client._execute_download_request( + path="/foo", + overrides={"timeout": 60}, + ) + patched_session_request.assert_called_once_with( + "GET", + "https://test.nylas.com/foo", + headers={ + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + "Content-type": "application/json", + }, + timeout=60, + stream=False, + ) + def test_validate_response(self): response = Mock() response.status_code = 200 @@ -232,6 +302,34 @@ def test_execute( data=None, ) + def test_execute_override_timeout( + self, http_client, patched_version_and_sys, patched_session_request + ): + response = http_client._execute( + method="GET", + path="/foo", + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + overrides={"timeout": 60}, + ) + + assert response == {"foo": "bar"} + patched_session_request.assert_called_once_with( + "GET", + "https://test.nylas.com/foo?query=param", + headers={ + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + "Content-type": "application/json", + "test": "header", + }, + json={"foo": "bar"}, + timeout=60, + data=None, + ) + def test_execute_timeout(self, http_client, mock_session_timeout): with pytest.raises(Exception) as e: http_client._execute( diff --git a/tests/resources/test_applications.py b/tests/resources/test_applications.py index 9b47eca7..43c65971 100644 --- a/tests/resources/test_applications.py +++ b/tests/resources/test_applications.py @@ -57,7 +57,7 @@ def test_info(self): res = app.info() mock_http_client._execute.assert_called_once_with( - method="GET", path="/v3/applications" + method="GET", path="/v3/applications", overrides=None ) assert type(res.data) == ApplicationDetails assert res.data.application_id == "ad410018-d306-43f9-8361-fa5d7b2172e0" diff --git a/tests/resources/test_attachments.py b/tests/resources/test_attachments.py index ab62d2c8..9854172f 100644 --- a/tests/resources/test_attachments.py +++ b/tests/resources/test_attachments.py @@ -42,6 +42,7 @@ def test_find_attachment(self, http_client_response): None, query_params, None, + overrides=None, ) def test_download_attachment(self): @@ -54,12 +55,14 @@ def test_download_attachment(self): identifier="abc-123", attachment_id="attachment-123", query_params=query_params, + overrides=None, ) mock_http_client._execute_download_request.assert_called_once_with( path="/v3/grants/abc-123/attachments/attachment-123/download", query_params=query_params, stream=True, + overrides=None, ) def test_download_bytes(self): @@ -72,10 +75,12 @@ def test_download_bytes(self): identifier="abc-123", attachment_id="attachment-123", query_params=query_params, + overrides=None, ) mock_http_client._execute_download_request.assert_called_once_with( path="/v3/grants/abc-123/attachments/attachment-123/download", query_params=query_params, stream=False, + overrides=None, ) diff --git a/tests/resources/test_auth.py b/tests/resources/test_auth.py index 07796158..8d3ecd38 100644 --- a/tests/resources/test_auth.py +++ b/tests/resources/test_auth.py @@ -84,12 +84,10 @@ def test_get_token(self, http_client_token_exchange): "client_secret": "client_secret", } - res = auth._get_token(req) + res = auth._get_token(req, overrides=None) http_client_token_exchange._execute.assert_called_once_with( - method="POST", - path="/v3/connect/token", - request_body=req, + method="POST", path="/v3/connect/token", request_body=req, overrides=None ) assert type(res) is CodeExchangeResponse assert res.access_token == "nylas_access_token" @@ -107,12 +105,10 @@ def test_get_token_info(self, http_client_token_info): "foo": "bar", } - res = auth._get_token_info(req) + res = auth._get_token_info(req, overrides=None) http_client_token_info._execute.assert_called_once_with( - method="GET", - path="/v3/connect/tokeninfo", - query_params=req, + method="GET", path="/v3/connect/tokeninfo", query_params=req, overrides=None ) assert type(res.data) is TokenInfoResponse assert res.data.iss == "https://nylas.com" @@ -162,6 +158,7 @@ def test_exchange_code_for_token(self, http_client_token_exchange): "redirect_uri": "https://example.com/oauth/callback", "grant_type": "authorization_code", }, + overrides=None, ) def test_exchange_code_for_token_no_secret(self, http_client_token_exchange): @@ -185,6 +182,7 @@ def test_exchange_code_for_token_no_secret(self, http_client_token_exchange): "client_secret": "nylas-api-key", "grant_type": "authorization_code", }, + overrides=None, ) def test_custom_authentication(self): @@ -214,6 +212,7 @@ def test_custom_authentication(self): method="POST", path="/v3/connect/custom", request_body={"provider": "google", "settings": {"foo": "bar"}}, + overrides=None, ) assert type(res.data) is Grant assert res.data.id == "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47" @@ -248,6 +247,7 @@ def test_refresh_access_token(self, http_client_token_exchange): "client_secret": "secret", "grant_type": "refresh_token", }, + overrides=None, ) def test_refresh_access_token_no_secret(self, http_client_token_exchange): @@ -271,6 +271,7 @@ def test_refresh_access_token_no_secret(self, http_client_token_exchange): "client_secret": "nylas-api-key", "grant_type": "refresh_token", }, + overrides=None, ) def test_id_token_info(self, http_client_token_info): @@ -282,6 +283,7 @@ def test_id_token_info(self, http_client_token_info): method="GET", path="/v3/connect/tokeninfo", query_params={"id_token": "id-123"}, + overrides=None, ) def test_validate_access_token(self, http_client_token_info): @@ -293,6 +295,7 @@ def test_validate_access_token(self, http_client_token_info): method="GET", path="/v3/connect/tokeninfo", query_params={"access_token": "id-123"}, + overrides=None, ) @mock.patch("uuid.uuid4") @@ -349,6 +352,7 @@ def test_revoke(self, http_client_response): method="POST", path="/v3/connect/revoke", query_params={"token": "access_token"}, + overrides=None, ) assert res is True @@ -364,14 +368,11 @@ def test_detect_provider(self): }, } auth = Auth(mock_http_client) - req = { - "email": "test@gmail.com", - "all_provider_types": True, - } + req = {"email": "test@gmail.com", "all_provider_types": True} res = auth.detect_provider(req) mock_http_client._execute.assert_called_once_with( - method="POST", path="/v3/providers/detect", query_params=req + method="POST", path="/v3/providers/detect", query_params=req, overrides=None ) assert type(res.data) == ProviderDetectResponse diff --git a/tests/resources/test_calendars.py b/tests/resources/test_calendars.py index c7571850..2f0aae9d 100644 --- a/tests/resources/test_calendars.py +++ b/tests/resources/test_calendars.py @@ -43,7 +43,7 @@ def test_list_calendars(self, http_client_list_response): calendars.list(identifier="abc-123") http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/calendars", None, None, None + "GET", "/v3/grants/abc-123/calendars", None, None, None, overrides=None ) def test_list_calendars_with_query_params(self, http_client_list_response): @@ -52,7 +52,12 @@ def test_list_calendars_with_query_params(self, http_client_list_response): calendars.list(identifier="abc-123", query_params={"limit": 20}) http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/calendars", None, {"limit": 20}, None + "GET", + "/v3/grants/abc-123/calendars", + None, + {"limit": 20}, + None, + overrides=None, ) def test_find_calendar(self, http_client_response): @@ -61,7 +66,12 @@ def test_find_calendar(self, http_client_response): calendars.find(identifier="abc-123", calendar_id="calendar-123") http_client_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/calendars/calendar-123", None, None, None + "GET", + "/v3/grants/abc-123/calendars/calendar-123", + None, + None, + None, + overrides=None, ) def test_create_calendar(self, http_client_response): @@ -82,6 +92,7 @@ def test_create_calendar(self, http_client_response): None, None, request_body, + overrides=None, ) def test_update_calendar(self, http_client_response): @@ -104,6 +115,7 @@ def test_update_calendar(self, http_client_response): None, None, request_body, + overrides=None, ) def test_destroy_calendar(self, http_client_delete_response): @@ -117,6 +129,7 @@ def test_destroy_calendar(self, http_client_delete_response): None, None, None, + overrides=None, ) def test_get_availability(self, http_client_response): @@ -164,6 +177,7 @@ def test_get_availability(self, http_client_response): method="POST", path="/v3/calendars/availability", request_body=request_body, + overrides=None, ) def test_get_free_busy(self, http_client_free_busy): @@ -182,6 +196,7 @@ def test_get_free_busy(self, http_client_free_busy): method="POST", path="/v3/grants/abc-123/calendars/free-busy", request_body=request_body, + overrides=None, ) assert len(response.data) == 2 assert response.request_id == "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5" diff --git a/tests/resources/test_connectors.py b/tests/resources/test_connectors.py index 2aa62338..fc5c202b 100644 --- a/tests/resources/test_connectors.py +++ b/tests/resources/test_connectors.py @@ -33,7 +33,7 @@ def test_list_connectors(self, http_client_list_response): connectors.list() http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/connectors", None, None, None + "GET", "/v3/connectors", None, None, None, overrides=None ) def test_find_connector(self, http_client_response): @@ -42,7 +42,7 @@ def test_find_connector(self, http_client_response): connectors.find("google") http_client_response._execute.assert_called_once_with( - "GET", "/v3/connectors/google", None, None, None + "GET", "/v3/connectors/google", None, None, None, overrides=None ) def test_create_connector(self, http_client_response): @@ -63,11 +63,7 @@ def test_create_connector(self, http_client_response): connectors.create(request_body=request_body) http_client_response._execute.assert_called_once_with( - "POST", - "/v3/connectors", - None, - None, - request_body, + "POST", "/v3/connectors", None, None, request_body, overrides=None ) def test_update_connector(self, http_client_response): @@ -90,11 +86,7 @@ def test_update_connector(self, http_client_response): ) http_client_response._execute.assert_called_once_with( - "PATCH", - "/v3/connectors/google", - None, - None, - request_body, + "PATCH", "/v3/connectors/google", None, None, request_body, overrides=None ) def test_destroy_connector(self, http_client_delete_response): @@ -103,9 +95,5 @@ def test_destroy_connector(self, http_client_delete_response): connectors.destroy("google") http_client_delete_response._execute.assert_called_once_with( - "DELETE", - "/v3/connectors/google", - None, - None, - None, + "DELETE", "/v3/connectors/google", None, None, None, overrides=None ) diff --git a/tests/resources/test_contacts.py b/tests/resources/test_contacts.py index d107fc30..cc0322eb 100644 --- a/tests/resources/test_contacts.py +++ b/tests/resources/test_contacts.py @@ -95,7 +95,7 @@ def test_list_contacts(self, http_client_list_response): contacts.list(identifier="abc-123") http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/contacts", None, None, None + "GET", "/v3/grants/abc-123/contacts", None, None, None, overrides=None ) def test_list_contacts_with_query_params(self, http_client_list_response): @@ -104,7 +104,12 @@ def test_list_contacts_with_query_params(self, http_client_list_response): contacts.list(identifier="abc-123", query_params={"limit": 20}) http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/contacts", None, {"limit": 20}, None + "GET", + "/v3/grants/abc-123/contacts", + None, + {"limit": 20}, + None, + overrides=None, ) def test_find_contact(self, http_client_response): @@ -113,7 +118,12 @@ def test_find_contact(self, http_client_response): contacts.find(identifier="abc-123", contact_id="contact-123") http_client_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/contacts/contact-123", None, None, None + "GET", + "/v3/grants/abc-123/contacts/contact-123", + None, + None, + None, + overrides=None, ) def test_find_contact_with_query_params(self, http_client_response): @@ -131,6 +141,7 @@ def test_find_contact_with_query_params(self, http_client_response): None, {"profile_picture": True}, None, + overrides=None, ) def test_create_contact(self, http_client_response): @@ -149,6 +160,7 @@ def test_create_contact(self, http_client_response): None, None, request_body, + overrides=None, ) def test_update_contact(self, http_client_response): @@ -169,6 +181,7 @@ def test_update_contact(self, http_client_response): None, None, request_body, + overrides=None, ) def test_destroy_contact(self, http_client_delete_response): @@ -182,6 +195,7 @@ def test_destroy_contact(self, http_client_delete_response): None, None, None, + overrides=None, ) def test_list_groups(self, http_client_list_response): @@ -193,4 +207,5 @@ def test_list_groups(self, http_client_list_response): method="GET", path="/v3/grants/abc-123/contacts/groups", query_params={"limit": 20}, + overrides=None, ) diff --git a/tests/resources/test_credentials.py b/tests/resources/test_credentials.py index c2eb6a94..71b67367 100644 --- a/tests/resources/test_credentials.py +++ b/tests/resources/test_credentials.py @@ -24,7 +24,7 @@ def test_list_credentials(self, http_client_list_response): credentials.list("google") http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/connectors/google/creds", None, None, None + "GET", "/v3/connectors/google/creds", None, None, None, overrides=None ) def test_find_credential(self, http_client_response): @@ -33,7 +33,12 @@ def test_find_credential(self, http_client_response): credentials.find("google", "abc-123") http_client_response._execute.assert_called_once_with( - "GET", "/v3/connectors/google/creds/abc-123", None, None, None + "GET", + "/v3/connectors/google/creds/abc-123", + None, + None, + None, + overrides=None, ) def test_create_credential(self, http_client_response): @@ -56,6 +61,7 @@ def test_create_credential(self, http_client_response): None, None, request_body, + overrides=None, ) def test_update_credential(self, http_client_response): @@ -81,6 +87,7 @@ def test_update_credential(self, http_client_response): None, None, request_body, + overrides=None, ) def test_destroy_credential(self, http_client_delete_response): @@ -94,4 +101,5 @@ def test_destroy_credential(self, http_client_delete_response): None, None, None, + overrides=None, ) diff --git a/tests/resources/test_drafts.py b/tests/resources/test_drafts.py index 8c2fdf59..26e2a591 100644 --- a/tests/resources/test_drafts.py +++ b/tests/resources/test_drafts.py @@ -68,7 +68,7 @@ def test_list_drafts(self, http_client_list_response): drafts.list(identifier="abc-123") http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/drafts", None, None, None + "GET", "/v3/grants/abc-123/drafts", None, None, None, overrides=None ) def test_list_drafts_with_query_params(self, http_client_list_response): @@ -89,6 +89,7 @@ def test_list_drafts_with_query_params(self, http_client_list_response): "subject": "Hello from Nylas!", }, None, + overrides=None, ) def test_find_draft(self, http_client_response): @@ -97,7 +98,12 @@ def test_find_draft(self, http_client_response): drafts.find(identifier="abc-123", draft_id="draft-123") http_client_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/drafts/draft-123", None, None, None + "GET", + "/v3/grants/abc-123/drafts/draft-123", + None, + None, + None, + overrides=None, ) def test_create_draft(self, http_client_response): @@ -112,7 +118,12 @@ def test_create_draft(self, http_client_response): drafts.create(identifier="abc-123", request_body=request_body) http_client_response._execute.assert_called_once_with( - "POST", "/v3/grants/abc-123/drafts", None, None, request_body + "POST", + "/v3/grants/abc-123/drafts", + None, + None, + request_body, + overrides=None, ) def test_create_draft_small_attachment(self, http_client_response): @@ -135,7 +146,12 @@ def test_create_draft_small_attachment(self, http_client_response): drafts.create(identifier="abc-123", request_body=request_body) http_client_response._execute.assert_called_once_with( - "POST", "/v3/grants/abc-123/drafts", None, None, request_body + "POST", + "/v3/grants/abc-123/drafts", + None, + None, + request_body, + overrides=None, ) def test_create_draft_large_attachment(self, http_client_response): @@ -165,6 +181,7 @@ def test_create_draft_large_attachment(self, http_client_response): method="POST", path="/v3/grants/abc-123/drafts", data=mock_encoder, + overrides=None, ) def test_update_draft(self, http_client_response): @@ -186,6 +203,7 @@ def test_update_draft(self, http_client_response): None, None, request_body, + overrides=None, ) def test_update_draft_small_attachment(self, http_client_response): @@ -215,6 +233,7 @@ def test_update_draft_small_attachment(self, http_client_response): None, None, request_body, + overrides=None, ) def test_update_draft_large_attachment(self, http_client_response): @@ -246,6 +265,7 @@ def test_update_draft_large_attachment(self, http_client_response): method="PUT", path="/v3/grants/abc-123/drafts/draft-123", data=mock_encoder, + overrides=None, ) def test_destroy_draft(self, http_client_delete_response): @@ -259,6 +279,7 @@ def test_destroy_draft(self, http_client_delete_response): None, None, None, + overrides=None, ) def test_send_draft(self, http_client_response): @@ -267,6 +288,5 @@ def test_send_draft(self, http_client_response): drafts.send(identifier="abc-123", draft_id="draft-123") http_client_response._execute.assert_called_once_with( - method="POST", - path="/v3/grants/abc-123/drafts/draft-123", + method="POST", path="/v3/grants/abc-123/drafts/draft-123", overrides=None ) diff --git a/tests/resources/test_events.py b/tests/resources/test_events.py index 0f426a74..6f06e324 100644 --- a/tests/resources/test_events.py +++ b/tests/resources/test_events.py @@ -118,6 +118,7 @@ def test_list_events(self, http_client_list_response): "limit": 20, }, None, + overrides=None, ) def test_find_event(self, http_client_response): @@ -135,6 +136,7 @@ def test_find_event(self, http_client_response): None, {"calendar_id": "abc-123"}, None, + overrides=None, ) def test_create_event(self, http_client_response): @@ -163,6 +165,7 @@ def test_create_event(self, http_client_response): None, {"calendar_id": "abc-123"}, request_body, + overrides=None, ) def test_update_event(self, http_client_response): @@ -192,6 +195,7 @@ def test_update_event(self, http_client_response): None, {"calendar_id": "abc-123"}, request_body, + overrides=None, ) def test_destroy_event(self, http_client_delete_response): @@ -209,6 +213,7 @@ def test_destroy_event(self, http_client_delete_response): None, {"calendar_id": "abc-123"}, None, + overrides=None, ) def test_send_rsvp(self, http_client_response): @@ -227,4 +232,5 @@ def test_send_rsvp(self, http_client_response): path="/v3/grants/abc-123/events/event-123/send-rsvp", request_body=request_body, query_params={"calendar_id": "abc-123"}, + overrides=None, ) diff --git a/tests/resources/test_folders.py b/tests/resources/test_folders.py index 5dfed482..c1443c8c 100644 --- a/tests/resources/test_folders.py +++ b/tests/resources/test_folders.py @@ -41,11 +41,7 @@ def test_list_folders(self, http_client_list_response): folders.list(identifier="abc-123") http_client_list_response._execute.assert_called_once_with( - "GET", - "/v3/grants/abc-123/folders", - None, - None, - None, + "GET", "/v3/grants/abc-123/folders", None, None, None, overrides=None ) def test_find_folder(self, http_client_response): @@ -59,6 +55,7 @@ def test_find_folder(self, http_client_response): None, None, None, + overrides=None, ) def test_create_folder(self, http_client_response): @@ -78,6 +75,7 @@ def test_create_folder(self, http_client_response): None, None, request_body, + overrides=None, ) def test_update_folder(self, http_client_response): @@ -101,6 +99,7 @@ def test_update_folder(self, http_client_response): None, None, request_body, + overrides=None, ) def test_destroy_folder(self, http_client_delete_response): @@ -117,4 +116,5 @@ def test_destroy_folder(self, http_client_delete_response): None, None, None, + overrides=None, ) diff --git a/tests/resources/test_grants.py b/tests/resources/test_grants.py index d850c13c..9dfcef94 100644 --- a/tests/resources/test_grants.py +++ b/tests/resources/test_grants.py @@ -36,7 +36,7 @@ def test_list_grants(self, http_client_list_response): grants.list() http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/grants", None, None, None + "GET", "/v3/grants", None, None, None, overrides=None ) def test_find_grant(self, http_client_response): @@ -45,7 +45,7 @@ def test_find_grant(self, http_client_response): grants.find("grant-123") http_client_response._execute.assert_called_once_with( - "GET", "/v3/grants/grant-123", None, None, None + "GET", "/v3/grants/grant-123", None, None, None, overrides=None ) def test_update_grant(self, http_client_response): @@ -67,11 +67,7 @@ def test_update_grant(self, http_client_response): ) http_client_response._execute.assert_called_once_with( - "PUT", - "/v3/grants/grant-123", - None, - None, - request_body, + "PUT", "/v3/grants/grant-123", None, None, request_body, overrides=None ) def test_destroy_grant(self, http_client_delete_response): @@ -80,9 +76,5 @@ def test_destroy_grant(self, http_client_delete_response): grants.destroy("grant-123") http_client_delete_response._execute.assert_called_once_with( - "DELETE", - "/v3/grants/grant-123", - None, - None, - None, + "DELETE", "/v3/grants/grant-123", None, None, None, overrides=None ) diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 9a1be5bb..1a977909 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -71,7 +71,7 @@ def test_list_messages(self, http_client_list_response): messages.list(identifier="abc-123") http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/messages", None, None, None + "GET", "/v3/grants/abc-123/messages", None, None, None, overrides=None ) def test_list_messages_with_query_params(self, http_client_list_response): @@ -92,6 +92,7 @@ def test_list_messages_with_query_params(self, http_client_list_response): "subject": "Hello from Nylas!", }, None, + overrides=None, ) def test_find_message(self, http_client_response): @@ -100,7 +101,12 @@ def test_find_message(self, http_client_response): messages.find(identifier="abc-123", message_id="message-123") http_client_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/messages/message-123", None, None, None + "GET", + "/v3/grants/abc-123/messages/message-123", + None, + None, + None, + overrides=None, ) def test_find_message_with_query_params(self, http_client_response): @@ -118,6 +124,7 @@ def test_find_message_with_query_params(self, http_client_response): None, {"fields": "standard"}, None, + overrides=None, ) def test_update_message(self, http_client_response): @@ -141,6 +148,7 @@ def test_update_message(self, http_client_response): None, None, request_body, + overrides=None, ) def test_destroy_message(self, http_client_delete_response): @@ -154,6 +162,7 @@ def test_destroy_message(self, http_client_delete_response): None, None, None, + overrides=None, ) def test_send_message(self, http_client_response): @@ -172,6 +181,7 @@ def test_send_message(self, http_client_response): path="/v3/grants/abc-123/messages/send", request_body=request_body, data=None, + overrides=None, ) def test_send_message_small_attachment(self, http_client_response): @@ -198,6 +208,7 @@ def test_send_message_small_attachment(self, http_client_response): path="/v3/grants/abc-123/messages/send", request_body=request_body, data=None, + overrides=None, ) def test_send_message_large_attachment(self, http_client_response): @@ -228,6 +239,7 @@ def test_send_message_large_attachment(self, http_client_response): path="/v3/grants/abc-123/messages/send", request_body=None, data=mock_encoder, + overrides=None, ) def test_list_scheduled_messages(self, http_client_list_scheduled_messages): @@ -236,8 +248,7 @@ def test_list_scheduled_messages(self, http_client_list_scheduled_messages): res = messages.list_scheduled_messages(identifier="abc-123") http_client_list_scheduled_messages._execute.assert_called_once_with( - method="GET", - path="/v3/grants/abc-123/messages/schedules", + method="GET", path="/v3/grants/abc-123/messages/schedules", overrides=None ) assert res.request_id == "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5" assert len(res.data) == 2 @@ -259,6 +270,7 @@ def test_find_scheduled_message(self, http_client_response): http_client_response._execute.assert_called_once_with( method="GET", path="/v3/grants/abc-123/messages/schedules/schedule-123", + overrides=None, ) def test_stop_scheduled_message(self, http_client_response): @@ -271,6 +283,7 @@ def test_stop_scheduled_message(self, http_client_response): http_client_response._execute.assert_called_once_with( method="DELETE", path="/v3/grants/abc-123/messages/schedules/schedule-123", + overrides=None, ) def test_clean_messages(self, http_client_clean_messages): @@ -293,6 +306,7 @@ def test_clean_messages(self, http_client_clean_messages): method="PUT", path="/v3/grants/abc-123/messages/clean", request_body=request_body, + overrides=None, ) # Assert the conversation field, and the typical message fields serialize properly diff --git a/tests/resources/test_redirect_uris.py b/tests/resources/test_redirect_uris.py index fa7febc2..cc8c5ffc 100644 --- a/tests/resources/test_redirect_uris.py +++ b/tests/resources/test_redirect_uris.py @@ -37,7 +37,7 @@ def test_list_redirect_uris(self, http_client_list_response): redirect_uris.list() http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/applications/redirect-uris", None, None, None + "GET", "/v3/applications/redirect-uris", None, None, None, overrides=None ) def test_find_redirect_uri(self, http_client_response): @@ -46,7 +46,12 @@ def test_find_redirect_uri(self, http_client_response): redirect_uris.find(redirect_uri_id="redirect_uri-123") http_client_response._execute.assert_called_once_with( - "GET", "/v3/applications/redirect-uris/redirect_uri-123", None, None, None + "GET", + "/v3/applications/redirect-uris/redirect_uri-123", + None, + None, + None, + overrides=None, ) def test_create_redirect_uri(self, http_client_response): @@ -72,6 +77,7 @@ def test_create_redirect_uri(self, http_client_response): None, None, request_body, + overrides=None, ) def test_update_redirect_uri(self, http_client_response): @@ -100,6 +106,7 @@ def test_update_redirect_uri(self, http_client_response): None, None, request_body, + overrides=None, ) def test_destroy_redirect_uri(self, http_client_delete_response): @@ -113,4 +120,5 @@ def test_destroy_redirect_uri(self, http_client_delete_response): None, None, None, + overrides=None, ) diff --git a/tests/resources/test_smart_compose.py b/tests/resources/test_smart_compose.py index a04e7dbe..798689fe 100644 --- a/tests/resources/test_smart_compose.py +++ b/tests/resources/test_smart_compose.py @@ -20,6 +20,7 @@ def test_compose_message(self, http_client_response): method="POST", path="/v3/grants/grant-123/messages/smart-compose", request_body=request_body, + overrides=None, ) def test_compose_message_reply(self, http_client_response): @@ -32,4 +33,5 @@ def test_compose_message_reply(self, http_client_response): method="POST", path="/v3/grants/grant-123/messages/message-123/smart-compose", request_body=request_body, + overrides=None, ) diff --git a/tests/resources/test_threads.py b/tests/resources/test_threads.py index 5a0e24da..73df4ec8 100644 --- a/tests/resources/test_threads.py +++ b/tests/resources/test_threads.py @@ -130,7 +130,7 @@ def test_list_threads(self, http_client_list_response): threads.list(identifier="abc-123") http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/threads", None, None, None + "GET", "/v3/grants/abc-123/threads", None, None, None, overrides=None ) def test_list_threads_with_query_params(self, http_client_list_response): @@ -139,7 +139,12 @@ def test_list_threads_with_query_params(self, http_client_list_response): threads.list(identifier="abc-123", query_params={"to": "abc@gmail.com"}) http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/threads", None, {"to": "abc@gmail.com"}, None + "GET", + "/v3/grants/abc-123/threads", + None, + {"to": "abc@gmail.com"}, + None, + overrides=None, ) def test_find_thread(self, http_client_response): @@ -148,7 +153,12 @@ def test_find_thread(self, http_client_response): threads.find(identifier="abc-123", thread_id="thread-123") http_client_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/threads/thread-123", None, None, None + "GET", + "/v3/grants/abc-123/threads/thread-123", + None, + None, + None, + overrides=None, ) def test_update_thread(self, http_client_response): @@ -169,6 +179,7 @@ def test_update_thread(self, http_client_response): None, None, request_body, + overrides=None, ) def test_destroy_thread(self, http_client_delete_response): @@ -182,4 +193,5 @@ def test_destroy_thread(self, http_client_delete_response): None, None, None, + overrides=None, ) diff --git a/tests/resources/test_webhooks.py b/tests/resources/test_webhooks.py index 19d57b78..d4749060 100644 --- a/tests/resources/test_webhooks.py +++ b/tests/resources/test_webhooks.py @@ -39,7 +39,7 @@ def test_list_webhooks(self, http_client_list_response): webhooks.list() http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/webhooks", None, None, None + "GET", "/v3/webhooks", None, None, None, overrides=None ) def test_find_webhook(self, http_client_response): @@ -48,7 +48,7 @@ def test_find_webhook(self, http_client_response): webhooks.find("webhook-123") http_client_response._execute.assert_called_once_with( - "GET", "/v3/webhooks/webhook-123", None, None, None + "GET", "/v3/webhooks/webhook-123", None, None, None, overrides=None ) def test_create_webhook(self, http_client_response): @@ -63,11 +63,7 @@ def test_create_webhook(self, http_client_response): webhooks.create(request_body=request_body) http_client_response._execute.assert_called_once_with( - "POST", - "/v3/webhooks", - None, - None, - request_body, + "POST", "/v3/webhooks", None, None, request_body, overrides=None ) def test_update_webhook(self, http_client_response): @@ -85,11 +81,7 @@ def test_update_webhook(self, http_client_response): ) http_client_response._execute.assert_called_once_with( - "PUT", - "/v3/webhooks/webhook-123", - None, - None, - request_body, + "PUT", "/v3/webhooks/webhook-123", None, None, request_body, overrides=None ) def test_destroy_webhook(self, http_client_delete_response): @@ -98,11 +90,7 @@ def test_destroy_webhook(self, http_client_delete_response): webhooks.destroy("webhook-123") http_client_delete_response._execute.assert_called_once_with( - "DELETE", - "/v3/webhooks/webhook-123", - None, - None, - None, + "DELETE", "/v3/webhooks/webhook-123", None, None, None, overrides=None ) def test_rotate_secret(self, http_client_response): @@ -114,6 +102,7 @@ def test_rotate_secret(self, http_client_response): method="PUT", path="/v3/webhooks/webhook-123/rotate-secret", request_body={}, + overrides=None, ) def test_ip_addresses(self, http_client_response): @@ -122,8 +111,7 @@ def test_ip_addresses(self, http_client_response): webhooks.ip_addresses() http_client_response._execute.assert_called_once_with( - method="GET", - path="/v3/webhooks/ip-addresses", + method="GET", path="/v3/webhooks/ip-addresses", overrides=None ) def test_extract_challenge_parameter(self, http_client): From ceac21d74816ee9bf8ff50d69ccc36984156badb Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 17 May 2024 13:18:58 -0400 Subject: [PATCH 094/186] Added support for event_type filtering field for listing events (#364) This PR adds support for filtering events by event type. Note that this feature is only for Google events. --- CHANGELOG.md | 1 + nylas/models/events.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e23b198..0eb1d754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Unreleased * Added support for adding custom headers to outgoing requests * Added support for overriding various fields of outgoing requests * Added support for `provider` field in code exchange response +* Added support for `event_type` filtering field for listing events * Added clean messages support * Added additional webhook triggers * Made event visibility optional to support iCloud events diff --git a/nylas/models/events.py b/nylas/models/events.py index 9ae3ce6c..fc27480e 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -18,6 +18,9 @@ SendRsvpStatus = Literal["yes", "no", "maybe"] """ Literal representing the status of an RSVP. """ +EventType = Literal["default", "outOfOffice", "focusTime", "workingLocation"] +""" Literal representing the event type to filter by. """ + @dataclass_json @dataclass @@ -722,6 +725,8 @@ class ListEventQueryParams(ListQueryParams): This field defaults to 50. The maximum allowed value is 200. page_token (NotRequired[str]): An identifier that specifies which page of data to return. This value should be taken from a ListResponse object's next_cursor parameter. + event_type (NotRequired[List[EventType]]): (Google only) Filter events by event type. + You can pass the query parameter multiple times to select or exclude multiple event types. """ calendar_id: str @@ -735,6 +740,7 @@ class ListEventQueryParams(ListQueryParams): expand_recurring: NotRequired[bool] busy: NotRequired[bool] order_by: NotRequired[str] + event_type: NotRequired[List[EventType]] class CreateEventQueryParams(TypedDict): From 25d73310e8c893e50ce5a4ea7ce22416eaee3969 Mon Sep 17 00:00:00 2001 From: kraju3 <35513942+kraju3@users.noreply.github.com> Date: Fri, 17 May 2024 12:51:26 -0500 Subject: [PATCH 095/186] Adding scope as an optional parameter to the CodeExchangeResponse (#368) Adding scope as an optional string due to ICloud and IMAP grants not having a scope property associated with them. --- nylas/models/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nylas/models/auth.py b/nylas/models/auth.py index 3b57ac36..e073fb0e 100644 --- a/nylas/models/auth.py +++ b/nylas/models/auth.py @@ -119,10 +119,10 @@ class CodeExchangeResponse: access_token: str grant_id: str - scope: str expires_in: int email: Optional[str] = None refresh_token: Optional[str] = None + scope: Optional[str] = None id_token: Optional[str] = None token_type: Optional[str] = None provider: Optional[Provider] = None From bea67e30f0045536b2faf9da6e6f7194c055c02d Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 17 May 2024 14:12:17 -0400 Subject: [PATCH 096/186] Fix deserialization error with event participant (#369) Event participant status is optional, as the field doesn't get populated for iCloud events. --- CHANGELOG.md | 1 + nylas/models/events.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb1d754..94ddfd7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Unreleased * Added additional webhook triggers * Made event visibility optional to support iCloud events * Fixed issue where attachments < 3mb were not being encoded correctly +* Fixed issue deserializing event and code exchange responses. v6.1.1 ---------------- diff --git a/nylas/models/events.py b/nylas/models/events.py index fc27480e..5448dcb2 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -37,7 +37,7 @@ class Participant: """ email: str - status: ParticipantStatus + status: Optional[ParticipantStatus] = None name: Optional[str] = None comment: Optional[str] = None phone_number: Optional[str] = None From 0857efbb8961985be07edf8c5de5f997766b122c Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 17 May 2024 14:24:05 -0400 Subject: [PATCH 097/186] v6.2.0 Release (#370) # Changelog * Added support for custom headers field for drafts and messages (#360) * Added support for overriding various fields of outgoing requests (#363) * Added support for `provider` field in code exchange response (#360) * Added support for `event_type` filtering field for listing events (#364) * Added clean messages support (#361) * Added additional webhook triggers (#357) * Fixed issue where attachments < 3mb were not being encoded correctly (#362) * Fixed issue deserializing event and code exchange responses (#358, #368, #369) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 7 +++---- nylas/_client_sdk_version.py | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 4423062b..58293020 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.1.1 +current_version = 6.2.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 94ddfd7b..e53c656d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,16 @@ nylas-python Changelog ====================== -Unreleased +v6.2.0 ---------------- -* Added support for adding custom headers to outgoing requests +* Added support for custom headers field for drafts and messages * Added support for overriding various fields of outgoing requests * Added support for `provider` field in code exchange response * Added support for `event_type` filtering field for listing events * Added clean messages support * Added additional webhook triggers -* Made event visibility optional to support iCloud events * Fixed issue where attachments < 3mb were not being encoded correctly -* Fixed issue deserializing event and code exchange responses. +* Fixed issue deserializing event and code exchange responses v6.1.1 ---------------- diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index eb1a8dd8..feed9350 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.1.1" +__VERSION__ = "6.2.0" From edb8b06fc3e869f43cae297a336f7d50d7f53270 Mon Sep 17 00:00:00 2001 From: kraju3 <35513942+kraju3@users.noreply.github.com> Date: Fri, 31 May 2024 05:37:18 -0500 Subject: [PATCH 098/186] Adding Folder query param support and setting background image to none (#371) * Setting background image to none * Adding query param support for Folders * fixing tests and formatting --- nylas/models/application_details.py | 6 +++--- nylas/models/folders.py | 13 +++++++++++++ nylas/resources/folders.py | 6 ++++++ tests/resources/test_folders.py | 2 +- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/nylas/models/application_details.py b/nylas/models/application_details.py index daf79493..5c465a01 100644 --- a/nylas/models/application_details.py +++ b/nylas/models/application_details.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Literal, Optional, List from dataclasses_json import dataclass_json @@ -48,7 +48,7 @@ class HostedAuthentication: spacing: CSS spacing attribute in px. """ - background_image_url: str + background_image_url: Optional[str] = None alignment: Optional[str] = None color_primary: Optional[str] = None color_secondary: Optional[str] = None @@ -80,4 +80,4 @@ class ApplicationDetails: environment: Environment branding: Branding hosted_authentication: Optional[HostedAuthentication] = None - callback_uris: List[RedirectUri] = None + callback_uris: List[RedirectUri] = field(default_factory=list) diff --git a/nylas/models/folders.py b/nylas/models/folders.py index 06a68b64..33ade3f5 100644 --- a/nylas/models/folders.py +++ b/nylas/models/folders.py @@ -4,6 +4,8 @@ from dataclasses_json import dataclass_json from typing_extensions import TypedDict, NotRequired +from nylas.models.list_query_params import ListQueryParams + @dataclass_json @dataclass @@ -75,3 +77,14 @@ class UpdateFolderRequest(TypedDict): parent_id: NotRequired[str] background_color: NotRequired[str] text_color: NotRequired[str] + + +class ListFolderQueryParams(ListQueryParams): + """ + Interface representing the query parameters for listing folders. + + Attributes: + parent_id: (Microsoft and EWS only.) Use the ID of a folder to find all child folders it contains. + """ + + parent_id: NotRequired[str] diff --git a/nylas/resources/folders.py b/nylas/resources/folders.py index 885056ff..0dfccdda 100644 --- a/nylas/resources/folders.py +++ b/nylas/resources/folders.py @@ -1,3 +1,5 @@ +from typing import Optional + from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, @@ -10,6 +12,7 @@ Folder, CreateFolderRequest, UpdateFolderRequest, + ListFolderQueryParams, ) from nylas.models.response import Response, ListResponse, DeleteResponse @@ -30,6 +33,7 @@ class Folders( def list( self, identifier: str, + query_params: Optional[ListFolderQueryParams] = None, overrides: RequestOverrides = None, ) -> ListResponse[Folder]: """ @@ -37,6 +41,7 @@ def list( Args: identifier: The identifier of the Grant to act upon. + query_params: The query parameters to include in the request. overrides: The request overrides to use. Returns: @@ -46,6 +51,7 @@ def list( return super().list( path=f"/v3/grants/{identifier}/folders", response_type=Folder, + query_params=query_params, overrides=overrides, ) diff --git a/tests/resources/test_folders.py b/tests/resources/test_folders.py index c1443c8c..955e3ee1 100644 --- a/tests/resources/test_folders.py +++ b/tests/resources/test_folders.py @@ -38,7 +38,7 @@ def test_folder_deserialization(self): def test_list_folders(self, http_client_list_response): folders = Folders(http_client_list_response) - folders.list(identifier="abc-123") + folders.list(identifier="abc-123",query_params=None) http_client_list_response._execute.assert_called_once_with( "GET", "/v3/grants/abc-123/folders", None, None, None, overrides=None From 420443fc3028422b3c769605c681c13aa9833325 Mon Sep 17 00:00:00 2001 From: nickbair-nylas <75274059+nickbair-nylas@users.noreply.github.com> Date: Tue, 4 Jun 2024 15:17:44 -0600 Subject: [PATCH 099/186] Add master_event_id to events (#372) * Add master_event_id to events * Fix master_event_id type * Address pylint feedback * Add master_event_id to event list queries --- nylas/models/events.py | 5 +++++ tests/resources/test_events.py | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/nylas/models/events.py b/nylas/models/events.py index 5448dcb2..575a402f 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -312,6 +312,7 @@ class Event: status: The Event's status. visibility: The Event's visibility (private or public). capacity: Sets the maximum number of participants that may attend the event. + master_event_id: For recurring events, this field contains the main (master) event's ID. """ id: str @@ -341,6 +342,7 @@ class Event: capacity: Optional[int] = None created_at: Optional[int] = None updated_at: Optional[int] = None + master_event_id: Optional[str] = None class CreateParticipant(TypedDict): @@ -727,6 +729,8 @@ class ListEventQueryParams(ListQueryParams): This value should be taken from a ListResponse object's next_cursor parameter. event_type (NotRequired[List[EventType]]): (Google only) Filter events by event type. You can pass the query parameter multiple times to select or exclude multiple event types. + master_event_id (NotRequired[str]): Filter for instances of recurring events with the + specified master_event_id. Not respected by metadata filtering. """ calendar_id: str @@ -741,6 +745,7 @@ class ListEventQueryParams(ListQueryParams): busy: NotRequired[bool] order_by: NotRequired[str] event_type: NotRequired[List[EventType]] + master_event_id: NotRequired[str] class CreateEventQueryParams(TypedDict): diff --git a/tests/resources/test_events.py b/tests/resources/test_events.py index 6f06e324..1afd9b4a 100644 --- a/tests/resources/test_events.py +++ b/tests/resources/test_events.py @@ -21,7 +21,8 @@ def test_event_deserialization(self): "hide_participants": False, "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", "html_link": "https://www.google.com/calendar/event?eid=bTMzcGJrNW4yYjk4bjk3OWE4Ef3feD2VuM29fMjAyMjA2MjdUMjIwMDAwWiBoYWxsYUBueWxhcy5jb20", - "id": "5d3qmne77v32r8l4phyuksl2x", + "id": "5d3qmne77v32r8l4phyuksl2x_20240603T180000Z", + "master_event_id": "5d3qmne77v32r8l4phyuksl2x", "location": "Roller Rink", "metadata": {"your_key": "your_value"}, "object": "event", @@ -73,7 +74,8 @@ def test_event_deserialization(self): event.html_link == "https://www.google.com/calendar/event?eid=bTMzcGJrNW4yYjk4bjk3OWE4Ef3feD2VuM29fMjAyMjA2MjdUMjIwMDAwWiBoYWxsYUBueWxhcy5jb20" ) - assert event.id == "5d3qmne77v32r8l4phyuksl2x" + assert event.id == "5d3qmne77v32r8l4phyuksl2x_20240603T180000Z" + assert event.master_event_id == "5d3qmne77v32r8l4phyuksl2x" assert event.location == "Roller Rink" assert event.metadata == {"your_key": "your_value"} assert event.object == "event" From bfefb78d55c46f55c7da61f73d0d19c1346c02c3 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Wed, 5 Jun 2024 12:55:25 -0400 Subject: [PATCH 100/186] v6.3.0 Release (#373) * Added Folder query param support (#371) * Added `master_event_id` field to events (#372) * Fixed issue with application models not being deserialized correctly (#371) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 6 ++++++ nylas/_client_sdk_version.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 58293020..efd5ea8f 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.2.0 +current_version = 6.3.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index e53c656d..b3fbda8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ nylas-python Changelog ====================== +v6.3.0 +---------------- +* Added Folder query param support +* Added `master_event_id` field to events +* Fixed issue with application models not being deserialized correctly + v6.2.0 ---------------- * Added support for custom headers field for drafts and messages diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index feed9350..2ba3ae0d 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.2.0" +__VERSION__ = "6.3.0" From 2e869bc6be216dda5057ea55f8fba3caf3f5f7ac Mon Sep 17 00:00:00 2001 From: Blag Date: Fri, 26 Jul 2024 08:35:42 -0500 Subject: [PATCH 101/186] Fix typo on Clean Messages (#375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json_response instead of json_resposne ๐Ÿ˜ --- nylas/resources/messages.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index 6e518598..4fd22ed0 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -281,11 +281,11 @@ def clean_messages( Returns: The list of cleaned messages. """ - json_resposne = self._http_client._execute( + json_response = self._http_client._execute( method="PUT", path=f"/v3/grants/{identifier}/messages/clean", request_body=request_body, overrides=overrides, ) - return ListResponse.from_dict(json_resposne, CleanMessagesResponse) + return ListResponse.from_dict(json_response, CleanMessagesResponse) From b25f3eced49b7d9a9e92b6de0628150d0be4b64a Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:11:25 -0400 Subject: [PATCH 102/186] Remove use of TestCommand (#377) This PR removes the use of `TestCommand` and uses `Command` instead. `setuptools` is set to remove `TestCommand` in November. Closes #376. --- setup.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index fc555b39..937d3977 100644 --- a/setup.py +++ b/setup.py @@ -3,8 +3,7 @@ import sys import re import subprocess -from setuptools import setup, find_packages -from setuptools.command.test import test as TestCommand +from setuptools import setup, find_packages, Command VERSION = "" @@ -36,11 +35,10 @@ RELEASE_DEPENDENCIES = ["bumpversion>=0.6.0", "twine>=4.0.2"] -class PyTest(TestCommand): +class PyTest(Command): user_options = [("pytest-args=", "a", "Arguments to pass to pytest")] def initialize_options(self): - TestCommand.initialize_options(self) # pylint: disable=attribute-defined-outside-init self.pytest_args = [ "--cov", @@ -52,12 +50,11 @@ def initialize_options(self): self.lint = False def finalize_options(self): - TestCommand.finalize_options(self) # pylint: disable=attribute-defined-outside-init self.test_args = [] self.test_suite = True - def run_tests(self): + def run(self): # import here, cause outside the eggs aren't loaded import pytest From 7bba2ba375da645f2048497658a3618bff560f5b Mon Sep 17 00:00:00 2001 From: Blag Date: Tue, 30 Jul 2024 09:17:18 -0500 Subject: [PATCH 103/186] Add Folder Webhooks (#374) Add "Folders" Webhook triggers --- nylas/models/webhooks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nylas/models/webhooks.py b/nylas/models/webhooks.py index c4239f3a..36ea508c 100644 --- a/nylas/models/webhooks.py +++ b/nylas/models/webhooks.py @@ -30,6 +30,9 @@ class WebhookTriggers(str, Enum): MESSAGE_OPENED = "message.opened" MESSAGE_LINK_CLICKED = "message.link_clicked" THREAD_REPLIED = "thread.replied" + FOLDER_CREATED = "folder.created" + FOLDER_UPDATED = "folder.updated" + FOLDER_DELETED = "folder.deleted" @dataclass_json From 83a60590adcdc9245c058b2da3ba1dbb3ae6b167 Mon Sep 17 00:00:00 2001 From: Austin Gregory Date: Wed, 4 Sep 2024 16:28:08 -0400 Subject: [PATCH 104/186] Use a fresh connection on every request --- nylas/handler/http_client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index 2d9ace51..5b1d77ab 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -70,7 +70,6 @@ def __init__(self, api_server, api_key, timeout): self.api_server = api_server self.api_key = api_key self.timeout = timeout - self.session = requests.Session() def _execute( self, @@ -90,7 +89,7 @@ def _execute( if overrides and overrides.get("timeout"): timeout = overrides["timeout"] try: - response = self.session.request( + response = requests.request( request["method"], request["url"], headers=request["headers"], @@ -117,7 +116,7 @@ def _execute_download_request( if overrides and overrides.get("timeout"): timeout = overrides["timeout"] try: - response = self.session.request( + response = requests.request( request["method"], request["url"], headers=request["headers"], From c27ae2be2035f9e2e5e4fc6fc62acf1a2e51b035 Mon Sep 17 00:00:00 2001 From: Austin Gregory Date: Wed, 4 Sep 2024 16:46:32 -0400 Subject: [PATCH 105/186] Fix tests --- tests/conftest.py | 6 +++--- tests/handler/test_http_client.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 390dc54f..196695a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,19 +34,19 @@ def patched_version_and_sys(): @pytest.fixture -def patched_session_request(): +def patched_request(): mock_response = Mock() mock_response.content = b"mock data" mock_response.json.return_value = {"foo": "bar"} mock_response.status_code = 200 - with patch("requests.Session.request", return_value=mock_response) as mock_request: + with patch("requests.request", return_value=mock_response) as mock_request: yield mock_request @pytest.fixture def mock_session_timeout(): - with patch("requests.Session.request", side_effect=requests.exceptions.Timeout): + with patch("requests.request", side_effect=requests.exceptions.Timeout): yield diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index dbcf6c13..1292f7fd 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -162,14 +162,14 @@ def test_build_query_params(self, patched_version_and_sys): == "https://test.nylas.com/foo?foo=bar&list=a&list=b&list=c&map=key1:value1&map=key2:value2" ) - def test_execute_download_request(self, http_client, patched_session_request): + def test_execute_download_request(self, http_client, patched_request): response = http_client._execute_download_request( path="/foo", ) assert response == b"mock data" def test_execute_download_request_with_stream( - self, http_client, patched_session_request + self, http_client, patched_request ): response = http_client._execute_download_request( path="/foo", @@ -189,13 +189,13 @@ def test_execute_download_request_timeout(self, http_client, mock_session_timeou ) def test_execute_download_request_override_timeout( - self, http_client, patched_version_and_sys, patched_session_request + self, http_client, patched_version_and_sys, patched_request ): response = http_client._execute_download_request( path="/foo", overrides={"timeout": 60}, ) - patched_session_request.assert_called_once_with( + patched_request.assert_called_once_with( "GET", "https://test.nylas.com/foo", headers={ @@ -276,7 +276,7 @@ def test_validate_response_400_keyerror(self): assert e.value.status_code == 400 def test_execute( - self, http_client, patched_version_and_sys, patched_session_request + self, http_client, patched_version_and_sys, patched_request ): response = http_client._execute( method="GET", @@ -287,7 +287,7 @@ def test_execute( ) assert response == {"foo": "bar"} - patched_session_request.assert_called_once_with( + patched_request.assert_called_once_with( "GET", "https://test.nylas.com/foo?query=param", headers={ @@ -303,7 +303,7 @@ def test_execute( ) def test_execute_override_timeout( - self, http_client, patched_version_and_sys, patched_session_request + self, http_client, patched_version_and_sys, patched_request ): response = http_client._execute( method="GET", @@ -315,7 +315,7 @@ def test_execute_override_timeout( ) assert response == {"foo": "bar"} - patched_session_request.assert_called_once_with( + patched_request.assert_called_once_with( "GET", "https://test.nylas.com/foo?query=param", headers={ From 81c8de30bc7a6664c36f6101df5d38d12971b9d5 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Wed, 4 Sep 2024 16:58:51 -0400 Subject: [PATCH 106/186] v6.3.1 release (#381) * Fix typo on Clean Messages (Fix typo on Clean Messages (#375) * Remove use of TestCommand (Remove use of TestCommand (#377) * Add Folder Webhooks (Add Folder Webhooks - Python SDK (#374) * Fix request session being reused across multiple requests (#380) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 10 +++++++++- nylas/_client_sdk_version.py | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index efd5ea8f..921f5ccd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.3.0 +current_version = 6.3.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index b3fbda8a..0a963084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ nylas-python Changelog ====================== +v6.3.1 +---------------- +* Fix typo on Clean Messages +* Remove use of TestCommand +* Add Folder Webhooks +* Fix request session being reused across multiple requests + + v6.3.0 ---------------- * Added Folder query param support @@ -193,7 +201,7 @@ v5.0.0 * Transitioned from `app_id` and `app_secret` naming to `client_id` and `client_secret` * Add support for the Nylas Neural API * Add `metadata` field in the Event model to support new event metadata feature -* Add new Room Resource fields +* Add new Room Resource fields * Add `Nylas-API-Version` header support * Fix adding a tracking object to an existing `draft` * Fix issue when converting offset-aware `datetime` objects to `timestamp` diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 2ba3ae0d..e554ee00 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.3.0" +__VERSION__ = "6.3.1" From 6955356a7ac4c23b665df44057b3c12accb92a94 Mon Sep 17 00:00:00 2001 From: kraju3 <35513942+kraju3@users.noreply.github.com> Date: Mon, 9 Sep 2024 14:32:17 -0500 Subject: [PATCH 107/186] "Make from field Optional to not cause Key errors" (#382) --- nylas/models/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nylas/models/messages.py b/nylas/models/messages.py index 58f917ce..2ab36c6d 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -56,7 +56,7 @@ class Message: """ grant_id: str - from_: List[EmailName] = field(metadata=config(field_name="from")) + from_: Optional[List[EmailName]] = field(default=None,metadata=config(field_name="from")) object: str = "message" id: Optional[str] = None body: Optional[str] = None From 9c8cead668b912634d24a4217528e19e24d5e775 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:30:13 -0400 Subject: [PATCH 108/186] Fix IMAP identifiers not encoding correctly (#383) IMAP identifiers sometimes have characters that do not work well in the URL, including slashes, that would make the request fail. For these endpoints we ensure that we are encoding these IDs properly. --- CHANGELOG.md | 4 ++ nylas/resources/drafts.py | 9 ++-- nylas/resources/messages.py | 8 ++-- nylas/resources/threads.py | 8 ++-- tests/resources/test_drafts.py | 72 ++++++++++++++++++++++++++++++++ tests/resources/test_messages.py | 58 +++++++++++++++++++++++++ tests/resources/test_threads.py | 57 +++++++++++++++++++++++++ 7 files changed, 206 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a963084..6c51a4ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Fix IMAP identifiers not encoding correctly + v6.3.1 ---------------- * Fix typo on Clean Messages diff --git a/nylas/resources/drafts.py b/nylas/resources/drafts.py index 1a18546e..23146d88 100644 --- a/nylas/resources/drafts.py +++ b/nylas/resources/drafts.py @@ -1,4 +1,5 @@ import io +import urllib.parse from typing import Optional from nylas.config import RequestOverrides @@ -79,7 +80,7 @@ def find( The requested Draft. """ return super().find( - path=f"/v3/grants/{identifier}/drafts/{draft_id}", + path=f"/v3/grants/{identifier}/drafts/{urllib.parse.quote(draft_id, safe='')}", response_type=Draft, overrides=overrides, ) @@ -149,7 +150,7 @@ def update( Returns: The updated Draft. """ - path = f"/v3/grants/{identifier}/drafts/{draft_id}" + path = f"/v3/grants/{identifier}/drafts/{urllib.parse.quote(draft_id, safe='')}" # Use form data only if the attachment size is greater than 3mb attachment_size = sum( @@ -196,7 +197,7 @@ def destroy( The deletion response. """ return super().destroy( - path=f"/v3/grants/{identifier}/drafts/{draft_id}", + path=f"/v3/grants/{identifier}/drafts/{urllib.parse.quote(draft_id, safe='')}", overrides=overrides, ) @@ -216,7 +217,7 @@ def send( """ json_response = self._http_client._execute( method="POST", - path=f"/v3/grants/{identifier}/drafts/{draft_id}", + path=f"/v3/grants/{identifier}/drafts/{urllib.parse.quote(draft_id, safe='')}", overrides=overrides, ) diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index 4fd22ed0..7687335f 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -1,4 +1,5 @@ import io +import urllib.parse from typing import Optional, List from nylas.config import RequestOverrides @@ -96,7 +97,7 @@ def find( The requested Message. """ return super().find( - path=f"/v3/grants/{identifier}/messages/{message_id}", + path=f"/v3/grants/{identifier}/messages/{urllib.parse.quote(message_id, safe='')}", response_type=Message, query_params=query_params, overrides=overrides, @@ -122,7 +123,7 @@ def update( The updated Message. """ return super().update( - path=f"/v3/grants/{identifier}/messages/{message_id}", + path=f"/v3/grants/{identifier}/messages/{urllib.parse.quote(message_id, safe='')}", response_type=Message, request_body=request_body, overrides=overrides, @@ -143,7 +144,8 @@ def destroy( The deletion response. """ return super().destroy( - path=f"/v3/grants/{identifier}/messages/{message_id}", overrides=overrides + path=f"/v3/grants/{identifier}/messages/{urllib.parse.quote(message_id, safe='')}", + overrides=overrides, ) def send( diff --git a/nylas/resources/threads.py b/nylas/resources/threads.py index 712199a1..521ef918 100644 --- a/nylas/resources/threads.py +++ b/nylas/resources/threads.py @@ -1,3 +1,4 @@ +import urllib.parse from nylas.config import RequestOverrides from nylas.handler.api_resources import ( ListableApiResource, @@ -60,7 +61,7 @@ def find( The requested Thread. """ return super().find( - path=f"/v3/grants/{identifier}/threads/{thread_id}", + path=f"/v3/grants/{identifier}/threads/{urllib.parse.quote(thread_id, safe='')}", response_type=Thread, overrides=overrides, ) @@ -85,7 +86,7 @@ def update( The updated Thread. """ return super().update( - path=f"/v3/grants/{identifier}/threads/{thread_id}", + path=f"/v3/grants/{identifier}/threads/{urllib.parse.quote(thread_id, safe='')}", response_type=Thread, request_body=request_body, overrides=overrides, @@ -106,5 +107,6 @@ def destroy( The deletion response. """ return super().destroy( - path=f"/v3/grants/{identifier}/threads/{thread_id}", overrides=overrides + path=f"/v3/grants/{identifier}/threads/{urllib.parse.quote(thread_id, safe='')}", + overrides=overrides, ) diff --git a/tests/resources/test_drafts.py b/tests/resources/test_drafts.py index 26e2a591..ecd51ebd 100644 --- a/tests/resources/test_drafts.py +++ b/tests/resources/test_drafts.py @@ -106,6 +106,23 @@ def test_find_draft(self, http_client_response): overrides=None, ) + def test_find_draft_encoded_id(self, http_client_response): + drafts = Drafts(http_client_response) + + drafts.find( + identifier="abc-123", + draft_id="", + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/drafts/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + None, + overrides=None, + ) + def test_create_draft(self, http_client_response): drafts = Drafts(http_client_response) request_body = { @@ -206,6 +223,30 @@ def test_update_draft(self, http_client_response): overrides=None, ) + def test_update_draft_encoded_id(self, http_client_response): + drafts = Drafts(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + } + + drafts.update( + identifier="abc-123", + draft_id="", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/drafts/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + request_body, + overrides=None, + ) + def test_update_draft_small_attachment(self, http_client_response): drafts = Drafts(http_client_response) request_body = { @@ -282,6 +323,23 @@ def test_destroy_draft(self, http_client_delete_response): overrides=None, ) + def test_destroy_draft_encoded_id(self, http_client_delete_response): + drafts = Drafts(http_client_delete_response) + + drafts.destroy( + identifier="abc-123", + draft_id="", + ) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/drafts/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + None, + overrides=None, + ) + def test_send_draft(self, http_client_response): drafts = Drafts(http_client_response) @@ -290,3 +348,17 @@ def test_send_draft(self, http_client_response): http_client_response._execute.assert_called_once_with( method="POST", path="/v3/grants/abc-123/drafts/draft-123", overrides=None ) + + def test_send_draft_encoded_id(self, http_client_response): + drafts = Drafts(http_client_response) + + drafts.send( + identifier="abc-123", + draft_id="", + ) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/drafts/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + overrides=None, + ) diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 1a977909..1af9a957 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -109,6 +109,23 @@ def test_find_message(self, http_client_response): overrides=None, ) + def test_find_message_encoded_id(self, http_client_response): + messages = Messages(http_client_response) + + messages.find( + identifier="abc-123", + message_id="", + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + None, + overrides=None, + ) + def test_find_message_with_query_params(self, http_client_response): messages = Messages(http_client_response) @@ -151,6 +168,30 @@ def test_update_message(self, http_client_response): overrides=None, ) + def test_update_message_encoded_id(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "starred": True, + "unread": False, + "folders": ["folder-123"], + "metadata": {"foo": "bar"}, + } + + messages.update( + identifier="abc-123", + message_id="", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + request_body, + overrides=None, + ) + def test_destroy_message(self, http_client_delete_response): messages = Messages(http_client_delete_response) @@ -165,6 +206,23 @@ def test_destroy_message(self, http_client_delete_response): overrides=None, ) + def test_destroy_message_encoded_id(self, http_client_delete_response): + messages = Messages(http_client_delete_response) + + messages.destroy( + identifier="abc-123", + message_id="", + ) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + None, + overrides=None, + ) + def test_send_message(self, http_client_response): messages = Messages(http_client_response) request_body = { diff --git a/tests/resources/test_threads.py b/tests/resources/test_threads.py index 73df4ec8..47c9cbec 100644 --- a/tests/resources/test_threads.py +++ b/tests/resources/test_threads.py @@ -161,6 +161,23 @@ def test_find_thread(self, http_client_response): overrides=None, ) + def test_find_thread_encoded_id(self, http_client_response): + threads = Threads(http_client_response) + + threads.find( + identifier="abc-123", + thread_id="", + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/threads/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + None, + overrides=None, + ) + def test_update_thread(self, http_client_response): threads = Threads(http_client_response) request_body = { @@ -182,6 +199,29 @@ def test_update_thread(self, http_client_response): overrides=None, ) + def test_update_thread_encoded_id(self, http_client_response): + threads = Threads(http_client_response) + request_body = { + "starred": True, + "unread": False, + "folders": ["folder-123"], + } + + threads.update( + identifier="abc-123", + thread_id="", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/threads/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + request_body, + overrides=None, + ) + def test_destroy_thread(self, http_client_delete_response): threads = Threads(http_client_delete_response) @@ -195,3 +235,20 @@ def test_destroy_thread(self, http_client_delete_response): None, overrides=None, ) + + def test_destroy_thread_encode_id(self, http_client_delete_response): + threads = Threads(http_client_delete_response) + + threads.destroy( + identifier="abc-123", + thread_id="", + ) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/threads/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + None, + overrides=None, + ) From a53c1643a6ab1c67bfee51d4cc23928abd3254df Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Fri, 20 Sep 2024 17:48:26 -0400 Subject: [PATCH 109/186] Add missing schedule-specific fields to Message model (#385) This PR adds in two fields, schedule_id and send_at, to the Message model. These fields are returned by the API if the message being sent is scheduled. --- CHANGELOG.md | 1 + nylas/models/messages.py | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c51a4ce..41aa8d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Fix IMAP identifiers not encoding correctly +* Add missing schedule-specific fields to Message model v6.3.1 ---------------- diff --git a/nylas/models/messages.py b/nylas/models/messages.py index 2ab36c6d..12da517d 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -53,10 +53,14 @@ class Message: folders: The folders that the message is in. headers: The headers of the message. created_at: Unix timestamp of when the message was created. + schedule_id: The ID of the scheduled email message. Nylas returns the schedule_id if send_at is set. + send_at: Unix timestamp of when the message will be sent, if scheduled. """ grant_id: str - from_: Optional[List[EmailName]] = field(default=None,metadata=config(field_name="from")) + from_: Optional[List[EmailName]] = field( + default=None, metadata=config(field_name="from") + ) object: str = "message" id: Optional[str] = None body: Optional[str] = None @@ -74,6 +78,8 @@ class Message: starred: Optional[bool] = None created_at: Optional[int] = None date: Optional[int] = None + schedule_id: Optional[str] = None + send_at: Optional[int] = None # Need to use Functional typed dicts because "from" and "in" are Python From 7757cc6b80d12715c546c9e282bfaf193bccb912 Mon Sep 17 00:00:00 2001 From: kraju3 <35513942+kraju3@users.noreply.github.com> Date: Tue, 24 Sep 2024 12:13:11 -0500 Subject: [PATCH 110/186] Add migration grant props (#387) * Add account_id to the grant object * Revert "Add account_id to the grant object" This reverts commit 0483e587265905fc778d4988e9a9c9a916d2a99a. * Add account_id to the grant model for migrated accounts --- nylas/models/grants.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nylas/models/grants.py b/nylas/models/grants.py index e9be3ff8..61db3d65 100644 --- a/nylas/models/grants.py +++ b/nylas/models/grants.py @@ -16,6 +16,7 @@ class Grant: Attributes: id: Globally unique object identifier. provider: OAuth provider that the user authenticated with. + account_id: Globally unique identifier for your v2 account that has been migrated using our migration APIs. scope: Scopes specified for the grant. created_at: Unix timestamp when the grant was created. grant_status: Status of the grant, if it is still valid or if the user needs to re-authenticate. @@ -31,6 +32,7 @@ class Grant: id: str provider: str scope: List[str] = field(default_factory=list) + account_id: Optional[str] = None grant_status: Optional[str] = None email: Optional[str] = None user_agent: Optional[str] = None From f30867b318620505fdb8fb87c33ec1f2cbd81a06 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:28:00 -0400 Subject: [PATCH 111/186] Add support for from field for sending messages (#386) Added support for a from field in SendMessageRequest. --- CHANGELOG.md | 1 + nylas/models/drafts.py | 2 ++ nylas/resources/messages.py | 3 +++ 3 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41aa8d86..a26d56f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased ---------------- +* Add support for from field for sending messages * Fix IMAP identifiers not encoding correctly * Add missing schedule-specific fields to Message model diff --git a/nylas/models/drafts.py b/nylas/models/drafts.py index 3f77aaed..f77a1254 100644 --- a/nylas/models/drafts.py +++ b/nylas/models/drafts.py @@ -164,7 +164,9 @@ class SendMessageRequest(CreateDraftRequest): reply_to_message_id (NotRequired[str]): The ID of the message that you are replying to. tracking_options (NotRequired[TrackingOptions]): Options for tracking opens, links, and thread replies. custom_headers(NotRequired[List[CustomHeader]]): Custom headers to add to the message. + from_: The sender of the message. use_draft: Whether or not to use draft support. This is primarily used when dealing with large attachments. """ + from_: NotRequired[List[EmailName]] use_draft: NotRequired[bool] diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index 7687335f..310f3682 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -169,6 +169,9 @@ def send( form_data = None json_body = None + # From is a reserved keyword in Python, so we need to pull the data from 'from_' instead + request_body["from"] = request_body.get("from_", None) + # Use form data only if the attachment size is greater than 3mb attachment_size = sum( attachment.get("size", 0) From fe4b231d6988da3b0e4f3a62e4c45e737a8884a4 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:39:59 -0400 Subject: [PATCH 112/186] Fix NylasOAuthError not setting the status code properly (#388) We were setting the wrong positional arguments when initing a NylasOAuthError. Closes #379. --- CHANGELOG.md | 1 + nylas/models/errors.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a26d56f9..2fa7ca33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Unreleased * Add support for from field for sending messages * Fix IMAP identifiers not encoding correctly * Add missing schedule-specific fields to Message model +* Fix NylasOAuthError not setting the status code properly v6.3.1 ---------------- diff --git a/nylas/models/errors.py b/nylas/models/errors.py index d59b61ba..83ff3def 100644 --- a/nylas/models/errors.py +++ b/nylas/models/errors.py @@ -134,7 +134,7 @@ def __init__( oauth_error: The error details from the API. status_code: The HTTP status code of the error response. """ - super().__init__(oauth_error.error_description, status_code) + super().__init__(oauth_error.error_description, None, status_code) self.error: str = oauth_error.error self.error_code: int = oauth_error.error_code self.error_description: str = oauth_error.error_description From 220cc27693a0add27567186847c005971017b289 Mon Sep 17 00:00:00 2001 From: Mostafa Rashed <17770919+mrashed-dev@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:51:48 -0400 Subject: [PATCH 113/186] v6.4.0 Release (#389) * Add support for from field for sending messages (#386) * Add missing schedule-specific fields to Message model (#385) * Add migration grant properties (#387) * Fix from field not being optional causing deserialization errors (#382) * Fix IMAP identifiers not encoding correctly (#383) * Fix NylasOAuthError not setting the status code properly (#388, #379) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 6 ++++-- nylas/_client_sdk_version.py | 2 +- nylas/models/events.py | 2 +- tests/handler/test_http_client.py | 8 ++------ tests/resources/test_folders.py | 2 +- 6 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 921f5ccd..ceb32a18 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.3.1 +current_version = 6.4.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa7ca33..c5ecbb7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ nylas-python Changelog ====================== -Unreleased +v6.4.0 ---------------- * Add support for from field for sending messages -* Fix IMAP identifiers not encoding correctly * Add missing schedule-specific fields to Message model +* Add migration grant properties +* Fix from field not being optional causing deserialization errors +* Fix IMAP identifiers not encoding correctly * Fix NylasOAuthError not setting the status code properly v6.3.1 diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index e554ee00..e0fede44 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.3.1" +__VERSION__ = "6.4.0" diff --git a/nylas/models/events.py b/nylas/models/events.py index 575a402f..dc47531b 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -729,7 +729,7 @@ class ListEventQueryParams(ListQueryParams): This value should be taken from a ListResponse object's next_cursor parameter. event_type (NotRequired[List[EventType]]): (Google only) Filter events by event type. You can pass the query parameter multiple times to select or exclude multiple event types. - master_event_id (NotRequired[str]): Filter for instances of recurring events with the + master_event_id (NotRequired[str]): Filter for instances of recurring events with the specified master_event_id. Not respected by metadata filtering. """ diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index 1292f7fd..e6a61576 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -168,9 +168,7 @@ def test_execute_download_request(self, http_client, patched_request): ) assert response == b"mock data" - def test_execute_download_request_with_stream( - self, http_client, patched_request - ): + def test_execute_download_request_with_stream(self, http_client, patched_request): response = http_client._execute_download_request( path="/foo", stream=True, @@ -275,9 +273,7 @@ def test_validate_response_400_keyerror(self): assert e.value.request_id == "123" assert e.value.status_code == 400 - def test_execute( - self, http_client, patched_version_and_sys, patched_request - ): + def test_execute(self, http_client, patched_version_and_sys, patched_request): response = http_client._execute( method="GET", path="/foo", diff --git a/tests/resources/test_folders.py b/tests/resources/test_folders.py index 955e3ee1..763b41af 100644 --- a/tests/resources/test_folders.py +++ b/tests/resources/test_folders.py @@ -38,7 +38,7 @@ def test_folder_deserialization(self): def test_list_folders(self, http_client_list_response): folders = Folders(http_client_list_response) - folders.list(identifier="abc-123",query_params=None) + folders.list(identifier="abc-123", query_params=None) http_client_list_response._execute.assert_called_once_with( "GET", "/v3/grants/abc-123/folders", None, None, None, overrides=None From 5ed3ab2da9696c92aad4494ff8bf668050379c80 Mon Sep 17 00:00:00 2001 From: John Jung Date: Fri, 18 Oct 2024 13:42:44 -0400 Subject: [PATCH 114/186] adding this header reduces the networking by 10X --- nylas/handler/http_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index 5b1d77ab..ee29665a 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -179,6 +179,7 @@ def _build_headers( "X-Nylas-API-Wrapper": "python", "User-Agent": user_agent_header, "Authorization": f"Bearer {api_key}", + "Accept-Encoding": "gzip", } if data is not None and data.content_type is not None: headers["Content-type"] = data.content_type From 556a30f2cc8ff53298c5723a636aedd1c9aebf58 Mon Sep 17 00:00:00 2001 From: John Jung Date: Fri, 18 Oct 2024 13:48:55 -0400 Subject: [PATCH 115/186] fix tests --- tests/handler/test_http_client.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index e6a61576..4f5cd984 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -34,6 +34,7 @@ def test_build_headers_default(self, http_client, patched_version_and_sys): "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", + "Accept-Encoding": "gzip", } def test_build_headers_extra_headers(self, http_client, patched_version_and_sys): @@ -50,6 +51,7 @@ def test_build_headers_extra_headers(self, http_client, patched_version_and_sys) "Authorization": "Bearer test-key", "foo": "bar", "X-Test": "test", + "Accept-Encoding": "gzip", } def test_build_headers_json_body(self, http_client, patched_version_and_sys): @@ -64,6 +66,7 @@ def test_build_headers_json_body(self, http_client, patched_version_and_sys): "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", "Content-type": "application/json", + "Accept-Encoding": "gzip", } def test_build_headers_form_body(self, http_client, patched_version_and_sys): @@ -79,6 +82,7 @@ def test_build_headers_form_body(self, http_client, patched_version_and_sys): "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", "Content-type": "application/x-www-form-urlencoded", + "Accept-Encoding": "gzip", } def test_build_headers_override_headers(self, http_client, patched_version_and_sys): @@ -97,6 +101,7 @@ def test_build_headers_override_headers(self, http_client, patched_version_and_s "Authorization": "Bearer test-key", "foo": "bar", "X-Test": "test", + "Accept-Encoding": "gzip", } def test_build_headers_override_api_key(self, http_client, patched_version_and_sys): @@ -110,6 +115,7 @@ def test_build_headers_override_api_key(self, http_client, patched_version_and_s "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key-override", + "Accept-Encoding": "gzip", } def test_build_request_default(self, http_client, patched_version_and_sys): @@ -125,6 +131,7 @@ def test_build_request_default(self, http_client, patched_version_and_sys): "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", + "Accept-Encoding": "gzip", }, } @@ -144,6 +151,7 @@ def test_build_request_override_api_uri(self, http_client, patched_version_and_s "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", + "Accept-Encoding": "gzip", }, } @@ -201,6 +209,7 @@ def test_execute_download_request_override_timeout( "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", "Content-type": "application/json", + "Accept-Encoding": "gzip", }, timeout=60, stream=False, @@ -291,6 +300,7 @@ def test_execute(self, http_client, patched_version_and_sys, patched_request): "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", "Content-type": "application/json", + "Accept-Encoding": "gzip", "test": "header", }, json={"foo": "bar"}, @@ -319,6 +329,7 @@ def test_execute_override_timeout( "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", "Content-type": "application/json", + "Accept-Encoding": "gzip", "test": "header", }, json={"foo": "bar"}, From 89b466421e2b6d62b82a5961ecce2e244ffc13f0 Mon Sep 17 00:00:00 2001 From: Subash Pradhan Date: Wed, 30 Oct 2024 17:42:34 +0100 Subject: [PATCH 116/186] Revert "adding this header reduces the networking by 10X" --- nylas/handler/http_client.py | 1 - tests/handler/test_http_client.py | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index ee29665a..5b1d77ab 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -179,7 +179,6 @@ def _build_headers( "X-Nylas-API-Wrapper": "python", "User-Agent": user_agent_header, "Authorization": f"Bearer {api_key}", - "Accept-Encoding": "gzip", } if data is not None and data.content_type is not None: headers["Content-type"] = data.content_type diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index 4f5cd984..e6a61576 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -34,7 +34,6 @@ def test_build_headers_default(self, http_client, patched_version_and_sys): "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", - "Accept-Encoding": "gzip", } def test_build_headers_extra_headers(self, http_client, patched_version_and_sys): @@ -51,7 +50,6 @@ def test_build_headers_extra_headers(self, http_client, patched_version_and_sys) "Authorization": "Bearer test-key", "foo": "bar", "X-Test": "test", - "Accept-Encoding": "gzip", } def test_build_headers_json_body(self, http_client, patched_version_and_sys): @@ -66,7 +64,6 @@ def test_build_headers_json_body(self, http_client, patched_version_and_sys): "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", "Content-type": "application/json", - "Accept-Encoding": "gzip", } def test_build_headers_form_body(self, http_client, patched_version_and_sys): @@ -82,7 +79,6 @@ def test_build_headers_form_body(self, http_client, patched_version_and_sys): "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", "Content-type": "application/x-www-form-urlencoded", - "Accept-Encoding": "gzip", } def test_build_headers_override_headers(self, http_client, patched_version_and_sys): @@ -101,7 +97,6 @@ def test_build_headers_override_headers(self, http_client, patched_version_and_s "Authorization": "Bearer test-key", "foo": "bar", "X-Test": "test", - "Accept-Encoding": "gzip", } def test_build_headers_override_api_key(self, http_client, patched_version_and_sys): @@ -115,7 +110,6 @@ def test_build_headers_override_api_key(self, http_client, patched_version_and_s "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key-override", - "Accept-Encoding": "gzip", } def test_build_request_default(self, http_client, patched_version_and_sys): @@ -131,7 +125,6 @@ def test_build_request_default(self, http_client, patched_version_and_sys): "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", - "Accept-Encoding": "gzip", }, } @@ -151,7 +144,6 @@ def test_build_request_override_api_uri(self, http_client, patched_version_and_s "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", - "Accept-Encoding": "gzip", }, } @@ -209,7 +201,6 @@ def test_execute_download_request_override_timeout( "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", "Content-type": "application/json", - "Accept-Encoding": "gzip", }, timeout=60, stream=False, @@ -300,7 +291,6 @@ def test_execute(self, http_client, patched_version_and_sys, patched_request): "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", "Content-type": "application/json", - "Accept-Encoding": "gzip", "test": "header", }, json={"foo": "bar"}, @@ -329,7 +319,6 @@ def test_execute_override_timeout( "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", "Content-type": "application/json", - "Accept-Encoding": "gzip", "test": "header", }, json={"foo": "bar"}, From 64a0e028bf0b09046fc896363b207bdfa121e13b Mon Sep 17 00:00:00 2001 From: Subash Pradhan Date: Fri, 13 Dec 2024 07:54:25 +0100 Subject: [PATCH 117/186] Scheduler API support (#393) --- CHANGELOG.md | 4 + nylas/client.py | 11 + nylas/handler/api_resources.py | 18 + nylas/models/availability.py | 4 +- nylas/models/messages.py | 2 - nylas/models/scheduler.py | 527 +++++++++++++++++++++++++ nylas/resources/bookings.py | 176 +++++++++ nylas/resources/configurations.py | 160 ++++++++ nylas/resources/scheduler.py | 42 ++ nylas/resources/sessions.py | 56 +++ tests/resources/test_bookings.py | 116 ++++++ tests/resources/test_calendars.py | 2 +- tests/resources/test_configurations.py | 234 +++++++++++ tests/resources/test_sessions.py | 45 +++ 14 files changed, 1392 insertions(+), 5 deletions(-) create mode 100644 nylas/models/scheduler.py create mode 100644 nylas/resources/bookings.py create mode 100644 nylas/resources/configurations.py create mode 100644 nylas/resources/scheduler.py create mode 100644 nylas/resources/sessions.py create mode 100644 tests/resources/test_bookings.py create mode 100644 tests/resources/test_configurations.py create mode 100644 tests/resources/test_sessions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c5ecbb7e..f66fc440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +-------------- +* Add support for Scheduler APIs + v6.4.0 ---------------- * Add support for from field for sending messages diff --git a/nylas/client.py b/nylas/client.py index 00b5ad8c..349c55fb 100644 --- a/nylas/client.py +++ b/nylas/client.py @@ -13,6 +13,7 @@ from nylas.resources.contacts import Contacts from nylas.resources.drafts import Drafts from nylas.resources.grants import Grants +from nylas.resources.scheduler import Scheduler class Client: @@ -169,3 +170,13 @@ def webhooks(self) -> Webhooks: The Webhooks API. """ return Webhooks(self.http_client) + + @property + def scheduler(self) -> Scheduler: + """ + Access the Scheduler API. + + Returns: + The Scheduler API. + """ + return Scheduler(self.http_client) diff --git a/nylas/handler/api_resources.py b/nylas/handler/api_resources.py index ca38bce2..35c6dce2 100644 --- a/nylas/handler/api_resources.py +++ b/nylas/handler/api_resources.py @@ -75,6 +75,24 @@ def update( return Response.from_dict(response_json, response_type) +class UpdatablePatchApiResource(Resource): + def patch( + self, + path, + response_type, + headers=None, + query_params=None, + request_body=None, + method="PATCH", + overrides=None, + ): + response_json = self._http_client._execute( + method, path, headers, query_params, request_body, overrides=overrides + ) + + return Response.from_dict(response_json, response_type) + + class DestroyableApiResource(Resource): def destroy( self, diff --git a/nylas/models/availability.py b/nylas/models/availability.py index 005977d3..68497f38 100644 --- a/nylas/models/availability.py +++ b/nylas/models/availability.py @@ -87,7 +87,7 @@ class AvailabilityRules(TypedDict): default_open_hours: A default set of open hours to apply to all participants. You can overwrite these open hours for individual participants by specifying open_hours on the participant object. - round_robin_event_id: The ID on events that Nylas considers when calculating the order of + round_robin_group_id: The ID on events that Nylas considers when calculating the order of round-robin participants. This is used for both max-fairness and max-availability methods. """ @@ -95,7 +95,7 @@ class AvailabilityRules(TypedDict): availability_method: NotRequired[AvailabilityMethod] buffer: NotRequired[MeetingBuffer] default_open_hours: NotRequired[List[OpenHours]] - round_robin_event_id: NotRequired[str] + round_robin_group_id: NotRequired[str] class AvailabilityParticipant(TypedDict): diff --git a/nylas/models/messages.py b/nylas/models/messages.py index 12da517d..7d5d405e 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -132,7 +132,6 @@ class Message: class FindMessageQueryParams(TypedDict): - """ Query parameters for finding a message. @@ -144,7 +143,6 @@ class FindMessageQueryParams(TypedDict): class UpdateMessageRequest(TypedDict): - """ Request payload for updating a message. diff --git a/nylas/models/scheduler.py b/nylas/models/scheduler.py new file mode 100644 index 00000000..7b20b565 --- /dev/null +++ b/nylas/models/scheduler.py @@ -0,0 +1,527 @@ +from dataclasses import dataclass +from typing import Dict, Optional, List + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired, Literal +from nylas.models.events import Conferencing +from nylas.models.availability import AvailabilityRules, OpenHours + +BookingType = Literal["booking", "organizer-confirmation"] +BookingReminderType = Literal["email", "webhook"] +BookingRecipientType = Literal["host", "guest", "all"] +EmailLanguage = Literal["en", "es", "fr", "de", "nl", "sv", "ja", "zh"] +AdditionalFieldType = Literal[ + "text", + "multi_line_text", + "email", + "phone_number", + "dropdown", + "date", + "checkbox", + "radio_button", +] +AdditonalFieldOptionsType = Literal[ + "text", "email", "phone_number", "date", "checkbox", "radio_button" +] + + +@dataclass_json +@dataclass +class BookingConfirmedTemplate: + """ + Class representation of booking confirmed template settings. + + Attributes: + title: The title to replace the default 'Booking Confirmed' title. + body: The additional body to be appended after the default body. + """ + + title: Optional[str] = None + body: Optional[str] = None + + +@dataclass_json +@dataclass +class EmailTemplate: + """ + Class representation of email template settings. + + Attributes: + logo: The URL to a custom logo that appears at the top of the booking email. + booking_confirmed: Configurable settings specifically for booking confirmed emails. + """ + + # logo: Optional[str] = None + booking_confirmed: Optional[BookingConfirmedTemplate] = None + + +@dataclass_json +@dataclass +class AdditionalField: + """ + Class representation of an additional field. + + Atributes: + label: The text label to be displayed in the Scheduler UI. + type: The field type. Supported values are text, multi_line_text, + email, phone_number, dropdown, date, checkbox, and radio_button + required: Whether the field is required to be filled out by the guest when booking an event. + pattern: A regular expression pattern that the value of the field must match. + order: The order in which the field will be displayed in the Scheduler UI. + Fields with lower order values will be displayed first. + options: A list of options for the dropdown or radio_button types. + This field is required for the dropdown and radio_button types. + """ + + label: str + type: AdditionalFieldType + required: bool + pattern: Optional[str] = None + order: Optional[int] = None + options: Optional[AdditonalFieldOptionsType] = None + + +@dataclass_json +@dataclass +class SchedulerSettings: + """ + Class representation of scheduler settings. + + Attributes: + additional_fields: Definitions for additional fields to be displayed in the Scheduler UI. + available_days_in_future: Number of days in the future that Scheduler is available for scheduling events. + min_booking_notice: Minimum number of minutes in the future that a user can make a new booking. + min_cancellation_notice: Minimum number of minutes before a booking can be cancelled. + cancellation_policy: A message about the cancellation policy to display when booking an event. + rescheduling_url: The URL used to reschedule bookings. + cancellation_url: The URL used to cancel bookings. + organizer_confirmation_url: The URL used to confirm or cancel pending bookings. + confirmation_redirect_url: The custom URL to redirect to once the booking is confirmed. + hide_rescheduling_options: Whether the option to reschedule an event + is hidden in booking confirmations and notifications. + hide_cancellation_options: Whether the option to cancel an event + is hidden in booking confirmations and notifications. + hide_additional_guests: Whether to hide the additional guests field on the scheduling page. + email_template: Configurable settings for booking emails. + """ + + additional_fields: Optional[Dict[str, AdditionalField]] = None + available_days_in_future: Optional[int] = None + min_booking_notice: Optional[int] = None + min_cancellation_notice: Optional[int] = None + cancellation_policy: Optional[str] = None + rescheduling_url: Optional[str] = None + cancellation_url: Optional[str] = None + organizer_confirmation_url: Optional[str] = None + confirmation_redirect_url: Optional[str] = None + hide_rescheduling_options: Optional[bool] = None + hide_cancellation_options: Optional[bool] = None + hide_additional_guests: Optional[bool] = None + email_template: Optional[EmailTemplate] = None + + +@dataclass_json +@dataclass +class BookingReminder: + """ + Class representation of a booking reminder. + + Attributes: + type: The reminder type. + minutes_before_event: The number of minutes before the event to send the reminder. + recipient: The recipient of the reminder. + email_subject: The subject of the email reminder. + """ + + type: str + minutes_before_event: int + recipient: Optional[str] = None + email_subject: Optional[str] = None + + +@dataclass_json +@dataclass +class EventBooking: + """ + Class representation of an event booking. + + Attributes: + title: The title of the event. + description: The description of the event. + location: The location of the event. + timezone: The timezone for displaying times in confirmation email messages and reminders. + booking_type: The type of booking. + conferencing: Conference details for the event. + disable_emails: Whether Nylas sends email messages when an event is booked, cancelled, or rescheduled. + reminders: The list of reminders to send to participants before the event starts. + """ + + title: str + description: Optional[str] = None + location: Optional[str] = None + timezone: Optional[str] = None + booking_type: Optional[BookingType] = None + conferencing: Optional[Conferencing] = None + disable_emails: Optional[bool] = None + reminders: Optional[List[BookingReminder]] = None + + +@dataclass_json +@dataclass +class Availability: + """ + Class representation of availability settings. + + Attributes: + duration_minutes: The total number of minutes the event should last. + interval_minutes: The interval between meetings in minutes. + round_to: Nylas rounds each time slot to the nearest multiple of this number of minutes. + availability_rules: Availability rules for scheduling configuration. + """ + + duration_minutes: int + interval_minutes: Optional[int] = None + round_to: Optional[int] = None + availability_rules: Optional[AvailabilityRules] = None + + +@dataclass_json +@dataclass +class ParticipantBooking: + """ + Class representation of a participant booking. + + Attributes: + calendar_id: The calendar ID that the event is created in. + """ + + calendar_id: str + + +@dataclass_json +@dataclass +class ParticipantAvailability: + """ + Class representation of participant availability. + + Attributes: + calendar_ids: List of calendar IDs associated with the participant's email address. + open_hours: Open hours for this participant. The endpoint searches for free time slots during these open hours. + """ + + calendar_ids: List[str] + open_hours: Optional[List[OpenHours]] = None + + +@dataclass_json +@dataclass +class ConfigParticipant: + """ + Class representation of a booking participant. + + Attributes: + email: Participant's email address. + availability: Availability data for the participant. + booking: Booking data for the participant. + name: Participant's name. + is_organizer: Whether the participant is the organizer of the event. + timezone: The participant's timezone. + """ + + email: str + availability: ParticipantAvailability + booking: ParticipantBooking + name: Optional[str] = None + is_organizer: Optional[bool] = None + timezone: Optional[str] = None + + +@dataclass_json +@dataclass +class Configuration: + """ + Class representation of a scheduler configuration. + + Attributes: + participants: List of participants included in the scheduled event. + availability: Rules that determine available time slots for the event. + event_booking: Booking data for the event. + slug: Unique identifier for the Configuration object. + requires_session_auth: If true, scheduling Availability and Bookings endpoints require a valid session ID. + scheduler: Settings for the Scheduler UI. + appearance: Appearance settings for the Scheduler UI. + """ + + id: str + participants: List[ConfigParticipant] + availability: Availability + event_booking: EventBooking + slug: Optional[str] = None + requires_session_auth: Optional[bool] = None + scheduler: Optional[SchedulerSettings] = None + appearance: Optional[Dict[str, str]] = None + + +class CreateConfigurationRequest(TypedDict): + """ + Interface of a Nylas create configuration request. + + Attributes: + participants: List of participants included in the scheduled event. + availability: Rules that determine available time slots for the event. + event_booking: Booking data for the event. + slug: Unique identifier for the Configuration object. + requires_session_auth: If true, scheduling Availability and Bookings endpoints require a valid session ID. + scheduler: Settings for the Scheduler UI. + appearance: Appearance settings for the Scheduler UI. + """ + + participants: List[ConfigParticipant] + availability: Availability + event_booking: EventBooking + slug: NotRequired[str] + requires_session_auth: NotRequired[bool] + scheduler: NotRequired[SchedulerSettings] + appearance: NotRequired[Dict[str, str]] + + +class UpdateConfigurationRequest(TypedDict): + """ + Interface of a Nylas update configuration request. + + Attributes: + participants: List of participants included in the scheduled event. + availability: Rules that determine available time slots for the event. + event_booking: Booking data for the event. + slug: Unique identifier for the Configuration object. + requires_session_auth: If true, scheduling Availability and Bookings endpoints require a valid session ID. + scheduler: Settings for the Scheduler UI. + appearance: Appearance settings for the Scheduler UI. + """ + participants: NotRequired[List[ConfigParticipant]] + availability: NotRequired[Availability] + event_booking: NotRequired[EventBooking] + slug: NotRequired[str] + requires_session_auth: NotRequired[bool] + scheduler: NotRequired[SchedulerSettings] + appearance: NotRequired[Dict[str, str]] + + +class CreateSessionRequest(TypedDict): + """ + Interface of a Nylas create session request. + + Attributes: + configuration_id: The ID of the Configuration object whose settings are used for calculating availability. + If you're using session authentication (requires_session_auth is set to true), + configuration_id is not required. + slug: The slug of the Configuration object whose settings are used for calculating availability. + If you're using session authentication (requires_session_auth is set to true) or using configurationId, + slug is not required. + time_to_live: The time-to-live in seconds for the session + """ + configuration_id: NotRequired[str] + slug: NotRequired[str] + time_to_live: NotRequired[int] + + +@dataclass_json +@dataclass +class Session: + """ + Class representation of a session. + + Attributes: + session_id: The ID of the session. + """ + + session_id: str + + +@dataclass_json +@dataclass +class BookingGuest: + """ + Class representation of a booking guest. + + Attributes: + email: The email address of the guest. + name: The name of the guest. + """ + + email: str + name: str + + +@dataclass_json +@dataclass +class BookingParticipant: + """ + Class representation of a booking participant. + + Attributes: + email: The email address of the participant to include in the booking. + """ + + email: str + + +@dataclass_json +@dataclass +class CreateBookingRequest: + """ + Class representation of a create booking request. + + Attributes: + start_time: The event's start time, in Unix epoch format. + end_time: The event's end time, in Unix epoch format. + guest: Details about the guest that is creating the booking. + participants: List of participant email addresses from the + Configuration object to include in the booking. + timezone: The guest's timezone that is used in email notifications. + email_language: The language of the guest email notifications. + additional_guests: List of additional guest email addresses to include in the booking. + additional_fields: Dictionary of additional field keys mapped to + values populated by the guest in the booking form. + """ + + start_time: int + end_time: int + guest: BookingGuest + participants: Optional[List[BookingParticipant]] = None + timezone: Optional[str] = None + email_language: Optional[EmailLanguage] = None + additional_guests: Optional[List[BookingGuest]] = None + additional_fields: Optional[Dict[str, str]] = None + + +@dataclass_json +@dataclass +class BookingOrganizer: + """ + Class representation of a booking organizer. + + Attributes: + email: The email address of the participant designated as the organizer of the event. + name: The name of the participant designated as the organizer of the event. + """ + + email: str + name: Optional[str] = None + + +BookingStatus = Literal["pending", "confirmed", "cancelled"] +ConfirmBookingStatus = Literal["confirm", "cancel"] + + +@dataclass_json +@dataclass +class Booking: + """ + Class representation of a booking. + + Attributes: + booking_id: The unique ID of the booking. + event_id: The unique ID of the event associated with the booking. + title: The title of the event. + organizer: The participant designated as the organizer of the event. + status: The current status of the booking. + description: The description of the event. + """ + + booking_id: str + event_id: str + title: str + organizer: BookingOrganizer + status: BookingStatus + description: Optional[str] = None + + +@dataclass_json +@dataclass +class ConfirmBookingRequest: + """ + Class representation of a confirm booking request. + + Attributes: + salt: The salt extracted from the booking reference embedded in the organizer confirmation link. + status: The action to take on the pending booking. + cancellation_reason: The reason the booking is being cancelled. + """ + + salt: str + status: ConfirmBookingStatus + cancellation_reason: Optional[str] = None + + +@dataclass_json +@dataclass +class DeleteBookingRequest: + """ + Class representation of a delete booking request. + + Attributes: + cancellation_reason: The reason the booking is being cancelled. + """ + + cancellation_reason: Optional[str] = None + + +@dataclass_json +@dataclass +class RescheduleBookingRequest: + """ + Class representation of a reschedule booking request. + + Attributes: + start_time: The event's start time, in Unix epoch format. + end_time: The event's end time, in Unix epoch format. + """ + + start_time: int + end_time: int + + +@dataclass_json +@dataclass +class CreateBookingQueryParams: + """ + Class representation of query parameters for creating a booking. + + Attributes: + configuration_id: The ID of the Configuration object whose settings are used for calculating availability. + If you're using session authentication (requires_session_auth is set to true), configuration_id is not required. + slug: The slug of the Configuration object whose settings are used for calculating availability. + If you're using session authentication (requires_session_auth is set to true) or using configurationId, + slug is not required. + timezone: The timezone to use for the booking. + If not provided, Nylas uses the timezone from the Configuration object. + """ + + configuration_id: Optional[str] = None + slug: Optional[str] = None + timezone: Optional[str] = None + + +class FindBookingQueryParams: + """ + Class representation of query parameters for finding a booking. + + Attributes: + configuration_id: The ID of the Configuration object whose settings are used for calculating availability. + If you're using session authentication (requires_session_auth is set to true), configuration_id is not required. + slug: The slug of the Configuration object whose settings are used for calculating availability. + If you're using session authentication (requires_session_auth is set to true) + or using configurationId, slug is not required. + client_id: The client ID that was used to create the Configuration object. + client_id is required only if using slug. + """ + + configuration_id: Optional[str] = None + slug: Optional[str] = None + client_id: Optional[str] = None + + +ConfirmBookingQueryParams = FindBookingQueryParams +RescheduleBookingQueryParams = FindBookingQueryParams +DestroyBookingQueryParams = FindBookingQueryParams diff --git a/nylas/resources/bookings.py b/nylas/resources/bookings.py new file mode 100644 index 00000000..0fa3cf55 --- /dev/null +++ b/nylas/resources/bookings.py @@ -0,0 +1,176 @@ +from nylas.config import RequestOverrides +from nylas.handler.api_resources import ( + CreatableApiResource, + DestroyableApiResource, + FindableApiResource, + ListableApiResource, + UpdatableApiResource, + UpdatablePatchApiResource, +) +from nylas.models.response import DeleteResponse, Response +from nylas.models.scheduler import ( + Booking, + ConfirmBookingQueryParams, + ConfirmBookingRequest, + CreateBookingQueryParams, + CreateBookingRequest, + DeleteBookingRequest, + DestroyBookingQueryParams, + RescheduleBookingRequest, + FindBookingQueryParams, + RescheduleBookingQueryParams, +) + + +class Bookings( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + UpdatablePatchApiResource, + DestroyableApiResource, +): + """ + Nylas Bookings API + + The Nylas Bookings API allows you to create new bookings or manage existing ones, as well as getting + bookings details for a user. + + A booking can be accessed by one, or several people, and can contain events. + """ + + def find( + self, + booking_id: str, + query_params: FindBookingQueryParams = None, + overrides: RequestOverrides = None, + ) -> Response[Booking]: + """ + Return a Booking. + + Args: + identifier: The identifier of the Grant to act upon. + booking_id: The identifier of the Booking to get. + query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. + + Returns: + The Booking. + """ + + return super().find( + path=f"/v3/scheduling/bookings/{booking_id}", + query_params=query_params, + response_type=Booking, + overrides=overrides, + ) + + def create( + self, + request_body: CreateBookingRequest, + query_params: CreateBookingQueryParams = None, + overrides: RequestOverrides = None, + ) -> Response[Booking]: + """ + Create a Booking. + + Args: + request_body: The values to create booking with. + overrides: The request overrides to use for the request. + query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. + + Returns: + The created Booking. + """ + + return super().create( + path="/v3/scheduling/bookings", + request_body=request_body, + query_params=query_params, + response_type=Booking, + overrides=overrides, + ) + + def confirm( + self, + booking_id: str, + request_body: ConfirmBookingRequest, + query_params: ConfirmBookingQueryParams = None, + overrides: RequestOverrides = None, + ) -> Response[Booking]: + """ + Confirm a Booking. + + Args: + booking_id: The identifier of the Booking to confirm. + request_body: The values to confirm booking with. + query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. + + Returns: + The confirmed Booking. + """ + + return super().update( + path=f"/v3/scheduling/bookings/{booking_id}", + request_body=request_body, + query_params=query_params, + response_type=Booking, + overrides=overrides, + ) + + def reschedule( + self, + booking_id: str, + request_body: RescheduleBookingRequest, + query_params: RescheduleBookingQueryParams = None, + overrides: RequestOverrides = None, + ) -> Response[Booking]: + """ + Reschedule a Booking. + + Args: + booking_id: The identifier of the Booking to reschedule. + request_body: The values to reschedule booking with. + query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. + + Returns: + The rescheduled Booking. + """ + + return super().patch( + path=f"/v3/scheduling/bookings/{booking_id}", + request_body=request_body, + query_params=query_params, + response_type=Booking, + overrides=overrides, + ) + + def destroy( + self, + booking_id: str, + request_body: DeleteBookingRequest, + query_params: DestroyBookingQueryParams = None, + overrides: RequestOverrides = None, + ) -> DeleteResponse: + """ + Delete a Booking. + + Args: + booking_id: The identifier of the Booking to delete. + request_body: The reason to delete booking with. + query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. + + Returns: + None. + """ + + return super().destroy( + path=f"/v3/scheduling/bookings/{booking_id}", + request_body=request_body, + query_params=query_params, + overrides=overrides, + ) diff --git a/nylas/resources/configurations.py b/nylas/resources/configurations.py new file mode 100644 index 00000000..85c47ec6 --- /dev/null +++ b/nylas/resources/configurations.py @@ -0,0 +1,160 @@ +from nylas.config import RequestOverrides +from nylas.handler.api_resources import ( + CreatableApiResource, + DestroyableApiResource, + FindableApiResource, + ListableApiResource, + UpdatableApiResource, +) +from nylas.models.list_query_params import ListQueryParams +from nylas.models.response import DeleteResponse, ListResponse, Response +from nylas.models.scheduler import ( + Configuration, + CreateConfigurationRequest, + UpdateConfigurationRequest, +) + + +class ListConfigurationsParams(ListQueryParams): + """ + Interface of the query parameters for listing configurations. + + Attributes: + limit: The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token: An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + identifier: The identifier of the Grant to act upon. + """ + + identifier: str + + +class Configurations( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Configuration API + + The Nylas configuration API allows you to create new configurations or manage existing ones, as well as getting + configurations details for a user. + + Nylas Scheduler stores Configuration objects in the Scheduler database and loads + them as Scheduling Pages in the Scheduler UI. + """ + + def list( + self, + identifier: str, + query_params: ListConfigurationsParams = None, + overrides: RequestOverrides = None, + ) -> ListResponse[Configuration]: + """ + Return all Configurations. + + Args: + identifier: The identifier of the Grant to act upon. + overrides: The request overrides to use for the request. + + Returns: + The list of Configurations. + """ + # import pdb; pdb.set_trace(); + res = super().list( + path=f"/v3/grants/{identifier}/scheduling/configurations", + overrides=overrides, + response_type=Configuration, + query_params=query_params, + ) + print("What's this", res) + return res + + def find( + self, identifier: str, config_id: str, overrides: RequestOverrides = None + ) -> Response[Configuration]: + """ + Return a Configuration. + + Args: + identifier: The identifier of the Grant to act upon. + config_id: The identifier of the Configuration to get. + overrides: The request overrides to use for the request. + + Returns: + The Configuration object. + """ + return super().find( + path=f"/v3/grants/{identifier}/scheduling/configurations/{config_id}", + overrides=overrides, + response_type=Configuration, + ) + + def create( + self, + identifier: str, + request_body: CreateConfigurationRequest, + overrides: RequestOverrides = None, + ) -> Response[Configuration]: + """ + Create a new Configuration. + + Args: + identifier: The identifier of the Grant to act upon. + data: The data to create the Configuration with. + overrides: The request overrides to use for the request. + + Returns: + The Configuration object. + """ + return super().create( + path=f"/v3/grants/{identifier}/scheduling/configurations", + request_body=request_body, + overrides=overrides, + response_type=Configuration, + ) + + def update( + self, + identifier: str, + config_id: str, + request_body: UpdateConfigurationRequest, + overrides: RequestOverrides = None, + ) -> Response[Configuration]: + """ + Update a Configuration. + + Args: + identifier: The identifier of the Grant to act upon. + config_id: The identifier of the Configuration to update. + data: The data to update the Configuration with. + overrides: The request overrides to use for the request. + + Returns: + The Configuration object. + """ + return super().update( + path=f"/v3/grants/{identifier}/scheduling/configurations/{config_id}", + request_body=request_body, + overrides=overrides, + response_type=Configuration, + ) + + def destroy( + self, identifier: str, config_id: str, overrides: RequestOverrides = None + ) -> DeleteResponse: + """ + Delete a Configuration. + + Args: + identifier: The identifier of the Grant to act upon. + config_id: The identifier of the Configuration to delete. + overrides: The request overrides to use for the request. + """ + return super().destroy( + path=f"/v3/grants/{identifier}/scheduling/configurations/{config_id}", + overrides=overrides, + ) diff --git a/nylas/resources/scheduler.py b/nylas/resources/scheduler.py new file mode 100644 index 00000000..e337de46 --- /dev/null +++ b/nylas/resources/scheduler.py @@ -0,0 +1,42 @@ +from nylas.resources.bookings import Bookings +from nylas.resources.configurations import Configurations +from nylas.resources.sessions import Sessions + + +class Scheduler: + """ + Class representation of a Nylas Scheduler API. + """ + + def __init__(self, http_client): + self.http_client = http_client + + @property + def configurations(self) -> Configurations: + """ + Access the Configurations API. + + Returns: + The Configurations API. + """ + return Configurations(self.http_client) + + @property + def bookings(self) -> Bookings: + """ + Access the Bookings API. + + Returns: + The Bookings API. + """ + return Bookings(self.http_client) + + @property + def sessions(self) -> Sessions: + """ + Access the Sessions API. + + Returns: + The Sessions API. + """ + return Sessions(self.http_client) diff --git a/nylas/resources/sessions.py b/nylas/resources/sessions.py new file mode 100644 index 00000000..556009a4 --- /dev/null +++ b/nylas/resources/sessions.py @@ -0,0 +1,56 @@ +from nylas.config import RequestOverrides +from nylas.handler.api_resources import CreatableApiResource, DestroyableApiResource +from nylas.models.response import DeleteResponse, Response +from nylas.models.scheduler import CreateSessionRequest, Session + + +class Sessions(CreatableApiResource, DestroyableApiResource): + """ + Nylas Sessions API + + The Nylas Sessions API allows you to create new sessions or manage existing ones. + """ + + def create( + self, + request_body: CreateSessionRequest, + overrides: RequestOverrides = None, + ) -> Response[Session]: + """ + Create a Session. + + Args: + request_body: The request body to create the Session. + overrides: The request overrides to use for the request. + + Returns: + The Session. + """ + + return super().create( + path="/v3/scheduling/sessions", + request_body=request_body, + response_type=Session, + overrides=overrides, + ) + + def destroy( + self, + session_id: str, + overrides: RequestOverrides = None, + ) -> DeleteResponse: + """ + Destroy a Session. + + Args: + session_id: The identifier of the Session to destroy. + overrides: The request overrides to use for the request. + + Returns: + None. + """ + + return super().destroy( + path=f"/v3/scheduling/sessions/{session_id}", + overrides=overrides, + ) diff --git a/tests/resources/test_bookings.py b/tests/resources/test_bookings.py new file mode 100644 index 00000000..15dc87e2 --- /dev/null +++ b/tests/resources/test_bookings.py @@ -0,0 +1,116 @@ +from nylas.resources.bookings import Bookings + +from nylas.models.scheduler import Booking + +class TestBooking: + def test_booking_deserialization(self): + booking_json = { + "booking_id": "AAAA-BBBB-1111-2222", + "event_id": "CCCC-DDDD-3333-4444", + "title": "My test event", + "organizer": { + "name": "John Doe", + "email": "user@example.com" + }, + "status": "booked", + "description": "This is an example of a description." + } + + booking = Booking.from_dict(booking_json) + + assert booking.booking_id == "AAAA-BBBB-1111-2222" + assert booking.event_id == "CCCC-DDDD-3333-4444" + assert booking.title == "My test event" + assert booking.organizer.name == "John Doe" + assert booking.organizer.email == "user@example.com" + assert booking.status == "booked" + assert booking.description == "This is an example of a description." + + def test_find_booking(self, http_client_response): + bookings = Bookings(http_client_response) + + bookings.find(booking_id="booking-123") + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/scheduling/bookings/booking-123", + None, + None, + None, + overrides=None + ) + + def test_create_booking(self, http_client_response): + bookings = Bookings(http_client_response) + request_body = { + "start_time": 1730725200, + "end_time": 1730727000, + "participants": [ + { + "email": "test@nylas.com" + } + ], + "guest": { + "name": "TEST", + "email": "user@gmail.com" + } + } + bookings.create(request_body=request_body) + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/scheduling/bookings", + None, + None, + request_body, + overrides=None + ) + + def test_confirm_booking(self, http_client_response): + bookings = Bookings(http_client_response) + request_body = { + "salt": "_zfg12it", + "status": "cancelled", + } + + bookings.confirm(booking_id="booking-123", request_body=request_body) + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/scheduling/bookings/booking-123", + None, + None, + request_body, + overrides=None + ) + + def test_reschedule_booking(self, http_client_response): + bookings = Bookings(http_client_response) + request_body = { + "start_time": 1730725200, + "end_time": 1730727000, + } + + bookings.reschedule(booking_id="booking-123", request_body=request_body) + http_client_response._execute.assert_called_once_with( + "PATCH", + "/v3/scheduling/bookings/booking-123", + None, + None, + request_body, + overrides=None + ) + + def test_destroy_booking(self, http_client_delete_response): + bookings = Bookings(http_client_delete_response) + request_body = { + "cancellation_reason": "I am no longer available at this time." + } + bookings.destroy(booking_id="booking-123", request_body=request_body) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/scheduling/bookings/booking-123", + None, + None, + request_body, + overrides=None + ) \ No newline at end of file diff --git a/tests/resources/test_calendars.py b/tests/resources/test_calendars.py index 2f0aae9d..7fe6b1fd 100644 --- a/tests/resources/test_calendars.py +++ b/tests/resources/test_calendars.py @@ -167,7 +167,7 @@ def test_get_availability(self, http_client_response): "exdates": ["2021-03-01"], } ], - "round_robin_event_id": "event-123", + "round_robin_group_id": "event-123", }, } diff --git a/tests/resources/test_configurations.py b/tests/resources/test_configurations.py new file mode 100644 index 00000000..8b2388f3 --- /dev/null +++ b/tests/resources/test_configurations.py @@ -0,0 +1,234 @@ +from nylas.resources.configurations import Configurations + +from nylas.models.scheduler import Configuration + +class TestConfiguration: + def test_configuration_deserialization(self): + configuration_json = { + "id": "abc-123-configuration-id", + "slug": None, + "participants": [ + { + "email": "test@nylas.com", + "is_organizer": True, + "name": "Test", + "availability": { + "calendar_ids": [ + "primary" + ], + "open_hours": [ + { + "days": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "exdates": None, + "timezone": "", + "start": "09:00", + "end": "17:00" + } + ] + }, + "booking": { + "calendar_id": "primary" + }, + "timezone": "" + } + ], + "requires_session_auth": False, + "availability": { + "duration_minutes": 30, + "interval_minutes": 15, + "round_to": 15, + "availability_rules": { + "availability_method": "collective", + "buffer": { + "before": 60, + "after": 0 + }, + "default_open_hours": [ + { + "days": [ + 0, + 1, + 2, + 5, + 6 + ], + "exdates": None, + "timezone": "", + "start": "09:00", + "end": "18:00" + } + ], + "round_robin_group_id": "" + } + }, + "event_booking": { + "title": "Updated Title", + "timezone": "utc", + "description": "", + "location": "none", + "booking_type": "booking", + "conferencing": { + "provider": "Microsoft Teams", + "autocreate": { + "conf_grant_id": "", + "conf_settings": None + } + }, + "hide_participants": None, + "disable_emails": None + }, + "scheduler": { + "available_days_in_future": 7, + "min_cancellation_notice": 60, + "min_booking_notice": 120, + "confirmation_redirect_url": "", + "hide_rescheduling_options": False, + "hide_cancellation_options": False, + "hide_additional_guests": True, + "cancellation_policy": "", + "email_template": { + "booking_confirmed": {} + } + }, + "appearance": { + "submit_button_label": "submit", + "thank_you_message": "thank you for your business. your booking was successful." + } + } + + configuration = Configuration.from_dict(configuration_json) + + assert configuration.id == "abc-123-configuration-id" + assert configuration.slug == None + assert configuration.participants[0].email == "test@nylas.com" + assert configuration.participants[0].is_organizer == True + assert configuration.participants[0].name == "Test" + assert configuration.participants[0].availability.calendar_ids == ["primary"] + assert configuration.participants[0].availability.open_hours[0]["days"] == [0, 1, 2, 3, 4, 5, 6] + assert configuration.participants[0].availability.open_hours[0]["exdates"] == None + assert configuration.participants[0].availability.open_hours[0]["timezone"] == "" + assert configuration.participants[0].booking.calendar_id == "primary" + assert configuration.participants[0].timezone == "" + assert configuration.requires_session_auth == False + assert configuration.availability.duration_minutes == 30 + assert configuration.availability.interval_minutes == 15 + assert configuration.availability.round_to == 15 + assert configuration.availability.availability_rules["availability_method"] == "collective" + assert configuration.availability.availability_rules["buffer"]["before"] == 60 + assert configuration.availability.availability_rules["buffer"]["after"] == 0 + assert configuration.availability.availability_rules["default_open_hours"][0]["days"] == [0, 1, 2, 5, 6] + assert configuration.availability.availability_rules["default_open_hours"][0]["exdates"] == None + assert configuration.availability.availability_rules["default_open_hours"][0]["timezone"] == "" + assert configuration.availability.availability_rules["default_open_hours"][0]["start"] == "09:00" + assert configuration.availability.availability_rules["default_open_hours"][0]["end"] == "18:00" + assert configuration.event_booking.title == "Updated Title" + assert configuration.event_booking.timezone == "utc" + assert configuration.event_booking.description == "" + assert configuration.event_booking.location == "none" + assert configuration.event_booking.booking_type == "booking" + assert configuration.event_booking.conferencing.provider == "Microsoft Teams" + assert configuration.scheduler.available_days_in_future == 7 + assert configuration.scheduler.min_cancellation_notice == 60 + assert configuration.scheduler.min_booking_notice == 120 + assert configuration.appearance["submit_button_label"] == "submit" + + def test_list_configurations(self, http_client_list_response): + configurations = Configurations(http_client_list_response) + configurations.list(identifier="grant-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/grant-123/scheduling/configurations", + None, + None, + None, + overrides=None, + ) + + def test_find_configuration(self, http_client_response): + configurations = Configurations(http_client_response) + configurations.find(identifier="grant-123", config_id="config-123") + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/grant-123/scheduling/configurations/config-123", + None, + None, + None, + overrides=None, + ) + + def test_create_configuration(self, http_client_response): + configurations = Configurations(http_client_response) + request_body = { + "requires_session_auth": False, + "participants": [ + { + "name": "Test", + "email": "test@nylas.com", + "is_organizer": True, + "availability": { + "calendar_ids": [ + "primary" + ] + }, + "booking": { + "calendar_id": "primary" + } + } + ], + "availability": { + "duration_minutes": 30 + }, + "event_booking": { + "title": "My test event" + } + } + configurations.create(identifier="grant-123", request_body=request_body) + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/grant-123/scheduling/configurations", + None, + None, + request_body, + overrides=None, + ) + + def test_update_configuration(self, http_client_response): + configurations = Configurations(http_client_response) + request_body = { + "event_booking": { + "title": "My test event" + } + } + configurations.update(identifier="grant-123", config_id="config-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/grant-123/scheduling/configurations/config-123", + None, + None, + request_body, + overrides=None, + ) + + def test_destroy_configuration(self, http_client_delete_response): + configurations = Configurations(http_client_delete_response) + configurations.destroy(identifier="grant-123", config_id="config-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/grant-123/scheduling/configurations/config-123", + None, + None, + None, + overrides=None, + ) \ No newline at end of file diff --git a/tests/resources/test_sessions.py b/tests/resources/test_sessions.py new file mode 100644 index 00000000..5d4590db --- /dev/null +++ b/tests/resources/test_sessions.py @@ -0,0 +1,45 @@ +from nylas.resources.scheduler import Sessions + +from nylas.models.scheduler import Session + +class TestSession: + def test_session_deserialization(self): + session_json = { + "session_id": "session-id", + } + + session = Session.from_dict(session_json) + + assert session.session_id == "session-id" + + def test_create_session(self, http_client_response): + sessions = Sessions(http_client_response) + request_body = { + "configuration_id": "configuration-123", + "time_to_live": 30 + } + + sessions.create(request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/scheduling/sessions", + None, + None, + request_body, + overrides=None, + ) + + def test_destroy_session(self, http_client_delete_response): + sessions = Sessions(http_client_delete_response) + + sessions.destroy(session_id="session-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/scheduling/sessions/session-123", + None, + None, + None, + overrides=None, + ) \ No newline at end of file From b68c89331be14dda0e1fe4d169eeb4c70004ec17 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 13 Dec 2024 01:56:52 -0500 Subject: [PATCH 118/186] fix: handle missing attributes when using select param (#397) --- nylas/models/response.py | 2 +- tests/resources/test_threads.py | 44 ++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/nylas/models/response.py b/nylas/models/response.py index d00798c5..f8a91652 100644 --- a/nylas/models/response.py +++ b/nylas/models/response.py @@ -94,7 +94,7 @@ def from_dict(cls, resp: dict, generic_type): converted_data = [] for item in resp["data"]: - converted_data.append(generic_type.from_dict(item)) + converted_data.append(generic_type.from_dict(item, infer_missing=True)) return cls( data=converted_data, diff --git a/tests/resources/test_threads.py b/tests/resources/test_threads.py index 47c9cbec..749c4b90 100644 --- a/tests/resources/test_threads.py +++ b/tests/resources/test_threads.py @@ -1,7 +1,7 @@ from nylas.models.attachments import Attachment from nylas.models.events import EmailName +from nylas.models.response import ListResponse from nylas.resources.threads import Threads - from nylas.models.threads import Thread @@ -147,6 +147,48 @@ def test_list_threads_with_query_params(self, http_client_list_response): overrides=None, ) + def test_list_threads_with_select_param(self, http_client_list_response): + threads = Threads(http_client_list_response) + + # Set up mock response data + http_client_list_response._execute.return_value = { + "request_id": "abc-123", + "data": [{ + "id": "thread-123", + "has_attachments": False, + "earliest_message_date": 1634149514, + "participants": [ + {"email": "test@example.com", "name": "Test User"} + ], + "snippet": "Test snippet", + "unread": False, + "subject": "Test subject", + "message_ids": ["msg-123"], + "folders": ["folder-123"] + }] + } + + # Call the API method + result = threads.list( + identifier="abc-123", + query_params={ + "select": "id,has_attachments,earliest_message_date,participants,snippet,unread,subject,message_ids,folders" + } + ) + + # Verify API call + http_client_list_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/threads", + None, + {"select": "id,has_attachments,earliest_message_date,participants,snippet,unread,subject,message_ids,folders"}, + None, + overrides=None, + ) + + # The actual response validation is handled by the mock in conftest.py + assert result is not None + def test_find_thread(self, http_client_response): threads = Threads(http_client_response) From 5922f8c589d1d7e5683ec4d4cfc65a860c2064f0 Mon Sep 17 00:00:00 2001 From: kraju3 <35513942+kraju3@users.noreply.github.com> Date: Mon, 16 Dec 2024 09:49:07 -0600 Subject: [PATCH 119/186] Validate attachment response and throw error (#396) * Validate attachment response and throw error * Update nylas/handler/http_client.py Co-authored-by: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> * Resolve PR comments * Fix pylint warnings * Check the status code before and remove utlity function * Added changelog --------- Co-authored-by: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> --- CHANGELOG.md | 1 + nylas/handler/http_client.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f66fc440..0f96548c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased -------------- * Add support for Scheduler APIs +* Fixed attachment download response handling v6.4.0 ---------------- diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index 5b1d77ab..a2a7be2f 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -45,7 +45,6 @@ def _validate_response(response: Response) -> dict: return json - def _build_query_params(base_url: str, query_params: dict = None) -> str: query_param_parts = [] for key, value in query_params.items(): @@ -109,7 +108,7 @@ def _execute_download_request( query_params=None, stream=False, overrides=None, - ) -> Union[bytes, Response]: + ) -> Union[bytes, Response,dict]: request = self._build_request("GET", path, headers, query_params, overrides) timeout = self.timeout @@ -124,6 +123,9 @@ def _execute_download_request( stream=stream, ) + if not response.ok: + return _validate_response(response) + # If we stream an iterator for streaming the content, otherwise return the entire byte array if stream: return response From e0c4cf26dd129ced511965b17d152b031f7b8b1d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 20 Dec 2024 14:06:21 -0500 Subject: [PATCH 120/186] Add metadata field to SendMessageRequest and Message model (#399) * Add metadata field to SendMessageRequest model - Added metadata field to CreateDraftRequest which is inherited by SendMessageRequest - Added test cases for metadata in draft creation and message sending - Updated docstring to include metadata field description Fixes #394 Co-Authored-By: Aaron de Mello * Update test assertion to use keyword arguments Co-Authored-By: Aaron de Mello * Update CHANGELOG.md for metadata field addition Co-Authored-By: Aaron de Mello * Update changelog format to match version entry style Co-Authored-By: Aaron de Mello * Add E2E example for metadata field functionality Co-Authored-By: Aaron de Mello * Update metadata example to use local package instead of pip install Co-Authored-By: Aaron de Mello * Added the metadata property to the Message model --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Aaron de Mello --- CHANGELOG.md | 3 +- examples/metadata_field_demo/README.md | 67 ++++ .../metadata_field_demo/metadata_example.py | 137 +++++++ nylas/models/drafts.py | 4 +- nylas/models/messages.py | 1 + tests/resources/test_drafts.py | 41 +++ tests/resources/test_messages.py | 336 ++++++++++++++++++ 7 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 examples/metadata_field_demo/README.md create mode 100644 examples/metadata_field_demo/metadata_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f96548c..0b2bbd58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ nylas-python Changelog ====================== Unreleased --------------- +---------------- * Add support for Scheduler APIs * Fixed attachment download response handling +* Add metadata field support for drafts and messages through CreateDraftRequest model v6.4.0 ---------------- diff --git a/examples/metadata_field_demo/README.md b/examples/metadata_field_demo/README.md new file mode 100644 index 00000000..f5310f31 --- /dev/null +++ b/examples/metadata_field_demo/README.md @@ -0,0 +1,67 @@ +# Metadata Field Example + +This example demonstrates how to use metadata fields when creating drafts and sending messages using the Nylas Python SDK. + +## Features + +- Create drafts with custom metadata fields +- Send messages with custom metadata fields +- Error handling and environment variable configuration +- Clear output and status messages + +## Prerequisites + +1. A Nylas account with API access +2. Python 3.x installed +3. Local installation of the Nylas Python SDK (this repository) + +## Setup + +1. Install the SDK in development mode from the repository root: +```bash +cd /path/to/nylas-python +pip install -e . +``` + +2. Set your environment variables: +```bash +export NYLAS_API_KEY="your_api_key" +export NYLAS_GRANT_ID="your_grant_id" +export TEST_EMAIL="recipient@example.com" # Optional +``` + +3. Run the example from the repository root: +```bash +python examples/metadata_field_demo/metadata_example.py +``` + +## Example Output + +``` +Demonstrating Metadata Field Usage +================================= + +1. Creating draft with metadata... +โœ“ Created draft with ID: draft-abc123 + Request ID: req-xyz789 + +2. Sending message with metadata... +โœ“ Sent message with ID: msg-def456 + Request ID: req-uvw321 + +Example completed successfully! +``` + +## Error Handling + +The example includes proper error handling for: +- Missing environment variables +- API authentication errors +- Draft creation failures +- Message sending failures + +## Documentation + +For more information about the Nylas Python SDK and its features, visit: +- [Nylas Python SDK Documentation](https://developer.nylas.com/docs/sdks/python/) +- [Nylas API Reference](https://developer.nylas.com/docs/api/) diff --git a/examples/metadata_field_demo/metadata_example.py b/examples/metadata_field_demo/metadata_example.py new file mode 100644 index 00000000..737dbaf7 --- /dev/null +++ b/examples/metadata_field_demo/metadata_example.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Using Metadata Fields with Drafts and Messages + +This example demonstrates how to use metadata fields when creating drafts +and sending messages using the Nylas Python SDK. + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID + TEST_EMAIL: Email address for sending test messages (optional) + +Usage: + First, install the SDK in development mode: + cd /path/to/nylas-python + pip install -e . + + Then set environment variables and run: + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + export TEST_EMAIL="recipient@example.com" + python examples/metadata_field_demo/metadata_example.py +""" + +import os +import sys +from typing import Dict, Any, Optional + +# Import from local nylas package +from nylas import Client +from nylas.models.errors import NylasApiError + + +def get_env_or_exit(var_name: str, required: bool = True) -> Optional[str]: + """Get an environment variable or exit if required and not found.""" + value = os.getenv(var_name) + if required and not value: + print(f"Error: {var_name} environment variable is required") + sys.exit(1) + return value + + +def create_draft_with_metadata( + client: Client, grant_id: str, metadata: Dict[str, Any], recipient: str +) -> str: + """Create a draft message with metadata fields.""" + try: + draft_request = { + "subject": "Test Draft with Metadata", + "to": [{"email": recipient}], + "body": "This is a test draft with metadata fields.", + "metadata": metadata + } + + draft, request_id = client.drafts.create( + identifier=grant_id, + request_body=draft_request + ) + print(f"โœ“ Created draft with ID: {draft.id}") + print(f" Request ID: {request_id}") + return draft.id + except NylasApiError as e: + print(f"โœ— Failed to create draft: {e}") + sys.exit(1) + + +def send_message_with_metadata( + client: Client, grant_id: str, metadata: Dict[str, Any], recipient: str +) -> str: + """Send a message directly with metadata fields.""" + try: + message_request = { + "subject": "Test Message with Metadata", + "to": [{"email": recipient}], + "body": "This is a test message with metadata fields.", + "metadata": metadata + } + + message, request_id = client.messages.send( + identifier=grant_id, + request_body=message_request + ) + print(f"โœ“ Sent message with ID: {message.id}") + print(f" Request ID: {request_id}") + + return message.id + except NylasApiError as e: + print(f"โœ— Failed to send message: {e}") + sys.exit(1) + + +def main(): + """Main function demonstrating metadata field usage.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + recipient = get_env_or_exit("TEST_EMAIL", required=False) or "recipient@example.com" + + # Initialize Nylas client + client = Client( + api_key=api_key, + ) + + # Example metadata + metadata = { + "campaign_id": "example-123", + "user_id": "user-456", + "custom_field": "test-value" + } + + print("\nDemonstrating Metadata Field Usage") + print("=================================") + + # Create a draft with metadata + print("\n1. Creating draft with metadata...") + draft_id = create_draft_with_metadata(client, grant_id, metadata, recipient) + + # Send a message with metadata + print("\n2. Sending message with metadata...") + message_id = send_message_with_metadata(client, grant_id, metadata, recipient) + + print("\nExample completed successfully!") + + # Get the draft and message to demonstrate metadata retrieval + draft = client.drafts.find(identifier=grant_id, draft_id=draft_id) + message = client.messages.find(identifier=grant_id, message_id=message_id) + + print("\nRetrieved Draft Metadata:") + print("-------------------------") + print(draft.data) + + print("\nRetrieved Message Metadata:") + print("---------------------------") + print(message.data) + +if __name__ == "__main__": + main() diff --git a/nylas/models/drafts.py b/nylas/models/drafts.py index f77a1254..f6e38037 100644 --- a/nylas/models/drafts.py +++ b/nylas/models/drafts.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, get_type_hints +from typing import List, Dict, Any, get_type_hints from dataclasses_json import dataclass_json from typing_extensions import TypedDict, NotRequired @@ -87,6 +87,7 @@ class CreateDraftRequest(TypedDict): reply_to_message_id: The ID of the message that you are replying to. tracking_options: Options for tracking opens, links, and thread replies. custom_headers: Custom headers to add to the message. + metadata: A dictionary of key-value pairs storing additional data. """ body: NotRequired[str] @@ -101,6 +102,7 @@ class CreateDraftRequest(TypedDict): reply_to_message_id: NotRequired[str] tracking_options: NotRequired[TrackingOptions] custom_headers: NotRequired[List[CustomHeader]] + metadata: NotRequired[Dict[str, Any]] UpdateDraftRequest = CreateDraftRequest diff --git a/nylas/models/messages.py b/nylas/models/messages.py index 7d5d405e..b6f0bbdc 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -80,6 +80,7 @@ class Message: date: Optional[int] = None schedule_id: Optional[str] = None send_at: Optional[int] = None + metadata: Optional[Dict[str, Any]] = None # Need to use Functional typed dicts because "from" and "in" are Python diff --git a/tests/resources/test_drafts.py b/tests/resources/test_drafts.py index ecd51ebd..8613a2fc 100644 --- a/tests/resources/test_drafts.py +++ b/tests/resources/test_drafts.py @@ -2,6 +2,7 @@ from nylas.models.drafts import Draft from nylas.resources.drafts import Drafts +from nylas.resources.messages import Messages class TestDraft: @@ -143,6 +144,27 @@ def test_create_draft(self, http_client_response): overrides=None, ) + def test_create_draft_with_metadata(self, http_client_response): + drafts = Drafts(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + "metadata": {"custom_field": "value", "another_field": 123} + } + + drafts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/drafts", + None, + None, + request_body, + overrides=None, + ) + def test_create_draft_small_attachment(self, http_client_response): drafts = Drafts(http_client_response) request_body = { @@ -349,6 +371,25 @@ def test_send_draft(self, http_client_response): method="POST", path="/v3/grants/abc-123/drafts/draft-123", overrides=None ) + def test_send_message_with_metadata(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + "metadata": {"custom_field": "value", "another_field": 123} + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + def test_send_draft_encoded_id(self, http_client_response): drafts = Drafts(http_client_response) diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 1af9a957..5e9b3720 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -37,6 +37,7 @@ def test_message_deserialization(self): "thread_id": "1t8tv3890q4vgmwq6pmdwm8qgsaer", "to": [{"name": "Jon Snow", "email": "j.snow@example.com"}], "unread": True, + "metadata": {"custom_field": "value", "another_field": 123}, } message = Message.from_dict(message_json) @@ -64,6 +65,341 @@ def test_message_deserialization(self): assert message.thread_id == "1t8tv3890q4vgmwq6pmdwm8qgsaer" assert message.to == [{"name": "Jon Snow", "email": "j.snow@example.com"}] assert message.unread is True + assert message.metadata == {"custom_field": "value", "another_field": 123} + + def test_list_messages(self, http_client_list_response): + messages = Messages(http_client_list_response) + + messages.list(identifier="abc-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/messages", None, None, None, overrides=None + ) + + def test_list_messages_with_query_params(self, http_client_list_response): + messages = Messages(http_client_list_response) + + messages.list( + identifier="abc-123", + query_params={ + "subject": "Hello from Nylas!", + }, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages", + None, + { + "subject": "Hello from Nylas!", + }, + None, + overrides=None, + ) + + def test_find_message(self, http_client_response): + messages = Messages(http_client_response) + + messages.find(identifier="abc-123", message_id="message-123") + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/message-123", + None, + None, + None, + overrides=None, + ) + + def test_find_message_encoded_id(self, http_client_response): + messages = Messages(http_client_response) + + messages.find( + identifier="abc-123", + message_id="", + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + None, + overrides=None, + ) + + def test_find_message_with_query_params(self, http_client_response): + messages = Messages(http_client_response) + + messages.find( + identifier="abc-123", + message_id="message-123", + query_params={"fields": "standard"}, + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/message-123", + None, + {"fields": "standard"}, + None, + overrides=None, + ) + + def test_update_message(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "starred": True, + "unread": False, + "folders": ["folder-123"], + "metadata": {"foo": "bar"}, + } + + messages.update( + identifier="abc-123", + message_id="message-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/messages/message-123", + None, + None, + request_body, + overrides=None, + ) + + def test_update_message_encoded_id(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "starred": True, + "unread": False, + "folders": ["folder-123"], + "metadata": {"foo": "bar"}, + } + + messages.update( + identifier="abc-123", + message_id="", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + request_body, + overrides=None, + ) + + def test_destroy_message(self, http_client_delete_response): + messages = Messages(http_client_delete_response) + + messages.destroy(identifier="abc-123", message_id="message-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/messages/message-123", + None, + None, + None, + overrides=None, + ) + + def test_destroy_message_encoded_id(self, http_client_delete_response): + messages = Messages(http_client_delete_response) + + messages.destroy( + identifier="abc-123", + message_id="", + ) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + None, + overrides=None, + ) + + def test_send_message(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + "metadata": {"custom_field": "value", "another_field": 123}, + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + + def test_send_message_small_attachment(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3, + }, + ], + "metadata": {"custom_field": "value", "another_field": 123}, + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + + def test_send_message_large_attachment(self, http_client_response): + messages = Messages(http_client_response) + mock_encoder = Mock() + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3 * 1024 * 1024, + }, + ], + "metadata": {"custom_field": "value", "another_field": 123}, + } + + with patch( + "nylas.resources.messages._build_form_request", return_value=mock_encoder + ): + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=None, + data=mock_encoder, + overrides=None, + ) + + def test_list_scheduled_messages(self, http_client_list_scheduled_messages): + messages = Messages(http_client_list_scheduled_messages) + + res = messages.list_scheduled_messages(identifier="abc-123") + + http_client_list_scheduled_messages._execute.assert_called_once_with( + method="GET", path="/v3/grants/abc-123/messages/schedules", overrides=None + ) + assert res.request_id == "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5" + assert len(res.data) == 2 + assert res.data[0].schedule_id == "8cd56334-6d95-432c-86d1-c5dab0ce98be" + assert res.data[0].status.code == "pending" + assert res.data[0].status.description == "schedule send awaiting send at time" + assert res.data[1].schedule_id == "rb856334-6d95-432c-86d1-c5dab0ce98be" + assert res.data[1].status.code == "success" + assert res.data[1].status.description == "schedule send succeeded" + assert res.data[1].close_time == 1690579819 + + def test_find_scheduled_message(self, http_client_response): + messages = Messages(http_client_response) + + messages.find_scheduled_message( + identifier="abc-123", schedule_id="schedule-123" + ) + + http_client_response._execute.assert_called_once_with( + method="GET", + path="/v3/grants/abc-123/messages/schedules/schedule-123", + overrides=None, + ) + + def test_stop_scheduled_message(self, http_client_response): + messages = Messages(http_client_response) + + messages.stop_scheduled_message( + identifier="abc-123", schedule_id="schedule-123" + ) + + http_client_response._execute.assert_called_once_with( + method="DELETE", + path="/v3/grants/abc-123/messages/schedules/schedule-123", + overrides=None, + ) + + def test_clean_messages(self, http_client_clean_messages): + messages = Messages(http_client_clean_messages) + request_body = { + "message_id": ["message-1", "message-2"], + "ignore_images": True, + "ignore_links": True, + "ignore_tables": True, + "images_as_markdown": True, + "remove_conclusion_phrases": True, + } + + response = messages.clean_messages( + identifier="abc-123", + request_body=request_body, + ) + + http_client_clean_messages._execute.assert_called_once_with( + method="PUT", + path="/v3/grants/abc-123/messages/clean", + request_body=request_body, + overrides=None, + ) + + # Assert the conversation field, and the typical message fields serialize properly + assert len(response.data) == 2 + assert response.data[0].body == "Hello, I just sent a message using Nylas!" + assert response.data[0].from_ == [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ] + assert response.data[0].object == "message" + assert response.data[0].id == "message-1" + assert response.data[0].grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert response.data[0].conversation == "cleaned example" + assert response.data[1].conversation == "another example" + assert message.folders[0] == "8l6c4d11y1p4dm4fxj52whyr9" + assert message.folders[1] == "d9zkcr2tljpu3m4qpj7l2hbr0" + assert message.from_ == [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ] + assert message.grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert message.id == "5d3qmne77v32r8l4phyuksl2x" + assert message.object == "message" + assert message.reply_to == [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ] + assert message.snippet == "Hello, I just sent a message using Nylas!" + assert message.starred is True + assert message.subject == "Hello from Nylas!" + assert message.thread_id == "1t8tv3890q4vgmwq6pmdwm8qgsaer" + assert message.to == [{"name": "Jon Snow", "email": "j.snow@example.com"}] + assert message.unread is True def test_list_messages(self, http_client_list_response): messages = Messages(http_client_list_response) From a080a3472daae87da359e8adac13f46dfed4d944 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Fri, 20 Dec 2024 14:36:21 -0500 Subject: [PATCH 121/186] v6.5.0 Release (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated CHANGELOG.md * Bump version: 6.4.0 โ†’ 6.5.0 --- .bumpversion.cfg | 2 +- CHANGELOG.md | 26 +++++++++++++------------- nylas/_client_sdk_version.py | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ceb32a18..afb65e07 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.4.0 +current_version = 6.5.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2bbd58..a1b892bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,27 +1,27 @@ nylas-python Changelog ====================== -Unreleased +v6.5.0 ---------------- -* Add support for Scheduler APIs +* Added support for Scheduler APIs +* Added metadata field support for drafts and messages through CreateDraftRequest and Message model * Fixed attachment download response handling -* Add metadata field support for drafts and messages through CreateDraftRequest model v6.4.0 ---------------- -* Add support for from field for sending messages -* Add missing schedule-specific fields to Message model -* Add migration grant properties -* Fix from field not being optional causing deserialization errors -* Fix IMAP identifiers not encoding correctly -* Fix NylasOAuthError not setting the status code properly +* Added support for from field for sending messages +* Added missing schedule-specific fields to Message model +* Added migration grant properties +* Fixed from field not being optional causing deserialization errors +* Fixed IMAP identifiers not encoding correctly +* Fixed NylasOAuthError not setting the status code properly v6.3.1 ---------------- -* Fix typo on Clean Messages -* Remove use of TestCommand -* Add Folder Webhooks -* Fix request session being reused across multiple requests +* Fixed typo on Clean Messages +* Fixed request session being reused across multiple requests +* Added Folder Webhooks +* Removed use of TestCommand v6.3.0 diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index e0fede44..2b6377d7 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.4.0" +__VERSION__ = "6.5.0" From 6ce4b62eee508ebc79a19c3b45734aa17a514ae9 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Wed, 22 Jan 2025 14:45:27 -0500 Subject: [PATCH 122/186] Added an example showing how to access provider errors (#403) --- examples/provider_error_demo/README.md | 72 +++++++++++++++ .../provider_error_example.py | 87 +++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 examples/provider_error_demo/README.md create mode 100644 examples/provider_error_demo/provider_error_example.py diff --git a/examples/provider_error_demo/README.md b/examples/provider_error_demo/README.md new file mode 100644 index 00000000..930a7236 --- /dev/null +++ b/examples/provider_error_demo/README.md @@ -0,0 +1,72 @@ +# Provider Error Handling Example + +This example demonstrates how to properly handle provider errors when working with the Nylas API. It specifically shows how to catch and process errors that occur when trying to access a non-existent calendar. + +## Features + +- Demonstrates proper error handling for Nylas API provider errors +- Shows how to access error details including: + - Error message + - Error type + - Provider error message + - Request ID + - Status code +- Includes clear output and status messages + +## Prerequisites + +1. A Nylas account with API access +2. Python 3.x installed +3. Local installation of the Nylas Python SDK (this repository) + +## Setup + +1. Install the SDK in development mode from the repository root: +```bash +cd /path/to/nylas-python +pip install -e . +``` + +2. Set your environment variables: +```bash +export NYLAS_API_KEY="your_api_key" +export NYLAS_GRANT_ID="your_grant_id" +``` + +3. Run the example from the repository root: +```bash +python examples/provider_error_demo/provider_error_example.py +``` + +## Example Output + +``` +Demonstrating Provider Error Handling +==================================== + +Attempting to fetch events from non-existent calendar: non-existent-calendar-123 +------------------------------------------------------------------ + +Caught NylasApiError: +โœ— Error Message: Calendar not found +โœ— Error Type: invalid_request_error +โœ— Provider Error: The calendar ID provided does not exist +โœ— Request ID: req-abc-123 +โœ— Status Code: 404 + +Example completed! +``` + +## Error Handling + +The example demonstrates how to handle: +- Missing environment variables +- API authentication errors +- Provider-specific errors +- Non-existent resource errors + +## Documentation + +For more information about the Nylas Python SDK and its features, visit: +- [Nylas Python SDK Documentation](https://developer.nylas.com/docs/sdks/python/) +- [Nylas API Reference](https://developer.nylas.com/docs/api/) \ No newline at end of file diff --git a/examples/provider_error_demo/provider_error_example.py b/examples/provider_error_demo/provider_error_example.py new file mode 100644 index 00000000..3289a485 --- /dev/null +++ b/examples/provider_error_demo/provider_error_example.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Handling Provider Errors + +This example demonstrates how to handle provider errors when working with the Nylas API, +specifically when trying to access a non-existent calendar. + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID + +Usage: + First, install the SDK in development mode: + cd /path/to/nylas-python + pip install -e . + + Then set environment variables and run: + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + python examples/provider_error_demo/provider_error_example.py +""" + +import os +import sys +from typing import Optional + +from nylas import Client +from nylas.models.errors import NylasApiError + + +def get_env_or_exit(var_name: str) -> str: + """Get an environment variable or exit if not found.""" + value = os.getenv(var_name) + if not value: + print(f"Error: {var_name} environment variable is required") + sys.exit(1) + return value + + +def demonstrate_provider_error(client: Client, grant_id: str) -> None: + """Demonstrate how to handle provider errors.""" + # Use a non-existent calendar ID to trigger a provider error + non_existent_calendar_id = "non-existent-calendar-123" + + try: + print(f"\nAttempting to fetch events from non-existent calendar: {non_existent_calendar_id}") + print("------------------------------------------------------------------") + + # Attempt to list events with the invalid calendar ID + events, request_id = client.events.list( + identifier=grant_id, + query_params={"calendar_id": non_existent_calendar_id} + ) + + # Note: We won't reach this code due to the error + print("Events retrieved:", events) + + except NylasApiError as e: + print("\nCaught NylasApiError:") + print(f"โœ— Error Type: {e.type}") + print(f"โœ— Provider Error: {e.provider_error}") + print(f"โœ— Request ID: {e.request_id}") + print(f"โœ— Status Code: {e.status_code}") + + +def main(): + """Main function demonstrating provider error handling.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + + # Initialize Nylas client + client = Client( + api_key=api_key, + ) + + print("\nDemonstrating Provider Error Handling") + print("====================================") + + # Demonstrate provider error handling + demonstrate_provider_error(client, grant_id) + + print("\nExample completed!") + + +if __name__ == "__main__": + main() \ No newline at end of file From 3b86655b7758064e5c79272f54a133413d5762de Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:47:44 -0500 Subject: [PATCH 123/186] Added response headers to all responses from the Nylas API (#404) * feat: add response headers to responses and add demo example * Updated the changelog * Fix tests * Fix: lint errors * Brought back previous doc comment * Updated the example --- CHANGELOG.md | 4 + examples/response_headers_demo/README.md | 107 ++++++++++++ .../response_headers_example.py | 161 ++++++++++++++++++ nylas/handler/api_resources.py | 28 +-- nylas/handler/http_client.py | 13 +- nylas/models/errors.py | 18 +- nylas/models/response.py | 63 +++++-- nylas/resources/applications.py | 4 +- nylas/resources/auth.py | 14 +- nylas/resources/calendars.py | 8 +- nylas/resources/configurations.py | 1 - nylas/resources/contacts.py | 4 +- nylas/resources/drafts.py | 4 +- nylas/resources/events.py | 7 +- nylas/resources/messages.py | 22 +-- nylas/resources/smart_compose.py | 8 +- nylas/resources/webhooks.py | 8 +- tests/conftest.py | 48 +++--- tests/handler/test_api_resources.py | 105 ++++++++++++ tests/handler/test_http_client.py | 105 +++++++++++- tests/resources/test_applications.py | 4 +- tests/resources/test_auth.py | 8 +- 22 files changed, 630 insertions(+), 114 deletions(-) create mode 100644 examples/response_headers_demo/README.md create mode 100644 examples/response_headers_demo/response_headers_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b892bd..b84572d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Added response headers to all responses from the Nylas API + v6.5.0 ---------------- * Added support for Scheduler APIs diff --git a/examples/response_headers_demo/README.md b/examples/response_headers_demo/README.md new file mode 100644 index 00000000..8ba6959d --- /dev/null +++ b/examples/response_headers_demo/README.md @@ -0,0 +1,107 @@ +# Response Headers Demo + +This example demonstrates how to access and use response headers from various Nylas API responses. It shows how headers are available in different types of responses: + +1. List responses (from methods like `list()`) +2. Single-item responses (from methods like `find()`) +3. Error responses (when API calls fail) + +## What You'll Learn + +- How to access response headers from successful API calls +- How to access headers from error responses +- Common headers you'll encounter in Nylas API responses +- How headers differ between list and single-item responses + +## Headers Demonstrated + +The example will show various headers that Nylas includes in responses, such as: + +- `request-id`: Unique identifier for the API request +- `x-ratelimit-limit`: Your rate limit for the endpoint +- `x-ratelimit-remaining`: Remaining requests within the current window +- `x-ratelimit-reset`: When the rate limit window resets +- And more... + +## Prerequisites + +Before running this example, make sure you have: + +1. A Nylas API key +2. A Nylas grant ID +3. Python 3.7 or later installed +4. The Nylas Python SDK installed + +## Setup + +1. First, install the SDK in development mode: + ```bash + cd /path/to/nylas-python + pip install -e . + ``` + +2. Set up your environment variables: + ```bash + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + ``` + +## Running the Example + +Run the example with: +```bash +python examples/response_headers_demo/response_headers_example.py +``` + +The script will: +1. Demonstrate headers from a list response by fetching messages +2. Show headers from a single-item response by fetching one message +3. Trigger and catch an error to show error response headers + +## Example Output + +You'll see output similar to this: + +``` +Demonstrating Response Headers +============================ + +Demonstrating List Response Headers +---------------------------------- +โœ“ Successfully retrieved messages + +Response Headers: +------------------------ +request-id: req_abcd1234 +x-ratelimit-limit: 1000 +x-ratelimit-remaining: 999 +... + +Demonstrating Find Response Headers +---------------------------------- +โœ“ Successfully retrieved single message + +Response Headers: +------------------------ +request-id: req_efgh5678 +... + +Demonstrating Error Response Headers +--------------------------------- +โœ“ Successfully caught expected error +โœ— Error Type: invalid_request +โœ— Request ID: req_ijkl9012 +โœ— Status Code: 404 + +Error Response Headers: +------------------------ +request-id: req_ijkl9012 +... +``` + +## Error Handling + +The example includes proper error handling and will show you how to: +- Catch `NylasApiError` exceptions +- Access error details and headers +- Handle different types of API errors gracefully \ No newline at end of file diff --git a/examples/response_headers_demo/response_headers_example.py b/examples/response_headers_demo/response_headers_example.py new file mode 100644 index 00000000..d93d8b04 --- /dev/null +++ b/examples/response_headers_demo/response_headers_example.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Response Headers Demo + +This example demonstrates how to access and use response headers from various Nylas API +responses, including successful responses and error cases. + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID + +Usage: + First, install the SDK in development mode: + cd /path/to/nylas-python + pip install -e . + + Then set environment variables and run: + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + python examples/response_headers_demo/response_headers_example.py +""" + +import os +import sys +from typing import Optional + +from nylas import Client +from nylas.models.errors import NylasApiError + + +def get_env_or_exit(var_name: str) -> str: + """Get an environment variable or exit if not found.""" + value = os.getenv(var_name) + if not value: + print(f"Error: {var_name} environment variable is required") + sys.exit(1) + return value + + +def print_response_headers(headers: dict, prefix: str = "") -> None: + """Helper function to print response headers.""" + print(f"\n{prefix} Response Headers:") + print("------------------------") + for key, value in headers.items(): + print(f"{key}: {value}") + + +def demonstrate_list_response_headers(client: Client, grant_id: str) -> None: + """Demonstrate headers in list responses.""" + print("\nDemonstrating List Response Headers") + print("----------------------------------") + + try: + # List messages to get a ListResponse + messages = client.messages.list(identifier=grant_id) + + print("โœ“ Successfully retrieved messages") + print_response_headers(messages.headers) + print(f"Total messages count: {len(messages.data)}") + + except NylasApiError as e: + print("\nError occurred while listing messages:") + print(f"โœ— Error Type: {e.type}") + print(f"โœ— Provider Error: {e.provider_error}") + print(f"โœ— Request ID: {e.request_id}") + print_response_headers(e.headers, "Error") + + +def demonstrate_list_response_headers_with_pagination(client: Client, grant_id: str) -> None: + """Demonstrate headers in list responses with pagination.""" + print("\nDemonstrating List Response Headers with Pagination") + print("--------------------------------------------------") + + try: + # List messages to get a ListResponse + threads = client.threads.list(identifier=grant_id, query_params={"limit": 1}) + + print("โœ“ Successfully retrieved threads") + print_response_headers(threads.headers) + print(f"Total threads count: {len(threads.data)}") + + except NylasApiError as e: + print("\nError occurred while listing threads:") + print(f"โœ— Error Type: {e.type}") + print(f"โœ— Provider Error: {e.provider_error}") + print(f"โœ— Request ID: {e.request_id}") + print_response_headers(e.headers, "Error") + + +def demonstrate_find_response_headers(client: Client, grant_id: str) -> None: + """Demonstrate headers in find/single-item responses.""" + print("\nDemonstrating Find Response Headers") + print("----------------------------------") + + try: + # Get the first message to demonstrate single-item response + messages = client.messages.list(identifier=grant_id) + if not messages.data: + print("No messages found to demonstrate find response") + return + + message_id = messages.data[0].id + message = client.messages.find(identifier=grant_id, message_id=message_id) + + print("โœ“ Successfully retrieved single message") + print_response_headers(message.headers) + + except NylasApiError as e: + print("\nError occurred while finding message:") + print(f"โœ— Error Type: {e.type}") + print(f"โœ— Provider Error: {e.provider_error}") + print(f"โœ— Request ID: {e.request_id}") + print_response_headers(e.headers, "Error") + + +def demonstrate_error_response_headers(client: Client, grant_id: str) -> None: + """Demonstrate headers in error responses.""" + print("\nDemonstrating Error Response Headers") + print("---------------------------------") + + try: + # Attempt to find a non-existent message + message = client.messages.find( + identifier=grant_id, + message_id="non-existent-id-123" + ) + + except NylasApiError as e: + print("โœ“ Successfully caught expected error") + print(f"โœ— Error Type: {e.type}") + print(f"โœ— Provider Error: {e.provider_error}") + print(f"โœ— Request ID: {e.request_id}") + print(f"โœ— Status Code: {e.status_code}") + print_response_headers(e.headers, "Error") + + +def main(): + """Main function demonstrating response headers.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + + # Initialize Nylas client + client = Client( + api_key=api_key, + ) + + print("\nDemonstrating Response Headers") + print("============================") + + # Demonstrate different types of responses and their headers + demonstrate_list_response_headers(client, grant_id) + demonstrate_list_response_headers_with_pagination(client, grant_id) + demonstrate_find_response_headers(client, grant_id) + demonstrate_error_response_headers(client, grant_id) + + print("\nExample completed!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/nylas/handler/api_resources.py b/nylas/handler/api_resources.py index 35c6dce2..ce2d3efa 100644 --- a/nylas/handler/api_resources.py +++ b/nylas/handler/api_resources.py @@ -16,11 +16,11 @@ def list( request_body=None, overrides=None, ) -> ListResponse: - response_json = self._http_client._execute( + response_json, response_headers = self._http_client._execute( "GET", path, headers, query_params, request_body, overrides=overrides ) - return ListResponse.from_dict(response_json, response_type) + return ListResponse.from_dict(response_json, response_type, response_headers) class FindableApiResource(Resource): @@ -33,11 +33,11 @@ def find( request_body=None, overrides=None, ) -> Response: - response_json = self._http_client._execute( + response_json, response_headers = self._http_client._execute( "GET", path, headers, query_params, request_body, overrides=overrides ) - return Response.from_dict(response_json, response_type) + return Response.from_dict(response_json, response_type, response_headers) class CreatableApiResource(Resource): @@ -50,11 +50,11 @@ def create( request_body=None, overrides=None, ) -> Response: - response_json = self._http_client._execute( + response_json, response_headers = self._http_client._execute( "POST", path, headers, query_params, request_body, overrides=overrides ) - return Response.from_dict(response_json, response_type) + return Response.from_dict(response_json, response_type, response_headers) class UpdatableApiResource(Resource): @@ -68,11 +68,11 @@ def update( method="PUT", overrides=None, ): - response_json = self._http_client._execute( + response_json, response_headers = self._http_client._execute( method, path, headers, query_params, request_body, overrides=overrides ) - return Response.from_dict(response_json, response_type) + return Response.from_dict(response_json, response_type, response_headers) class UpdatablePatchApiResource(Resource): @@ -86,11 +86,11 @@ def patch( method="PATCH", overrides=None, ): - response_json = self._http_client._execute( + response_json, response_headers = self._http_client._execute( method, path, headers, query_params, request_body, overrides=overrides ) - return Response.from_dict(response_json, response_type) + return Response.from_dict(response_json, response_type, response_headers) class DestroyableApiResource(Resource): @@ -106,7 +106,11 @@ def destroy( if response_type is None: response_type = DeleteResponse - response_json = self._http_client._execute( + response_json, response_headers = self._http_client._execute( "DELETE", path, headers, query_params, request_body, overrides=overrides ) - return response_type.from_dict(response_json) + + # Check if the response type is a dataclass_json class + if hasattr(response_type, "from_dict") and not hasattr(response_type, "headers"): + return response_type.from_dict(response_json) + return response_type.from_dict(response_json, headers=response_headers) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index a2a7be2f..76bcd7f1 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -1,9 +1,10 @@ import sys -from typing import Union +from typing import Union, Tuple, Dict from urllib.parse import urlparse, quote import requests from requests import Response +from requests.structures import CaseInsensitiveDict from nylas._client_sdk_version import __VERSION__ from nylas.models.errors import ( @@ -16,7 +17,7 @@ ) -def _validate_response(response: Response) -> dict: +def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: json = response.json() if response.status_code >= 400: parsed_url = urlparse(response.url) @@ -26,10 +27,10 @@ def _validate_response(response: Response) -> dict: or "connect/revoke" in parsed_url.path ): parsed_error = NylasOAuthErrorResponse.from_dict(json) - raise NylasOAuthError(parsed_error, response.status_code) + raise NylasOAuthError(parsed_error, response.status_code, response.headers) parsed_error = NylasApiErrorResponse.from_dict(json) - raise NylasApiError(parsed_error, response.status_code) + raise NylasApiError(parsed_error, response.status_code, response.headers) except (KeyError, TypeError) as exc: request_id = json.get("request_id", None) raise NylasApiError( @@ -41,9 +42,9 @@ def _validate_response(response: Response) -> dict: ), ), status_code=response.status_code, + headers=response.headers, ) from exc - - return json + return (json, response.headers) def _build_query_params(base_url: str, query_params: dict = None) -> str: query_param_parts = [] diff --git a/nylas/models/errors.py b/nylas/models/errors.py index 83ff3def..43e02d4b 100644 --- a/nylas/models/errors.py +++ b/nylas/models/errors.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Optional +from requests.structures import CaseInsensitiveDict from dataclasses_json import dataclass_json @@ -11,6 +12,7 @@ class AbstractNylasApiError(Exception): Attributes: request_id: The unique identifier of the request. status_code: The HTTP status code of the error response. + headers: The headers returned from the API. """ def __init__( @@ -18,6 +20,7 @@ def __init__( message: str, request_id: Optional[str] = None, status_code: Optional[int] = None, + headers: Optional[CaseInsensitiveDict] = None, ): """ Args: @@ -27,6 +30,7 @@ def __init__( """ self.request_id: str = request_id self.status_code: int = status_code + self.headers: CaseInsensitiveDict = headers super().__init__(message) @@ -96,22 +100,24 @@ class NylasApiError(AbstractNylasApiError): Attributes: type: Error type. provider_error: Provider Error. + headers: The headers returned from the API. """ def __init__( self, api_error: NylasApiErrorResponse, status_code: Optional[int] = None, + headers: Optional[CaseInsensitiveDict] = None, ): """ Args: api_error: The error details from the API. status_code: The HTTP status code of the error response. """ - super().__init__(api_error.error.message, api_error.request_id, status_code) + super().__init__(api_error.error.message, api_error.request_id, status_code, headers) self.type: str = api_error.error.type self.provider_error: Optional[dict] = api_error.error.provider_error - + self.headers: CaseInsensitiveDict = headers class NylasOAuthError(AbstractNylasApiError): """ @@ -128,18 +134,19 @@ def __init__( self, oauth_error: NylasOAuthErrorResponse, status_code: Optional[int] = None, + headers: Optional[CaseInsensitiveDict] = None, ): """ Args: oauth_error: The error details from the API. status_code: The HTTP status code of the error response. """ - super().__init__(oauth_error.error_description, None, status_code) + super().__init__(oauth_error.error_description, None, status_code, headers) self.error: str = oauth_error.error self.error_code: int = oauth_error.error_code self.error_description: str = oauth_error.error_description self.error_uri: str = oauth_error.error_uri - + self.headers: CaseInsensitiveDict = headers class NylasSdkTimeoutError(AbstractNylasSdkError): """ @@ -150,7 +157,7 @@ class NylasSdkTimeoutError(AbstractNylasSdkError): timeout: The timeout value set in the Nylas SDK, in seconds. """ - def __init__(self, url: str, timeout: int): + def __init__(self, url: str, timeout: int, headers: Optional[CaseInsensitiveDict] = None): """ Args: url: The URL that timed out. @@ -161,3 +168,4 @@ def __init__(self, url: str, timeout: int): ) self.url: str = url self.timeout: int = timeout + self.headers: CaseInsensitiveDict = headers diff --git a/nylas/models/response.py b/nylas/models/response.py index f8a91652..bcb0fba1 100644 --- a/nylas/models/response.py +++ b/nylas/models/response.py @@ -1,7 +1,9 @@ from dataclasses import dataclass from typing import TypeVar, Generic, Optional, List -from dataclasses_json import DataClassJsonMixin, dataclass_json +from dataclasses_json import DataClassJsonMixin + +from requests.structures import CaseInsensitiveDict T = TypeVar("T", bound=DataClassJsonMixin) @@ -17,36 +19,41 @@ class Response(tuple, Generic[T]): data: T request_id: str + headers: Optional[CaseInsensitiveDict] = None - def __new__(cls, data: T, request_id: str): + def __new__(cls, data: T, request_id: str, headers: Optional[CaseInsensitiveDict] = None): """ Initialize the response object. Args: data: The requested data object. request_id: The request ID. + headers: The headers returned from the API. """ # Initialize the tuple for destructuring support - instance = super().__new__(cls, (data, request_id)) + instance = super().__new__(cls, (data, request_id, headers)) instance.data = data instance.request_id = request_id + instance.headers = headers return instance @classmethod - def from_dict(cls, resp: dict, generic_type): + def from_dict(cls, resp: dict, generic_type, headers: Optional[CaseInsensitiveDict] = None): """ Convert a dictionary to a response object. Args: resp: The dictionary to convert. generic_type: The type to deserialize the data object into. + headers: The headers returned from the API. """ return cls( data=generic_type.from_dict(resp["data"]), request_id=resp["request_id"], + headers=headers, ) @@ -58,13 +65,21 @@ class ListResponse(tuple, Generic[T]): data: The list of requested data objects. request_id: The request ID. next_cursor: The cursor to use to get the next page of data. + headers: The headers returned from the API. """ data: List[T] request_id: str next_cursor: Optional[str] = None - - def __new__(cls, data: List[T], request_id: str, next_cursor: Optional[str] = None): + headers: Optional[CaseInsensitiveDict] = None + + def __new__( + cls, + data: List[T], + request_id: str, + next_cursor: Optional[str] = None, + headers: Optional[CaseInsensitiveDict] = None + ): """ Initialize the response object. @@ -72,24 +87,27 @@ def __new__(cls, data: List[T], request_id: str, next_cursor: Optional[str] = No data: The list of requested data objects. request_id: The request ID. next_cursor: The cursor to use to get the next page of data. + headers: The headers returned from the API. """ # Initialize the tuple for destructuring support - instance = super().__new__(cls, (data, request_id, next_cursor)) + instance = super().__new__(cls, (data, request_id, next_cursor, headers)) instance.data = data instance.request_id = request_id instance.next_cursor = next_cursor + instance.headers = headers return instance @classmethod - def from_dict(cls, resp: dict, generic_type): + def from_dict(cls, resp: dict, generic_type, headers: Optional[CaseInsensitiveDict] = None): """ Convert a dictionary to a response object. Args: resp: The dictionary to convert. generic_type: The type to deserialize the data objects into. + headers: The headers returned from the API. """ converted_data = [] @@ -100,10 +118,10 @@ def from_dict(cls, resp: dict, generic_type): data=converted_data, request_id=resp["request_id"], next_cursor=resp.get("next_cursor", None), + headers=headers, ) -@dataclass_json @dataclass class DeleteResponse: """ @@ -111,12 +129,24 @@ class DeleteResponse: Attributes: request_id: The request ID returned from the API. + headers: The headers returned from the API. """ request_id: str + headers: Optional[CaseInsensitiveDict] = None + + @classmethod + def from_dict(cls, resp: dict, headers: Optional[CaseInsensitiveDict] = None): + """ + Convert a dictionary to a response object. + + Args: + resp: The dictionary to convert. + headers: The headers returned from the API. + """ + return cls(request_id=resp["request_id"], headers=headers) -@dataclass_json @dataclass class RequestIdOnlyResponse: """ @@ -124,6 +154,19 @@ class RequestIdOnlyResponse: Attributes: request_id: The request ID returned from the API. + headers: The headers returned from the API. """ request_id: str + headers: Optional[CaseInsensitiveDict] = None + + @classmethod + def from_dict(cls, resp: dict, headers: Optional[CaseInsensitiveDict] = None): + """ + Convert a dictionary to a response object. + + Args: + resp: The dictionary to convert. + headers: The headers returned from the API. + """ + return cls(request_id=resp["request_id"], headers=headers) diff --git a/nylas/resources/applications.py b/nylas/resources/applications.py index 9510cf32..ed20c5ae 100644 --- a/nylas/resources/applications.py +++ b/nylas/resources/applications.py @@ -34,7 +34,7 @@ def info(self, overrides: RequestOverrides = None) -> Response[ApplicationDetail Response: The application information. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="GET", path="/v3/applications", overrides=overrides ) - return Response.from_dict(json_response, ApplicationDetails) + return Response.from_dict(json_response, ApplicationDetails, headers) diff --git a/nylas/resources/auth.py b/nylas/resources/auth.py index cccc35f1..18dfc658 100644 --- a/nylas/resources/auth.py +++ b/nylas/resources/auth.py @@ -114,13 +114,13 @@ def custom_authentication( The created Grant. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="POST", path="/v3/connect/custom", request_body=request_body, overrides=overrides, ) - return Response.from_dict(json_response, Grant) + return Response.from_dict(json_response, Grant, headers) def refresh_access_token( self, request: TokenExchangeRequest, overrides: RequestOverrides = None @@ -248,13 +248,13 @@ def detect_provider( The detected provider, if found. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="POST", path="/v3/providers/detect", query_params=params, overrides=overrides, ) - return Response.from_dict(json_response, ProviderDetectResponse) + return Response.from_dict(json_response, ProviderDetectResponse, headers) def _url_auth_builder(self, query: dict) -> str: base = f"{self._http_client.api_server}/v3/connect/auth" @@ -263,7 +263,7 @@ def _url_auth_builder(self, query: dict) -> str: def _get_token( self, request_body: dict, overrides: RequestOverrides ) -> CodeExchangeResponse: - json_response = self._http_client._execute( + json_response, _ = self._http_client._execute( method="POST", path="/v3/connect/token", request_body=request_body, @@ -274,10 +274,10 @@ def _get_token( def _get_token_info( self, query_params: dict, overrides: RequestOverrides ) -> Response[TokenInfoResponse]: - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="GET", path="/v3/connect/tokeninfo", query_params=query_params, overrides=overrides, ) - return Response.from_dict(json_response, TokenInfoResponse) + return Response.from_dict(json_response, TokenInfoResponse, headers) diff --git a/nylas/resources/calendars.py b/nylas/resources/calendars.py index 145da03a..14db916c 100644 --- a/nylas/resources/calendars.py +++ b/nylas/resources/calendars.py @@ -169,14 +169,14 @@ def get_availability( Returns: Response: The availability response from the API. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="POST", path="/v3/calendars/availability", request_body=request_body, overrides=overrides, ) - return Response.from_dict(json_response, GetAvailabilityResponse) + return Response.from_dict(json_response, GetAvailabilityResponse, headers) def get_free_busy( self, @@ -195,7 +195,7 @@ def get_free_busy( Returns: Response: The free/busy response from the API. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="POST", path=f"/v3/grants/{identifier}/calendars/free-busy", request_body=request_body, @@ -210,4 +210,4 @@ def get_free_busy( else: data.append(FreeBusy.from_dict(item)) - return Response(data, request_id) + return Response(data, request_id, headers) diff --git a/nylas/resources/configurations.py b/nylas/resources/configurations.py index 85c47ec6..103b0c54 100644 --- a/nylas/resources/configurations.py +++ b/nylas/resources/configurations.py @@ -70,7 +70,6 @@ def list( response_type=Configuration, query_params=query_params, ) - print("What's this", res) return res def find( diff --git a/nylas/resources/contacts.py b/nylas/resources/contacts.py index 66c45557..de78a9fd 100644 --- a/nylas/resources/contacts.py +++ b/nylas/resources/contacts.py @@ -172,11 +172,11 @@ def list_groups( Returns: The list of contact groups. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="GET", path=f"/v3/grants/{identifier}/contacts/groups", query_params=query_params, overrides=overrides, ) - return ListResponse.from_dict(json_response, ContactGroup) + return ListResponse.from_dict(json_response, ContactGroup, headers) diff --git a/nylas/resources/drafts.py b/nylas/resources/drafts.py index 23146d88..222ccdc4 100644 --- a/nylas/resources/drafts.py +++ b/nylas/resources/drafts.py @@ -215,10 +215,10 @@ def send( draft_id: The identifier of the draft to send. overrides: The request overrides to use for the request. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="POST", path=f"/v3/grants/{identifier}/drafts/{urllib.parse.quote(draft_id, safe='')}", overrides=overrides, ) - return Response.from_dict(json_response, Message) + return Response.from_dict(json_response, Message, headers) diff --git a/nylas/resources/events.py b/nylas/resources/events.py index 416e8529..95f05bdd 100644 --- a/nylas/resources/events.py +++ b/nylas/resources/events.py @@ -183,7 +183,8 @@ def send_rsvp( query_params: SendRsvpQueryParams, overrides: RequestOverrides = None, ) -> RequestIdOnlyResponse: - """Send RSVP for an event. + """ + Send an RSVP for an event. Args: identifier: The grant ID or email account to send RSVP for. @@ -195,7 +196,7 @@ def send_rsvp( Returns: Response: The RSVP response from the API. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="POST", path=f"/v3/grants/{identifier}/events/{event_id}/send-rsvp", query_params=query_params, @@ -203,4 +204,4 @@ def send_rsvp( overrides=overrides, ) - return RequestIdOnlyResponse.from_dict(json_response) + return RequestIdOnlyResponse.from_dict(json_response, headers) diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index 310f3682..a61f037f 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -189,7 +189,7 @@ def send( json_body = request_body - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="POST", path=path, request_body=json_body, @@ -197,7 +197,7 @@ def send( overrides=overrides, ) - return Response.from_dict(json_response, Message) + return Response.from_dict(json_response, Message, headers) def list_scheduled_messages( self, identifier: str, overrides: RequestOverrides = None @@ -212,7 +212,7 @@ def list_scheduled_messages( Returns: Response: The list of scheduled messages. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="GET", path=f"/v3/grants/{identifier}/messages/schedules", overrides=overrides, @@ -223,7 +223,7 @@ def list_scheduled_messages( for item in json_response["data"]: data.append(ScheduledMessage.from_dict(item)) - return Response(data, request_id) + return Response(data, request_id, headers) def find_scheduled_message( self, identifier: str, schedule_id: str, overrides: RequestOverrides = None @@ -239,13 +239,13 @@ def find_scheduled_message( Returns: Response: The scheduled message. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="GET", path=f"/v3/grants/{identifier}/messages/schedules/{schedule_id}", overrides=overrides, ) - return Response.from_dict(json_response, ScheduledMessage) + return Response.from_dict(json_response, ScheduledMessage, headers) def stop_scheduled_message( self, identifier: str, schedule_id: str, overrides: RequestOverrides = None @@ -261,13 +261,13 @@ def stop_scheduled_message( Returns: Response: The confirmation of the stopped scheduled message. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="DELETE", path=f"/v3/grants/{identifier}/messages/schedules/{schedule_id}", overrides=overrides, ) - return Response.from_dict(json_response, StopScheduledMessageResponse) + return Response.from_dict(json_response, StopScheduledMessageResponse, headers) def clean_messages( self, @@ -284,13 +284,13 @@ def clean_messages( overrides: The request overrides to apply to the request. Returns: - The list of cleaned messages. + ListResponse: The list of cleaned messages. """ - json_response = self._http_client._execute( + json_response, headers = self._http_client._execute( method="PUT", path=f"/v3/grants/{identifier}/messages/clean", request_body=request_body, overrides=overrides, ) - return ListResponse.from_dict(json_response, CleanMessagesResponse) + return ListResponse.from_dict(json_response, CleanMessagesResponse, headers) diff --git a/nylas/resources/smart_compose.py b/nylas/resources/smart_compose.py index 9f54135b..1d266d89 100644 --- a/nylas/resources/smart_compose.py +++ b/nylas/resources/smart_compose.py @@ -29,14 +29,14 @@ def compose_message( Returns: The generated message. """ - res = self._http_client._execute( + res, headers = self._http_client._execute( method="POST", path=f"/v3/grants/{identifier}/messages/smart-compose", request_body=request_body, overrides=overrides, ) - return Response.from_dict(res, ComposeMessageResponse) + return Response.from_dict(res, ComposeMessageResponse, headers) def compose_message_reply( self, @@ -57,11 +57,11 @@ def compose_message_reply( Returns: The generated message reply. """ - res = self._http_client._execute( + res, headers = self._http_client._execute( method="POST", path=f"/v3/grants/{identifier}/messages/{message_id}/smart-compose", request_body=request_body, overrides=overrides, ) - return Response.from_dict(res, ComposeMessageResponse) + return Response.from_dict(res, ComposeMessageResponse, headers) diff --git a/nylas/resources/webhooks.py b/nylas/resources/webhooks.py index b649e5ba..443c159d 100644 --- a/nylas/resources/webhooks.py +++ b/nylas/resources/webhooks.py @@ -139,13 +139,13 @@ def rotate_secret( Returns: The updated webhook destination """ - res = self._http_client._execute( + res, headers = self._http_client._execute( method="PUT", path=f"/v3/webhooks/{webhook_id}/rotate-secret", request_body={}, overrides=overrides, ) - return Response.from_dict(res, WebhookWithSecret) + return Response.from_dict(res, WebhookWithSecret, headers) def ip_addresses( self, overrides: RequestOverrides = None @@ -159,10 +159,10 @@ def ip_addresses( Returns: The list of IP addresses that Nylas sends webhooks from """ - res = self._http_client._execute( + res, headers = self._http_client._execute( method="GET", path="/v3/webhooks/ip-addresses", overrides=overrides ) - return Response.from_dict(res, WebhookIpAddressesResponse) + return Response.from_dict(res, WebhookIpAddressesResponse, headers) def extract_challenge_parameter(url: str) -> str: diff --git a/tests/conftest.py b/tests/conftest.py index 196695a3..35bca720 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -53,33 +53,23 @@ def mock_session_timeout(): @pytest.fixture def http_client_list_response(): with patch( - "nylas.models.response.ListResponse.from_dict", - return_value=ListResponse([], "bar"), + "nylas.models.response.ListResponse.from_dict", return_value=ListResponse([], "bar", None, {"X-Test-Header": "test"}) ): mock_http_client = Mock() - mock_http_client._execute.return_value = { + mock_http_client._execute.return_value = ({ "request_id": "abc-123", - "data": [ - { - "id": "calendar-123", - "grant_id": "grant-123", - "name": "Mock Calendar", - "read_only": False, - "is_owned_by_user": True, - "object": "calendar", - } - ], - } + "data": [], + }, {"X-Test-Header": "test"}) yield mock_http_client @pytest.fixture def http_client_response(): with patch( - "nylas.models.response.Response.from_dict", return_value=Response({}, "bar") + "nylas.models.response.Response.from_dict", return_value=Response({}, "bar", {"X-Test-Header": "test"}) ): mock_http_client = Mock() - mock_http_client._execute.return_value = { + mock_http_client._execute.return_value = ({ "request_id": "abc-123", "data": { "id": "calendar-123", @@ -89,23 +79,23 @@ def http_client_response(): "is_owned_by_user": True, "object": "calendar", }, - } + }, {"X-Test-Header": "test"}) yield mock_http_client @pytest.fixture def http_client_delete_response(): mock_http_client = Mock() - mock_http_client._execute.return_value = { + mock_http_client._execute.return_value = ({ "request_id": "abc-123", - } + }, {"X-Test-Header": "test"}) return mock_http_client @pytest.fixture def http_client_token_exchange(): mock_http_client = Mock() - mock_http_client._execute.return_value = { + mock_http_client._execute.return_value = ({ "access_token": "nylas_access_token", "expires_in": 3600, "id_token": "jwt_token", @@ -114,14 +104,14 @@ def http_client_token_exchange(): "token_type": "Bearer", "grant_id": "grant_123", "provider": "google", - } + }, {"X-Test-Header": "test"}) return mock_http_client @pytest.fixture def http_client_token_info(): mock_http_client = Mock() - mock_http_client._execute.return_value = { + mock_http_client._execute.return_value = ({ "request_id": "abc-123", "data": { "iss": "https://nylas.com", @@ -131,14 +121,14 @@ def http_client_token_info(): "iat": 1692094848, "exp": 1692095173, }, - } + }, {"X-Test-Header": "test"}) return mock_http_client @pytest.fixture def http_client_free_busy(): mock_http_client = Mock() - mock_http_client._execute.return_value = { + mock_http_client._execute.return_value = ({ "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5", "data": [ { @@ -165,14 +155,14 @@ def http_client_free_busy(): "object": "error", }, ], - } + }, {"X-Test-Header": "test"}) return mock_http_client @pytest.fixture def http_client_list_scheduled_messages(): mock_http_client = Mock() - mock_http_client._execute.return_value = { + mock_http_client._execute.return_value = ({ "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5", "data": [ { @@ -188,14 +178,14 @@ def http_client_list_scheduled_messages(): "close_time": 1690579819, }, ], - } + }, {"X-Test-Header": "test"}) return mock_http_client @pytest.fixture def http_client_clean_messages(): mock_http_client = Mock() - mock_http_client._execute.return_value = { + mock_http_client._execute.return_value = ({ "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5", "data": [ { @@ -217,5 +207,5 @@ def http_client_clean_messages(): "conversation": "another example", }, ], - } + }, {"X-Test-Header": "test"}) return mock_http_client diff --git a/tests/handler/test_api_resources.py b/tests/handler/test_api_resources.py index e40aadf6..80ae1d5f 100644 --- a/tests/handler/test_api_resources.py +++ b/tests/handler/test_api_resources.py @@ -160,3 +160,108 @@ def test_destroy_resource_default_type(self, http_client_delete_response): {"foo": "bar"}, overrides=None, ) + + def test_list_resource_with_headers(self, http_client_list_response): + resource = MockResource(http_client_list_response) + + response = resource.list( + path="/foo", + response_type=Calendar, + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert response.headers == {"X-Test-Header": "test"} + http_client_list_response._execute.assert_called_once_with( + "GET", + "/foo", + {"test": "header"}, + {"query": "param"}, + {"foo": "bar"}, + overrides=None, + ) + + def test_find_resource_with_headers(self, http_client_response): + resource = MockResource(http_client_response) + + response = resource.find( + path="/foo", + response_type=Calendar, + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert response.headers == {"X-Test-Header": "test"} + http_client_response._execute.assert_called_once_with( + "GET", + "/foo", + {"test": "header"}, + {"query": "param"}, + {"foo": "bar"}, + overrides=None, + ) + + def test_create_resource_with_headers(self, http_client_response): + resource = MockResource(http_client_response) + + response = resource.create( + path="/foo", + response_type=Calendar, + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert response.headers == {"X-Test-Header": "test"} + http_client_response._execute.assert_called_once_with( + "POST", + "/foo", + {"test": "header"}, + {"query": "param"}, + {"foo": "bar"}, + overrides=None, + ) + + def test_update_resource_with_headers(self, http_client_response): + resource = MockResource(http_client_response) + + response = resource.update( + path="/foo", + response_type=Calendar, + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert response.headers == {"X-Test-Header": "test"} + http_client_response._execute.assert_called_once_with( + "PUT", + "/foo", + {"test": "header"}, + {"query": "param"}, + {"foo": "bar"}, + overrides=None, + ) + + def test_destroy_resource_with_headers(self, http_client_delete_response): + resource = MockResource(http_client_delete_response) + + response = resource.destroy( + path="/foo", + response_type=RequestIdOnlyResponse, + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert response.headers == {"X-Test-Header": "test"} + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/foo", + {"test": "header"}, + {"query": "param"}, + {"foo": "bar"}, + overrides=None, + ) diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index e6a61576..9fb0684a 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -211,9 +211,11 @@ def test_validate_response(self): response.status_code = 200 response.json.return_value = {"foo": "bar"} response.url = "https://test.nylas.com/foo" + response.headers = {"X-Test-Header": "test"} - validation = _validate_response(response) - assert validation == {"foo": "bar"} + response_json, response_headers = _validate_response(response) + assert response_json == {"foo": "bar"} + assert response_headers == {"X-Test-Header": "test"} def test_validate_response_400_error(self): response = Mock() @@ -274,7 +276,13 @@ def test_validate_response_400_keyerror(self): assert e.value.status_code == 400 def test_execute(self, http_client, patched_version_and_sys, patched_request): - response = http_client._execute( + mock_response = Mock() + mock_response.json.return_value = {"foo": "bar"} + mock_response.headers = {"X-Test-Header": "test"} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + response_json, response_headers = http_client._execute( method="GET", path="/foo", headers={"test": "header"}, @@ -282,7 +290,8 @@ def test_execute(self, http_client, patched_version_and_sys, patched_request): request_body={"foo": "bar"}, ) - assert response == {"foo": "bar"} + assert response_json == {"foo": "bar"} + assert response_headers == {"X-Test-Header": "test"} patched_request.assert_called_once_with( "GET", "https://test.nylas.com/foo?query=param", @@ -301,7 +310,13 @@ def test_execute(self, http_client, patched_version_and_sys, patched_request): def test_execute_override_timeout( self, http_client, patched_version_and_sys, patched_request ): - response = http_client._execute( + mock_response = Mock() + mock_response.json.return_value = {"foo": "bar"} + mock_response.headers = {"X-Test-Header": "test"} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + response_json, response_headers = http_client._execute( method="GET", path="/foo", headers={"test": "header"}, @@ -310,7 +325,8 @@ def test_execute_override_timeout( overrides={"timeout": 60}, ) - assert response == {"foo": "bar"} + assert response_json == {"foo": "bar"} + assert response_headers == {"X-Test-Header": "test"} patched_request.assert_called_once_with( "GET", "https://test.nylas.com/foo?query=param", @@ -339,3 +355,80 @@ def test_execute_timeout(self, http_client, mock_session_timeout): str(e.value) == "Nylas SDK timed out before receiving a response from the server." ) + + def test_validate_response_with_headers(self): + response = Mock() + response.status_code = 200 + response.json.return_value = {"foo": "bar"} + response.url = "https://test.nylas.com/foo" + response.headers = {"X-Test-Header": "test"} + + json_response, headers = _validate_response(response) + assert json_response == {"foo": "bar"} + assert headers == {"X-Test-Header": "test"} + + def test_validate_response_400_error_with_headers(self): + response = Mock() + response.status_code = 400 + response.json.return_value = { + "request_id": "123", + "error": { + "type": "api_error", + "message": "The request is invalid.", + "provider_error": {"foo": "bar"}, + }, + } + response.url = "https://test.nylas.com/foo" + response.headers = {"X-Test-Header": "test"} + + with pytest.raises(NylasApiError) as e: + _validate_response(response) + assert e.value.headers == {"X-Test-Header": "test"} + + def test_validate_response_auth_error_with_headers(self): + response = Mock() + response.status_code = 401 + response.json.return_value = { + "error": "invalid_request", + "error_description": "The request is invalid.", + "error_uri": "https://docs.nylas.com/reference#authentication-errors", + "error_code": 100241, + } + response.url = "https://test.nylas.com/connect/token" + response.headers = {"X-Test-Header": "test"} + + with pytest.raises(NylasOAuthError) as e: + _validate_response(response) + assert e.value.headers == {"X-Test-Header": "test"} + + def test_execute_with_headers(self, http_client, patched_version_and_sys, patched_request): + mock_response = Mock() + mock_response.json.return_value = {"foo": "bar"} + mock_response.headers = {"X-Test-Header": "test"} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + response_json, response_headers = http_client._execute( + method="GET", + path="/foo", + headers={"test": "header"}, + query_params={"query": "param"}, + request_body={"foo": "bar"}, + ) + + assert response_json == {"foo": "bar"} + assert response_headers == {"X-Test-Header": "test"} + patched_request.assert_called_once_with( + "GET", + "https://test.nylas.com/foo?query=param", + headers={ + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + "Content-type": "application/json", + "test": "header", + }, + json={"foo": "bar"}, + timeout=30, + data=None, + ) diff --git a/tests/resources/test_applications.py b/tests/resources/test_applications.py index 43c65971..9de17fb4 100644 --- a/tests/resources/test_applications.py +++ b/tests/resources/test_applications.py @@ -14,7 +14,7 @@ def test_redirect_uris_property(self, http_client): def test_info(self): mock_http_client = Mock() - mock_http_client._execute.return_value = { + mock_http_client._execute.return_value = ({ "request_id": "req-123", "data": { "application_id": "ad410018-d306-43f9-8361-fa5d7b2172e0", @@ -51,7 +51,7 @@ def test_info(self): } ], }, - } + }, {"X-Test-Header": "test"}) app = Applications(mock_http_client) res = app.info() diff --git a/tests/resources/test_auth.py b/tests/resources/test_auth.py index 8d3ecd38..db88ca56 100644 --- a/tests/resources/test_auth.py +++ b/tests/resources/test_auth.py @@ -187,7 +187,7 @@ def test_exchange_code_for_token_no_secret(self, http_client_token_exchange): def test_custom_authentication(self): mock_http_client = Mock() - mock_http_client._execute.return_value = { + mock_http_client._execute.return_value = ({ "request_id": "abc-123", "data": { "id": "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47", @@ -201,7 +201,7 @@ def test_custom_authentication(self): "created_at": 1617817109, "updated_at": 1617817109, }, - } + }, {"X-Test-Header": "test"}) auth = Auth(mock_http_client) res = auth.custom_authentication( @@ -358,7 +358,7 @@ def test_revoke(self, http_client_response): def test_detect_provider(self): mock_http_client = Mock() - mock_http_client._execute.return_value = { + mock_http_client._execute.return_value = ({ "request_id": "abc-123", "data": { "email_address": "test@gmail.com", @@ -366,7 +366,7 @@ def test_detect_provider(self): "provider": "google", "type": "string", }, - } + }, {"X-Test-Header": "test"}) auth = Auth(mock_http_client) req = {"email": "test@gmail.com", "all_provider_types": True} From 807d3b7d5830134ca9d33575c82dbe69ca5cc8cd Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:22:12 -0500 Subject: [PATCH 124/186] v6.6.0 Release (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated CHANGELOG * Bump version: 6.5.0 โ†’ 6.6.0 --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index afb65e07..b3d18065 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.5.0 +current_version = 6.6.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index b84572d9..0201aa53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.6.0 ---------------- * Added response headers to all responses from the Nylas API diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 2b6377d7..2fa8fbe0 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.5.0" +__VERSION__ = "6.6.0" From 970148455edc024f97deadc9e7c25fa18fa2ff6b Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 11 Feb 2025 13:05:35 -0500 Subject: [PATCH 125/186] feat: Added support for select query parameter to optimize API response size (#407) * feat: add support for select query parameter to optimize API response size * fix: update events.py --- .coveragerc | 4 +- CHANGELOG.md | 5 +- examples/select_param_demo/README.md | 58 ++++++ .../select_param_demo/select_param_example.py | 180 ++++++++++++++++++ nylas/models/calendars.py | 14 ++ nylas/models/contacts.py | 43 +++-- nylas/models/drafts.py | 12 ++ nylas/models/events.py | 15 +- nylas/models/folders.py | 18 ++ nylas/models/list_query_params.py | 3 + nylas/models/messages.py | 6 + nylas/models/threads.py | 3 + nylas/resources/calendars.py | 9 +- nylas/resources/drafts.py | 4 + nylas/resources/folders.py | 4 + tests/conftest.py | 2 +- tests/resources/test_calendars.py | 67 +++++++ tests/resources/test_contacts.py | 69 +++++++ tests/resources/test_events.py | 95 +++++++++ tests/resources/test_folders.py | 83 ++++++++ tests/resources/test_messages.py | 54 ++++-- 21 files changed, 699 insertions(+), 49 deletions(-) create mode 100644 examples/select_param_demo/README.md create mode 100644 examples/select_param_demo/select_param_example.py diff --git a/.coveragerc b/.coveragerc index 01586ee0..cf7ed82e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,3 +1,5 @@ [run] source = nylas -omit = tests/* +omit = + tests/* + examples/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 0201aa53..0a75e2ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Added support for `select` query parameter in list calendars, list events, and list messages. + v6.6.0 ---------------- * Added response headers to all responses from the Nylas API @@ -27,7 +31,6 @@ v6.3.1 * Added Folder Webhooks * Removed use of TestCommand - v6.3.0 ---------------- * Added Folder query param support diff --git a/examples/select_param_demo/README.md b/examples/select_param_demo/README.md new file mode 100644 index 00000000..8277ae58 --- /dev/null +++ b/examples/select_param_demo/README.md @@ -0,0 +1,58 @@ +# Select Parameter Demo + +This example demonstrates the usage of the `select` query parameter across different Nylas resources. The `select` parameter allows you to specify which fields you want to receive in the API response, helping to optimize your API calls by reducing the amount of data transferred. + +## Features Demonstrated + +1. **Backwards Compatibility**: Shows that existing code that doesn't use the `select` parameter continues to work as expected, receiving all fields. +2. **Field Selection**: Demonstrates how to use the `select` parameter to request only specific fields for better performance. +3. **Multiple Resources**: Shows the `select` parameter working across different resources: + - Messages + - Calendars + - Events + - Drafts + - Contacts + +## Setup + +1. Create a `.env` file in the root directory with your Nylas API credentials: + ``` + NYLAS_API_KEY=your_api_key_here + ``` + +2. Install the required dependencies: + ```bash + pip install nylas python-dotenv + ``` + +## Running the Example + +Run the example script: +```bash +python select_param_example.py +``` + +The script will demonstrate both the traditional way of fetching all fields and the new selective field fetching for each resource type. + +## Example Output + +The script will show output similar to this for each resource: +``` +=== Messages Resource === + +Fetching messages (all fields): +Full message - Subject: Example Subject, ID: abc123... + +Fetching messages with select (only id and subject): +Minimal message - Subject: Example Subject, ID: abc123... +``` + +## Benefits of Using Select + +1. **Reduced Data Transfer**: By selecting only the fields you need, you reduce the amount of data transferred over the network. +2. **Improved Performance**: Smaller payloads mean faster API responses and less processing time. +3. **Bandwidth Optimization**: Especially useful in mobile applications or when dealing with limited bandwidth. + +## Available Fields + +The fields available for selection vary by resource type. Refer to the [Nylas API documentation](https://developer.nylas.com/) for a complete list of available fields for each resource type. \ No newline at end of file diff --git a/examples/select_param_demo/select_param_example.py b/examples/select_param_demo/select_param_example.py new file mode 100644 index 00000000..76ad8b6b --- /dev/null +++ b/examples/select_param_demo/select_param_example.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Using Select Parameters + +This example demonstrates how to use the 'select' query parameter across different Nylas resources +to optimize API response size and performance by requesting only specific fields. + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID + +Usage: + First, install the SDK in development mode: + cd /path/to/nylas-python + pip install -e . + + Then set environment variables and run: + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + python examples/select_param_demo/select_param_example.py +""" + +import os +import sys +import json +from nylas import Client + + +def get_env_or_exit(var_name: str) -> str: + """Get an environment variable or exit if not found.""" + value = os.getenv(var_name) + if not value: + print(f"Error: {var_name} environment variable is required") + sys.exit(1) + return value + + +def print_data(data: list, title: str) -> None: + """Pretty print the data with a title.""" + print(f"\n{title}:") + for item in data: + # Convert to dict and pretty print + item_dict = item.to_dict() + print(json.dumps(item_dict, indent=2)) + + +def demonstrate_messages(client: Client, grant_id: str) -> None: + """Demonstrate select parameter usage with Messages resource.""" + print("\n=== Messages Resource ===") + + # Backwards compatibility - fetch all fields + print("\nFetching messages (all fields):") + messages = client.messages.list(identifier=grant_id, query_params={"limit": 2}) + print_data(messages.data, "Full message data (all fields)") + + # Using select parameter - fetch only specific fields + print("\nFetching messages with select (only id and subject):") + messages = client.messages.list( + identifier=grant_id, + query_params={"limit": 2, "select": "id,subject"} + ) + print_data(messages.data, "Minimal message data (only selected fields)") + + +def demonstrate_calendars(client: Client, grant_id: str) -> None: + """Demonstrate select parameter usage with Calendars resource.""" + print("\n=== Calendars Resource ===") + + # Backwards compatibility - fetch all fields + print("\nFetching calendars (all fields):") + calendars = client.calendars.list(identifier=grant_id, query_params={"limit": 2}) + print_data(calendars.data, "Full calendar data (all fields)") + + # Using select parameter - fetch only specific fields + print("\nFetching calendars with select (only id and name):") + calendars = client.calendars.list( + identifier=grant_id, + query_params={"limit": 2, "select": "id,name"} + ) + print_data(calendars.data, "Minimal calendar data (only selected fields)") + + +def demonstrate_events(client: Client, grant_id: str) -> None: + """Demonstrate select parameter usage with Events resource.""" + print("\n=== Events Resource ===") + + # First, get a calendar ID + print("\nFetching first calendar to use for events...") + calendars = client.calendars.list(identifier=grant_id, query_params={"limit": 1}) + if not calendars.data: + print("No calendars found. Skipping events demonstration.") + return + + calendar_id = calendars.data[0].id + print(f"Using calendar: {calendars.data[0].name} (ID: {calendar_id})") + + # Backwards compatibility - fetch all fields + print("\nFetching events (all fields):") + events = client.events.list( + identifier=grant_id, + query_params={"limit": 2, "calendar_id": calendar_id} + ) + print_data(events.data, "Full event data (all fields)") + + # Using select parameter - fetch only specific fields + print("\nFetching events with select (only id and title):") + events = client.events.list( + identifier=grant_id, + query_params={ + "limit": 2, + "calendar_id": calendar_id, + "select": "id,title" + } + ) + print_data(events.data, "Minimal event data (only selected fields)") + + +def demonstrate_drafts(client: Client, grant_id: str) -> None: + """Demonstrate select parameter usage with Drafts resource.""" + print("\n=== Drafts Resource ===") + + # Backwards compatibility - fetch all fields + print("\nFetching drafts (all fields):") + drafts = client.drafts.list(identifier=grant_id, query_params={"limit": 2}) + print_data(drafts.data, "Full draft data (all fields)") + + # Using select parameter - fetch only specific fields + print("\nFetching drafts with select (only id and subject):") + drafts = client.drafts.list( + identifier=grant_id, + query_params={"limit": 2, "select": "id,subject"} + ) + print_data(drafts.data, "Minimal draft data (only selected fields)") + + +def demonstrate_contacts(client: Client, grant_id: str) -> None: + """Demonstrate select parameter usage with Contacts resource.""" + print("\n=== Contacts Resource ===") + + # Backwards compatibility - fetch all fields + print("\nFetching contacts (all fields):") + contacts = client.contacts.list(identifier=grant_id, query_params={"limit": 2}) + print_data(contacts.data, "Full contact data (all fields)") + + # Using select parameter - fetch only specific fields + print("\nFetching contacts with select (only id, grant_id, and given_name):") + contacts = client.contacts.list( + identifier=grant_id, + query_params={"limit": 2, "select": "id,grant_id,given_name"} + ) + print_data(contacts.data, "Minimal contact data (only selected fields)") + + +def main(): + """Main function demonstrating select parameter usage across resources.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + + # Initialize Nylas client + client = Client( + api_key=api_key, + ) + + print("\nDemonstrating Select Parameter Usage") + print("===================================") + print("This shows both backwards compatibility and selective field fetching") + + # Demonstrate select parameter across different resources + demonstrate_messages(client, grant_id) + demonstrate_calendars(client, grant_id) + demonstrate_events(client, grant_id) + demonstrate_drafts(client, grant_id) + demonstrate_contacts(client, grant_id) + + print("\nExample completed!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/nylas/models/calendars.py b/nylas/models/calendars.py index 9b3468a3..3a01ee87 100644 --- a/nylas/models/calendars.py +++ b/nylas/models/calendars.py @@ -57,11 +57,25 @@ class ListCalendarsQueryParams(ListQueryParams): page_token (NotRequired[str]): An identifier that specifies which page of data to return. This value should be taken from a ListResponse object's next_cursor parameter. metadata_pair: Pass in your metadata key-value pair to search for metadata. + select (NotRequired[str]): Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. """ metadata_pair: NotRequired[Dict[str, str]] +class FindCalendarQueryParams(TypedDict): + """ + Interface of the query parameters for finding a calendar. + + Attributes: + select: Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. + """ + + select: NotRequired[str] + + class CreateCalendarRequest(TypedDict): """ Interface of a Nylas create calendar request diff --git a/nylas/models/contacts.py b/nylas/models/contacts.py index 507c0592..9ddcc1cf 100644 --- a/nylas/models/contacts.py +++ b/nylas/models/contacts.py @@ -173,6 +173,20 @@ class Contact: groups: Optional[List[ContactGroupId]] = None +class FindContactQueryParams(TypedDict): + """ + The available query parameters for finding a contact. + Attributes: + profile_picture: If true and picture_url is present, the response includes a Base64 binary data blob that + you can use to view information as an image file. + select: Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. + """ + + profile_picture: NotRequired[bool] + select: NotRequired[str] + + class WriteablePhoneNumber(TypedDict): """ A phone number for a contact. @@ -316,21 +330,20 @@ class CreateContactRequest(TypedDict): class ListContactsQueryParams(ListQueryParams): """ - Interface of the query parameters for listing calendars. + Interface representing the query parameters for listing contacts. Attributes: + email: Return contacts with matching email address. + phone_number: Return contacts with matching phone number. + source: Return contacts from a specific source. + group: Return contacts from a specific group. + recurse: Return contacts from all sub-groups of the specified group. + select (NotRequired[str]): Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. limit (NotRequired[int]): The maximum number of objects to return. This field defaults to 50. The maximum allowed value is 200. page_token (NotRequired[str]): An identifier that specifies which page of data to return. This value should be taken from a ListResponse object's next_cursor parameter. - email: Returns the contacts matching the exact contact's email. - phone_number: Returns the contacts matching the contact's exact phone number - source: Returns the contacts matching from the address book or auto-generated contacts from emails. - For example of contacts only from the address book: /contacts?source=address_bookor - for only autogenerated contacts:/contacts?source=inbox` - group: Returns the contacts belonging to the Contact Group matching this ID - recurse: When set to true, returns the contacts also within the specified Contact Group subgroups, - if the group parameter is set. """ email: NotRequired[str] @@ -340,18 +353,6 @@ class ListContactsQueryParams(ListQueryParams): recurse: NotRequired[bool] -class FindContactQueryParams(TypedDict): - """ - The available query parameters for finding a contact. - - Attributes: - profile_picture: If true and picture_url is present, the response includes a Base64 binary data blob that - you can use to view information as an image file. - """ - - profile_picture: NotRequired[bool] - - class GroupType(str, Enum): """Enum representing the different types of contact groups.""" diff --git a/nylas/models/drafts.py b/nylas/models/drafts.py index f6e38037..34361bd5 100644 --- a/nylas/models/drafts.py +++ b/nylas/models/drafts.py @@ -149,6 +149,18 @@ class CreateDraftRequest(TypedDict): """ +class FindDraftQueryParams(TypedDict): + """ + Query parameters for finding a draft. + + Attributes: + select: Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. + """ + + select: NotRequired[str] + + class SendMessageRequest(CreateDraftRequest): """ A request to send a message. diff --git a/nylas/models/events.py b/nylas/models/events.py index dc47531b..a16069cc 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -703,11 +703,11 @@ class ListEventQueryParams(ListQueryParams): Interface representing the query parameters for listing events. Attributes: + calendar_id: Specify calendar ID of the event. "primary" is a supported value + indicating the user's primary calendar. show_cancelled: Return events that have a status of cancelled. If an event is recurring, then it returns no matter the value set. Different providers have different semantics for cancelled events. - calendar_id: Specify calendar ID of the event. "primary" is a supported value - indicating the user's primary calendar. title: Return events matching the specified title. description: Return events matching the specified description. location: Return events matching the specified location. @@ -723,14 +723,16 @@ class ListEventQueryParams(ListQueryParams): busy: Returns events with a busy status of true. order_by: Order results by the specified field. Currently only start is supported. - limit (NotRequired[int]): The maximum number of objects to return. - This field defaults to 50. The maximum allowed value is 200. - page_token (NotRequired[str]): An identifier that specifies which page of data to return. - This value should be taken from a ListResponse object's next_cursor parameter. event_type (NotRequired[List[EventType]]): (Google only) Filter events by event type. You can pass the query parameter multiple times to select or exclude multiple event types. master_event_id (NotRequired[str]): Filter for instances of recurring events with the specified master_event_id. Not respected by metadata filtering. + select: Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. """ calendar_id: str @@ -746,6 +748,7 @@ class ListEventQueryParams(ListQueryParams): order_by: NotRequired[str] event_type: NotRequired[List[EventType]] master_event_id: NotRequired[str] + select: NotRequired[str] class CreateEventQueryParams(TypedDict): diff --git a/nylas/models/folders.py b/nylas/models/folders.py index 33ade3f5..205502db 100644 --- a/nylas/models/folders.py +++ b/nylas/models/folders.py @@ -85,6 +85,24 @@ class ListFolderQueryParams(ListQueryParams): Attributes: parent_id: (Microsoft and EWS only.) Use the ID of a folder to find all child folders it contains. + select (NotRequired[str]): Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. """ parent_id: NotRequired[str] + + +class FindFolderQueryParams(TypedDict): + """ + Interface representing the query parameters for finding a folder. + + Attributes: + select: Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. + """ + + select: NotRequired[str] diff --git a/nylas/models/list_query_params.py b/nylas/models/list_query_params.py index e865d9cc..6ac6b645 100644 --- a/nylas/models/list_query_params.py +++ b/nylas/models/list_query_params.py @@ -10,7 +10,10 @@ class ListQueryParams(TypedDict): This field defaults to 50. The maximum allowed value is 200. page_token: An identifier that specifies which page of data to return. This value should be taken from a ListResponse object's next_cursor parameter. + select: Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. """ limit: NotRequired[int] page_token: NotRequired[str] + select: NotRequired[str] diff --git a/nylas/models/messages.py b/nylas/models/messages.py index b6f0bbdc..7e6c617d 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -104,6 +104,7 @@ class Message: "has_attachment": NotRequired[bool], "fields": NotRequired[Fields], "search_query_native": NotRequired[str], + "select": NotRequired[str], }, ) """ @@ -125,6 +126,8 @@ class Message: has_attachment: Filter messages by whether they have an attachment. fields: Specify "include_headers" to include headers in the response. "standard" is the default. search_query_native: A native provider search query for Google or Microsoft. + select: Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. limit (NotRequired[int]): The maximum number of objects to return. This field defaults to 50. The maximum allowed value is 200. page_token (NotRequired[str]): An identifier that specifies which page of data to return. @@ -138,9 +141,12 @@ class FindMessageQueryParams(TypedDict): Attributes: fields: Specify "include_headers" to include headers in the response. "standard" is the default. + select: Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. """ fields: NotRequired[Fields] + select: NotRequired[str] class UpdateMessageRequest(TypedDict): diff --git a/nylas/models/threads.py b/nylas/models/threads.py index 1485d3b0..5d46fe15 100644 --- a/nylas/models/threads.py +++ b/nylas/models/threads.py @@ -116,6 +116,7 @@ class UpdateThreadRequest(TypedDict): "latest_message_after": NotRequired[int], "has_attachment": NotRequired[bool], "search_query_native": NotRequired[str], + "select": NotRequired[str], }, ) """ @@ -136,6 +137,8 @@ class UpdateThreadRequest(TypedDict): latest_message_after: Return threads whose most recent message was received after this Unix timestamp. has_attachment: Filter threads by whether they have an attachment. search_query_native: A native provider search query for Google or Microsoft. + select: Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. limit (NotRequired[int]): The maximum number of objects to return. This field defaults to 50. The maximum allowed value is 200. page_token (NotRequired[str]): An identifier that specifies which page of data to return. diff --git a/nylas/resources/calendars.py b/nylas/resources/calendars.py index 14db916c..684df4e4 100644 --- a/nylas/resources/calendars.py +++ b/nylas/resources/calendars.py @@ -20,6 +20,7 @@ CreateCalendarRequest, UpdateCalendarRequest, ListCalendarsQueryParams, + FindCalendarQueryParams, ) from nylas.models.response import Response, ListResponse, DeleteResponse @@ -66,7 +67,11 @@ def list( ) def find( - self, identifier: str, calendar_id: str, overrides: RequestOverrides = None + self, + identifier: str, + calendar_id: str, + overrides: RequestOverrides = None, + query_params: FindCalendarQueryParams = None, ) -> Response[Calendar]: """ Return a Calendar. @@ -76,6 +81,7 @@ def find( calendar_id: The ID of the Calendar to retrieve. Use "primary" to refer to the primary Calendar associated with the Grant. overrides: The request overrides to use for the request. + query_params: The query parameters to include in the request. Returns: The Calendar. @@ -83,6 +89,7 @@ def find( return super().find( path=f"/v3/grants/{identifier}/calendars/{calendar_id}", response_type=Calendar, + query_params=query_params, overrides=overrides, ) diff --git a/nylas/resources/drafts.py b/nylas/resources/drafts.py index 222ccdc4..e817d5fc 100644 --- a/nylas/resources/drafts.py +++ b/nylas/resources/drafts.py @@ -15,6 +15,7 @@ Draft, UpdateDraftRequest, CreateDraftRequest, + FindDraftQueryParams, ) from nylas.models.messages import Message from nylas.models.response import ListResponse, Response, DeleteResponse @@ -67,6 +68,7 @@ def find( identifier: str, draft_id: str, overrides: RequestOverrides = None, + query_params: FindDraftQueryParams = None, ) -> Response[Draft]: """ Return a Draft. @@ -75,6 +77,7 @@ def find( identifier: The identifier of the grant to get the draft for. draft_id: The identifier of the draft to get. overrides: The request overrides to use for the request. + query_params: The query parameters to include in the request. Returns: The requested Draft. @@ -82,6 +85,7 @@ def find( return super().find( path=f"/v3/grants/{identifier}/drafts/{urllib.parse.quote(draft_id, safe='')}", response_type=Draft, + query_params=query_params, overrides=overrides, ) diff --git a/nylas/resources/folders.py b/nylas/resources/folders.py index 0dfccdda..487d7ea3 100644 --- a/nylas/resources/folders.py +++ b/nylas/resources/folders.py @@ -13,6 +13,7 @@ CreateFolderRequest, UpdateFolderRequest, ListFolderQueryParams, + FindFolderQueryParams, ) from nylas.models.response import Response, ListResponse, DeleteResponse @@ -60,6 +61,7 @@ def find( identifier: str, folder_id: str, overrides: RequestOverrides = None, + query_params: FindFolderQueryParams = None, ) -> Response[Folder]: """ Return a Folder. @@ -68,6 +70,7 @@ def find( identifier: The identifier of the Grant to act upon. folder_id: The ID of the Folder to retrieve. overrides: The request overrides to use. + query_params: The query parameters to include in the request. Returns: The Folder. @@ -75,6 +78,7 @@ def find( return super().find( path=f"/v3/grants/{identifier}/folders/{folder_id}", response_type=Folder, + query_params=query_params, overrides=overrides, ) diff --git a/tests/conftest.py b/tests/conftest.py index 35bca720..54eaeb81 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -174,7 +174,7 @@ def http_client_list_scheduled_messages(): }, { "schedule_id": "rb856334-6d95-432c-86d1-c5dab0ce98be", - "status": {"code": "sucess", "description": "schedule send succeeded"}, + "status": {"code": "success", "description": "schedule send succeeded"}, "close_time": 1690579819, }, ], diff --git a/tests/resources/test_calendars.py b/tests/resources/test_calendars.py index 7fe6b1fd..4e42267a 100644 --- a/tests/resources/test_calendars.py +++ b/tests/resources/test_calendars.py @@ -60,6 +60,40 @@ def test_list_calendars_with_query_params(self, http_client_list_response): overrides=None, ) + def test_list_calendars_with_select_param(self, http_client_list_response): + calendars = Calendars(http_client_list_response) + + # Set up mock response data + http_client_list_response._execute.return_value = { + "request_id": "abc-123", + "data": [{ + "id": "calendar-123", + "name": "My Calendar", + "description": "My calendar description" + }] + } + + # Call the API method + result = calendars.list( + identifier="abc-123", + query_params={ + "select": "id,name,description" + } + ) + + # Verify API call + http_client_list_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/calendars", + None, + {"select": "id,name,description"}, + None, + overrides=None, + ) + + # The actual response validation is handled by the mock in conftest.py + assert result is not None + def test_find_calendar(self, http_client_response): calendars = Calendars(http_client_response) @@ -74,6 +108,39 @@ def test_find_calendar(self, http_client_response): overrides=None, ) + def test_find_calendar_with_select_param(self, http_client_response): + calendars = Calendars(http_client_response) + + # Set up mock response data + http_client_response._execute.return_value = ({ + "request_id": "abc-123", + "data": { + "id": "calendar-123", + "name": "My Calendar", + "description": "My calendar description" + } + }, {"X-Test-Header": "test"}) + + # Call the API method + result = calendars.find( + identifier="abc-123", + calendar_id="calendar-123", + query_params={"select": "id,name,description"} + ) + + # Verify API call + http_client_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/calendars/calendar-123", + None, + {"select": "id,name,description"}, + None, + overrides=None, + ) + + # The actual response validation is handled by the mock in conftest.py + assert result is not None + def test_create_calendar(self, http_client_response): calendars = Calendars(http_client_response) request_body = { diff --git a/tests/resources/test_contacts.py b/tests/resources/test_contacts.py index cc0322eb..c55cff08 100644 --- a/tests/resources/test_contacts.py +++ b/tests/resources/test_contacts.py @@ -112,6 +112,41 @@ def test_list_contacts_with_query_params(self, http_client_list_response): overrides=None, ) + def test_list_contacts_with_select_param(self, http_client_list_response): + contacts = Contacts(http_client_list_response) + + # Set up mock response data + http_client_list_response._execute.return_value = { + "request_id": "abc-123", + "data": [{ + "id": "contact-123", + "given_name": "John", + "surname": "Doe", + "emails": [{"email": "john@example.com", "type": "work"}] + }] + } + + # Call the API method + result = contacts.list( + identifier="abc-123", + query_params={ + "select": "id,given_name,surname,emails" + } + ) + + # Verify API call + http_client_list_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/contacts", + None, + {"select": "id,given_name,surname,emails"}, + None, + overrides=None, + ) + + # The actual response validation is handled by the mock in conftest.py + assert result is not None + def test_find_contact(self, http_client_response): contacts = Contacts(http_client_response) @@ -126,6 +161,40 @@ def test_find_contact(self, http_client_response): overrides=None, ) + def test_find_contact_with_select_param(self, http_client_response): + contacts = Contacts(http_client_response) + + # Set up mock response data + http_client_response._execute.return_value = ({ + "request_id": "abc-123", + "data": { + "id": "contact-123", + "given_name": "John", + "surname": "Doe", + "emails": [{"email": "john@example.com", "type": "work"}] + } + }, {"X-Test-Header": "test"}) + + # Call the API method + result = contacts.find( + identifier="abc-123", + contact_id="contact-123", + query_params={"select": "id,given_name,surname,emails"} + ) + + # Verify API call + http_client_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/contacts/contact-123", + None, + {"select": "id,given_name,surname,emails"}, + None, + overrides=None, + ) + + # The actual response validation is handled by the mock in conftest.py + assert result is not None + def test_find_contact_with_query_params(self, http_client_response): contacts = Contacts(http_client_response) diff --git a/tests/resources/test_events.py b/tests/resources/test_events.py index 1afd9b4a..a66f4127 100644 --- a/tests/resources/test_events.py +++ b/tests/resources/test_events.py @@ -123,6 +123,58 @@ def test_list_events(self, http_client_list_response): overrides=None, ) + def test_list_events_with_query_params(self, http_client_list_response): + events = Events(http_client_list_response) + + events.list(identifier="abc-123", query_params={"limit": 20}) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/events", + None, + {"limit": 20}, + None, + overrides=None, + ) + + def test_list_events_with_select_param(self, http_client_list_response): + events = Events(http_client_list_response) + + # Set up mock response data + http_client_list_response._execute.return_value = { + "request_id": "abc-123", + "data": [{ + "id": "event-123", + "title": "Team Meeting", + "description": "Weekly team sync", + "when": { + "start_time": 1625097600, + "end_time": 1625101200 + } + }] + } + + # Call the API method + result = events.list( + identifier="abc-123", + query_params={ + "select": "id,title,description,when" + } + ) + + # Verify API call + http_client_list_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/events", + None, + {"select": "id,title,description,when"}, + None, + overrides=None, + ) + + # The actual response validation is handled by the mock in conftest.py + assert result is not None + def test_find_event(self, http_client_response): events = Events(http_client_response) @@ -141,6 +193,49 @@ def test_find_event(self, http_client_response): overrides=None, ) + def test_find_event_with_select_param(self, http_client_response): + events = Events(http_client_response) + + # Set up mock response data + http_client_response._execute.return_value = ({ + "request_id": "abc-123", + "data": { + "id": "event-123", + "title": "Team Meeting", + "description": "Weekly team sync", + "when": { + "start_time": 1625097600, + "end_time": 1625101200 + } + } + }, {"X-Test-Header": "test"}) + + # Call the API method + result = events.find( + identifier="abc-123", + event_id="event-123", + query_params={ + "calendar_id": "abc-123", + "select": "id,title,description,when" + } + ) + + # Verify API call + http_client_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/events/event-123", + None, + { + "calendar_id": "abc-123", + "select": "id,title,description,when" + }, + None, + overrides=None, + ) + + # The actual response validation is handled by the mock in conftest.py + assert result is not None + def test_create_event(self, http_client_response): events = Events(http_client_response) request_body = { diff --git a/tests/resources/test_folders.py b/tests/resources/test_folders.py index 763b41af..da53dec7 100644 --- a/tests/resources/test_folders.py +++ b/tests/resources/test_folders.py @@ -44,6 +44,55 @@ def test_list_folders(self, http_client_list_response): "GET", "/v3/grants/abc-123/folders", None, None, None, overrides=None ) + def test_list_folders_with_query_params(self, http_client_list_response): + folders = Folders(http_client_list_response) + + folders.list(identifier="abc-123", query_params={"limit": 20}) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/folders", + None, + {"limit": 20}, + None, + overrides=None, + ) + + def test_list_folders_with_select_param(self, http_client_list_response): + folders = Folders(http_client_list_response) + + # Set up mock response data + http_client_list_response._execute.return_value = { + "request_id": "abc-123", + "data": [{ + "id": "folder-123", + "name": "Important", + "total_count": 42, + "unread_count": 5 + }] + } + + # Call the API method + result = folders.list( + identifier="abc-123", + query_params={ + "select": "id,name,total_count,unread_count" + } + ) + + # Verify API call + http_client_list_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/folders", + None, + {"select": "id,name,total_count,unread_count"}, + None, + overrides=None, + ) + + # The actual response validation is handled by the mock in conftest.py + assert result is not None + def test_find_folder(self, http_client_response): folders = Folders(http_client_response) @@ -58,6 +107,40 @@ def test_find_folder(self, http_client_response): overrides=None, ) + def test_find_folder_with_select_param(self, http_client_response): + folders = Folders(http_client_response) + + # Set up mock response data + http_client_response._execute.return_value = ({ + "request_id": "abc-123", + "data": { + "id": "folder-123", + "name": "Important", + "total_count": 42, + "unread_count": 5 + } + }, {"X-Test-Header": "test"}) + + # Call the API method + result = folders.find( + identifier="abc-123", + folder_id="folder-123", + query_params={"select": "id,name,total_count,unread_count"} + ) + + # Verify API call + http_client_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/folders/folder-123", + None, + {"select": "id,name,total_count,unread_count"}, + None, + overrides=None, + ) + + # The actual response validation is handled by the mock in conftest.py + assert result is not None + def test_create_folder(self, http_client_response): folders = Folders(http_client_response) request_body = { diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 5e9b3720..4577bc7b 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -383,23 +383,6 @@ def test_clean_messages(self, http_client_clean_messages): assert response.data[0].grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" assert response.data[0].conversation == "cleaned example" assert response.data[1].conversation == "another example" - assert message.folders[0] == "8l6c4d11y1p4dm4fxj52whyr9" - assert message.folders[1] == "d9zkcr2tljpu3m4qpj7l2hbr0" - assert message.from_ == [ - {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} - ] - assert message.grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" - assert message.id == "5d3qmne77v32r8l4phyuksl2x" - assert message.object == "message" - assert message.reply_to == [ - {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} - ] - assert message.snippet == "Hello, I just sent a message using Nylas!" - assert message.starred is True - assert message.subject == "Hello from Nylas!" - assert message.thread_id == "1t8tv3890q4vgmwq6pmdwm8qgsaer" - assert message.to == [{"name": "Jon Snow", "email": "j.snow@example.com"}] - assert message.unread is True def test_list_messages(self, http_client_list_response): messages = Messages(http_client_list_response) @@ -650,7 +633,7 @@ def test_list_scheduled_messages(self, http_client_list_scheduled_messages): assert res.data[0].status.code == "pending" assert res.data[0].status.description == "schedule send awaiting send at time" assert res.data[1].schedule_id == "rb856334-6d95-432c-86d1-c5dab0ce98be" - assert res.data[1].status.code == "sucess" + assert res.data[1].status.code == "success" assert res.data[1].status.description == "schedule send succeeded" assert res.data[1].close_time == 1690579819 @@ -714,3 +697,38 @@ def test_clean_messages(self, http_client_clean_messages): assert response.data[0].grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" assert response.data[0].conversation == "cleaned example" assert response.data[1].conversation == "another example" + + + def test_list_messages_select_param(self, http_client_list_response): + messages = Messages(http_client_list_response) + + messages.list(identifier="abc-123", query_params={"select": ["id", "subject", "from", "to"]}) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages", + None, + {"select": ["id", "subject", "from", "to"]}, + None, + overrides=None, + ) + + # Make sure query params are properly serialized + assert http_client_list_response._execute.call_args[0][3] == {"select": ["id", "subject", "from", "to"]} + + def test_find_message_select_param(self, http_client_response): + messages = Messages(http_client_response) + + messages.find(identifier="abc-123", message_id="message-123", query_params={"select": ["id", "subject", "from", "to"]}) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/message-123", + None, + {"select": ["id", "subject", "from", "to"]}, + None, + overrides=None, + ) + + # Make sure query params are properly serialized + assert http_client_response._execute.call_args[0][3] == {"select": ["id", "subject", "from", "to"]} From b3323a414d70a6689d0db83ae88b5eec1ea4ff30 Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Fri, 14 Feb 2025 18:34:08 -0300 Subject: [PATCH 126/186] Updated the Environment enum, added 'sandbox' --- nylas/models/application_details.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nylas/models/application_details.py b/nylas/models/application_details.py index 5c465a01..9f919fd3 100644 --- a/nylas/models/application_details.py +++ b/nylas/models/application_details.py @@ -8,7 +8,7 @@ Region = Literal["us", "eu"] """ Literal representing the available Nylas API regions. """ -Environment = Literal["production", "staging"] +Environment = Literal["production", "staging", "sandbox"] """ Literal representing the different Nylas API environments. """ From e4eb7c29900327ae6431be1feba5c4272cce25e7 Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Fri, 14 Feb 2025 18:45:36 -0300 Subject: [PATCH 127/186] Changed data-type of 'in' to [str] rather than [List[str]] as per API doc. spec. --- nylas/models/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nylas/models/messages.py b/nylas/models/messages.py index 7e6c617d..ba1ea995 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -95,7 +95,7 @@ class Message: "to": NotRequired[List[str]], "cc": NotRequired[List[str]], "bcc": NotRequired[List[str]], - "in": NotRequired[List[str]], + "in": NotRequired[str], "unread": NotRequired[bool], "starred": NotRequired[bool], "thread_id": NotRequired[str], From 9f6a92503ce8a7dac5e2be9bee72308f8c40a4f0 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Wed, 19 Feb 2025 14:44:09 -0500 Subject: [PATCH 128/186] Release v6.7.0 (#409) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a75e2ba..07e7c651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.7.0 ---------------- * Added support for `select` query parameter in list calendars, list events, and list messages. From f843f255fc51e149b3462f7cce13149b341566fd Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Mon, 3 Mar 2025 10:47:05 -0500 Subject: [PATCH 129/186] feat: Added support for list_import_events (#411) * feat: Added support for list_import_events * Renamed max_results to limit --- CHANGELOG.md | 4 + examples/import_events_demo/README.md | 68 ++++++ .../import_events_example.py | 197 ++++++++++++++++++ nylas/models/events.py | 22 ++ nylas/resources/events.py | 29 +++ tests/resources/test_events.py | 110 ++++++++++ 6 files changed, 430 insertions(+) create mode 100644 examples/import_events_demo/README.md create mode 100644 examples/import_events_demo/import_events_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e7c651..5547e880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Added support for `list_import_events` + v6.7.0 ---------------- * Added support for `select` query parameter in list calendars, list events, and list messages. diff --git a/examples/import_events_demo/README.md b/examples/import_events_demo/README.md new file mode 100644 index 00000000..cb52af00 --- /dev/null +++ b/examples/import_events_demo/README.md @@ -0,0 +1,68 @@ +# Import Events Demo + +This example demonstrates the usage of the `list_import_events` method in the Nylas SDK. This method returns a list of recurring events, recurring event exceptions, and single events from a specified calendar within a given time frame. It's particularly useful when you want to import, store, and synchronize events from a calendar to your application. + +## Features Demonstrated + +1. **Basic Usage**: Shows how to use `list_import_events` with required parameters. +2. **Time Filtering**: Demonstrates filtering events by start and end time. +3. **Pagination**: Shows how to handle paginated results with `limit` and `page_token`. +4. **Field Selection**: Demonstrates how to use the `select` parameter to request only specific fields. +5. **Multiple Scenarios**: Shows various parameter combinations for different use cases. + +## Setup + +1. Create a `.env` file in the root directory with your Nylas API credentials: + ``` + NYLAS_API_KEY=your_api_key_here + NYLAS_GRANT_ID=your_grant_id_here + ``` + +2. Install the required dependencies: + ```bash + pip install nylas python-dotenv + ``` + +## Running the Example + +Run the example script: +```bash +python examples/import_events_demo/import_events_example.py +``` + +The script will demonstrate different ways to use the `list_import_events` method with various parameters. + +## Example Output + +The script will show output similar to this: +``` +=== Import Events Demo === + +Basic import (primary calendar): +Event - Title: Team Meeting, ID: abc123... + +Time-filtered import (Jan 1, 2023 - Dec 31, 2023): +Event - Title: Annual Review, ID: def456... + +Limited results with field selection (only id, title and when): +Event - Title: Client Call, ID: ghi789... +``` + +## Benefits of Using Import Events + +1. **Efficient Syncing**: Easily synchronize calendar events to your application or database. +2. **Better Performance**: Using time filters and limiting results can improve performance. +3. **Selective Data**: Using the select parameter allows you to request only the fields you need. + +## Available Parameters + +The `list_import_events` method accepts the following parameters: + +- `calendar_id` (required): Specify the calendar ID to import events from. You can use "primary" for the user's primary calendar. +- `start`: Filter for events starting at or after this Unix timestamp. +- `end`: Filter for events ending at or before this Unix timestamp. +- `select`: Comma-separated list of fields to return in the response. +- `limit`: Maximum number of objects to return (defaults to 50, max 200). +- `page_token`: Token for retrieving the next page of results. + +For more information, refer to the [Nylas API documentation](https://developer.nylas.com/). \ No newline at end of file diff --git a/examples/import_events_demo/import_events_example.py b/examples/import_events_demo/import_events_example.py new file mode 100644 index 00000000..da347e60 --- /dev/null +++ b/examples/import_events_demo/import_events_example.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Using Import Events + +This example demonstrates how to use the 'list_import_events' method to import and +synchronize events from a calendar within a given time frame. + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID + +Usage: + First, install the SDK in development mode: + cd /path/to/nylas-python + pip install -e . + + Then set environment variables and run: + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + python examples/import_events_demo/import_events_example.py +""" + +import os +import sys +import json +import time +from datetime import datetime, timedelta +from nylas import Client + + +def get_env_or_exit(var_name: str) -> str: + """Get an environment variable or exit if not found.""" + value = os.getenv(var_name) + if not value: + print(f"Error: {var_name} environment variable is required") + sys.exit(1) + return value + + +def print_data(data: list, title: str) -> None: + """Pretty print the data with a title.""" + print(f"\n{title}:") + for item in data: + # Convert to dict and pretty print + item_dict = item.to_dict() + print(json.dumps(item_dict, indent=2)) + + +def demonstrate_basic_import(client: Client, grant_id: str) -> None: + """Demonstrate basic usage of list_import_events with primary calendar.""" + print("\n=== Basic Import Events ===") + + print("\nFetching events from primary calendar:") + events = client.events.list_import_events( + identifier=grant_id, + query_params={"calendar_id": "primary", "limit": 2} + ) + print_data(events.data, "Basic import events") + + +def demonstrate_time_filtered_import(client: Client, grant_id: str) -> None: + """Demonstrate import events with time filtering.""" + print("\n=== Time Filtered Import Events ===") + + # Get timestamps for a one-month period + now = int(time.time()) + one_month_ago = now - (30 * 24 * 60 * 60) # 30 days ago + one_month_future = now + (30 * 24 * 60 * 60) # 30 days in future + + # Format dates for display + from_date = datetime.fromtimestamp(one_month_ago).strftime("%Y-%m-%d") + to_date = datetime.fromtimestamp(one_month_future).strftime("%Y-%m-%d") + + print(f"\nFetching events from {from_date} to {to_date}:") + events = client.events.list_import_events( + identifier=grant_id, + query_params={ + "calendar_id": "primary", + "start": one_month_ago, + "end": one_month_future + } + ) + print_data(events.data, f"Events from {from_date} to {to_date}") + + +def demonstrate_limit(client: Client, grant_id: str) -> None: + """Demonstrate import events with limit parameter.""" + print("\n=== Import Events with Max Results ===") + + print("\nFetching events with limit=5:") + events = client.events.list_import_events( + identifier=grant_id, + query_params={ + "calendar_id": "primary", + "limit": 5 + } + ) + print_data(events.data, "Events with limit=5") + + +def demonstrate_field_selection(client: Client, grant_id: str) -> None: + """Demonstrate import events with field selection.""" + print("\n=== Import Events with Field Selection ===") + + print("\nFetching events with select parameter (only id, title, and when):") + events = client.events.list_import_events( + identifier=grant_id, + query_params={ + "calendar_id": "primary", + "limit": 2, + "select": "id,title,when" + } + ) + print_data(events.data, "Events with selected fields only") + + +def demonstrate_pagination(client: Client, grant_id: str) -> None: + """Demonstrate pagination for import events.""" + print("\n=== Import Events with Pagination ===") + + # First page + print("\nFetching first page of events (limit=3):") + first_page = client.events.list_import_events( + identifier=grant_id, + query_params={ + "calendar_id": "primary", + "limit": 3 + } + ) + print_data(first_page.data, "First page of events") + + # If there's a next page, fetch it + if hasattr(first_page, 'next_cursor') and first_page.next_cursor: + print("\nFetching second page of events:") + second_page = client.events.list_import_events( + identifier=grant_id, + query_params={ + "calendar_id": "primary", + "limit": 3, + "page_token": first_page.next_cursor + } + ) + print_data(second_page.data, "Second page of events") + else: + print("\nNo second page available - not enough events to paginate") + + +def demonstrate_full_example(client: Client, grant_id: str) -> None: + """Demonstrate a full example with all parameters.""" + print("\n=== Full Import Events Example ===") + + # Get timestamps for the current year + now = datetime.now() + start_of_year = datetime(now.year, 1, 1).timestamp() + end_of_year = datetime(now.year, 12, 31, 23, 59, 59).timestamp() + + print(f"\nFetching events for {now.year} with all parameters:") + events = client.events.list_import_events( + identifier=grant_id, + query_params={ + "calendar_id": "primary", + "limit": 10, + "start": int(start_of_year), + "end": int(end_of_year), + "select": "id,title,description,when,participants,location" + } + ) + print_data(events.data, f"Events for {now.year} with all parameters") + + +def main(): + """Main function demonstrating the import events method.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + + # Initialize Nylas client + client = Client( + api_key=api_key, + ) + + print("\nDemonstrating Import Events Functionality") + print("========================================") + + # Demonstrate different ways to use list_import_events + demonstrate_basic_import(client, grant_id) + demonstrate_time_filtered_import(client, grant_id) + demonstrate_limit(client, grant_id) + demonstrate_field_selection(client, grant_id) + demonstrate_pagination(client, grant_id) + demonstrate_full_example(client, grant_id) + + print("\nExample completed!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/nylas/models/events.py b/nylas/models/events.py index a16069cc..c95c6677 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -804,3 +804,25 @@ class SendRsvpRequest(TypedDict): """ status: SendRsvpStatus + + +class ListImportEventsQueryParams(ListQueryParams): + """ + Interface representing the query parameters for listing imported events. + + Attributes: + calendar_id: Specify calendar ID to import events to. "primary" is a supported value + indicating the user's primary calendar. + start: Filter for events that start at or after the specified time, in Unix timestamp format. + end: Filter for events that end at or before the specified time, in Unix timestamp format. + select: Comma-separated list of fields to return in the response. + This allows you to receive only the portion of object data that you're interested in. + page_token: An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + """ + + calendar_id: str + start: NotRequired[int] + end: NotRequired[int] + select: NotRequired[str] + page_token: NotRequired[str] diff --git a/nylas/resources/events.py b/nylas/resources/events.py index 95f05bdd..3b9223ab 100644 --- a/nylas/resources/events.py +++ b/nylas/resources/events.py @@ -12,6 +12,7 @@ CreateEventRequest, FindEventQueryParams, ListEventQueryParams, + ListImportEventsQueryParams, CreateEventQueryParams, UpdateEventQueryParams, DestroyEventQueryParams, @@ -64,6 +65,34 @@ def list( overrides=overrides, ) + def list_import_events( + self, + identifier: str, + query_params: ListImportEventsQueryParams, + overrides: RequestOverrides = None, + ) -> ListResponse[Event]: + """ + Returns a list of recurring events, recurring event exceptions, and + single events from the specified calendar within a given time frame. + This is useful when you want to import, store, and synchronize events + from the time frame to your application + + Args: + identifier: The identifier of the Grant to act upon. + query_params: The query parameters to include in the request. + overrides: The request overrides to use for the request. + + Returns: + The list of imported Events. + """ + + return super().list( + path=f"/v3/grants/{identifier}/events/import", + response_type=Event, + query_params=query_params, + overrides=overrides, + ) + def find( self, identifier: str, diff --git a/tests/resources/test_events.py b/tests/resources/test_events.py index a66f4127..686fe173 100644 --- a/tests/resources/test_events.py +++ b/tests/resources/test_events.py @@ -175,6 +175,116 @@ def test_list_events_with_select_param(self, http_client_list_response): # The actual response validation is handled by the mock in conftest.py assert result is not None + def test_list_import_events(self, http_client_list_response): + events = Events(http_client=http_client_list_response) + events.list_import_events( + identifier="grant-123", + query_params={"calendar_id": "primary"}, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/grant-123/events/import", + None, + {"calendar_id": "primary"}, + None, + overrides=None, + ) + + def test_list_import_events_with_select_param(self, http_client_list_response): + events = Events(http_client=http_client_list_response) + events.list_import_events( + identifier="grant-123", + query_params={"calendar_id": "primary", "select": "id,title,participants"}, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/grant-123/events/import", + None, + {"calendar_id": "primary", "select": "id,title,participants"}, + None, + overrides=None, + ) + + def test_list_import_events_with_limit(self, http_client_list_response): + events = Events(http_client=http_client_list_response) + events.list_import_events( + identifier="grant-123", + query_params={"calendar_id": "primary", "limit": 100}, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/grant-123/events/import", + None, + {"calendar_id": "primary", "limit": 100}, + None, + overrides=None, + ) + + def test_list_import_events_with_time_filters(self, http_client_list_response): + events = Events(http_client=http_client_list_response) + # Using Unix timestamps for Jan 1, 2023 and Dec 31, 2023 + start_time = 1672531200 # Jan 1, 2023 + end_time = 1704067199 # Dec 31, 2023 + + events.list_import_events( + identifier="grant-123", + query_params={ + "calendar_id": "primary", + "start": start_time, + "end": end_time + }, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/grant-123/events/import", + None, + { + "calendar_id": "primary", + "start": start_time, + "end": end_time + }, + None, + overrides=None, + ) + + def test_list_import_events_with_all_params(self, http_client_list_response): + events = Events(http_client=http_client_list_response) + # Using Unix timestamps for Jan 1, 2023 and Dec 31, 2023 + start_time = 1672531200 # Jan 1, 2023 + end_time = 1704067199 # Dec 31, 2023 + + events.list_import_events( + identifier="grant-123", + query_params={ + "calendar_id": "primary", + "limit": 50, + "start": start_time, + "end": end_time, + "select": "id,title,participants,when", + "page_token": "next-page-token-123" + }, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/grant-123/events/import", + None, + { + "calendar_id": "primary", + "limit": 50, + "start": start_time, + "end": end_time, + "select": "id,title,participants,when", + "page_token": "next-page-token-123" + }, + None, + overrides=None, + ) + def test_find_event(self, http_client_response): events = Events(http_client_response) From b30bfe45de15d704a52ef4641ff82f23b232fcd9 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Mon, 3 Mar 2025 16:38:08 -0500 Subject: [PATCH 130/186] Release v6.8.0 (#413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated changelog * Bump version: 6.6.0 โ†’ 6.7.0 * Bump version: 6.7.0 โ†’ 6.8.0 --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index b3d18065..2a660d8e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.6.0 +current_version = 6.8.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 5547e880..65444a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.8.0 ---------------- * Added support for `list_import_events` diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 2fa8fbe0..9d9cc746 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.6.0" +__VERSION__ = "6.8.0" From 414311ef6a52bbf64784304042babdc2b3b67f9a Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 8 Apr 2025 16:24:06 -0400 Subject: [PATCH 131/186] feat: Add Notetaker API support (#414) * feat: Added support for Notetaker APIs * feat: Added support for Notetaker via the calendar and event APIs * fix: resolve test failures in calendar and configuration models * Fix lint errors * fix paths, added example * Fixed issues with the current notetaker implementation --- CHANGELOG.md | 5 + examples/notetaker_api_demo/README.md | 52 ++ examples/notetaker_api_demo/notetaker_demo.py | 145 +++++ examples/notetaker_calendar_demo/README.md | 50 ++ .../notetaker_calendar_demo.py | 178 ++++++ nylas/client.py | 11 + nylas/handler/api_resources.py | 1 + nylas/models/calendars.py | 139 ++++- nylas/models/events.py | 95 +++ nylas/models/notetakers.py | 254 ++++++++ nylas/models/scheduler.py | 23 +- nylas/resources/calendars.py | 16 +- nylas/resources/notetakers.py | 236 ++++++++ tests/resources/test_calendars.py | 227 +++++-- tests/resources/test_events.py | 111 +++- tests/resources/test_notetakers.py | 561 ++++++++++++++++++ tests/test_client.py | 6 + 17 files changed, 2037 insertions(+), 73 deletions(-) create mode 100644 examples/notetaker_api_demo/README.md create mode 100644 examples/notetaker_api_demo/notetaker_demo.py create mode 100644 examples/notetaker_calendar_demo/README.md create mode 100644 examples/notetaker_calendar_demo/notetaker_calendar_demo.py create mode 100644 nylas/models/notetakers.py create mode 100644 nylas/resources/notetakers.py create mode 100644 tests/resources/test_notetakers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 65444a9d..ac7f9a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Added support for Notetaker APIs +* Added support for Notetaker via the calendar and event APIs + v6.8.0 ---------------- * Added support for `list_import_events` diff --git a/examples/notetaker_api_demo/README.md b/examples/notetaker_api_demo/README.md new file mode 100644 index 00000000..81b5c26c --- /dev/null +++ b/examples/notetaker_api_demo/README.md @@ -0,0 +1,52 @@ +# Notetaker API Demo + +This demo showcases how to use the Nylas Notetaker API to create, manage, and interact with notes. + +## Features Demonstrated + +- Creating new notes +- Retrieving notes +- Updating notes +- Deleting notes +- Managing note metadata +- Sharing notes with other users + +## Prerequisites + +- Python 3.8+ +- Nylas Python SDK (local version from this repository) +- Nylas API credentials (Client ID and Client Secret) + +## Setup + +1. Install the SDK in development mode: +```bash +# From the root of the nylas-python repository +pip install -e . +``` + +2. Set up your environment variables: +```bash +export NYLAS_API_KEY='your_api_key' +export NYLAS_API_URI='https://api.nylas.com' # Optional, defaults to https://api.nylas.com +``` + +## Running the Demo + +From the root of the repository: +```bash +python examples/notetaker_api_demo/notetaker_demo.py +``` + +## Code Examples + +The demo includes examples of: + +1. Creating a new note +2. Retrieving a list of notes +3. Updating an existing note +4. Deleting a note +5. Managing note metadata +6. Sharing notes with other users + +Each example is documented with comments explaining the functionality and expected output. \ No newline at end of file diff --git a/examples/notetaker_api_demo/notetaker_demo.py b/examples/notetaker_api_demo/notetaker_demo.py new file mode 100644 index 00000000..4e27085c --- /dev/null +++ b/examples/notetaker_api_demo/notetaker_demo.py @@ -0,0 +1,145 @@ +import os +import sys +import json +from nylas import Client +from nylas.models.notetakers import NotetakerMeetingSettingsRequest, NotetakerState, InviteNotetakerRequest +from nylas.models.errors import NylasApiError + +# Initialize the Nylas client +nylas = Client( + api_key=os.getenv("NYLAS_API_KEY"), + api_uri=os.getenv("NYLAS_API_URI", "https://api.us.nylas.com") +) + +def invite_notetaker(): + """Demonstrates how to invite a Notetaker to a meeting.""" + print("\n=== Inviting Notetaker to Meeting ===") + + try: + meeting_link = os.getenv("MEETING_LINK") + if not meeting_link: + raise ValueError("MEETING_LINK environment variable is not set. Please set it with your meeting URL.") + + request_body: InviteNotetakerRequest = { + "meeting_link": meeting_link, + "name": "Nylas Notetaker", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + } + + print(f"Request body: {json.dumps(request_body, indent=2)}") + + notetaker = nylas.notetakers.invite(request_body=request_body) + + print(f"Invited Notetaker with ID: {notetaker.data.id}") + print(f"Name: {notetaker.data.name}") + print(f"State: {notetaker.data.state}") + return notetaker + except NylasApiError as e: + print(f"Error inviting notetaker: {str(e)}") + print(f"Error details: {e.__dict__}") + raise + except json.JSONDecodeError as e: + print(f"JSON decode error: {str(e)}") + raise + except Exception as e: + print(f"Unexpected error in invite_notetaker: {str(e)}") + print(f"Error type: {type(e)}") + print(f"Error details: {e.__dict__}") + raise + +def list_notetakers(): + """Demonstrates how to list all Notetakers.""" + print("\n=== Listing All Notetakers ===") + + try: + notetakers = nylas.notetakers.list() + + print(f"Found {len(notetakers.data)} notetakers:") + for notetaker in notetakers.data: + print(f"- {notetaker.name} (ID: {notetaker.id}, State: {notetaker.state})") + + return notetakers + except NylasApiError as e: + print(f"Error listing notetakers: {str(e)}") + raise + except Exception as e: + print(f"Unexpected error in list_notetakers: {str(e)}") + raise + +def get_notetaker_media(notetaker_id): + """Demonstrates how to get media from a Notetaker.""" + print("\n=== Getting Notetaker Media ===") + + try: + media = nylas.notetakers.get_media(notetaker_id) + + if media.recording: + print(f"Recording URL: {media.data.recording.url}") + print(f"Recording Size: {media.data.recording.size} MB") + if media.transcript: + print(f"Transcript URL: {media.data.transcript.url}") + print(f"Transcript Size: {media.data.transcript.size} MB") + + return media + except NylasApiError as e: + print(f"Error getting notetaker media: {str(e)}") + raise + except Exception as e: + print(f"Unexpected error in get_notetaker_media: {str(e)}") + raise + +def leave_notetaker(notetaker_id): + """Demonstrates how to leave a Notetaker.""" + print("\n=== Leaving Notetaker ===") + + try: + nylas.notetakers.leave(notetaker_id) + print(f"Left Notetaker with ID: {notetaker_id}") + except NylasApiError as e: + print(f"Error leaving notetaker: {str(e)}") + raise + except Exception as e: + print(f"Unexpected error in leave_notetaker: {str(e)}") + raise + +def main(): + """Main function to run all demo examples.""" + try: + # Check for required environment variables + api_key = os.getenv("NYLAS_API_KEY") + if not api_key: + raise ValueError("NYLAS_API_KEY environment variable is not set") + print(f"Using API key: {api_key[:5]}...") + + # Invite a Notetaker to a meeting + notetaker = invite_notetaker() + + # List all Notetakers + list_notetakers() + + # Get media from the Notetaker (if available) + if notetaker.data.state == NotetakerState.MEDIA_AVAILABLE: + get_notetaker_media(notetaker.data.id) + + # Leave the Notetaker + leave_notetaker(notetaker.data.id) + + except NylasApiError as e: + print(f"\nNylas API Error: {str(e)}") + print(f"Error details: {e.__dict__}") + sys.exit(1) + except ValueError as e: + print(f"\nConfiguration Error: {str(e)}") + sys.exit(1) + except Exception as e: + print(f"\nUnexpected Error: {str(e)}") + print(f"Error type: {type(e)}") + print(f"Error details: {e.__dict__}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/notetaker_calendar_demo/README.md b/examples/notetaker_calendar_demo/README.md new file mode 100644 index 00000000..028bf97b --- /dev/null +++ b/examples/notetaker_calendar_demo/README.md @@ -0,0 +1,50 @@ +# Notetaker Calendar Integration Demo + +This demo showcases how to use the Nylas Notetaker API in conjunction with calendar and event APIs to create and manage notes associated with calendar events. + +## Features Demonstrated + +- Creating notes linked to calendar events +- Retrieving notes associated with events +- Managing event-related notes +- Syncing notes with event updates +- Using note metadata for event organization + +## Prerequisites + +- Python 3.8+ +- Nylas Python SDK (local version from this repository) +- Nylas API credentials (Client ID and Client Secret) + +## Setup + +1. Install the SDK in development mode: +```bash +# From the root of the nylas-python repository +pip install -e . +``` + +2. Set up your environment variables: +```bash +export NYLAS_API_KEY='your_api_key' +export NYLAS_API_URI='https://api.nylas.com' # Optional, defaults to https://api.nylas.com +``` + +## Running the Demo + +From the root of the repository: +```bash +python examples/notetaker_calendar_demo/notetaker_calendar_demo.py +``` + +## Code Examples + +The demo includes examples of: + +1. Creating a calendar event with associated notes +2. Retrieving notes linked to specific events +3. Updating event notes when the event changes +4. Managing note metadata for event organization +5. Syncing notes across multiple events + +Each example is documented with comments explaining the functionality and expected output. \ No newline at end of file diff --git a/examples/notetaker_calendar_demo/notetaker_calendar_demo.py b/examples/notetaker_calendar_demo/notetaker_calendar_demo.py new file mode 100644 index 00000000..022f9ded --- /dev/null +++ b/examples/notetaker_calendar_demo/notetaker_calendar_demo.py @@ -0,0 +1,178 @@ +import os +from datetime import datetime, timedelta +from typing import Optional + +from nylas import Client +from nylas.models.notetakers import Notetaker +from nylas.models.events import ( + UpdateEventRequest, + CreateEventRequest, + EventNotetakerRequest, + EventNotetakerSettings, + CreateTimespan, + CreateEventQueryParams, + UpdateEventQueryParams, + CreateAutocreate, + CreateEventNotetaker +) + +# Initialize the Nylas client +nylas = Client( + api_key=os.getenv("NYLAS_API_KEY"), + api_uri=os.getenv("NYLAS_API_URI", "https://api.us.nylas.com") +) + +def create_event_with_notetaker(): + """Demonstrates how to create a calendar event with a Notetaker bot.""" + print("\n=== Creating Event with Notetaker ===") + + # Create the event + start_time = datetime.now() + timedelta(days=1) + end_time = start_time + timedelta(hours=1) + + + # Create the request body with proper types + request_body = CreateEventRequest( + title="Project Planning Meeting", + description="Initial project planning and resource allocation", + when=CreateTimespan( + start_time=int(start_time.timestamp()), + end_time=int(end_time.timestamp()) + ), + metadata={ + "project_id": "PROJ-123", + "priority": "high" + }, + conferencing=CreateAutocreate( + provider="Google Meet", + autocreate={} + ), + notetaker=CreateEventNotetaker( + name="Nylas Notetaker", + meeting_settings=EventNotetakerSettings( + video_recording=True, + audio_recording=True, + transcription=True + ) + ) + ) + + # Create the query parameters + query_params = CreateEventQueryParams( + calendar_id=os.getenv("NYLAS_CALENDAR_ID") + ) + + event = nylas.events.create( + identifier=os.getenv("NYLAS_GRANT_ID"), + request_body=request_body, + query_params=query_params + ) + + return event + + +def get_event_notetaker(event_id: str) -> Optional[Notetaker]: + """Demonstrates how to retrieve the Notetaker associated with an event.""" + print("\n=== Retrieving Event Notetaker ===") + + # First get the event to get the Notetaker ID + try: + event = nylas.events.find( + identifier=os.getenv("NYLAS_GRANT_ID"), + event_id=event_id, + query_params={"calendar_id": os.getenv("NYLAS_CALENDAR_ID")} + ) + except Exception as e: + print(f"Error getting event: {e}") + return None + + if not event.data.notetaker or not event.data.notetaker.id: + print(f"No Notetaker found for event {event_id}") + return None + + notetaker = nylas.notetakers.find(notetaker_id=event.data.notetaker.id, identifier=os.getenv("NYLAS_GRANT_ID")) + print(f"Found Notetaker for event {event_id}:") + print(f"- ID: {notetaker.data.id}") + print(f"- State: {notetaker.data.state}") + print(f"- Meeting Provider: {notetaker.data.meeting_provider}") + print(f"- Meeting Settings:") + print(f" - Video Recording: {notetaker.data.meeting_settings.video_recording}") + print(f" - Audio Recording: {notetaker.data.meeting_settings.audio_recording}") + print(f" - Transcription: {notetaker.data.meeting_settings.transcription}") + + return notetaker + +def update_event_and_notetaker(event_id: str, notetaker_id: str): + """Demonstrates how to update both an event and its Notetaker.""" + print("\n=== Updating Event and Notetaker ===") + + # Create the notetaker meeting settings + notetaker_settings = EventNotetakerSettings( + video_recording=False, + audio_recording=True, + transcription=False + ) + + # Create the notetaker request + notetaker = EventNotetakerRequest( + id=notetaker_id, + name="Updated Nylas Notetaker", + meeting_settings=notetaker_settings + ) + + # Create the update request with proper types + request_body = UpdateEventRequest( + title="Updated Project Planning Meeting", + description="Revised project planning with new timeline", + metadata={ + "project_id": "PROJ-123", + "priority": "urgent" + }, + notetaker=notetaker + ) + + # Create the query parameters + query_params = UpdateEventQueryParams( + calendar_id=os.getenv("NYLAS_CALENDAR_ID") + ) + + updated_event = nylas.events.update( + identifier=os.getenv("NYLAS_GRANT_ID"), + event_id=event_id, + request_body=request_body, + query_params=query_params + ) + + return updated_event + +def main(): + """Main function to run all demo examples.""" + try: + # Create an event with a Notetaker + event = create_event_with_notetaker() + if not event: + print("Failed to create event") + return + + print(f"Created event with ID: {event.data.id}") + print(f"Event Notetaker ID: {event.data.notetaker.id}") + + # Get the Notetaker for the event + notetaker = get_event_notetaker(event.data.id) + if not notetaker: + print(f"Failed to get Notetaker for event {event.data.id}") + return + + # Update both the event and its Notetaker + updated_event = update_event_and_notetaker(event.data.id, notetaker.data.id) + if not updated_event: + print(f"Failed to update event {event.data.id}") + return + + print(f"Updated event with ID: {updated_event.data.id}") + + except Exception as e: + print(f"An error occurred: {str(e)}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/nylas/client.py b/nylas/client.py index 349c55fb..00dbf94c 100644 --- a/nylas/client.py +++ b/nylas/client.py @@ -14,6 +14,7 @@ from nylas.resources.drafts import Drafts from nylas.resources.grants import Grants from nylas.resources.scheduler import Scheduler +from nylas.resources.notetakers import Notetakers class Client: @@ -180,3 +181,13 @@ def scheduler(self) -> Scheduler: The Scheduler API. """ return Scheduler(self.http_client) + + @property + def notetakers(self) -> Notetakers: + """ + Access the Notetakers API. + + Returns: + The Notetakers API. + """ + return Notetakers(self.http_client) diff --git a/nylas/handler/api_resources.py b/nylas/handler/api_resources.py index ce2d3efa..25af6a69 100644 --- a/nylas/handler/api_resources.py +++ b/nylas/handler/api_resources.py @@ -50,6 +50,7 @@ def create( request_body=None, overrides=None, ) -> Response: + response_json, response_headers = self._http_client._execute( "POST", path, headers, query_params, request_body, overrides=overrides ) diff --git a/nylas/models/calendars.py b/nylas/models/calendars.py index 3a01ee87..95dbf6f9 100644 --- a/nylas/models/calendars.py +++ b/nylas/models/calendars.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List +from enum import Enum from dataclasses_json import dataclass_json from typing_extensions import TypedDict, NotRequired @@ -7,6 +8,84 @@ from nylas.models.list_query_params import ListQueryParams +class EventSelection(str, Enum): + """ + Enum representing the different types of events to include for notetaking. + + Values: + INTERNAL: Events where the host domain matches all participants' domain names + EXTERNAL: Events where the host domain differs from any participant's domain name + OWN_EVENTS: Events where the host is the same as the user's grant + PARTICIPANT_ONLY: Events where the user's grant is a participant but not the host + ALL: When all options are included, all events with meeting links will have Notetakers + """ + INTERNAL = "internal" + EXTERNAL = "external" + OWN_EVENTS = "own_events" + PARTICIPANT_ONLY = "participant_only" + ALL = "all" + + +@dataclass_json +@dataclass +class NotetakerParticipantFilter: + """ + Class representation of Notetaker participant filter settings. + + Attributes: + participants_gte: Only have meeting bot join meetings with greater than or equal to this number of participants. + participants_lte: Only have meeting bot join meetings with less than or equal to this number of participants. + """ + participants_gte: Optional[int] = None + participants_lte: Optional[int] = None + + +@dataclass_json +@dataclass +class NotetakerRules: + """ + Class representation of Notetaker rules for joining meetings. + + Attributes: + event_selection: Types of events to include for notetaking. + participant_filter: Filters to apply based on the number of participants. + """ + event_selection: Optional[List[EventSelection]] = None + participant_filter: Optional[NotetakerParticipantFilter] = None + + +@dataclass_json +@dataclass +class NotetakerMeetingSettings: + """ + Class representation of Notetaker meeting settings. + + Attributes: + video_recording: When true, Notetaker records the meeting's video. + audio_recording: When true, Notetaker records the meeting's audio. + transcription: When true, Notetaker transcribes the meeting's audio. + """ + video_recording: Optional[bool] = True + audio_recording: Optional[bool] = True + transcription: Optional[bool] = True + + +@dataclass_json +@dataclass +class CalendarNotetaker: + """ + Class representation of Notetaker settings for a calendar. + + Attributes: + name: The display name for the Notetaker bot. + meeting_settings: Notetaker Meeting Settings. + rules: Rules for when the Notetaker should join a meeting. + """ + name: Optional[str] = "Nylas Notetaker" + meeting_settings: Optional[NotetakerMeetingSettings] = None + rules: Optional[NotetakerRules] = None + + @dataclass_json @dataclass class Calendar: @@ -30,6 +109,7 @@ class Calendar: If not defined, the default color is used (Google only). is_primary: If the Calendar is the account's primary calendar. metadata: A list of key-value pairs storing additional data. + notetaker: Notetaker meeting bot settings for the calendar. """ id: str @@ -45,6 +125,7 @@ class Calendar: hex_foreground_color: Optional[str] = None is_primary: Optional[bool] = None metadata: Optional[Dict[str, Any]] = None + notetaker: Optional[CalendarNotetaker] = None class ListCalendarsQueryParams(ListQueryParams): @@ -76,6 +157,58 @@ class FindCalendarQueryParams(TypedDict): select: NotRequired[str] +class NotetakerCalendarSettings(TypedDict): + """ + Interface for Notetaker meeting settings for a calendar. + + Attributes: + video_recording: When true, Notetaker records the meeting's video. + audio_recording: When true, Notetaker records the meeting's audio. + transcription: When true, Notetaker transcribes the meeting's audio. + """ + video_recording: NotRequired[bool] + audio_recording: NotRequired[bool] + transcription: NotRequired[bool] + + +class NotetakerCalendarParticipantFilter(TypedDict): + """ + Interface for Notetaker participant filter settings. + + Attributes: + participants_gte: Only have meeting bot join meetings with greater than or equal to this number of participants. + participants_lte: Only have meeting bot join meetings with less than or equal to this number of participants. + """ + participants_gte: NotRequired[int] + participants_lte: NotRequired[int] + + +class NotetakerCalendarRules(TypedDict): + """ + Interface for Notetaker rules for joining meetings. + + Attributes: + event_selection: Types of events to include for notetaking. + participant_filter: Filters to apply based on the number of participants. + """ + event_selection: NotRequired[List[EventSelection]] + participant_filter: NotRequired[NotetakerCalendarParticipantFilter] + + +class NotetakerCalendarRequest(TypedDict): + """ + Interface for Notetaker settings in a calendar request. + + Attributes: + name: The display name for the Notetaker bot. + meeting_settings: Notetaker Meeting Settings. + rules: Rules for when the Notetaker should join a meeting. + """ + name: NotRequired[str] + meeting_settings: NotRequired[NotetakerCalendarSettings] + rules: NotRequired[NotetakerCalendarRules] + + class CreateCalendarRequest(TypedDict): """ Interface of a Nylas create calendar request @@ -86,6 +219,7 @@ class CreateCalendarRequest(TypedDict): location: Geographic location of the calendar as free-form text. timezone: IANA time zone database formatted string (e.g. America/New_York). metadata: A list of key-value pairs storing additional data. + notetaker: Notetaker meeting bot settings. """ name: str @@ -93,6 +227,7 @@ class CreateCalendarRequest(TypedDict): location: NotRequired[str] timezone: NotRequired[str] metadata: NotRequired[Dict[str, str]] + notetaker: NotRequired[NotetakerCalendarRequest] class UpdateCalendarRequest(CreateCalendarRequest): @@ -104,7 +239,9 @@ class UpdateCalendarRequest(CreateCalendarRequest): Empty indicates default color. hexForegroundColor: The background color of the calendar in the hexadecimal format (e.g. #0099EE). Empty indicates default color. (Google only) + notetaker: Notetaker meeting bot settings. """ hexColor: NotRequired[str] hexForegroundColor: NotRequired[str] + notetaker: NotRequired[NotetakerCalendarRequest] diff --git a/nylas/models/events.py b/nylas/models/events.py index c95c6677..77c671fa 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -244,6 +244,19 @@ def _decode_conferencing(conferencing: dict) -> Union[Conferencing, None]: if "autocreate" in conferencing: return Autocreate.from_dict(conferencing) + # Handle case where provider exists but details/autocreate doesn't + if "provider" in conferencing: + # Create a Details object with empty details + details_dict = { + "provider": conferencing["provider"], + "details": ( + conferencing.get("conf_settings", {}) + if "conf_settings" in conferencing + else {} + ), + } + return Details.from_dict(details_dict) + raise ValueError(f"Invalid conferencing object, unknown type found: {conferencing}") @@ -279,6 +292,40 @@ class Reminders: overrides: Optional[List[ReminderOverride]] = None +@dataclass_json +@dataclass +class NotetakerMeetingSettings: + """ + Class representing Notetaker meeting settings. + + Attributes: + video_recording: When true, Notetaker records the meeting's video. + audio_recording: When true, Notetaker records the meeting's audio. + transcription: When true, Notetaker transcribes the meeting's audio. + """ + + video_recording: Optional[bool] = True + audio_recording: Optional[bool] = True + transcription: Optional[bool] = True + + +@dataclass_json +@dataclass +class EventNotetaker: + """ + Class representing Notetaker settings for an event. + + Attributes: + id: The Notetaker bot ID. + name: The display name for the Notetaker bot. + meeting_settings: Notetaker Meeting Settings. + """ + + id: Optional[str] = None + name: Optional[str] = "Nylas Notetaker" + meeting_settings: Optional[NotetakerMeetingSettings] = None + + @dataclass_json @dataclass class Event: @@ -313,6 +360,7 @@ class Event: visibility: The Event's visibility (private or public). capacity: Sets the maximum number of participants that may attend the event. master_event_id: For recurring events, this field contains the main (master) event's ID. + notetaker: Notetaker meeting bot settings. """ id: str @@ -343,6 +391,7 @@ class Event: created_at: Optional[int] = None updated_at: Optional[int] = None master_event_id: Optional[str] = None + notetaker: Optional[EventNotetaker] = None class CreateParticipant(TypedDict): @@ -627,6 +676,48 @@ class UpdateDatespan(TypedDict): """ Union type representing the different types of event time configurations for updating an Event.""" +class EventNotetakerSettings(TypedDict): + """ + Interface representing Notetaker meeting settings for an event. + + Attributes: + video_recording: When true, Notetaker records the meeting's video. + audio_recording: When true, Notetaker records the meeting's audio. + transcription: When true, Notetaker transcribes the meeting's audio. + """ + + video_recording: NotRequired[bool] + audio_recording: NotRequired[bool] + transcription: NotRequired[bool] + + +class EventNotetakerRequest(TypedDict): + """ + Interface representing Notetaker settings for an event. + + Attributes: + id: The Notetaker bot ID. + name: The display name for the Notetaker bot. + meeting_settings: Notetaker Meeting Settings. + """ + + id: NotRequired[str] + name: NotRequired[str] + meeting_settings: NotRequired[EventNotetakerSettings] + + +class CreateEventNotetaker(TypedDict): + """ + Class representing Notetaker settings for an event. + + Attributes: + name: The display name for the Notetaker bot. + meeting_settings: Notetaker Meeting Settings. + """ + + name: Optional[str] = "Nylas Notetaker" + meeting_settings: Optional[EventNotetakerSettings] = None + class CreateEventRequest(TypedDict): """ Interface representing a request to create an event. @@ -646,6 +737,7 @@ class CreateEventRequest(TypedDict): visibility: The visibility of the event. capacity: The capacity of the event. hide_participants: Whether to hide participants of the event. + notetaker: Notetaker meeting bot settings. """ when: CreateWhen @@ -661,6 +753,7 @@ class CreateEventRequest(TypedDict): visibility: NotRequired[Visibility] capacity: NotRequired[int] hide_participants: NotRequired[bool] + notetaker: NotRequired[CreateEventNotetaker] class UpdateEventRequest(TypedDict): @@ -681,6 +774,7 @@ class UpdateEventRequest(TypedDict): visibility: The visibility of the event. capacity: The capacity of the event. hide_participants: Whether to hide participants of the event. + notetaker: Notetaker meeting bot settings. """ when: NotRequired[UpdateWhen] @@ -696,6 +790,7 @@ class UpdateEventRequest(TypedDict): visibility: NotRequired[Visibility] capacity: NotRequired[int] hide_participants: NotRequired[bool] + notetaker: NotRequired[EventNotetakerRequest] class ListEventQueryParams(ListQueryParams): diff --git a/nylas/models/notetakers.py b/nylas/models/notetakers.py new file mode 100644 index 00000000..af405bb7 --- /dev/null +++ b/nylas/models/notetakers.py @@ -0,0 +1,254 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from dataclasses_json import dataclass_json +from typing_extensions import NotRequired, TypedDict + +from nylas.models.list_query_params import ListQueryParams + + +class NotetakerState(str, Enum): + """ + Enum representing the possible states of a Notetaker bot. + + Values: + SCHEDULED: The Notetaker is scheduled to join a meeting. + CONNECTING: The Notetaker is connecting to the meeting. + WAITING_FOR_ENTRY: The Notetaker is waiting to be admitted to the meeting. + FAILED_ENTRY: The Notetaker failed to join the meeting. + ATTENDING: The Notetaker is currently in the meeting. + MEDIA_PROCESSING: The Notetaker is processing media from the meeting. + MEDIA_AVAILABLE: The Notetaker has processed media available for download. + MEDIA_ERROR: An error occurred while processing the media. + MEDIA_DELETED: The meeting media has been deleted. + """ + + SCHEDULED = "scheduled" + CONNECTING = "connecting" + WAITING_FOR_ENTRY = "waiting_for_entry" + FAILED_ENTRY = "failed_entry" + ATTENDING = "attending" + MEDIA_PROCESSING = "media_processing" + MEDIA_AVAILABLE = "media_available" + MEDIA_ERROR = "media_error" + MEDIA_DELETED = "media_deleted" + + +class MeetingProvider(str, Enum): + """ + Enum representing the possible meeting providers for Notetaker. + + Values: + GOOGLE_MEET: Google Meet meetings + ZOOM: Zoom meetings + MICROSOFT_TEAMS: Microsoft Teams meetings + """ + + GOOGLE_MEET = "Google Meet" + ZOOM = "Zoom Meeting" + MICROSOFT_TEAMS = "Microsoft Teams" + + +class NotetakerMeetingSettingsRequest(TypedDict): + """ + Interface representing Notetaker meeting settings for request objects. + + Attributes: + video_recording: When true, Notetaker records the meeting's video. + audio_recording: When true, Notetaker records the meeting's audio. + transcription: When true, Notetaker transcribes the meeting's audio. + If transcription is true, audio_recording must also be true. + """ + + video_recording: Optional[bool] + audio_recording: Optional[bool] + transcription: Optional[bool] + + +@dataclass_json +@dataclass +class NotetakerMeetingSettings: + """ + Class representing Notetaker meeting settings. + + Attributes: + video_recording: When true, Notetaker records the meeting's video. + audio_recording: When true, Notetaker records the meeting's audio. + transcription: When true, Notetaker transcribes the meeting's audio. + If transcription is true, audio_recording must also be true. + """ + + video_recording: bool = True + audio_recording: bool = True + transcription: bool = True + + +@dataclass_json +@dataclass +class NotetakerMediaRecording: + """ + Class representing a Notetaker media recording. + + Attributes: + url: A link to the meeting recording. + size: The size of the file, in MB. + """ + + url: str + size: int + + +@dataclass_json +@dataclass +class NotetakerMedia: + """ + Class representing Notetaker media. + + Attributes: + recording: The meeting recording. + transcript: The meeting transcript. + """ + + recording: Optional[NotetakerMediaRecording] = None + transcript: Optional[NotetakerMediaRecording] = None + + +@dataclass_json +@dataclass +class Notetaker: + """ + Class representing a Nylas Notetaker. + + Attributes: + id: The Notetaker ID. + name: The display name for the Notetaker bot. + join_time: When Notetaker joined the meeting, in Unix timestamp format. + meeting_link: The meeting link. + meeting_provider: The meeting provider. + state: The current state of the Notetaker bot. + meeting_settings: Notetaker Meeting Settings. + message: A message describing the API response (only included in some responses). + """ + + id: str + name: str + join_time: int + meeting_link: str + state: NotetakerState + meeting_settings: NotetakerMeetingSettings + meeting_provider: Optional[MeetingProvider] = None + message: Optional[str] = None + object: str = "notetaker" + + def is_state(self, state: NotetakerState) -> bool: + """ + Check if the notetaker is in a specific state. + + Args: + state: The NotetakerState to check against. + + Returns: + True if the notetaker is in the specified state, False otherwise. + """ + return self.state == state + + def is_scheduled(self) -> bool: + """Check if the notetaker is in the scheduled state.""" + return self.is_state(NotetakerState.SCHEDULED) + + def is_attending(self) -> bool: + """Check if the notetaker is currently attending a meeting.""" + return self.is_state(NotetakerState.ATTENDING) + + def has_media_available(self) -> bool: + """Check if the notetaker has media available for download.""" + return self.is_state(NotetakerState.MEDIA_AVAILABLE) + + +class InviteNotetakerRequest(TypedDict): + """ + Interface representing the Nylas notetaker creation request. + + Attributes: + meeting_link: A meeting invitation link that Notetaker uses to join the meeting. + join_time: When Notetaker should join the meeting, in Unix timestamp format. + If empty, Notetaker joins the meeting immediately. + name: The display name for the Notetaker bot. + meeting_settings: Notetaker Meeting Settings. + """ + + meeting_link: str + join_time: NotRequired[int] + name: NotRequired[str] + meeting_settings: NotRequired[NotetakerMeetingSettingsRequest] + + +class UpdateNotetakerRequest(TypedDict): + """ + Interface representing the Nylas notetaker update request. + + Attributes: + join_time: When Notetaker should join the meeting, in Unix timestamp format. + name: The display name for the Notetaker bot. + meeting_settings: Notetaker Meeting Settings. + """ + + join_time: NotRequired[int] + name: NotRequired[str] + meeting_settings: NotRequired[NotetakerMeetingSettingsRequest] + + +class ListNotetakerQueryParams(ListQueryParams): + """ + Interface representing the query parameters for listing notetakers. + + Attributes: + state: Filter for Notetaker bots with the specified meeting state. + Use the NotetakerState enum. + Example: state=NotetakerState.SCHEDULED + join_time_from: Filter for Notetaker bots that are scheduled to join meetings after the specified time. + join_time_until: Filter for Notetaker bots that are scheduled to join meetings until the specified time. + limit: The maximum number of objects to return. This field defaults to 50. The maximum allowed value is 200. + page_token: An identifier that specifies which page of data to return. + prev_page_token: An identifier that specifies which page of data to return. + """ + + state: NotRequired[NotetakerState] + join_time_from: NotRequired[int] + join_time_until: NotRequired[int] + + def __post_init__(self): + """Convert NotetakerState enum to string value for API requests.""" + super().__post_init__() + # Convert state enum to string if present + if hasattr(self, "state") and isinstance(self.state, NotetakerState): + self.state = self.state.value + + +class FindNotetakerQueryParams(TypedDict): + """ + Interface representing the query parameters for finding a notetaker. + + Attributes: + select: Comma-separated list of fields to return in the response. + Use this to limit the fields returned in the response. + """ + + select: NotRequired[str] + + +@dataclass_json +@dataclass +class NotetakerLeaveResponse: + """ + Class representing a Notetaker leave response. + + Attributes: + id: The Notetaker ID. + message: A message describing the API response. + """ + + id: str + message: str + object: str = "notetaker_leave_response" diff --git a/nylas/models/scheduler.py b/nylas/models/scheduler.py index 7b20b565..155e2441 100644 --- a/nylas/models/scheduler.py +++ b/nylas/models/scheduler.py @@ -1,10 +1,11 @@ -from dataclasses import dataclass -from typing import Dict, Optional, List +from dataclasses import dataclass, field +from typing import Dict, List, Literal, Optional + +from dataclasses_json import config, dataclass_json +from typing_extensions import NotRequired, TypedDict -from dataclasses_json import dataclass_json -from typing_extensions import TypedDict, NotRequired, Literal -from nylas.models.events import Conferencing from nylas.models.availability import AvailabilityRules, OpenHours +from nylas.models.events import Conferencing, _decode_conferencing BookingType = Literal["booking", "organizer-confirmation"] BookingReminderType = Literal["email", "webhook"] @@ -99,7 +100,7 @@ class SchedulerSettings: confirmation_redirect_url: The custom URL to redirect to once the booking is confirmed. hide_rescheduling_options: Whether the option to reschedule an event is hidden in booking confirmations and notifications. - hide_cancellation_options: Whether the option to cancel an event + hide_cancellation_options: Whether the option to cancel an event is hidden in booking confirmations and notifications. hide_additional_guests: Whether to hide the additional guests field on the scheduling page. email_template: Configurable settings for booking emails. @@ -161,7 +162,9 @@ class EventBooking: location: Optional[str] = None timezone: Optional[str] = None booking_type: Optional[BookingType] = None - conferencing: Optional[Conferencing] = None + conferencing: Optional[Conferencing] = field( + default=None, metadata=config(decoder=_decode_conferencing) + ) disable_emails: Optional[bool] = None reminders: Optional[List[BookingReminder]] = None @@ -298,6 +301,7 @@ class UpdateConfigurationRequest(TypedDict): scheduler: Settings for the Scheduler UI. appearance: Appearance settings for the Scheduler UI. """ + participants: NotRequired[List[ConfigParticipant]] availability: NotRequired[Availability] event_booking: NotRequired[EventBooking] @@ -320,6 +324,7 @@ class CreateSessionRequest(TypedDict): slug is not required. time_to_live: The time-to-live in seconds for the session """ + configuration_id: NotRequired[str] slug: NotRequired[str] time_to_live: NotRequired[int] @@ -381,7 +386,7 @@ class CreateBookingRequest: timezone: The guest's timezone that is used in email notifications. email_language: The language of the guest email notifications. additional_guests: List of additional guest email addresses to include in the booking. - additional_fields: Dictionary of additional field keys mapped to + additional_fields: Dictionary of additional field keys mapped to values populated by the guest in the booking form. """ @@ -494,7 +499,7 @@ class CreateBookingQueryParams: slug: The slug of the Configuration object whose settings are used for calculating availability. If you're using session authentication (requires_session_auth is set to true) or using configurationId, slug is not required. - timezone: The timezone to use for the booking. + timezone: The timezone to use for the booking. If not provided, Nylas uses the timezone from the Configuration object. """ diff --git a/nylas/resources/calendars.py b/nylas/resources/calendars.py index 684df4e4..e82670e7 100644 --- a/nylas/resources/calendars.py +++ b/nylas/resources/calendars.py @@ -177,9 +177,11 @@ def get_availability( Response: The availability response from the API. """ json_response, headers = self._http_client._execute( - method="POST", - path="/v3/calendars/availability", - request_body=request_body, + "POST", + "/v3/calendars/availability", + None, + None, + request_body, overrides=overrides, ) @@ -203,9 +205,11 @@ def get_free_busy( Response: The free/busy response from the API. """ json_response, headers = self._http_client._execute( - method="POST", - path=f"/v3/grants/{identifier}/calendars/free-busy", - request_body=request_body, + "POST", + f"/v3/grants/{identifier}/calendars/free-busy", + None, + None, + request_body, overrides=overrides, ) diff --git a/nylas/resources/notetakers.py b/nylas/resources/notetakers.py new file mode 100644 index 00000000..275025ea --- /dev/null +++ b/nylas/resources/notetakers.py @@ -0,0 +1,236 @@ +from typing import Optional + +from nylas.config import RequestOverrides +from nylas.handler.api_resources import (CreatableApiResource, + DestroyableApiResource, + FindableApiResource, + ListableApiResource, + UpdatablePatchApiResource) +from nylas.models.notetakers import (FindNotetakerQueryParams, + InviteNotetakerRequest, + ListNotetakerQueryParams, + Notetaker, NotetakerMedia, + NotetakerLeaveResponse, + UpdateNotetakerRequest) +from nylas.models.response import DeleteResponse, ListResponse, Response + + +class Notetakers( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatablePatchApiResource, + DestroyableApiResource, +): + """ + Nylas Notetakers API + + The Nylas Notetakers API allows you to invite Notetaker bots to meetings and manage their status. + Notetaker states are represented by the NotetakerState enum, and meeting providers by the MeetingProvider enum. + """ + + def list( + self, + identifier: str = None, + query_params: Optional[ListNotetakerQueryParams] = None, + overrides: RequestOverrides = None, + ) -> ListResponse[Notetaker]: + """ + Return all Notetakers. + + Args: + identifier: The identifier of the Grant to act upon. Optional. + query_params: The query parameters to include in the request. + You can use NotetakerState enum values for the state parameter: + e.g., {"state": NotetakerState.SCHEDULED.value} + overrides: The request overrides to use. + + Returns: + The list of Notetakers. + """ + path = ( + "/v3/notetakers" + if identifier is None + else f"/v3/grants/{identifier}/notetakers" + ) + return super().list( + path=path, + response_type=Notetaker, + query_params=query_params, + overrides=overrides, + ) + + def find( + self, + notetaker_id: str, + identifier: str = None, + overrides: RequestOverrides = None, + query_params: FindNotetakerQueryParams = None, + ) -> Response[Notetaker]: + """ + Return a Notetaker. + + Args: + notetaker_id: The ID of the Notetaker to retrieve. + identifier: The identifier of the Grant to act upon. Optional. + overrides: The request overrides to use. + query_params: The query parameters to include in the request. + + Returns: + The Notetaker with properties like state (NotetakerState) and meeting_provider (MeetingProvider). + """ + path = ( + f"/v3/notetakers/{notetaker_id}" + if identifier is None + else f"/v3/grants/{identifier}/notetakers/{notetaker_id}" + ) + return super().find( + path=path, + response_type=Notetaker, + query_params=query_params, + overrides=overrides, + ) + + def invite( + self, + request_body: InviteNotetakerRequest, + identifier: str = None, + overrides: RequestOverrides = None, + ) -> Response[Notetaker]: + """ + Invite a Notetaker to a meeting. + + Args: + request_body: The values to create the Notetaker with. + identifier: The identifier of the Grant to act upon. Optional. + overrides: The request overrides to use. + + Returns: + The created Notetaker with state set to NotetakerState.SCHEDULED. + """ + path = ( + "/v3/notetakers" + if identifier is None + else f"/v3/grants/{identifier}/notetakers" + ) + return super().create( + path=path, + response_type=Notetaker, + request_body=request_body, + overrides=overrides, + ) + + def update( + self, + notetaker_id: str, + request_body: UpdateNotetakerRequest, + identifier: str = None, + overrides: RequestOverrides = None, + ) -> Response[Notetaker]: + """ + Update a Notetaker. + + Args: + notetaker_id: The ID of the Notetaker to update. + request_body: The values to update the Notetaker with. + identifier: The identifier of the Grant to act upon. Optional. + overrides: The request overrides to use. + + Returns: + The updated Notetaker. + """ + path = ( + f"/v3/notetakers/{notetaker_id}" + if identifier is None + else f"/v3/grants/{identifier}/notetakers/{notetaker_id}" + ) + return super().patch( + path=path, + response_type=Notetaker, + request_body=request_body, + overrides=overrides, + ) + + def leave( + self, + notetaker_id: str, + identifier: str = None, + overrides: RequestOverrides = None, + ) -> Response[NotetakerLeaveResponse]: + """ + Remove Notetaker from a meeting. + + Args: + notetaker_id: The ID of the Notetaker to remove from the meeting. + identifier: The identifier of the Grant to act upon. Optional. + overrides: The request overrides to use. + + Returns: + The response with information about the Notetaker that left, + including the Notetaker ID and a message. + """ + path = ( + f"/v3/notetakers/{notetaker_id}/leave" + if identifier is None + else f"/v3/grants/{identifier}/notetakers/{notetaker_id}/leave" + ) + return super().create( + path=path, + response_type=NotetakerLeaveResponse, + overrides=overrides, + ) + + def get_media( + self, + notetaker_id: str, + identifier: str = None, + overrides: RequestOverrides = None, + ) -> Response[NotetakerMedia]: + """ + Download Notetaker media. + + Args: + notetaker_id: The ID of the Notetaker to get media from. + identifier: The identifier of the Grant to act upon. Optional. + overrides: The request overrides to use. + + Returns: + The Notetaker media information including URLs for recordings and transcripts. + """ + path = ( + f"/v3/notetakers/{notetaker_id}/media" + if identifier is None + else f"/v3/grants/{identifier}/notetakers/{notetaker_id}/media" + ) + return super().find( + path=path, + response_type=NotetakerMedia, + overrides=overrides, + ) + + def cancel( + self, + notetaker_id: str, + identifier: str = None, + overrides: RequestOverrides = None, + ) -> DeleteResponse: + """ + Cancel a scheduled Notetaker. + + Args: + notetaker_id: The ID of the Notetaker to cancel. + identifier: The identifier of the Grant to act upon. Optional. + overrides: The request overrides to use. + + Returns: + The deletion response. + """ + path = ( + f"/v3/notetakers/{notetaker_id}/cancel" + if identifier is None + else f"/v3/grants/{identifier}/notetakers/{notetaker_id}/cancel" + ) + return super().destroy( + path=path, + overrides=overrides, + ) diff --git a/tests/resources/test_calendars.py b/tests/resources/test_calendars.py index 4e42267a..79e8a384 100644 --- a/tests/resources/test_calendars.py +++ b/tests/resources/test_calendars.py @@ -1,6 +1,6 @@ from nylas.resources.calendars import Calendars -from nylas.models.calendars import Calendar +from nylas.models.calendars import Calendar, EventSelection class TestCalendar: @@ -37,6 +37,54 @@ def test_calendar_deserialization(self): assert cal.read_only is False assert cal.timezone == "America/Los_Angeles" + def test_calendar_with_notetaker_deserialization(self): + calendar_json = { + "grant_id": "abc-123-grant-id", + "description": "Description of my new calendar", + "id": "5d3qmne77v32r8l4phyuksl2x", + "is_owned_by_user": True, + "name": "My New Calendar", + "object": "calendar", + "read_only": False, + "notetaker": { + "name": "My Notetaker", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + }, + "rules": { + "event_selection": ["internal", "external"], + "participant_filter": { + "participants_gte": 3, + "participants_lte": 10 + } + } + } + } + + cal = Calendar.from_dict(calendar_json) + + assert cal.grant_id == "abc-123-grant-id" + assert cal.id == "5d3qmne77v32r8l4phyuksl2x" + assert cal.is_owned_by_user is True + assert cal.name == "My New Calendar" + assert cal.object == "calendar" + assert cal.read_only is False + assert cal.notetaker is not None + assert cal.notetaker.name == "My Notetaker" + assert cal.notetaker.meeting_settings is not None + assert cal.notetaker.meeting_settings.video_recording is True + assert cal.notetaker.meeting_settings.audio_recording is True + assert cal.notetaker.meeting_settings.transcription is True + assert cal.notetaker.rules is not None + assert len(cal.notetaker.rules.event_selection) == 2 + assert EventSelection.INTERNAL in cal.notetaker.rules.event_selection + assert EventSelection.EXTERNAL in cal.notetaker.rules.event_selection + assert cal.notetaker.rules.participant_filter is not None + assert cal.notetaker.rules.participant_filter.participants_gte == 3 + assert cal.notetaker.rules.participant_filter.participants_lte == 10 + def test_list_calendars(self, http_client_list_response): calendars = Calendars(http_client_list_response) @@ -162,6 +210,41 @@ def test_create_calendar(self, http_client_response): overrides=None, ) + def test_create_calendar_with_notetaker(self, http_client_response): + calendars = Calendars(http_client_response) + request_body = { + "name": "My New Calendar", + "description": "Description of my new calendar", + "location": "Los Angeles, CA", + "timezone": "America/Los_Angeles", + "notetaker": { + "name": "My Notetaker", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + }, + "rules": { + "event_selection": [EventSelection.INTERNAL.value, EventSelection.EXTERNAL.value], + "participant_filter": { + "participants_gte": 3, + "participants_lte": 10 + } + } + } + } + + calendars.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/calendars", + None, + None, + request_body, + overrides=None, + ) + def test_update_calendar(self, http_client_response): calendars = Calendars(http_client_response) request_body = { @@ -185,6 +268,39 @@ def test_update_calendar(self, http_client_response): overrides=None, ) + def test_update_calendar_with_notetaker(self, http_client_response): + calendars = Calendars(http_client_response) + request_body = { + "name": "My Updated Calendar", + "notetaker": { + "name": "Updated Notetaker", + "meeting_settings": { + "video_recording": False, + "audio_recording": True, + "transcription": False + }, + "rules": { + "event_selection": [EventSelection.ALL.value], + "participant_filter": { + "participants_gte": 2 + } + } + } + } + + calendars.update( + identifier="abc-123", calendar_id="calendar-123", request_body=request_body + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/calendars/calendar-123", + None, + None, + request_body, + overrides=None, + ) + def test_destroy_calendar(self, http_client_delete_response): calendars = Calendars(http_client_delete_response) @@ -202,81 +318,80 @@ def test_destroy_calendar(self, http_client_delete_response): def test_get_availability(self, http_client_response): calendars = Calendars(http_client_response) request_body = { - "start_time": 1614556800, - "end_time": 1614643200, - "participants": [ + "start_time": 1497916800, + "end_time": 1498003200, + "duration_minutes": 30, + "interval_minutes": 30, + "free_busy": [ { "email": "test@gmail.com", - "calendar_ids": ["calendar-123"], - "open_hours": [ + } + ], + "open_hours": [ + { + "days": ["monday", "wednesday"], + "timezone": "America/New_York", + "start": "08:00", + "end": "18:00", + "restrictions": [ { - "days": [0], - "timezone": "America/Los_Angeles", - "start": "09:00", - "end": "17:00", - "exdates": ["2021-03-01"], + "days": ["monday"], + "start": "12:00", + "end": "13:00", } ], } ], - "duration_minutes": 60, - "interval_minutes": 30, - "round_to_30_minutes": True, - "availability_rules": { - "availability_method": "max-availability", - "buffer": {"before": 10, "after": 10}, - "default_open_hours": [ - { - "days": [0], - "timezone": "America/Los_Angeles", - "start": "09:00", - "end": "17:00", - "exdates": ["2021-03-01"], - } - ], - "round_robin_group_id": "event-123", - }, + "locale": "en", } - calendars.get_availability(request_body) + # Set up mock response data + http_client_response._execute.return_value = ({ + "availability": [ + { + "end_time": 1497960800, + "start_time": 1497960600, + "status": "free", + "object": "availability_status", + } + ], + "object": "availability", + "time_slots": [ + {"end_time": 1497960800, "start_time": 1497960600, "status": "free"} + ], + }, {}) + + calendars.get_availability(request_body=request_body) http_client_response._execute.assert_called_once_with( - method="POST", - path="/v3/calendars/availability", - request_body=request_body, + "POST", + "/v3/calendars/availability", + None, + None, + request_body, overrides=None, ) def test_get_free_busy(self, http_client_free_busy): calendars = Calendars(http_client_free_busy) - request_body = { - "start_time": 1614556800, - "end_time": 1614643200, - "emails": ["test@gmail.com"], + free_busy_request = { + "emails": ["test@gmail.com", "test2@gmail.com"], + "start_time": 1497916800, + "end_time": 1498003200, } - response = calendars.get_free_busy( - identifier="abc-123", request_body=request_body + # Http client is mocked in conftest.py, specific + # mock for free busy is configured there + calendars.get_free_busy( + identifier="abc123", request_body=free_busy_request, overrides=None ) http_client_free_busy._execute.assert_called_once_with( - method="POST", - path="/v3/grants/abc-123/calendars/free-busy", - request_body=request_body, + "POST", + "/v3/grants/abc123/calendars/free-busy", + None, + None, + free_busy_request, overrides=None, ) - assert len(response.data) == 2 - assert response.request_id == "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5" - assert response.data[0].email == "user1@example.com" - assert len(response.data[0].time_slots) == 2 - assert response.data[0].time_slots[0].start_time == 1690898400 - assert response.data[0].time_slots[0].end_time == 1690902000 - assert response.data[0].time_slots[0].status == "busy" - assert response.data[0].time_slots[1].start_time == 1691064000 - assert response.data[0].time_slots[1].end_time == 1691067600 - assert response.data[0].time_slots[1].status == "busy" - assert response.data[1].email == "user2@example.com" - assert ( - response.data[1].error - == "Unable to resolve e-mail address user2@example.com to an Active Directory object." - ) + diff --git a/tests/resources/test_events.py b/tests/resources/test_events.py index 686fe173..25aba856 100644 --- a/tests/resources/test_events.py +++ b/tests/resources/test_events.py @@ -1,5 +1,4 @@ from nylas.resources.events import Events - from nylas.models.events import Event @@ -441,3 +440,113 @@ def test_send_rsvp(self, http_client_response): query_params={"calendar_id": "abc-123"}, overrides=None, ) + + def test_event_with_notetaker_deserialization(self): + event_json = { + "id": "event-123", + "grant_id": "grant-123", + "calendar_id": "calendar-123", + "busy": True, + "participants": [ + {"email": "test@example.com", "name": "Test User", "status": "yes"} + ], + "when": { + "start_time": 1497916800, + "end_time": 1497920400, + "object": "timespan" + }, + "title": "Test Event with Notetaker", + "notetaker": { + "id": "notetaker-123", + "name": "Custom Notetaker", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + } + } + + event = Event.from_dict(event_json) + + assert event.id == "event-123" + assert event.grant_id == "grant-123" + assert event.calendar_id == "calendar-123" + assert event.busy is True + assert event.title == "Test Event with Notetaker" + assert event.notetaker is not None + assert event.notetaker.id == "notetaker-123" + assert event.notetaker.name == "Custom Notetaker" + assert event.notetaker.meeting_settings is not None + assert event.notetaker.meeting_settings.video_recording is True + assert event.notetaker.meeting_settings.audio_recording is True + assert event.notetaker.meeting_settings.transcription is True + + def test_create_event_with_notetaker(self, http_client_response): + events = Events(http_client_response) + request_body = { + "title": "Test Event with Notetaker", + "when": { + "start_time": 1497916800, + "end_time": 1497920400 + }, + "participants": [ + {"email": "test@example.com", "name": "Test User"} + ], + "notetaker": { + "name": "Custom Notetaker", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + } + } + query_params = {"calendar_id": "calendar-123"} + + events.create( + identifier="abc-123", + request_body=request_body, + query_params=query_params + ) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/events", + None, + query_params, + request_body, + overrides=None, + ) + + def test_update_event_with_notetaker(self, http_client_response): + events = Events(http_client_response) + request_body = { + "title": "Updated Test Event", + "notetaker": { + "id": "notetaker-123", + "name": "Updated Notetaker", + "meeting_settings": { + "video_recording": False, + "audio_recording": True, + "transcription": False + } + } + } + query_params = {"calendar_id": "calendar-123"} + + events.update( + identifier="abc-123", + event_id="event-123", + request_body=request_body, + query_params=query_params + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/events/event-123", + None, + query_params, + request_body, + overrides=None, + ) diff --git a/tests/resources/test_notetakers.py b/tests/resources/test_notetakers.py new file mode 100644 index 00000000..11374718 --- /dev/null +++ b/tests/resources/test_notetakers.py @@ -0,0 +1,561 @@ +from nylas.resources.notetakers import Notetakers +from nylas.models.notetakers import ( + Notetaker, + NotetakerMeetingSettings, + NotetakerMeetingSettingsRequest, + NotetakerMedia, + NotetakerMediaRecording, + NotetakerState, + MeetingProvider, + ListNotetakerQueryParams, + NotetakerLeaveResponse +) + + +class TestNotetaker: + def test_notetaker_deserialization(self): + notetaker_json = { + "id": "notetaker-123", + "name": "Nylas Notetaker", + "join_time": 1656090000, + "meeting_link": "https://meet.google.com/abc-def-ghi", + "meeting_provider": "Google Meet", + "state": "scheduled", + "object": "notetaker", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + } + + notetaker = Notetaker.from_dict(notetaker_json) + + assert notetaker.id == "notetaker-123" + assert notetaker.name == "Nylas Notetaker" + assert notetaker.join_time == 1656090000 + assert notetaker.meeting_link == "https://meet.google.com/abc-def-ghi" + assert notetaker.meeting_provider == MeetingProvider.GOOGLE_MEET + assert notetaker.state == NotetakerState.SCHEDULED + assert notetaker.object == "notetaker" + assert notetaker.meeting_settings.video_recording is True + assert notetaker.meeting_settings.audio_recording is True + assert notetaker.meeting_settings.transcription is True + + def test_notetaker_state_enum(self): + """Test that the NotetakerState enum works correctly.""" + # Test all enum values + states = [ + ("scheduled", NotetakerState.SCHEDULED), + ("connecting", NotetakerState.CONNECTING), + ("waiting_for_entry", NotetakerState.WAITING_FOR_ENTRY), + ("failed_entry", NotetakerState.FAILED_ENTRY), + ("attending", NotetakerState.ATTENDING), + ("media_processing", NotetakerState.MEDIA_PROCESSING), + ("media_available", NotetakerState.MEDIA_AVAILABLE), + ("media_error", NotetakerState.MEDIA_ERROR), + ("media_deleted", NotetakerState.MEDIA_DELETED), + ] + + for state_str, state_enum in states: + notetaker_json = { + "id": "notetaker-123", + "name": "Nylas Notetaker", + "join_time": 1656090000, + "meeting_link": "https://meet.google.com/abc-def-ghi", + "state": state_str, + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + } + + notetaker = Notetaker.from_dict(notetaker_json) + assert notetaker.state == state_enum + assert notetaker.state.value == state_str + + def test_list_notetakers(self, http_client_list_response): + notetakers = Notetakers(http_client_list_response) + + notetakers.list(identifier="abc-123", query_params=None) + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/notetakers", None, None, None, overrides=None + ) + + def test_list_notetakers_without_identifier(self, http_client_list_response): + notetakers = Notetakers(http_client_list_response) + + notetakers.list(query_params=None) + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/notetakers", None, None, None, overrides=None + ) + + def test_list_notetakers_with_query_params(self, http_client_list_response): + notetakers = Notetakers(http_client_list_response) + + notetakers.list( + identifier="abc-123", + query_params={ + "limit": 20, + "state": NotetakerState.SCHEDULED.value # Use enum value as string for raw dict + } + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/notetakers", + None, + {"limit": 20, "state": "scheduled"}, + None, + overrides=None, + ) + + def test_list_notetakers_with_enum_query_params(self, http_client_list_response): + """Test that the NotetakerState enum can be used directly in query params.""" + notetakers = Notetakers(http_client_list_response) + + # Create query params using the enum directly + query_params = ListNotetakerQueryParams( + state=NotetakerState.SCHEDULED, + limit=20 + ) + + notetakers.list( + identifier="abc-123", + query_params=query_params + ) + + # Verify the enum is converted to string in the API call + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/notetakers", + None, + {"state": "scheduled", "limit": 20}, + None, + overrides=None, + ) + + def test_find_notetaker(self, http_client_response): + notetakers = Notetakers(http_client_response) + + notetakers.find(identifier="abc-123", notetaker_id="notetaker-123") + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/notetakers/notetaker-123", + None, + None, + None, + overrides=None, + ) + + def test_find_notetaker_without_identifier(self, http_client_response): + notetakers = Notetakers(http_client_response) + + notetakers.find(notetaker_id="notetaker-123") + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/notetakers/notetaker-123", + None, + None, + None, + overrides=None, + ) + + def test_invite_notetaker(self, http_client_response): + notetakers = Notetakers(http_client_response) + request_body = { + "meeting_link": "https://meet.google.com/abc-def-ghi", + "join_time": 1656090000, + "name": "Custom Notetaker", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + } + + notetakers.invite(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/notetakers", + None, + None, + request_body, + overrides=None, + ) + + def test_invite_notetaker_without_identifier(self, http_client_response): + notetakers = Notetakers(http_client_response) + request_body = { + "meeting_link": "https://meet.google.com/abc-def-ghi", + "join_time": 1656090000, + "name": "Custom Notetaker", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + } + + notetakers.invite(request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/notetakers", + None, + None, + request_body, + overrides=None, + ) + + def test_update_notetaker(self, http_client_response): + notetakers = Notetakers(http_client_response) + request_body = { + "name": "Updated Notetaker", + "join_time": 1656100000, + "meeting_settings": { + "video_recording": False, + "audio_recording": True, + "transcription": True + } + } + + notetakers.update( + identifier="abc-123", + notetaker_id="notetaker-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PATCH", + "/v3/grants/abc-123/notetakers/notetaker-123", + None, + None, + request_body, + overrides=None, + ) + + def test_update_notetaker_without_identifier(self, http_client_response): + notetakers = Notetakers(http_client_response) + request_body = { + "name": "Updated Notetaker", + "join_time": 1656100000 + } + + notetakers.update( + notetaker_id="notetaker-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PATCH", + "/v3/notetakers/notetaker-123", + None, + None, + request_body, + overrides=None, + ) + + def test_leave_meeting(self, http_client_response): + notetakers = Notetakers(http_client_response) + + notetakers.leave( + identifier="abc-123", + notetaker_id="notetaker-123", + ) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/notetakers/notetaker-123/leave", + None, + None, + None, + overrides=None, + ) + + def test_leave_meeting_without_identifier(self, http_client_response): + notetakers = Notetakers(http_client_response) + + notetakers.leave( + notetaker_id="notetaker-123", + ) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/notetakers/notetaker-123/leave", + None, + None, + None, + overrides=None, + ) + + def test_get_media(self, http_client_response): + notetakers = Notetakers(http_client_response) + + notetakers.get_media( + identifier="abc-123", + notetaker_id="notetaker-123", + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/notetakers/notetaker-123/media", + None, + None, + None, + overrides=None, + ) + + def test_get_media_without_identifier(self, http_client_response): + notetakers = Notetakers(http_client_response) + + notetakers.get_media( + notetaker_id="notetaker-123", + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/notetakers/notetaker-123/media", + None, + None, + None, + overrides=None, + ) + + def test_cancel_notetaker(self, http_client_delete_response): + notetakers = Notetakers(http_client_delete_response) + + notetakers.cancel( + identifier="abc-123", + notetaker_id="notetaker-123", + ) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/notetakers/notetaker-123/cancel", + None, + None, + None, + overrides=None, + ) + + def test_cancel_notetaker_without_identifier(self, http_client_delete_response): + notetakers = Notetakers(http_client_delete_response) + + notetakers.cancel( + notetaker_id="notetaker-123", + ) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/notetakers/notetaker-123/cancel", + None, + None, + None, + overrides=None, + ) + + def test_media_deserialization(self): + media_json = { + "recording": { + "url": "https://example.com/recording.mp4", + "size": 25 + }, + "transcript": { + "url": "https://example.com/transcript.txt", + "size": 2 + } + } + + media = NotetakerMedia.from_dict(media_json) + + assert media.recording.url == "https://example.com/recording.mp4" + assert media.recording.size == 25 + assert media.transcript.url == "https://example.com/transcript.txt" + assert media.transcript.size == 2 + + def test_meeting_provider_enum(self): + """Test that the MeetingProvider enum works correctly.""" + # Test all enum values + providers = [ + ("Google Meet", MeetingProvider.GOOGLE_MEET), + ("Zoom Meeting", MeetingProvider.ZOOM), + ("Microsoft Teams", MeetingProvider.MICROSOFT_TEAMS), + ] + + for provider_str, provider_enum in providers: + notetaker_json = { + "id": "notetaker-123", + "name": "Nylas Notetaker", + "join_time": 1656090000, + "meeting_link": "https://meet.example.com", + "meeting_provider": provider_str, + "state": "scheduled", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + } + + notetaker = Notetaker.from_dict(notetaker_json) + assert notetaker.meeting_provider == provider_enum + assert notetaker.meeting_provider.value == provider_str + + def test_state_enum_comparison(self): + """Test that enum values can be compared directly.""" + # Create a notetaker with a state enum + notetaker_json = { + "id": "notetaker-123", + "name": "Nylas Notetaker", + "join_time": 1656090000, + "meeting_link": "https://meet.google.com/abc-def-ghi", + "state": "scheduled", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + } + + notetaker = Notetaker.from_dict(notetaker_json) + + # Check direct comparison with enum + assert notetaker.state == NotetakerState.SCHEDULED + + # Value of the enum matches original string + assert notetaker.state.value == "scheduled" + + def test_meeting_provider_enum_comparison(self): + """Test that meeting provider enum values can be compared directly.""" + # Create a notetaker with a meeting provider enum + notetaker_json = { + "id": "notetaker-123", + "name": "Nylas Notetaker", + "join_time": 1656090000, + "meeting_link": "https://meet.google.com/abc-def-ghi", + "meeting_provider": "Google Meet", + "state": "scheduled", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + } + + notetaker = Notetaker.from_dict(notetaker_json) + + # Check direct comparison with enum + assert notetaker.meeting_provider == MeetingProvider.GOOGLE_MEET + + # Value of the enum matches original string + assert notetaker.meeting_provider.value == "Google Meet" + + def test_notetaker_helper_methods(self): + """Test the helper methods for checking state and provider.""" + # Test with a scheduled notetaker + scheduled_notetaker = Notetaker.from_dict({ + "id": "notetaker-123", + "name": "Nylas Notetaker", + "join_time": 1656090000, + "meeting_link": "https://meet.google.com/abc-def-ghi", + "meeting_provider": "Google Meet", + "state": "scheduled", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + }) + + assert scheduled_notetaker.is_state(NotetakerState.SCHEDULED) is True + assert scheduled_notetaker.is_scheduled() is True + assert scheduled_notetaker.is_attending() is False + assert scheduled_notetaker.has_media_available() is False + + # Test with an attending notetaker + attending_notetaker = Notetaker.from_dict({ + "id": "notetaker-456", + "name": "Nylas Notetaker", + "join_time": 1656090000, + "meeting_link": "https://zoom.us/j/123456789", + "meeting_provider": "Zoom Meeting", + "state": "attending", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + }) + + assert attending_notetaker.is_state(NotetakerState.ATTENDING) is True + assert attending_notetaker.is_scheduled() is False + assert attending_notetaker.is_attending() is True + assert attending_notetaker.has_media_available() is False + + # Test with a media available notetaker + media_available_notetaker = Notetaker.from_dict({ + "id": "notetaker-789", + "name": "Nylas Notetaker", + "join_time": 1656090000, + "meeting_link": "https://teams.microsoft.com/l/meetup-join/123", + "meeting_provider": "Microsoft Teams", + "state": "media_available", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True + } + }) + + assert media_available_notetaker.is_state(NotetakerState.MEDIA_AVAILABLE) is True + assert media_available_notetaker.is_scheduled() is False + assert media_available_notetaker.is_attending() is False + assert media_available_notetaker.has_media_available() is True + + def test_query_params_with_enum_state(self, http_client_list_response): + """Test that query params require enum state values.""" + from nylas.models.notetakers import ListNotetakerQueryParams + + # Create query params directly with the enum + query_params = { + "state": NotetakerState.SCHEDULED, # Use enum directly in dict + "limit": 20 + } + + notetakers = Notetakers(http_client_list_response) + + notetakers.list( + identifier="abc-123", + query_params=query_params + ) + + # Verify the enum is converted to string in the API call + http_client_list_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/notetakers", + None, + {"state": "scheduled", "limit": 20}, + None, + overrides=None, + ) + + def test_notetaker_leave_response_deserialization(self): + """Test deserialization of the NotetakerLeaveResponse model.""" + leave_response_json = { + "id": "notetaker-123", + "message": "Notetaker has left the meeting", + "object": "notetaker_leave_response" + } + + leave_response = NotetakerLeaveResponse.from_dict(leave_response_json) + + assert leave_response.id == "notetaker-123" + assert leave_response.message == "Notetaker has left the meeting" + assert leave_response.object == "notetaker_leave_response" \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py index 3fd84b67..58181841 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -86,3 +86,9 @@ def test_client_threads_property(self, client): def test_client_webhooks_property(self, client): assert client.webhooks is not None assert type(client.webhooks) is Webhooks + + def test_scheduler(self, client): + assert client.scheduler is not None + + def test_notetakers(self, client): + assert client.notetakers is not None From e5e109d33392417a88202c553b03f29e40e670d8 Mon Sep 17 00:00:00 2001 From: kraju3 <35513942+kraju3@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:50:29 -0500 Subject: [PATCH 132/186] Add tentative_as_busy flag to the availability request (#415) * Add tentative_as_busy flag to the availability request * Added changelog and fix lint issues * Fix tests * Update the tentative_as_busy property to the availability request * Updated changelog --------- Co-authored-by: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> --- CHANGELOG.md | 2 ++ nylas/models/availability.py | 4 ++++ nylas/models/events.py | 9 ++++++++ nylas/resources/calendars.py | 3 +-- tests/resources/test_calendars.py | 37 ++++++++++++++++--------------- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac7f9a6e..68b6e3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ nylas-python Changelog Unreleased ---------------- +* Support for tentative_as_busy parameter that controls whether tentative events are treated as busy time. +* Available as a query parameter for Events requests and as a property in Availability request bodies * Added support for Notetaker APIs * Added support for Notetaker via the calendar and event APIs diff --git a/nylas/models/availability.py b/nylas/models/availability.py index 68497f38..931d3943 100644 --- a/nylas/models/availability.py +++ b/nylas/models/availability.py @@ -90,12 +90,16 @@ class AvailabilityRules(TypedDict): round_robin_group_id: The ID on events that Nylas considers when calculating the order of round-robin participants. This is used for both max-fairness and max-availability methods. + tentative_as_busy: Controls whether tentative calendar events should be treated as busy time. + When set to false, tentative events will be considered as free in availability calculations. + Defaults to true. Only applicable for Microsoft and EWS calendar providers. """ availability_method: NotRequired[AvailabilityMethod] buffer: NotRequired[MeetingBuffer] default_open_hours: NotRequired[List[OpenHours]] round_robin_group_id: NotRequired[str] + tentative_as_busy: NotRequired[bool] class AvailabilityParticipant(TypedDict): diff --git a/nylas/models/events.py b/nylas/models/events.py index 77c671fa..79569bb5 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -822,6 +822,8 @@ class ListEventQueryParams(ListQueryParams): You can pass the query parameter multiple times to select or exclude multiple event types. master_event_id (NotRequired[str]): Filter for instances of recurring events with the specified master_event_id. Not respected by metadata filtering. + tentative_as_busy: When set to false, treats tentative calendar events as busy:false. + Only applicable for Microsoft and EWS calendar providers. Defaults to true. select: Comma-separated list of fields to return in the response. This allows you to receive only the portion of object data that you're interested in. limit (NotRequired[int]): The maximum number of objects to return. @@ -844,6 +846,7 @@ class ListEventQueryParams(ListQueryParams): event_type: NotRequired[List[EventType]] master_event_id: NotRequired[str] select: NotRequired[str] + tentative_as_busy: NotRequired[bool] class CreateEventQueryParams(TypedDict): @@ -853,10 +856,13 @@ class CreateEventQueryParams(TypedDict): Attributes: calendar_id: The ID of the calendar to create the event in. notify_participants: Email notifications containing the calendar event is sent to all event participants. + tentative_as_busy: When set to false, treats tentative calendar events as busy:false. + Only applicable for Microsoft and EWS calendar providers. Defaults to true. """ calendar_id: str notify_participants: NotRequired[bool] + tentative_as_busy: NotRequired[bool] class FindEventQueryParams(TypedDict): @@ -866,9 +872,12 @@ class FindEventQueryParams(TypedDict): Attributes: calendar_id: Calendar ID to find the event in. "primary" is a supported value indicating the user's primary calendar. + tentative_as_busy: When set to false, treats tentative calendar events as busy:false. + Only applicable for Microsoft and EWS calendar providers. Defaults to true. """ calendar_id: str + tentative_as_busy: NotRequired[bool] UpdateEventQueryParams = CreateEventQueryParams diff --git a/nylas/resources/calendars.py b/nylas/resources/calendars.py index e82670e7..971fa22b 100644 --- a/nylas/resources/calendars.py +++ b/nylas/resources/calendars.py @@ -163,8 +163,7 @@ def destroy( path=f"/v3/grants/{identifier}/calendars/{calendar_id}", overrides=overrides ) - def get_availability( - self, request_body: GetAvailabilityRequest, overrides: RequestOverrides = None + def get_availability(self, request_body: GetAvailabilityRequest, overrides: RequestOverrides = None ) -> Response[GetAvailabilityResponse]: """ Get availability for a Calendar. diff --git a/tests/resources/test_calendars.py b/tests/resources/test_calendars.py index 79e8a384..c2f9f8f6 100644 --- a/tests/resources/test_calendars.py +++ b/tests/resources/test_calendars.py @@ -342,26 +342,27 @@ def test_get_availability(self, http_client_response): ], } ], - "locale": "en", + "duration_minutes": 60, + "interval_minutes": 30, + "round_to_30_minutes": True, + "availability_rules": { + "availability_method": "max-availability", + "buffer": {"before": 10, "after": 10}, + "default_open_hours": [ + { + "days": [0], + "timezone": "America/Los_Angeles", + "start": "09:00", + "end": "17:00", + "exdates": ["2021-03-01"], + } + ], + "round_robin_group_id": "event-123", + "tentative_as_busy": False + }, } - # Set up mock response data - http_client_response._execute.return_value = ({ - "availability": [ - { - "end_time": 1497960800, - "start_time": 1497960600, - "status": "free", - "object": "availability_status", - } - ], - "object": "availability", - "time_slots": [ - {"end_time": 1497960800, "start_time": 1497960600, "status": "free"} - ], - }, {}) - - calendars.get_availability(request_body=request_body) + calendars.get_availability(request_body,overrides=None,) http_client_response._execute.assert_called_once_with( "POST", From 42671795cb5ab3f52095c62fe3a80b475d619d87 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Wed, 9 Apr 2025 17:01:45 -0400 Subject: [PATCH 133/186] Update application_details.py --- nylas/models/application_details.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nylas/models/application_details.py b/nylas/models/application_details.py index 9f919fd3..c017f9d0 100644 --- a/nylas/models/application_details.py +++ b/nylas/models/application_details.py @@ -8,7 +8,7 @@ Region = Literal["us", "eu"] """ Literal representing the available Nylas API regions. """ -Environment = Literal["production", "staging", "sandbox"] +Environment = Literal["production", "staging", "development", "sandbox"] """ Literal representing the different Nylas API environments. """ From 5365f6a63eb1e505e6bedb1099d8012496de848c Mon Sep 17 00:00:00 2001 From: kraju3 <35513942+kraju3@users.noreply.github.com> Date: Wed, 9 Apr 2025 16:06:30 -0500 Subject: [PATCH 134/186] Add webhook triggers to bypass ValueErrors (#406) * Add webhook triggers to bypass ValueErrors * Add deserialization test for all triggers --------- Co-authored-by: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> --- nylas/models/webhooks.py | 14 ++++++- tests/resources/test_webhooks.py | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/nylas/models/webhooks.py b/nylas/models/webhooks.py index 36ea508c..71b65827 100644 --- a/nylas/models/webhooks.py +++ b/nylas/models/webhooks.py @@ -11,10 +11,16 @@ class WebhookTriggers(str, Enum): """Enum representing the available webhook triggers.""" - + BOOKING_CREATED = "booking.created" + BOOKING_PENDING = "booking.pending" + BOOKING_RESCHEDULED = "booking.rescheduled" + BOOKING_CANCELLED = "booking.cancelled" + BOOKING_REMINDER = "booking.reminder" CALENDAR_CREATED = "calendar.created" CALENDAR_UPDATED = "calendar.updated" CALENDAR_DELETED = "calendar.deleted" + CONTACT_UPDATED = "contact.updated" + CONTACT_DELETED = "contact.deleted" EVENT_CREATED = "event.created" EVENT_UPDATED = "event.updated" EVENT_DELETED = "event.deleted" @@ -29,7 +35,13 @@ class WebhookTriggers(str, Enum): MESSAGE_UPDATED = "message.updated" MESSAGE_OPENED = "message.opened" MESSAGE_LINK_CLICKED = "message.link_clicked" + MESSAGE_OPENED_LEGACY = "message.opened.legacy" + MESSAGE_LINK_CLICKED_LEGACY = "message.link_clicked.legacy" + MESSAGE_INTELLIGENCE_ORDER = "message.intelligence.order" + MESSAGE_INTELLIGENCE_TRACKING = "message.intelligence.tracking" + MESSAGE_INTELLIGENCE_RETURN = "message.intelligence.return" THREAD_REPLIED = "thread.replied" + THREAD_REPLIED_LEGACY = "thread.replied.legacy" FOLDER_CREATED = "folder.created" FOLDER_UPDATED = "folder.updated" FOLDER_DELETED = "folder.deleted" diff --git a/tests/resources/test_webhooks.py b/tests/resources/test_webhooks.py index d4749060..98f016de 100644 --- a/tests/resources/test_webhooks.py +++ b/tests/resources/test_webhooks.py @@ -33,6 +33,71 @@ def test_webhook_deserialization(self, http_client): assert webhook.created_at == 1234567890 assert webhook.updated_at == 1234567890 + def test_webhook_deserialization_all(self, http_client): + trigger_types = [ + "booking.created", + "booking.pending", + "booking.rescheduled", + "booking.cancelled", + "booking.reminder", + "calendar.created", + "calendar.updated", + "calendar.deleted", + "contact.updated", + "contact.deleted", + "event.created", + "event.updated", + "event.deleted", + "grant.created", + "grant.updated", + "grant.deleted", + "grant.expired", + "message.send_success", + "message.send_failed", + "message.bounce_detected", + "message.created", + "message.updated", + "message.opened", + "message.link_clicked", + "message.opened.legacy", + "message.link_clicked.legacy", + "message.intelligence.order", + "message.intelligence.tracking", + "message.intelligence.return", + "thread.replied", + "thread.replied.legacy", + "folder.created", + "folder.updated", + "folder.deleted" + ] + + webhook_json = { + "id": "UMWjAjMeWQ4D8gYF2moonK4486", + "description": "Production webhook destination", + "trigger_types": trigger_types, + "webhook_url": "https://example.com/webhooks", + "status": "active", + "notification_email_addresses": ["jane@example.com", "joe@example.com"], + "status_updated_at": 1234567890, + "created_at": 1234567890, + "updated_at": 1234567890, + } + + webhook = Webhook.from_dict(webhook_json) + + assert webhook.id == "UMWjAjMeWQ4D8gYF2moonK4486" + assert webhook.description == "Production webhook destination" + assert webhook.trigger_types == trigger_types + assert webhook.webhook_url == "https://example.com/webhooks" + assert webhook.status == "active" + assert webhook.notification_email_addresses == [ + "jane@example.com", + "joe@example.com", + ] + assert webhook.status_updated_at == 1234567890 + assert webhook.created_at == 1234567890 + assert webhook.updated_at == 1234567890 + def test_list_webhooks(self, http_client_list_response): webhooks = Webhooks(http_client_list_response) From d95f1da8d42f8b550a3b8d95d5c25d26070d0787 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:38:32 -0400 Subject: [PATCH 135/186] fix: update notetaker media endpoint (#416) * fix: update notetaker media endpoint * updated changelog --- CHANGELOG.md | 4 +- examples/notetaker_api_demo/notetaker_demo.py | 14 ++++++- nylas/models/notetakers.py | 20 +++++++--- tests/resources/test_notetakers.py | 37 +++++++++++++++---- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68b6e3e8..6ff0494f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,8 @@ nylas-python Changelog Unreleased ---------------- -* Support for tentative_as_busy parameter that controls whether tentative events are treated as busy time. -* Available as a query parameter for Events requests and as a property in Availability request bodies +* Added support for for tentative_as_busy parameter to the availability request +* Added missing webhook triggers * Added support for Notetaker APIs * Added support for Notetaker via the calendar and event APIs diff --git a/examples/notetaker_api_demo/notetaker_demo.py b/examples/notetaker_api_demo/notetaker_demo.py index 4e27085c..e48d8e8a 100644 --- a/examples/notetaker_api_demo/notetaker_demo.py +++ b/examples/notetaker_api_demo/notetaker_demo.py @@ -79,10 +79,20 @@ def get_notetaker_media(notetaker_id): if media.recording: print(f"Recording URL: {media.data.recording.url}") - print(f"Recording Size: {media.data.recording.size} MB") + print(f"Recording Name: {media.data.recording.name}") + print(f"Recording Type: {media.data.recording.type}") + print(f"Recording Size: {media.data.recording.size} bytes") + print(f"Recording Created At: {media.data.recording.created_at}") + print(f"Recording Expires At: {media.data.recording.expires_at}") + print(f"Recording TTL: {media.data.recording.ttl} seconds") if media.transcript: print(f"Transcript URL: {media.data.transcript.url}") - print(f"Transcript Size: {media.data.transcript.size} MB") + print(f"Transcript Name: {media.data.transcript.name}") + print(f"Transcript Type: {media.data.transcript.type}") + print(f"Transcript Size: {media.data.transcript.size} bytes") + print(f"Transcript Created At: {media.data.transcript.created_at}") + print(f"Transcript Expires At: {media.data.transcript.expires_at}") + print(f"Transcript TTL: {media.data.transcript.ttl} seconds") return media except NylasApiError as e: diff --git a/nylas/models/notetakers.py b/nylas/models/notetakers.py index af405bb7..2d196b3c 100644 --- a/nylas/models/notetakers.py +++ b/nylas/models/notetakers.py @@ -91,12 +91,22 @@ class NotetakerMediaRecording: Class representing a Notetaker media recording. Attributes: - url: A link to the meeting recording. - size: The size of the file, in MB. + size: The size of the file in bytes. + name: The name of the file. + type: The MIME type of the file. + created_at: Unix timestamp when the file was uploaded to the storage server. + expires_at: Unix timestamp when the file will be deleted. + url: A link to download the file. + ttl: Time-to-live in seconds until the file will be deleted off Nylas' storage server. """ - url: str size: int + name: str + type: str + created_at: int + expires_at: int + url: str + ttl: int @dataclass_json @@ -106,8 +116,8 @@ class NotetakerMedia: Class representing Notetaker media. Attributes: - recording: The meeting recording. - transcript: The meeting transcript. + recording: The meeting recording (video/mp4). + transcript: The meeting transcript (application/json). """ recording: Optional[NotetakerMediaRecording] = None diff --git a/tests/resources/test_notetakers.py b/tests/resources/test_notetakers.py index 11374718..44bca39f 100644 --- a/tests/resources/test_notetakers.py +++ b/tests/resources/test_notetakers.py @@ -364,21 +364,42 @@ def test_cancel_notetaker_without_identifier(self, http_client_delete_response): def test_media_deserialization(self): media_json = { "recording": { - "url": "https://example.com/recording.mp4", - "size": 25 + "size": 21550491, + "name": "meeting_recording.mp4", + "type": "video/mp4", + "created_at": 1744222418, + "expires_at": 1744481618, + "url": "url_for_recording", + "ttl": 259106 }, "transcript": { - "url": "https://example.com/transcript.txt", - "size": 2 + "size": 862, + "name": "raw_transcript.json", + "type": "application/json", + "created_at": 1744222418, + "expires_at": 1744481618, + "url": "url_for_transcript", + "ttl": 259106 } } media = NotetakerMedia.from_dict(media_json) - assert media.recording.url == "https://example.com/recording.mp4" - assert media.recording.size == 25 - assert media.transcript.url == "https://example.com/transcript.txt" - assert media.transcript.size == 2 + assert media.recording.url == "url_for_recording" + assert media.recording.size == 21550491 + assert media.recording.name == "meeting_recording.mp4" + assert media.recording.type == "video/mp4" + assert media.recording.created_at == 1744222418 + assert media.recording.expires_at == 1744481618 + assert media.recording.ttl == 259106 + + assert media.transcript.url == "url_for_transcript" + assert media.transcript.size == 862 + assert media.transcript.name == "raw_transcript.json" + assert media.transcript.type == "application/json" + assert media.transcript.created_at == 1744222418 + assert media.transcript.expires_at == 1744481618 + assert media.transcript.ttl == 259106 def test_meeting_provider_enum(self): """Test that the MeetingProvider enum works correctly.""" From 2e33dd204c67c874fda382044563db1e2c4f86df Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 29 Apr 2025 14:28:05 -0400 Subject: [PATCH 136/186] Updated notetaker GET endpoint to support changes to the query params (#417) --- nylas/models/notetakers.py | 62 ++++++- tests/resources/test_notetakers.py | 276 ++++++++++++++++------------- 2 files changed, 211 insertions(+), 127 deletions(-) diff --git a/nylas/models/notetakers.py b/nylas/models/notetakers.py index 2d196b3c..a889fecc 100644 --- a/nylas/models/notetakers.py +++ b/nylas/models/notetakers.py @@ -35,6 +35,34 @@ class NotetakerState(str, Enum): MEDIA_DELETED = "media_deleted" +class NotetakerOrderBy(str, Enum): + """ + Enum representing the possible fields to order Notetaker bots by. + + Values: + NAME: Order by the Notetaker's name. + JOIN_TIME: Order by the Notetaker's join time. + CREATED_AT: Order by when the Notetaker was created. + """ + + NAME = "name" + JOIN_TIME = "join_time" + CREATED_AT = "created_at" + + +class NotetakerOrderDirection(str, Enum): + """ + Enum representing the possible directions to order Notetaker bots by. + + Values: + ASC: Ascending order. + DESC: Descending order. + """ + + ASC = "asc" + DESC = "desc" + + class MeetingProvider(str, Enum): """ Enum representing the possible meeting providers for Notetaker. @@ -57,7 +85,7 @@ class NotetakerMeetingSettingsRequest(TypedDict): Attributes: video_recording: When true, Notetaker records the meeting's video. audio_recording: When true, Notetaker records the meeting's audio. - transcription: When true, Notetaker transcribes the meeting's audio. + transcription: When true, Notetaker transcribes the meeting's audio. If transcription is true, audio_recording must also be true. """ @@ -75,7 +103,7 @@ class NotetakerMeetingSettings: Attributes: video_recording: When true, Notetaker records the meeting's video. audio_recording: When true, Notetaker records the meeting's audio. - transcription: When true, Notetaker transcribes the meeting's audio. + transcription: When true, Notetaker transcribes the meeting's audio. If transcription is true, audio_recording must also be true. """ @@ -182,7 +210,7 @@ class InviteNotetakerRequest(TypedDict): Attributes: meeting_link: A meeting invitation link that Notetaker uses to join the meeting. - join_time: When Notetaker should join the meeting, in Unix timestamp format. + join_time: When Notetaker should join the meeting, in Unix timestamp format. If empty, Notetaker joins the meeting immediately. name: The display name for the Notetaker bot. meeting_settings: Notetaker Meeting Settings. @@ -217,23 +245,41 @@ class ListNotetakerQueryParams(ListQueryParams): state: Filter for Notetaker bots with the specified meeting state. Use the NotetakerState enum. Example: state=NotetakerState.SCHEDULED - join_time_from: Filter for Notetaker bots that are scheduled to join meetings after the specified time. - join_time_until: Filter for Notetaker bots that are scheduled to join meetings until the specified time. + join_time_start: Filter for Notetaker bots that have join times that start at or after a specific time, + in Unix timestamp format. + join_time_end: Filter for Notetaker bots that have join times that end at or are before a specific time, + in Unix timestamp format. limit: The maximum number of objects to return. This field defaults to 50. The maximum allowed value is 200. page_token: An identifier that specifies which page of data to return. prev_page_token: An identifier that specifies which page of data to return. + order_by: The field to order the Notetaker bots by. Defaults to created_at. + Use the NotetakerOrderBy enum. + Example: order_by=NotetakerOrderBy.NAME + order_direction: The direction to order the Notetaker bots by. Defaults to asc. + Use the NotetakerOrderDirection enum. + Example: order_direction=NotetakerOrderDirection.DESC """ state: NotRequired[NotetakerState] - join_time_from: NotRequired[int] - join_time_until: NotRequired[int] + join_time_start: NotRequired[int] + join_time_end: NotRequired[int] + order_by: NotRequired[NotetakerOrderBy] + order_direction: NotRequired[NotetakerOrderDirection] def __post_init__(self): - """Convert NotetakerState enum to string value for API requests.""" + """Convert enums to string values for API requests.""" super().__post_init__() # Convert state enum to string if present if hasattr(self, "state") and isinstance(self.state, NotetakerState): self.state = self.state.value + # Convert order_by enum to string if present + if hasattr(self, "order_by") and isinstance(self.order_by, NotetakerOrderBy): + self.order_by = self.order_by.value + # Convert order_direction enum to string if present + if hasattr(self, "order_direction") and isinstance( + self.order_direction, NotetakerOrderDirection + ): + self.order_direction = self.order_direction.value class FindNotetakerQueryParams(TypedDict): diff --git a/tests/resources/test_notetakers.py b/tests/resources/test_notetakers.py index 44bca39f..a094139a 100644 --- a/tests/resources/test_notetakers.py +++ b/tests/resources/test_notetakers.py @@ -1,14 +1,13 @@ from nylas.resources.notetakers import Notetakers from nylas.models.notetakers import ( - Notetaker, - NotetakerMeetingSettings, - NotetakerMeetingSettingsRequest, - NotetakerMedia, - NotetakerMediaRecording, + Notetaker, + NotetakerMedia, NotetakerState, MeetingProvider, ListNotetakerQueryParams, - NotetakerLeaveResponse + NotetakerLeaveResponse, + NotetakerOrderBy, + NotetakerOrderDirection, ) @@ -25,8 +24,8 @@ def test_notetaker_deserialization(self): "meeting_settings": { "video_recording": True, "audio_recording": True, - "transcription": True - } + "transcription": True, + }, } notetaker = Notetaker.from_dict(notetaker_json) @@ -56,7 +55,7 @@ def test_notetaker_state_enum(self): ("media_error", NotetakerState.MEDIA_ERROR), ("media_deleted", NotetakerState.MEDIA_DELETED), ] - + for state_str, state_enum in states: notetaker_json = { "id": "notetaker-123", @@ -67,21 +66,26 @@ def test_notetaker_state_enum(self): "meeting_settings": { "video_recording": True, "audio_recording": True, - "transcription": True - } + "transcription": True, + }, } - + notetaker = Notetaker.from_dict(notetaker_json) assert notetaker.state == state_enum assert notetaker.state.value == state_str - + def test_list_notetakers(self, http_client_list_response): notetakers = Notetakers(http_client_list_response) notetakers.list(identifier="abc-123", query_params=None) http_client_list_response._execute.assert_called_once_with( - "GET", "/v3/grants/abc-123/notetakers", None, None, None, overrides=None + "GET", + "/v3/grants/abc-123/notetakers", + None, + None, + None, + overrides=None, ) def test_list_notetakers_without_identifier(self, http_client_list_response): @@ -97,18 +101,15 @@ def test_list_notetakers_with_query_params(self, http_client_list_response): notetakers = Notetakers(http_client_list_response) notetakers.list( - identifier="abc-123", - query_params={ - "limit": 20, - "state": NotetakerState.SCHEDULED.value # Use enum value as string for raw dict - } + identifier="abc-123", + query_params={"state": NotetakerState.SCHEDULED, "limit": 20}, ) http_client_list_response._execute.assert_called_once_with( "GET", "/v3/grants/abc-123/notetakers", None, - {"limit": 20, "state": "scheduled"}, + {"state": "scheduled", "limit": 20}, None, overrides=None, ) @@ -116,17 +117,13 @@ def test_list_notetakers_with_query_params(self, http_client_list_response): def test_list_notetakers_with_enum_query_params(self, http_client_list_response): """Test that the NotetakerState enum can be used directly in query params.""" notetakers = Notetakers(http_client_list_response) - + # Create query params using the enum directly query_params = ListNotetakerQueryParams( - state=NotetakerState.SCHEDULED, - limit=20 + state=NotetakerState.SCHEDULED, limit=20 ) - notetakers.list( - identifier="abc-123", - query_params=query_params - ) + notetakers.list(identifier="abc-123", query_params=query_params) # Verify the enum is converted to string in the API call http_client_list_response._execute.assert_called_once_with( @@ -175,8 +172,8 @@ def test_invite_notetaker(self, http_client_response): "meeting_settings": { "video_recording": True, "audio_recording": True, - "transcription": True - } + "transcription": True, + }, } notetakers.invite(identifier="abc-123", request_body=request_body) @@ -199,8 +196,8 @@ def test_invite_notetaker_without_identifier(self, http_client_response): "meeting_settings": { "video_recording": True, "audio_recording": True, - "transcription": True - } + "transcription": True, + }, } notetakers.invite(request_body=request_body) @@ -222,8 +219,8 @@ def test_update_notetaker(self, http_client_response): "meeting_settings": { "video_recording": False, "audio_recording": True, - "transcription": True - } + "transcription": True, + }, } notetakers.update( @@ -243,10 +240,7 @@ def test_update_notetaker(self, http_client_response): def test_update_notetaker_without_identifier(self, http_client_response): notetakers = Notetakers(http_client_response) - request_body = { - "name": "Updated Notetaker", - "join_time": 1656100000 - } + request_body = {"name": "Updated Notetaker", "join_time": 1656100000} notetakers.update( notetaker_id="notetaker-123", @@ -370,7 +364,7 @@ def test_media_deserialization(self): "created_at": 1744222418, "expires_at": 1744481618, "url": "url_for_recording", - "ttl": 259106 + "ttl": 259106, }, "transcript": { "size": 862, @@ -379,8 +373,8 @@ def test_media_deserialization(self): "created_at": 1744222418, "expires_at": 1744481618, "url": "url_for_transcript", - "ttl": 259106 - } + "ttl": 259106, + }, } media = NotetakerMedia.from_dict(media_json) @@ -409,7 +403,7 @@ def test_meeting_provider_enum(self): ("Zoom Meeting", MeetingProvider.ZOOM), ("Microsoft Teams", MeetingProvider.MICROSOFT_TEAMS), ] - + for provider_str, provider_enum in providers: notetaker_json = { "id": "notetaker-123", @@ -421,14 +415,14 @@ def test_meeting_provider_enum(self): "meeting_settings": { "video_recording": True, "audio_recording": True, - "transcription": True - } + "transcription": True, + }, } - + notetaker = Notetaker.from_dict(notetaker_json) assert notetaker.meeting_provider == provider_enum assert notetaker.meeting_provider.value == provider_str - + def test_state_enum_comparison(self): """Test that enum values can be compared directly.""" # Create a notetaker with a state enum @@ -441,18 +435,18 @@ def test_state_enum_comparison(self): "meeting_settings": { "video_recording": True, "audio_recording": True, - "transcription": True - } + "transcription": True, + }, } - + notetaker = Notetaker.from_dict(notetaker_json) - + # Check direct comparison with enum assert notetaker.state == NotetakerState.SCHEDULED - + # Value of the enum matches original string assert notetaker.state.value == "scheduled" - + def test_meeting_provider_enum_comparison(self): """Test that meeting provider enum values can be compared directly.""" # Create a notetaker with a meeting provider enum @@ -466,103 +460,113 @@ def test_meeting_provider_enum_comparison(self): "meeting_settings": { "video_recording": True, "audio_recording": True, - "transcription": True - } + "transcription": True, + }, } - + notetaker = Notetaker.from_dict(notetaker_json) - + # Check direct comparison with enum assert notetaker.meeting_provider == MeetingProvider.GOOGLE_MEET - + # Value of the enum matches original string assert notetaker.meeting_provider.value == "Google Meet" def test_notetaker_helper_methods(self): """Test the helper methods for checking state and provider.""" # Test with a scheduled notetaker - scheduled_notetaker = Notetaker.from_dict({ - "id": "notetaker-123", - "name": "Nylas Notetaker", - "join_time": 1656090000, - "meeting_link": "https://meet.google.com/abc-def-ghi", - "meeting_provider": "Google Meet", - "state": "scheduled", - "meeting_settings": { - "video_recording": True, - "audio_recording": True, - "transcription": True + scheduled_notetaker = Notetaker.from_dict( + { + "id": "notetaker-123", + "name": "Nylas Notetaker", + "join_time": 1656090000, + "meeting_link": ("https://meet.google.com/abc-def-ghi"), + "meeting_provider": "Google Meet", + "state": "scheduled", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True, + }, } - }) - + ) + assert scheduled_notetaker.is_state(NotetakerState.SCHEDULED) is True assert scheduled_notetaker.is_scheduled() is True assert scheduled_notetaker.is_attending() is False assert scheduled_notetaker.has_media_available() is False - + # Test with an attending notetaker - attending_notetaker = Notetaker.from_dict({ - "id": "notetaker-456", - "name": "Nylas Notetaker", - "join_time": 1656090000, - "meeting_link": "https://zoom.us/j/123456789", - "meeting_provider": "Zoom Meeting", - "state": "attending", - "meeting_settings": { - "video_recording": True, - "audio_recording": True, - "transcription": True + attending_notetaker = Notetaker.from_dict( + { + "id": "notetaker-456", + "name": "Nylas Notetaker", + "join_time": 1656090000, + "meeting_link": "https://zoom.us/j/123456789", + "meeting_provider": "Zoom Meeting", + "state": "attending", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True, + }, } - }) - + ) + assert attending_notetaker.is_state(NotetakerState.ATTENDING) is True assert attending_notetaker.is_scheduled() is False assert attending_notetaker.is_attending() is True assert attending_notetaker.has_media_available() is False - + # Test with a media available notetaker - media_available_notetaker = Notetaker.from_dict({ - "id": "notetaker-789", - "name": "Nylas Notetaker", - "join_time": 1656090000, - "meeting_link": "https://teams.microsoft.com/l/meetup-join/123", - "meeting_provider": "Microsoft Teams", - "state": "media_available", - "meeting_settings": { - "video_recording": True, - "audio_recording": True, - "transcription": True + media_available_notetaker = Notetaker.from_dict( + { + "id": "notetaker-789", + "name": "Nylas Notetaker", + "join_time": 1656090000, + "meeting_link": ("https://teams.microsoft.com/l/meetup-join/123"), + "meeting_provider": "Microsoft Teams", + "state": "media_available", + "meeting_settings": { + "video_recording": True, + "audio_recording": True, + "transcription": True, + }, } - }) - - assert media_available_notetaker.is_state(NotetakerState.MEDIA_AVAILABLE) is True + ) + + assert ( + media_available_notetaker.is_state(NotetakerState.MEDIA_AVAILABLE) is True + ) assert media_available_notetaker.is_scheduled() is False assert media_available_notetaker.is_attending() is False assert media_available_notetaker.has_media_available() is True - def test_query_params_with_enum_state(self, http_client_list_response): - """Test that query params require enum state values.""" - from nylas.models.notetakers import ListNotetakerQueryParams - - # Create query params directly with the enum - query_params = { - "state": NotetakerState.SCHEDULED, # Use enum directly in dict - "limit": 20 - } - - notetakers = Notetakers(http_client_list_response) - - notetakers.list( - identifier="abc-123", - query_params=query_params + def test_list_notetakers_with_time_filters(self, http_client_list_response): + """Test that join_time_start and join_time_end query parameters work correctly.""" + # Using Unix timestamps for Jan 1, 2024 and Jan 2, 2024 + start_time = 1704067200 # Jan 1, 2024 + end_time = 1704153600 # Jan 2, 2024 + + # Create query params with time filters + query_params = ListNotetakerQueryParams( + join_time_start=start_time, join_time_end=end_time, limit=20 ) - - # Verify the enum is converted to string in the API call - http_client_list_response._execute.assert_called_with( + + notetakers = Notetakers(http_client_list_response) + + notetakers.list(identifier="abc-123", query_params=query_params) + + # Verify the API call includes the time filter parameters + http_client_list_response._execute.assert_called_once_with( "GET", "/v3/grants/abc-123/notetakers", None, - {"state": "scheduled", "limit": 20}, + { + "join_time_start": start_time, + "join_time_end": end_time, + "limit": 20, + }, None, overrides=None, ) @@ -572,11 +576,45 @@ def test_notetaker_leave_response_deserialization(self): leave_response_json = { "id": "notetaker-123", "message": "Notetaker has left the meeting", - "object": "notetaker_leave_response" + "object": "notetaker_leave_response", } leave_response = NotetakerLeaveResponse.from_dict(leave_response_json) assert leave_response.id == "notetaker-123" assert leave_response.message == "Notetaker has left the meeting" - assert leave_response.object == "notetaker_leave_response" \ No newline at end of file + assert leave_response.object == "notetaker_leave_response" + + def test_list_notetakers_with_order_params(self, http_client_list_response): + notetakers = Notetakers(http_client_list_response) + + notetakers.list( + identifier="abc-123", + query_params={ + "order_by": NotetakerOrderBy.NAME, + "order_direction": NotetakerOrderDirection.DESC, + }, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/notetakers", + None, + {"order_by": "name", "order_direction": "desc"}, + None, + overrides=None, + ) + + def test_list_notetakers_with_default_order(self, http_client_list_response): + notetakers = Notetakers(http_client_list_response) + + notetakers.list(identifier="abc-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/notetakers", + None, + None, + None, + overrides=None, + ) From 601a4bea138f9b57fdb8465a54b190647e687bb3 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:26:16 -0400 Subject: [PATCH 137/186] Release v6.9.0 (#418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update changelog * Bump version: 6.8.0 โ†’ 6.9.0 --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2a660d8e..ef961e93 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.8.0 +current_version = 6.9.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ff0494f..f4372213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.9.0 ---------------- * Added support for for tentative_as_busy parameter to the availability request * Added missing webhook triggers diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 9d9cc746..94a322c3 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.8.0" +__VERSION__ = "6.9.0" From 148af5c9e74ad0c1396aabc8853b81f7d2ce5677 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Thu, 29 May 2025 16:53:24 -0400 Subject: [PATCH 138/186] feat: add support for include_tracking_options and raw_mime message fields (#419) - Add new Fields enum values: include_tracking_options, raw_mime - Add TrackingOptions model with opens, thread_replies, links, label fields - Add tracking_options and raw_mime fields to Message model - Update ListMessagesQueryParams and FindMessageQueryParams documentation - Add 8 comprehensive tests for new functionality with backwards compatibility - Add complete example in examples/message_fields_demo/ with documentation - Update CHANGELOG.md This implementation supports the latest Nylas API message fields while maintaining full backwards compatibility. All existing functionality continues to work unchanged. --- CHANGELOG.md | 8 + examples/message_fields_demo/README.md | 99 +++++++ .../message_fields_example.py | 275 ++++++++++++++++++ nylas/models/messages.py | 37 ++- tests/resources/test_messages.py | 158 ++++++++++ 5 files changed, 574 insertions(+), 3 deletions(-) create mode 100644 examples/message_fields_demo/README.md create mode 100644 examples/message_fields_demo/message_fields_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f4372213..4746969c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Added support for new message fields query parameter values: `include_tracking_options` and `raw_mime` +* Added `tracking_options` field to Message model for message tracking settings +* Added `raw_mime` field to Message model for Base64url-encoded message data +* Added TrackingOptions model for message tracking configuration +* Maintained backwards compatibility for existing message functionality + v6.9.0 ---------------- * Added support for for tentative_as_busy parameter to the availability request diff --git a/examples/message_fields_demo/README.md b/examples/message_fields_demo/README.md new file mode 100644 index 00000000..5b0a2f18 --- /dev/null +++ b/examples/message_fields_demo/README.md @@ -0,0 +1,99 @@ +# Message Fields Demo + +This example demonstrates the usage of the new message `fields` query parameter values (`include_tracking_options` and `raw_mime`) introduced in the Nylas API. These fields allow you to access tracking information and raw MIME data for messages. + +## Features Demonstrated + +1. **include_tracking_options Field**: Shows how to fetch messages with their tracking options (opens, thread_replies, links, and label). +2. **raw_mime Field**: Demonstrates how to retrieve the raw MIME content of messages as Base64url-encoded data. +3. **Backwards Compatibility**: Shows that existing code continues to work as expected without specifying the new fields. +4. **TrackingOptions Model**: Demonstrates working with the new TrackingOptions dataclass for serialization and deserialization. + +## API Fields Overview + +### include_tracking_options +When using `fields=include_tracking_options`, the API returns messages with their tracking settings: +- `opens`: Boolean indicating if message open tracking is enabled +- `thread_replies`: Boolean indicating if thread replied tracking is enabled +- `links`: Boolean indicating if link clicked tracking is enabled +- `label`: String label describing the message tracking purpose + +### raw_mime +When using `fields=raw_mime`, the API returns only essential fields plus the raw MIME content: +- `grant_id`: The grant identifier +- `object`: The object type ("message") +- `id`: The message identifier +- `raw_mime`: Base64url-encoded string containing the complete message data + +## Setup + +1. Install the SDK in development mode from the repository root: +```bash +cd /path/to/nylas-python +pip install -e . +``` + +2. Set your environment variables: +```bash +export NYLAS_API_KEY="your_api_key" +export NYLAS_GRANT_ID="your_grant_id" +``` + +3. Run the example from the repository root: +```bash +python examples/message_fields_demo/message_fields_example.py +``` + +## Example Output + +``` +Demonstrating Message Fields Usage +================================= + +=== Standard Message Fetching (Backwards Compatible) === +Fetching messages with standard fields... +โœ“ Found 2 messages with standard payload + +=== Include Tracking Options === +Fetching messages with tracking options... +โœ“ Found 2 messages with tracking data +Message tracking: opens=True, links=False, label="Campaign A" + +=== Raw MIME Content === +Fetching messages with raw MIME data... +โœ“ Found 2 messages with raw MIME content +Raw MIME length: 1245 characters + +=== TrackingOptions Model Demo === +Creating and serializing TrackingOptions... +โœ“ TrackingOptions serialization works correctly + +Example completed successfully! +``` + +## Use Cases + +### Tracking Options +- **Email Campaign Analytics**: Monitor open rates, link clicks, and thread engagement +- **Marketing Automation**: Track customer engagement with promotional emails +- **CRM Integration**: Feed tracking data into customer relationship management systems + +### Raw MIME +- **Email Archival**: Store complete email data including headers and formatting +- **Email Migration**: Transfer emails between systems with full fidelity +- **Security Analysis**: Examine email headers and structure for security purposes +- **Custom Email Parsing**: Build custom email processing pipelines + +## Error Handling + +The example includes proper error handling for: +- Missing environment variables +- API authentication errors +- Empty message collections +- Invalid field parameters + +## Documentation + +For more information about the Nylas Python SDK and message fields, visit: +- [Nylas Python SDK Documentation](https://developer.nylas.com/docs/sdks/python/) +- [Nylas API Messages Reference](https://developer.nylas.com/docs/api/v3/ecc/#tag--Messages) \ No newline at end of file diff --git a/examples/message_fields_demo/message_fields_example.py b/examples/message_fields_demo/message_fields_example.py new file mode 100644 index 00000000..5faa8d84 --- /dev/null +++ b/examples/message_fields_demo/message_fields_example.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Using Message Fields (include_tracking_options and raw_mime) + +This example demonstrates how to use the new 'fields' query parameter values +'include_tracking_options' and 'raw_mime' to access message tracking data and raw MIME content. + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID + +Usage: + First, install the SDK in development mode: + cd /path/to/nylas-python + pip install -e . + + Then set environment variables and run: + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + python examples/message_fields_demo/message_fields_example.py +""" + +import os +import sys +import json +import base64 +from nylas import Client +from nylas.models.messages import TrackingOptions + + +def get_env_or_exit(var_name: str) -> str: + """Get an environment variable or exit if not found.""" + value = os.getenv(var_name) + if not value: + print(f"Error: {var_name} environment variable is required") + sys.exit(1) + return value + + +def print_separator(title: str) -> None: + """Print a formatted section separator.""" + print(f"\n=== {title} ===") + + +def demonstrate_standard_fields(client: Client, grant_id: str) -> None: + """Demonstrate backwards compatible message fetching (standard fields).""" + print_separator("Standard Message Fetching (Backwards Compatible)") + + try: + print("Fetching messages with standard fields...") + messages = client.messages.list( + identifier=grant_id, + query_params={"limit": 2} + ) + + if not messages.data: + print("โš ๏ธ No messages found in this account") + return + + print(f"โœ“ Found {len(messages.data)} messages with standard payload") + + for i, message in enumerate(messages.data, 1): + print(f"\nMessage {i}:") + print(f" ID: {message.id}") + print(f" Subject: {message.subject or 'No subject'}") + print(f" From: {message.from_[0].email if message.from_ else 'Unknown'}") + print(f" Tracking Options: {message.tracking_options}") # Should be None + print(f" Raw MIME: {message.raw_mime}") # Should be None + + except Exception as e: + print(f"โŒ Error fetching standard messages: {e}") + + +def demonstrate_tracking_options(client: Client, grant_id: str) -> None: + """Demonstrate fetching messages with tracking options.""" + print_separator("Include Tracking Options") + + try: + print("Fetching messages with tracking options...") + messages = client.messages.list( + identifier=grant_id, + query_params={ + "limit": 2, + "fields": "include_tracking_options" + } + ) + + if not messages.data: + print("โš ๏ธ No messages found in this account") + return + + print(f"โœ“ Found {len(messages.data)} messages with tracking data") + + for i, message in enumerate(messages.data, 1): + print(f"\nMessage {i}:") + print(f" ID: {message.id}") + print(f" Subject: {message.subject or 'No subject'}") + + if message.tracking_options: + print(f" Tracking Options:") + print(f" Opens: {message.tracking_options.opens}") + print(f" Thread Replies: {message.tracking_options.thread_replies}") + print(f" Links: {message.tracking_options.links}") + print(f" Label: {message.tracking_options.label}") + else: + print(" Tracking Options: None (tracking not enabled for this message)") + + except Exception as e: + print(f"โŒ Error fetching messages with tracking options: {e}") + + +def demonstrate_raw_mime(client: Client, grant_id: str) -> None: + """Demonstrate fetching messages with raw MIME content.""" + print_separator("Raw MIME Content") + + try: + print("Fetching messages with raw MIME data...") + messages = client.messages.list( + identifier=grant_id, + query_params={ + "limit": 2, + "fields": "raw_mime" + } + ) + + if not messages.data: + print("โš ๏ธ No messages found in this account") + return + + print(f"โœ“ Found {len(messages.data)} messages with raw MIME content") + + for i, message in enumerate(messages.data, 1): + print(f"\nMessage {i}:") + print(f" ID: {message.id}") + print(f" Grant ID: {message.grant_id}") + print(f" Object: {message.object}") + + if message.raw_mime: + print(f" Raw MIME length: {len(message.raw_mime)} characters") + + # Decode a small portion to show it's real MIME data + try: + # Show first 200 characters of decoded MIME + decoded_sample = base64.urlsafe_b64decode( + message.raw_mime + '=' * (4 - len(message.raw_mime) % 4) + ).decode('utf-8', errors='ignore')[:200] + print(f" MIME preview: {decoded_sample}...") + except Exception as decode_error: + print(f" MIME preview: Unable to decode preview ({decode_error})") + else: + print(" Raw MIME: None") + + # Note: In raw_mime mode, most other fields should be None + print(f" Subject (should be None): {message.subject}") + print(f" Body (should be None): {getattr(message, 'body', 'N/A')}") + + except Exception as e: + print(f"โŒ Error fetching messages with raw MIME: {e}") + + +def demonstrate_single_message_fields(client: Client, grant_id: str) -> None: + """Demonstrate fetching a single message with different field options.""" + print_separator("Single Message with Different Fields") + + try: + # First get a message ID + print("Finding a message to demonstrate single message field options...") + messages = client.messages.list( + identifier=grant_id, + query_params={"limit": 1} + ) + + if not messages.data: + print("โš ๏ธ No messages found for single message demo") + return + + message_id = messages.data[0].id + print(f"Using message ID: {message_id}") + + # Fetch with tracking options + print("\nFetching single message with tracking options...") + message = client.messages.find( + identifier=grant_id, + message_id=message_id, + query_params={"fields": "include_tracking_options"} + ) + + print(f"โœ“ Message fetched with tracking: {message.tracking_options is not None}") + + # Fetch with raw MIME + print("\nFetching single message with raw MIME...") + message = client.messages.find( + identifier=grant_id, + message_id=message_id, + query_params={"fields": "raw_mime"} + ) + + print(f"โœ“ Message fetched with raw MIME: {message.raw_mime is not None}") + if message.raw_mime: + print(f" Raw MIME size: {len(message.raw_mime)} characters") + + except Exception as e: + print(f"โŒ Error in single message demo: {e}") + + +def demonstrate_tracking_options_model() -> None: + """Demonstrate working with the TrackingOptions model directly.""" + print_separator("TrackingOptions Model Demo") + + try: + print("Creating TrackingOptions object...") + + # Create a TrackingOptions instance + tracking = TrackingOptions( + opens=True, + thread_replies=False, + links=True, + label="Marketing Campaign Demo" + ) + + print("โœ“ TrackingOptions created:") + print(f" Opens: {tracking.opens}") + print(f" Thread Replies: {tracking.thread_replies}") + print(f" Links: {tracking.links}") + print(f" Label: {tracking.label}") + + # Demonstrate serialization + print("\nSerializing to dict...") + tracking_dict = tracking.to_dict() + print(f"โœ“ Serialized: {json.dumps(tracking_dict, indent=2)}") + + # Demonstrate deserialization + print("\nDeserializing from dict...") + restored_tracking = TrackingOptions.from_dict(tracking_dict) + print(f"โœ“ Deserialized: opens={restored_tracking.opens}, label='{restored_tracking.label}'") + + # Demonstrate JSON serialization + print("\nJSON serialization...") + tracking_json = tracking.to_json() + print(f"โœ“ JSON: {tracking_json}") + + restored_from_json = TrackingOptions.from_json(tracking_json) + print(f"โœ“ From JSON: {restored_from_json.to_dict()}") + + except Exception as e: + print(f"โŒ Error in TrackingOptions demo: {e}") + + +def main(): + """Main function demonstrating message fields usage.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + + # Initialize Nylas client + client = Client(api_key=api_key) + + print("Demonstrating Message Fields Usage") + print("=================================") + print("This shows the new 'include_tracking_options' and 'raw_mime' field options") + + # Demonstrate different field options + demonstrate_standard_fields(client, grant_id) + demonstrate_tracking_options(client, grant_id) + demonstrate_raw_mime(client, grant_id) + demonstrate_single_message_fields(client, grant_id) + demonstrate_tracking_options_model() + + print("\n" + "="*50) + print("Example completed successfully!") + print("="*50) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/nylas/models/messages.py b/nylas/models/messages.py index ba1ea995..342df897 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -8,7 +8,7 @@ from nylas.models.events import EmailName -Fields = Literal["standard", "include_headers"] +Fields = Literal["standard", "include_headers", "include_tracking_options", "raw_mime"] """ Literal representing which headers to include with a message. """ @@ -27,6 +27,25 @@ class MessageHeader: value: str +@dataclass_json +@dataclass +class TrackingOptions: + """ + Message tracking options. + + Attributes: + opens: When true, shows that message open tracking is enabled. + thread_replies: When true, shows that thread replied tracking is enabled. + links: When true, shows that link clicked tracking is enabled. + label: A label describing the message tracking purpose. + """ + + opens: Optional[bool] = None + thread_replies: Optional[bool] = None + links: Optional[bool] = None + label: Optional[str] = None + + @dataclass_json @dataclass class Message: @@ -55,6 +74,8 @@ class Message: created_at: Unix timestamp of when the message was created. schedule_id: The ID of the scheduled email message. Nylas returns the schedule_id if send_at is set. send_at: Unix timestamp of when the message will be sent, if scheduled. + tracking_options: The tracking options for the message. + raw_mime: A Base64url-encoded string containing the message data (including the body content). """ grant_id: str @@ -81,6 +102,8 @@ class Message: schedule_id: Optional[str] = None send_at: Optional[int] = None metadata: Optional[Dict[str, Any]] = None + tracking_options: Optional[TrackingOptions] = None + raw_mime: Optional[str] = None # Need to use Functional typed dicts because "from" and "in" are Python @@ -124,7 +147,11 @@ class Message: received_before: Return messages with received dates before received_before. received_after: Return messages with received dates after received_after. has_attachment: Filter messages by whether they have an attachment. - fields: Specify "include_headers" to include headers in the response. "standard" is the default. + fields: Specify which headers to include in the response. + - "standard" (default): Returns the standard message payload. + - "include_headers": Returns messages and their custom headers. + - "include_tracking_options": Returns messages and their tracking settings. + - "raw_mime": Returns the grant_id, object, id, and raw_mime fields for each message. search_query_native: A native provider search query for Google or Microsoft. select: Comma-separated list of fields to return in the response. This allows you to receive only the portion of object data that you're interested in. @@ -140,7 +167,11 @@ class FindMessageQueryParams(TypedDict): Query parameters for finding a message. Attributes: - fields: Specify "include_headers" to include headers in the response. "standard" is the default. + fields: Specify which headers to include in the response. + - "standard" (default): Returns the standard message payload. + - "include_headers": Returns messages and their custom headers. + - "include_tracking_options": Returns messages and their tracking settings. + - "raw_mime": Returns the grant_id, object, id, and raw_mime fields for each message. select: Comma-separated list of fields to return in the response. This allows you to receive only the portion of object data that you're interested in. """ diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 4577bc7b..50f071c1 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -732,3 +732,161 @@ def test_find_message_select_param(self, http_client_response): # Make sure query params are properly serialized assert http_client_response._execute.call_args[0][3] == {"select": ["id", "subject", "from", "to"]} + + # New tests for tracking_options and raw_mime features + def test_message_deserialization_with_tracking_options(self): + """Test deserialization of message with tracking_options field.""" + message_json = { + "body": "Hello, I just sent a message using Nylas!", + "from": [{"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}], + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "5d3qmne77v32r8l4phyuksl2x", + "object": "message", + "subject": "Hello from Nylas!", + "tracking_options": { + "opens": True, + "thread_replies": False, + "links": True, + "label": "Marketing Campaign" + } + } + + message = Message.from_dict(message_json) + + assert message.tracking_options is not None + assert message.tracking_options.opens is True + assert message.tracking_options.thread_replies is False + assert message.tracking_options.links is True + assert message.tracking_options.label == "Marketing Campaign" + + def test_message_deserialization_with_raw_mime(self): + """Test deserialization of message with raw_mime field.""" + message_json = { + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "5d3qmne77v32r8l4phyuksl2x", + "object": "message", + "raw_mime": "TUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluOyBjaGFyc2V0PXV0Zi04CgpIZWxsbyBXb3JsZCE=" + } + + message = Message.from_dict(message_json) + + assert message.raw_mime == "TUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluOyBjaGFyc2V0PXV0Zi04CgpIZWxsbyBXb3JsZCE=" + + def test_message_deserialization_backwards_compatibility(self): + """Test that existing message deserialization still works (backwards compatibility).""" + message_json = { + "body": "Hello, I just sent a message using Nylas!", + "from": [{"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}], + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "5d3qmne77v32r8l4phyuksl2x", + "object": "message", + "subject": "Hello from Nylas!", + } + + message = Message.from_dict(message_json) + + # These new fields should be None when not provided + assert message.tracking_options is None + assert message.raw_mime is None + # Existing fields should still work + assert message.body == "Hello, I just sent a message using Nylas!" + assert message.subject == "Hello from Nylas!" + + def test_list_messages_with_include_tracking_options_field(self, http_client_list_response): + """Test listing messages with include_tracking_options field.""" + messages = Messages(http_client_list_response) + + messages.list( + identifier="abc-123", + query_params={"fields": "include_tracking_options"}, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages", + None, + {"fields": "include_tracking_options"}, + None, + overrides=None, + ) + + def test_list_messages_with_raw_mime_field(self, http_client_list_response): + """Test listing messages with raw_mime field.""" + messages = Messages(http_client_list_response) + + messages.list( + identifier="abc-123", + query_params={"fields": "raw_mime"}, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages", + None, + {"fields": "raw_mime"}, + None, + overrides=None, + ) + + def test_find_message_with_include_tracking_options_field(self, http_client_response): + """Test finding a message with include_tracking_options field.""" + messages = Messages(http_client_response) + + messages.find( + identifier="abc-123", + message_id="message-123", + query_params={"fields": "include_tracking_options"}, + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/message-123", + None, + {"fields": "include_tracking_options"}, + None, + overrides=None, + ) + + def test_find_message_with_raw_mime_field(self, http_client_response): + """Test finding a message with raw_mime field.""" + messages = Messages(http_client_response) + + messages.find( + identifier="abc-123", + message_id="message-123", + query_params={"fields": "raw_mime"}, + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/message-123", + None, + {"fields": "raw_mime"}, + None, + overrides=None, + ) + + def test_tracking_options_serialization(self): + """Test that tracking_options can be serialized to JSON.""" + from nylas.models.messages import TrackingOptions + + tracking_options = TrackingOptions( + opens=True, + thread_replies=False, + links=True, + label="Test Campaign" + ) + + # Test serialization + json_data = tracking_options.to_dict() + assert json_data["opens"] is True + assert json_data["thread_replies"] is False + assert json_data["links"] is True + assert json_data["label"] == "Test Campaign" + + # Test deserialization + tracking_options_from_dict = TrackingOptions.from_dict(json_data) + assert tracking_options_from_dict.opens is True + assert tracking_options_from_dict.thread_replies is False + assert tracking_options_from_dict.links is True + assert tracking_options_from_dict.label == "Test Campaign" \ No newline at end of file From ee4116412b1a770a2b6accfd6fd964f959366de5 Mon Sep 17 00:00:00 2001 From: Samuel Xavier <107475513+samuelpx@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:10:07 -0300 Subject: [PATCH 139/186] [Build] Added UV folders to .gitignore + created pyproject.toml (#420) * Added UV folders to .gitignore * Add pyproject.toml for modern Python packaging --- .gitignore | 61 +++++++++++++++++++++++++++++++++++++++++++------- pyproject.toml | 50 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index 1e7c9032..2940379b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,81 @@ -# Byte-compiled / optimized / DLL files +# UV virtual environment +.venv/ +.venv + +# UV cache and lock files +.uv/ +uv.lock + +# Python cache and compiled files __pycache__/ *.py[cod] - -# C extensions +*$py.class *.so # Distribution / packaging .Python -env/ -bin/ build/ develop-eggs/ dist/ +downloads/ eggs/ +.eggs/ +lib/ +lib64/ parts/ sdist/ var/ +wheels/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# PyInstaller +*.manifest +*.spec # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage +.coverage.* .cache nosetests.xml coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Environments +.env +.venv +ENV/ +env/ +venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# macOS +.DS_Store + +# Docs +docs/_build/ +site/ + + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt # Translations *.mo diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..c46c1aff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["setuptools>=69.0.3", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "nylas" +dynamic = ["version"] +description = "Python bindings for the Nylas API platform." +readme = "README.md" +license = {text = "MIT"} +authors = [ + {name = "Nylas Team", email = "support@nylas.com"} +] +keywords = ["inbox", "app", "appserver", "email", "nylas", "contacts", "calendar"] +requires-python = ">=3.8" +dependencies = [ + "requests[security]>=2.31.0", + "requests-toolbelt>=1.0.0", + "dataclasses-json>=0.5.9", + "typing_extensions>=4.7.1", +] + +[project.optional-dependencies] +test = [ + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "setuptools>=69.0.3", +] +docs = [ + "mkdocs>=1.5.2", + "mkdocstrings[python]>=0.22.0", + "mkdocs-material>=9.2.6", + "mkdocs-gen-files>=0.5.0", + "mkdocs-literate-nav>=0.6.0", +] +release = [ + "bumpversion>=0.6.0", + "twine>=4.0.2", +] + +[project.urls] +Homepage = "https://github.com/nylas/nylas-python" +Repository = "https://github.com/nylas/nylas-python" + +[tool.setuptools.dynamic] +version = {attr = "nylas._client_sdk_version.__VERSION__"} + +[tool.setuptools.packages.find] +where = ["."] +include = ["nylas*"] From fb63388a9494e8417a1565d5d161cf9541cb3b3c Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Wed, 11 Jun 2025 11:14:56 -0300 Subject: [PATCH 140/186] CUST-4499 Added missing param earliest_message_date --- nylas/models/threads.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nylas/models/threads.py b/nylas/models/threads.py index 5d46fe15..57062b00 100644 --- a/nylas/models/threads.py +++ b/nylas/models/threads.py @@ -112,6 +112,7 @@ class UpdateThreadRequest(TypedDict): "unread": NotRequired[bool], "starred": NotRequired[bool], "thread_id": NotRequired[str], + "earliest_message_date": NotRequired[int], "latest_message_before": NotRequired[int], "latest_message_after": NotRequired[int], "has_attachment": NotRequired[bool], From 097e36df61c20b187039443b01b80bec658bf596 Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Wed, 11 Jun 2025 11:20:38 -0300 Subject: [PATCH 141/186] CUST-4499 Added missing param earliest_message_date --- nylas/models/threads.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nylas/models/threads.py b/nylas/models/threads.py index 57062b00..90c30d32 100644 --- a/nylas/models/threads.py +++ b/nylas/models/threads.py @@ -134,6 +134,7 @@ class UpdateThreadRequest(TypedDict): unread: Filter threads by unread status. starred: Filter threads by starred status. thread_id: Filter threads by thread_id. + earliest_message_date: Unix timestamp of the earliest or first message in the thread. latest_message_before: Return threads whose most recent message was received before this Unix timestamp. latest_message_after: Return threads whose most recent message was received after this Unix timestamp. has_attachment: Filter threads by whether they have an attachment. From 884fd8efd0d84a96d9989867e54610df4992ea88 Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Wed, 11 Jun 2025 12:01:07 -0300 Subject: [PATCH 142/186] CUST 4499 Added tests, modified CHANGELOG --- CHANGELOG.md | 1 + tests/resources/test_threads.py | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4746969c..550ec01b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased ---------------- +* Added support for `earliest_message_date` query parameter for threads * Added support for new message fields query parameter values: `include_tracking_options` and `raw_mime` * Added `tracking_options` field to Message model for message tracking settings * Added `raw_mime` field to Message model for Base64url-encoded message data diff --git a/tests/resources/test_threads.py b/tests/resources/test_threads.py index 749c4b90..e1144e9e 100644 --- a/tests/resources/test_threads.py +++ b/tests/resources/test_threads.py @@ -188,6 +188,44 @@ def test_list_threads_with_select_param(self, http_client_list_response): # The actual response validation is handled by the mock in conftest.py assert result is not None + + def test_list_threads_with_earliest_message_date_param(self, http_client_list_response): + threads = Threads(http_client_list_response) + + timestamp = 1672531200 + + http_client_list_response._execute.return_value = { + "request_id": "abc-123", + "data": [{ + "id": "thread-123", + "has_attachments": False, + "earliest_message_date": 1672617600, + "participants": [ + {"email": "test@example.com", "name": "Test User"} + ], + "snippet": "Test snippet", + "unread": False, + "subject": "Test subject", + "message_ids": ["msg-123"], + "folders": ["folder-123"] + }] + } + + result = threads.list( + identifier="abc-123", + query_params={"earliest_message_date": timestamp} + ) + + http_client_list_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/threads", + None, + {"earliest_message_date": timestamp}, + None, + overrides=None, + ) + + assert result is not None def test_find_thread(self, http_client_response): threads = Threads(http_client_response) From 4c1404ff7f4da211cc6eeb981f812dde93c680cb Mon Sep 17 00:00:00 2001 From: Samuel Xavier Date: Fri, 13 Jun 2025 22:18:08 -0300 Subject: [PATCH 143/186] CUST-4511 - earliest_message_date on models.Thread made optional, added tests, added line in CHANGELOG --- CHANGELOG.md | 1 + nylas/models/threads.py | 2 +- tests/resources/test_threads.py | 47 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 550ec01b..81f7ecc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------------- * Added support for `earliest_message_date` query parameter for threads +* Fixed `earliest_message_date` not being an optional response field * Added support for new message fields query parameter values: `include_tracking_options` and `raw_mime` * Added `tracking_options` field to Message model for message tracking settings * Added `raw_mime` field to Message model for Base64url-encoded message data diff --git a/nylas/models/threads.py b/nylas/models/threads.py index 90c30d32..17798e87 100644 --- a/nylas/models/threads.py +++ b/nylas/models/threads.py @@ -65,13 +65,13 @@ class Thread: has_drafts: bool starred: bool unread: bool - earliest_message_date: int message_ids: List[str] folders: List[str] latest_draft_or_message: Union[Message, Draft] = field( metadata=config(decoder=_decode_draft_or_message) ) object: str = "thread" + earliest_message_date: Optional[int] = None latest_message_received_date: Optional[int] = None draft_ids: Optional[List[str]] = None snippet: Optional[str] = None diff --git a/tests/resources/test_threads.py b/tests/resources/test_threads.py index e1144e9e..14c5eb26 100644 --- a/tests/resources/test_threads.py +++ b/tests/resources/test_threads.py @@ -227,6 +227,53 @@ def test_list_threads_with_earliest_message_date_param(self, http_client_list_re assert result is not None + def test_list_threads_without_earliest_message_date_in_response(self, http_client_list_response): + threads = Threads(http_client_list_response) + + http_client_list_response._execute.return_value = { + "request_id": "abc-123", + "data": [{ + "id": "thread-123", + "grant_id": "test-grant-id", + "has_drafts": False, + "starred": False, + "unread": False, + "message_ids": ["msg-123"], + "folders": ["folder-123"], + "latest_draft_or_message": { + "body": "Test message body", + "date": 1672617600, + "from": [{"name": "Test User", "email": "test@example.com"}], + "grant_id": "test-grant-id", + "id": "msg-123", + "object": "message", + "subject": "Test subject", + "thread_id": "thread-123", + "to": [{"name": "Recipient", "email": "recipient@example.com"}], + "unread": False, + }, + "has_attachments": False, + "participants": [ + {"email": "test@example.com", "name": "Test User"} + ], + "snippet": "Test snippet", + "subject": "Test subject" + }] + } + + result = threads.list(identifier="abc-123") + + http_client_list_response._execute.assert_called_with( + "GET", + "/v3/grants/abc-123/threads", + None, + None, + None, + overrides=None, + ) + + assert result is not None + def test_find_thread(self, http_client_response): threads = Threads(http_client_response) From eb178f11a96378f816eb846b73eb6d1019917513 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Wed, 18 Jun 2025 19:18:23 -0400 Subject: [PATCH 144/186] feat: add single_level query parameter support for folders API (#425) --- CHANGELOG.md | 3 +- examples/folders_demo/README.md | 64 +++++++ .../folders_single_level_example.py | 167 ++++++++++++++++++ nylas/models/folders.py | 3 + tests/resources/test_folders.py | 86 +++++++-- 5 files changed, 303 insertions(+), 20 deletions(-) create mode 100644 examples/folders_demo/README.md create mode 100644 examples/folders_demo/folders_single_level_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f7ecc5..66d519ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased ---------------- +* Added support for `single_level` query parameter in `ListFolderQueryParams` for Microsoft accounts to control folder hierarchy traversal * Added support for `earliest_message_date` query parameter for threads * Fixed `earliest_message_date` not being an optional response field * Added support for new message fields query parameter values: `include_tracking_options` and `raw_mime` @@ -97,7 +98,7 @@ v6.0.0 * **BREAKING CHANGE**: Models no longer inherit from `dict` but instead either are a `dataclass` or inherit from `TypedDict` * **BREAKING CHANGE**: Renamed the SDK entrypoint from `APIClient` to `Client` * **REMOVED**: Local Webhook development support is removed due to incompatibility -* Rewrote the majority of SDK to be more intuitive, explicit, and efficient +* Rewritten the majority of SDK to be more intuitive, explicit, and efficient * Created models for all API resources and endpoints, for all HTTP methods to reduce confusion on which fields are available for each endpoint * Created error classes for the different API errors as well as SDK-specific errors diff --git a/examples/folders_demo/README.md b/examples/folders_demo/README.md new file mode 100644 index 00000000..413d2a36 --- /dev/null +++ b/examples/folders_demo/README.md @@ -0,0 +1,64 @@ +# Folders Single Level Parameter Example + +This example demonstrates how to use the `single_level` query parameter when listing folders to control folder hierarchy traversal for Microsoft accounts. + +## Overview + +The `single_level` parameter is a Microsoft-only feature that allows you to control whether the folders API returns: +- **`single_level=true`**: Only top-level folders (single-level hierarchy) +- **`single_level=false`**: All folders including nested ones (multi-level hierarchy, default) + +This parameter is useful for: +- **Performance optimization**: Reducing response size when you only need top-level folders +- **UI simplification**: Building folder trees incrementally +- **Microsoft-specific behavior**: Taking advantage of Microsoft's folder hierarchy structure + +## Prerequisites + +- Nylas API key +- Nylas grant ID for a Microsoft account (this parameter only works with Microsoft accounts) + +## Setup + +1. Install the SDK in development mode: + ```bash + cd /path/to/nylas-python + pip install -e . + ``` + +2. Set your environment variables: + ```bash + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_microsoft_grant_id" + ``` + +## Running the Example + +```bash +python examples/folders_demo/folders_single_level_example.py +``` + +## What the Example Demonstrates + +1. **Multi-level folder hierarchy** (default behavior) +2. **Single-level folder hierarchy** using `single_level=true` +3. **Combined parameters** showing how to use `single_level` with other query parameters +4. **Hierarchy comparison** showing the difference in folder counts + +## Expected Output + +The example will show: +- Folders returned with multi-level hierarchy +- Folders returned with single-level hierarchy only +- Count comparison between the two approaches +- How to combine the parameter with other options like `limit` and `select` + +## Use Cases + +- **Folder tree UI**: Load top-level folders first, then expand as needed +- **Performance**: Reduce API response size for Microsoft accounts with deep folder structures +- **Microsoft-specific integrations**: Take advantage of Microsoft's native folder organization + +## Note + +This parameter only works with Microsoft accounts. If you use it with other providers, it will be ignored. \ No newline at end of file diff --git a/examples/folders_demo/folders_single_level_example.py b/examples/folders_demo/folders_single_level_example.py new file mode 100644 index 00000000..335b464f --- /dev/null +++ b/examples/folders_demo/folders_single_level_example.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Using Single Level Parameter for Folders + +This example demonstrates how to use the 'single_level' query parameter when listing folders +to control the folder hierarchy traversal for Microsoft accounts. + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID (must be a Microsoft account) + +Usage: + First, install the SDK in development mode: + cd /path/to/nylas-python + pip install -e . + + Then set environment variables and run: + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_microsoft_grant_id" + python examples/folders_demo/folders_single_level_example.py +""" + +import os +import sys +import json +from nylas import Client + + +def get_env_or_exit(var_name: str) -> str: + """Get an environment variable or exit if not found.""" + value = os.getenv(var_name) + if not value: + print(f"Error: {var_name} environment variable is required") + sys.exit(1) + return value + + +def print_folders(folders: list, title: str) -> None: + """Pretty print the folders with a title.""" + print(f"\n{title}:") + if not folders: + print(" No folders found.") + return + + for folder in folders: + # Convert to dict and pretty print relevant fields + folder_dict = folder.to_dict() + print( + f" - {folder_dict.get('name', 'Unknown')} (ID: {folder_dict.get('id', 'Unknown')})" + ) + if folder_dict.get("parent_id"): + print(f" Parent ID: {folder_dict['parent_id']}") + if folder_dict.get("child_count") is not None: + print(f" Child Count: {folder_dict['child_count']}") + + +def demonstrate_multi_level_folders(client: Client, grant_id: str) -> None: + """Demonstrate multi-level folder hierarchy (default behavior).""" + print("\n=== Multi-Level Folder Hierarchy (Default) ===") + + # Default behavior - retrieves folders across multi-level hierarchy + print("\nFetching folders with multi-level hierarchy (single_level=False):") + folders = client.folders.list( + identifier=grant_id, query_params={"single_level": False} + ) + print_folders(folders.data, "Multi-level folder hierarchy") + + # Also demonstrate without explicitly setting single_level (default behavior) + print("\nFetching folders without single_level parameter (default behavior):") + folders = client.folders.list(identifier=grant_id) + print_folders(folders.data, "Default folder hierarchy (multi-level)") + + +def demonstrate_single_level_folders(client: Client, grant_id: str) -> None: + """Demonstrate single-level folder hierarchy.""" + print("\n=== Single-Level Folder Hierarchy ===") + + # Single-level hierarchy - only retrieves folders from the top level + print("\nFetching folders with single-level hierarchy (single_level=True):") + folders = client.folders.list( + identifier=grant_id, query_params={"single_level": True} + ) + print_folders(folders.data, "Single-level folder hierarchy") + + +def demonstrate_combined_parameters(client: Client, grant_id: str) -> None: + """Demonstrate single_level combined with other parameters.""" + print("\n=== Combined Parameters ===") + + # Combine single_level with other query parameters + print("\nFetching limited single-level folders with select fields:") + folders = client.folders.list( + identifier=grant_id, + query_params={ + "single_level": True, + "limit": 5, + "select": "id,name,parent_id,child_count", + }, + ) + print_folders(folders.data, "Limited single-level folders with selected fields") + + +def compare_hierarchies(client: Client, grant_id: str) -> None: + """Compare single-level vs multi-level folder counts.""" + print("\n=== Hierarchy Comparison ===") + + # Get multi-level count + multi_level_folders = client.folders.list( + identifier=grant_id, query_params={"single_level": False} + ) + multi_level_count = len(multi_level_folders.data) + + # Get single-level count + single_level_folders = client.folders.list( + identifier=grant_id, query_params={"single_level": True} + ) + single_level_count = len(single_level_folders.data) + + print(f"\nFolder count comparison:") + print(f" Multi-level hierarchy: {multi_level_count} folders") + print(f" Single-level hierarchy: {single_level_count} folders") + + if multi_level_count > single_level_count: + print( + f" Difference: {multi_level_count - single_level_count} folders in sub-hierarchies" + ) + elif single_level_count == multi_level_count: + print(" No nested folders detected in this account") + + +def main(): + """Main function demonstrating single_level parameter usage for folders.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + + # Initialize Nylas client + client = Client(api_key=api_key) + + print("\nDemonstrating Single Level Parameter for Folders") + print("===============================================") + print("This parameter is Microsoft-only and controls folder hierarchy traversal") + print(f"Using Grant ID: {grant_id}") + + try: + # Demonstrate different folder hierarchy options + demonstrate_multi_level_folders(client, grant_id) + demonstrate_single_level_folders(client, grant_id) + demonstrate_combined_parameters(client, grant_id) + compare_hierarchies(client, grant_id) + + print("\n=== Summary ===") + print("โ€ข single_level=True: Returns only top-level folders (Microsoft only)") + print("โ€ข single_level=False: Returns folders from all levels (default)") + print("โ€ข This parameter helps optimize performance for Microsoft accounts") + print("โ€ข Can be combined with other query parameters like limit and select") + + except Exception as e: + print(f"\nError: {e}") + print("\nNote: This example requires a Microsoft grant ID.") + print("The single_level parameter only works with Microsoft accounts.") + + print("\nExample completed!") + + +if __name__ == "__main__": + main() diff --git a/nylas/models/folders.py b/nylas/models/folders.py index 205502db..0d5e979c 100644 --- a/nylas/models/folders.py +++ b/nylas/models/folders.py @@ -85,6 +85,8 @@ class ListFolderQueryParams(ListQueryParams): Attributes: parent_id: (Microsoft and EWS only.) Use the ID of a folder to find all child folders it contains. + single_level: (Microsoft only) If true, retrieves folders from a single-level hierarchy only. + If false, retrieves folders across a multi-level hierarchy. Defaults to false. select (NotRequired[str]): Comma-separated list of fields to return in the response. This allows you to receive only the portion of object data that you're interested in. limit (NotRequired[int]): The maximum number of objects to return. @@ -94,6 +96,7 @@ class ListFolderQueryParams(ListQueryParams): """ parent_id: NotRequired[str] + single_level: NotRequired[bool] class FindFolderQueryParams(TypedDict): diff --git a/tests/resources/test_folders.py b/tests/resources/test_folders.py index da53dec7..021e7b48 100644 --- a/tests/resources/test_folders.py +++ b/tests/resources/test_folders.py @@ -58,26 +58,71 @@ def test_list_folders_with_query_params(self, http_client_list_response): overrides=None, ) + def test_list_folders_with_single_level_param(self, http_client_list_response): + folders = Folders(http_client_list_response) + + folders.list(identifier="abc-123", query_params={"single_level": True}) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/folders", + None, + {"single_level": True}, + None, + overrides=None, + ) + + def test_list_folders_with_single_level_false(self, http_client_list_response): + folders = Folders(http_client_list_response) + + folders.list(identifier="abc-123", query_params={"single_level": False}) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/folders", + None, + {"single_level": False}, + None, + overrides=None, + ) + + def test_list_folders_with_combined_params(self, http_client_list_response): + folders = Folders(http_client_list_response) + + folders.list( + identifier="abc-123", + query_params={"single_level": True, "parent_id": "parent-123", "limit": 10}, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/folders", + None, + {"single_level": True, "parent_id": "parent-123", "limit": 10}, + None, + overrides=None, + ) + def test_list_folders_with_select_param(self, http_client_list_response): folders = Folders(http_client_list_response) # Set up mock response data http_client_list_response._execute.return_value = { "request_id": "abc-123", - "data": [{ - "id": "folder-123", - "name": "Important", - "total_count": 42, - "unread_count": 5 - }] + "data": [ + { + "id": "folder-123", + "name": "Important", + "total_count": 42, + "unread_count": 5, + } + ], } # Call the API method result = folders.list( identifier="abc-123", - query_params={ - "select": "id,name,total_count,unread_count" - } + query_params={"select": "id,name,total_count,unread_count"}, ) # Verify API call @@ -111,21 +156,24 @@ def test_find_folder_with_select_param(self, http_client_response): folders = Folders(http_client_response) # Set up mock response data - http_client_response._execute.return_value = ({ - "request_id": "abc-123", - "data": { - "id": "folder-123", - "name": "Important", - "total_count": 42, - "unread_count": 5 - } - }, {"X-Test-Header": "test"}) + http_client_response._execute.return_value = ( + { + "request_id": "abc-123", + "data": { + "id": "folder-123", + "name": "Important", + "total_count": 42, + "unread_count": 5, + }, + }, + {"X-Test-Header": "test"}, + ) # Call the API method result = folders.find( identifier="abc-123", folder_id="folder-123", - query_params={"select": "id,name,total_count,unread_count"} + query_params={"select": "id,name,total_count,unread_count"}, ) # Verify API call From 5536a003f86c662ef5c1b16795e48d3e446580e0 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Wed, 18 Jun 2025 19:40:54 -0400 Subject: [PATCH 145/186] feat: add support for include_hidden_folders query parameter in list folders endpoint (#426) * feat: add support for include_hidden_folders query parameter in list folders endpoint * Fix conflicts, synchronize tests --------- Co-authored-by: Samuel Xavier <107475513+samuelpx@users.noreply.github.com> Co-authored-by: Samuel Xavier --- CHANGELOG.md | 1 + .../include_hidden_folders_demo/README.md | 67 +++++++++++++++++ .../include_hidden_folders_example.py | 71 +++++++++++++++++++ nylas/models/folders.py | 2 + tests/resources/test_folders.py | 40 ++++++++++- 5 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 examples/include_hidden_folders_demo/README.md create mode 100644 examples/include_hidden_folders_demo/include_hidden_folders_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 66d519ee..4a05e40f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Unreleased * Added `raw_mime` field to Message model for Base64url-encoded message data * Added TrackingOptions model for message tracking configuration * Maintained backwards compatibility for existing message functionality +* Added support for `include_hidden_folders` query parameter for listing folders (Microsoft only) v6.9.0 ---------------- diff --git a/examples/include_hidden_folders_demo/README.md b/examples/include_hidden_folders_demo/README.md new file mode 100644 index 00000000..dc1b99aa --- /dev/null +++ b/examples/include_hidden_folders_demo/README.md @@ -0,0 +1,67 @@ +# Include Hidden Folders Example + +This example demonstrates how to use the `include_hidden_folders` query parameter when listing folders with the Nylas Python SDK. + +## Overview + +The `include_hidden_folders` parameter is Microsoft-specific and allows you to include hidden folders in the folder listing response. By default, this parameter is `False` and hidden folders are not included. + +## Prerequisites + +1. A Nylas application with Microsoft OAuth configured +2. A valid Nylas API key +3. A grant ID for a Microsoft account + +## Setup + +1. Set your environment variables: + ```bash + export NYLAS_API_KEY="your_api_key_here" + export NYLAS_GRANT_ID="your_grant_id_here" + export NYLAS_API_URI="https://api.us.nylas.com" # Optional, defaults to US + ``` + +2. Install the Nylas Python SDK: + ```bash + pip install nylas + ``` + +## Running the Example + +```bash +python include_hidden_folders_example.py +``` + +## Code Explanation + +The example demonstrates two scenarios: + +1. **Default behavior**: Lists folders without hidden folders + ```python + folders_response = nylas.folders.list( + identifier=grant_id, + query_params={"limit": 10} + ) + ``` + +2. **With hidden folders**: Lists folders including hidden folders (Microsoft only) + ```python + folders_with_hidden_response = nylas.folders.list( + identifier=grant_id, + query_params={ + "include_hidden_folders": True, + "limit": 10 + } + ) + ``` + +## Expected Output + +The example will show: +- List of regular folders +- List of folders including hidden ones (if any) +- Comparison showing how many additional hidden folders were found + +## Note + +This feature is **Microsoft-specific only**. For other providers (Google, IMAP), the `include_hidden_folders` parameter will be ignored. \ No newline at end of file diff --git a/examples/include_hidden_folders_demo/include_hidden_folders_example.py b/examples/include_hidden_folders_demo/include_hidden_folders_example.py new file mode 100644 index 00000000..39f2226e --- /dev/null +++ b/examples/include_hidden_folders_demo/include_hidden_folders_example.py @@ -0,0 +1,71 @@ +import os +from nylas import Client + + +def main(): + """ + This example demonstrates how to use the include_hidden_folders parameter + when listing folders with the Nylas SDK. + + The include_hidden_folders parameter is Microsoft-specific and when set to True, + it includes hidden folders in the response. + """ + + # Initialize the client + nylas = Client( + api_key=os.environ.get("NYLAS_API_KEY"), + api_uri=os.environ.get("NYLAS_API_URI", "https://api.us.nylas.com"), + ) + + # Get the grant ID from environment variable + grant_id = os.environ.get("NYLAS_GRANT_ID") + + if not grant_id: + print("Please set the NYLAS_GRANT_ID environment variable") + return + + try: + print("Listing folders without hidden folders (default behavior):") + print("=" * 60) + + # List folders without hidden folders (default) + folders_response = nylas.folders.list( + identifier=grant_id, query_params={"limit": 10} + ) + + for folder in folders_response.data: + print(f"- {folder.name} (ID: {folder.id})") + + print(f"\nTotal folders found: {len(folders_response.data)}") + + # Now list folders WITH hidden folders (Microsoft only) + print("\n\nListing folders with hidden folders included (Microsoft only):") + print("=" * 70) + + folders_with_hidden_response = nylas.folders.list( + identifier=grant_id, + query_params={"include_hidden_folders": True, "limit": 10}, + ) + + for folder in folders_with_hidden_response.data: + print(f"- {folder.name} (ID: {folder.id})") + + print( + f"\nTotal folders found (including hidden): {len(folders_with_hidden_response.data)}" + ) + + # Compare the counts + hidden_count = len(folders_with_hidden_response.data) - len( + folders_response.data + ) + if hidden_count > 0: + print(f"\nFound {hidden_count} additional hidden folder(s)") + else: + print("\nNo additional hidden folders found") + + except Exception as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + main() diff --git a/nylas/models/folders.py b/nylas/models/folders.py index 0d5e979c..5eac55f3 100644 --- a/nylas/models/folders.py +++ b/nylas/models/folders.py @@ -85,6 +85,7 @@ class ListFolderQueryParams(ListQueryParams): Attributes: parent_id: (Microsoft and EWS only.) Use the ID of a folder to find all child folders it contains. + include_hidden_folders: (Microsoft only) When true, Nylas includes hidden folders in its response. single_level: (Microsoft only) If true, retrieves folders from a single-level hierarchy only. If false, retrieves folders across a multi-level hierarchy. Defaults to false. select (NotRequired[str]): Comma-separated list of fields to return in the response. @@ -96,6 +97,7 @@ class ListFolderQueryParams(ListQueryParams): """ parent_id: NotRequired[str] + include_hidden_folders: NotRequired[bool] single_level: NotRequired[bool] diff --git a/tests/resources/test_folders.py b/tests/resources/test_folders.py index 021e7b48..6da3cce0 100644 --- a/tests/resources/test_folders.py +++ b/tests/resources/test_folders.py @@ -58,6 +58,15 @@ def test_list_folders_with_query_params(self, http_client_list_response): overrides=None, ) + def test_list_folders_with_include_hidden_folders_param( + self, http_client_list_response + ): + folders = Folders(http_client_list_response) + + folders.list( + identifier="abc-123", query_params={"include_hidden_folders": True} + ) + def test_list_folders_with_single_level_param(self, http_client_list_response): folders = Folders(http_client_list_response) @@ -72,6 +81,24 @@ def test_list_folders_with_single_level_param(self, http_client_list_response): overrides=None, ) + def test_list_folders_with_include_hidden_folders_false( + self, http_client_list_response + ): + folders = Folders(http_client_list_response) + + folders.list( + identifier="abc-123", query_params={"include_hidden_folders": False} + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/folders", + None, + {"include_hidden_folders": False}, + None, + overrides=None, + ) + def test_list_folders_with_single_level_false(self, http_client_list_response): folders = Folders(http_client_list_response) @@ -86,19 +113,26 @@ def test_list_folders_with_single_level_false(self, http_client_list_response): overrides=None, ) - def test_list_folders_with_combined_params(self, http_client_list_response): + def test_list_folders_with_multiple_params_including_hidden_folders( + self, http_client_list_response + ): folders = Folders(http_client_list_response) folders.list( identifier="abc-123", - query_params={"single_level": True, "parent_id": "parent-123", "limit": 10}, + query_params={ + "limit": 20, + "parent_id": "parent-123", + "include_hidden_folders": True, + "single_level": True, + }, ) http_client_list_response._execute.assert_called_once_with( "GET", "/v3/grants/abc-123/folders", None, - {"single_level": True, "parent_id": "parent-123", "limit": 10}, + {"limit": 20, "parent_id": "parent-123", "include_hidden_folders": True, "single_level": True}, None, overrides=None, ) From 9bd4e6c8d7cfec8c47e2c28a54d9363419d40fd4 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Fri, 20 Jun 2025 09:41:12 -0400 Subject: [PATCH 146/186] Release v6.10.0 (#427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Release v6.10.0 * Bump version: 6.9.0 โ†’ 6.10.0 --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ef961e93..70c91c0e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.9.0 +current_version = 6.10.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a05e40f..a34e8183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.10.0 ---------------- * Added support for `single_level` query parameter in `ListFolderQueryParams` for Microsoft accounts to control folder hierarchy traversal * Added support for `earliest_message_date` query parameter for threads diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 94a322c3..1a65cb3e 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.9.0" +__VERSION__ = "6.10.0" From fd8a7974cf1c105198165174e22b6ff1e71db262 Mon Sep 17 00:00:00 2001 From: Samuel Xavier <107475513+samuelpx@users.noreply.github.com> Date: Tue, 29 Jul 2025 16:06:57 -0300 Subject: [PATCH 147/186] Added 'unknown' to ConferencingProvider (#429) --- nylas/models/events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nylas/models/events.py b/nylas/models/events.py index 79569bb5..0e7929a4 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -164,7 +164,7 @@ def _decode_when(when: dict) -> When: ConferencingProvider = Literal[ - "Google Meet", "Zoom Meeting", "Microsoft Teams", "GoToMeeting", "WebEx" + "Google Meet", "Zoom Meeting", "Microsoft Teams", "GoToMeeting", "WebEx", "unknown" ] """ Literal for the different conferencing providers. """ From b5c26f5f3d7631c9bb12779ef764f10074d00519 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 5 Aug 2025 14:37:11 -0400 Subject: [PATCH 148/186] Release v6.11.0 (#431) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a34e8183..8d64ebd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +v6.11.0 +---------------- +* Added `unknown` to ConferencingProvider + v6.10.0 ---------------- * Added support for `single_level` query parameter in `ListFolderQueryParams` for Microsoft accounts to control folder hierarchy traversal From bc8b7739878eb94640ab77ed07c344a3c8866713 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Mon, 18 Aug 2025 09:27:19 -0400 Subject: [PATCH 149/186] fix: handle empty or incomplete conferencing objects in events (#432) * fix: handle empty or incomplete conferencing objects in events - Fixed KeyError when processing events with empty conferencing objects ({}) - Fixed KeyError when conferencing details missing required provider field - Fixed KeyError when conferencing autocreate missing required provider field - Added comprehensive backwards compatibility tests for edge cases - Maintains existing behavior for valid conferencing objects - Returns None for malformed conferencing data instead of raising errors Resolves issue where SDK version 6.11.0 would throw KeyError when processing events containing empty or incomplete conferencing objects. * style: fix pylint issues in _decode_conferencing function - Remove unnecessary else statements after return (no-else-return) - Remove trailing whitespace (trailing-whitespace) - Reduce number of return statements to comply with complexity limits (too-many-return-statements) - Consolidate conditional logic for better readability - Maintain identical functionality and test coverage --- CHANGELOG.md | 4 ++ nylas/models/events.py | 16 +++-- tests/resources/test_events.py | 110 +++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d64ebd4..e32f08de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------- +* Fixed KeyError when processing events with empty or incomplete conferencing objects + v6.11.0 ---------------- * Added `unknown` to ConferencingProvider diff --git a/nylas/models/events.py b/nylas/models/events.py index 0e7929a4..118eeaf2 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -227,21 +227,23 @@ class Autocreate: def _decode_conferencing(conferencing: dict) -> Union[Conferencing, None]: """ - Decode a when object into a When object. + Decode a conferencing object into a Conferencing object. Args: - when: The when object to decode. + conferencing: The conferencing object to decode. Returns: - The decoded When object. + The decoded Conferencing object, or None if empty or incomplete. """ if not conferencing: return None - if "details" in conferencing: + # Handle details case - must have provider to be valid + if "details" in conferencing and "provider" in conferencing: return Details.from_dict(conferencing) - if "autocreate" in conferencing: + # Handle autocreate case - must have provider to be valid + if "autocreate" in conferencing and "provider" in conferencing: return Autocreate.from_dict(conferencing) # Handle case where provider exists but details/autocreate doesn't @@ -257,7 +259,9 @@ def _decode_conferencing(conferencing: dict) -> Union[Conferencing, None]: } return Details.from_dict(details_dict) - raise ValueError(f"Invalid conferencing object, unknown type found: {conferencing}") + # Handle unknown or incomplete conferencing objects by returning None + # This provides backwards compatibility for malformed conferencing data + return None @dataclass_json diff --git a/tests/resources/test_events.py b/tests/resources/test_events.py index 25aba856..14e62886 100644 --- a/tests/resources/test_events.py +++ b/tests/resources/test_events.py @@ -550,3 +550,113 @@ def test_update_event_with_notetaker(self, http_client_response): request_body, overrides=None, ) + + def test_event_with_empty_conferencing_deserialization(self): + """Test event deserialization with empty conferencing object.""" + event_json = { + "id": "test-event-id", + "grant_id": "test-grant-id", + "calendar_id": "test-calendar-id", + "busy": True, + "participants": [ + {"email": "test@example.com", "name": "Test User", "status": "yes"} + ], + "when": { + "start_time": 1497916800, + "end_time": 1497920400, + "object": "timespan" + }, + "conferencing": {}, # Empty conferencing object + "title": "Test Event with Empty Conferencing" + } + + event = Event.from_dict(event_json) + + assert event.id == "test-event-id" + assert event.title == "Test Event with Empty Conferencing" + assert event.conferencing is None + + def test_event_with_incomplete_conferencing_details_deserialization(self): + """Test event deserialization with conferencing details missing provider.""" + event_json = { + "id": "test-event-id", + "grant_id": "test-grant-id", + "calendar_id": "test-calendar-id", + "busy": True, + "participants": [ + {"email": "test@example.com", "name": "Test User", "status": "yes"} + ], + "when": { + "start_time": 1497916800, + "end_time": 1497920400, + "object": "timespan" + }, + "conferencing": { + "details": { + "meeting_code": "code-123456", + "password": "password-123456", + "url": "https://zoom.us/j/1234567890?pwd=1234567890", + } + }, # Details without provider + "title": "Test Event with Incomplete Conferencing Details" + } + + event = Event.from_dict(event_json) + + assert event.id == "test-event-id" + assert event.title == "Test Event with Incomplete Conferencing Details" + assert event.conferencing is None + + def test_event_with_incomplete_conferencing_autocreate_deserialization(self): + """Test event deserialization with conferencing autocreate missing provider.""" + event_json = { + "id": "test-event-id", + "grant_id": "test-grant-id", + "calendar_id": "test-calendar-id", + "busy": True, + "participants": [ + {"email": "test@example.com", "name": "Test User", "status": "yes"} + ], + "when": { + "start_time": 1497916800, + "end_time": 1497920400, + "object": "timespan" + }, + "conferencing": { + "autocreate": {} + }, # Autocreate without provider + "title": "Test Event with Incomplete Conferencing Autocreate" + } + + event = Event.from_dict(event_json) + + assert event.id == "test-event-id" + assert event.title == "Test Event with Incomplete Conferencing Autocreate" + assert event.conferencing is None + + def test_event_with_unknown_conferencing_fields_deserialization(self): + """Test event deserialization with conferencing containing unknown fields.""" + event_json = { + "id": "test-event-id", + "grant_id": "test-grant-id", + "calendar_id": "test-calendar-id", + "busy": True, + "participants": [ + {"email": "test@example.com", "name": "Test User", "status": "yes"} + ], + "when": { + "start_time": 1497916800, + "end_time": 1497920400, + "object": "timespan" + }, + "conferencing": { + "unknown_field": "value" + }, # Unknown conferencing fields + "title": "Test Event with Unknown Conferencing Fields" + } + + event = Event.from_dict(event_json) + + assert event.id == "test-event-id" + assert event.title == "Test Event with Unknown Conferencing Fields" + assert event.conferencing is None From 23045b6e132a85d8badcbf881937dd643ebebedf Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Mon, 18 Aug 2025 09:40:38 -0400 Subject: [PATCH 150/186] Release v6.11.1 --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 70c91c0e..24bf55d9 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.10.0 +current_version = 6.11.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index e32f08de..1be11ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.11.1 ---------- * Fixed KeyError when processing events with empty or incomplete conferencing objects diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 1a65cb3e..aa567e47 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.10.0" +__VERSION__ = "6.11.1" From be711bfebc2cf394648ff0627a6fb1356d336994 Mon Sep 17 00:00:00 2001 From: Samuel Xavier <107475513+samuelpx@users.noreply.github.com> Date: Fri, 22 Aug 2025 10:46:08 -0300 Subject: [PATCH 151/186] CUST-3643 added yahoo, zoom, ews as providers on auth.py for completeness (#434) --- CHANGELOG.md | 4 ++++ nylas/models/auth.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be11ab8..e50fc314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------- +* Added Yahoo, Zoom, EWS as providers to models/auth.py + v6.11.1 ---------- * Fixed KeyError when processing events with empty or incomplete conferencing objects diff --git a/nylas/models/auth.py b/nylas/models/auth.py index e073fb0e..9146796c 100644 --- a/nylas/models/auth.py +++ b/nylas/models/auth.py @@ -7,7 +7,7 @@ AccessType = Literal["online", "offline"] """ Literal for the access type of the authentication URL. """ -Provider = Literal["google", "imap", "microsoft", "icloud", "virtual-calendar"] +Provider = Literal["google", "imap", "microsoft", "icloud", "virtual-calendar", "yahoo", "ews", "zoom"] """ Literal for the different authentication providers. """ Prompt = Literal[ From cc8fcc14972d85629702c4a8265d0da633584744 Mon Sep 17 00:00:00 2001 From: Samuel Xavier <107475513+samuelpx@users.noreply.github.com> Date: Mon, 25 Aug 2025 22:45:16 -0300 Subject: [PATCH 152/186] CUST-4719 Fixed grants.update() not using PATCH, fixed tests) (#435) --- CHANGELOG.md | 1 + nylas/resources/grants.py | 1 + tests/resources/test_grants.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e50fc314..33f1e2c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------- * Added Yahoo, Zoom, EWS as providers to models/auth.py +* Fixed grants.update() not using the correct "PATCH" method v6.11.1 ---------- diff --git a/nylas/resources/grants.py b/nylas/resources/grants.py index 5f6e0266..06a951bc 100644 --- a/nylas/resources/grants.py +++ b/nylas/resources/grants.py @@ -92,6 +92,7 @@ def update( response_type=Grant, request_body=request_body, overrides=overrides, + method="PATCH" ) def destroy( diff --git a/tests/resources/test_grants.py b/tests/resources/test_grants.py index 9dfcef94..e7d61d32 100644 --- a/tests/resources/test_grants.py +++ b/tests/resources/test_grants.py @@ -67,7 +67,7 @@ def test_update_grant(self, http_client_response): ) http_client_response._execute.assert_called_once_with( - "PUT", "/v3/grants/grant-123", None, None, request_body, overrides=None + "PATCH", "/v3/grants/grant-123", None, None, request_body, overrides=None ) def test_destroy_grant(self, http_client_delete_response): From bf0f8ebbb94d6899126a1803c8d55b245b665c44 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Thu, 28 Aug 2025 14:29:03 -0400 Subject: [PATCH 153/186] feat: add is_plaintext support for messages send and drafts create endpoints (#436) - Add is_plaintext property to CreateDraftRequest and SendMessageRequest models - Property defaults to false (HTML) for backwards compatibility - When true, sends message body as plain text without HTML in MIME data - When false, sends message body as HTML with full MIME formatting - Include comprehensive test coverage for both endpoints - Add example demonstrating usage in examples/is_plaintext_demo/ - Update CHANGELOG.md with new feature --- CHANGELOG.md | 1 + examples/is_plaintext_demo/README.md | 156 ++++++++++ .../is_plaintext_demo/is_plaintext_example.py | 294 ++++++++++++++++++ nylas/models/drafts.py | 5 + tests/resources/test_drafts.py | 63 ++++ tests/resources/test_messages.py | 62 +++- 6 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 examples/is_plaintext_demo/README.md create mode 100644 examples/is_plaintext_demo/is_plaintext_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 33f1e2c7..08f132a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Unreleased ---------- * Added Yahoo, Zoom, EWS as providers to models/auth.py * Fixed grants.update() not using the correct "PATCH" method +* Added support for `is_plaintext` property in messages send and drafts create endpoints v6.11.1 ---------- diff --git a/examples/is_plaintext_demo/README.md b/examples/is_plaintext_demo/README.md new file mode 100644 index 00000000..da0d31b1 --- /dev/null +++ b/examples/is_plaintext_demo/README.md @@ -0,0 +1,156 @@ +# is_plaintext Demo + +This example demonstrates the usage of the new `is_plaintext` property for messages and drafts in the Nylas API. This property controls whether message content is sent as plain text or HTML in the MIME data. + +## Features Demonstrated + +1. **Plain Text Messages**: Shows how to send messages with `is_plaintext=True` to send content as plain text without HTML in MIME data. +2. **HTML Messages**: Demonstrates sending messages with `is_plaintext=False` to include HTML formatting in MIME data. +3. **Backwards Compatibility**: Shows that existing code continues to work without specifying the `is_plaintext` property. +4. **Draft Operations**: Demonstrates using `is_plaintext` with draft creation and updates. + +## API Property Overview + +### is_plaintext + +- **Type**: `boolean` +- **Default**: `false` +- **Available in**: + - `messages.send()` - Send message endpoint + - `drafts.create()` - Create draft endpoint + - `drafts.update()` - Update draft endpoint + +When `is_plaintext` is: +- `true`: The message body is sent as plain text and the MIME data doesn't include the HTML version of the message +- `false`: The message body is sent as HTML and MIME data includes HTML formatting +- Not specified: Uses API default behavior (same as `false`) + +## Setup + +1. Install the SDK in development mode from the repository root: +```bash +cd /path/to/nylas-python +pip install -e . +``` + +2. Set your environment variables: +```bash +export NYLAS_API_KEY="your_api_key" +export NYLAS_GRANT_ID="your_grant_id" +``` + +3. Run the example from the repository root: +```bash +python examples/is_plaintext_demo/is_plaintext_example.py +``` + +## Example Output + +``` +Demonstrating is_plaintext Property Usage +======================================= + +=== Sending Plain Text Message === +Sending message with is_plaintext=True... +โœ“ Message request prepared with is_plaintext=True +๐Ÿ“ง Message configured to be sent as plain text (MIME without HTML version) + +=== Sending HTML Message === +Sending message with is_plaintext=False (HTML)... +โœ“ Message request prepared with is_plaintext=False +๐ŸŒ Message configured to be sent as HTML (MIME includes HTML version) + +=== Backwards Compatibility (No is_plaintext specified) === +Sending message without is_plaintext property... +โœ“ Existing code continues to work without modification + +=== Creating Plain Text Draft === +Creating draft with is_plaintext=True... +โœ“ Draft request prepared with is_plaintext=True +๐Ÿ“ Draft configured to be sent as plain text when sent + +Example completed successfully! +``` + +## Use Cases + +### Plain Text (is_plaintext=true) +- **Simple Notifications**: System alerts, password resets, account confirmations +- **Text-Only Emails**: Newsletters or announcements that don't need formatting +- **Lightweight Messaging**: Reduce message size and improve compatibility +- **Accessibility**: Better support for screen readers and text-only email clients + +### HTML (is_plaintext=false) +- **Marketing Emails**: Rich formatting, images, and branded content +- **Newsletters**: Complex layouts with multiple sections and styling +- **Transactional Emails**: Formatted receipts, invoices, and reports +- **Interactive Content**: Buttons, links, and styled call-to-action elements + +## Code Examples + +### Send Plain Text Message +```python +message_request = { + "to": [{"email": "user@example.com", "name": "User"}], + "subject": "Plain Text Notification", + "body": "This is a plain text message.", + "is_plaintext": True +} + +response = client.messages.send( + identifier=grant_id, + request_body=message_request +) +``` + +### Send HTML Message +```python +message_request = { + "to": [{"email": "user@example.com", "name": "User"}], + "subject": "HTML Newsletter", + "body": "

Welcome!

This is HTML content.

", + "is_plaintext": False +} + +response = client.messages.send( + identifier=grant_id, + request_body=message_request +) +``` + +### Create Plain Text Draft +```python +draft_request = { + "to": [{"email": "user@example.com", "name": "User"}], + "subject": "Draft Message", + "body": "This draft will be sent as plain text.", + "is_plaintext": True +} + +response = client.drafts.create( + identifier=grant_id, + request_body=draft_request +) +``` + +## Important Notes + +- **Backwards Compatibility**: Existing code without `is_plaintext` continues to work unchanged +- **Default Behavior**: When `is_plaintext` is not specified, it defaults to `false` (HTML) +- **Content Type**: The property affects MIME structure, not just content rendering +- **Safety**: The example includes commented API calls to prevent unintended message sends + +## Error Handling + +The example includes proper error handling for: +- Missing environment variables +- API authentication errors +- Invalid request parameters +- Network connectivity issues + +## Documentation + +For more information about the Nylas Python SDK and message properties, visit: +- [Nylas Python SDK Documentation](https://developer.nylas.com/docs/sdks/python/) +- [Nylas API Messages Reference](https://developer.nylas.com/docs/api/v3/ecc/#tag--Messages) +- [Nylas API Drafts Reference](https://developer.nylas.com/docs/api/v3/ecc/#tag--Drafts) diff --git a/examples/is_plaintext_demo/is_plaintext_example.py b/examples/is_plaintext_demo/is_plaintext_example.py new file mode 100644 index 00000000..3b3a1c23 --- /dev/null +++ b/examples/is_plaintext_demo/is_plaintext_example.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Using is_plaintext for Messages and Drafts + +This example demonstrates how to use the new 'is_plaintext' property when sending +messages and creating drafts to control whether content is sent as plain text or HTML. + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID + +Usage: + First, install the SDK in development mode: + cd /path/to/nylas-python + pip install -e . + + Then set environment variables and run: + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + python examples/is_plaintext_demo/is_plaintext_example.py +""" + +import os +import sys +from nylas import Client + + +def get_env_or_exit(var_name: str) -> str: + """Get an environment variable or exit if not found.""" + value = os.getenv(var_name) + if not value: + print(f"Error: {var_name} environment variable is required") + sys.exit(1) + return value + + +def print_separator(title: str) -> None: + """Print a formatted section separator.""" + print(f"\n=== {title} ===") + + +def demonstrate_plaintext_message(client: Client, grant_id: str) -> None: + """Demonstrate sending a message with is_plaintext=True.""" + print_separator("Sending Plain Text Message") + + try: + print("Sending message with is_plaintext=True...") + + # Example message content with HTML tags that will be sent as plain text + body_content = """Hello World! + +This is a test message sent as plain text. +Even if this contained HTML tags, they would be sent as plain text. + +Best regards, +The Nylas SDK Team""" + + # Send message with is_plaintext=True + message_request = { + "to": [{"email": "test@example.com", "name": "Test Recipient"}], + "subject": "Plain Text Message Example", + "body": body_content, + "is_plaintext": True + } + + print("โœ“ Message request prepared with is_plaintext=True") + print(f" Subject: {message_request['subject']}") + print(f" Body preview: {body_content[:100]}...") + print(f" is_plaintext: {message_request['is_plaintext']}") + + # Note: Uncomment the following line to actually send the message + # response = client.messages.send(identifier=grant_id, request_body=message_request) + # print(f"โœ“ Message sent! ID: {response.data.id}") + + print("๐Ÿ“ง Message configured to be sent as plain text (MIME without HTML version)") + + except Exception as e: + print(f"โŒ Error sending plain text message: {e}") + + +def demonstrate_html_message(client: Client, grant_id: str) -> None: + """Demonstrate sending a message with is_plaintext=False (HTML).""" + print_separator("Sending HTML Message") + + try: + print("Sending message with is_plaintext=False (HTML)...") + + # Example message content with HTML formatting + body_content = """ + +

Hello World!

+ +

This is a test message sent as HTML.

+ +

The HTML tags will be properly rendered:

+ + +

Best regards,
+ The Nylas SDK Team

+ +""" + + # Send message with is_plaintext=False (default behavior) + message_request = { + "to": [{"email": "test@example.com", "name": "Test Recipient"}], + "subject": "HTML Message Example", + "body": body_content, + "is_plaintext": False + } + + print("โœ“ Message request prepared with is_plaintext=False") + print(f" Subject: {message_request['subject']}") + print(f" HTML body includes formatting tags") + print(f" is_plaintext: {message_request['is_plaintext']}") + + # Note: Uncomment the following line to actually send the message + # response = client.messages.send(identifier=grant_id, request_body=message_request) + # print(f"โœ“ Message sent! ID: {response.data.id}") + + print("๐ŸŒ Message configured to be sent as HTML (MIME includes HTML version)") + + except Exception as e: + print(f"โŒ Error sending HTML message: {e}") + + +def demonstrate_backwards_compatibility(client: Client, grant_id: str) -> None: + """Demonstrate that existing code without is_plaintext still works.""" + print_separator("Backwards Compatibility (No is_plaintext specified)") + + try: + print("Sending message without is_plaintext property...") + + # Traditional message request without is_plaintext + message_request = { + "to": [{"email": "test@example.com", "name": "Test Recipient"}], + "subject": "Traditional Message Example", + "body": "This message doesn't specify is_plaintext, so it uses the default behavior." + } + + print("โœ“ Message request prepared without is_plaintext property") + print(f" Subject: {message_request['subject']}") + print(f" Body: {message_request['body']}") + print(f" is_plaintext: Not specified (uses API default)") + + # Note: Uncomment the following line to actually send the message + # response = client.messages.send(identifier=grant_id, request_body=message_request) + # print(f"โœ“ Message sent! ID: {response.data.id}") + + print("โœ“ Existing code continues to work without modification") + + except Exception as e: + print(f"โŒ Error sending traditional message: {e}") + + +def demonstrate_plaintext_draft(client: Client, grant_id: str) -> None: + """Demonstrate creating a draft with is_plaintext=True.""" + print_separator("Creating Plain Text Draft") + + try: + print("Creating draft with is_plaintext=True...") + + draft_request = { + "to": [{"email": "test@example.com", "name": "Test Recipient"}], + "subject": "Plain Text Draft Example", + "body": "This is a draft that will be sent as plain text when sent.", + "is_plaintext": True + } + + print("โœ“ Draft request prepared with is_plaintext=True") + print(f" Subject: {draft_request['subject']}") + print(f" Body: {draft_request['body']}") + print(f" is_plaintext: {draft_request['is_plaintext']}") + + # Note: Uncomment the following lines to actually create the draft + # response = client.drafts.create(identifier=grant_id, request_body=draft_request) + # print(f"โœ“ Draft created! ID: {response.data.id}") + + print("๐Ÿ“ Draft configured to be sent as plain text when sent") + + except Exception as e: + print(f"โŒ Error creating plain text draft: {e}") + + +def demonstrate_html_draft(client: Client, grant_id: str) -> None: + """Demonstrate creating a draft with is_plaintext=False.""" + print_separator("Creating HTML Draft") + + try: + print("Creating draft with is_plaintext=False...") + + html_body = """ + +

Draft Message

+

This is a draft with HTML formatting.

+

It will include HTML in the MIME data when sent.

+ +""" + + draft_request = { + "to": [{"email": "test@example.com", "name": "Test Recipient"}], + "subject": "HTML Draft Example", + "body": html_body, + "is_plaintext": False + } + + print("โœ“ Draft request prepared with is_plaintext=False") + print(f" Subject: {draft_request['subject']}") + print(f" HTML body includes formatting") + print(f" is_plaintext: {draft_request['is_plaintext']}") + + # Note: Uncomment the following lines to actually create the draft + # response = client.drafts.create(identifier=grant_id, request_body=draft_request) + # print(f"โœ“ Draft created! ID: {response.data.id}") + + print("๐Ÿ“ Draft configured to be sent as HTML when sent") + + except Exception as e: + print(f"โŒ Error creating HTML draft: {e}") + + +def demonstrate_draft_update(client: Client, grant_id: str) -> None: + """Demonstrate updating a draft with is_plaintext property.""" + print_separator("Updating Draft with is_plaintext") + + try: + print("Demonstrating draft update with is_plaintext...") + + # Example update request + update_request = { + "subject": "Updated Draft with Plain Text", + "body": "This draft has been updated to use plain text format.", + "is_plaintext": True + } + + print("โœ“ Draft update request prepared with is_plaintext=True") + print(f" Updated subject: {update_request['subject']}") + print(f" Updated body: {update_request['body']}") + print(f" is_plaintext: {update_request['is_plaintext']}") + + # Note: Uncomment the following lines to actually update a draft + # draft_id = "your_draft_id_here" + # response = client.drafts.update( + # identifier=grant_id, + # draft_id=draft_id, + # request_body=update_request + # ) + # print(f"โœ“ Draft updated! ID: {response.data.id}") + + print("๐Ÿ“ Draft update includes is_plaintext configuration") + + except Exception as e: + print(f"โŒ Error updating draft: {e}") + + +def main(): + """Main function demonstrating is_plaintext usage.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + + # Initialize Nylas client + client = Client(api_key=api_key) + + print("Demonstrating is_plaintext Property Usage") + print("=======================================") + print("This shows the new 'is_plaintext' property for messages and drafts") + print("Note: Actual API calls are commented out to prevent unintended sends") + + # Demonstrate message sending with different is_plaintext values + demonstrate_plaintext_message(client, grant_id) + demonstrate_html_message(client, grant_id) + demonstrate_backwards_compatibility(client, grant_id) + + # Demonstrate draft creation and updating with is_plaintext + demonstrate_plaintext_draft(client, grant_id) + demonstrate_html_draft(client, grant_id) + demonstrate_draft_update(client, grant_id) + + print("\n" + "="*60) + print("Example completed successfully!") + print("="*60) + print("\n๐Ÿ’ก Key Takeaways:") + print("โ€ข is_plaintext=True: Sends content as plain text (no HTML in MIME)") + print("โ€ข is_plaintext=False: Sends content as HTML (includes HTML in MIME)") + print("โ€ข Not specified: Uses API default behavior (backwards compatible)") + print("โ€ข Available in: messages.send(), drafts.create(), drafts.update()") + + +if __name__ == "__main__": + main() diff --git a/nylas/models/drafts.py b/nylas/models/drafts.py index 34361bd5..3c8a8c28 100644 --- a/nylas/models/drafts.py +++ b/nylas/models/drafts.py @@ -88,6 +88,8 @@ class CreateDraftRequest(TypedDict): tracking_options: Options for tracking opens, links, and thread replies. custom_headers: Custom headers to add to the message. metadata: A dictionary of key-value pairs storing additional data. + is_plaintext: When true, the message body is sent as plain text and the MIME data doesn't include + the HTML version of the message. When false, the message body is sent as HTML. """ body: NotRequired[str] @@ -103,6 +105,7 @@ class CreateDraftRequest(TypedDict): tracking_options: NotRequired[TrackingOptions] custom_headers: NotRequired[List[CustomHeader]] metadata: NotRequired[Dict[str, Any]] + is_plaintext: NotRequired[bool] UpdateDraftRequest = CreateDraftRequest @@ -178,6 +181,8 @@ class SendMessageRequest(CreateDraftRequest): reply_to_message_id (NotRequired[str]): The ID of the message that you are replying to. tracking_options (NotRequired[TrackingOptions]): Options for tracking opens, links, and thread replies. custom_headers(NotRequired[List[CustomHeader]]): Custom headers to add to the message. + is_plaintext (NotRequired[bool]): When true, the message body is sent as plain text and the MIME data + doesn't include the HTML version of the message. When false, the message body is sent as HTML. from_: The sender of the message. use_draft: Whether or not to use draft support. This is primarily used when dealing with large attachments. """ diff --git a/tests/resources/test_drafts.py b/tests/resources/test_drafts.py index 8613a2fc..b0e9fe54 100644 --- a/tests/resources/test_drafts.py +++ b/tests/resources/test_drafts.py @@ -403,3 +403,66 @@ def test_send_draft_encoded_id(self, http_client_response): path="/v3/grants/abc-123/drafts/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", overrides=None, ) + + def test_create_draft_with_is_plaintext_true(self, http_client_response): + """Test creating a draft with is_plaintext=True.""" + drafts = Drafts(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my draft message.", + "is_plaintext": True, + } + + drafts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/drafts", + None, + None, + request_body, + overrides=None, + ) + + def test_create_draft_with_is_plaintext_false(self, http_client_response): + """Test creating a draft with is_plaintext=False.""" + drafts = Drafts(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my draft message.", + "is_plaintext": False, + } + + drafts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/drafts", + None, + None, + request_body, + overrides=None, + ) + + def test_create_draft_without_is_plaintext_backwards_compatibility(self, http_client_response): + """Test that existing code without is_plaintext still works (backwards compatibility).""" + drafts = Drafts(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my draft message.", + } + + # Should work without any issues + drafts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/drafts", + None, + None, + request_body, + overrides=None, + ) diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 50f071c1..bdf67606 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -889,4 +889,64 @@ def test_tracking_options_serialization(self): assert tracking_options_from_dict.opens is True assert tracking_options_from_dict.thread_replies is False assert tracking_options_from_dict.links is True - assert tracking_options_from_dict.label == "Test Campaign" \ No newline at end of file + assert tracking_options_from_dict.label == "Test Campaign" + + def test_send_message_with_is_plaintext_true(self, http_client_response): + """Test sending a message with is_plaintext=True.""" + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + "is_plaintext": True, + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + + def test_send_message_with_is_plaintext_false(self, http_client_response): + """Test sending a message with is_plaintext=False.""" + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + "is_plaintext": False, + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + + def test_send_message_without_is_plaintext_backwards_compatibility(self, http_client_response): + """Test that existing code without is_plaintext still works (backwards compatibility).""" + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + } + + # Should work without any issues + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) \ No newline at end of file From 3fc4279b6d87554904c3015a0054c24f4317b457 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:32:26 -0400 Subject: [PATCH 154/186] Release v6.12.0 release (#437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Release v6.12.0 * Bump version: 6.11.1 โ†’ 6.12.0 --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 24bf55d9..32e0e248 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.11.1 +current_version = 6.12.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 08f132a7..3d31825b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.12.0 ---------- * Added Yahoo, Zoom, EWS as providers to models/auth.py * Fixed grants.update() not using the correct "PATCH" method diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index aa567e47..54746723 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.11.1" +__VERSION__ = "6.12.0" From a8cd1f3047c72177923e4caddc857ad93a714bb7 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 2 Sep 2025 18:33:45 -0400 Subject: [PATCH 155/186] Improved the contributing to explain how to get the SDK running (#438) --- Contributing.md | 145 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/Contributing.md b/Contributing.md index 3954c295..1a25264f 100644 --- a/Contributing.md +++ b/Contributing.md @@ -3,6 +3,151 @@ The following is a set of guidelines for contributing to the Nylas Python SDK; these are guidelines, not rules, so please use your best judgement and feel free to propose changes to this document via pull request. +# Development Setup + +To get started contributing to this repository, you'll need to set up a local development environment. Follow these steps: + +## Prerequisites + +- Python 3.8 or higher (the project supports Python 3.8+) +- Git +- A GitHub account + +## Setup Steps + +### 1. Fork and Clone the Repository + +```bash +# Fork the repository on GitHub, then clone your fork +git clone https://github.com/YOUR_USERNAME/nylas-python.git +cd nylas-python + +# Add the upstream repository as a remote +git remote add upstream https://github.com/nylas/nylas-python.git +``` + +### 2. Set Up Python Virtual Environment + +We recommend using a virtual environment to isolate your development dependencies: + +```bash +# Create a virtual environment +python3 -m venv .venv + +# Activate the virtual environment +# On macOS/Linux: +source .venv/bin/activate + +# On Windows: +# .venv\Scripts\activate +``` + +### 3. Install Development Dependencies + +Install the package in editable mode with all optional dependencies: + +```bash +# Install the package in development mode with all optional dependencies +pip install -e ".[test,docs,release]" + +# Or install specific dependency groups as needed: +# pip install -e ".[test]" # For running tests +# pip install -e ".[docs]" # For building documentation +# pip install -e ".[release]" # For release management +``` + +### 4. Install Code Quality Tools + +Install the linting and formatting tools used by the project: + +```bash +pip install pylint black +``` + +### 5. Verify Your Setup + +Run the tests to make sure everything is working correctly: + +```bash +# Run the test suite +python setup.py test + +# Or run tests with pytest directly +pytest + +# Run with coverage +pytest --cov=nylas tests/ +``` + +Check code formatting and linting: + +```bash +# Check code formatting (this will modify files) +black . + +# Run linting +pylint nylas +``` + +## Development Workflow + +1. **Create a branch** for your feature or bug fix: + ```bash + git checkout -b your-feature-branch + ``` + +2. **Make your changes** and write tests for any new functionality + +3. **Run tests and linting**: + ```bash + python setup.py test + black . + pylint nylas + ``` + +4. **Commit your changes** following [conventional commit practices](https://www.conventionalcommits.org/) + +5. **Push to your fork** and create a pull request + +## Project Structure + +- `nylas/` - Main SDK source code +- `tests/` - Test files +- `examples/` - Example usage scripts +- `scripts/` - Build and development scripts +- `pyproject.toml` - Project configuration and dependencies +- `setup.py` - Legacy setup file (still used for some operations) + +## Running Tests + +The project uses pytest for testing: + +```bash +# Run all tests +pytest + +# Run tests with coverage +pytest --cov=nylas tests/ + +# Run specific test files +pytest tests/test_specific_module.py + +# Run tests matching a pattern +pytest -k "test_pattern" +``` + +## Documentation + +To build the documentation locally: + +```bash +# Make sure you have docs dependencies installed +pip install -e ".[docs]" + +# Generate documentation (if there are scripts for this) +python scripts/generate-docs.py +``` + # How to Ask a Question If you have a question about how to use the Python SDK, please [create an issue](https://github.com/nylas/nylas-python/issues) and label it as a question. If you have more general questions about the Nylas Communications Platform, or the Nylas Email, Calendar, and Contacts API, please reach out to support@nylas.com to get help. From 6c71c7940764f2f024289f4330728174d51a4aeb Mon Sep 17 00:00:00 2001 From: samLRodrigues Date: Mon, 22 Sep 2025 16:12:15 -0300 Subject: [PATCH 156/186] CUST-4797 from field handles 'from' and 'from_' --- nylas/resources/messages.py | 6 +- tests/resources/test_messages.py | 122 ++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index a61f037f..d544cee8 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -170,7 +170,11 @@ def send( json_body = None # From is a reserved keyword in Python, so we need to pull the data from 'from_' instead - request_body["from"] = request_body.get("from_", None) + # Handle both dictionary-style "from" and typed "from_" field + if "from_" in request_body and "from" not in request_body: + request_body["from"] = request_body["from_"] + del request_body["from_"] + # If "from" already exists, leave it unchanged # Use form data only if the attachment size is greater than 3mb attachment_size = sum( diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index bdf67606..1efe0aab 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -949,4 +949,124 @@ def test_send_message_without_is_plaintext_backwards_compatibility(self, http_cl request_body=request_body, data=None, overrides=None, - ) \ No newline at end of file + ) + + def test_send_message_with_from_field_mapping(self, http_client_response): + """Test that from_ field is properly mapped to from field in request body.""" + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + "from_": [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ], + } + + messages.send(identifier="abc-123", request_body=request_body) + + # Verify that from_ was mapped to from and from_ was removed + expected_request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + "from": [{"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}], + } + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=expected_request_body, + data=None, + overrides=None, + ) + + def test_send_message_with_existing_from_field_unchanged( + self, http_client_response + ): + """Test that existing from field is left unchanged when both from and from_ are present.""" + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + "from": [{"name": "Existing Sender", "email": "existing@example.com"}], + "from_": [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ], + } + + messages.send(identifier="abc-123", request_body=request_body) + + # Verify that the original from field is preserved and from_ is not processed + expected_request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + "from": [{"name": "Existing Sender", "email": "existing@example.com"}], + "from_": [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ], + } + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=expected_request_body, + data=None, + overrides=None, + ) + + def test_send_message_with_only_from_field_unchanged(self, http_client_response): + """Test that when only from field is present, it remains unchanged.""" + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + "from": [{"name": "Direct Sender", "email": "direct@example.com"}], + } + + messages.send(identifier="abc-123", request_body=request_body) + + # Verify that the from field remains unchanged + expected_request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + "from": [{"name": "Direct Sender", "email": "direct@example.com"}], + } + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=expected_request_body, + data=None, + overrides=None, + ) + + def test_send_message_without_from_fields_unchanged(self, http_client_response): + """Test that request body without from or from_ fields remains unchanged.""" + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + } + + messages.send(identifier="abc-123", request_body=request_body) + + # Verify that the request body remains unchanged + expected_request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + } + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=expected_request_body, + data=None, + overrides=None, + ) \ No newline at end of file From 8b18a36be7a007de5c15c85ab3163a824b72c685 Mon Sep 17 00:00:00 2001 From: samLRodrigues Date: Mon, 22 Sep 2025 16:37:03 -0300 Subject: [PATCH 157/186] CUST-4797: Add from field handling changes to CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d31825b..7a0d6dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ nylas-python Changelog ====================== +Unreleased +---------- +* Fixed from field handling in messages.send() to properly map "from_" field to "from field +* Added comprehensive tests for from field mapping functionality + v6.12.0 ---------- * Added Yahoo, Zoom, EWS as providers to models/auth.py From 5b8e2a6670b4b6d7ae4dd9b1445402da46aad728 Mon Sep 17 00:00:00 2001 From: samLRodrigues Date: Mon, 22 Sep 2025 16:37:56 -0300 Subject: [PATCH 158/186] CUST-4797: Add from field handling changes to CHANGELOG --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0d6dcb..348b2302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,6 @@ nylas-python Changelog Unreleased ---------- * Fixed from field handling in messages.send() to properly map "from_" field to "from field -* Added comprehensive tests for from field mapping functionality v6.12.0 ---------- From df2f0c97587716aeaa7959fa6af0fb3baf8dc5ed Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:26:02 -0400 Subject: [PATCH 159/186] [CUST-4448] Fix: content id not being respected for large inline attachments. (#440) --- CHANGELOG.md | 1 + examples/inline_attachment_demo/README.md | 65 ++++++ .../inline_attachment_example.py | 192 ++++++++++++++++++ nylas/utils/file_utils.py | 4 +- tests/utils/test_file_utils.py | 130 +++++++++++- 5 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 examples/inline_attachment_demo/README.md create mode 100644 examples/inline_attachment_demo/inline_attachment_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 348b2302..be2411d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------- * Fixed from field handling in messages.send() to properly map "from_" field to "from field +* Fixed content_id handling for large inline attachments to use content_id as field name instead of generic file{index} v6.12.0 ---------- diff --git a/examples/inline_attachment_demo/README.md b/examples/inline_attachment_demo/README.md new file mode 100644 index 00000000..5baaca0d --- /dev/null +++ b/examples/inline_attachment_demo/README.md @@ -0,0 +1,65 @@ +# Inline Attachment Example + +This example demonstrates how to send messages and drafts with inline attachments using the `content_id` field in the Nylas Python SDK. + +## What This Example Shows + +- How to create inline attachments with `content_id` for HTML emails +- How the SDK properly handles `content_id` for large attachments (>3MB) +- The difference between inline attachments and regular attachments +- How to reference inline attachments in HTML email bodies using `cid:` syntax + +## Key Features Demonstrated + +### Content ID Usage +When an attachment includes a `content_id` field, the SDK will use this as the field name in multipart form data instead of the generic `file{index}` pattern. This is crucial for inline attachments that need to be referenced in the email body. + +### HTML Email with Inline Images +The example shows how to: +1. Set the `content_id` field in the attachment +2. Reference the attachment in HTML using `src="cid:your-content-id"` +3. Set appropriate inline properties (`is_inline: True`, `content_disposition: "inline"`) + +### Large Attachment Handling +For attachments larger than 3MB, the SDK automatically switches from JSON to multipart form data. With this fix, the `content_id` is now properly respected in the form field names. + +## Running the Example + +1. Set your Nylas API key: + ```bash + export NYLAS_API_KEY='your-api-key-here' + ``` + +2. Update the grant ID and email addresses in the script + +3. Run the example: + ```bash + python inline_attachment_example.py + ``` + +## Important Notes + +- **Content ID Format**: Use a unique identifier for each inline attachment (e.g., `"image1@example.com"`, `"logo"`, `"banner-image"`) +- **HTML Reference**: Reference inline attachments in HTML using `src="cid:your-content-id"` +- **Backward Compatibility**: Attachments without `content_id` still work as before using `file{index}` naming +- **File Size Threshold**: The 3MB threshold determines whether JSON or form data is used for the request + +## Expected Behavior + +### Before the Fix (Problematic) +``` +Form data fields: +- message: (JSON payload) +- file0: (inline image - content_id ignored) +- file1: (regular attachment) +``` + +### After the Fix (Correct) +``` +Form data fields: +- message: (JSON payload) +- my-inline-image: (inline image - uses content_id) +- file1: (regular attachment - fallback to file{index}) +``` + +This ensures that email clients can properly display inline images by matching the `content_id` in the HTML `cid:` reference with the multipart form field name. diff --git a/examples/inline_attachment_demo/inline_attachment_example.py b/examples/inline_attachment_demo/inline_attachment_example.py new file mode 100644 index 00000000..21fc1bc0 --- /dev/null +++ b/examples/inline_attachment_demo/inline_attachment_example.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 + +import base64 +import io +import os +from nylas import Client + + +def send_message_with_inline_attachment(): + """ + This example demonstrates how to send a message with an inline attachment + that uses a content_id for referencing in HTML email bodies. + + This is particularly useful for embedding images directly in HTML emails + where the image is referenced using 'cid:' in the src attribute. + """ + + # Initialize the Nylas client + nylas = Client( + api_key=os.environ.get("NYLAS_API_KEY"), # Replace with your API key + ) + + # Get test email + test_email = os.environ.get("TEST_EMAIL") + + # Get grant + grant_id = os.environ.get("NYLAS_GRANT_ID") + + # Create a sample image content using base64 decoded data + # This is a small PNG image that can be used for demonstration + base64_image = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEQUlEQVRYhe2WW2wUVRyHv5nZS7vb3XbLdrfXjSZ9AxOqDRANGjDRSKLRKoIYNLENpEgJRQIKKvSi0tI28oImhtISqg9cTEzUhIgmSkAQY4UWQ2yALi3d7S4UaPcyO7MzPkwlJXG3S02sD5yH8zLJ//vO7/zPmSMsqR0MMIvDBHhnU0CcTfh9gf+FgOnfFlCTIKs6mg6iABaTgFn6DwQ0DUYnNBaVW3iyMpvcHImJmMaPvTG+75PxOkSkDPKdkUBC1bFZRb76sIhyn/WubyufyePaqEJdW5DAWBKrWUhb6557QFZ13E6Jb/eUUe6zcuzkOMu3DlG0/DLPbrrKl8dvUewxc6S1lJI5EqqWvp6wpHZQzxiu6HjyJI60lCKKsLkjwI1xjfdr3PiKLYyMKnQcvE7oVpIDjSWEx1QW1w1R5Ey9zowTkBUd7xR4fVsAm1Wgc0cxD5RY6P8zRrHHTPtbhZS6TXzxzU3cLhNVC7NJqKnXmJGArOgUuiQOtxrwjbsDOG0ijW96kRM6T2+4yorGIAuqB1FUnc2vu9lz9BYAC+ZmkVBT155WQFZ0ilwSh1tKEQXY0DqCK0ekYZ2HeEJn6Xo/t6MahU6RuKJztj9Kfq7EwA1j8y1mkXR7nFZASeoU50scai1FEKCuZQS3U2JHrYe4rLGgZpDmmjn4CiRkVcdmFaica2PsdpLyfKN0QtFIdw5SHkNdB6tZ4FBLKQDrd43gdUm8t9ZDTNaorPbzycYCHq+0Y8sSWdMe4sTeMswmgd1dYTZU5QJwpj+OJc1hT5uApsGAX6Zu1wiF+ZPwuMbD1X4+rTfgv5yPUtUU5MTeMhx2kY4DYQaDKquW5REeUzl6OobFlDqDlAKCYKSwuP4aZV4T767xEI1rVFT7+WxTAYsfsXPmfJTnGoL07fPhsIu0d4c5e1Gm54MSANa1BCiwp2+ztDfheFxny/MO6le7icY0Kmr8dG4u4LEKO6fPRXmhKciFTh8up8TurjC9AzKfT8Jf3T7M0PUkWdPchCkFlCQ8Mc9K/Wo3kZix8q4tBTw6387Pv0d4sXmUC50+8hwSrftDnLuUoKfZgK/aPszlUZXsaeCQZgskEa4EVH76NcKitX66txrwU70RXpoCb+kM0XcpwcEmA/7KtmGuZAhPm4AoQHhcY1lTkO+aCln4kI2Tv0VY8dEo/ft95OZI7NoX4o/BBAcm4Su3DeMPqdPGnpEAQHhC44fmQirnGfA32kL0d/pw5hixX7qm0N1owFe8M8TV8PR7nrFARNZpeM1lwHsjPLUzyLGdXpw5Em3dYT7+eoLjrUUAvPz2UEYN908j5d8woeosnZ/N3ActNPTcxOMQybOJlHkkTl1M4MgSUJNGryQ1HbN07/C0An9LKEmwW43img5JjTtPLn1yEmbGBqbpAYtJuOsaFQUQp7z3hDvTzMesv4rvC8y6gAmIzKbAX+u0pDGsEb6KAAAAAElFTkSuQmCC" + image_content = base64.b64decode(base64_image) + + # Create the message with inline attachment + message_request = { + "to": [{"email": test_email, "name": "Recipient Name"}], + "from": [{"email": test_email, "name": "Sender Name"}], + "subject": "Message with Inline Image", + "body": """ + + +

Hello!

+

This email contains an inline image:

+ Inline Image +

The image above is embedded directly in the email using content_id.

+ + + """, + "attachments": [ + { + "filename": "inline-image.png", + "content_type": "image/png", + "content": io.BytesIO(image_content), + "size": len(image_content), + "content_id": "my-inline-image", # This is the key for inline attachments + "is_inline": True, + "content_disposition": "inline" + }, + { + # Regular attachment without content_id for comparison + "filename": "regular-attachment.txt", + "content_type": "text/plain", + "content": io.BytesIO(b"This is a regular attachment"), + "size": 28, + # No content_id - this will use the default file{index} naming + } + ] + } + + try: + # Send the message + response = nylas.messages.send( + identifier=grant_id, # Replace with your grant ID + request_body=message_request + ) + + print("Message sent successfully!") + print(f"Message ID: {response.data.id}") + print(f"Thread ID: {response.data.thread_id}") + + # The inline attachment will be referenced by its content_id in the form data + # instead of a generic file{index} name, allowing proper inline display + + except Exception as e: + print(f"Error sending message: {e}") + + +def send_draft_with_inline_attachment(): + """ + This example demonstrates how to create and send a draft with an inline attachment. + """ + + # Initialize the Nylas client + nylas = Client( + api_key=os.environ.get("NYLAS_API_KEY"), # Replace with your API key + ) + + # Get test email + test_email = os.environ.get("TEST_EMAIL") + + # Get grant + grant_id = os.environ.get("NYLAS_GRANT_ID") + + # Create a larger image content to trigger form data usage (>3MB threshold) + # For demo purposes, we'll replicate the same image data multiple times + # In real usage, large images would automatically use the content_id functionality + base64_image = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEQUlEQVRYhe2WW2wUVRyHv5nZS7vb3XbLdrfXjSZ9AxOqDRANGjDRSKLRKoIYNLENpEgJRQIKKvSi0tI28oImhtISqg9cTEzUhIgmSkAQY4UWQ2yALi3d7S4UaPcyO7MzPkwlJXG3S02sD5yH8zLJ//vO7/zPmSMsqR0MMIvDBHhnU0CcTfh9gf+FgOnfFlCTIKs6mg6iABaTgFn6DwQ0DUYnNBaVW3iyMpvcHImJmMaPvTG+75PxOkSkDPKdkUBC1bFZRb76sIhyn/WubyufyePaqEJdW5DAWBKrWUhb6557QFZ13E6Jb/eUUe6zcuzkOMu3DlG0/DLPbrrKl8dvUewxc6S1lJI5EqqWvp6wpHZQzxiu6HjyJI60lCKKsLkjwI1xjfdr3PiKLYyMKnQcvE7oVpIDjSWEx1QW1w1R5Ey9zowTkBUd7xR4fVsAm1Wgc0cxD5RY6P8zRrHHTPtbhZS6TXzxzU3cLhNVC7NJqKnXmJGArOgUuiQOtxrwjbsDOG0ijW96kRM6T2+4yorGIAuqB1FUnc2vu9lz9BYAC+ZmkVBT155WQFZ0ilwSh1tKEQXY0DqCK0ekYZ2HeEJn6Xo/t6MahU6RuKJztj9Kfq7EwA1j8y1mkXR7nFZASeoU50scai1FEKCuZQS3U2JHrYe4rLGgZpDmmjn4CiRkVcdmFaica2PsdpLyfKN0QtFIdw5SHkNdB6tZ4FBLKQDrd43gdUm8t9ZDTNaorPbzycYCHq+0Y8sSWdMe4sTeMswmgd1dYTZU5QJwpj+OJc1hT5uApsGAX6Zu1wiF+ZPwuMbD1X4+rTfgv5yPUtUU5MTeMhx2kY4DYQaDKquW5REeUzl6OobFlDqDlAKCYKSwuP4aZV4T767xEI1rVFT7+WxTAYsfsXPmfJTnGoL07fPhsIu0d4c5e1Gm54MSANa1BCiwp2+ztDfheFxny/MO6le7icY0Kmr8dG4u4LEKO6fPRXmhKciFTh8up8TurjC9AzKfT8Jf3T7M0PUkWdPchCkFlCQ8Mc9K/Wo3kZix8q4tBTw6387Pv0d4sXmUC50+8hwSrftDnLuUoKfZgK/aPszlUZXsaeCQZgskEa4EVH76NcKitX66txrwU70RXpoCb+kM0XcpwcEmA/7KtmGuZAhPm4AoQHhcY1lTkO+aCln4kI2Tv0VY8dEo/ft95OZI7NoX4o/BBAcm4Su3DeMPqdPGnpEAQHhC44fmQirnGfA32kL0d/pw5hixX7qm0N1owFe8M8TV8PR7nrFARNZpeM1lwHsjPLUzyLGdXpw5Em3dYT7+eoLjrUUAvPz2UEYN908j5d8woeosnZ/N3ActNPTcxOMQybOJlHkkTl1M4MgSUJNGryQ1HbN07/C0An9LKEmwW43img5JjTtPLn1yEmbGBqbpAYtJuOsaFQUQp7z3hDvTzMesv4rvC8y6gAmIzKbAX+u0pDGsEb6KAAAAAElFTkSuQmCC" + large_image_content = base64.b64decode(base64_image) * 1000 # Replicated to make it large + + # Create the draft with inline attachment + draft_request = { + "to": [{"email": test_email, "name": "Recipient Name"}], + "from": [{"email": test_email, "name": "Sender Name"}], + "subject": "Draft with Inline Image", + "body": """ + + +

Draft Email

+

This draft contains an inline image:

+ Company Logo +

Best regards,
Your Team

+ + + """, + "attachments": [ + { + "filename": "company-logo.png", + "content_type": "image/png", + "content": io.BytesIO(large_image_content), + "size": len(large_image_content), + "content_id": "logo-image", # Content ID for inline reference + "is_inline": True, + "content_disposition": "inline" + } + ] + } + + try: + # Create the draft + draft_response = nylas.drafts.create( + identifier=grant_id, # Replace with your grant ID + request_body=draft_request + ) + + print("Draft created successfully!") + print(f"Draft ID: {draft_response.data.id}") + + # Send the draft + send_response = nylas.drafts.send( + identifier=grant_id, # Replace with your grant ID + draft_id=draft_response.data.id + ) + + print("Draft sent successfully!") + print(f"Message ID: {send_response.data.id}") + + except Exception as e: + print(f"Error with draft: {e}") + + +if __name__ == "__main__": + print("Inline Attachment Example") + print("=" * 50) + print() + + # Check if API key is set + if not os.environ.get("NYLAS_API_KEY"): + print("Please set the NYLAS_API_KEY environment variable") + print("export NYLAS_API_KEY='your-api-key-here'") + exit(1) + + # Check if grant ID is set + if not os.environ.get("NYLAS_GRANT_ID"): + print("Please set the NYLAS_GRANT_ID environment variable") + print("export NYLAS_GRANT_ID='your-grant-id-here'") + exit(1) + + # Check if test email is set + if not os.environ.get("TEST_EMAIL"): + print("Please set the TEST_EMAIL environment variable") + print("export TEST_EMAIL='your-test-email-here'") + exit(1) + + print("1. Sending message with inline attachment...") + send_message_with_inline_attachment() + + print("\n2. Creating and sending draft with inline attachment...") + send_draft_with_inline_attachment() + + print("\nNote: The content_id field ensures that large inline attachments") + print("are properly referenced in the multipart form data, allowing") + print("email clients to display them inline correctly.") diff --git a/nylas/utils/file_utils.py b/nylas/utils/file_utils.py index 69dd7dc3..ece1e659 100644 --- a/nylas/utils/file_utils.py +++ b/nylas/utils/file_utils.py @@ -70,7 +70,9 @@ def _build_form_request(request_body: dict) -> MultipartEncoder: # Create the multipart/form-data encoder fields = {"message": ("", message_payload, "application/json")} for index, attachment in enumerate(attachments): - fields[f"file{index}"] = ( + # Use content_id as field name if provided, otherwise fallback to file{index} + field_name = attachment.get("content_id", f"file{index}") + fields[field_name] = ( attachment["filename"], attachment["content"], attachment["content_type"], diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py index 2058541a..4ad4ef7d 100644 --- a/tests/utils/test_file_utils.py +++ b/tests/utils/test_file_utils.py @@ -1,6 +1,6 @@ from unittest.mock import patch, mock_open -from nylas.utils.file_utils import attach_file_request_builder, _build_form_request +from nylas.utils.file_utils import attach_file_request_builder, _build_form_request, encode_stream_to_base64 class TestFileUtils: @@ -47,10 +47,110 @@ def test_build_form_request(self): == '{"to": [{"email": "test@gmail.com"}], "subject": "test subject", "body": "test body"}' ) assert request.fields["message"][2] == "application/json" - assert len(request.fields["file0"]) == 3 - assert request.fields["file0"][0] == "attachment.txt" - assert request.fields["file0"][1] == b"test data" + + def test_encode_stream_to_base64(self): + """Test that binary streams are properly encoded to base64.""" + import io + + # Create a binary stream with test data + test_data = b"Hello, World! This is test data." + binary_stream = io.BytesIO(test_data) + + # Move the stream position to simulate it being read + binary_stream.seek(10) + + # Encode to base64 + encoded = encode_stream_to_base64(binary_stream) + + # Verify the result + import base64 + expected = base64.b64encode(test_data).decode("utf-8") + assert encoded == expected + + # Verify the stream position was reset to 0 and read completely + assert binary_stream.tell() == len(test_data) + + def test_build_form_request_with_content_id(self): + """Test that content_id is used as field name when provided.""" + request_body = { + "to": [{"email": "test@gmail.com"}], + "subject": "test subject", + "body": "test body", + "attachments": [ + { + "filename": "inline_image.png", + "content_type": "image/png", + "content": b"image data", + "size": 1234, + "content_id": "image1@example.com", + }, + { + "filename": "regular_attachment.txt", + "content_type": "text/plain", + "content": b"text data", + "size": 5678, + # No content_id, should fallback to file{index} + } + ], + } + + request = _build_form_request(request_body) + + assert len(request.fields) == 3 + assert "message" in request.fields + assert "image1@example.com" in request.fields # Uses content_id + assert "file1" in request.fields # Falls back to file{index} for attachment without content_id + + # Verify the inline attachment with content_id + assert len(request.fields["image1@example.com"]) == 3 + assert request.fields["image1@example.com"][0] == "inline_image.png" + assert request.fields["image1@example.com"][1] == b"image data" + assert request.fields["image1@example.com"][2] == "image/png" + + # Verify the regular attachment without content_id + assert len(request.fields["file1"]) == 3 + assert request.fields["file1"][0] == "regular_attachment.txt" + assert request.fields["file1"][1] == b"text data" + assert request.fields["file1"][2] == "text/plain" + + def test_build_form_request_backwards_compatibility(self): + """Test that existing behavior is preserved when no content_id is provided.""" + request_body = { + "to": [{"email": "test@gmail.com"}], + "subject": "test subject", + "body": "test body", + "attachments": [ + { + "filename": "attachment1.txt", + "content_type": "text/plain", + "content": b"test data 1", + "size": 1234, + }, + { + "filename": "attachment2.txt", + "content_type": "text/plain", + "content": b"test data 2", + "size": 5678, + } + ], + } + + request = _build_form_request(request_body) + + assert len(request.fields) == 3 + assert "message" in request.fields + assert "file0" in request.fields # First attachment + assert "file1" in request.fields # Second attachment + + # Verify first attachment + assert request.fields["file0"][0] == "attachment1.txt" + assert request.fields["file0"][1] == b"test data 1" assert request.fields["file0"][2] == "text/plain" + + # Verify second attachment + assert request.fields["file1"][0] == "attachment2.txt" + assert request.fields["file1"][1] == b"test data 2" + assert request.fields["file1"][2] == "text/plain" def test_build_form_request_no_attachments(self): request_body = { @@ -70,3 +170,25 @@ def test_build_form_request_no_attachments(self): == '{"to": [{"email": "test@gmail.com"}], "subject": "test subject", "body": "test body"}' ) assert request.fields["message"][2] == "application/json" + + def test_encode_stream_to_base64(self): + """Test that binary streams are properly encoded to base64.""" + import io + + # Create a binary stream with test data + test_data = b"Hello, World! This is test data." + binary_stream = io.BytesIO(test_data) + + # Move the stream position to simulate it being read + binary_stream.seek(10) + + # Encode to base64 + encoded = encode_stream_to_base64(binary_stream) + + # Verify the result + import base64 + expected = base64.b64encode(test_data).decode("utf-8") + assert encoded == expected + + # Verify the stream position was reset to 0 and read completely + assert binary_stream.tell() == len(test_data) From 0a65ea22a583322c082182f6c58039abce6abd3f Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:43:07 -0400 Subject: [PATCH 160/186] Release v6.13.0 (#441) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- Contributing.md | 19 ++++++++++++++----- nylas/_client_sdk_version.py | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 32e0e248..d1090ebb 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.12.0 +current_version = 6.13.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index be2411d1..e5935a9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.13.0 ---------- * Fixed from field handling in messages.send() to properly map "from_" field to "from field * Fixed content_id handling for large inline attachments to use content_id as field name instead of generic file{index} diff --git a/Contributing.md b/Contributing.md index 1a25264f..76a78806 100644 --- a/Contributing.md +++ b/Contributing.md @@ -42,26 +42,35 @@ source .venv/bin/activate # .venv\Scripts\activate ``` +**Important**: If you encounter issues with `pip` not being available in the virtual environment, run: + +```bash +# Ensure pip is available in the virtual environment +python -m ensurepip --upgrade +``` + ### 3. Install Development Dependencies Install the package in editable mode with all optional dependencies: ```bash # Install the package in development mode with all optional dependencies -pip install -e ".[test,docs,release]" +python -m pip install -e ".[test,docs,release]" # Or install specific dependency groups as needed: -# pip install -e ".[test]" # For running tests -# pip install -e ".[docs]" # For building documentation -# pip install -e ".[release]" # For release management +# python -m pip install -e ".[test]" # For running tests +# python -m pip install -e ".[docs]" # For building documentation +# python -m pip install -e ".[release]" # For release management ``` +**Note**: We use `python -m pip` instead of just `pip` to ensure we're using the pip from the virtual environment. + ### 4. Install Code Quality Tools Install the linting and formatting tools used by the project: ```bash -pip install pylint black +python -m pip install pylint black ``` ### 5. Verify Your Setup diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 54746723..5dd6c425 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.12.0" +__VERSION__ = "6.13.0" From d226ff113a62108381cb3220683d225e36c2c368 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Wed, 8 Oct 2025 09:36:18 -0400 Subject: [PATCH 161/186] Investigate and fix email subject encoding issue (#442) Co-authored-by: Cursor Agent --- CHANGELOG.md | 1 + examples/special_characters_demo/README.md | 121 +++++++ .../special_characters_example.py | 325 ++++++++++++++++++ nylas/handler/http_client.py | 24 +- nylas/utils/file_utils.py | 4 +- tests/handler/test_http_client.py | 132 ++++++- tests/resources/test_drafts.py | 64 ++++ tests/resources/test_messages.py | 67 +++- tests/utils/test_file_utils.py | 71 +++- 9 files changed, 771 insertions(+), 38 deletions(-) create mode 100644 examples/special_characters_demo/README.md create mode 100755 examples/special_characters_demo/special_characters_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e5935a9a..f70efae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ v6.13.0 ---------- * Fixed from field handling in messages.send() to properly map "from_" field to "from field * Fixed content_id handling for large inline attachments to use content_id as field name instead of generic file{index} +* Fixed UTF-8 character encoding for all API requests to preserve special characters (accented letters, emoji, etc.) instead of escaping them as unicode sequences v6.12.0 ---------- diff --git a/examples/special_characters_demo/README.md b/examples/special_characters_demo/README.md new file mode 100644 index 00000000..bbbdce9d --- /dev/null +++ b/examples/special_characters_demo/README.md @@ -0,0 +1,121 @@ +# Special Characters Encoding Example + +This example demonstrates how the Nylas Python SDK correctly handles special characters (accented letters, unicode characters) in email subjects and message bodies. + +## The Problem + +Previously, when sending emails with large attachments (>3MB), special characters in the subject line would be incorrectly encoded. For example: + +- **Intended Subject:** "De l'idรฉe ร  la post-prod, sans friction" +- **What Recipients Saw:** "De lรขโ‚ฌโ„ขidรฉe ร  la post-prod, sans friction" + +This issue occurred because the SDK was using `json.dumps()` with the default `ensure_ascii=True` parameter when creating multipart/form-data requests for large attachments. + +## The Solution + +The SDK now uses `json.dumps(request_body, ensure_ascii=False)` to preserve UTF-8 characters correctly in the JSON payload, ensuring that special characters are displayed properly in recipient inboxes. + +## What This Example Demonstrates + +1. **Small Messages** - Sending messages with special characters (no attachments) +2. **Large Messages** - Sending messages with special characters AND large attachments (>3MB) +3. **Drafts** - Creating drafts with special characters +4. **International Support** - Handling various international character sets + +## Usage + +### Prerequisites + +1. Install the SDK in development mode: + ```bash + cd /path/to/nylas-python + pip install -e . + ``` + +2. Set up environment variables: + ```bash + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + export RECIPIENT_EMAIL="recipient@example.com" + ``` + +### Run the Example + +```bash +python examples/special_characters_demo/special_characters_example.py +``` + +## Test Coverage + +This fix is covered by comprehensive tests: + +```bash +# Test the core fix in file_utils +pytest tests/utils/test_file_utils.py::TestFileUtils::test_build_form_request_with_special_characters + +# Test message sending with special characters +pytest tests/resources/test_messages.py::TestMessage::test_send_message_with_special_characters_in_subject +pytest tests/resources/test_messages.py::TestMessage::test_send_message_with_special_characters_large_attachment + +# Test draft creation with special characters +pytest tests/resources/test_drafts.py::TestDraft::test_create_draft_with_special_characters_in_subject +pytest tests/resources/test_drafts.py::TestDraft::test_create_draft_with_special_characters_large_attachment +``` + +## Supported Character Sets + +The SDK correctly handles: + +- **French:** รฉ, รจ, รช, ร , รน, รง, ล“ +- **Spanish:** รฑ, รก, รญ, รณ, รบ, ยฟ, ยก +- **German:** รค, รถ, รผ, รŸ +- **Portuguese:** รฃ, รต, รข, รช +- **Italian:** ร , รจ, รฉ, รฌ, รฒ, รน +- **Russian:** Cyrillic characters +- **Japanese:** Hiragana, Katakana, Kanji +- **Chinese:** Simplified and Traditional characters +- **Emoji:** ๐ŸŽ‰ ๐ŸŽŠ ๐Ÿฅณ and many more +- **Special symbols:** โ‚ฌ, ยฃ, ยฅ, ยฉ, ยฎ, โ„ข + +## Technical Details + +### The Bug + +When using multipart/form-data encoding (for large attachments), the message payload was serialized as: + +```python +message_payload = json.dumps(request_body) # Default: ensure_ascii=True +``` + +This caused special characters to be escaped as unicode sequences: +```json +{"subject": "De l\u2019id\u00e9e"} +``` + +### The Fix + +The payload is now serialized as: + +```python +message_payload = json.dumps(request_body, ensure_ascii=False) +``` + +This preserves the actual UTF-8 characters: +```json +{"subject": "De l'idรฉe"} +``` + +The multipart/form-data Content-Type header correctly specifies UTF-8 encoding, ensuring email clients display the characters properly. + +## Related Files + +- **Core Fix:** `nylas/utils/file_utils.py` - Line 70 +- **Tests:** `tests/utils/test_file_utils.py`, `tests/resources/test_messages.py`, `tests/resources/test_drafts.py` +- **Example:** `examples/special_characters_demo/special_characters_example.py` + +## Impact + +โœ… **Before Fix:** Special characters in subjects were garbled when sending emails with large attachments +โœ… **After Fix:** All special characters are correctly preserved and displayed + +The fix ensures backwards compatibility - all existing code continues to work without changes. diff --git a/examples/special_characters_demo/special_characters_example.py b/examples/special_characters_demo/special_characters_example.py new file mode 100755 index 00000000..e112e332 --- /dev/null +++ b/examples/special_characters_demo/special_characters_example.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Handling Special Characters in Email Subjects and Bodies + +This example demonstrates proper handling of special characters (accented letters, +unicode characters) in email subjects and message bodies, particularly when sending +messages with large attachments. + +The SDK now correctly preserves UTF-8 characters in email subjects and bodies, +preventing encoding issues like "De l'idรฉe ร  la post-prod" becoming +"De lรƒยขร‚โ‚ฌร‚โ„ขidรƒฦ’ร‚ยฉe รƒฦ’ร‚ la post-prod". + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID + RECIPIENT_EMAIL: Email address to send test messages to + +Usage: + First, install the SDK in development mode: + cd /path/to/nylas-python + pip install -e . + + Then set environment variables and run: + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + export RECIPIENT_EMAIL="recipient@example.com" + python examples/special_characters_demo/special_characters_example.py +""" + +import os +import sys +import io +from nylas import Client + + +def get_env_or_exit(var_name: str) -> str: + """Get an environment variable or exit if not found.""" + value = os.getenv(var_name) + if not value: + print(f"Error: {var_name} environment variable is required") + sys.exit(1) + return value + + +def print_separator(title: str) -> None: + """Print a formatted section separator.""" + print(f"\n{'='*60}") + print(f" {title}") + print('='*60) + + +def demonstrate_small_message_with_special_chars(client: Client, grant_id: str, recipient: str) -> None: + """Demonstrate sending a message with special characters (no attachments).""" + print_separator("Sending Message with Special Characters (No Attachments)") + + try: + # This is the exact subject from the bug report + subject = "De l'idรฉe ร  la post-prod, sans friction" + body = """ + + +

Bonjour!

+

Ce message contient des caractรจres spรฉciaux:

+
    +
  • Accents franรงais: รฉ, รจ, รช, ร , รน, รง
  • +
  • Espagnol: รฑ, รก, รญ, รณ, รบ
  • +
  • Allemand: รค, รถ, รผ, รŸ
  • +
  • Portugais: รฃ, รต, รข
  • +
  • Symboles: โ‚ฌ, ยฃ, ยฅ, ยฉ, ยฎ, โ„ข
  • +
  • Citation: "De l'idรฉe ร  la rรฉalisation"
  • +
+

+ Expressions courantes: cafรฉ, naรฏve, rรฉsumรฉ, cรดtรฉ, forรชt, + crรจme brรปlรฉe, piรฑata, Zรผrich +

+ + + """ + + print(f"Subject: {subject}") + print(f"To: {recipient}") + print("Body contains various special characters...") + + print("\nSending message...") + response = client.messages.send( + identifier=grant_id, + request_body={ + "subject": subject, + "to": [{"email": recipient}], + "body": body, + } + ) + + print(f"โœ“ Message sent successfully!") + print(f" Message ID: {response.data.id}") + print(f" Subject preserved: {response.data.subject == subject}") + print(f"\nโœ… Special characters in subject and body are correctly encoded") + + except Exception as e: + print(f"โŒ Error sending message: {e}") + + +def demonstrate_message_with_large_attachment(client: Client, grant_id: str, recipient: str) -> None: + """Demonstrate sending a message with special characters AND large attachment.""" + print_separator("Message with Special Characters + Large Attachment") + + try: + # This is the exact subject from the bug report + subject = "De l'idรฉe ร  la post-prod, sans friction" + body = """ + + +

Message avec piรจce jointe volumineuse

+

+ Ce message dรฉmontre que les caractรจres spรฉciaux sont + correctement prรฉservรฉs mรชme lors de l'utilisation de + multipart/form-data pour les grandes piรจces jointes. +

+

Caractรจres accentuรฉs: cafรฉ, naรฏve, rรฉsumรฉ, cรดtรฉ

+ + + """ + + # Create a large attachment (>3MB) to trigger multipart/form-data encoding + # This is where the encoding bug was happening + large_content = b"A" * (3 * 1024 * 1024 + 1000) # Slightly over 3MB + attachment_stream = io.BytesIO(large_content) + + print(f"Subject: {subject}") + print(f"To: {recipient}") + print(f"Attachment size: {len(large_content) / (1024*1024):.2f} MB") + print(" (Using multipart/form-data encoding)") + + print("\nSending message with large attachment...") + response = client.messages.send( + identifier=grant_id, + request_body={ + "subject": subject, + "to": [{"email": recipient}], + "body": body, + "attachments": [ + { + "filename": "large_file.txt", + "content_type": "text/plain", + "content": attachment_stream, + "size": len(large_content), + } + ], + } + ) + + print(f"โœ“ Message with large attachment sent successfully!") + print(f" Message ID: {response.data.id}") + print(f" Subject preserved: {response.data.subject == subject}") + print(f"\nโœ… Special characters are correctly encoded even with large attachments!") + print(" (The fix ensures ensure_ascii=False in json.dumps for multipart data)") + + except Exception as e: + print(f"โŒ Error sending message with large attachment: {e}") + + +def demonstrate_draft_with_special_chars(client: Client, grant_id: str, recipient: str) -> None: + """Demonstrate creating a draft with special characters.""" + print_separator("Creating Draft with Special Characters") + + try: + subject = "Rรฉunion importante: cafรฉ & stratรฉgie" + body = """ + + +

Ordre du jour

+
    +
  1. Rรฉvision du budget (โ‚ฌ)
  2. +
  3. Stratรฉgie de dรฉveloppement
  4. +
  5. Cafรฉ et discussion informelle
  6. +
+

ร€ bientรดt!

+ + + """ + + print(f"Subject: {subject}") + print(f"To: {recipient}") + + print("\nCreating draft...") + response = client.drafts.create( + identifier=grant_id, + request_body={ + "subject": subject, + "to": [{"email": recipient}], + "body": body, + } + ) + + print(f"โœ“ Draft created successfully!") + print(f" Draft ID: {response.data.id}") + print(f" Subject preserved: {response.data.subject == subject}") + + # Clean up - delete the draft + print("\nCleaning up draft...") + client.drafts.destroy(identifier=grant_id, draft_id=response.data.id) + print("โœ“ Draft deleted") + + print(f"\nโœ… Special characters in drafts are correctly handled") + + except Exception as e: + print(f"โŒ Error with draft: {e}") + + +def demonstrate_various_languages(client: Client, grant_id: str, recipient: str) -> None: + """Demonstrate various international characters.""" + print_separator("International Characters - Various Languages") + + test_cases = [ + ("French", "Rรฉservation confirmรฉe: cafรฉ ร  15h"), + ("Spanish", "ยกHola! ยฟCรณmo estรกs? Maรฑana serรก mejor"), + ("German", "GrรถรŸe: รผber 100 Stรผck verfรผgbar"), + ("Portuguese", "Atenรงรฃo: promoรงรฃo vรกlida atรฉ amanhรฃ"), + ("Italian", "Caffรจ espresso: รจ cosรฌ buono!"), + ("Russian", "ะŸั€ะธะฒะตั‚! ะšะฐะบ ะดะตะปะฐ?"), + ("Japanese", "ใ“ใ‚“ใซใกใฏใ€ใŠๅ…ƒๆฐ—ใงใ™ใ‹๏ผŸ"), + ("Chinese", "ไฝ ๅฅฝ๏ผŒๆœ€่ฟ‘ๆ€Žไนˆๆ ท๏ผŸ"), + ("Emoji", "๐ŸŽ‰ Celebration time! ๐ŸŽŠ Let's party ๐Ÿฅณ"), + ] + + print("Testing subjects in various languages:") + print("(Note: Not actually sending to avoid spam)") + print() + + for language, subject in test_cases: + print(f" {language:15} : {subject}") + # In a real scenario, you could send these + # For demo purposes, we just show they can be handled + + print(f"\nโœ… All international characters can be properly encoded") + print(" The SDK preserves UTF-8 encoding correctly") + + +def demonstrate_encoding_explanation() -> None: + """Explain the encoding fix.""" + print_separator("Technical Explanation of the Fix") + + print(""" +The Bug: +-------- +When sending emails with large attachments (>3MB), the SDK uses +multipart/form-data encoding. Previously, the message payload was +serialized using: + + json.dumps(request_body) # Default: ensure_ascii=True + +This caused special characters to be escaped as unicode sequences: + "De l'idรฉe" โ†’ "De l\\u2019id\\u00e9e" + +When Gmail received this, it would sometimes double-decode or misinterpret +these escape sequences, resulting in: + "De lรขโ‚ฌโ„ขidรฉe" or similar garbled text + +The Fix: +-------- +The SDK now uses: + + json.dumps(request_body, ensure_ascii=False) + +This preserves the actual UTF-8 characters in the JSON payload: + "De l'idรฉe" โ†’ "De l'idรฉe" (unchanged) + +The multipart/form-data Content-Type header correctly specifies UTF-8, +so email clients now receive and display the characters correctly. + +Impact: +------- +โœ“ Small messages (no large attachments): Always worked correctly +โœ“ Large messages (with attachments >3MB): Now work correctly! +โœ“ Drafts with large attachments: Now work correctly! +โœ“ All international characters: Properly preserved + +Testing: +-------- +Run the included tests to verify: + pytest tests/utils/test_file_utils.py::TestFileUtils::test_build_form_request_with_special_characters + pytest tests/resources/test_messages.py::TestMessage::test_send_message_with_special_characters_large_attachment + pytest tests/resources/test_drafts.py::TestDraft::test_create_draft_with_special_characters_large_attachment + """) + + +def main(): + """Main function demonstrating special character handling.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + recipient = get_env_or_exit("RECIPIENT_EMAIL") + + # Initialize Nylas client + client = Client(api_key=api_key) + + print("โ•”" + "="*58 + "โ•—") + print("โ•‘ Nylas SDK: Special Characters Encoding Example โ•‘") + print("โ•š" + "="*58 + "โ•") + print() + print("This example demonstrates the fix for email subject/body") + print("encoding issues with special characters (accented letters).") + print() + print(f"Testing with:") + print(f" Grant ID: {grant_id}") + print(f" Recipient: {recipient}") + + # Demonstrate different scenarios + demonstrate_small_message_with_special_chars(client, grant_id, recipient) + demonstrate_message_with_large_attachment(client, grant_id, recipient) + demonstrate_draft_with_special_chars(client, grant_id, recipient) + demonstrate_various_languages(client, grant_id, recipient) + demonstrate_encoding_explanation() + + print_separator("Example Completed Successfully! โœ…") + print("\nKey Takeaways:") + print("1. Special characters are now correctly preserved in all email subjects") + print("2. The fix applies to both small and large messages (with attachments)") + print("3. Drafts also handle special characters correctly") + print("4. All international character sets are supported") + print() + + +if __name__ == "__main__": + main() diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index 76bcd7f1..ec95a21f 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -1,4 +1,5 @@ import sys +import json from typing import Union, Tuple, Dict from urllib.parse import urlparse, quote @@ -18,7 +19,7 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: - json = response.json() + response_data = response.json() if response.status_code >= 400: parsed_url = urlparse(response.url) try: @@ -26,25 +27,25 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: "connect/token" in parsed_url.path or "connect/revoke" in parsed_url.path ): - parsed_error = NylasOAuthErrorResponse.from_dict(json) + parsed_error = NylasOAuthErrorResponse.from_dict(response_data) raise NylasOAuthError(parsed_error, response.status_code, response.headers) - parsed_error = NylasApiErrorResponse.from_dict(json) + parsed_error = NylasApiErrorResponse.from_dict(response_data) raise NylasApiError(parsed_error, response.status_code, response.headers) except (KeyError, TypeError) as exc: - request_id = json.get("request_id", None) + request_id = response_data.get("request_id", None) raise NylasApiError( NylasApiErrorResponse( request_id, NylasApiErrorResponseData( type="unknown", - message=json, + message=response_data, ), ), status_code=response.status_code, headers=response.headers, ) from exc - return (json, response.headers) + return (response_data, response.headers) def _build_query_params(base_url: str, query_params: dict = None) -> str: query_param_parts = [] @@ -88,14 +89,19 @@ def _execute( timeout = self.timeout if overrides and overrides.get("timeout"): timeout = overrides["timeout"] + + # Serialize request_body to JSON with ensure_ascii=False to preserve UTF-8 characters + # This ensures special characters (accented letters, emoji, etc.) are not escaped + json_data = None + if request_body is not None and data is None: + json_data = json.dumps(request_body, ensure_ascii=False) try: response = requests.request( request["method"], request["url"], headers=request["headers"], - json=request_body, + data=json_data or data, timeout=timeout, - data=data, ) except requests.exceptions.Timeout as exc: raise NylasSdkTimeoutError(url=request["url"], timeout=timeout) from exc @@ -186,6 +192,6 @@ def _build_headers( if data is not None and data.content_type is not None: headers["Content-type"] = data.content_type elif response_body is not None: - headers["Content-type"] = "application/json" + headers["Content-type"] = "application/json; charset=utf-8" return {**headers, **extra_headers, **override_headers} diff --git a/nylas/utils/file_utils.py b/nylas/utils/file_utils.py index ece1e659..f4c4ef05 100644 --- a/nylas/utils/file_utils.py +++ b/nylas/utils/file_utils.py @@ -65,7 +65,9 @@ def _build_form_request(request_body: dict) -> MultipartEncoder: """ attachments = request_body.get("attachments", []) request_body.pop("attachments", None) - message_payload = json.dumps(request_body) + # Use ensure_ascii=False to preserve UTF-8 characters (accented letters, etc.) + # instead of escaping them as unicode sequences + message_payload = json.dumps(request_body, ensure_ascii=False) # Create the multipart/form-data encoder fields = {"message": ("", message_payload, "application/json")} diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index 9fb0684a..76767f72 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -63,7 +63,7 @@ def test_build_headers_json_body(self, http_client, patched_version_and_sys): "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", - "Content-type": "application/json", + "Content-type": "application/json; charset=utf-8", } def test_build_headers_form_body(self, http_client, patched_version_and_sys): @@ -200,7 +200,7 @@ def test_execute_download_request_override_timeout( "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", - "Content-type": "application/json", + "Content-type": "application/json; charset=utf-8", }, timeout=60, stream=False, @@ -299,12 +299,11 @@ def test_execute(self, http_client, patched_version_and_sys, patched_request): "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", - "Content-type": "application/json", + "Content-type": "application/json; charset=utf-8", "test": "header", }, - json={"foo": "bar"}, + data='{"foo": "bar"}', timeout=30, - data=None, ) def test_execute_override_timeout( @@ -334,12 +333,11 @@ def test_execute_override_timeout( "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", - "Content-type": "application/json", + "Content-type": "application/json; charset=utf-8", "test": "header", }, - json={"foo": "bar"}, + data='{"foo": "bar"}', timeout=60, - data=None, ) def test_execute_timeout(self, http_client, mock_session_timeout): @@ -425,10 +423,122 @@ def test_execute_with_headers(self, http_client, patched_version_and_sys, patche "X-Nylas-API-Wrapper": "python", "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", "Authorization": "Bearer test-key", - "Content-type": "application/json", + "Content-type": "application/json; charset=utf-8", "test": "header", }, - json={"foo": "bar"}, + data='{"foo": "bar"}', timeout=30, - data=None, ) + + def test_execute_with_utf8_characters(self, http_client, patched_version_and_sys, patched_request): + """Test that UTF-8 characters are preserved in JSON requests (not escaped).""" + mock_response = Mock() + mock_response.json.return_value = {"success": True} + mock_response.headers = {"X-Test-Header": "test"} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + # Request with special characters + request_body = { + "title": "Rรฉunion d'รฉquipe", + "description": "De l'idรฉe ร  la post-prod, sans friction", + "location": "cafรฉ", + } + + response_json, response_headers = http_client._execute( + method="POST", + path="/events", + request_body=request_body, + ) + + assert response_json == {"success": True} + # Verify that the data sent preserves UTF-8 characters (not escaped) + call_kwargs = patched_request.call_args[1] + assert "data" in call_kwargs + sent_data = call_kwargs["data"] + + # The JSON should contain actual UTF-8 characters, not escape sequences + assert "Rรฉunion d'รฉquipe" in sent_data + assert "De l'idรฉe ร  la post-prod" in sent_data + assert "cafรฉ" in sent_data + # Should NOT contain unicode escape sequences + assert "\\u" not in sent_data + + def test_execute_with_none_request_body(self, http_client, patched_version_and_sys, patched_request): + """Test that None request_body is handled correctly.""" + mock_response = Mock() + mock_response.json.return_value = {"success": True} + mock_response.headers = {"X-Test-Header": "test"} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + response_json, response_headers = http_client._execute( + method="GET", + path="/events", + request_body=None, + ) + + assert response_json == {"success": True} + # Verify that data is None when request_body is None + call_kwargs = patched_request.call_args[1] + assert "data" in call_kwargs + assert call_kwargs["data"] is None + + def test_execute_with_emoji_and_international_characters(self, http_client, patched_version_and_sys, patched_request): + """Test that emoji and various international characters are preserved.""" + mock_response = Mock() + mock_response.json.return_value = {"success": True} + mock_response.headers = {"X-Test-Header": "test"} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + request_body = { + "emoji": "๐ŸŽ‰ Party time! ๐Ÿฅณ", + "japanese": "ใ“ใ‚“ใซใกใฏ", + "chinese": "ไฝ ๅฅฝ", + "russian": "ะŸั€ะธะฒะตั‚", + "german": "GrรถรŸe", + "spanish": "ยฟCรณmo estรกs?", + } + + response_json, response_headers = http_client._execute( + method="POST", + path="/messages", + request_body=request_body, + ) + + assert response_json == {"success": True} + call_kwargs = patched_request.call_args[1] + sent_data = call_kwargs["data"] + + # All characters should be preserved + assert "๐ŸŽ‰ Party time! ๐Ÿฅณ" in sent_data + assert "ใ“ใ‚“ใซใกใฏ" in sent_data + assert "ไฝ ๅฅฝ" in sent_data + assert "ะŸั€ะธะฒะตั‚" in sent_data + assert "GrรถรŸe" in sent_data + assert "ยฟCรณmo estรกs?" in sent_data + + def test_execute_with_multipart_data_not_affected(self, http_client, patched_version_and_sys, patched_request): + """Test that multipart/form-data is not affected by the change.""" + mock_response = Mock() + mock_response.json.return_value = {"success": True} + mock_response.headers = {"X-Test-Header": "test"} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + # When data is provided (multipart), request_body should be ignored + mock_data = Mock() + mock_data.content_type = "multipart/form-data" + + response_json, response_headers = http_client._execute( + method="POST", + path="/messages/send", + request_body={"foo": "bar"}, # This should be ignored + data=mock_data, + ) + + assert response_json == {"success": True} + call_kwargs = patched_request.call_args[1] + # Should use the multipart data, not JSON + assert call_kwargs["data"] == mock_data diff --git a/tests/resources/test_drafts.py b/tests/resources/test_drafts.py index b0e9fe54..a0d3dbed 100644 --- a/tests/resources/test_drafts.py +++ b/tests/resources/test_drafts.py @@ -466,3 +466,67 @@ def test_create_draft_without_is_plaintext_backwards_compatibility(self, http_cl request_body, overrides=None, ) + + def test_create_draft_with_special_characters_in_subject(self, http_client_response): + """Test creating a draft with special characters (accented letters) in subject.""" + drafts = Drafts(http_client_response) + # This is the exact subject from the bug report + request_body = { + "subject": "De l'idรฉe ร  la post-prod, sans friction", + "to": [{"name": "Jean Dupont", "email": "jean@example.com"}], + "body": "Message avec des caractรจres accentuรฉs: cafรฉ, naรฏve, rรฉsumรฉ", + } + + drafts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/drafts", + None, + None, + request_body, + overrides=None, + ) + + def test_create_draft_with_special_characters_large_attachment(self, http_client_response): + """Test that special characters are preserved in drafts when using form data (large attachments).""" + from unittest.mock import Mock + + drafts = Drafts(http_client_response) + mock_encoder = Mock() + + # Mock the _build_form_request to capture what it's called with + with patch("nylas.resources.drafts._build_form_request") as mock_build_form: + mock_build_form.return_value = mock_encoder + + # This is the exact subject from the bug report + request_body = { + "subject": "De l'idรฉe ร  la post-prod, sans friction", + "to": [{"name": "Jean Dupont", "email": "jean@example.com"}], + "body": "Message avec des caractรจres: cafรฉ, naรฏve", + "attachments": [ + { + "filename": "large_file.pdf", + "content_type": "application/pdf", + "content": b"large file content", + "size": 3 * 1024 * 1024, # 3MB - triggers form data + } + ], + } + + drafts.create(identifier="abc-123", request_body=request_body) + + # Verify _build_form_request was called + mock_build_form.assert_called_once() + + # Verify the subject with special characters was passed correctly + call_args = mock_build_form.call_args[0][0] + assert call_args["subject"] == "De l'idรฉe ร  la post-prod, sans friction" + assert "cafรฉ" in call_args["body"] + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/drafts", + data=mock_encoder, + overrides=None, + ) diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 1efe0aab..15493c76 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -1069,4 +1069,69 @@ def test_send_message_without_from_fields_unchanged(self, http_client_response): request_body=expected_request_body, data=None, overrides=None, - ) \ No newline at end of file + ) + + def test_send_message_with_special_characters_in_subject(self, http_client_response): + """Test sending a message with special characters (accented letters) in subject.""" + messages = Messages(http_client_response) + # This is the exact subject from the bug report + request_body = { + "subject": "De l'idรฉe ร  la post-prod, sans friction", + "to": [{"name": "Jean Dupont", "email": "jean@example.com"}], + "body": "Message avec des caractรจres accentuรฉs: cafรฉ, naรฏve, rรฉsumรฉ", + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + + def test_send_message_with_special_characters_large_attachment(self, http_client_response): + """Test that special characters are preserved when using form data (large attachments).""" + from unittest.mock import Mock + import json + + messages = Messages(http_client_response) + mock_encoder = Mock() + + # Mock the _build_form_request to capture what it's called with + with patch("nylas.resources.messages._build_form_request") as mock_build_form: + mock_build_form.return_value = mock_encoder + + # This is the exact subject from the bug report + request_body = { + "subject": "De l'idรฉe ร  la post-prod, sans friction", + "to": [{"name": "Jean Dupont", "email": "jean@example.com"}], + "body": "Message avec des caractรจres: cafรฉ, naรฏve", + "attachments": [ + { + "filename": "large_file.pdf", + "content_type": "application/pdf", + "content": b"large file content", + "size": 3 * 1024 * 1024, # 3MB - triggers form data + } + ], + } + + messages.send(identifier="abc-123", request_body=request_body) + + # Verify _build_form_request was called + mock_build_form.assert_called_once() + + # Verify the subject with special characters was passed correctly + call_args = mock_build_form.call_args[0][0] + assert call_args["subject"] == "De l'idรฉe ร  la post-prod, sans friction" + assert "cafรฉ" in call_args["body"] + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=None, + data=mock_encoder, + overrides=None, + ) \ No newline at end of file diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py index 4ad4ef7d..bfd9fddb 100644 --- a/tests/utils/test_file_utils.py +++ b/tests/utils/test_file_utils.py @@ -171,24 +171,63 @@ def test_build_form_request_no_attachments(self): ) assert request.fields["message"][2] == "application/json" - def test_encode_stream_to_base64(self): - """Test that binary streams are properly encoded to base64.""" - import io + def test_build_form_request_with_special_characters(self): + """Test that special characters (accented letters) are properly encoded in form requests.""" + import json - # Create a binary stream with test data - test_data = b"Hello, World! This is test data." - binary_stream = io.BytesIO(test_data) + # This is the exact subject from the bug report + request_body = { + "to": [{"email": "test@gmail.com"}], + "subject": "De l'idรฉe ร  la post-prod, sans friction", + "body": "Test body with special chars: cafรฉ, naรฏve, rรฉsumรฉ", + "attachments": [ + { + "filename": "attachment.txt", + "content_type": "text/plain", + "content": b"test data", + "size": 1234, + } + ], + } + + request = _build_form_request(request_body) + + # Verify the message field exists + assert "message" in request.fields + message_content = request.fields["message"][1] - # Move the stream position to simulate it being read - binary_stream.seek(10) + # Parse the JSON to verify it contains the correct characters + parsed_message = json.loads(message_content) + assert parsed_message["subject"] == "De l'idรฉe ร  la post-prod, sans friction" + assert "cafรฉ" in parsed_message["body"] + assert "naรฏve" in parsed_message["body"] + assert "rรฉsumรฉ" in parsed_message["body"] - # Encode to base64 - encoded = encode_stream_to_base64(binary_stream) + # Verify that the special characters are preserved in the JSON string itself + # They should NOT be escaped as unicode escape sequences + assert "idรฉe" in message_content + assert "cafรฉ" in message_content - # Verify the result - import base64 - expected = base64.b64encode(test_data).decode("utf-8") - assert encoded == expected + def test_build_form_request_encoding_comparison(self): + """Test to demonstrate the difference between ensure_ascii=True and ensure_ascii=False.""" + import json - # Verify the stream position was reset to 0 and read completely - assert binary_stream.tell() == len(test_data) + test_subject = "De l'idรฉe ร  la post-prod, sans friction" + + # With ensure_ascii=True (default - this causes the bug) + encoded_with_ascii = json.dumps({"subject": test_subject}, ensure_ascii=True) + # This will produce escape sequences like \u00e9 for รฉ + + # With ensure_ascii=False (the fix) + encoded_without_ascii = json.dumps({"subject": test_subject}, ensure_ascii=False) + # This will preserve the actual UTF-8 characters + + # Verify the difference + assert "\\u" in encoded_with_ascii or test_subject not in encoded_with_ascii + assert test_subject in encoded_without_ascii + assert "idรฉe" in encoded_without_ascii + assert "cafรฉ" not in encoded_with_ascii # Would be escaped + + # Both should decode to the same value + assert json.loads(encoded_with_ascii)["subject"] == test_subject + assert json.loads(encoded_without_ascii)["subject"] == test_subject From 0ff64e5a1af76abe7e973e509ff004df2952a31a Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Wed, 8 Oct 2025 09:49:39 -0400 Subject: [PATCH 162/186] Release v6.13.1 release (#443) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 5 ++++- nylas/_client_sdk_version.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d1090ebb..97fa2df5 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.13.0 +current_version = 6.13.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index f70efae5..d1a81801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,14 @@ nylas-python Changelog ====================== +v6.13.1 +---------- +* Fixed UTF-8 character encoding for all API requests to preserve special characters (accented letters, emoji, etc.) instead of escaping them as unicode sequences + v6.13.0 ---------- * Fixed from field handling in messages.send() to properly map "from_" field to "from field * Fixed content_id handling for large inline attachments to use content_id as field name instead of generic file{index} -* Fixed UTF-8 character encoding for all API requests to preserve special characters (accented letters, emoji, etc.) instead of escaping them as unicode sequences v6.12.0 ---------- diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 5dd6c425..e8a66e99 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.13.0" +__VERSION__ = "6.13.1" From db9fd38f63169b5f1607fcbf22995035378b4dc6 Mon Sep 17 00:00:00 2001 From: Samuel Xavier <107475513+samuelpx@users.noreply.github.com> Date: Wed, 5 Nov 2025 22:26:55 -0300 Subject: [PATCH 163/186] CUST-4901 added message.deleted to the webhook enum, appended webhook test suite (#444) --- CHANGELOG.md | 4 ++++ nylas/models/webhooks.py | 1 + tests/resources/test_webhooks.py | 1 + 3 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a81801..fc6f78f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------- +* Added `message.deleted` to the Webhook enum, appended tests + v6.13.1 ---------- * Fixed UTF-8 character encoding for all API requests to preserve special characters (accented letters, emoji, etc.) instead of escaping them as unicode sequences diff --git a/nylas/models/webhooks.py b/nylas/models/webhooks.py index 71b65827..7cbee4a1 100644 --- a/nylas/models/webhooks.py +++ b/nylas/models/webhooks.py @@ -33,6 +33,7 @@ class WebhookTriggers(str, Enum): MESSAGE_BOUNCE_DETECTED = "message.bounce_detected" MESSAGE_CREATED = "message.created" MESSAGE_UPDATED = "message.updated" + MESSAGE_DELETED= "message.deleted" MESSAGE_OPENED = "message.opened" MESSAGE_LINK_CLICKED = "message.link_clicked" MESSAGE_OPENED_LEGACY = "message.opened.legacy" diff --git a/tests/resources/test_webhooks.py b/tests/resources/test_webhooks.py index 98f016de..159a94d7 100644 --- a/tests/resources/test_webhooks.py +++ b/tests/resources/test_webhooks.py @@ -57,6 +57,7 @@ def test_webhook_deserialization_all(self, http_client): "message.bounce_detected", "message.created", "message.updated", + "message.deleted", "message.opened", "message.link_clicked", "message.opened.legacy", From a57282f21782e0655c59dc165f32b09a50623eb2 Mon Sep 17 00:00:00 2001 From: Samuel Xavier <107475513+samuelpx@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:51:10 -0300 Subject: [PATCH 164/186] CUST-4960 Fixed Participant.email not being optional (#445) --- CHANGELOG.md | 1 + nylas/models/events.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc6f78f0..348db45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------- * Added `message.deleted` to the Webhook enum, appended tests +* Fixed Participant.email not being optional, Microsoft events can now be represented v6.13.1 ---------- diff --git a/nylas/models/events.py b/nylas/models/events.py index 118eeaf2..c4a20458 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -29,14 +29,14 @@ class Participant: Interface representing an Event participant. Attributes: - email: Participant's email address. + email: Participant's email address. Required for all providers except Microsoft. name: Participant's name. status: Participant's status. comment: Comment by the participant. phone_number: Participant's phone number. """ - email: str + email: Optional[str] = None status: Optional[ParticipantStatus] = None name: Optional[str] = None comment: Optional[str] = None @@ -409,7 +409,7 @@ class CreateParticipant(TypedDict): phone_number: Participant's phone number. """ - email: str + email: NotRequired[str] name: NotRequired[str] comment: NotRequired[str] phone_number: NotRequired[str] From e2359dacb430ce9e50205229432468b311bb2877 Mon Sep 17 00:00:00 2001 From: kraju3 <35513942+kraju3@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:03:20 -0600 Subject: [PATCH 165/186] feat(drafts, messages): Add metadata_pair to both messages and drafts (#430) Co-authored-by: Kiran Raju Co-authored-by: Aaron de Mello --- .pylintrc | 1 + CHANGELOG.md | 1 + nylas/models/drafts.py | 2 ++ nylas/models/messages.py | 3 +++ nylas/models/notetakers.py | 1 - 5 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index f789fc61..cfbff336 100644 --- a/.pylintrc +++ b/.pylintrc @@ -11,6 +11,7 @@ disable= too-many-instance-attributes, unnecessary-pass, too-many-arguments, + too-many-positional-arguments, too-few-public-methods, [TYPECHECK] diff --git a/CHANGELOG.md b/CHANGELOG.md index 348db45b..178b0eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Unreleased ---------- * Added `message.deleted` to the Webhook enum, appended tests * Fixed Participant.email not being optional, Microsoft events can now be represented +* Added support for metadata_pair query params to the messages and drafts list endpoints v6.13.1 ---------- diff --git a/nylas/models/drafts.py b/nylas/models/drafts.py index 3c8a8c28..78626b95 100644 --- a/nylas/models/drafts.py +++ b/nylas/models/drafts.py @@ -129,6 +129,7 @@ class CreateDraftRequest(TypedDict): "starred": NotRequired[bool], "thread_id": NotRequired[str], "has_attachment": NotRequired[bool], + "metadata_pair": NotRequired[str], }, ) """ @@ -145,6 +146,7 @@ class CreateDraftRequest(TypedDict): unread: Filter messages by unread status. starred: Filter messages by starred status. has_attachment: Filter messages by whether they have an attachment. + metadata_pair (NotRequired[str]): Filter messages by metadata key/value pair. limit (NotRequired[int]): The maximum number of objects to return. This field defaults to 50. The maximum allowed value is 200. page_token (NotRequired[str]): An identifier that specifies which page of data to return. diff --git a/nylas/models/messages.py b/nylas/models/messages.py index 342df897..9caf5bfc 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -128,6 +128,7 @@ class Message: "fields": NotRequired[Fields], "search_query_native": NotRequired[str], "select": NotRequired[str], + "metadata_pair": NotRequired[str] }, ) """ @@ -159,6 +160,8 @@ class Message: This field defaults to 50. The maximum allowed value is 200. page_token (NotRequired[str]): An identifier that specifies which page of data to return. This value should be taken from a ListResponse object's next_cursor parameter. + metadata_pair (NotRequired[str]): Pass a metadata key/value pair (for example, ?metadata_pair=key1:value) + to search for metadata associated with objects. See Metadata for more information. """ diff --git a/nylas/models/notetakers.py b/nylas/models/notetakers.py index a889fecc..17badf6e 100644 --- a/nylas/models/notetakers.py +++ b/nylas/models/notetakers.py @@ -268,7 +268,6 @@ class ListNotetakerQueryParams(ListQueryParams): def __post_init__(self): """Convert enums to string values for API requests.""" - super().__post_init__() # Convert state enum to string if present if hasattr(self, "state") and isinstance(self.state, NotetakerState): self.state = self.state.value From e5d6e07165da57da8e4fa6c3c7d1d4586e67fc8d Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:05:11 -0500 Subject: [PATCH 166/186] fix(encoding): ensure ASCII characters are not escaped in JSON payloads (#446) --- CHANGELOG.md | 1 + examples/send_email_demo/README.md | 77 ++++++++++++ .../send_email_demo/send_email_example.py | 113 ++++++++++++++++++ nylas/utils/file_utils.py | 4 +- tests/utils/test_file_utils.py | 37 ------ 5 files changed, 192 insertions(+), 40 deletions(-) create mode 100644 examples/send_email_demo/README.md create mode 100644 examples/send_email_demo/send_email_example.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 178b0eb5..99823c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Unreleased ---------- * Added `message.deleted` to the Webhook enum, appended tests * Fixed Participant.email not being optional, Microsoft events can now be represented +* Clarified UTF-8 encoding behavior: ASCII characters are preserved as-is (not escaped) while non-ASCII characters are preserved as UTF-8 in JSON payloads * Added support for metadata_pair query params to the messages and drafts list endpoints v6.13.1 diff --git a/examples/send_email_demo/README.md b/examples/send_email_demo/README.md new file mode 100644 index 00000000..fa5edfe4 --- /dev/null +++ b/examples/send_email_demo/README.md @@ -0,0 +1,77 @@ +# Send Email Example + +This example demonstrates how to send an email with special characters (accented letters) in the subject line using the Nylas Python SDK. + +## Overview + +The example sends an email with the subject **"De l'idรฉe ร  la post-prod, sans friction"** to demonstrate proper handling of UTF-8 characters in email subjects. + +## Prerequisites + +- Python 3.8 or higher +- Nylas Python SDK installed +- Nylas API key +- Nylas grant ID +- Email address for testing + +## Setup + +1. Install the SDK in development mode: + ```bash + cd /path/to/nylas-python + pip install -e . + ``` + +2. Set the required environment variables: + ```bash + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + export RECIPIENT_EMAIL="recipient@example.com" + ``` + +## Running the Example + +```bash +python examples/send_email_demo/send_email_example.py +``` + +## What This Example Demonstrates + +- Sending an email with special characters (accented letters) in the subject +- Proper UTF-8 encoding of email subjects +- Using the `messages.send()` method to send emails directly + +## Expected Output + +``` +============================================================ + Nylas SDK: Send Email with Special Characters Example +============================================================ + +This example sends an email with the subject: + "De l'idรฉe ร  la post-prod, sans friction" + +Grant ID: your_grant_id +Recipient: recipient@example.com + +Sending email... + To: recipient@example.com + Subject: De l'idรฉe ร  la post-prod, sans friction + +โœ“ Email sent successfully! + Message ID: message-id-here + Subject: De l'idรฉe ร  la post-prod, sans friction + +โœ… Special characters in subject are correctly preserved + +============================================================ +Example completed successfully! โœ… +============================================================ +``` + +## Notes + +- The SDK properly handles UTF-8 characters in email subjects and bodies +- Special characters like รฉ, ร , and other accented letters are preserved correctly +- The email will be delivered with the subject exactly as specified + diff --git a/examples/send_email_demo/send_email_example.py b/examples/send_email_demo/send_email_example.py new file mode 100644 index 00000000..6edf46dc --- /dev/null +++ b/examples/send_email_demo/send_email_example.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Send Email with Special Characters + +This example demonstrates how to send an email with special characters +(accented letters) in the subject line using the Nylas Python SDK. + +The example sends an email with the subject "De l'idรฉe ร  la post-prod, sans friction" +to demonstrate proper handling of UTF-8 characters in email subjects. + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID + RECIPIENT_EMAIL: Email address to send the message to + +Usage: + First, install the SDK in development mode: + cd /path/to/nylas-python + pip install -e . + + Then set environment variables and run: + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + export RECIPIENT_EMAIL="recipient@example.com" + python examples/send_email_demo/send_email_example.py +""" + +import os +import sys +from nylas import Client + + +def get_env_or_exit(var_name: str) -> str: + """Get an environment variable or exit if not found.""" + value = os.getenv(var_name) + if not value: + print(f"Error: {var_name} environment variable is required") + sys.exit(1) + return value + + +def send_email(client: Client, grant_id: str, recipient: str) -> None: + """Send an email with special characters in the subject.""" + # Subject with special characters (accented letters) + subject = "De l'idรฉe ร  la post-prod, sans friction" + + body = """ + + +

Bonjour!

+

Ce message dรฉmontre l'envoi d'un email avec des caractรจres spรฉciaux dans le sujet.

+

Le sujet de cet email est: De l'idรฉe ร  la post-prod, sans friction

+

Les caractรจres accentuรฉs sont correctement prรฉservรฉs grรขce ร  l'encodage UTF-8.

+ + + """ + + print(f"Sending email...") + print(f" To: {recipient}") + print(f" Subject: {subject}") + + try: + response = client.messages.send( + identifier=grant_id, + request_body={ + "subject": subject, + "to": [{"email": recipient}], + "body": body, + } + ) + + print(f"\nโœ“ Email sent successfully!") + print(f" Message ID: {response.data.id}") + print(f" Subject: {response.data.subject}") + print(f"\nโœ… Special characters in subject are correctly preserved") + + except Exception as e: + print(f"\nโŒ Error sending email: {e}") + sys.exit(1) + + +def main(): + """Main function.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + recipient = get_env_or_exit("RECIPIENT_EMAIL") + + # Initialize Nylas client + client = Client(api_key=api_key) + + print("=" * 60) + print(" Nylas SDK: Send Email with Special Characters Example") + print("=" * 60) + print() + print("This example sends an email with the subject:") + print(' "De l\'idรฉe ร  la post-prod, sans friction"') + print() + print(f"Grant ID: {grant_id}") + print(f"Recipient: {recipient}") + print() + + # Send the email + send_email(client, grant_id, recipient) + + print("\n" + "=" * 60) + print("Example completed successfully! โœ…") + print("=" * 60) + + +if __name__ == "__main__": + main() + diff --git a/nylas/utils/file_utils.py b/nylas/utils/file_utils.py index f4c4ef05..ece1e659 100644 --- a/nylas/utils/file_utils.py +++ b/nylas/utils/file_utils.py @@ -65,9 +65,7 @@ def _build_form_request(request_body: dict) -> MultipartEncoder: """ attachments = request_body.get("attachments", []) request_body.pop("attachments", None) - # Use ensure_ascii=False to preserve UTF-8 characters (accented letters, etc.) - # instead of escaping them as unicode sequences - message_payload = json.dumps(request_body, ensure_ascii=False) + message_payload = json.dumps(request_body) # Create the multipart/form-data encoder fields = {"message": ("", message_payload, "application/json")} diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py index bfd9fddb..4394b52f 100644 --- a/tests/utils/test_file_utils.py +++ b/tests/utils/test_file_utils.py @@ -171,43 +171,6 @@ def test_build_form_request_no_attachments(self): ) assert request.fields["message"][2] == "application/json" - def test_build_form_request_with_special_characters(self): - """Test that special characters (accented letters) are properly encoded in form requests.""" - import json - - # This is the exact subject from the bug report - request_body = { - "to": [{"email": "test@gmail.com"}], - "subject": "De l'idรฉe ร  la post-prod, sans friction", - "body": "Test body with special chars: cafรฉ, naรฏve, rรฉsumรฉ", - "attachments": [ - { - "filename": "attachment.txt", - "content_type": "text/plain", - "content": b"test data", - "size": 1234, - } - ], - } - - request = _build_form_request(request_body) - - # Verify the message field exists - assert "message" in request.fields - message_content = request.fields["message"][1] - - # Parse the JSON to verify it contains the correct characters - parsed_message = json.loads(message_content) - assert parsed_message["subject"] == "De l'idรฉe ร  la post-prod, sans friction" - assert "cafรฉ" in parsed_message["body"] - assert "naรฏve" in parsed_message["body"] - assert "rรฉsumรฉ" in parsed_message["body"] - - # Verify that the special characters are preserved in the JSON string itself - # They should NOT be escaped as unicode escape sequences - assert "idรฉe" in message_content - assert "cafรฉ" in message_content - def test_build_form_request_encoding_comparison(self): """Test to demonstrate the difference between ensure_ascii=True and ensure_ascii=False.""" import json From 6eddb615987fc46e4263ffef3fa152353ff6fb9c Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:28:31 -0500 Subject: [PATCH 167/186] Release v6.14. (#447) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 97fa2df5..5aef9d04 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.13.1 +current_version = 6.14.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 99823c84..5149718b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.14.0 ---------- * Added `message.deleted` to the Webhook enum, appended tests * Fixed Participant.email not being optional, Microsoft events can now be represented diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index e8a66e99..40211220 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.13.1" +__VERSION__ = "6.14.0" From c79c77fa73c8b302f05f49f507d7893bbb56aa1c Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Mon, 12 Jan 2026 13:22:08 -0500 Subject: [PATCH 168/186] CUST-5037 Fix attachment id to not be a requirement (#449) --- CHANGELOG.md | 4 + nylas/models/attachments.py | 2 +- tests/resources/test_attachments.py | 237 +++++++++++++++++++++++++++- 3 files changed, 239 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5149718b..b7c3316e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unrelease +---------- +* Update attachment schema to not make it mandatory + v6.14.0 ---------- * Added `message.deleted` to the Webhook enum, appended tests diff --git a/nylas/models/attachments.py b/nylas/models/attachments.py index 59c2d52b..37fdb167 100644 --- a/nylas/models/attachments.py +++ b/nylas/models/attachments.py @@ -22,7 +22,7 @@ class Attachment: is_inline: Whether the attachment is inline. """ - id: str + id: Optional[str] = None grant_id: Optional[str] = None filename: Optional[str] = None content_type: Optional[str] = None diff --git a/tests/resources/test_attachments.py b/tests/resources/test_attachments.py index 9854172f..5bd43e64 100644 --- a/tests/resources/test_attachments.py +++ b/tests/resources/test_attachments.py @@ -1,11 +1,15 @@ +from io import BytesIO from unittest.mock import Mock -from nylas.models.attachments import Attachment, FindAttachmentQueryParams +from nylas.models.attachments import Attachment, CreateAttachmentRequest, FindAttachmentQueryParams from nylas.resources.attachments import Attachments -class TestAttachments: - def test_attachment_deserialization(self, http_client): +class TestAttachmentModel: + """Tests for the Attachment dataclass model.""" + + def test_attachment_deserialization(self): + """Test full deserialization of Attachment from dict.""" attach_json = { "content_type": "image/png", "filename": "pic.png", @@ -14,6 +18,7 @@ def test_attachment_deserialization(self, http_client): "is_inline": True, "size": 13068, "content_id": "", + "content_disposition": "inline", } attachment = Attachment.from_dict(attach_json) @@ -25,6 +30,232 @@ def test_attachment_deserialization(self, http_client): assert attachment.is_inline is True assert attachment.size == 13068 assert attachment.content_id == "" + assert attachment.content_disposition == "inline" + + def test_attachment_serialization(self): + """Test serialization of Attachment to dict.""" + attachment = Attachment( + id="185e56cb50e12e82", + grant_id="41009df5-bf11-4c97-aa18-b285b5f2e386", + filename="document.pdf", + content_type="application/pdf", + size=2048, + content_id="", + content_disposition="attachment", + is_inline=False, + ) + + result = attachment.to_dict() + + assert result["id"] == "185e56cb50e12e82" + assert result["grant_id"] == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert result["filename"] == "document.pdf" + assert result["content_type"] == "application/pdf" + assert result["size"] == 2048 + assert result["content_id"] == "" + assert result["content_disposition"] == "attachment" + assert result["is_inline"] is False + + def test_attachment_deserialization_partial_fields(self): + """Test deserialization with only required fields.""" + attach_json = { + "id": "abc123", + "filename": "test.txt", + } + + attachment = Attachment.from_dict(attach_json) + + assert attachment.id == "abc123" + assert attachment.filename == "test.txt" + assert attachment.grant_id is None + assert attachment.content_type is None + assert attachment.size is None + assert attachment.content_id is None + assert attachment.content_disposition is None + assert attachment.is_inline is None + + def test_attachment_deserialization_empty_dict(self): + """Test deserialization from empty dict.""" + attachment = Attachment.from_dict({}) + + assert attachment.id is None + assert attachment.grant_id is None + assert attachment.filename is None + assert attachment.content_type is None + assert attachment.size is None + assert attachment.content_id is None + assert attachment.content_disposition is None + assert attachment.is_inline is None + + def test_attachment_default_values(self): + """Test Attachment instantiation with default values.""" + attachment = Attachment() + + assert attachment.id is None + assert attachment.grant_id is None + assert attachment.filename is None + assert attachment.content_type is None + assert attachment.size is None + assert attachment.content_id is None + assert attachment.content_disposition is None + assert attachment.is_inline is None + + def test_attachment_content_disposition_attachment(self): + """Test attachment with content_disposition set to 'attachment'.""" + attach_json = { + "id": "file-123", + "filename": "report.xlsx", + "content_disposition": "attachment", + "is_inline": False, + } + + attachment = Attachment.from_dict(attach_json) + + assert attachment.content_disposition == "attachment" + assert attachment.is_inline is False + + def test_attachment_content_disposition_inline(self): + """Test inline attachment with content_disposition.""" + attach_json = { + "id": "img-456", + "filename": "logo.png", + "content_disposition": "inline", + "is_inline": True, + "content_id": "", + } + + attachment = Attachment.from_dict(attach_json) + + assert attachment.content_disposition == "inline" + assert attachment.is_inline is True + assert attachment.content_id == "" + + def test_attachment_roundtrip_serialization(self): + """Test that serialization and deserialization are inverses.""" + original = Attachment( + id="test-id", + grant_id="grant-123", + filename="file.txt", + content_type="text/plain", + size=100, + content_id="", + content_disposition="attachment", + is_inline=False, + ) + + serialized = original.to_dict() + deserialized = Attachment.from_dict(serialized) + + assert deserialized.id == original.id + assert deserialized.grant_id == original.grant_id + assert deserialized.filename == original.filename + assert deserialized.content_type == original.content_type + assert deserialized.size == original.size + assert deserialized.content_id == original.content_id + assert deserialized.content_disposition == original.content_disposition + assert deserialized.is_inline == original.is_inline + + +class TestCreateAttachmentRequest: + """Tests for the CreateAttachmentRequest TypedDict.""" + + def test_create_attachment_request_with_base64_content(self): + """Test creating attachment request with base64 encoded content.""" + request: CreateAttachmentRequest = { + "filename": "test.txt", + "content_type": "text/plain", + "content": "SGVsbG8gV29ybGQh", # base64 for "Hello World!" + "size": 12, + } + + assert request["filename"] == "test.txt" + assert request["content_type"] == "text/plain" + assert request["content"] == "SGVsbG8gV29ybGQh" + assert request["size"] == 12 + + def test_create_attachment_request_with_file_object(self): + """Test creating attachment request with file-like object.""" + file_content = BytesIO(b"File content here") + + request: CreateAttachmentRequest = { + "filename": "document.pdf", + "content_type": "application/pdf", + "content": file_content, + "size": 17, + } + + assert request["filename"] == "document.pdf" + assert request["content_type"] == "application/pdf" + assert request["content"] == file_content + assert request["size"] == 17 + + def test_create_attachment_request_with_optional_fields(self): + """Test creating attachment request with all optional fields.""" + request: CreateAttachmentRequest = { + "filename": "image.png", + "content_type": "image/png", + "content": "iVBORw0KGgo=", + "size": 1024, + "content_id": "", + "content_disposition": "inline", + "is_inline": True, + } + + assert request["filename"] == "image.png" + assert request["content_type"] == "image/png" + assert request["content"] == "iVBORw0KGgo=" + assert request["size"] == 1024 + assert request["content_id"] == "" + assert request["content_disposition"] == "inline" + assert request["is_inline"] is True + + def test_create_attachment_request_minimal(self): + """Test creating attachment request with only required fields.""" + request: CreateAttachmentRequest = { + "filename": "minimal.txt", + "content_type": "text/plain", + "content": "data", + "size": 4, + } + + assert "filename" in request + assert "content_type" in request + assert "content" in request + assert "size" in request + # Optional fields should not be present + assert "content_id" not in request + assert "content_disposition" not in request + assert "is_inline" not in request + + +class TestFindAttachmentQueryParams: + """Tests for the FindAttachmentQueryParams TypedDict.""" + + def test_find_attachment_query_params(self): + """Test creating find attachment query params.""" + params: FindAttachmentQueryParams = { + "message_id": "msg-12345", + } + + assert params["message_id"] == "msg-12345" + + def test_find_attachment_query_params_various_message_ids(self): + """Test find attachment query params with various message ID formats.""" + # Simple ID + params1: FindAttachmentQueryParams = {"message_id": "abc123"} + assert params1["message_id"] == "abc123" + + # UUID format + params2: FindAttachmentQueryParams = {"message_id": "550e8400-e29b-41d4-a716-446655440000"} + assert params2["message_id"] == "550e8400-e29b-41d4-a716-446655440000" + + # Complex message ID (email message-id format) + params3: FindAttachmentQueryParams = {"message_id": ""} + assert params3["message_id"] == "" + + +class TestAttachments: + """Tests for the Attachments resource API calls.""" def test_find_attachment(self, http_client_response): attachments = Attachments(http_client_response) From 00e3f81aad55b5b3ee69ed154a6ee5392d96a6f0 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Wed, 14 Jan 2026 21:01:20 -0500 Subject: [PATCH 169/186] update new release v6.14.1 (#450) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 4 ++-- nylas/_client_sdk_version.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 5aef9d04..0bb4b723 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.14.0 +current_version = 6.14.1 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c3316e..6f382ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ nylas-python Changelog ====================== -Unrelease +v6.14.1 ---------- -* Update attachment schema to not make it mandatory +* Fix attachment id to not be a requirement v6.14.0 ---------- diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 40211220..c168bd9a 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.14.0" +__VERSION__ = "6.14.1" From 75687c9e3ea14d1de807f3ad32382bee1b6f8954 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Fri, 16 Jan 2026 09:10:21 -0500 Subject: [PATCH 170/186] Fix UTF-8 encoding for special characters (emoji, accented letters, etc.) by encoding JSON as UTF-8 bytes (#452) --- CHANGELOG.md | 4 + nylas/handler/http_client.py | 9 +- tests/handler/test_http_client.py | 177 +++++++++++++++++++++++++++--- 3 files changed, 169 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f382ed6..75d91740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ nylas-python Changelog ====================== +Unreleased +---------- +* Fix UTF-8 encoding for special characters (emoji, accented letters, etc.) by encoding JSON as UTF-8 bytes + v6.14.1 ---------- * Fix attachment id to not be a requirement diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index ec95a21f..9027736d 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -1,5 +1,5 @@ -import sys import json +import sys from typing import Union, Tuple, Dict from urllib.parse import urlparse, quote @@ -91,16 +91,17 @@ def _execute( timeout = overrides["timeout"] # Serialize request_body to JSON with ensure_ascii=False to preserve UTF-8 characters - # This ensures special characters (accented letters, emoji, etc.) are not escaped + # and allow_nan=True to support NaN/Infinity values (matching default json.dumps behavior). + # Encode as UTF-8 bytes to avoid Latin-1 encoding errors with special characters. json_data = None if request_body is not None and data is None: - json_data = json.dumps(request_body, ensure_ascii=False) + json_data = json.dumps(request_body, ensure_ascii=False, allow_nan=True).encode("utf-8") try: response = requests.request( request["method"], request["url"], headers=request["headers"], - data=json_data or data, + data=json_data if json_data is not None else data, timeout=timeout, ) except requests.exceptions.Timeout as exc: diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index 76767f72..f9511d4c 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -302,7 +302,7 @@ def test_execute(self, http_client, patched_version_and_sys, patched_request): "Content-type": "application/json; charset=utf-8", "test": "header", }, - data='{"foo": "bar"}', + data=b'{"foo": "bar"}', timeout=30, ) @@ -336,7 +336,7 @@ def test_execute_override_timeout( "Content-type": "application/json; charset=utf-8", "test": "header", }, - data='{"foo": "bar"}', + data=b'{"foo": "bar"}', timeout=60, ) @@ -426,7 +426,7 @@ def test_execute_with_headers(self, http_client, patched_version_and_sys, patche "Content-type": "application/json; charset=utf-8", "test": "header", }, - data='{"foo": "bar"}', + data=b'{"foo": "bar"}', timeout=30, ) @@ -452,17 +452,19 @@ def test_execute_with_utf8_characters(self, http_client, patched_version_and_sys ) assert response_json == {"success": True} - # Verify that the data sent preserves UTF-8 characters (not escaped) + # Verify that the data is sent as UTF-8 encoded bytes call_kwargs = patched_request.call_args[1] assert "data" in call_kwargs sent_data = call_kwargs["data"] - # The JSON should contain actual UTF-8 characters, not escape sequences - assert "Rรฉunion d'รฉquipe" in sent_data - assert "De l'idรฉe ร  la post-prod" in sent_data - assert "cafรฉ" in sent_data + # The data should be bytes with actual UTF-8 characters (not escape sequences) + assert isinstance(sent_data, bytes) + decoded_data = sent_data.decode("utf-8") + assert "Rรฉunion d'รฉquipe" in decoded_data + assert "De l'idรฉe ร  la post-prod, sans friction" in decoded_data + assert "cafรฉ" in decoded_data # Should NOT contain unicode escape sequences - assert "\\u" not in sent_data + assert "\\u" not in decoded_data def test_execute_with_none_request_body(self, http_client, patched_version_and_sys, patched_request): """Test that None request_body is handled correctly.""" @@ -479,9 +481,33 @@ def test_execute_with_none_request_body(self, http_client, patched_version_and_s ) assert response_json == {"success": True} - # Verify that data is None when request_body is None + # Verify that data branch is used when request_body is None call_kwargs = patched_request.call_args[1] + # Should use data= parameter, not json= parameter assert "data" in call_kwargs + assert "json" not in call_kwargs + assert call_kwargs["data"] is None + + def test_execute_with_none_request_body_and_none_data(self, http_client, patched_version_and_sys, patched_request): + """Test that both None request_body and None data are handled correctly.""" + mock_response = Mock() + mock_response.json.return_value = {"success": True} + mock_response.headers = {"X-Test-Header": "test"} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + response_json, response_headers = http_client._execute( + method="DELETE", + path="/events/123", + request_body=None, + data=None, + ) + + assert response_json == {"success": True} + call_kwargs = patched_request.call_args[1] + # Should use data= parameter with None value + assert "data" in call_kwargs + assert "json" not in call_kwargs assert call_kwargs["data"] is None def test_execute_with_emoji_and_international_characters(self, http_client, patched_version_and_sys, patched_request): @@ -511,13 +537,130 @@ def test_execute_with_emoji_and_international_characters(self, http_client, patc call_kwargs = patched_request.call_args[1] sent_data = call_kwargs["data"] - # All characters should be preserved - assert "๐ŸŽ‰ Party time! ๐Ÿฅณ" in sent_data - assert "ใ“ใ‚“ใซใกใฏ" in sent_data - assert "ไฝ ๅฅฝ" in sent_data - assert "ะŸั€ะธะฒะตั‚" in sent_data - assert "GrรถรŸe" in sent_data - assert "ยฟCรณmo estรกs?" in sent_data + # All characters should be preserved as UTF-8 encoded bytes + assert isinstance(sent_data, bytes) + decoded_data = sent_data.decode("utf-8") + assert "๐ŸŽ‰ Party time! ๐Ÿฅณ" in decoded_data + assert "ใ“ใ‚“ใซใกใฏ" in decoded_data + assert "ไฝ ๅฅฝ" in decoded_data + assert "ะŸั€ะธะฒะตั‚" in decoded_data + assert "GrรถรŸe" in decoded_data + assert "ยฟCรณmo estรกs?" in decoded_data + + def test_execute_with_right_single_quotation_mark(self, http_client, patched_version_and_sys, patched_request): + """Test that right single quotation mark (\\u2019) is handled correctly. + + This character caused UnicodeEncodeError: 'latin-1' codec can't encode character '\\u2019'. + """ + mock_response = Mock() + mock_response.json.return_value = {"success": True} + mock_response.headers = {"X-Test-Header": "test"} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + # The \u2019 character is the right single quotation mark (') + # This was the exact character that caused the original encoding error + request_body = { + "subject": "It's a test", # Contains \u2019 (right single quotation mark) + "body": "Here's another example with curly apostrophe", + } + + response_json, response_headers = http_client._execute( + method="POST", + path="/messages/send", + request_body=request_body, + ) + + assert response_json == {"success": True} + call_kwargs = patched_request.call_args[1] + sent_data = call_kwargs["data"] + + # The data should be UTF-8 encoded bytes with the \u2019 character preserved + assert isinstance(sent_data, bytes) + decoded_data = sent_data.decode("utf-8") + assert "'" in decoded_data # \u2019 right single quotation mark + assert "It's a test" in decoded_data + assert "Here's another" in decoded_data + + def test_execute_with_emojis(self, http_client, patched_version_and_sys, patched_request): + """Test that emojis are handled correctly in request bodies. + + Emojis are multi-byte UTF-8 characters that could cause encoding issues + if not handled properly. + """ + mock_response = Mock() + mock_response.json.return_value = {"success": True} + mock_response.headers = {"X-Test-Header": "test"} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + request_body = { + "subject": "Hello ๐Ÿ‘‹ World ๐ŸŒ", + "body": "Great job! ๐ŸŽ‰ Keep up the good work ๐Ÿ’ช See you soon ๐Ÿ˜Š", + "emoji_only": "๐Ÿ”ฅ๐Ÿš€โœจ๐Ÿ’ฏ", + "mixed": "Meeting at 3pm ๐Ÿ“… Don't forget! โฐ", + } + + response_json, response_headers = http_client._execute( + method="POST", + path="/messages/send", + request_body=request_body, + ) + + assert response_json == {"success": True} + call_kwargs = patched_request.call_args[1] + sent_data = call_kwargs["data"] + + # All emojis should be preserved in UTF-8 encoded bytes + assert isinstance(sent_data, bytes) + decoded_data = sent_data.decode("utf-8") + assert "Hello ๐Ÿ‘‹ World ๐ŸŒ" in decoded_data + assert "๐ŸŽ‰" in decoded_data + assert "๐Ÿ’ช" in decoded_data + assert "๐Ÿ˜Š" in decoded_data + assert "๐Ÿ”ฅ๐Ÿš€โœจ๐Ÿ’ฏ" in decoded_data + assert "๐Ÿ“…" in decoded_data + assert "โฐ" in decoded_data + + def test_execute_with_nan_and_infinity(self, http_client, patched_version_and_sys, patched_request): + """Test that NaN and Infinity float values are handled correctly. + + The requests library's json= parameter uses allow_nan=False which raises + ValueError for NaN/Infinity. Our implementation uses json.dumps with + allow_nan=True to maintain backward compatibility. + """ + mock_response = Mock() + mock_response.json.return_value = {"success": True} + mock_response.headers = {"X-Test-Header": "test"} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + request_body = { + "nan_value": float("nan"), + "infinity": float("inf"), + "neg_infinity": float("-inf"), + "normal": 42.5, + } + + # This should NOT raise ValueError + response_json, response_headers = http_client._execute( + method="POST", + path="/data", + request_body=request_body, + ) + + assert response_json == {"success": True} + call_kwargs = patched_request.call_args[1] + sent_data = call_kwargs["data"] + + # The data should be UTF-8 encoded bytes with NaN/Infinity serialized + assert isinstance(sent_data, bytes) + decoded_data = sent_data.decode("utf-8") + # json.dumps with allow_nan=True produces NaN, Infinity, -Infinity (JS-style) + assert "NaN" in decoded_data + assert "Infinity" in decoded_data + assert "-Infinity" in decoded_data + assert "42.5" in decoded_data def test_execute_with_multipart_data_not_affected(self, http_client, patched_version_and_sys, patched_request): """Test that multipart/form-data is not affected by the change.""" From 2017e11f76f86b1e71b82c3ae683d3e02c701fb8 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Fri, 16 Jan 2026 09:36:31 -0500 Subject: [PATCH 171/186] v6.14.2-release (#453) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 2 +- nylas/_client_sdk_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0bb4b723..ae660d52 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.14.1 +current_version = 6.14.2 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d91740..73fde8bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ nylas-python Changelog ====================== -Unreleased +v6.14.2 ---------- * Fix UTF-8 encoding for special characters (emoji, accented letters, etc.) by encoding JSON as UTF-8 bytes diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index c168bd9a..f86ae333 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.14.1" +__VERSION__ = "6.14.2" From 2deab361e84ab7ede4c9b5b86936109f86684b5c Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Fri, 6 Feb 2026 11:28:05 -0500 Subject: [PATCH 172/186] UAS multi-credential update (#456) --- CHANGELOG.md | 3 ++ nylas/models/auth.py | 1 + nylas/models/connectors.py | 2 ++ nylas/models/grants.py | 1 + tests/resources/test_auth.py | 19 +++++++++++++ tests/resources/test_connectors.py | 45 ++++++++++++++++++++++++++++++ tests/resources/test_grants.py | 29 +++++++++++++++++++ 7 files changed, 100 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73fde8bd..cc91d8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ nylas-python Changelog ====================== +Unreleased +---------- +* UAS multi-credential update v6.14.2 ---------- diff --git a/nylas/models/auth.py b/nylas/models/auth.py index 9146796c..22464783 100644 --- a/nylas/models/auth.py +++ b/nylas/models/auth.py @@ -47,6 +47,7 @@ class URLForAuthenticationConfig(TypedDict): include_grant_scopes: NotRequired[bool] state: NotRequired[str] login_hint: NotRequired[str] + credential_id: NotRequired[str] class URLForAdminConsentConfig(URLForAuthenticationConfig): diff --git a/nylas/models/connectors.py b/nylas/models/connectors.py index 6ffd5ef0..65eb3306 100644 --- a/nylas/models/connectors.py +++ b/nylas/models/connectors.py @@ -34,6 +34,7 @@ class BaseCreateConnectorRequest(TypedDict): """ provider: Provider + active_credential_id: NotRequired[str] class GoogleCreateConnectorSettings(TypedDict): @@ -141,6 +142,7 @@ class UpdateConnectorRequest(TypedDict): name: NotRequired[str] settings: NotRequired[Dict[str, Any]] scope: NotRequired[List[str]] + active_credential_id: NotRequired[str] class ListConnectorQueryParams(ListQueryParams): diff --git a/nylas/models/grants.py b/nylas/models/grants.py index 61db3d65..4cae4c12 100644 --- a/nylas/models/grants.py +++ b/nylas/models/grants.py @@ -42,6 +42,7 @@ class Grant: updated_at: Optional[int] = None provider_user_id: Optional[str] = None settings: Optional[Dict[str, Any]] = None + credential_id: Optional[str] = None class CreateGrantRequest(TypedDict): diff --git a/tests/resources/test_auth.py b/tests/resources/test_auth.py index db88ca56..f592de8f 100644 --- a/tests/resources/test_auth.py +++ b/tests/resources/test_auth.py @@ -137,6 +137,25 @@ def test_url_for_oauth2(self, http_client): == "https://test.nylas.com/v3/connect/auth?client_id=abc-123&redirect_uri=https%3A//example.com/oauth/callback&scope=email.read_only%20calendar%20contacts&login_hint=test%40gmail.com&provider=google&prompt=select_provider%2Cdetect&state=abc-123-state&response_type=code&access_type=online" ) + def test_url_for_oauth2_with_credential_id(self, http_client): + auth = Auth(http_client) + config = { + "client_id": "abc-123", + "redirect_uri": "https://example.com/oauth/callback", + "scope": ["Mail.Read", "User.Read"], + "login_hint": "test@outlook.com", + "provider": "microsoft", + "state": "abc-123-state", + "credential_id": "cred-abc-123", + } + + url = auth.url_for_oauth2(config) + + assert ( + url + == "https://test.nylas.com/v3/connect/auth?client_id=abc-123&redirect_uri=https%3A//example.com/oauth/callback&scope=Mail.Read%20User.Read&login_hint=test%40outlook.com&provider=microsoft&state=abc-123-state&credential_id=cred-abc-123&response_type=code&access_type=online" + ) + def test_exchange_code_for_token(self, http_client_token_exchange): auth = Auth(http_client_token_exchange) config = { diff --git a/tests/resources/test_connectors.py b/tests/resources/test_connectors.py index fc5c202b..b43e3627 100644 --- a/tests/resources/test_connectors.py +++ b/tests/resources/test_connectors.py @@ -66,6 +66,28 @@ def test_create_connector(self, http_client_response): "POST", "/v3/connectors", None, None, request_body, overrides=None ) + def test_create_connector_with_active_credential_id(self, http_client_response): + connectors = Connectors(http_client_response) + request_body = { + "provider": "microsoft", + "settings": { + "client_id": "string", + "client_secret": "string", + "tenant": "common", + }, + "scope": [ + "Mail.Read", + "User.Read", + ], + "active_credential_id": "cred-abc-123", + } + + connectors.create(request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", "/v3/connectors", None, None, request_body, overrides=None + ) + def test_update_connector(self, http_client_response): connectors = Connectors(http_client_response) request_body = { @@ -89,6 +111,29 @@ def test_update_connector(self, http_client_response): "PATCH", "/v3/connectors/google", None, None, request_body, overrides=None ) + def test_update_connector_with_active_credential_id(self, http_client_response): + connectors = Connectors(http_client_response) + request_body = { + "settings": { + "client_id": "string", + "client_secret": "string", + }, + "scope": [ + "Mail.Read", + "User.Read", + ], + "active_credential_id": "cred-xyz-789", + } + + connectors.update( + provider="microsoft", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PATCH", "/v3/connectors/microsoft", None, None, request_body, overrides=None + ) + def test_destroy_connector(self, http_client_delete_response): connectors = Connectors(http_client_delete_response) diff --git a/tests/resources/test_grants.py b/tests/resources/test_grants.py index e7d61d32..bcb6e88b 100644 --- a/tests/resources/test_grants.py +++ b/tests/resources/test_grants.py @@ -30,6 +30,35 @@ def test_grant_deserialization(self, http_client): assert grant.created_at == 1617817109 assert grant.updated_at == 1617817109 + def test_grant_deserialization_with_credential_id(self, http_client): + grant_json = { + "id": "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47", + "provider": "microsoft", + "grant_status": "valid", + "email": "email@example.com", + "scope": ["Mail.Read", "User.Read", "offline_access"], + "user_agent": "string", + "ip": "string", + "state": "my-state", + "created_at": 1617817109, + "updated_at": 1617817109, + "credential_id": "cred-abc-123", + } + + grant = Grant.from_dict(grant_json) + + assert grant.id == "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47" + assert grant.provider == "microsoft" + assert grant.grant_status == "valid" + assert grant.email == "email@example.com" + assert grant.scope == ["Mail.Read", "User.Read", "offline_access"] + assert grant.user_agent == "string" + assert grant.ip == "string" + assert grant.state == "my-state" + assert grant.created_at == 1617817109 + assert grant.updated_at == 1617817109 + assert grant.credential_id == "cred-abc-123" + def test_list_grants(self, http_client_list_response): grants = Grants(http_client_list_response) From 8eda37c760c8e1f10486625e75b4257f0fdd7fa4 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Tue, 17 Feb 2026 10:12:37 -0500 Subject: [PATCH 173/186] TW-4624: Add specific_time_availability to AvailabilityParticipant (#457) Co-authored-by: Cursor --- CHANGELOG.md | 1 + nylas/models/availability.py | 19 ++++++++ tests/resources/test_calendars.py | 77 +++++++++++++++++++++++-------- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc91d8f7..17ee02bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ nylas-python Changelog Unreleased ---------- * UAS multi-credential update +* Added `specific_time_availability` field to `AvailabilityParticipant` for overriding open hours on specific dates v6.14.2 ---------- diff --git a/nylas/models/availability.py b/nylas/models/availability.py index 931d3943..87255ffc 100644 --- a/nylas/models/availability.py +++ b/nylas/models/availability.py @@ -77,6 +77,22 @@ class OpenHours(TypedDict): exdates: NotRequired[List[str]] +class SpecificTimeAvailability(TypedDict): + """ + Interface of a participant's availability for specific dates. + Overrides open_hours for the specified dates. + + Attributes: + dates: The date in ISO 8601 format. + start: Start time in 24-hour time format. Leading 0's are left off. + end: End time in 24-hour time format. Leading 0's are left off. + """ + + date: str + start: str + end: str + + class AvailabilityRules(TypedDict): """ Interface for the availability rules for a Nylas calendar. @@ -111,11 +127,14 @@ class AvailabilityParticipant(TypedDict): calendar_ids: An optional list of the calendar IDs associated with each participant's email address. If not provided, Nylas uses the primary calendar ID. open_hours: Open hours for this participant. The endpoint searches for free time slots during these open hours. + specific_time_availability: Specific availability for this participant that overrides open_hours + for the specified dates. """ email: str calendar_ids: NotRequired[List[str]] open_hours: NotRequired[List[OpenHours]] + specific_time_availability: NotRequired[List[SpecificTimeAvailability]] class GetAvailabilityRequest(TypedDict): diff --git a/tests/resources/test_calendars.py b/tests/resources/test_calendars.py index c2f9f8f6..a07ccd44 100644 --- a/tests/resources/test_calendars.py +++ b/tests/resources/test_calendars.py @@ -320,31 +320,23 @@ def test_get_availability(self, http_client_response): request_body = { "start_time": 1497916800, "end_time": 1498003200, - "duration_minutes": 30, + "duration_minutes": 60, "interval_minutes": 30, - "free_busy": [ + "round_to_30_minutes": True, + "participants": [ { "email": "test@gmail.com", - } - ], - "open_hours": [ - { - "days": ["monday", "wednesday"], - "timezone": "America/New_York", - "start": "08:00", - "end": "18:00", - "restrictions": [ + "calendar_ids": ["primary"], + "open_hours": [ { - "days": ["monday"], - "start": "12:00", - "end": "13:00", + "days": [1, 3], + "timezone": "America/New_York", + "start": "08:00", + "end": "18:00", } ], } ], - "duration_minutes": 60, - "interval_minutes": 30, - "round_to_30_minutes": True, "availability_rules": { "availability_method": "max-availability", "buffer": {"before": 10, "after": 10}, @@ -362,7 +354,56 @@ def test_get_availability(self, http_client_response): }, } - calendars.get_availability(request_body,overrides=None,) + calendars.get_availability(request_body, overrides=None) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/calendars/availability", + None, + None, + request_body, + overrides=None, + ) + + def test_get_availability_with_specific_time_availability(self, http_client_response): + calendars = Calendars(http_client_response) + request_body = { + "start_time": 1497916800, + "end_time": 1498003200, + "duration_minutes": 60, + "interval_minutes": 30, + "participants": [ + { + "email": "test@gmail.com", + "calendar_ids": ["primary"], + "open_hours": [ + { + "days": [1, 2, 3, 4, 5], + "timezone": "America/New_York", + "start": "9:00", + "end": "17:00", + } + ], + "specific_time_availability": [ + { + "date": "2024-03-15", + "start": "10:00", + "end": "14:00", + }, + { + "date": "2024-03-16", + "start": "10:00", + "end": "14:00", + } + ], + } + ], + "availability_rules": { + "availability_method": "max-availability", + }, + } + + calendars.get_availability(request_body, overrides=None) http_client_response._execute.assert_called_once_with( "POST", From c04c3d8429794cd61a1bf767f155af630a416290 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Thu, 19 Feb 2026 10:29:57 -0500 Subject: [PATCH 174/186] Add 'smtp_required' option to Python SDK hosted auth (#458) --- CHANGELOG.md | 1 + nylas/models/auth.py | 3 +++ nylas/resources/auth.py | 3 +++ tests/resources/test_auth.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ee02bc..c8ef1311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Unreleased ---------- * UAS multi-credential update * Added `specific_time_availability` field to `AvailabilityParticipant` for overriding open hours on specific dates +* Added `smtp_required` option to hosted authentication config to require users to enter SMTP settings during IMAP authentication v6.14.2 ---------- diff --git a/nylas/models/auth.py b/nylas/models/auth.py index 22464783..6c836285 100644 --- a/nylas/models/auth.py +++ b/nylas/models/auth.py @@ -36,6 +36,8 @@ class URLForAuthenticationConfig(TypedDict): state: Optional state to be returned after authentication login_hint: Prefill the login name (usually email) during authorization flow. If a Grant for the provided email already exists, a Grant's re-auth will automatically be initiated. + smtp_required: If True, adds options=smtp_required so users must enter SMTP settings during + authentication. Relevant for IMAP; avoids grant errors when sending email later. """ client_id: str @@ -48,6 +50,7 @@ class URLForAuthenticationConfig(TypedDict): state: NotRequired[str] login_hint: NotRequired[str] credential_id: NotRequired[str] + smtp_required: NotRequired[bool] class URLForAdminConsentConfig(URLForAuthenticationConfig): diff --git a/nylas/resources/auth.py b/nylas/resources/auth.py index 18dfc658..6129bb28 100644 --- a/nylas/resources/auth.py +++ b/nylas/resources/auth.py @@ -35,6 +35,9 @@ def _build_query(config: dict) -> dict: if "scope" in config: config["scope"] = " ".join(config["scope"]) + if config.pop("smtp_required", None): + config["options"] = "smtp_required" + return config diff --git a/tests/resources/test_auth.py b/tests/resources/test_auth.py index f592de8f..8548c347 100644 --- a/tests/resources/test_auth.py +++ b/tests/resources/test_auth.py @@ -37,6 +37,30 @@ def test_build_query(self): "scope": "email calendar", } + def test_build_query_with_smtp_required_true(self): + config = { + "foo": "bar", + "scope": ["email"], + "smtp_required": True, + } + result = _build_query(config) + assert result["options"] == "smtp_required" + assert "smtp_required" not in result # must not leak into URL params + + def test_build_query_smtp_required_false_omits_options(self): + config = { + "foo": "bar", + "scope": ["email"], + "smtp_required": False, + } + result = _build_query(config) + assert "options" not in result + + def test_build_query_smtp_required_omitted_omits_options(self): + config = {"foo": "bar", "scope": ["email"]} + result = _build_query(config) + assert "options" not in result + def test_build_query_with_pkce(self): config = { "foo": "bar", @@ -52,6 +76,18 @@ def test_build_query_with_pkce(self): "code_challenge_method": "s256", } + def test_build_query_with_pkce_and_smtp_required(self): + config = { + "foo": "bar", + "scope": ["email"], + "smtp_required": True, + } + result = _build_query_with_pkce(config, "secret-hash-123") + assert result["options"] == "smtp_required" + assert "smtp_required" not in result # must not leak into URL params + assert result["code_challenge"] == "secret-hash-123" + assert result["code_challenge_method"] == "s256" + def test_build_query_with_admin_consent(self): config = { "foo": "bar", From 204e7ef862905ce8133ac4d6e35638a343fe6d4a Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Fri, 3 Apr 2026 12:08:12 -0400 Subject: [PATCH 175/186] Clarify list-events query param documentation (#460) --- CHANGELOG.md | 1 + nylas/models/events.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ef1311..99230eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Unreleased * UAS multi-credential update * Added `specific_time_availability` field to `AvailabilityParticipant` for overriding open hours on specific dates * Added `smtp_required` option to hosted authentication config to require users to enter SMTP settings during IMAP authentication +* Clarified `ListEventQueryParams` documentation (attribute order, field descriptions, and `expand_recurring` wording) v6.14.2 ---------- diff --git a/nylas/models/events.py b/nylas/models/events.py index c4a20458..3fa7686e 100644 --- a/nylas/models/events.py +++ b/nylas/models/events.py @@ -818,21 +818,21 @@ class ListEventQueryParams(ListQueryParams): expand_recurring: If true, the response will include an event for each occurrence of a recurring event within the requested time range. If false, only a single primary event will be returned for each recurring event. - Cannot be used when filtering on metadata. Defaults to false. + Cannot be used when filtering on metadata. busy: Returns events with a busy status of true. order_by: Order results by the specified field. Currently only start is supported. - event_type (NotRequired[List[EventType]]): (Google only) Filter events by event type. + event_type: (Google only) Filter events by event type. You can pass the query parameter multiple times to select or exclude multiple event types. - master_event_id (NotRequired[str]): Filter for instances of recurring events with the - specified master_event_id. Not respected by metadata filtering. - tentative_as_busy: When set to false, treats tentative calendar events as busy:false. - Only applicable for Microsoft and EWS calendar providers. Defaults to true. + master_event_id: Filter for instances of recurring events with the given + master event ID. Not respected by metadata filtering. select: Comma-separated list of fields to return in the response. This allows you to receive only the portion of object data that you're interested in. - limit (NotRequired[int]): The maximum number of objects to return. + tentative_as_busy: When set to false, treats tentative calendar events as busy:false. + Only applicable for Microsoft and EWS calendar providers. Defaults to true. + limit: The maximum number of objects to return. This field defaults to 50. The maximum allowed value is 200. - page_token (NotRequired[str]): An identifier that specifies which page of data to return. + page_token: An identifier that specifies which page of data to return. This value should be taken from a ListResponse object's next_cursor parameter. """ From c15faa5d35720e138a9ea1950d6ef355ff0bbfcf Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Fri, 3 Apr 2026 13:50:38 -0400 Subject: [PATCH 176/186] v6.14.3-release (#463) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 3 +++ nylas/_client_sdk_version.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ae660d52..f4a7cada 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.14.2 +current_version = 6.14.3 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 99230eb4..c218c5d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ nylas-python Changelog ====================== Unreleased ---------- + +v6.14.3 +---------- * UAS multi-credential update * Added `specific_time_availability` field to `AvailabilityParticipant` for overriding open hours on specific dates * Added `smtp_required` option to hosted authentication config to require users to enter SMTP settings during IMAP authentication diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index f86ae333..e3477235 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.14.2" +__VERSION__ = "6.14.3" From 2910584df6459784a3376d7e384f1a3457acd2b7 Mon Sep 17 00:00:00 2001 From: samLRodrigues <165329729+samLRodrigues@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:47:02 -0300 Subject: [PATCH 177/186] =?UTF-8?q?CUST-5289=20[v3]=20Python=20SDK=20ListG?= =?UTF-8?q?rantsQueryParams=20uses=20camelCase=20keys=20t=E2=80=A6=20(#464?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nylas/models/grants.py | 19 ++++++++---- nylas/resources/grants.py | 24 ++++++++++++++- tests/resources/test_grants.py | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/nylas/models/grants.py b/nylas/models/grants.py index 4cae4c12..7d793a21 100644 --- a/nylas/models/grants.py +++ b/nylas/models/grants.py @@ -83,23 +83,30 @@ class ListGrantsQueryParams(TypedDict): limit: The maximum number of objects to return. This field defaults to 10. The maximum allowed value is 200. offset: Offset grant results by this number. - sortBy: Sort entries by field name - orderBy: Specify ascending or descending order. + sort_by: Sort entries by field name. + order_by: Specify ascending or descending order. since: Scope grants from a specific point in time by Unix timestamp. before: Scope grants to a specific point in time by Unix timestamp. email: Filtering your query based on grant email address (if applicable) - grantStatus: Filtering your query based on grant email status (if applicable) + grant_status: Filtering your query based on grant email status (if applicable) ip: Filtering your query based on grant IP address provider: Filtering your query based on OAuth provider + sortBy: Deprecated camelCase alias for sort_by. + orderBy: Deprecated camelCase alias for order_by. + grantStatus: Deprecated camelCase alias for grant_status. """ limit: NotRequired[int] offset: NotRequired[int] - sortBy: NotRequired[str] - orderBy: NotRequired[str] + sort_by: NotRequired[str] + order_by: NotRequired[str] since: NotRequired[int] before: NotRequired[int] email: NotRequired[str] - grantStatus: NotRequired[str] + grant_status: NotRequired[str] ip: NotRequired[str] provider: NotRequired[Provider] + # Backward-compatible aliases for callers still passing camelCase keys. + sortBy: NotRequired[str] + orderBy: NotRequired[str] + grantStatus: NotRequired[str] diff --git a/nylas/resources/grants.py b/nylas/resources/grants.py index 06a951bc..4a7675b6 100644 --- a/nylas/resources/grants.py +++ b/nylas/resources/grants.py @@ -13,6 +13,28 @@ from nylas.models.response import Response, ListResponse, DeleteResponse +def _normalize_grants_query_params(query_params: ListGrantsQueryParams = None) -> dict: + if not query_params: + return query_params + + normalized_query_params = dict(query_params) + key_aliases = { + "sortBy": "sort_by", + "orderBy": "order_by", + "grantStatus": "grant_status", + } + + for camel_case_key, snake_case_key in key_aliases.items(): + if camel_case_key in normalized_query_params: + if snake_case_key not in normalized_query_params: + normalized_query_params[snake_case_key] = normalized_query_params[ + camel_case_key + ] + del normalized_query_params[camel_case_key] + + return normalized_query_params + + class Grants( ListableApiResource, FindableApiResource, @@ -47,7 +69,7 @@ def list( return super().list( path="/v3/grants", response_type=Grant, - query_params=query_params, + query_params=_normalize_grants_query_params(query_params), overrides=overrides, ) diff --git a/tests/resources/test_grants.py b/tests/resources/test_grants.py index bcb6e88b..c96a6755 100644 --- a/tests/resources/test_grants.py +++ b/tests/resources/test_grants.py @@ -68,6 +68,61 @@ def test_list_grants(self, http_client_list_response): "GET", "/v3/grants", None, None, None, overrides=None ) + def test_list_grants_normalizes_camel_case_query_params( + self, http_client_list_response + ): + grants = Grants(http_client_list_response) + + grants.list( + query_params={ + "sortBy": "created_at", + "orderBy": "asc", + "grantStatus": "valid", + "limit": 10, + } + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants", + None, + { + "sort_by": "created_at", + "order_by": "asc", + "grant_status": "valid", + "limit": 10, + }, + None, + overrides=None, + ) + + def test_list_grants_prefers_snake_case_query_params(self, http_client_list_response): + grants = Grants(http_client_list_response) + + grants.list( + query_params={ + "sortBy": "updated_at", + "sort_by": "created_at", + "orderBy": "desc", + "order_by": "asc", + "grantStatus": "invalid", + "grant_status": "valid", + } + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants", + None, + { + "sort_by": "created_at", + "order_by": "asc", + "grant_status": "valid", + }, + None, + overrides=None, + ) + def test_find_grant(self, http_client_response): grants = Grants(http_client_response) From 6853659250cee9202d99373b3c351da5d9c05051 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Fri, 17 Apr 2026 14:58:58 -0400 Subject: [PATCH 178/186] Add Transactional Send (POST /v3/domains/{domain_name}/messages/send) (#465) --- CHANGELOG.md | 1 + nylas/client.py | 11 ++ nylas/models/transactional_send.py | 63 +++++++++++ nylas/resources/transactional_send.py | 73 ++++++++++++ tests/resources/test_transactional_send.py | 125 +++++++++++++++++++++ tests/test_client.py | 5 + 6 files changed, 278 insertions(+) create mode 100644 nylas/models/transactional_send.py create mode 100644 nylas/resources/transactional_send.py create mode 100644 tests/resources/test_transactional_send.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c218c5d4..711a96fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ nylas-python Changelog ====================== Unreleased ---------- +* 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`) v6.14.3 ---------- diff --git a/nylas/client.py b/nylas/client.py index 00dbf94c..50cee026 100644 --- a/nylas/client.py +++ b/nylas/client.py @@ -9,6 +9,7 @@ from nylas.resources.folders import Folders from nylas.resources.messages import Messages from nylas.resources.threads import Threads +from nylas.resources.transactional_send import TransactionalSend from nylas.resources.webhooks import Webhooks from nylas.resources.contacts import Contacts from nylas.resources.drafts import Drafts @@ -162,6 +163,16 @@ def threads(self) -> Threads: """ return Threads(self.http_client) + @property + def transactional_send(self) -> TransactionalSend: + """ + Access the Transactional Send API. + + Returns: + The Transactional Send API. + """ + return TransactionalSend(self.http_client) + @property def webhooks(self) -> Webhooks: """ diff --git a/nylas/models/transactional_send.py b/nylas/models/transactional_send.py new file mode 100644 index 00000000..b9b4a815 --- /dev/null +++ b/nylas/models/transactional_send.py @@ -0,0 +1,63 @@ +from typing import Any, Dict, List + +from typing_extensions import NotRequired, Required, TypedDict + +from nylas.models.attachments import CreateAttachmentRequest +from nylas.models.drafts import CustomHeader, TrackingOptions +from nylas.models.events import EmailName + + +class TransactionalTemplate(TypedDict, total=False): + """ + Template selection for a transactional send request. + + Attributes: + id: The template ID. + strict: When true, Nylas returns an error if the template contains undefined variables. + variables: Key/value pairs substituted into the template. + """ + + id: Required[str] + strict: NotRequired[bool] + variables: NotRequired[Dict[str, Any]] + + +class TransactionalSendMessageRequest(TypedDict, total=False): + """ + Request body for POST /v3/domains/{domain_name}/messages/send. + + Use ``from_`` for the sender; it is serialized as JSON ``from`` (``from`` is a Python keyword). + + Attributes: + to: Recipients (required by the API). + from_: Sender ``email`` / optional ``name`` (required by the API). + subject: Subject line. + body: HTML or plain body depending on ``is_plaintext``. + cc: CC recipients. + bcc: BCC recipients. + reply_to: Reply-To recipients. + attachments: File attachments. + send_at: Unix timestamp to send the message later. + reply_to_message_id: Message being replied to. + tracking_options: Open/link tracking settings. + custom_headers: Custom MIME headers. + metadata: String-keyed metadata. + is_plaintext: Send body as plain text when true. + template: Application template to render (optional vs. body/subject). + """ + + to: Required[List[EmailName]] + from_: Required[EmailName] + subject: NotRequired[str] + body: NotRequired[str] + cc: NotRequired[List[EmailName]] + bcc: NotRequired[List[EmailName]] + reply_to: NotRequired[List[EmailName]] + attachments: NotRequired[List[CreateAttachmentRequest]] + send_at: NotRequired[int] + reply_to_message_id: NotRequired[str] + tracking_options: NotRequired[TrackingOptions] + custom_headers: NotRequired[List[CustomHeader]] + metadata: NotRequired[Dict[str, Any]] + is_plaintext: NotRequired[bool] + template: NotRequired[TransactionalTemplate] diff --git a/nylas/resources/transactional_send.py b/nylas/resources/transactional_send.py new file mode 100644 index 00000000..e6eae0e9 --- /dev/null +++ b/nylas/resources/transactional_send.py @@ -0,0 +1,73 @@ +import io +import urllib.parse + +from nylas.config import RequestOverrides +from nylas.models.messages import Message +from nylas.models.response import Response +from nylas.models.transactional_send import TransactionalSendMessageRequest +from nylas.resources.resource import Resource +from nylas.utils.file_utils import ( + MAXIMUM_JSON_ATTACHMENT_SIZE, + _build_form_request, + encode_stream_to_base64, +) + + +class TransactionalSend(Resource): + """ + Nylas Transactional Send API. + + Send email from a verified domain without a grant context. + """ + + def send( + self, + domain_name: str, + request_body: TransactionalSendMessageRequest, + overrides: RequestOverrides = None, + ) -> Response[Message]: + """ + Send a transactional email from the specified domain. + + Args: + domain_name: The domain Nylas sends from (must be verified in the dashboard). + request_body: Message fields; use ``from_`` for the sender (maps to JSON ``from``). + overrides: Per-request overrides for the HTTP client. + + Returns: + The sent message in a ``Response``. + """ + path = ( + f"/v3/domains/{urllib.parse.quote(domain_name, safe='')}/messages/send" + ) + form_data = None + json_body = None + + if "from_" in request_body and "from" not in request_body: + request_body["from"] = request_body["from_"] + del request_body["from_"] + + attachment_size = sum( + attachment.get("size", 0) + for attachment in request_body.get("attachments", []) + ) + if attachment_size >= MAXIMUM_JSON_ATTACHMENT_SIZE: + form_data = _build_form_request(request_body) + else: + for attachment in request_body.get("attachments", []): + if issubclass(type(attachment["content"]), io.IOBase): + attachment["content"] = encode_stream_to_base64( + attachment["content"] + ) + + json_body = request_body + + json_response, headers = self._http_client._execute( + method="POST", + path=path, + request_body=json_body, + data=form_data, + overrides=overrides, + ) + + return Response.from_dict(json_response, Message, headers) diff --git a/tests/resources/test_transactional_send.py b/tests/resources/test_transactional_send.py new file mode 100644 index 00000000..a4843dab --- /dev/null +++ b/tests/resources/test_transactional_send.py @@ -0,0 +1,125 @@ +from unittest.mock import Mock, patch + +from nylas.resources.transactional_send import TransactionalSend + + +class TestTransactionalSend: + def test_send_transactional_message(self, http_client_response): + transactional_send = TransactionalSend(http_client_response) + request_body = { + "subject": "Welcome", + "to": [{"name": "Jane Doe", "email": "jane.doe@example.com"}], + "from_": {"name": "ACME Support", "email": "support@acme.com"}, + "body": "Welcome to ACME.", + } + + transactional_send.send(domain_name="mail.acme.com", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/mail.acme.com/messages/send", + request_body={ + "subject": "Welcome", + "to": [{"name": "Jane Doe", "email": "jane.doe@example.com"}], + "from": {"name": "ACME Support", "email": "support@acme.com"}, + "body": "Welcome to ACME.", + }, + data=None, + overrides=None, + ) + + def test_send_domain_name_url_encoded(self, http_client_response): + transactional_send = TransactionalSend(http_client_response) + request_body = { + "to": [{"email": "a@b.com"}], + "from_": {"email": "support@acme.com"}, + } + + transactional_send.send( + domain_name="weird/slash.com", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/weird%2Fslash.com/messages/send", + request_body={ + "to": [{"email": "a@b.com"}], + "from": {"email": "support@acme.com"}, + }, + data=None, + overrides=None, + ) + + def test_send_small_attachment(self, http_client_response): + transactional_send = TransactionalSend(http_client_response) + request_body = { + "to": [{"email": "j@example.com"}], + "from_": {"email": "support@acme.com"}, + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3, + }, + ], + } + + transactional_send.send(domain_name="acme.com", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/acme.com/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + + def test_send_large_attachment(self, http_client_response): + transactional_send = TransactionalSend(http_client_response) + mock_encoder = Mock() + request_body = { + "to": [{"email": "j@example.com"}], + "from_": {"email": "support@acme.com"}, + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3 * 1024 * 1024, + }, + ], + } + + with patch( + "nylas.resources.transactional_send._build_form_request", + return_value=mock_encoder, + ): + transactional_send.send(domain_name="acme.com", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/acme.com/messages/send", + request_body=None, + data=mock_encoder, + overrides=None, + ) + + def test_send_with_existing_from_field_unchanged(self, http_client_response): + transactional_send = TransactionalSend(http_client_response) + request_body = { + "to": [{"email": "j@example.com"}], + "from": {"email": "direct@acme.com"}, + "from_": {"email": "ignored@acme.com"}, + } + + transactional_send.send(domain_name="acme.com", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/domains/acme.com/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) diff --git a/tests/test_client.py b/tests/test_client.py index 58181841..ac3d23bc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,6 +11,7 @@ from nylas.resources.grants import Grants from nylas.resources.messages import Messages from nylas.resources.threads import Threads +from nylas.resources.transactional_send import TransactionalSend from nylas.resources.webhooks import Webhooks @@ -83,6 +84,10 @@ def test_client_threads_property(self, client): assert client.threads is not None assert type(client.threads) is Threads + def test_client_transactional_send_property(self, client): + assert client.transactional_send is not None + assert type(client.transactional_send) is TransactionalSend + def test_client_webhooks_property(self, client): assert client.webhooks is not None assert type(client.webhooks) is Webhooks From b2421bb10678e247a2a778c0704ff059e6db5169 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Sun, 19 Apr 2026 17:43:04 -0400 Subject: [PATCH 179/186] Add Manage Domains API and Nylas service account request signing (#466) --- .pylintrc | 1 - CHANGELOG.md | 1 + nylas/client.py | 11 + nylas/handler/api_resources.py | 18 +- nylas/handler/http_client.py | 22 +- nylas/handler/service_account.py | 136 +++++++++++ nylas/models/domains.py | 100 ++++++++ nylas/resources/domains.py | 222 +++++++++++++++++ pyproject.toml | 1 + setup.py | 2 +- tests/handler/test_http_client.py | 43 ++++ tests/handler/test_service_account.py | 177 ++++++++++++++ tests/resources/test_domains.py | 334 ++++++++++++++++++++++++++ tests/test_client.py | 5 + 14 files changed, 1065 insertions(+), 8 deletions(-) create mode 100644 nylas/handler/service_account.py create mode 100644 nylas/models/domains.py create mode 100644 nylas/resources/domains.py create mode 100644 tests/handler/test_service_account.py create mode 100644 tests/resources/test_domains.py diff --git a/.pylintrc b/.pylintrc index cfbff336..f789fc61 100644 --- a/.pylintrc +++ b/.pylintrc @@ -11,7 +11,6 @@ disable= too-many-instance-attributes, unnecessary-pass, too-many-arguments, - too-many-positional-arguments, too-few-public-methods, [TYPECHECK] diff --git a/CHANGELOG.md b/CHANGELOG.md index 711a96fd..11a746d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ nylas-python Changelog ====================== Unreleased ---------- +* 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`) v6.14.3 diff --git a/nylas/client.py b/nylas/client.py index 50cee026..1af8d53f 100644 --- a/nylas/client.py +++ b/nylas/client.py @@ -13,6 +13,7 @@ from nylas.resources.webhooks import Webhooks from nylas.resources.contacts import Contacts from nylas.resources.drafts import Drafts +from nylas.resources.domains import Domains from nylas.resources.grants import Grants from nylas.resources.scheduler import Scheduler from nylas.resources.notetakers import Notetakers @@ -113,6 +114,16 @@ def drafts(self) -> Drafts: """ return Drafts(self.http_client) + @property + def domains(self) -> Domains: + """ + Access the Manage Domains API. + + Returns: + The Manage Domains API. + """ + return Domains(self.http_client) + @property def events(self) -> Events: """ diff --git a/nylas/handler/api_resources.py b/nylas/handler/api_resources.py index 25af6a69..6badcf43 100644 --- a/nylas/handler/api_resources.py +++ b/nylas/handler/api_resources.py @@ -49,10 +49,14 @@ def create( query_params=None, request_body=None, overrides=None, + serialized_json_body=None, ) -> Response: + kwargs = {"overrides": overrides} + if serialized_json_body is not None: + kwargs["serialized_json_body"] = serialized_json_body response_json, response_headers = self._http_client._execute( - "POST", path, headers, query_params, request_body, overrides=overrides + "POST", path, headers, query_params, request_body, **kwargs ) return Response.from_dict(response_json, response_type, response_headers) @@ -68,9 +72,13 @@ def update( request_body=None, method="PUT", overrides=None, + serialized_json_body=None, ): + kwargs = {"overrides": overrides} + if serialized_json_body is not None: + kwargs["serialized_json_body"] = serialized_json_body response_json, response_headers = self._http_client._execute( - method, path, headers, query_params, request_body, overrides=overrides + method, path, headers, query_params, request_body, **kwargs ) return Response.from_dict(response_json, response_type, response_headers) @@ -86,9 +94,13 @@ def patch( request_body=None, method="PATCH", overrides=None, + serialized_json_body=None, ): + kwargs = {"overrides": overrides} + if serialized_json_body is not None: + kwargs["serialized_json_body"] = serialized_json_body response_json, response_headers = self._http_client._execute( - method, path, headers, query_params, request_body, overrides=overrides + method, path, headers, query_params, request_body, **kwargs ) return Response.from_dict(response_json, response_type, response_headers) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index 9027736d..023e2d10 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -81,9 +81,17 @@ def _execute( request_body=None, data=None, overrides=None, + serialized_json_body=None, ) -> dict: request = self._build_request( - method, path, headers, query_params, request_body, data, overrides + method, + path, + headers, + query_params, + request_body, + data, + overrides, + serialized_json_body=serialized_json_body, ) timeout = self.timeout @@ -93,8 +101,12 @@ def _execute( # Serialize request_body to JSON with ensure_ascii=False to preserve UTF-8 characters # and allow_nan=True to support NaN/Infinity values (matching default json.dumps behavior). # Encode as UTF-8 bytes to avoid Latin-1 encoding errors with special characters. + # When serialized_json_body is set (e.g. Nylas service account signing), send those exact + # bytes so the wire body matches the payload that was signed. json_data = None - if request_body is not None and data is None: + if serialized_json_body is not None and data is None: + json_data = serialized_json_body + elif request_body is not None and data is None: json_data = json.dumps(request_body, ensure_ascii=False, allow_nan=True).encode("utf-8") try: response = requests.request( @@ -151,6 +163,7 @@ def _build_request( request_body=None, data=None, overrides=None, + serialized_json_body=None, ) -> dict: api_server = self.api_server if overrides and overrides.get("api_uri"): @@ -158,7 +171,10 @@ def _build_request( base_url = f"{api_server}{path}" url = _build_query_params(base_url, query_params) if query_params else base_url - headers = self._build_headers(headers, request_body, data, overrides) + body_for_content_type = ( + request_body if request_body is not None else serialized_json_body + ) + headers = self._build_headers(headers, body_for_content_type, data, overrides) return { "method": method, diff --git a/nylas/handler/service_account.py b/nylas/handler/service_account.py new file mode 100644 index 00000000..ce86b8d6 --- /dev/null +++ b/nylas/handler/service_account.py @@ -0,0 +1,136 @@ +""" +Nylas Service Account request signing for organization admin APIs. + +See https://developer.nylas.com/docs/v3/auth/nylas-service-account/ + +If you set X-Nylas-* headers manually via RequestOverrides, the HTTP request body must be +byte-identical to the canonical JSON string used when computing the signature. +""" + +from __future__ import annotations + +import base64 +import json +import secrets +import string +import time +from typing import Any, Dict, Optional, Tuple + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa + +_NONCE_ALPHABET = string.ascii_letters + string.digits +_NONCE_LENGTH = 20 + + +def canonical_json(data: Dict[str, Any]) -> str: + """ + Deterministic JSON with sorted keys at each object level, matching Nylas's reference + implementation for service account signing. + """ + keys = sorted(data.keys()) + parts = [] + for k in keys: + key_json = json.dumps(k, ensure_ascii=False, allow_nan=False) + v = data[k] + if isinstance(v, dict): + val_json = canonical_json(v) + else: + val_json = json.dumps( + v, ensure_ascii=False, allow_nan=False, separators=(",", ":") + ) + parts.append(f"{key_json}:{val_json}") + return "{" + ",".join(parts) + "}" + + +def load_rsa_private_key_from_pem(pem: str) -> rsa.RSAPrivateKey: + """Load an RSA private key from a PEM string (PKCS#1 or PKCS#8).""" + key_bytes = pem.encode("utf-8") if isinstance(pem, str) else pem + loaded = serialization.load_pem_private_key(key_bytes, password=None) + if not isinstance(loaded, rsa.RSAPrivateKey): + raise ValueError("Private key must be RSA") + return loaded + + +def _signing_envelope_bytes( + path: str, + method: str, + timestamp: int, + nonce: str, + body: Optional[Dict[str, Any]], +) -> bytes: + method_l = method.lower() + envelope: Dict[str, Any] = { + "method": method_l, + "nonce": nonce, + "path": path, + "timestamp": timestamp, + } + if method_l in ("post", "put", "patch") and body is not None: + envelope["payload"] = canonical_json(body) + canonical = canonical_json(envelope) + return canonical.encode("utf-8") + + +def sign_bytes(private_key: rsa.RSAPrivateKey, message: bytes) -> str: + """RSA PKCS#1 v1.5 signature over SHA-256(message), Base64-encoded.""" + signature = private_key.sign(message, padding.PKCS1v15(), hashes.SHA256()) + return base64.b64encode(signature).decode("ascii") + + +def generate_nonce(length: int = _NONCE_LENGTH) -> str: + """Cryptographically secure nonce (alphanumeric), default length 20.""" + return "".join(secrets.choice(_NONCE_ALPHABET) for _ in range(length)) + + +class ServiceAccountSigner: + """ + Builds the four required Nylas service account headers for a single request. + + Args: + private_key_pem: RSA private key in PEM text form (from the service account JSON). + private_key_id: Value for X-Nylas-Kid (``private_key_id`` in the JSON credentials). + """ + + def __init__(self, private_key_pem: str, private_key_id: str): + self._private_key = load_rsa_private_key_from_pem(private_key_pem) + self._private_key_id = private_key_id + + def build_headers( + self, + method: str, + path: str, + body: Optional[Dict[str, Any]] = None, + *, + timestamp: Optional[int] = None, + nonce: Optional[str] = None, + ) -> Tuple[Dict[str, str], Optional[bytes]]: + """ + Produce signing headers and optional canonical JSON body bytes. + + For POST/PUT/PATCH, ``body`` must be the same dict that will be sent; returned bytes + should be passed to HttpClient as ``serialized_json_body`` so the wire body matches + the signed payload. + + Returns: + (headers, serialized_json_body) where serialized_json_body is set for + POST/PUT/PATCH when body is not None, else None. + """ + ts = int(time.time()) if timestamp is None else int(timestamp) + n = generate_nonce() if nonce is None else nonce + + serialized: Optional[bytes] = None + body_for_sign: Optional[Dict[str, Any]] = body + if method.lower() in ("post", "put", "patch") and body is not None: + serialized = canonical_json(body).encode("utf-8") + + envelope = _signing_envelope_bytes(path, method, ts, n, body_for_sign) + signature_b64 = sign_bytes(self._private_key, envelope) + + headers = { + "X-Nylas-Kid": self._private_key_id, + "X-Nylas-Nonce": n, + "X-Nylas-Timestamp": str(ts), + "X-Nylas-Signature": signature_b64, + } + return headers, serialized diff --git a/nylas/models/domains.py b/nylas/models/domains.py new file mode 100644 index 00000000..cca1b7c1 --- /dev/null +++ b/nylas/models/domains.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass, field +from typing import Any, Literal, Optional + +from dataclasses_json import config, dataclass_json +from typing_extensions import TypedDict + +from nylas.models.list_query_params import ListQueryParams + +DomainVerificationType = Literal["ownership", "dkim", "spf", "feedback", "mx"] + + +class ListDomainsQueryParams(ListQueryParams): + """ + Query parameters for listing domains. + + Attributes: + limit: Maximum number of objects to return. + page_token: Cursor for the next page (from ``next_cursor`` on the previous response). + """ + + pass + + +class CreateDomainRequest(TypedDict): + """Request body for registering a domain.""" + + name: str + domain_address: str + + +class UpdateDomainRequest(TypedDict, total=False): + """Request body for updating a domain (currently only ``name`` is supported).""" + + name: str + + +class GetDomainInfoRequest(TypedDict): + """Request body for retrieving DNS records for a verification type.""" + + type: DomainVerificationType + + +class VerifyDomainRequest(TypedDict): + """Request body for triggering DNS verification.""" + + type: DomainVerificationType + + +@dataclass_json +@dataclass +class Domain: + """ + A domain registered for Transactional Send or Nylas Inbound. + """ + + id: str + name: str + branded: bool + domain_address: str + organization_id: str + region: str + verified_ownership: bool + verified_dkim: bool + verified_spf: bool + verified_mx: bool + verified_feedback: bool + verified_dmarc: bool + verified_arc: bool + created_at: int + updated_at: int + + +@dataclass_json +@dataclass +class DomainVerificationAttempt: + """ + DNS verification attempt or required records for a verification type. + """ + + verification_type: Optional[str] = field( + default=None, metadata=config(field_name="type") + ) + options: Optional[Any] = None + host: Optional[str] = None + value: Optional[str] = None + status: Optional[str] = None + + +@dataclass_json +@dataclass +class DomainVerificationDetails: + """ + Response data from get domain info or verify domain endpoints. + """ + + domain_id: str + attempt: Optional[DomainVerificationAttempt] = None + created_at: Optional[int] = None + expires_at: Optional[int] = None + message: Optional[str] = None diff --git a/nylas/resources/domains.py b/nylas/resources/domains.py new file mode 100644 index 00000000..bde153e1 --- /dev/null +++ b/nylas/resources/domains.py @@ -0,0 +1,222 @@ +from typing import Optional + +from nylas.config import RequestOverrides +from nylas.handler.api_resources import ( + CreatableApiResource, + DestroyableApiResource, + FindableApiResource, + ListableApiResource, + UpdatableApiResource, +) +from nylas.handler.service_account import ServiceAccountSigner +from nylas.models.domains import ( + CreateDomainRequest, + Domain, + DomainVerificationDetails, + GetDomainInfoRequest, + ListDomainsQueryParams, + UpdateDomainRequest, + VerifyDomainRequest, +) +from nylas.models.response import DeleteResponse, ListResponse, Response + + +def _merge_signer_headers( + overrides: Optional[RequestOverrides], signer_headers: Optional[dict] +) -> Optional[RequestOverrides]: + if not signer_headers: + return overrides + merged: RequestOverrides = dict(overrides) if overrides else {} + headers = dict(merged.get("headers") or {}) + headers.update(signer_headers) + merged["headers"] = headers + return merged + + +class Domains( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Manage Domains API (``/v3/admin/domains``). + + Organization admin endpoints for registering and verifying email domains used with + Transactional Send and Nylas Inbound. Optional :class:`ServiceAccountSigner` adds the + required ``X-Nylas-*`` headers; you can also supply those headers via ``RequestOverrides``. + """ + + def list( + self, + query_params: Optional[ListDomainsQueryParams] = None, + signer: Optional[ServiceAccountSigner] = None, + overrides: RequestOverrides = None, + ) -> ListResponse[Domain]: + path = "/v3/admin/domains" + merged = overrides + if signer: + hdrs, _ = signer.build_headers("GET", path, None) + merged = _merge_signer_headers(overrides, hdrs) + return super().list( + path=path, + response_type=Domain, + query_params=query_params, + overrides=merged, + ) + + def create( + self, + request_body: CreateDomainRequest, + signer: Optional[ServiceAccountSigner] = None, + overrides: RequestOverrides = None, + ) -> Response[Domain]: + path = "/v3/admin/domains" + merged = overrides + serialized = None + body_arg = request_body + if signer: + hdrs, serialized = signer.build_headers("POST", path, dict(request_body)) + merged = _merge_signer_headers(overrides, hdrs) + if serialized is not None: + body_arg = None + return super().create( + path=path, + request_body=body_arg, + response_type=Domain, + overrides=merged, + serialized_json_body=serialized, + ) + + def find( + self, + domain_id: str, + signer: Optional[ServiceAccountSigner] = None, + overrides: RequestOverrides = None, + ) -> Response[Domain]: + path = f"/v3/admin/domains/{domain_id}" + merged = overrides + if signer: + hdrs, _ = signer.build_headers("GET", path, None) + merged = _merge_signer_headers(overrides, hdrs) + return super().find( + path=path, + response_type=Domain, + overrides=merged, + ) + + def update( + self, + domain_id: str, + request_body: UpdateDomainRequest, + signer: Optional[ServiceAccountSigner] = None, + overrides: RequestOverrides = None, + ) -> Response[Domain]: + path = f"/v3/admin/domains/{domain_id}" + merged = overrides + serialized = None + body_arg = request_body + if signer: + hdrs, serialized = signer.build_headers("PUT", path, dict(request_body)) + merged = _merge_signer_headers(overrides, hdrs) + if serialized is not None: + body_arg = None + return super().update( + path=path, + request_body=body_arg, + response_type=Domain, + overrides=merged, + serialized_json_body=serialized, + ) + + def destroy( + self, + domain_id: str, + signer: Optional[ServiceAccountSigner] = None, + overrides: RequestOverrides = None, + ) -> DeleteResponse: + path = f"/v3/admin/domains/{domain_id}" + merged = overrides + if signer: + hdrs, _ = signer.build_headers("DELETE", path, None) + merged = _merge_signer_headers(overrides, hdrs) + return super().destroy(path=path, overrides=merged) + + def get_info( + self, + domain_id: str, + request_body: GetDomainInfoRequest, + signer: Optional[ServiceAccountSigner] = None, + overrides: RequestOverrides = None, + ) -> Response[DomainVerificationDetails]: + """ + Return DNS record information and verification status for the given verification type. + + Args: + domain_id: The domain ID. + request_body: Body with ``type`` (for example ``ownership`` or ``dkim``). + signer: Optional service account signer for ``X-Nylas-*`` headers. + overrides: Request overrides (for example extra headers). + + Returns: + Verification details including required DNS records. + """ + path = f"/v3/admin/domains/{domain_id}/info" + body = dict(request_body) + merged = overrides + serialized = None + if signer: + hdrs, serialized = signer.build_headers("POST", path, body) + merged = _merge_signer_headers(overrides, hdrs) + exec_kwargs = {"overrides": merged} + if serialized is not None: + exec_kwargs["serialized_json_body"] = serialized + res, headers = self._http_client._execute( + "POST", + path, + None, + None, + None if serialized is not None else body, + **exec_kwargs, + ) + return Response.from_dict(res, DomainVerificationDetails, headers) + + def verify( + self, + domain_id: str, + request_body: VerifyDomainRequest, + signer: Optional[ServiceAccountSigner] = None, + overrides: RequestOverrides = None, + ) -> Response[DomainVerificationDetails]: + """ + Trigger a verification check for the specified DNS record type. + + Args: + domain_id: The domain ID. + request_body: Body with ``type`` of verification to run. + signer: Optional service account signer for ``X-Nylas-*`` headers. + overrides: Request overrides (for example extra headers). + + Returns: + Verification attempt details and status. + """ + path = f"/v3/admin/domains/{domain_id}/verify" + body = dict(request_body) + merged = overrides + serialized = None + if signer: + hdrs, serialized = signer.build_headers("POST", path, body) + merged = _merge_signer_headers(overrides, hdrs) + exec_kwargs = {"overrides": merged} + if serialized is not None: + exec_kwargs["serialized_json_body"] = serialized + res, headers = self._http_client._execute( + "POST", + path, + None, + None, + None if serialized is not None else body, + **exec_kwargs, + ) + return Response.from_dict(res, DomainVerificationDetails, headers) diff --git a/pyproject.toml b/pyproject.toml index c46c1aff..42b4f9ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "requests-toolbelt>=1.0.0", "dataclasses-json>=0.5.9", "typing_extensions>=4.7.1", + "cryptography>=42.0.0", ] [project.optional-dependencies] diff --git a/setup.py b/setup.py index 937d3977..bbe31737 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ "requests-toolbelt>=1.0.0", "dataclasses-json>=0.5.9", "typing_extensions>=4.7.1", + "cryptography>=42.0.0", ] TEST_DEPENDENCIES = ["pytest>=7.4.0", "pytest-cov>=4.1.0", "setuptools>=69.0.3"] @@ -112,7 +113,6 @@ def main(): packages=find_packages(), install_requires=RUN_DEPENDENCIES, dependency_links=[], - tests_require=TEST_DEPENDENCIES, extras_require={ "test": TEST_DEPENDENCIES, "docs": DOCS_DEPENDENCIES, diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index f9511d4c..a0ae7d6f 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -306,6 +306,49 @@ def test_execute(self, http_client, patched_version_and_sys, patched_request): timeout=30, ) + def test_execute_with_serialized_json_body( + self, http_client, patched_version_and_sys, patched_request + ): + """Pre-serialized body bytes are sent as-is (e.g. Nylas service account signing).""" + mock_response = Mock() + mock_response.json.return_value = {"ok": True} + mock_response.headers = {} + mock_response.status_code = 200 + patched_request.return_value = mock_response + + canonical = b'{"a":1,"b":2}' + response_json, response_headers = http_client._execute( + method="POST", + path="/v3/admin/domains", + request_body=None, + serialized_json_body=canonical, + ) + + assert response_json == {"ok": True} + patched_request.assert_called_once_with( + "POST", + "https://test.nylas.com/v3/admin/domains", + headers={ + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + "Authorization": "Bearer test-key", + "Content-type": "application/json; charset=utf-8", + }, + data=canonical, + timeout=30, + ) + + def test_build_request_sets_content_type_for_serialized_json_body( + self, http_client, patched_version_and_sys + ): + request = http_client._build_request( + method="POST", + path="/signed", + request_body=None, + serialized_json_body=b"{}", + ) + assert request["headers"]["Content-type"] == "application/json; charset=utf-8" + def test_execute_override_timeout( self, http_client, patched_version_and_sys, patched_request ): diff --git a/tests/handler/test_service_account.py b/tests/handler/test_service_account.py new file mode 100644 index 00000000..bfcb4064 --- /dev/null +++ b/tests/handler/test_service_account.py @@ -0,0 +1,177 @@ +import base64 +import string + +import pytest +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa + +from nylas.handler.service_account import ( + ServiceAccountSigner, + _signing_envelope_bytes, + canonical_json, + generate_nonce, + load_rsa_private_key_from_pem, + sign_bytes, +) + + +class TestCanonicalJson: + def test_sorted_keys_flat(self): + assert canonical_json({"b": 1, "a": 2}) == '{"a":2,"b":1}' + + def test_nested_dict_sorted(self): + assert ( + canonical_json({"z": {"b": 1, "a": 2}, "y": 0}) + == '{"y":0,"z":{"a":2,"b":1}}' + ) + + def test_string_escaping(self): + s = canonical_json({"msg": 'quote"here'}) + assert s.startswith("{") + assert '"msg":' in s + assert "quote" in s + + def test_list_and_bool_values_use_json_dumps(self): + s = canonical_json({"ok": True, "items": [3, 1, 2]}) + assert '"items":[3,1,2]' in s + assert '"ok":true' in s + + +class TestServiceAccountSigning: + @pytest.fixture + def rsa_pem(self): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return ( + key, + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii"), + ) + + def test_load_pkcs8_pem(self, rsa_pem): + _, pem = rsa_pem + loaded = load_rsa_private_key_from_pem(pem) + assert isinstance(loaded, rsa.RSAPrivateKey) + + def test_load_pkcs1_traditional_pem(self, rsa_pem): + private_key, _ = rsa_pem + pem_pkcs1 = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + loaded = load_rsa_private_key_from_pem(pem_pkcs1) + assert isinstance(loaded, rsa.RSAPrivateKey) + + def test_load_pem_accepts_bytes(self, rsa_pem): + _, pem = rsa_pem + loaded = load_rsa_private_key_from_pem(pem.encode("ascii")) + assert isinstance(loaded, rsa.RSAPrivateKey) + + def test_load_non_rsa_raises(self): + ec_key = ec.generate_private_key(ec.SECP256R1()) + pem = ec_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii") + with pytest.raises(ValueError, match="Private key must be RSA"): + load_rsa_private_key_from_pem(pem) + + def test_sign_bytes_verifies_with_public_key(self, rsa_pem): + private_key, pem = rsa_pem + message = b"hello nylas canonical envelope" + sig_b64 = sign_bytes(private_key, message) + sig = base64.b64decode(sig_b64) + public_key = private_key.public_key() + public_key.verify(sig, message, padding.PKCS1v15(), hashes.SHA256()) + + def test_golden_envelope_signature_round_trip(self, rsa_pem): + """Fixed inputs: signature must verify (independent of ServiceAccountSigner time).""" + private_key, pem = rsa_pem + path = "/v3/admin/domains" + ts = 1742932766 + nonce = "abcdefabcdefabcdefab" + body = {"type": "ownership"} + envelope = _signing_envelope_bytes(path, "POST", ts, nonce, body) + sig_b64 = sign_bytes(private_key, envelope) + sig = base64.b64decode(sig_b64) + private_key.public_key().verify(sig, envelope, padding.PKCS1v15(), hashes.SHA256()) + + def test_service_account_signer_build_headers_post(self, rsa_pem): + private_key, pem = rsa_pem + signer = ServiceAccountSigner(pem, "test-kid-uuid") + headers, body_bytes = signer.build_headers( + "POST", + "/v3/admin/domains", + {"name": "My domain", "domain_address": "mail.example.com"}, + timestamp=1700000000, + nonce="nonce123456789012345", + ) + assert headers["X-Nylas-Kid"] == "test-kid-uuid" + assert headers["X-Nylas-Nonce"] == "nonce123456789012345" + assert headers["X-Nylas-Timestamp"] == "1700000000" + assert len(headers["X-Nylas-Signature"]) > 0 + assert body_bytes == canonical_json( + {"name": "My domain", "domain_address": "mail.example.com"} + ).encode("utf-8") + + envelope = _signing_envelope_bytes( + "/v3/admin/domains", + "POST", + 1700000000, + "nonce123456789012345", + {"name": "My domain", "domain_address": "mail.example.com"}, + ) + sig = base64.b64decode(headers["X-Nylas-Signature"]) + private_key.public_key().verify(sig, envelope, padding.PKCS1v15(), hashes.SHA256()) + + def test_service_account_signer_get_no_body_bytes(self, rsa_pem): + _, pem = rsa_pem + signer = ServiceAccountSigner(pem, "kid") + headers, body_bytes = signer.build_headers( + "GET", "/v3/admin/domains", None, timestamp=1, nonce="n" * 20 + ) + assert body_bytes is None + assert "X-Nylas-Signature" in headers + + def test_signing_envelope_get_omits_payload(self, rsa_pem): + private_key, _ = rsa_pem + env = _signing_envelope_bytes("/v3/admin/domains", "GET", 1, "n" * 20, None) + assert b"payload" not in env + sig_b64 = sign_bytes(private_key, env) + private_key.public_key().verify( + base64.b64decode(sig_b64), env, padding.PKCS1v15(), hashes.SHA256() + ) + + def test_signing_envelope_put_and_patch_include_payload(self, rsa_pem): + private_key, _ = rsa_pem + for method in ("PUT", "patch"): + env = _signing_envelope_bytes( + "/v3/admin/domains/x", method, 2, "m" * 20, {"name": "n"} + ) + assert b"payload" in env + sig_b64 = sign_bytes(private_key, env) + private_key.public_key().verify( + base64.b64decode(sig_b64), env, padding.PKCS1v15(), hashes.SHA256() + ) + + def test_generate_nonce_custom_length(self): + n = generate_nonce(12) + assert len(n) == 12 + assert all(c in (string.ascii_letters + string.digits) for c in n) + + def test_build_headers_patch(self, rsa_pem): + _, pem = rsa_pem + signer = ServiceAccountSigner(pem, "kid") + headers, body_bytes = signer.build_headers( + "PATCH", + "/v3/admin/example", + {"op": "replace"}, + timestamp=9, + nonce="z" * 20, + ) + assert body_bytes == canonical_json({"op": "replace"}).encode("utf-8") + assert headers["X-Nylas-Timestamp"] == "9" diff --git a/tests/resources/test_domains.py b/tests/resources/test_domains.py new file mode 100644 index 00000000..fe9d1f98 --- /dev/null +++ b/tests/resources/test_domains.py @@ -0,0 +1,334 @@ +from unittest.mock import patch + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from nylas.handler.service_account import ServiceAccountSigner +from nylas.models.domains import Domain, DomainVerificationDetails +from nylas.models.response import ListResponse, Response +from nylas.resources import domains as domains_module +from nylas.resources.domains import Domains + + +def _test_rsa_pem(): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii") + + +@pytest.fixture +def domain_data(): + return { + "id": "dom_123", + "name": "My domain", + "branded": False, + "domain_address": "mail.example.com", + "organization_id": "org_1", + "region": "us", + "verified_ownership": False, + "verified_dkim": False, + "verified_spf": False, + "verified_mx": False, + "verified_feedback": False, + "verified_dmarc": False, + "verified_arc": False, + "created_at": 1, + "updated_at": 2, + } + + +class TestMergeSignerHeaders: + def test_returns_overrides_when_no_signer_headers(self): + assert domains_module._merge_signer_headers({"timeout": 5}, None) == {"timeout": 5} + assert domains_module._merge_signer_headers(None, {}) is None + + def test_merges_headers_preserving_existing(self): + merged = domains_module._merge_signer_headers( + {"headers": {"X-Existing": "keep"}, "timeout": 30}, + {"X-Nylas-Kid": "kid", "X-Nylas-Signature": "sig"}, + ) + assert merged["timeout"] == 30 + assert merged["headers"]["X-Existing"] == "keep" + assert merged["headers"]["X-Nylas-Kid"] == "kid" + assert merged["headers"]["X-Nylas-Signature"] == "sig" + + def test_creates_overrides_when_none(self): + merged = domains_module._merge_signer_headers( + None, {"X-Nylas-Kid": "kid"} + ) + assert merged == {"headers": {"X-Nylas-Kid": "kid"}} + + +class TestDomains: + def test_domain_model_from_dict(self, domain_data): + d = Domain.from_dict(domain_data) + assert d.id == "dom_123" + assert d.domain_address == "mail.example.com" + + def test_domain_verification_details_from_dict(self): + raw = { + "domain_id": "d1", + "attempt": {"type": "dkim", "status": "pending"}, + "message": "add TXT", + } + d = DomainVerificationDetails.from_dict(raw, infer_missing=True) + assert d.domain_id == "d1" + assert d.attempt is not None + assert d.attempt.verification_type == "dkim" + assert d.message == "add TXT" + + def test_list_without_signer(self, http_client_list_response): + with patch( + "nylas.models.response.ListResponse.from_dict", + return_value=ListResponse([], "rid", None, {}), + ): + domains = Domains(http_client_list_response) + domains.list() + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/admin/domains", + None, + None, + None, + overrides=None, + ) + + def test_list_with_query_and_signer(self, http_client_list_response): + pem = _test_rsa_pem() + signer = ServiceAccountSigner(pem, "kid-1") + with patch( + "nylas.models.response.ListResponse.from_dict", + return_value=ListResponse([], "rid", None, {}), + ): + domains = Domains(http_client_list_response) + domains.list(query_params={"limit": 10}, signer=signer) + args, kwargs = http_client_list_response._execute.call_args + assert args[0] == "GET" + assert "/v3/admin/domains" in args[1] + ov = kwargs.get("overrides") or {} + assert "X-Nylas-Signature" in (ov.get("headers") or {}) + + def test_create_without_signer(self, http_client_response, domain_data): + with patch( + "nylas.models.response.Response.from_dict", + return_value=Response(domain_data, "rid", {}), + ): + domains = Domains(http_client_response) + domains.create( + {"name": "My domain", "domain_address": "mail.example.com"}, + ) + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/admin/domains", + None, + None, + {"name": "My domain", "domain_address": "mail.example.com"}, + overrides=None, + ) + + def test_find_without_signer(self, http_client_response, domain_data): + with patch( + "nylas.models.response.Response.from_dict", + return_value=Response(domain_data, "rid", {}), + ): + domains = Domains(http_client_response) + domains.find("dom_abc") + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/admin/domains/dom_abc", + None, + None, + None, + overrides=None, + ) + + def test_find_with_signer(self, http_client_response, domain_data): + pem = _test_rsa_pem() + signer = ServiceAccountSigner(pem, "kid-x") + with patch( + "nylas.models.response.Response.from_dict", + return_value=Response(domain_data, "rid", {}), + ): + domains = Domains(http_client_response) + domains.find("dom_abc", signer=signer) + ov = http_client_response._execute.call_args.kwargs.get("overrides") or {} + assert "X-Nylas-Signature" in (ov.get("headers") or {}) + + def test_update_without_signer(self, http_client_response, domain_data): + with patch( + "nylas.models.response.Response.from_dict", + return_value=Response(domain_data, "rid", {}), + ): + domains = Domains(http_client_response) + domains.update("dom_123", {"name": "Renamed"}) + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/admin/domains/dom_123", + None, + None, + {"name": "Renamed"}, + overrides=None, + ) + + def test_update_with_signer(self, http_client_response, domain_data): + pem = _test_rsa_pem() + signer = ServiceAccountSigner(pem, "kid-1") + with patch( + "nylas.models.response.Response.from_dict", + return_value=Response(domain_data, "rid", {}), + ): + domains = Domains(http_client_response) + domains.update("dom_123", {"name": "Renamed"}, signer=signer) + kwargs = http_client_response._execute.call_args.kwargs + assert "serialized_json_body" in kwargs + assert http_client_response._execute.call_args[0][4] is None + + def test_create_with_signer_sends_serialized_body(self, http_client_response, domain_data): + pem = _test_rsa_pem() + signer = ServiceAccountSigner(pem, "kid-1") + with patch( + "nylas.models.response.Response.from_dict", + return_value=Response(domain_data, "rid", {}), + ): + domains = Domains(http_client_response) + domains.create( + {"name": "My domain", "domain_address": "mail.example.com"}, + signer=signer, + ) + kwargs = http_client_response._execute.call_args.kwargs + assert "serialized_json_body" in kwargs + assert kwargs["serialized_json_body"].startswith(b"{") + pos = http_client_response._execute.call_args[0] + assert pos[4] is None + + def test_destroy_with_signer(self, http_client_delete_response): + from nylas.models.response import DeleteResponse + + pem = _test_rsa_pem() + signer = ServiceAccountSigner(pem, "kid-del") + http_client_delete_response._execute.return_value = ( + {"request_id": "del-rid"}, + {}, + ) + domains = Domains(http_client_delete_response) + domains.destroy("dom_123", signer=signer) + ov = http_client_delete_response._execute.call_args.kwargs["overrides"] + assert "X-Nylas-Signature" in ov["headers"] + + def test_destroy(self, http_client_delete_response): + from nylas.models.response import DeleteResponse + + http_client_delete_response._execute.return_value = ( + {"request_id": "del-rid"}, + {}, + ) + domains = Domains(http_client_delete_response) + out = domains.destroy("dom_123") + assert isinstance(out, DeleteResponse) + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/admin/domains/dom_123", + None, + None, + None, + overrides=None, + ) + + def test_get_info_with_signer(self, http_client_response): + pem = _test_rsa_pem() + signer = ServiceAccountSigner(pem, "kid-i") + info = {"domain_id": "dom_123", "attempt": {"type": "spf"}} + http_client_response._execute.return_value = ( + {"request_id": "r1", "data": info}, + {}, + ) + with patch( + "nylas.models.response.Response.from_dict", + return_value=Response(info, "r1", {}), + ): + domains = Domains(http_client_response) + domains.get_info("dom_123", {"type": "spf"}, signer=signer) + kwargs = http_client_response._execute.call_args.kwargs + assert "serialized_json_body" in kwargs + assert http_client_response._execute.call_args[0][4] is None + + def test_verify_without_signer(self, http_client_response): + info = {"domain_id": "dom_123", "attempt": {"type": "mx"}} + http_client_response._execute.return_value = ( + {"request_id": "rv", "data": info}, + {}, + ) + with patch( + "nylas.models.response.Response.from_dict", + return_value=Response(info, "rv", {}), + ): + domains = Domains(http_client_response) + domains.verify("dom_123", {"type": "mx"}) + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/admin/domains/dom_123/verify", + None, + None, + {"type": "mx"}, + overrides=None, + ) + + def test_verify_with_signer(self, http_client_response): + pem = _test_rsa_pem() + signer = ServiceAccountSigner(pem, "kid-v") + info = {"domain_id": "dom_123"} + http_client_response._execute.return_value = ( + {"request_id": "rv", "data": info}, + {}, + ) + with patch( + "nylas.models.response.Response.from_dict", + return_value=Response(info, "rv", {}), + ): + domains = Domains(http_client_response) + domains.verify("dom_123", {"type": "dkim"}, signer=signer) + assert "serialized_json_body" in http_client_response._execute.call_args.kwargs + + def test_get_info(self, http_client_response): + info = { + "domain_id": "dom_123", + "attempt": {"type": "ownership", "status": "pending"}, + } + http_client_response._execute.return_value = ( + {"request_id": "r1", "data": info}, + {}, + ) + with patch( + "nylas.models.response.Response.from_dict", + return_value=Response(info, "r1", {}), + ): + domains = Domains(http_client_response) + domains.get_info("dom_123", {"type": "ownership"}) + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/admin/domains/dom_123/info", + None, + None, + {"type": "ownership"}, + overrides=None, + ) + + def test_merge_signer_with_existing_headers(self, http_client_list_response): + pem = _test_rsa_pem() + signer = ServiceAccountSigner(pem, "kid-1") + with patch( + "nylas.models.response.ListResponse.from_dict", + return_value=ListResponse([], "rid", None, {}), + ): + domains = Domains(http_client_list_response) + domains.list( + signer=signer, + overrides={"headers": {"X-Custom": "precedence"}}, + ) + headers = http_client_list_response._execute.call_args.kwargs["overrides"]["headers"] + assert headers["X-Custom"] == "precedence" + assert "X-Nylas-Kid" in headers diff --git a/tests/test_client.py b/tests/test_client.py index ac3d23bc..44b3a11a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,6 +6,7 @@ from nylas.resources.connectors import Connectors from nylas.resources.contacts import Contacts from nylas.resources.drafts import Drafts +from nylas.resources.domains import Domains from nylas.resources.events import Events from nylas.resources.folders import Folders from nylas.resources.grants import Grants @@ -64,6 +65,10 @@ def test_client_drafts_property(self, client): assert client.drafts is not None assert type(client.drafts) is Drafts + def test_client_domains_property(self, client): + assert client.domains is not None + assert type(client.domains) is Domains + def test_client_events_property(self, client): assert client.events is not None assert type(client.events) is Events From 6f089b17857f6f6717d711ca3f34e74245824f51 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Wed, 22 Apr 2026 11:03:28 -0400 Subject: [PATCH 180/186] Add Policies API support with typed models, client wiring, and expanded test coverage (#467) --- CHANGELOG.md | 1 + nylas/client.py | 11 ++ nylas/models/auth.py | 2 +- nylas/models/policies.py | 118 +++++++++++++++ nylas/resources/policies.py | 81 +++++++++++ tests/resources/test_policies.py | 238 +++++++++++++++++++++++++++++++ tests/test_client.py | 5 + 7 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 nylas/models/policies.py create mode 100644 nylas/resources/policies.py create mode 100644 tests/resources/test_policies.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a746d8..efece918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Unreleased ---------- * 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` v6.14.3 ---------- diff --git a/nylas/client.py b/nylas/client.py index 1af8d53f..a5e3f417 100644 --- a/nylas/client.py +++ b/nylas/client.py @@ -15,6 +15,7 @@ from nylas.resources.drafts import Drafts from nylas.resources.domains import Domains from nylas.resources.grants import Grants +from nylas.resources.policies import Policies from nylas.resources.scheduler import Scheduler from nylas.resources.notetakers import Notetakers @@ -154,6 +155,16 @@ def grants(self) -> Grants: """ return Grants(self.http_client) + @property + def policies(self) -> Policies: + """ + Access the Policies API. + + Returns: + The Policies API. + """ + return Policies(self.http_client) + @property def messages(self) -> Messages: """ diff --git a/nylas/models/auth.py b/nylas/models/auth.py index 6c836285..97481e88 100644 --- a/nylas/models/auth.py +++ b/nylas/models/auth.py @@ -7,7 +7,7 @@ AccessType = Literal["online", "offline"] """ Literal for the access type of the authentication URL. """ -Provider = Literal["google", "imap", "microsoft", "icloud", "virtual-calendar", "yahoo", "ews", "zoom"] +Provider = Literal["google", "imap", "microsoft", "icloud", "virtual-calendar", "yahoo", "ews", "zoom", "nylas"] """ Literal for the different authentication providers. """ Prompt = Literal[ diff --git a/nylas/models/policies.py b/nylas/models/policies.py new file mode 100644 index 00000000..48dbafef --- /dev/null +++ b/nylas/models/policies.py @@ -0,0 +1,118 @@ +from dataclasses import dataclass +from typing import List, Optional + +from dataclasses_json import dataclass_json +from typing_extensions import NotRequired, TypedDict + +from nylas.models.list_query_params import ListQueryParams + + +class ListPoliciesQueryParams(ListQueryParams): + """ + Query parameters for listing policies. + + Attributes: + limit: Maximum number of objects to return. + page_token: Cursor for the next page (from ``next_cursor`` on the previous response). + """ + + pass + + +class PolicyOptionsRequest(TypedDict, total=False): + """Request shape for policy options.""" + + additional_folders: NotRequired[List[str]] + use_cidr_aliasing: NotRequired[bool] + + +class PolicyLimitsRequest(TypedDict, total=False): + """Request shape for policy limits.""" + + limit_attachment_size_limit: NotRequired[int] + limit_attachment_count_limit: NotRequired[int] + limit_attachment_allowed_types: NotRequired[List[str]] + limit_size_total_mime: NotRequired[int] + limit_storage_total: NotRequired[int] + limit_count_daily_message_per_grant: NotRequired[int] + limit_inbox_retention_period: NotRequired[int] + limit_spam_retention_period: NotRequired[int] + + +class PolicySpamDetectionRequest(TypedDict, total=False): + """Request shape for policy spam detection settings.""" + + use_list_dnsbl: NotRequired[bool] + use_header_anomaly_detection: NotRequired[bool] + spam_sensitivity: NotRequired[float] + + +class CreatePolicyRequest(TypedDict): + """Request body for creating a policy.""" + + name: str + options: NotRequired[PolicyOptionsRequest] + limits: NotRequired[PolicyLimitsRequest] + rules: NotRequired[List[str]] + spam_detection: NotRequired[PolicySpamDetectionRequest] + + +class UpdatePolicyRequest(TypedDict, total=False): + """Request body for updating a policy.""" + + name: NotRequired[str] + options: NotRequired[PolicyOptionsRequest] + limits: NotRequired[PolicyLimitsRequest] + rules: NotRequired[List[str]] + spam_detection: NotRequired[PolicySpamDetectionRequest] + + +@dataclass_json +@dataclass +class PolicyOptions: + """Policy options applied to inboxes that use this policy.""" + + additional_folders: Optional[List[str]] = None + use_cidr_aliasing: Optional[bool] = None + + +@dataclass_json +@dataclass +class PolicyLimits: + """Operational limits applied to inboxes that use this policy.""" + + limit_attachment_size_limit: Optional[int] = None + limit_attachment_count_limit: Optional[int] = None + limit_attachment_allowed_types: Optional[List[str]] = None + limit_size_total_mime: Optional[int] = None + limit_storage_total: Optional[int] = None + limit_count_daily_message_per_grant: Optional[int] = None + limit_inbox_retention_period: Optional[int] = None + limit_spam_retention_period: Optional[int] = None + + +@dataclass_json +@dataclass +class PolicySpamDetection: + """Spam detection settings applied to inboxes that use this policy.""" + + use_list_dnsbl: Optional[bool] = None + use_header_anomaly_detection: Optional[bool] = None + spam_sensitivity: Optional[float] = None + + +@dataclass_json +@dataclass +class Policy: + """A policy for Nylas Agent Accounts.""" + + id: Optional[str] = None + name: Optional[str] = None + application_id: Optional[str] = None + organization_id: Optional[str] = None + options: Optional[PolicyOptions] = None + limits: Optional[PolicyLimits] = None + rules: Optional[List[str]] = None + spam_detection: Optional[PolicySpamDetection] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None diff --git a/nylas/resources/policies.py b/nylas/resources/policies.py new file mode 100644 index 00000000..88c8ed29 --- /dev/null +++ b/nylas/resources/policies.py @@ -0,0 +1,81 @@ +from nylas.config import RequestOverrides +from nylas.handler.api_resources import ( + CreatableApiResource, + DestroyableApiResource, + FindableApiResource, + ListableApiResource, + UpdatableApiResource, +) +from nylas.models.policies import ( + CreatePolicyRequest, + ListPoliciesQueryParams, + Policy, + UpdatePolicyRequest, +) +from nylas.models.response import DeleteResponse, ListResponse, Response + + +class Policies( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Policies API. + + Policies define operational configuration for Nylas Agent Accounts. + """ + + def list( + self, + query_params: ListPoliciesQueryParams = None, + overrides: RequestOverrides = None, + ) -> ListResponse[Policy]: + return super().list( + path="/v3/policies", + response_type=Policy, + query_params=query_params, + overrides=overrides, + ) + + def create( + self, + request_body: CreatePolicyRequest, + overrides: RequestOverrides = None, + ) -> Response[Policy]: + return super().create( + path="/v3/policies", + request_body=request_body, + response_type=Policy, + overrides=overrides, + ) + + def find( + self, policy_id: str, overrides: RequestOverrides = None + ) -> Response[Policy]: + return super().find( + path=f"/v3/policies/{policy_id}", + response_type=Policy, + overrides=overrides, + ) + + def update( + self, + policy_id: str, + request_body: UpdatePolicyRequest, + overrides: RequestOverrides = None, + ) -> Response[Policy]: + return super().update( + path=f"/v3/policies/{policy_id}", + response_type=Policy, + request_body=request_body, + method="PUT", + overrides=overrides, + ) + + def destroy( + self, policy_id: str, overrides: RequestOverrides = None + ) -> DeleteResponse: + return super().destroy(path=f"/v3/policies/{policy_id}", overrides=overrides) diff --git a/tests/resources/test_policies.py b/tests/resources/test_policies.py new file mode 100644 index 00000000..bd45171e --- /dev/null +++ b/tests/resources/test_policies.py @@ -0,0 +1,238 @@ +from nylas.models.policies import Policy +from nylas.resources.policies import Policies + + +class TestPolicies: + def test_policy_deserialization(self, http_client): + policy_json = { + "id": "policy-123", + "name": "Standard Agent Account Policy", + "application_id": "app-123", + "organization_id": "org-123", + "options": { + "additional_folders": ["processed", "spam-review"], + "use_cidr_aliasing": True, + }, + "limits": { + "limit_attachment_size_limit": 26214400, + "limit_attachment_count_limit": 50, + "limit_attachment_allowed_types": ["image/png", "application/pdf"], + "limit_size_total_mime": 52428800, + "limit_storage_total": 1073741824, + "limit_count_daily_message_per_grant": 1000, + "limit_inbox_retention_period": 365, + "limit_spam_retention_period": 30, + }, + "rules": ["rule-1", "rule-2"], + "spam_detection": { + "use_list_dnsbl": True, + "use_header_anomaly_detection": True, + "spam_sensitivity": 1.5, + }, + "created_at": 1712450952, + "updated_at": 1712450952, + } + + policy = Policy.from_dict(policy_json) + + assert policy.id == "policy-123" + assert policy.name == "Standard Agent Account Policy" + assert policy.application_id == "app-123" + assert policy.organization_id == "org-123" + assert policy.options is not None + assert policy.options.additional_folders == ["processed", "spam-review"] + assert policy.options.use_cidr_aliasing is True + assert policy.limits is not None + assert policy.limits.limit_attachment_size_limit == 26214400 + assert policy.limits.limit_attachment_count_limit == 50 + assert policy.limits.limit_attachment_allowed_types == [ + "image/png", + "application/pdf", + ] + assert policy.limits.limit_size_total_mime == 52428800 + assert policy.limits.limit_storage_total == 1073741824 + assert policy.limits.limit_count_daily_message_per_grant == 1000 + assert policy.limits.limit_inbox_retention_period == 365 + assert policy.limits.limit_spam_retention_period == 30 + assert policy.rules == ["rule-1", "rule-2"] + assert policy.spam_detection is not None + assert policy.spam_detection.use_list_dnsbl is True + assert policy.spam_detection.use_header_anomaly_detection is True + assert policy.spam_detection.spam_sensitivity == 1.5 + assert policy.created_at == 1712450952 + assert policy.updated_at == 1712450952 + + def test_policy_deserialization_with_minimal_fields(self, http_client): + policy_json = { + "id": "policy-123", + "name": "Minimal Policy", + "application_id": "app-123", + "organization_id": "org-123", + } + + policy = Policy.from_dict(policy_json, infer_missing=True) + + assert policy.id == "policy-123" + assert policy.name == "Minimal Policy" + assert policy.application_id == "app-123" + assert policy.organization_id == "org-123" + assert policy.options is None + assert policy.limits is None + assert policy.rules is None + assert policy.spam_detection is None + assert policy.created_at is None + assert policy.updated_at is None + + def test_list_policies(self, http_client_list_response): + policies = Policies(http_client_list_response) + + policies.list() + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/policies", None, None, None, overrides=None + ) + + def test_list_policies_with_query_params(self, http_client_list_response): + policies = Policies(http_client_list_response) + + policies.list(query_params={"limit": 10, "page_token": "next-page-token"}) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/policies", + None, + {"limit": 10, "page_token": "next-page-token"}, + None, + overrides=None, + ) + + def test_list_policies_with_overrides(self, http_client_list_response): + policies = Policies(http_client_list_response) + overrides = {"headers": {"X-Test": "value"}, "timeout": 42} + + policies.list(overrides=overrides) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/policies", + None, + None, + None, + overrides=overrides, + ) + + def test_create_policy(self, http_client_response): + policies = Policies(http_client_response) + request_body = { + "name": "Standard Agent Account Policy", + "spam_detection": { + "use_list_dnsbl": True, + "use_header_anomaly_detection": True, + "spam_sensitivity": 1.5, + }, + "limits": { + "limit_attachment_size_limit": 26214400, + "limit_attachment_count_limit": 50, + "limit_inbox_retention_period": 365, + "limit_spam_retention_period": 30, + }, + } + + policies.create(request_body) + + http_client_response._execute.assert_called_once_with( + "POST", "/v3/policies", None, None, request_body, overrides=None + ) + + def test_create_policy_with_overrides(self, http_client_response): + policies = Policies(http_client_response) + request_body = {"name": "Standard Agent Account Policy"} + overrides = {"headers": {"X-Test": "value"}} + + policies.create(request_body, overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/policies", + None, + None, + request_body, + overrides=overrides, + ) + + def test_find_policy(self, http_client_response): + policies = Policies(http_client_response) + + policies.find("policy-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/policies/policy-123", None, None, None, overrides=None + ) + + def test_find_policy_with_overrides(self, http_client_response): + policies = Policies(http_client_response) + overrides = {"headers": {"X-Test": "value"}} + + policies.find("policy-123", overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/policies/policy-123", + None, + None, + None, + overrides=overrides, + ) + + def test_update_policy(self, http_client_response): + policies = Policies(http_client_response) + request_body = { + "name": "Updated Agent Policy", + "rules": ["rule-1", "rule-2"], + } + + policies.update("policy-123", request_body) + + http_client_response._execute.assert_called_once_with( + "PUT", "/v3/policies/policy-123", None, None, request_body, overrides=None + ) + + def test_update_policy_with_overrides(self, http_client_response): + policies = Policies(http_client_response) + request_body = {"rules": ["rule-1"]} + overrides = {"headers": {"X-Test": "value"}} + + policies.update("policy-123", request_body, overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/policies/policy-123", + None, + None, + request_body, + overrides=overrides, + ) + + def test_destroy_policy(self, http_client_delete_response): + policies = Policies(http_client_delete_response) + + policies.destroy("policy-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", "/v3/policies/policy-123", None, None, None, overrides=None + ) + + def test_destroy_policy_with_overrides(self, http_client_delete_response): + policies = Policies(http_client_delete_response) + overrides = {"headers": {"X-Test": "value"}} + + policies.destroy("policy-123", overrides=overrides) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/policies/policy-123", + None, + None, + None, + overrides=overrides, + ) diff --git a/tests/test_client.py b/tests/test_client.py index 44b3a11a..dd01c93c 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.policies import Policies from nylas.resources.threads import Threads from nylas.resources.transactional_send import TransactionalSend from nylas.resources.webhooks import Webhooks @@ -81,6 +82,10 @@ def test_client_grants_property(self, client): assert client.grants is not None assert type(client.grants) is Grants + def test_client_policies_property(self, client): + assert client.policies is not None + assert type(client.policies) is Policies + def test_client_messages_property(self, client): assert client.messages is not None assert type(client.messages) is Messages From 1a4267e5aad95035f6c58528bdf5c72e2989ef55 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Thu, 23 Apr 2026 10:37:50 -0400 Subject: [PATCH 181/186] Add full Rules resource support to Python SDK (#468) --- CHANGELOG.md | 1 + nylas/client.py | 11 ++ nylas/models/rules.py | 155 +++++++++++++++++ nylas/resources/rules.py | 94 +++++++++++ tests/resources/test_rules.py | 306 ++++++++++++++++++++++++++++++++++ tests/test_client.py | 5 + 6 files changed, 572 insertions(+) create mode 100644 nylas/models/rules.py create mode 100644 nylas/resources/rules.py create mode 100644 tests/resources/test_rules.py diff --git a/CHANGELOG.md b/CHANGELOG.md index efece918..4b7e6a0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Unreleased * 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` +* Added Rules support (`Client.rules`): list, create, find, update, and delete for `/v3/rules`, plus `list_evaluations` for `/v3/grants/{grant_id}/rule-evaluations`, with typed request/response models in `nylas.models.rules` v6.14.3 ---------- diff --git a/nylas/client.py b/nylas/client.py index a5e3f417..8bad36e8 100644 --- a/nylas/client.py +++ b/nylas/client.py @@ -18,6 +18,7 @@ from nylas.resources.policies import Policies from nylas.resources.scheduler import Scheduler from nylas.resources.notetakers import Notetakers +from nylas.resources.rules import Rules class Client: @@ -165,6 +166,16 @@ def policies(self) -> Policies: """ return Policies(self.http_client) + @property + def rules(self) -> Rules: + """ + Access the Rules API. + + Returns: + The Rules API. + """ + return Rules(self.http_client) + @property def messages(self) -> Messages: """ diff --git a/nylas/models/rules.py b/nylas/models/rules.py new file mode 100644 index 00000000..2299c1f7 --- /dev/null +++ b/nylas/models/rules.py @@ -0,0 +1,155 @@ +from dataclasses import dataclass +from typing import Any, List, Optional + +from dataclasses_json import dataclass_json +from typing_extensions import NotRequired, TypedDict + +from nylas.models.list_query_params import ListQueryParams + + +class ListRulesQueryParams(ListQueryParams): + """Query parameters for listing rules.""" + + pass + + +class ListRuleEvaluationsQueryParams(ListQueryParams): + """Query parameters for listing rule evaluations.""" + + pass + + +class RuleConditionRequest(TypedDict): + """A single condition used in a rule match clause.""" + + field: str + operator: str + value: Any + + +class RuleMatchRequest(TypedDict): + """Match clause for create/update rule requests.""" + + conditions: List[RuleConditionRequest] + operator: NotRequired[str] + + +class RuleActionRequest(TypedDict): + """Action object used in create/update rule requests.""" + + type: str + value: NotRequired[str] + + +class CreateRuleRequest(TypedDict): + """Request body for creating a rule.""" + + name: str + match: RuleMatchRequest + actions: List[RuleActionRequest] + description: NotRequired[str] + priority: NotRequired[int] + enabled: NotRequired[bool] + trigger: NotRequired[str] + + +class UpdateRuleRequest(TypedDict, total=False): + """Request body for updating a rule.""" + + name: NotRequired[str] + match: NotRequired[RuleMatchRequest] + actions: NotRequired[List[RuleActionRequest]] + description: NotRequired[str] + priority: NotRequired[int] + enabled: NotRequired[bool] + trigger: NotRequired[str] + + +@dataclass_json +@dataclass +class RuleCondition: + """A condition in a rule match clause.""" + + field: Optional[str] = None + operator: Optional[str] = None + value: Optional[Any] = None + + +@dataclass_json +@dataclass +class RuleMatch: + """A rule's condition set and matching strategy.""" + + operator: Optional[str] = None + conditions: Optional[List[RuleCondition]] = None + + +@dataclass_json +@dataclass +class RuleAction: + """An action applied when a rule matches.""" + + type: Optional[str] = None + value: Optional[str] = None + + +@dataclass_json +@dataclass +class Rule: + """A rule used for automated filtering and routing.""" + + id: Optional[str] = None + name: Optional[str] = None + description: Optional[str] = None + priority: Optional[int] = None + enabled: Optional[bool] = None + trigger: Optional[str] = None + match: Optional[RuleMatch] = None + actions: Optional[List[RuleAction]] = 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 RuleEvaluationInput: + """Sender data used as input to rule evaluation.""" + + from_address: Optional[str] = None + from_domain: Optional[str] = None + from_tld: Optional[str] = None + + +@dataclass_json +@dataclass +class RuleEvaluationAppliedActions: + """Actions applied when rules matched.""" + + blocked: Optional[bool] = None + marked_as_spam: Optional[bool] = None + marked_as_read: Optional[bool] = None + marked_starred: Optional[bool] = None + archived: Optional[bool] = None + trashed: Optional[bool] = None + folder_ids: Optional[List[str]] = None + + +@dataclass_json +@dataclass +class RuleEvaluation: + """An audit record describing rule evaluation for a grant.""" + + id: Optional[str] = None + grant_id: Optional[str] = None + message_id: Optional[str] = None + evaluated_at: Optional[int] = None + evaluation_stage: Optional[str] = None + evaluation_input: Optional[RuleEvaluationInput] = None + applied_actions: Optional[RuleEvaluationAppliedActions] = None + matched_rule_ids: Optional[List[str]] = None + application_id: Optional[str] = None + organization_id: Optional[str] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None diff --git a/nylas/resources/rules.py b/nylas/resources/rules.py new file mode 100644 index 00000000..698831d1 --- /dev/null +++ b/nylas/resources/rules.py @@ -0,0 +1,94 @@ +from nylas.config import RequestOverrides +from nylas.handler.api_resources import ( + CreatableApiResource, + DestroyableApiResource, + FindableApiResource, + ListableApiResource, + UpdatableApiResource, +) +from nylas.models.response import DeleteResponse, ListResponse, Response +from nylas.models.rules import ( + CreateRuleRequest, + ListRuleEvaluationsQueryParams, + ListRulesQueryParams, + Rule, + RuleEvaluation, + UpdateRuleRequest, +) + + +class Rules( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """Nylas Rules API.""" + + def list( + self, + query_params: ListRulesQueryParams = None, + overrides: RequestOverrides = None, + ) -> ListResponse[Rule]: + """Return all rules for the application.""" + return super().list( + path="/v3/rules", + response_type=Rule, + query_params=query_params, + overrides=overrides, + ) + + def create( + self, + request_body: CreateRuleRequest, + overrides: RequestOverrides = None, + ) -> Response[Rule]: + """Create a new rule.""" + return super().create( + path="/v3/rules", + request_body=request_body, + response_type=Rule, + overrides=overrides, + ) + + def find(self, rule_id: str, overrides: RequestOverrides = None) -> Response[Rule]: + """Return a specific rule by ID.""" + return super().find( + path=f"/v3/rules/{rule_id}", + response_type=Rule, + overrides=overrides, + ) + + def update( + self, + rule_id: str, + request_body: UpdateRuleRequest, + overrides: RequestOverrides = None, + ) -> Response[Rule]: + """Update a rule by ID.""" + return super().update( + path=f"/v3/rules/{rule_id}", + response_type=Rule, + request_body=request_body, + method="PUT", + overrides=overrides, + ) + + def destroy(self, rule_id: str, overrides: RequestOverrides = None) -> DeleteResponse: + """Delete a rule by ID.""" + return super().destroy(path=f"/v3/rules/{rule_id}", overrides=overrides) + + def list_evaluations( + self, + grant_id: str, + query_params: ListRuleEvaluationsQueryParams = None, + overrides: RequestOverrides = None, + ) -> ListResponse[RuleEvaluation]: + """Return rule evaluation audit records for a grant.""" + return super().list( + path=f"/v3/grants/{grant_id}/rule-evaluations", + response_type=RuleEvaluation, + query_params=query_params, + overrides=overrides, + ) diff --git a/tests/resources/test_rules.py b/tests/resources/test_rules.py new file mode 100644 index 00000000..c881cac2 --- /dev/null +++ b/tests/resources/test_rules.py @@ -0,0 +1,306 @@ +from nylas.models.rules import Rule, RuleEvaluation +from nylas.resources.rules import Rules + + +class TestRules: + def test_rule_deserialization(self, http_client): + rule_json = { + "id": "rule-123", + "name": "Block spam senders", + "description": "Marks mail from spam-domain.com as spam", + "priority": 1, + "enabled": True, + "trigger": "inbound", + "match": { + "operator": "any", + "conditions": [ + {"field": "from.domain", "operator": "is", "value": "spam-domain.com"} + ], + }, + "actions": [{"type": "mark_as_spam"}], + "application_id": "app-123", + "organization_id": "org-123", + "created_at": 1712450952, + "updated_at": 1712450952, + } + + rule = Rule.from_dict(rule_json) + + assert rule.id == "rule-123" + assert rule.name == "Block spam senders" + assert rule.description == "Marks mail from spam-domain.com as spam" + assert rule.priority == 1 + assert rule.enabled is True + assert rule.trigger == "inbound" + assert rule.match is not None + assert rule.match.operator == "any" + assert rule.match.conditions is not None + assert rule.match.conditions[0].field == "from.domain" + assert rule.match.conditions[0].operator == "is" + assert rule.match.conditions[0].value == "spam-domain.com" + assert rule.actions is not None + assert rule.actions[0].type == "mark_as_spam" + assert rule.actions[0].value is None + assert rule.application_id == "app-123" + assert rule.organization_id == "org-123" + assert rule.created_at == 1712450952 + assert rule.updated_at == 1712450952 + + def test_rule_deserialization_with_minimal_fields(self, http_client): + rule_json = { + "id": "rule-123", + "name": "Minimal rule", + } + + rule = Rule.from_dict(rule_json, infer_missing=True) + + assert rule.id == "rule-123" + assert rule.name == "Minimal rule" + assert rule.description is None + assert rule.match is None + assert rule.actions is None + assert rule.created_at is None + assert rule.updated_at is None + + def test_rule_evaluation_deserialization(self, http_client): + evaluation_json = { + "id": "evaluation-123", + "grant_id": "grant-123", + "message_id": "message-123", + "evaluated_at": 1712450952, + "evaluation_stage": "inbox_processing", + "evaluation_input": { + "from_address": "spammer@spam-domain.com", + "from_domain": "spam-domain.com", + "from_tld": "com", + }, + "applied_actions": { + "marked_as_spam": True, + "archived": True, + "folder_ids": ["spam-folder"], + }, + "matched_rule_ids": ["rule-123"], + "application_id": "app-123", + "organization_id": "org-123", + "created_at": 1712450952, + "updated_at": 1712450952, + } + + evaluation = RuleEvaluation.from_dict(evaluation_json) + + assert evaluation.id == "evaluation-123" + assert evaluation.grant_id == "grant-123" + assert evaluation.message_id == "message-123" + assert evaluation.evaluated_at == 1712450952 + assert evaluation.evaluation_stage == "inbox_processing" + assert evaluation.evaluation_input is not None + assert evaluation.evaluation_input.from_address == "spammer@spam-domain.com" + assert evaluation.applied_actions is not None + assert evaluation.applied_actions.marked_as_spam is True + assert evaluation.applied_actions.archived is True + assert evaluation.applied_actions.folder_ids == ["spam-folder"] + assert evaluation.matched_rule_ids == ["rule-123"] + assert evaluation.application_id == "app-123" + assert evaluation.organization_id == "org-123" + + def test_rule_evaluation_deserialization_with_minimal_fields(self, http_client): + evaluation_json = { + "id": "evaluation-123", + "grant_id": "grant-123", + } + + evaluation = RuleEvaluation.from_dict(evaluation_json, infer_missing=True) + + assert evaluation.id == "evaluation-123" + assert evaluation.grant_id == "grant-123" + assert evaluation.message_id is None + assert evaluation.evaluation_input is None + assert evaluation.applied_actions is None + assert evaluation.matched_rule_ids is None + + def test_list_rules(self, http_client_list_response): + rules = Rules(http_client_list_response) + + rules.list() + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/rules", None, None, None, overrides=None + ) + + def test_list_rules_with_query_params(self, http_client_list_response): + rules = Rules(http_client_list_response) + + rules.list(query_params={"limit": 10, "page_token": "next-page-token"}) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/rules", + None, + {"limit": 10, "page_token": "next-page-token"}, + None, + overrides=None, + ) + + def test_create_rule(self, http_client_response): + rules = Rules(http_client_response) + request_body = { + "name": "Block spam domains", + "priority": 1, + "trigger": "inbound", + "match": { + "operator": "any", + "conditions": [ + {"field": "from.domain", "operator": "is", "value": "spam-domain.com"} + ], + }, + "actions": [{"type": "block"}], + } + + rules.create(request_body) + + http_client_response._execute.assert_called_once_with( + "POST", "/v3/rules", None, None, request_body, overrides=None + ) + + def test_create_rule_with_overrides(self, http_client_response): + rules = Rules(http_client_response) + request_body = { + "name": "Block spam domains", + "match": { + "conditions": [ + {"field": "from.domain", "operator": "is", "value": "spam-domain.com"} + ], + }, + "actions": [{"type": "block"}], + } + overrides = {"headers": {"X-Test": "value"}} + + rules.create(request_body, overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/rules", + None, + None, + request_body, + overrides=overrides, + ) + + def test_find_rule(self, http_client_response): + rules = Rules(http_client_response) + + rules.find("rule-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/rules/rule-123", None, None, None, overrides=None + ) + + def test_find_rule_with_overrides(self, http_client_response): + rules = Rules(http_client_response) + overrides = {"headers": {"X-Test": "value"}} + + rules.find("rule-123", overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/rules/rule-123", + None, + None, + None, + overrides=overrides, + ) + + def test_update_rule(self, http_client_response): + rules = Rules(http_client_response) + request_body = { + "enabled": False, + "actions": [{"type": "archive"}], + } + + rules.update("rule-123", request_body) + + http_client_response._execute.assert_called_once_with( + "PUT", "/v3/rules/rule-123", None, None, request_body, overrides=None + ) + + def test_update_rule_with_overrides(self, http_client_response): + rules = Rules(http_client_response) + request_body = { + "enabled": False, + } + overrides = {"headers": {"X-Test": "value"}, "timeout": 42} + + rules.update("rule-123", request_body, overrides=overrides) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/rules/rule-123", + None, + None, + request_body, + overrides=overrides, + ) + + def test_destroy_rule(self, http_client_delete_response): + rules = Rules(http_client_delete_response) + + rules.destroy("rule-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", "/v3/rules/rule-123", None, None, None, overrides=None + ) + + def test_destroy_rule_with_overrides(self, http_client_delete_response): + rules = Rules(http_client_delete_response) + overrides = {"headers": {"X-Test": "value"}} + + rules.destroy("rule-123", overrides=overrides) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/rules/rule-123", + None, + None, + None, + overrides=overrides, + ) + + def test_list_rule_evaluations(self, http_client_list_response): + rules = Rules(http_client_list_response) + + rules.list_evaluations("grant-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/grant-123/rule-evaluations", None, None, None, overrides=None + ) + + def test_list_rule_evaluations_with_query_params(self, http_client_list_response): + rules = Rules(http_client_list_response) + + rules.list_evaluations( + "grant-123", query_params={"limit": 5, "page_token": "cursor-token"} + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/grant-123/rule-evaluations", + None, + {"limit": 5, "page_token": "cursor-token"}, + None, + overrides=None, + ) + + def test_list_rule_evaluations_with_overrides(self, http_client_list_response): + rules = Rules(http_client_list_response) + overrides = {"headers": {"X-Test": "value"}} + + rules.list_evaluations("grant-123", overrides=overrides) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/grant-123/rule-evaluations", + None, + None, + None, + overrides=overrides, + ) diff --git a/tests/test_client.py b/tests/test_client.py index dd01c93c..b06769f3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,6 +12,7 @@ from nylas.resources.grants import Grants from nylas.resources.messages import Messages from nylas.resources.policies import Policies +from nylas.resources.rules import Rules from nylas.resources.threads import Threads from nylas.resources.transactional_send import TransactionalSend from nylas.resources.webhooks import Webhooks @@ -90,6 +91,10 @@ def test_client_messages_property(self, client): assert client.messages is not None assert type(client.messages) is Messages + def test_client_rules_property(self, client): + assert client.rules is not None + assert type(client.rules) is Rules + def test_client_threads_property(self, client): assert client.threads is not None assert type(client.threads) is Threads From 8edee4b96ea55cf3cb61f97e1d03b2f7efd65570 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Thu, 23 Apr 2026 19:16:19 -0400 Subject: [PATCH 182/186] Add full Lists API support with list-item operations (#469) --- CHANGELOG.md | 1 + nylas/client.py | 11 ++ nylas/models/lists.py | 69 ++++++++ nylas/resources/lists.py | 126 ++++++++++++++ tests/resources/test_lists.py | 303 ++++++++++++++++++++++++++++++++++ tests/test_client.py | 5 + 6 files changed, 515 insertions(+) create mode 100644 nylas/models/lists.py create mode 100644 nylas/resources/lists.py create mode 100644 tests/resources/test_lists.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7e6a0f..51a316da 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 8bad36e8..66ce84b1 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 00000000..c4732e61 --- /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 00000000..aa28cea5 --- /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 00000000..168a3d0e --- /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 b06769f3..9b81f338 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 From b23ec1ed0c2e40609f96f764a1d91a1a53c78f23 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Fri, 24 Apr 2026 13:02:33 -0400 Subject: [PATCH 183/186] V6.15.0 release (#470) --- .bumpversion.cfg | 2 +- CHANGELOG.md | 3 +++ nylas/_client_sdk_version.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f4a7cada..9d4a19e8 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 6.14.3 +current_version = 6.15.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a316da..c5e0eed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ nylas-python Changelog ====================== Unreleased ---------- + +v6.15.0 +---------- * 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`) diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index e3477235..85e889a5 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "6.14.3" +__VERSION__ = "6.15.0" From b762597fb6653d293406282fc4228895caed4dd5 Mon Sep 17 00:00:00 2001 From: PengFei Ye Date: Mon, 27 Apr 2026 16:42:30 -0400 Subject: [PATCH 184/186] Add live E2E coverage for policies, rules, and lists (#471) --- nylas/models/response.py | 12 +++- pyproject.toml | 6 ++ tests/e2e/conftest.py | 118 +++++++++++++++++++++++++++++++++ tests/e2e/test_lists_e2e.py | 60 +++++++++++++++++ tests/e2e/test_policies_e2e.py | 69 +++++++++++++++++++ tests/e2e/test_rules_e2e.py | 50 ++++++++++++++ tests/test_response.py | 34 ++++++++++ 7 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_lists_e2e.py create mode 100644 tests/e2e/test_policies_e2e.py create mode 100644 tests/e2e/test_rules_e2e.py create mode 100644 tests/test_response.py diff --git a/nylas/models/response.py b/nylas/models/response.py index bcb0fba1..64b03d0d 100644 --- a/nylas/models/response.py +++ b/nylas/models/response.py @@ -110,14 +110,22 @@ def from_dict(cls, resp: dict, generic_type, headers: Optional[CaseInsensitiveDi headers: The headers returned from the API. """ + raw_data = resp.get("data", []) + if isinstance(raw_data, dict): + next_cursor = resp.get("next_cursor", raw_data.get("next_cursor")) + data = raw_data.get("items", []) + else: + next_cursor = resp.get("next_cursor") + data = raw_data + converted_data = [] - for item in resp["data"]: + for item in data: converted_data.append(generic_type.from_dict(item, infer_missing=True)) return cls( data=converted_data, request_id=resp["request_id"], - next_cursor=resp.get("next_cursor", None), + next_cursor=next_cursor, headers=headers, ) diff --git a/pyproject.toml b/pyproject.toml index 42b4f9ff..46395eff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,3 +49,9 @@ version = {attr = "nylas._client_sdk_version.__VERSION__"} [tool.setuptools.packages.find] where = ["."] include = ["nylas*"] + +[tool.pytest.ini_options] +addopts = "-m 'not e2e'" +markers = [ + "e2e: marks tests that call live Nylas APIs", +] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..89193c86 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,118 @@ +import os +from typing import Dict, List +from uuid import uuid4 + +import pytest + +from nylas import Client + + +_E2E_API_KEY_ENV = "NYLAS_E2E_API_KEY" +_E2E_API_URI_ENV = "NYLAS_E2E_API_URI" +_E2E_RUN_ENV = "NYLAS_E2E_RUN" + + +def _is_truthy(value: str) -> bool: + return value.lower() in {"1", "true", "yes", "on"} + + +def pytest_addoption(parser): + parser.addoption( + "--run-e2e", + action="store_true", + default=False, + help="Run live E2E tests that call Nylas APIs.", + ) + + +def pytest_collection_modifyitems(config, items): + run_e2e = config.getoption("--run-e2e") or _is_truthy(os.getenv(_E2E_RUN_ENV, "")) + if run_e2e: + return + + skip_e2e = pytest.mark.skip( + reason=( + "E2E tests are opt-in. Set NYLAS_E2E_RUN=1 or pass --run-e2e to execute." + ) + ) + for item in items: + if "e2e" in item.keywords: + item.add_marker(skip_e2e) + + +@pytest.fixture +def paginated_list_contains_id(): + def _contains_id(list_method, resource_id: str, limit: int = 100, max_pages: int = 20) -> bool: + next_cursor = None + seen_cursors = set() + + for _ in range(max_pages): + query_params = {"limit": limit} + if next_cursor: + query_params["page_token"] = next_cursor + + response = list_method(query_params=query_params) + if any(item.id == resource_id for item in response.data if item and item.id): + return True + + if not response.next_cursor or response.next_cursor in seen_cursors: + return False + + seen_cursors.add(response.next_cursor) + next_cursor = response.next_cursor + + return False + + return _contains_id + + +@pytest.fixture(scope="session") +def e2e_client() -> Client: + api_key = os.getenv(_E2E_API_KEY_ENV, "") + if not api_key: + pytest.skip( + "E2E tests require NYLAS_E2E_API_KEY to be set." + ) + + api_uri = os.getenv(_E2E_API_URI_ENV, "") + timeout = int(os.getenv("NYLAS_E2E_TIMEOUT", "90")) + if api_uri: + return Client(api_key=api_key, api_uri=api_uri, timeout=timeout) + return Client(api_key=api_key, timeout=timeout) + + +@pytest.fixture +def unique_name(): + def _build(prefix: str) -> str: + return f"{prefix}-{uuid4().hex[:10]}" + + return _build + + +@pytest.fixture +def e2e_resource_registry(e2e_client): + registry: Dict[str, List[str]] = { + "policies": [], + "rules": [], + "lists": [], + } + yield registry + + for policy_id in reversed(registry["policies"]): + try: + e2e_client.policies.destroy(policy_id) + except Exception: + pass + + for rule_id in reversed(registry["rules"]): + try: + e2e_client.rules.destroy(rule_id) + except Exception: + pass + + for list_id in reversed(registry["lists"]): + try: + e2e_client.lists.destroy(list_id) + except Exception: + pass + diff --git a/tests/e2e/test_lists_e2e.py b/tests/e2e/test_lists_e2e.py new file mode 100644 index 00000000..a9778d14 --- /dev/null +++ b/tests/e2e/test_lists_e2e.py @@ -0,0 +1,60 @@ +import pytest + + +@pytest.mark.e2e +def test_lists_lifecycle_e2e(e2e_client, e2e_resource_registry, unique_name): + create_response = e2e_client.lists.create( + { + "name": unique_name("e2e-list"), + "type": "domain", + "description": "Created by SDK e2e test", + } + ) + created_list = create_response.data + assert created_list.id + assert created_list.type == "domain" + e2e_resource_registry["lists"].append(created_list.id) + + found_response = e2e_client.lists.find(created_list.id) + assert found_response.data.id == created_list.id + + updated_name = unique_name("e2e-list-updated") + update_response = e2e_client.lists.update( + created_list.id, + {"name": updated_name, "description": "Updated by SDK e2e test"}, + ) + assert update_response.data.id == created_list.id + assert update_response.data.name == updated_name + + first_domain = f"{unique_name('allowed')}.example" + second_domain = f"{unique_name('blocked')}.example" + add_items_response = e2e_client.lists.add_items( + created_list.id, {"items": [first_domain, second_domain]} + ) + assert add_items_response.data.id == created_list.id + + list_items_response = e2e_client.lists.list_items( + created_list.id, query_params={"limit": 200} + ) + item_values = {item.value for item in list_items_response.data if item.value} + assert first_domain in item_values + assert second_domain in item_values + + remove_items_response = e2e_client.lists.remove_items( + created_list.id, {"items": [first_domain]} + ) + assert remove_items_response.data.id == created_list.id + + after_remove_response = e2e_client.lists.list_items( + created_list.id, query_params={"limit": 200} + ) + item_values_after_remove = { + item.value for item in after_remove_response.data if item.value + } + assert first_domain not in item_values_after_remove + assert second_domain in item_values_after_remove + + destroy_response = e2e_client.lists.destroy(created_list.id) + assert destroy_response.request_id + e2e_resource_registry["lists"].remove(created_list.id) + diff --git a/tests/e2e/test_policies_e2e.py b/tests/e2e/test_policies_e2e.py new file mode 100644 index 00000000..6f842095 --- /dev/null +++ b/tests/e2e/test_policies_e2e.py @@ -0,0 +1,69 @@ +import pytest + + +@pytest.mark.e2e +def test_policies_lifecycle_with_rule_association_e2e( + e2e_client, e2e_resource_registry, unique_name, paginated_list_contains_id +): + rule_response = e2e_client.rules.create( + { + "name": unique_name("e2e-policy-rule"), + "trigger": "inbound", + "match": { + "operator": "any", + "conditions": [ + { + "field": "from.domain", + "operator": "is", + "value": "example.com", + } + ], + }, + "actions": [{"type": "archive"}], + } + ) + created_rule = rule_response.data + assert created_rule.id + e2e_resource_registry["rules"].append(created_rule.id) + + policy_response = e2e_client.policies.create( + {"name": unique_name("e2e-policy"), "rules": [created_rule.id]} + ) + created_policy = policy_response.data + assert created_policy.id + e2e_resource_registry["policies"].append(created_policy.id) + + find_response = e2e_client.policies.find(created_policy.id) + assert find_response.data.id == created_policy.id + + updated_name = unique_name("e2e-policy-updated") + update_response = e2e_client.policies.update( + created_policy.id, + { + "name": updated_name, + "rules": [created_rule.id], + "spam_detection": { + "use_list_dnsbl": True, + "use_header_anomaly_detection": True, + }, + }, + ) + # Some policy update responses may omit id; verify canonical state by refetching. + assert update_response.data.name == updated_name + + refetch_response = e2e_client.policies.find(created_policy.id) + assert refetch_response.data.id == created_policy.id + assert refetch_response.data.name == updated_name + assert refetch_response.data.rules is not None + assert created_rule.id in refetch_response.data.rules + + assert paginated_list_contains_id(e2e_client.policies.list, created_policy.id) + + destroy_policy_response = e2e_client.policies.destroy(created_policy.id) + assert destroy_policy_response.request_id + e2e_resource_registry["policies"].remove(created_policy.id) + + destroy_rule_response = e2e_client.rules.destroy(created_rule.id) + assert destroy_rule_response.request_id + e2e_resource_registry["rules"].remove(created_rule.id) + diff --git a/tests/e2e/test_rules_e2e.py b/tests/e2e/test_rules_e2e.py new file mode 100644 index 00000000..677aa175 --- /dev/null +++ b/tests/e2e/test_rules_e2e.py @@ -0,0 +1,50 @@ +import pytest + + +@pytest.mark.e2e +def test_rules_lifecycle_e2e( + e2e_client, e2e_resource_registry, unique_name, paginated_list_contains_id +): + create_response = e2e_client.rules.create( + { + "name": unique_name("e2e-rule"), + "description": "Created by SDK e2e test", + "trigger": "inbound", + "match": { + "operator": "any", + "conditions": [ + { + "field": "from.domain", + "operator": "is", + "value": "example.com", + } + ], + }, + "actions": [{"type": "archive"}], + } + ) + created_rule = create_response.data + assert created_rule.id + e2e_resource_registry["rules"].append(created_rule.id) + + find_response = e2e_client.rules.find(created_rule.id) + assert find_response.data.id == created_rule.id + + updated_name = unique_name("e2e-rule-updated") + update_response = e2e_client.rules.update( + created_rule.id, + { + "name": updated_name, + "enabled": False, + "actions": [{"type": "mark_as_spam"}], + }, + ) + assert update_response.data.id == created_rule.id + assert update_response.data.name == updated_name + + assert paginated_list_contains_id(e2e_client.rules.list, created_rule.id) + + destroy_response = e2e_client.rules.destroy(created_rule.id) + assert destroy_response.request_id + e2e_resource_registry["rules"].remove(created_rule.id) + diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 00000000..1ae87bb0 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,34 @@ +from nylas.models.response import ListResponse +from nylas.models.rules import Rule + + +class TestListResponse: + def test_from_dict_with_list_data(self): + response = { + "request_id": "req-123", + "data": [{"id": "rule-1", "name": "Rule One"}], + "next_cursor": "cursor-1", + } + + parsed = ListResponse.from_dict(response, Rule) + + assert parsed.request_id == "req-123" + assert parsed.next_cursor == "cursor-1" + assert len(parsed.data) == 1 + assert parsed.data[0].id == "rule-1" + + def test_from_dict_with_items_wrapper(self): + response = { + "request_id": "req-456", + "data": { + "items": [{"id": "rule-2", "name": "Rule Two"}], + "next_cursor": "cursor-2", + }, + } + + parsed = ListResponse.from_dict(response, Rule) + + assert parsed.request_id == "req-456" + assert parsed.next_cursor == "cursor-2" + assert len(parsed.data) == 1 + assert parsed.data[0].id == "rule-2" From 6f16e0a60400ef707cf7c91d65e71d21be625b31 Mon Sep 17 00:00:00 2001 From: benjaminwhtan <55721901+benjaminwhtan@users.noreply.github.com> Date: Wed, 6 May 2026 10:35:31 +0100 Subject: [PATCH 185/186] Add Graph large attachment support (#472) --- nylas/models/attachments.py | 72 +++++++- nylas/resources/attachments.py | 65 ++++++- nylas/resources/drafts.py | 8 +- nylas/resources/messages.py | 4 +- tests/resources/test_attachments.py | 251 +++++++++++++++++++++++++++- 5 files changed, 393 insertions(+), 7 deletions(-) diff --git a/nylas/models/attachments.py b/nylas/models/attachments.py index 37fdb167..7bd8aeb9 100644 --- a/nylas/models/attachments.py +++ b/nylas/models/attachments.py @@ -1,8 +1,8 @@ from dataclasses import dataclass -from typing import Optional, Union, BinaryIO +from typing import Optional, Union, BinaryIO, Dict from dataclasses_json import dataclass_json -from typing_extensions import TypedDict, NotRequired +from typing_extensions import TypedDict, NotRequired, Literal @dataclass_json @@ -66,3 +66,71 @@ class FindAttachmentQueryParams(TypedDict): """ message_id: str + + +AttachmentUploadSessionStatusType = Literal["uploading", "ready", "failed", "expired"] + + +class CreateAttachmentUploadSessionRequest(TypedDict): + """ + Request body for creating a large-attachment upload session (Graph only). + + Attributes: + filename: The name of the file as it will appear in the email. + content_type: MIME type of the file (e.g. 'application/pdf'). + size: Expected file size in bytes (max 157286400 / 150 MB). Recommended โ€” + Nylas validates the upload matches this size at completion. + """ + + filename: str + content_type: str + size: NotRequired[int] + + +@dataclass_json +@dataclass +class AttachmentUploadSession: + """ + Upload session returned when creating a large-attachment upload session. + + Attributes: + attachment_id: Unique session ID โ€” use when completing the session and when + referencing the attachment in send/draft. + method: HTTP method to use when uploading to `url`. Always 'PUT'. + url: Pre-signed URL to upload file bytes (no Nylas auth header needed). + headers: Headers to include when uploading to `url`. + expires_at: When the session expires (RFC 3339). + max_size: Maximum allowed file size in bytes (157286400). + size: Expected file size echoing the request; 0 if `size` was omitted. + content_type: MIME type of the file. + filename: Name of the file. + grant_id: Grant ID the session belongs to. + """ + + attachment_id: Optional[str] = None + method: Optional[str] = None + url: Optional[str] = None + headers: Optional[Dict[str, str]] = None + expires_at: Optional[str] = None + max_size: Optional[int] = None + size: Optional[int] = None + content_type: Optional[str] = None + filename: Optional[str] = None + grant_id: Optional[str] = None + + +@dataclass_json +@dataclass +class AttachmentUploadSessionComplete: + """ + Result of completing a large-attachment upload session. + + Attributes: + attachment_id: The session ID. + grant_id: Grant ID the session belongs to. + status: Upload status; typically 'ready' after successful completion. + """ + + attachment_id: Optional[str] = None + grant_id: Optional[str] = None + status: Optional[str] = None diff --git a/nylas/resources/attachments.py b/nylas/resources/attachments.py index eafb70ce..1c4616b5 100644 --- a/nylas/resources/attachments.py +++ b/nylas/resources/attachments.py @@ -3,13 +3,21 @@ from nylas.config import RequestOverrides from nylas.handler.api_resources import ( FindableApiResource, + CreatableApiResource, +) +from nylas.models.attachments import ( + Attachment, + FindAttachmentQueryParams, + CreateAttachmentUploadSessionRequest, + AttachmentUploadSession, + AttachmentUploadSessionComplete, ) -from nylas.models.attachments import Attachment, FindAttachmentQueryParams from nylas.models.response import Response as NylasResponse class Attachments( FindableApiResource, + CreatableApiResource, ): """ Nylas Attachments API @@ -112,3 +120,58 @@ def download_bytes( stream=False, overrides=overrides, ) + + def create_upload_session( + self, + identifier: str, + request_body: CreateAttachmentUploadSessionRequest, + overrides: RequestOverrides = None, + ) -> NylasResponse[AttachmentUploadSession]: + """ + Create a resumable upload session for a large attachment (up to 150 MB). + + After receiving the session, upload file bytes via HTTP PUT to the returned + `url` (include the returned `headers`; no Nylas auth header needed), then + call complete_upload_session() with the returned `attachment_id`. + + Args: + identifier: The identifier of the Grant to act upon. + request_body: Session parameters (filename, content_type, optional size). + overrides: The request overrides to use for the request. + + Returns: + The upload session, including the pre-signed URL and attachment_id. + """ + return super().create( + path=f"/v3/grants/{identifier}/attachment-uploads", + response_type=AttachmentUploadSession, + request_body=request_body, + overrides=overrides, + ) + + def complete_upload_session( + self, + identifier: str, + attachment_id: str, + overrides: RequestOverrides = None, + ) -> NylasResponse[AttachmentUploadSessionComplete]: + """ + Complete an upload session after file bytes have been PUT to the pre-signed URL. + + Use the `attachment_id` from the completed session when referencing the + attachment in a subsequent messages.send() or drafts.create() call. + + Args: + identifier: The identifier of the Grant to act upon. + attachment_id: The upload session ID returned by create_upload_session(). + overrides: The request overrides to use for the request. + + Returns: + The completed session status. + """ + return super().create( + path=f"/v3/grants/{identifier}/attachment-uploads/{attachment_id}/complete", + response_type=AttachmentUploadSessionComplete, + request_body={}, + overrides=overrides, + ) diff --git a/nylas/resources/drafts.py b/nylas/resources/drafts.py index e817d5fc..52d94b2b 100644 --- a/nylas/resources/drafts.py +++ b/nylas/resources/drafts.py @@ -125,7 +125,9 @@ def create( # Encode the content of the attachments to base64 for attachment in request_body.get("attachments", []): - if issubclass(type(attachment["content"]), io.IOBase): + if "content" in attachment and issubclass( + type(attachment["content"]), io.IOBase + ): attachment["content"] = encode_stream_to_base64(attachment["content"]) return super().create( @@ -173,7 +175,9 @@ def update( # Encode the content of the attachments to base64 for attachment in request_body.get("attachments", []): - if issubclass(type(attachment["content"]), io.IOBase): + if "content" in attachment and issubclass( + type(attachment["content"]), io.IOBase + ): attachment["content"] = encode_stream_to_base64(attachment["content"]) return super().update( diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index d544cee8..1e0b1fb0 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -186,7 +186,9 @@ def send( else: # Encode the content of the attachments to base64 for attachment in request_body.get("attachments", []): - if issubclass(type(attachment["content"]), io.IOBase): + if "content" in attachment and issubclass( + type(attachment["content"]), io.IOBase + ): attachment["content"] = encode_stream_to_base64( attachment["content"] ) diff --git a/tests/resources/test_attachments.py b/tests/resources/test_attachments.py index 5bd43e64..c4f36418 100644 --- a/tests/resources/test_attachments.py +++ b/tests/resources/test_attachments.py @@ -1,7 +1,14 @@ from io import BytesIO from unittest.mock import Mock -from nylas.models.attachments import Attachment, CreateAttachmentRequest, FindAttachmentQueryParams +from nylas.models.attachments import ( + Attachment, + CreateAttachmentRequest, + FindAttachmentQueryParams, + AttachmentUploadSession, + AttachmentUploadSessionComplete, + CreateAttachmentUploadSessionRequest, +) from nylas.resources.attachments import Attachments @@ -315,3 +322,245 @@ def test_download_bytes(self): stream=False, overrides=None, ) + + def test_create_upload_session(self, http_client_response): + attachments = Attachments(http_client_response) + request_body: CreateAttachmentUploadSessionRequest = { + "filename": "document.pdf", + "content_type": "application/pdf", + "size": 5242880, + } + + attachments.create_upload_session( + identifier="abc-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/attachment-uploads", + None, + None, + request_body, + overrides=None, + ) + + def test_create_upload_session_without_size(self, http_client_response): + attachments = Attachments(http_client_response) + request_body: CreateAttachmentUploadSessionRequest = { + "filename": "report.xlsx", + "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + } + + attachments.create_upload_session( + identifier="abc-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/attachment-uploads", + None, + None, + request_body, + overrides=None, + ) + + def test_complete_upload_session(self, http_client_response): + attachments = Attachments(http_client_response) + + attachments.complete_upload_session( + identifier="abc-123", + attachment_id="session-id-123", + ) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/attachment-uploads/session-id-123/complete", + None, + None, + {}, + overrides=None, + ) + + +class TestAttachmentUploadSession: + """Tests for the AttachmentUploadSession dataclass model.""" + + def test_deserialization_full(self): + session_json = { + "attachment_id": "session-abc-123", + "method": "PUT", + "url": "https://storage.example.com/upload/session-abc-123", + "headers": {"x-ms-blob-type": "BlockBlob"}, + "expires_at": "2026-05-05T12:00:00Z", + "max_size": 157286400, + "size": 5242880, + "content_type": "application/pdf", + "filename": "document.pdf", + "grant_id": "grant-abc-123", + } + + session = AttachmentUploadSession.from_dict(session_json) + + assert session.attachment_id == "session-abc-123" + assert session.method == "PUT" + assert session.url == "https://storage.example.com/upload/session-abc-123" + assert session.headers == {"x-ms-blob-type": "BlockBlob"} + assert session.expires_at == "2026-05-05T12:00:00Z" + assert session.max_size == 157286400 + assert session.size == 5242880 + assert session.content_type == "application/pdf" + assert session.filename == "document.pdf" + assert session.grant_id == "grant-abc-123" + + def test_deserialization_without_size(self): + """When size is omitted from the request, the API echoes 0.""" + session_json = { + "attachment_id": "session-no-size", + "method": "PUT", + "url": "https://storage.example.com/upload/session-no-size", + "headers": {}, + "expires_at": "2026-05-05T12:00:00Z", + "max_size": 157286400, + "size": 0, + "content_type": "text/plain", + "filename": "notes.txt", + "grant_id": "grant-xyz", + } + + session = AttachmentUploadSession.from_dict(session_json) + + assert session.attachment_id == "session-no-size" + assert session.size == 0 + assert session.headers == {} + + def test_deserialization_partial(self): + """Partial response should not raise; unset fields default to None.""" + session = AttachmentUploadSession.from_dict({"attachment_id": "partial-session"}) + + assert session.attachment_id == "partial-session" + assert session.method is None + assert session.url is None + assert session.headers is None + assert session.expires_at is None + assert session.max_size is None + assert session.size is None + assert session.content_type is None + assert session.filename is None + assert session.grant_id is None + + def test_deserialization_empty_dict(self): + session = AttachmentUploadSession.from_dict({}) + + assert session.attachment_id is None + assert session.grant_id is None + + def test_roundtrip_serialization(self): + original = AttachmentUploadSession( + attachment_id="rt-session", + method="PUT", + url="https://example.com/upload", + headers={"Content-Type": "application/octet-stream"}, + expires_at="2026-05-05T12:00:00Z", + max_size=157286400, + size=1024, + content_type="application/octet-stream", + filename="file.bin", + grant_id="grant-rt", + ) + + serialized = original.to_dict() + deserialized = AttachmentUploadSession.from_dict(serialized) + + assert deserialized.attachment_id == original.attachment_id + assert deserialized.method == original.method + assert deserialized.url == original.url + assert deserialized.headers == original.headers + assert deserialized.expires_at == original.expires_at + assert deserialized.max_size == original.max_size + assert deserialized.size == original.size + assert deserialized.content_type == original.content_type + assert deserialized.filename == original.filename + assert deserialized.grant_id == original.grant_id + + +class TestAttachmentUploadSessionComplete: + """Tests for the AttachmentUploadSessionComplete dataclass model.""" + + def test_deserialization_ready(self): + complete_json = { + "attachment_id": "session-abc-123", + "grant_id": "grant-abc-123", + "status": "ready", + } + + complete = AttachmentUploadSessionComplete.from_dict(complete_json) + + assert complete.attachment_id == "session-abc-123" + assert complete.grant_id == "grant-abc-123" + assert complete.status == "ready" + + def test_deserialization_various_statuses(self): + for status in ("uploading", "failed", "expired"): + complete = AttachmentUploadSessionComplete.from_dict({ + "attachment_id": "session-123", + "grant_id": "grant-123", + "status": status, + }) + assert complete.status == status + + def test_deserialization_empty_dict(self): + complete = AttachmentUploadSessionComplete.from_dict({}) + + assert complete.attachment_id is None + assert complete.grant_id is None + assert complete.status is None + + def test_roundtrip_serialization(self): + original = AttachmentUploadSessionComplete( + attachment_id="session-rt", + grant_id="grant-rt", + status="ready", + ) + + serialized = original.to_dict() + deserialized = AttachmentUploadSessionComplete.from_dict(serialized) + + assert deserialized.attachment_id == original.attachment_id + assert deserialized.grant_id == original.grant_id + assert deserialized.status == original.status + + +class TestCreateAttachmentUploadSessionRequest: + """Tests for the CreateAttachmentUploadSessionRequest TypedDict.""" + + def test_required_fields_only(self): + request: CreateAttachmentUploadSessionRequest = { + "filename": "document.pdf", + "content_type": "application/pdf", + } + + assert request["filename"] == "document.pdf" + assert request["content_type"] == "application/pdf" + assert "size" not in request + + def test_with_size(self): + request: CreateAttachmentUploadSessionRequest = { + "filename": "video.mp4", + "content_type": "video/mp4", + "size": 104857600, # 100 MB + } + + assert request["filename"] == "video.mp4" + assert request["content_type"] == "video/mp4" + assert request["size"] == 104857600 + + def test_max_allowed_size(self): + request: CreateAttachmentUploadSessionRequest = { + "filename": "archive.zip", + "content_type": "application/zip", + "size": 157286400, # 150 MB max + } + + assert request["size"] == 157286400 From f833d177fd5b2966a76a6b05e985337ea3b6d19d Mon Sep 17 00:00:00 2001 From: Nick Barraclough <66260480+nbarraclough@users.noreply.github.com> Date: Wed, 6 May 2026 14:49:39 -0600 Subject: [PATCH 186/186] Refresh README to match nylas-nodejs style (#473) Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 207 ++++++++++++++++++++++++++-------------- diagrams/nylas-logo.png | Bin 0 -> 24968 bytes 2 files changed, 137 insertions(+), 70 deletions(-) create mode 100644 diagrams/nylas-logo.png diff --git a/README.md b/README.md index f9d33072..f923483d 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,190 @@ - - Aimeos logo - +
+ + Nylas + -# Nylas Python SDK +

Nylas Python SDK

-[![PyPI - Version](https://img.shields.io/pypi/v/nylas)](https://pypi.org/project/nylas/) -[![codecov](https://codecov.io/gh/nylas/nylas-python/branch/main/graph/badge.svg?token=HyxGAn5bJR)](https://codecov.io/gh/nylas/nylas-python) +

+ The official Python SDK for Nylas โ€” the infrastructure that powers communications +

-This is the GitHub repository for the Nylas Python SDK. The repo is primarily for anyone who wants to install the SDK from source or make contributions to it. +

+ version + code coverage + downloads + license +

-If you're looking to use Python to access the Nylas Email, Calendar, or Contacts APIs, see our [Python SDK Quickstart guide](https://docs.nylas.com/docs/quickstart-python). +

+ ๐Ÿ“– SDK Guide ยท + ๐Ÿ“š API Reference ยท + ๐Ÿš€ Sign up ยท + ๐Ÿ’ก Samples ยท + ๐Ÿ’ฌ Forum +

+
-The Nylas platform provides REST APIs for [Email](https://docs.nylas.com/docs/quickstart-email), [Calendar](https://docs.nylas.com/docs/quickstart-calendar), and [Contacts](https://docs.nylas.com/docs/quickstart-contacts), and the Python SDK is the quickest way to build your integration using Python. +
-Here are some resources to help you get started: +The official Python SDK for [Nylas](https://developer.nylas.com/docs/v3/) โ€” the infrastructure that powers communications. Integrate with Gmail, Microsoft, IMAP, Zoom, and 250+ email, calendar, and meeting providers in 5 minutes. Covers [Email](https://developer.nylas.com/docs/v3/email/), [Calendar](https://developer.nylas.com/docs/v3/calendar/), [Contacts](https://developer.nylas.com/docs/v3/email/contacts/), [Scheduler](https://developer.nylas.com/docs/v3/scheduler/), [Notetaker](https://developer.nylas.com/docs/v3/notetaker/), and [Agent Accounts](https://developer.nylas.com/docs/v3/agent-accounts/). -- [Sign up for a free Nylas account](https://dashboard.nylas.com/register). -- Follow the [Nylas API v3 Quickstart guide](https://developer.nylas.com/docs/v3-beta/v3-quickstart/). -- Browse the [Nylas SDK reference docs](https://nylas-python-sdk-reference.pages.dev/). -- Browse the [Nylas API reference docs](https://developer.nylas.com/docs/api/). -- See our code samples in the [Nylas Samples repo](https://github.com/orgs/nylas-samples/repositories?q=&type=all&language=python). +This repository is for contributors and anyone installing the SDK from source. If you just want to use the SDK in your app, head straight to the [**Python SDK guide**](https://developer.nylas.com/docs/v3/sdks/python/) on developer.nylas.com. -If you have any questions about the Nylas platform, please reach out to support@nylas.com. +## Get started -## โš™๏ธ Install +1. [Sign up for a free Nylas account](https://dashboard-v3.nylas.com/register) and grab your API key from the [Nylas Dashboard](https://dashboard-v3.nylas.com/). +2. Read the [Getting started guide](https://developer.nylas.com/docs/v3/getting-started/) for the core concepts (applications, grants, API keys). +3. Install the SDK and make your first request โ€” see below. -The Nylas Python SDK is available via pip: +You can also bootstrap from the terminal: ```bash -pip install nylas --pre +brew install nylas/nylas-cli/nylas +nylas init ``` -To install the SDK from source, clone this repo and run the install script: +More options in the [CLI getting-started guide](https://cli.nylas.com/guides/getting-started). + +## โš™๏ธ Install + +> **Requirements:** Python 3.8 or later. ```bash -git clone https://github.com/nylas/nylas-python.git && cd nylas-python -python setup.py install +pip install nylas ``` -## โšก๏ธ Usage +To install from source: -Before you use the Nylas Python SDK, you must first [create a Nylas account](https://dashboard.nylas.com/register). Then, follow our [API v3 Quickstart guide](https://developer.nylas.com/docs/v3-beta/v3-quickstart/) to set up your first app and get your API keys. +```bash +git clone https://github.com/nylas/nylas-python.git +cd nylas-python +pip install -e . +``` -For code samples and example applications, take a look at our [Python repos in the Nylas Samples collection](https://github.com/orgs/nylas-samples/repositories?q=&type=all&language=python). +### Runtime support -### ๐Ÿš€ Make your first request +Tested on CPython 3.8+. Runs on standard servers as well as serverless platforms like AWS Lambda, Google Cloud Functions, and Vercel โ€” install `nylas` like any other dependency in your deployment package. -After you've installed and set up the Nylas Python SDK, you can make your first API request. To do so, use the `Client` class from the `nylas` package. +## โšก๏ธ Usage -The SDK is organized into different resources, each of which has methods to make requests to the Nylas API. Each resource is available through the `Client` object that you configured with your API key. For example, you can use this code to get a list of Calendars: +You access Nylas resources (messages, calendars, events, contacts, โ€ฆ) through an instance of `Client`. Initialize it with your API key โ€” and optionally an `api_uri` matching your [data residency](https://developer.nylas.com/docs/dev-guide/platform/data-residency/). ```python +import os from nylas import Client nylas = Client( - api_key="API_KEY", + api_key=os.environ["NYLAS_API_KEY"], + api_uri=os.environ.get("NYLAS_API_URI", "https://api.us.nylas.com"), + timeout=30, # optional, in seconds ) +``` -calendars, request_id, next_cursor = nylas.calendars.list("GRANT_ID") - -event, request_id = nylas.events.create( - identifier="GRANT_ID", - request_body={ - "title": "test title", - "description": "test description", - "when": { - "start_time": start_unix_timestamp, - "end_time": end_unix_timestamp, - } - }, - query_params={"calendar_id": "primary", "notify_participants": True}, - ) -) +Once initialized, use it to make requests against a [grant](https://developer.nylas.com/docs/v3/auth/) (an authenticated end-user account): -event, request_id = nylas.events.find( - identifier="GRANT_ID", - event_id=event.id, - query_params={ - "calendar_id": "primary", - }, +```python +calendars, request_id, next_cursor = nylas.calendars.list( + identifier=os.environ["NYLAS_GRANT_ID"], ) +print(calendars) +``` + +Resources expose a consistent CRUD surface โ€” `create()`, `find()`, `list()`, `update()`, `destroy()` โ€” plus resource-specific methods (e.g. `messages.send()`, `events.send_rsvp()`). Request and response models are [`dataclasses-json`](https://github.com/lidatong/dataclasses-json) dataclasses, so every payload is fully type-hinted and supports `to_dict()` / `from_dict()`. -nylas.events.destroy("GRANT_ID", event.id, {"calendar_id": "primary"}) +### Error handling +The SDK raises typed exceptions you can catch and inspect. Every API error carries a `request_id` and `status_code` โ€” include both when filing a support ticket so we can trace the request end-to-end. + +```python +from nylas import Client +from nylas.models.errors import ( + NylasApiError, + NylasOAuthError, + NylasSdkTimeoutError, +) + +try: + nylas.calendars.list(identifier=grant_id) +except NylasApiError as err: + print(err.status_code, err.type, str(err), err.request_id) +except NylasOAuthError as err: + print(err.error, err.error_code, err.error_description) +except NylasSdkTimeoutError as err: + print("Timed out:", err.url, err.timeout) ``` -## ๐Ÿ“š Documentation +Step-by-step walkthroughs in the SDK guide: -This SDK makes heavy use of [Python 3 dataclasses](https://realpython.com/python-data-classes/) to define the REST resources and request/response schemas of the Nylas APIs. The Client object is a wrapper around all of these resources and is used to interact with the corresponding APIs. Basic CRUD operations are handled by the `create()`, `find()`, `list()`, `update()`, and `destroy()` methods on each resource. Resources may also have other methods which are all detailed in the [reference guide for the Python SDK](https://nylas-python-sdk-reference.pages.dev/). In the code reference, start at `client`, and then `resources` will give more info on available API call methods. `models` is the place to find schemas for requests, responses, and all Nylas object types. +- [Send messages](https://developer.nylas.com/docs/v3/sdks/python/send-email/) +- [Read messages and threads](https://developer.nylas.com/docs/v3/sdks/python/read-messages-threads/) +- [Manage events on a calendar](https://developer.nylas.com/docs/v3/sdks/python/manage-events/) +- [Manage contacts](https://developer.nylas.com/docs/v3/sdks/python/manage-contacts/) +- [Manage folders and labels](https://developer.nylas.com/docs/v3/sdks/python/manage-folders-labels/) -While most resources are accessed via the top-level Client object, note that `auth` contains the sub-resource `grants` as well as a collection of other auth-related API calls. +### Debugging -You'll want to catch `nylas.models.errors.NylasAPIError` to handle errors. +To inspect the raw HTTP traffic the SDK sends, turn on `requests`-level logging: -Have fun!! +```python +import logging -## โœจ Upgrade from v5.x +logging.basicConfig(level=logging.DEBUG) +logging.getLogger("urllib3").setLevel(logging.DEBUG) +``` -See [UPGRADE.md](UPGRADE.md) for instructions on upgrading from v5.x to v6.x. +## ๐Ÿ’ก Examples -## ๐Ÿ’™ Contribute +Runnable examples live in [`examples/`](examples/) โ€” including [send email](examples/send_email_demo/), [inline attachments](examples/inline_attachment_demo/), [folders](examples/folders_demo/), [import events](examples/import_events_demo/), [Notetaker API](examples/notetaker_api_demo/), [Notetaker calendar](examples/notetaker_calendar_demo/), [message fields](examples/message_fields_demo/), [metadata fields](examples/metadata_field_demo/), [provider errors](examples/provider_error_demo/), [response headers](examples/response_headers_demo/), [`select` parameter](examples/select_param_demo/), [special characters](examples/special_characters_demo/), [hidden folders](examples/include_hidden_folders_demo/), and [plain text](examples/is_plaintext_demo/). -Please refer to [Contributing](Contributing.md) for information about how to make contributions to this project. We welcome questions, bug reports, and pull requests. +For full sample apps and product quickstarts, browse [**nylas-samples** on GitHub](https://github.com/orgs/nylas-samples/repositories?q=python) โ€” every official SDK has Email, Calendar, Contacts, Scheduler, and Webhooks quickstarts. -## ๐Ÿ› ๏ธ Debugging +## ๐Ÿค– AI agents -It can sometimes be helpful to turn on request logging during development. Adding the following snippet to your code that calls the SDK should get you sorted: +[nylas/skills](https://github.com/nylas/skills) drops Nylas into Claude Code, Cursor, Codex, and other agents that support the skills format: +```bash +npx skills add nylas/skills +/plugin marketplace add nylas/skills # Claude Code ``` -import logging -import requests -# Set up logging to print out HTTP request information -logging.basicConfig(level=logging.DEBUG) -requests_log = logging.getLogger("requests.packages.urllib3") -requests_log.setLevel(logging.DEBUG) -requests_log.propagate = True +The CLI also installs an MCP server for Claude Desktop, Claude Code, Cursor, Windsurf, or VS Code: + +```bash +brew install nylas/nylas-cli/nylas +nylas mcp install ``` +Walkthrough: [give AI agents email access via MCP](https://cli.nylas.com/guides/give-ai-agents-email-access-via-mcp). + +## ๐Ÿ“š Reference + +- **SDK guide:** [developer.nylas.com/docs/v3/sdks/python](https://developer.nylas.com/docs/v3/sdks/python/) +- **API reference:** [developer.nylas.com/docs/api/v3](https://developer.nylas.com/docs/api/v3/) +- **Python SDK reference:** [nylas-python-sdk-reference.pages.dev](https://nylas-python-sdk-reference.pages.dev/) โ€” generated method/class docs for this SDK +- **Webhooks (notifications):** [developer.nylas.com/docs/v3/notifications](https://developer.nylas.com/docs/v3/notifications/) +- **Auth flows:** [developer.nylas.com/docs/v3/auth](https://developer.nylas.com/docs/v3/auth/) +- **Dev guide & best practices:** [developer.nylas.com/docs/dev-guide](https://developer.nylas.com/docs/dev-guide/) +- **Changelog:** [CHANGELOG.md](CHANGELOG.md) + +## โœจ Upgrading + +See [`CHANGELOG.md`](CHANGELOG.md) for per-release notes. Older upgrade guidance (v5.x โ†’ v6.x) lives in [`UPGRADE.md`](UPGRADE.md). + +## ๐Ÿ’™ Contributing + +Issues, ideas, and pull requests welcome โ€” see [Contributing.md](Contributing.md). Before opening a large change, please open an issue or post in the [forum](https://forums.nylas.com) so we can sanity-check the direction. + +## ๐Ÿ”’ Security + +Found a vulnerability? Please **don't** open a public issue. Report it through our [Vulnerability Disclosure Policy](https://www.nylas.com/security/vulnerability-disclosure-policy/). + +## ๐Ÿ”— Other Nylas SDKs + +- [nylas-nodejs](https://github.com/nylas/nylas-nodejs) ยท `npm install nylas` +- [nylas-ruby](https://github.com/nylas/nylas-ruby) ยท `gem install nylas` +- [nylas-java](https://github.com/nylas/nylas-java) ยท Maven / Gradle (Kotlin too) + ## ๐Ÿ“ License -This project is licensed under the terms of the MIT license. Please refer to [LICENSE](LICENSE) for the full terms. +MIT โ€” see [LICENSE](LICENSE). diff --git a/diagrams/nylas-logo.png b/diagrams/nylas-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..87ad1f17660a56f91345697b5296a52f84033bf6 GIT binary patch literal 24968 zcmcG$cR1DY-#`AgLS#gavPYrJL^dHKL|VczLpE_7dyBHNGBZMym1OT79Wy!P7$qy) zAszGgI_SPX_kDl={kg8L&gHz{&)0g+$Mf;>T<5mxnbQ|eLlAUET}@dRf=Ff|h#-ON z6!=Z*9tS7*kK9S^kqZRz-@^YRKzh1rK@dBnu6*O3d)m@?A7X6y&!6?V3B=q+2|-vT zA>X;M8$=H-3(K~*-6DB=<%3M~&^?kECWZXdocEphuC3{x`^7TV|BP{;!x>s~p=!t| z|CA;nUadsPy=G#b;kUoPJxK^gg-jkA4cV$&4I^APZSPy!F5;(>zTvod^d6&GPW9#zgcFtE+Ds7hq zWFU8=scjU!)b%TU_l*)pi%zB*5op3X|Hx=4&qi@OIj4GeYoz!MThu0&v*`~ydN6wJ zz-UlKOV96$wUWun9&JXOu&%v?ZE0$e$s-%7zVFL@FyF>bA2yTxxxRCLxTN6f?!k~T zUIoC<2ev?uDM&CZqtaJ#KA7!$GHdA8woTwpd5&$M=kX&`!o=97D;T@KE@C(Ko?8e6 zNW9wY-S_SZ-?^=&)@ZhQ=J>HSDIkg5<4#5a`E=hmlw~LOHl{DME}xAXUa*kUSRp`h z{2Uor`4mmZ_D{9q)by7pr{jL1S>}>}(C@qIFwO|J76-M)v(pa8&qIM8n%YJ~j@$x6 z#2a_@>coguW99PCBVBkFborT&h5A3|OzM36sb+fe5&Eh#Pi(`j8dUg>{@_LU=CKgG zHgO9H{SHZ?!ikRE`j9RAdsanDp^yJ$joe_*)Sph<^j;KbuS#J|Q`=m&m@-oDz>aLN^tTH{D;E7R5zQywSyxd8~#C9}n!|(A1Zn zmy>Ecyo$IXJEEOPuZ-i<_n#jSiGDJ3(?@VY7xgkD(2aEe&E>Lkd&%RG6p+N&u-o_} zC&Y;^>I>JHtNYE~^vS-IImw7VQDNo?6V?GImtTpoa*TbUYU8uh%aUZBiW~ z=XjGhdOuCQ1{`ur4bAa;Qx{!PToMk>=!6`)X>j=z5izJ|bTG`@%Fu(rAv zy!G;RFg&qk_U9(79T>tAzxN9cr=C6KU(jZFMsoC;0vV$P5nV)aKulk;OZtHLu`FSw zP8k)j5$|#gXuDfG4AgBaSx!7kHdj(-{>B?4HT{C~0)%>!cj$UE3}DA!`Y&)Kbw1g6 z(-6OT9~Gy{7{$Hcj5+Z>5s@aWK|mR_jE1hLvlZGBq_2(IXB-dL#7j0S!dv-yQGZZ+ zTGv0Z?n}7ma>c{rFHLlav5cBVL!x3?d(Z7Jc2y<1V%z%aE$)y@-W5frJDq5L&`vah zK<)04fqTq$H7?yAM5k@0q-6VBFDq1 z0dw+HfdqltrFy6tygr#jeX3%=<361J*~ODw0|Qiu_vjPkj?m2 zJ;X{CUMwoOI;O`^_JyQ3T5;}yo;*_i?n{QScVL@HRuGv!X1IxXS&#zEqizZLIM4PW5FFTqnV8^Rqv z*TJunCq`zpKUM>^*(+x4z0R4uxNm&lGMq1DL;GE~-Tj2o>YT&7*cuE|;X*(%pvd_Dnw z)-DHn$XvU)$pQKETElo zL=TrQSxXOEIvAV;YsHO{#>0cLnp2GI@s&Rqh+o%c+*P+aWr4{j|Ba3v77>h3g>5Mg zK3tTdS_6BXi=YM54ebv16i786mfO=$82yevWdA%Gl(x>Vti2%4RGtjjR<-Z5b)L8F zGRwDn{y4FGeI8CRCv>jUZI68+J_a~ecJZTps?{XEr;VmNx;12dETi9ek!~ByA@rM6 z9c`nyV>|ZI=t08hWuf2pbAIzBB3$;z`?N;Hf~8aBmFM5yG2)F9_GP2CY58@ZxMWw~ zxo-AOlnL0EuQxk^);9^`!(|8UBlqen^Ez@MP%mN*GzO*HaTnxg)CNh_{bp{~yJedP zPFI9irz~^3COW)6)@w+UG28BvzfOanpkcj}yhuZFs8pQmE7+E<7Hr=_ExhA_g&Wuv z!yk@ws}a7@Jg6mv3cr;WU4gW5vo$lq06ED8>A>NpB(n6v~yPO`qlMxA8{q;a3)4JGe&E z%0&T1$@Q-A9mPSbMJc0ogZq|fFn}C}>>d70Iec$Vyp!~(PxwSNQ%eGw-4KrEbDkTh zmCwq!TN#aT7@dvzILvZ+xz;&11J;|qF@zlXV}>psuSK9&&|3E`wf8-mD4M+k!E$~` zaXomkt=!gfT^+{ZZ3+%Uw~Wy-f2H7R>LVBd^P6*RedkuKrh`&eKbMpJCYZWUj8z-$ zE!Cpr*lTe3m2!57d6ihym)<>cOvuX9JMR|nq*5)ir10hwYHDKabB_2`QghonV$q39 zfplHl_mhhX!=dzPm9J^F8P$V(nspDc^&UNNerQdbmRY+#*n~B@YPMk6xyRtESqCC7 zqf^>;OxHidkj(GBWk#AchW3t+XMbO~=~(SU@mC}qRK!?vj`;04qt=9<`&Y@jB2(7I znF5&eq-jTI(-x!pJXQ<6-dthFeJ`a|3!uIv1jrhHZyJ&JurtuJ2!Y&`vfj{zLPL+ zrFngvM7_UKce!lUA=1CR#oIV~2$NiTt(a(}IIpyS6NEf2F|H32U{htS>pva%0;>ph zp6i6!wEU&%BjVqen26aF6Z-9Fqu82YeP3-*d8Nsxx0H3&7HMcO=zBf%PrD zfp~wPa=|D8dMg#Nw{+?m7ky@n6Rf$Cq8YR5*kGFQE4^qT6&@RvF}?a#m<>Y$`8p@+ zrqNI$^QPI8VwV}kv?{l6a;EiXSu8-*elos=%K{Ks@^Xdfrd(eKR-i1)=So=TmqlhvJczOJywVJ$c6KAHT86a8cs>qgKKxs_cN6<~ zYp8}1f^>gHSr6L~DyXcFO5Al}t>nnesCdBMC)wdtF~_*%_#EsEC4TDg_2%EW_0Lw@ zz7gF5-N@^Iuq!{3gbX(==6)pv%{ZsMtLAz#<4^+f<#e|w>#9W|%1+MA3~oXXx?>?^ z`0XY93a5RAWcX3n!osA4mZ|%7Q`Tjdo~OiZ;@u-{^JGw{E}B757;S(zJsBlWpn@D3 zc$qUPni#ySh|7#*?Io2NoDG(IIuAU2ZjQnFBUm7pn2V$1cwj9k<*AFiru2z7M65}l zxlHT#XAhHHCB!+|ZC4unsk}ymQuWuFpw9B9=WoH~=In}OxM))>64JnsP2N`+6etEt zbL2thZCMA>u|AMzM;cHp`I*sn>9K?(kg@&W*{T&7is{65ndZ*Na3xVQA5AI0PE?P^ zO=JVRJk4TUIMUfmfPIdTbssPg8^=Zz5JD!QyS|vP@+RFE+ONz!<*l z(J@#<gmD!KUA-V8aOvG$8m{eINJ&~5J3IR zeO0vwa#wm?jOvzxxMxjOTg3JaHFlJiqDv)XHwPjse>fz3ra_UuI9ljXLzK)Ffi|k) z5Cj2P;^MQv8Yqv5Z^=or)KJogVYr{)^V$PlWX|kfR7bFXHFh}2-6AQpiy}!CEH|)YP zm3CWSOv#t(vz-|H{lAJ~kmSmu2Pk}G%CW^Z<~ znSGjWp2hJnQoAff51Tq?eyT9K{o~ne+qWjt-z}~2xw_BOY>lLg(QtV}rIeVCnUywp zxb(xfzg{!hw>B`a2jU=_?p=;}?THyib5_OWluAKJ9CPEVzITOIkLqn50*KG)%qeuN zoB=v8v;OW^**?8S+N!tLSK3|!Z^QJbe2oUh#D!mtJh^J@11{NzPvBTb^7*%&K0iY}&bvJ1pm6;5hnvYAe2E>K)( z$NI)?E>g%hR?8!24vb#NMbYaPsklcefs8CzT4G1T*;gpjkLTyG1Ein~%;Fy1fir(l zaZ<>*sG^6=bb$d8aR&%7P4r+Y>JssXZ!K%}klsEeV~0O0%BpwOXT%2+QVuhkfu9Cx z!L1*5=I`o|?gXSL_l`eqcfYQ}A;{>b;t}#z&Q3ly8|)J0w^tQ&G0l8RG6VZY4(OXH z{B!((i2PNcJ><_eCb{=PV%PZos&q;B(#H~X<>fMiZTGp@1MNYtZEq22riE7pnH{6T zM1uCg8}9AmX?ZTJ5KY=};8yU){8zV?Jxo>9lWOzM1OyA>Us zjbOhtN5nEKWJpXNsLnoDd}qLsxUjuF24_=-KLq;Oxg*z|$yz+Ph_PH@dzm3R4Q$H_ z2ek;1!Dzvq+!X(VaCl9 z%7m(LlJUA}s)Th~6m^8ogf=0*Vl`Gg{R>&`f1o%hblTSYi$>HnCv=@x<0&>#SEyr@ zyTwO{raQa9UP6AeT^)iBvc4J8OydN8s;I7B!MrAMELv0;YB<{)9d6>QfL|%uj6A43 zFMPSR;ZQnqS8RM=gt5R@j^1UFaH?dh9{S6kMhZnlNgvQaEjX(xY((EMf}PyBS_vsb zj3okwP3M6xMju|kbV;gs#rRw=W8z%IfEe4LL~1pHU~O7)sQl-ik%Iee`r&pZNCD)u zv@|U^mZItn1>@m}uPmg^dl0Z|W!COVbt#}W_V6(3hllT4*z84fi*a1V`kmoAtVEL2 zZS;`vHAN5NFsFmk+#K^o&X@6A>S#=sv`io_9k0dg@fqOcO9gYNR}RgKKeXbw)WZWZ zxZ+KWYy@Ai-%d26y+j#wGX;WzIO0{C$mTB2+>}k+Y_6irSy1lr;6J4-Jjn%gH70c9 zp^Z@^JIsB#d;bu1&P}3Ef{D0CpoMpH z0_C!9#E0fJ!Se;u%z-#o;G3Sy#-oXAlDNp`U_|lQHZuxuU97EVpZ(U_452$FT8 zI4C+dhZA_ausGD>fh-o8AD{Mj;a)*8Qa0D|fEDlsZ@hEsUbP9qW*nHGl`$pP*l0nPDTUy4czA&+p6^r66@H%?R?jr}=2i``(Pga3ikSTUvPMV0$aODI z{={2z!Sql+k;=Z88gctvFYbX|FMyC9SX6uxYzt+w>JIso zH?F?3;+x)0IIO)l;l?)hhIZbm)8mfuJqtH$xEhLV)>r|bz4+=rI6c>Nu(>5a_=uM! zPw7DZr&Hn!te4r7$st#r!toUPdgqKD+j6RpIrBV)Gm>RKsxG3XWzc0Jp&|@HvDXMOa1|zEal5&*Iq}JYX81GZjYSl)gEcd^& zIoZ;qHnBe$=NJz^xq{s9H9}eVkn%`3<6Pc31>$fz)JrPclv`_|ndWjdE47@%eKj23 zajy2XQ0;1+!M@>|>knTEYkR{QI^Bjb2+|nvsgITxJEOKpLkQny@m=<R}|(<0}H z7^567XY#tRSlSN~E28bEW8N9aoTbHgbFQZQ)m0J|h@9Cs1A`#`(W2x7@TuY6W>wS9 zEz`PHuw#wq*JSHr_7k2;&9*k%d-ZJh=H`6sA2;89x_g+LBD{02r`e1wL;ID)lT(nL zQZ99eMevf579$(VxyQxO=Zg9jbi7mXrMhythveT%Q>HWLrTWG>Q$=R)!e#Qwr-hWN z*ekw-Y}qb!wwH$NCU3O&f;va2YoG>Ki6F>2%IoBd%V?gFj3%KOOz-1`|h+BOYW@_x6xt|EdiefA+OELk92G^OKayWrpBLECSUSF2EaziMB*pGBON|QDDM6DvnxON zzKcUyM1eSlqx1O+5{>c}$-f%1Nw6RKG_Qt97(HHY)=E1^TBc`8o5J5G)8X1HE{Lk8 zthiA?Cc!<$_B4NuVZ_CeiwhwX6hcGH*t&;bH3Kpp(57qTvZ9O^tje_M=NM|=Up07W z;Z{7`GLnFhJ%x95NysB_(@LIe>nlEG?s&?3NlCc3qd&NIrZHdgNt9b~G{rm+cx_S- zZ9WxLyUYLyb3}ab$Qe&&f+P1D^x>PLwEdMVSKiQwtc!?WkM(tib;N!)$ApxIyoL2o zs<<~_AtS~DmkRnQugVbww>Ma@!j+!{O&%j{s8WPg%Aa87B7!o9pe98qlS2_1zY*M za__5+<_Kc8qmB5B#RHMq`+e1ST>|*Ko@FF*593!;`5x8VHMFNCp2UNXKhZGF6%B^3 zKN_Up_6zuBle1o{b%?HRscMq=L{T$KU~=}=3v5~?^;qq}@Nh`*XT(Oj#?Jk(5`R3p zEv7n^KwNru|jD&pbayFV$Q8R)(wJr!oCK zu0d@yX0TT50@VMrW3}CLV%EG?ycZyLj8zn{yp&?jQ?eK~(EmN=vK-iQv)^Ps- zA-v=%v}h9ZU)O!mr9Rqsl=JILJRH#Iem;tr{ySunB9iwvV$ zw5mc|6gzEKE?;1Q)rGbuYYJ=Qy)!Em*wFJ0mbE8(iKbVm5I;LJ3wTpl1;wqa*eX(o z7)6L^;67M`gTi1xkXPag4G1G&YGn0Bn$5D0Z z3#!JS&lzsj*c3$$M3L)-`3O2m*m}?!;Iodbz>U|uq7^8j z7>Q|F2Y%l_%#b&t*JF5|@zKVKU}gRgs-Q$vAw^OWegkl?b$sQ4Ow>NH|0hr`Gj`p- zZ#kE?dM593nTGnRSY$O86qtQQ6l?8p{#^=_!k!>sw=S2&*05NrS#o?8O4LNncPjDW z%43RMirlzdM*u}Y(0-Cmr3}SzbeotnN-WaJ*BY*%vBZMCK1|Z~_FQjteaU$KdD04I zoJUr0kjIg)g8H3!sziJf>wZ0p8*M$$%1^z_|A9&bQb6*RJTGf!Y>270Gb`SV3UC7z z2iq>olq(!5yn*WoN|6SlEP1<5EbMG)si298-So72k-@=@g?;(Rw}=mE7b?i1urvd@ zxwfi^EZT0SWE~0lkZ=R@DerQw%91nnEWUTYUhHET>WHN{URCQug@R7ULJCGk7P4s% zTew1BQv2^NAMLyf#_u3DLH?DwVE4G`^mzeO*v%CmKH1ZeEV^Om|j2G$5-<+*n)J!4G&3vWPfTmIbWqck%IEs z+SZa~Ovs(E*6KmBSfl`DhI9zFe!4C{arh>;**o-hskC}y|3$V$-V-%u?8nqVR-#kp zp&zRRcAM5_he0&SE&s;mwIInwtPOoYvzX&2mdPnxJ|$g|X4fZ9qW&?DlxNBHEi5NkRbq ziPLBF1X=4~4Hsv>Se_Kxx#XQ0I#i}IHi8;FYrjQ`~ zQQDbq*4bvyv2+`#S4LEvNa~cuv@E8#{Go=_@rY*%)zCc*&DGpy z3pT#UPm))=iHA@1NxnK_ebisdlut9jAk5*UJG9=%^K7UcuIMEA>B}%oB0){$r``k7 z7oS|=PAONOVG{S6A0|Dh@|4xXB*1r4_^WHnddr!1tm`S4y_=P3C(6ukj8SH8q*qxX z`ZvY6{o2X7<$4n>#!I+`x+`LE0xeqL&1Hs?XY*soxQpSXB&MVK9*L z*oPpuTPlzYI=oEuXaP1|7!DlN?X0*9CE@!%2OV!pDll&}OOX;i_;4oE^E$E3y?8HP zBg!DQ;)7tODg&(bttU?xVC}?;d)z5GTb+I0UDxT~g#V2Ed6j*RE)m$EeX*!HZx%C6 zVG)IzA@8coL)%3{cD{pcjz9YVwSRT1a<4x1W|VTZGylUQ53!auFd4G2pr+NZmALq( zajN)s&ZYcgyY&X3vzHYr#gAX;H$})dfkXSLPU?&KZwj91l}=u*t`LM`f6B+a@R5i7 zGnl)qH)YrbPUS>G>d9%qO^q~yQq+ULQZ<*hK7PICFT)Km>|m@9ja#l^M58y zp4&P|yk|dust~j$$IqmQOw2Hp!Jl9J)NlE~t6{q@2Zta~i6+LsJ9q85yr0lmV9|Wrm&nK?Oe`|`CXZyRp zea@vH7L<%2NC!GN6$4UnRx=rKB7eRl16Y5MZ2?vx>DCW3ROubAzV0c>yH1=7**Wsq zHwV%}zim4v*smRydYTReWf=Ae67H~Hvrk}PnZ@7 z*T`T1RIgWm(y)g=%aY4wi*@;{-(1Bym+nooxfs7EPx>)ew0p;Bh}x#N2yG+C;DCDH zl9TL;qQgVWtHpEntEhU|-KXz_wNE)b+v7xJdnw-Nzg`FyASaPYd3$Kw-<6I)!AbY8 zA*<&ecT2@b!A$)A^w03T>FlI< zFf}$|Gu4>^lxDYPu41won9N#JOH%;guGqta^3F{X&zc(WTo;_5BDXm+vB#|T|cXNUSzUnGYa6H5S_d5PD;EIuBhalTd(Se z<)UZaPL=z?S4o`#FTg)Y7N8!Gn#xl1-u0D(;Is27*O|AGd{F{LGUtZMlKAT)&*FFR zIXk0I7vOCa_3`zj^#RTn_t->#W$Uv+0U-C63&h8m)I@Qs?Q&T{4&a6;zAnj7R_I03jH3Jd+>6 zrWT?^#GgFh=ZxH%j}6K3n!d1BBdX5Ez0a|52&jaMfjiaK_sP0V{`oY*(LYh&3@^ev zTrIA>L^~LpyM7Tm<-4Z@92=16pL}8y?7T;Qfx2)U$sR^PiNe+xmd ztUZ03A^Q6|@U)CxqO$d*SlVY^pg{n@s*H-|bmPzq{iJ)}Wv$j6aCP~8=XwbZs9$Ah z0BOQ7gif3SP58l0+4rCqn~XRE$N=0vrNu<`5)a}?96BfLY8R|3EKHx&pZaf^#>lr2 z(Tzrbeo6KUev369dzAlvS09`q8{ZHWyYfCX(7+G$g=wy=?6uuB zn-o;637=6kgfvD=Q${!aXb1E15>4a=ogBlDdVoltt9w_YH_K}iT_szv{Kp+0KpxY) zr-mGJqxXWsP1_pa#B>ogGM0iZI6n3q?FghWn{`#?O?-hi(dTuY&cT(o(JeftqVgrf zKy}&oPOuz*);88AGe~KYL{=;q8#hF?;5e?i^K#`^6Kh={F057dxy}Ua2*qsmVKN`c z2Nb%~i{-#g{sVl;4*ExNbayX{9h=sq5; zVebd)wF&p+8MVGLp^g#ozUCO%J~qt$bJz#_%?Kw}n~xp9Sd4gtPYZaw{-2{esGK*Q zu9=Ar^-U+jIEV9qw>cSHRIfej9H%FG5`ul$k=rz!oFh*@-#T1w?wc#oif0-e=|?S2 zH(^u{KK&&o-OBjNF(2H7T?C0DmA#w;C=>vW0v#8^olF*H)ZESbd!n_x;zuun;tuHo za!Ou`(o-V>LLwb_(iB1ON4ci&H4w4DY!r^Sw*)S6I8j4IoW;`LR~UX{I@4sLXhr&# z+A!#@oeb~s(pBy!RzTIe*vH#E0yL#T3LN@{%Qwc=(eQMO)1?JJke}A1!JbhqCpGGL z6R;f>z~JR^fsWH}7hd6+tNsJ+GToOh3(`p^Z1&VJ5Kjl?0Q#u8xUyMiR`Z@&80V>@ z7B9Xlj%4GHSt=B}y zL~|X*j^ZM(5BYx$4Zm+8ZmGli^R)u{!7iRy+Mf+@1BlgSZp=mfdPgohv*XR}y5~w# zvvW%4M4;ov@+;x4#{yPQdF|8iuMsT*?iGZ;?6>z` ztl-v}&!RG@9^7m^0?&3!sbNs$-)oEN@*?yHPua#%Pb<5Bh?`b520?N_;r z;9Oj%+|-2Sncj!79yL$_%riMYKbBs5bI}eA*wlUG;-IE$pjGEwHs^QW#ephK4N#9g z9Sl`BsNj2IkN@j{t!TR)RS$w^Cv2oa-41l};eE@+_o&+dvUGKXQ=?dpC>$XGUeLIB zoZA9xYLG6lU)42ra6C~b=~RQfao) zq^0v$z#xNXzvv@`n+W56;E{bm2of_dBA;I$u*jGn~exvg=qpG)&)~s58%pF{UY6hJ777BN)4Ip18f~7yrDNpf3bJWb*iienJ0cd(9-u) zoA;CJX+`JrmRQW~cMxjAjjZp(sE)TBqzJusV<3(z4vyjJB@gc>+XJc;2t6;&8Va6* zW<_8J%Q@QjM}%0-!IId9^NB(0K96sV94xV*K%4CWV`bq{BOQMTuPu1}E-zq5`n>#) zhbMdR1X;pnb|i*3H4H$u#obFsi0&Q6WR8yYZ$*=&+M$96H5cB55&fr_LzTfTAO@h_yBxtly6?SDp1WU*j%zm7}J z{{8|KtT5<~A}$KZtZG-2)05dwXH`CuHQ1R?B*XJdI(2~V`ftnfuj+mmEeG>{vS4Rx zr6rP(9AkPy62K4_P2zPL@Qp~1JAD^@%H3Cp3@=1&gY8g|5eHQnxSt?jfm|LKu+aK!=>Iayg`Kq+kexXj?V{OLD(Y zm`zPSyG~z0k$<^t>^k%HNVJbYRjlp$Y-Qq@Za-&u~h;4y13o7;lPd;ZEFtq&#MMJoNln}HhZh39?j^v zuiKK}h(c>KZGHVFSg`gEcT#e5aw%-)Vl8*(mOhlH5QQUnDMmp!0sK(-Q*(;N)XMy0uFu{+M~~Gw}Zf^l9PucdJWD$wB=ywZIE*y{xc?w1!-u>?YY_+GqB` zszj?!w#9O_{RT8(jjJ!wXBbeOfxuUQ| zl=#DzW7r_24qPjGaa~CvCq3zowvK!uATr@J&$zeefLs>00vuep@VyC(`sMaNJF;PP zG*Hru@%2bDB@~4vq5bt2US4vr&h|_LRs!~Xw)POv(fju0XNd@B{A!d=LoJ!o419RZ zD57LivJzy{{$ci@cn~fV%(N~f>ahs_*LS`bP z#ZludSKXVrr9sVB%N4&WIH*(I<2e?-zO~yz#so>|pf$i3&b>VAlUrrX4hsq{pjqE=Lp)e#VXG(R<^8SzDXp$_!w?LtK= z2;RmFFu{oUtAela>5G4Q`yQ^UoThm%en23ax4QlD!xgL4=$`RDgVgtLn&b{&s7HPT zP8Pq?6G_8p$7V3KBvyyfGrb@IO1kwvyA+UM#1>)gOW^E65VVXL(k0(4RFUQk^`VW)?!T9 zU$%(M0-H#hx=qeqAeP~KX#qw%SUDhe7^qGR`T=;$fjQ^b;g4=-$ETn>BB7HYGn;sL zmYs}>VwF^P>=YVPXsyOG3w{Z%xqnyPHbg1~*&iQujsB;g@o=Ks zP*M%B68B$Vi-PdXE-J>d$%i4|I=f=#t@0F9SKdgl=9{dS zHU5klp-Lt}2P-Lt9NK?TZ+-yKv3D+=41lYnrnZD6d0U|kx;g#XK$I|Z%}fBgn%VZ! zLb(0Kpp-{2n)f*%=Qy@UnR~C6fR8b5lr9~)0#S1Op;=49&Ccplm=)peYg0V8oDG9H zOhW&B;`L?e$F*voC<>t_R&arX0`w4jR&b2i^gG;ipawqjS--MAK5krY-`kP>yjD)+ z-`38G(lD$lR}r2pq5so0RPh|ZAhMiwT3IzR8cuDRo*{ZrI<4jr(YAC#y?uQ757-BdyDV)KYch$mQYk#KVsat9lQYvzrZg9kT!+yxK2=Ug-mAv zg_6~lbQAdjMm~y}c^f&N65G2rc4| z1h*v%!~C?!lR{p$ zphF5MIPCXVl7c*{rq%cu^&SDE0;)%Z``v3r%e#%M)vfbr{Z&OV%B1eN{dYRz6M66# z#0p@{9R^bWKViUC9*rZ{}tX^Dc}OlFtQI zJgA%Vj*EqD6@I(ur>5fCqn2s{93&8oZzvwcu>Y8>|A(BRtlb#@cg8DQdr#nzKi?XK{7;$)OWO*Dvr6Pe&zz05m^3 zM$d2_F$eMaRnYX3a&FXFUe$+h_y|PwzP_@C08PcPK#Eze!jC11JyvGE$F8h0MKKkC#eS32us0F*|NUQ?J27g~3eQQ!Gb3#CYO;i8$MhjQ{6EfGsH)>Mr&tkR{esJ*ewseW#Adh^S$5<%DB(u}1b)*e7~`^5YyH z+L40h?x~kgj6E2tnv8rG0WywFE*cP54bU{4*#GV2@CO#AJ!CpcK{mJjtCwjdPTuKafLRRoiFk=qMREsBh;Ew28@ z1xWv-IH}i%i!J|p(a%or^p8FFSNN=BxW->6nFJfdW@tc~9lXKvpAL+s5^t@XX?`ld z=FYs+*t*TYx8Y=@^-?sKsvU5-u;;%MA!DYs+f%P|e;H@_7^9WAcemmPo;&=w8Z91T zo(a5Y)>ZOZF8Qj8|EqmZp9@Nisz^-!tRZ~(V!O`t^n+Vo8Qw74k;R(XyA4ho@uFfb zb8{UCWj=r?rt-*l5WbP{-<7L-wHey-B@kTp1vel7Wbn?J%TD)EUq}?rDuNZI>g(hK z$~m^9{05Y)r2ejjKp#h*=Zw9Zyf7j4Cv2-`f^sYOYe}OFt`Xc*ql3vAJ1OR z{roh^xhsPA>d#?+1;v3&d0L6y^P(N{b2oi@e9_d~Qbt!M_8ei&d!j05X1SHTc|KS4J5U|xtt7;L@r?C z&b?r3G*rNc$fz#vX{29uUj0?&vyqBm;Tmx>6Dp#R43=iNjrg&IW-jpPE9R=7Hq~)3 zc&j0&5Hs2QVSM!xVs9`}IXVa=oxyhT|Dn6bObdmYxBX&`Tes=v<;E7iD|^5Fr0t6e zm#T2Sdv`fy^1Iu*Ao=Ok0bY+gjzg%}XfZ1_T{EO`-QBX;)**SnKe2zEb$)ZL)={$l zx7J=g0=ytKJwQuKc|bZ}vCH3;+!cd7rKk)3hJ==FhD^-nL;f2jyQn=gcxWk?*o{rg z7W0`>q0z4w%3plEQOvhCV-SG0{(DL9oS({~F3#Jlm!Ngn{V2NN;WTTcL2DJgy(-m7Ug z{seC6AtpoEifqsKm-?Jdd@|Z4vp|*_gJm6% zx@*E#+?H*{IPMg7GLMM-@OjE4QKZBwInyh|FpMDnrPp(_IkFaOYH6rlVmM#P@(3GB z_ABUD;ArEu*Q+h&Vn{nX4BTII+EZm)nVrpcr4j(Rb!IRB&j~ecg1lLHGk+IVljKuH zg|ii_v~4UeCJh2;jLE!a&*yiD{N2Z(vz^kRSRXnPRfWqi;QoWDVP`Ae^~}ArY5rL2 zF8}}-C3Rh3Q-_!omM-K#t@OhOu?HD%X+Jvx|4laTx8tjDoaKUK;SwJCp@!%HXFv*E zXiIVXTzh6-=o^1i_34FIrpO5AV$1a|y=c)7JeAr;d=uDaIq;VlTv%cfV~dr(^L%sd zaim|BoO?NO%M)Db3`!BUo|(mx_8?d?3OpQfX^~$PdoEnLJHe(sWT?FJJ8Gf$ViD0U zW~mK6KX#zGF53|OLv%>Lpa@^CL>c38U*XJpK1UKV=v=TPcpz}+)^NdX+@ z(R@@(Fagem;j7*N#sidNR+sRib}950I_!3^;J0Ajzj@2gm-UH4ZmqbRP3%11s^U~0 zSPHd|@ynoEzAdjbQGTD++rh13U$Z{JRa)ixlSNTCrk!?A!mrjZYW6aHi*_1~FI@(~XZGO>L(>$avBo%hW3r>~rv zuF=r;(}37roS=1C-}5-8#QpPqgX?1mwT*3-X55V#~qCx$>ySz57x^EsAaM_ zG7V>rqVVNi9`&cIKO$4M9kVks3MQ$^mklyXbg>O}1ATpZ4P%3qG+g#Z*rA5)%#2-( zo8Q<$t?OUks`1r|+uxSLm;KV8uU7bAXu4NBYt2upYLML=IUIAE8024YX)*J&bmp7i zL*OEwBIb0|rgAx3MfGS+gvR>NL)DeLXC>God+Jlx4kCZbQY&d>?DAwJt5GKRq#rC} z6G29&F~uHs?1sIYh`=ojKr9oREQJWcrR?$?$6>^jM{(R{OHCBH{N-8d;X(6lVO4 z%k))fzLP|2cwWJ7{?)3zsPJ4*Hzaq$W0sJ)t35iYq^wx01vem{;k5(e&N_ONTJPu_45h}@2{H243JfA zFLc*XnfwN~ZD^}A4H{B?q-KTatv&#M$`h<3H$0$zvN82eqH(Ye3&=f!!jjf$Zjk>w z1*6Mchas=hXNS*DHC`e~NP@|Bwen5SKEwB9{=H`AeTs$w*&MM>=_~<5Nxd)pyDDS*o08&3ex#M3H;!vGTuBl0~NApqq7(wxVgL%{{$1>Au{m<0T$ zD=RLP?B`t6nLxDW(8|Dk&bFMjP zh%+~M835OJ3I(UZ&a%$2N$!?4dmC%ZfGbbw*1yw z^%`GT`dIeVj&5jMW(Y-oOh)>&&>l?>wA<&V3U4AEJm`$De6@ASMoUk`r7>OY75V z(z$O5`zfdpF^zdr>`fLmw0}E*qW&dYBRwkAlO*_m=wUO59$|~31RHGo{4HKGMl)qL zm9+-^ALgJv^*y}Hjm4_$|EucC1EI{{_$$J$HnrL)a@71#?#h)I5hb~5k^6|`Sd$~? zFiKI*LOGj}Bgvhk+#*@Wkoz_YGZ@A(t}&S3_qFY={d4C19?$oAKF{-g=6#OwnL*YZ zX+vF5`Sxr#DDpQR4~cuDoAbt11=&dx-=nA)t_5yu(2|6CGoapA*Z=u2NDbr;-LhXN z4RZ`L(vM9Kf_SK$KVt+V|6k0FyNM|rI?COS3)9d{hb>`J_-P?~_PUIZREpJSjk7DH zmdL=AcQ5!C^un1Lft9U0pOnW=6-ewGhvM$17^UPCngZUVNdMgRfveH75Y;V*MY~JUMvk zSWw}|+e_scs4tn;oPQM|NX~#ZC3U}-*857vyv}J3U6#msg&}zNZBcMa7ZbdLwI4Zar)P|p6n{>*Yk`>X;0iq%hjQ~?D0OJ41u@CBE`l3l z`w#Mmd~7SX6*(b3V~QVhhyA~VVaamIPt86(urBF)C^#1xcA@@+aVwP!lPw9k6)DL!`n!bwRE<1Iw_A(;byU} zHD6Evb_q_dXCIi`M=w( zpY%GTIXsRlqff=2`?{n~Usdd)789jb6mDA9tfczyr&5@y<_%Wgc73~l$nF|xZjnmU z=4MxOrd;I4p9HU>@;F$LPbsw1o5C}3R82G25`4jTAJ#~JS(Aa5if3QTmGp+_`0t6x zp@VY^qlhGj`C%D?Wv}mBDgFP5>$+Nwc6_Its-P)1e)@CNBQu`0-`lXbFgC5)a!Fd` zeVGlJ_em6A7pa${rrf_iw0*@%v=E$er(L*PL7t+aSkb z|0k<~-O3cXo3Tqm0rDV{l_)}ZK)>i*>m>pQ5u3ODMmACY)nC}ysCl+8ohnF`EGzO( zYSrEScjgcLb5%jm=K|S@P5Hwt`z{IEh3S#gS}=`1$pfP~ipDAD_)kenPS6YPDqQU? z(++IEdKv|^!HTj~A?qS7wLOisGO}OO)N!a6k+sU0H<0I~tmpTLw^6M=2<4)&!++(H zfRGUrbTyoebue;HsChCt5!>@~BD>$I&f_R*jt{|>ntJE*QiMmNPkF$i`CM>z`Hgco z!X#<@A31pESKqXof7mi1ujjAf`54`|ZJcFoZZH0Aq&Okfq>-XVxY2{t1;=*F9bTRR z&|w5bwLxNu88DDQ(2nh+17YLW^rt)c=-qw1x&;7QhCx1g8f-+E96S2DUOQUa18fH_>N{f1rqZ-uG z4}+*p#cGX=q_{Yzldy2x$dUbOG!_uPwiSrzTDx^uR}^``JAyW1@jv-czF^%mz<>Hn zKS7|m`}@PXn{H>puVl5QcYSK<7JXX5&y5uR^!de~Wtj_<>=bi`kTttdd>oN?e8(4% z*&ht#Uhm#{KvCfNQ#I-9%d{ol zVIEY)4LF|YZtF#R)s*J4A@7OXrGgG&z!CRDyI?TWbm<>i^04;;g3f-PXZ`bub}x^u z1lD9HI)Y#hi`&po(;bbEi|@Mhe*mR+*}|z-Pu5*Ujbn_^eQ9~6ob5e)-Qq`3V@Gq? zWSz6j&sKEw-013mg+X5ZQH=q|Dwv?UeAsWZsJz|7(deII|Lsy&X;OY+q?^RmM@q)G zo^m*Jy5X3$Ww~#)_dW{tT-*fZhpFT+16iM^!)#Iri;qgo+~27lcjcYd@Ss1y1Jl~& zg#wck%icg2$~k^V?rguPeQ$8?;9OHJgS=477^|!p*iAJ6w>kflSaQ|oU5hJMK%EoF z_kPd>U?Mig6=yZ`j2EkP=hjEut!Um5uPk9i1;yQ^De#H<3ueb;?iGm<@sEO5MjRqy=?;FQC;An+S#i0X}hM=L@fQN!OQAZq1w?q7h8no)d}-oD8pr+JKR-k=g-|$TA9qTSVpCl zS1T==FWvDlVxJH>`J>_k7LV$RoRceOL?_7YyXe@HUyOC5Z~kc>Z%eoHdhPglWmr0J zB}!*pF>mSS@Vy!HF7qX<^A2N;BA6X17Co^c_50ccmuTBhYPyFaB@ekjv?&`p;PG&r z>%ib$r481NePL+kk4)41ZtISlytNnOm;Nd-v+5k6VtC}!lp16Frag!@+fJ?5JQT{{a@7;3w$H#x!C0J5_G8G{o^A>a%pJ!G_-%( zMW~R&YJaBNta3*{8y#KFUOn8%JkSziao4CTx_IJGyPSy8fsJ+EtTFP3_<@K34l&eB z@B>_bRge14*2r9|RLel|rN_PoHM|Bj$_D(4re>dx-EmT@hr4q>OZ^r_w%^^VHotE(smSs$hD!*0?L0}AOd2<`Qum)! zH|o&t(Alw*wy}&`(2PUr0pVTSJagt1LQ$7Sjlr{H;wRtqI29(L#CdFLt_`Dgf2P`> zuXRxz+)&V^_4n$mJ6f70M=KTlcA!}^7uh~rx}vkW%7<>-93kb?ZrQDlREfzQu9kly zE!EpFsO6TVP(fcxP_=5mr~7o!?hbBKlbl4t|kR>Bq$)aRfdG`U0W~ zAMY>qhl8tUXYy&Z& zG#AgCexR?47iwWIHP~iP-{+H|T^=sY_jd&rUA>22);>w(aKg2YA`L(^+#J@Q<6qd+ zSzEl|w{Fdaw>;$k)1C*?08gzw{!!}*>zVC|Owd5>tI&UiV4VQVCkFEpd%HCkq51JF-!+?gK6p}^em~%wd0LbPXubV^cc^F)FwVLEhuq_3W zdO)LeXX%-3hx~)_x_gF!*ewZ8>fp4_j1T~3L0*0W_f%!}maRH$8`vNpwJB0_N0BMm z75YO0UikOGA|Y6>nW2lXm#LHSAH`vr(AT%+YMzP`bf&m@;pM@0r&#Y{*MAit7I*%& zc5S=Z58<%hD-wq1+n%-H&!SMP;w#R;YuI~M&~pA!TbC)ANHncHNbdVOKk(6`x%$7oQ}Z0r z$us5Lm35mFN-d7$E#sHRc*gi~FT{Bo!K%JF#i-xfmD9w|mBFyFN1a4rV_A!m6~z`@ z9RK)!KaV*k$feUQrZ%2G%^gK5?zDixI31skmr7FZ#E6~OHV)uOm;F3hs&a*f@V)65 z7tN6VSU@$Z9z0*B0HFa}Of4nFE!a|&CQ&t&s?1dbJy zHS(sN4ANZJCPlS?qPoqRNk~PM8Q+Z4+vFym@P{CDtBD3_2!f6jTFPK7J;VCBG>3B} zS{zh(N9|?31r)&hyp5L_yhD18gg6tF>dR_}%{^#$ND`AMVwl{bHQSc2yZL z9kQ>&b!f(Ch5~{aeKtEyX*a9Pb!jX0}=GUmdJT6l58Vr_zO+I1{XZrqi4FS72E9aHl?eNmV%N*>8} z=DVZs$7P`8PanS?M<^@^=;S=vQXo4##3$nW;W27mrFjWlBV+aJpu^e6gR?2TfSPHu z1OeN)4)A@elLlULV(XHnV4FjFG~Vn9BgrroGvBdc5mO7K)2&V)WP%o*IJMeS3Mq^x62zN^F zck9Gx#5~3wgX+2gSQMHC9yd*ghvpuwUvaeIf}j_sp)@LjA*s+bp1x(u6;%+k^*$3t zD;tClS#~XfaUEC_>zt^h4&%WN{^@AN1W_D%%x~&^bAc8`37x~KwQ~jtsZMwF3Oum2 zhQ2a$Gsf<$g(ZUaM@KY9-(W{3c4ql}PI3b?ZvRgh{OH#8>s#JLAiy}_;1vOn3Qnf=FGKp61jE61suHT_*y;plYmS}c@?PF z1=wdA$V@L_Sf6V5E|;00Db6x zFek$h%JH}Mb&!G}f2vCYq37~uEOEkFUW+m(0D9au+=Fa(<^du^;cF$7WC(6nNP&;} zAaz}<9b8&zD}oTj_vXqv+%li%t+&Z2v$KI8=<xoZc@+RMh7sfy#=b@P7n=4A9pVc&8UzyK5F|!7GLkg>nQjhVX(u5qU1a zUlj69p1cL*nc4w64^YUsMNBwgit+A{D7d$O&-G{E@de;@LHfy|i2lQqOwil7-6!8w znhj%GX)Y^8i{hZLAqoC+Wt5IE23LCpe3HUn;Avm4kb9Sm%W{B7?>+(|O_ib)4=L?9 z0V@^94@yD2G~lhTRp8Hju?k-T=l>bz%0Lz!5l);K(lKX(6fVQ-6pC?bxxNoul-D|{ z20pTbzY
, ,