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] 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