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 35c8efc2..9d4a19e8 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,7 +1,6 @@
[bumpversion]
commit = True
tag = True
-current_version = 1.2.2
+current_version = 6.15.0
[bumpversion:file:nylas/_client_sdk_version.py]
-
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 00000000..cf7ed82e
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,5 @@
+[run]
+source = nylas
+omit =
+ tests/*
+ examples/*
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000..6918ccc7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,26 @@
+---
+name: Bug report
+about: Report a bug or issue to help us improve
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Some steps involved to reproduce the bug and any code samples you can share.
+```
+// Helps us with reproducing the error :)
+```
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**SDK Version:**
+Providing the SDK version can help with the reproduction of the issue and to know if a change could have broken something.
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000..11fc491e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: enhancement
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
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
new file mode 100644
index 00000000..f7763d52
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,56 @@
+name: Test
+on:
+ # Trigger the workflow on push or pull request,
+ # but only for the main branch
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ pytest:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.8", "3.x"]
+ name: Python ${{ matrix.python-version }}
+ steps:
+ - uses: actions/checkout@v2
+ - name: Setup python
+ uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: pip install .[test]
+
+ - name: Run tests
+ run: python setup.py test
+
+ - name: Upload coverage to Codecov
+ if: ${{ always() }}
+ uses: codecov/codecov-action@v3
+
+ black:
+ runs-on: ubuntu-latest
+ name: Pylint and Black
+ steps:
+ - uses: actions/checkout@v2
+ - name: Setup python
+ uses: actions/setup-python@v2
+ with:
+ python-version: "3.8"
+
+ - name: Install dependencies
+ run: pip install .
+
+ - name: Install pylint and black
+ run: pip install pylint black
+
+ - name: Run pylint
+ run: pylint nylas
+
+ - name: Run black
+ run: black .
diff --git a/.gitignore b/.gitignore
index f7f6ed15..2940379b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,36 +1,81 @@
-# Byte-compiled / optimized / DLL files
+# UV virtual environment
+.venv/
+.venv
+
+# UV cache and lock files
+.uv/
+uv.lock
+
+# Python cache and compiled files
__pycache__/
*.py[cod]
-
-# C extensions
+*$py.class
*.so
# Distribution / packaging
.Python
-env/
-bin/
build/
develop-eggs/
dist/
+downloads/
eggs/
+.eggs/
+lib/
+lib64/
parts/
sdist/
var/
+wheels/
+share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
+MANIFEST
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
+# PyInstaller
+*.manifest
+*.spec
# Unit test / coverage reports
htmlcov/
.tox/
+.nox/
.coverage
+.coverage.*
.cache
nosetests.xml
coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Environments
+.env
+.venv
+ENV/
+env/
+venv/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# macOS
+.DS_Store
+
+# Docs
+docs/_build/
+site/
+
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
# Translations
*.mo
@@ -50,9 +95,22 @@ coverage.xml
# Sphinx documentation
docs/_build/
+# Editors
*.swp
+.vscode/
+.idea/
include/
lib/
local/
.eggs/
+
+pip-selfcheck.json
+tests/output
+local.env
+test.py
+.env
+
+# Documentation
+site
+docs
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 00000000..f789fc61
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,34 @@
+[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,
+ FreeBusyError.from_dict,
+ FreeBusy.from_dict,
+ ScheduledMessage.from_dict,
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 759ca256..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-language: python
-python:
- - "2.7"
-install: "python setup.py install"
-script: "python setup.py test"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b53cba4a..c5e0eed8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,487 @@
nylas-python Changelog
======================
+Unreleased
+----------
+
+v6.15.0
+----------
+* Added Lists support (`Client.lists`, `/v3/lists`): list, create, find, update, and delete lists, plus `list_items`, `add_items`, and `remove_items` for `/v3/lists/{list_id}/items`, with typed request/response models in `nylas.models.lists`
+* Added Manage Domains (`Client.domains`, `/v3/admin/domains`): list, create, find, update, delete, `get_info`, and `verify` with models in `nylas.models.domains`; optional `ServiceAccountSigner` (`nylas.handler.service_account`) for service-account headers (`X-Nylas-Kid`, `X-Nylas-Nonce`, `X-Nylas-Timestamp`, `X-Nylas-Signature`) on each `Domains` method; new `cryptography` dependency, RSA signing, and `HttpClient` `serialized_json_body` so signed payloads match the wire body
+* Added Transactional Send: `Client.transactional_send.send()` for `POST /v3/domains/{domain_name}/messages/send`, with `TransactionalSendMessageRequest` and `TransactionalTemplate` models (JSON and multipart send behavior aligned with grant `messages.send`)
+* Added Policies support (`Client.policies`, `/v3/policies`): list, create, find, update, and delete, with typed request/response models in `nylas.models.policies`
+* Added Rules support (`Client.rules`): list, create, find, update, and delete for `/v3/rules`, plus `list_evaluations` for `/v3/grants/{grant_id}/rule-evaluations`, with typed request/response models in `nylas.models.rules`
+
+v6.14.3
+----------
+* UAS multi-credential update
+* Added `specific_time_availability` field to `AvailabilityParticipant` for overriding open hours on specific dates
+* Added `smtp_required` option to hosted authentication config to require users to enter SMTP settings during IMAP authentication
+* Clarified `ListEventQueryParams` documentation (attribute order, field descriptions, and `expand_recurring` wording)
+
+v6.14.2
+----------
+* Fix UTF-8 encoding for special characters (emoji, accented letters, etc.) by encoding JSON as UTF-8 bytes
+
+v6.14.1
+----------
+* Fix attachment id to not be a requirement
+
+v6.14.0
+----------
+* Added `message.deleted` to the Webhook enum, appended tests
+* Fixed Participant.email not being optional, Microsoft events can now be represented
+* Clarified UTF-8 encoding behavior: ASCII characters are preserved as-is (not escaped) while non-ASCII characters are preserved as UTF-8 in JSON payloads
+* Added support for metadata_pair query params to the messages and drafts list endpoints
+
+v6.13.1
+----------
+* Fixed UTF-8 character encoding for all API requests to preserve special characters (accented letters, emoji, etc.) instead of escaping them as unicode sequences
+
+v6.13.0
+----------
+* Fixed from field handling in messages.send() to properly map "from_" field to "from field
+* Fixed content_id handling for large inline attachments to use content_id as field name instead of generic file{index}
+
+v6.12.0
+----------
+* Added Yahoo, Zoom, EWS as providers to models/auth.py
+* Fixed grants.update() not using the correct "PATCH" method
+* Added support for `is_plaintext` property in messages send and drafts create endpoints
+
+v6.11.1
+----------
+* Fixed KeyError when processing events with empty or incomplete conferencing objects
+
+v6.11.0
+----------------
+* Added `unknown` to ConferencingProvider
+
+v6.10.0
+----------------
+* Added support for `single_level` query parameter in `ListFolderQueryParams` for Microsoft accounts to control folder hierarchy traversal
+* Added support for `earliest_message_date` query parameter for threads
+* Fixed `earliest_message_date` not being an optional response field
+* Added support for new message fields query parameter values: `include_tracking_options` and `raw_mime`
+* Added `tracking_options` field to Message model for message tracking settings
+* Added `raw_mime` field to Message model for Base64url-encoded message data
+* Added TrackingOptions model for message tracking configuration
+* Maintained backwards compatibility for existing message functionality
+* Added support for `include_hidden_folders` query parameter for listing folders (Microsoft only)
+
+v6.9.0
+----------------
+* Added support for for tentative_as_busy parameter to the availability request
+* Added missing webhook triggers
+* Added support for Notetaker APIs
+* Added support for Notetaker via the calendar and event APIs
+
+v6.8.0
+----------------
+* Added support for `list_import_events`
+
+v6.7.0
+----------------
+* Added support for `select` query parameter in list calendars, list events, and list messages.
+
+v6.6.0
+----------------
+* Added response headers to all responses from the Nylas API
+
+v6.5.0
+----------------
+* Added support for Scheduler APIs
+* Added metadata field support for drafts and messages through CreateDraftRequest and Message model
+* Fixed attachment download response handling
+
+v6.4.0
+----------------
+* Added support for from field for sending messages
+* Added missing schedule-specific fields to Message model
+* Added migration grant properties
+* Fixed from field not being optional causing deserialization errors
+* Fixed IMAP identifiers not encoding correctly
+* Fixed NylasOAuthError not setting the status code properly
+
+v6.3.1
+----------------
+* Fixed typo on Clean Messages
+* Fixed request session being reused across multiple requests
+* Added Folder Webhooks
+* Removed use of TestCommand
+
+v6.3.0
+----------------
+* Added Folder query param support
+* Added `master_event_id` field to events
+* Fixed issue with application models not being deserialized correctly
+
+v6.2.0
+----------------
+* Added support for custom headers field for drafts and messages
+* Added support for overriding various fields of outgoing requests
+* Added support for `provider` field in code exchange response
+* Added support for `event_type` filtering field for listing events
+* Added clean messages support
+* Added additional webhook triggers
+* Fixed issue where attachments < 3mb were not being encoded correctly
+* Fixed issue deserializing event and code exchange responses
+
+v6.1.1
+----------------
+* Improved message sending and draft create/update performance
+* Change default timeout to match API (90 seconds)
+
+v6.1.0
+----------------
+* Added support for `round_to` field in availability response
+* Added support for `attributes` field in folder model
+* Added support for icloud as an auth provider
+* Fixed webhook secret not returning on creation of webhook
+* Fixed issue with free busy and scheduled message responses not being deserialized correctly
+* Removed `client_id` from `detect_provider()`
+
+v6.0.1
+----------------
+* Fix deserialization error when getting token info or verifying access token
+* Fix schemas issue in the `Event` and `CodeExchangeResponse` models
+
+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
+* Rewritten the majority of SDK to be more intuitive, explicit, and efficient
+* Created models for all API resources and endpoints, for all HTTP methods to reduce confusion on which fields are available for each endpoint
+* Created error classes for the different API errors as well as SDK-specific errors
+
+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()`
+
+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 verifying webhook signatures
+* Add optional parameter for token-info endpoint
+
+v5.13.1
+----------------
+* Fix `send_authorization` not returning the correct dict
+* Fix expanded threads not inflating the messages objects properly
+* Fix class attributes with leading underscores not serializing as expected
+
+v5.13.0
+----------------
+* Add local webhook development support
+* Use PEP508 syntax for conditional dependencies
+
+v5.12.1
+----------------
+* Only install enum34 on python34 and below
+
+v5.12.0
+----------------
+* Add support for sending raw MIME messages
+
+v5.11.0
+----------------
+* Add support for calendar colors (for Microsoft calendars)
+* Add support for rate limit errors
+* Add support for visibility field in Event
+
+v5.10.2
+----------------
+* Update package setup to be compatible with PEP 517
+
+v5.10.1
+----------------
+* Fix authentication for integrations
+
+v5.10.0
+----------------
+* Add `metadata` field to `JobStatus`
+* Add missing hosted authentication parameters
+* Add support for `calendar` field in free-busy, availability, and consecutive availability queries
+
+v5.9.2
+----------------
+* Add `enforce_read_only` parameter to overriding `as_json` functions
+
+v5.9.1
+----------------
+* Add option to include read only params in `as_json`
+* Change config file in `hosted-oauth` example to match new Flask rules
+* Fix unauthorized error for `revoke_token`
+
+v5.9.0
+----------------
+* Add support for collective and group events
+
+v5.8.0
+----------------
+* Add support for getting the number of queried objects (count view)
+* Improve usage of read only fields in models
+* Fix Calendar availability functions not using the correct authentication method
+
+v5.7.0
+----------------
+* Add Outbox support
+* Add support for new (beta) Integrations authentication (Integrations API, Grants API, Hosted Authentication for Integrations)
+* Add support for `limit` and `offset` for message/thread search
+* Add `authentication_type` field to `Account`
+* Enable Nylas API v2.5 support
+* Fix `Draft` not sending metadata
+
+v5.6.0
+----------------
+* Add Delta support
+* Add Webhook support
+* Omit `None` values from resulting `as_json()` object
+* Enable Nylas API v2.4 support
+
+v5.5.1
+----------------
+* Add validation for `send_authorization`
+* Fix `native-authentication-gmail` example app
+
+v5.5.0
+----------------
+* Add support for `Event` to ICS
+* Enable full payload response for exchanging the token for code
+
+v5.4.2
+----------------
+* Add missing `source` field in `Contact` class
+
+v5.4.1
+----------------
+* Fix issue where keyword arguments calling `_update_resource` were not correctly resolving to URL params
+* Improved support for Application Details
+
+v5.4.0
+----------------
+* Add job status support
+* Add `is_primary` field to Calendar
+* Fix bug where updating an Event results in an API error
+
+v5.3.0
+----------------
+* Add support for Scheduler API
+* Add support for Event notifications
+* Add support for Component CRUD
+* Add metadata support for `Calendar`, `Message` and `Account`
+* Improve error details returned from the API
+
+v5.2.0
+----------------
+* Add support for calendar consecutive availability
+* Add dynamic conferencing link creation support
+
+v5.1.0
+----------------
+* Add Event conferencing support
+* Add filtering of "None" value attributes before making requests
+* Fix `categorized_at` type to be `epoch` in `NeuralCategorizer`
+
+v5.0.0
+----------------
+* Transitioned from `app_id` and `app_secret` naming to `client_id` and `client_secret`
+* Add support for the Nylas Neural API
+* Add `metadata` field in the Event model to support new event metadata feature
+* Add new Room Resource fields
+* Add `Nylas-API-Version` header support
+* Fix adding a tracking object to an existing `draft`
+* Fix issue when converting offset-aware `datetime` objects to `timestamp`
+* Fix `limit` value in filter not being used when making `.all()` call
+* Fix `from_` field set by attribute on draft ignored
+* Remove `bumpversion` from a required dependency to an extra dependency
+
+v4.12.1
+-------
+* Bugfix: Previously, if you passed a timedelta to the calendar availability
+ API endpoint, it was converted to a float. Now, it is coerced to an int.
+
+v4.12.0
+-------
+* Add calendar availability information, available at `/calendars/availability` API endpoint
+
+v4.11.0
+-------
+* Bugfix: Previously, if you specified a limit of 50 or more for any resource, you would receive ALL the resources available on the server. The SDK now properly respects the limit provided.
+* Add `has_attachments` to Thread model
+
+v4.10.0
+-------
+* Add `ical_uid` to Event model, only available on API version 2.1 or above. See https://headwayapp.co/nylas-changelog/icaluid-support-132816
+* Add RoomResource model, available at `/resources` API endpoint
+* Add free/busy information, available at `/calendars/free-busy` API endpoint
+
+v4.9.0
+------
+* Add `provider` to Account model
+* Add `reply_to` to Message model
+* Add `Event.rsvp()` method
+
+v4.8.1
+------
+* Bugfix: `/token-info` endpoint
+
+v4.8.0
+------
+* Add support for `/token-info` endpoint, which allows you to query the
+ available scopes and validity of a given access token for an account.
+* Add message.from_ alias
+* Bugfix: contact.email_addresses renamed to contact.emails
+
+v4.7.0
+------
+* Add support for `/ip_addresses` endpoint.
+
+v4.6.0
+------
+* You can now pass a list of `scopes` when calling `APIClient.authentication_url()`
+ in order to enable
+ [selective sync](https://docs.nylas.com/docs/how-to-use-selective-sync).
+ Previously, we only set `scope=email` by default; now, the default is to use
+ all scopes.
+* Add X-Nylas-Client-Id header for HTTP requests
+
+v4.4.0
+------
+* Add support for `revoke-all` endpoint.
+
+
+v4.3.0
+------
+* Raise `UnsyncedError` when a message isn't ready to be retrieved yet (HTTP 202) when
+fetching a raw message.
+
+
+v4.2.0
+------
+
+
+
+v3.0.0
+------
+
+## Large changes
+
+* The Nylas Python SDK now fully supports both Python 2.7 and Python 3.3+.
+* The SDK has a new dependency: the
+ [URLObject](https://pypi.python.org/pypi/URLObject) library.
+ This dependency will be automatically installed when you upgrade.
+* The SDK now automatically converts between timestamps and Python datetime
+ objects. These automatic conversions are opt-in: your existing code should
+ continue to work unmodified. See the "Timestamps and Datetimes"
+ section of this document for more information.
+
+## Small changes
+
+* The SDK now has over 95% automated test coverage.
+* Previously, trying to access the following model properties would raise an error:
+ `Folder.threads`, `Folder.messages`, `Label.threads`, `Label.messages`.
+ These properties should now work as expected.
+* The `Thread` model now exposes the `last_message_received_timestamp` and
+ `last_message_sent_timestamp` properties, obtained from the Nylas API.
+* Previously, if you created a `Draft` object, saved it, and then
+ deleted it without modifying it further, the deletion would fail silently.
+ Now, the SDK will actually attempt to delete a newly-saved `Draft` object,
+ and will raise an error if it is unable to do so.
+* Previously, you could initialize an `APIClient` with an `api_server`
+ value set to an `http://` URL. Now, `APIClient` will verify that the
+ `api_server` value starts with `https://`, and will raise an error if it
+ does not.
+* The `APIClient` constructor no longers accepts the `auth_server` argument,
+ as it was never used for anything.
+* The `nylas.client.util.url_concat` and `nylas.client.util.generate_id`
+ functions have been removed. These functions were meant for internal use,
+ and were never documented or expected to be used by others.
+* You can now pass a `state` argument to `APIClient.authentication_url`,
+ as per the OAuth 2.0 spec.
+
+## Timestamps and Datetimes
+
+Some properties in the Nylas API use timestamp integers to represent a specific
+moment in time, such as `Message.date`. The Python SDK now exposes new properties
+that have converted these existing properties from integers to Python datetime
+objects. You can still access the existing properties to get the timestamp
+integer -- these new properties are just a convenient way to access Python
+datetime objects, if you want them.
+
+This table summarizes the new datetime properties, and which existing timestamp
+properties they match up with.
+
+| New Property (datetime) | Existing Property (timestamp) |
+| --------------------------------- | ---------------------------------------- |
+| `Message.received_at` | `Message.date` |
+| `Thread.first_message_at` | `Thread.first_message_timestamp` |
+| `Thread.last_message_at` | `Thread.last_message_timestamp` |
+| `Thread.last_message_received_at` | `Thread.last_message_received_timestamp` |
+| `Thread.last_message_sent_at` | `Thread.last_message_sent_timestamp` |
+| `Draft.last_modified_at` | `Draft.date` |
+| `Event.original_start_at` | `Event.original_start_time` |
+
+You can also use datetime objects when filtering on models with the `.where()`
+method. For example, if you wanted to find all messages that were received
+before Jan 1, 2015, previously you would run this code:
+
+```python
+client.messages.where(received_before=1420070400).all()
+```
+
+That code will still work, but if you prefer, you can run this code instead:
+
+```python
+from datetime import datetime
+
+client.messages.where(received_before=datetime(2015, 1, 1)).all()
+```
+
+You can now use datetimes with the following filters:
+
+* `client.messages.where(received_before=datetime())`
+* `client.messages.where(received_after=datetime())`
+* `client.threads.where(last_message_before=datetime())`
+* `client.threads.where(last_message_after=datetime())`
+* `client.threads.where(started_before=datetime())`
+* `client.threads.where(started_after=datetime())`
+
+
+[Full Changelog](https://github.com/nylas/nylas-python/compare/v2.0.0...v3.0.0)
+
+
+v2.0.0
+------
+
+Release May 18, 2017:
+- Add support for expanded message view
+- Remove deprecated "Inbox" name
+- Send correct auth headers for account management
+- Respect the offset parameter for restfulmodelcollection
+- Add ability to revoke token
+
+[Full Changelog](https://github.com/nylas/nylas-python/compare/v1.2.3...v2.0.0)
+
+
+v1.2.3
+------
+
+Release August 9, 2016:
+- Adds full-text search support for Messages and Threads
v1.2.2
------
diff --git a/Contributing.md b/Contributing.md
new file mode 100644
index 00000000..76a78806
--- /dev/null
+++ b/Contributing.md
@@ -0,0 +1,179 @@
+# Contribute to Nylas
+👍🎉 First off, thanks for taking the time to contribute! 🎉👍
+
+The following is a set of guidelines for contributing to the Nylas Python SDK; these are guidelines, not rules, so please use your best judgement and feel free to propose changes to this document via pull request.
+
+# Development Setup
+
+To get started contributing to this repository, you'll need to set up a local development environment. Follow these steps:
+
+## Prerequisites
+
+- Python 3.8 or higher (the project supports Python 3.8+)
+- Git
+- A GitHub account
+
+## Setup Steps
+
+### 1. Fork and Clone the Repository
+
+```bash
+# Fork the repository on GitHub, then clone your fork
+git clone https://github.com/YOUR_USERNAME/nylas-python.git
+cd nylas-python
+
+# Add the upstream repository as a remote
+git remote add upstream https://github.com/nylas/nylas-python.git
+```
+
+### 2. Set Up Python Virtual Environment
+
+We recommend using a virtual environment to isolate your development dependencies:
+
+```bash
+# Create a virtual environment
+python3 -m venv .venv
+
+# Activate the virtual environment
+# On macOS/Linux:
+source .venv/bin/activate
+
+# On Windows:
+# .venv\Scripts\activate
+```
+
+**Important**: If you encounter issues with `pip` not being available in the virtual environment, run:
+
+```bash
+# Ensure pip is available in the virtual environment
+python -m ensurepip --upgrade
+```
+
+### 3. Install Development Dependencies
+
+Install the package in editable mode with all optional dependencies:
+
+```bash
+# Install the package in development mode with all optional dependencies
+python -m pip install -e ".[test,docs,release]"
+
+# Or install specific dependency groups as needed:
+# python -m pip install -e ".[test]" # For running tests
+# python -m pip install -e ".[docs]" # For building documentation
+# python -m pip install -e ".[release]" # For release management
+```
+
+**Note**: We use `python -m pip` instead of just `pip` to ensure we're using the pip from the virtual environment.
+
+### 4. Install Code Quality Tools
+
+Install the linting and formatting tools used by the project:
+
+```bash
+python -m pip install pylint black
+```
+
+### 5. Verify Your Setup
+
+Run the tests to make sure everything is working correctly:
+
+```bash
+# Run the test suite
+python setup.py test
+
+# Or run tests with pytest directly
+pytest
+
+# Run with coverage
+pytest --cov=nylas tests/
+```
+
+Check code formatting and linting:
+
+```bash
+# Check code formatting (this will modify files)
+black .
+
+# Run linting
+pylint nylas
+```
+
+## Development Workflow
+
+1. **Create a branch** for your feature or bug fix:
+ ```bash
+ git checkout -b your-feature-branch
+ ```
+
+2. **Make your changes** and write tests for any new functionality
+
+3. **Run tests and linting**:
+ ```bash
+ python setup.py test
+ black .
+ pylint nylas
+ ```
+
+4. **Commit your changes** following [conventional commit practices](https://www.conventionalcommits.org/)
+
+5. **Push to your fork** and create a pull request
+
+## Project Structure
+
+- `nylas/` - Main SDK source code
+- `tests/` - Test files
+- `examples/` - Example usage scripts
+- `scripts/` - Build and development scripts
+- `pyproject.toml` - Project configuration and dependencies
+- `setup.py` - Legacy setup file (still used for some operations)
+
+## Running Tests
+
+The project uses pytest for testing:
+
+```bash
+# Run all tests
+pytest
+
+# Run tests with coverage
+pytest --cov=nylas tests/
+
+# Run specific test files
+pytest tests/test_specific_module.py
+
+# Run tests matching a pattern
+pytest -k "test_pattern"
+```
+
+## Documentation
+
+To build the documentation locally:
+
+```bash
+# Make sure you have docs dependencies installed
+pip install -e ".[docs]"
+
+# Generate documentation (if there are scripts for this)
+python scripts/generate-docs.py
+```
+
+# How to Ask a Question
+
+If you have a question about how to use the Python SDK, please [create an issue](https://github.com/nylas/nylas-python/issues) and label it as a question. If you have more general questions about the Nylas Communications Platform, or the Nylas Email, Calendar, and Contacts API, please reach out to support@nylas.com to get help.
+
+# How To Contribute
+## Report a Bug or Request a Feature
+
+If you encounter any bugs while using this software, or want to request a new feature or enhancement, please [create an issue](https://github.com/nylas/nylas-python/issues) to report it, and make sure you add a label to indicate what type of issue it is.
+
+## Contribute Code
+
+Pull requests are welcome for bug fixes. If you want to implement something new, [please request a feature](https://github.com/nylas/nylas-python/issues) first so we can discuss it.
+
+While writing your code contribution, make sure you follow the testing conventions found in the [tests directory](https://github.com/nylas/nylas-python/tree/main/tests) for any components that you add. We use [codecov](https://codecov.io/gh/nylas/nylas-python) to test coverage, please ensure that your contributions don’t cause a decrease to test coverage.
+
+## Creating a Pull Request
+
+Please follow [best practices](https://github.com/trein/dev-best-practices/wiki/Git-Commit-Best-Practices) for creating git commits. When your code is ready to be submitted, you can [submit a pull request](https://help.github.com/articles/creating-a-pull-request/) to begin the code review process.
+
+All PRs from contributors that aren't employed by Nylas must contain the following text in the PR description: "I confirm that this contribution is made under the terms of the MIT license and that I have the authority necessary to make this contribution on behalf of its copyright owner."
diff --git a/README.md b/README.md
index 09df78c0..f923483d 100644
--- a/README.md
+++ b/README.md
@@ -1,358 +1,190 @@
-# Nylas REST API Python bindings 
+
+
+
+
-Python bindings for the Nylas REST API. https://www.nylas.com/docs
+
Nylas Python SDK
-## Installation
+
+ The official Python SDK for Nylas — the infrastructure that powers communications
+
-This library is available on pypi. You can install it by running `pip install nylas`.
+
+
+
+
+
+
-##Requirements
+
+ 📖 SDK Guide ·
+ 📚 API Reference ·
+ 🚀 Sign up ·
+ 💡 Samples ·
+ 💬 Forum
+
+
-- requests (>= 2.4.2)
+
-## Examples
+The official Python SDK for [Nylas](https://developer.nylas.com/docs/v3/) — the infrastructure that powers communications. Integrate with Gmail, Microsoft, IMAP, Zoom, and 250+ email, calendar, and meeting providers in 5 minutes. Covers [Email](https://developer.nylas.com/docs/v3/email/), [Calendar](https://developer.nylas.com/docs/v3/calendar/), [Contacts](https://developer.nylas.com/docs/v3/email/contacts/), [Scheduler](https://developer.nylas.com/docs/v3/scheduler/), [Notetaker](https://developer.nylas.com/docs/v3/notetaker/), and [Agent Accounts](https://developer.nylas.com/docs/v3/agent-accounts/).
-There's an example Flask app in the `examples` directory. You can run the sample app to see how an authentication flow might be implemented.
+This repository is for contributors and anyone installing the SDK from source. If you just want to use the SDK in your app, head straight to the [**Python SDK guide**](https://developer.nylas.com/docs/v3/sdks/python/) on developer.nylas.com.
-*Note: you will need to replace the APP_ID and APP_SECRET with your Nylas App ID and secret to use the sample app.*
+## Get started
-## Usage
+1. [Sign up for a free Nylas account](https://dashboard-v3.nylas.com/register) and grab your API key from the [Nylas Dashboard](https://dashboard-v3.nylas.com/).
+2. Read the [Getting started guide](https://developer.nylas.com/docs/v3/getting-started/) for the core concepts (applications, grants, API keys).
+3. Install the SDK and make your first request — see below.
-### App ID and Secret
+You can also bootstrap from the terminal:
-Before you can interact with the Nylas REST API, you need to create a Nylas developer account at [https://www.nylas.com/](https://www.nylas.com/). After you've created a developer account, you can create a new application to generate an App ID / Secret pair.
-
-Generally, you should store your App ID and Secret into environment variables to avoid adding them to source control. That said, in the example project and code snippets below, the values are hardcoded for convenience.
-
-
-### Authentication
-
-The Nylas REST API uses server-side (three-legged) OAuth, and this library provides convenience methods to simplify the OAuth process.
-Here's how it works:
-
-1. You redirect the user to our login page, along with your App Id and Secret
-2. Your user logs in
-3. She is redirected to a callback URL of your own, along with an access code
-4. You use this access code to get an authorization token to the API
-
-For more information about authenticating with Nylas, visit the [Developer Documentation](https://www.nylas.com/docs/gettingstarted-hosted#authenticating).
-
-In practice, the Nylas REST API client simplifies this down to two steps.
-
-**Step 1: Redirect the user to Nylas:**
-
-```python
-from flask import Flask, session, request, redirect, Response
-from nylas import APIClient
-
-@app.route('/')
-def index():
- redirect_url = "http://0.0.0.0:8888/login_callback"
- client = APIClient(APP_ID, APP_SECRET)
- return redirect(client.authentication_url(redirect_url))
-
-```
-
-**Step 2: Handle the Authentication Response:**
-
-```python
-@app.route('/login_callback')
-def login_callback():
- if 'error' in request.args:
- return "Login error: {0}".format(request.args['error'])
-
- # Exchange the authorization code for an access token
- client = APIClient(APP_ID, APP_SECRET)
- code = request.args.get('code')
- session['access_token'] = client.token_for_code(code)
+```bash
+brew install nylas/nylas-cli/nylas
+nylas init
```
-You can take a look at [examples/server.py](examples/server.py) to see a server
-implementing the auth flow.
+More options in the [CLI getting-started guide](https://cli.nylas.com/guides/getting-started).
-### Connecting to an account
+## ⚙️ Install
-```python
-client = APIClient(APP_ID, APP_SECRET, token)
+> **Requirements:** Python 3.8 or later.
-# Print out the email address and provider (Gmail, Exchange)
-print client.account.email_address
-print client.account.provider
+```bash
+pip install nylas
```
+To install from source:
-### Fetching Threads
-
-```python
-# Fetch the first thread
-thread = client.threads.first()
-
-# Fetch a specific thread
-thread = client.threads.find('ac123acd123ef123')
-
-# List all threads tagged `inbox`
-# (paginating 50 at a time until no more are returned.)
-for thread in client.threads.items():
- print thread.subject
-
-# List the 5 most recent unread threads
-for thread in client.threads.where(unread=True, limit=5):
- print thread.subject
-
-# List starred threads
-for thread in client.threads.where(starred=True):
- print thread.subject
-
-# List all threads with 'ben@nylas.com'
-for thread in client.threads.where(any_email='ben@nylas.com').items():
- print thread.subject
+```bash
+git clone https://github.com/nylas/nylas-python.git
+cd nylas-python
+pip install -e .
```
+### Runtime support
-### Working with Threads and Messages
-
-```python
-# List thread participants
-for participant in thread.participants:
- print participant["email"]
-
-# Mark as read
-thread.mark_as_read()
-
-# Mark as unread
-thread.mark_as_unread()
+Tested on CPython 3.8+. Runs on standard servers as well as serverless platforms like AWS Lambda, Google Cloud Functions, and Vercel — install `nylas` like any other dependency in your deployment package.
-# Star a thread
-thread.star()
+## ⚡️ Usage
-# Unstar it
-thread.unstar()
-
-# Add a new label to a message or thread (Gmail)
-important_id = 'aw6p0mya6v3r96vyj8kooxa5v'
-message.add_label(important_id)
-
-# Remove a label from a message or thread (Gmail)
-important_id = 'aw6p0mya6v3r96vyj8kooxa5v'
-message.remove_label(important_id)
-
-# Batch update labels on a message or thread (Gmail)
-label_ids = ['aw6p0mya6v3r96vyj8kooxa5v', '2ywxapx5g8vybui7hgpzbr33d']
-message.update_labels(label_ids)
-
-# Move a message or thread to a different folder (Non-Gmail)
-
-trash_id = 'ds36ik7o55gdqlvpbrjbg9ovn'
-message.update_folder(trash_id)
-
-# List messages
-for message in thread.messages.items():
- print message.subject
-
-# Get the raw contents of a message
-print message.raw
-```
-
-### Working with Folders and Labels
-
-The Folders and Labels API replaces the now deprecated Tags API. For Gmail accounts, this API allows you to apply labels to whole threads or individual messages. For providers other than Gmail, you can move threads and messages between folders -- a message can only belong to one folder.
+You access Nylas resources (messages, calendars, events, contacts, …) through an instance of `Client`. Initialize it with your API key — and optionally an `api_uri` matching your [data residency](https://developer.nylas.com/docs/dev-guide/platform/data-residency/).
```python
-# List labels
-for label in client.labels:
- print label.id, label.display_name
-
-# Create a label
-label = client.labels.create()
-label.display_name = 'My Label'
-label.save()
-
-# Create a folder
-# Note that folders and labels behave identically, except that a message can have many labels but only belong to a single folder.
-folder = client.folders.create()
-folder.display_name = 'My Folder'
-folder.save()
-
-# Rename a folder (or label)
-
-# Note that you can't rename core folders like INBOX, Trash, etc.
-folder = client.folders.first()
-folder.display_name = 'A Different Folder'
-folder.save()
+import os
+from nylas import Client
+
+nylas = Client(
+ api_key=os.environ["NYLAS_API_KEY"],
+ api_uri=os.environ.get("NYLAS_API_URI", "https://api.us.nylas.com"),
+ timeout=30, # optional, in seconds
+)
```
-### Working with Files
-
-Files can be uploaded via two interfaces. One is providing data directly, another is by providing a stream (e.g. to an open file).
+Once initialized, use it to make requests against a [grant](https://developer.nylas.com/docs/v3/auth/) (an authenticated end-user account):
```python
-# List files
-for file in client.files:
- print file.filename
-
-# Create a new file with the stream interface
-f = open('test.py', 'r')
-myfile = client.files.create()
-myfile.filename = 'test.py'
-myfile.stream = f
-myfile.save()
-f.close()
-
-# Create a new file with the data interface
-myfile2 = client.files.create()
-myfile2.filename = 'test.txt'
-myfile2.data = "Hello World."
-myfile2.save()
+calendars, request_id, next_cursor = nylas.calendars.list(
+ identifier=os.environ["NYLAS_GRANT_ID"],
+)
+print(calendars)
```
-Once the files have been created, they can be added to a draft via the `attach()` function.
+Resources expose a consistent CRUD surface — `create()`, `find()`, `list()`, `update()`, `destroy()` — plus resource-specific methods (e.g. `messages.send()`, `events.send_rsvp()`). Request and response models are [`dataclasses-json`](https://github.com/lidatong/dataclasses-json) dataclasses, so every payload is fully type-hinted and supports `to_dict()` / `from_dict()`.
-### Working with Drafts
+### Error handling
-Drafts can be created, saved and then sent. The following example will create a draft, attach a file to it and then send it.
+The SDK raises typed exceptions you can catch and inspect. Every API error carries a `request_id` and `status_code` — include both when filing a support ticket so we can trace the request end-to-end.
```python
-# Create the attachment
-myfile = client.files.create()
-myfile.filename = 'test.txt'
-myfile.data = "hello world"
-
-# Create a new draft
-draft = client.drafts.create()
-draft.to = [{'name': 'My Friend', 'email': 'my.friend@example.com'}]
-draft.subject = "Here's an attachment"
-draft.body = "Cheers mate!"
-draft.attach(myfile)
-
-# Send it
-try:
- draft.send()
-except nylas.client.errors.ConnectionError as e:
- print "Unable to connect to the SMTP server."
-except nylas.client.errors.MessageRejectedError as e:
- print "Message got rejected by the SMTP server!"
- print e.message
-
- # Sometimes the API gives us the exact error message
- # returned by the server. Display it since it can be
- # helpful to know exactly why our message got rejected:
- print e.server_error
-
-# Delete a draft
-draft = client.drafts.create()
-draft.subject = "Delete me"
-draft.save()
-draft.body = "I really mean it."
-draft.delete()
-# or:
-# client.drafts.delete(draft.id, {'version': draft.version})
-```
-
-### Working with Events
+from nylas import Client
+from nylas.models.errors import (
+ NylasApiError,
+ NylasOAuthError,
+ NylasSdkTimeoutError,
+)
-The following example shows how to create and update an event.
-
-```python
-# Get a calendar that's not read only
-calendar = filter(lambda cal: not cal.read_only, client.calendars)[0]
-# Create the event
-ev = client.events.create()
-ev.title = "Party at the Ritz"
-ev.when = {"start_time": 1416423667, "end_time": 1416448867} # These numbers are UTC timestamps
-ev.location = "The Old Ritz"
-ev.participants = [{"name": "My Friend", 'email': 'my.friend@example.com'}]
-ev.calendar_id = calendar.id
-ev.save()
-
-# Update it
-ev.location = "The Waldorf-Astoria"
-ev.save()
+try:
+ nylas.calendars.list(identifier=grant_id)
+except NylasApiError as err:
+ print(err.status_code, err.type, str(err), err.request_id)
+except NylasOAuthError as err:
+ print(err.error, err.error_code, err.error_description)
+except NylasSdkTimeoutError as err:
+ print("Timed out:", err.url, err.timeout)
```
-The following example shows how to delete an event. We will pass the parameter notify_participants to the DELETE request to send the cancellation to the event invitees. See the [Deleting Events](https://www.nylas.com/docs/platform?node#deleting_events) API documentation for more details.
+Step-by-step walkthroughs in the SDK guide:
-```
-# Delete it
-client.events.delete(ev.id, notify_participants='true')
-```
+- [Send messages](https://developer.nylas.com/docs/v3/sdks/python/send-email/)
+- [Read messages and threads](https://developer.nylas.com/docs/v3/sdks/python/read-messages-threads/)
+- [Manage events on a calendar](https://developer.nylas.com/docs/v3/sdks/python/manage-events/)
+- [Manage contacts](https://developer.nylas.com/docs/v3/sdks/python/manage-contacts/)
+- [Manage folders and labels](https://developer.nylas.com/docs/v3/sdks/python/manage-folders-labels/)
-### Working with Messages, Contacts, Calendars, etc.
+### Debugging
-Each of the primary collections (contacts, messages, etc.) behaves the same way as `threads`. For example, finding messages with a filter is similar to finding threads:
+To inspect the raw HTTP traffic the SDK sends, turn on `requests`-level logging:
```python
-messages = client.messages.where(to=ben@nylas.com).all()
+import logging
+
+logging.basicConfig(level=logging.DEBUG)
+logging.getLogger("urllib3").setLevel(logging.DEBUG)
```
-The `where` method accepts a keyword argument for each of the filters documented in the [Nylas Filters Documentation](https://www.nylas.com/docs/api#filters).
+## 💡 Examples
-Note: Because `from` is a reserved word in Python, to filter by the 'from' field, there are two options:
-```python
-messages = client.messages.where(from_='email@example.com')
-# or
-messages = client.messages.where(**{'from': 'email@example.com'})
-```
+Runnable examples live in [`examples/`](examples/) — including [send email](examples/send_email_demo/), [inline attachments](examples/inline_attachment_demo/), [folders](examples/folders_demo/), [import events](examples/import_events_demo/), [Notetaker API](examples/notetaker_api_demo/), [Notetaker calendar](examples/notetaker_calendar_demo/), [message fields](examples/message_fields_demo/), [metadata fields](examples/metadata_field_demo/), [provider errors](examples/provider_error_demo/), [response headers](examples/response_headers_demo/), [`select` parameter](examples/select_param_demo/), [special characters](examples/special_characters_demo/), [hidden folders](examples/include_hidden_folders_demo/), and [plain text](examples/is_plaintext_demo/).
-## Account Management
+For full sample apps and product quickstarts, browse [**nylas-samples** on GitHub](https://github.com/orgs/nylas-samples/repositories?q=python) — every official SDK has Email, Calendar, Contacts, Scheduler, and Webhooks quickstarts.
-### Account status
+## 🤖 AI agents
-It's possible to query the status of all the user accounts registered to an app by using `.accounts`:
+[nylas/skills](https://github.com/nylas/skills) drops Nylas into Claude Code, Cursor, Codex, and other agents that support the skills format:
-```python
-accounts = client.accounts
-print [(acc.sync_status, acc.account_id, acc.trial, acc.trial_expires) for acc in accounts.all()]
+```bash
+npx skills add nylas/skills
+/plugin marketplace add nylas/skills # Claude Code
```
-## Open-Source Sync Engine
-
-The [Nylas Sync Engine](http://github.com/nylas/sync-engine) is open-source, and you can also use the Python library with the open-source API. Since the open-source API provides no authentication or security, connecting to it is simple. When you instantiate the Nylas object, provide null for the App ID, App Secret, and API Token, and pass the fully-qualified address of your copy of the sync engine:
+The CLI also installs an MCP server for Claude Desktop, Claude Code, Cursor, Windsurf, or VS Code:
-```python
-from nylas import APIClient
-client = APIClient(None, None, None, 'http://localhost:5555/')
+```bash
+brew install nylas/nylas-cli/nylas
+nylas mcp install
+```
-# Get the id of the first account -- this is the access token we're
-# going to use.
-account_id = client.accounts.first().id
+Walkthrough: [give AI agents email access via MCP](https://cli.nylas.com/guides/give-ai-agents-email-access-via-mcp).
-# Display the contents of the first message for the first account
-client = APIClient(None, None, account_id, 'http://localhost:5555/')
-puts client.messages.first().body
-```
+## 📚 Reference
+- **SDK guide:** [developer.nylas.com/docs/v3/sdks/python](https://developer.nylas.com/docs/v3/sdks/python/)
+- **API reference:** [developer.nylas.com/docs/api/v3](https://developer.nylas.com/docs/api/v3/)
+- **Python SDK reference:** [nylas-python-sdk-reference.pages.dev](https://nylas-python-sdk-reference.pages.dev/) — generated method/class docs for this SDK
+- **Webhooks (notifications):** [developer.nylas.com/docs/v3/notifications](https://developer.nylas.com/docs/v3/notifications/)
+- **Auth flows:** [developer.nylas.com/docs/v3/auth](https://developer.nylas.com/docs/v3/auth/)
+- **Dev guide & best practices:** [developer.nylas.com/docs/dev-guide](https://developer.nylas.com/docs/dev-guide/)
+- **Changelog:** [CHANGELOG.md](CHANGELOG.md)
-## Contributing
+## ✨ Upgrading
-We'd love your help making Nylas better. We hang out on Slack. [Join the channel here ](http://slack-invite.nylas.com) You can also email [support@nylas.com](mailto:support@nylas.com).
+See [`CHANGELOG.md`](CHANGELOG.md) for per-release notes. Older upgrade guidance (v5.x → v6.x) lives in [`UPGRADE.md`](UPGRADE.md).
-Please sign the [Contributor License Agreement](https://nylas.com/cla.html) before submitting pull requests. (It's similar to other projects, like NodeJS or Meteor.)
+## 💙 Contributing
-### Releasing a new version
+Issues, ideas, and pull requests welcome — see [Contributing.md](Contributing.md). Before opening a large change, please open an issue or post in the [forum](https://forums.nylas.com) so we can sanity-check the direction.
-We have a three-step process for releasing a new version of the Python SDK. Remember that people depend on this library not breaking, so don't cut corners.
+## 🔒 Security
-1. Run the unit tests.
- `python setup.py test`
-2. Run the "system" tests. They use a live server to check that everything works as expected, so you'll need to have a valid Nylas application id and secret.
- In the tests directory you'll find a file named `tests/credentials.py.template`. Rename it into `credentials.py` and change the APP_ID and APP_SECRET to your own app id and secret.
+Found a vulnerability? Please **don't** open a public issue. Report it through our [Vulnerability Disclosure Policy](https://www.nylas.com/security/vulnerability-disclosure-policy/).
- Run the tests:
-
- ```shell
- PYTHONPATH=/your-sdk-path python tests/oauth.py
- PYTHONPATH=/your-sdk-path python tests/system.py
- ```
-
-3. Finally, you can create a new release by doing:
+## 🔗 Other Nylas SDKs
- ```shell
- python setup.py release
- git log # to verify
- python setup.py publish
- git push --tags # update the release tags on GitHub.
- ```
+- [nylas-nodejs](https://github.com/nylas/nylas-nodejs) · `npm install nylas`
+- [nylas-ruby](https://github.com/nylas/nylas-ruby) · `gem install nylas`
+- [nylas-java](https://github.com/nylas/nylas-java) · Maven / Gradle (Kotlin too)
-## Looking for inbox.py?
+## 📝 License
-If you're looking for Kenneth Reitz's SMTP project, please update your `requirements.txt` file to use `inbox.py` or see the [Inbox.py repo on GitHub](https://github.com/kennethreitz/inbox.py).
+MIT — see [LICENSE](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/diagrams/nylas-logo.png b/diagrams/nylas-logo.png
new file mode 100644
index 00000000..87ad1f17
Binary files /dev/null and b/diagrams/nylas-logo.png differ
diff --git a/examples/folders_demo/README.md b/examples/folders_demo/README.md
new file mode 100644
index 00000000..413d2a36
--- /dev/null
+++ b/examples/folders_demo/README.md
@@ -0,0 +1,64 @@
+# Folders Single Level Parameter Example
+
+This example demonstrates how to use the `single_level` query parameter when listing folders to control folder hierarchy traversal for Microsoft accounts.
+
+## Overview
+
+The `single_level` parameter is a Microsoft-only feature that allows you to control whether the folders API returns:
+- **`single_level=true`**: Only top-level folders (single-level hierarchy)
+- **`single_level=false`**: All folders including nested ones (multi-level hierarchy, default)
+
+This parameter is useful for:
+- **Performance optimization**: Reducing response size when you only need top-level folders
+- **UI simplification**: Building folder trees incrementally
+- **Microsoft-specific behavior**: Taking advantage of Microsoft's folder hierarchy structure
+
+## Prerequisites
+
+- Nylas API key
+- Nylas grant ID for a Microsoft account (this parameter only works with Microsoft accounts)
+
+## Setup
+
+1. Install the SDK in development mode:
+ ```bash
+ cd /path/to/nylas-python
+ pip install -e .
+ ```
+
+2. Set your environment variables:
+ ```bash
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_microsoft_grant_id"
+ ```
+
+## Running the Example
+
+```bash
+python examples/folders_demo/folders_single_level_example.py
+```
+
+## What the Example Demonstrates
+
+1. **Multi-level folder hierarchy** (default behavior)
+2. **Single-level folder hierarchy** using `single_level=true`
+3. **Combined parameters** showing how to use `single_level` with other query parameters
+4. **Hierarchy comparison** showing the difference in folder counts
+
+## Expected Output
+
+The example will show:
+- Folders returned with multi-level hierarchy
+- Folders returned with single-level hierarchy only
+- Count comparison between the two approaches
+- How to combine the parameter with other options like `limit` and `select`
+
+## Use Cases
+
+- **Folder tree UI**: Load top-level folders first, then expand as needed
+- **Performance**: Reduce API response size for Microsoft accounts with deep folder structures
+- **Microsoft-specific integrations**: Take advantage of Microsoft's native folder organization
+
+## Note
+
+This parameter only works with Microsoft accounts. If you use it with other providers, it will be ignored.
\ No newline at end of file
diff --git a/examples/folders_demo/folders_single_level_example.py b/examples/folders_demo/folders_single_level_example.py
new file mode 100644
index 00000000..335b464f
--- /dev/null
+++ b/examples/folders_demo/folders_single_level_example.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+"""
+Nylas SDK Example: Using Single Level Parameter for Folders
+
+This example demonstrates how to use the 'single_level' query parameter when listing folders
+to control the folder hierarchy traversal for Microsoft accounts.
+
+Required Environment Variables:
+ NYLAS_API_KEY: Your Nylas API key
+ NYLAS_GRANT_ID: Your Nylas grant ID (must be a Microsoft account)
+
+Usage:
+ First, install the SDK in development mode:
+ cd /path/to/nylas-python
+ pip install -e .
+
+ Then set environment variables and run:
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_microsoft_grant_id"
+ python examples/folders_demo/folders_single_level_example.py
+"""
+
+import os
+import sys
+import json
+from nylas import Client
+
+
+def get_env_or_exit(var_name: str) -> str:
+ """Get an environment variable or exit if not found."""
+ value = os.getenv(var_name)
+ if not value:
+ print(f"Error: {var_name} environment variable is required")
+ sys.exit(1)
+ return value
+
+
+def print_folders(folders: list, title: str) -> None:
+ """Pretty print the folders with a title."""
+ print(f"\n{title}:")
+ if not folders:
+ print(" No folders found.")
+ return
+
+ for folder in folders:
+ # Convert to dict and pretty print relevant fields
+ folder_dict = folder.to_dict()
+ print(
+ f" - {folder_dict.get('name', 'Unknown')} (ID: {folder_dict.get('id', 'Unknown')})"
+ )
+ if folder_dict.get("parent_id"):
+ print(f" Parent ID: {folder_dict['parent_id']}")
+ if folder_dict.get("child_count") is not None:
+ print(f" Child Count: {folder_dict['child_count']}")
+
+
+def demonstrate_multi_level_folders(client: Client, grant_id: str) -> None:
+ """Demonstrate multi-level folder hierarchy (default behavior)."""
+ print("\n=== Multi-Level Folder Hierarchy (Default) ===")
+
+ # Default behavior - retrieves folders across multi-level hierarchy
+ print("\nFetching folders with multi-level hierarchy (single_level=False):")
+ folders = client.folders.list(
+ identifier=grant_id, query_params={"single_level": False}
+ )
+ print_folders(folders.data, "Multi-level folder hierarchy")
+
+ # Also demonstrate without explicitly setting single_level (default behavior)
+ print("\nFetching folders without single_level parameter (default behavior):")
+ folders = client.folders.list(identifier=grant_id)
+ print_folders(folders.data, "Default folder hierarchy (multi-level)")
+
+
+def demonstrate_single_level_folders(client: Client, grant_id: str) -> None:
+ """Demonstrate single-level folder hierarchy."""
+ print("\n=== Single-Level Folder Hierarchy ===")
+
+ # Single-level hierarchy - only retrieves folders from the top level
+ print("\nFetching folders with single-level hierarchy (single_level=True):")
+ folders = client.folders.list(
+ identifier=grant_id, query_params={"single_level": True}
+ )
+ print_folders(folders.data, "Single-level folder hierarchy")
+
+
+def demonstrate_combined_parameters(client: Client, grant_id: str) -> None:
+ """Demonstrate single_level combined with other parameters."""
+ print("\n=== Combined Parameters ===")
+
+ # Combine single_level with other query parameters
+ print("\nFetching limited single-level folders with select fields:")
+ folders = client.folders.list(
+ identifier=grant_id,
+ query_params={
+ "single_level": True,
+ "limit": 5,
+ "select": "id,name,parent_id,child_count",
+ },
+ )
+ print_folders(folders.data, "Limited single-level folders with selected fields")
+
+
+def compare_hierarchies(client: Client, grant_id: str) -> None:
+ """Compare single-level vs multi-level folder counts."""
+ print("\n=== Hierarchy Comparison ===")
+
+ # Get multi-level count
+ multi_level_folders = client.folders.list(
+ identifier=grant_id, query_params={"single_level": False}
+ )
+ multi_level_count = len(multi_level_folders.data)
+
+ # Get single-level count
+ single_level_folders = client.folders.list(
+ identifier=grant_id, query_params={"single_level": True}
+ )
+ single_level_count = len(single_level_folders.data)
+
+ print(f"\nFolder count comparison:")
+ print(f" Multi-level hierarchy: {multi_level_count} folders")
+ print(f" Single-level hierarchy: {single_level_count} folders")
+
+ if multi_level_count > single_level_count:
+ print(
+ f" Difference: {multi_level_count - single_level_count} folders in sub-hierarchies"
+ )
+ elif single_level_count == multi_level_count:
+ print(" No nested folders detected in this account")
+
+
+def main():
+ """Main function demonstrating single_level parameter usage for folders."""
+ # Get required environment variables
+ api_key = get_env_or_exit("NYLAS_API_KEY")
+ grant_id = get_env_or_exit("NYLAS_GRANT_ID")
+
+ # Initialize Nylas client
+ client = Client(api_key=api_key)
+
+ print("\nDemonstrating Single Level Parameter for Folders")
+ print("===============================================")
+ print("This parameter is Microsoft-only and controls folder hierarchy traversal")
+ print(f"Using Grant ID: {grant_id}")
+
+ try:
+ # Demonstrate different folder hierarchy options
+ demonstrate_multi_level_folders(client, grant_id)
+ demonstrate_single_level_folders(client, grant_id)
+ demonstrate_combined_parameters(client, grant_id)
+ compare_hierarchies(client, grant_id)
+
+ print("\n=== Summary ===")
+ print("• single_level=True: Returns only top-level folders (Microsoft only)")
+ print("• single_level=False: Returns folders from all levels (default)")
+ print("• This parameter helps optimize performance for Microsoft accounts")
+ print("• Can be combined with other query parameters like limit and select")
+
+ except Exception as e:
+ print(f"\nError: {e}")
+ print("\nNote: This example requires a Microsoft grant ID.")
+ print("The single_level parameter only works with Microsoft accounts.")
+
+ print("\nExample completed!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/import_events_demo/README.md b/examples/import_events_demo/README.md
new file mode 100644
index 00000000..cb52af00
--- /dev/null
+++ b/examples/import_events_demo/README.md
@@ -0,0 +1,68 @@
+# Import Events Demo
+
+This example demonstrates the usage of the `list_import_events` method in the Nylas SDK. This method returns a list of recurring events, recurring event exceptions, and single events from a specified calendar within a given time frame. It's particularly useful when you want to import, store, and synchronize events from a calendar to your application.
+
+## Features Demonstrated
+
+1. **Basic Usage**: Shows how to use `list_import_events` with required parameters.
+2. **Time Filtering**: Demonstrates filtering events by start and end time.
+3. **Pagination**: Shows how to handle paginated results with `limit` and `page_token`.
+4. **Field Selection**: Demonstrates how to use the `select` parameter to request only specific fields.
+5. **Multiple Scenarios**: Shows various parameter combinations for different use cases.
+
+## Setup
+
+1. Create a `.env` file in the root directory with your Nylas API credentials:
+ ```
+ NYLAS_API_KEY=your_api_key_here
+ NYLAS_GRANT_ID=your_grant_id_here
+ ```
+
+2. Install the required dependencies:
+ ```bash
+ pip install nylas python-dotenv
+ ```
+
+## Running the Example
+
+Run the example script:
+```bash
+python examples/import_events_demo/import_events_example.py
+```
+
+The script will demonstrate different ways to use the `list_import_events` method with various parameters.
+
+## Example Output
+
+The script will show output similar to this:
+```
+=== Import Events Demo ===
+
+Basic import (primary calendar):
+Event - Title: Team Meeting, ID: abc123...
+
+Time-filtered import (Jan 1, 2023 - Dec 31, 2023):
+Event - Title: Annual Review, ID: def456...
+
+Limited results with field selection (only id, title and when):
+Event - Title: Client Call, ID: ghi789...
+```
+
+## Benefits of Using Import Events
+
+1. **Efficient Syncing**: Easily synchronize calendar events to your application or database.
+2. **Better Performance**: Using time filters and limiting results can improve performance.
+3. **Selective Data**: Using the select parameter allows you to request only the fields you need.
+
+## Available Parameters
+
+The `list_import_events` method accepts the following parameters:
+
+- `calendar_id` (required): Specify the calendar ID to import events from. You can use "primary" for the user's primary calendar.
+- `start`: Filter for events starting at or after this Unix timestamp.
+- `end`: Filter for events ending at or before this Unix timestamp.
+- `select`: Comma-separated list of fields to return in the response.
+- `limit`: Maximum number of objects to return (defaults to 50, max 200).
+- `page_token`: Token for retrieving the next page of results.
+
+For more information, refer to the [Nylas API documentation](https://developer.nylas.com/).
\ No newline at end of file
diff --git a/examples/import_events_demo/import_events_example.py b/examples/import_events_demo/import_events_example.py
new file mode 100644
index 00000000..da347e60
--- /dev/null
+++ b/examples/import_events_demo/import_events_example.py
@@ -0,0 +1,197 @@
+#!/usr/bin/env python3
+"""
+Nylas SDK Example: Using Import Events
+
+This example demonstrates how to use the 'list_import_events' method to import and
+synchronize events from a calendar within a given time frame.
+
+Required Environment Variables:
+ NYLAS_API_KEY: Your Nylas API key
+ NYLAS_GRANT_ID: Your Nylas grant ID
+
+Usage:
+ First, install the SDK in development mode:
+ cd /path/to/nylas-python
+ pip install -e .
+
+ Then set environment variables and run:
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ python examples/import_events_demo/import_events_example.py
+"""
+
+import os
+import sys
+import json
+import time
+from datetime import datetime, timedelta
+from nylas import Client
+
+
+def get_env_or_exit(var_name: str) -> str:
+ """Get an environment variable or exit if not found."""
+ value = os.getenv(var_name)
+ if not value:
+ print(f"Error: {var_name} environment variable is required")
+ sys.exit(1)
+ return value
+
+
+def print_data(data: list, title: str) -> None:
+ """Pretty print the data with a title."""
+ print(f"\n{title}:")
+ for item in data:
+ # Convert to dict and pretty print
+ item_dict = item.to_dict()
+ print(json.dumps(item_dict, indent=2))
+
+
+def demonstrate_basic_import(client: Client, grant_id: str) -> None:
+ """Demonstrate basic usage of list_import_events with primary calendar."""
+ print("\n=== Basic Import Events ===")
+
+ print("\nFetching events from primary calendar:")
+ events = client.events.list_import_events(
+ identifier=grant_id,
+ query_params={"calendar_id": "primary", "limit": 2}
+ )
+ print_data(events.data, "Basic import events")
+
+
+def demonstrate_time_filtered_import(client: Client, grant_id: str) -> None:
+ """Demonstrate import events with time filtering."""
+ print("\n=== Time Filtered Import Events ===")
+
+ # Get timestamps for a one-month period
+ now = int(time.time())
+ one_month_ago = now - (30 * 24 * 60 * 60) # 30 days ago
+ one_month_future = now + (30 * 24 * 60 * 60) # 30 days in future
+
+ # Format dates for display
+ from_date = datetime.fromtimestamp(one_month_ago).strftime("%Y-%m-%d")
+ to_date = datetime.fromtimestamp(one_month_future).strftime("%Y-%m-%d")
+
+ print(f"\nFetching events from {from_date} to {to_date}:")
+ events = client.events.list_import_events(
+ identifier=grant_id,
+ query_params={
+ "calendar_id": "primary",
+ "start": one_month_ago,
+ "end": one_month_future
+ }
+ )
+ print_data(events.data, f"Events from {from_date} to {to_date}")
+
+
+def demonstrate_limit(client: Client, grant_id: str) -> None:
+ """Demonstrate import events with limit parameter."""
+ print("\n=== Import Events with Max Results ===")
+
+ print("\nFetching events with limit=5:")
+ events = client.events.list_import_events(
+ identifier=grant_id,
+ query_params={
+ "calendar_id": "primary",
+ "limit": 5
+ }
+ )
+ print_data(events.data, "Events with limit=5")
+
+
+def demonstrate_field_selection(client: Client, grant_id: str) -> None:
+ """Demonstrate import events with field selection."""
+ print("\n=== Import Events with Field Selection ===")
+
+ print("\nFetching events with select parameter (only id, title, and when):")
+ events = client.events.list_import_events(
+ identifier=grant_id,
+ query_params={
+ "calendar_id": "primary",
+ "limit": 2,
+ "select": "id,title,when"
+ }
+ )
+ print_data(events.data, "Events with selected fields only")
+
+
+def demonstrate_pagination(client: Client, grant_id: str) -> None:
+ """Demonstrate pagination for import events."""
+ print("\n=== Import Events with Pagination ===")
+
+ # First page
+ print("\nFetching first page of events (limit=3):")
+ first_page = client.events.list_import_events(
+ identifier=grant_id,
+ query_params={
+ "calendar_id": "primary",
+ "limit": 3
+ }
+ )
+ print_data(first_page.data, "First page of events")
+
+ # If there's a next page, fetch it
+ if hasattr(first_page, 'next_cursor') and first_page.next_cursor:
+ print("\nFetching second page of events:")
+ second_page = client.events.list_import_events(
+ identifier=grant_id,
+ query_params={
+ "calendar_id": "primary",
+ "limit": 3,
+ "page_token": first_page.next_cursor
+ }
+ )
+ print_data(second_page.data, "Second page of events")
+ else:
+ print("\nNo second page available - not enough events to paginate")
+
+
+def demonstrate_full_example(client: Client, grant_id: str) -> None:
+ """Demonstrate a full example with all parameters."""
+ print("\n=== Full Import Events Example ===")
+
+ # Get timestamps for the current year
+ now = datetime.now()
+ start_of_year = datetime(now.year, 1, 1).timestamp()
+ end_of_year = datetime(now.year, 12, 31, 23, 59, 59).timestamp()
+
+ print(f"\nFetching events for {now.year} with all parameters:")
+ events = client.events.list_import_events(
+ identifier=grant_id,
+ query_params={
+ "calendar_id": "primary",
+ "limit": 10,
+ "start": int(start_of_year),
+ "end": int(end_of_year),
+ "select": "id,title,description,when,participants,location"
+ }
+ )
+ print_data(events.data, f"Events for {now.year} with all parameters")
+
+
+def main():
+ """Main function demonstrating the import events method."""
+ # Get required environment variables
+ api_key = get_env_or_exit("NYLAS_API_KEY")
+ grant_id = get_env_or_exit("NYLAS_GRANT_ID")
+
+ # Initialize Nylas client
+ client = Client(
+ api_key=api_key,
+ )
+
+ print("\nDemonstrating Import Events Functionality")
+ print("========================================")
+
+ # Demonstrate different ways to use list_import_events
+ demonstrate_basic_import(client, grant_id)
+ demonstrate_time_filtered_import(client, grant_id)
+ demonstrate_limit(client, grant_id)
+ demonstrate_field_selection(client, grant_id)
+ demonstrate_pagination(client, grant_id)
+ demonstrate_full_example(client, grant_id)
+
+ print("\nExample completed!")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/examples/include_hidden_folders_demo/README.md b/examples/include_hidden_folders_demo/README.md
new file mode 100644
index 00000000..dc1b99aa
--- /dev/null
+++ b/examples/include_hidden_folders_demo/README.md
@@ -0,0 +1,67 @@
+# Include Hidden Folders Example
+
+This example demonstrates how to use the `include_hidden_folders` query parameter when listing folders with the Nylas Python SDK.
+
+## Overview
+
+The `include_hidden_folders` parameter is Microsoft-specific and allows you to include hidden folders in the folder listing response. By default, this parameter is `False` and hidden folders are not included.
+
+## Prerequisites
+
+1. A Nylas application with Microsoft OAuth configured
+2. A valid Nylas API key
+3. A grant ID for a Microsoft account
+
+## Setup
+
+1. Set your environment variables:
+ ```bash
+ export NYLAS_API_KEY="your_api_key_here"
+ export NYLAS_GRANT_ID="your_grant_id_here"
+ export NYLAS_API_URI="https://api.us.nylas.com" # Optional, defaults to US
+ ```
+
+2. Install the Nylas Python SDK:
+ ```bash
+ pip install nylas
+ ```
+
+## Running the Example
+
+```bash
+python include_hidden_folders_example.py
+```
+
+## Code Explanation
+
+The example demonstrates two scenarios:
+
+1. **Default behavior**: Lists folders without hidden folders
+ ```python
+ folders_response = nylas.folders.list(
+ identifier=grant_id,
+ query_params={"limit": 10}
+ )
+ ```
+
+2. **With hidden folders**: Lists folders including hidden folders (Microsoft only)
+ ```python
+ folders_with_hidden_response = nylas.folders.list(
+ identifier=grant_id,
+ query_params={
+ "include_hidden_folders": True,
+ "limit": 10
+ }
+ )
+ ```
+
+## Expected Output
+
+The example will show:
+- List of regular folders
+- List of folders including hidden ones (if any)
+- Comparison showing how many additional hidden folders were found
+
+## Note
+
+This feature is **Microsoft-specific only**. For other providers (Google, IMAP), the `include_hidden_folders` parameter will be ignored.
\ No newline at end of file
diff --git a/examples/include_hidden_folders_demo/include_hidden_folders_example.py b/examples/include_hidden_folders_demo/include_hidden_folders_example.py
new file mode 100644
index 00000000..39f2226e
--- /dev/null
+++ b/examples/include_hidden_folders_demo/include_hidden_folders_example.py
@@ -0,0 +1,71 @@
+import os
+from nylas import Client
+
+
+def main():
+ """
+ This example demonstrates how to use the include_hidden_folders parameter
+ when listing folders with the Nylas SDK.
+
+ The include_hidden_folders parameter is Microsoft-specific and when set to True,
+ it includes hidden folders in the response.
+ """
+
+ # Initialize the client
+ nylas = Client(
+ api_key=os.environ.get("NYLAS_API_KEY"),
+ api_uri=os.environ.get("NYLAS_API_URI", "https://api.us.nylas.com"),
+ )
+
+ # Get the grant ID from environment variable
+ grant_id = os.environ.get("NYLAS_GRANT_ID")
+
+ if not grant_id:
+ print("Please set the NYLAS_GRANT_ID environment variable")
+ return
+
+ try:
+ print("Listing folders without hidden folders (default behavior):")
+ print("=" * 60)
+
+ # List folders without hidden folders (default)
+ folders_response = nylas.folders.list(
+ identifier=grant_id, query_params={"limit": 10}
+ )
+
+ for folder in folders_response.data:
+ print(f"- {folder.name} (ID: {folder.id})")
+
+ print(f"\nTotal folders found: {len(folders_response.data)}")
+
+ # Now list folders WITH hidden folders (Microsoft only)
+ print("\n\nListing folders with hidden folders included (Microsoft only):")
+ print("=" * 70)
+
+ folders_with_hidden_response = nylas.folders.list(
+ identifier=grant_id,
+ query_params={"include_hidden_folders": True, "limit": 10},
+ )
+
+ for folder in folders_with_hidden_response.data:
+ print(f"- {folder.name} (ID: {folder.id})")
+
+ print(
+ f"\nTotal folders found (including hidden): {len(folders_with_hidden_response.data)}"
+ )
+
+ # Compare the counts
+ hidden_count = len(folders_with_hidden_response.data) - len(
+ folders_response.data
+ )
+ if hidden_count > 0:
+ print(f"\nFound {hidden_count} additional hidden folder(s)")
+ else:
+ print("\nNo additional hidden folders found")
+
+ except Exception as e:
+ print(f"Error: {e}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/inline_attachment_demo/README.md b/examples/inline_attachment_demo/README.md
new file mode 100644
index 00000000..5baaca0d
--- /dev/null
+++ b/examples/inline_attachment_demo/README.md
@@ -0,0 +1,65 @@
+# Inline Attachment Example
+
+This example demonstrates how to send messages and drafts with inline attachments using the `content_id` field in the Nylas Python SDK.
+
+## What This Example Shows
+
+- How to create inline attachments with `content_id` for HTML emails
+- How the SDK properly handles `content_id` for large attachments (>3MB)
+- The difference between inline attachments and regular attachments
+- How to reference inline attachments in HTML email bodies using `cid:` syntax
+
+## Key Features Demonstrated
+
+### Content ID Usage
+When an attachment includes a `content_id` field, the SDK will use this as the field name in multipart form data instead of the generic `file{index}` pattern. This is crucial for inline attachments that need to be referenced in the email body.
+
+### HTML Email with Inline Images
+The example shows how to:
+1. Set the `content_id` field in the attachment
+2. Reference the attachment in HTML using `src="cid:your-content-id"`
+3. Set appropriate inline properties (`is_inline: True`, `content_disposition: "inline"`)
+
+### Large Attachment Handling
+For attachments larger than 3MB, the SDK automatically switches from JSON to multipart form data. With this fix, the `content_id` is now properly respected in the form field names.
+
+## Running the Example
+
+1. Set your Nylas API key:
+ ```bash
+ export NYLAS_API_KEY='your-api-key-here'
+ ```
+
+2. Update the grant ID and email addresses in the script
+
+3. Run the example:
+ ```bash
+ python inline_attachment_example.py
+ ```
+
+## Important Notes
+
+- **Content ID Format**: Use a unique identifier for each inline attachment (e.g., `"image1@example.com"`, `"logo"`, `"banner-image"`)
+- **HTML Reference**: Reference inline attachments in HTML using `src="cid:your-content-id"`
+- **Backward Compatibility**: Attachments without `content_id` still work as before using `file{index}` naming
+- **File Size Threshold**: The 3MB threshold determines whether JSON or form data is used for the request
+
+## Expected Behavior
+
+### Before the Fix (Problematic)
+```
+Form data fields:
+- message: (JSON payload)
+- file0: (inline image - content_id ignored)
+- file1: (regular attachment)
+```
+
+### After the Fix (Correct)
+```
+Form data fields:
+- message: (JSON payload)
+- my-inline-image: (inline image - uses content_id)
+- file1: (regular attachment - fallback to file{index})
+```
+
+This ensures that email clients can properly display inline images by matching the `content_id` in the HTML `cid:` reference with the multipart form field name.
diff --git a/examples/inline_attachment_demo/inline_attachment_example.py b/examples/inline_attachment_demo/inline_attachment_example.py
new file mode 100644
index 00000000..21fc1bc0
--- /dev/null
+++ b/examples/inline_attachment_demo/inline_attachment_example.py
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+
+import base64
+import io
+import os
+from nylas import Client
+
+
+def send_message_with_inline_attachment():
+ """
+ This example demonstrates how to send a message with an inline attachment
+ that uses a content_id for referencing in HTML email bodies.
+
+ This is particularly useful for embedding images directly in HTML emails
+ where the image is referenced using 'cid:' in the src attribute.
+ """
+
+ # Initialize the Nylas client
+ nylas = Client(
+ api_key=os.environ.get("NYLAS_API_KEY"), # Replace with your API key
+ )
+
+ # Get test email
+ test_email = os.environ.get("TEST_EMAIL")
+
+ # Get grant
+ grant_id = os.environ.get("NYLAS_GRANT_ID")
+
+ # Create a sample image content using base64 decoded data
+ # This is a small PNG image that can be used for demonstration
+ base64_image = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEQUlEQVRYhe2WW2wUVRyHv5nZS7vb3XbLdrfXjSZ9AxOqDRANGjDRSKLRKoIYNLENpEgJRQIKKvSi0tI28oImhtISqg9cTEzUhIgmSkAQY4UWQ2yALi3d7S4UaPcyO7MzPkwlJXG3S02sD5yH8zLJ//vO7/zPmSMsqR0MMIvDBHhnU0CcTfh9gf+FgOnfFlCTIKs6mg6iABaTgFn6DwQ0DUYnNBaVW3iyMpvcHImJmMaPvTG+75PxOkSkDPKdkUBC1bFZRb76sIhyn/WubyufyePaqEJdW5DAWBKrWUhb6557QFZ13E6Jb/eUUe6zcuzkOMu3DlG0/DLPbrrKl8dvUewxc6S1lJI5EqqWvp6wpHZQzxiu6HjyJI60lCKKsLkjwI1xjfdr3PiKLYyMKnQcvE7oVpIDjSWEx1QW1w1R5Ey9zowTkBUd7xR4fVsAm1Wgc0cxD5RY6P8zRrHHTPtbhZS6TXzxzU3cLhNVC7NJqKnXmJGArOgUuiQOtxrwjbsDOG0ijW96kRM6T2+4yorGIAuqB1FUnc2vu9lz9BYAC+ZmkVBT155WQFZ0ilwSh1tKEQXY0DqCK0ekYZ2HeEJn6Xo/t6MahU6RuKJztj9Kfq7EwA1j8y1mkXR7nFZASeoU50scai1FEKCuZQS3U2JHrYe4rLGgZpDmmjn4CiRkVcdmFaica2PsdpLyfKN0QtFIdw5SHkNdB6tZ4FBLKQDrd43gdUm8t9ZDTNaorPbzycYCHq+0Y8sSWdMe4sTeMswmgd1dYTZU5QJwpj+OJc1hT5uApsGAX6Zu1wiF+ZPwuMbD1X4+rTfgv5yPUtUU5MTeMhx2kY4DYQaDKquW5REeUzl6OobFlDqDlAKCYKSwuP4aZV4T767xEI1rVFT7+WxTAYsfsXPmfJTnGoL07fPhsIu0d4c5e1Gm54MSANa1BCiwp2+ztDfheFxny/MO6le7icY0Kmr8dG4u4LEKO6fPRXmhKciFTh8up8TurjC9AzKfT8Jf3T7M0PUkWdPchCkFlCQ8Mc9K/Wo3kZix8q4tBTw6387Pv0d4sXmUC50+8hwSrftDnLuUoKfZgK/aPszlUZXsaeCQZgskEa4EVH76NcKitX66txrwU70RXpoCb+kM0XcpwcEmA/7KtmGuZAhPm4AoQHhcY1lTkO+aCln4kI2Tv0VY8dEo/ft95OZI7NoX4o/BBAcm4Su3DeMPqdPGnpEAQHhC44fmQirnGfA32kL0d/pw5hixX7qm0N1owFe8M8TV8PR7nrFARNZpeM1lwHsjPLUzyLGdXpw5Em3dYT7+eoLjrUUAvPz2UEYN908j5d8woeosnZ/N3ActNPTcxOMQybOJlHkkTl1M4MgSUJNGryQ1HbN07/C0An9LKEmwW43img5JjTtPLn1yEmbGBqbpAYtJuOsaFQUQp7z3hDvTzMesv4rvC8y6gAmIzKbAX+u0pDGsEb6KAAAAAElFTkSuQmCC"
+ image_content = base64.b64decode(base64_image)
+
+ # Create the message with inline attachment
+ message_request = {
+ "to": [{"email": test_email, "name": "Recipient Name"}],
+ "from": [{"email": test_email, "name": "Sender Name"}],
+ "subject": "Message with Inline Image",
+ "body": """
+
+
+ Hello!
+ This email contains an inline image:
+
+ The image above is embedded directly in the email using content_id.
+
+
+ """,
+ "attachments": [
+ {
+ "filename": "inline-image.png",
+ "content_type": "image/png",
+ "content": io.BytesIO(image_content),
+ "size": len(image_content),
+ "content_id": "my-inline-image", # This is the key for inline attachments
+ "is_inline": True,
+ "content_disposition": "inline"
+ },
+ {
+ # Regular attachment without content_id for comparison
+ "filename": "regular-attachment.txt",
+ "content_type": "text/plain",
+ "content": io.BytesIO(b"This is a regular attachment"),
+ "size": 28,
+ # No content_id - this will use the default file{index} naming
+ }
+ ]
+ }
+
+ try:
+ # Send the message
+ response = nylas.messages.send(
+ identifier=grant_id, # Replace with your grant ID
+ request_body=message_request
+ )
+
+ print("Message sent successfully!")
+ print(f"Message ID: {response.data.id}")
+ print(f"Thread ID: {response.data.thread_id}")
+
+ # The inline attachment will be referenced by its content_id in the form data
+ # instead of a generic file{index} name, allowing proper inline display
+
+ except Exception as e:
+ print(f"Error sending message: {e}")
+
+
+def send_draft_with_inline_attachment():
+ """
+ This example demonstrates how to create and send a draft with an inline attachment.
+ """
+
+ # Initialize the Nylas client
+ nylas = Client(
+ api_key=os.environ.get("NYLAS_API_KEY"), # Replace with your API key
+ )
+
+ # Get test email
+ test_email = os.environ.get("TEST_EMAIL")
+
+ # Get grant
+ grant_id = os.environ.get("NYLAS_GRANT_ID")
+
+ # Create a larger image content to trigger form data usage (>3MB threshold)
+ # For demo purposes, we'll replicate the same image data multiple times
+ # In real usage, large images would automatically use the content_id functionality
+ base64_image = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEQUlEQVRYhe2WW2wUVRyHv5nZS7vb3XbLdrfXjSZ9AxOqDRANGjDRSKLRKoIYNLENpEgJRQIKKvSi0tI28oImhtISqg9cTEzUhIgmSkAQY4UWQ2yALi3d7S4UaPcyO7MzPkwlJXG3S02sD5yH8zLJ//vO7/zPmSMsqR0MMIvDBHhnU0CcTfh9gf+FgOnfFlCTIKs6mg6iABaTgFn6DwQ0DUYnNBaVW3iyMpvcHImJmMaPvTG+75PxOkSkDPKdkUBC1bFZRb76sIhyn/WubyufyePaqEJdW5DAWBKrWUhb6557QFZ13E6Jb/eUUe6zcuzkOMu3DlG0/DLPbrrKl8dvUewxc6S1lJI5EqqWvp6wpHZQzxiu6HjyJI60lCKKsLkjwI1xjfdr3PiKLYyMKnQcvE7oVpIDjSWEx1QW1w1R5Ey9zowTkBUd7xR4fVsAm1Wgc0cxD5RY6P8zRrHHTPtbhZS6TXzxzU3cLhNVC7NJqKnXmJGArOgUuiQOtxrwjbsDOG0ijW96kRM6T2+4yorGIAuqB1FUnc2vu9lz9BYAC+ZmkVBT155WQFZ0ilwSh1tKEQXY0DqCK0ekYZ2HeEJn6Xo/t6MahU6RuKJztj9Kfq7EwA1j8y1mkXR7nFZASeoU50scai1FEKCuZQS3U2JHrYe4rLGgZpDmmjn4CiRkVcdmFaica2PsdpLyfKN0QtFIdw5SHkNdB6tZ4FBLKQDrd43gdUm8t9ZDTNaorPbzycYCHq+0Y8sSWdMe4sTeMswmgd1dYTZU5QJwpj+OJc1hT5uApsGAX6Zu1wiF+ZPwuMbD1X4+rTfgv5yPUtUU5MTeMhx2kY4DYQaDKquW5REeUzl6OobFlDqDlAKCYKSwuP4aZV4T767xEI1rVFT7+WxTAYsfsXPmfJTnGoL07fPhsIu0d4c5e1Gm54MSANa1BCiwp2+ztDfheFxny/MO6le7icY0Kmr8dG4u4LEKO6fPRXmhKciFTh8up8TurjC9AzKfT8Jf3T7M0PUkWdPchCkFlCQ8Mc9K/Wo3kZix8q4tBTw6387Pv0d4sXmUC50+8hwSrftDnLuUoKfZgK/aPszlUZXsaeCQZgskEa4EVH76NcKitX66txrwU70RXpoCb+kM0XcpwcEmA/7KtmGuZAhPm4AoQHhcY1lTkO+aCln4kI2Tv0VY8dEo/ft95OZI7NoX4o/BBAcm4Su3DeMPqdPGnpEAQHhC44fmQirnGfA32kL0d/pw5hixX7qm0N1owFe8M8TV8PR7nrFARNZpeM1lwHsjPLUzyLGdXpw5Em3dYT7+eoLjrUUAvPz2UEYN908j5d8woeosnZ/N3ActNPTcxOMQybOJlHkkTl1M4MgSUJNGryQ1HbN07/C0An9LKEmwW43img5JjTtPLn1yEmbGBqbpAYtJuOsaFQUQp7z3hDvTzMesv4rvC8y6gAmIzKbAX+u0pDGsEb6KAAAAAElFTkSuQmCC"
+ large_image_content = base64.b64decode(base64_image) * 1000 # Replicated to make it large
+
+ # Create the draft with inline attachment
+ draft_request = {
+ "to": [{"email": test_email, "name": "Recipient Name"}],
+ "from": [{"email": test_email, "name": "Sender Name"}],
+ "subject": "Draft with Inline Image",
+ "body": """
+
+
+ Draft Email
+ This draft contains an inline image:
+
+ Best regards,
Your Team
+
+
+ """,
+ "attachments": [
+ {
+ "filename": "company-logo.png",
+ "content_type": "image/png",
+ "content": io.BytesIO(large_image_content),
+ "size": len(large_image_content),
+ "content_id": "logo-image", # Content ID for inline reference
+ "is_inline": True,
+ "content_disposition": "inline"
+ }
+ ]
+ }
+
+ try:
+ # Create the draft
+ draft_response = nylas.drafts.create(
+ identifier=grant_id, # Replace with your grant ID
+ request_body=draft_request
+ )
+
+ print("Draft created successfully!")
+ print(f"Draft ID: {draft_response.data.id}")
+
+ # Send the draft
+ send_response = nylas.drafts.send(
+ identifier=grant_id, # Replace with your grant ID
+ draft_id=draft_response.data.id
+ )
+
+ print("Draft sent successfully!")
+ print(f"Message ID: {send_response.data.id}")
+
+ except Exception as e:
+ print(f"Error with draft: {e}")
+
+
+if __name__ == "__main__":
+ print("Inline Attachment Example")
+ print("=" * 50)
+ print()
+
+ # Check if API key is set
+ if not os.environ.get("NYLAS_API_KEY"):
+ print("Please set the NYLAS_API_KEY environment variable")
+ print("export NYLAS_API_KEY='your-api-key-here'")
+ exit(1)
+
+ # Check if grant ID is set
+ if not os.environ.get("NYLAS_GRANT_ID"):
+ print("Please set the NYLAS_GRANT_ID environment variable")
+ print("export NYLAS_GRANT_ID='your-grant-id-here'")
+ exit(1)
+
+ # Check if test email is set
+ if not os.environ.get("TEST_EMAIL"):
+ print("Please set the TEST_EMAIL environment variable")
+ print("export TEST_EMAIL='your-test-email-here'")
+ exit(1)
+
+ print("1. Sending message with inline attachment...")
+ send_message_with_inline_attachment()
+
+ print("\n2. Creating and sending draft with inline attachment...")
+ send_draft_with_inline_attachment()
+
+ print("\nNote: The content_id field ensures that large inline attachments")
+ print("are properly referenced in the multipart form data, allowing")
+ print("email clients to display them inline correctly.")
diff --git a/examples/is_plaintext_demo/README.md b/examples/is_plaintext_demo/README.md
new file mode 100644
index 00000000..da0d31b1
--- /dev/null
+++ b/examples/is_plaintext_demo/README.md
@@ -0,0 +1,156 @@
+# is_plaintext Demo
+
+This example demonstrates the usage of the new `is_plaintext` property for messages and drafts in the Nylas API. This property controls whether message content is sent as plain text or HTML in the MIME data.
+
+## Features Demonstrated
+
+1. **Plain Text Messages**: Shows how to send messages with `is_plaintext=True` to send content as plain text without HTML in MIME data.
+2. **HTML Messages**: Demonstrates sending messages with `is_plaintext=False` to include HTML formatting in MIME data.
+3. **Backwards Compatibility**: Shows that existing code continues to work without specifying the `is_plaintext` property.
+4. **Draft Operations**: Demonstrates using `is_plaintext` with draft creation and updates.
+
+## API Property Overview
+
+### is_plaintext
+
+- **Type**: `boolean`
+- **Default**: `false`
+- **Available in**:
+ - `messages.send()` - Send message endpoint
+ - `drafts.create()` - Create draft endpoint
+ - `drafts.update()` - Update draft endpoint
+
+When `is_plaintext` is:
+- `true`: The message body is sent as plain text and the MIME data doesn't include the HTML version of the message
+- `false`: The message body is sent as HTML and MIME data includes HTML formatting
+- Not specified: Uses API default behavior (same as `false`)
+
+## Setup
+
+1. Install the SDK in development mode from the repository root:
+```bash
+cd /path/to/nylas-python
+pip install -e .
+```
+
+2. Set your environment variables:
+```bash
+export NYLAS_API_KEY="your_api_key"
+export NYLAS_GRANT_ID="your_grant_id"
+```
+
+3. Run the example from the repository root:
+```bash
+python examples/is_plaintext_demo/is_plaintext_example.py
+```
+
+## Example Output
+
+```
+Demonstrating is_plaintext Property Usage
+=======================================
+
+=== Sending Plain Text Message ===
+Sending message with is_plaintext=True...
+✓ Message request prepared with is_plaintext=True
+📧 Message configured to be sent as plain text (MIME without HTML version)
+
+=== Sending HTML Message ===
+Sending message with is_plaintext=False (HTML)...
+✓ Message request prepared with is_plaintext=False
+🌐 Message configured to be sent as HTML (MIME includes HTML version)
+
+=== Backwards Compatibility (No is_plaintext specified) ===
+Sending message without is_plaintext property...
+✓ Existing code continues to work without modification
+
+=== Creating Plain Text Draft ===
+Creating draft with is_plaintext=True...
+✓ Draft request prepared with is_plaintext=True
+📝 Draft configured to be sent as plain text when sent
+
+Example completed successfully!
+```
+
+## Use Cases
+
+### Plain Text (is_plaintext=true)
+- **Simple Notifications**: System alerts, password resets, account confirmations
+- **Text-Only Emails**: Newsletters or announcements that don't need formatting
+- **Lightweight Messaging**: Reduce message size and improve compatibility
+- **Accessibility**: Better support for screen readers and text-only email clients
+
+### HTML (is_plaintext=false)
+- **Marketing Emails**: Rich formatting, images, and branded content
+- **Newsletters**: Complex layouts with multiple sections and styling
+- **Transactional Emails**: Formatted receipts, invoices, and reports
+- **Interactive Content**: Buttons, links, and styled call-to-action elements
+
+## Code Examples
+
+### Send Plain Text Message
+```python
+message_request = {
+ "to": [{"email": "user@example.com", "name": "User"}],
+ "subject": "Plain Text Notification",
+ "body": "This is a plain text message.",
+ "is_plaintext": True
+}
+
+response = client.messages.send(
+ identifier=grant_id,
+ request_body=message_request
+)
+```
+
+### Send HTML Message
+```python
+message_request = {
+ "to": [{"email": "user@example.com", "name": "User"}],
+ "subject": "HTML Newsletter",
+ "body": "Welcome!
This is HTML content.
",
+ "is_plaintext": False
+}
+
+response = client.messages.send(
+ identifier=grant_id,
+ request_body=message_request
+)
+```
+
+### Create Plain Text Draft
+```python
+draft_request = {
+ "to": [{"email": "user@example.com", "name": "User"}],
+ "subject": "Draft Message",
+ "body": "This draft will be sent as plain text.",
+ "is_plaintext": True
+}
+
+response = client.drafts.create(
+ identifier=grant_id,
+ request_body=draft_request
+)
+```
+
+## Important Notes
+
+- **Backwards Compatibility**: Existing code without `is_plaintext` continues to work unchanged
+- **Default Behavior**: When `is_plaintext` is not specified, it defaults to `false` (HTML)
+- **Content Type**: The property affects MIME structure, not just content rendering
+- **Safety**: The example includes commented API calls to prevent unintended message sends
+
+## Error Handling
+
+The example includes proper error handling for:
+- Missing environment variables
+- API authentication errors
+- Invalid request parameters
+- Network connectivity issues
+
+## Documentation
+
+For more information about the Nylas Python SDK and message properties, visit:
+- [Nylas Python SDK Documentation](https://developer.nylas.com/docs/sdks/python/)
+- [Nylas API Messages Reference](https://developer.nylas.com/docs/api/v3/ecc/#tag--Messages)
+- [Nylas API Drafts Reference](https://developer.nylas.com/docs/api/v3/ecc/#tag--Drafts)
diff --git a/examples/is_plaintext_demo/is_plaintext_example.py b/examples/is_plaintext_demo/is_plaintext_example.py
new file mode 100644
index 00000000..3b3a1c23
--- /dev/null
+++ b/examples/is_plaintext_demo/is_plaintext_example.py
@@ -0,0 +1,294 @@
+#!/usr/bin/env python3
+"""
+Nylas SDK Example: Using is_plaintext for Messages and Drafts
+
+This example demonstrates how to use the new 'is_plaintext' property when sending
+messages and creating drafts to control whether content is sent as plain text or HTML.
+
+Required Environment Variables:
+ NYLAS_API_KEY: Your Nylas API key
+ NYLAS_GRANT_ID: Your Nylas grant ID
+
+Usage:
+ First, install the SDK in development mode:
+ cd /path/to/nylas-python
+ pip install -e .
+
+ Then set environment variables and run:
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ python examples/is_plaintext_demo/is_plaintext_example.py
+"""
+
+import os
+import sys
+from nylas import Client
+
+
+def get_env_or_exit(var_name: str) -> str:
+ """Get an environment variable or exit if not found."""
+ value = os.getenv(var_name)
+ if not value:
+ print(f"Error: {var_name} environment variable is required")
+ sys.exit(1)
+ return value
+
+
+def print_separator(title: str) -> None:
+ """Print a formatted section separator."""
+ print(f"\n=== {title} ===")
+
+
+def demonstrate_plaintext_message(client: Client, grant_id: str) -> None:
+ """Demonstrate sending a message with is_plaintext=True."""
+ print_separator("Sending Plain Text Message")
+
+ try:
+ print("Sending message with is_plaintext=True...")
+
+ # Example message content with HTML tags that will be sent as plain text
+ body_content = """Hello World!
+
+This is a test message sent as plain text.
+Even if this contained HTML tags, they would be sent as plain text.
+
+Best regards,
+The Nylas SDK Team"""
+
+ # Send message with is_plaintext=True
+ message_request = {
+ "to": [{"email": "test@example.com", "name": "Test Recipient"}],
+ "subject": "Plain Text Message Example",
+ "body": body_content,
+ "is_plaintext": True
+ }
+
+ print("✓ Message request prepared with is_plaintext=True")
+ print(f" Subject: {message_request['subject']}")
+ print(f" Body preview: {body_content[:100]}...")
+ print(f" is_plaintext: {message_request['is_plaintext']}")
+
+ # Note: Uncomment the following line to actually send the message
+ # response = client.messages.send(identifier=grant_id, request_body=message_request)
+ # print(f"✓ Message sent! ID: {response.data.id}")
+
+ print("📧 Message configured to be sent as plain text (MIME without HTML version)")
+
+ except Exception as e:
+ print(f"❌ Error sending plain text message: {e}")
+
+
+def demonstrate_html_message(client: Client, grant_id: str) -> None:
+ """Demonstrate sending a message with is_plaintext=False (HTML)."""
+ print_separator("Sending HTML Message")
+
+ try:
+ print("Sending message with is_plaintext=False (HTML)...")
+
+ # Example message content with HTML formatting
+ body_content = """
+
+ Hello World!
+
+ This is a test message sent as HTML.
+
+ The HTML tags will be properly rendered:
+
+
+ Best regards,
+ The Nylas SDK Team
+
+"""
+
+ # Send message with is_plaintext=False (default behavior)
+ message_request = {
+ "to": [{"email": "test@example.com", "name": "Test Recipient"}],
+ "subject": "HTML Message Example",
+ "body": body_content,
+ "is_plaintext": False
+ }
+
+ print("✓ Message request prepared with is_plaintext=False")
+ print(f" Subject: {message_request['subject']}")
+ print(f" HTML body includes formatting tags")
+ print(f" is_plaintext: {message_request['is_plaintext']}")
+
+ # Note: Uncomment the following line to actually send the message
+ # response = client.messages.send(identifier=grant_id, request_body=message_request)
+ # print(f"✓ Message sent! ID: {response.data.id}")
+
+ print("🌐 Message configured to be sent as HTML (MIME includes HTML version)")
+
+ except Exception as e:
+ print(f"❌ Error sending HTML message: {e}")
+
+
+def demonstrate_backwards_compatibility(client: Client, grant_id: str) -> None:
+ """Demonstrate that existing code without is_plaintext still works."""
+ print_separator("Backwards Compatibility (No is_plaintext specified)")
+
+ try:
+ print("Sending message without is_plaintext property...")
+
+ # Traditional message request without is_plaintext
+ message_request = {
+ "to": [{"email": "test@example.com", "name": "Test Recipient"}],
+ "subject": "Traditional Message Example",
+ "body": "This message doesn't specify is_plaintext, so it uses the default behavior."
+ }
+
+ print("✓ Message request prepared without is_plaintext property")
+ print(f" Subject: {message_request['subject']}")
+ print(f" Body: {message_request['body']}")
+ print(f" is_plaintext: Not specified (uses API default)")
+
+ # Note: Uncomment the following line to actually send the message
+ # response = client.messages.send(identifier=grant_id, request_body=message_request)
+ # print(f"✓ Message sent! ID: {response.data.id}")
+
+ print("✓ Existing code continues to work without modification")
+
+ except Exception as e:
+ print(f"❌ Error sending traditional message: {e}")
+
+
+def demonstrate_plaintext_draft(client: Client, grant_id: str) -> None:
+ """Demonstrate creating a draft with is_plaintext=True."""
+ print_separator("Creating Plain Text Draft")
+
+ try:
+ print("Creating draft with is_plaintext=True...")
+
+ draft_request = {
+ "to": [{"email": "test@example.com", "name": "Test Recipient"}],
+ "subject": "Plain Text Draft Example",
+ "body": "This is a draft that will be sent as plain text when sent.",
+ "is_plaintext": True
+ }
+
+ print("✓ Draft request prepared with is_plaintext=True")
+ print(f" Subject: {draft_request['subject']}")
+ print(f" Body: {draft_request['body']}")
+ print(f" is_plaintext: {draft_request['is_plaintext']}")
+
+ # Note: Uncomment the following lines to actually create the draft
+ # response = client.drafts.create(identifier=grant_id, request_body=draft_request)
+ # print(f"✓ Draft created! ID: {response.data.id}")
+
+ print("📝 Draft configured to be sent as plain text when sent")
+
+ except Exception as e:
+ print(f"❌ Error creating plain text draft: {e}")
+
+
+def demonstrate_html_draft(client: Client, grant_id: str) -> None:
+ """Demonstrate creating a draft with is_plaintext=False."""
+ print_separator("Creating HTML Draft")
+
+ try:
+ print("Creating draft with is_plaintext=False...")
+
+ html_body = """
+
+ Draft Message
+ This is a draft with HTML formatting.
+ It will include HTML in the MIME data when sent.
+
+"""
+
+ draft_request = {
+ "to": [{"email": "test@example.com", "name": "Test Recipient"}],
+ "subject": "HTML Draft Example",
+ "body": html_body,
+ "is_plaintext": False
+ }
+
+ print("✓ Draft request prepared with is_plaintext=False")
+ print(f" Subject: {draft_request['subject']}")
+ print(f" HTML body includes formatting")
+ print(f" is_plaintext: {draft_request['is_plaintext']}")
+
+ # Note: Uncomment the following lines to actually create the draft
+ # response = client.drafts.create(identifier=grant_id, request_body=draft_request)
+ # print(f"✓ Draft created! ID: {response.data.id}")
+
+ print("📝 Draft configured to be sent as HTML when sent")
+
+ except Exception as e:
+ print(f"❌ Error creating HTML draft: {e}")
+
+
+def demonstrate_draft_update(client: Client, grant_id: str) -> None:
+ """Demonstrate updating a draft with is_plaintext property."""
+ print_separator("Updating Draft with is_plaintext")
+
+ try:
+ print("Demonstrating draft update with is_plaintext...")
+
+ # Example update request
+ update_request = {
+ "subject": "Updated Draft with Plain Text",
+ "body": "This draft has been updated to use plain text format.",
+ "is_plaintext": True
+ }
+
+ print("✓ Draft update request prepared with is_plaintext=True")
+ print(f" Updated subject: {update_request['subject']}")
+ print(f" Updated body: {update_request['body']}")
+ print(f" is_plaintext: {update_request['is_plaintext']}")
+
+ # Note: Uncomment the following lines to actually update a draft
+ # draft_id = "your_draft_id_here"
+ # response = client.drafts.update(
+ # identifier=grant_id,
+ # draft_id=draft_id,
+ # request_body=update_request
+ # )
+ # print(f"✓ Draft updated! ID: {response.data.id}")
+
+ print("📝 Draft update includes is_plaintext configuration")
+
+ except Exception as e:
+ print(f"❌ Error updating draft: {e}")
+
+
+def main():
+ """Main function demonstrating is_plaintext usage."""
+ # Get required environment variables
+ api_key = get_env_or_exit("NYLAS_API_KEY")
+ grant_id = get_env_or_exit("NYLAS_GRANT_ID")
+
+ # Initialize Nylas client
+ client = Client(api_key=api_key)
+
+ print("Demonstrating is_plaintext Property Usage")
+ print("=======================================")
+ print("This shows the new 'is_plaintext' property for messages and drafts")
+ print("Note: Actual API calls are commented out to prevent unintended sends")
+
+ # Demonstrate message sending with different is_plaintext values
+ demonstrate_plaintext_message(client, grant_id)
+ demonstrate_html_message(client, grant_id)
+ demonstrate_backwards_compatibility(client, grant_id)
+
+ # Demonstrate draft creation and updating with is_plaintext
+ demonstrate_plaintext_draft(client, grant_id)
+ demonstrate_html_draft(client, grant_id)
+ demonstrate_draft_update(client, grant_id)
+
+ print("\n" + "="*60)
+ print("Example completed successfully!")
+ print("="*60)
+ print("\n💡 Key Takeaways:")
+ print("• is_plaintext=True: Sends content as plain text (no HTML in MIME)")
+ print("• is_plaintext=False: Sends content as HTML (includes HTML in MIME)")
+ print("• Not specified: Uses API default behavior (backwards compatible)")
+ print("• Available in: messages.send(), drafts.create(), drafts.update()")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/lib/random_words.py b/examples/lib/random_words.py
deleted file mode 100755
index 7f69888a..00000000
--- a/examples/lib/random_words.py
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/usr/bin/env python
-import random
-import json
-import sys
-
-DICT_FILE = '/etc/dictionaries-common/words'
-
-def get_words():
- words = []
- try:
- with open(DICT_FILE, 'r') as f:
- words.extend(f.read().split('\n'))
- except IOError:
- print json.dumps({'error': "couldn't open dictionary file",
- 'filename': DICT_FILE})
- sys.exit(1)
- return words
-
-
-def random_words(count=int(random.uniform(1,500)), sig='me'):
- words = get_words()
- random_word_list = []
-
- if sig:
- word_index = int(random.uniform(1, len(words)))
- random_word = words[word_index]
-
- salutation = ['Hey', 'Hi', 'Ahoy', 'Yo'][int(random.uniform(0,3))]
- random_word_list.append("{} {},\n\n".format(salutation, random_word))
-
-
- just_entered = False
- for i in range(count):
- word_index = int(random.uniform(1, len(words)))
- random_word = words[word_index]
-
- if i > 0 and not just_entered:
- random_word = ' ' + random_word
-
- just_entered = False
-
- if int(random.uniform(1,15)) == 1:
- random_word += ('.')
-
- if int(random.uniform(1,3)) == 1 and sig:
- random_word += ('\n')
- just_entered = True
-
- if int(random.uniform(1,3)) == 1 and sig:
- random_word += ('\n')
- just_entered = True
-
- random_word_list.append(random_word)
-
- text = ''.join(random_word_list) + '.'
- if sig:
- if int(random.uniform(1,2)) == 1:
- salutation = ['Cheers', 'Adios', 'Ciao', 'Bye'][int(random.uniform(0,3))]
- punct = ['.', ',', '!', ''][int(random.uniform(0,3))]
- text += "\n\n{}{}\n".format(salutation, punct)
- else:
- text += '\n\n'
-
- punct = ['-', '- ', '--', '-- '][int(random.uniform(0,3))]
- text += '{}{}'.format(punct, sig)
-
- return text
-
-
-if __name__ == '__main__':
- print random_words()
diff --git a/examples/message_fields_demo/README.md b/examples/message_fields_demo/README.md
new file mode 100644
index 00000000..5b0a2f18
--- /dev/null
+++ b/examples/message_fields_demo/README.md
@@ -0,0 +1,99 @@
+# Message Fields Demo
+
+This example demonstrates the usage of the new message `fields` query parameter values (`include_tracking_options` and `raw_mime`) introduced in the Nylas API. These fields allow you to access tracking information and raw MIME data for messages.
+
+## Features Demonstrated
+
+1. **include_tracking_options Field**: Shows how to fetch messages with their tracking options (opens, thread_replies, links, and label).
+2. **raw_mime Field**: Demonstrates how to retrieve the raw MIME content of messages as Base64url-encoded data.
+3. **Backwards Compatibility**: Shows that existing code continues to work as expected without specifying the new fields.
+4. **TrackingOptions Model**: Demonstrates working with the new TrackingOptions dataclass for serialization and deserialization.
+
+## API Fields Overview
+
+### include_tracking_options
+When using `fields=include_tracking_options`, the API returns messages with their tracking settings:
+- `opens`: Boolean indicating if message open tracking is enabled
+- `thread_replies`: Boolean indicating if thread replied tracking is enabled
+- `links`: Boolean indicating if link clicked tracking is enabled
+- `label`: String label describing the message tracking purpose
+
+### raw_mime
+When using `fields=raw_mime`, the API returns only essential fields plus the raw MIME content:
+- `grant_id`: The grant identifier
+- `object`: The object type ("message")
+- `id`: The message identifier
+- `raw_mime`: Base64url-encoded string containing the complete message data
+
+## Setup
+
+1. Install the SDK in development mode from the repository root:
+```bash
+cd /path/to/nylas-python
+pip install -e .
+```
+
+2. Set your environment variables:
+```bash
+export NYLAS_API_KEY="your_api_key"
+export NYLAS_GRANT_ID="your_grant_id"
+```
+
+3. Run the example from the repository root:
+```bash
+python examples/message_fields_demo/message_fields_example.py
+```
+
+## Example Output
+
+```
+Demonstrating Message Fields Usage
+=================================
+
+=== Standard Message Fetching (Backwards Compatible) ===
+Fetching messages with standard fields...
+✓ Found 2 messages with standard payload
+
+=== Include Tracking Options ===
+Fetching messages with tracking options...
+✓ Found 2 messages with tracking data
+Message tracking: opens=True, links=False, label="Campaign A"
+
+=== Raw MIME Content ===
+Fetching messages with raw MIME data...
+✓ Found 2 messages with raw MIME content
+Raw MIME length: 1245 characters
+
+=== TrackingOptions Model Demo ===
+Creating and serializing TrackingOptions...
+✓ TrackingOptions serialization works correctly
+
+Example completed successfully!
+```
+
+## Use Cases
+
+### Tracking Options
+- **Email Campaign Analytics**: Monitor open rates, link clicks, and thread engagement
+- **Marketing Automation**: Track customer engagement with promotional emails
+- **CRM Integration**: Feed tracking data into customer relationship management systems
+
+### Raw MIME
+- **Email Archival**: Store complete email data including headers and formatting
+- **Email Migration**: Transfer emails between systems with full fidelity
+- **Security Analysis**: Examine email headers and structure for security purposes
+- **Custom Email Parsing**: Build custom email processing pipelines
+
+## Error Handling
+
+The example includes proper error handling for:
+- Missing environment variables
+- API authentication errors
+- Empty message collections
+- Invalid field parameters
+
+## Documentation
+
+For more information about the Nylas Python SDK and message fields, visit:
+- [Nylas Python SDK Documentation](https://developer.nylas.com/docs/sdks/python/)
+- [Nylas API Messages Reference](https://developer.nylas.com/docs/api/v3/ecc/#tag--Messages)
\ No newline at end of file
diff --git a/examples/message_fields_demo/message_fields_example.py b/examples/message_fields_demo/message_fields_example.py
new file mode 100644
index 00000000..5faa8d84
--- /dev/null
+++ b/examples/message_fields_demo/message_fields_example.py
@@ -0,0 +1,275 @@
+#!/usr/bin/env python3
+"""
+Nylas SDK Example: Using Message Fields (include_tracking_options and raw_mime)
+
+This example demonstrates how to use the new 'fields' query parameter values
+'include_tracking_options' and 'raw_mime' to access message tracking data and raw MIME content.
+
+Required Environment Variables:
+ NYLAS_API_KEY: Your Nylas API key
+ NYLAS_GRANT_ID: Your Nylas grant ID
+
+Usage:
+ First, install the SDK in development mode:
+ cd /path/to/nylas-python
+ pip install -e .
+
+ Then set environment variables and run:
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ python examples/message_fields_demo/message_fields_example.py
+"""
+
+import os
+import sys
+import json
+import base64
+from nylas import Client
+from nylas.models.messages import TrackingOptions
+
+
+def get_env_or_exit(var_name: str) -> str:
+ """Get an environment variable or exit if not found."""
+ value = os.getenv(var_name)
+ if not value:
+ print(f"Error: {var_name} environment variable is required")
+ sys.exit(1)
+ return value
+
+
+def print_separator(title: str) -> None:
+ """Print a formatted section separator."""
+ print(f"\n=== {title} ===")
+
+
+def demonstrate_standard_fields(client: Client, grant_id: str) -> None:
+ """Demonstrate backwards compatible message fetching (standard fields)."""
+ print_separator("Standard Message Fetching (Backwards Compatible)")
+
+ try:
+ print("Fetching messages with standard fields...")
+ messages = client.messages.list(
+ identifier=grant_id,
+ query_params={"limit": 2}
+ )
+
+ if not messages.data:
+ print("⚠️ No messages found in this account")
+ return
+
+ print(f"✓ Found {len(messages.data)} messages with standard payload")
+
+ for i, message in enumerate(messages.data, 1):
+ print(f"\nMessage {i}:")
+ print(f" ID: {message.id}")
+ print(f" Subject: {message.subject or 'No subject'}")
+ print(f" From: {message.from_[0].email if message.from_ else 'Unknown'}")
+ print(f" Tracking Options: {message.tracking_options}") # Should be None
+ print(f" Raw MIME: {message.raw_mime}") # Should be None
+
+ except Exception as e:
+ print(f"❌ Error fetching standard messages: {e}")
+
+
+def demonstrate_tracking_options(client: Client, grant_id: str) -> None:
+ """Demonstrate fetching messages with tracking options."""
+ print_separator("Include Tracking Options")
+
+ try:
+ print("Fetching messages with tracking options...")
+ messages = client.messages.list(
+ identifier=grant_id,
+ query_params={
+ "limit": 2,
+ "fields": "include_tracking_options"
+ }
+ )
+
+ if not messages.data:
+ print("⚠️ No messages found in this account")
+ return
+
+ print(f"✓ Found {len(messages.data)} messages with tracking data")
+
+ for i, message in enumerate(messages.data, 1):
+ print(f"\nMessage {i}:")
+ print(f" ID: {message.id}")
+ print(f" Subject: {message.subject or 'No subject'}")
+
+ if message.tracking_options:
+ print(f" Tracking Options:")
+ print(f" Opens: {message.tracking_options.opens}")
+ print(f" Thread Replies: {message.tracking_options.thread_replies}")
+ print(f" Links: {message.tracking_options.links}")
+ print(f" Label: {message.tracking_options.label}")
+ else:
+ print(" Tracking Options: None (tracking not enabled for this message)")
+
+ except Exception as e:
+ print(f"❌ Error fetching messages with tracking options: {e}")
+
+
+def demonstrate_raw_mime(client: Client, grant_id: str) -> None:
+ """Demonstrate fetching messages with raw MIME content."""
+ print_separator("Raw MIME Content")
+
+ try:
+ print("Fetching messages with raw MIME data...")
+ messages = client.messages.list(
+ identifier=grant_id,
+ query_params={
+ "limit": 2,
+ "fields": "raw_mime"
+ }
+ )
+
+ if not messages.data:
+ print("⚠️ No messages found in this account")
+ return
+
+ print(f"✓ Found {len(messages.data)} messages with raw MIME content")
+
+ for i, message in enumerate(messages.data, 1):
+ print(f"\nMessage {i}:")
+ print(f" ID: {message.id}")
+ print(f" Grant ID: {message.grant_id}")
+ print(f" Object: {message.object}")
+
+ if message.raw_mime:
+ print(f" Raw MIME length: {len(message.raw_mime)} characters")
+
+ # Decode a small portion to show it's real MIME data
+ try:
+ # Show first 200 characters of decoded MIME
+ decoded_sample = base64.urlsafe_b64decode(
+ message.raw_mime + '=' * (4 - len(message.raw_mime) % 4)
+ ).decode('utf-8', errors='ignore')[:200]
+ print(f" MIME preview: {decoded_sample}...")
+ except Exception as decode_error:
+ print(f" MIME preview: Unable to decode preview ({decode_error})")
+ else:
+ print(" Raw MIME: None")
+
+ # Note: In raw_mime mode, most other fields should be None
+ print(f" Subject (should be None): {message.subject}")
+ print(f" Body (should be None): {getattr(message, 'body', 'N/A')}")
+
+ except Exception as e:
+ print(f"❌ Error fetching messages with raw MIME: {e}")
+
+
+def demonstrate_single_message_fields(client: Client, grant_id: str) -> None:
+ """Demonstrate fetching a single message with different field options."""
+ print_separator("Single Message with Different Fields")
+
+ try:
+ # First get a message ID
+ print("Finding a message to demonstrate single message field options...")
+ messages = client.messages.list(
+ identifier=grant_id,
+ query_params={"limit": 1}
+ )
+
+ if not messages.data:
+ print("⚠️ No messages found for single message demo")
+ return
+
+ message_id = messages.data[0].id
+ print(f"Using message ID: {message_id}")
+
+ # Fetch with tracking options
+ print("\nFetching single message with tracking options...")
+ message = client.messages.find(
+ identifier=grant_id,
+ message_id=message_id,
+ query_params={"fields": "include_tracking_options"}
+ )
+
+ print(f"✓ Message fetched with tracking: {message.tracking_options is not None}")
+
+ # Fetch with raw MIME
+ print("\nFetching single message with raw MIME...")
+ message = client.messages.find(
+ identifier=grant_id,
+ message_id=message_id,
+ query_params={"fields": "raw_mime"}
+ )
+
+ print(f"✓ Message fetched with raw MIME: {message.raw_mime is not None}")
+ if message.raw_mime:
+ print(f" Raw MIME size: {len(message.raw_mime)} characters")
+
+ except Exception as e:
+ print(f"❌ Error in single message demo: {e}")
+
+
+def demonstrate_tracking_options_model() -> None:
+ """Demonstrate working with the TrackingOptions model directly."""
+ print_separator("TrackingOptions Model Demo")
+
+ try:
+ print("Creating TrackingOptions object...")
+
+ # Create a TrackingOptions instance
+ tracking = TrackingOptions(
+ opens=True,
+ thread_replies=False,
+ links=True,
+ label="Marketing Campaign Demo"
+ )
+
+ print("✓ TrackingOptions created:")
+ print(f" Opens: {tracking.opens}")
+ print(f" Thread Replies: {tracking.thread_replies}")
+ print(f" Links: {tracking.links}")
+ print(f" Label: {tracking.label}")
+
+ # Demonstrate serialization
+ print("\nSerializing to dict...")
+ tracking_dict = tracking.to_dict()
+ print(f"✓ Serialized: {json.dumps(tracking_dict, indent=2)}")
+
+ # Demonstrate deserialization
+ print("\nDeserializing from dict...")
+ restored_tracking = TrackingOptions.from_dict(tracking_dict)
+ print(f"✓ Deserialized: opens={restored_tracking.opens}, label='{restored_tracking.label}'")
+
+ # Demonstrate JSON serialization
+ print("\nJSON serialization...")
+ tracking_json = tracking.to_json()
+ print(f"✓ JSON: {tracking_json}")
+
+ restored_from_json = TrackingOptions.from_json(tracking_json)
+ print(f"✓ From JSON: {restored_from_json.to_dict()}")
+
+ except Exception as e:
+ print(f"❌ Error in TrackingOptions demo: {e}")
+
+
+def main():
+ """Main function demonstrating message fields usage."""
+ # Get required environment variables
+ api_key = get_env_or_exit("NYLAS_API_KEY")
+ grant_id = get_env_or_exit("NYLAS_GRANT_ID")
+
+ # Initialize Nylas client
+ client = Client(api_key=api_key)
+
+ print("Demonstrating Message Fields Usage")
+ print("=================================")
+ print("This shows the new 'include_tracking_options' and 'raw_mime' field options")
+
+ # Demonstrate different field options
+ demonstrate_standard_fields(client, grant_id)
+ demonstrate_tracking_options(client, grant_id)
+ demonstrate_raw_mime(client, grant_id)
+ demonstrate_single_message_fields(client, grant_id)
+ demonstrate_tracking_options_model()
+
+ print("\n" + "="*50)
+ print("Example completed successfully!")
+ print("="*50)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/examples/metadata_field_demo/README.md b/examples/metadata_field_demo/README.md
new file mode 100644
index 00000000..f5310f31
--- /dev/null
+++ b/examples/metadata_field_demo/README.md
@@ -0,0 +1,67 @@
+# Metadata Field Example
+
+This example demonstrates how to use metadata fields when creating drafts and sending messages using the Nylas Python SDK.
+
+## Features
+
+- Create drafts with custom metadata fields
+- Send messages with custom metadata fields
+- Error handling and environment variable configuration
+- Clear output and status messages
+
+## Prerequisites
+
+1. A Nylas account with API access
+2. Python 3.x installed
+3. Local installation of the Nylas Python SDK (this repository)
+
+## Setup
+
+1. Install the SDK in development mode from the repository root:
+```bash
+cd /path/to/nylas-python
+pip install -e .
+```
+
+2. Set your environment variables:
+```bash
+export NYLAS_API_KEY="your_api_key"
+export NYLAS_GRANT_ID="your_grant_id"
+export TEST_EMAIL="recipient@example.com" # Optional
+```
+
+3. Run the example from the repository root:
+```bash
+python examples/metadata_field_demo/metadata_example.py
+```
+
+## Example Output
+
+```
+Demonstrating Metadata Field Usage
+=================================
+
+1. Creating draft with metadata...
+✓ Created draft with ID: draft-abc123
+ Request ID: req-xyz789
+
+2. Sending message with metadata...
+✓ Sent message with ID: msg-def456
+ Request ID: req-uvw321
+
+Example completed successfully!
+```
+
+## Error Handling
+
+The example includes proper error handling for:
+- Missing environment variables
+- API authentication errors
+- Draft creation failures
+- Message sending failures
+
+## Documentation
+
+For more information about the Nylas Python SDK and its features, visit:
+- [Nylas Python SDK Documentation](https://developer.nylas.com/docs/sdks/python/)
+- [Nylas API Reference](https://developer.nylas.com/docs/api/)
diff --git a/examples/metadata_field_demo/metadata_example.py b/examples/metadata_field_demo/metadata_example.py
new file mode 100644
index 00000000..737dbaf7
--- /dev/null
+++ b/examples/metadata_field_demo/metadata_example.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+"""
+Nylas SDK Example: Using Metadata Fields with Drafts and Messages
+
+This example demonstrates how to use metadata fields when creating drafts
+and sending messages using the Nylas Python SDK.
+
+Required Environment Variables:
+ NYLAS_API_KEY: Your Nylas API key
+ NYLAS_GRANT_ID: Your Nylas grant ID
+ TEST_EMAIL: Email address for sending test messages (optional)
+
+Usage:
+ First, install the SDK in development mode:
+ cd /path/to/nylas-python
+ pip install -e .
+
+ Then set environment variables and run:
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ export TEST_EMAIL="recipient@example.com"
+ python examples/metadata_field_demo/metadata_example.py
+"""
+
+import os
+import sys
+from typing import Dict, Any, Optional
+
+# Import from local nylas package
+from nylas import Client
+from nylas.models.errors import NylasApiError
+
+
+def get_env_or_exit(var_name: str, required: bool = True) -> Optional[str]:
+ """Get an environment variable or exit if required and not found."""
+ value = os.getenv(var_name)
+ if required and not value:
+ print(f"Error: {var_name} environment variable is required")
+ sys.exit(1)
+ return value
+
+
+def create_draft_with_metadata(
+ client: Client, grant_id: str, metadata: Dict[str, Any], recipient: str
+) -> str:
+ """Create a draft message with metadata fields."""
+ try:
+ draft_request = {
+ "subject": "Test Draft with Metadata",
+ "to": [{"email": recipient}],
+ "body": "This is a test draft with metadata fields.",
+ "metadata": metadata
+ }
+
+ draft, request_id = client.drafts.create(
+ identifier=grant_id,
+ request_body=draft_request
+ )
+ print(f"✓ Created draft with ID: {draft.id}")
+ print(f" Request ID: {request_id}")
+ return draft.id
+ except NylasApiError as e:
+ print(f"✗ Failed to create draft: {e}")
+ sys.exit(1)
+
+
+def send_message_with_metadata(
+ client: Client, grant_id: str, metadata: Dict[str, Any], recipient: str
+) -> str:
+ """Send a message directly with metadata fields."""
+ try:
+ message_request = {
+ "subject": "Test Message with Metadata",
+ "to": [{"email": recipient}],
+ "body": "This is a test message with metadata fields.",
+ "metadata": metadata
+ }
+
+ message, request_id = client.messages.send(
+ identifier=grant_id,
+ request_body=message_request
+ )
+ print(f"✓ Sent message with ID: {message.id}")
+ print(f" Request ID: {request_id}")
+
+ return message.id
+ except NylasApiError as e:
+ print(f"✗ Failed to send message: {e}")
+ sys.exit(1)
+
+
+def main():
+ """Main function demonstrating metadata field usage."""
+ # Get required environment variables
+ api_key = get_env_or_exit("NYLAS_API_KEY")
+ grant_id = get_env_or_exit("NYLAS_GRANT_ID")
+ recipient = get_env_or_exit("TEST_EMAIL", required=False) or "recipient@example.com"
+
+ # Initialize Nylas client
+ client = Client(
+ api_key=api_key,
+ )
+
+ # Example metadata
+ metadata = {
+ "campaign_id": "example-123",
+ "user_id": "user-456",
+ "custom_field": "test-value"
+ }
+
+ print("\nDemonstrating Metadata Field Usage")
+ print("=================================")
+
+ # Create a draft with metadata
+ print("\n1. Creating draft with metadata...")
+ draft_id = create_draft_with_metadata(client, grant_id, metadata, recipient)
+
+ # Send a message with metadata
+ print("\n2. Sending message with metadata...")
+ message_id = send_message_with_metadata(client, grant_id, metadata, recipient)
+
+ print("\nExample completed successfully!")
+
+ # Get the draft and message to demonstrate metadata retrieval
+ draft = client.drafts.find(identifier=grant_id, draft_id=draft_id)
+ message = client.messages.find(identifier=grant_id, message_id=message_id)
+
+ print("\nRetrieved Draft Metadata:")
+ print("-------------------------")
+ print(draft.data)
+
+ print("\nRetrieved Message Metadata:")
+ print("---------------------------")
+ print(message.data)
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/most_talked_to.py b/examples/most_talked_to.py
deleted file mode 100644
index bf50bbb6..00000000
--- a/examples/most_talked_to.py
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/python
-
-from operator import itemgetter
-from nylas import APIClient
-
-APP_ID = '[YOUR_APP_ID]'
-APP_SECRET = '[YOUR_APP_SECRET]'
-ACCESS_TOKEN = '[YOUR_ACCESS_TOKEN]'
-client = APIClient(APP_ID, APP_SECRET, ACCESS_TOKEN)
-
-counts = {}
-
-for m in client.messages:
- for p in map(lambda x: x['email'], m['from']):
- if p not in counts:
- counts[p] = 0
- counts[p] += 1
-
-most_chatted = sorted(counts.iteritems(), key=itemgetter(1))
-for i in most_chatted:
- print i
diff --git a/examples/notetaker_api_demo/README.md b/examples/notetaker_api_demo/README.md
new file mode 100644
index 00000000..81b5c26c
--- /dev/null
+++ b/examples/notetaker_api_demo/README.md
@@ -0,0 +1,52 @@
+# Notetaker API Demo
+
+This demo showcases how to use the Nylas Notetaker API to create, manage, and interact with notes.
+
+## Features Demonstrated
+
+- Creating new notes
+- Retrieving notes
+- Updating notes
+- Deleting notes
+- Managing note metadata
+- Sharing notes with other users
+
+## Prerequisites
+
+- Python 3.8+
+- Nylas Python SDK (local version from this repository)
+- Nylas API credentials (Client ID and Client Secret)
+
+## Setup
+
+1. Install the SDK in development mode:
+```bash
+# From the root of the nylas-python repository
+pip install -e .
+```
+
+2. Set up your environment variables:
+```bash
+export NYLAS_API_KEY='your_api_key'
+export NYLAS_API_URI='https://api.nylas.com' # Optional, defaults to https://api.nylas.com
+```
+
+## Running the Demo
+
+From the root of the repository:
+```bash
+python examples/notetaker_api_demo/notetaker_demo.py
+```
+
+## Code Examples
+
+The demo includes examples of:
+
+1. Creating a new note
+2. Retrieving a list of notes
+3. Updating an existing note
+4. Deleting a note
+5. Managing note metadata
+6. Sharing notes with other users
+
+Each example is documented with comments explaining the functionality and expected output.
\ No newline at end of file
diff --git a/examples/notetaker_api_demo/notetaker_demo.py b/examples/notetaker_api_demo/notetaker_demo.py
new file mode 100644
index 00000000..e48d8e8a
--- /dev/null
+++ b/examples/notetaker_api_demo/notetaker_demo.py
@@ -0,0 +1,155 @@
+import os
+import sys
+import json
+from nylas import Client
+from nylas.models.notetakers import NotetakerMeetingSettingsRequest, NotetakerState, InviteNotetakerRequest
+from nylas.models.errors import NylasApiError
+
+# Initialize the Nylas client
+nylas = Client(
+ api_key=os.getenv("NYLAS_API_KEY"),
+ api_uri=os.getenv("NYLAS_API_URI", "https://api.us.nylas.com")
+)
+
+def invite_notetaker():
+ """Demonstrates how to invite a Notetaker to a meeting."""
+ print("\n=== Inviting Notetaker to Meeting ===")
+
+ try:
+ meeting_link = os.getenv("MEETING_LINK")
+ if not meeting_link:
+ raise ValueError("MEETING_LINK environment variable is not set. Please set it with your meeting URL.")
+
+ request_body: InviteNotetakerRequest = {
+ "meeting_link": meeting_link,
+ "name": "Nylas Notetaker",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True
+ }
+ }
+
+ print(f"Request body: {json.dumps(request_body, indent=2)}")
+
+ notetaker = nylas.notetakers.invite(request_body=request_body)
+
+ print(f"Invited Notetaker with ID: {notetaker.data.id}")
+ print(f"Name: {notetaker.data.name}")
+ print(f"State: {notetaker.data.state}")
+ return notetaker
+ except NylasApiError as e:
+ print(f"Error inviting notetaker: {str(e)}")
+ print(f"Error details: {e.__dict__}")
+ raise
+ except json.JSONDecodeError as e:
+ print(f"JSON decode error: {str(e)}")
+ raise
+ except Exception as e:
+ print(f"Unexpected error in invite_notetaker: {str(e)}")
+ print(f"Error type: {type(e)}")
+ print(f"Error details: {e.__dict__}")
+ raise
+
+def list_notetakers():
+ """Demonstrates how to list all Notetakers."""
+ print("\n=== Listing All Notetakers ===")
+
+ try:
+ notetakers = nylas.notetakers.list()
+
+ print(f"Found {len(notetakers.data)} notetakers:")
+ for notetaker in notetakers.data:
+ print(f"- {notetaker.name} (ID: {notetaker.id}, State: {notetaker.state})")
+
+ return notetakers
+ except NylasApiError as e:
+ print(f"Error listing notetakers: {str(e)}")
+ raise
+ except Exception as e:
+ print(f"Unexpected error in list_notetakers: {str(e)}")
+ raise
+
+def get_notetaker_media(notetaker_id):
+ """Demonstrates how to get media from a Notetaker."""
+ print("\n=== Getting Notetaker Media ===")
+
+ try:
+ media = nylas.notetakers.get_media(notetaker_id)
+
+ if media.recording:
+ print(f"Recording URL: {media.data.recording.url}")
+ print(f"Recording Name: {media.data.recording.name}")
+ print(f"Recording Type: {media.data.recording.type}")
+ print(f"Recording Size: {media.data.recording.size} bytes")
+ print(f"Recording Created At: {media.data.recording.created_at}")
+ print(f"Recording Expires At: {media.data.recording.expires_at}")
+ print(f"Recording TTL: {media.data.recording.ttl} seconds")
+ if media.transcript:
+ print(f"Transcript URL: {media.data.transcript.url}")
+ print(f"Transcript Name: {media.data.transcript.name}")
+ print(f"Transcript Type: {media.data.transcript.type}")
+ print(f"Transcript Size: {media.data.transcript.size} bytes")
+ print(f"Transcript Created At: {media.data.transcript.created_at}")
+ print(f"Transcript Expires At: {media.data.transcript.expires_at}")
+ print(f"Transcript TTL: {media.data.transcript.ttl} seconds")
+
+ return media
+ except NylasApiError as e:
+ print(f"Error getting notetaker media: {str(e)}")
+ raise
+ except Exception as e:
+ print(f"Unexpected error in get_notetaker_media: {str(e)}")
+ raise
+
+def leave_notetaker(notetaker_id):
+ """Demonstrates how to leave a Notetaker."""
+ print("\n=== Leaving Notetaker ===")
+
+ try:
+ nylas.notetakers.leave(notetaker_id)
+ print(f"Left Notetaker with ID: {notetaker_id}")
+ except NylasApiError as e:
+ print(f"Error leaving notetaker: {str(e)}")
+ raise
+ except Exception as e:
+ print(f"Unexpected error in leave_notetaker: {str(e)}")
+ raise
+
+def main():
+ """Main function to run all demo examples."""
+ try:
+ # Check for required environment variables
+ api_key = os.getenv("NYLAS_API_KEY")
+ if not api_key:
+ raise ValueError("NYLAS_API_KEY environment variable is not set")
+ print(f"Using API key: {api_key[:5]}...")
+
+ # Invite a Notetaker to a meeting
+ notetaker = invite_notetaker()
+
+ # List all Notetakers
+ list_notetakers()
+
+ # Get media from the Notetaker (if available)
+ if notetaker.data.state == NotetakerState.MEDIA_AVAILABLE:
+ get_notetaker_media(notetaker.data.id)
+
+ # Leave the Notetaker
+ leave_notetaker(notetaker.data.id)
+
+ except NylasApiError as e:
+ print(f"\nNylas API Error: {str(e)}")
+ print(f"Error details: {e.__dict__}")
+ sys.exit(1)
+ except ValueError as e:
+ print(f"\nConfiguration Error: {str(e)}")
+ sys.exit(1)
+ except Exception as e:
+ print(f"\nUnexpected Error: {str(e)}")
+ print(f"Error type: {type(e)}")
+ print(f"Error details: {e.__dict__}")
+ sys.exit(1)
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/examples/notetaker_calendar_demo/README.md b/examples/notetaker_calendar_demo/README.md
new file mode 100644
index 00000000..028bf97b
--- /dev/null
+++ b/examples/notetaker_calendar_demo/README.md
@@ -0,0 +1,50 @@
+# Notetaker Calendar Integration Demo
+
+This demo showcases how to use the Nylas Notetaker API in conjunction with calendar and event APIs to create and manage notes associated with calendar events.
+
+## Features Demonstrated
+
+- Creating notes linked to calendar events
+- Retrieving notes associated with events
+- Managing event-related notes
+- Syncing notes with event updates
+- Using note metadata for event organization
+
+## Prerequisites
+
+- Python 3.8+
+- Nylas Python SDK (local version from this repository)
+- Nylas API credentials (Client ID and Client Secret)
+
+## Setup
+
+1. Install the SDK in development mode:
+```bash
+# From the root of the nylas-python repository
+pip install -e .
+```
+
+2. Set up your environment variables:
+```bash
+export NYLAS_API_KEY='your_api_key'
+export NYLAS_API_URI='https://api.nylas.com' # Optional, defaults to https://api.nylas.com
+```
+
+## Running the Demo
+
+From the root of the repository:
+```bash
+python examples/notetaker_calendar_demo/notetaker_calendar_demo.py
+```
+
+## Code Examples
+
+The demo includes examples of:
+
+1. Creating a calendar event with associated notes
+2. Retrieving notes linked to specific events
+3. Updating event notes when the event changes
+4. Managing note metadata for event organization
+5. Syncing notes across multiple events
+
+Each example is documented with comments explaining the functionality and expected output.
\ No newline at end of file
diff --git a/examples/notetaker_calendar_demo/notetaker_calendar_demo.py b/examples/notetaker_calendar_demo/notetaker_calendar_demo.py
new file mode 100644
index 00000000..022f9ded
--- /dev/null
+++ b/examples/notetaker_calendar_demo/notetaker_calendar_demo.py
@@ -0,0 +1,178 @@
+import os
+from datetime import datetime, timedelta
+from typing import Optional
+
+from nylas import Client
+from nylas.models.notetakers import Notetaker
+from nylas.models.events import (
+ UpdateEventRequest,
+ CreateEventRequest,
+ EventNotetakerRequest,
+ EventNotetakerSettings,
+ CreateTimespan,
+ CreateEventQueryParams,
+ UpdateEventQueryParams,
+ CreateAutocreate,
+ CreateEventNotetaker
+)
+
+# Initialize the Nylas client
+nylas = Client(
+ api_key=os.getenv("NYLAS_API_KEY"),
+ api_uri=os.getenv("NYLAS_API_URI", "https://api.us.nylas.com")
+)
+
+def create_event_with_notetaker():
+ """Demonstrates how to create a calendar event with a Notetaker bot."""
+ print("\n=== Creating Event with Notetaker ===")
+
+ # Create the event
+ start_time = datetime.now() + timedelta(days=1)
+ end_time = start_time + timedelta(hours=1)
+
+
+ # Create the request body with proper types
+ request_body = CreateEventRequest(
+ title="Project Planning Meeting",
+ description="Initial project planning and resource allocation",
+ when=CreateTimespan(
+ start_time=int(start_time.timestamp()),
+ end_time=int(end_time.timestamp())
+ ),
+ metadata={
+ "project_id": "PROJ-123",
+ "priority": "high"
+ },
+ conferencing=CreateAutocreate(
+ provider="Google Meet",
+ autocreate={}
+ ),
+ notetaker=CreateEventNotetaker(
+ name="Nylas Notetaker",
+ meeting_settings=EventNotetakerSettings(
+ video_recording=True,
+ audio_recording=True,
+ transcription=True
+ )
+ )
+ )
+
+ # Create the query parameters
+ query_params = CreateEventQueryParams(
+ calendar_id=os.getenv("NYLAS_CALENDAR_ID")
+ )
+
+ event = nylas.events.create(
+ identifier=os.getenv("NYLAS_GRANT_ID"),
+ request_body=request_body,
+ query_params=query_params
+ )
+
+ return event
+
+
+def get_event_notetaker(event_id: str) -> Optional[Notetaker]:
+ """Demonstrates how to retrieve the Notetaker associated with an event."""
+ print("\n=== Retrieving Event Notetaker ===")
+
+ # First get the event to get the Notetaker ID
+ try:
+ event = nylas.events.find(
+ identifier=os.getenv("NYLAS_GRANT_ID"),
+ event_id=event_id,
+ query_params={"calendar_id": os.getenv("NYLAS_CALENDAR_ID")}
+ )
+ except Exception as e:
+ print(f"Error getting event: {e}")
+ return None
+
+ if not event.data.notetaker or not event.data.notetaker.id:
+ print(f"No Notetaker found for event {event_id}")
+ return None
+
+ notetaker = nylas.notetakers.find(notetaker_id=event.data.notetaker.id, identifier=os.getenv("NYLAS_GRANT_ID"))
+ print(f"Found Notetaker for event {event_id}:")
+ print(f"- ID: {notetaker.data.id}")
+ print(f"- State: {notetaker.data.state}")
+ print(f"- Meeting Provider: {notetaker.data.meeting_provider}")
+ print(f"- Meeting Settings:")
+ print(f" - Video Recording: {notetaker.data.meeting_settings.video_recording}")
+ print(f" - Audio Recording: {notetaker.data.meeting_settings.audio_recording}")
+ print(f" - Transcription: {notetaker.data.meeting_settings.transcription}")
+
+ return notetaker
+
+def update_event_and_notetaker(event_id: str, notetaker_id: str):
+ """Demonstrates how to update both an event and its Notetaker."""
+ print("\n=== Updating Event and Notetaker ===")
+
+ # Create the notetaker meeting settings
+ notetaker_settings = EventNotetakerSettings(
+ video_recording=False,
+ audio_recording=True,
+ transcription=False
+ )
+
+ # Create the notetaker request
+ notetaker = EventNotetakerRequest(
+ id=notetaker_id,
+ name="Updated Nylas Notetaker",
+ meeting_settings=notetaker_settings
+ )
+
+ # Create the update request with proper types
+ request_body = UpdateEventRequest(
+ title="Updated Project Planning Meeting",
+ description="Revised project planning with new timeline",
+ metadata={
+ "project_id": "PROJ-123",
+ "priority": "urgent"
+ },
+ notetaker=notetaker
+ )
+
+ # Create the query parameters
+ query_params = UpdateEventQueryParams(
+ calendar_id=os.getenv("NYLAS_CALENDAR_ID")
+ )
+
+ updated_event = nylas.events.update(
+ identifier=os.getenv("NYLAS_GRANT_ID"),
+ event_id=event_id,
+ request_body=request_body,
+ query_params=query_params
+ )
+
+ return updated_event
+
+def main():
+ """Main function to run all demo examples."""
+ try:
+ # Create an event with a Notetaker
+ event = create_event_with_notetaker()
+ if not event:
+ print("Failed to create event")
+ return
+
+ print(f"Created event with ID: {event.data.id}")
+ print(f"Event Notetaker ID: {event.data.notetaker.id}")
+
+ # Get the Notetaker for the event
+ notetaker = get_event_notetaker(event.data.id)
+ if not notetaker:
+ print(f"Failed to get Notetaker for event {event.data.id}")
+ return
+
+ # Update both the event and its Notetaker
+ updated_event = update_event_and_notetaker(event.data.id, notetaker.data.id)
+ if not updated_event:
+ print(f"Failed to update event {event.data.id}")
+ return
+
+ print(f"Updated event with ID: {updated_event.data.id}")
+
+ except Exception as e:
+ print(f"An error occurred: {str(e)}")
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/examples/provider_error_demo/README.md b/examples/provider_error_demo/README.md
new file mode 100644
index 00000000..930a7236
--- /dev/null
+++ b/examples/provider_error_demo/README.md
@@ -0,0 +1,72 @@
+# Provider Error Handling Example
+
+This example demonstrates how to properly handle provider errors when working with the Nylas API. It specifically shows how to catch and process errors that occur when trying to access a non-existent calendar.
+
+## Features
+
+- Demonstrates proper error handling for Nylas API provider errors
+- Shows how to access error details including:
+ - Error message
+ - Error type
+ - Provider error message
+ - Request ID
+ - Status code
+- Includes clear output and status messages
+
+## Prerequisites
+
+1. A Nylas account with API access
+2. Python 3.x installed
+3. Local installation of the Nylas Python SDK (this repository)
+
+## Setup
+
+1. Install the SDK in development mode from the repository root:
+```bash
+cd /path/to/nylas-python
+pip install -e .
+```
+
+2. Set your environment variables:
+```bash
+export NYLAS_API_KEY="your_api_key"
+export NYLAS_GRANT_ID="your_grant_id"
+```
+
+3. Run the example from the repository root:
+```bash
+python examples/provider_error_demo/provider_error_example.py
+```
+
+## Example Output
+
+```
+Demonstrating Provider Error Handling
+====================================
+
+Attempting to fetch events from non-existent calendar: non-existent-calendar-123
+------------------------------------------------------------------
+
+Caught NylasApiError:
+✗ Error Message: Calendar not found
+✗ Error Type: invalid_request_error
+✗ Provider Error: The calendar ID provided does not exist
+✗ Request ID: req-abc-123
+✗ Status Code: 404
+
+Example completed!
+```
+
+## Error Handling
+
+The example demonstrates how to handle:
+- Missing environment variables
+- API authentication errors
+- Provider-specific errors
+- Non-existent resource errors
+
+## Documentation
+
+For more information about the Nylas Python SDK and its features, visit:
+- [Nylas Python SDK Documentation](https://developer.nylas.com/docs/sdks/python/)
+- [Nylas API Reference](https://developer.nylas.com/docs/api/)
\ No newline at end of file
diff --git a/examples/provider_error_demo/provider_error_example.py b/examples/provider_error_demo/provider_error_example.py
new file mode 100644
index 00000000..3289a485
--- /dev/null
+++ b/examples/provider_error_demo/provider_error_example.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""
+Nylas SDK Example: Handling Provider Errors
+
+This example demonstrates how to handle provider errors when working with the Nylas API,
+specifically when trying to access a non-existent calendar.
+
+Required Environment Variables:
+ NYLAS_API_KEY: Your Nylas API key
+ NYLAS_GRANT_ID: Your Nylas grant ID
+
+Usage:
+ First, install the SDK in development mode:
+ cd /path/to/nylas-python
+ pip install -e .
+
+ Then set environment variables and run:
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ python examples/provider_error_demo/provider_error_example.py
+"""
+
+import os
+import sys
+from typing import Optional
+
+from nylas import Client
+from nylas.models.errors import NylasApiError
+
+
+def get_env_or_exit(var_name: str) -> str:
+ """Get an environment variable or exit if not found."""
+ value = os.getenv(var_name)
+ if not value:
+ print(f"Error: {var_name} environment variable is required")
+ sys.exit(1)
+ return value
+
+
+def demonstrate_provider_error(client: Client, grant_id: str) -> None:
+ """Demonstrate how to handle provider errors."""
+ # Use a non-existent calendar ID to trigger a provider error
+ non_existent_calendar_id = "non-existent-calendar-123"
+
+ try:
+ print(f"\nAttempting to fetch events from non-existent calendar: {non_existent_calendar_id}")
+ print("------------------------------------------------------------------")
+
+ # Attempt to list events with the invalid calendar ID
+ events, request_id = client.events.list(
+ identifier=grant_id,
+ query_params={"calendar_id": non_existent_calendar_id}
+ )
+
+ # Note: We won't reach this code due to the error
+ print("Events retrieved:", events)
+
+ except NylasApiError as e:
+ print("\nCaught NylasApiError:")
+ print(f"✗ Error Type: {e.type}")
+ print(f"✗ Provider Error: {e.provider_error}")
+ print(f"✗ Request ID: {e.request_id}")
+ print(f"✗ Status Code: {e.status_code}")
+
+
+def main():
+ """Main function demonstrating provider error handling."""
+ # Get required environment variables
+ api_key = get_env_or_exit("NYLAS_API_KEY")
+ grant_id = get_env_or_exit("NYLAS_GRANT_ID")
+
+ # Initialize Nylas client
+ client = Client(
+ api_key=api_key,
+ )
+
+ print("\nDemonstrating Provider Error Handling")
+ print("====================================")
+
+ # Demonstrate provider error handling
+ demonstrate_provider_error(client, grant_id)
+
+ print("\nExample completed!")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/examples/response_headers_demo/README.md b/examples/response_headers_demo/README.md
new file mode 100644
index 00000000..8ba6959d
--- /dev/null
+++ b/examples/response_headers_demo/README.md
@@ -0,0 +1,107 @@
+# Response Headers Demo
+
+This example demonstrates how to access and use response headers from various Nylas API responses. It shows how headers are available in different types of responses:
+
+1. List responses (from methods like `list()`)
+2. Single-item responses (from methods like `find()`)
+3. Error responses (when API calls fail)
+
+## What You'll Learn
+
+- How to access response headers from successful API calls
+- How to access headers from error responses
+- Common headers you'll encounter in Nylas API responses
+- How headers differ between list and single-item responses
+
+## Headers Demonstrated
+
+The example will show various headers that Nylas includes in responses, such as:
+
+- `request-id`: Unique identifier for the API request
+- `x-ratelimit-limit`: Your rate limit for the endpoint
+- `x-ratelimit-remaining`: Remaining requests within the current window
+- `x-ratelimit-reset`: When the rate limit window resets
+- And more...
+
+## Prerequisites
+
+Before running this example, make sure you have:
+
+1. A Nylas API key
+2. A Nylas grant ID
+3. Python 3.7 or later installed
+4. The Nylas Python SDK installed
+
+## Setup
+
+1. First, install the SDK in development mode:
+ ```bash
+ cd /path/to/nylas-python
+ pip install -e .
+ ```
+
+2. Set up your environment variables:
+ ```bash
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ ```
+
+## Running the Example
+
+Run the example with:
+```bash
+python examples/response_headers_demo/response_headers_example.py
+```
+
+The script will:
+1. Demonstrate headers from a list response by fetching messages
+2. Show headers from a single-item response by fetching one message
+3. Trigger and catch an error to show error response headers
+
+## Example Output
+
+You'll see output similar to this:
+
+```
+Demonstrating Response Headers
+============================
+
+Demonstrating List Response Headers
+----------------------------------
+✓ Successfully retrieved messages
+
+Response Headers:
+------------------------
+request-id: req_abcd1234
+x-ratelimit-limit: 1000
+x-ratelimit-remaining: 999
+...
+
+Demonstrating Find Response Headers
+----------------------------------
+✓ Successfully retrieved single message
+
+Response Headers:
+------------------------
+request-id: req_efgh5678
+...
+
+Demonstrating Error Response Headers
+---------------------------------
+✓ Successfully caught expected error
+✗ Error Type: invalid_request
+✗ Request ID: req_ijkl9012
+✗ Status Code: 404
+
+Error Response Headers:
+------------------------
+request-id: req_ijkl9012
+...
+```
+
+## Error Handling
+
+The example includes proper error handling and will show you how to:
+- Catch `NylasApiError` exceptions
+- Access error details and headers
+- Handle different types of API errors gracefully
\ No newline at end of file
diff --git a/examples/response_headers_demo/response_headers_example.py b/examples/response_headers_demo/response_headers_example.py
new file mode 100644
index 00000000..d93d8b04
--- /dev/null
+++ b/examples/response_headers_demo/response_headers_example.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+"""
+Nylas SDK Example: Response Headers Demo
+
+This example demonstrates how to access and use response headers from various Nylas API
+responses, including successful responses and error cases.
+
+Required Environment Variables:
+ NYLAS_API_KEY: Your Nylas API key
+ NYLAS_GRANT_ID: Your Nylas grant ID
+
+Usage:
+ First, install the SDK in development mode:
+ cd /path/to/nylas-python
+ pip install -e .
+
+ Then set environment variables and run:
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ python examples/response_headers_demo/response_headers_example.py
+"""
+
+import os
+import sys
+from typing import Optional
+
+from nylas import Client
+from nylas.models.errors import NylasApiError
+
+
+def get_env_or_exit(var_name: str) -> str:
+ """Get an environment variable or exit if not found."""
+ value = os.getenv(var_name)
+ if not value:
+ print(f"Error: {var_name} environment variable is required")
+ sys.exit(1)
+ return value
+
+
+def print_response_headers(headers: dict, prefix: str = "") -> None:
+ """Helper function to print response headers."""
+ print(f"\n{prefix} Response Headers:")
+ print("------------------------")
+ for key, value in headers.items():
+ print(f"{key}: {value}")
+
+
+def demonstrate_list_response_headers(client: Client, grant_id: str) -> None:
+ """Demonstrate headers in list responses."""
+ print("\nDemonstrating List Response Headers")
+ print("----------------------------------")
+
+ try:
+ # List messages to get a ListResponse
+ messages = client.messages.list(identifier=grant_id)
+
+ print("✓ Successfully retrieved messages")
+ print_response_headers(messages.headers)
+ print(f"Total messages count: {len(messages.data)}")
+
+ except NylasApiError as e:
+ print("\nError occurred while listing messages:")
+ print(f"✗ Error Type: {e.type}")
+ print(f"✗ Provider Error: {e.provider_error}")
+ print(f"✗ Request ID: {e.request_id}")
+ print_response_headers(e.headers, "Error")
+
+
+def demonstrate_list_response_headers_with_pagination(client: Client, grant_id: str) -> None:
+ """Demonstrate headers in list responses with pagination."""
+ print("\nDemonstrating List Response Headers with Pagination")
+ print("--------------------------------------------------")
+
+ try:
+ # List messages to get a ListResponse
+ threads = client.threads.list(identifier=grant_id, query_params={"limit": 1})
+
+ print("✓ Successfully retrieved threads")
+ print_response_headers(threads.headers)
+ print(f"Total threads count: {len(threads.data)}")
+
+ except NylasApiError as e:
+ print("\nError occurred while listing threads:")
+ print(f"✗ Error Type: {e.type}")
+ print(f"✗ Provider Error: {e.provider_error}")
+ print(f"✗ Request ID: {e.request_id}")
+ print_response_headers(e.headers, "Error")
+
+
+def demonstrate_find_response_headers(client: Client, grant_id: str) -> None:
+ """Demonstrate headers in find/single-item responses."""
+ print("\nDemonstrating Find Response Headers")
+ print("----------------------------------")
+
+ try:
+ # Get the first message to demonstrate single-item response
+ messages = client.messages.list(identifier=grant_id)
+ if not messages.data:
+ print("No messages found to demonstrate find response")
+ return
+
+ message_id = messages.data[0].id
+ message = client.messages.find(identifier=grant_id, message_id=message_id)
+
+ print("✓ Successfully retrieved single message")
+ print_response_headers(message.headers)
+
+ except NylasApiError as e:
+ print("\nError occurred while finding message:")
+ print(f"✗ Error Type: {e.type}")
+ print(f"✗ Provider Error: {e.provider_error}")
+ print(f"✗ Request ID: {e.request_id}")
+ print_response_headers(e.headers, "Error")
+
+
+def demonstrate_error_response_headers(client: Client, grant_id: str) -> None:
+ """Demonstrate headers in error responses."""
+ print("\nDemonstrating Error Response Headers")
+ print("---------------------------------")
+
+ try:
+ # Attempt to find a non-existent message
+ message = client.messages.find(
+ identifier=grant_id,
+ message_id="non-existent-id-123"
+ )
+
+ except NylasApiError as e:
+ print("✓ Successfully caught expected error")
+ print(f"✗ Error Type: {e.type}")
+ print(f"✗ Provider Error: {e.provider_error}")
+ print(f"✗ Request ID: {e.request_id}")
+ print(f"✗ Status Code: {e.status_code}")
+ print_response_headers(e.headers, "Error")
+
+
+def main():
+ """Main function demonstrating response headers."""
+ # Get required environment variables
+ api_key = get_env_or_exit("NYLAS_API_KEY")
+ grant_id = get_env_or_exit("NYLAS_GRANT_ID")
+
+ # Initialize Nylas client
+ client = Client(
+ api_key=api_key,
+ )
+
+ print("\nDemonstrating Response Headers")
+ print("============================")
+
+ # Demonstrate different types of responses and their headers
+ demonstrate_list_response_headers(client, grant_id)
+ demonstrate_list_response_headers_with_pagination(client, grant_id)
+ demonstrate_find_response_headers(client, grant_id)
+ demonstrate_error_response_headers(client, grant_id)
+
+ print("\nExample completed!")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/examples/select_param_demo/README.md b/examples/select_param_demo/README.md
new file mode 100644
index 00000000..8277ae58
--- /dev/null
+++ b/examples/select_param_demo/README.md
@@ -0,0 +1,58 @@
+# Select Parameter Demo
+
+This example demonstrates the usage of the `select` query parameter across different Nylas resources. The `select` parameter allows you to specify which fields you want to receive in the API response, helping to optimize your API calls by reducing the amount of data transferred.
+
+## Features Demonstrated
+
+1. **Backwards Compatibility**: Shows that existing code that doesn't use the `select` parameter continues to work as expected, receiving all fields.
+2. **Field Selection**: Demonstrates how to use the `select` parameter to request only specific fields for better performance.
+3. **Multiple Resources**: Shows the `select` parameter working across different resources:
+ - Messages
+ - Calendars
+ - Events
+ - Drafts
+ - Contacts
+
+## Setup
+
+1. Create a `.env` file in the root directory with your Nylas API credentials:
+ ```
+ NYLAS_API_KEY=your_api_key_here
+ ```
+
+2. Install the required dependencies:
+ ```bash
+ pip install nylas python-dotenv
+ ```
+
+## Running the Example
+
+Run the example script:
+```bash
+python select_param_example.py
+```
+
+The script will demonstrate both the traditional way of fetching all fields and the new selective field fetching for each resource type.
+
+## Example Output
+
+The script will show output similar to this for each resource:
+```
+=== Messages Resource ===
+
+Fetching messages (all fields):
+Full message - Subject: Example Subject, ID: abc123...
+
+Fetching messages with select (only id and subject):
+Minimal message - Subject: Example Subject, ID: abc123...
+```
+
+## Benefits of Using Select
+
+1. **Reduced Data Transfer**: By selecting only the fields you need, you reduce the amount of data transferred over the network.
+2. **Improved Performance**: Smaller payloads mean faster API responses and less processing time.
+3. **Bandwidth Optimization**: Especially useful in mobile applications or when dealing with limited bandwidth.
+
+## Available Fields
+
+The fields available for selection vary by resource type. Refer to the [Nylas API documentation](https://developer.nylas.com/) for a complete list of available fields for each resource type.
\ No newline at end of file
diff --git a/examples/select_param_demo/select_param_example.py b/examples/select_param_demo/select_param_example.py
new file mode 100644
index 00000000..76ad8b6b
--- /dev/null
+++ b/examples/select_param_demo/select_param_example.py
@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+"""
+Nylas SDK Example: Using Select Parameters
+
+This example demonstrates how to use the 'select' query parameter across different Nylas resources
+to optimize API response size and performance by requesting only specific fields.
+
+Required Environment Variables:
+ NYLAS_API_KEY: Your Nylas API key
+ NYLAS_GRANT_ID: Your Nylas grant ID
+
+Usage:
+ First, install the SDK in development mode:
+ cd /path/to/nylas-python
+ pip install -e .
+
+ Then set environment variables and run:
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ python examples/select_param_demo/select_param_example.py
+"""
+
+import os
+import sys
+import json
+from nylas import Client
+
+
+def get_env_or_exit(var_name: str) -> str:
+ """Get an environment variable or exit if not found."""
+ value = os.getenv(var_name)
+ if not value:
+ print(f"Error: {var_name} environment variable is required")
+ sys.exit(1)
+ return value
+
+
+def print_data(data: list, title: str) -> None:
+ """Pretty print the data with a title."""
+ print(f"\n{title}:")
+ for item in data:
+ # Convert to dict and pretty print
+ item_dict = item.to_dict()
+ print(json.dumps(item_dict, indent=2))
+
+
+def demonstrate_messages(client: Client, grant_id: str) -> None:
+ """Demonstrate select parameter usage with Messages resource."""
+ print("\n=== Messages Resource ===")
+
+ # Backwards compatibility - fetch all fields
+ print("\nFetching messages (all fields):")
+ messages = client.messages.list(identifier=grant_id, query_params={"limit": 2})
+ print_data(messages.data, "Full message data (all fields)")
+
+ # Using select parameter - fetch only specific fields
+ print("\nFetching messages with select (only id and subject):")
+ messages = client.messages.list(
+ identifier=grant_id,
+ query_params={"limit": 2, "select": "id,subject"}
+ )
+ print_data(messages.data, "Minimal message data (only selected fields)")
+
+
+def demonstrate_calendars(client: Client, grant_id: str) -> None:
+ """Demonstrate select parameter usage with Calendars resource."""
+ print("\n=== Calendars Resource ===")
+
+ # Backwards compatibility - fetch all fields
+ print("\nFetching calendars (all fields):")
+ calendars = client.calendars.list(identifier=grant_id, query_params={"limit": 2})
+ print_data(calendars.data, "Full calendar data (all fields)")
+
+ # Using select parameter - fetch only specific fields
+ print("\nFetching calendars with select (only id and name):")
+ calendars = client.calendars.list(
+ identifier=grant_id,
+ query_params={"limit": 2, "select": "id,name"}
+ )
+ print_data(calendars.data, "Minimal calendar data (only selected fields)")
+
+
+def demonstrate_events(client: Client, grant_id: str) -> None:
+ """Demonstrate select parameter usage with Events resource."""
+ print("\n=== Events Resource ===")
+
+ # First, get a calendar ID
+ print("\nFetching first calendar to use for events...")
+ calendars = client.calendars.list(identifier=grant_id, query_params={"limit": 1})
+ if not calendars.data:
+ print("No calendars found. Skipping events demonstration.")
+ return
+
+ calendar_id = calendars.data[0].id
+ print(f"Using calendar: {calendars.data[0].name} (ID: {calendar_id})")
+
+ # Backwards compatibility - fetch all fields
+ print("\nFetching events (all fields):")
+ events = client.events.list(
+ identifier=grant_id,
+ query_params={"limit": 2, "calendar_id": calendar_id}
+ )
+ print_data(events.data, "Full event data (all fields)")
+
+ # Using select parameter - fetch only specific fields
+ print("\nFetching events with select (only id and title):")
+ events = client.events.list(
+ identifier=grant_id,
+ query_params={
+ "limit": 2,
+ "calendar_id": calendar_id,
+ "select": "id,title"
+ }
+ )
+ print_data(events.data, "Minimal event data (only selected fields)")
+
+
+def demonstrate_drafts(client: Client, grant_id: str) -> None:
+ """Demonstrate select parameter usage with Drafts resource."""
+ print("\n=== Drafts Resource ===")
+
+ # Backwards compatibility - fetch all fields
+ print("\nFetching drafts (all fields):")
+ drafts = client.drafts.list(identifier=grant_id, query_params={"limit": 2})
+ print_data(drafts.data, "Full draft data (all fields)")
+
+ # Using select parameter - fetch only specific fields
+ print("\nFetching drafts with select (only id and subject):")
+ drafts = client.drafts.list(
+ identifier=grant_id,
+ query_params={"limit": 2, "select": "id,subject"}
+ )
+ print_data(drafts.data, "Minimal draft data (only selected fields)")
+
+
+def demonstrate_contacts(client: Client, grant_id: str) -> None:
+ """Demonstrate select parameter usage with Contacts resource."""
+ print("\n=== Contacts Resource ===")
+
+ # Backwards compatibility - fetch all fields
+ print("\nFetching contacts (all fields):")
+ contacts = client.contacts.list(identifier=grant_id, query_params={"limit": 2})
+ print_data(contacts.data, "Full contact data (all fields)")
+
+ # Using select parameter - fetch only specific fields
+ print("\nFetching contacts with select (only id, grant_id, and given_name):")
+ contacts = client.contacts.list(
+ identifier=grant_id,
+ query_params={"limit": 2, "select": "id,grant_id,given_name"}
+ )
+ print_data(contacts.data, "Minimal contact data (only selected fields)")
+
+
+def main():
+ """Main function demonstrating select parameter usage across resources."""
+ # Get required environment variables
+ api_key = get_env_or_exit("NYLAS_API_KEY")
+ grant_id = get_env_or_exit("NYLAS_GRANT_ID")
+
+ # Initialize Nylas client
+ client = Client(
+ api_key=api_key,
+ )
+
+ print("\nDemonstrating Select Parameter Usage")
+ print("===================================")
+ print("This shows both backwards compatibility and selective field fetching")
+
+ # Demonstrate select parameter across different resources
+ demonstrate_messages(client, grant_id)
+ demonstrate_calendars(client, grant_id)
+ demonstrate_events(client, grant_id)
+ demonstrate_drafts(client, grant_id)
+ demonstrate_contacts(client, grant_id)
+
+ print("\nExample completed!")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/examples/send_email_demo/README.md b/examples/send_email_demo/README.md
new file mode 100644
index 00000000..fa5edfe4
--- /dev/null
+++ b/examples/send_email_demo/README.md
@@ -0,0 +1,77 @@
+# Send Email Example
+
+This example demonstrates how to send an email with special characters (accented letters) in the subject line using the Nylas Python SDK.
+
+## Overview
+
+The example sends an email with the subject **"De l'idée à la post-prod, sans friction"** to demonstrate proper handling of UTF-8 characters in email subjects.
+
+## Prerequisites
+
+- Python 3.8 or higher
+- Nylas Python SDK installed
+- Nylas API key
+- Nylas grant ID
+- Email address for testing
+
+## Setup
+
+1. Install the SDK in development mode:
+ ```bash
+ cd /path/to/nylas-python
+ pip install -e .
+ ```
+
+2. Set the required environment variables:
+ ```bash
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ export RECIPIENT_EMAIL="recipient@example.com"
+ ```
+
+## Running the Example
+
+```bash
+python examples/send_email_demo/send_email_example.py
+```
+
+## What This Example Demonstrates
+
+- Sending an email with special characters (accented letters) in the subject
+- Proper UTF-8 encoding of email subjects
+- Using the `messages.send()` method to send emails directly
+
+## Expected Output
+
+```
+============================================================
+ Nylas SDK: Send Email with Special Characters Example
+============================================================
+
+This example sends an email with the subject:
+ "De l'idée à la post-prod, sans friction"
+
+Grant ID: your_grant_id
+Recipient: recipient@example.com
+
+Sending email...
+ To: recipient@example.com
+ Subject: De l'idée à la post-prod, sans friction
+
+✓ Email sent successfully!
+ Message ID: message-id-here
+ Subject: De l'idée à la post-prod, sans friction
+
+✅ Special characters in subject are correctly preserved
+
+============================================================
+Example completed successfully! ✅
+============================================================
+```
+
+## Notes
+
+- The SDK properly handles UTF-8 characters in email subjects and bodies
+- Special characters like é, à, and other accented letters are preserved correctly
+- The email will be delivered with the subject exactly as specified
+
diff --git a/examples/send_email_demo/send_email_example.py b/examples/send_email_demo/send_email_example.py
new file mode 100644
index 00000000..6edf46dc
--- /dev/null
+++ b/examples/send_email_demo/send_email_example.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""
+Nylas SDK Example: Send Email with Special Characters
+
+This example demonstrates how to send an email with special characters
+(accented letters) in the subject line using the Nylas Python SDK.
+
+The example sends an email with the subject "De l'idée à la post-prod, sans friction"
+to demonstrate proper handling of UTF-8 characters in email subjects.
+
+Required Environment Variables:
+ NYLAS_API_KEY: Your Nylas API key
+ NYLAS_GRANT_ID: Your Nylas grant ID
+ RECIPIENT_EMAIL: Email address to send the message to
+
+Usage:
+ First, install the SDK in development mode:
+ cd /path/to/nylas-python
+ pip install -e .
+
+ Then set environment variables and run:
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ export RECIPIENT_EMAIL="recipient@example.com"
+ python examples/send_email_demo/send_email_example.py
+"""
+
+import os
+import sys
+from nylas import Client
+
+
+def get_env_or_exit(var_name: str) -> str:
+ """Get an environment variable or exit if not found."""
+ value = os.getenv(var_name)
+ if not value:
+ print(f"Error: {var_name} environment variable is required")
+ sys.exit(1)
+ return value
+
+
+def send_email(client: Client, grant_id: str, recipient: str) -> None:
+ """Send an email with special characters in the subject."""
+ # Subject with special characters (accented letters)
+ subject = "De l'idée à la post-prod, sans friction"
+
+ body = """
+
+
+ Bonjour!
+ Ce message démontre l'envoi d'un email avec des caractères spéciaux dans le sujet.
+ Le sujet de cet email est: De l'idée à la post-prod, sans friction
+ Les caractères accentués sont correctement préservés grâce à l'encodage UTF-8.
+
+
+ """
+
+ print(f"Sending email...")
+ print(f" To: {recipient}")
+ print(f" Subject: {subject}")
+
+ try:
+ response = client.messages.send(
+ identifier=grant_id,
+ request_body={
+ "subject": subject,
+ "to": [{"email": recipient}],
+ "body": body,
+ }
+ )
+
+ print(f"\n✓ Email sent successfully!")
+ print(f" Message ID: {response.data.id}")
+ print(f" Subject: {response.data.subject}")
+ print(f"\n✅ Special characters in subject are correctly preserved")
+
+ except Exception as e:
+ print(f"\n❌ Error sending email: {e}")
+ sys.exit(1)
+
+
+def main():
+ """Main function."""
+ # Get required environment variables
+ api_key = get_env_or_exit("NYLAS_API_KEY")
+ grant_id = get_env_or_exit("NYLAS_GRANT_ID")
+ recipient = get_env_or_exit("RECIPIENT_EMAIL")
+
+ # Initialize Nylas client
+ client = Client(api_key=api_key)
+
+ print("=" * 60)
+ print(" Nylas SDK: Send Email with Special Characters Example")
+ print("=" * 60)
+ print()
+ print("This example sends an email with the subject:")
+ print(' "De l\'idée à la post-prod, sans friction"')
+ print()
+ print(f"Grant ID: {grant_id}")
+ print(f"Recipient: {recipient}")
+ print()
+
+ # Send the email
+ send_email(client, grant_id, recipient)
+
+ print("\n" + "=" * 60)
+ print("Example completed successfully! ✅")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/examples/server.py b/examples/server.py
deleted file mode 100755
index 493a6e96..00000000
--- a/examples/server.py
+++ /dev/null
@@ -1,102 +0,0 @@
-#!/usr/bin/env python
-#
-# This demo app shows how to use the Nylas client to authenticate against
-# the Nylas API and how to fetch emails from an authenticated account.
-#
-# NOTE: This app does NOT use SSL. Before deploying this code to a
-# server environment, you should ENABLE SSL to avoid exposing your API
-# access token in plaintext.
-#
-# To run this demo app:
-# 1. Save this file to your computer as `server.py`
-#
-# 2. In the Nylas Developer Portal, create a new application. Replace the
-# APP_ID and APP_SECRET variables below with the App ID and App
-# Secret of your application.
-# https://nylas.com/
-#
-# 3. In the Nylas Developer Portal, edit your application and add the
-# callback URL: http://localhost:8888/login_callback
-#
-# 4. On the command line, `cd` to the folder where you saved the file
-#
-# 5. On the command line, run `python ./server.py`
-# - You may need to install Python: https://www.python.org/download/
-# - You may need to install dependencies using pip:
-# (http://pip.readthedocs.org/en/latest/installing.html)
-# pip install nylas flask requests
-# - Note: You may want to set up a virtualenv to isolate these
-# dependencies from other packages on your system. Otherwise, you
-# will need to sudo pip install, to install them globally.
-# http://docs.python-guide.org/en/latest/dev/virtualenvs/
-#
-# 6. In the browser, visit http://localhost:8888/
-#
-
-import time
-from flask import Flask, url_for, session, request, redirect, Response
-
-from nylas import APIClient
-
-APP_ID = 'YOUR_APP_ID'
-APP_SECRET = 'YOUR_APP_SECRET'
-
-app = Flask(__name__)
-app.debug = True
-app.secret_key = 'secret'
-
-assert APP_ID != 'YOUR_APP_ID' or APP_SECRET != 'YOUR_APP_SECRET',\
- "You should change the value of APP_ID and APP_SECRET"
-
-
-@app.route('/')
-def index():
- # If we have an access_token, we may interact with the Nylas Server
- if 'access_token' in session:
- client = APIClient(APP_ID, APP_SECRET, session['access_token'])
- message = None
- while not message:
- try:
- # Get the latest message from namespace zero.
- message = client.messages.first()
- if not message: # A new account takes a little time to sync
- print "No messages yet. Checking again in 2 seconds."
- time.sleep(2)
- except Exception as e:
- print(e.message)
- return Response("An error occurred.")
- # Format the output
- text = "Here's a message from your inbox:
From: "
- for sender in message["from"]:
- text += "{} <{}>".format(sender['name'], sender['email'])
- text += "
Subject: " + message.subject
- text += "
Body: " + message.body
- text += ""
-
- # Return result to the client
- return Response(text)
- else:
- # We don't have an access token, so we're going to use OAuth to
- # authenticate the user
-
- # Ask flask to generate the url corresponding to the login_callback
- # route. This is similar to using reverse() in django.
- redirect_uri = url_for('.login_callback', _external=True)
-
- client = APIClient(APP_ID, APP_SECRET)
- return redirect(client.authentication_url(redirect_uri))
-
-
-@app.route('/login_callback')
-def login_callback():
- if 'error' in request.args:
- return "Login error: {0}".format(request.args['error'])
-
- # Exchange the authorization code for an access token
- client = APIClient(APP_ID, APP_SECRET)
- code = request.args.get('code')
- session['access_token'] = client.token_for_code(code)
- return index()
-
-if __name__ == '__main__':
- app.run(host='0.0.0.0', port=8888)
diff --git a/examples/special_characters_demo/README.md b/examples/special_characters_demo/README.md
new file mode 100644
index 00000000..bbbdce9d
--- /dev/null
+++ b/examples/special_characters_demo/README.md
@@ -0,0 +1,121 @@
+# Special Characters Encoding Example
+
+This example demonstrates how the Nylas Python SDK correctly handles special characters (accented letters, unicode characters) in email subjects and message bodies.
+
+## The Problem
+
+Previously, when sending emails with large attachments (>3MB), special characters in the subject line would be incorrectly encoded. For example:
+
+- **Intended Subject:** "De l'idée à la post-prod, sans friction"
+- **What Recipients Saw:** "De l’idée à la post-prod, sans friction"
+
+This issue occurred because the SDK was using `json.dumps()` with the default `ensure_ascii=True` parameter when creating multipart/form-data requests for large attachments.
+
+## The Solution
+
+The SDK now uses `json.dumps(request_body, ensure_ascii=False)` to preserve UTF-8 characters correctly in the JSON payload, ensuring that special characters are displayed properly in recipient inboxes.
+
+## What This Example Demonstrates
+
+1. **Small Messages** - Sending messages with special characters (no attachments)
+2. **Large Messages** - Sending messages with special characters AND large attachments (>3MB)
+3. **Drafts** - Creating drafts with special characters
+4. **International Support** - Handling various international character sets
+
+## Usage
+
+### Prerequisites
+
+1. Install the SDK in development mode:
+ ```bash
+ cd /path/to/nylas-python
+ pip install -e .
+ ```
+
+2. Set up environment variables:
+ ```bash
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ export RECIPIENT_EMAIL="recipient@example.com"
+ ```
+
+### Run the Example
+
+```bash
+python examples/special_characters_demo/special_characters_example.py
+```
+
+## Test Coverage
+
+This fix is covered by comprehensive tests:
+
+```bash
+# Test the core fix in file_utils
+pytest tests/utils/test_file_utils.py::TestFileUtils::test_build_form_request_with_special_characters
+
+# Test message sending with special characters
+pytest tests/resources/test_messages.py::TestMessage::test_send_message_with_special_characters_in_subject
+pytest tests/resources/test_messages.py::TestMessage::test_send_message_with_special_characters_large_attachment
+
+# Test draft creation with special characters
+pytest tests/resources/test_drafts.py::TestDraft::test_create_draft_with_special_characters_in_subject
+pytest tests/resources/test_drafts.py::TestDraft::test_create_draft_with_special_characters_large_attachment
+```
+
+## Supported Character Sets
+
+The SDK correctly handles:
+
+- **French:** é, è, ê, à, ù, ç, œ
+- **Spanish:** ñ, á, í, ó, ú, ¿, ¡
+- **German:** ä, ö, ü, ß
+- **Portuguese:** ã, õ, â, ê
+- **Italian:** à, è, é, ì, ò, ù
+- **Russian:** Cyrillic characters
+- **Japanese:** Hiragana, Katakana, Kanji
+- **Chinese:** Simplified and Traditional characters
+- **Emoji:** 🎉 🎊 🥳 and many more
+- **Special symbols:** €, £, ¥, ©, ®, ™
+
+## Technical Details
+
+### The Bug
+
+When using multipart/form-data encoding (for large attachments), the message payload was serialized as:
+
+```python
+message_payload = json.dumps(request_body) # Default: ensure_ascii=True
+```
+
+This caused special characters to be escaped as unicode sequences:
+```json
+{"subject": "De l\u2019id\u00e9e"}
+```
+
+### The Fix
+
+The payload is now serialized as:
+
+```python
+message_payload = json.dumps(request_body, ensure_ascii=False)
+```
+
+This preserves the actual UTF-8 characters:
+```json
+{"subject": "De l'idée"}
+```
+
+The multipart/form-data Content-Type header correctly specifies UTF-8 encoding, ensuring email clients display the characters properly.
+
+## Related Files
+
+- **Core Fix:** `nylas/utils/file_utils.py` - Line 70
+- **Tests:** `tests/utils/test_file_utils.py`, `tests/resources/test_messages.py`, `tests/resources/test_drafts.py`
+- **Example:** `examples/special_characters_demo/special_characters_example.py`
+
+## Impact
+
+✅ **Before Fix:** Special characters in subjects were garbled when sending emails with large attachments
+✅ **After Fix:** All special characters are correctly preserved and displayed
+
+The fix ensures backwards compatibility - all existing code continues to work without changes.
diff --git a/examples/special_characters_demo/special_characters_example.py b/examples/special_characters_demo/special_characters_example.py
new file mode 100755
index 00000000..e112e332
--- /dev/null
+++ b/examples/special_characters_demo/special_characters_example.py
@@ -0,0 +1,325 @@
+#!/usr/bin/env python3
+"""
+Nylas SDK Example: Handling Special Characters in Email Subjects and Bodies
+
+This example demonstrates proper handling of special characters (accented letters,
+unicode characters) in email subjects and message bodies, particularly when sending
+messages with large attachments.
+
+The SDK now correctly preserves UTF-8 characters in email subjects and bodies,
+preventing encoding issues like "De l'idée à la post-prod" becoming
+"De l’idée àla post-prod".
+
+Required Environment Variables:
+ NYLAS_API_KEY: Your Nylas API key
+ NYLAS_GRANT_ID: Your Nylas grant ID
+ RECIPIENT_EMAIL: Email address to send test messages to
+
+Usage:
+ First, install the SDK in development mode:
+ cd /path/to/nylas-python
+ pip install -e .
+
+ Then set environment variables and run:
+ export NYLAS_API_KEY="your_api_key"
+ export NYLAS_GRANT_ID="your_grant_id"
+ export RECIPIENT_EMAIL="recipient@example.com"
+ python examples/special_characters_demo/special_characters_example.py
+"""
+
+import os
+import sys
+import io
+from nylas import Client
+
+
+def get_env_or_exit(var_name: str) -> str:
+ """Get an environment variable or exit if not found."""
+ value = os.getenv(var_name)
+ if not value:
+ print(f"Error: {var_name} environment variable is required")
+ sys.exit(1)
+ return value
+
+
+def print_separator(title: str) -> None:
+ """Print a formatted section separator."""
+ print(f"\n{'='*60}")
+ print(f" {title}")
+ print('='*60)
+
+
+def demonstrate_small_message_with_special_chars(client: Client, grant_id: str, recipient: str) -> None:
+ """Demonstrate sending a message with special characters (no attachments)."""
+ print_separator("Sending Message with Special Characters (No Attachments)")
+
+ try:
+ # This is the exact subject from the bug report
+ subject = "De l'idée à la post-prod, sans friction"
+ body = """
+
+
+ Bonjour!
+ Ce message contient des caractères spéciaux:
+
+ - Accents français: é, è, ê, à, ù, ç
+ - Espagnol: ñ, á, í, ó, ú
+ - Allemand: ä, ö, ü, ß
+ - Portugais: ã, õ, â
+ - Symboles: €, £, ¥, ©, ®, ™
+ - Citation: "De l'idée à la réalisation"
+
+
+ Expressions courantes: café, naïve, résumé, côté, forêt,
+ crème brûlée, piñata, Zürich
+
+
+
+ """
+
+ print(f"Subject: {subject}")
+ print(f"To: {recipient}")
+ print("Body contains various special characters...")
+
+ print("\nSending message...")
+ response = client.messages.send(
+ identifier=grant_id,
+ request_body={
+ "subject": subject,
+ "to": [{"email": recipient}],
+ "body": body,
+ }
+ )
+
+ print(f"✓ Message sent successfully!")
+ print(f" Message ID: {response.data.id}")
+ print(f" Subject preserved: {response.data.subject == subject}")
+ print(f"\n✅ Special characters in subject and body are correctly encoded")
+
+ except Exception as e:
+ print(f"❌ Error sending message: {e}")
+
+
+def demonstrate_message_with_large_attachment(client: Client, grant_id: str, recipient: str) -> None:
+ """Demonstrate sending a message with special characters AND large attachment."""
+ print_separator("Message with Special Characters + Large Attachment")
+
+ try:
+ # This is the exact subject from the bug report
+ subject = "De l'idée à la post-prod, sans friction"
+ body = """
+
+
+ Message avec pièce jointe volumineuse
+
+ Ce message démontre que les caractères spéciaux sont
+ correctement préservés même lors de l'utilisation de
+ multipart/form-data pour les grandes pièces jointes.
+
+ Caractères accentués: café, naïve, résumé, côté
+
+
+ """
+
+ # Create a large attachment (>3MB) to trigger multipart/form-data encoding
+ # This is where the encoding bug was happening
+ large_content = b"A" * (3 * 1024 * 1024 + 1000) # Slightly over 3MB
+ attachment_stream = io.BytesIO(large_content)
+
+ print(f"Subject: {subject}")
+ print(f"To: {recipient}")
+ print(f"Attachment size: {len(large_content) / (1024*1024):.2f} MB")
+ print(" (Using multipart/form-data encoding)")
+
+ print("\nSending message with large attachment...")
+ response = client.messages.send(
+ identifier=grant_id,
+ request_body={
+ "subject": subject,
+ "to": [{"email": recipient}],
+ "body": body,
+ "attachments": [
+ {
+ "filename": "large_file.txt",
+ "content_type": "text/plain",
+ "content": attachment_stream,
+ "size": len(large_content),
+ }
+ ],
+ }
+ )
+
+ print(f"✓ Message with large attachment sent successfully!")
+ print(f" Message ID: {response.data.id}")
+ print(f" Subject preserved: {response.data.subject == subject}")
+ print(f"\n✅ Special characters are correctly encoded even with large attachments!")
+ print(" (The fix ensures ensure_ascii=False in json.dumps for multipart data)")
+
+ except Exception as e:
+ print(f"❌ Error sending message with large attachment: {e}")
+
+
+def demonstrate_draft_with_special_chars(client: Client, grant_id: str, recipient: str) -> None:
+ """Demonstrate creating a draft with special characters."""
+ print_separator("Creating Draft with Special Characters")
+
+ try:
+ subject = "Réunion importante: café & stratégie"
+ body = """
+
+
+ Ordre du jour
+
+ - Révision du budget (€)
+ - Stratégie de développement
+ - Café et discussion informelle
+
+ À bientôt!
+
+
+ """
+
+ print(f"Subject: {subject}")
+ print(f"To: {recipient}")
+
+ print("\nCreating draft...")
+ response = client.drafts.create(
+ identifier=grant_id,
+ request_body={
+ "subject": subject,
+ "to": [{"email": recipient}],
+ "body": body,
+ }
+ )
+
+ print(f"✓ Draft created successfully!")
+ print(f" Draft ID: {response.data.id}")
+ print(f" Subject preserved: {response.data.subject == subject}")
+
+ # Clean up - delete the draft
+ print("\nCleaning up draft...")
+ client.drafts.destroy(identifier=grant_id, draft_id=response.data.id)
+ print("✓ Draft deleted")
+
+ print(f"\n✅ Special characters in drafts are correctly handled")
+
+ except Exception as e:
+ print(f"❌ Error with draft: {e}")
+
+
+def demonstrate_various_languages(client: Client, grant_id: str, recipient: str) -> None:
+ """Demonstrate various international characters."""
+ print_separator("International Characters - Various Languages")
+
+ test_cases = [
+ ("French", "Réservation confirmée: café à 15h"),
+ ("Spanish", "¡Hola! ¿Cómo estás? Mañana será mejor"),
+ ("German", "Größe: über 100 Stück verfügbar"),
+ ("Portuguese", "Atenção: promoção válida até amanhã"),
+ ("Italian", "Caffè espresso: è così buono!"),
+ ("Russian", "Привет! Как дела?"),
+ ("Japanese", "こんにちは、お元気ですか?"),
+ ("Chinese", "你好,最近怎么样?"),
+ ("Emoji", "🎉 Celebration time! 🎊 Let's party 🥳"),
+ ]
+
+ print("Testing subjects in various languages:")
+ print("(Note: Not actually sending to avoid spam)")
+ print()
+
+ for language, subject in test_cases:
+ print(f" {language:15} : {subject}")
+ # In a real scenario, you could send these
+ # For demo purposes, we just show they can be handled
+
+ print(f"\n✅ All international characters can be properly encoded")
+ print(" The SDK preserves UTF-8 encoding correctly")
+
+
+def demonstrate_encoding_explanation() -> None:
+ """Explain the encoding fix."""
+ print_separator("Technical Explanation of the Fix")
+
+ print("""
+The Bug:
+--------
+When sending emails with large attachments (>3MB), the SDK uses
+multipart/form-data encoding. Previously, the message payload was
+serialized using:
+
+ json.dumps(request_body) # Default: ensure_ascii=True
+
+This caused special characters to be escaped as unicode sequences:
+ "De l'idée" → "De l\\u2019id\\u00e9e"
+
+When Gmail received this, it would sometimes double-decode or misinterpret
+these escape sequences, resulting in:
+ "De l’idée" or similar garbled text
+
+The Fix:
+--------
+The SDK now uses:
+
+ json.dumps(request_body, ensure_ascii=False)
+
+This preserves the actual UTF-8 characters in the JSON payload:
+ "De l'idée" → "De l'idée" (unchanged)
+
+The multipart/form-data Content-Type header correctly specifies UTF-8,
+so email clients now receive and display the characters correctly.
+
+Impact:
+-------
+✓ Small messages (no large attachments): Always worked correctly
+✓ Large messages (with attachments >3MB): Now work correctly!
+✓ Drafts with large attachments: Now work correctly!
+✓ All international characters: Properly preserved
+
+Testing:
+--------
+Run the included tests to verify:
+ pytest tests/utils/test_file_utils.py::TestFileUtils::test_build_form_request_with_special_characters
+ pytest tests/resources/test_messages.py::TestMessage::test_send_message_with_special_characters_large_attachment
+ pytest tests/resources/test_drafts.py::TestDraft::test_create_draft_with_special_characters_large_attachment
+ """)
+
+
+def main():
+ """Main function demonstrating special character handling."""
+ # Get required environment variables
+ api_key = get_env_or_exit("NYLAS_API_KEY")
+ grant_id = get_env_or_exit("NYLAS_GRANT_ID")
+ recipient = get_env_or_exit("RECIPIENT_EMAIL")
+
+ # Initialize Nylas client
+ client = Client(api_key=api_key)
+
+ print("╔" + "="*58 + "╗")
+ print("║ Nylas SDK: Special Characters Encoding Example ║")
+ print("╚" + "="*58 + "╝")
+ print()
+ print("This example demonstrates the fix for email subject/body")
+ print("encoding issues with special characters (accented letters).")
+ print()
+ print(f"Testing with:")
+ print(f" Grant ID: {grant_id}")
+ print(f" Recipient: {recipient}")
+
+ # Demonstrate different scenarios
+ demonstrate_small_message_with_special_chars(client, grant_id, recipient)
+ demonstrate_message_with_large_attachment(client, grant_id, recipient)
+ demonstrate_draft_with_special_chars(client, grant_id, recipient)
+ demonstrate_various_languages(client, grant_id, recipient)
+ demonstrate_encoding_explanation()
+
+ print_separator("Example Completed Successfully! ✅")
+ print("\nKey Takeaways:")
+ print("1. Special characters are now correctly preserved in all email subjects")
+ print("2. The fix applies to both small and large messages (with attachments)")
+ print("3. Drafts also handle special characters correctly")
+ print("4. All international character sets are supported")
+ print()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/upload_and_download.py b/examples/upload_and_download.py
deleted file mode 100755
index 7857dfed..00000000
--- a/examples/upload_and_download.py
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/python
-
-import time
-from nylas import APIClient
-from nylas.util import generate_id
-
-APP_ID = '[YOUR_APP_ID]'
-APP_SECRET = '[YOUR_APP_SECRET]'
-ACCESS_TOKEN = '[YOUR_ACCESS_TOKEN]'
-client = APIClient(APP_ID, APP_SECRET, ACCESS_TOKEN)
-
-subject = generate_id()
-
-f = open('test.py', 'r')
-data = f.read()
-f.close()
-
-myfile = client.files.create()
-myfile.filename = 'test.py'
-myfile.data = data
-
-# Create a new draft
-draft = client.drafts.create()
-draft.to = [{'name': 'Charles Gruenwald', 'email': 'nylastestempty@gmail.com'}]
-draft.subject = subject
-draft.body = ""
-draft.attach(myfile)
-draft.send()
-
-x = 0
-th = client.threads.where({'in': 'Sent', 'subject': subject}).first()
-while not th:
- time.sleep(0.5)
- x += 1
- th = client.threads.where({'in': 'Sent', 'subject': subject}).first()
-
-m = th.messages[0]
-
-print m.attachments[0].download()
diff --git a/examples/upload_files_in_dir.py b/examples/upload_files_in_dir.py
deleted file mode 100755
index 5df3f3fd..00000000
--- a/examples/upload_files_in_dir.py
+++ /dev/null
@@ -1,35 +0,0 @@
-#!/usr/bin/python
-
-import os
-import time
-from nylas import APIClient
-from nylas.util import generate_id
-
-APP_ID = '[YOUR_APP_ID]'
-APP_SECRET = '[YOUR_APP_SECRET]'
-ACCESS_TOKEN = '[YOUR_ACCESS_TOKEN]'
-client = APIClient(APP_ID, APP_SECRET, ACCESS_TOKEN)
-
-subject = generate_id()
-# Create a new draft
-draft = client.drafts.create()
-draft.to = [{'name': 'Nylas PythonSDK', 'email': 'nylastestempty@gmail.com'}]
-draft.subject = subject
-draft.body = ""
-
-for filename in filter(lambda x: not os.path.isdir(x), os.listdir(".")):
- f = open(filename, 'r')
- attachment = client.files.create()
- attachment.filename = filename
- attachment.stream = f
- attachment.save()
- draft.attach(attachment)
-
-draft.send()
-
-th = client.threads.where({'in': 'Sent', 'subject': subject}).first()
-while not th:
- time.sleep(0.5)
- th = client.threads.where({'in': 'Sent', 'subject': subject}).first()
-
-print th.messages[0].attachments[0].download()
diff --git a/inbox/__init__.py b/inbox/__init__.py
deleted file mode 100644
index 1830fba7..00000000
--- a/inbox/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from nylas import *
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 92c8e68f..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 c18258b6..85e889a5 100644
--- a/nylas/_client_sdk_version.py
+++ b/nylas/_client_sdk_version.py
@@ -1 +1 @@
-__VERSION__ = "1.2.2"
+__VERSION__ = "6.15.0"
diff --git a/nylas/client.py b/nylas/client.py
new file mode 100644
index 00000000..66ce84b1
--- /dev/null
+++ b/nylas/client.py
@@ -0,0 +1,248 @@
+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.lists import Lists
+from nylas.resources.threads import Threads
+from nylas.resources.transactional_send import TransactionalSend
+from nylas.resources.webhooks import Webhooks
+from nylas.resources.contacts import Contacts
+from nylas.resources.drafts import Drafts
+from nylas.resources.domains import Domains
+from nylas.resources.grants import Grants
+from nylas.resources.policies import Policies
+from nylas.resources.scheduler import Scheduler
+from nylas.resources.notetakers import Notetakers
+from nylas.resources.rules import Rules
+
+
+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 = 90
+ ):
+ """
+ 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 domains(self) -> Domains:
+ """
+ Access the Manage Domains API.
+
+ Returns:
+ The Manage Domains API.
+ """
+ return Domains(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 policies(self) -> Policies:
+ """
+ Access the Policies API.
+
+ Returns:
+ The Policies API.
+ """
+ return Policies(self.http_client)
+
+ @property
+ def rules(self) -> Rules:
+ """
+ Access the Rules API.
+
+ Returns:
+ The Rules API.
+ """
+ return Rules(self.http_client)
+
+ @property
+ def messages(self) -> Messages:
+ """
+ Access the Messages API.
+
+ Returns:
+ The Messages API.
+ """
+ return Messages(self.http_client)
+
+ @property
+ def lists(self) -> Lists:
+ """
+ Access the Lists API.
+
+ Returns:
+ The Lists API.
+ """
+ return Lists(self.http_client)
+
+ @property
+ def threads(self) -> Threads:
+ """
+ Access the Threads API.
+
+ Returns:
+ The Threads API.
+ """
+ return Threads(self.http_client)
+
+ @property
+ def transactional_send(self) -> TransactionalSend:
+ """
+ Access the Transactional Send API.
+
+ Returns:
+ The Transactional Send API.
+ """
+ return TransactionalSend(self.http_client)
+
+ @property
+ def webhooks(self) -> Webhooks:
+ """
+ Access the Webhooks API.
+
+ Returns:
+ The Webhooks API.
+ """
+ return Webhooks(self.http_client)
+
+ @property
+ def scheduler(self) -> Scheduler:
+ """
+ Access the Scheduler API.
+
+ Returns:
+ The Scheduler API.
+ """
+ return Scheduler(self.http_client)
+
+ @property
+ def notetakers(self) -> Notetakers:
+ """
+ Access the Notetakers API.
+
+ Returns:
+ The Notetakers API.
+ """
+ return Notetakers(self.http_client)
diff --git a/nylas/client/__init__.py b/nylas/client/__init__.py
deleted file mode 100644
index c54e0d60..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/client.py b/nylas/client/client.py
deleted file mode 100644
index a391a4ea..00000000
--- a/nylas/client/client.py
+++ /dev/null
@@ -1,364 +0,0 @@
-import sys
-import requests
-import json
-try:
- from urllib import urlencode
-except ImportError:
- from urllib.parse import urlencode
-from os import environ
-from base64 import b64encode
-from six.moves.urllib.parse import urlencode
-from nylas._client_sdk_version import __VERSION__
-from .util import url_concat, generate_id
-from .restful_model_collection import RestfulModelCollection
-from .restful_models import (Calendar, Contact, Event, Message, Thread, File,
- Account, APIAccount, SingletonAccount, Folder,
- Label, Draft)
-from .errors import (APIClientError, ConnectionError, NotAuthorizedError,
- InvalidRequestError, NotFoundError, MethodNotSupportedError,
- ServerError, ServiceUnavailableError, ConflictError,
- SendingQuotaExceededError, ServerTimeoutError,
- MessageRejectedError)
-
-DEBUG = environ.get('NYLAS_CLIENT_DEBUG')
-API_SERVER = "https://api.nylas.com"
-
-
-def _validate(response):
- status_code_to_exc = {400: InvalidRequestError,
- 401: NotAuthorizedError,
- 402: MessageRejectedError,
- 403: NotAuthorizedError,
- 404: NotFoundError,
- 405: MethodNotSupportedError,
- 409: ConflictError,
- 429: SendingQuotaExceededError,
- 500: ServerError,
- 503: ServiceUnavailableError,
- 504: ServerTimeoutError}
- request = response.request
- url = request.url
- status_code = response.status_code
- data = request.body
-
- if DEBUG:
- print("{} {} ({}) => {}: {}".format(request.method, url, data,
- status_code, response.text))
-
- try:
- data = json.loads(data) if data else None
- except (ValueError, TypeError):
- pass
-
- if status_code == 200:
- return response
- elif status_code in status_code_to_exc:
- cls = status_code_to_exc[status_code]
- try:
- response = json.loads(response.text)
- kwargs = dict(url=url, status_code=status_code,
- data=data)
-
- for key in ['message', 'server_error']:
- if key in response:
- kwargs[key] = response[key]
-
- raise cls(**kwargs)
-
- except (ValueError, TypeError):
- raise cls(url=url, status_code=status_code,
- data=data, message="Malformed")
- else:
- raise APIClientError(url=url, status_code=status_code,
- data=data, message="Unknown status code.")
-
-
-def nylas_excepted(f):
- def caught(*args, **kwargs):
- try:
- return f(*args, **kwargs)
- except requests.exceptions.ConnectionError:
- server = args[0].api_server
- raise ConnectionError(url=server)
- return caught
-
-
-class APIClient(json.JSONEncoder):
- """API client for the Nylas API."""
-
- def __init__(self, app_id=environ.get('NYLAS_APP_ID'),
- app_secret=environ.get('NYLAS_APP_SECRET'),
- access_token=environ.get('NYLAS_ACCESS_TOKEN'),
- api_server=API_SERVER,
- auth_server=None):
- if "://" not in api_server:
- raise Exception("When overriding the Nylas API server address, you"
- " must include https://")
- self.api_server = api_server
- self.authorize_url = api_server + '/oauth/authorize'
- self.access_token_url = api_server + '/oauth/token'
-
- self.app_secret = app_secret
- self.app_id = app_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',
- 'User-Agent': version_header}
- self.access_token = access_token
-
- # Requests to the /a/ namespace don't use an auth token but
- # the app_secret. Set up a specific session for this.
- self.admin_session = requests.Session()
-
- if app_secret is not None:
- self.admin_session.headers = {'Authorization': 'Bearer ' +
- app_secret,
- 'X-Nylas-API-Wrapper': 'python',
- 'User-Agent': version_header}
-
- @property
- def access_token(self):
- return self._access_token
-
- @access_token.setter
- def access_token(self, value):
- self._access_token = value
- if value:
- self.session.headers.update({'Authorization': 'Bearer ' +
- value})
- else:
- if 'Authorization' in self.session.headers:
- del self.session.headers['Authorization']
-
- def authentication_url(self, redirect_uri, login_hint=''):
- args = {'redirect_uri': redirect_uri,
- 'client_id': self.app_id,
- 'response_type': 'code',
- 'scope': 'email',
- 'login_hint': login_hint,
- 'state': generate_id()}
-
- return url_concat(self.authorize_url, args)
-
- def token_for_code(self, code):
- args = {'client_id': self.app_id,
- 'client_secret': self.app_secret,
- 'grant_type': 'authorization_code',
- 'code': code}
-
- headers = {'Content-type': 'application/x-www-form-urlencoded',
- 'Accept': 'text/plain'}
-
- resp = requests.post(self.access_token_url, data=urlencode(args),
- headers=headers).json()
-
- self.auth_token = resp[u'access_token']
- return self.auth_token
-
- def is_opensource_api(self):
- if self.app_id is None and self.app_secret is None:
- return True
-
- return False
-
- @property
- def account(self):
- return self._get_resource(SingletonAccount, '')
-
- @property
- def accounts(self):
- if self.is_opensource_api():
- return RestfulModelCollection(APIAccount, self)
- else:
- 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 calendars(self):
- return RestfulModelCollection(Calendar, 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 app_secret
- # instead of the secret_token
- if api_root == 'a':
- return self.admin_session
- else:
- return self.session
-
- @nylas_excepted
- def _get_resources(self, cls, **filters):
- # FIXME @karim: remove this interim code when we've got rid
- # of the old accounts API.
- if cls.api_root != 'a':
- url = "{}/{}".format(self.api_server, cls.collection_name)
- else:
- url = "{}/a/{}/{}".format(self.api_server, self.app_id,
- cls.collection_name)
-
- url = url_concat(url, filters)
- response = self._get_http_session(cls.api_root).get(url)
- results = _validate(response).json()
- return list(
- filter(
- lambda x: x is not None,
- map(lambda x: cls.create(self, **x), results)
- )
- )
-
- @nylas_excepted
- def _get_resource_raw(self, cls, id, extra=None,
- headers=None, **filters):
- """Get an individual REST resource"""
- headers = headers or {}
- headers.update(self.session.headers)
-
- postfix = "/{}".format(extra) if extra else ''
- if cls.api_root != 'a':
- url = "{}/{}/{}{}".format(self.api_server, cls.collection_name, id,
- postfix)
- else:
- url = "{}/a/{}/{}/{}{}".format(self.api_server, self.app_id,
- cls.collection_name, id, postfix)
-
- url = url_concat(url, filters)
-
- response = self._get_http_session(cls.api_root).get(url, headers=headers)
- return _validate(response)
-
- def _get_resource(self, cls, id, **filters):
- response = self._get_resource_raw(cls, id, **filters)
- result = response.json()
- if isinstance(result, list):
- result = result[0]
- return cls.create(self, **result)
-
- def _get_resource_data(self, cls, id,
- extra=None, headers=None, **filters):
- response = self._get_resource_raw(cls, id, extra=extra,
- headers=headers, **filters)
- return response.content
-
- @nylas_excepted
- def _create_resource(self, cls, data, **kwargs):
- url = "{}/{}/".format(self.api_server, cls.collection_name)
-
- if len(kwargs.keys()) > 0:
- url = "{}?{}".format(url, urlencode(kwargs))
-
- session = self._get_http_session(cls.api_root)
-
- if cls == File:
- response = session.post(url, files=data)
- else:
- data = json.dumps(data)
- headers = {'Content-Type': 'application/json'}
- headers.update(self.session.headers)
- response = session.post(url, data=data, headers=headers)
-
- result = _validate(response).json()
- if cls.collection_name == 'send':
- return result
- return cls.create(self, **result)
-
- @nylas_excepted
- def _create_resources(self, cls, data):
- url = "{}/{}/".format(self.api_server, cls.collection_name)
- session = self._get_http_session(cls.api_root)
-
- if cls == File:
- response = session.post(url, files=data)
- else:
- data = json.dumps(data)
- headers = {'Content-Type': 'application/json'}
- headers.update(self.session.headers)
- response = session.post(url, data=data, headers=headers)
-
- results = _validate(response).json()
- return list(map(lambda x: cls.create(self, **x), results))
-
- @nylas_excepted
- def _delete_resource(self, cls, id, data=None, **kwargs):
- name = cls.collection_name
- url = "{}/{}/{}".format(self.api_server, name, id)
-
- if len(kwargs.keys()) > 0:
- url = "{}?{}".format(url, urlencode(kwargs))
- session = self._get_http_session(cls.api_root)
- if data:
- _validate(session.delete(url, json=data))
- else:
- _validate(session.delete(url))
-
- @nylas_excepted
- def _update_resource(self, cls, id, data, **kwargs):
- name = cls.collection_name
- url = "{}/{}/{}".format(self.api_server, name, id)
-
- if len(kwargs.keys()) > 0:
- url = "{}?{}".format(url, urlencode(kwargs))
-
- session = self._get_http_session(cls.api_root)
-
- response = session.put(url, json=data)
-
- result = _validate(response).json()
- return cls.create(self, **result)
-
- @nylas_excepted
- def _call_resource_method(self, cls, id, method_name, data):
- """POST a dictionnary to an API method,
- for example /a/.../accounts/id/upgrade"""
- name = cls.collection_name
- if cls.api_root != 'a':
- url = "{}/{}/{}/{}".format(self.api_server, name, id, method_name)
- else:
- # Management method.
- url = "{}/a/{}/{}/{}/{}".format(self.api_server, self.app_id,
- cls.collection_name, id, method_name)
-
-
- session = self._get_http_session(cls.api_root)
- response = session.post(url, json=data)
-
- return _validate(response).json()
diff --git a/nylas/client/errors.py b/nylas/client/errors.py
deleted file mode 100644
index 026ce175..00000000
--- a/nylas/client/errors.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import json
-
-
-class APIClientError(Exception):
- def __init__(self, **kwargs):
- if 'message' in kwargs:
- Exception.__init__(self, kwargs['message'])
- else:
- Exception.__init__(self, '')
-
- self.attrs = kwargs.keys()
- for k, v in kwargs.items():
- setattr(self, k, v)
-
- def as_dict(self):
- resp = {}
- for attr in self.attrs:
- resp[attr] = getattr(self, attr)
- return resp
-
- def __str__(self):
- return json.dumps(self.as_dict())
-
-
-class ConnectionError(APIClientError):
- pass
-
-
-class NotAuthorizedError(APIClientError):
- pass
-
-
-class InvalidRequestError(APIClientError):
- pass
-
-
-class MessageRejectedError(APIClientError):
- pass
-
-
-class ConflictError(APIClientError):
- pass
-
-
-class SendingQuotaExceededError(APIClientError):
- pass
-
-
-class NotFoundError(APIClientError):
- pass
-
-
-class MethodNotSupportedError(APIClientError):
- pass
-
-
-class ServerError(APIClientError):
- pass
-
-
-class ServiceUnavailableError(APIClientError):
- pass
-
-
-class ServerTimeoutError(APIClientError):
- pass
-
-
-class FileUploadError(APIClientError):
- pass
diff --git a/nylas/client/restful_model_collection.py b/nylas/client/restful_model_collection.py
deleted file mode 100644
index 191dcc25..00000000
--- a/nylas/client/restful_model_collection.py
+++ /dev/null
@@ -1,115 +0,0 @@
-from copy import copy
-
-CHUNK_SIZE = 50
-
-class RestfulModelCollection(object):
- def __init__(self, cls, api, filter={}, offset=0,
- **filters):
- filters.update(filter)
- from nylas.client import APIClient
- if not isinstance(api, APIClient):
- raise Exception("Provided api was not an APIClient.")
-
- filters.setdefault('offset', 0)
-
- self.model_class = cls
- self.filters = filters
- self.api = api
-
- def __iter__(self):
- return self.items()
-
- def items(self):
- offset = self.filters['offset']
- while True:
- items = self._get_model_collection(offset, CHUNK_SIZE)
- if not items:
- break
-
- for item in items:
- yield item
-
- if len(items) < CHUNK_SIZE:
- break
-
- offset += len(items)
-
- def first(self):
- results = self._get_model_collection(0, 1)
- if len(results):
- return results[0]
- return None
-
- def all(self, limit=float('infinity')):
- return self._range(self.filters['offset'], limit)
-
- def where(self, filter={}, **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]
-
- filters.update(filter)
- filters.setdefault('offset', 0)
- collection = copy(self)
- collection.filters = filters
- return collection
-
- def find(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 __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 items 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 95194906..00000000
--- a/nylas/client/restful_models.py
+++ /dev/null
@@ -1,470 +0,0 @@
-from .restful_model_collection import RestfulModelCollection
-from .errors import FileUploadError
-from six import StringIO
-import base64
-import json
-
-
-class NylasAPIObject(dict):
- attrs = []
- # 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 = 'n'
-
- def __init__(self, cls, api):
- self.id = None
- self.cls = cls
- self.api = api
-
- __setattr__ = dict.__setitem__
- __delattr__ = dict.__delitem__
- __getattr__ = dict.get
-
- @classmethod
- def create(cls, api, **kwargs):
- object_type = kwargs.get('object')
- if (object_type and object_type != cls.__name__.lower() and
- object_type != 'account'):
- # 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.
- return
- obj = cls(api)
- 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 'id' not in kwargs:
- obj['id'] = None
-
- return obj
-
- def as_json(self):
- dct = {}
- for attr in self.cls.attrs:
- if hasattr(self, attr):
- dct[attr] = getattr(self, attr)
- return dct
-
- def child_collection(self, cls, **filters):
- return RestfulModelCollection(cls, self.api, **filters)
-
- def save(self, **kwargs):
- if self.id:
- new_obj = self.api._update_resource(self.cls, self.id,
- self.as_json(), **kwargs)
- else:
- new_obj = self.api._create_resource(self.cls,
- self.as_json(), **kwargs)
- for attr in self.cls.attrs:
- if hasattr(new_obj, attr):
- setattr(self, attr, getattr(new_obj, attr))
-
- def update(self):
- new_obj = self.api._update_resource(self.cls,
- self.id, self.as_json())
- 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", "to", "unread", "starred", "_folder", "_labels"]
- 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]
- else:
- 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=[]):
- 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=[]):
- 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=[]):
- 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"}
- data = self.api._get_resource_data(Message, self.id, headers=headers)
- return data
-
-
-class Folder(NylasAPIObject):
- attrs = ["id", "display_name", "name", "object", "account_id"]
- collection_name = "folders"
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Folder, api)
-
- @property
- def threads(self):
- return self.child_collection({'in': self.id})
-
- @property
- def messages(self):
- return self.child_collection({'in': self.id})
-
-
-class Label(NylasAPIObject):
- attrs = ["id", "display_name", "name", "object", "account_id"]
- collection_name = "labels"
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Label, api)
-
- @property
- def threads(self):
- return self.child_collection({'in': self.id})
-
- @property
- def messages(self):
- return self.child_collection({'in': 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",
- "unread", "starred", "version", "_folders", "_labels",
- "received_recent_date"]
- collection_name = 'threads'
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Thread, api)
-
- @property
- def messages(self):
- 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]
- else:
- return []
-
- @property
- def labels(self):
- if self._labels:
- return [Label.create(self.api, **l)
- for l in self._labels]
- else:
- 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=[]):
- 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=[]):
- 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=[]):
- 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):
- d = self.drafts.create()
- d.thread_id = self.id
- d.subject = self.subject
- return d
-
-# 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):
- NylasAPIObject.__init__(self, Send, api)
-
-
-class Draft(Message):
- attrs = ["bcc", "cc", "body", "date", "files", "from", "id",
- "account_id", "object", "subject", "thread_id", "to",
- "unread", "version", "file_ids", "reply_to_message_id",
- "reply_to", "starred", "snippet"]
- collection_name = 'drafts'
-
- def __init__(self, api, thread_id=None):
- Message.__init__(self, api)
- NylasAPIObject.__init__(self, Thread, api)
- 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(self):
- if not self.id:
- data = self.as_json()
- else:
- data = {'draft_id': self.id}
- if hasattr(self, 'version'):
- data['version'] = self.version
-
- msg = self.api._create_resource(Send, data)
- if msg:
- return msg
-
- def delete(self):
- if self.id and self.version:
- 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):
- if hasattr(self, 'stream') and self.stream is not None:
- data = {self.filename: self.stream}
- elif hasattr(self, 'data') and self.data is not None:
- data = {self.filename: StringIO(self.data)}
- else:
- raise FileUploadError(message=("File object not properly "
- "formatted, must provide "
- "either a stream or data."))
-
- new_obj = self.api._create_resources(File, data)
- 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:
- raise FileUploadError(message=("Can't download a file that "
- "hasn't been uploaded."))
-
- 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", "account_id", "name", "email", "object"]
- collection_name = 'contacts'
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Contact, api)
-
-
-class Calendar(NylasAPIObject):
- attrs = ["id", "account_id", "name", "description", "read_only", "object"]
- collection_name = 'calendars'
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Calendar, api)
-
- @property
- def events(self):
- return self.child_collection(Event, calendar_id=self.id)
-
-
-class Event(NylasAPIObject):
- attrs = ["id", "account_id", "title", "description", "location",
- "read_only", "when", "busy", "participants", "calendar_id",
- "recurrence", "status", "master_event_id", "owner",
- "original_start_time", "object"]
- collection_name = 'events'
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Event, api)
-
- def as_json(self):
- dct = NylasAPIObject.as_json(self)
- # Filter some parameters we got from the API
- if 'when' in dct:
- if 'object' in dct['when']:
- del dct['when']['object']
-
- return dct
-
-
-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", "trial", "trial_expires", "sync_state",
- "billing_state", "account_id"]
-
- collection_name = 'accounts'
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Account, api)
-
- def as_json(self):
- dct = NylasAPIObject.as_json(self)
- return dct
-
- def upgrade(self):
- self.api._call_resource_method(self, self.account_id,
- 'upgrade', None)
-
- def downgrade(self):
- self.api._call_resource_method(self, self.account_id,
- 'downgrade', None)
-
-
-class APIAccount(NylasAPIObject):
- attrs = ["email_address", "id", "account_id", "object",
- "provider", "name", "organization_unit"]
-
- collection_name = 'accounts'
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, APIAccount, api)
-
- def as_json(self):
- dct = NylasAPIObject.as_json(self)
- return dct
-
-
-class SingletonAccount(APIAccount):
- # This is an APIAccount that lives under /account.
- collection_name = 'account'
diff --git a/nylas/client/util.py b/nylas/client/util.py
deleted file mode 100644
index 531be942..00000000
--- a/nylas/client/util.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import six
-from six.moves.urllib.parse import urlencode
-from uuid import uuid4
-from struct import unpack
-
-
-# From tornado.httputil
-def url_concat(url, args, fragments=None):
- """Concatenate url and argument dictionary regardless of whether
- url has existing query parameters.
-
- >>> url_concat("http://example.com/foo?a=b", dict(c="d"))
- 'http://example.com/foo?a=b&c=d'
- """
-
- if not args and not fragments:
- return url
-
- # Strip off hashes
- while url[-1] == '#':
- url = url[:-1]
-
- fragment_tail = ''
- if fragments:
- fragment_tail = '#' + urlencode(fragments)
-
- args_tail = ''
- if args:
- if url[-1] not in ('?', '&'):
- args_tail += '&' if ('?' in url) else '?'
- args_tail += urlencode(args)
-
- return url + args_tail + fragment_tail
-
-
-def generate_id():
- a, b = unpack('>QQ', uuid4().bytes)
- num = a << 64 | b
-
- alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
-
- base36 = ''
- while num:
- num, i = divmod(num, 36)
- base36 = alphabet[i] + base36
-
- return base36
diff --git a/nylas/config.py b/nylas/config.py
new file mode 100644
index 00000000..ee625e02
--- /dev/null
+++ b/nylas/config.py
@@ -0,0 +1,47 @@
+from enum import Enum
+from typing import TypedDict
+
+from typing_extensions import NotRequired
+
+
+class Region(str, Enum):
+ """
+ Enum representing the regions supported by the Nylas API
+ """
+
+ US = "us"
+ EU = "eu"
+
+
+class RequestOverrides(TypedDict):
+ """
+ Overrides to use for an outgoing request to the Nylas API
+
+ Attributes:
+ api_key: The API key to use for the request.
+ api_uri: The API URI to use for the request.
+ timeout: The timeout to use for the request.
+ headers: Additional headers to include in the request.
+ """
+
+ api_key: NotRequired[str]
+ api_uri: NotRequired[str]
+ timeout: NotRequired[int]
+ headers: NotRequired[dict]
+
+
+DEFAULT_REGION = Region.US
+""" The default Nylas API region. """
+
+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/examples/lib/__init__.py b/nylas/handler/__init__.py
similarity index 100%
rename from examples/lib/__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..6badcf43
--- /dev/null
+++ b/nylas/handler/api_resources.py
@@ -0,0 +1,129 @@
+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,
+ overrides=None,
+ ) -> ListResponse:
+ response_json, response_headers = self._http_client._execute(
+ "GET", path, headers, query_params, request_body, overrides=overrides
+ )
+
+ return ListResponse.from_dict(response_json, response_type, response_headers)
+
+
+class FindableApiResource(Resource):
+ def find(
+ self,
+ path,
+ response_type,
+ headers=None,
+ query_params=None,
+ request_body=None,
+ overrides=None,
+ ) -> Response:
+ response_json, response_headers = self._http_client._execute(
+ "GET", path, headers, query_params, request_body, overrides=overrides
+ )
+
+ return Response.from_dict(response_json, response_type, response_headers)
+
+
+class CreatableApiResource(Resource):
+ def create(
+ self,
+ path,
+ response_type,
+ headers=None,
+ query_params=None,
+ request_body=None,
+ overrides=None,
+ serialized_json_body=None,
+ ) -> Response:
+
+ kwargs = {"overrides": overrides}
+ if serialized_json_body is not None:
+ kwargs["serialized_json_body"] = serialized_json_body
+ response_json, response_headers = self._http_client._execute(
+ "POST", path, headers, query_params, request_body, **kwargs
+ )
+
+ return Response.from_dict(response_json, response_type, response_headers)
+
+
+class UpdatableApiResource(Resource):
+ def update(
+ self,
+ path,
+ response_type,
+ headers=None,
+ query_params=None,
+ request_body=None,
+ method="PUT",
+ overrides=None,
+ serialized_json_body=None,
+ ):
+ kwargs = {"overrides": overrides}
+ if serialized_json_body is not None:
+ kwargs["serialized_json_body"] = serialized_json_body
+ response_json, response_headers = self._http_client._execute(
+ method, path, headers, query_params, request_body, **kwargs
+ )
+
+ return Response.from_dict(response_json, response_type, response_headers)
+
+
+class UpdatablePatchApiResource(Resource):
+ def patch(
+ self,
+ path,
+ response_type,
+ headers=None,
+ query_params=None,
+ request_body=None,
+ method="PATCH",
+ overrides=None,
+ serialized_json_body=None,
+ ):
+ kwargs = {"overrides": overrides}
+ if serialized_json_body is not None:
+ kwargs["serialized_json_body"] = serialized_json_body
+ response_json, response_headers = self._http_client._execute(
+ method, path, headers, query_params, request_body, **kwargs
+ )
+
+ return Response.from_dict(response_json, response_type, response_headers)
+
+
+class DestroyableApiResource(Resource):
+ def destroy(
+ self,
+ path,
+ response_type=None,
+ headers=None,
+ query_params=None,
+ request_body=None,
+ overrides=None,
+ ):
+ if response_type is None:
+ response_type = DeleteResponse
+
+ response_json, response_headers = self._http_client._execute(
+ "DELETE", path, headers, query_params, request_body, overrides=overrides
+ )
+
+ # Check if the response type is a dataclass_json class
+ if hasattr(response_type, "from_dict") and not hasattr(response_type, "headers"):
+ return response_type.from_dict(response_json)
+ return response_type.from_dict(response_json, headers=response_headers)
diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py
new file mode 100644
index 00000000..023e2d10
--- /dev/null
+++ b/nylas/handler/http_client.py
@@ -0,0 +1,214 @@
+import json
+import sys
+from typing import Union, Tuple, Dict
+from urllib.parse import urlparse, quote
+
+import requests
+from requests import Response
+from requests.structures import CaseInsensitiveDict
+
+from nylas._client_sdk_version import __VERSION__
+from nylas.models.errors import (
+ NylasApiError,
+ NylasApiErrorResponse,
+ NylasSdkTimeoutError,
+ NylasOAuthError,
+ NylasOAuthErrorResponse,
+ NylasApiErrorResponseData,
+)
+
+
+def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]:
+ response_data = 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(response_data)
+ raise NylasOAuthError(parsed_error, response.status_code, response.headers)
+
+ parsed_error = NylasApiErrorResponse.from_dict(response_data)
+ raise NylasApiError(parsed_error, response.status_code, response.headers)
+ except (KeyError, TypeError) as exc:
+ request_id = response_data.get("request_id", None)
+ raise NylasApiError(
+ NylasApiErrorResponse(
+ request_id,
+ NylasApiErrorResponseData(
+ type="unknown",
+ message=response_data,
+ ),
+ ),
+ status_code=response.status_code,
+ headers=response.headers,
+ ) from exc
+ return (response_data, response.headers)
+
+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
+
+ def _execute(
+ self,
+ method,
+ path,
+ headers=None,
+ query_params=None,
+ request_body=None,
+ data=None,
+ overrides=None,
+ serialized_json_body=None,
+ ) -> dict:
+ request = self._build_request(
+ method,
+ path,
+ headers,
+ query_params,
+ request_body,
+ data,
+ overrides,
+ serialized_json_body=serialized_json_body,
+ )
+
+ timeout = self.timeout
+ if overrides and overrides.get("timeout"):
+ timeout = overrides["timeout"]
+
+ # Serialize request_body to JSON with ensure_ascii=False to preserve UTF-8 characters
+ # and allow_nan=True to support NaN/Infinity values (matching default json.dumps behavior).
+ # Encode as UTF-8 bytes to avoid Latin-1 encoding errors with special characters.
+ # When serialized_json_body is set (e.g. Nylas service account signing), send those exact
+ # bytes so the wire body matches the payload that was signed.
+ json_data = None
+ if serialized_json_body is not None and data is None:
+ json_data = serialized_json_body
+ elif request_body is not None and data is None:
+ json_data = json.dumps(request_body, ensure_ascii=False, allow_nan=True).encode("utf-8")
+ try:
+ response = requests.request(
+ request["method"],
+ request["url"],
+ headers=request["headers"],
+ data=json_data if json_data is not None else data,
+ timeout=timeout,
+ )
+ except requests.exceptions.Timeout as exc:
+ raise NylasSdkTimeoutError(url=request["url"], timeout=timeout) from exc
+
+ return _validate_response(response)
+
+ def _execute_download_request(
+ self,
+ path,
+ headers=None,
+ query_params=None,
+ stream=False,
+ overrides=None,
+ ) -> Union[bytes, Response,dict]:
+ request = self._build_request("GET", path, headers, query_params, overrides)
+
+ timeout = self.timeout
+ if overrides and overrides.get("timeout"):
+ timeout = overrides["timeout"]
+ try:
+ response = requests.request(
+ request["method"],
+ request["url"],
+ headers=request["headers"],
+ timeout=timeout,
+ stream=stream,
+ )
+
+ if not response.ok:
+ return _validate_response(response)
+
+ # If we stream an iterator for streaming the content, otherwise return the entire byte array
+ if stream:
+ return response
+
+ return response.content if response.content else None
+ except requests.exceptions.Timeout as exc:
+ raise NylasSdkTimeoutError(url=request["url"], timeout=timeout) from exc
+
+ def _build_request(
+ self,
+ method: str,
+ path: str,
+ headers: dict = None,
+ query_params: dict = None,
+ request_body=None,
+ data=None,
+ overrides=None,
+ serialized_json_body=None,
+ ) -> dict:
+ api_server = self.api_server
+ if overrides and overrides.get("api_uri"):
+ api_server = overrides["api_uri"]
+
+ base_url = f"{api_server}{path}"
+ url = _build_query_params(base_url, query_params) if query_params else base_url
+ body_for_content_type = (
+ request_body if request_body is not None else serialized_json_body
+ )
+ headers = self._build_headers(headers, body_for_content_type, data, overrides)
+
+ return {
+ "method": method,
+ "url": url,
+ "headers": headers,
+ }
+
+ def _build_headers(
+ self, extra_headers: dict = None, response_body=None, data=None, overrides=None
+ ) -> dict:
+ override_headers = {}
+ if overrides and overrides.get("headers"):
+ override_headers = overrides["headers"]
+
+ if extra_headers is None:
+ extra_headers = {}
+
+ major, minor, revision, _, __ = sys.version_info
+ user_agent_header = (
+ f"Nylas Python SDK {__VERSION__} - {major}.{minor}.{revision}"
+ )
+
+ api_key = self.api_key
+ if overrides and overrides.get("api_key"):
+ api_key = overrides["api_key"]
+
+ headers = {
+ "X-Nylas-API-Wrapper": "python",
+ "User-Agent": user_agent_header,
+ "Authorization": f"Bearer {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; charset=utf-8"
+
+ return {**headers, **extra_headers, **override_headers}
diff --git a/nylas/handler/service_account.py b/nylas/handler/service_account.py
new file mode 100644
index 00000000..ce86b8d6
--- /dev/null
+++ b/nylas/handler/service_account.py
@@ -0,0 +1,136 @@
+"""
+Nylas Service Account request signing for organization admin APIs.
+
+See https://developer.nylas.com/docs/v3/auth/nylas-service-account/
+
+If you set X-Nylas-* headers manually via RequestOverrides, the HTTP request body must be
+byte-identical to the canonical JSON string used when computing the signature.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import secrets
+import string
+import time
+from typing import Any, Dict, Optional, Tuple
+
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import padding, rsa
+
+_NONCE_ALPHABET = string.ascii_letters + string.digits
+_NONCE_LENGTH = 20
+
+
+def canonical_json(data: Dict[str, Any]) -> str:
+ """
+ Deterministic JSON with sorted keys at each object level, matching Nylas's reference
+ implementation for service account signing.
+ """
+ keys = sorted(data.keys())
+ parts = []
+ for k in keys:
+ key_json = json.dumps(k, ensure_ascii=False, allow_nan=False)
+ v = data[k]
+ if isinstance(v, dict):
+ val_json = canonical_json(v)
+ else:
+ val_json = json.dumps(
+ v, ensure_ascii=False, allow_nan=False, separators=(",", ":")
+ )
+ parts.append(f"{key_json}:{val_json}")
+ return "{" + ",".join(parts) + "}"
+
+
+def load_rsa_private_key_from_pem(pem: str) -> rsa.RSAPrivateKey:
+ """Load an RSA private key from a PEM string (PKCS#1 or PKCS#8)."""
+ key_bytes = pem.encode("utf-8") if isinstance(pem, str) else pem
+ loaded = serialization.load_pem_private_key(key_bytes, password=None)
+ if not isinstance(loaded, rsa.RSAPrivateKey):
+ raise ValueError("Private key must be RSA")
+ return loaded
+
+
+def _signing_envelope_bytes(
+ path: str,
+ method: str,
+ timestamp: int,
+ nonce: str,
+ body: Optional[Dict[str, Any]],
+) -> bytes:
+ method_l = method.lower()
+ envelope: Dict[str, Any] = {
+ "method": method_l,
+ "nonce": nonce,
+ "path": path,
+ "timestamp": timestamp,
+ }
+ if method_l in ("post", "put", "patch") and body is not None:
+ envelope["payload"] = canonical_json(body)
+ canonical = canonical_json(envelope)
+ return canonical.encode("utf-8")
+
+
+def sign_bytes(private_key: rsa.RSAPrivateKey, message: bytes) -> str:
+ """RSA PKCS#1 v1.5 signature over SHA-256(message), Base64-encoded."""
+ signature = private_key.sign(message, padding.PKCS1v15(), hashes.SHA256())
+ return base64.b64encode(signature).decode("ascii")
+
+
+def generate_nonce(length: int = _NONCE_LENGTH) -> str:
+ """Cryptographically secure nonce (alphanumeric), default length 20."""
+ return "".join(secrets.choice(_NONCE_ALPHABET) for _ in range(length))
+
+
+class ServiceAccountSigner:
+ """
+ Builds the four required Nylas service account headers for a single request.
+
+ Args:
+ private_key_pem: RSA private key in PEM text form (from the service account JSON).
+ private_key_id: Value for X-Nylas-Kid (``private_key_id`` in the JSON credentials).
+ """
+
+ def __init__(self, private_key_pem: str, private_key_id: str):
+ self._private_key = load_rsa_private_key_from_pem(private_key_pem)
+ self._private_key_id = private_key_id
+
+ def build_headers(
+ self,
+ method: str,
+ path: str,
+ body: Optional[Dict[str, Any]] = None,
+ *,
+ timestamp: Optional[int] = None,
+ nonce: Optional[str] = None,
+ ) -> Tuple[Dict[str, str], Optional[bytes]]:
+ """
+ Produce signing headers and optional canonical JSON body bytes.
+
+ For POST/PUT/PATCH, ``body`` must be the same dict that will be sent; returned bytes
+ should be passed to HttpClient as ``serialized_json_body`` so the wire body matches
+ the signed payload.
+
+ Returns:
+ (headers, serialized_json_body) where serialized_json_body is set for
+ POST/PUT/PATCH when body is not None, else None.
+ """
+ ts = int(time.time()) if timestamp is None else int(timestamp)
+ n = generate_nonce() if nonce is None else nonce
+
+ serialized: Optional[bytes] = None
+ body_for_sign: Optional[Dict[str, Any]] = body
+ if method.lower() in ("post", "put", "patch") and body is not None:
+ serialized = canonical_json(body).encode("utf-8")
+
+ envelope = _signing_envelope_bytes(path, method, ts, n, body_for_sign)
+ signature_b64 = sign_bytes(self._private_key, envelope)
+
+ headers = {
+ "X-Nylas-Kid": self._private_key_id,
+ "X-Nylas-Nonce": n,
+ "X-Nylas-Timestamp": str(ts),
+ "X-Nylas-Signature": signature_b64,
+ }
+ return headers, serialized
diff --git a/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..c017f9d0
--- /dev/null
+++ b/nylas/models/application_details.py
@@ -0,0 +1,83 @@
+from dataclasses import dataclass, field
+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", "development", "sandbox"]
+""" 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: Optional[str] = None
+ 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] = field(default_factory=list)
diff --git a/nylas/models/attachments.py b/nylas/models/attachments.py
new file mode 100644
index 00000000..7bd8aeb9
--- /dev/null
+++ b/nylas/models/attachments.py
@@ -0,0 +1,136 @@
+from dataclasses import dataclass
+from typing import Optional, Union, BinaryIO, Dict
+
+from dataclasses_json import dataclass_json
+from typing_extensions import TypedDict, NotRequired, Literal
+
+
+@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: Optional[str] = None
+ 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
+
+
+AttachmentUploadSessionStatusType = Literal["uploading", "ready", "failed", "expired"]
+
+
+class CreateAttachmentUploadSessionRequest(TypedDict):
+ """
+ Request body for creating a large-attachment upload session (Graph only).
+
+ Attributes:
+ filename: The name of the file as it will appear in the email.
+ content_type: MIME type of the file (e.g. 'application/pdf').
+ size: Expected file size in bytes (max 157286400 / 150 MB). Recommended —
+ Nylas validates the upload matches this size at completion.
+ """
+
+ filename: str
+ content_type: str
+ size: NotRequired[int]
+
+
+@dataclass_json
+@dataclass
+class AttachmentUploadSession:
+ """
+ Upload session returned when creating a large-attachment upload session.
+
+ Attributes:
+ attachment_id: Unique session ID — use when completing the session and when
+ referencing the attachment in send/draft.
+ method: HTTP method to use when uploading to `url`. Always 'PUT'.
+ url: Pre-signed URL to upload file bytes (no Nylas auth header needed).
+ headers: Headers to include when uploading to `url`.
+ expires_at: When the session expires (RFC 3339).
+ max_size: Maximum allowed file size in bytes (157286400).
+ size: Expected file size echoing the request; 0 if `size` was omitted.
+ content_type: MIME type of the file.
+ filename: Name of the file.
+ grant_id: Grant ID the session belongs to.
+ """
+
+ attachment_id: Optional[str] = None
+ method: Optional[str] = None
+ url: Optional[str] = None
+ headers: Optional[Dict[str, str]] = None
+ expires_at: Optional[str] = None
+ max_size: Optional[int] = None
+ size: Optional[int] = None
+ content_type: Optional[str] = None
+ filename: Optional[str] = None
+ grant_id: Optional[str] = None
+
+
+@dataclass_json
+@dataclass
+class AttachmentUploadSessionComplete:
+ """
+ Result of completing a large-attachment upload session.
+
+ Attributes:
+ attachment_id: The session ID.
+ grant_id: Grant ID the session belongs to.
+ status: Upload status; typically 'ready' after successful completion.
+ """
+
+ attachment_id: Optional[str] = None
+ grant_id: Optional[str] = None
+ status: Optional[str] = None
diff --git a/nylas/models/auth.py b/nylas/models/auth.py
new file mode 100644
index 00000000..97481e88
--- /dev/null
+++ b/nylas/models/auth.py
@@ -0,0 +1,204 @@
+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", "icloud", "virtual-calendar", "yahoo", "ews", "zoom", "nylas"]
+""" 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.
+ smtp_required: If True, adds options=smtp_required so users must enter SMTP settings during
+ authentication. Relevant for IMAP; avoids grant errors when sending email later.
+ """
+
+ client_id: str
+ 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]
+ credential_id: NotRequired[str]
+ smtp_required: NotRequired[bool]
+
+
+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.
+ email: Email address of the grant that is created.
+ refresh_token: Returned only if the code is requested using "access_type=offline".
+ id_token: A JWT that contains identity information about the user. Digitally signed by Nylas.
+ token_type: Always "Bearer".
+ provider: The provider that the code was exchanged with.
+ """
+
+ access_token: str
+ grant_id: str
+ expires_in: int
+ email: Optional[str] = None
+ refresh_token: Optional[str] = None
+ scope: Optional[str] = None
+ id_token: Optional[str] = None
+ token_type: Optional[str] = None
+ provider: Optional[Provider] = None
+
+
+@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.
+ all_provider_types: Search by all providers regardless of created integrations. If unset, defaults to false.
+ """
+
+ email: 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..87255ffc
--- /dev/null
+++ b/nylas/models/availability.py
@@ -0,0 +1,169 @@
+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 SpecificTimeAvailability(TypedDict):
+ """
+ Interface of a participant's availability for specific dates.
+ Overrides open_hours for the specified dates.
+
+ Attributes:
+ dates: The date in ISO 8601 format.
+ start: Start time in 24-hour time format. Leading 0's are left off.
+ end: End time in 24-hour time format. Leading 0's are left off.
+ """
+
+ date: str
+ start: str
+ end: str
+
+
+class AvailabilityRules(TypedDict):
+ """
+ Interface for the availability rules for a Nylas calendar.
+
+ 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_group_id: The ID on events that Nylas considers when calculating the order of
+ round-robin participants.
+ This is used for both max-fairness and max-availability methods.
+ tentative_as_busy: Controls whether tentative calendar events should be treated as busy time.
+ When set to false, tentative events will be considered as free in availability calculations.
+ Defaults to true. Only applicable for Microsoft and EWS calendar providers.
+ """
+
+ availability_method: NotRequired[AvailabilityMethod]
+ buffer: NotRequired[MeetingBuffer]
+ default_open_hours: NotRequired[List[OpenHours]]
+ round_robin_group_id: NotRequired[str]
+ tentative_as_busy: NotRequired[bool]
+
+
+class AvailabilityParticipant(TypedDict):
+ """
+ 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.
+ specific_time_availability: Specific availability for this participant that overrides open_hours
+ for the specified dates.
+ """
+
+ email: str
+ calendar_ids: NotRequired[List[str]]
+ open_hours: NotRequired[List[OpenHours]]
+ specific_time_availability: NotRequired[List[SpecificTimeAvailability]]
+
+
+class GetAvailabilityRequest(TypedDict):
+ """
+ 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.
+ Note: This field is deprecated, use round_to instead.
+ availability_rules: The rules to apply when checking availability.
+ round_to: The number of minutes to round the time slots to.
+ This allows for rounding to any multiple of 5 minutes, up to a maximum of 60 minutes.
+ The default value is set to 15 minutes.
+ When this variable is assigned a value, it overrides the behavior of the roundTo30Minutes flag,if it was set
+ """
+
+ start_time: int
+ end_time: int
+ participants: List[AvailabilityParticipant]
+ duration_minutes: int
+ interval_minutes: NotRequired[int]
+ round_to_30_minutes: NotRequired[bool]
+ availability_rules: NotRequired[AvailabilityRules]
+ round_to: NotRequired[int]
diff --git a/nylas/models/calendars.py b/nylas/models/calendars.py
new file mode 100644
index 00000000..95dbf6f9
--- /dev/null
+++ b/nylas/models/calendars.py
@@ -0,0 +1,247 @@
+from dataclasses import dataclass
+from typing import Dict, Any, Optional, List
+from enum import Enum
+
+from dataclasses_json import dataclass_json
+from typing_extensions import TypedDict, NotRequired
+
+from nylas.models.list_query_params import ListQueryParams
+
+
+class EventSelection(str, Enum):
+ """
+ Enum representing the different types of events to include for notetaking.
+
+ Values:
+ INTERNAL: Events where the host domain matches all participants' domain names
+ EXTERNAL: Events where the host domain differs from any participant's domain name
+ OWN_EVENTS: Events where the host is the same as the user's grant
+ PARTICIPANT_ONLY: Events where the user's grant is a participant but not the host
+ ALL: When all options are included, all events with meeting links will have Notetakers
+ """
+ INTERNAL = "internal"
+ EXTERNAL = "external"
+ OWN_EVENTS = "own_events"
+ PARTICIPANT_ONLY = "participant_only"
+ ALL = "all"
+
+
+@dataclass_json
+@dataclass
+class NotetakerParticipantFilter:
+ """
+ Class representation of Notetaker participant filter settings.
+
+ Attributes:
+ participants_gte: Only have meeting bot join meetings with greater than or equal to this number of participants.
+ participants_lte: Only have meeting bot join meetings with less than or equal to this number of participants.
+ """
+ participants_gte: Optional[int] = None
+ participants_lte: Optional[int] = None
+
+
+@dataclass_json
+@dataclass
+class NotetakerRules:
+ """
+ Class representation of Notetaker rules for joining meetings.
+
+ Attributes:
+ event_selection: Types of events to include for notetaking.
+ participant_filter: Filters to apply based on the number of participants.
+ """
+ event_selection: Optional[List[EventSelection]] = None
+ participant_filter: Optional[NotetakerParticipantFilter] = None
+
+
+@dataclass_json
+@dataclass
+class NotetakerMeetingSettings:
+ """
+ Class representation of Notetaker meeting settings.
+
+ Attributes:
+ video_recording: When true, Notetaker records the meeting's video.
+ audio_recording: When true, Notetaker records the meeting's audio.
+ transcription: When true, Notetaker transcribes the meeting's audio.
+ """
+ video_recording: Optional[bool] = True
+ audio_recording: Optional[bool] = True
+ transcription: Optional[bool] = True
+
+
+@dataclass_json
+@dataclass
+class CalendarNotetaker:
+ """
+ Class representation of Notetaker settings for a calendar.
+
+ Attributes:
+ name: The display name for the Notetaker bot.
+ meeting_settings: Notetaker Meeting Settings.
+ rules: Rules for when the Notetaker should join a meeting.
+ """
+ name: Optional[str] = "Nylas Notetaker"
+ meeting_settings: Optional[NotetakerMeetingSettings] = None
+ rules: Optional[NotetakerRules] = None
+
+
+@dataclass_json
+@dataclass
+class Calendar:
+ """
+ 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.
+ notetaker: Notetaker meeting bot settings for the calendar.
+ """
+
+ 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
+ notetaker: Optional[CalendarNotetaker] = 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.
+ select (NotRequired[str]): Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ """
+
+ metadata_pair: NotRequired[Dict[str, str]]
+
+
+class FindCalendarQueryParams(TypedDict):
+ """
+ Interface of the query parameters for finding a calendar.
+
+ Attributes:
+ select: Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ """
+
+ select: NotRequired[str]
+
+
+class NotetakerCalendarSettings(TypedDict):
+ """
+ Interface for Notetaker meeting settings for a calendar.
+
+ Attributes:
+ video_recording: When true, Notetaker records the meeting's video.
+ audio_recording: When true, Notetaker records the meeting's audio.
+ transcription: When true, Notetaker transcribes the meeting's audio.
+ """
+ video_recording: NotRequired[bool]
+ audio_recording: NotRequired[bool]
+ transcription: NotRequired[bool]
+
+
+class NotetakerCalendarParticipantFilter(TypedDict):
+ """
+ Interface for Notetaker participant filter settings.
+
+ Attributes:
+ participants_gte: Only have meeting bot join meetings with greater than or equal to this number of participants.
+ participants_lte: Only have meeting bot join meetings with less than or equal to this number of participants.
+ """
+ participants_gte: NotRequired[int]
+ participants_lte: NotRequired[int]
+
+
+class NotetakerCalendarRules(TypedDict):
+ """
+ Interface for Notetaker rules for joining meetings.
+
+ Attributes:
+ event_selection: Types of events to include for notetaking.
+ participant_filter: Filters to apply based on the number of participants.
+ """
+ event_selection: NotRequired[List[EventSelection]]
+ participant_filter: NotRequired[NotetakerCalendarParticipantFilter]
+
+
+class NotetakerCalendarRequest(TypedDict):
+ """
+ Interface for Notetaker settings in a calendar request.
+
+ Attributes:
+ name: The display name for the Notetaker bot.
+ meeting_settings: Notetaker Meeting Settings.
+ rules: Rules for when the Notetaker should join a meeting.
+ """
+ name: NotRequired[str]
+ meeting_settings: NotRequired[NotetakerCalendarSettings]
+ rules: NotRequired[NotetakerCalendarRules]
+
+
+class CreateCalendarRequest(TypedDict):
+ """
+ Interface of a Nylas create calendar request
+
+ 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.
+ notetaker: Notetaker meeting bot settings.
+ """
+
+ name: str
+ description: NotRequired[str]
+ location: NotRequired[str]
+ timezone: NotRequired[str]
+ metadata: NotRequired[Dict[str, str]]
+ notetaker: NotRequired[NotetakerCalendarRequest]
+
+
+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)
+ notetaker: Notetaker meeting bot settings.
+ """
+
+ hexColor: NotRequired[str]
+ hexForegroundColor: NotRequired[str]
+ notetaker: NotRequired[NotetakerCalendarRequest]
diff --git a/nylas/models/connectors.py b/nylas/models/connectors.py
new file mode 100644
index 00000000..65eb3306
--- /dev/null
+++ b/nylas/models/connectors.py
@@ -0,0 +1,159 @@
+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
+ active_credential_id: NotRequired[str]
+
+
+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]]
+ active_credential_id: NotRequired[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..9ddcc1cf
--- /dev/null
+++ b/nylas/models/contacts.py
@@ -0,0 +1,388 @@
+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 FindContactQueryParams(TypedDict):
+ """
+ The available query parameters for finding a contact.
+ Attributes:
+ profile_picture: If true and picture_url is present, the response includes a Base64 binary data blob that
+ you can use to view information as an image file.
+ select: Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ """
+
+ profile_picture: NotRequired[bool]
+ select: NotRequired[str]
+
+
+class WriteablePhoneNumber(TypedDict):
+ """
+ A phone number for a contact.
+
+ 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 representing the query parameters for listing contacts.
+
+ Attributes:
+ email: Return contacts with matching email address.
+ phone_number: Return contacts with matching phone number.
+ source: Return contacts from a specific source.
+ group: Return contacts from a specific group.
+ recurse: Return contacts from all sub-groups of the specified group.
+ select (NotRequired[str]): Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ limit (NotRequired[int]): The maximum number of objects to return.
+ This field defaults to 50. The maximum allowed value is 200.
+ page_token (NotRequired[str]): An identifier that specifies which page of data to return.
+ This value should be taken from a ListResponse object's next_cursor parameter.
+ """
+
+ email: NotRequired[str]
+ phone_number: NotRequired[str]
+ source: NotRequired[SourceType]
+ group: NotRequired[str]
+ recurse: 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/domains.py b/nylas/models/domains.py
new file mode 100644
index 00000000..cca1b7c1
--- /dev/null
+++ b/nylas/models/domains.py
@@ -0,0 +1,100 @@
+from dataclasses import dataclass, field
+from typing import Any, Literal, Optional
+
+from dataclasses_json import config, dataclass_json
+from typing_extensions import TypedDict
+
+from nylas.models.list_query_params import ListQueryParams
+
+DomainVerificationType = Literal["ownership", "dkim", "spf", "feedback", "mx"]
+
+
+class ListDomainsQueryParams(ListQueryParams):
+ """
+ Query parameters for listing domains.
+
+ Attributes:
+ limit: Maximum number of objects to return.
+ page_token: Cursor for the next page (from ``next_cursor`` on the previous response).
+ """
+
+ pass
+
+
+class CreateDomainRequest(TypedDict):
+ """Request body for registering a domain."""
+
+ name: str
+ domain_address: str
+
+
+class UpdateDomainRequest(TypedDict, total=False):
+ """Request body for updating a domain (currently only ``name`` is supported)."""
+
+ name: str
+
+
+class GetDomainInfoRequest(TypedDict):
+ """Request body for retrieving DNS records for a verification type."""
+
+ type: DomainVerificationType
+
+
+class VerifyDomainRequest(TypedDict):
+ """Request body for triggering DNS verification."""
+
+ type: DomainVerificationType
+
+
+@dataclass_json
+@dataclass
+class Domain:
+ """
+ A domain registered for Transactional Send or Nylas Inbound.
+ """
+
+ id: str
+ name: str
+ branded: bool
+ domain_address: str
+ organization_id: str
+ region: str
+ verified_ownership: bool
+ verified_dkim: bool
+ verified_spf: bool
+ verified_mx: bool
+ verified_feedback: bool
+ verified_dmarc: bool
+ verified_arc: bool
+ created_at: int
+ updated_at: int
+
+
+@dataclass_json
+@dataclass
+class DomainVerificationAttempt:
+ """
+ DNS verification attempt or required records for a verification type.
+ """
+
+ verification_type: Optional[str] = field(
+ default=None, metadata=config(field_name="type")
+ )
+ options: Optional[Any] = None
+ host: Optional[str] = None
+ value: Optional[str] = None
+ status: Optional[str] = None
+
+
+@dataclass_json
+@dataclass
+class DomainVerificationDetails:
+ """
+ Response data from get domain info or verify domain endpoints.
+ """
+
+ domain_id: str
+ attempt: Optional[DomainVerificationAttempt] = None
+ created_at: Optional[int] = None
+ expires_at: Optional[int] = None
+ message: Optional[str] = None
diff --git a/nylas/models/drafts.py b/nylas/models/drafts.py
new file mode 100644
index 00000000..78626b95
--- /dev/null
+++ b/nylas/models/drafts.py
@@ -0,0 +1,193 @@
+from dataclasses import dataclass
+from typing import List, Dict, Any, 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 CustomHeader(TypedDict):
+ """
+ A key-value pair representing a header that can be added to drafts and outgoing messages.
+
+ Attributes:
+ name: The name of the custom header.
+ value: The value of the custom header.
+ """
+
+ name: str
+ value: str
+
+
+class CreateDraftRequest(TypedDict):
+ """
+ A request to create a draft.
+
+ 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.
+ custom_headers: Custom headers to add to the message.
+ metadata: A dictionary of key-value pairs storing additional data.
+ is_plaintext: When true, the message body is sent as plain text and the MIME data doesn't include
+ the HTML version of the message. When false, the message body is sent as HTML.
+ """
+
+ body: NotRequired[str]
+ 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]
+ custom_headers: NotRequired[List[CustomHeader]]
+ metadata: NotRequired[Dict[str, Any]]
+ is_plaintext: NotRequired[bool]
+
+
+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],
+ "metadata_pair": NotRequired[str],
+ },
+)
+"""
+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.
+ metadata_pair (NotRequired[str]): Filter messages by metadata key/value pair.
+ limit (NotRequired[int]): The maximum number of objects to return.
+ This field defaults to 50. The maximum allowed value is 200.
+ page_token (NotRequired[str]): An identifier that specifies which page of data to return.
+ This value should be taken from a ListResponse object's next_cursor parameter.
+"""
+
+
+class FindDraftQueryParams(TypedDict):
+ """
+ Query parameters for finding a draft.
+
+ Attributes:
+ select: Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ """
+
+ select: NotRequired[str]
+
+
+class SendMessageRequest(CreateDraftRequest):
+ """
+ A request to send a message.
+
+ 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.
+ custom_headers(NotRequired[List[CustomHeader]]): Custom headers to add to the message.
+ is_plaintext (NotRequired[bool]): When true, the message body is sent as plain text and the MIME data
+ doesn't include the HTML version of the message. When false, the message body is sent as HTML.
+ from_: The sender of the message.
+ use_draft: Whether or not to use draft support. This is primarily used when dealing with large attachments.
+ """
+
+ from_: NotRequired[List[EmailName]]
+ use_draft: NotRequired[bool]
diff --git a/nylas/models/errors.py b/nylas/models/errors.py
new file mode 100644
index 00000000..43e02d4b
--- /dev/null
+++ b/nylas/models/errors.py
@@ -0,0 +1,171 @@
+from dataclasses import dataclass
+from typing import Optional
+
+from requests.structures import CaseInsensitiveDict
+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.
+ headers: The headers returned from the API.
+ """
+
+ def __init__(
+ self,
+ message: str,
+ request_id: Optional[str] = None,
+ status_code: Optional[int] = None,
+ headers: Optional[CaseInsensitiveDict] = 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
+ self.headers: CaseInsensitiveDict = headers
+ 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.
+ headers: The headers returned from the API.
+ """
+
+ def __init__(
+ self,
+ api_error: NylasApiErrorResponse,
+ status_code: Optional[int] = None,
+ headers: Optional[CaseInsensitiveDict] = None,
+ ):
+ """
+ Args:
+ api_error: The error details from the API.
+ status_code: The HTTP status code of the error response.
+ """
+ super().__init__(api_error.error.message, api_error.request_id, status_code, headers)
+ self.type: str = api_error.error.type
+ self.provider_error: Optional[dict] = api_error.error.provider_error
+ self.headers: CaseInsensitiveDict = headers
+
+class NylasOAuthError(AbstractNylasApiError):
+ """
+ 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,
+ headers: Optional[CaseInsensitiveDict] = None,
+ ):
+ """
+ Args:
+ oauth_error: The error details from the API.
+ status_code: The HTTP status code of the error response.
+ """
+ super().__init__(oauth_error.error_description, None, status_code, headers)
+ self.error: str = oauth_error.error
+ self.error_code: int = oauth_error.error_code
+ self.error_description: str = oauth_error.error_description
+ self.error_uri: str = oauth_error.error_uri
+ self.headers: CaseInsensitiveDict = headers
+
+class NylasSdkTimeoutError(AbstractNylasSdkError):
+ """
+ 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, headers: Optional[CaseInsensitiveDict] = None):
+ """
+ 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
+ self.headers: CaseInsensitiveDict = headers
diff --git a/nylas/models/events.py b/nylas/models/events.py
new file mode 100644
index 00000000..3fa7686e
--- /dev/null
+++ b/nylas/models/events.py
@@ -0,0 +1,936 @@
+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. """
+
+EventType = Literal["default", "outOfOffice", "focusTime", "workingLocation"]
+""" Literal representing the event type to filter by. """
+
+
+@dataclass_json
+@dataclass
+class Participant:
+ """
+ Interface representing an Event participant.
+
+ Attributes:
+ email: Participant's email address. Required for all providers except Microsoft.
+ name: Participant's name.
+ status: Participant's status.
+ comment: Comment by the participant.
+ phone_number: Participant's phone number.
+ """
+
+ email: Optional[str] = None
+ status: Optional[ParticipantStatus] = None
+ 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", "unknown"
+]
+""" 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 conferencing object into a Conferencing object.
+
+ Args:
+ conferencing: The conferencing object to decode.
+
+ Returns:
+ The decoded Conferencing object, or None if empty or incomplete.
+ """
+ if not conferencing:
+ return None
+
+ # Handle details case - must have provider to be valid
+ if "details" in conferencing and "provider" in conferencing:
+ return Details.from_dict(conferencing)
+
+ # Handle autocreate case - must have provider to be valid
+ if "autocreate" in conferencing and "provider" in conferencing:
+ return Autocreate.from_dict(conferencing)
+
+ # Handle case where provider exists but details/autocreate doesn't
+ if "provider" in conferencing:
+ # Create a Details object with empty details
+ details_dict = {
+ "provider": conferencing["provider"],
+ "details": (
+ conferencing.get("conf_settings", {})
+ if "conf_settings" in conferencing
+ else {}
+ ),
+ }
+ return Details.from_dict(details_dict)
+
+ # Handle unknown or incomplete conferencing objects by returning None
+ # This provides backwards compatibility for malformed conferencing data
+ return None
+
+
+@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 NotetakerMeetingSettings:
+ """
+ Class representing Notetaker meeting settings.
+
+ Attributes:
+ video_recording: When true, Notetaker records the meeting's video.
+ audio_recording: When true, Notetaker records the meeting's audio.
+ transcription: When true, Notetaker transcribes the meeting's audio.
+ """
+
+ video_recording: Optional[bool] = True
+ audio_recording: Optional[bool] = True
+ transcription: Optional[bool] = True
+
+
+@dataclass_json
+@dataclass
+class EventNotetaker:
+ """
+ Class representing Notetaker settings for an event.
+
+ Attributes:
+ id: The Notetaker bot ID.
+ name: The display name for the Notetaker bot.
+ meeting_settings: Notetaker Meeting Settings.
+ """
+
+ id: Optional[str] = None
+ name: Optional[str] = "Nylas Notetaker"
+ meeting_settings: Optional[NotetakerMeetingSettings] = None
+
+
+@dataclass_json
+@dataclass
+class Event:
+ """
+ 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.
+ master_event_id: For recurring events, this field contains the main (master) event's ID.
+ notetaker: Notetaker meeting bot settings.
+ """
+
+ id: str
+ grant_id: str
+ calendar_id: str
+ busy: bool
+ participants: List[Participant]
+ when: When = field(metadata=config(decoder=_decode_when))
+ conferencing: Optional[Conferencing] = field(
+ default=None, metadata=config(decoder=_decode_conferencing)
+ )
+ object: str = "event"
+ visibility: Optional[Visibility] = None
+ read_only: Optional[bool] = None
+ description: Optional[str] = None
+ location: Optional[str] = None
+ 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
+ created_at: Optional[int] = None
+ updated_at: Optional[int] = None
+ master_event_id: Optional[str] = None
+ notetaker: Optional[EventNotetaker] = 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: NotRequired[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 EventNotetakerSettings(TypedDict):
+ """
+ Interface representing Notetaker meeting settings for an event.
+
+ Attributes:
+ video_recording: When true, Notetaker records the meeting's video.
+ audio_recording: When true, Notetaker records the meeting's audio.
+ transcription: When true, Notetaker transcribes the meeting's audio.
+ """
+
+ video_recording: NotRequired[bool]
+ audio_recording: NotRequired[bool]
+ transcription: NotRequired[bool]
+
+
+class EventNotetakerRequest(TypedDict):
+ """
+ Interface representing Notetaker settings for an event.
+
+ Attributes:
+ id: The Notetaker bot ID.
+ name: The display name for the Notetaker bot.
+ meeting_settings: Notetaker Meeting Settings.
+ """
+
+ id: NotRequired[str]
+ name: NotRequired[str]
+ meeting_settings: NotRequired[EventNotetakerSettings]
+
+
+class CreateEventNotetaker(TypedDict):
+ """
+ Class representing Notetaker settings for an event.
+
+ Attributes:
+ name: The display name for the Notetaker bot.
+ meeting_settings: Notetaker Meeting Settings.
+ """
+
+ name: Optional[str] = "Nylas Notetaker"
+ meeting_settings: Optional[EventNotetakerSettings] = None
+
+class CreateEventRequest(TypedDict):
+ """
+ Interface representing a request to create an event.
+
+ 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.
+ notetaker: Notetaker meeting bot settings.
+ """
+
+ 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]
+ notetaker: NotRequired[CreateEventNotetaker]
+
+
+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.
+ notetaker: Notetaker meeting bot settings.
+ """
+
+ 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]
+ notetaker: NotRequired[EventNotetakerRequest]
+
+
+class ListEventQueryParams(ListQueryParams):
+ """
+ Interface representing the query parameters for listing events.
+
+ Attributes:
+ calendar_id: Specify calendar ID of the event. "primary" is a supported value
+ indicating the user's primary calendar.
+ show_cancelled: Return events that have a status of cancelled.
+ If an event is recurring, then it returns no matter the value set.
+ Different providers have different semantics for cancelled events.
+ 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.
+ busy: Returns events with a busy status of true.
+ order_by: Order results by the specified field.
+ Currently only start is supported.
+ event_type: (Google only) Filter events by event type.
+ You can pass the query parameter multiple times to select or exclude multiple event types.
+ master_event_id: Filter for instances of recurring events with the given
+ master event ID. Not respected by metadata filtering.
+ select: Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ tentative_as_busy: When set to false, treats tentative calendar events as busy:false.
+ Only applicable for Microsoft and EWS calendar providers. Defaults to true.
+ limit: The maximum number of objects to return.
+ This field defaults to 50. The maximum allowed value is 200.
+ page_token: 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]
+ event_type: NotRequired[List[EventType]]
+ master_event_id: NotRequired[str]
+ select: NotRequired[str]
+ tentative_as_busy: NotRequired[bool]
+
+
+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.
+ tentative_as_busy: When set to false, treats tentative calendar events as busy:false.
+ Only applicable for Microsoft and EWS calendar providers. Defaults to true.
+ """
+
+ calendar_id: str
+ notify_participants: NotRequired[bool]
+ tentative_as_busy: NotRequired[bool]
+
+
+class FindEventQueryParams(TypedDict):
+ """
+ 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.
+ tentative_as_busy: When set to false, treats tentative calendar events as busy:false.
+ Only applicable for Microsoft and EWS calendar providers. Defaults to true.
+ """
+
+ calendar_id: str
+ tentative_as_busy: NotRequired[bool]
+
+
+UpdateEventQueryParams = CreateEventQueryParams
+""" 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
+
+
+class ListImportEventsQueryParams(ListQueryParams):
+ """
+ Interface representing the query parameters for listing imported events.
+
+ Attributes:
+ calendar_id: Specify calendar ID to import events to. "primary" is a supported value
+ indicating the user's primary calendar.
+ start: Filter for events that start at or after the specified time, in Unix timestamp format.
+ end: Filter for events that end at or before the specified time, in Unix timestamp format.
+ select: Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ page_token: An identifier that specifies which page of data to return.
+ This value should be taken from a ListResponse object's next_cursor parameter.
+ """
+
+ calendar_id: str
+ start: NotRequired[int]
+ end: NotRequired[int]
+ select: NotRequired[str]
+ page_token: NotRequired[str]
diff --git a/nylas/models/folders.py b/nylas/models/folders.py
new file mode 100644
index 00000000..5eac55f3
--- /dev/null
+++ b/nylas/models/folders.py
@@ -0,0 +1,113 @@
+from dataclasses import dataclass
+from typing import 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 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.
+ attributes: Common attribute descriptors shared by system folders across providers.
+ For example, Sent email folders have the `["\\Sent"]` attribute.
+ For IMAP grants, IMAP providers provide the attributes.
+ For Google and Microsoft Graph, Nylas matches system folders to a set of common attributes.
+ """
+
+ id: str
+ 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
+ attributes: Optional[str] = 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]
+
+
+class ListFolderQueryParams(ListQueryParams):
+ """
+ Interface representing the query parameters for listing folders.
+
+ Attributes:
+ parent_id: (Microsoft and EWS only.) Use the ID of a folder to find all child folders it contains.
+ include_hidden_folders: (Microsoft only) When true, Nylas includes hidden folders in its response.
+ single_level: (Microsoft only) If true, retrieves folders from a single-level hierarchy only.
+ If false, retrieves folders across a multi-level hierarchy. Defaults to false.
+ select (NotRequired[str]): Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ limit (NotRequired[int]): The maximum number of objects to return.
+ This field defaults to 50. The maximum allowed value is 200.
+ page_token (NotRequired[str]): An identifier that specifies which page of data to return.
+ This value should be taken from a ListResponse object's next_cursor parameter.
+ """
+
+ parent_id: NotRequired[str]
+ include_hidden_folders: NotRequired[bool]
+ single_level: NotRequired[bool]
+
+
+class FindFolderQueryParams(TypedDict):
+ """
+ Interface representing the query parameters for finding a folder.
+
+ Attributes:
+ select: Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ """
+
+ select: NotRequired[str]
diff --git a/nylas/models/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..7d793a21
--- /dev/null
+++ b/nylas/models/grants.py
@@ -0,0 +1,112 @@
+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.
+ account_id: Globally unique identifier for your v2 account that has been migrated using our migration APIs.
+ scope: Scopes specified for the grant.
+ created_at: Unix timestamp when the grant was created.
+ grant_status: Status of the grant, if it is still valid or if the user needs to re-authenticate.
+ 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)
+ account_id: Optional[str] = None
+ 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
+ credential_id: Optional[str] = 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.
+ sort_by: Sort entries by field name.
+ order_by: Specify ascending or descending order.
+ since: Scope grants from a specific point in time by Unix timestamp.
+ before: Scope grants to a specific point in time by Unix timestamp.
+ email: Filtering your query based on grant email address (if applicable)
+ grant_status: Filtering your query based on grant email status (if applicable)
+ ip: Filtering your query based on grant IP address
+ provider: Filtering your query based on OAuth provider
+ sortBy: Deprecated camelCase alias for sort_by.
+ orderBy: Deprecated camelCase alias for order_by.
+ grantStatus: Deprecated camelCase alias for grant_status.
+ """
+
+ limit: NotRequired[int]
+ offset: NotRequired[int]
+ sort_by: NotRequired[str]
+ order_by: NotRequired[str]
+ since: NotRequired[int]
+ before: NotRequired[int]
+ email: NotRequired[str]
+ grant_status: NotRequired[str]
+ ip: NotRequired[str]
+ provider: NotRequired[Provider]
+ # Backward-compatible aliases for callers still passing camelCase keys.
+ sortBy: NotRequired[str]
+ orderBy: NotRequired[str]
+ grantStatus: NotRequired[str]
diff --git a/nylas/models/list_query_params.py b/nylas/models/list_query_params.py
new file mode 100644
index 00000000..6ac6b645
--- /dev/null
+++ b/nylas/models/list_query_params.py
@@ -0,0 +1,19 @@
+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.
+ select: Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ """
+
+ limit: NotRequired[int]
+ page_token: NotRequired[str]
+ select: NotRequired[str]
diff --git a/nylas/models/lists.py b/nylas/models/lists.py
new file mode 100644
index 00000000..c4732e61
--- /dev/null
+++ b/nylas/models/lists.py
@@ -0,0 +1,69 @@
+from dataclasses import dataclass
+from typing import List as TypingList, Literal, Optional
+
+from dataclasses_json import dataclass_json
+from typing_extensions import NotRequired, TypedDict
+
+from nylas.models.list_query_params import ListQueryParams
+
+ListType = Literal["domain", "tld", "address"]
+
+
+class ListListsQueryParams(ListQueryParams):
+ """Query parameters for listing lists."""
+
+ pass
+
+
+class ListListItemsQueryParams(ListQueryParams):
+ """Query parameters for listing items in a list."""
+
+ pass
+
+
+class CreateListRequest(TypedDict):
+ """Request body for creating a list."""
+
+ name: str
+ type: ListType
+ description: NotRequired[str]
+
+
+class UpdateListRequest(TypedDict, total=False):
+ """Request body for updating a list."""
+
+ name: NotRequired[str]
+ description: NotRequired[str]
+
+
+class UpdateListItemsRequest(TypedDict):
+ """Request body for adding/removing list items."""
+
+ items: TypingList[str]
+
+
+@dataclass_json
+@dataclass
+class NylasList:
+ """A typed collection used in `in_list` rule conditions."""
+
+ id: Optional[str] = None
+ name: Optional[str] = None
+ description: Optional[str] = None
+ type: Optional[str] = None
+ items_count: Optional[int] = None
+ application_id: Optional[str] = None
+ organization_id: Optional[str] = None
+ created_at: Optional[int] = None
+ updated_at: Optional[int] = None
+
+
+@dataclass_json
+@dataclass
+class ListItem:
+ """A single value belonging to a Nylas list."""
+
+ id: Optional[str] = None
+ list_id: Optional[str] = None
+ value: Optional[str] = None
+ created_at: Optional[int] = None
diff --git a/nylas/models/messages.py b/nylas/models/messages.py
new file mode 100644
index 00000000..9caf5bfc
--- /dev/null
+++ b/nylas/models/messages.py
@@ -0,0 +1,298 @@
+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", "include_tracking_options", "raw_mime"]
+""" 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 TrackingOptions:
+ """
+ Message tracking options.
+
+ Attributes:
+ opens: When true, shows that message open tracking is enabled.
+ thread_replies: When true, shows that thread replied tracking is enabled.
+ links: When true, shows that link clicked tracking is enabled.
+ label: A label describing the message tracking purpose.
+ """
+
+ opens: Optional[bool] = None
+ thread_replies: Optional[bool] = None
+ links: Optional[bool] = None
+ label: Optional[str] = None
+
+
+@dataclass_json
+@dataclass
+class Message:
+ """
+ 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.
+ schedule_id: The ID of the scheduled email message. Nylas returns the schedule_id if send_at is set.
+ send_at: Unix timestamp of when the message will be sent, if scheduled.
+ tracking_options: The tracking options for the message.
+ raw_mime: A Base64url-encoded string containing the message data (including the body content).
+ """
+
+ grant_id: str
+ from_: Optional[List[EmailName]] = field(
+ default=None, 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
+ schedule_id: Optional[str] = None
+ send_at: Optional[int] = None
+ metadata: Optional[Dict[str, Any]] = None
+ tracking_options: Optional[TrackingOptions] = None
+ raw_mime: Optional[str] = None
+
+
+# Need to use Functional typed dicts because "from" and "in" are Python
+# 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[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],
+ "select": NotRequired[str],
+ "metadata_pair": 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 which headers to include in the response.
+ - "standard" (default): Returns the standard message payload.
+ - "include_headers": Returns messages and their custom headers.
+ - "include_tracking_options": Returns messages and their tracking settings.
+ - "raw_mime": Returns the grant_id, object, id, and raw_mime fields for each message.
+ search_query_native: A native provider search query for Google or Microsoft.
+ select: Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ 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 (NotRequired[str]): Pass a metadata key/value pair (for example, ?metadata_pair=key1:value)
+ to search for metadata associated with objects. See Metadata for more information.
+"""
+
+
+class FindMessageQueryParams(TypedDict):
+ """
+ Query parameters for finding a message.
+
+ Attributes:
+ fields: Specify which headers to include in the response.
+ - "standard" (default): Returns the standard message payload.
+ - "include_headers": Returns messages and their custom headers.
+ - "include_tracking_options": Returns messages and their tracking settings.
+ - "raw_mime": Returns the grant_id, object, id, and raw_mime fields for each message.
+ select: Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ """
+
+ fields: NotRequired[Fields]
+ select: NotRequired[str]
+
+
+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: str
+ status: ScheduledMessageStatus
+ close_time: Optional[int] = None
+
+
+@dataclass_json
+@dataclass
+class StopScheduledMessageResponse:
+ """
+ The response from stopping a scheduled message.
+
+ Attributes:
+ message: A message describing the result of the request.
+ """
+
+ message: str
+
+
+class CleanMessagesRequest(TypedDict):
+ """
+ Request to clean a list of messages.
+
+ Attributes:
+ message_id: IDs of the email messages to clean.
+ ignore_links: If true, removes link-related tags () from the email message while keeping the text.
+ ignore_images: If true, removes images from the email message.
+ images_as_markdown: If true, converts images in the email message to Markdown.
+ ignore_tables: If true, removes table-related tags (, | , | , | ) from the email message while
+ keeping rows.
+ remove_conclusion_phrases: If true, removes phrases such as "Best" and "Regards" in the email message signature.
+ """
+
+ message_id: List[str]
+ ignore_links: NotRequired[bool]
+ ignore_images: NotRequired[bool]
+ images_as_markdown: NotRequired[bool]
+ ignore_tables: NotRequired[bool]
+ remove_conclusion_phrases: NotRequired[bool]
+
+
+@dataclass_json
+@dataclass
+class CleanMessagesResponse(Message):
+ """
+ Message object with the cleaned HTML message body.
+
+ Attributes:
+ id (str): Globally unique object identifier.
+ grant_id (str): The grant that this message belongs to.
+ from_ (List[EmailName]): The sender of the message.
+ date (int): The date the message was received.
+ object: The type of object.
+ thread_id (Optional[str]): The thread that this message belongs to.
+ subject (Optional[str]): The subject of the message.
+ to (Optional[List[EmailName]]): The recipients of the message.
+ cc (Optional[List[EmailName]]): The CC recipients of the message.
+ bcc (Optional[List[EmailName]]): The BCC recipients of the message.
+ reply_to (Optional[List[EmailName]]): The reply-to recipients of the message.
+ unread (Optional[bool]): Whether the message is unread.
+ starred (Optional[bool]): Whether the message is starred.
+ snippet (Optional[str]): A snippet of the message body.
+ body (Optional[str]): The body of the message.
+ attachments (Optional[List[Attachment]]): The attachments on the message.
+ folders (Optional[List[str]]): The folders that the message is in.
+ created_at (Optional[int]): Unix timestamp of when the message was created.
+ conversation (str): The cleaned HTML message body.
+ """
+
+ conversation: str = ""
diff --git a/nylas/models/notetakers.py b/nylas/models/notetakers.py
new file mode 100644
index 00000000..17badf6e
--- /dev/null
+++ b/nylas/models/notetakers.py
@@ -0,0 +1,309 @@
+from dataclasses import dataclass
+from enum import Enum
+from typing import Optional
+
+from dataclasses_json import dataclass_json
+from typing_extensions import NotRequired, TypedDict
+
+from nylas.models.list_query_params import ListQueryParams
+
+
+class NotetakerState(str, Enum):
+ """
+ Enum representing the possible states of a Notetaker bot.
+
+ Values:
+ SCHEDULED: The Notetaker is scheduled to join a meeting.
+ CONNECTING: The Notetaker is connecting to the meeting.
+ WAITING_FOR_ENTRY: The Notetaker is waiting to be admitted to the meeting.
+ FAILED_ENTRY: The Notetaker failed to join the meeting.
+ ATTENDING: The Notetaker is currently in the meeting.
+ MEDIA_PROCESSING: The Notetaker is processing media from the meeting.
+ MEDIA_AVAILABLE: The Notetaker has processed media available for download.
+ MEDIA_ERROR: An error occurred while processing the media.
+ MEDIA_DELETED: The meeting media has been deleted.
+ """
+
+ SCHEDULED = "scheduled"
+ CONNECTING = "connecting"
+ WAITING_FOR_ENTRY = "waiting_for_entry"
+ FAILED_ENTRY = "failed_entry"
+ ATTENDING = "attending"
+ MEDIA_PROCESSING = "media_processing"
+ MEDIA_AVAILABLE = "media_available"
+ MEDIA_ERROR = "media_error"
+ MEDIA_DELETED = "media_deleted"
+
+
+class NotetakerOrderBy(str, Enum):
+ """
+ Enum representing the possible fields to order Notetaker bots by.
+
+ Values:
+ NAME: Order by the Notetaker's name.
+ JOIN_TIME: Order by the Notetaker's join time.
+ CREATED_AT: Order by when the Notetaker was created.
+ """
+
+ NAME = "name"
+ JOIN_TIME = "join_time"
+ CREATED_AT = "created_at"
+
+
+class NotetakerOrderDirection(str, Enum):
+ """
+ Enum representing the possible directions to order Notetaker bots by.
+
+ Values:
+ ASC: Ascending order.
+ DESC: Descending order.
+ """
+
+ ASC = "asc"
+ DESC = "desc"
+
+
+class MeetingProvider(str, Enum):
+ """
+ Enum representing the possible meeting providers for Notetaker.
+
+ Values:
+ GOOGLE_MEET: Google Meet meetings
+ ZOOM: Zoom meetings
+ MICROSOFT_TEAMS: Microsoft Teams meetings
+ """
+
+ GOOGLE_MEET = "Google Meet"
+ ZOOM = "Zoom Meeting"
+ MICROSOFT_TEAMS = "Microsoft Teams"
+
+
+class NotetakerMeetingSettingsRequest(TypedDict):
+ """
+ Interface representing Notetaker meeting settings for request objects.
+
+ Attributes:
+ video_recording: When true, Notetaker records the meeting's video.
+ audio_recording: When true, Notetaker records the meeting's audio.
+ transcription: When true, Notetaker transcribes the meeting's audio.
+ If transcription is true, audio_recording must also be true.
+ """
+
+ video_recording: Optional[bool]
+ audio_recording: Optional[bool]
+ transcription: Optional[bool]
+
+
+@dataclass_json
+@dataclass
+class NotetakerMeetingSettings:
+ """
+ Class representing Notetaker meeting settings.
+
+ Attributes:
+ video_recording: When true, Notetaker records the meeting's video.
+ audio_recording: When true, Notetaker records the meeting's audio.
+ transcription: When true, Notetaker transcribes the meeting's audio.
+ If transcription is true, audio_recording must also be true.
+ """
+
+ video_recording: bool = True
+ audio_recording: bool = True
+ transcription: bool = True
+
+
+@dataclass_json
+@dataclass
+class NotetakerMediaRecording:
+ """
+ Class representing a Notetaker media recording.
+
+ Attributes:
+ size: The size of the file in bytes.
+ name: The name of the file.
+ type: The MIME type of the file.
+ created_at: Unix timestamp when the file was uploaded to the storage server.
+ expires_at: Unix timestamp when the file will be deleted.
+ url: A link to download the file.
+ ttl: Time-to-live in seconds until the file will be deleted off Nylas' storage server.
+ """
+
+ size: int
+ name: str
+ type: str
+ created_at: int
+ expires_at: int
+ url: str
+ ttl: int
+
+
+@dataclass_json
+@dataclass
+class NotetakerMedia:
+ """
+ Class representing Notetaker media.
+
+ Attributes:
+ recording: The meeting recording (video/mp4).
+ transcript: The meeting transcript (application/json).
+ """
+
+ recording: Optional[NotetakerMediaRecording] = None
+ transcript: Optional[NotetakerMediaRecording] = None
+
+
+@dataclass_json
+@dataclass
+class Notetaker:
+ """
+ Class representing a Nylas Notetaker.
+
+ Attributes:
+ id: The Notetaker ID.
+ name: The display name for the Notetaker bot.
+ join_time: When Notetaker joined the meeting, in Unix timestamp format.
+ meeting_link: The meeting link.
+ meeting_provider: The meeting provider.
+ state: The current state of the Notetaker bot.
+ meeting_settings: Notetaker Meeting Settings.
+ message: A message describing the API response (only included in some responses).
+ """
+
+ id: str
+ name: str
+ join_time: int
+ meeting_link: str
+ state: NotetakerState
+ meeting_settings: NotetakerMeetingSettings
+ meeting_provider: Optional[MeetingProvider] = None
+ message: Optional[str] = None
+ object: str = "notetaker"
+
+ def is_state(self, state: NotetakerState) -> bool:
+ """
+ Check if the notetaker is in a specific state.
+
+ Args:
+ state: The NotetakerState to check against.
+
+ Returns:
+ True if the notetaker is in the specified state, False otherwise.
+ """
+ return self.state == state
+
+ def is_scheduled(self) -> bool:
+ """Check if the notetaker is in the scheduled state."""
+ return self.is_state(NotetakerState.SCHEDULED)
+
+ def is_attending(self) -> bool:
+ """Check if the notetaker is currently attending a meeting."""
+ return self.is_state(NotetakerState.ATTENDING)
+
+ def has_media_available(self) -> bool:
+ """Check if the notetaker has media available for download."""
+ return self.is_state(NotetakerState.MEDIA_AVAILABLE)
+
+
+class InviteNotetakerRequest(TypedDict):
+ """
+ Interface representing the Nylas notetaker creation request.
+
+ Attributes:
+ meeting_link: A meeting invitation link that Notetaker uses to join the meeting.
+ join_time: When Notetaker should join the meeting, in Unix timestamp format.
+ If empty, Notetaker joins the meeting immediately.
+ name: The display name for the Notetaker bot.
+ meeting_settings: Notetaker Meeting Settings.
+ """
+
+ meeting_link: str
+ join_time: NotRequired[int]
+ name: NotRequired[str]
+ meeting_settings: NotRequired[NotetakerMeetingSettingsRequest]
+
+
+class UpdateNotetakerRequest(TypedDict):
+ """
+ Interface representing the Nylas notetaker update request.
+
+ Attributes:
+ join_time: When Notetaker should join the meeting, in Unix timestamp format.
+ name: The display name for the Notetaker bot.
+ meeting_settings: Notetaker Meeting Settings.
+ """
+
+ join_time: NotRequired[int]
+ name: NotRequired[str]
+ meeting_settings: NotRequired[NotetakerMeetingSettingsRequest]
+
+
+class ListNotetakerQueryParams(ListQueryParams):
+ """
+ Interface representing the query parameters for listing notetakers.
+
+ Attributes:
+ state: Filter for Notetaker bots with the specified meeting state.
+ Use the NotetakerState enum.
+ Example: state=NotetakerState.SCHEDULED
+ join_time_start: Filter for Notetaker bots that have join times that start at or after a specific time,
+ in Unix timestamp format.
+ join_time_end: Filter for Notetaker bots that have join times that end at or are before a specific time,
+ in Unix timestamp format.
+ limit: The maximum number of objects to return. This field defaults to 50. The maximum allowed value is 200.
+ page_token: An identifier that specifies which page of data to return.
+ prev_page_token: An identifier that specifies which page of data to return.
+ order_by: The field to order the Notetaker bots by. Defaults to created_at.
+ Use the NotetakerOrderBy enum.
+ Example: order_by=NotetakerOrderBy.NAME
+ order_direction: The direction to order the Notetaker bots by. Defaults to asc.
+ Use the NotetakerOrderDirection enum.
+ Example: order_direction=NotetakerOrderDirection.DESC
+ """
+
+ state: NotRequired[NotetakerState]
+ join_time_start: NotRequired[int]
+ join_time_end: NotRequired[int]
+ order_by: NotRequired[NotetakerOrderBy]
+ order_direction: NotRequired[NotetakerOrderDirection]
+
+ def __post_init__(self):
+ """Convert enums to string values for API requests."""
+ # Convert state enum to string if present
+ if hasattr(self, "state") and isinstance(self.state, NotetakerState):
+ self.state = self.state.value
+ # Convert order_by enum to string if present
+ if hasattr(self, "order_by") and isinstance(self.order_by, NotetakerOrderBy):
+ self.order_by = self.order_by.value
+ # Convert order_direction enum to string if present
+ if hasattr(self, "order_direction") and isinstance(
+ self.order_direction, NotetakerOrderDirection
+ ):
+ self.order_direction = self.order_direction.value
+
+
+class FindNotetakerQueryParams(TypedDict):
+ """
+ Interface representing the query parameters for finding a notetaker.
+
+ Attributes:
+ select: Comma-separated list of fields to return in the response.
+ Use this to limit the fields returned in the response.
+ """
+
+ select: NotRequired[str]
+
+
+@dataclass_json
+@dataclass
+class NotetakerLeaveResponse:
+ """
+ Class representing a Notetaker leave response.
+
+ Attributes:
+ id: The Notetaker ID.
+ message: A message describing the API response.
+ """
+
+ id: str
+ message: str
+ object: str = "notetaker_leave_response"
diff --git a/nylas/models/policies.py b/nylas/models/policies.py
new file mode 100644
index 00000000..48dbafef
--- /dev/null
+++ b/nylas/models/policies.py
@@ -0,0 +1,118 @@
+from dataclasses import dataclass
+from typing import List, Optional
+
+from dataclasses_json import dataclass_json
+from typing_extensions import NotRequired, TypedDict
+
+from nylas.models.list_query_params import ListQueryParams
+
+
+class ListPoliciesQueryParams(ListQueryParams):
+ """
+ Query parameters for listing policies.
+
+ Attributes:
+ limit: Maximum number of objects to return.
+ page_token: Cursor for the next page (from ``next_cursor`` on the previous response).
+ """
+
+ pass
+
+
+class PolicyOptionsRequest(TypedDict, total=False):
+ """Request shape for policy options."""
+
+ additional_folders: NotRequired[List[str]]
+ use_cidr_aliasing: NotRequired[bool]
+
+
+class PolicyLimitsRequest(TypedDict, total=False):
+ """Request shape for policy limits."""
+
+ limit_attachment_size_limit: NotRequired[int]
+ limit_attachment_count_limit: NotRequired[int]
+ limit_attachment_allowed_types: NotRequired[List[str]]
+ limit_size_total_mime: NotRequired[int]
+ limit_storage_total: NotRequired[int]
+ limit_count_daily_message_per_grant: NotRequired[int]
+ limit_inbox_retention_period: NotRequired[int]
+ limit_spam_retention_period: NotRequired[int]
+
+
+class PolicySpamDetectionRequest(TypedDict, total=False):
+ """Request shape for policy spam detection settings."""
+
+ use_list_dnsbl: NotRequired[bool]
+ use_header_anomaly_detection: NotRequired[bool]
+ spam_sensitivity: NotRequired[float]
+
+
+class CreatePolicyRequest(TypedDict):
+ """Request body for creating a policy."""
+
+ name: str
+ options: NotRequired[PolicyOptionsRequest]
+ limits: NotRequired[PolicyLimitsRequest]
+ rules: NotRequired[List[str]]
+ spam_detection: NotRequired[PolicySpamDetectionRequest]
+
+
+class UpdatePolicyRequest(TypedDict, total=False):
+ """Request body for updating a policy."""
+
+ name: NotRequired[str]
+ options: NotRequired[PolicyOptionsRequest]
+ limits: NotRequired[PolicyLimitsRequest]
+ rules: NotRequired[List[str]]
+ spam_detection: NotRequired[PolicySpamDetectionRequest]
+
+
+@dataclass_json
+@dataclass
+class PolicyOptions:
+ """Policy options applied to inboxes that use this policy."""
+
+ additional_folders: Optional[List[str]] = None
+ use_cidr_aliasing: Optional[bool] = None
+
+
+@dataclass_json
+@dataclass
+class PolicyLimits:
+ """Operational limits applied to inboxes that use this policy."""
+
+ limit_attachment_size_limit: Optional[int] = None
+ limit_attachment_count_limit: Optional[int] = None
+ limit_attachment_allowed_types: Optional[List[str]] = None
+ limit_size_total_mime: Optional[int] = None
+ limit_storage_total: Optional[int] = None
+ limit_count_daily_message_per_grant: Optional[int] = None
+ limit_inbox_retention_period: Optional[int] = None
+ limit_spam_retention_period: Optional[int] = None
+
+
+@dataclass_json
+@dataclass
+class PolicySpamDetection:
+ """Spam detection settings applied to inboxes that use this policy."""
+
+ use_list_dnsbl: Optional[bool] = None
+ use_header_anomaly_detection: Optional[bool] = None
+ spam_sensitivity: Optional[float] = None
+
+
+@dataclass_json
+@dataclass
+class Policy:
+ """A policy for Nylas Agent Accounts."""
+
+ id: Optional[str] = None
+ name: Optional[str] = None
+ application_id: Optional[str] = None
+ organization_id: Optional[str] = None
+ options: Optional[PolicyOptions] = None
+ limits: Optional[PolicyLimits] = None
+ rules: Optional[List[str]] = None
+ spam_detection: Optional[PolicySpamDetection] = None
+ created_at: Optional[int] = None
+ updated_at: Optional[int] = None
diff --git a/nylas/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..64b03d0d
--- /dev/null
+++ b/nylas/models/response.py
@@ -0,0 +1,180 @@
+from dataclasses import dataclass
+from typing import TypeVar, Generic, Optional, List
+
+from dataclasses_json import DataClassJsonMixin
+
+from requests.structures import CaseInsensitiveDict
+
+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
+ headers: Optional[CaseInsensitiveDict] = None
+
+ def __new__(cls, data: T, request_id: str, headers: Optional[CaseInsensitiveDict] = None):
+ """
+ Initialize the response object.
+
+ Args:
+ data: The requested data object.
+ request_id: The request ID.
+ headers: The headers returned from the API.
+ """
+ # Initialize the tuple for destructuring support
+ instance = super().__new__(cls, (data, request_id, headers))
+
+ instance.data = data
+ instance.request_id = request_id
+ instance.headers = headers
+
+ return instance
+
+ @classmethod
+ def from_dict(cls, resp: dict, generic_type, headers: Optional[CaseInsensitiveDict] = None):
+ """
+ Convert a dictionary to a response object.
+
+ Args:
+ resp: The dictionary to convert.
+ generic_type: The type to deserialize the data object into.
+ headers: The headers returned from the API.
+ """
+
+ return cls(
+ data=generic_type.from_dict(resp["data"]),
+ request_id=resp["request_id"],
+ headers=headers,
+ )
+
+
+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.
+ headers: The headers returned from the API.
+ """
+
+ data: List[T]
+ request_id: str
+ next_cursor: Optional[str] = None
+ headers: Optional[CaseInsensitiveDict] = None
+
+ def __new__(
+ cls,
+ data: List[T],
+ request_id: str,
+ next_cursor: Optional[str] = None,
+ headers: Optional[CaseInsensitiveDict] = None
+ ):
+ """
+ Initialize the response object.
+
+ 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.
+ headers: The headers returned from the API.
+ """
+ # Initialize the tuple for destructuring support
+ instance = super().__new__(cls, (data, request_id, next_cursor, headers))
+
+ instance.data = data
+ instance.request_id = request_id
+ instance.next_cursor = next_cursor
+ instance.headers = headers
+
+ return instance
+
+ @classmethod
+ def from_dict(cls, resp: dict, generic_type, headers: Optional[CaseInsensitiveDict] = None):
+ """
+ Convert a dictionary to a response object.
+
+ Args:
+ resp: The dictionary to convert.
+ generic_type: The type to deserialize the data objects into.
+ headers: The headers returned from the API.
+ """
+
+ raw_data = resp.get("data", [])
+ if isinstance(raw_data, dict):
+ next_cursor = resp.get("next_cursor", raw_data.get("next_cursor"))
+ data = raw_data.get("items", [])
+ else:
+ next_cursor = resp.get("next_cursor")
+ data = raw_data
+
+ converted_data = []
+ for item in data:
+ converted_data.append(generic_type.from_dict(item, infer_missing=True))
+
+ return cls(
+ data=converted_data,
+ request_id=resp["request_id"],
+ next_cursor=next_cursor,
+ headers=headers,
+ )
+
+
+@dataclass
+class DeleteResponse:
+ """
+ Delete response object returned from the Nylas API.
+
+ Attributes:
+ request_id: The request ID returned from the API.
+ headers: The headers returned from the API.
+ """
+
+ request_id: str
+ headers: Optional[CaseInsensitiveDict] = None
+
+ @classmethod
+ def from_dict(cls, resp: dict, headers: Optional[CaseInsensitiveDict] = None):
+ """
+ Convert a dictionary to a response object.
+
+ Args:
+ resp: The dictionary to convert.
+ headers: The headers returned from the API.
+ """
+ return cls(request_id=resp["request_id"], headers=headers)
+
+
+@dataclass
+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.
+ headers: The headers returned from the API.
+ """
+
+ request_id: str
+ headers: Optional[CaseInsensitiveDict] = None
+
+ @classmethod
+ def from_dict(cls, resp: dict, headers: Optional[CaseInsensitiveDict] = None):
+ """
+ Convert a dictionary to a response object.
+
+ Args:
+ resp: The dictionary to convert.
+ headers: The headers returned from the API.
+ """
+ return cls(request_id=resp["request_id"], headers=headers)
diff --git a/nylas/models/rules.py b/nylas/models/rules.py
new file mode 100644
index 00000000..2299c1f7
--- /dev/null
+++ b/nylas/models/rules.py
@@ -0,0 +1,155 @@
+from dataclasses import dataclass
+from typing import Any, List, Optional
+
+from dataclasses_json import dataclass_json
+from typing_extensions import NotRequired, TypedDict
+
+from nylas.models.list_query_params import ListQueryParams
+
+
+class ListRulesQueryParams(ListQueryParams):
+ """Query parameters for listing rules."""
+
+ pass
+
+
+class ListRuleEvaluationsQueryParams(ListQueryParams):
+ """Query parameters for listing rule evaluations."""
+
+ pass
+
+
+class RuleConditionRequest(TypedDict):
+ """A single condition used in a rule match clause."""
+
+ field: str
+ operator: str
+ value: Any
+
+
+class RuleMatchRequest(TypedDict):
+ """Match clause for create/update rule requests."""
+
+ conditions: List[RuleConditionRequest]
+ operator: NotRequired[str]
+
+
+class RuleActionRequest(TypedDict):
+ """Action object used in create/update rule requests."""
+
+ type: str
+ value: NotRequired[str]
+
+
+class CreateRuleRequest(TypedDict):
+ """Request body for creating a rule."""
+
+ name: str
+ match: RuleMatchRequest
+ actions: List[RuleActionRequest]
+ description: NotRequired[str]
+ priority: NotRequired[int]
+ enabled: NotRequired[bool]
+ trigger: NotRequired[str]
+
+
+class UpdateRuleRequest(TypedDict, total=False):
+ """Request body for updating a rule."""
+
+ name: NotRequired[str]
+ match: NotRequired[RuleMatchRequest]
+ actions: NotRequired[List[RuleActionRequest]]
+ description: NotRequired[str]
+ priority: NotRequired[int]
+ enabled: NotRequired[bool]
+ trigger: NotRequired[str]
+
+
+@dataclass_json
+@dataclass
+class RuleCondition:
+ """A condition in a rule match clause."""
+
+ field: Optional[str] = None
+ operator: Optional[str] = None
+ value: Optional[Any] = None
+
+
+@dataclass_json
+@dataclass
+class RuleMatch:
+ """A rule's condition set and matching strategy."""
+
+ operator: Optional[str] = None
+ conditions: Optional[List[RuleCondition]] = None
+
+
+@dataclass_json
+@dataclass
+class RuleAction:
+ """An action applied when a rule matches."""
+
+ type: Optional[str] = None
+ value: Optional[str] = None
+
+
+@dataclass_json
+@dataclass
+class Rule:
+ """A rule used for automated filtering and routing."""
+
+ id: Optional[str] = None
+ name: Optional[str] = None
+ description: Optional[str] = None
+ priority: Optional[int] = None
+ enabled: Optional[bool] = None
+ trigger: Optional[str] = None
+ match: Optional[RuleMatch] = None
+ actions: Optional[List[RuleAction]] = None
+ application_id: Optional[str] = None
+ organization_id: Optional[str] = None
+ created_at: Optional[int] = None
+ updated_at: Optional[int] = None
+
+
+@dataclass_json
+@dataclass
+class RuleEvaluationInput:
+ """Sender data used as input to rule evaluation."""
+
+ from_address: Optional[str] = None
+ from_domain: Optional[str] = None
+ from_tld: Optional[str] = None
+
+
+@dataclass_json
+@dataclass
+class RuleEvaluationAppliedActions:
+ """Actions applied when rules matched."""
+
+ blocked: Optional[bool] = None
+ marked_as_spam: Optional[bool] = None
+ marked_as_read: Optional[bool] = None
+ marked_starred: Optional[bool] = None
+ archived: Optional[bool] = None
+ trashed: Optional[bool] = None
+ folder_ids: Optional[List[str]] = None
+
+
+@dataclass_json
+@dataclass
+class RuleEvaluation:
+ """An audit record describing rule evaluation for a grant."""
+
+ id: Optional[str] = None
+ grant_id: Optional[str] = None
+ message_id: Optional[str] = None
+ evaluated_at: Optional[int] = None
+ evaluation_stage: Optional[str] = None
+ evaluation_input: Optional[RuleEvaluationInput] = None
+ applied_actions: Optional[RuleEvaluationAppliedActions] = None
+ matched_rule_ids: Optional[List[str]] = None
+ application_id: Optional[str] = None
+ organization_id: Optional[str] = None
+ created_at: Optional[int] = None
+ updated_at: Optional[int] = None
diff --git a/nylas/models/scheduler.py b/nylas/models/scheduler.py
new file mode 100644
index 00000000..155e2441
--- /dev/null
+++ b/nylas/models/scheduler.py
@@ -0,0 +1,532 @@
+from dataclasses import dataclass, field
+from typing import Dict, List, Literal, Optional
+
+from dataclasses_json import config, dataclass_json
+from typing_extensions import NotRequired, TypedDict
+
+from nylas.models.availability import AvailabilityRules, OpenHours
+from nylas.models.events import Conferencing, _decode_conferencing
+
+BookingType = Literal["booking", "organizer-confirmation"]
+BookingReminderType = Literal["email", "webhook"]
+BookingRecipientType = Literal["host", "guest", "all"]
+EmailLanguage = Literal["en", "es", "fr", "de", "nl", "sv", "ja", "zh"]
+AdditionalFieldType = Literal[
+ "text",
+ "multi_line_text",
+ "email",
+ "phone_number",
+ "dropdown",
+ "date",
+ "checkbox",
+ "radio_button",
+]
+AdditonalFieldOptionsType = Literal[
+ "text", "email", "phone_number", "date", "checkbox", "radio_button"
+]
+
+
+@dataclass_json
+@dataclass
+class BookingConfirmedTemplate:
+ """
+ Class representation of booking confirmed template settings.
+
+ Attributes:
+ title: The title to replace the default 'Booking Confirmed' title.
+ body: The additional body to be appended after the default body.
+ """
+
+ title: Optional[str] = None
+ body: Optional[str] = None
+
+
+@dataclass_json
+@dataclass
+class EmailTemplate:
+ """
+ Class representation of email template settings.
+
+ Attributes:
+ logo: The URL to a custom logo that appears at the top of the booking email.
+ booking_confirmed: Configurable settings specifically for booking confirmed emails.
+ """
+
+ # logo: Optional[str] = None
+ booking_confirmed: Optional[BookingConfirmedTemplate] = None
+
+
+@dataclass_json
+@dataclass
+class AdditionalField:
+ """
+ Class representation of an additional field.
+
+ Atributes:
+ label: The text label to be displayed in the Scheduler UI.
+ type: The field type. Supported values are text, multi_line_text,
+ email, phone_number, dropdown, date, checkbox, and radio_button
+ required: Whether the field is required to be filled out by the guest when booking an event.
+ pattern: A regular expression pattern that the value of the field must match.
+ order: The order in which the field will be displayed in the Scheduler UI.
+ Fields with lower order values will be displayed first.
+ options: A list of options for the dropdown or radio_button types.
+ This field is required for the dropdown and radio_button types.
+ """
+
+ label: str
+ type: AdditionalFieldType
+ required: bool
+ pattern: Optional[str] = None
+ order: Optional[int] = None
+ options: Optional[AdditonalFieldOptionsType] = None
+
+
+@dataclass_json
+@dataclass
+class SchedulerSettings:
+ """
+ Class representation of scheduler settings.
+
+ Attributes:
+ additional_fields: Definitions for additional fields to be displayed in the Scheduler UI.
+ available_days_in_future: Number of days in the future that Scheduler is available for scheduling events.
+ min_booking_notice: Minimum number of minutes in the future that a user can make a new booking.
+ min_cancellation_notice: Minimum number of minutes before a booking can be cancelled.
+ cancellation_policy: A message about the cancellation policy to display when booking an event.
+ rescheduling_url: The URL used to reschedule bookings.
+ cancellation_url: The URL used to cancel bookings.
+ organizer_confirmation_url: The URL used to confirm or cancel pending bookings.
+ confirmation_redirect_url: The custom URL to redirect to once the booking is confirmed.
+ hide_rescheduling_options: Whether the option to reschedule an event
+ is hidden in booking confirmations and notifications.
+ hide_cancellation_options: Whether the option to cancel an event
+ is hidden in booking confirmations and notifications.
+ hide_additional_guests: Whether to hide the additional guests field on the scheduling page.
+ email_template: Configurable settings for booking emails.
+ """
+
+ additional_fields: Optional[Dict[str, AdditionalField]] = None
+ available_days_in_future: Optional[int] = None
+ min_booking_notice: Optional[int] = None
+ min_cancellation_notice: Optional[int] = None
+ cancellation_policy: Optional[str] = None
+ rescheduling_url: Optional[str] = None
+ cancellation_url: Optional[str] = None
+ organizer_confirmation_url: Optional[str] = None
+ confirmation_redirect_url: Optional[str] = None
+ hide_rescheduling_options: Optional[bool] = None
+ hide_cancellation_options: Optional[bool] = None
+ hide_additional_guests: Optional[bool] = None
+ email_template: Optional[EmailTemplate] = None
+
+
+@dataclass_json
+@dataclass
+class BookingReminder:
+ """
+ Class representation of a booking reminder.
+
+ Attributes:
+ type: The reminder type.
+ minutes_before_event: The number of minutes before the event to send the reminder.
+ recipient: The recipient of the reminder.
+ email_subject: The subject of the email reminder.
+ """
+
+ type: str
+ minutes_before_event: int
+ recipient: Optional[str] = None
+ email_subject: Optional[str] = None
+
+
+@dataclass_json
+@dataclass
+class EventBooking:
+ """
+ Class representation of an event booking.
+
+ Attributes:
+ title: The title of the event.
+ description: The description of the event.
+ location: The location of the event.
+ timezone: The timezone for displaying times in confirmation email messages and reminders.
+ booking_type: The type of booking.
+ conferencing: Conference details for the event.
+ disable_emails: Whether Nylas sends email messages when an event is booked, cancelled, or rescheduled.
+ reminders: The list of reminders to send to participants before the event starts.
+ """
+
+ title: str
+ description: Optional[str] = None
+ location: Optional[str] = None
+ timezone: Optional[str] = None
+ booking_type: Optional[BookingType] = None
+ conferencing: Optional[Conferencing] = field(
+ default=None, metadata=config(decoder=_decode_conferencing)
+ )
+ disable_emails: Optional[bool] = None
+ reminders: Optional[List[BookingReminder]] = None
+
+
+@dataclass_json
+@dataclass
+class Availability:
+ """
+ Class representation of availability settings.
+
+ Attributes:
+ duration_minutes: The total number of minutes the event should last.
+ interval_minutes: The interval between meetings in minutes.
+ round_to: Nylas rounds each time slot to the nearest multiple of this number of minutes.
+ availability_rules: Availability rules for scheduling configuration.
+ """
+
+ duration_minutes: int
+ interval_minutes: Optional[int] = None
+ round_to: Optional[int] = None
+ availability_rules: Optional[AvailabilityRules] = None
+
+
+@dataclass_json
+@dataclass
+class ParticipantBooking:
+ """
+ Class representation of a participant booking.
+
+ Attributes:
+ calendar_id: The calendar ID that the event is created in.
+ """
+
+ calendar_id: str
+
+
+@dataclass_json
+@dataclass
+class ParticipantAvailability:
+ """
+ Class representation of participant availability.
+
+ Attributes:
+ calendar_ids: List of calendar IDs associated with the participant's email address.
+ open_hours: Open hours for this participant. The endpoint searches for free time slots during these open hours.
+ """
+
+ calendar_ids: List[str]
+ open_hours: Optional[List[OpenHours]] = None
+
+
+@dataclass_json
+@dataclass
+class ConfigParticipant:
+ """
+ Class representation of a booking participant.
+
+ Attributes:
+ email: Participant's email address.
+ availability: Availability data for the participant.
+ booking: Booking data for the participant.
+ name: Participant's name.
+ is_organizer: Whether the participant is the organizer of the event.
+ timezone: The participant's timezone.
+ """
+
+ email: str
+ availability: ParticipantAvailability
+ booking: ParticipantBooking
+ name: Optional[str] = None
+ is_organizer: Optional[bool] = None
+ timezone: Optional[str] = None
+
+
+@dataclass_json
+@dataclass
+class Configuration:
+ """
+ Class representation of a scheduler configuration.
+
+ Attributes:
+ participants: List of participants included in the scheduled event.
+ availability: Rules that determine available time slots for the event.
+ event_booking: Booking data for the event.
+ slug: Unique identifier for the Configuration object.
+ requires_session_auth: If true, scheduling Availability and Bookings endpoints require a valid session ID.
+ scheduler: Settings for the Scheduler UI.
+ appearance: Appearance settings for the Scheduler UI.
+ """
+
+ id: str
+ participants: List[ConfigParticipant]
+ availability: Availability
+ event_booking: EventBooking
+ slug: Optional[str] = None
+ requires_session_auth: Optional[bool] = None
+ scheduler: Optional[SchedulerSettings] = None
+ appearance: Optional[Dict[str, str]] = None
+
+
+class CreateConfigurationRequest(TypedDict):
+ """
+ Interface of a Nylas create configuration request.
+
+ Attributes:
+ participants: List of participants included in the scheduled event.
+ availability: Rules that determine available time slots for the event.
+ event_booking: Booking data for the event.
+ slug: Unique identifier for the Configuration object.
+ requires_session_auth: If true, scheduling Availability and Bookings endpoints require a valid session ID.
+ scheduler: Settings for the Scheduler UI.
+ appearance: Appearance settings for the Scheduler UI.
+ """
+
+ participants: List[ConfigParticipant]
+ availability: Availability
+ event_booking: EventBooking
+ slug: NotRequired[str]
+ requires_session_auth: NotRequired[bool]
+ scheduler: NotRequired[SchedulerSettings]
+ appearance: NotRequired[Dict[str, str]]
+
+
+class UpdateConfigurationRequest(TypedDict):
+ """
+ Interface of a Nylas update configuration request.
+
+ Attributes:
+ participants: List of participants included in the scheduled event.
+ availability: Rules that determine available time slots for the event.
+ event_booking: Booking data for the event.
+ slug: Unique identifier for the Configuration object.
+ requires_session_auth: If true, scheduling Availability and Bookings endpoints require a valid session ID.
+ scheduler: Settings for the Scheduler UI.
+ appearance: Appearance settings for the Scheduler UI.
+ """
+
+ participants: NotRequired[List[ConfigParticipant]]
+ availability: NotRequired[Availability]
+ event_booking: NotRequired[EventBooking]
+ slug: NotRequired[str]
+ requires_session_auth: NotRequired[bool]
+ scheduler: NotRequired[SchedulerSettings]
+ appearance: NotRequired[Dict[str, str]]
+
+
+class CreateSessionRequest(TypedDict):
+ """
+ Interface of a Nylas create session request.
+
+ Attributes:
+ configuration_id: The ID of the Configuration object whose settings are used for calculating availability.
+ If you're using session authentication (requires_session_auth is set to true),
+ configuration_id is not required.
+ slug: The slug of the Configuration object whose settings are used for calculating availability.
+ If you're using session authentication (requires_session_auth is set to true) or using configurationId,
+ slug is not required.
+ time_to_live: The time-to-live in seconds for the session
+ """
+
+ configuration_id: NotRequired[str]
+ slug: NotRequired[str]
+ time_to_live: NotRequired[int]
+
+
+@dataclass_json
+@dataclass
+class Session:
+ """
+ Class representation of a session.
+
+ Attributes:
+ session_id: The ID of the session.
+ """
+
+ session_id: str
+
+
+@dataclass_json
+@dataclass
+class BookingGuest:
+ """
+ Class representation of a booking guest.
+
+ Attributes:
+ email: The email address of the guest.
+ name: The name of the guest.
+ """
+
+ email: str
+ name: str
+
+
+@dataclass_json
+@dataclass
+class BookingParticipant:
+ """
+ Class representation of a booking participant.
+
+ Attributes:
+ email: The email address of the participant to include in the booking.
+ """
+
+ email: str
+
+
+@dataclass_json
+@dataclass
+class CreateBookingRequest:
+ """
+ Class representation of a create booking request.
+
+ Attributes:
+ start_time: The event's start time, in Unix epoch format.
+ end_time: The event's end time, in Unix epoch format.
+ guest: Details about the guest that is creating the booking.
+ participants: List of participant email addresses from the
+ Configuration object to include in the booking.
+ timezone: The guest's timezone that is used in email notifications.
+ email_language: The language of the guest email notifications.
+ additional_guests: List of additional guest email addresses to include in the booking.
+ additional_fields: Dictionary of additional field keys mapped to
+ values populated by the guest in the booking form.
+ """
+
+ start_time: int
+ end_time: int
+ guest: BookingGuest
+ participants: Optional[List[BookingParticipant]] = None
+ timezone: Optional[str] = None
+ email_language: Optional[EmailLanguage] = None
+ additional_guests: Optional[List[BookingGuest]] = None
+ additional_fields: Optional[Dict[str, str]] = None
+
+
+@dataclass_json
+@dataclass
+class BookingOrganizer:
+ """
+ Class representation of a booking organizer.
+
+ Attributes:
+ email: The email address of the participant designated as the organizer of the event.
+ name: The name of the participant designated as the organizer of the event.
+ """
+
+ email: str
+ name: Optional[str] = None
+
+
+BookingStatus = Literal["pending", "confirmed", "cancelled"]
+ConfirmBookingStatus = Literal["confirm", "cancel"]
+
+
+@dataclass_json
+@dataclass
+class Booking:
+ """
+ Class representation of a booking.
+
+ Attributes:
+ booking_id: The unique ID of the booking.
+ event_id: The unique ID of the event associated with the booking.
+ title: The title of the event.
+ organizer: The participant designated as the organizer of the event.
+ status: The current status of the booking.
+ description: The description of the event.
+ """
+
+ booking_id: str
+ event_id: str
+ title: str
+ organizer: BookingOrganizer
+ status: BookingStatus
+ description: Optional[str] = None
+
+
+@dataclass_json
+@dataclass
+class ConfirmBookingRequest:
+ """
+ Class representation of a confirm booking request.
+
+ Attributes:
+ salt: The salt extracted from the booking reference embedded in the organizer confirmation link.
+ status: The action to take on the pending booking.
+ cancellation_reason: The reason the booking is being cancelled.
+ """
+
+ salt: str
+ status: ConfirmBookingStatus
+ cancellation_reason: Optional[str] = None
+
+
+@dataclass_json
+@dataclass
+class DeleteBookingRequest:
+ """
+ Class representation of a delete booking request.
+
+ Attributes:
+ cancellation_reason: The reason the booking is being cancelled.
+ """
+
+ cancellation_reason: Optional[str] = None
+
+
+@dataclass_json
+@dataclass
+class RescheduleBookingRequest:
+ """
+ Class representation of a reschedule booking request.
+
+ Attributes:
+ start_time: The event's start time, in Unix epoch format.
+ end_time: The event's end time, in Unix epoch format.
+ """
+
+ start_time: int
+ end_time: int
+
+
+@dataclass_json
+@dataclass
+class CreateBookingQueryParams:
+ """
+ Class representation of query parameters for creating a booking.
+
+ Attributes:
+ configuration_id: The ID of the Configuration object whose settings are used for calculating availability.
+ If you're using session authentication (requires_session_auth is set to true), configuration_id is not required.
+ slug: The slug of the Configuration object whose settings are used for calculating availability.
+ If you're using session authentication (requires_session_auth is set to true) or using configurationId,
+ slug is not required.
+ timezone: The timezone to use for the booking.
+ If not provided, Nylas uses the timezone from the Configuration object.
+ """
+
+ configuration_id: Optional[str] = None
+ slug: Optional[str] = None
+ timezone: Optional[str] = None
+
+
+class FindBookingQueryParams:
+ """
+ Class representation of query parameters for finding a booking.
+
+ Attributes:
+ configuration_id: The ID of the Configuration object whose settings are used for calculating availability.
+ If you're using session authentication (requires_session_auth is set to true), configuration_id is not required.
+ slug: The slug of the Configuration object whose settings are used for calculating availability.
+ If you're using session authentication (requires_session_auth is set to true)
+ or using configurationId, slug is not required.
+ client_id: The client ID that was used to create the Configuration object.
+ client_id is required only if using slug.
+ """
+
+ configuration_id: Optional[str] = None
+ slug: Optional[str] = None
+ client_id: Optional[str] = None
+
+
+ConfirmBookingQueryParams = FindBookingQueryParams
+RescheduleBookingQueryParams = FindBookingQueryParams
+DestroyBookingQueryParams = FindBookingQueryParams
diff --git a/nylas/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..17798e87
--- /dev/null
+++ b/nylas/models/threads.py
@@ -0,0 +1,148 @@
+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
+ message_ids: List[str]
+ folders: List[str]
+ latest_draft_or_message: Union[Message, Draft] = field(
+ metadata=config(decoder=_decode_draft_or_message)
+ )
+ object: str = "thread"
+ earliest_message_date: Optional[int] = None
+ latest_message_received_date: Optional[int] = None
+ draft_ids: Optional[List[str]] = None
+ snippet: Optional[str] = None
+ 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],
+ "earliest_message_date": NotRequired[int],
+ "latest_message_before": NotRequired[int],
+ "latest_message_after": NotRequired[int],
+ "has_attachment": NotRequired[bool],
+ "search_query_native": NotRequired[str],
+ "select": 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.
+ earliest_message_date: Unix timestamp of the earliest or first message in the thread.
+ latest_message_before: Return threads whose most recent message was received before this Unix timestamp.
+ latest_message_after: Return threads whose most recent message was received after this Unix timestamp.
+ has_attachment: Filter threads by whether they have an attachment.
+ search_query_native: A native provider search query for Google or Microsoft.
+ select: Comma-separated list of fields to return in the response.
+ This allows you to receive only the portion of object data that you're interested in.
+ limit (NotRequired[int]): The maximum number of objects to return.
+ This field defaults to 50. The maximum allowed value is 200.
+ page_token (NotRequired[str]): An identifier that specifies which page of data to return.
+ This value should be taken from a ListResponse object's next_cursor parameter.
+"""
diff --git a/nylas/models/transactional_send.py b/nylas/models/transactional_send.py
new file mode 100644
index 00000000..b9b4a815
--- /dev/null
+++ b/nylas/models/transactional_send.py
@@ -0,0 +1,63 @@
+from typing import Any, Dict, List
+
+from typing_extensions import NotRequired, Required, TypedDict
+
+from nylas.models.attachments import CreateAttachmentRequest
+from nylas.models.drafts import CustomHeader, TrackingOptions
+from nylas.models.events import EmailName
+
+
+class TransactionalTemplate(TypedDict, total=False):
+ """
+ Template selection for a transactional send request.
+
+ Attributes:
+ id: The template ID.
+ strict: When true, Nylas returns an error if the template contains undefined variables.
+ variables: Key/value pairs substituted into the template.
+ """
+
+ id: Required[str]
+ strict: NotRequired[bool]
+ variables: NotRequired[Dict[str, Any]]
+
+
+class TransactionalSendMessageRequest(TypedDict, total=False):
+ """
+ Request body for POST /v3/domains/{domain_name}/messages/send.
+
+ Use ``from_`` for the sender; it is serialized as JSON ``from`` (``from`` is a Python keyword).
+
+ Attributes:
+ to: Recipients (required by the API).
+ from_: Sender ``email`` / optional ``name`` (required by the API).
+ subject: Subject line.
+ body: HTML or plain body depending on ``is_plaintext``.
+ cc: CC recipients.
+ bcc: BCC recipients.
+ reply_to: Reply-To recipients.
+ attachments: File attachments.
+ send_at: Unix timestamp to send the message later.
+ reply_to_message_id: Message being replied to.
+ tracking_options: Open/link tracking settings.
+ custom_headers: Custom MIME headers.
+ metadata: String-keyed metadata.
+ is_plaintext: Send body as plain text when true.
+ template: Application template to render (optional vs. body/subject).
+ """
+
+ to: Required[List[EmailName]]
+ from_: Required[EmailName]
+ subject: NotRequired[str]
+ body: NotRequired[str]
+ cc: NotRequired[List[EmailName]]
+ bcc: NotRequired[List[EmailName]]
+ reply_to: NotRequired[List[EmailName]]
+ attachments: NotRequired[List[CreateAttachmentRequest]]
+ send_at: NotRequired[int]
+ reply_to_message_id: NotRequired[str]
+ tracking_options: NotRequired[TrackingOptions]
+ custom_headers: NotRequired[List[CustomHeader]]
+ metadata: NotRequired[Dict[str, Any]]
+ is_plaintext: NotRequired[bool]
+ template: NotRequired[TransactionalTemplate]
diff --git a/nylas/models/webhooks.py b/nylas/models/webhooks.py
new file mode 100644
index 00000000..7cbee4a1
--- /dev/null
+++ b/nylas/models/webhooks.py
@@ -0,0 +1,167 @@
+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."""
+ BOOKING_CREATED = "booking.created"
+ BOOKING_PENDING = "booking.pending"
+ BOOKING_RESCHEDULED = "booking.rescheduled"
+ BOOKING_CANCELLED = "booking.cancelled"
+ BOOKING_REMINDER = "booking.reminder"
+ CALENDAR_CREATED = "calendar.created"
+ CALENDAR_UPDATED = "calendar.updated"
+ CALENDAR_DELETED = "calendar.deleted"
+ CONTACT_UPDATED = "contact.updated"
+ CONTACT_DELETED = "contact.deleted"
+ EVENT_CREATED = "event.created"
+ EVENT_UPDATED = "event.updated"
+ EVENT_DELETED = "event.deleted"
+ 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"
+ MESSAGE_BOUNCE_DETECTED = "message.bounce_detected"
+ MESSAGE_CREATED = "message.created"
+ MESSAGE_UPDATED = "message.updated"
+ MESSAGE_DELETED= "message.deleted"
+ MESSAGE_OPENED = "message.opened"
+ MESSAGE_LINK_CLICKED = "message.link_clicked"
+ MESSAGE_OPENED_LEGACY = "message.opened.legacy"
+ MESSAGE_LINK_CLICKED_LEGACY = "message.link_clicked.legacy"
+ MESSAGE_INTELLIGENCE_ORDER = "message.intelligence.order"
+ MESSAGE_INTELLIGENCE_TRACKING = "message.intelligence.tracking"
+ MESSAGE_INTELLIGENCE_RETURN = "message.intelligence.return"
+ THREAD_REPLIED = "thread.replied"
+ THREAD_REPLIED_LEGACY = "thread.replied.legacy"
+ FOLDER_CREATED = "folder.created"
+ FOLDER_UPDATED = "folder.updated"
+ FOLDER_DELETED = "folder.deleted"
+
+
+@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
+
+
+@dataclass_json
+@dataclass
+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..ed20c5ae
--- /dev/null
+++ b/nylas/resources/applications.py
@@ -0,0 +1,40 @@
+from nylas.config import RequestOverrides
+from nylas.models.application_details import ApplicationDetails
+from nylas.models.response import Response
+from nylas.resources.redirect_uris import RedirectUris
+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, overrides: RequestOverrides = None) -> Response[ApplicationDetails]:
+ """
+ Get the application information.
+
+ Args:
+ overrides: The query parameters to include in the request.
+
+ Returns:
+ Response: The application information.
+ """
+
+ json_response, headers = self._http_client._execute(
+ method="GET", path="/v3/applications", overrides=overrides
+ )
+ return Response.from_dict(json_response, ApplicationDetails, headers)
diff --git a/nylas/resources/attachments.py b/nylas/resources/attachments.py
new file mode 100644
index 00000000..1c4616b5
--- /dev/null
+++ b/nylas/resources/attachments.py
@@ -0,0 +1,177 @@
+from requests import Response
+
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import (
+ FindableApiResource,
+ CreatableApiResource,
+)
+from nylas.models.attachments import (
+ Attachment,
+ FindAttachmentQueryParams,
+ CreateAttachmentUploadSessionRequest,
+ AttachmentUploadSession,
+ AttachmentUploadSessionComplete,
+)
+from nylas.models.response import Response as NylasResponse
+
+
+class Attachments(
+ FindableApiResource,
+ CreatableApiResource,
+):
+ """
+ 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,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The attachment metadata.
+ """
+ return super().find(
+ path=f"/v3/grants/{identifier}/attachments/{attachment_id}",
+ response_type=Attachment,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def download(
+ self,
+ identifier: str,
+ attachment_id: str,
+ query_params: FindAttachmentQueryParams,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for 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,
+ overrides=overrides,
+ )
+
+ def download_bytes(
+ self,
+ identifier: str,
+ attachment_id: str,
+ query_params: FindAttachmentQueryParams,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for 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,
+ overrides=overrides,
+ )
+
+ def create_upload_session(
+ self,
+ identifier: str,
+ request_body: CreateAttachmentUploadSessionRequest,
+ overrides: RequestOverrides = None,
+ ) -> NylasResponse[AttachmentUploadSession]:
+ """
+ Create a resumable upload session for a large attachment (up to 150 MB).
+
+ After receiving the session, upload file bytes via HTTP PUT to the returned
+ `url` (include the returned `headers`; no Nylas auth header needed), then
+ call complete_upload_session() with the returned `attachment_id`.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ request_body: Session parameters (filename, content_type, optional size).
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The upload session, including the pre-signed URL and attachment_id.
+ """
+ return super().create(
+ path=f"/v3/grants/{identifier}/attachment-uploads",
+ response_type=AttachmentUploadSession,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def complete_upload_session(
+ self,
+ identifier: str,
+ attachment_id: str,
+ overrides: RequestOverrides = None,
+ ) -> NylasResponse[AttachmentUploadSessionComplete]:
+ """
+ Complete an upload session after file bytes have been PUT to the pre-signed URL.
+
+ Use the `attachment_id` from the completed session when referencing the
+ attachment in a subsequent messages.send() or drafts.create() call.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ attachment_id: The upload session ID returned by create_upload_session().
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The completed session status.
+ """
+ return super().create(
+ path=f"/v3/grants/{identifier}/attachment-uploads/{attachment_id}/complete",
+ response_type=AttachmentUploadSessionComplete,
+ request_body={},
+ overrides=overrides,
+ )
diff --git a/nylas/resources/auth.py b/nylas/resources/auth.py
new file mode 100644
index 00000000..6129bb28
--- /dev/null
+++ b/nylas/resources/auth.py
@@ -0,0 +1,286 @@
+import base64
+import hashlib
+import uuid
+
+from nylas.config import RequestOverrides
+from nylas.handler.http_client import _build_query_params
+from nylas.models.grants import CreateGrantRequest, Grant
+
+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"])
+
+ if config.pop("smtp_required", None):
+ config["options"] = "smtp_required"
+
+ 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, overrides: RequestOverrides = None
+ ) -> CodeExchangeResponse:
+ """
+ Exchange an authorization code for an access token.
+
+ Args:
+ request: The request parameters for the code exchange
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ Information about the Nylas application
+ """
+ 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, overrides)
+
+ def custom_authentication(
+ self, request_body: CreateGrantRequest, overrides: RequestOverrides = None
+ ) -> Response[Grant]:
+ """
+ Create a Grant via Custom Authentication.
+
+ Args:
+ request_body: The values to create the Grant with.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The created Grant.
+ """
+
+ json_response, headers = self._http_client._execute(
+ method="POST",
+ path="/v3/connect/custom",
+ request_body=request_body,
+ overrides=overrides,
+ )
+ return Response.from_dict(json_response, Grant, headers)
+
+ def refresh_access_token(
+ self, request: TokenExchangeRequest, overrides: RequestOverrides = None
+ ) -> CodeExchangeResponse:
+ """
+ Refresh an access token.
+
+ Args:
+ request: The refresh token request.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The response containing the new access token.
+ """
+ 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, overrides)
+
+ def id_token_info(
+ self, id_token: str, overrides: RequestOverrides = None
+ ) -> Response[TokenInfoResponse]:
+ """
+ Get info about an ID token.
+
+ Args:
+ id_token: The ID token to query.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The API response with the token information.
+ """
+
+ query_params = {
+ "id_token": id_token,
+ }
+
+ return self._get_token_info(query_params, overrides)
+
+ def validate_access_token(
+ self, access_token: str, overrides: RequestOverrides = None
+ ) -> Response[TokenInfoResponse]:
+ """
+ Get info about an access token.
+
+ Args:
+ access_token: The access token to query.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The API response with the token information.
+ """
+
+ query_params = {
+ "access_token": access_token,
+ }
+
+ return self._get_token_info(query_params, overrides)
+
+ 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, overrides: RequestOverrides = None) -> True:
+ """Revoke a single access token.
+
+ Args:
+ token: The access token to revoke.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ True: If the token was revoked successfully.
+ """
+ self._http_client._execute(
+ method="POST",
+ path="/v3/connect/revoke",
+ query_params={"token": token},
+ overrides=overrides,
+ )
+
+ return True
+
+ def detect_provider(
+ self, params: ProviderDetectParams, overrides: RequestOverrides = None
+ ) -> Response[ProviderDetectResponse]:
+ """
+ Detect provider from email address.
+
+ Args:
+ params: The parameters to include in the request
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The detected provider, if found.
+ """
+
+ json_response, headers = self._http_client._execute(
+ method="POST",
+ path="/v3/providers/detect",
+ query_params=params,
+ overrides=overrides,
+ )
+ return Response.from_dict(json_response, ProviderDetectResponse, headers)
+
+ 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, overrides: RequestOverrides
+ ) -> CodeExchangeResponse:
+ json_response, _ = self._http_client._execute(
+ method="POST",
+ path="/v3/connect/token",
+ request_body=request_body,
+ overrides=overrides,
+ )
+ return CodeExchangeResponse.from_dict(json_response)
+
+ def _get_token_info(
+ self, query_params: dict, overrides: RequestOverrides
+ ) -> Response[TokenInfoResponse]:
+ json_response, headers = self._http_client._execute(
+ method="GET",
+ path="/v3/connect/tokeninfo",
+ query_params=query_params,
+ overrides=overrides,
+ )
+ return Response.from_dict(json_response, TokenInfoResponse, headers)
diff --git a/nylas/resources/bookings.py b/nylas/resources/bookings.py
new file mode 100644
index 00000000..0fa3cf55
--- /dev/null
+++ b/nylas/resources/bookings.py
@@ -0,0 +1,176 @@
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import (
+ CreatableApiResource,
+ DestroyableApiResource,
+ FindableApiResource,
+ ListableApiResource,
+ UpdatableApiResource,
+ UpdatablePatchApiResource,
+)
+from nylas.models.response import DeleteResponse, Response
+from nylas.models.scheduler import (
+ Booking,
+ ConfirmBookingQueryParams,
+ ConfirmBookingRequest,
+ CreateBookingQueryParams,
+ CreateBookingRequest,
+ DeleteBookingRequest,
+ DestroyBookingQueryParams,
+ RescheduleBookingRequest,
+ FindBookingQueryParams,
+ RescheduleBookingQueryParams,
+)
+
+
+class Bookings(
+ ListableApiResource,
+ FindableApiResource,
+ CreatableApiResource,
+ UpdatableApiResource,
+ UpdatablePatchApiResource,
+ DestroyableApiResource,
+):
+ """
+ Nylas Bookings API
+
+ The Nylas Bookings API allows you to create new bookings or manage existing ones, as well as getting
+ bookings details for a user.
+
+ A booking can be accessed by one, or several people, and can contain events.
+ """
+
+ def find(
+ self,
+ booking_id: str,
+ query_params: FindBookingQueryParams = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[Booking]:
+ """
+ Return a Booking.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ booking_id: The identifier of the Booking to get.
+ query_params: The query parameters to include in the request.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The Booking.
+ """
+
+ return super().find(
+ path=f"/v3/scheduling/bookings/{booking_id}",
+ query_params=query_params,
+ response_type=Booking,
+ overrides=overrides,
+ )
+
+ def create(
+ self,
+ request_body: CreateBookingRequest,
+ query_params: CreateBookingQueryParams = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[Booking]:
+ """
+ Create a Booking.
+
+ Args:
+ request_body: The values to create booking with.
+ overrides: The request overrides to use for the request.
+ query_params: The query parameters to include in the request.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The created Booking.
+ """
+
+ return super().create(
+ path="/v3/scheduling/bookings",
+ request_body=request_body,
+ query_params=query_params,
+ response_type=Booking,
+ overrides=overrides,
+ )
+
+ def confirm(
+ self,
+ booking_id: str,
+ request_body: ConfirmBookingRequest,
+ query_params: ConfirmBookingQueryParams = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[Booking]:
+ """
+ Confirm a Booking.
+
+ Args:
+ booking_id: The identifier of the Booking to confirm.
+ request_body: The values to confirm booking with.
+ query_params: The query parameters to include in the request.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The confirmed Booking.
+ """
+
+ return super().update(
+ path=f"/v3/scheduling/bookings/{booking_id}",
+ request_body=request_body,
+ query_params=query_params,
+ response_type=Booking,
+ overrides=overrides,
+ )
+
+ def reschedule(
+ self,
+ booking_id: str,
+ request_body: RescheduleBookingRequest,
+ query_params: RescheduleBookingQueryParams = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[Booking]:
+ """
+ Reschedule a Booking.
+
+ Args:
+ booking_id: The identifier of the Booking to reschedule.
+ request_body: The values to reschedule booking with.
+ query_params: The query parameters to include in the request.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The rescheduled Booking.
+ """
+
+ return super().patch(
+ path=f"/v3/scheduling/bookings/{booking_id}",
+ request_body=request_body,
+ query_params=query_params,
+ response_type=Booking,
+ overrides=overrides,
+ )
+
+ def destroy(
+ self,
+ booking_id: str,
+ request_body: DeleteBookingRequest,
+ query_params: DestroyBookingQueryParams = None,
+ overrides: RequestOverrides = None,
+ ) -> DeleteResponse:
+ """
+ Delete a Booking.
+
+ Args:
+ booking_id: The identifier of the Booking to delete.
+ request_body: The reason to delete booking with.
+ query_params: The query parameters to include in the request.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ None.
+ """
+
+ return super().destroy(
+ path=f"/v3/scheduling/bookings/{booking_id}",
+ request_body=request_body,
+ query_params=query_params,
+ overrides=overrides,
+ )
diff --git a/nylas/resources/calendars.py b/nylas/resources/calendars.py
new file mode 100644
index 00000000..971fa22b
--- /dev/null
+++ b/nylas/resources/calendars.py
@@ -0,0 +1,223 @@
+from typing import List
+
+from nylas.config import RequestOverrides
+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,
+ FreeBusyError,
+ FreeBusy,
+)
+from nylas.models.calendars import (
+ Calendar,
+ CreateCalendarRequest,
+ UpdateCalendarRequest,
+ ListCalendarsQueryParams,
+ FindCalendarQueryParams,
+)
+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,
+ overrides: RequestOverrides = 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The list of Calendars.
+ """
+
+ return super().list(
+ path=f"/v3/grants/{identifier}/calendars",
+ query_params=query_params,
+ response_type=Calendar,
+ overrides=overrides,
+ )
+
+ def find(
+ self,
+ identifier: str,
+ calendar_id: str,
+ overrides: RequestOverrides = None,
+ query_params: FindCalendarQueryParams = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+ query_params: The query parameters to include in the request.
+
+ Returns:
+ The Calendar.
+ """
+ return super().find(
+ path=f"/v3/grants/{identifier}/calendars/{calendar_id}",
+ response_type=Calendar,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def create(
+ self,
+ identifier: str,
+ request_body: CreateCalendarRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Calendar]:
+ """
+ Create a Calendar.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ request_body: The values to create the Calendar with.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The created Calendar.
+ """
+ return super().create(
+ path=f"/v3/grants/{identifier}/calendars",
+ response_type=Calendar,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ identifier: str,
+ calendar_id: str,
+ request_body: UpdateCalendarRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The updated Calendar.
+ """
+ return super().update(
+ path=f"/v3/grants/{identifier}/calendars/{calendar_id}",
+ response_type=Calendar,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def destroy(
+ self, identifier: str, calendar_id: str, overrides: RequestOverrides = None
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The deletion response.
+ """
+ return super().destroy(
+ path=f"/v3/grants/{identifier}/calendars/{calendar_id}", overrides=overrides
+ )
+
+ def get_availability(self, request_body: GetAvailabilityRequest, overrides: RequestOverrides = None
+ ) -> Response[GetAvailabilityResponse]:
+ """
+ Get availability for a Calendar.
+
+ Args:
+ request_body: The request body to send to the API.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ Response: The availability response from the API.
+ """
+ json_response, headers = self._http_client._execute(
+ "POST",
+ "/v3/calendars/availability",
+ None,
+ None,
+ request_body,
+ overrides=overrides,
+ )
+
+ return Response.from_dict(json_response, GetAvailabilityResponse, headers)
+
+ def get_free_busy(
+ self,
+ identifier: str,
+ request_body: GetFreeBusyRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[List[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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ Response: The free/busy response from the API.
+ """
+ json_response, headers = self._http_client._execute(
+ "POST",
+ f"/v3/grants/{identifier}/calendars/free-busy",
+ None,
+ None,
+ request_body,
+ overrides=overrides,
+ )
+
+ data = []
+ request_id = json_response["request_id"]
+ for item in json_response["data"]:
+ if item.get("object") == "error":
+ data.append(FreeBusyError.from_dict(item))
+ else:
+ data.append(FreeBusy.from_dict(item))
+
+ return Response(data, request_id, headers)
diff --git a/nylas/resources/configurations.py b/nylas/resources/configurations.py
new file mode 100644
index 00000000..103b0c54
--- /dev/null
+++ b/nylas/resources/configurations.py
@@ -0,0 +1,159 @@
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import (
+ CreatableApiResource,
+ DestroyableApiResource,
+ FindableApiResource,
+ ListableApiResource,
+ UpdatableApiResource,
+)
+from nylas.models.list_query_params import ListQueryParams
+from nylas.models.response import DeleteResponse, ListResponse, Response
+from nylas.models.scheduler import (
+ Configuration,
+ CreateConfigurationRequest,
+ UpdateConfigurationRequest,
+)
+
+
+class ListConfigurationsParams(ListQueryParams):
+ """
+ Interface of the query parameters for listing configurations.
+
+ Attributes:
+ limit: The maximum number of objects to return.
+ This field defaults to 50. The maximum allowed value is 200.
+ page_token: An identifier that specifies which page of data to return.
+ This value should be taken from a ListResponse object's next_cursor parameter.
+ identifier: The identifier of the Grant to act upon.
+ """
+
+ identifier: str
+
+
+class Configurations(
+ ListableApiResource,
+ FindableApiResource,
+ CreatableApiResource,
+ UpdatableApiResource,
+ DestroyableApiResource,
+):
+ """
+ Nylas Configuration API
+
+ The Nylas configuration API allows you to create new configurations or manage existing ones, as well as getting
+ configurations details for a user.
+
+ Nylas Scheduler stores Configuration objects in the Scheduler database and loads
+ them as Scheduling Pages in the Scheduler UI.
+ """
+
+ def list(
+ self,
+ identifier: str,
+ query_params: ListConfigurationsParams = None,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[Configuration]:
+ """
+ Return all Configurations.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The list of Configurations.
+ """
+ # import pdb; pdb.set_trace();
+ res = super().list(
+ path=f"/v3/grants/{identifier}/scheduling/configurations",
+ overrides=overrides,
+ response_type=Configuration,
+ query_params=query_params,
+ )
+ return res
+
+ def find(
+ self, identifier: str, config_id: str, overrides: RequestOverrides = None
+ ) -> Response[Configuration]:
+ """
+ Return a Configuration.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ config_id: The identifier of the Configuration to get.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The Configuration object.
+ """
+ return super().find(
+ path=f"/v3/grants/{identifier}/scheduling/configurations/{config_id}",
+ overrides=overrides,
+ response_type=Configuration,
+ )
+
+ def create(
+ self,
+ identifier: str,
+ request_body: CreateConfigurationRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Configuration]:
+ """
+ Create a new Configuration.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ data: The data to create the Configuration with.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The Configuration object.
+ """
+ return super().create(
+ path=f"/v3/grants/{identifier}/scheduling/configurations",
+ request_body=request_body,
+ overrides=overrides,
+ response_type=Configuration,
+ )
+
+ def update(
+ self,
+ identifier: str,
+ config_id: str,
+ request_body: UpdateConfigurationRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Configuration]:
+ """
+ Update a Configuration.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ config_id: The identifier of the Configuration to update.
+ data: The data to update the Configuration with.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The Configuration object.
+ """
+ return super().update(
+ path=f"/v3/grants/{identifier}/scheduling/configurations/{config_id}",
+ request_body=request_body,
+ overrides=overrides,
+ response_type=Configuration,
+ )
+
+ def destroy(
+ self, identifier: str, config_id: str, overrides: RequestOverrides = None
+ ) -> DeleteResponse:
+ """
+ Delete a Configuration.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ config_id: The identifier of the Configuration to delete.
+ overrides: The request overrides to use for the request.
+ """
+ return super().destroy(
+ path=f"/v3/grants/{identifier}/scheduling/configurations/{config_id}",
+ overrides=overrides,
+ )
diff --git a/nylas/resources/connectors.py b/nylas/resources/connectors.py
new file mode 100644
index 00000000..7ce217b4
--- /dev/null
+++ b/nylas/resources/connectors.py
@@ -0,0 +1,146 @@
+from nylas.config import RequestOverrides
+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,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[Connector]:
+ """
+ Return all Connectors.
+
+ Args:
+ query_params: The query parameters to include in the request.
+ overrides: The request overrides to use.
+
+ Returns:
+ The list of Connectors.
+ """
+
+ return super().list(
+ path="/v3/connectors",
+ response_type=Connector,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def find(
+ self, provider: Provider, overrides: RequestOverrides = None
+ ) -> Response[Connector]:
+ """
+ Return a connector associated with the provider.
+
+ Args:
+ provider: The provider associated to the connector to retrieve.
+ overrides: The request overrides to use.
+
+ Returns:
+ The Connector.
+ """
+ return super().find(
+ path=f"/v3/connectors/{provider}",
+ response_type=Connector,
+ overrides=overrides,
+ )
+
+ def create(
+ self, request_body: CreateConnectorRequest, overrides: RequestOverrides = None
+ ) -> Response[Connector]:
+ """
+ Create a connector.
+
+ Args:
+ request_body: The values to create the connector with.
+ overrides: The request overrides to use.
+
+ Returns:
+ The created connector.
+ """
+ return super().create(
+ path="/v3/connectors",
+ request_body=request_body,
+ response_type=Connector,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ provider: Provider,
+ request_body: UpdateConnectorRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Connector]:
+ """
+ Create a connector.
+
+ Args:
+ provider: The provider associated to the connector to update.
+ request_body: The values to update the connector with.
+ overrides: The request overrides to use.
+
+ Returns:
+ The created connector.
+ """
+ return super().update(
+ path=f"/v3/connectors/{provider}",
+ request_body=request_body,
+ response_type=Connector,
+ method="PATCH",
+ overrides=overrides,
+ )
+
+ def destroy(
+ self, provider: Provider, overrides: RequestOverrides = None
+ ) -> DeleteResponse:
+ """
+ Delete a connector.
+
+ Args:
+ provider: The provider associated to the connector to delete.
+ overrides: The request overrides to use.
+
+ Returns:
+ The deleted connector.
+ """
+ return super().destroy(path=f"/v3/connectors/{provider}", overrides=overrides)
diff --git a/nylas/resources/contacts.py b/nylas/resources/contacts.py
new file mode 100644
index 00000000..de78a9fd
--- /dev/null
+++ b/nylas/resources/contacts.py
@@ -0,0 +1,182 @@
+from nylas.config import RequestOverrides
+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,
+ overrides: RequestOverrides = 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The list of contacts.
+ """
+
+ return super().list(
+ path=f"/v3/grants/{identifier}/contacts",
+ query_params=query_params,
+ response_type=Contact,
+ overrides=overrides,
+ )
+
+ def find(
+ self,
+ identifier: str,
+ contact_id: str,
+ query_params: FindContactQueryParams = None,
+ overrides: RequestOverrides = 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The contact.
+ """
+ return super().find(
+ path=f"/v3/grants/{identifier}/contacts/{contact_id}",
+ response_type=Contact,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def create(
+ self,
+ identifier: str,
+ request_body: CreateContactRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Contact]:
+ """
+ Create a Contact.
+
+ Attributes:
+ identifier: The identifier of the Grant to act upon.
+ request_body: The values to create the Contact with.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The created contact.
+ """
+ return super().create(
+ path=f"/v3/grants/{identifier}/contacts",
+ response_type=Contact,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ identifier: str,
+ contact_id: str,
+ request_body: UpdateContactRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The updated contact.
+ """
+ return super().update(
+ path=f"/v3/grants/{identifier}/contacts/{contact_id}",
+ response_type=Contact,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def destroy(
+ self,
+ identifier: str,
+ contact_id: str,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The deletion response.
+ """
+ return super().destroy(
+ path=f"/v3/grants/{identifier}/contacts/{contact_id}", overrides=overrides
+ )
+
+ def list_groups(
+ self,
+ identifier: str,
+ query_params: ListContactGroupsQueryParams = None,
+ overrides: RequestOverrides = 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The list of contact groups.
+ """
+ json_response, headers = self._http_client._execute(
+ method="GET",
+ path=f"/v3/grants/{identifier}/contacts/groups",
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ return ListResponse.from_dict(json_response, ContactGroup, headers)
diff --git a/nylas/resources/credentials.py b/nylas/resources/credentials.py
new file mode 100644
index 00000000..336cb06c
--- /dev/null
+++ b/nylas/resources/credentials.py
@@ -0,0 +1,156 @@
+from nylas.config import RequestOverrides
+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,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[Credential]:
+ """
+ Return all credentials for a particular provider.
+
+ Args:
+ provider: The provider.
+ query_params: The query parameters to include in the request.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The list of credentials.
+ """
+
+ return super().list(
+ path=f"/v3/connectors/{provider}/creds",
+ response_type=Credential,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def find(
+ self,
+ provider: Provider,
+ credential_id: str,
+ overrides: RequestOverrides = None,
+ ) -> Response[Credential]:
+ """
+ Return a credential.
+
+ Args:
+ provider: The provider of the credential.
+ credential_id: The ID of the credential to retrieve.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The Credential.
+ """
+
+ return super().find(
+ path=f"/v3/connectors/{provider}/creds/{credential_id}",
+ response_type=Credential,
+ overrides=overrides,
+ )
+
+ def create(
+ self,
+ provider: Provider,
+ request_body: CredentialRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Credential]:
+ """
+ Create a credential for a particular provider.
+
+ Args:
+ provider: The provider.
+ request_body: The values to create the Credential with.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The created Credential.
+ """
+
+ return super().create(
+ path=f"/v3/connectors/{provider}/creds",
+ response_type=Credential,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ provider: Provider,
+ credential_id: str,
+ request_body: UpdateCredentialRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The updated credential.
+ """
+
+ return super().update(
+ path=f"/v3/connectors/{provider}/creds/{credential_id}",
+ response_type=Credential,
+ request_body=request_body,
+ method="PATCH",
+ overrides=overrides,
+ )
+
+ def destroy(
+ self,
+ provider: Provider,
+ credential_id: str,
+ overrides: RequestOverrides = None,
+ ) -> DeleteResponse:
+ """
+ Delete a credential.
+
+ Args:
+ provider: the provider for the grant
+ credential_id: The ID of the credential to delete.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The deletion response.
+ """
+
+ return super().destroy(
+ path=f"/v3/connectors/{provider}/creds/{credential_id}", overrides=overrides
+ )
diff --git a/nylas/resources/domains.py b/nylas/resources/domains.py
new file mode 100644
index 00000000..bde153e1
--- /dev/null
+++ b/nylas/resources/domains.py
@@ -0,0 +1,222 @@
+from typing import Optional
+
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import (
+ CreatableApiResource,
+ DestroyableApiResource,
+ FindableApiResource,
+ ListableApiResource,
+ UpdatableApiResource,
+)
+from nylas.handler.service_account import ServiceAccountSigner
+from nylas.models.domains import (
+ CreateDomainRequest,
+ Domain,
+ DomainVerificationDetails,
+ GetDomainInfoRequest,
+ ListDomainsQueryParams,
+ UpdateDomainRequest,
+ VerifyDomainRequest,
+)
+from nylas.models.response import DeleteResponse, ListResponse, Response
+
+
+def _merge_signer_headers(
+ overrides: Optional[RequestOverrides], signer_headers: Optional[dict]
+) -> Optional[RequestOverrides]:
+ if not signer_headers:
+ return overrides
+ merged: RequestOverrides = dict(overrides) if overrides else {}
+ headers = dict(merged.get("headers") or {})
+ headers.update(signer_headers)
+ merged["headers"] = headers
+ return merged
+
+
+class Domains(
+ ListableApiResource,
+ FindableApiResource,
+ CreatableApiResource,
+ UpdatableApiResource,
+ DestroyableApiResource,
+):
+ """
+ Nylas Manage Domains API (``/v3/admin/domains``).
+
+ Organization admin endpoints for registering and verifying email domains used with
+ Transactional Send and Nylas Inbound. Optional :class:`ServiceAccountSigner` adds the
+ required ``X-Nylas-*`` headers; you can also supply those headers via ``RequestOverrides``.
+ """
+
+ def list(
+ self,
+ query_params: Optional[ListDomainsQueryParams] = None,
+ signer: Optional[ServiceAccountSigner] = None,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[Domain]:
+ path = "/v3/admin/domains"
+ merged = overrides
+ if signer:
+ hdrs, _ = signer.build_headers("GET", path, None)
+ merged = _merge_signer_headers(overrides, hdrs)
+ return super().list(
+ path=path,
+ response_type=Domain,
+ query_params=query_params,
+ overrides=merged,
+ )
+
+ def create(
+ self,
+ request_body: CreateDomainRequest,
+ signer: Optional[ServiceAccountSigner] = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[Domain]:
+ path = "/v3/admin/domains"
+ merged = overrides
+ serialized = None
+ body_arg = request_body
+ if signer:
+ hdrs, serialized = signer.build_headers("POST", path, dict(request_body))
+ merged = _merge_signer_headers(overrides, hdrs)
+ if serialized is not None:
+ body_arg = None
+ return super().create(
+ path=path,
+ request_body=body_arg,
+ response_type=Domain,
+ overrides=merged,
+ serialized_json_body=serialized,
+ )
+
+ def find(
+ self,
+ domain_id: str,
+ signer: Optional[ServiceAccountSigner] = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[Domain]:
+ path = f"/v3/admin/domains/{domain_id}"
+ merged = overrides
+ if signer:
+ hdrs, _ = signer.build_headers("GET", path, None)
+ merged = _merge_signer_headers(overrides, hdrs)
+ return super().find(
+ path=path,
+ response_type=Domain,
+ overrides=merged,
+ )
+
+ def update(
+ self,
+ domain_id: str,
+ request_body: UpdateDomainRequest,
+ signer: Optional[ServiceAccountSigner] = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[Domain]:
+ path = f"/v3/admin/domains/{domain_id}"
+ merged = overrides
+ serialized = None
+ body_arg = request_body
+ if signer:
+ hdrs, serialized = signer.build_headers("PUT", path, dict(request_body))
+ merged = _merge_signer_headers(overrides, hdrs)
+ if serialized is not None:
+ body_arg = None
+ return super().update(
+ path=path,
+ request_body=body_arg,
+ response_type=Domain,
+ overrides=merged,
+ serialized_json_body=serialized,
+ )
+
+ def destroy(
+ self,
+ domain_id: str,
+ signer: Optional[ServiceAccountSigner] = None,
+ overrides: RequestOverrides = None,
+ ) -> DeleteResponse:
+ path = f"/v3/admin/domains/{domain_id}"
+ merged = overrides
+ if signer:
+ hdrs, _ = signer.build_headers("DELETE", path, None)
+ merged = _merge_signer_headers(overrides, hdrs)
+ return super().destroy(path=path, overrides=merged)
+
+ def get_info(
+ self,
+ domain_id: str,
+ request_body: GetDomainInfoRequest,
+ signer: Optional[ServiceAccountSigner] = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[DomainVerificationDetails]:
+ """
+ Return DNS record information and verification status for the given verification type.
+
+ Args:
+ domain_id: The domain ID.
+ request_body: Body with ``type`` (for example ``ownership`` or ``dkim``).
+ signer: Optional service account signer for ``X-Nylas-*`` headers.
+ overrides: Request overrides (for example extra headers).
+
+ Returns:
+ Verification details including required DNS records.
+ """
+ path = f"/v3/admin/domains/{domain_id}/info"
+ body = dict(request_body)
+ merged = overrides
+ serialized = None
+ if signer:
+ hdrs, serialized = signer.build_headers("POST", path, body)
+ merged = _merge_signer_headers(overrides, hdrs)
+ exec_kwargs = {"overrides": merged}
+ if serialized is not None:
+ exec_kwargs["serialized_json_body"] = serialized
+ res, headers = self._http_client._execute(
+ "POST",
+ path,
+ None,
+ None,
+ None if serialized is not None else body,
+ **exec_kwargs,
+ )
+ return Response.from_dict(res, DomainVerificationDetails, headers)
+
+ def verify(
+ self,
+ domain_id: str,
+ request_body: VerifyDomainRequest,
+ signer: Optional[ServiceAccountSigner] = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[DomainVerificationDetails]:
+ """
+ Trigger a verification check for the specified DNS record type.
+
+ Args:
+ domain_id: The domain ID.
+ request_body: Body with ``type`` of verification to run.
+ signer: Optional service account signer for ``X-Nylas-*`` headers.
+ overrides: Request overrides (for example extra headers).
+
+ Returns:
+ Verification attempt details and status.
+ """
+ path = f"/v3/admin/domains/{domain_id}/verify"
+ body = dict(request_body)
+ merged = overrides
+ serialized = None
+ if signer:
+ hdrs, serialized = signer.build_headers("POST", path, body)
+ merged = _merge_signer_headers(overrides, hdrs)
+ exec_kwargs = {"overrides": merged}
+ if serialized is not None:
+ exec_kwargs["serialized_json_body"] = serialized
+ res, headers = self._http_client._execute(
+ "POST",
+ path,
+ None,
+ None,
+ None if serialized is not None else body,
+ **exec_kwargs,
+ )
+ return Response.from_dict(res, DomainVerificationDetails, headers)
diff --git a/nylas/resources/drafts.py b/nylas/resources/drafts.py
new file mode 100644
index 00000000..52d94b2b
--- /dev/null
+++ b/nylas/resources/drafts.py
@@ -0,0 +1,232 @@
+import io
+import urllib.parse
+from typing import Optional
+
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import (
+ ListableApiResource,
+ FindableApiResource,
+ UpdatableApiResource,
+ DestroyableApiResource,
+ CreatableApiResource,
+)
+from nylas.models.drafts import (
+ ListDraftsQueryParams,
+ Draft,
+ UpdateDraftRequest,
+ CreateDraftRequest,
+ FindDraftQueryParams,
+)
+from nylas.models.messages import Message
+from nylas.models.response import ListResponse, Response, DeleteResponse
+from nylas.utils.file_utils import (
+ _build_form_request,
+ MAXIMUM_JSON_ATTACHMENT_SIZE,
+ encode_stream_to_base64,
+)
+
+
+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,
+ overrides: RequestOverrides = 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ A list of Drafts.
+ """
+ return super().list(
+ path=f"/v3/grants/{identifier}/drafts",
+ response_type=Draft,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def find(
+ self,
+ identifier: str,
+ draft_id: str,
+ overrides: RequestOverrides = None,
+ query_params: FindDraftQueryParams = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+ query_params: The query parameters to include in the request.
+
+ Returns:
+ The requested Draft.
+ """
+ return super().find(
+ path=f"/v3/grants/{identifier}/drafts/{urllib.parse.quote(draft_id, safe='')}",
+ response_type=Draft,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def create(
+ self,
+ identifier: str,
+ request_body: CreateDraftRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The newly created Draft.
+ """
+ path = f"/v3/grants/{identifier}/drafts"
+
+ # Use form data only if the attachment size is greater than 3mb
+ attachment_size = sum(
+ attachment.get("size", 0)
+ for attachment in request_body.get("attachments", [])
+ )
+ if attachment_size >= MAXIMUM_JSON_ATTACHMENT_SIZE:
+ json_response = self._http_client._execute(
+ method="POST",
+ path=path,
+ data=_build_form_request(request_body),
+ overrides=overrides,
+ )
+
+ return Response.from_dict(json_response, Draft)
+
+ # Encode the content of the attachments to base64
+ for attachment in request_body.get("attachments", []):
+ if "content" in attachment and issubclass(
+ type(attachment["content"]), io.IOBase
+ ):
+ attachment["content"] = encode_stream_to_base64(attachment["content"])
+
+ return super().create(
+ path=path,
+ response_type=Draft,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ identifier: str,
+ draft_id: str,
+ request_body: UpdateDraftRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The updated Draft.
+ """
+ path = f"/v3/grants/{identifier}/drafts/{urllib.parse.quote(draft_id, safe='')}"
+
+ # Use form data only if the attachment size is greater than 3mb
+ attachment_size = sum(
+ attachment.get("size", 0)
+ for attachment in request_body.get("attachments", [])
+ )
+ if attachment_size >= MAXIMUM_JSON_ATTACHMENT_SIZE:
+ json_response = self._http_client._execute(
+ method="PUT",
+ path=path,
+ data=_build_form_request(request_body),
+ overrides=overrides,
+ )
+
+ return Response.from_dict(json_response, Draft)
+
+ # Encode the content of the attachments to base64
+ for attachment in request_body.get("attachments", []):
+ if "content" in attachment and issubclass(
+ type(attachment["content"]), io.IOBase
+ ):
+ attachment["content"] = encode_stream_to_base64(attachment["content"])
+
+ return super().update(
+ path=path,
+ response_type=Draft,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def destroy(
+ self,
+ identifier: str,
+ draft_id: str,
+ overrides: RequestOverrides = None,
+ ) -> DeleteResponse:
+ """
+ Delete a Draft.
+
+ Args:
+ identifier: The identifier of the grant to delete the draft for.
+ draft_id: The identifier of the draft to delete.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The deletion response.
+ """
+ return super().destroy(
+ path=f"/v3/grants/{identifier}/drafts/{urllib.parse.quote(draft_id, safe='')}",
+ overrides=overrides,
+ )
+
+ def send(
+ self,
+ identifier: str,
+ draft_id: str,
+ overrides: RequestOverrides = None,
+ ) -> Response[Message]:
+ """
+ Send a Draft.
+
+ Args:
+ identifier: The identifier of the grant to send the draft for.
+ draft_id: The identifier of the draft to send.
+ overrides: The request overrides to use for the request.
+ """
+ json_response, headers = self._http_client._execute(
+ method="POST",
+ path=f"/v3/grants/{identifier}/drafts/{urllib.parse.quote(draft_id, safe='')}",
+ overrides=overrides,
+ )
+
+ return Response.from_dict(json_response, Message, headers)
diff --git a/nylas/resources/events.py b/nylas/resources/events.py
new file mode 100644
index 00000000..3b9223ab
--- /dev/null
+++ b/nylas/resources/events.py
@@ -0,0 +1,236 @@
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import (
+ ListableApiResource,
+ FindableApiResource,
+ CreatableApiResource,
+ UpdatableApiResource,
+ DestroyableApiResource,
+)
+from nylas.models.events import (
+ Event,
+ UpdateEventRequest,
+ CreateEventRequest,
+ FindEventQueryParams,
+ ListEventQueryParams,
+ ListImportEventsQueryParams,
+ 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,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The list of Events.
+ """
+
+ return super().list(
+ path=f"/v3/grants/{identifier}/events",
+ response_type=Event,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def list_import_events(
+ self,
+ identifier: str,
+ query_params: ListImportEventsQueryParams,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[Event]:
+ """
+ Returns a list of recurring events, recurring event exceptions, and
+ single events from the specified calendar within a given time frame.
+ This is useful when you want to import, store, and synchronize events
+ from the time frame to your application
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ query_params: The query parameters to include in the request.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The list of imported Events.
+ """
+
+ return super().list(
+ path=f"/v3/grants/{identifier}/events/import",
+ response_type=Event,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def find(
+ self,
+ identifier: str,
+ event_id: str,
+ query_params: FindEventQueryParams,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The Event.
+ """
+
+ return super().find(
+ path=f"/v3/grants/{identifier}/events/{event_id}",
+ response_type=Event,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def create(
+ self,
+ identifier: str,
+ request_body: CreateEventRequest,
+ query_params: CreateEventQueryParams,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for 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,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ identifier: str,
+ event_id: str,
+ request_body: UpdateEventRequest,
+ query_params: UpdateEventQueryParams,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for 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,
+ overrides=overrides,
+ )
+
+ def destroy(
+ self,
+ identifier: str,
+ event_id: str,
+ query_params: DestroyEventQueryParams,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The deletion response.
+ """
+
+ return super().destroy(
+ path=f"/v3/grants/{identifier}/events/{event_id}",
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def send_rsvp(
+ self,
+ identifier: str,
+ event_id: str,
+ request_body: SendRsvpRequest,
+ query_params: SendRsvpQueryParams,
+ overrides: RequestOverrides = None,
+ ) -> RequestIdOnlyResponse:
+ """
+ Send an 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.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ Response: The RSVP response from the API.
+ """
+ json_response, headers = self._http_client._execute(
+ method="POST",
+ path=f"/v3/grants/{identifier}/events/{event_id}/send-rsvp",
+ query_params=query_params,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ return RequestIdOnlyResponse.from_dict(json_response, headers)
diff --git a/nylas/resources/folders.py b/nylas/resources/folders.py
new file mode 100644
index 00000000..487d7ea3
--- /dev/null
+++ b/nylas/resources/folders.py
@@ -0,0 +1,154 @@
+from typing import Optional
+
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import (
+ ListableApiResource,
+ FindableApiResource,
+ CreatableApiResource,
+ UpdatableApiResource,
+ DestroyableApiResource,
+)
+from nylas.models.folders import (
+ Folder,
+ CreateFolderRequest,
+ UpdateFolderRequest,
+ ListFolderQueryParams,
+ FindFolderQueryParams,
+)
+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,
+ query_params: Optional[ListFolderQueryParams] = None,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[Folder]:
+ """
+ Return all Folders.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ query_params: The query parameters to include in the request.
+ overrides: The request overrides to use.
+
+ Returns:
+ The list of Folders.
+ """
+
+ return super().list(
+ path=f"/v3/grants/{identifier}/folders",
+ response_type=Folder,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def find(
+ self,
+ identifier: str,
+ folder_id: str,
+ overrides: RequestOverrides = None,
+ query_params: FindFolderQueryParams = None,
+ ) -> Response[Folder]:
+ """
+ Return a Folder.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ folder_id: The ID of the Folder to retrieve.
+ overrides: The request overrides to use.
+ query_params: The query parameters to include in the request.
+
+ Returns:
+ The Folder.
+ """
+ return super().find(
+ path=f"/v3/grants/{identifier}/folders/{folder_id}",
+ response_type=Folder,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def create(
+ self,
+ identifier: str,
+ request_body: CreateFolderRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Folder]:
+ """
+ Create a Folder.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ request_body: The values to create the Folder with.
+ overrides: The request overrides to use.
+
+ Returns:
+ The created Folder.
+ """
+ return super().create(
+ path=f"/v3/grants/{identifier}/folders",
+ response_type=Folder,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ identifier: str,
+ folder_id: str,
+ request_body: UpdateFolderRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to use.
+
+ Returns:
+ The updated Folder.
+ """
+ return super().update(
+ path=f"/v3/grants/{identifier}/folders/{folder_id}",
+ response_type=Folder,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def destroy(
+ self,
+ identifier: str,
+ folder_id: str,
+ overrides: RequestOverrides = None,
+ ) -> DeleteResponse:
+ """
+ Delete a Folder.
+
+ Args:
+ identifier: The identifier of the Grant to act upon.
+ folder_id: The ID of the Folder to delete.
+ overrides: The request overrides to use.
+
+ Returns:
+ The deletion response.
+ """
+ return super().destroy(
+ path=f"/v3/grants/{identifier}/folders/{folder_id}", overrides=overrides
+ )
diff --git a/nylas/resources/grants.py b/nylas/resources/grants.py
new file mode 100644
index 00000000..4a7675b6
--- /dev/null
+++ b/nylas/resources/grants.py
@@ -0,0 +1,134 @@
+from nylas.config import RequestOverrides
+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
+
+
+def _normalize_grants_query_params(query_params: ListGrantsQueryParams = None) -> dict:
+ if not query_params:
+ return query_params
+
+ normalized_query_params = dict(query_params)
+ key_aliases = {
+ "sortBy": "sort_by",
+ "orderBy": "order_by",
+ "grantStatus": "grant_status",
+ }
+
+ for camel_case_key, snake_case_key in key_aliases.items():
+ if camel_case_key in normalized_query_params:
+ if snake_case_key not in normalized_query_params:
+ normalized_query_params[snake_case_key] = normalized_query_params[
+ camel_case_key
+ ]
+ del normalized_query_params[camel_case_key]
+
+ return normalized_query_params
+
+
+class Grants(
+ ListableApiResource,
+ FindableApiResource,
+ 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,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[Grant]:
+ """
+ Return all Grants.
+
+ Args:
+ query_params: The query parameters to include in the request.
+ overrides: The request overrides to use.
+
+ Returns:
+ A list of Grants.
+ """
+
+ return super().list(
+ path="/v3/grants",
+ response_type=Grant,
+ query_params=_normalize_grants_query_params(query_params),
+ overrides=overrides,
+ )
+
+ def find(
+ self, grant_id: str, overrides: RequestOverrides = None
+ ) -> Response[Grant]:
+ """
+ Return a Grant.
+
+ Args:
+ grant_id: The ID of the Grant to retrieve.
+ overrides: The request overrides to use.
+
+ Returns:
+ The Grant.
+ """
+
+ return super().find(
+ path=f"/v3/grants/{grant_id}", response_type=Grant, overrides=overrides
+ )
+
+ def update(
+ self,
+ grant_id: str,
+ request_body: UpdateGrantRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Grant]:
+ """
+ Update a Grant.
+
+ Args:
+ grant_id: The ID of the Grant to update.
+ request_body: The values to update the Grant with.
+ overrides: The request overrides to use.
+
+ Returns:
+ The updated Grant.
+ """
+
+ return super().update(
+ path=f"/v3/grants/{grant_id}",
+ response_type=Grant,
+ request_body=request_body,
+ overrides=overrides,
+ method="PATCH"
+ )
+
+ def destroy(
+ self, grant_id: str, overrides: RequestOverrides = None
+ ) -> DeleteResponse:
+ """
+ Delete a Grant.
+
+ Args:
+ grant_id: The ID of the Grant to delete.
+ overrides: The request overrides to use.
+
+ Returns:
+ The deletion response.
+ """
+
+ return super().destroy(path=f"/v3/grants/{grant_id}", overrides=overrides)
diff --git a/nylas/resources/lists.py b/nylas/resources/lists.py
new file mode 100644
index 00000000..aa28cea5
--- /dev/null
+++ b/nylas/resources/lists.py
@@ -0,0 +1,126 @@
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import (
+ CreatableApiResource,
+ DestroyableApiResource,
+ FindableApiResource,
+ ListableApiResource,
+ UpdatableApiResource,
+)
+from nylas.models.lists import (
+ CreateListRequest,
+ ListItem,
+ ListListItemsQueryParams,
+ ListListsQueryParams,
+ NylasList,
+ UpdateListItemsRequest,
+ UpdateListRequest,
+)
+from nylas.models.response import DeleteResponse, ListResponse, Response
+
+
+class Lists(
+ ListableApiResource,
+ FindableApiResource,
+ CreatableApiResource,
+ UpdatableApiResource,
+ DestroyableApiResource,
+):
+ """Nylas Lists API."""
+
+ def list(
+ self,
+ query_params: ListListsQueryParams = None,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[NylasList]:
+ """Return all lists for the application."""
+ return super().list(
+ path="/v3/lists",
+ response_type=NylasList,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def create(
+ self,
+ request_body: CreateListRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[NylasList]:
+ """Create a new list."""
+ return super().create(
+ path="/v3/lists",
+ request_body=request_body,
+ response_type=NylasList,
+ overrides=overrides,
+ )
+
+ def find(self, list_id: str, overrides: RequestOverrides = None) -> Response[NylasList]:
+ """Return a specific list by ID."""
+ return super().find(
+ path=f"/v3/lists/{list_id}",
+ response_type=NylasList,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ list_id: str,
+ request_body: UpdateListRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[NylasList]:
+ """Update a list by ID."""
+ return super().update(
+ path=f"/v3/lists/{list_id}",
+ response_type=NylasList,
+ request_body=request_body,
+ method="PUT",
+ overrides=overrides,
+ )
+
+ def destroy(self, list_id: str, overrides: RequestOverrides = None) -> DeleteResponse:
+ """Delete a list by ID."""
+ return super().destroy(path=f"/v3/lists/{list_id}", overrides=overrides)
+
+ def list_items(
+ self,
+ list_id: str,
+ query_params: ListListItemsQueryParams = None,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[ListItem]:
+ """Return all items in a list."""
+ return super().list(
+ path=f"/v3/lists/{list_id}/items",
+ response_type=ListItem,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def add_items(
+ self,
+ list_id: str,
+ request_body: UpdateListItemsRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[NylasList]:
+ """Add items to a list."""
+ return super().create(
+ path=f"/v3/lists/{list_id}/items",
+ request_body=request_body,
+ response_type=NylasList,
+ overrides=overrides,
+ )
+
+ def remove_items(
+ self,
+ list_id: str,
+ request_body: UpdateListItemsRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[NylasList]:
+ """Remove items from a list."""
+ json_response, headers = self._http_client._execute(
+ "DELETE",
+ f"/v3/lists/{list_id}/items",
+ None,
+ None,
+ request_body,
+ overrides=overrides,
+ )
+ return Response.from_dict(json_response, NylasList, headers)
diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py
new file mode 100644
index 00000000..1e0b1fb0
--- /dev/null
+++ b/nylas/resources/messages.py
@@ -0,0 +1,302 @@
+import io
+import urllib.parse
+from typing import Optional, List
+
+from nylas.config import RequestOverrides
+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,
+ ScheduledMessage,
+ StopScheduledMessageResponse,
+ CleanMessagesRequest,
+ CleanMessagesResponse,
+)
+from nylas.models.response import Response, ListResponse, DeleteResponse
+from nylas.resources.smart_compose import SmartCompose
+from nylas.utils.file_utils import (
+ _build_form_request,
+ MAXIMUM_JSON_ATTACHMENT_SIZE,
+ encode_stream_to_base64,
+)
+
+
+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,
+ overrides: RequestOverrides = 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.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ A list of Messages.
+ """
+ return super().list(
+ path=f"/v3/grants/{identifier}/messages",
+ response_type=Message,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def find(
+ self,
+ identifier: str,
+ message_id: str,
+ query_params: Optional[FindMessageQueryParams] = None,
+ overrides: RequestOverrides = 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.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The requested Message.
+ """
+ return super().find(
+ path=f"/v3/grants/{identifier}/messages/{urllib.parse.quote(message_id, safe='')}",
+ response_type=Message,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ identifier: str,
+ message_id: str,
+ request_body: UpdateMessageRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The updated Message.
+ """
+ return super().update(
+ path=f"/v3/grants/{identifier}/messages/{urllib.parse.quote(message_id, safe='')}",
+ response_type=Message,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def destroy(
+ self, identifier: str, message_id: str, overrides: RequestOverrides = None
+ ) -> DeleteResponse:
+ """
+ Delete a Message.
+
+ Args:
+ identifier: The identifier of the grant to delete the message for.
+ message_id: The identifier of the message to delete.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The deletion response.
+ """
+ return super().destroy(
+ path=f"/v3/grants/{identifier}/messages/{urllib.parse.quote(message_id, safe='')}",
+ overrides=overrides,
+ )
+
+ def send(
+ self,
+ identifier: str,
+ request_body: SendMessageRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The sent message.
+ """
+ path = f"/v3/grants/{identifier}/messages/send"
+ form_data = None
+ json_body = None
+
+ # From is a reserved keyword in Python, so we need to pull the data from 'from_' instead
+ # Handle both dictionary-style "from" and typed "from_" field
+ if "from_" in request_body and "from" not in request_body:
+ request_body["from"] = request_body["from_"]
+ del request_body["from_"]
+ # If "from" already exists, leave it unchanged
+
+ # Use form data only if the attachment size is greater than 3mb
+ attachment_size = sum(
+ attachment.get("size", 0)
+ for attachment in request_body.get("attachments", [])
+ )
+ if attachment_size >= MAXIMUM_JSON_ATTACHMENT_SIZE:
+ form_data = _build_form_request(request_body)
+ else:
+ # Encode the content of the attachments to base64
+ for attachment in request_body.get("attachments", []):
+ if "content" in attachment and issubclass(
+ type(attachment["content"]), io.IOBase
+ ):
+ attachment["content"] = encode_stream_to_base64(
+ attachment["content"]
+ )
+
+ json_body = request_body
+
+ json_response, headers = self._http_client._execute(
+ method="POST",
+ path=path,
+ request_body=json_body,
+ data=form_data,
+ overrides=overrides,
+ )
+
+ return Response.from_dict(json_response, Message, headers)
+
+ def list_scheduled_messages(
+ self, identifier: str, overrides: RequestOverrides = None
+ ) -> Response[List[ScheduledMessage]]:
+ """
+ Retrieve your scheduled messages.
+
+ Args:
+ identifier: The identifier of the grant to delete the message for.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ Response: The list of scheduled messages.
+ """
+ json_response, headers = self._http_client._execute(
+ method="GET",
+ path=f"/v3/grants/{identifier}/messages/schedules",
+ overrides=overrides,
+ )
+
+ data = []
+ request_id = json_response["request_id"]
+ for item in json_response["data"]:
+ data.append(ScheduledMessage.from_dict(item))
+
+ return Response(data, request_id, headers)
+
+ def find_scheduled_message(
+ self, identifier: str, schedule_id: str, overrides: RequestOverrides = None
+ ) -> 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.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ Response: The scheduled message.
+ """
+ json_response, headers = self._http_client._execute(
+ method="GET",
+ path=f"/v3/grants/{identifier}/messages/schedules/{schedule_id}",
+ overrides=overrides,
+ )
+
+ return Response.from_dict(json_response, ScheduledMessage, headers)
+
+ def stop_scheduled_message(
+ self, identifier: str, schedule_id: str, overrides: RequestOverrides = None
+ ) -> 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.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ Response: The confirmation of the stopped scheduled message.
+ """
+ json_response, headers = self._http_client._execute(
+ method="DELETE",
+ path=f"/v3/grants/{identifier}/messages/schedules/{schedule_id}",
+ overrides=overrides,
+ )
+
+ return Response.from_dict(json_response, StopScheduledMessageResponse, headers)
+
+ def clean_messages(
+ self,
+ identifier: str,
+ request_body: CleanMessagesRequest,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[CleanMessagesResponse]:
+ """
+ Remove extra information from a list of messages.
+
+ Args:
+ identifier: The identifier of the grant to clean the message for.
+ request_body: The values to clean the message with.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ ListResponse: The list of cleaned messages.
+ """
+ json_response, headers = self._http_client._execute(
+ method="PUT",
+ path=f"/v3/grants/{identifier}/messages/clean",
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ return ListResponse.from_dict(json_response, CleanMessagesResponse, headers)
diff --git a/nylas/resources/notetakers.py b/nylas/resources/notetakers.py
new file mode 100644
index 00000000..275025ea
--- /dev/null
+++ b/nylas/resources/notetakers.py
@@ -0,0 +1,236 @@
+from typing import Optional
+
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import (CreatableApiResource,
+ DestroyableApiResource,
+ FindableApiResource,
+ ListableApiResource,
+ UpdatablePatchApiResource)
+from nylas.models.notetakers import (FindNotetakerQueryParams,
+ InviteNotetakerRequest,
+ ListNotetakerQueryParams,
+ Notetaker, NotetakerMedia,
+ NotetakerLeaveResponse,
+ UpdateNotetakerRequest)
+from nylas.models.response import DeleteResponse, ListResponse, Response
+
+
+class Notetakers(
+ ListableApiResource,
+ FindableApiResource,
+ CreatableApiResource,
+ UpdatablePatchApiResource,
+ DestroyableApiResource,
+):
+ """
+ Nylas Notetakers API
+
+ The Nylas Notetakers API allows you to invite Notetaker bots to meetings and manage their status.
+ Notetaker states are represented by the NotetakerState enum, and meeting providers by the MeetingProvider enum.
+ """
+
+ def list(
+ self,
+ identifier: str = None,
+ query_params: Optional[ListNotetakerQueryParams] = None,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[Notetaker]:
+ """
+ Return all Notetakers.
+
+ Args:
+ identifier: The identifier of the Grant to act upon. Optional.
+ query_params: The query parameters to include in the request.
+ You can use NotetakerState enum values for the state parameter:
+ e.g., {"state": NotetakerState.SCHEDULED.value}
+ overrides: The request overrides to use.
+
+ Returns:
+ The list of Notetakers.
+ """
+ path = (
+ "/v3/notetakers"
+ if identifier is None
+ else f"/v3/grants/{identifier}/notetakers"
+ )
+ return super().list(
+ path=path,
+ response_type=Notetaker,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def find(
+ self,
+ notetaker_id: str,
+ identifier: str = None,
+ overrides: RequestOverrides = None,
+ query_params: FindNotetakerQueryParams = None,
+ ) -> Response[Notetaker]:
+ """
+ Return a Notetaker.
+
+ Args:
+ notetaker_id: The ID of the Notetaker to retrieve.
+ identifier: The identifier of the Grant to act upon. Optional.
+ overrides: The request overrides to use.
+ query_params: The query parameters to include in the request.
+
+ Returns:
+ The Notetaker with properties like state (NotetakerState) and meeting_provider (MeetingProvider).
+ """
+ path = (
+ f"/v3/notetakers/{notetaker_id}"
+ if identifier is None
+ else f"/v3/grants/{identifier}/notetakers/{notetaker_id}"
+ )
+ return super().find(
+ path=path,
+ response_type=Notetaker,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def invite(
+ self,
+ request_body: InviteNotetakerRequest,
+ identifier: str = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[Notetaker]:
+ """
+ Invite a Notetaker to a meeting.
+
+ Args:
+ request_body: The values to create the Notetaker with.
+ identifier: The identifier of the Grant to act upon. Optional.
+ overrides: The request overrides to use.
+
+ Returns:
+ The created Notetaker with state set to NotetakerState.SCHEDULED.
+ """
+ path = (
+ "/v3/notetakers"
+ if identifier is None
+ else f"/v3/grants/{identifier}/notetakers"
+ )
+ return super().create(
+ path=path,
+ response_type=Notetaker,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ notetaker_id: str,
+ request_body: UpdateNotetakerRequest,
+ identifier: str = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[Notetaker]:
+ """
+ Update a Notetaker.
+
+ Args:
+ notetaker_id: The ID of the Notetaker to update.
+ request_body: The values to update the Notetaker with.
+ identifier: The identifier of the Grant to act upon. Optional.
+ overrides: The request overrides to use.
+
+ Returns:
+ The updated Notetaker.
+ """
+ path = (
+ f"/v3/notetakers/{notetaker_id}"
+ if identifier is None
+ else f"/v3/grants/{identifier}/notetakers/{notetaker_id}"
+ )
+ return super().patch(
+ path=path,
+ response_type=Notetaker,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def leave(
+ self,
+ notetaker_id: str,
+ identifier: str = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[NotetakerLeaveResponse]:
+ """
+ Remove Notetaker from a meeting.
+
+ Args:
+ notetaker_id: The ID of the Notetaker to remove from the meeting.
+ identifier: The identifier of the Grant to act upon. Optional.
+ overrides: The request overrides to use.
+
+ Returns:
+ The response with information about the Notetaker that left,
+ including the Notetaker ID and a message.
+ """
+ path = (
+ f"/v3/notetakers/{notetaker_id}/leave"
+ if identifier is None
+ else f"/v3/grants/{identifier}/notetakers/{notetaker_id}/leave"
+ )
+ return super().create(
+ path=path,
+ response_type=NotetakerLeaveResponse,
+ overrides=overrides,
+ )
+
+ def get_media(
+ self,
+ notetaker_id: str,
+ identifier: str = None,
+ overrides: RequestOverrides = None,
+ ) -> Response[NotetakerMedia]:
+ """
+ Download Notetaker media.
+
+ Args:
+ notetaker_id: The ID of the Notetaker to get media from.
+ identifier: The identifier of the Grant to act upon. Optional.
+ overrides: The request overrides to use.
+
+ Returns:
+ The Notetaker media information including URLs for recordings and transcripts.
+ """
+ path = (
+ f"/v3/notetakers/{notetaker_id}/media"
+ if identifier is None
+ else f"/v3/grants/{identifier}/notetakers/{notetaker_id}/media"
+ )
+ return super().find(
+ path=path,
+ response_type=NotetakerMedia,
+ overrides=overrides,
+ )
+
+ def cancel(
+ self,
+ notetaker_id: str,
+ identifier: str = None,
+ overrides: RequestOverrides = None,
+ ) -> DeleteResponse:
+ """
+ Cancel a scheduled Notetaker.
+
+ Args:
+ notetaker_id: The ID of the Notetaker to cancel.
+ identifier: The identifier of the Grant to act upon. Optional.
+ overrides: The request overrides to use.
+
+ Returns:
+ The deletion response.
+ """
+ path = (
+ f"/v3/notetakers/{notetaker_id}/cancel"
+ if identifier is None
+ else f"/v3/grants/{identifier}/notetakers/{notetaker_id}/cancel"
+ )
+ return super().destroy(
+ path=path,
+ overrides=overrides,
+ )
diff --git a/nylas/resources/policies.py b/nylas/resources/policies.py
new file mode 100644
index 00000000..88c8ed29
--- /dev/null
+++ b/nylas/resources/policies.py
@@ -0,0 +1,81 @@
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import (
+ CreatableApiResource,
+ DestroyableApiResource,
+ FindableApiResource,
+ ListableApiResource,
+ UpdatableApiResource,
+)
+from nylas.models.policies import (
+ CreatePolicyRequest,
+ ListPoliciesQueryParams,
+ Policy,
+ UpdatePolicyRequest,
+)
+from nylas.models.response import DeleteResponse, ListResponse, Response
+
+
+class Policies(
+ ListableApiResource,
+ FindableApiResource,
+ CreatableApiResource,
+ UpdatableApiResource,
+ DestroyableApiResource,
+):
+ """
+ Nylas Policies API.
+
+ Policies define operational configuration for Nylas Agent Accounts.
+ """
+
+ def list(
+ self,
+ query_params: ListPoliciesQueryParams = None,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[Policy]:
+ return super().list(
+ path="/v3/policies",
+ response_type=Policy,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def create(
+ self,
+ request_body: CreatePolicyRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Policy]:
+ return super().create(
+ path="/v3/policies",
+ request_body=request_body,
+ response_type=Policy,
+ overrides=overrides,
+ )
+
+ def find(
+ self, policy_id: str, overrides: RequestOverrides = None
+ ) -> Response[Policy]:
+ return super().find(
+ path=f"/v3/policies/{policy_id}",
+ response_type=Policy,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ policy_id: str,
+ request_body: UpdatePolicyRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Policy]:
+ return super().update(
+ path=f"/v3/policies/{policy_id}",
+ response_type=Policy,
+ request_body=request_body,
+ method="PUT",
+ overrides=overrides,
+ )
+
+ def destroy(
+ self, policy_id: str, overrides: RequestOverrides = None
+ ) -> DeleteResponse:
+ return super().destroy(path=f"/v3/policies/{policy_id}", overrides=overrides)
diff --git a/nylas/resources/redirect_uris.py b/nylas/resources/redirect_uris.py
new file mode 100644
index 00000000..b2831afc
--- /dev/null
+++ b/nylas/resources/redirect_uris.py
@@ -0,0 +1,130 @@
+from nylas.config import RequestOverrides
+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, overrides: RequestOverrides = None) -> ListResponse[RedirectUri]:
+ """
+ Return all Redirect URIs.
+
+ Args:
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The list of Redirect URIs.
+ """
+
+ return super().list(
+ path="/v3/applications/redirect-uris",
+ response_type=RedirectUri,
+ overrides=overrides,
+ )
+
+ def find(
+ self, redirect_uri_id: str, overrides: RequestOverrides = None
+ ) -> Response[RedirectUri]:
+ """
+ Return a Redirect URI.
+
+ Args:
+ redirect_uri_id: The ID of the Redirect URI to retrieve.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The Redirect URI.
+ """
+
+ return super().find(
+ path=f"/v3/applications/redirect-uris/{redirect_uri_id}",
+ response_type=RedirectUri,
+ overrides=overrides,
+ )
+
+ def create(
+ self, request_body: CreateRedirectUriRequest, overrides: RequestOverrides = None
+ ) -> Response[RedirectUri]:
+ """
+ Create a Redirect URI.
+
+ Args:
+ request_body: The values to create the Redirect URI with.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The created Redirect URI.
+ """
+
+ return super().create(
+ path="/v3/applications/redirect-uris",
+ request_body=request_body,
+ response_type=RedirectUri,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ redirect_uri_id: str,
+ request_body: UpdateRedirectUriRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The updated Redirect URI.
+ """
+
+ return super().update(
+ path=f"/v3/applications/redirect-uris/{redirect_uri_id}",
+ request_body=request_body,
+ response_type=RedirectUri,
+ overrides=overrides,
+ )
+
+ def destroy(
+ self, redirect_uri_id: str, overrides: RequestOverrides = None
+ ) -> DeleteResponse:
+ """
+ Delete a Redirect URI.
+
+ Args:
+ redirect_uri_id: The ID of the Redirect URI to delete.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The deletion response.
+ """
+
+ return super().destroy(
+ path=f"/v3/applications/redirect-uris/{redirect_uri_id}",
+ overrides=overrides,
+ )
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/rules.py b/nylas/resources/rules.py
new file mode 100644
index 00000000..698831d1
--- /dev/null
+++ b/nylas/resources/rules.py
@@ -0,0 +1,94 @@
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import (
+ CreatableApiResource,
+ DestroyableApiResource,
+ FindableApiResource,
+ ListableApiResource,
+ UpdatableApiResource,
+)
+from nylas.models.response import DeleteResponse, ListResponse, Response
+from nylas.models.rules import (
+ CreateRuleRequest,
+ ListRuleEvaluationsQueryParams,
+ ListRulesQueryParams,
+ Rule,
+ RuleEvaluation,
+ UpdateRuleRequest,
+)
+
+
+class Rules(
+ ListableApiResource,
+ FindableApiResource,
+ CreatableApiResource,
+ UpdatableApiResource,
+ DestroyableApiResource,
+):
+ """Nylas Rules API."""
+
+ def list(
+ self,
+ query_params: ListRulesQueryParams = None,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[Rule]:
+ """Return all rules for the application."""
+ return super().list(
+ path="/v3/rules",
+ response_type=Rule,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def create(
+ self,
+ request_body: CreateRuleRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Rule]:
+ """Create a new rule."""
+ return super().create(
+ path="/v3/rules",
+ request_body=request_body,
+ response_type=Rule,
+ overrides=overrides,
+ )
+
+ def find(self, rule_id: str, overrides: RequestOverrides = None) -> Response[Rule]:
+ """Return a specific rule by ID."""
+ return super().find(
+ path=f"/v3/rules/{rule_id}",
+ response_type=Rule,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ rule_id: str,
+ request_body: UpdateRuleRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Rule]:
+ """Update a rule by ID."""
+ return super().update(
+ path=f"/v3/rules/{rule_id}",
+ response_type=Rule,
+ request_body=request_body,
+ method="PUT",
+ overrides=overrides,
+ )
+
+ def destroy(self, rule_id: str, overrides: RequestOverrides = None) -> DeleteResponse:
+ """Delete a rule by ID."""
+ return super().destroy(path=f"/v3/rules/{rule_id}", overrides=overrides)
+
+ def list_evaluations(
+ self,
+ grant_id: str,
+ query_params: ListRuleEvaluationsQueryParams = None,
+ overrides: RequestOverrides = None,
+ ) -> ListResponse[RuleEvaluation]:
+ """Return rule evaluation audit records for a grant."""
+ return super().list(
+ path=f"/v3/grants/{grant_id}/rule-evaluations",
+ response_type=RuleEvaluation,
+ query_params=query_params,
+ overrides=overrides,
+ )
diff --git a/nylas/resources/scheduler.py b/nylas/resources/scheduler.py
new file mode 100644
index 00000000..e337de46
--- /dev/null
+++ b/nylas/resources/scheduler.py
@@ -0,0 +1,42 @@
+from nylas.resources.bookings import Bookings
+from nylas.resources.configurations import Configurations
+from nylas.resources.sessions import Sessions
+
+
+class Scheduler:
+ """
+ Class representation of a Nylas Scheduler API.
+ """
+
+ def __init__(self, http_client):
+ self.http_client = http_client
+
+ @property
+ def configurations(self) -> Configurations:
+ """
+ Access the Configurations API.
+
+ Returns:
+ The Configurations API.
+ """
+ return Configurations(self.http_client)
+
+ @property
+ def bookings(self) -> Bookings:
+ """
+ Access the Bookings API.
+
+ Returns:
+ The Bookings API.
+ """
+ return Bookings(self.http_client)
+
+ @property
+ def sessions(self) -> Sessions:
+ """
+ Access the Sessions API.
+
+ Returns:
+ The Sessions API.
+ """
+ return Sessions(self.http_client)
diff --git a/nylas/resources/sessions.py b/nylas/resources/sessions.py
new file mode 100644
index 00000000..556009a4
--- /dev/null
+++ b/nylas/resources/sessions.py
@@ -0,0 +1,56 @@
+from nylas.config import RequestOverrides
+from nylas.handler.api_resources import CreatableApiResource, DestroyableApiResource
+from nylas.models.response import DeleteResponse, Response
+from nylas.models.scheduler import CreateSessionRequest, Session
+
+
+class Sessions(CreatableApiResource, DestroyableApiResource):
+ """
+ Nylas Sessions API
+
+ The Nylas Sessions API allows you to create new sessions or manage existing ones.
+ """
+
+ def create(
+ self,
+ request_body: CreateSessionRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Session]:
+ """
+ Create a Session.
+
+ Args:
+ request_body: The request body to create the Session.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ The Session.
+ """
+
+ return super().create(
+ path="/v3/scheduling/sessions",
+ request_body=request_body,
+ response_type=Session,
+ overrides=overrides,
+ )
+
+ def destroy(
+ self,
+ session_id: str,
+ overrides: RequestOverrides = None,
+ ) -> DeleteResponse:
+ """
+ Destroy a Session.
+
+ Args:
+ session_id: The identifier of the Session to destroy.
+ overrides: The request overrides to use for the request.
+
+ Returns:
+ None.
+ """
+
+ return super().destroy(
+ path=f"/v3/scheduling/sessions/{session_id}",
+ overrides=overrides,
+ )
diff --git a/nylas/resources/smart_compose.py b/nylas/resources/smart_compose.py
new file mode 100644
index 00000000..1d266d89
--- /dev/null
+++ b/nylas/resources/smart_compose.py
@@ -0,0 +1,67 @@
+from nylas.config import RequestOverrides
+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,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The generated message.
+ """
+ res, headers = self._http_client._execute(
+ method="POST",
+ path=f"/v3/grants/{identifier}/messages/smart-compose",
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ return Response.from_dict(res, ComposeMessageResponse, headers)
+
+ def compose_message_reply(
+ self,
+ identifier: str,
+ message_id: str,
+ request_body: ComposeMessageRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The generated message reply.
+ """
+ res, headers = self._http_client._execute(
+ method="POST",
+ path=f"/v3/grants/{identifier}/messages/{message_id}/smart-compose",
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ return Response.from_dict(res, ComposeMessageResponse, headers)
diff --git a/nylas/resources/threads.py b/nylas/resources/threads.py
new file mode 100644
index 00000000..521ef918
--- /dev/null
+++ b/nylas/resources/threads.py
@@ -0,0 +1,112 @@
+import urllib.parse
+from nylas.config import RequestOverrides
+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,
+ overrides: RequestOverrides = 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.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ A list of Threads.
+ """
+ return super().list(
+ path=f"/v3/grants/{identifier}/threads",
+ response_type=Thread,
+ query_params=query_params,
+ overrides=overrides,
+ )
+
+ def find(
+ self, identifier: str, thread_id: str, overrides: RequestOverrides = None
+ ) -> Response[Thread]:
+ """
+ Return a Thread.
+
+ Args:
+ identifier: The identifier of the grant to get the thread for.
+ thread_id: The identifier of the thread to get.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The requested Thread.
+ """
+ return super().find(
+ path=f"/v3/grants/{identifier}/threads/{urllib.parse.quote(thread_id, safe='')}",
+ response_type=Thread,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ identifier: str,
+ thread_id: str,
+ request_body: UpdateThreadRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The updated Thread.
+ """
+ return super().update(
+ path=f"/v3/grants/{identifier}/threads/{urllib.parse.quote(thread_id, safe='')}",
+ response_type=Thread,
+ request_body=request_body,
+ overrides=overrides,
+ )
+
+ def destroy(
+ self, identifier: str, thread_id: str, overrides: RequestOverrides = None
+ ) -> DeleteResponse:
+ """
+ Delete a Thread.
+
+ Args:
+ identifier: The identifier of the grant to delete the thread for.
+ thread_id: The identifier of the thread to delete.
+ overrides: The request overrides to apply to the request.
+
+ Returns:
+ The deletion response.
+ """
+ return super().destroy(
+ path=f"/v3/grants/{identifier}/threads/{urllib.parse.quote(thread_id, safe='')}",
+ overrides=overrides,
+ )
diff --git a/nylas/resources/transactional_send.py b/nylas/resources/transactional_send.py
new file mode 100644
index 00000000..e6eae0e9
--- /dev/null
+++ b/nylas/resources/transactional_send.py
@@ -0,0 +1,73 @@
+import io
+import urllib.parse
+
+from nylas.config import RequestOverrides
+from nylas.models.messages import Message
+from nylas.models.response import Response
+from nylas.models.transactional_send import TransactionalSendMessageRequest
+from nylas.resources.resource import Resource
+from nylas.utils.file_utils import (
+ MAXIMUM_JSON_ATTACHMENT_SIZE,
+ _build_form_request,
+ encode_stream_to_base64,
+)
+
+
+class TransactionalSend(Resource):
+ """
+ Nylas Transactional Send API.
+
+ Send email from a verified domain without a grant context.
+ """
+
+ def send(
+ self,
+ domain_name: str,
+ request_body: TransactionalSendMessageRequest,
+ overrides: RequestOverrides = None,
+ ) -> Response[Message]:
+ """
+ Send a transactional email from the specified domain.
+
+ Args:
+ domain_name: The domain Nylas sends from (must be verified in the dashboard).
+ request_body: Message fields; use ``from_`` for the sender (maps to JSON ``from``).
+ overrides: Per-request overrides for the HTTP client.
+
+ Returns:
+ The sent message in a ``Response``.
+ """
+ path = (
+ f"/v3/domains/{urllib.parse.quote(domain_name, safe='')}/messages/send"
+ )
+ form_data = None
+ json_body = None
+
+ if "from_" in request_body and "from" not in request_body:
+ request_body["from"] = request_body["from_"]
+ del request_body["from_"]
+
+ attachment_size = sum(
+ attachment.get("size", 0)
+ for attachment in request_body.get("attachments", [])
+ )
+ if attachment_size >= MAXIMUM_JSON_ATTACHMENT_SIZE:
+ form_data = _build_form_request(request_body)
+ else:
+ for attachment in request_body.get("attachments", []):
+ if issubclass(type(attachment["content"]), io.IOBase):
+ attachment["content"] = encode_stream_to_base64(
+ attachment["content"]
+ )
+
+ json_body = request_body
+
+ json_response, headers = self._http_client._execute(
+ method="POST",
+ path=path,
+ request_body=json_body,
+ data=form_data,
+ overrides=overrides,
+ )
+
+ return Response.from_dict(json_response, Message, headers)
diff --git a/nylas/resources/webhooks.py b/nylas/resources/webhooks.py
new file mode 100644
index 00000000..443c159d
--- /dev/null
+++ b/nylas/resources/webhooks.py
@@ -0,0 +1,184 @@
+import urllib.parse
+
+from nylas.config import RequestOverrides
+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, overrides: RequestOverrides = None) -> ListResponse[Webhook]:
+ """
+ List all webhook destinations
+
+ Args:
+ overrides: The request overrides to apply to the request
+
+ Returns:
+ The list of webhook destinations
+ """
+ return super().list(path="/v3/webhooks", response_type=Webhook)
+
+ def find(
+ self, webhook_id: str, overrides: RequestOverrides = None
+ ) -> Response[Webhook]:
+ """
+ Get a webhook destination
+
+ Parameters:
+ webhook_id: The ID of the webhook destination to get
+ overrides: The request overrides to apply to the request
+
+ Returns:
+ The webhook destination
+ """
+ return super().find(
+ path=f"/v3/webhooks/{webhook_id}",
+ response_type=Webhook,
+ overrides=overrides,
+ )
+
+ def create(
+ self, request_body: CreateWebhookRequest, overrides: RequestOverrides = None
+ ) -> Response[WebhookWithSecret]:
+ """
+ Create a webhook destination
+
+ Parameters:
+ request_body: The request body to create the webhook destination
+ overrides: The request overrides to apply to the request
+
+ Returns:
+ The created webhook destination
+ """
+ return super().create(
+ path="/v3/webhooks",
+ request_body=request_body,
+ response_type=WebhookWithSecret,
+ overrides=overrides,
+ )
+
+ def update(
+ self,
+ webhook_id: str,
+ request_body: UpdateWebhookRequest,
+ overrides: RequestOverrides = None,
+ ) -> 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
+ overrides: The request overrides to apply to the request
+
+ Returns:
+ The updated webhook destination
+ """
+ return super().update(
+ path=f"/v3/webhooks/{webhook_id}",
+ request_body=request_body,
+ response_type=Webhook,
+ overrides=overrides,
+ )
+
+ def destroy(
+ self, webhook_id: str, overrides: RequestOverrides = None
+ ) -> WebhookDeleteResponse:
+ """
+ Delete a webhook destination
+
+ Parameters:
+ webhook_id: The ID of the webhook destination to delete
+ overrides: The request overrides to apply to the request
+
+ Returns:
+ The response from deleting the webhook destination
+ """
+ return super().destroy(
+ path=f"/v3/webhooks/{webhook_id}",
+ response_type=WebhookDeleteResponse,
+ overrides=overrides,
+ )
+
+ def rotate_secret(
+ self, webhook_id: str, overrides: RequestOverrides = None
+ ) -> Response[WebhookWithSecret]:
+ """
+ Update the webhook secret value for a destination
+
+ Parameters:
+ webhook_id: The ID of the webhook destination to update
+ overrides: The request overrides to apply to the request
+
+ Returns:
+ The updated webhook destination
+ """
+ res, headers = self._http_client._execute(
+ method="PUT",
+ path=f"/v3/webhooks/{webhook_id}/rotate-secret",
+ request_body={},
+ overrides=overrides,
+ )
+ return Response.from_dict(res, WebhookWithSecret, headers)
+
+ def ip_addresses(
+ self, overrides: RequestOverrides = None
+ ) -> Response[WebhookIpAddressesResponse]:
+ """
+ Get the current list of IP addresses that Nylas sends webhooks from
+
+ Args:
+ overrides: The request overrides to apply to the request
+
+ Returns:
+ The list of IP addresses that Nylas sends webhooks from
+ """
+ res, headers = self._http_client._execute(
+ method="GET", path="/v3/webhooks/ip-addresses", overrides=overrides
+ )
+ return Response.from_dict(res, WebhookIpAddressesResponse, headers)
+
+
+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/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..ece1e659
--- /dev/null
+++ b/nylas/utils/file_utils.py
@@ -0,0 +1,81 @@
+import base64
+import json
+import mimetypes
+import os
+from pathlib import Path
+from typing import BinaryIO
+
+from requests_toolbelt import MultipartEncoder
+
+from nylas.models.attachments import CreateAttachmentRequest
+
+
+MAXIMUM_JSON_ATTACHMENT_SIZE = 3 * 1024 * 1024
+"""The maximum size of an attachment that can be sent using json."""
+
+
+def attach_file_request_builder(file_path) -> CreateAttachmentRequest:
+ """
+ Build a request to attach a file.
+
+ 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 encode_stream_to_base64(binary_stream: BinaryIO) -> str:
+ """
+ Encode the content of a binary stream to a base64 string.
+
+ Attributes:
+ binary_stream: The binary stream to encode.
+
+ Returns:
+ The base64 encoded content of the binary stream.
+ """
+ binary_stream.seek(0)
+ binary_content = binary_stream.read()
+ return base64.b64encode(binary_content).decode("utf-8")
+
+
+def _build_form_request(request_body: dict) -> MultipartEncoder:
+ """
+ Build a form-data request.
+
+ 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):
+ # Use content_id as field name if provided, otherwise fallback to file{index}
+ field_name = attachment.get("content_id", f"file{index}")
+ fields[field_name] = (
+ attachment["filename"],
+ attachment["content"],
+ attachment["content_type"],
+ )
+
+ return MultipartEncoder(fields=fields)
diff --git a/pull_request_template.md b/pull_request_template.md
new file mode 100644
index 00000000..21c385c5
--- /dev/null
+++ b/pull_request_template.md
@@ -0,0 +1,3 @@
+# License
+
+I confirm that this contribution is made under the terms of the MIT license and that I have the authority necessary to make this contribution on behalf of its copyright owner.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..46395eff
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,57 @@
+[build-system]
+requires = ["setuptools>=69.0.3", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "nylas"
+dynamic = ["version"]
+description = "Python bindings for the Nylas API platform."
+readme = "README.md"
+license = {text = "MIT"}
+authors = [
+ {name = "Nylas Team", email = "support@nylas.com"}
+]
+keywords = ["inbox", "app", "appserver", "email", "nylas", "contacts", "calendar"]
+requires-python = ">=3.8"
+dependencies = [
+ "requests[security]>=2.31.0",
+ "requests-toolbelt>=1.0.0",
+ "dataclasses-json>=0.5.9",
+ "typing_extensions>=4.7.1",
+ "cryptography>=42.0.0",
+]
+
+[project.optional-dependencies]
+test = [
+ "pytest>=7.4.0",
+ "pytest-cov>=4.1.0",
+ "setuptools>=69.0.3",
+]
+docs = [
+ "mkdocs>=1.5.2",
+ "mkdocstrings[python]>=0.22.0",
+ "mkdocs-material>=9.2.6",
+ "mkdocs-gen-files>=0.5.0",
+ "mkdocs-literate-nav>=0.6.0",
+]
+release = [
+ "bumpversion>=0.6.0",
+ "twine>=4.0.2",
+]
+
+[project.urls]
+Homepage = "https://github.com/nylas/nylas-python"
+Repository = "https://github.com/nylas/nylas-python"
+
+[tool.setuptools.dynamic]
+version = {attr = "nylas._client_sdk_version.__VERSION__"}
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["nylas*"]
+
+[tool.pytest.ini_options]
+addopts = "-m 'not e2e'"
+markers = [
+ "e2e: marks tests that call live Nylas APIs",
+]
diff --git a/requirements-dev.txt b/requirements-dev.txt
deleted file mode 100644
index 208ec64c..00000000
--- a/requirements-dev.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-pytest
-responses
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.py b/setup.py
index 504a3ea5..bbe31737 100644
--- a/setup.py
+++ b/setup.py
@@ -1,71 +1,134 @@
import os
+import shutil
import sys
-sys.path.append('nylas/')
+import re
+import subprocess
+from setuptools import setup, find_packages, Command
-from setuptools import setup, find_packages
-from setuptools.command.test import test as TestCommand
-from _client_sdk_version import __VERSION__
+VERSION = ""
+with open("nylas/_client_sdk_version.py", "r") as fd:
+ VERSION = re.search(
+ r'^__VERSION__\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE
+ ).group(1)
-class PyTest(TestCommand):
- user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")]
+with open("README.md", "r", encoding="utf-8") as f:
+ README = f.read()
+
+RUN_DEPENDENCIES = [
+ "requests[security]>=2.31.0",
+ "requests-toolbelt>=1.0.0",
+ "dataclasses-json>=0.5.9",
+ "typing_extensions>=4.7.1",
+ "cryptography>=42.0.0",
+]
+
+TEST_DEPENDENCIES = ["pytest>=7.4.0", "pytest-cov>=4.1.0", "setuptools>=69.0.3"]
+
+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.6.0", "twine>=4.0.2"]
+
+
+class PyTest(Command):
+ user_options = [("pytest-args=", "a", "Arguments to pass to pytest")]
def initialize_options(self):
- TestCommand.initialize_options(self)
- self.pytest_args = '--junitxml ./tests/output tests/'
+ # pylint: disable=attribute-defined-outside-init
+ self.pytest_args = [
+ "--cov",
+ "--cov-report=xml",
+ "--junitxml",
+ "./tests/output",
+ "tests/",
+ ]
+ self.lint = False
def finalize_options(self):
- TestCommand.finalize_options(self)
+ # pylint: disable=attribute-defined-outside-init
self.test_args = []
self.test_suite = True
- def run_tests(self):
+ def run(self):
# import here, cause outside the eggs aren't loaded
import pytest
+
errno = pytest.main(self.pytest_args)
sys.exit(errno)
def main():
# A few handy release helpers.
+ # For publishing you should install the extra 'release' dependencies
+ # by running: pip install nylas['release']
if len(sys.argv) > 1:
- if sys.argv[1] == 'publish':
- os.system('git push --follow-tags && python setup.py sdist upload')
+ if sys.argv[1] == "publish":
+ try:
+ subprocess.check_output(["git", "push", "--follow-tags"])
+ subprocess.check_output(["python", "setup.py", "sdist"])
+ subprocess.check_output(["twine", "upload", "-r", "testpypi", "dist/*"])
+ subprocess.check_output(["twine", "upload", "dist/*"])
+ except FileNotFoundError as e:
+ print("Error encountered: {}.\n\n".format(e))
sys.exit()
- elif sys.argv[1] == 'release':
+ 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'
+ type_ = "patch"
else:
type_ = sys.argv[2]
- os.system('bumpversion --current-version {} {}'
- .format(__VERSION__, type_))
+ try:
+ subprocess.check_output(
+ ["bumpversion", "--current-version", VERSION, type_]
+ )
+ except FileNotFoundError as e:
+ print(
+ "Error encountered: {}.\n\n".format(e),
+ "Did you install the extra 'release' dependencies? (pip install nylas['release'])",
+ )
sys.exit()
setup(
name="nylas",
- version=__VERSION__,
+ version=VERSION,
+ python_requires=">=3.8",
packages=find_packages(),
-
- install_requires=[
- "requests>=2.4.2",
- "six>=1.4.1",
- "bumpversion>=0.5.0",
- # needed for SNI support, required by api.nylas.com
- "pyOpenSSL",
- "ndg-httpsclient",
- "pyasn1",
- ],
+ install_requires=RUN_DEPENDENCIES,
dependency_links=[],
- tests_require=["pytest", "coverage", "responses", "httpretty"],
- cmdclass={'test': PyTest},
+ 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",
- url='https://github.com/nylas/nylas-python'
+ keywords="inbox app appserver email nylas contacts calendar",
+ url="https://github.com/nylas/nylas-python",
+ long_description_content_type="text/markdown",
+ long_description=README,
)
-if __name__ == '__main__':
- sys.exit(main())
+if __name__ == "__main__":
+ main()
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 de46d45c..54eaeb81 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,44 +1,211 @@
-import re
-import json
+from unittest.mock import patch, Mock
+
import pytest
-import responses
-from nylas import APIClient
-from nylas.client.errors import *
+import requests
+from nylas.models.response import Response, ListResponse
+
+from nylas.handler.http_client import HttpClient
+
+from nylas import Client
+
+
+@pytest.fixture
+def client():
+ return Client(
+ api_key="test-key",
+ )
+
+
+@pytest.fixture
+def http_client():
+ return HttpClient(
+ api_server="https://test.nylas.com",
+ api_key="test-key",
+ timeout=30,
+ )
+
+
+@pytest.fixture
+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
-API_URL = 'http://localhost:2222'
+
+@pytest.fixture
+def patched_request():
+ mock_response = Mock()
+ mock_response.content = b"mock data"
+ mock_response.json.return_value = {"foo": "bar"}
+ mock_response.status_code = 200
+
+ with patch("requests.request", return_value=mock_response) as mock_request:
+ yield mock_request
@pytest.fixture
-def api_client():
- return APIClient(None, None, None, API_URL)
+def mock_session_timeout():
+ with patch("requests.request", side_effect=requests.exceptions.Timeout):
+ yield
@pytest.fixture
-def mock_account():
- response_body = json.dumps([
- {
- "account_id": "4dl0ni6vxomazo73r5ozdo16j",
- "email_address": "ben.bitdiddle1861@gmail.com",
- "id": "4dl0ni6vxomazo73r5ozdo16j",
- "name": "Ben Bitdiddle",
- "object": "account",
- "provider": "gmail"
- }
- ])
- responses.add(responses.GET, API_URL + '/n?limit=1&offset=0',
- content_type='application/json', status=200,
- body=response_body, match_querystring=True)
+def http_client_list_response():
+ with patch(
+ "nylas.models.response.ListResponse.from_dict", return_value=ListResponse([], "bar", None, {"X-Test-Header": "test"})
+ ):
+ mock_http_client = Mock()
+ mock_http_client._execute.return_value = ({
+ "request_id": "abc-123",
+ "data": [],
+ }, {"X-Test-Header": "test"})
+ yield mock_http_client
@pytest.fixture
-def mock_save_draft():
- save_endpoint = re.compile(API_URL + '/drafts/')
- response_body = json.dumps({
- "id": "4dl0ni6vxomazo73r5oydo16k",
- "version": "4dw0ni6txomazo33r5ozdo16j"
- })
- responses.add(responses.POST, save_endpoint,
- content_type='application/json', status=200,
- body=response_body, match_querystring=True)
+def http_client_response():
+ with patch(
+ "nylas.models.response.Response.from_dict", return_value=Response({}, "bar", {"X-Test-Header": "test"})
+ ):
+ 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",
+ },
+ }, {"X-Test-Header": "test"})
+ yield mock_http_client
+@pytest.fixture
+def http_client_delete_response():
+ mock_http_client = Mock()
+ mock_http_client._execute.return_value = ({
+ "request_id": "abc-123",
+ }, {"X-Test-Header": "test"})
+ return mock_http_client
+
+
+@pytest.fixture
+def http_client_token_exchange():
+ mock_http_client = Mock()
+ mock_http_client._execute.return_value = ({
+ "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",
+ "provider": "google",
+ }, {"X-Test-Header": "test"})
+ return mock_http_client
+
+
+@pytest.fixture
+def http_client_token_info():
+ mock_http_client = Mock()
+ mock_http_client._execute.return_value = ({
+ "request_id": "abc-123",
+ "data": {
+ "iss": "https://nylas.com",
+ "aud": "http://localhost:3030",
+ "sub": "Jaf84d88-£274-46cc-bbc9-aed7dac061c7",
+ "email": "user@example.com",
+ "iat": 1692094848,
+ "exp": 1692095173,
+ },
+ }, {"X-Test-Header": "test"})
+ return mock_http_client
+
+
+@pytest.fixture
+def http_client_free_busy():
+ mock_http_client = Mock()
+ mock_http_client._execute.return_value = ({
+ "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5",
+ "data": [
+ {
+ "email": "user1@example.com",
+ "time_slots": [
+ {
+ "start_time": 1690898400,
+ "end_time": 1690902000,
+ "status": "busy",
+ "object": "time_slot",
+ },
+ {
+ "start_time": 1691064000,
+ "end_time": 1691067600,
+ "status": "busy",
+ "object": "time_slot",
+ },
+ ],
+ "object": "free_busy",
+ },
+ {
+ "email": "user2@example.com",
+ "error": "Unable to resolve e-mail address user2@example.com to an Active Directory object.",
+ "object": "error",
+ },
+ ],
+ }, {"X-Test-Header": "test"})
+ return mock_http_client
+
+
+@pytest.fixture
+def http_client_list_scheduled_messages():
+ mock_http_client = Mock()
+ mock_http_client._execute.return_value = ({
+ "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5",
+ "data": [
+ {
+ "schedule_id": "8cd56334-6d95-432c-86d1-c5dab0ce98be",
+ "status": {
+ "code": "pending",
+ "description": "schedule send awaiting send at time",
+ },
+ },
+ {
+ "schedule_id": "rb856334-6d95-432c-86d1-c5dab0ce98be",
+ "status": {"code": "success", "description": "schedule send succeeded"},
+ "close_time": 1690579819,
+ },
+ ],
+ }, {"X-Test-Header": "test"})
+ return mock_http_client
+
+
+@pytest.fixture
+def http_client_clean_messages():
+ mock_http_client = Mock()
+ mock_http_client._execute.return_value = ({
+ "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5",
+ "data": [
+ {
+ "body": "Hello, I just sent a message using Nylas!",
+ "from": [
+ {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}
+ ],
+ "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386",
+ "id": "message-1",
+ "object": "message",
+ "conversation": "cleaned example",
+ },
+ {
+ "body": "Hello, this is a test message!",
+ "from": [{"name": "Michael Scott", "email": "m.scott@email.com"}],
+ "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386",
+ "id": "message-2",
+ "object": "message",
+ "conversation": "another example",
+ },
+ ],
+ }, {"X-Test-Header": "test"})
+ return mock_http_client
diff --git a/tests/credentials.py.template b/tests/credentials.py.template
deleted file mode 100644
index f445e0f0..00000000
--- a/tests/credentials.py.template
+++ /dev/null
@@ -1,3 +0,0 @@
-APP_ID = 'APP ID'
-APP_SECRET = 'APP SECRET'
-AUTH_TOKEN = 'local' # The access token to a local account (i.e: running on your machine).
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
new file mode 100644
index 00000000..89193c86
--- /dev/null
+++ b/tests/e2e/conftest.py
@@ -0,0 +1,118 @@
+import os
+from typing import Dict, List
+from uuid import uuid4
+
+import pytest
+
+from nylas import Client
+
+
+_E2E_API_KEY_ENV = "NYLAS_E2E_API_KEY"
+_E2E_API_URI_ENV = "NYLAS_E2E_API_URI"
+_E2E_RUN_ENV = "NYLAS_E2E_RUN"
+
+
+def _is_truthy(value: str) -> bool:
+ return value.lower() in {"1", "true", "yes", "on"}
+
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--run-e2e",
+ action="store_true",
+ default=False,
+ help="Run live E2E tests that call Nylas APIs.",
+ )
+
+
+def pytest_collection_modifyitems(config, items):
+ run_e2e = config.getoption("--run-e2e") or _is_truthy(os.getenv(_E2E_RUN_ENV, ""))
+ if run_e2e:
+ return
+
+ skip_e2e = pytest.mark.skip(
+ reason=(
+ "E2E tests are opt-in. Set NYLAS_E2E_RUN=1 or pass --run-e2e to execute."
+ )
+ )
+ for item in items:
+ if "e2e" in item.keywords:
+ item.add_marker(skip_e2e)
+
+
+@pytest.fixture
+def paginated_list_contains_id():
+ def _contains_id(list_method, resource_id: str, limit: int = 100, max_pages: int = 20) -> bool:
+ next_cursor = None
+ seen_cursors = set()
+
+ for _ in range(max_pages):
+ query_params = {"limit": limit}
+ if next_cursor:
+ query_params["page_token"] = next_cursor
+
+ response = list_method(query_params=query_params)
+ if any(item.id == resource_id for item in response.data if item and item.id):
+ return True
+
+ if not response.next_cursor or response.next_cursor in seen_cursors:
+ return False
+
+ seen_cursors.add(response.next_cursor)
+ next_cursor = response.next_cursor
+
+ return False
+
+ return _contains_id
+
+
+@pytest.fixture(scope="session")
+def e2e_client() -> Client:
+ api_key = os.getenv(_E2E_API_KEY_ENV, "")
+ if not api_key:
+ pytest.skip(
+ "E2E tests require NYLAS_E2E_API_KEY to be set."
+ )
+
+ api_uri = os.getenv(_E2E_API_URI_ENV, "")
+ timeout = int(os.getenv("NYLAS_E2E_TIMEOUT", "90"))
+ if api_uri:
+ return Client(api_key=api_key, api_uri=api_uri, timeout=timeout)
+ return Client(api_key=api_key, timeout=timeout)
+
+
+@pytest.fixture
+def unique_name():
+ def _build(prefix: str) -> str:
+ return f"{prefix}-{uuid4().hex[:10]}"
+
+ return _build
+
+
+@pytest.fixture
+def e2e_resource_registry(e2e_client):
+ registry: Dict[str, List[str]] = {
+ "policies": [],
+ "rules": [],
+ "lists": [],
+ }
+ yield registry
+
+ for policy_id in reversed(registry["policies"]):
+ try:
+ e2e_client.policies.destroy(policy_id)
+ except Exception:
+ pass
+
+ for rule_id in reversed(registry["rules"]):
+ try:
+ e2e_client.rules.destroy(rule_id)
+ except Exception:
+ pass
+
+ for list_id in reversed(registry["lists"]):
+ try:
+ e2e_client.lists.destroy(list_id)
+ except Exception:
+ pass
+
diff --git a/tests/e2e/test_lists_e2e.py b/tests/e2e/test_lists_e2e.py
new file mode 100644
index 00000000..a9778d14
--- /dev/null
+++ b/tests/e2e/test_lists_e2e.py
@@ -0,0 +1,60 @@
+import pytest
+
+
+@pytest.mark.e2e
+def test_lists_lifecycle_e2e(e2e_client, e2e_resource_registry, unique_name):
+ create_response = e2e_client.lists.create(
+ {
+ "name": unique_name("e2e-list"),
+ "type": "domain",
+ "description": "Created by SDK e2e test",
+ }
+ )
+ created_list = create_response.data
+ assert created_list.id
+ assert created_list.type == "domain"
+ e2e_resource_registry["lists"].append(created_list.id)
+
+ found_response = e2e_client.lists.find(created_list.id)
+ assert found_response.data.id == created_list.id
+
+ updated_name = unique_name("e2e-list-updated")
+ update_response = e2e_client.lists.update(
+ created_list.id,
+ {"name": updated_name, "description": "Updated by SDK e2e test"},
+ )
+ assert update_response.data.id == created_list.id
+ assert update_response.data.name == updated_name
+
+ first_domain = f"{unique_name('allowed')}.example"
+ second_domain = f"{unique_name('blocked')}.example"
+ add_items_response = e2e_client.lists.add_items(
+ created_list.id, {"items": [first_domain, second_domain]}
+ )
+ assert add_items_response.data.id == created_list.id
+
+ list_items_response = e2e_client.lists.list_items(
+ created_list.id, query_params={"limit": 200}
+ )
+ item_values = {item.value for item in list_items_response.data if item.value}
+ assert first_domain in item_values
+ assert second_domain in item_values
+
+ remove_items_response = e2e_client.lists.remove_items(
+ created_list.id, {"items": [first_domain]}
+ )
+ assert remove_items_response.data.id == created_list.id
+
+ after_remove_response = e2e_client.lists.list_items(
+ created_list.id, query_params={"limit": 200}
+ )
+ item_values_after_remove = {
+ item.value for item in after_remove_response.data if item.value
+ }
+ assert first_domain not in item_values_after_remove
+ assert second_domain in item_values_after_remove
+
+ destroy_response = e2e_client.lists.destroy(created_list.id)
+ assert destroy_response.request_id
+ e2e_resource_registry["lists"].remove(created_list.id)
+
diff --git a/tests/e2e/test_policies_e2e.py b/tests/e2e/test_policies_e2e.py
new file mode 100644
index 00000000..6f842095
--- /dev/null
+++ b/tests/e2e/test_policies_e2e.py
@@ -0,0 +1,69 @@
+import pytest
+
+
+@pytest.mark.e2e
+def test_policies_lifecycle_with_rule_association_e2e(
+ e2e_client, e2e_resource_registry, unique_name, paginated_list_contains_id
+):
+ rule_response = e2e_client.rules.create(
+ {
+ "name": unique_name("e2e-policy-rule"),
+ "trigger": "inbound",
+ "match": {
+ "operator": "any",
+ "conditions": [
+ {
+ "field": "from.domain",
+ "operator": "is",
+ "value": "example.com",
+ }
+ ],
+ },
+ "actions": [{"type": "archive"}],
+ }
+ )
+ created_rule = rule_response.data
+ assert created_rule.id
+ e2e_resource_registry["rules"].append(created_rule.id)
+
+ policy_response = e2e_client.policies.create(
+ {"name": unique_name("e2e-policy"), "rules": [created_rule.id]}
+ )
+ created_policy = policy_response.data
+ assert created_policy.id
+ e2e_resource_registry["policies"].append(created_policy.id)
+
+ find_response = e2e_client.policies.find(created_policy.id)
+ assert find_response.data.id == created_policy.id
+
+ updated_name = unique_name("e2e-policy-updated")
+ update_response = e2e_client.policies.update(
+ created_policy.id,
+ {
+ "name": updated_name,
+ "rules": [created_rule.id],
+ "spam_detection": {
+ "use_list_dnsbl": True,
+ "use_header_anomaly_detection": True,
+ },
+ },
+ )
+ # Some policy update responses may omit id; verify canonical state by refetching.
+ assert update_response.data.name == updated_name
+
+ refetch_response = e2e_client.policies.find(created_policy.id)
+ assert refetch_response.data.id == created_policy.id
+ assert refetch_response.data.name == updated_name
+ assert refetch_response.data.rules is not None
+ assert created_rule.id in refetch_response.data.rules
+
+ assert paginated_list_contains_id(e2e_client.policies.list, created_policy.id)
+
+ destroy_policy_response = e2e_client.policies.destroy(created_policy.id)
+ assert destroy_policy_response.request_id
+ e2e_resource_registry["policies"].remove(created_policy.id)
+
+ destroy_rule_response = e2e_client.rules.destroy(created_rule.id)
+ assert destroy_rule_response.request_id
+ e2e_resource_registry["rules"].remove(created_rule.id)
+
diff --git a/tests/e2e/test_rules_e2e.py b/tests/e2e/test_rules_e2e.py
new file mode 100644
index 00000000..677aa175
--- /dev/null
+++ b/tests/e2e/test_rules_e2e.py
@@ -0,0 +1,50 @@
+import pytest
+
+
+@pytest.mark.e2e
+def test_rules_lifecycle_e2e(
+ e2e_client, e2e_resource_registry, unique_name, paginated_list_contains_id
+):
+ create_response = e2e_client.rules.create(
+ {
+ "name": unique_name("e2e-rule"),
+ "description": "Created by SDK e2e test",
+ "trigger": "inbound",
+ "match": {
+ "operator": "any",
+ "conditions": [
+ {
+ "field": "from.domain",
+ "operator": "is",
+ "value": "example.com",
+ }
+ ],
+ },
+ "actions": [{"type": "archive"}],
+ }
+ )
+ created_rule = create_response.data
+ assert created_rule.id
+ e2e_resource_registry["rules"].append(created_rule.id)
+
+ find_response = e2e_client.rules.find(created_rule.id)
+ assert find_response.data.id == created_rule.id
+
+ updated_name = unique_name("e2e-rule-updated")
+ update_response = e2e_client.rules.update(
+ created_rule.id,
+ {
+ "name": updated_name,
+ "enabled": False,
+ "actions": [{"type": "mark_as_spam"}],
+ },
+ )
+ assert update_response.data.id == created_rule.id
+ assert update_response.data.name == updated_name
+
+ assert paginated_list_contains_id(e2e_client.rules.list, created_rule.id)
+
+ destroy_response = e2e_client.rules.destroy(created_rule.id)
+ assert destroy_response.request_id
+ e2e_resource_registry["rules"].remove(created_rule.id)
+
diff --git a/tests/handler/test_api_resources.py b/tests/handler/test_api_resources.py
new file mode 100644
index 00000000..80ae1d5f
--- /dev/null
+++ b/tests/handler/test_api_resources.py
@@ -0,0 +1,267 @@
+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"},
+ overrides=None,
+ )
+
+ 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"},
+ overrides=None,
+ )
+
+ 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"},
+ overrides=None,
+ )
+
+ assert type(response) is Response
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/foo",
+ {"test": "header"},
+ {"query": "param"},
+ {"foo": "bar"},
+ overrides=None,
+ )
+
+ 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"},
+ overrides=None,
+ )
+
+ assert type(response) is Response
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/foo",
+ {"test": "header"},
+ {"query": "param"},
+ {"foo": "bar"},
+ overrides=None,
+ )
+
+ 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"},
+ overrides=None,
+ )
+
+ assert type(response) is RequestIdOnlyResponse
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/foo",
+ {"test": "header"},
+ {"query": "param"},
+ {"foo": "bar"},
+ overrides=None,
+ )
+
+ 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"},
+ overrides=None,
+ )
+
+ assert type(response) is DeleteResponse
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/foo",
+ {"test": "header"},
+ {"query": "param"},
+ {"foo": "bar"},
+ overrides=None,
+ )
+
+ def test_list_resource_with_headers(self, http_client_list_response):
+ resource = MockResource(http_client_list_response)
+
+ response = resource.list(
+ path="/foo",
+ response_type=Calendar,
+ headers={"test": "header"},
+ query_params={"query": "param"},
+ request_body={"foo": "bar"},
+ )
+
+ assert response.headers == {"X-Test-Header": "test"}
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/foo",
+ {"test": "header"},
+ {"query": "param"},
+ {"foo": "bar"},
+ overrides=None,
+ )
+
+ def test_find_resource_with_headers(self, http_client_response):
+ resource = MockResource(http_client_response)
+
+ response = resource.find(
+ path="/foo",
+ response_type=Calendar,
+ headers={"test": "header"},
+ query_params={"query": "param"},
+ request_body={"foo": "bar"},
+ )
+
+ assert response.headers == {"X-Test-Header": "test"}
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/foo",
+ {"test": "header"},
+ {"query": "param"},
+ {"foo": "bar"},
+ overrides=None,
+ )
+
+ def test_create_resource_with_headers(self, http_client_response):
+ resource = MockResource(http_client_response)
+
+ response = resource.create(
+ path="/foo",
+ response_type=Calendar,
+ headers={"test": "header"},
+ query_params={"query": "param"},
+ request_body={"foo": "bar"},
+ )
+
+ assert response.headers == {"X-Test-Header": "test"}
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/foo",
+ {"test": "header"},
+ {"query": "param"},
+ {"foo": "bar"},
+ overrides=None,
+ )
+
+ def test_update_resource_with_headers(self, http_client_response):
+ resource = MockResource(http_client_response)
+
+ response = resource.update(
+ path="/foo",
+ response_type=Calendar,
+ headers={"test": "header"},
+ query_params={"query": "param"},
+ request_body={"foo": "bar"},
+ )
+
+ assert response.headers == {"X-Test-Header": "test"}
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/foo",
+ {"test": "header"},
+ {"query": "param"},
+ {"foo": "bar"},
+ overrides=None,
+ )
+
+ def test_destroy_resource_with_headers(self, http_client_delete_response):
+ resource = MockResource(http_client_delete_response)
+
+ response = resource.destroy(
+ path="/foo",
+ response_type=RequestIdOnlyResponse,
+ headers={"test": "header"},
+ query_params={"query": "param"},
+ request_body={"foo": "bar"},
+ )
+
+ assert response.headers == {"X-Test-Header": "test"}
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/foo",
+ {"test": "header"},
+ {"query": "param"},
+ {"foo": "bar"},
+ overrides=None,
+ )
diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py
new file mode 100644
index 00000000..a0ae7d6f
--- /dev/null
+++ b/tests/handler/test_http_client.py
@@ -0,0 +1,730 @@
+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; charset=utf-8",
+ }
+
+ 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_headers_override_headers(self, http_client, patched_version_and_sys):
+ headers = http_client._build_headers(
+ overrides={
+ "headers": {
+ "foo": "bar",
+ "X-Test": "test",
+ }
+ }
+ )
+
+ assert headers == {
+ "X-Nylas-API-Wrapper": "python",
+ "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
+ "Authorization": "Bearer test-key",
+ "foo": "bar",
+ "X-Test": "test",
+ }
+
+ def test_build_headers_override_api_key(self, http_client, patched_version_and_sys):
+ headers = http_client._build_headers(
+ overrides={
+ "api_key": "test-key-override",
+ }
+ )
+
+ assert headers == {
+ "X-Nylas-API-Wrapper": "python",
+ "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
+ "Authorization": "Bearer test-key-override",
+ }
+
+ def test_build_request_default(self, http_client, patched_version_and_sys):
+ request = http_client._build_request(
+ method="GET",
+ 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_request_override_api_uri(self, http_client, patched_version_and_sys):
+ request = http_client._build_request(
+ method="GET",
+ path="/foo",
+ overrides={
+ "api_uri": "https://override.nylas.com",
+ },
+ )
+
+ assert request == {
+ "method": "GET",
+ "url": "https://override.nylas.com/foo",
+ "headers": {
+ "X-Nylas-API-Wrapper": "python",
+ "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
+ "Authorization": "Bearer test-key",
+ },
+ }
+
+ def test_build_query_params(self, patched_version_and_sys):
+ url = _build_query_params(
+ base_url="https://test.nylas.com/foo",
+ 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_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_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_execute_download_request_override_timeout(
+ self, http_client, patched_version_and_sys, patched_request
+ ):
+ response = http_client._execute_download_request(
+ path="/foo",
+ overrides={"timeout": 60},
+ )
+ patched_request.assert_called_once_with(
+ "GET",
+ "https://test.nylas.com/foo",
+ headers={
+ "X-Nylas-API-Wrapper": "python",
+ "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
+ "Authorization": "Bearer test-key",
+ "Content-type": "application/json; charset=utf-8",
+ },
+ timeout=60,
+ stream=False,
+ )
+
+ def test_validate_response(self):
+ response = Mock()
+ response.status_code = 200
+ response.json.return_value = {"foo": "bar"}
+ response.url = "https://test.nylas.com/foo"
+ response.headers = {"X-Test-Header": "test"}
+
+ response_json, response_headers = _validate_response(response)
+ assert response_json == {"foo": "bar"}
+ assert response_headers == {"X-Test-Header": "test"}
+
+ def test_validate_response_400_error(self):
+ response = Mock()
+ 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_request):
+ mock_response = Mock()
+ mock_response.json.return_value = {"foo": "bar"}
+ mock_response.headers = {"X-Test-Header": "test"}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ response_json, response_headers = http_client._execute(
+ method="GET",
+ path="/foo",
+ headers={"test": "header"},
+ query_params={"query": "param"},
+ request_body={"foo": "bar"},
+ )
+
+ assert response_json == {"foo": "bar"}
+ assert response_headers == {"X-Test-Header": "test"}
+ patched_request.assert_called_once_with(
+ "GET",
+ "https://test.nylas.com/foo?query=param",
+ headers={
+ "X-Nylas-API-Wrapper": "python",
+ "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
+ "Authorization": "Bearer test-key",
+ "Content-type": "application/json; charset=utf-8",
+ "test": "header",
+ },
+ data=b'{"foo": "bar"}',
+ timeout=30,
+ )
+
+ def test_execute_with_serialized_json_body(
+ self, http_client, patched_version_and_sys, patched_request
+ ):
+ """Pre-serialized body bytes are sent as-is (e.g. Nylas service account signing)."""
+ mock_response = Mock()
+ mock_response.json.return_value = {"ok": True}
+ mock_response.headers = {}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ canonical = b'{"a":1,"b":2}'
+ response_json, response_headers = http_client._execute(
+ method="POST",
+ path="/v3/admin/domains",
+ request_body=None,
+ serialized_json_body=canonical,
+ )
+
+ assert response_json == {"ok": True}
+ patched_request.assert_called_once_with(
+ "POST",
+ "https://test.nylas.com/v3/admin/domains",
+ headers={
+ "X-Nylas-API-Wrapper": "python",
+ "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
+ "Authorization": "Bearer test-key",
+ "Content-type": "application/json; charset=utf-8",
+ },
+ data=canonical,
+ timeout=30,
+ )
+
+ def test_build_request_sets_content_type_for_serialized_json_body(
+ self, http_client, patched_version_and_sys
+ ):
+ request = http_client._build_request(
+ method="POST",
+ path="/signed",
+ request_body=None,
+ serialized_json_body=b"{}",
+ )
+ assert request["headers"]["Content-type"] == "application/json; charset=utf-8"
+
+ def test_execute_override_timeout(
+ self, http_client, patched_version_and_sys, patched_request
+ ):
+ mock_response = Mock()
+ mock_response.json.return_value = {"foo": "bar"}
+ mock_response.headers = {"X-Test-Header": "test"}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ response_json, response_headers = http_client._execute(
+ method="GET",
+ path="/foo",
+ headers={"test": "header"},
+ query_params={"query": "param"},
+ request_body={"foo": "bar"},
+ overrides={"timeout": 60},
+ )
+
+ assert response_json == {"foo": "bar"}
+ assert response_headers == {"X-Test-Header": "test"}
+ patched_request.assert_called_once_with(
+ "GET",
+ "https://test.nylas.com/foo?query=param",
+ headers={
+ "X-Nylas-API-Wrapper": "python",
+ "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
+ "Authorization": "Bearer test-key",
+ "Content-type": "application/json; charset=utf-8",
+ "test": "header",
+ },
+ data=b'{"foo": "bar"}',
+ timeout=60,
+ )
+
+ 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."
+ )
+
+ def test_validate_response_with_headers(self):
+ response = Mock()
+ response.status_code = 200
+ response.json.return_value = {"foo": "bar"}
+ response.url = "https://test.nylas.com/foo"
+ response.headers = {"X-Test-Header": "test"}
+
+ json_response, headers = _validate_response(response)
+ assert json_response == {"foo": "bar"}
+ assert headers == {"X-Test-Header": "test"}
+
+ def test_validate_response_400_error_with_headers(self):
+ response = Mock()
+ response.status_code = 400
+ response.json.return_value = {
+ "request_id": "123",
+ "error": {
+ "type": "api_error",
+ "message": "The request is invalid.",
+ "provider_error": {"foo": "bar"},
+ },
+ }
+ response.url = "https://test.nylas.com/foo"
+ response.headers = {"X-Test-Header": "test"}
+
+ with pytest.raises(NylasApiError) as e:
+ _validate_response(response)
+ assert e.value.headers == {"X-Test-Header": "test"}
+
+ def test_validate_response_auth_error_with_headers(self):
+ response = Mock()
+ response.status_code = 401
+ response.json.return_value = {
+ "error": "invalid_request",
+ "error_description": "The request is invalid.",
+ "error_uri": "https://docs.nylas.com/reference#authentication-errors",
+ "error_code": 100241,
+ }
+ response.url = "https://test.nylas.com/connect/token"
+ response.headers = {"X-Test-Header": "test"}
+
+ with pytest.raises(NylasOAuthError) as e:
+ _validate_response(response)
+ assert e.value.headers == {"X-Test-Header": "test"}
+
+ def test_execute_with_headers(self, http_client, patched_version_and_sys, patched_request):
+ mock_response = Mock()
+ mock_response.json.return_value = {"foo": "bar"}
+ mock_response.headers = {"X-Test-Header": "test"}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ response_json, response_headers = http_client._execute(
+ method="GET",
+ path="/foo",
+ headers={"test": "header"},
+ query_params={"query": "param"},
+ request_body={"foo": "bar"},
+ )
+
+ assert response_json == {"foo": "bar"}
+ assert response_headers == {"X-Test-Header": "test"}
+ patched_request.assert_called_once_with(
+ "GET",
+ "https://test.nylas.com/foo?query=param",
+ headers={
+ "X-Nylas-API-Wrapper": "python",
+ "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
+ "Authorization": "Bearer test-key",
+ "Content-type": "application/json; charset=utf-8",
+ "test": "header",
+ },
+ data=b'{"foo": "bar"}',
+ timeout=30,
+ )
+
+ def test_execute_with_utf8_characters(self, http_client, patched_version_and_sys, patched_request):
+ """Test that UTF-8 characters are preserved in JSON requests (not escaped)."""
+ mock_response = Mock()
+ mock_response.json.return_value = {"success": True}
+ mock_response.headers = {"X-Test-Header": "test"}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ # Request with special characters
+ request_body = {
+ "title": "Réunion d'équipe",
+ "description": "De l'idée à la post-prod, sans friction",
+ "location": "café",
+ }
+
+ response_json, response_headers = http_client._execute(
+ method="POST",
+ path="/events",
+ request_body=request_body,
+ )
+
+ assert response_json == {"success": True}
+ # Verify that the data is sent as UTF-8 encoded bytes
+ call_kwargs = patched_request.call_args[1]
+ assert "data" in call_kwargs
+ sent_data = call_kwargs["data"]
+
+ # The data should be bytes with actual UTF-8 characters (not escape sequences)
+ assert isinstance(sent_data, bytes)
+ decoded_data = sent_data.decode("utf-8")
+ assert "Réunion d'équipe" in decoded_data
+ assert "De l'idée à la post-prod, sans friction" in decoded_data
+ assert "café" in decoded_data
+ # Should NOT contain unicode escape sequences
+ assert "\\u" not in decoded_data
+
+ def test_execute_with_none_request_body(self, http_client, patched_version_and_sys, patched_request):
+ """Test that None request_body is handled correctly."""
+ mock_response = Mock()
+ mock_response.json.return_value = {"success": True}
+ mock_response.headers = {"X-Test-Header": "test"}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ response_json, response_headers = http_client._execute(
+ method="GET",
+ path="/events",
+ request_body=None,
+ )
+
+ assert response_json == {"success": True}
+ # Verify that data branch is used when request_body is None
+ call_kwargs = patched_request.call_args[1]
+ # Should use data= parameter, not json= parameter
+ assert "data" in call_kwargs
+ assert "json" not in call_kwargs
+ assert call_kwargs["data"] is None
+
+ def test_execute_with_none_request_body_and_none_data(self, http_client, patched_version_and_sys, patched_request):
+ """Test that both None request_body and None data are handled correctly."""
+ mock_response = Mock()
+ mock_response.json.return_value = {"success": True}
+ mock_response.headers = {"X-Test-Header": "test"}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ response_json, response_headers = http_client._execute(
+ method="DELETE",
+ path="/events/123",
+ request_body=None,
+ data=None,
+ )
+
+ assert response_json == {"success": True}
+ call_kwargs = patched_request.call_args[1]
+ # Should use data= parameter with None value
+ assert "data" in call_kwargs
+ assert "json" not in call_kwargs
+ assert call_kwargs["data"] is None
+
+ def test_execute_with_emoji_and_international_characters(self, http_client, patched_version_and_sys, patched_request):
+ """Test that emoji and various international characters are preserved."""
+ mock_response = Mock()
+ mock_response.json.return_value = {"success": True}
+ mock_response.headers = {"X-Test-Header": "test"}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ request_body = {
+ "emoji": "🎉 Party time! 🥳",
+ "japanese": "こんにちは",
+ "chinese": "你好",
+ "russian": "Привет",
+ "german": "Größe",
+ "spanish": "¿Cómo estás?",
+ }
+
+ response_json, response_headers = http_client._execute(
+ method="POST",
+ path="/messages",
+ request_body=request_body,
+ )
+
+ assert response_json == {"success": True}
+ call_kwargs = patched_request.call_args[1]
+ sent_data = call_kwargs["data"]
+
+ # All characters should be preserved as UTF-8 encoded bytes
+ assert isinstance(sent_data, bytes)
+ decoded_data = sent_data.decode("utf-8")
+ assert "🎉 Party time! 🥳" in decoded_data
+ assert "こんにちは" in decoded_data
+ assert "你好" in decoded_data
+ assert "Привет" in decoded_data
+ assert "Größe" in decoded_data
+ assert "¿Cómo estás?" in decoded_data
+
+ def test_execute_with_right_single_quotation_mark(self, http_client, patched_version_and_sys, patched_request):
+ """Test that right single quotation mark (\\u2019) is handled correctly.
+
+ This character caused UnicodeEncodeError: 'latin-1' codec can't encode character '\\u2019'.
+ """
+ mock_response = Mock()
+ mock_response.json.return_value = {"success": True}
+ mock_response.headers = {"X-Test-Header": "test"}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ # The \u2019 character is the right single quotation mark (')
+ # This was the exact character that caused the original encoding error
+ request_body = {
+ "subject": "It's a test", # Contains \u2019 (right single quotation mark)
+ "body": "Here's another example with curly apostrophe",
+ }
+
+ response_json, response_headers = http_client._execute(
+ method="POST",
+ path="/messages/send",
+ request_body=request_body,
+ )
+
+ assert response_json == {"success": True}
+ call_kwargs = patched_request.call_args[1]
+ sent_data = call_kwargs["data"]
+
+ # The data should be UTF-8 encoded bytes with the \u2019 character preserved
+ assert isinstance(sent_data, bytes)
+ decoded_data = sent_data.decode("utf-8")
+ assert "'" in decoded_data # \u2019 right single quotation mark
+ assert "It's a test" in decoded_data
+ assert "Here's another" in decoded_data
+
+ def test_execute_with_emojis(self, http_client, patched_version_and_sys, patched_request):
+ """Test that emojis are handled correctly in request bodies.
+
+ Emojis are multi-byte UTF-8 characters that could cause encoding issues
+ if not handled properly.
+ """
+ mock_response = Mock()
+ mock_response.json.return_value = {"success": True}
+ mock_response.headers = {"X-Test-Header": "test"}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ request_body = {
+ "subject": "Hello 👋 World 🌍",
+ "body": "Great job! 🎉 Keep up the good work 💪 See you soon 😊",
+ "emoji_only": "🔥🚀✨💯",
+ "mixed": "Meeting at 3pm 📅 Don't forget! ⏰",
+ }
+
+ response_json, response_headers = http_client._execute(
+ method="POST",
+ path="/messages/send",
+ request_body=request_body,
+ )
+
+ assert response_json == {"success": True}
+ call_kwargs = patched_request.call_args[1]
+ sent_data = call_kwargs["data"]
+
+ # All emojis should be preserved in UTF-8 encoded bytes
+ assert isinstance(sent_data, bytes)
+ decoded_data = sent_data.decode("utf-8")
+ assert "Hello 👋 World 🌍" in decoded_data
+ assert "🎉" in decoded_data
+ assert "💪" in decoded_data
+ assert "😊" in decoded_data
+ assert "🔥🚀✨💯" in decoded_data
+ assert "📅" in decoded_data
+ assert "⏰" in decoded_data
+
+ def test_execute_with_nan_and_infinity(self, http_client, patched_version_and_sys, patched_request):
+ """Test that NaN and Infinity float values are handled correctly.
+
+ The requests library's json= parameter uses allow_nan=False which raises
+ ValueError for NaN/Infinity. Our implementation uses json.dumps with
+ allow_nan=True to maintain backward compatibility.
+ """
+ mock_response = Mock()
+ mock_response.json.return_value = {"success": True}
+ mock_response.headers = {"X-Test-Header": "test"}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ request_body = {
+ "nan_value": float("nan"),
+ "infinity": float("inf"),
+ "neg_infinity": float("-inf"),
+ "normal": 42.5,
+ }
+
+ # This should NOT raise ValueError
+ response_json, response_headers = http_client._execute(
+ method="POST",
+ path="/data",
+ request_body=request_body,
+ )
+
+ assert response_json == {"success": True}
+ call_kwargs = patched_request.call_args[1]
+ sent_data = call_kwargs["data"]
+
+ # The data should be UTF-8 encoded bytes with NaN/Infinity serialized
+ assert isinstance(sent_data, bytes)
+ decoded_data = sent_data.decode("utf-8")
+ # json.dumps with allow_nan=True produces NaN, Infinity, -Infinity (JS-style)
+ assert "NaN" in decoded_data
+ assert "Infinity" in decoded_data
+ assert "-Infinity" in decoded_data
+ assert "42.5" in decoded_data
+
+ def test_execute_with_multipart_data_not_affected(self, http_client, patched_version_and_sys, patched_request):
+ """Test that multipart/form-data is not affected by the change."""
+ mock_response = Mock()
+ mock_response.json.return_value = {"success": True}
+ mock_response.headers = {"X-Test-Header": "test"}
+ mock_response.status_code = 200
+ patched_request.return_value = mock_response
+
+ # When data is provided (multipart), request_body should be ignored
+ mock_data = Mock()
+ mock_data.content_type = "multipart/form-data"
+
+ response_json, response_headers = http_client._execute(
+ method="POST",
+ path="/messages/send",
+ request_body={"foo": "bar"}, # This should be ignored
+ data=mock_data,
+ )
+
+ assert response_json == {"success": True}
+ call_kwargs = patched_request.call_args[1]
+ # Should use the multipart data, not JSON
+ assert call_kwargs["data"] == mock_data
diff --git a/tests/handler/test_service_account.py b/tests/handler/test_service_account.py
new file mode 100644
index 00000000..bfcb4064
--- /dev/null
+++ b/tests/handler/test_service_account.py
@@ -0,0 +1,177 @@
+import base64
+import string
+
+import pytest
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
+
+from nylas.handler.service_account import (
+ ServiceAccountSigner,
+ _signing_envelope_bytes,
+ canonical_json,
+ generate_nonce,
+ load_rsa_private_key_from_pem,
+ sign_bytes,
+)
+
+
+class TestCanonicalJson:
+ def test_sorted_keys_flat(self):
+ assert canonical_json({"b": 1, "a": 2}) == '{"a":2,"b":1}'
+
+ def test_nested_dict_sorted(self):
+ assert (
+ canonical_json({"z": {"b": 1, "a": 2}, "y": 0})
+ == '{"y":0,"z":{"a":2,"b":1}}'
+ )
+
+ def test_string_escaping(self):
+ s = canonical_json({"msg": 'quote"here'})
+ assert s.startswith("{")
+ assert '"msg":' in s
+ assert "quote" in s
+
+ def test_list_and_bool_values_use_json_dumps(self):
+ s = canonical_json({"ok": True, "items": [3, 1, 2]})
+ assert '"items":[3,1,2]' in s
+ assert '"ok":true' in s
+
+
+class TestServiceAccountSigning:
+ @pytest.fixture
+ def rsa_pem(self):
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ return (
+ key,
+ key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption(),
+ ).decode("ascii"),
+ )
+
+ def test_load_pkcs8_pem(self, rsa_pem):
+ _, pem = rsa_pem
+ loaded = load_rsa_private_key_from_pem(pem)
+ assert isinstance(loaded, rsa.RSAPrivateKey)
+
+ def test_load_pkcs1_traditional_pem(self, rsa_pem):
+ private_key, _ = rsa_pem
+ pem_pkcs1 = private_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=serialization.NoEncryption(),
+ )
+ loaded = load_rsa_private_key_from_pem(pem_pkcs1)
+ assert isinstance(loaded, rsa.RSAPrivateKey)
+
+ def test_load_pem_accepts_bytes(self, rsa_pem):
+ _, pem = rsa_pem
+ loaded = load_rsa_private_key_from_pem(pem.encode("ascii"))
+ assert isinstance(loaded, rsa.RSAPrivateKey)
+
+ def test_load_non_rsa_raises(self):
+ ec_key = ec.generate_private_key(ec.SECP256R1())
+ pem = ec_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption(),
+ ).decode("ascii")
+ with pytest.raises(ValueError, match="Private key must be RSA"):
+ load_rsa_private_key_from_pem(pem)
+
+ def test_sign_bytes_verifies_with_public_key(self, rsa_pem):
+ private_key, pem = rsa_pem
+ message = b"hello nylas canonical envelope"
+ sig_b64 = sign_bytes(private_key, message)
+ sig = base64.b64decode(sig_b64)
+ public_key = private_key.public_key()
+ public_key.verify(sig, message, padding.PKCS1v15(), hashes.SHA256())
+
+ def test_golden_envelope_signature_round_trip(self, rsa_pem):
+ """Fixed inputs: signature must verify (independent of ServiceAccountSigner time)."""
+ private_key, pem = rsa_pem
+ path = "/v3/admin/domains"
+ ts = 1742932766
+ nonce = "abcdefabcdefabcdefab"
+ body = {"type": "ownership"}
+ envelope = _signing_envelope_bytes(path, "POST", ts, nonce, body)
+ sig_b64 = sign_bytes(private_key, envelope)
+ sig = base64.b64decode(sig_b64)
+ private_key.public_key().verify(sig, envelope, padding.PKCS1v15(), hashes.SHA256())
+
+ def test_service_account_signer_build_headers_post(self, rsa_pem):
+ private_key, pem = rsa_pem
+ signer = ServiceAccountSigner(pem, "test-kid-uuid")
+ headers, body_bytes = signer.build_headers(
+ "POST",
+ "/v3/admin/domains",
+ {"name": "My domain", "domain_address": "mail.example.com"},
+ timestamp=1700000000,
+ nonce="nonce123456789012345",
+ )
+ assert headers["X-Nylas-Kid"] == "test-kid-uuid"
+ assert headers["X-Nylas-Nonce"] == "nonce123456789012345"
+ assert headers["X-Nylas-Timestamp"] == "1700000000"
+ assert len(headers["X-Nylas-Signature"]) > 0
+ assert body_bytes == canonical_json(
+ {"name": "My domain", "domain_address": "mail.example.com"}
+ ).encode("utf-8")
+
+ envelope = _signing_envelope_bytes(
+ "/v3/admin/domains",
+ "POST",
+ 1700000000,
+ "nonce123456789012345",
+ {"name": "My domain", "domain_address": "mail.example.com"},
+ )
+ sig = base64.b64decode(headers["X-Nylas-Signature"])
+ private_key.public_key().verify(sig, envelope, padding.PKCS1v15(), hashes.SHA256())
+
+ def test_service_account_signer_get_no_body_bytes(self, rsa_pem):
+ _, pem = rsa_pem
+ signer = ServiceAccountSigner(pem, "kid")
+ headers, body_bytes = signer.build_headers(
+ "GET", "/v3/admin/domains", None, timestamp=1, nonce="n" * 20
+ )
+ assert body_bytes is None
+ assert "X-Nylas-Signature" in headers
+
+ def test_signing_envelope_get_omits_payload(self, rsa_pem):
+ private_key, _ = rsa_pem
+ env = _signing_envelope_bytes("/v3/admin/domains", "GET", 1, "n" * 20, None)
+ assert b"payload" not in env
+ sig_b64 = sign_bytes(private_key, env)
+ private_key.public_key().verify(
+ base64.b64decode(sig_b64), env, padding.PKCS1v15(), hashes.SHA256()
+ )
+
+ def test_signing_envelope_put_and_patch_include_payload(self, rsa_pem):
+ private_key, _ = rsa_pem
+ for method in ("PUT", "patch"):
+ env = _signing_envelope_bytes(
+ "/v3/admin/domains/x", method, 2, "m" * 20, {"name": "n"}
+ )
+ assert b"payload" in env
+ sig_b64 = sign_bytes(private_key, env)
+ private_key.public_key().verify(
+ base64.b64decode(sig_b64), env, padding.PKCS1v15(), hashes.SHA256()
+ )
+
+ def test_generate_nonce_custom_length(self):
+ n = generate_nonce(12)
+ assert len(n) == 12
+ assert all(c in (string.ascii_letters + string.digits) for c in n)
+
+ def test_build_headers_patch(self, rsa_pem):
+ _, pem = rsa_pem
+ signer = ServiceAccountSigner(pem, "kid")
+ headers, body_bytes = signer.build_headers(
+ "PATCH",
+ "/v3/admin/example",
+ {"op": "replace"},
+ timestamp=9,
+ nonce="z" * 20,
+ )
+ assert body_bytes == canonical_json({"op": "replace"}).encode("utf-8")
+ assert headers["X-Nylas-Timestamp"] == "9"
diff --git a/tests/oauth.py b/tests/oauth.py
deleted file mode 100644
index 4571de18..00000000
--- a/tests/oauth.py
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/usr/bin/env python
-
-import time
-from flask import Flask, url_for, session, request, redirect, Response
-
-from nylas import APIClient
-
-try:
- from credentials import APP_ID, APP_SECRET
-except ImportError:
- print "Couldn't import credentials.py --- you'll need to create it."
- print "See credentials.py.template for more details."
- sys.exit(-1)
-
-app = Flask(__name__)
-app.debug = True
-app.secret_key = 'secret'
-
-assert APP_ID != 'YOUR_APP_ID' or APP_SECRET != 'YOUR_APP_SECRET',\
- "You should change the value of APP_ID and APP_SECRET"
-
-
-@app.route('/')
-def index():
- # We don't have an access token, so we're going to use OAuth to
- # authenticate the user
-
- # Ask flask to generate the url corresponding to the login_callback
- # route. This is similar to using reverse() in django.
- redirect_uri = url_for('.login_callback', _external=True)
-
- client = APIClient(APP_ID, APP_SECRET)
- return redirect(client.authentication_url(redirect_uri))
-
-
-@app.route('/login_callback')
-def login_callback():
- if 'error' in request.args:
- return "Login error: {0}".format(request.args['error'])
-
- # Exchange the authorization code for an access token
- client = APIClient(APP_ID, APP_SECRET)
- code = request.args.get('code')
- token = client.token_for_code(code)
- return token
-
-if __name__ == '__main__':
- print "\033[94mOauth self-test. Please browse to http://localhost:5555 and make\033[0m"
- print "\033[94msure that you're seeing a valid API token.\033[0m"
-
- app.run(host='0.0.0.0', port=5555)
diff --git a/tests/resources/test_applications.py b/tests/resources/test_applications.py
new file mode 100644
index 00000000..9de17fb4
--- /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",
+ },
+ }
+ ],
+ },
+ }, {"X-Test-Header": "test"})
+ app = Applications(mock_http_client)
+
+ res = app.info()
+
+ mock_http_client._execute.assert_called_once_with(
+ method="GET", path="/v3/applications", overrides=None
+ )
+ 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..c4f36418
--- /dev/null
+++ b/tests/resources/test_attachments.py
@@ -0,0 +1,566 @@
+from io import BytesIO
+from unittest.mock import Mock
+
+from nylas.models.attachments import (
+ Attachment,
+ CreateAttachmentRequest,
+ FindAttachmentQueryParams,
+ AttachmentUploadSession,
+ AttachmentUploadSessionComplete,
+ CreateAttachmentUploadSessionRequest,
+)
+from nylas.resources.attachments import Attachments
+
+
+class TestAttachmentModel:
+ """Tests for the Attachment dataclass model."""
+
+ def test_attachment_deserialization(self):
+ """Test full deserialization of Attachment from dict."""
+ attach_json = {
+ "content_type": "image/png",
+ "filename": "pic.png",
+ "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386",
+ "id": "185e56cb50e12e82",
+ "is_inline": True,
+ "size": 13068,
+ "content_id": "",
+ "content_disposition": "inline",
+ }
+
+ 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 == ""
+ assert attachment.content_disposition == "inline"
+
+ def test_attachment_serialization(self):
+ """Test serialization of Attachment to dict."""
+ attachment = Attachment(
+ id="185e56cb50e12e82",
+ grant_id="41009df5-bf11-4c97-aa18-b285b5f2e386",
+ filename="document.pdf",
+ content_type="application/pdf",
+ size=2048,
+ content_id="",
+ content_disposition="attachment",
+ is_inline=False,
+ )
+
+ result = attachment.to_dict()
+
+ assert result["id"] == "185e56cb50e12e82"
+ assert result["grant_id"] == "41009df5-bf11-4c97-aa18-b285b5f2e386"
+ assert result["filename"] == "document.pdf"
+ assert result["content_type"] == "application/pdf"
+ assert result["size"] == 2048
+ assert result["content_id"] == ""
+ assert result["content_disposition"] == "attachment"
+ assert result["is_inline"] is False
+
+ def test_attachment_deserialization_partial_fields(self):
+ """Test deserialization with only required fields."""
+ attach_json = {
+ "id": "abc123",
+ "filename": "test.txt",
+ }
+
+ attachment = Attachment.from_dict(attach_json)
+
+ assert attachment.id == "abc123"
+ assert attachment.filename == "test.txt"
+ assert attachment.grant_id is None
+ assert attachment.content_type is None
+ assert attachment.size is None
+ assert attachment.content_id is None
+ assert attachment.content_disposition is None
+ assert attachment.is_inline is None
+
+ def test_attachment_deserialization_empty_dict(self):
+ """Test deserialization from empty dict."""
+ attachment = Attachment.from_dict({})
+
+ assert attachment.id is None
+ assert attachment.grant_id is None
+ assert attachment.filename is None
+ assert attachment.content_type is None
+ assert attachment.size is None
+ assert attachment.content_id is None
+ assert attachment.content_disposition is None
+ assert attachment.is_inline is None
+
+ def test_attachment_default_values(self):
+ """Test Attachment instantiation with default values."""
+ attachment = Attachment()
+
+ assert attachment.id is None
+ assert attachment.grant_id is None
+ assert attachment.filename is None
+ assert attachment.content_type is None
+ assert attachment.size is None
+ assert attachment.content_id is None
+ assert attachment.content_disposition is None
+ assert attachment.is_inline is None
+
+ def test_attachment_content_disposition_attachment(self):
+ """Test attachment with content_disposition set to 'attachment'."""
+ attach_json = {
+ "id": "file-123",
+ "filename": "report.xlsx",
+ "content_disposition": "attachment",
+ "is_inline": False,
+ }
+
+ attachment = Attachment.from_dict(attach_json)
+
+ assert attachment.content_disposition == "attachment"
+ assert attachment.is_inline is False
+
+ def test_attachment_content_disposition_inline(self):
+ """Test inline attachment with content_disposition."""
+ attach_json = {
+ "id": "img-456",
+ "filename": "logo.png",
+ "content_disposition": "inline",
+ "is_inline": True,
+ "content_id": "",
+ }
+
+ attachment = Attachment.from_dict(attach_json)
+
+ assert attachment.content_disposition == "inline"
+ assert attachment.is_inline is True
+ assert attachment.content_id == ""
+
+ def test_attachment_roundtrip_serialization(self):
+ """Test that serialization and deserialization are inverses."""
+ original = Attachment(
+ id="test-id",
+ grant_id="grant-123",
+ filename="file.txt",
+ content_type="text/plain",
+ size=100,
+ content_id="",
+ content_disposition="attachment",
+ is_inline=False,
+ )
+
+ serialized = original.to_dict()
+ deserialized = Attachment.from_dict(serialized)
+
+ assert deserialized.id == original.id
+ assert deserialized.grant_id == original.grant_id
+ assert deserialized.filename == original.filename
+ assert deserialized.content_type == original.content_type
+ assert deserialized.size == original.size
+ assert deserialized.content_id == original.content_id
+ assert deserialized.content_disposition == original.content_disposition
+ assert deserialized.is_inline == original.is_inline
+
+
+class TestCreateAttachmentRequest:
+ """Tests for the CreateAttachmentRequest TypedDict."""
+
+ def test_create_attachment_request_with_base64_content(self):
+ """Test creating attachment request with base64 encoded content."""
+ request: CreateAttachmentRequest = {
+ "filename": "test.txt",
+ "content_type": "text/plain",
+ "content": "SGVsbG8gV29ybGQh", # base64 for "Hello World!"
+ "size": 12,
+ }
+
+ assert request["filename"] == "test.txt"
+ assert request["content_type"] == "text/plain"
+ assert request["content"] == "SGVsbG8gV29ybGQh"
+ assert request["size"] == 12
+
+ def test_create_attachment_request_with_file_object(self):
+ """Test creating attachment request with file-like object."""
+ file_content = BytesIO(b"File content here")
+
+ request: CreateAttachmentRequest = {
+ "filename": "document.pdf",
+ "content_type": "application/pdf",
+ "content": file_content,
+ "size": 17,
+ }
+
+ assert request["filename"] == "document.pdf"
+ assert request["content_type"] == "application/pdf"
+ assert request["content"] == file_content
+ assert request["size"] == 17
+
+ def test_create_attachment_request_with_optional_fields(self):
+ """Test creating attachment request with all optional fields."""
+ request: CreateAttachmentRequest = {
+ "filename": "image.png",
+ "content_type": "image/png",
+ "content": "iVBORw0KGgo=",
+ "size": 1024,
+ "content_id": "",
+ "content_disposition": "inline",
+ "is_inline": True,
+ }
+
+ assert request["filename"] == "image.png"
+ assert request["content_type"] == "image/png"
+ assert request["content"] == "iVBORw0KGgo="
+ assert request["size"] == 1024
+ assert request["content_id"] == ""
+ assert request["content_disposition"] == "inline"
+ assert request["is_inline"] is True
+
+ def test_create_attachment_request_minimal(self):
+ """Test creating attachment request with only required fields."""
+ request: CreateAttachmentRequest = {
+ "filename": "minimal.txt",
+ "content_type": "text/plain",
+ "content": "data",
+ "size": 4,
+ }
+
+ assert "filename" in request
+ assert "content_type" in request
+ assert "content" in request
+ assert "size" in request
+ # Optional fields should not be present
+ assert "content_id" not in request
+ assert "content_disposition" not in request
+ assert "is_inline" not in request
+
+
+class TestFindAttachmentQueryParams:
+ """Tests for the FindAttachmentQueryParams TypedDict."""
+
+ def test_find_attachment_query_params(self):
+ """Test creating find attachment query params."""
+ params: FindAttachmentQueryParams = {
+ "message_id": "msg-12345",
+ }
+
+ assert params["message_id"] == "msg-12345"
+
+ def test_find_attachment_query_params_various_message_ids(self):
+ """Test find attachment query params with various message ID formats."""
+ # Simple ID
+ params1: FindAttachmentQueryParams = {"message_id": "abc123"}
+ assert params1["message_id"] == "abc123"
+
+ # UUID format
+ params2: FindAttachmentQueryParams = {"message_id": "550e8400-e29b-41d4-a716-446655440000"}
+ assert params2["message_id"] == "550e8400-e29b-41d4-a716-446655440000"
+
+ # Complex message ID (email message-id format)
+ params3: FindAttachmentQueryParams = {"message_id": ""}
+ assert params3["message_id"] == ""
+
+
+class TestAttachments:
+ """Tests for the Attachments resource API calls."""
+
+ def test_find_attachment(self, http_client_response):
+ attachments = Attachments(http_client_response)
+ 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,
+ overrides=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,
+ overrides=None,
+ )
+
+ mock_http_client._execute_download_request.assert_called_once_with(
+ path="/v3/grants/abc-123/attachments/attachment-123/download",
+ query_params=query_params,
+ stream=True,
+ overrides=None,
+ )
+
+ def test_download_bytes(self):
+ 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,
+ overrides=None,
+ )
+
+ mock_http_client._execute_download_request.assert_called_once_with(
+ path="/v3/grants/abc-123/attachments/attachment-123/download",
+ query_params=query_params,
+ stream=False,
+ overrides=None,
+ )
+
+ def test_create_upload_session(self, http_client_response):
+ attachments = Attachments(http_client_response)
+ request_body: CreateAttachmentUploadSessionRequest = {
+ "filename": "document.pdf",
+ "content_type": "application/pdf",
+ "size": 5242880,
+ }
+
+ attachments.create_upload_session(
+ identifier="abc-123",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/attachment-uploads",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_create_upload_session_without_size(self, http_client_response):
+ attachments = Attachments(http_client_response)
+ request_body: CreateAttachmentUploadSessionRequest = {
+ "filename": "report.xlsx",
+ "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ }
+
+ attachments.create_upload_session(
+ identifier="abc-123",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/attachment-uploads",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_complete_upload_session(self, http_client_response):
+ attachments = Attachments(http_client_response)
+
+ attachments.complete_upload_session(
+ identifier="abc-123",
+ attachment_id="session-id-123",
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/attachment-uploads/session-id-123/complete",
+ None,
+ None,
+ {},
+ overrides=None,
+ )
+
+
+class TestAttachmentUploadSession:
+ """Tests for the AttachmentUploadSession dataclass model."""
+
+ def test_deserialization_full(self):
+ session_json = {
+ "attachment_id": "session-abc-123",
+ "method": "PUT",
+ "url": "https://storage.example.com/upload/session-abc-123",
+ "headers": {"x-ms-blob-type": "BlockBlob"},
+ "expires_at": "2026-05-05T12:00:00Z",
+ "max_size": 157286400,
+ "size": 5242880,
+ "content_type": "application/pdf",
+ "filename": "document.pdf",
+ "grant_id": "grant-abc-123",
+ }
+
+ session = AttachmentUploadSession.from_dict(session_json)
+
+ assert session.attachment_id == "session-abc-123"
+ assert session.method == "PUT"
+ assert session.url == "https://storage.example.com/upload/session-abc-123"
+ assert session.headers == {"x-ms-blob-type": "BlockBlob"}
+ assert session.expires_at == "2026-05-05T12:00:00Z"
+ assert session.max_size == 157286400
+ assert session.size == 5242880
+ assert session.content_type == "application/pdf"
+ assert session.filename == "document.pdf"
+ assert session.grant_id == "grant-abc-123"
+
+ def test_deserialization_without_size(self):
+ """When size is omitted from the request, the API echoes 0."""
+ session_json = {
+ "attachment_id": "session-no-size",
+ "method": "PUT",
+ "url": "https://storage.example.com/upload/session-no-size",
+ "headers": {},
+ "expires_at": "2026-05-05T12:00:00Z",
+ "max_size": 157286400,
+ "size": 0,
+ "content_type": "text/plain",
+ "filename": "notes.txt",
+ "grant_id": "grant-xyz",
+ }
+
+ session = AttachmentUploadSession.from_dict(session_json)
+
+ assert session.attachment_id == "session-no-size"
+ assert session.size == 0
+ assert session.headers == {}
+
+ def test_deserialization_partial(self):
+ """Partial response should not raise; unset fields default to None."""
+ session = AttachmentUploadSession.from_dict({"attachment_id": "partial-session"})
+
+ assert session.attachment_id == "partial-session"
+ assert session.method is None
+ assert session.url is None
+ assert session.headers is None
+ assert session.expires_at is None
+ assert session.max_size is None
+ assert session.size is None
+ assert session.content_type is None
+ assert session.filename is None
+ assert session.grant_id is None
+
+ def test_deserialization_empty_dict(self):
+ session = AttachmentUploadSession.from_dict({})
+
+ assert session.attachment_id is None
+ assert session.grant_id is None
+
+ def test_roundtrip_serialization(self):
+ original = AttachmentUploadSession(
+ attachment_id="rt-session",
+ method="PUT",
+ url="https://example.com/upload",
+ headers={"Content-Type": "application/octet-stream"},
+ expires_at="2026-05-05T12:00:00Z",
+ max_size=157286400,
+ size=1024,
+ content_type="application/octet-stream",
+ filename="file.bin",
+ grant_id="grant-rt",
+ )
+
+ serialized = original.to_dict()
+ deserialized = AttachmentUploadSession.from_dict(serialized)
+
+ assert deserialized.attachment_id == original.attachment_id
+ assert deserialized.method == original.method
+ assert deserialized.url == original.url
+ assert deserialized.headers == original.headers
+ assert deserialized.expires_at == original.expires_at
+ assert deserialized.max_size == original.max_size
+ assert deserialized.size == original.size
+ assert deserialized.content_type == original.content_type
+ assert deserialized.filename == original.filename
+ assert deserialized.grant_id == original.grant_id
+
+
+class TestAttachmentUploadSessionComplete:
+ """Tests for the AttachmentUploadSessionComplete dataclass model."""
+
+ def test_deserialization_ready(self):
+ complete_json = {
+ "attachment_id": "session-abc-123",
+ "grant_id": "grant-abc-123",
+ "status": "ready",
+ }
+
+ complete = AttachmentUploadSessionComplete.from_dict(complete_json)
+
+ assert complete.attachment_id == "session-abc-123"
+ assert complete.grant_id == "grant-abc-123"
+ assert complete.status == "ready"
+
+ def test_deserialization_various_statuses(self):
+ for status in ("uploading", "failed", "expired"):
+ complete = AttachmentUploadSessionComplete.from_dict({
+ "attachment_id": "session-123",
+ "grant_id": "grant-123",
+ "status": status,
+ })
+ assert complete.status == status
+
+ def test_deserialization_empty_dict(self):
+ complete = AttachmentUploadSessionComplete.from_dict({})
+
+ assert complete.attachment_id is None
+ assert complete.grant_id is None
+ assert complete.status is None
+
+ def test_roundtrip_serialization(self):
+ original = AttachmentUploadSessionComplete(
+ attachment_id="session-rt",
+ grant_id="grant-rt",
+ status="ready",
+ )
+
+ serialized = original.to_dict()
+ deserialized = AttachmentUploadSessionComplete.from_dict(serialized)
+
+ assert deserialized.attachment_id == original.attachment_id
+ assert deserialized.grant_id == original.grant_id
+ assert deserialized.status == original.status
+
+
+class TestCreateAttachmentUploadSessionRequest:
+ """Tests for the CreateAttachmentUploadSessionRequest TypedDict."""
+
+ def test_required_fields_only(self):
+ request: CreateAttachmentUploadSessionRequest = {
+ "filename": "document.pdf",
+ "content_type": "application/pdf",
+ }
+
+ assert request["filename"] == "document.pdf"
+ assert request["content_type"] == "application/pdf"
+ assert "size" not in request
+
+ def test_with_size(self):
+ request: CreateAttachmentUploadSessionRequest = {
+ "filename": "video.mp4",
+ "content_type": "video/mp4",
+ "size": 104857600, # 100 MB
+ }
+
+ assert request["filename"] == "video.mp4"
+ assert request["content_type"] == "video/mp4"
+ assert request["size"] == 104857600
+
+ def test_max_allowed_size(self):
+ request: CreateAttachmentUploadSessionRequest = {
+ "filename": "archive.zip",
+ "content_type": "application/zip",
+ "size": 157286400, # 150 MB max
+ }
+
+ assert request["size"] == 157286400
diff --git a/tests/resources/test_auth.py b/tests/resources/test_auth.py
new file mode 100644
index 00000000..8548c347
--- /dev/null
+++ b/tests/resources/test_auth.py
@@ -0,0 +1,433 @@
+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_smtp_required_true(self):
+ config = {
+ "foo": "bar",
+ "scope": ["email"],
+ "smtp_required": True,
+ }
+ result = _build_query(config)
+ assert result["options"] == "smtp_required"
+ assert "smtp_required" not in result # must not leak into URL params
+
+ def test_build_query_smtp_required_false_omits_options(self):
+ config = {
+ "foo": "bar",
+ "scope": ["email"],
+ "smtp_required": False,
+ }
+ result = _build_query(config)
+ assert "options" not in result
+
+ def test_build_query_smtp_required_omitted_omits_options(self):
+ config = {"foo": "bar", "scope": ["email"]}
+ result = _build_query(config)
+ assert "options" not in result
+
+ def test_build_query_with_pkce(self):
+ config = {
+ "foo": "bar",
+ "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_pkce_and_smtp_required(self):
+ config = {
+ "foo": "bar",
+ "scope": ["email"],
+ "smtp_required": True,
+ }
+ result = _build_query_with_pkce(config, "secret-hash-123")
+ assert result["options"] == "smtp_required"
+ assert "smtp_required" not in result # must not leak into URL params
+ assert result["code_challenge"] == "secret-hash-123"
+ assert result["code_challenge_method"] == "s256"
+
+ def test_build_query_with_admin_consent(self):
+ config = {
+ "foo": "bar",
+ "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, overrides=None)
+
+ http_client_token_exchange._execute.assert_called_once_with(
+ method="POST", path="/v3/connect/token", request_body=req, overrides=None
+ )
+ 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"
+ assert res.provider == "google"
+
+ 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, overrides=None)
+
+ http_client_token_info._execute.assert_called_once_with(
+ method="GET", path="/v3/connect/tokeninfo", query_params=req, overrides=None
+ )
+ assert type(res.data) is TokenInfoResponse
+ assert res.data.iss == "https://nylas.com"
+ assert res.data.aud == "http://localhost:3030"
+ assert res.data.sub == "Jaf84d88-£274-46cc-bbc9-aed7dac061c7"
+ assert res.data.email == "user@example.com"
+ assert res.data.iat == 1692094848
+ assert res.data.exp == 1692095173
+
+ def test_url_for_oauth2(self, http_client):
+ auth = Auth(http_client)
+ 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_url_for_oauth2_with_credential_id(self, http_client):
+ auth = Auth(http_client)
+ config = {
+ "client_id": "abc-123",
+ "redirect_uri": "https://example.com/oauth/callback",
+ "scope": ["Mail.Read", "User.Read"],
+ "login_hint": "test@outlook.com",
+ "provider": "microsoft",
+ "state": "abc-123-state",
+ "credential_id": "cred-abc-123",
+ }
+
+ url = auth.url_for_oauth2(config)
+
+ assert (
+ url
+ == "https://test.nylas.com/v3/connect/auth?client_id=abc-123&redirect_uri=https%3A//example.com/oauth/callback&scope=Mail.Read%20User.Read&login_hint=test%40outlook.com&provider=microsoft&state=abc-123-state&credential_id=cred-abc-123&response_type=code&access_type=online"
+ )
+
+ def test_exchange_code_for_token(self, http_client_token_exchange):
+ auth = Auth(http_client_token_exchange)
+ config = {
+ "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",
+ },
+ overrides=None,
+ )
+
+ 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",
+ },
+ overrides=None,
+ )
+
+ 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,
+ },
+ }, {"X-Test-Header": "test"})
+ 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"}},
+ overrides=None,
+ )
+ 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",
+ },
+ overrides=None,
+ )
+
+ 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",
+ },
+ overrides=None,
+ )
+
+ 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"},
+ overrides=None,
+ )
+
+ 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"},
+ overrides=None,
+ )
+
+ @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"},
+ overrides=None,
+ )
+ 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",
+ },
+ }, {"X-Test-Header": "test"})
+ auth = Auth(mock_http_client)
+ req = {"email": "test@gmail.com", "all_provider_types": True}
+
+ res = auth.detect_provider(req)
+
+ mock_http_client._execute.assert_called_once_with(
+ method="POST", path="/v3/providers/detect", query_params=req, overrides=None
+ )
+ assert type(res.data) == ProviderDetectResponse
diff --git a/tests/resources/test_bookings.py b/tests/resources/test_bookings.py
new file mode 100644
index 00000000..15dc87e2
--- /dev/null
+++ b/tests/resources/test_bookings.py
@@ -0,0 +1,116 @@
+from nylas.resources.bookings import Bookings
+
+from nylas.models.scheduler import Booking
+
+class TestBooking:
+ def test_booking_deserialization(self):
+ booking_json = {
+ "booking_id": "AAAA-BBBB-1111-2222",
+ "event_id": "CCCC-DDDD-3333-4444",
+ "title": "My test event",
+ "organizer": {
+ "name": "John Doe",
+ "email": "user@example.com"
+ },
+ "status": "booked",
+ "description": "This is an example of a description."
+ }
+
+ booking = Booking.from_dict(booking_json)
+
+ assert booking.booking_id == "AAAA-BBBB-1111-2222"
+ assert booking.event_id == "CCCC-DDDD-3333-4444"
+ assert booking.title == "My test event"
+ assert booking.organizer.name == "John Doe"
+ assert booking.organizer.email == "user@example.com"
+ assert booking.status == "booked"
+ assert booking.description == "This is an example of a description."
+
+ def test_find_booking(self, http_client_response):
+ bookings = Bookings(http_client_response)
+
+ bookings.find(booking_id="booking-123")
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/scheduling/bookings/booking-123",
+ None,
+ None,
+ None,
+ overrides=None
+ )
+
+ def test_create_booking(self, http_client_response):
+ bookings = Bookings(http_client_response)
+ request_body = {
+ "start_time": 1730725200,
+ "end_time": 1730727000,
+ "participants": [
+ {
+ "email": "test@nylas.com"
+ }
+ ],
+ "guest": {
+ "name": "TEST",
+ "email": "user@gmail.com"
+ }
+ }
+ bookings.create(request_body=request_body)
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/scheduling/bookings",
+ None,
+ None,
+ request_body,
+ overrides=None
+ )
+
+ def test_confirm_booking(self, http_client_response):
+ bookings = Bookings(http_client_response)
+ request_body = {
+ "salt": "_zfg12it",
+ "status": "cancelled",
+ }
+
+ bookings.confirm(booking_id="booking-123", request_body=request_body)
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/scheduling/bookings/booking-123",
+ None,
+ None,
+ request_body,
+ overrides=None
+ )
+
+ def test_reschedule_booking(self, http_client_response):
+ bookings = Bookings(http_client_response)
+ request_body = {
+ "start_time": 1730725200,
+ "end_time": 1730727000,
+ }
+
+ bookings.reschedule(booking_id="booking-123", request_body=request_body)
+ http_client_response._execute.assert_called_once_with(
+ "PATCH",
+ "/v3/scheduling/bookings/booking-123",
+ None,
+ None,
+ request_body,
+ overrides=None
+ )
+
+ def test_destroy_booking(self, http_client_delete_response):
+ bookings = Bookings(http_client_delete_response)
+ request_body = {
+ "cancellation_reason": "I am no longer available at this time."
+ }
+ bookings.destroy(booking_id="booking-123", request_body=request_body)
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/scheduling/bookings/booking-123",
+ None,
+ None,
+ request_body,
+ overrides=None
+ )
\ No newline at end of file
diff --git a/tests/resources/test_calendars.py b/tests/resources/test_calendars.py
new file mode 100644
index 00000000..a07ccd44
--- /dev/null
+++ b/tests/resources/test_calendars.py
@@ -0,0 +1,439 @@
+from nylas.resources.calendars import Calendars
+
+from nylas.models.calendars import Calendar, EventSelection
+
+
+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_calendar_with_notetaker_deserialization(self):
+ calendar_json = {
+ "grant_id": "abc-123-grant-id",
+ "description": "Description of my new calendar",
+ "id": "5d3qmne77v32r8l4phyuksl2x",
+ "is_owned_by_user": True,
+ "name": "My New Calendar",
+ "object": "calendar",
+ "read_only": False,
+ "notetaker": {
+ "name": "My Notetaker",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True
+ },
+ "rules": {
+ "event_selection": ["internal", "external"],
+ "participant_filter": {
+ "participants_gte": 3,
+ "participants_lte": 10
+ }
+ }
+ }
+ }
+
+ cal = Calendar.from_dict(calendar_json)
+
+ assert cal.grant_id == "abc-123-grant-id"
+ assert cal.id == "5d3qmne77v32r8l4phyuksl2x"
+ assert cal.is_owned_by_user is True
+ assert cal.name == "My New Calendar"
+ assert cal.object == "calendar"
+ assert cal.read_only is False
+ assert cal.notetaker is not None
+ assert cal.notetaker.name == "My Notetaker"
+ assert cal.notetaker.meeting_settings is not None
+ assert cal.notetaker.meeting_settings.video_recording is True
+ assert cal.notetaker.meeting_settings.audio_recording is True
+ assert cal.notetaker.meeting_settings.transcription is True
+ assert cal.notetaker.rules is not None
+ assert len(cal.notetaker.rules.event_selection) == 2
+ assert EventSelection.INTERNAL in cal.notetaker.rules.event_selection
+ assert EventSelection.EXTERNAL in cal.notetaker.rules.event_selection
+ assert cal.notetaker.rules.participant_filter is not None
+ assert cal.notetaker.rules.participant_filter.participants_gte == 3
+ assert cal.notetaker.rules.participant_filter.participants_lte == 10
+
+ def test_list_calendars(self, http_client_list_response):
+ calendars = Calendars(http_client_list_response)
+
+ calendars.list(identifier="abc-123")
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/grants/abc-123/calendars", None, None, None, overrides=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,
+ overrides=None,
+ )
+
+ def test_list_calendars_with_select_param(self, http_client_list_response):
+ calendars = Calendars(http_client_list_response)
+
+ # Set up mock response data
+ http_client_list_response._execute.return_value = {
+ "request_id": "abc-123",
+ "data": [{
+ "id": "calendar-123",
+ "name": "My Calendar",
+ "description": "My calendar description"
+ }]
+ }
+
+ # Call the API method
+ result = calendars.list(
+ identifier="abc-123",
+ query_params={
+ "select": "id,name,description"
+ }
+ )
+
+ # Verify API call
+ http_client_list_response._execute.assert_called_with(
+ "GET",
+ "/v3/grants/abc-123/calendars",
+ None,
+ {"select": "id,name,description"},
+ None,
+ overrides=None,
+ )
+
+ # The actual response validation is handled by the mock in conftest.py
+ assert result is not None
+
+ def test_find_calendar(self, http_client_response):
+ calendars = Calendars(http_client_response)
+
+ 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,
+ overrides=None,
+ )
+
+ def test_find_calendar_with_select_param(self, http_client_response):
+ calendars = Calendars(http_client_response)
+
+ # Set up mock response data
+ http_client_response._execute.return_value = ({
+ "request_id": "abc-123",
+ "data": {
+ "id": "calendar-123",
+ "name": "My Calendar",
+ "description": "My calendar description"
+ }
+ }, {"X-Test-Header": "test"})
+
+ # Call the API method
+ result = calendars.find(
+ identifier="abc-123",
+ calendar_id="calendar-123",
+ query_params={"select": "id,name,description"}
+ )
+
+ # Verify API call
+ http_client_response._execute.assert_called_with(
+ "GET",
+ "/v3/grants/abc-123/calendars/calendar-123",
+ None,
+ {"select": "id,name,description"},
+ None,
+ overrides=None,
+ )
+
+ # The actual response validation is handled by the mock in conftest.py
+ assert result is not None
+
+ def test_create_calendar(self, http_client_response):
+ calendars = Calendars(http_client_response)
+ request_body = {
+ "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,
+ overrides=None,
+ )
+
+ def test_create_calendar_with_notetaker(self, http_client_response):
+ calendars = Calendars(http_client_response)
+ request_body = {
+ "name": "My New Calendar",
+ "description": "Description of my new calendar",
+ "location": "Los Angeles, CA",
+ "timezone": "America/Los_Angeles",
+ "notetaker": {
+ "name": "My Notetaker",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True
+ },
+ "rules": {
+ "event_selection": [EventSelection.INTERNAL.value, EventSelection.EXTERNAL.value],
+ "participant_filter": {
+ "participants_gte": 3,
+ "participants_lte": 10
+ }
+ }
+ }
+ }
+
+ calendars.create(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/calendars",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_update_calendar(self, http_client_response):
+ calendars = Calendars(http_client_response)
+ request_body = {
+ "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,
+ overrides=None,
+ )
+
+ def test_update_calendar_with_notetaker(self, http_client_response):
+ calendars = Calendars(http_client_response)
+ request_body = {
+ "name": "My Updated Calendar",
+ "notetaker": {
+ "name": "Updated Notetaker",
+ "meeting_settings": {
+ "video_recording": False,
+ "audio_recording": True,
+ "transcription": False
+ },
+ "rules": {
+ "event_selection": [EventSelection.ALL.value],
+ "participant_filter": {
+ "participants_gte": 2
+ }
+ }
+ }
+ }
+
+ calendars.update(
+ identifier="abc-123", calendar_id="calendar-123", request_body=request_body
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/grants/abc-123/calendars/calendar-123",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_destroy_calendar(self, http_client_delete_response):
+ calendars = Calendars(http_client_delete_response)
+
+ 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,
+ overrides=None,
+ )
+
+ def test_get_availability(self, http_client_response):
+ calendars = Calendars(http_client_response)
+ request_body = {
+ "start_time": 1497916800,
+ "end_time": 1498003200,
+ "duration_minutes": 60,
+ "interval_minutes": 30,
+ "round_to_30_minutes": True,
+ "participants": [
+ {
+ "email": "test@gmail.com",
+ "calendar_ids": ["primary"],
+ "open_hours": [
+ {
+ "days": [1, 3],
+ "timezone": "America/New_York",
+ "start": "08:00",
+ "end": "18:00",
+ }
+ ],
+ }
+ ],
+ "availability_rules": {
+ "availability_method": "max-availability",
+ "buffer": {"before": 10, "after": 10},
+ "default_open_hours": [
+ {
+ "days": [0],
+ "timezone": "America/Los_Angeles",
+ "start": "09:00",
+ "end": "17:00",
+ "exdates": ["2021-03-01"],
+ }
+ ],
+ "round_robin_group_id": "event-123",
+ "tentative_as_busy": False
+ },
+ }
+
+ calendars.get_availability(request_body, overrides=None)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/calendars/availability",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_get_availability_with_specific_time_availability(self, http_client_response):
+ calendars = Calendars(http_client_response)
+ request_body = {
+ "start_time": 1497916800,
+ "end_time": 1498003200,
+ "duration_minutes": 60,
+ "interval_minutes": 30,
+ "participants": [
+ {
+ "email": "test@gmail.com",
+ "calendar_ids": ["primary"],
+ "open_hours": [
+ {
+ "days": [1, 2, 3, 4, 5],
+ "timezone": "America/New_York",
+ "start": "9:00",
+ "end": "17:00",
+ }
+ ],
+ "specific_time_availability": [
+ {
+ "date": "2024-03-15",
+ "start": "10:00",
+ "end": "14:00",
+ },
+ {
+ "date": "2024-03-16",
+ "start": "10:00",
+ "end": "14:00",
+ }
+ ],
+ }
+ ],
+ "availability_rules": {
+ "availability_method": "max-availability",
+ },
+ }
+
+ calendars.get_availability(request_body, overrides=None)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/calendars/availability",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_get_free_busy(self, http_client_free_busy):
+ calendars = Calendars(http_client_free_busy)
+ free_busy_request = {
+ "emails": ["test@gmail.com", "test2@gmail.com"],
+ "start_time": 1497916800,
+ "end_time": 1498003200,
+ }
+
+ # Http client is mocked in conftest.py, specific
+ # mock for free busy is configured there
+ calendars.get_free_busy(
+ identifier="abc123", request_body=free_busy_request, overrides=None
+ )
+
+ http_client_free_busy._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc123/calendars/free-busy",
+ None,
+ None,
+ free_busy_request,
+ overrides=None,
+ )
+
diff --git a/tests/resources/test_configurations.py b/tests/resources/test_configurations.py
new file mode 100644
index 00000000..8b2388f3
--- /dev/null
+++ b/tests/resources/test_configurations.py
@@ -0,0 +1,234 @@
+from nylas.resources.configurations import Configurations
+
+from nylas.models.scheduler import Configuration
+
+class TestConfiguration:
+ def test_configuration_deserialization(self):
+ configuration_json = {
+ "id": "abc-123-configuration-id",
+ "slug": None,
+ "participants": [
+ {
+ "email": "test@nylas.com",
+ "is_organizer": True,
+ "name": "Test",
+ "availability": {
+ "calendar_ids": [
+ "primary"
+ ],
+ "open_hours": [
+ {
+ "days": [
+ 0,
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6
+ ],
+ "exdates": None,
+ "timezone": "",
+ "start": "09:00",
+ "end": "17:00"
+ }
+ ]
+ },
+ "booking": {
+ "calendar_id": "primary"
+ },
+ "timezone": ""
+ }
+ ],
+ "requires_session_auth": False,
+ "availability": {
+ "duration_minutes": 30,
+ "interval_minutes": 15,
+ "round_to": 15,
+ "availability_rules": {
+ "availability_method": "collective",
+ "buffer": {
+ "before": 60,
+ "after": 0
+ },
+ "default_open_hours": [
+ {
+ "days": [
+ 0,
+ 1,
+ 2,
+ 5,
+ 6
+ ],
+ "exdates": None,
+ "timezone": "",
+ "start": "09:00",
+ "end": "18:00"
+ }
+ ],
+ "round_robin_group_id": ""
+ }
+ },
+ "event_booking": {
+ "title": "Updated Title",
+ "timezone": "utc",
+ "description": "",
+ "location": "none",
+ "booking_type": "booking",
+ "conferencing": {
+ "provider": "Microsoft Teams",
+ "autocreate": {
+ "conf_grant_id": "",
+ "conf_settings": None
+ }
+ },
+ "hide_participants": None,
+ "disable_emails": None
+ },
+ "scheduler": {
+ "available_days_in_future": 7,
+ "min_cancellation_notice": 60,
+ "min_booking_notice": 120,
+ "confirmation_redirect_url": "",
+ "hide_rescheduling_options": False,
+ "hide_cancellation_options": False,
+ "hide_additional_guests": True,
+ "cancellation_policy": "",
+ "email_template": {
+ "booking_confirmed": {}
+ }
+ },
+ "appearance": {
+ "submit_button_label": "submit",
+ "thank_you_message": "thank you for your business. your booking was successful."
+ }
+ }
+
+ configuration = Configuration.from_dict(configuration_json)
+
+ assert configuration.id == "abc-123-configuration-id"
+ assert configuration.slug == None
+ assert configuration.participants[0].email == "test@nylas.com"
+ assert configuration.participants[0].is_organizer == True
+ assert configuration.participants[0].name == "Test"
+ assert configuration.participants[0].availability.calendar_ids == ["primary"]
+ assert configuration.participants[0].availability.open_hours[0]["days"] == [0, 1, 2, 3, 4, 5, 6]
+ assert configuration.participants[0].availability.open_hours[0]["exdates"] == None
+ assert configuration.participants[0].availability.open_hours[0]["timezone"] == ""
+ assert configuration.participants[0].booking.calendar_id == "primary"
+ assert configuration.participants[0].timezone == ""
+ assert configuration.requires_session_auth == False
+ assert configuration.availability.duration_minutes == 30
+ assert configuration.availability.interval_minutes == 15
+ assert configuration.availability.round_to == 15
+ assert configuration.availability.availability_rules["availability_method"] == "collective"
+ assert configuration.availability.availability_rules["buffer"]["before"] == 60
+ assert configuration.availability.availability_rules["buffer"]["after"] == 0
+ assert configuration.availability.availability_rules["default_open_hours"][0]["days"] == [0, 1, 2, 5, 6]
+ assert configuration.availability.availability_rules["default_open_hours"][0]["exdates"] == None
+ assert configuration.availability.availability_rules["default_open_hours"][0]["timezone"] == ""
+ assert configuration.availability.availability_rules["default_open_hours"][0]["start"] == "09:00"
+ assert configuration.availability.availability_rules["default_open_hours"][0]["end"] == "18:00"
+ assert configuration.event_booking.title == "Updated Title"
+ assert configuration.event_booking.timezone == "utc"
+ assert configuration.event_booking.description == ""
+ assert configuration.event_booking.location == "none"
+ assert configuration.event_booking.booking_type == "booking"
+ assert configuration.event_booking.conferencing.provider == "Microsoft Teams"
+ assert configuration.scheduler.available_days_in_future == 7
+ assert configuration.scheduler.min_cancellation_notice == 60
+ assert configuration.scheduler.min_booking_notice == 120
+ assert configuration.appearance["submit_button_label"] == "submit"
+
+ def test_list_configurations(self, http_client_list_response):
+ configurations = Configurations(http_client_list_response)
+ configurations.list(identifier="grant-123")
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/grant-123/scheduling/configurations",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_find_configuration(self, http_client_response):
+ configurations = Configurations(http_client_response)
+ configurations.find(identifier="grant-123", config_id="config-123")
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/grant-123/scheduling/configurations/config-123",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_create_configuration(self, http_client_response):
+ configurations = Configurations(http_client_response)
+ request_body = {
+ "requires_session_auth": False,
+ "participants": [
+ {
+ "name": "Test",
+ "email": "test@nylas.com",
+ "is_organizer": True,
+ "availability": {
+ "calendar_ids": [
+ "primary"
+ ]
+ },
+ "booking": {
+ "calendar_id": "primary"
+ }
+ }
+ ],
+ "availability": {
+ "duration_minutes": 30
+ },
+ "event_booking": {
+ "title": "My test event"
+ }
+ }
+ configurations.create(identifier="grant-123", request_body=request_body)
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/grant-123/scheduling/configurations",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_update_configuration(self, http_client_response):
+ configurations = Configurations(http_client_response)
+ request_body = {
+ "event_booking": {
+ "title": "My test event"
+ }
+ }
+ configurations.update(identifier="grant-123", config_id="config-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/grants/grant-123/scheduling/configurations/config-123",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_destroy_configuration(self, http_client_delete_response):
+ configurations = Configurations(http_client_delete_response)
+ configurations.destroy(identifier="grant-123", config_id="config-123")
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/grants/grant-123/scheduling/configurations/config-123",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
\ No newline at end of file
diff --git a/tests/resources/test_connectors.py b/tests/resources/test_connectors.py
new file mode 100644
index 00000000..b43e3627
--- /dev/null
+++ b/tests/resources/test_connectors.py
@@ -0,0 +1,144 @@
+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, overrides=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, overrides=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, overrides=None
+ )
+
+ def test_create_connector_with_active_credential_id(self, http_client_response):
+ connectors = Connectors(http_client_response)
+ request_body = {
+ "provider": "microsoft",
+ "settings": {
+ "client_id": "string",
+ "client_secret": "string",
+ "tenant": "common",
+ },
+ "scope": [
+ "Mail.Read",
+ "User.Read",
+ ],
+ "active_credential_id": "cred-abc-123",
+ }
+
+ connectors.create(request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST", "/v3/connectors", None, None, request_body, overrides=None
+ )
+
+ def test_update_connector(self, http_client_response):
+ connectors = Connectors(http_client_response)
+ request_body = {
+ "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, overrides=None
+ )
+
+ def test_update_connector_with_active_credential_id(self, http_client_response):
+ connectors = Connectors(http_client_response)
+ request_body = {
+ "settings": {
+ "client_id": "string",
+ "client_secret": "string",
+ },
+ "scope": [
+ "Mail.Read",
+ "User.Read",
+ ],
+ "active_credential_id": "cred-xyz-789",
+ }
+
+ connectors.update(
+ provider="microsoft",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PATCH", "/v3/connectors/microsoft", None, None, request_body, overrides=None
+ )
+
+ def test_destroy_connector(self, http_client_delete_response):
+ connectors = Connectors(http_client_delete_response)
+
+ connectors.destroy("google")
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE", "/v3/connectors/google", None, None, None, overrides=None
+ )
diff --git a/tests/resources/test_contacts.py b/tests/resources/test_contacts.py
new file mode 100644
index 00000000..c55cff08
--- /dev/null
+++ b/tests/resources/test_contacts.py
@@ -0,0 +1,280 @@
+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, overrides=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,
+ overrides=None,
+ )
+
+ def test_list_contacts_with_select_param(self, http_client_list_response):
+ contacts = Contacts(http_client_list_response)
+
+ # Set up mock response data
+ http_client_list_response._execute.return_value = {
+ "request_id": "abc-123",
+ "data": [{
+ "id": "contact-123",
+ "given_name": "John",
+ "surname": "Doe",
+ "emails": [{"email": "john@example.com", "type": "work"}]
+ }]
+ }
+
+ # Call the API method
+ result = contacts.list(
+ identifier="abc-123",
+ query_params={
+ "select": "id,given_name,surname,emails"
+ }
+ )
+
+ # Verify API call
+ http_client_list_response._execute.assert_called_with(
+ "GET",
+ "/v3/grants/abc-123/contacts",
+ None,
+ {"select": "id,given_name,surname,emails"},
+ None,
+ overrides=None,
+ )
+
+ # The actual response validation is handled by the mock in conftest.py
+ assert result is not None
+
+ def test_find_contact(self, http_client_response):
+ contacts = Contacts(http_client_response)
+
+ 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,
+ overrides=None,
+ )
+
+ def test_find_contact_with_select_param(self, http_client_response):
+ contacts = Contacts(http_client_response)
+
+ # Set up mock response data
+ http_client_response._execute.return_value = ({
+ "request_id": "abc-123",
+ "data": {
+ "id": "contact-123",
+ "given_name": "John",
+ "surname": "Doe",
+ "emails": [{"email": "john@example.com", "type": "work"}]
+ }
+ }, {"X-Test-Header": "test"})
+
+ # Call the API method
+ result = contacts.find(
+ identifier="abc-123",
+ contact_id="contact-123",
+ query_params={"select": "id,given_name,surname,emails"}
+ )
+
+ # Verify API call
+ http_client_response._execute.assert_called_with(
+ "GET",
+ "/v3/grants/abc-123/contacts/contact-123",
+ None,
+ {"select": "id,given_name,surname,emails"},
+ None,
+ overrides=None,
+ )
+
+ # The actual response validation is handled by the mock in conftest.py
+ assert result is not None
+
+ def test_find_contact_with_query_params(self, http_client_response):
+ contacts = Contacts(http_client_response)
+
+ 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,
+ overrides=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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=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},
+ overrides=None,
+ )
diff --git a/tests/resources/test_credentials.py b/tests/resources/test_credentials.py
new file mode 100644
index 00000000..71b67367
--- /dev/null
+++ b/tests/resources/test_credentials.py
@@ -0,0 +1,105 @@
+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, overrides=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,
+ overrides=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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=None,
+ )
diff --git a/tests/resources/test_domains.py b/tests/resources/test_domains.py
new file mode 100644
index 00000000..fe9d1f98
--- /dev/null
+++ b/tests/resources/test_domains.py
@@ -0,0 +1,334 @@
+from unittest.mock import patch
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+
+from nylas.handler.service_account import ServiceAccountSigner
+from nylas.models.domains import Domain, DomainVerificationDetails
+from nylas.models.response import ListResponse, Response
+from nylas.resources import domains as domains_module
+from nylas.resources.domains import Domains
+
+
+def _test_rsa_pem():
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ return key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption(),
+ ).decode("ascii")
+
+
+@pytest.fixture
+def domain_data():
+ return {
+ "id": "dom_123",
+ "name": "My domain",
+ "branded": False,
+ "domain_address": "mail.example.com",
+ "organization_id": "org_1",
+ "region": "us",
+ "verified_ownership": False,
+ "verified_dkim": False,
+ "verified_spf": False,
+ "verified_mx": False,
+ "verified_feedback": False,
+ "verified_dmarc": False,
+ "verified_arc": False,
+ "created_at": 1,
+ "updated_at": 2,
+ }
+
+
+class TestMergeSignerHeaders:
+ def test_returns_overrides_when_no_signer_headers(self):
+ assert domains_module._merge_signer_headers({"timeout": 5}, None) == {"timeout": 5}
+ assert domains_module._merge_signer_headers(None, {}) is None
+
+ def test_merges_headers_preserving_existing(self):
+ merged = domains_module._merge_signer_headers(
+ {"headers": {"X-Existing": "keep"}, "timeout": 30},
+ {"X-Nylas-Kid": "kid", "X-Nylas-Signature": "sig"},
+ )
+ assert merged["timeout"] == 30
+ assert merged["headers"]["X-Existing"] == "keep"
+ assert merged["headers"]["X-Nylas-Kid"] == "kid"
+ assert merged["headers"]["X-Nylas-Signature"] == "sig"
+
+ def test_creates_overrides_when_none(self):
+ merged = domains_module._merge_signer_headers(
+ None, {"X-Nylas-Kid": "kid"}
+ )
+ assert merged == {"headers": {"X-Nylas-Kid": "kid"}}
+
+
+class TestDomains:
+ def test_domain_model_from_dict(self, domain_data):
+ d = Domain.from_dict(domain_data)
+ assert d.id == "dom_123"
+ assert d.domain_address == "mail.example.com"
+
+ def test_domain_verification_details_from_dict(self):
+ raw = {
+ "domain_id": "d1",
+ "attempt": {"type": "dkim", "status": "pending"},
+ "message": "add TXT",
+ }
+ d = DomainVerificationDetails.from_dict(raw, infer_missing=True)
+ assert d.domain_id == "d1"
+ assert d.attempt is not None
+ assert d.attempt.verification_type == "dkim"
+ assert d.message == "add TXT"
+
+ def test_list_without_signer(self, http_client_list_response):
+ with patch(
+ "nylas.models.response.ListResponse.from_dict",
+ return_value=ListResponse([], "rid", None, {}),
+ ):
+ domains = Domains(http_client_list_response)
+ domains.list()
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/admin/domains",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_list_with_query_and_signer(self, http_client_list_response):
+ pem = _test_rsa_pem()
+ signer = ServiceAccountSigner(pem, "kid-1")
+ with patch(
+ "nylas.models.response.ListResponse.from_dict",
+ return_value=ListResponse([], "rid", None, {}),
+ ):
+ domains = Domains(http_client_list_response)
+ domains.list(query_params={"limit": 10}, signer=signer)
+ args, kwargs = http_client_list_response._execute.call_args
+ assert args[0] == "GET"
+ assert "/v3/admin/domains" in args[1]
+ ov = kwargs.get("overrides") or {}
+ assert "X-Nylas-Signature" in (ov.get("headers") or {})
+
+ def test_create_without_signer(self, http_client_response, domain_data):
+ with patch(
+ "nylas.models.response.Response.from_dict",
+ return_value=Response(domain_data, "rid", {}),
+ ):
+ domains = Domains(http_client_response)
+ domains.create(
+ {"name": "My domain", "domain_address": "mail.example.com"},
+ )
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/admin/domains",
+ None,
+ None,
+ {"name": "My domain", "domain_address": "mail.example.com"},
+ overrides=None,
+ )
+
+ def test_find_without_signer(self, http_client_response, domain_data):
+ with patch(
+ "nylas.models.response.Response.from_dict",
+ return_value=Response(domain_data, "rid", {}),
+ ):
+ domains = Domains(http_client_response)
+ domains.find("dom_abc")
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/admin/domains/dom_abc",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_find_with_signer(self, http_client_response, domain_data):
+ pem = _test_rsa_pem()
+ signer = ServiceAccountSigner(pem, "kid-x")
+ with patch(
+ "nylas.models.response.Response.from_dict",
+ return_value=Response(domain_data, "rid", {}),
+ ):
+ domains = Domains(http_client_response)
+ domains.find("dom_abc", signer=signer)
+ ov = http_client_response._execute.call_args.kwargs.get("overrides") or {}
+ assert "X-Nylas-Signature" in (ov.get("headers") or {})
+
+ def test_update_without_signer(self, http_client_response, domain_data):
+ with patch(
+ "nylas.models.response.Response.from_dict",
+ return_value=Response(domain_data, "rid", {}),
+ ):
+ domains = Domains(http_client_response)
+ domains.update("dom_123", {"name": "Renamed"})
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/admin/domains/dom_123",
+ None,
+ None,
+ {"name": "Renamed"},
+ overrides=None,
+ )
+
+ def test_update_with_signer(self, http_client_response, domain_data):
+ pem = _test_rsa_pem()
+ signer = ServiceAccountSigner(pem, "kid-1")
+ with patch(
+ "nylas.models.response.Response.from_dict",
+ return_value=Response(domain_data, "rid", {}),
+ ):
+ domains = Domains(http_client_response)
+ domains.update("dom_123", {"name": "Renamed"}, signer=signer)
+ kwargs = http_client_response._execute.call_args.kwargs
+ assert "serialized_json_body" in kwargs
+ assert http_client_response._execute.call_args[0][4] is None
+
+ def test_create_with_signer_sends_serialized_body(self, http_client_response, domain_data):
+ pem = _test_rsa_pem()
+ signer = ServiceAccountSigner(pem, "kid-1")
+ with patch(
+ "nylas.models.response.Response.from_dict",
+ return_value=Response(domain_data, "rid", {}),
+ ):
+ domains = Domains(http_client_response)
+ domains.create(
+ {"name": "My domain", "domain_address": "mail.example.com"},
+ signer=signer,
+ )
+ kwargs = http_client_response._execute.call_args.kwargs
+ assert "serialized_json_body" in kwargs
+ assert kwargs["serialized_json_body"].startswith(b"{")
+ pos = http_client_response._execute.call_args[0]
+ assert pos[4] is None
+
+ def test_destroy_with_signer(self, http_client_delete_response):
+ from nylas.models.response import DeleteResponse
+
+ pem = _test_rsa_pem()
+ signer = ServiceAccountSigner(pem, "kid-del")
+ http_client_delete_response._execute.return_value = (
+ {"request_id": "del-rid"},
+ {},
+ )
+ domains = Domains(http_client_delete_response)
+ domains.destroy("dom_123", signer=signer)
+ ov = http_client_delete_response._execute.call_args.kwargs["overrides"]
+ assert "X-Nylas-Signature" in ov["headers"]
+
+ def test_destroy(self, http_client_delete_response):
+ from nylas.models.response import DeleteResponse
+
+ http_client_delete_response._execute.return_value = (
+ {"request_id": "del-rid"},
+ {},
+ )
+ domains = Domains(http_client_delete_response)
+ out = domains.destroy("dom_123")
+ assert isinstance(out, DeleteResponse)
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/admin/domains/dom_123",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_get_info_with_signer(self, http_client_response):
+ pem = _test_rsa_pem()
+ signer = ServiceAccountSigner(pem, "kid-i")
+ info = {"domain_id": "dom_123", "attempt": {"type": "spf"}}
+ http_client_response._execute.return_value = (
+ {"request_id": "r1", "data": info},
+ {},
+ )
+ with patch(
+ "nylas.models.response.Response.from_dict",
+ return_value=Response(info, "r1", {}),
+ ):
+ domains = Domains(http_client_response)
+ domains.get_info("dom_123", {"type": "spf"}, signer=signer)
+ kwargs = http_client_response._execute.call_args.kwargs
+ assert "serialized_json_body" in kwargs
+ assert http_client_response._execute.call_args[0][4] is None
+
+ def test_verify_without_signer(self, http_client_response):
+ info = {"domain_id": "dom_123", "attempt": {"type": "mx"}}
+ http_client_response._execute.return_value = (
+ {"request_id": "rv", "data": info},
+ {},
+ )
+ with patch(
+ "nylas.models.response.Response.from_dict",
+ return_value=Response(info, "rv", {}),
+ ):
+ domains = Domains(http_client_response)
+ domains.verify("dom_123", {"type": "mx"})
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/admin/domains/dom_123/verify",
+ None,
+ None,
+ {"type": "mx"},
+ overrides=None,
+ )
+
+ def test_verify_with_signer(self, http_client_response):
+ pem = _test_rsa_pem()
+ signer = ServiceAccountSigner(pem, "kid-v")
+ info = {"domain_id": "dom_123"}
+ http_client_response._execute.return_value = (
+ {"request_id": "rv", "data": info},
+ {},
+ )
+ with patch(
+ "nylas.models.response.Response.from_dict",
+ return_value=Response(info, "rv", {}),
+ ):
+ domains = Domains(http_client_response)
+ domains.verify("dom_123", {"type": "dkim"}, signer=signer)
+ assert "serialized_json_body" in http_client_response._execute.call_args.kwargs
+
+ def test_get_info(self, http_client_response):
+ info = {
+ "domain_id": "dom_123",
+ "attempt": {"type": "ownership", "status": "pending"},
+ }
+ http_client_response._execute.return_value = (
+ {"request_id": "r1", "data": info},
+ {},
+ )
+ with patch(
+ "nylas.models.response.Response.from_dict",
+ return_value=Response(info, "r1", {}),
+ ):
+ domains = Domains(http_client_response)
+ domains.get_info("dom_123", {"type": "ownership"})
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/admin/domains/dom_123/info",
+ None,
+ None,
+ {"type": "ownership"},
+ overrides=None,
+ )
+
+ def test_merge_signer_with_existing_headers(self, http_client_list_response):
+ pem = _test_rsa_pem()
+ signer = ServiceAccountSigner(pem, "kid-1")
+ with patch(
+ "nylas.models.response.ListResponse.from_dict",
+ return_value=ListResponse([], "rid", None, {}),
+ ):
+ domains = Domains(http_client_list_response)
+ domains.list(
+ signer=signer,
+ overrides={"headers": {"X-Custom": "precedence"}},
+ )
+ headers = http_client_list_response._execute.call_args.kwargs["overrides"]["headers"]
+ assert headers["X-Custom"] == "precedence"
+ assert "X-Nylas-Kid" in headers
diff --git a/tests/resources/test_drafts.py b/tests/resources/test_drafts.py
new file mode 100644
index 00000000..a0d3dbed
--- /dev/null
+++ b/tests/resources/test_drafts.py
@@ -0,0 +1,532 @@
+from unittest.mock import patch, Mock
+
+from nylas.models.drafts import Draft
+from nylas.resources.drafts import Drafts
+from nylas.resources.messages import Messages
+
+
+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, overrides=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,
+ overrides=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,
+ overrides=None,
+ )
+
+ def test_find_draft_encoded_id(self, http_client_response):
+ drafts = Drafts(http_client_response)
+
+ drafts.find(
+ identifier="abc-123",
+ draft_id="",
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/drafts/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_create_draft(self, http_client_response):
+ drafts = Drafts(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ }
+
+ drafts.create(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/drafts",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_create_draft_with_metadata(self, http_client_response):
+ drafts = Drafts(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ "metadata": {"custom_field": "value", "another_field": 123}
+ }
+
+ drafts.create(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/drafts",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_create_draft_small_attachment(self, http_client_response):
+ drafts = Drafts(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ "attachments": [
+ {
+ "filename": "file1.txt",
+ "content_type": "text/plain",
+ "content": "this is a file",
+ "size": 3,
+ },
+ ],
+ }
+
+ drafts.create(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/drafts",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_create_draft_large_attachment(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.",
+ "attachments": [
+ {
+ "filename": "file1.txt",
+ "content_type": "text/plain",
+ "content": "this is a file",
+ "size": 3 * 1024 * 1024,
+ },
+ ],
+ }
+
+ 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,
+ overrides=None,
+ )
+
+ def test_update_draft(self, http_client_response):
+ drafts = Drafts(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ }
+
+ drafts.update(
+ identifier="abc-123", draft_id="draft-123", request_body=request_body
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/grants/abc-123/drafts/draft-123",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_update_draft_encoded_id(self, http_client_response):
+ drafts = Drafts(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ }
+
+ drafts.update(
+ identifier="abc-123",
+ draft_id="",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/grants/abc-123/drafts/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_update_draft_small_attachment(self, http_client_response):
+ drafts = Drafts(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ "attachments": [
+ {
+ "filename": "file1.txt",
+ "content_type": "text/plain",
+ "content": "this is a file",
+ "size": 3,
+ },
+ ],
+ }
+
+ drafts.update(
+ identifier="abc-123", draft_id="draft-123", request_body=request_body
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/grants/abc-123/drafts/draft-123",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_update_draft_large_attachment(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.",
+ "attachments": [
+ {
+ "filename": "file1.txt",
+ "content_type": "text/plain",
+ "content": "this is a file",
+ "size": 3 * 1024 * 1024,
+ },
+ ],
+ }
+
+ 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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=None,
+ )
+
+ def test_destroy_draft_encoded_id(self, http_client_delete_response):
+ drafts = Drafts(http_client_delete_response)
+
+ drafts.destroy(
+ identifier="abc-123",
+ draft_id="",
+ )
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/grants/abc-123/drafts/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_send_draft(self, http_client_response):
+ drafts = Drafts(http_client_response)
+
+ 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", overrides=None
+ )
+
+ def test_send_message_with_metadata(self, http_client_response):
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ "metadata": {"custom_field": "value", "another_field": 123}
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_draft_encoded_id(self, http_client_response):
+ drafts = Drafts(http_client_response)
+
+ drafts.send(
+ identifier="abc-123",
+ draft_id="",
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/drafts/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ overrides=None,
+ )
+
+ def test_create_draft_with_is_plaintext_true(self, http_client_response):
+ """Test creating a draft with is_plaintext=True."""
+ drafts = Drafts(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ "is_plaintext": True,
+ }
+
+ drafts.create(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/drafts",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_create_draft_with_is_plaintext_false(self, http_client_response):
+ """Test creating a draft with is_plaintext=False."""
+ drafts = Drafts(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ "is_plaintext": False,
+ }
+
+ drafts.create(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/drafts",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_create_draft_without_is_plaintext_backwards_compatibility(self, http_client_response):
+ """Test that existing code without is_plaintext still works (backwards compatibility)."""
+ drafts = Drafts(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ }
+
+ # Should work without any issues
+ drafts.create(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/drafts",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_create_draft_with_special_characters_in_subject(self, http_client_response):
+ """Test creating a draft with special characters (accented letters) in subject."""
+ drafts = Drafts(http_client_response)
+ # This is the exact subject from the bug report
+ request_body = {
+ "subject": "De l'idée à la post-prod, sans friction",
+ "to": [{"name": "Jean Dupont", "email": "jean@example.com"}],
+ "body": "Message avec des caractères accentués: café, naïve, résumé",
+ }
+
+ drafts.create(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/drafts",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_create_draft_with_special_characters_large_attachment(self, http_client_response):
+ """Test that special characters are preserved in drafts when using form data (large attachments)."""
+ from unittest.mock import Mock
+
+ drafts = Drafts(http_client_response)
+ mock_encoder = Mock()
+
+ # Mock the _build_form_request to capture what it's called with
+ with patch("nylas.resources.drafts._build_form_request") as mock_build_form:
+ mock_build_form.return_value = mock_encoder
+
+ # This is the exact subject from the bug report
+ request_body = {
+ "subject": "De l'idée à la post-prod, sans friction",
+ "to": [{"name": "Jean Dupont", "email": "jean@example.com"}],
+ "body": "Message avec des caractères: café, naïve",
+ "attachments": [
+ {
+ "filename": "large_file.pdf",
+ "content_type": "application/pdf",
+ "content": b"large file content",
+ "size": 3 * 1024 * 1024, # 3MB - triggers form data
+ }
+ ],
+ }
+
+ drafts.create(identifier="abc-123", request_body=request_body)
+
+ # Verify _build_form_request was called
+ mock_build_form.assert_called_once()
+
+ # Verify the subject with special characters was passed correctly
+ call_args = mock_build_form.call_args[0][0]
+ assert call_args["subject"] == "De l'idée à la post-prod, sans friction"
+ assert "café" in call_args["body"]
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/drafts",
+ data=mock_encoder,
+ overrides=None,
+ )
diff --git a/tests/resources/test_events.py b/tests/resources/test_events.py
new file mode 100644
index 00000000..14e62886
--- /dev/null
+++ b/tests/resources/test_events.py
@@ -0,0 +1,662 @@
+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_20240603T180000Z",
+ "master_event_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_20240603T180000Z"
+ assert event.master_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,
+ overrides=None,
+ )
+
+ def test_list_events_with_query_params(self, http_client_list_response):
+ events = Events(http_client_list_response)
+
+ events.list(identifier="abc-123", query_params={"limit": 20})
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/events",
+ None,
+ {"limit": 20},
+ None,
+ overrides=None,
+ )
+
+ def test_list_events_with_select_param(self, http_client_list_response):
+ events = Events(http_client_list_response)
+
+ # Set up mock response data
+ http_client_list_response._execute.return_value = {
+ "request_id": "abc-123",
+ "data": [{
+ "id": "event-123",
+ "title": "Team Meeting",
+ "description": "Weekly team sync",
+ "when": {
+ "start_time": 1625097600,
+ "end_time": 1625101200
+ }
+ }]
+ }
+
+ # Call the API method
+ result = events.list(
+ identifier="abc-123",
+ query_params={
+ "select": "id,title,description,when"
+ }
+ )
+
+ # Verify API call
+ http_client_list_response._execute.assert_called_with(
+ "GET",
+ "/v3/grants/abc-123/events",
+ None,
+ {"select": "id,title,description,when"},
+ None,
+ overrides=None,
+ )
+
+ # The actual response validation is handled by the mock in conftest.py
+ assert result is not None
+
+ def test_list_import_events(self, http_client_list_response):
+ events = Events(http_client=http_client_list_response)
+ events.list_import_events(
+ identifier="grant-123",
+ query_params={"calendar_id": "primary"},
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/grant-123/events/import",
+ None,
+ {"calendar_id": "primary"},
+ None,
+ overrides=None,
+ )
+
+ def test_list_import_events_with_select_param(self, http_client_list_response):
+ events = Events(http_client=http_client_list_response)
+ events.list_import_events(
+ identifier="grant-123",
+ query_params={"calendar_id": "primary", "select": "id,title,participants"},
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/grant-123/events/import",
+ None,
+ {"calendar_id": "primary", "select": "id,title,participants"},
+ None,
+ overrides=None,
+ )
+
+ def test_list_import_events_with_limit(self, http_client_list_response):
+ events = Events(http_client=http_client_list_response)
+ events.list_import_events(
+ identifier="grant-123",
+ query_params={"calendar_id": "primary", "limit": 100},
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/grant-123/events/import",
+ None,
+ {"calendar_id": "primary", "limit": 100},
+ None,
+ overrides=None,
+ )
+
+ def test_list_import_events_with_time_filters(self, http_client_list_response):
+ events = Events(http_client=http_client_list_response)
+ # Using Unix timestamps for Jan 1, 2023 and Dec 31, 2023
+ start_time = 1672531200 # Jan 1, 2023
+ end_time = 1704067199 # Dec 31, 2023
+
+ events.list_import_events(
+ identifier="grant-123",
+ query_params={
+ "calendar_id": "primary",
+ "start": start_time,
+ "end": end_time
+ },
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/grant-123/events/import",
+ None,
+ {
+ "calendar_id": "primary",
+ "start": start_time,
+ "end": end_time
+ },
+ None,
+ overrides=None,
+ )
+
+ def test_list_import_events_with_all_params(self, http_client_list_response):
+ events = Events(http_client=http_client_list_response)
+ # Using Unix timestamps for Jan 1, 2023 and Dec 31, 2023
+ start_time = 1672531200 # Jan 1, 2023
+ end_time = 1704067199 # Dec 31, 2023
+
+ events.list_import_events(
+ identifier="grant-123",
+ query_params={
+ "calendar_id": "primary",
+ "limit": 50,
+ "start": start_time,
+ "end": end_time,
+ "select": "id,title,participants,when",
+ "page_token": "next-page-token-123"
+ },
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/grant-123/events/import",
+ None,
+ {
+ "calendar_id": "primary",
+ "limit": 50,
+ "start": start_time,
+ "end": end_time,
+ "select": "id,title,participants,when",
+ "page_token": "next-page-token-123"
+ },
+ None,
+ overrides=None,
+ )
+
+ def test_find_event(self, http_client_response):
+ events = Events(http_client_response)
+
+ 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,
+ overrides=None,
+ )
+
+ def test_find_event_with_select_param(self, http_client_response):
+ events = Events(http_client_response)
+
+ # Set up mock response data
+ http_client_response._execute.return_value = ({
+ "request_id": "abc-123",
+ "data": {
+ "id": "event-123",
+ "title": "Team Meeting",
+ "description": "Weekly team sync",
+ "when": {
+ "start_time": 1625097600,
+ "end_time": 1625101200
+ }
+ }
+ }, {"X-Test-Header": "test"})
+
+ # Call the API method
+ result = events.find(
+ identifier="abc-123",
+ event_id="event-123",
+ query_params={
+ "calendar_id": "abc-123",
+ "select": "id,title,description,when"
+ }
+ )
+
+ # Verify API call
+ http_client_response._execute.assert_called_with(
+ "GET",
+ "/v3/grants/abc-123/events/event-123",
+ None,
+ {
+ "calendar_id": "abc-123",
+ "select": "id,title,description,when"
+ },
+ None,
+ overrides=None,
+ )
+
+ # The actual response validation is handled by the mock in conftest.py
+ assert result is not None
+
+ def test_create_event(self, http_client_response):
+ events = Events(http_client_response)
+ request_body = {
+ "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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=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"},
+ overrides=None,
+ )
+
+ def test_event_with_notetaker_deserialization(self):
+ event_json = {
+ "id": "event-123",
+ "grant_id": "grant-123",
+ "calendar_id": "calendar-123",
+ "busy": True,
+ "participants": [
+ {"email": "test@example.com", "name": "Test User", "status": "yes"}
+ ],
+ "when": {
+ "start_time": 1497916800,
+ "end_time": 1497920400,
+ "object": "timespan"
+ },
+ "title": "Test Event with Notetaker",
+ "notetaker": {
+ "id": "notetaker-123",
+ "name": "Custom Notetaker",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True
+ }
+ }
+ }
+
+ event = Event.from_dict(event_json)
+
+ assert event.id == "event-123"
+ assert event.grant_id == "grant-123"
+ assert event.calendar_id == "calendar-123"
+ assert event.busy is True
+ assert event.title == "Test Event with Notetaker"
+ assert event.notetaker is not None
+ assert event.notetaker.id == "notetaker-123"
+ assert event.notetaker.name == "Custom Notetaker"
+ assert event.notetaker.meeting_settings is not None
+ assert event.notetaker.meeting_settings.video_recording is True
+ assert event.notetaker.meeting_settings.audio_recording is True
+ assert event.notetaker.meeting_settings.transcription is True
+
+ def test_create_event_with_notetaker(self, http_client_response):
+ events = Events(http_client_response)
+ request_body = {
+ "title": "Test Event with Notetaker",
+ "when": {
+ "start_time": 1497916800,
+ "end_time": 1497920400
+ },
+ "participants": [
+ {"email": "test@example.com", "name": "Test User"}
+ ],
+ "notetaker": {
+ "name": "Custom Notetaker",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True
+ }
+ }
+ }
+ query_params = {"calendar_id": "calendar-123"}
+
+ events.create(
+ identifier="abc-123",
+ request_body=request_body,
+ query_params=query_params
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/events",
+ None,
+ query_params,
+ request_body,
+ overrides=None,
+ )
+
+ def test_update_event_with_notetaker(self, http_client_response):
+ events = Events(http_client_response)
+ request_body = {
+ "title": "Updated Test Event",
+ "notetaker": {
+ "id": "notetaker-123",
+ "name": "Updated Notetaker",
+ "meeting_settings": {
+ "video_recording": False,
+ "audio_recording": True,
+ "transcription": False
+ }
+ }
+ }
+ query_params = {"calendar_id": "calendar-123"}
+
+ events.update(
+ identifier="abc-123",
+ event_id="event-123",
+ request_body=request_body,
+ query_params=query_params
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/grants/abc-123/events/event-123",
+ None,
+ query_params,
+ request_body,
+ overrides=None,
+ )
+
+ def test_event_with_empty_conferencing_deserialization(self):
+ """Test event deserialization with empty conferencing object."""
+ event_json = {
+ "id": "test-event-id",
+ "grant_id": "test-grant-id",
+ "calendar_id": "test-calendar-id",
+ "busy": True,
+ "participants": [
+ {"email": "test@example.com", "name": "Test User", "status": "yes"}
+ ],
+ "when": {
+ "start_time": 1497916800,
+ "end_time": 1497920400,
+ "object": "timespan"
+ },
+ "conferencing": {}, # Empty conferencing object
+ "title": "Test Event with Empty Conferencing"
+ }
+
+ event = Event.from_dict(event_json)
+
+ assert event.id == "test-event-id"
+ assert event.title == "Test Event with Empty Conferencing"
+ assert event.conferencing is None
+
+ def test_event_with_incomplete_conferencing_details_deserialization(self):
+ """Test event deserialization with conferencing details missing provider."""
+ event_json = {
+ "id": "test-event-id",
+ "grant_id": "test-grant-id",
+ "calendar_id": "test-calendar-id",
+ "busy": True,
+ "participants": [
+ {"email": "test@example.com", "name": "Test User", "status": "yes"}
+ ],
+ "when": {
+ "start_time": 1497916800,
+ "end_time": 1497920400,
+ "object": "timespan"
+ },
+ "conferencing": {
+ "details": {
+ "meeting_code": "code-123456",
+ "password": "password-123456",
+ "url": "https://zoom.us/j/1234567890?pwd=1234567890",
+ }
+ }, # Details without provider
+ "title": "Test Event with Incomplete Conferencing Details"
+ }
+
+ event = Event.from_dict(event_json)
+
+ assert event.id == "test-event-id"
+ assert event.title == "Test Event with Incomplete Conferencing Details"
+ assert event.conferencing is None
+
+ def test_event_with_incomplete_conferencing_autocreate_deserialization(self):
+ """Test event deserialization with conferencing autocreate missing provider."""
+ event_json = {
+ "id": "test-event-id",
+ "grant_id": "test-grant-id",
+ "calendar_id": "test-calendar-id",
+ "busy": True,
+ "participants": [
+ {"email": "test@example.com", "name": "Test User", "status": "yes"}
+ ],
+ "when": {
+ "start_time": 1497916800,
+ "end_time": 1497920400,
+ "object": "timespan"
+ },
+ "conferencing": {
+ "autocreate": {}
+ }, # Autocreate without provider
+ "title": "Test Event with Incomplete Conferencing Autocreate"
+ }
+
+ event = Event.from_dict(event_json)
+
+ assert event.id == "test-event-id"
+ assert event.title == "Test Event with Incomplete Conferencing Autocreate"
+ assert event.conferencing is None
+
+ def test_event_with_unknown_conferencing_fields_deserialization(self):
+ """Test event deserialization with conferencing containing unknown fields."""
+ event_json = {
+ "id": "test-event-id",
+ "grant_id": "test-grant-id",
+ "calendar_id": "test-calendar-id",
+ "busy": True,
+ "participants": [
+ {"email": "test@example.com", "name": "Test User", "status": "yes"}
+ ],
+ "when": {
+ "start_time": 1497916800,
+ "end_time": 1497920400,
+ "object": "timespan"
+ },
+ "conferencing": {
+ "unknown_field": "value"
+ }, # Unknown conferencing fields
+ "title": "Test Event with Unknown Conferencing Fields"
+ }
+
+ event = Event.from_dict(event_json)
+
+ assert event.id == "test-event-id"
+ assert event.title == "Test Event with Unknown Conferencing Fields"
+ assert event.conferencing is None
diff --git a/tests/resources/test_folders.py b/tests/resources/test_folders.py
new file mode 100644
index 00000000..6da3cce0
--- /dev/null
+++ b/tests/resources/test_folders.py
@@ -0,0 +1,285 @@
+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,
+ "attributes": ["\\Sent"],
+ }
+
+ 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
+ assert folder.attributes == "['\\\\Sent']"
+
+ def test_list_folders(self, http_client_list_response):
+ folders = Folders(http_client_list_response)
+
+ folders.list(identifier="abc-123", query_params=None)
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/grants/abc-123/folders", None, None, None, overrides=None
+ )
+
+ def test_list_folders_with_query_params(self, http_client_list_response):
+ folders = Folders(http_client_list_response)
+
+ folders.list(identifier="abc-123", query_params={"limit": 20})
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/folders",
+ None,
+ {"limit": 20},
+ None,
+ overrides=None,
+ )
+
+ def test_list_folders_with_include_hidden_folders_param(
+ self, http_client_list_response
+ ):
+ folders = Folders(http_client_list_response)
+
+ folders.list(
+ identifier="abc-123", query_params={"include_hidden_folders": True}
+ )
+
+ def test_list_folders_with_single_level_param(self, http_client_list_response):
+ folders = Folders(http_client_list_response)
+
+ folders.list(identifier="abc-123", query_params={"single_level": True})
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/folders",
+ None,
+ {"single_level": True},
+ None,
+ overrides=None,
+ )
+
+ def test_list_folders_with_include_hidden_folders_false(
+ self, http_client_list_response
+ ):
+ folders = Folders(http_client_list_response)
+
+ folders.list(
+ identifier="abc-123", query_params={"include_hidden_folders": False}
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/folders",
+ None,
+ {"include_hidden_folders": False},
+ None,
+ overrides=None,
+ )
+
+ def test_list_folders_with_single_level_false(self, http_client_list_response):
+ folders = Folders(http_client_list_response)
+
+ folders.list(identifier="abc-123", query_params={"single_level": False})
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/folders",
+ None,
+ {"single_level": False},
+ None,
+ overrides=None,
+ )
+
+ def test_list_folders_with_multiple_params_including_hidden_folders(
+ self, http_client_list_response
+ ):
+ folders = Folders(http_client_list_response)
+
+ folders.list(
+ identifier="abc-123",
+ query_params={
+ "limit": 20,
+ "parent_id": "parent-123",
+ "include_hidden_folders": True,
+ "single_level": True,
+ },
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/folders",
+ None,
+ {"limit": 20, "parent_id": "parent-123", "include_hidden_folders": True, "single_level": True},
+ None,
+ overrides=None,
+ )
+
+ def test_list_folders_with_select_param(self, http_client_list_response):
+ folders = Folders(http_client_list_response)
+
+ # Set up mock response data
+ http_client_list_response._execute.return_value = {
+ "request_id": "abc-123",
+ "data": [
+ {
+ "id": "folder-123",
+ "name": "Important",
+ "total_count": 42,
+ "unread_count": 5,
+ }
+ ],
+ }
+
+ # Call the API method
+ result = folders.list(
+ identifier="abc-123",
+ query_params={"select": "id,name,total_count,unread_count"},
+ )
+
+ # Verify API call
+ http_client_list_response._execute.assert_called_with(
+ "GET",
+ "/v3/grants/abc-123/folders",
+ None,
+ {"select": "id,name,total_count,unread_count"},
+ None,
+ overrides=None,
+ )
+
+ # The actual response validation is handled by the mock in conftest.py
+ assert result is not None
+
+ def test_find_folder(self, http_client_response):
+ folders = Folders(http_client_response)
+
+ 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,
+ overrides=None,
+ )
+
+ def test_find_folder_with_select_param(self, http_client_response):
+ folders = Folders(http_client_response)
+
+ # Set up mock response data
+ http_client_response._execute.return_value = (
+ {
+ "request_id": "abc-123",
+ "data": {
+ "id": "folder-123",
+ "name": "Important",
+ "total_count": 42,
+ "unread_count": 5,
+ },
+ },
+ {"X-Test-Header": "test"},
+ )
+
+ # Call the API method
+ result = folders.find(
+ identifier="abc-123",
+ folder_id="folder-123",
+ query_params={"select": "id,name,total_count,unread_count"},
+ )
+
+ # Verify API call
+ http_client_response._execute.assert_called_with(
+ "GET",
+ "/v3/grants/abc-123/folders/folder-123",
+ None,
+ {"select": "id,name,total_count,unread_count"},
+ None,
+ overrides=None,
+ )
+
+ # The actual response validation is handled by the mock in conftest.py
+ assert result is not None
+
+ def test_create_folder(self, http_client_response):
+ folders = Folders(http_client_response)
+ request_body = {
+ "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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=None,
+ )
diff --git a/tests/resources/test_grants.py b/tests/resources/test_grants.py
new file mode 100644
index 00000000..c96a6755
--- /dev/null
+++ b/tests/resources/test_grants.py
@@ -0,0 +1,164 @@
+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_grant_deserialization_with_credential_id(self, http_client):
+ grant_json = {
+ "id": "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47",
+ "provider": "microsoft",
+ "grant_status": "valid",
+ "email": "email@example.com",
+ "scope": ["Mail.Read", "User.Read", "offline_access"],
+ "user_agent": "string",
+ "ip": "string",
+ "state": "my-state",
+ "created_at": 1617817109,
+ "updated_at": 1617817109,
+ "credential_id": "cred-abc-123",
+ }
+
+ grant = Grant.from_dict(grant_json)
+
+ assert grant.id == "e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47"
+ assert grant.provider == "microsoft"
+ assert grant.grant_status == "valid"
+ assert grant.email == "email@example.com"
+ assert grant.scope == ["Mail.Read", "User.Read", "offline_access"]
+ assert grant.user_agent == "string"
+ assert grant.ip == "string"
+ assert grant.state == "my-state"
+ assert grant.created_at == 1617817109
+ assert grant.updated_at == 1617817109
+ assert grant.credential_id == "cred-abc-123"
+
+ def test_list_grants(self, http_client_list_response):
+ grants = Grants(http_client_list_response)
+
+ grants.list()
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/grants", None, None, None, overrides=None
+ )
+
+ def test_list_grants_normalizes_camel_case_query_params(
+ self, http_client_list_response
+ ):
+ grants = Grants(http_client_list_response)
+
+ grants.list(
+ query_params={
+ "sortBy": "created_at",
+ "orderBy": "asc",
+ "grantStatus": "valid",
+ "limit": 10,
+ }
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants",
+ None,
+ {
+ "sort_by": "created_at",
+ "order_by": "asc",
+ "grant_status": "valid",
+ "limit": 10,
+ },
+ None,
+ overrides=None,
+ )
+
+ def test_list_grants_prefers_snake_case_query_params(self, http_client_list_response):
+ grants = Grants(http_client_list_response)
+
+ grants.list(
+ query_params={
+ "sortBy": "updated_at",
+ "sort_by": "created_at",
+ "orderBy": "desc",
+ "order_by": "asc",
+ "grantStatus": "invalid",
+ "grant_status": "valid",
+ }
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants",
+ None,
+ {
+ "sort_by": "created_at",
+ "order_by": "asc",
+ "grant_status": "valid",
+ },
+ None,
+ overrides=None,
+ )
+
+ def test_find_grant(self, http_client_response):
+ grants = Grants(http_client_response)
+
+ grants.find("grant-123")
+
+ http_client_response._execute.assert_called_once_with(
+ "GET", "/v3/grants/grant-123", None, None, None, overrides=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(
+ "PATCH", "/v3/grants/grant-123", None, None, request_body, overrides=None
+ )
+
+ 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, overrides=None
+ )
diff --git a/tests/resources/test_lists.py b/tests/resources/test_lists.py
new file mode 100644
index 00000000..168a3d0e
--- /dev/null
+++ b/tests/resources/test_lists.py
@@ -0,0 +1,303 @@
+from unittest.mock import patch
+
+from nylas.models.lists import ListItem, NylasList
+from nylas.resources.lists import Lists
+
+
+class TestLists:
+ def test_list_deserialization(self):
+ list_json = {
+ "id": "list-123",
+ "name": "Blocked domains",
+ "description": "Known spam senders",
+ "type": "domain",
+ "items_count": 2,
+ "application_id": "app-123",
+ "organization_id": "org-123",
+ "created_at": 1712450952,
+ "updated_at": 1712451952,
+ }
+
+ nylas_list = NylasList.from_dict(list_json)
+
+ assert nylas_list.id == "list-123"
+ assert nylas_list.name == "Blocked domains"
+ assert nylas_list.description == "Known spam senders"
+ assert nylas_list.type == "domain"
+ assert nylas_list.items_count == 2
+ assert nylas_list.application_id == "app-123"
+ assert nylas_list.organization_id == "org-123"
+ assert nylas_list.created_at == 1712450952
+ assert nylas_list.updated_at == 1712451952
+
+ def test_list_item_deserialization(self):
+ item_json = {
+ "id": "item-123",
+ "list_id": "list-123",
+ "value": "spam-domain.com",
+ "created_at": 1712450952,
+ }
+
+ item = ListItem.from_dict(item_json)
+
+ assert item.id == "item-123"
+ assert item.list_id == "list-123"
+ assert item.value == "spam-domain.com"
+ assert item.created_at == 1712450952
+
+ def test_list_deserialization_with_minimal_fields(self):
+ nylas_list = NylasList.from_dict({"id": "list-123"}, infer_missing=True)
+
+ assert nylas_list.id == "list-123"
+ assert nylas_list.name is None
+ assert nylas_list.description is None
+ assert nylas_list.type is None
+ assert nylas_list.items_count is None
+
+ def test_list_item_deserialization_with_minimal_fields(self):
+ item = ListItem.from_dict({"id": "item-123"}, infer_missing=True)
+
+ assert item.id == "item-123"
+ assert item.list_id is None
+ assert item.value is None
+ assert item.created_at is None
+
+ def test_list_lists(self, http_client_list_response):
+ lists = Lists(http_client_list_response)
+
+ lists.list()
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/lists", None, None, None, overrides=None
+ )
+
+ def test_list_lists_with_query_params(self, http_client_list_response):
+ lists = Lists(http_client_list_response)
+
+ lists.list(query_params={"limit": 10, "page_token": "cursor-token"})
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/lists",
+ None,
+ {"limit": 10, "page_token": "cursor-token"},
+ None,
+ overrides=None,
+ )
+
+ def test_create_list(self, http_client_response):
+ lists = Lists(http_client_response)
+ request_body = {
+ "name": "Blocked domains",
+ "description": "Known spam senders",
+ "type": "domain",
+ }
+
+ lists.create(request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST", "/v3/lists", None, None, request_body, overrides=None
+ )
+
+ def test_create_list_with_overrides(self, http_client_response):
+ lists = Lists(http_client_response)
+ request_body = {"name": "Allowed domains", "type": "domain"}
+ overrides = {"headers": {"X-Test": "value"}}
+
+ lists.create(request_body=request_body, overrides=overrides)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST", "/v3/lists", None, None, request_body, overrides=overrides
+ )
+
+ def test_find_list(self, http_client_response):
+ lists = Lists(http_client_response)
+
+ lists.find(list_id="list-123")
+
+ http_client_response._execute.assert_called_once_with(
+ "GET", "/v3/lists/list-123", None, None, None, overrides=None
+ )
+
+ def test_find_list_with_overrides(self, http_client_response):
+ lists = Lists(http_client_response)
+ overrides = {"headers": {"X-Test": "value"}}
+
+ lists.find(list_id="list-123", overrides=overrides)
+
+ http_client_response._execute.assert_called_once_with(
+ "GET", "/v3/lists/list-123", None, None, None, overrides=overrides
+ )
+
+ def test_update_list(self, http_client_response):
+ lists = Lists(http_client_response)
+ request_body = {"name": "Updated blocked domains", "description": "Updated description"}
+
+ lists.update(list_id="list-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT", "/v3/lists/list-123", None, None, request_body, overrides=None
+ )
+
+ def test_update_list_with_overrides(self, http_client_response):
+ lists = Lists(http_client_response)
+ request_body = {"description": "Updated description"}
+ overrides = {"headers": {"X-Test": "value"}, "timeout": 42}
+
+ lists.update(list_id="list-123", request_body=request_body, overrides=overrides)
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT", "/v3/lists/list-123", None, None, request_body, overrides=overrides
+ )
+
+ def test_destroy_list(self, http_client_delete_response):
+ lists = Lists(http_client_delete_response)
+
+ lists.destroy(list_id="list-123")
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE", "/v3/lists/list-123", None, None, None, overrides=None
+ )
+
+ def test_destroy_list_with_overrides(self, http_client_delete_response):
+ lists = Lists(http_client_delete_response)
+ overrides = {"headers": {"X-Test": "value"}}
+
+ lists.destroy(list_id="list-123", overrides=overrides)
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE", "/v3/lists/list-123", None, None, None, overrides=overrides
+ )
+
+ def test_list_items(self, http_client_list_response):
+ lists = Lists(http_client_list_response)
+
+ lists.list_items(list_id="list-123")
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/lists/list-123/items", None, None, None, overrides=None
+ )
+
+ def test_list_items_with_query_params(self, http_client_list_response):
+ lists = Lists(http_client_list_response)
+
+ lists.list_items(list_id="list-123", query_params={"limit": 50, "page_token": "next"})
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/lists/list-123/items",
+ None,
+ {"limit": 50, "page_token": "next"},
+ None,
+ overrides=None,
+ )
+
+ def test_list_items_with_overrides(self, http_client_list_response):
+ lists = Lists(http_client_list_response)
+ overrides = {"headers": {"X-Test": "value"}}
+
+ lists.list_items(list_id="list-123", overrides=overrides)
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/lists/list-123/items",
+ None,
+ None,
+ None,
+ overrides=overrides,
+ )
+
+ def test_add_items(self, http_client_response):
+ lists = Lists(http_client_response)
+ request_body = {"items": ["spam-domain.com", "phishing-example.net"]}
+
+ lists.add_items(list_id="list-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/lists/list-123/items",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_add_items_with_overrides(self, http_client_response):
+ lists = Lists(http_client_response)
+ request_body = {"items": ["trusted-domain.com"]}
+ overrides = {"headers": {"X-Test": "value"}}
+
+ lists.add_items(list_id="list-123", request_body=request_body, overrides=overrides)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/lists/list-123/items",
+ None,
+ None,
+ request_body,
+ overrides=overrides,
+ )
+
+ def test_remove_items(self, http_client_response):
+ lists = Lists(http_client_response)
+ request_body = {"items": ["spam-domain.com"]}
+
+ lists.remove_items(list_id="list-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/lists/list-123/items",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_remove_items_with_overrides(self, http_client_response):
+ lists = Lists(http_client_response)
+ request_body = {"items": ["spam-domain.com"]}
+ overrides = {"headers": {"X-Test": "value"}}
+
+ lists.remove_items(list_id="list-123", request_body=request_body, overrides=overrides)
+
+ http_client_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/lists/list-123/items",
+ None,
+ None,
+ request_body,
+ overrides=overrides,
+ )
+
+ def test_remove_items_deserializes_using_nylas_list(self, http_client):
+ lists = Lists(http_client)
+ request_body = {"items": ["spam-domain.com"]}
+ http_client._execute = lambda *args, **kwargs: (
+ {
+ "request_id": "abc-123",
+ "data": {
+ "id": "list-123",
+ "name": "Blocked domains",
+ "type": "domain",
+ "items_count": 0,
+ },
+ },
+ {"X-Test-Header": "test"},
+ )
+
+ with patch("nylas.resources.lists.Response.from_dict") as response_from_dict:
+ lists.remove_items(list_id="list-123", request_body=request_body)
+
+ response_from_dict.assert_called_once_with(
+ {
+ "request_id": "abc-123",
+ "data": {
+ "id": "list-123",
+ "name": "Blocked domains",
+ "type": "domain",
+ "items_count": 0,
+ },
+ },
+ NylasList,
+ {"X-Test-Header": "test"},
+ )
diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py
new file mode 100644
index 00000000..15493c76
--- /dev/null
+++ b/tests/resources/test_messages.py
@@ -0,0 +1,1137 @@
+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,
+ "metadata": {"custom_field": "value", "another_field": 123},
+ }
+
+ 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
+ assert message.metadata == {"custom_field": "value", "another_field": 123}
+
+ def test_list_messages(self, http_client_list_response):
+ messages = Messages(http_client_list_response)
+
+ messages.list(identifier="abc-123")
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/grants/abc-123/messages", None, None, None, overrides=None
+ )
+
+ def test_list_messages_with_query_params(self, http_client_list_response):
+ messages = Messages(http_client_list_response)
+
+ messages.list(
+ identifier="abc-123",
+ query_params={
+ "subject": "Hello from Nylas!",
+ },
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages",
+ None,
+ {
+ "subject": "Hello from Nylas!",
+ },
+ None,
+ overrides=None,
+ )
+
+ def test_find_message(self, http_client_response):
+ messages = Messages(http_client_response)
+
+ messages.find(identifier="abc-123", message_id="message-123")
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages/message-123",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_find_message_encoded_id(self, http_client_response):
+ messages = Messages(http_client_response)
+
+ messages.find(
+ identifier="abc-123",
+ message_id="",
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_find_message_with_query_params(self, http_client_response):
+ messages = Messages(http_client_response)
+
+ messages.find(
+ identifier="abc-123",
+ message_id="message-123",
+ query_params={"fields": "standard"},
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages/message-123",
+ None,
+ {"fields": "standard"},
+ None,
+ overrides=None,
+ )
+
+ def test_update_message(self, http_client_response):
+ messages = Messages(http_client_response)
+ request_body = {
+ "starred": True,
+ "unread": False,
+ "folders": ["folder-123"],
+ "metadata": {"foo": "bar"},
+ }
+
+ messages.update(
+ identifier="abc-123",
+ message_id="message-123",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/grants/abc-123/messages/message-123",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_update_message_encoded_id(self, http_client_response):
+ messages = Messages(http_client_response)
+ request_body = {
+ "starred": True,
+ "unread": False,
+ "folders": ["folder-123"],
+ "metadata": {"foo": "bar"},
+ }
+
+ messages.update(
+ identifier="abc-123",
+ message_id="",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_destroy_message(self, http_client_delete_response):
+ messages = Messages(http_client_delete_response)
+
+ messages.destroy(identifier="abc-123", message_id="message-123")
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/grants/abc-123/messages/message-123",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_destroy_message_encoded_id(self, http_client_delete_response):
+ messages = Messages(http_client_delete_response)
+
+ messages.destroy(
+ identifier="abc-123",
+ message_id="",
+ )
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_send_message(self, http_client_response):
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ "metadata": {"custom_field": "value", "another_field": 123},
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_small_attachment(self, http_client_response):
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ "attachments": [
+ {
+ "filename": "file1.txt",
+ "content_type": "text/plain",
+ "content": "this is a file",
+ "size": 3,
+ },
+ ],
+ "metadata": {"custom_field": "value", "another_field": 123},
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_large_attachment(self, http_client_response):
+ messages = Messages(http_client_response)
+ mock_encoder = Mock()
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ "attachments": [
+ {
+ "filename": "file1.txt",
+ "content_type": "text/plain",
+ "content": "this is a file",
+ "size": 3 * 1024 * 1024,
+ },
+ ],
+ "metadata": {"custom_field": "value", "another_field": 123},
+ }
+
+ with patch(
+ "nylas.resources.messages._build_form_request", return_value=mock_encoder
+ ):
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=None,
+ data=mock_encoder,
+ overrides=None,
+ )
+
+ def test_list_scheduled_messages(self, http_client_list_scheduled_messages):
+ messages = Messages(http_client_list_scheduled_messages)
+
+ res = messages.list_scheduled_messages(identifier="abc-123")
+
+ http_client_list_scheduled_messages._execute.assert_called_once_with(
+ method="GET", path="/v3/grants/abc-123/messages/schedules", overrides=None
+ )
+ assert res.request_id == "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5"
+ assert len(res.data) == 2
+ assert res.data[0].schedule_id == "8cd56334-6d95-432c-86d1-c5dab0ce98be"
+ assert res.data[0].status.code == "pending"
+ assert res.data[0].status.description == "schedule send awaiting send at time"
+ assert res.data[1].schedule_id == "rb856334-6d95-432c-86d1-c5dab0ce98be"
+ assert res.data[1].status.code == "success"
+ assert res.data[1].status.description == "schedule send succeeded"
+ assert res.data[1].close_time == 1690579819
+
+ def test_find_scheduled_message(self, http_client_response):
+ messages = Messages(http_client_response)
+
+ messages.find_scheduled_message(
+ identifier="abc-123", schedule_id="schedule-123"
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ method="GET",
+ path="/v3/grants/abc-123/messages/schedules/schedule-123",
+ overrides=None,
+ )
+
+ def test_stop_scheduled_message(self, http_client_response):
+ messages = Messages(http_client_response)
+
+ messages.stop_scheduled_message(
+ identifier="abc-123", schedule_id="schedule-123"
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ method="DELETE",
+ path="/v3/grants/abc-123/messages/schedules/schedule-123",
+ overrides=None,
+ )
+
+ def test_clean_messages(self, http_client_clean_messages):
+ messages = Messages(http_client_clean_messages)
+ request_body = {
+ "message_id": ["message-1", "message-2"],
+ "ignore_images": True,
+ "ignore_links": True,
+ "ignore_tables": True,
+ "images_as_markdown": True,
+ "remove_conclusion_phrases": True,
+ }
+
+ response = messages.clean_messages(
+ identifier="abc-123",
+ request_body=request_body,
+ )
+
+ http_client_clean_messages._execute.assert_called_once_with(
+ method="PUT",
+ path="/v3/grants/abc-123/messages/clean",
+ request_body=request_body,
+ overrides=None,
+ )
+
+ # Assert the conversation field, and the typical message fields serialize properly
+ assert len(response.data) == 2
+ assert response.data[0].body == "Hello, I just sent a message using Nylas!"
+ assert response.data[0].from_ == [
+ {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}
+ ]
+ assert response.data[0].object == "message"
+ assert response.data[0].id == "message-1"
+ assert response.data[0].grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386"
+ assert response.data[0].conversation == "cleaned example"
+ assert response.data[1].conversation == "another example"
+
+ def test_list_messages(self, http_client_list_response):
+ messages = Messages(http_client_list_response)
+
+ messages.list(identifier="abc-123")
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/grants/abc-123/messages", None, None, None, overrides=None
+ )
+
+ def test_list_messages_with_query_params(self, http_client_list_response):
+ messages = Messages(http_client_list_response)
+
+ messages.list(
+ identifier="abc-123",
+ query_params={
+ "subject": "Hello from Nylas!",
+ },
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages",
+ None,
+ {
+ "subject": "Hello from Nylas!",
+ },
+ None,
+ overrides=None,
+ )
+
+ def test_find_message(self, http_client_response):
+ messages = Messages(http_client_response)
+
+ messages.find(identifier="abc-123", message_id="message-123")
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages/message-123",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_find_message_encoded_id(self, http_client_response):
+ messages = Messages(http_client_response)
+
+ messages.find(
+ identifier="abc-123",
+ message_id="",
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_find_message_with_query_params(self, http_client_response):
+ messages = Messages(http_client_response)
+
+ messages.find(
+ identifier="abc-123",
+ message_id="message-123",
+ query_params={"fields": "standard"},
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages/message-123",
+ None,
+ {"fields": "standard"},
+ None,
+ overrides=None,
+ )
+
+ def test_update_message(self, http_client_response):
+ messages = Messages(http_client_response)
+ request_body = {
+ "starred": True,
+ "unread": False,
+ "folders": ["folder-123"],
+ "metadata": {"foo": "bar"},
+ }
+
+ messages.update(
+ identifier="abc-123",
+ message_id="message-123",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/grants/abc-123/messages/message-123",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_update_message_encoded_id(self, http_client_response):
+ messages = Messages(http_client_response)
+ request_body = {
+ "starred": True,
+ "unread": False,
+ "folders": ["folder-123"],
+ "metadata": {"foo": "bar"},
+ }
+
+ messages.update(
+ identifier="abc-123",
+ message_id="",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_destroy_message(self, http_client_delete_response):
+ messages = Messages(http_client_delete_response)
+
+ messages.destroy(identifier="abc-123", message_id="message-123")
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/grants/abc-123/messages/message-123",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_destroy_message_encoded_id(self, http_client_delete_response):
+ messages = Messages(http_client_delete_response)
+
+ messages.destroy(
+ identifier="abc-123",
+ message_id="",
+ )
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_send_message(self, http_client_response):
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_small_attachment(self, http_client_response):
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ "attachments": [
+ {
+ "filename": "file1.txt",
+ "content_type": "text/plain",
+ "content": "this is a file",
+ "size": 3,
+ },
+ ],
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_large_attachment(self, http_client_response):
+ messages = Messages(http_client_response)
+ mock_encoder = Mock()
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}],
+ "body": "This is the body of my draft message.",
+ "attachments": [
+ {
+ "filename": "file1.txt",
+ "content_type": "text/plain",
+ "content": "this is a file",
+ "size": 3 * 1024 * 1024,
+ },
+ ],
+ }
+
+ with patch(
+ "nylas.resources.messages._build_form_request", return_value=mock_encoder
+ ):
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=None,
+ data=mock_encoder,
+ overrides=None,
+ )
+
+ def test_list_scheduled_messages(self, http_client_list_scheduled_messages):
+ messages = Messages(http_client_list_scheduled_messages)
+
+ res = messages.list_scheduled_messages(identifier="abc-123")
+
+ http_client_list_scheduled_messages._execute.assert_called_once_with(
+ method="GET", path="/v3/grants/abc-123/messages/schedules", overrides=None
+ )
+ assert res.request_id == "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5"
+ assert len(res.data) == 2
+ assert res.data[0].schedule_id == "8cd56334-6d95-432c-86d1-c5dab0ce98be"
+ assert res.data[0].status.code == "pending"
+ assert res.data[0].status.description == "schedule send awaiting send at time"
+ assert res.data[1].schedule_id == "rb856334-6d95-432c-86d1-c5dab0ce98be"
+ assert res.data[1].status.code == "success"
+ assert res.data[1].status.description == "schedule send succeeded"
+ assert res.data[1].close_time == 1690579819
+
+ def test_find_scheduled_message(self, http_client_response):
+ messages = Messages(http_client_response)
+
+ messages.find_scheduled_message(
+ identifier="abc-123", schedule_id="schedule-123"
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ method="GET",
+ path="/v3/grants/abc-123/messages/schedules/schedule-123",
+ overrides=None,
+ )
+
+ def test_stop_scheduled_message(self, http_client_response):
+ messages = Messages(http_client_response)
+
+ messages.stop_scheduled_message(
+ identifier="abc-123", schedule_id="schedule-123"
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ method="DELETE",
+ path="/v3/grants/abc-123/messages/schedules/schedule-123",
+ overrides=None,
+ )
+
+ def test_clean_messages(self, http_client_clean_messages):
+ messages = Messages(http_client_clean_messages)
+ request_body = {
+ "message_id": ["message-1", "message-2"],
+ "ignore_images": True,
+ "ignore_links": True,
+ "ignore_tables": True,
+ "images_as_markdown": True,
+ "remove_conclusion_phrases": True,
+ }
+
+ response = messages.clean_messages(
+ identifier="abc-123",
+ request_body=request_body,
+ )
+
+ http_client_clean_messages._execute.assert_called_once_with(
+ method="PUT",
+ path="/v3/grants/abc-123/messages/clean",
+ request_body=request_body,
+ overrides=None,
+ )
+
+ # Assert the conversation field, and the typical message fields serialize properly
+ assert len(response.data) == 2
+ assert response.data[0].body == "Hello, I just sent a message using Nylas!"
+ assert response.data[0].from_ == [
+ {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}
+ ]
+ assert response.data[0].object == "message"
+ assert response.data[0].id == "message-1"
+ assert response.data[0].grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386"
+ assert response.data[0].conversation == "cleaned example"
+ assert response.data[1].conversation == "another example"
+
+
+ def test_list_messages_select_param(self, http_client_list_response):
+ messages = Messages(http_client_list_response)
+
+ messages.list(identifier="abc-123", query_params={"select": ["id", "subject", "from", "to"]})
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages",
+ None,
+ {"select": ["id", "subject", "from", "to"]},
+ None,
+ overrides=None,
+ )
+
+ # Make sure query params are properly serialized
+ assert http_client_list_response._execute.call_args[0][3] == {"select": ["id", "subject", "from", "to"]}
+
+ def test_find_message_select_param(self, http_client_response):
+ messages = Messages(http_client_response)
+
+ messages.find(identifier="abc-123", message_id="message-123", query_params={"select": ["id", "subject", "from", "to"]})
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages/message-123",
+ None,
+ {"select": ["id", "subject", "from", "to"]},
+ None,
+ overrides=None,
+ )
+
+ # Make sure query params are properly serialized
+ assert http_client_response._execute.call_args[0][3] == {"select": ["id", "subject", "from", "to"]}
+
+ # New tests for tracking_options and raw_mime features
+ def test_message_deserialization_with_tracking_options(self):
+ """Test deserialization of message with tracking_options field."""
+ message_json = {
+ "body": "Hello, I just sent a message using Nylas!",
+ "from": [{"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}],
+ "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386",
+ "id": "5d3qmne77v32r8l4phyuksl2x",
+ "object": "message",
+ "subject": "Hello from Nylas!",
+ "tracking_options": {
+ "opens": True,
+ "thread_replies": False,
+ "links": True,
+ "label": "Marketing Campaign"
+ }
+ }
+
+ message = Message.from_dict(message_json)
+
+ assert message.tracking_options is not None
+ assert message.tracking_options.opens is True
+ assert message.tracking_options.thread_replies is False
+ assert message.tracking_options.links is True
+ assert message.tracking_options.label == "Marketing Campaign"
+
+ def test_message_deserialization_with_raw_mime(self):
+ """Test deserialization of message with raw_mime field."""
+ message_json = {
+ "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386",
+ "id": "5d3qmne77v32r8l4phyuksl2x",
+ "object": "message",
+ "raw_mime": "TUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluOyBjaGFyc2V0PXV0Zi04CgpIZWxsbyBXb3JsZCE="
+ }
+
+ message = Message.from_dict(message_json)
+
+ assert message.raw_mime == "TUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluOyBjaGFyc2V0PXV0Zi04CgpIZWxsbyBXb3JsZCE="
+
+ def test_message_deserialization_backwards_compatibility(self):
+ """Test that existing message deserialization still works (backwards compatibility)."""
+ message_json = {
+ "body": "Hello, I just sent a message using Nylas!",
+ "from": [{"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}],
+ "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386",
+ "id": "5d3qmne77v32r8l4phyuksl2x",
+ "object": "message",
+ "subject": "Hello from Nylas!",
+ }
+
+ message = Message.from_dict(message_json)
+
+ # These new fields should be None when not provided
+ assert message.tracking_options is None
+ assert message.raw_mime is None
+ # Existing fields should still work
+ assert message.body == "Hello, I just sent a message using Nylas!"
+ assert message.subject == "Hello from Nylas!"
+
+ def test_list_messages_with_include_tracking_options_field(self, http_client_list_response):
+ """Test listing messages with include_tracking_options field."""
+ messages = Messages(http_client_list_response)
+
+ messages.list(
+ identifier="abc-123",
+ query_params={"fields": "include_tracking_options"},
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages",
+ None,
+ {"fields": "include_tracking_options"},
+ None,
+ overrides=None,
+ )
+
+ def test_list_messages_with_raw_mime_field(self, http_client_list_response):
+ """Test listing messages with raw_mime field."""
+ messages = Messages(http_client_list_response)
+
+ messages.list(
+ identifier="abc-123",
+ query_params={"fields": "raw_mime"},
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages",
+ None,
+ {"fields": "raw_mime"},
+ None,
+ overrides=None,
+ )
+
+ def test_find_message_with_include_tracking_options_field(self, http_client_response):
+ """Test finding a message with include_tracking_options field."""
+ messages = Messages(http_client_response)
+
+ messages.find(
+ identifier="abc-123",
+ message_id="message-123",
+ query_params={"fields": "include_tracking_options"},
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages/message-123",
+ None,
+ {"fields": "include_tracking_options"},
+ None,
+ overrides=None,
+ )
+
+ def test_find_message_with_raw_mime_field(self, http_client_response):
+ """Test finding a message with raw_mime field."""
+ messages = Messages(http_client_response)
+
+ messages.find(
+ identifier="abc-123",
+ message_id="message-123",
+ query_params={"fields": "raw_mime"},
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/messages/message-123",
+ None,
+ {"fields": "raw_mime"},
+ None,
+ overrides=None,
+ )
+
+ def test_tracking_options_serialization(self):
+ """Test that tracking_options can be serialized to JSON."""
+ from nylas.models.messages import TrackingOptions
+
+ tracking_options = TrackingOptions(
+ opens=True,
+ thread_replies=False,
+ links=True,
+ label="Test Campaign"
+ )
+
+ # Test serialization
+ json_data = tracking_options.to_dict()
+ assert json_data["opens"] is True
+ assert json_data["thread_replies"] is False
+ assert json_data["links"] is True
+ assert json_data["label"] == "Test Campaign"
+
+ # Test deserialization
+ tracking_options_from_dict = TrackingOptions.from_dict(json_data)
+ assert tracking_options_from_dict.opens is True
+ assert tracking_options_from_dict.thread_replies is False
+ assert tracking_options_from_dict.links is True
+ assert tracking_options_from_dict.label == "Test Campaign"
+
+ def test_send_message_with_is_plaintext_true(self, http_client_response):
+ """Test sending a message with is_plaintext=True."""
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ "is_plaintext": True,
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_with_is_plaintext_false(self, http_client_response):
+ """Test sending a message with is_plaintext=False."""
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ "is_plaintext": False,
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_without_is_plaintext_backwards_compatibility(self, http_client_response):
+ """Test that existing code without is_plaintext still works (backwards compatibility)."""
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ }
+
+ # Should work without any issues
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_with_from_field_mapping(self, http_client_response):
+ """Test that from_ field is properly mapped to from field in request body."""
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ "from_": [
+ {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}
+ ],
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ # Verify that from_ was mapped to from and from_ was removed
+ expected_request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ "from": [{"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}],
+ }
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=expected_request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_with_existing_from_field_unchanged(
+ self, http_client_response
+ ):
+ """Test that existing from field is left unchanged when both from and from_ are present."""
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ "from": [{"name": "Existing Sender", "email": "existing@example.com"}],
+ "from_": [
+ {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}
+ ],
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ # Verify that the original from field is preserved and from_ is not processed
+ expected_request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ "from": [{"name": "Existing Sender", "email": "existing@example.com"}],
+ "from_": [
+ {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}
+ ],
+ }
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=expected_request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_with_only_from_field_unchanged(self, http_client_response):
+ """Test that when only from field is present, it remains unchanged."""
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ "from": [{"name": "Direct Sender", "email": "direct@example.com"}],
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ # Verify that the from field remains unchanged
+ expected_request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ "from": [{"name": "Direct Sender", "email": "direct@example.com"}],
+ }
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=expected_request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_without_from_fields_unchanged(self, http_client_response):
+ """Test that request body without from or from_ fields remains unchanged."""
+ messages = Messages(http_client_response)
+ request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ # Verify that the request body remains unchanged
+ expected_request_body = {
+ "subject": "Hello from Nylas!",
+ "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}],
+ "body": "This is the body of my message.",
+ }
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=expected_request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_with_special_characters_in_subject(self, http_client_response):
+ """Test sending a message with special characters (accented letters) in subject."""
+ messages = Messages(http_client_response)
+ # This is the exact subject from the bug report
+ request_body = {
+ "subject": "De l'idée à la post-prod, sans friction",
+ "to": [{"name": "Jean Dupont", "email": "jean@example.com"}],
+ "body": "Message avec des caractères accentués: café, naïve, résumé",
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_message_with_special_characters_large_attachment(self, http_client_response):
+ """Test that special characters are preserved when using form data (large attachments)."""
+ from unittest.mock import Mock
+ import json
+
+ messages = Messages(http_client_response)
+ mock_encoder = Mock()
+
+ # Mock the _build_form_request to capture what it's called with
+ with patch("nylas.resources.messages._build_form_request") as mock_build_form:
+ mock_build_form.return_value = mock_encoder
+
+ # This is the exact subject from the bug report
+ request_body = {
+ "subject": "De l'idée à la post-prod, sans friction",
+ "to": [{"name": "Jean Dupont", "email": "jean@example.com"}],
+ "body": "Message avec des caractères: café, naïve",
+ "attachments": [
+ {
+ "filename": "large_file.pdf",
+ "content_type": "application/pdf",
+ "content": b"large file content",
+ "size": 3 * 1024 * 1024, # 3MB - triggers form data
+ }
+ ],
+ }
+
+ messages.send(identifier="abc-123", request_body=request_body)
+
+ # Verify _build_form_request was called
+ mock_build_form.assert_called_once()
+
+ # Verify the subject with special characters was passed correctly
+ call_args = mock_build_form.call_args[0][0]
+ assert call_args["subject"] == "De l'idée à la post-prod, sans friction"
+ assert "café" in call_args["body"]
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/grants/abc-123/messages/send",
+ request_body=None,
+ data=mock_encoder,
+ overrides=None,
+ )
\ No newline at end of file
diff --git a/tests/resources/test_notetakers.py b/tests/resources/test_notetakers.py
new file mode 100644
index 00000000..a094139a
--- /dev/null
+++ b/tests/resources/test_notetakers.py
@@ -0,0 +1,620 @@
+from nylas.resources.notetakers import Notetakers
+from nylas.models.notetakers import (
+ Notetaker,
+ NotetakerMedia,
+ NotetakerState,
+ MeetingProvider,
+ ListNotetakerQueryParams,
+ NotetakerLeaveResponse,
+ NotetakerOrderBy,
+ NotetakerOrderDirection,
+)
+
+
+class TestNotetaker:
+ def test_notetaker_deserialization(self):
+ notetaker_json = {
+ "id": "notetaker-123",
+ "name": "Nylas Notetaker",
+ "join_time": 1656090000,
+ "meeting_link": "https://meet.google.com/abc-def-ghi",
+ "meeting_provider": "Google Meet",
+ "state": "scheduled",
+ "object": "notetaker",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True,
+ },
+ }
+
+ notetaker = Notetaker.from_dict(notetaker_json)
+
+ assert notetaker.id == "notetaker-123"
+ assert notetaker.name == "Nylas Notetaker"
+ assert notetaker.join_time == 1656090000
+ assert notetaker.meeting_link == "https://meet.google.com/abc-def-ghi"
+ assert notetaker.meeting_provider == MeetingProvider.GOOGLE_MEET
+ assert notetaker.state == NotetakerState.SCHEDULED
+ assert notetaker.object == "notetaker"
+ assert notetaker.meeting_settings.video_recording is True
+ assert notetaker.meeting_settings.audio_recording is True
+ assert notetaker.meeting_settings.transcription is True
+
+ def test_notetaker_state_enum(self):
+ """Test that the NotetakerState enum works correctly."""
+ # Test all enum values
+ states = [
+ ("scheduled", NotetakerState.SCHEDULED),
+ ("connecting", NotetakerState.CONNECTING),
+ ("waiting_for_entry", NotetakerState.WAITING_FOR_ENTRY),
+ ("failed_entry", NotetakerState.FAILED_ENTRY),
+ ("attending", NotetakerState.ATTENDING),
+ ("media_processing", NotetakerState.MEDIA_PROCESSING),
+ ("media_available", NotetakerState.MEDIA_AVAILABLE),
+ ("media_error", NotetakerState.MEDIA_ERROR),
+ ("media_deleted", NotetakerState.MEDIA_DELETED),
+ ]
+
+ for state_str, state_enum in states:
+ notetaker_json = {
+ "id": "notetaker-123",
+ "name": "Nylas Notetaker",
+ "join_time": 1656090000,
+ "meeting_link": "https://meet.google.com/abc-def-ghi",
+ "state": state_str,
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True,
+ },
+ }
+
+ notetaker = Notetaker.from_dict(notetaker_json)
+ assert notetaker.state == state_enum
+ assert notetaker.state.value == state_str
+
+ def test_list_notetakers(self, http_client_list_response):
+ notetakers = Notetakers(http_client_list_response)
+
+ notetakers.list(identifier="abc-123", query_params=None)
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/notetakers",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_list_notetakers_without_identifier(self, http_client_list_response):
+ notetakers = Notetakers(http_client_list_response)
+
+ notetakers.list(query_params=None)
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/notetakers", None, None, None, overrides=None
+ )
+
+ def test_list_notetakers_with_query_params(self, http_client_list_response):
+ notetakers = Notetakers(http_client_list_response)
+
+ notetakers.list(
+ identifier="abc-123",
+ query_params={"state": NotetakerState.SCHEDULED, "limit": 20},
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/notetakers",
+ None,
+ {"state": "scheduled", "limit": 20},
+ None,
+ overrides=None,
+ )
+
+ def test_list_notetakers_with_enum_query_params(self, http_client_list_response):
+ """Test that the NotetakerState enum can be used directly in query params."""
+ notetakers = Notetakers(http_client_list_response)
+
+ # Create query params using the enum directly
+ query_params = ListNotetakerQueryParams(
+ state=NotetakerState.SCHEDULED, limit=20
+ )
+
+ notetakers.list(identifier="abc-123", query_params=query_params)
+
+ # Verify the enum is converted to string in the API call
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/notetakers",
+ None,
+ {"state": "scheduled", "limit": 20},
+ None,
+ overrides=None,
+ )
+
+ def test_find_notetaker(self, http_client_response):
+ notetakers = Notetakers(http_client_response)
+
+ notetakers.find(identifier="abc-123", notetaker_id="notetaker-123")
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/notetakers/notetaker-123",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_find_notetaker_without_identifier(self, http_client_response):
+ notetakers = Notetakers(http_client_response)
+
+ notetakers.find(notetaker_id="notetaker-123")
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/notetakers/notetaker-123",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_invite_notetaker(self, http_client_response):
+ notetakers = Notetakers(http_client_response)
+ request_body = {
+ "meeting_link": "https://meet.google.com/abc-def-ghi",
+ "join_time": 1656090000,
+ "name": "Custom Notetaker",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True,
+ },
+ }
+
+ notetakers.invite(identifier="abc-123", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/notetakers",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_invite_notetaker_without_identifier(self, http_client_response):
+ notetakers = Notetakers(http_client_response)
+ request_body = {
+ "meeting_link": "https://meet.google.com/abc-def-ghi",
+ "join_time": 1656090000,
+ "name": "Custom Notetaker",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True,
+ },
+ }
+
+ notetakers.invite(request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/notetakers",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_update_notetaker(self, http_client_response):
+ notetakers = Notetakers(http_client_response)
+ request_body = {
+ "name": "Updated Notetaker",
+ "join_time": 1656100000,
+ "meeting_settings": {
+ "video_recording": False,
+ "audio_recording": True,
+ "transcription": True,
+ },
+ }
+
+ notetakers.update(
+ identifier="abc-123",
+ notetaker_id="notetaker-123",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PATCH",
+ "/v3/grants/abc-123/notetakers/notetaker-123",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_update_notetaker_without_identifier(self, http_client_response):
+ notetakers = Notetakers(http_client_response)
+ request_body = {"name": "Updated Notetaker", "join_time": 1656100000}
+
+ notetakers.update(
+ notetaker_id="notetaker-123",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PATCH",
+ "/v3/notetakers/notetaker-123",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_leave_meeting(self, http_client_response):
+ notetakers = Notetakers(http_client_response)
+
+ notetakers.leave(
+ identifier="abc-123",
+ notetaker_id="notetaker-123",
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/grants/abc-123/notetakers/notetaker-123/leave",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_leave_meeting_without_identifier(self, http_client_response):
+ notetakers = Notetakers(http_client_response)
+
+ notetakers.leave(
+ notetaker_id="notetaker-123",
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/notetakers/notetaker-123/leave",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_get_media(self, http_client_response):
+ notetakers = Notetakers(http_client_response)
+
+ notetakers.get_media(
+ identifier="abc-123",
+ notetaker_id="notetaker-123",
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/notetakers/notetaker-123/media",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_get_media_without_identifier(self, http_client_response):
+ notetakers = Notetakers(http_client_response)
+
+ notetakers.get_media(
+ notetaker_id="notetaker-123",
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/notetakers/notetaker-123/media",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_cancel_notetaker(self, http_client_delete_response):
+ notetakers = Notetakers(http_client_delete_response)
+
+ notetakers.cancel(
+ identifier="abc-123",
+ notetaker_id="notetaker-123",
+ )
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/grants/abc-123/notetakers/notetaker-123/cancel",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_cancel_notetaker_without_identifier(self, http_client_delete_response):
+ notetakers = Notetakers(http_client_delete_response)
+
+ notetakers.cancel(
+ notetaker_id="notetaker-123",
+ )
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/notetakers/notetaker-123/cancel",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_media_deserialization(self):
+ media_json = {
+ "recording": {
+ "size": 21550491,
+ "name": "meeting_recording.mp4",
+ "type": "video/mp4",
+ "created_at": 1744222418,
+ "expires_at": 1744481618,
+ "url": "url_for_recording",
+ "ttl": 259106,
+ },
+ "transcript": {
+ "size": 862,
+ "name": "raw_transcript.json",
+ "type": "application/json",
+ "created_at": 1744222418,
+ "expires_at": 1744481618,
+ "url": "url_for_transcript",
+ "ttl": 259106,
+ },
+ }
+
+ media = NotetakerMedia.from_dict(media_json)
+
+ assert media.recording.url == "url_for_recording"
+ assert media.recording.size == 21550491
+ assert media.recording.name == "meeting_recording.mp4"
+ assert media.recording.type == "video/mp4"
+ assert media.recording.created_at == 1744222418
+ assert media.recording.expires_at == 1744481618
+ assert media.recording.ttl == 259106
+
+ assert media.transcript.url == "url_for_transcript"
+ assert media.transcript.size == 862
+ assert media.transcript.name == "raw_transcript.json"
+ assert media.transcript.type == "application/json"
+ assert media.transcript.created_at == 1744222418
+ assert media.transcript.expires_at == 1744481618
+ assert media.transcript.ttl == 259106
+
+ def test_meeting_provider_enum(self):
+ """Test that the MeetingProvider enum works correctly."""
+ # Test all enum values
+ providers = [
+ ("Google Meet", MeetingProvider.GOOGLE_MEET),
+ ("Zoom Meeting", MeetingProvider.ZOOM),
+ ("Microsoft Teams", MeetingProvider.MICROSOFT_TEAMS),
+ ]
+
+ for provider_str, provider_enum in providers:
+ notetaker_json = {
+ "id": "notetaker-123",
+ "name": "Nylas Notetaker",
+ "join_time": 1656090000,
+ "meeting_link": "https://meet.example.com",
+ "meeting_provider": provider_str,
+ "state": "scheduled",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True,
+ },
+ }
+
+ notetaker = Notetaker.from_dict(notetaker_json)
+ assert notetaker.meeting_provider == provider_enum
+ assert notetaker.meeting_provider.value == provider_str
+
+ def test_state_enum_comparison(self):
+ """Test that enum values can be compared directly."""
+ # Create a notetaker with a state enum
+ notetaker_json = {
+ "id": "notetaker-123",
+ "name": "Nylas Notetaker",
+ "join_time": 1656090000,
+ "meeting_link": "https://meet.google.com/abc-def-ghi",
+ "state": "scheduled",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True,
+ },
+ }
+
+ notetaker = Notetaker.from_dict(notetaker_json)
+
+ # Check direct comparison with enum
+ assert notetaker.state == NotetakerState.SCHEDULED
+
+ # Value of the enum matches original string
+ assert notetaker.state.value == "scheduled"
+
+ def test_meeting_provider_enum_comparison(self):
+ """Test that meeting provider enum values can be compared directly."""
+ # Create a notetaker with a meeting provider enum
+ notetaker_json = {
+ "id": "notetaker-123",
+ "name": "Nylas Notetaker",
+ "join_time": 1656090000,
+ "meeting_link": "https://meet.google.com/abc-def-ghi",
+ "meeting_provider": "Google Meet",
+ "state": "scheduled",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True,
+ },
+ }
+
+ notetaker = Notetaker.from_dict(notetaker_json)
+
+ # Check direct comparison with enum
+ assert notetaker.meeting_provider == MeetingProvider.GOOGLE_MEET
+
+ # Value of the enum matches original string
+ assert notetaker.meeting_provider.value == "Google Meet"
+
+ def test_notetaker_helper_methods(self):
+ """Test the helper methods for checking state and provider."""
+ # Test with a scheduled notetaker
+ scheduled_notetaker = Notetaker.from_dict(
+ {
+ "id": "notetaker-123",
+ "name": "Nylas Notetaker",
+ "join_time": 1656090000,
+ "meeting_link": ("https://meet.google.com/abc-def-ghi"),
+ "meeting_provider": "Google Meet",
+ "state": "scheduled",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True,
+ },
+ }
+ )
+
+ assert scheduled_notetaker.is_state(NotetakerState.SCHEDULED) is True
+ assert scheduled_notetaker.is_scheduled() is True
+ assert scheduled_notetaker.is_attending() is False
+ assert scheduled_notetaker.has_media_available() is False
+
+ # Test with an attending notetaker
+ attending_notetaker = Notetaker.from_dict(
+ {
+ "id": "notetaker-456",
+ "name": "Nylas Notetaker",
+ "join_time": 1656090000,
+ "meeting_link": "https://zoom.us/j/123456789",
+ "meeting_provider": "Zoom Meeting",
+ "state": "attending",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True,
+ },
+ }
+ )
+
+ assert attending_notetaker.is_state(NotetakerState.ATTENDING) is True
+ assert attending_notetaker.is_scheduled() is False
+ assert attending_notetaker.is_attending() is True
+ assert attending_notetaker.has_media_available() is False
+
+ # Test with a media available notetaker
+ media_available_notetaker = Notetaker.from_dict(
+ {
+ "id": "notetaker-789",
+ "name": "Nylas Notetaker",
+ "join_time": 1656090000,
+ "meeting_link": ("https://teams.microsoft.com/l/meetup-join/123"),
+ "meeting_provider": "Microsoft Teams",
+ "state": "media_available",
+ "meeting_settings": {
+ "video_recording": True,
+ "audio_recording": True,
+ "transcription": True,
+ },
+ }
+ )
+
+ assert (
+ media_available_notetaker.is_state(NotetakerState.MEDIA_AVAILABLE) is True
+ )
+ assert media_available_notetaker.is_scheduled() is False
+ assert media_available_notetaker.is_attending() is False
+ assert media_available_notetaker.has_media_available() is True
+
+ def test_list_notetakers_with_time_filters(self, http_client_list_response):
+ """Test that join_time_start and join_time_end query parameters work correctly."""
+ # Using Unix timestamps for Jan 1, 2024 and Jan 2, 2024
+ start_time = 1704067200 # Jan 1, 2024
+ end_time = 1704153600 # Jan 2, 2024
+
+ # Create query params with time filters
+ query_params = ListNotetakerQueryParams(
+ join_time_start=start_time, join_time_end=end_time, limit=20
+ )
+
+ notetakers = Notetakers(http_client_list_response)
+
+ notetakers.list(identifier="abc-123", query_params=query_params)
+
+ # Verify the API call includes the time filter parameters
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/notetakers",
+ None,
+ {
+ "join_time_start": start_time,
+ "join_time_end": end_time,
+ "limit": 20,
+ },
+ None,
+ overrides=None,
+ )
+
+ def test_notetaker_leave_response_deserialization(self):
+ """Test deserialization of the NotetakerLeaveResponse model."""
+ leave_response_json = {
+ "id": "notetaker-123",
+ "message": "Notetaker has left the meeting",
+ "object": "notetaker_leave_response",
+ }
+
+ leave_response = NotetakerLeaveResponse.from_dict(leave_response_json)
+
+ assert leave_response.id == "notetaker-123"
+ assert leave_response.message == "Notetaker has left the meeting"
+ assert leave_response.object == "notetaker_leave_response"
+
+ def test_list_notetakers_with_order_params(self, http_client_list_response):
+ notetakers = Notetakers(http_client_list_response)
+
+ notetakers.list(
+ identifier="abc-123",
+ query_params={
+ "order_by": NotetakerOrderBy.NAME,
+ "order_direction": NotetakerOrderDirection.DESC,
+ },
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/notetakers",
+ None,
+ {"order_by": "name", "order_direction": "desc"},
+ None,
+ overrides=None,
+ )
+
+ def test_list_notetakers_with_default_order(self, http_client_list_response):
+ notetakers = Notetakers(http_client_list_response)
+
+ notetakers.list(identifier="abc-123")
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/notetakers",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
diff --git a/tests/resources/test_policies.py b/tests/resources/test_policies.py
new file mode 100644
index 00000000..bd45171e
--- /dev/null
+++ b/tests/resources/test_policies.py
@@ -0,0 +1,238 @@
+from nylas.models.policies import Policy
+from nylas.resources.policies import Policies
+
+
+class TestPolicies:
+ def test_policy_deserialization(self, http_client):
+ policy_json = {
+ "id": "policy-123",
+ "name": "Standard Agent Account Policy",
+ "application_id": "app-123",
+ "organization_id": "org-123",
+ "options": {
+ "additional_folders": ["processed", "spam-review"],
+ "use_cidr_aliasing": True,
+ },
+ "limits": {
+ "limit_attachment_size_limit": 26214400,
+ "limit_attachment_count_limit": 50,
+ "limit_attachment_allowed_types": ["image/png", "application/pdf"],
+ "limit_size_total_mime": 52428800,
+ "limit_storage_total": 1073741824,
+ "limit_count_daily_message_per_grant": 1000,
+ "limit_inbox_retention_period": 365,
+ "limit_spam_retention_period": 30,
+ },
+ "rules": ["rule-1", "rule-2"],
+ "spam_detection": {
+ "use_list_dnsbl": True,
+ "use_header_anomaly_detection": True,
+ "spam_sensitivity": 1.5,
+ },
+ "created_at": 1712450952,
+ "updated_at": 1712450952,
+ }
+
+ policy = Policy.from_dict(policy_json)
+
+ assert policy.id == "policy-123"
+ assert policy.name == "Standard Agent Account Policy"
+ assert policy.application_id == "app-123"
+ assert policy.organization_id == "org-123"
+ assert policy.options is not None
+ assert policy.options.additional_folders == ["processed", "spam-review"]
+ assert policy.options.use_cidr_aliasing is True
+ assert policy.limits is not None
+ assert policy.limits.limit_attachment_size_limit == 26214400
+ assert policy.limits.limit_attachment_count_limit == 50
+ assert policy.limits.limit_attachment_allowed_types == [
+ "image/png",
+ "application/pdf",
+ ]
+ assert policy.limits.limit_size_total_mime == 52428800
+ assert policy.limits.limit_storage_total == 1073741824
+ assert policy.limits.limit_count_daily_message_per_grant == 1000
+ assert policy.limits.limit_inbox_retention_period == 365
+ assert policy.limits.limit_spam_retention_period == 30
+ assert policy.rules == ["rule-1", "rule-2"]
+ assert policy.spam_detection is not None
+ assert policy.spam_detection.use_list_dnsbl is True
+ assert policy.spam_detection.use_header_anomaly_detection is True
+ assert policy.spam_detection.spam_sensitivity == 1.5
+ assert policy.created_at == 1712450952
+ assert policy.updated_at == 1712450952
+
+ def test_policy_deserialization_with_minimal_fields(self, http_client):
+ policy_json = {
+ "id": "policy-123",
+ "name": "Minimal Policy",
+ "application_id": "app-123",
+ "organization_id": "org-123",
+ }
+
+ policy = Policy.from_dict(policy_json, infer_missing=True)
+
+ assert policy.id == "policy-123"
+ assert policy.name == "Minimal Policy"
+ assert policy.application_id == "app-123"
+ assert policy.organization_id == "org-123"
+ assert policy.options is None
+ assert policy.limits is None
+ assert policy.rules is None
+ assert policy.spam_detection is None
+ assert policy.created_at is None
+ assert policy.updated_at is None
+
+ def test_list_policies(self, http_client_list_response):
+ policies = Policies(http_client_list_response)
+
+ policies.list()
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/policies", None, None, None, overrides=None
+ )
+
+ def test_list_policies_with_query_params(self, http_client_list_response):
+ policies = Policies(http_client_list_response)
+
+ policies.list(query_params={"limit": 10, "page_token": "next-page-token"})
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/policies",
+ None,
+ {"limit": 10, "page_token": "next-page-token"},
+ None,
+ overrides=None,
+ )
+
+ def test_list_policies_with_overrides(self, http_client_list_response):
+ policies = Policies(http_client_list_response)
+ overrides = {"headers": {"X-Test": "value"}, "timeout": 42}
+
+ policies.list(overrides=overrides)
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/policies",
+ None,
+ None,
+ None,
+ overrides=overrides,
+ )
+
+ def test_create_policy(self, http_client_response):
+ policies = Policies(http_client_response)
+ request_body = {
+ "name": "Standard Agent Account Policy",
+ "spam_detection": {
+ "use_list_dnsbl": True,
+ "use_header_anomaly_detection": True,
+ "spam_sensitivity": 1.5,
+ },
+ "limits": {
+ "limit_attachment_size_limit": 26214400,
+ "limit_attachment_count_limit": 50,
+ "limit_inbox_retention_period": 365,
+ "limit_spam_retention_period": 30,
+ },
+ }
+
+ policies.create(request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST", "/v3/policies", None, None, request_body, overrides=None
+ )
+
+ def test_create_policy_with_overrides(self, http_client_response):
+ policies = Policies(http_client_response)
+ request_body = {"name": "Standard Agent Account Policy"}
+ overrides = {"headers": {"X-Test": "value"}}
+
+ policies.create(request_body, overrides=overrides)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/policies",
+ None,
+ None,
+ request_body,
+ overrides=overrides,
+ )
+
+ def test_find_policy(self, http_client_response):
+ policies = Policies(http_client_response)
+
+ policies.find("policy-123")
+
+ http_client_response._execute.assert_called_once_with(
+ "GET", "/v3/policies/policy-123", None, None, None, overrides=None
+ )
+
+ def test_find_policy_with_overrides(self, http_client_response):
+ policies = Policies(http_client_response)
+ overrides = {"headers": {"X-Test": "value"}}
+
+ policies.find("policy-123", overrides=overrides)
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/policies/policy-123",
+ None,
+ None,
+ None,
+ overrides=overrides,
+ )
+
+ def test_update_policy(self, http_client_response):
+ policies = Policies(http_client_response)
+ request_body = {
+ "name": "Updated Agent Policy",
+ "rules": ["rule-1", "rule-2"],
+ }
+
+ policies.update("policy-123", request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT", "/v3/policies/policy-123", None, None, request_body, overrides=None
+ )
+
+ def test_update_policy_with_overrides(self, http_client_response):
+ policies = Policies(http_client_response)
+ request_body = {"rules": ["rule-1"]}
+ overrides = {"headers": {"X-Test": "value"}}
+
+ policies.update("policy-123", request_body, overrides=overrides)
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/policies/policy-123",
+ None,
+ None,
+ request_body,
+ overrides=overrides,
+ )
+
+ def test_destroy_policy(self, http_client_delete_response):
+ policies = Policies(http_client_delete_response)
+
+ policies.destroy("policy-123")
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE", "/v3/policies/policy-123", None, None, None, overrides=None
+ )
+
+ def test_destroy_policy_with_overrides(self, http_client_delete_response):
+ policies = Policies(http_client_delete_response)
+ overrides = {"headers": {"X-Test": "value"}}
+
+ policies.destroy("policy-123", overrides=overrides)
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/policies/policy-123",
+ None,
+ None,
+ None,
+ overrides=overrides,
+ )
diff --git a/tests/resources/test_redirect_uris.py b/tests/resources/test_redirect_uris.py
new file mode 100644
index 00000000..cc8c5ffc
--- /dev/null
+++ b/tests/resources/test_redirect_uris.py
@@ -0,0 +1,124 @@
+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, overrides=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,
+ overrides=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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=None,
+ )
diff --git a/tests/resources/test_rules.py b/tests/resources/test_rules.py
new file mode 100644
index 00000000..c881cac2
--- /dev/null
+++ b/tests/resources/test_rules.py
@@ -0,0 +1,306 @@
+from nylas.models.rules import Rule, RuleEvaluation
+from nylas.resources.rules import Rules
+
+
+class TestRules:
+ def test_rule_deserialization(self, http_client):
+ rule_json = {
+ "id": "rule-123",
+ "name": "Block spam senders",
+ "description": "Marks mail from spam-domain.com as spam",
+ "priority": 1,
+ "enabled": True,
+ "trigger": "inbound",
+ "match": {
+ "operator": "any",
+ "conditions": [
+ {"field": "from.domain", "operator": "is", "value": "spam-domain.com"}
+ ],
+ },
+ "actions": [{"type": "mark_as_spam"}],
+ "application_id": "app-123",
+ "organization_id": "org-123",
+ "created_at": 1712450952,
+ "updated_at": 1712450952,
+ }
+
+ rule = Rule.from_dict(rule_json)
+
+ assert rule.id == "rule-123"
+ assert rule.name == "Block spam senders"
+ assert rule.description == "Marks mail from spam-domain.com as spam"
+ assert rule.priority == 1
+ assert rule.enabled is True
+ assert rule.trigger == "inbound"
+ assert rule.match is not None
+ assert rule.match.operator == "any"
+ assert rule.match.conditions is not None
+ assert rule.match.conditions[0].field == "from.domain"
+ assert rule.match.conditions[0].operator == "is"
+ assert rule.match.conditions[0].value == "spam-domain.com"
+ assert rule.actions is not None
+ assert rule.actions[0].type == "mark_as_spam"
+ assert rule.actions[0].value is None
+ assert rule.application_id == "app-123"
+ assert rule.organization_id == "org-123"
+ assert rule.created_at == 1712450952
+ assert rule.updated_at == 1712450952
+
+ def test_rule_deserialization_with_minimal_fields(self, http_client):
+ rule_json = {
+ "id": "rule-123",
+ "name": "Minimal rule",
+ }
+
+ rule = Rule.from_dict(rule_json, infer_missing=True)
+
+ assert rule.id == "rule-123"
+ assert rule.name == "Minimal rule"
+ assert rule.description is None
+ assert rule.match is None
+ assert rule.actions is None
+ assert rule.created_at is None
+ assert rule.updated_at is None
+
+ def test_rule_evaluation_deserialization(self, http_client):
+ evaluation_json = {
+ "id": "evaluation-123",
+ "grant_id": "grant-123",
+ "message_id": "message-123",
+ "evaluated_at": 1712450952,
+ "evaluation_stage": "inbox_processing",
+ "evaluation_input": {
+ "from_address": "spammer@spam-domain.com",
+ "from_domain": "spam-domain.com",
+ "from_tld": "com",
+ },
+ "applied_actions": {
+ "marked_as_spam": True,
+ "archived": True,
+ "folder_ids": ["spam-folder"],
+ },
+ "matched_rule_ids": ["rule-123"],
+ "application_id": "app-123",
+ "organization_id": "org-123",
+ "created_at": 1712450952,
+ "updated_at": 1712450952,
+ }
+
+ evaluation = RuleEvaluation.from_dict(evaluation_json)
+
+ assert evaluation.id == "evaluation-123"
+ assert evaluation.grant_id == "grant-123"
+ assert evaluation.message_id == "message-123"
+ assert evaluation.evaluated_at == 1712450952
+ assert evaluation.evaluation_stage == "inbox_processing"
+ assert evaluation.evaluation_input is not None
+ assert evaluation.evaluation_input.from_address == "spammer@spam-domain.com"
+ assert evaluation.applied_actions is not None
+ assert evaluation.applied_actions.marked_as_spam is True
+ assert evaluation.applied_actions.archived is True
+ assert evaluation.applied_actions.folder_ids == ["spam-folder"]
+ assert evaluation.matched_rule_ids == ["rule-123"]
+ assert evaluation.application_id == "app-123"
+ assert evaluation.organization_id == "org-123"
+
+ def test_rule_evaluation_deserialization_with_minimal_fields(self, http_client):
+ evaluation_json = {
+ "id": "evaluation-123",
+ "grant_id": "grant-123",
+ }
+
+ evaluation = RuleEvaluation.from_dict(evaluation_json, infer_missing=True)
+
+ assert evaluation.id == "evaluation-123"
+ assert evaluation.grant_id == "grant-123"
+ assert evaluation.message_id is None
+ assert evaluation.evaluation_input is None
+ assert evaluation.applied_actions is None
+ assert evaluation.matched_rule_ids is None
+
+ def test_list_rules(self, http_client_list_response):
+ rules = Rules(http_client_list_response)
+
+ rules.list()
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/rules", None, None, None, overrides=None
+ )
+
+ def test_list_rules_with_query_params(self, http_client_list_response):
+ rules = Rules(http_client_list_response)
+
+ rules.list(query_params={"limit": 10, "page_token": "next-page-token"})
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/rules",
+ None,
+ {"limit": 10, "page_token": "next-page-token"},
+ None,
+ overrides=None,
+ )
+
+ def test_create_rule(self, http_client_response):
+ rules = Rules(http_client_response)
+ request_body = {
+ "name": "Block spam domains",
+ "priority": 1,
+ "trigger": "inbound",
+ "match": {
+ "operator": "any",
+ "conditions": [
+ {"field": "from.domain", "operator": "is", "value": "spam-domain.com"}
+ ],
+ },
+ "actions": [{"type": "block"}],
+ }
+
+ rules.create(request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST", "/v3/rules", None, None, request_body, overrides=None
+ )
+
+ def test_create_rule_with_overrides(self, http_client_response):
+ rules = Rules(http_client_response)
+ request_body = {
+ "name": "Block spam domains",
+ "match": {
+ "conditions": [
+ {"field": "from.domain", "operator": "is", "value": "spam-domain.com"}
+ ],
+ },
+ "actions": [{"type": "block"}],
+ }
+ overrides = {"headers": {"X-Test": "value"}}
+
+ rules.create(request_body, overrides=overrides)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/rules",
+ None,
+ None,
+ request_body,
+ overrides=overrides,
+ )
+
+ def test_find_rule(self, http_client_response):
+ rules = Rules(http_client_response)
+
+ rules.find("rule-123")
+
+ http_client_response._execute.assert_called_once_with(
+ "GET", "/v3/rules/rule-123", None, None, None, overrides=None
+ )
+
+ def test_find_rule_with_overrides(self, http_client_response):
+ rules = Rules(http_client_response)
+ overrides = {"headers": {"X-Test": "value"}}
+
+ rules.find("rule-123", overrides=overrides)
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/rules/rule-123",
+ None,
+ None,
+ None,
+ overrides=overrides,
+ )
+
+ def test_update_rule(self, http_client_response):
+ rules = Rules(http_client_response)
+ request_body = {
+ "enabled": False,
+ "actions": [{"type": "archive"}],
+ }
+
+ rules.update("rule-123", request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT", "/v3/rules/rule-123", None, None, request_body, overrides=None
+ )
+
+ def test_update_rule_with_overrides(self, http_client_response):
+ rules = Rules(http_client_response)
+ request_body = {
+ "enabled": False,
+ }
+ overrides = {"headers": {"X-Test": "value"}, "timeout": 42}
+
+ rules.update("rule-123", request_body, overrides=overrides)
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/rules/rule-123",
+ None,
+ None,
+ request_body,
+ overrides=overrides,
+ )
+
+ def test_destroy_rule(self, http_client_delete_response):
+ rules = Rules(http_client_delete_response)
+
+ rules.destroy("rule-123")
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE", "/v3/rules/rule-123", None, None, None, overrides=None
+ )
+
+ def test_destroy_rule_with_overrides(self, http_client_delete_response):
+ rules = Rules(http_client_delete_response)
+ overrides = {"headers": {"X-Test": "value"}}
+
+ rules.destroy("rule-123", overrides=overrides)
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/rules/rule-123",
+ None,
+ None,
+ None,
+ overrides=overrides,
+ )
+
+ def test_list_rule_evaluations(self, http_client_list_response):
+ rules = Rules(http_client_list_response)
+
+ rules.list_evaluations("grant-123")
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/grants/grant-123/rule-evaluations", None, None, None, overrides=None
+ )
+
+ def test_list_rule_evaluations_with_query_params(self, http_client_list_response):
+ rules = Rules(http_client_list_response)
+
+ rules.list_evaluations(
+ "grant-123", query_params={"limit": 5, "page_token": "cursor-token"}
+ )
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/grant-123/rule-evaluations",
+ None,
+ {"limit": 5, "page_token": "cursor-token"},
+ None,
+ overrides=None,
+ )
+
+ def test_list_rule_evaluations_with_overrides(self, http_client_list_response):
+ rules = Rules(http_client_list_response)
+ overrides = {"headers": {"X-Test": "value"}}
+
+ rules.list_evaluations("grant-123", overrides=overrides)
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/grant-123/rule-evaluations",
+ None,
+ None,
+ None,
+ overrides=overrides,
+ )
diff --git a/tests/resources/test_sessions.py b/tests/resources/test_sessions.py
new file mode 100644
index 00000000..5d4590db
--- /dev/null
+++ b/tests/resources/test_sessions.py
@@ -0,0 +1,45 @@
+from nylas.resources.scheduler import Sessions
+
+from nylas.models.scheduler import Session
+
+class TestSession:
+ def test_session_deserialization(self):
+ session_json = {
+ "session_id": "session-id",
+ }
+
+ session = Session.from_dict(session_json)
+
+ assert session.session_id == "session-id"
+
+ def test_create_session(self, http_client_response):
+ sessions = Sessions(http_client_response)
+ request_body = {
+ "configuration_id": "configuration-123",
+ "time_to_live": 30
+ }
+
+ sessions.create(request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ "POST",
+ "/v3/scheduling/sessions",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_destroy_session(self, http_client_delete_response):
+ sessions = Sessions(http_client_delete_response)
+
+ sessions.destroy(session_id="session-123")
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/scheduling/sessions/session-123",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
\ No newline at end of file
diff --git a/tests/resources/test_smart_compose.py b/tests/resources/test_smart_compose.py
new file mode 100644
index 00000000..798689fe
--- /dev/null
+++ b/tests/resources/test_smart_compose.py
@@ -0,0 +1,37 @@
+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,
+ overrides=None,
+ )
+
+ 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,
+ overrides=None,
+ )
diff --git a/tests/resources/test_threads.py b/tests/resources/test_threads.py
new file mode 100644
index 00000000..14c5eb26
--- /dev/null
+++ b/tests/resources/test_threads.py
@@ -0,0 +1,381 @@
+from nylas.models.attachments import Attachment
+from nylas.models.events import EmailName
+from nylas.models.response import ListResponse
+from nylas.resources.threads import Threads
+from nylas.models.threads import Thread
+
+
+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, overrides=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,
+ overrides=None,
+ )
+
+ def test_list_threads_with_select_param(self, http_client_list_response):
+ threads = Threads(http_client_list_response)
+
+ # Set up mock response data
+ http_client_list_response._execute.return_value = {
+ "request_id": "abc-123",
+ "data": [{
+ "id": "thread-123",
+ "has_attachments": False,
+ "earliest_message_date": 1634149514,
+ "participants": [
+ {"email": "test@example.com", "name": "Test User"}
+ ],
+ "snippet": "Test snippet",
+ "unread": False,
+ "subject": "Test subject",
+ "message_ids": ["msg-123"],
+ "folders": ["folder-123"]
+ }]
+ }
+
+ # Call the API method
+ result = threads.list(
+ identifier="abc-123",
+ query_params={
+ "select": "id,has_attachments,earliest_message_date,participants,snippet,unread,subject,message_ids,folders"
+ }
+ )
+
+ # Verify API call
+ http_client_list_response._execute.assert_called_with(
+ "GET",
+ "/v3/grants/abc-123/threads",
+ None,
+ {"select": "id,has_attachments,earliest_message_date,participants,snippet,unread,subject,message_ids,folders"},
+ None,
+ overrides=None,
+ )
+
+ # The actual response validation is handled by the mock in conftest.py
+ assert result is not None
+
+ def test_list_threads_with_earliest_message_date_param(self, http_client_list_response):
+ threads = Threads(http_client_list_response)
+
+ timestamp = 1672531200
+
+ http_client_list_response._execute.return_value = {
+ "request_id": "abc-123",
+ "data": [{
+ "id": "thread-123",
+ "has_attachments": False,
+ "earliest_message_date": 1672617600,
+ "participants": [
+ {"email": "test@example.com", "name": "Test User"}
+ ],
+ "snippet": "Test snippet",
+ "unread": False,
+ "subject": "Test subject",
+ "message_ids": ["msg-123"],
+ "folders": ["folder-123"]
+ }]
+ }
+
+ result = threads.list(
+ identifier="abc-123",
+ query_params={"earliest_message_date": timestamp}
+ )
+
+ http_client_list_response._execute.assert_called_with(
+ "GET",
+ "/v3/grants/abc-123/threads",
+ None,
+ {"earliest_message_date": timestamp},
+ None,
+ overrides=None,
+ )
+
+ assert result is not None
+
+ def test_list_threads_without_earliest_message_date_in_response(self, http_client_list_response):
+ threads = Threads(http_client_list_response)
+
+ http_client_list_response._execute.return_value = {
+ "request_id": "abc-123",
+ "data": [{
+ "id": "thread-123",
+ "grant_id": "test-grant-id",
+ "has_drafts": False,
+ "starred": False,
+ "unread": False,
+ "message_ids": ["msg-123"],
+ "folders": ["folder-123"],
+ "latest_draft_or_message": {
+ "body": "Test message body",
+ "date": 1672617600,
+ "from": [{"name": "Test User", "email": "test@example.com"}],
+ "grant_id": "test-grant-id",
+ "id": "msg-123",
+ "object": "message",
+ "subject": "Test subject",
+ "thread_id": "thread-123",
+ "to": [{"name": "Recipient", "email": "recipient@example.com"}],
+ "unread": False,
+ },
+ "has_attachments": False,
+ "participants": [
+ {"email": "test@example.com", "name": "Test User"}
+ ],
+ "snippet": "Test snippet",
+ "subject": "Test subject"
+ }]
+ }
+
+ result = threads.list(identifier="abc-123")
+
+ http_client_list_response._execute.assert_called_with(
+ "GET",
+ "/v3/grants/abc-123/threads",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ assert result is not None
+
+ def test_find_thread(self, http_client_response):
+ threads = Threads(http_client_response)
+
+ 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,
+ overrides=None,
+ )
+
+ def test_find_thread_encoded_id(self, http_client_response):
+ threads = Threads(http_client_response)
+
+ threads.find(
+ identifier="abc-123",
+ thread_id="",
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "GET",
+ "/v3/grants/abc-123/threads/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
+
+ def test_update_thread(self, http_client_response):
+ threads = Threads(http_client_response)
+ request_body = {
+ "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,
+ overrides=None,
+ )
+
+ def test_update_thread_encoded_id(self, http_client_response):
+ threads = Threads(http_client_response)
+ request_body = {
+ "starred": True,
+ "unread": False,
+ "folders": ["folder-123"],
+ }
+
+ threads.update(
+ identifier="abc-123",
+ thread_id="",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ "PUT",
+ "/v3/grants/abc-123/threads/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ request_body,
+ overrides=None,
+ )
+
+ def test_destroy_thread(self, http_client_delete_response):
+ threads = Threads(http_client_delete_response)
+
+ 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,
+ overrides=None,
+ )
+
+ def test_destroy_thread_encode_id(self, http_client_delete_response):
+ threads = Threads(http_client_delete_response)
+
+ threads.destroy(
+ identifier="abc-123",
+ thread_id="",
+ )
+
+ http_client_delete_response._execute.assert_called_once_with(
+ "DELETE",
+ "/v3/grants/abc-123/threads/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E",
+ None,
+ None,
+ None,
+ overrides=None,
+ )
diff --git a/tests/resources/test_transactional_send.py b/tests/resources/test_transactional_send.py
new file mode 100644
index 00000000..a4843dab
--- /dev/null
+++ b/tests/resources/test_transactional_send.py
@@ -0,0 +1,125 @@
+from unittest.mock import Mock, patch
+
+from nylas.resources.transactional_send import TransactionalSend
+
+
+class TestTransactionalSend:
+ def test_send_transactional_message(self, http_client_response):
+ transactional_send = TransactionalSend(http_client_response)
+ request_body = {
+ "subject": "Welcome",
+ "to": [{"name": "Jane Doe", "email": "jane.doe@example.com"}],
+ "from_": {"name": "ACME Support", "email": "support@acme.com"},
+ "body": "Welcome to ACME.",
+ }
+
+ transactional_send.send(domain_name="mail.acme.com", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/domains/mail.acme.com/messages/send",
+ request_body={
+ "subject": "Welcome",
+ "to": [{"name": "Jane Doe", "email": "jane.doe@example.com"}],
+ "from": {"name": "ACME Support", "email": "support@acme.com"},
+ "body": "Welcome to ACME.",
+ },
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_domain_name_url_encoded(self, http_client_response):
+ transactional_send = TransactionalSend(http_client_response)
+ request_body = {
+ "to": [{"email": "a@b.com"}],
+ "from_": {"email": "support@acme.com"},
+ }
+
+ transactional_send.send(
+ domain_name="weird/slash.com",
+ request_body=request_body,
+ )
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/domains/weird%2Fslash.com/messages/send",
+ request_body={
+ "to": [{"email": "a@b.com"}],
+ "from": {"email": "support@acme.com"},
+ },
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_small_attachment(self, http_client_response):
+ transactional_send = TransactionalSend(http_client_response)
+ request_body = {
+ "to": [{"email": "j@example.com"}],
+ "from_": {"email": "support@acme.com"},
+ "attachments": [
+ {
+ "filename": "file1.txt",
+ "content_type": "text/plain",
+ "content": "this is a file",
+ "size": 3,
+ },
+ ],
+ }
+
+ transactional_send.send(domain_name="acme.com", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/domains/acme.com/messages/send",
+ request_body=request_body,
+ data=None,
+ overrides=None,
+ )
+
+ def test_send_large_attachment(self, http_client_response):
+ transactional_send = TransactionalSend(http_client_response)
+ mock_encoder = Mock()
+ request_body = {
+ "to": [{"email": "j@example.com"}],
+ "from_": {"email": "support@acme.com"},
+ "attachments": [
+ {
+ "filename": "file1.txt",
+ "content_type": "text/plain",
+ "content": "this is a file",
+ "size": 3 * 1024 * 1024,
+ },
+ ],
+ }
+
+ with patch(
+ "nylas.resources.transactional_send._build_form_request",
+ return_value=mock_encoder,
+ ):
+ transactional_send.send(domain_name="acme.com", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/domains/acme.com/messages/send",
+ request_body=None,
+ data=mock_encoder,
+ overrides=None,
+ )
+
+ def test_send_with_existing_from_field_unchanged(self, http_client_response):
+ transactional_send = TransactionalSend(http_client_response)
+ request_body = {
+ "to": [{"email": "j@example.com"}],
+ "from": {"email": "direct@acme.com"},
+ "from_": {"email": "ignored@acme.com"},
+ }
+
+ transactional_send.send(domain_name="acme.com", request_body=request_body)
+
+ http_client_response._execute.assert_called_once_with(
+ method="POST",
+ path="/v3/domains/acme.com/messages/send",
+ request_body=request_body,
+ data=None,
+ overrides=None,
+ )
diff --git a/tests/resources/test_webhooks.py b/tests/resources/test_webhooks.py
new file mode 100644
index 00000000..159a94d7
--- /dev/null
+++ b/tests/resources/test_webhooks.py
@@ -0,0 +1,196 @@
+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_webhook_deserialization_all(self, http_client):
+ trigger_types = [
+ "booking.created",
+ "booking.pending",
+ "booking.rescheduled",
+ "booking.cancelled",
+ "booking.reminder",
+ "calendar.created",
+ "calendar.updated",
+ "calendar.deleted",
+ "contact.updated",
+ "contact.deleted",
+ "event.created",
+ "event.updated",
+ "event.deleted",
+ "grant.created",
+ "grant.updated",
+ "grant.deleted",
+ "grant.expired",
+ "message.send_success",
+ "message.send_failed",
+ "message.bounce_detected",
+ "message.created",
+ "message.updated",
+ "message.deleted",
+ "message.opened",
+ "message.link_clicked",
+ "message.opened.legacy",
+ "message.link_clicked.legacy",
+ "message.intelligence.order",
+ "message.intelligence.tracking",
+ "message.intelligence.return",
+ "thread.replied",
+ "thread.replied.legacy",
+ "folder.created",
+ "folder.updated",
+ "folder.deleted"
+ ]
+
+ webhook_json = {
+ "id": "UMWjAjMeWQ4D8gYF2moonK4486",
+ "description": "Production webhook destination",
+ "trigger_types": trigger_types,
+ "webhook_url": "https://example.com/webhooks",
+ "status": "active",
+ "notification_email_addresses": ["jane@example.com", "joe@example.com"],
+ "status_updated_at": 1234567890,
+ "created_at": 1234567890,
+ "updated_at": 1234567890,
+ }
+
+ webhook = Webhook.from_dict(webhook_json)
+
+ assert webhook.id == "UMWjAjMeWQ4D8gYF2moonK4486"
+ assert webhook.description == "Production webhook destination"
+ assert webhook.trigger_types == trigger_types
+ assert webhook.webhook_url == "https://example.com/webhooks"
+ assert webhook.status == "active"
+ assert webhook.notification_email_addresses == [
+ "jane@example.com",
+ "joe@example.com",
+ ]
+ assert webhook.status_updated_at == 1234567890
+ assert webhook.created_at == 1234567890
+ assert webhook.updated_at == 1234567890
+
+ def test_list_webhooks(self, http_client_list_response):
+ webhooks = Webhooks(http_client_list_response)
+
+ webhooks.list()
+
+ http_client_list_response._execute.assert_called_once_with(
+ "GET", "/v3/webhooks", None, None, None, overrides=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, overrides=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, overrides=None
+ )
+
+ 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, overrides=None
+ )
+
+ 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, overrides=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={},
+ overrides=None,
+ )
+
+ 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", overrides=None
+ )
+
+ 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/system.py b/tests/system.py
deleted file mode 100644
index 3caf3f81..00000000
--- a/tests/system.py
+++ /dev/null
@@ -1,56 +0,0 @@
-# -*- coding: utf-8 -*-
-import json
-import re
-import pytest
-import time
-import datetime
-import sys
-from nylas import APIClient
-from nylas.client.restful_models import Label, Folder
-from nylas.client.errors import *
-from credentials import APP_ID, APP_SECRET, AUTH_TOKEN
-
-client = APIClient(APP_ID, APP_SECRET, AUTH_TOKEN)
-
-count = 0
-
-print "Listing accounts"
-for account in client.accounts:
- print (account.email_address, account.provider)
-
-print 'Marking the first thread as unread'
-th = client.threads.where({'in': 'inbox'}).first()
-print th.subject
-th.mark_as_unread()
-
-print "Displaying 10 thread subjects"
-for thread in client.threads.items():
- print thread.subject
- count += 1
- if count == 10:
- break
-
-print "Sending an email"
-draft = client.drafts.create()
-draft.to = [{'name': 'Python SDK test', 'email': 'karim@nylas.com'}]
-draft.subject = "Python SDK test"
-draft.body = "Stay polish, stay hungary"
-draft.send()
-
-print 'Creating an event'
-calendar = filter(lambda cal: not cal.read_only, client.calendars)[0]
-ev = client.events.create()
-ev.title = "Party at the Ritz"
-
-d1 = datetime.datetime.now() + datetime.timedelta(days=5,hours=4)
-d2 = datetime.datetime.now() + datetime.timedelta(days=5,hours=5)
-
-ev.when = {"start_time": time.mktime(d1.timetuple()), "end_time": time.mktime(d2.timetuple())}
-ev.location = "The Old Ritz"
-ev.participants = [{'name': 'Karim Hamidou', 'email': 'karim@nylas.com'}]
-ev.calendar_id = calendar.id
-ev.save(notify_participants='true')
-
-print 'Listing folders'
-for label in client.labels:
- print label.display_name
diff --git a/tests/test_client.py b/tests/test_client.py
new file mode 100644
index 00000000..9b81f338
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,119 @@
+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.domains import Domains
+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.lists import Lists
+from nylas.resources.policies import Policies
+from nylas.resources.rules import Rules
+from nylas.resources.threads import Threads
+from nylas.resources.transactional_send import TransactionalSend
+from nylas.resources.webhooks import Webhooks
+
+
+class TestClient:
+ def test_client_init(self):
+ client = Client(
+ api_key="test-key",
+ api_uri="https://test.nylas.com",
+ timeout=60,
+ )
+
+ assert client.api_key == "test-key"
+ assert client.api_uri == "https://test.nylas.com"
+ assert client.http_client.timeout == 60
+
+ def test_client_init_defaults(self):
+ client = Client(
+ api_key="test-key",
+ )
+
+ assert client.api_key == "test-key"
+ assert client.api_uri == "https://api.us.nylas.com"
+ assert client.http_client.timeout == 90
+
+ def test_client_auth_property(self, client):
+ assert client.auth is not None
+ assert type(client.auth) is Auth
+
+ def test_client_applications_property(self, client):
+ assert client.applications is not None
+ assert type(client.applications) is Applications
+
+ def test_client_attachments_property(self, client):
+ assert client.attachments is not None
+ assert type(client.attachments) is Attachments
+
+ 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_client_connectors_property(self, client):
+ assert client.connectors is not None
+ assert type(client.connectors) is Connectors
+
+ def test_client_drafts_property(self, client):
+ assert client.drafts is not None
+ assert type(client.drafts) is Drafts
+
+ def test_client_domains_property(self, client):
+ assert client.domains is not None
+ assert type(client.domains) is Domains
+
+ def test_client_events_property(self, client):
+ assert client.events is not None
+ assert type(client.events) is Events
+
+ 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_client_policies_property(self, client):
+ assert client.policies is not None
+ assert type(client.policies) is Policies
+
+ def test_client_messages_property(self, client):
+ assert client.messages is not None
+ assert type(client.messages) is Messages
+
+ def test_client_lists_property(self, client):
+ assert client.lists is not None
+ assert type(client.lists) is Lists
+
+ def test_client_rules_property(self, client):
+ assert client.rules is not None
+ assert type(client.rules) is Rules
+
+ def test_client_threads_property(self, client):
+ assert client.threads is not None
+ assert type(client.threads) is Threads
+
+ def test_client_transactional_send_property(self, client):
+ assert client.transactional_send is not None
+ assert type(client.transactional_send) is TransactionalSend
+
+ def test_client_webhooks_property(self, client):
+ assert client.webhooks is not None
+ assert type(client.webhooks) is Webhooks
+
+ def test_scheduler(self, client):
+ assert client.scheduler is not None
+
+ def test_notetakers(self, client):
+ assert client.notetakers is not None
diff --git a/tests/test_drafts.py b/tests/test_drafts.py
deleted file mode 100644
index ed9f85e9..00000000
--- a/tests/test_drafts.py
+++ /dev/null
@@ -1,152 +0,0 @@
-import json
-import pytest
-import responses
-from conftest import API_URL
-from nylas.client.errors import InvalidRequestError
-
-
-@pytest.fixture
-def mock_draft_saved_response():
- response_body = json.dumps(
- {
- "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
- })
-
- responses.add(responses.POST, API_URL + '/drafts/',
- content_type='application/json', status=200,
- body=response_body, match_querystring=True)
-
-
-@pytest.fixture
-def mock_draft_updated_response():
- body = {
- "bcc": [],
- "body": "",
- "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": "Stay polish, stay hungary",
- "thread_id": "clm33kapdxkposgltof845v9s",
- "to": [
- {
- "email": "helena@nylas.com",
- "name": "Helena Handbasket"
- }
- ],
- "unread": False,
- "version": 0
- }
-
- responses.add(responses.PUT, API_URL + '/drafts/2h111aefv8pzwzfykrn7hercj',
- content_type='application/json', status=200,
- body=json.dumps(body), match_querystring=True)
-
- body['subject'] = 'Update #2'
- responses.add(responses.PUT, API_URL + '/drafts/2h111aefv8pzwzfykrn7hercj?random_query=true¶m2=param',
- content_type='application/json', status=200,
- body=json.dumps(body), match_querystring=True)
-
-
-@pytest.fixture
-def mock_draft_sent_response():
- 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'
- assert payload['version'] == 0
- return values.pop()
-
- responses.add_callback(
- responses.POST, API_URL + '/send/',
- callback=callback,
- content_type='application/json')
-
-
-@responses.activate
-def test_save_send_draft(api_client, mock_draft_saved_response,
- mock_draft_updated_response, mock_draft_sent_response):
- 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(random_query='true', param2='param')
- assert draft.subject == 'Update #2'
-
- msg = draft.send()
- assert msg['thread_id'] == 'clm33kapdxkposgltof845v9s'
-
- # Second time should throw an error
- raised = False
- try:
- draft.send()
- except InvalidRequestError:
- raised = True
-
- assert raised is True
diff --git a/tests/test_events.py b/tests/test_events.py
deleted file mode 100644
index 9e3cdfec..00000000
--- a/tests/test_events.py
+++ /dev/null
@@ -1,96 +0,0 @@
-import json
-import pytest
-import responses
-import httpretty
-from httpretty import Response
-from conftest import API_URL
-from nylas.client.errors import InvalidRequestError
-
-
-url = API_URL + '/events/'
-body = {
- "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 mock_event_create_response():
- values = [Response(status=200, body=json.dumps(body)),
- Response(status=400, body='')]
-
- httpretty.register_uri(httpretty.POST, API_URL + '/events/', responses=values)
- put_values = [Response(status=200,
- body=json.dumps({'title': 'loaded from JSON',
- 'ignored': 'ignored'}))]
- httpretty.register_uri(httpretty.PUT, API_URL + '/events/cv4ei7syx10uvsxbs21ccsezf',
- responses=put_values)
-
-
-@pytest.fixture
-def mock_event_create_notify_response():
- httpretty.register_uri(httpretty.POST, API_URL + '/events/?notify_participants=true&other_param=1',
- body=json.dumps(body), status=200)
-
-
-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
-
-
-def test_event_crud(api_client, mock_event_create_response):
- httpretty.enable()
-
- e1 = blank_event(api_client)
- e1.save()
- assert e1.id == 'cv4ei7syx10uvsxbs21ccsezf'
-
- e1.title = 'blah'
- e1.save()
- assert e1.title == 'loaded from JSON'
- assert e1.get('ignored') is None
-
- # Third time should fail.
- e2 = blank_event(api_client)
- raised = False
- try:
- e2.save()
- except InvalidRequestError:
- raised = True
-
- assert raised is True
-
- httpretty.disable()
-
-
-def test_event_notify(api_client, mock_event_create_notify_response):
- httpretty.enable()
-
- e1 = blank_event(api_client)
- e1.save(notify_participants='true', other_param='1')
- assert e1.id == 'cv4ei7syx10uvsxbs21ccsezf'
-
- qs = httpretty.last_request().querystring
- assert qs['notify_participants'][0] == 'true'
- assert qs['other_param'][0] == '1'
-
- httpretty.disable()
diff --git a/tests/test_files.py b/tests/test_files.py
deleted file mode 100644
index 46f75db8..00000000
--- a/tests/test_files.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import json
-import pytest
-import responses
-import httpretty
-from httpretty import Response
-from conftest import API_URL
-from nylas.client.errors import InvalidRequestError, FileUploadError
-
-
-def test_file_upload(api_client):
- httpretty.enable()
- body = [{
- "content_type": "text/plain",
- "filename": "a.txt",
- "id": "3qfe4k3siosfjtjpfdnon8zbn",
- "account_id": "6aakaxzi4j5gn6f7kbb9e0fxs",
- "object": "file",
- "size": 762878
- }]
-
- values = [Response(status=200, body=json.dumps(body))]
- httpretty.register_uri(httpretty.POST, API_URL + '/files/', responses=values)
- httpretty.register_uri(httpretty.GET, API_URL + '/files/3qfe4k3siosfjtjpfdnon8zbn/download',
- body='test body')
-
- myfile = api_client.files.create()
- myfile.filename = 'test.txt'
- myfile.data = "Hello World."
- myfile.save()
-
- assert myfile.filename == 'a.txt'
- assert myfile.size == 762878
-
- data = myfile.download().decode()
- assert data == 'test body'
-
-
-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 exc.value.message == ("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 2fb9e244..00000000
--- a/tests/test_filter.py
+++ /dev/null
@@ -1,104 +0,0 @@
-import json
-import pytest
-import random
-import responses
-import httpretty
-from httpretty import Response
-from conftest import API_URL
-from nylas.client.errors import InvalidRequestError
-
-
-url = API_URL + '/events/'
-default_body = {
- "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
- }
-}
-
-body = [default_body for i in range(1, 51)]
-body2 = [default_body for i in range(1, 23)]
-
-
-def test_no_filter(api_client):
- httpretty.enable()
-
- # httpretty kind of sucks and strips & parameters from the URL
- values = [Response(status=200, body=json.dumps(body)),
- Response(status=200, body=json.dumps(body2))]
- httpretty.register_uri(httpretty.GET, API_URL + '/events', responses=values)
-
- events = api_client.events.all()
- assert len(events) == 72
- assert events[0].id == 'cv4ei7syx10uvsxbs21ccsezf'
-
- httpretty.disable()
-
-
-def test_two_filters(api_client):
- httpretty.enable()
-
- values2 = [Response(status=200, body='[]')]
- httpretty.register_uri(httpretty.GET, API_URL + '/events?param1=a¶m2=b', responses=values2)
- events = api_client.events.where(param1='a', param2='b').all()
- assert len(events) == 0
- qs = httpretty.last_request().querystring
- assert qs['param1'][0] == 'a'
- assert qs['param2'][0] == 'b'
- httpretty.disable()
-
-def test_no_offset(api_client):
- httpretty.enable()
-
- values = [Response(status=200, body='[]')]
- httpretty.register_uri(httpretty.GET, API_URL + '/events?in=Nylas', responses=values)
- events = api_client.events.where({'in': 'Nylas'}).items()
- for event in events:
- pass
- qs = httpretty.last_request().querystring
- assert qs['in'][0] == 'Nylas'
- assert qs['offset'][0] == '0'
- httpretty.disable()
-
-def test_zero_offset(api_client):
- httpretty.enable()
-
- values = [Response(status=200, body='[]')]
- httpretty.register_uri(httpretty.GET, API_URL + '/events?in=Nylas&offset=0', responses=values)
- events = api_client.events.where({'in': 'Nylas', 'offset': 0}).items()
- for event in events:
- pass
- qs = httpretty.last_request().querystring
- assert qs['in'][0] == 'Nylas'
- assert qs['offset'][0] == '0'
- httpretty.disable()
-
-def test_non_zero_offset(api_client):
- httpretty.enable()
-
- offset = random.randint(1,1000)
- values = [Response(status=200, body='[]')]
- httpretty.register_uri(httpretty.GET, API_URL + '/events?in=Nylas&offset=' +
- str(offset), responses=values)
- events = api_client.events.where({'in': 'Nylas', 'offset': offset}).items()
- for event in events:
- pass
- qs = httpretty.last_request().querystring
- assert qs['in'][0] == 'Nylas'
- assert qs['offset'][0] == str(offset)
- httpretty.disable()
-
diff --git a/tests/test_folder_labels.py b/tests/test_folder_labels.py
deleted file mode 100644
index 17ddedb0..00000000
--- a/tests/test_folder_labels.py
+++ /dev/null
@@ -1,345 +0,0 @@
-import json
-import re
-import pytest
-import responses
-import httpretty
-from nylas import APIClient
-from nylas.client.restful_models import Label, Folder
-from nylas.client.errors import *
-
-API_URL = 'http://localhost:2222'
-
-MOCK_ACCOUNT_ID = '4ennivvrcgsqytgybfk912dto'
-
-
-@pytest.fixture
-def api_client():
- return APIClient(None, None, None, API_URL)
-
-
-@pytest.fixture
-def mock_account():
- response_body = json.dumps(
- {
- "account_id": MOCK_ACCOUNT_ID,
- "email_address": "ben.bitdiddle1861@gmail.com",
- "id": MOCK_ACCOUNT_ID,
- "name": "Ben Bitdiddle",
- "object": "account",
- "provider": "gmail",
- "organization_unit": "label"
- }
- )
- responses.add(responses.GET, API_URL + '/account',
- content_type='application/json', status=200,
- body=response_body, match_querystring=True)
-
-
-@pytest.fixture
-def mock_folder_account():
- response_body = json.dumps(
- {
- "email_address": "ben.bitdiddle1861@office365.com",
- "id": MOCK_ACCOUNT_ID,
- "name": "Ben Bitdiddle",
- "account_id": MOCK_ACCOUNT_ID,
- "object": "account",
- "provider": "eas",
- "organization_unit": "folder"
- }
- )
- responses.add(responses.GET, API_URL + '/account',
- content_type='application/json', status=200,
- body=response_body, match_querystring=True)
-
-
-@pytest.fixture
-def mock_labels():
- response_body = json.dumps([
- {
- "display_name": "Important",
- "id": "anuep8pe5ugmxrucchrzba2o8",
- "name": "important",
- "account_id": MOCK_ACCOUNT_ID,
- "object": "label"
- },
- {
- "display_name": "Trash",
- "id": "f1xgowbgcehk235xiy3c3ek42",
- "name": "trash",
- "account_id": MOCK_ACCOUNT_ID,
- "object": "label"
- },
- {
- "display_name": "Sent Mail",
- "id": "ah14wp5fvypvjjnplh7nxgb4h",
- "name": "sent",
- "account_id": MOCK_ACCOUNT_ID,
- "object": "label"
- },
- {
- "display_name": "All Mail",
- "id": "ah14wp5fvypvjjnplh7nxgb4h",
- "name": "all",
- "account_id": MOCK_ACCOUNT_ID,
- "object": "label"
- },
- {
- "display_name": "Inbox",
- "id": "dc11kl3s9lj4760g6zb36spms",
- "name": "inbox",
- "account_id": MOCK_ACCOUNT_ID,
- "object": "label"
- }
- ])
- endpoint = re.compile(API_URL + '/labels.*')
- responses.add(responses.GET, endpoint,
- content_type='application/json', status=200,
- body=response_body)
-
-
-@pytest.fixture
-def mock_label():
- response_body = json.dumps(
- {
- "display_name": "Important",
- "id": "anuep8pe5ugmxrucchrzba2o8",
- "name": "important",
- "account_id": MOCK_ACCOUNT_ID,
- "object": "label"
- }
- )
- endpoint = re.compile(API_URL + '/labels/anuep8pe5ugmxrucchrzba2o8')
- responses.add(responses.GET, endpoint,
- content_type='application/json', status=200,
- body=response_body)
-
-
-@pytest.fixture
-def mock_folder():
- folder = {
- "display_name": "My Folder",
- "id": "anuep8pe5ug3xrupchwzba2o8",
- "name": None,
- "account_id": MOCK_ACCOUNT_ID,
- "object": "folder"
- }
- response_body = json.dumps(folder)
- endpoint = re.compile(API_URL + '/folders/anuep8pe5ug3xrupchwzba2o8')
- responses.add(responses.GET, endpoint,
- 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))
- responses.add_callback(responses.PUT, endpoint,
- content_type='application/json',
- callback=request_callback)
-
-
-
-@pytest.fixture
-def mock_messages():
- response_body = json.dumps([
- {
- "id": "1234",
- "subject": "Test Message",
- "account_id": MOCK_ACCOUNT_ID,
- "object": "message",
- "labels": [
- {
- "name": "inbox",
- "display_name": "Inbox",
- "id": "abcd"
- }
- ],
- "starred": False,
- "unread": True
- }
- ])
- endpoint = re.compile(API_URL + '/messages')
- responses.add(responses.GET, endpoint,
- content_type='application/json', status=200,
- body=response_body)
-
-
-@pytest.fixture
-def mock_message():
- base_msg = {
- "id": "1234",
- "subject": "Test Message",
- "account_id": MOCK_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
- return (200, {}, json.dumps(base_msg))
-
- endpoint = re.compile(API_URL + '/messages/1234')
- responses.add(responses.GET, endpoint,
- content_type='application/json', status=200,
- body=response_body)
- responses.add_callback(responses.PUT, endpoint,
- content_type='application/json',
- callback=request_callback)
-
-
-@pytest.fixture
-def mock_threads():
- response_body = json.dumps([
- {
- "id": "5678",
- "subject": "Test Thread",
- "account_id": MOCK_ACCOUNT_ID,
- "object": "thread",
- "folders": [{
- "name": "inbox",
- "display_name": "Inbox",
- "id": "abcd"
- }],
- "starred": True,
- "unread": False
- }
- ])
- endpoint = re.compile(API_URL + '/threads')
- responses.add(responses.GET, endpoint,
- content_type='application/json', status=200,
- body=response_body)
-
-
-@pytest.fixture
-def mock_thread():
- base_thrd = {
- "id": "5678",
- "subject": "Test Thread",
- "account_id": MOCK_ACCOUNT_ID,
- "object": "thread",
- "folders": [{
- "name": "inbox",
- "display_name": "Inbox",
- "id": "abcd"
- }],
- "starred": True,
- "unread": False
- }
- 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')
- responses.add(responses.GET, endpoint,
- content_type='application/json', status=200,
- body=response_body)
- responses.add_callback(responses.PUT, endpoint,
- content_type='application/json',
- callback=request_callback)
-
-
-@responses.activate
-def test_list_labels(api_client, mock_labels):
- labels = api_client.labels
- labels = [l for l in labels]
- assert len(labels) == 5
- assert all(isinstance(x, Label) for x in labels)
-
-
-@responses.activate
-def test_get_label(api_client, mock_label):
- label = api_client.labels.find('anuep8pe5ugmxrucchrzba2o8')
- assert label is not None
- assert isinstance(label, Label)
- assert label.display_name == 'Important'
-
-
-@responses.activate
-def test_get_change_folder(api_client, mock_folder):
- folder = api_client.folders.find('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'
-
-
-@responses.activate
-def test_messages(api_client, mock_messages):
- 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
-
-
-@responses.activate
-def test_message_change(api_client, mock_account, mock_messages,
- mock_message):
- message = api_client.messages.first()
- message.star()
- assert message.starred is True
- message.unstar()
- assert message.starred is False
- message.mark_as_read()
- assert message
-
- 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
-
-
-@responses.activate
-def test_thread_folder(api_client, mock_threads):
- thread = api_client.threads.first()
- assert len(thread.labels) == 0
- assert len(thread.folders) == 1
- assert thread.folders[0].display_name == 'Inbox'
- assert not thread.unread
- assert thread.starred
-
-
-@responses.activate
-def test_thread_change(api_client, mock_folder_account,
- mock_threads, mock_thread):
- thread = api_client.threads.first()
-
- assert thread.starred
- thread.unstar()
- assert not thread.starred
-
- thread.update_folder('qwer')
- assert len(thread.folders) == 1
- assert thread.folders[0].id == 'qwer'
diff --git a/tests/test_response.py b/tests/test_response.py
new file mode 100644
index 00000000..1ae87bb0
--- /dev/null
+++ b/tests/test_response.py
@@ -0,0 +1,34 @@
+from nylas.models.response import ListResponse
+from nylas.models.rules import Rule
+
+
+class TestListResponse:
+ def test_from_dict_with_list_data(self):
+ response = {
+ "request_id": "req-123",
+ "data": [{"id": "rule-1", "name": "Rule One"}],
+ "next_cursor": "cursor-1",
+ }
+
+ parsed = ListResponse.from_dict(response, Rule)
+
+ assert parsed.request_id == "req-123"
+ assert parsed.next_cursor == "cursor-1"
+ assert len(parsed.data) == 1
+ assert parsed.data[0].id == "rule-1"
+
+ def test_from_dict_with_items_wrapper(self):
+ response = {
+ "request_id": "req-456",
+ "data": {
+ "items": [{"id": "rule-2", "name": "Rule Two"}],
+ "next_cursor": "cursor-2",
+ },
+ }
+
+ parsed = ListResponse.from_dict(response, Rule)
+
+ assert parsed.request_id == "req-456"
+ assert parsed.next_cursor == "cursor-2"
+ assert len(parsed.data) == 1
+ assert parsed.data[0].id == "rule-2"
diff --git a/tests/test_send_error_handling.py b/tests/test_send_error_handling.py
deleted file mode 100644
index 1683a6f9..00000000
--- a/tests/test_send_error_handling.py
+++ /dev/null
@@ -1,82 +0,0 @@
-import json
-import re
-import pytest
-import responses
-from nylas import APIClient
-from nylas.client.errors import *
-
-API_URL = 'http://localhost:2222'
-
-
-def mock_sending_error(http_code, message, server_error=None):
- send_endpoint = re.compile(API_URL + '/send')
- response_body = {
- "type": "api_error",
- "message": message
- }
-
- if server_error is not None:
- response_body['server_error'] = server_error
-
- response_body = json.dumps(response_body)
- responses.add(responses.POST, send_endpoint,
- content_type='application/json', status=http_code,
- body=response_body)
-
-
-@responses.activate
-def test_handle_message_rejected(api_client, mock_account, mock_save_draft):
- draft = api_client.drafts.create()
- error_message = 'Sending to all recipients failed'
- mock_sending_error(402, error_message)
- with pytest.raises(MessageRejectedError) as exc:
- draft.send()
- assert exc.value.message == error_message
-
-
-@responses.activate
-def test_handle_quota_exceeded(api_client, mock_account, mock_save_draft):
- draft = api_client.drafts.create()
- error_message = 'Daily sending quota exceeded'
- mock_sending_error(429, error_message)
- with pytest.raises(SendingQuotaExceededError) as exc:
- draft.send()
- assert exc.value.message == error_message
-
-
-@responses.activate
-def test_handle_service_unavailable(api_client, mock_account,
- mock_save_draft):
- draft = api_client.drafts.create()
- error_message = 'The server unexpectedly closed the connection'
- mock_sending_error(503, error_message)
- with pytest.raises(ServiceUnavailableError) as exc:
- draft.send()
- assert exc.value.message == error_message
-
-
-@responses.activate
-def test_returns_server_error(api_client, mock_account,
- mock_save_draft):
- draft = api_client.drafts.create()
- error_message = 'The server unexpectedly closed the connection'
- reason = 'Rejected potential SPAM'
- mock_sending_error(503, error_message,
- server_error=reason)
- with pytest.raises(ServiceUnavailableError) as exc:
- draft.send()
-
- assert exc.value.message == error_message
- assert exc.value.server_error == reason
-
-
-@responses.activate
-def test_doesnt_return_server_error_if_not_defined(api_client, mock_account,
- mock_save_draft):
- draft = api_client.drafts.create()
- error_message = 'The server unexpectedly closed the connection'
- mock_sending_error(503, error_message)
- with pytest.raises(ServiceUnavailableError) as exc:
- draft.send()
- assert exc.value.message == error_message
- assert not hasattr(exc.value, 'server_error')
diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py
new file mode 100644
index 00000000..4394b52f
--- /dev/null
+++ b/tests/utils/test_file_utils.py
@@ -0,0 +1,196 @@
+from unittest.mock import patch, mock_open
+
+from nylas.utils.file_utils import attach_file_request_builder, _build_form_request, encode_stream_to_base64
+
+
+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"
+
+ def test_encode_stream_to_base64(self):
+ """Test that binary streams are properly encoded to base64."""
+ import io
+
+ # Create a binary stream with test data
+ test_data = b"Hello, World! This is test data."
+ binary_stream = io.BytesIO(test_data)
+
+ # Move the stream position to simulate it being read
+ binary_stream.seek(10)
+
+ # Encode to base64
+ encoded = encode_stream_to_base64(binary_stream)
+
+ # Verify the result
+ import base64
+ expected = base64.b64encode(test_data).decode("utf-8")
+ assert encoded == expected
+
+ # Verify the stream position was reset to 0 and read completely
+ assert binary_stream.tell() == len(test_data)
+
+ def test_build_form_request_with_content_id(self):
+ """Test that content_id is used as field name when provided."""
+ request_body = {
+ "to": [{"email": "test@gmail.com"}],
+ "subject": "test subject",
+ "body": "test body",
+ "attachments": [
+ {
+ "filename": "inline_image.png",
+ "content_type": "image/png",
+ "content": b"image data",
+ "size": 1234,
+ "content_id": "image1@example.com",
+ },
+ {
+ "filename": "regular_attachment.txt",
+ "content_type": "text/plain",
+ "content": b"text data",
+ "size": 5678,
+ # No content_id, should fallback to file{index}
+ }
+ ],
+ }
+
+ request = _build_form_request(request_body)
+
+ assert len(request.fields) == 3
+ assert "message" in request.fields
+ assert "image1@example.com" in request.fields # Uses content_id
+ assert "file1" in request.fields # Falls back to file{index} for attachment without content_id
+
+ # Verify the inline attachment with content_id
+ assert len(request.fields["image1@example.com"]) == 3
+ assert request.fields["image1@example.com"][0] == "inline_image.png"
+ assert request.fields["image1@example.com"][1] == b"image data"
+ assert request.fields["image1@example.com"][2] == "image/png"
+
+ # Verify the regular attachment without content_id
+ assert len(request.fields["file1"]) == 3
+ assert request.fields["file1"][0] == "regular_attachment.txt"
+ assert request.fields["file1"][1] == b"text data"
+ assert request.fields["file1"][2] == "text/plain"
+
+ def test_build_form_request_backwards_compatibility(self):
+ """Test that existing behavior is preserved when no content_id is provided."""
+ request_body = {
+ "to": [{"email": "test@gmail.com"}],
+ "subject": "test subject",
+ "body": "test body",
+ "attachments": [
+ {
+ "filename": "attachment1.txt",
+ "content_type": "text/plain",
+ "content": b"test data 1",
+ "size": 1234,
+ },
+ {
+ "filename": "attachment2.txt",
+ "content_type": "text/plain",
+ "content": b"test data 2",
+ "size": 5678,
+ }
+ ],
+ }
+
+ request = _build_form_request(request_body)
+
+ assert len(request.fields) == 3
+ assert "message" in request.fields
+ assert "file0" in request.fields # First attachment
+ assert "file1" in request.fields # Second attachment
+
+ # Verify first attachment
+ assert request.fields["file0"][0] == "attachment1.txt"
+ assert request.fields["file0"][1] == b"test data 1"
+ assert request.fields["file0"][2] == "text/plain"
+
+ # Verify second attachment
+ assert request.fields["file1"][0] == "attachment2.txt"
+ assert request.fields["file1"][1] == b"test data 2"
+ assert request.fields["file1"][2] == "text/plain"
+
+ def test_build_form_request_no_attachments(self):
+ request_body = {
+ "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"
+
+ def test_build_form_request_encoding_comparison(self):
+ """Test to demonstrate the difference between ensure_ascii=True and ensure_ascii=False."""
+ import json
+
+ test_subject = "De l'idée à la post-prod, sans friction"
+
+ # With ensure_ascii=True (default - this causes the bug)
+ encoded_with_ascii = json.dumps({"subject": test_subject}, ensure_ascii=True)
+ # This will produce escape sequences like \u00e9 for é
+
+ # With ensure_ascii=False (the fix)
+ encoded_without_ascii = json.dumps({"subject": test_subject}, ensure_ascii=False)
+ # This will preserve the actual UTF-8 characters
+
+ # Verify the difference
+ assert "\\u" in encoded_with_ascii or test_subject not in encoded_with_ascii
+ assert test_subject in encoded_without_ascii
+ assert "idée" in encoded_without_ascii
+ assert "café" not in encoded_with_ascii # Would be escaped
+
+ # Both should decode to the same value
+ assert json.loads(encoded_with_ascii)["subject"] == test_subject
+ assert json.loads(encoded_without_ascii)["subject"] == test_subject
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index ec874cae..00000000
--- a/tox.ini
+++ /dev/null
@@ -1,9 +0,0 @@
-[tox]
-envlist = py27,pypy,py34
-
-[testenv]
-commands =
- pip install -e .
- py.test
-deps =
- -rrequirements-dev.txt