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 f0337329..9d4a19e8 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,7 +1,6 @@
[bumpversion]
commit = True
tag = True
-current_version = 4.3.0
+current_version = 6.15.0
[bumpversion:file:nylas/_client_sdk_version.py]
-
diff --git a/.coveragerc b/.coveragerc
index 8460a941..cf7ed82e 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,2 +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 29bfda89..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,7 +95,10 @@ coverage.xml
# Sphinx documentation
docs/_build/
+# Editors
*.swp
+.vscode/
+.idea/
include/
lib/
@@ -59,3 +107,10 @@ local/
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 bf9b87f4..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-sudo: false
-language: python
-python:
- - "2.7"
- - "3.6"
-install:
- - "travis_retry pip install -U pip pytest pylint"
- - "travis_retry pip install .[test]"
- - "travis_retry pip install codecov"
-script: "python setup.py test --lint"
-after_success:
- - "codecov"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb22bc0f..c5e0eed8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,372 @@
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
+* Raise `UnsyncedError` when a message isn't ready to be retrieved yet (HTTP 202) when
fetching a raw message.
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 91adc1b2..f923483d 100644
--- a/README.md
+++ b/README.md
@@ -1,403 +1,190 @@
-# Nylas REST API Python bindings [](https://travis-ci.org/nylas/nylas-python) [](https://codecov.io/gh/nylas/nylas-python)
+
-Python bindings for the Nylas REST API. https://www.nylas.com/docs
+
+ The official Python SDK for Nylas — the infrastructure that powers communications
+
-The Nylas APIs power applications with email, calendar, and contacts sync from any inbox in the world.
+
+
+
+
+
+
-Nylas is compatible with 100% of email service providers, so you only have to integrate once.
-No more headaches building unique integrations against archaic and outdated IMAP and SMTP protocols.
+
-Connect to Nylas
-
-
-{% endblock %}
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 = """
+
+
+
+"""
+
+ # 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/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/native-authentication-exchange/README.md b/examples/native-authentication-exchange/README.md
deleted file mode 100644
index 09ef1d0d..00000000
--- a/examples/native-authentication-exchange/README.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Example: Native Authentication (Exchange)
-
-This is an example project that demonstrates how to connect to Nylas using the
-[Native Authentication](https://docs.nylas.com/reference#native-authentication-1)
-flow. Note that different email providers have different native authentication
-processes; this example project *only* works with Microsoft Exchange.
-
-This example uses the [Flask](http://flask.pocoo.org/) web framework to make
-a small website, and uses the [Flask-WTF](https://flask-wtf.readthedocs.io/)
-extension to implement an HTML form so the user can type in their
-Exchange account information.
-Once the native authentication has been set up, this example website will contact
-the Nylas API to learn some basic information about the current user,
-such as the user's name and email address. It will display that information
-on the page, just to prove that it can fetch it correctly.
-
-In order to successfully run this example, you need to do the following things:
-
-## Get a client ID & client secret from Nylas
-
-To do this, make a [Nylas Developer](https://developer.nylas.com/) account.
-You should see your client ID and client secret on the dashboard,
-once you've logged in on the
-[Nylas Developer](https://developer.nylas.com/) website.
-
-## Update the `config.json` File
-
-Open the `config.json` file in this directory, and replace the example
-values with the real values. This is where you'll need the client ID and
-client secret fron Nylas. You'll also need to replace the example secret key with
-any random string of letters and numbers: a keyboard mash will do.
-
-## Install the Dependencies
-
-This project depends on a few third-party Python modules, like Flask.
-These dependencies are listed in the `requirements.txt` file in this directory.
-To install them, use the `pip` tool, like this:
-
-```
-pip install -r requirements.txt
-```
-
-## Run the Example
-
-Finally, run the example project like this:
-
-```
-python server.py
-```
-
-Once the server is running, visit `http://127.0.0.1:5000/` in your browser
-to test it out!
diff --git a/examples/native-authentication-exchange/config.json b/examples/native-authentication-exchange/config.json
deleted file mode 100644
index 9b54e3df..00000000
--- a/examples/native-authentication-exchange/config.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "SECRET_KEY": "replace me with a random string",
- "NYLAS_OAUTH_CLIENT_ID": "replace me with the client ID from Nylas",
- "NYLAS_OAUTH_CLIENT_SECRET": "replace me with the client secret from Nylas"
-}
diff --git a/examples/native-authentication-exchange/requirements.txt b/examples/native-authentication-exchange/requirements.txt
deleted file mode 100644
index f03e8299..00000000
--- a/examples/native-authentication-exchange/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-Flask>=0.11
-Flask-WTF
-requests
diff --git a/examples/native-authentication-exchange/server.py b/examples/native-authentication-exchange/server.py
deleted file mode 100644
index 5594c1fb..00000000
--- a/examples/native-authentication-exchange/server.py
+++ /dev/null
@@ -1,170 +0,0 @@
-# Imports from the Python standard library
-from __future__ import print_function
-import os
-import sys
-import textwrap
-
-# Imports from third-party modules that this project depends on
-try:
- import requests
- from flask import Flask, render_template, session, redirect, url_for
- from flask_wtf import FlaskForm
- from wtforms.fields import StringField, PasswordField
- from wtforms.fields.html5 import EmailField
- from wtforms.validators import DataRequired
-except ImportError:
- message = textwrap.dedent("""
- You need to install the dependencies for this project.
- To do so, run this command:
-
- pip install -r requirements.txt
- """)
- print(message, file=sys.stderr)
- sys.exit(1)
-
-try:
- from nylas import APIClient
-except ImportError:
- message = textwrap.dedent("""
- You need to install the Nylas SDK for this project.
- To do so, run this command:
-
- pip install nylas
- """)
- print(message, file=sys.stderr)
- sys.exit(1)
-
-# This example uses Flask, a micro web framework written in Python.
-# For more information, check out the documentation: http://flask.pocoo.org
-# Create a Flask app, and load the configuration file.
-app = Flask(__name__)
-app.config.from_json('config.json')
-
-# Check for dummy configuration values.
-# If you are building your own application based on this example,
-# you can remove this check from your code.
-cfg_needs_replacing = [
- key for key, value in app.config.items()
- if isinstance(value, str) and value.startswith("replace me")
-]
-if cfg_needs_replacing:
- message = textwrap.dedent("""
- This example will only work if you replace the fake configuration
- values in `config.json` with real configuration values.
- The following config values need to be replaced:
- {keys}
- Consult the README.md file in this directory for more information.
- """).format(keys=", ".join(cfg_needs_replacing))
- print(message, file=sys.stderr)
- sys.exit(1)
-
-
-class ExchangeCredentialsForm(FlaskForm):
- name = StringField('Name', validators=[DataRequired()])
- email = EmailField('Email Address', validators=[DataRequired()])
- password = PasswordField('Password', validators=[DataRequired()])
- server_host = StringField(
- 'Server Host', render_kw={"placeholder": "(optional)"}
- )
-
-
-class APIError(Exception):
- pass
-
-
-# Define what Flask should do when someone visits the root URL of this website.
-@app.route("/", methods=('GET', 'POST'))
-def index():
- form = ExchangeCredentialsForm()
- api_error = None
- if form.validate_on_submit():
- try:
- return pass_creds_to_nylas(
- name=form.name.data,
- email=form.email.data,
- password=form.password.data,
- server_host=form.server_host.data,
- )
- except APIError as err:
- api_error = err.args[0]
- return render_template("index.html", form=form, api_error=api_error)
-
-
-def pass_creds_to_nylas(name, email, password, server_host=None):
- """
- Passes Exchange credentials to Nylas, to set up native authentication.
- """
- # Start the connection process by looking up all the information that
- # Nylas needs in order to connect, and sending it to the authorize API.
- nylas_authorize_data = {
- "client_id": app.config["NYLAS_OAUTH_CLIENT_ID"],
- "name": name,
- "email_address": email,
- "provider": "exchange",
- "settings": {
- "username": email,
- "password": password,
- }
- }
- if server_host:
- nylas_authorize_data["settings"]["eas_server_host"] = server_host
-
- nylas_authorize_resp = requests.post(
- "https://api.nylas.com/connect/authorize",
- json=nylas_authorize_data,
- )
- if not nylas_authorize_resp.ok:
- message = nylas_authorize_resp.json()["message"]
- raise APIError(message)
-
- nylas_code = nylas_authorize_resp.json()["code"]
-
- # Now that we've got the `code` from the authorize response,
- # pass it to the token response to complete the connection.
- nylas_token_data = {
- "client_id": app.config["NYLAS_OAUTH_CLIENT_ID"],
- "client_secret": app.config["NYLAS_OAUTH_CLIENT_SECRET"],
- "code": nylas_code,
- }
- nylas_token_resp = requests.post(
- "https://api.nylas.com/connect/token",
- json=nylas_token_data,
- )
- if not nylas_token_resp.ok:
- message = nylas_token_resp.json()["message"]
- raise APIError(message)
-
- nylas_access_token = nylas_token_resp.json()["access_token"]
-
- # Great, we've connected the Exchange account to Nylas!
- # In the process, Nylas gave us an OAuth access token, which we'll need
- # in order to make API requests to Nylas in the future.
- # We'll save that access token in the Flask session, so we can pick it up
- # later and use it when we need it.
- session["nylas_access_token"] = nylas_access_token
-
- # We're all done here. Redirect the user back to the success page,
- # which will pick up the access token we just saved.
- return redirect(url_for("success"))
-
-
-@app.route("/success")
-def success():
- if "nylas_access_token" not in session:
- return render_template("missing_token.html")
-
- client = APIClient(
- app_id=app.config["NYLAS_OAUTH_CLIENT_ID"],
- app_secret=app.config["NYLAS_OAUTH_CLIENT_SECRET"],
- access_token=session["nylas_access_token"],
- )
-
- # We'll use the Nylas client to fetch information from Nylas
- # about the current user, and pass that to the template.
- account = client.account
- return render_template("success.html", account=account)
-
-
-# When this file is executed, run the Flask web server.
-if __name__ == "__main__":
- app.run()
diff --git a/examples/native-authentication-exchange/templates/base.html b/examples/native-authentication-exchange/templates/base.html
deleted file mode 100644
index b0683cbb..00000000
--- a/examples/native-authentication-exchange/templates/base.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- Nylas Native Authentication: Exchange
-
-
-
-
You've successfully connected to Nylas via native authentication!
- Here's some information that I got from the Nylas API, to prove it:
-
-
- {% for key, value in account.items() %}
-
-
{{ key }}
-
{{ value }}
-
- {% endfor %}
-
-
-{% endblock %}
diff --git a/examples/native-authentication-gmail/README.md b/examples/native-authentication-gmail/README.md
deleted file mode 100644
index 2de2cbff..00000000
--- a/examples/native-authentication-gmail/README.md
+++ /dev/null
@@ -1,108 +0,0 @@
-# Example: Native Authentication (Gmail)
-
-This is an example project that demonstrates how to connect to Nylas using the
-[Native Authentication](https://docs.nylas.com/reference#native-authentication-1)
-flow. Note that different email providers have different native authentication
-processes; this example project *only* works with Gmail.
-
-This example uses the [Flask](http://flask.pocoo.org/) web framework to make
-a small website, and uses the [Flask-Dance](http://flask-dance.rtfd.org/)
-extension to handle the tricky bits of implementing the
-[OAuth protocol](https://oauth.net/).
-Once the native authentication has been set up, this example website will contact
-the Nylas API to learn some basic information about the current user,
-such as the user's name and email address. It will display that information
-on the page, just to prove that it can fetch it correctly.
-
-In order to successfully run this example, you need to do the following things:
-
-## Get a client ID & client secret from Nylas
-
-To do this, make a [Nylas Developer](https://developer.nylas.com/) account.
-You should see your client ID and client secret on the dashboard,
-once you've logged in on the
-[Nylas Developer](https://developer.nylas.com/) website.
-
-## Get a client ID & client secret from Google
-
-To do this, go to the
-[Google Developers Console](https://console.developers.google.com)
-and create a project. Then go to the "Library" section and enable the
-following APIs: "Gmail API", "Contacts API", "Google Calendar API".
-Then go to the "Credentials" section and create a new OAuth client ID.
-Select "Web application" for the application type, and click the "Create"
-button.
-
-Check out the
-[Google OAuth Setup Guide](https://support.nylas.com/hc/en-us/articles/222176307)
-on the Nylas support website, for more information.
-
-## Update the `config.json` File
-
-Open the `config.json` file in this directory, and replace the example
-values with the real values. You'll need the client ID and client secret
-from Nylas, and the client ID and client secret from Google.
-
-You'll also need to replace the example secret key with
-any random string of letters and numbers: a keyboard mash will do.
-
-## Set Up HTTPS
-
-The OAuth protocol requires that all communication occur via the secure HTTPS
-connections, rather than insecure HTTP connections. There are several ways
-to set up HTTPS on your computer, but perhaps the simplest is to use
-[ngrok](https://ngrok.com), a tool that lets you create a secure tunnel
-from the ngrok website to your computer. Install it from the website, and
-then run the following command:
-
-```
-ngrok http 5000
-```
-
-Notice that ngrok will show you two "forwarding" URLs, which may look something
-like `http://ed90abe7.ngrok.io` and `https://ed90abe7.ngrok.io`. (The hash
-subdomain will be different for you.) You'll be using the second URL, which
-starts with `https`.
-
-Alternatively, you can set the `OAUTHLIB_INSECURE_TRANSPORT` environment
-variable in your shell, to disable the HTTPS check. That way, you'll be
-able to use `localhost` to refer to your app, instead of an ngrok URL.
-However, be aware that you won't be able to do this when you deploy
-your app to production, so it's usually a better idea to set up HTTPS properly.
-
-## Set the Authorized Redirect URI for Google
-
-Once you have a HTTPS URL that points to your computer, you'll need to tell
-Google about it. On the
-[Google Developer Console](https://console.developers.google.com),
-click on the "Credentials" section, find the OAuth client that you
-already created, and click on the "edit" button on the right side.
-There is a section called "Authorized redirect URIs"; this is where
-you need to tell Google about your HTTPS URL.
-Paste your HTTPS URL into text field, and add `/login/google/authorized`
-after it. For example, if your HTTPS URL is `https://ad172180.ngrok.io`, then
-you would put `https://ad172180.ngrok.io/login/google/authorized` into
-the "Authorized redirect URIs" text field.
-
-Then click the "Done" button to save. Even after you save, it usually takes
-Google about 5 minutes to update everything behind the scenes.
-
-## Install the Dependencies
-
-This project depends on a few third-party Python modules, like Flask.
-These dependencies are listed in the `requirements.txt` file in this directory.
-To install them, use the `pip` tool, like this:
-
-```
-pip install -r requirements.txt
-```
-
-## Run the Example
-
-Finally, run the example project like this:
-
-```
-python server.py
-```
-
-Once the server is running, visit the ngrok URL in your browser to test it out!
diff --git a/examples/native-authentication-gmail/config.json b/examples/native-authentication-gmail/config.json
deleted file mode 100644
index f997e0fe..00000000
--- a/examples/native-authentication-gmail/config.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "SECRET_KEY": "replace me with a random string",
- "NYLAS_OAUTH_CLIENT_ID": "replace me with the client ID from Nylas",
- "NYLAS_OAUTH_CLIENT_SECRET": "replace me with the client secret from Nylas",
- "GOOGLE_OAUTH_CLIENT_ID": "replace me with the client ID from Google",
- "GOOGLE_OAUTH_CLIENT_SECRET": "replace me with the client secret from Google"
-}
diff --git a/examples/native-authentication-gmail/requirements.txt b/examples/native-authentication-gmail/requirements.txt
deleted file mode 100644
index e87c213b..00000000
--- a/examples/native-authentication-gmail/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-Flask>=0.11
-Flask-Dance>=0.11.1
-requests
diff --git a/examples/native-authentication-gmail/server.py b/examples/native-authentication-gmail/server.py
deleted file mode 100644
index 73a6fd6c..00000000
--- a/examples/native-authentication-gmail/server.py
+++ /dev/null
@@ -1,223 +0,0 @@
-# Imports from the Python standard library
-from __future__ import print_function
-import os
-import sys
-import textwrap
-
-# Imports from third-party modules that this project depends on
-try:
- import requests
- from flask import Flask, render_template, session, redirect, url_for
- from werkzeug.contrib.fixers import ProxyFix
- from flask_dance.contrib.google import make_google_blueprint, google
-except ImportError:
- message = textwrap.dedent("""
- You need to install the dependencies for this project.
- To do so, run this command:
-
- pip install -r requirements.txt
- """)
- print(message, file=sys.stderr)
- sys.exit(1)
-
-try:
- from nylas import APIClient
-except ImportError:
- message = textwrap.dedent("""
- You need to install the Nylas SDK for this project.
- To do so, run this command:
-
- pip install nylas
- """)
- print(message, file=sys.stderr)
- sys.exit(1)
-
-# This example uses Flask, a micro web framework written in Python.
-# For more information, check out the documentation: http://flask.pocoo.org
-# Create a Flask app, and load the configuration file.
-app = Flask(__name__)
-app.config.from_json('config.json')
-
-# Check for dummy configuration values.
-# If you are building your own application based on this example,
-# you can remove this check from your code.
-cfg_needs_replacing = [
- key for key, value in app.config.items()
- if isinstance(value, str) and value.startswith("replace me")
-]
-if cfg_needs_replacing:
- message = textwrap.dedent("""
- This example will only work if you replace the fake configuration
- values in `config.json` with real configuration values.
- The following config values need to be replaced:
- {keys}
- Consult the README.md file in this directory for more information.
- """).format(keys=", ".join(cfg_needs_replacing))
- print(message, file=sys.stderr)
- sys.exit(1)
-
-# Use Flask-Dance to automatically set up the OAuth endpoints for Google.
-# For more information, check out the documentation: http://flask-dance.rtfd.org
-google_bp = make_google_blueprint(
- scope=[
- "https://www.googleapis.com/auth/userinfo.email",
- "https://www.googleapis.com/auth/userinfo.profile",
- "https://mail.google.com/",
- "https://www.google.com/m8/feeds",
- "https://www.googleapis.com/auth/calendar",
- ],
- offline=True, # this allows you to get a refresh token from Google
- redirect_to="after_google",
- # If you get a "missing Google refresh token" error, uncomment this line:
- # reprompt_consent=True,
-
- # That `reprompt_consent` argument will force Google to re-ask the user
- # every single time if they want to connect with your application.
- # Google will only send the refresh token if the user has explicitly
- # given consent.
-)
-app.register_blueprint(google_bp, url_prefix="/login")
-
-# Teach Flask how to find out that it's behind an ngrok proxy
-app.wsgi_app = ProxyFix(app.wsgi_app)
-
-# Define what Flask should do when someone visits the root URL of this website.
-@app.route("/")
-def index():
- # If the user has already connected to Google via OAuth,
- # `google.authorized` will be True. We also need to be sure that
- # we have a refresh token from Google. If we don't have both of those,
- # that indicates that we haven't correctly connected with Google.
- if not (google.authorized and "refresh_token" in google.token):
- # Google requires HTTPS. The template will display a handy warning,
- # unless we've overridden the check.
- return render_template(
- "before_google.html",
- insecure_override=os.environ.get("OAUTHLIB_INSECURE_TRANSPORT"),
- )
-
- if "nylas_access_token" not in session:
- # The user has already connected to Google via OAuth,
- # but hasn't yet passed those credentials to Nylas.
- # We'll redirect the user to the right place to make that happen.
- return redirect(url_for("after_google"))
-
- # If we've gotten to this point, then the user has already connected
- # to both Google and Nylas.
- # Let's set up the SDK client with the OAuth token:
- client = APIClient(
- app_id=app.config["NYLAS_OAUTH_CLIENT_ID"],
- app_secret=app.config["NYLAS_OAUTH_CLIENT_SECRET"],
- access_token=session["nylas_access_token"],
- )
-
- # We'll use the Nylas client to fetch information from Nylas
- # about the current user, and pass that to the template.
- account = client.account
- return render_template("after_connected.html", account=account)
-
-
-@app.route("/google/success")
-def after_google():
- """
- This just renders a confirmation page, to let the user know that
- they've successfully connected to Google and need to move on to the
- next step: passing those authentication credentials to Nylas.
- """
- return render_template("after_google.html")
-
-
-@app.route("/nylas/connect")
-def pass_creds_to_nylas():
- """
- This view loads the credentials from Google and passes them to Nylas,
- to set up native authentication.
- """
- # If you haven't already connected with Google, this won't work.
- if not google.authorized:
- return "Error: not yet connected with Google!", 400
-
- if "refresh_token" not in google.token:
- # We're missing the refresh token from Google, and the only way to get
- # a new one is to force reauthentication. That's annoying.
- return (
- "Error: missing Google refresh token. "
- "Uncomment the `reprompt_consent` line in the code to fix this."
- ), 500
-
- # Look up the user's name and email address from Google.
- google_resp = google.get("/oauth2/v2/userinfo?fields=name,email")
- assert google_resp.ok, "Received failure response from Google userinfo API"
- google_userinfo = google_resp.json()
-
- # Start the connection process by looking up all the information that
- # Nylas needs in order to connect, and sending it to the authorize API.
- nylas_authorize_data = {
- "client_id": app.config["NYLAS_OAUTH_CLIENT_ID"],
- "name": google_userinfo["name"],
- "email_address": google_userinfo["email"],
- "provider": "gmail",
- "settings": {
- "google_client_id": app.config["GOOGLE_OAUTH_CLIENT_ID"],
- "google_client_secret": app.config["GOOGLE_OAUTH_CLIENT_SECRET"],
- "google_refresh_token": google.token["refresh_token"],
- }
- }
- nylas_authorize_resp = requests.post(
- "https://api.nylas.com/connect/authorize",
- json=nylas_authorize_data,
- )
- assert nylas_authorize_resp.ok, "Received failure response from Nylas authorize API"
- nylas_code = nylas_authorize_resp.json()["code"]
-
- # Now that we've got the `code` from the authorize response,
- # pass it to the token response to complete the connection.
- nylas_token_data = {
- "client_id": app.config["NYLAS_OAUTH_CLIENT_ID"],
- "client_secret": app.config["NYLAS_OAUTH_CLIENT_SECRET"],
- "code": nylas_code,
- }
- nylas_token_resp = requests.post(
- "https://api.nylas.com/connect/token",
- json=nylas_token_data,
- )
- assert nylas_token_resp.ok, "Received failure response from Nylas token API"
- nylas_access_token = nylas_token_resp.json()["access_token"]
-
- # Great, we've connected Google to Nylas! In the process, Nylas gave us
- # an OAuth access token, which we'll need in order to make API requests
- # to Nylas in the future. We'll save that access token in the Flask session,
- # so we can pick it up later and use it when we need it.
- session["nylas_access_token"] = nylas_access_token
-
- # We're all done here. Redirect the user back to the home page,
- # which will pick up the access token we just saved.
- return redirect(url_for("index"))
-
-
-def ngrok_url():
- """
- If ngrok is running, it exposes an API on port 4040. We can use that
- to figure out what URL it has assigned, and suggest that to the user.
- https://ngrok.com/docs#list-tunnels
- """
- try:
- ngrok_resp = requests.get("http://localhost:4040/api/tunnels")
- except requests.ConnectionError:
- # I guess ngrok isn't running.
- return None
- ngrok_data = ngrok_resp.json()
- secure_urls = [
- tunnel['public_url'] for tunnel in ngrok_data['tunnels']
- if tunnel['proto'] == 'https'
- ]
- return secure_urls[0]
-
-
-# When this file is executed, run the Flask web server.
-if __name__ == "__main__":
- url = ngrok_url()
- if url:
- print(" * Visit {url} to view this Nylas example".format(url=url))
-
- app.run()
diff --git a/examples/native-authentication-gmail/templates/after_connected.html b/examples/native-authentication-gmail/templates/after_connected.html
deleted file mode 100644
index b9a78636..00000000
--- a/examples/native-authentication-gmail/templates/after_connected.html
+++ /dev/null
@@ -1,16 +0,0 @@
-{% extends "base.html" %}
-{% block body %}
-
Done!
-
You've successfully connected to Nylas via native authentication!
- Here's some information that I got from the Nylas API, to prove it:
Great, you've succesfully connected with Google! The next step is to
- hand those authentication credentials over to Nylas, so that Nylas
- can connect with Google on your behalf.
- Please click this link to do that.
-
-
diff --git a/examples/native-authentication-gmail/templates/before_google.html b/examples/native-authentication-gmail/templates/before_google.html
deleted file mode 100644
index 07a59634..00000000
--- a/examples/native-authentication-gmail/templates/before_google.html
+++ /dev/null
@@ -1,34 +0,0 @@
-{% extends "base.html" %}
-{% block body %}
-{% if not insecure_override %}
-
-
Warning
-
OAuth requires HTTPS, and this page is loaded via insecure HTTP.
- If you try to connect to Google like this, it will fail.
- To fix this problem, serve this website over HTTPS.
-
-
Since this is just an example, you can disable this requirement by
- setting the OAUTHLIB_INSECURE_TRANSPORT environment
- variable to 1 before running this example again.
- However, you will not be able to do this when running
- in production.
-
-
-{% endif %}
-
-
Step 1: Connect with Google
-
-
Thanks for giving Nylas a try! Native Authentication is a two-step process,
- where the first step is connecting with the native email provider.
- This example is set up to work with Google, so it will only work if you
- have a Gmail account.
-
First, please click this button to connect with Google.
-Connect with Google
-
-
-{% endblock %}
diff --git a/examples/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"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/webhooks/README.md b/examples/webhooks/README.md
deleted file mode 100644
index ac9483a1..00000000
--- a/examples/webhooks/README.md
+++ /dev/null
@@ -1,125 +0,0 @@
-# Example: Webhooks
-
-This is an example project that demonstrates how to use
-[the webhooks feature on Nylas](https://docs.nylas.com/reference#webhooks).
-When you run the app and set up a webhook with Nylas, it will print out
-some information every time you receive a webhook notification from Nylas.
-
-In order to successfully run this example, you need to do the following things:
-
-## Install and run redis
-
-[Redis](https://redis.io/) is an in-memory data store. This example uses it
-as a message broker for the Celery task queue. You'll need to have it running
-on your local computer in order to use the task queue.
-
-If you're using macOS, you can install redis from [Homebrew](https://brew.sh/),
-like this:
-
-```
-brew install redis
-brew services start redis
-```
-
-If you're unable to install and run redis, you can still run this example
-without the task queue -- keep reading.
-
-## Get a client ID & client secret from Nylas
-
-To do this, make a [Nylas Developer](https://developer.nylas.com/) account.
-You should see your client ID and client secret on the dashboard,
-once you've logged in on the
-[Nylas Developer](https://developer.nylas.com/) website.
-
-## Update the `config.json` File
-
-Open the `config.json` file in this directory, and replace the example
-client ID and client secret with the real values that you got from the Nylas
-Developer dashboard. You'll also need to replace the example secret key with
-any random string of letters and numbers: a keyboard mash will do.
-
-The config file also has two options related to Celery, which you probably
-don't need to modify. `CELERY_BROKER_URL` should point to your running redis
-server: if you've got it running on your local computer, you're all set.
-However, if you haven't managed to get redis running on your computer, you
-can change `CELERY_TASK_ALWAYS_EAGER` to `true`. This will disable the task
-queue, and cause all Celery tasks to be run immediately rather than queuing
-them for later.
-
-## Set Up HTTPS
-
-Nylas requires that all webhooks be delivered to the secure HTTPS endpoints,
-rather than insecure HTTP endpoints. There are several ways
-to set up HTTPS on your computer, but perhaps the simplest is to use
-[ngrok](https://ngrok.com), a tool that lets you create a secure tunnel
-from the ngrok website to your computer. Install it from the website, and
-then run the following command:
-
-```
-ngrok http 5000
-```
-
-Notice that ngrok will show you two "forwarding" URLs, which may look something
-like `http://ed90abe7.ngrok.io` and `https://ed90abe7.ngrok.io`. (The hash
-subdomain will be different for you.) You'll be using the second URL, which
-starts with `https`.
-
-## Install the Dependencies
-
-This project depends on a few third-party Python modules.
-These dependencies are listed in the `requirements.txt` file in this directory.
-To install them, use the `pip` tool, like this:
-
-```
-pip install -r requirements.txt
-```
-
-## Run the Celery worker (if you're using redis)
-
-The Celery worker will continuously check the task queue to see if there are
-any new tasks to be run, and it will run any tasks that it finds. Without at
-least one worker running, tasks on the task queue will sit there unfinished
-forever. To run a celery worker, pass the `--worker` argument to the `server.py`
-script, like this:
-
-```
-python server.py --worker
-```
-
-Note that if you're not using redis, you don't need to run a Celery worker,
-because the tasks will be run immediately rather than put on the task queue.
-
-## Run the Example
-
-While the Celery worker is running, open a new terminal window and run the
-Flask web server, like this:
-
-```
-python server.py
-```
-
-You should see the ngrok URL in the console, and the web server will start
-on port 5000.
-
-## Set the Nylas Callback URL
-
-Now that your webhook is all set up and running, you need to tell
-Nylas about it. On the [Nylas Developer](https://developer.nylas.com) console,
-click on the "Webhooks" tab on the left side, then click the "Add Webhook"
-button.
-Paste your HTTPS URL into text field, and add `/webhook`
-after it. For example, if your HTTPS URL is `https://ad172180.ngrok.io`, then
-you would put `https://ad172180.ngrok.io/webhook` into the text field.
-
-Then click the "Create Webhook" button to save.
-
-## Trigger events and see webhook notifications!
-
-Send an email on an account that's connected to Nylas. In a minute or two,
-you'll get a webhook notification with information about the event that just
-happened!
-
-If you're using redis, you should see the information about the event in the
-terminal window where your Celery worker is running. If you're not using
-redis, you should see the information about the event in the terminal window
-where your Flask web server is running.
diff --git a/examples/webhooks/config.json b/examples/webhooks/config.json
deleted file mode 100644
index 04e9a645..00000000
--- a/examples/webhooks/config.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "SECRET_KEY": "replace me with a random string",
- "NYLAS_OAUTH_CLIENT_ID": "replace me with the client ID from Nylas",
- "NYLAS_OAUTH_CLIENT_SECRET": "replace me with the client secret from Nylas",
- "CELERY_BROKER_URL": "redis://localhost",
- "CELERY_TASK_ALWAYS_EAGER": false
-}
diff --git a/examples/webhooks/requirements.txt b/examples/webhooks/requirements.txt
deleted file mode 100644
index 38e90172..00000000
--- a/examples/webhooks/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-Flask>=0.11
-celery[redis]>=4.0.0
-requests
diff --git a/examples/webhooks/server.py b/examples/webhooks/server.py
deleted file mode 100755
index 4ccc8b3a..00000000
--- a/examples/webhooks/server.py
+++ /dev/null
@@ -1,195 +0,0 @@
-#!/usr/bin/env python
-
-# Imports from the Python standard library
-from __future__ import print_function
-import os
-import sys
-import datetime
-import textwrap
-import hmac
-import hashlib
-
-# Imports from third-party modules that this project depends on
-try:
- import requests
- from flask import Flask, request, render_template
- from werkzeug.contrib.fixers import ProxyFix
- from celery import Celery
-except ImportError:
- message = textwrap.dedent("""
- You need to install the dependencies for this project.
- To do so, run this command:
-
- pip install -r requirements.txt
- """)
- print(message, file=sys.stderr)
- sys.exit(1)
-
-# This example uses Flask, a micro web framework written in Python.
-# For more information, check out the documentation: http://flask.pocoo.org
-# Create a Flask app, and load the configuration file.
-app = Flask(__name__)
-app.config.from_json('config.json')
-
-# Check for dummy configuration values.
-# If you are building your own application based on this example,
-# you can remove this check from your code.
-cfg_needs_replacing = [
- key for key, value in app.config.items()
- if isinstance(value, str) and value.startswith("replace me")
-]
-if cfg_needs_replacing:
- message = textwrap.dedent("""
- This example will only work if you replace the fake configuration
- values in `config.json` with real configuration values.
- The following config values need to be replaced:
- {keys}
- Consult the README.md file in this directory for more information.
- """).format(keys=", ".join(cfg_needs_replacing))
- print(message, file=sys.stderr)
- sys.exit(1)
-
-# Teach Flask how to find out that it's behind an ngrok proxy
-app.wsgi_app = ProxyFix(app.wsgi_app)
-
-# This example also uses Celery, a task queue framework written in Python.
-# For more information, check out the documentation: http://docs.celeryproject.org
-# Create a Celery instance, and load its configuration from Flask.
-celery = Celery(app.import_name)
-celery.config_from_object(app.config, namespace='CELERY')
-
-
-@app.route('/webhook', methods=['GET', 'POST'])
-def webhook():
- """
- When the Flask server gets a request at the `/webhook` URL, it will run
- this function. Most of the time, that request will be a genuine webhook
- notification from Nylas. However, it's possible that the request could
- be a fake notification from someone else, trying to fool our app. This
- function needs to verify that the webhook is genuine!
- """
- # When you first tell Nylas about your webhook, it will test that webhook
- # URL with a GET request to make sure that it responds correctly.
- # We just need to return the `challenge` parameter to indicate that this
- # is a valid webhook URL.
- if request.method == "GET" and "challenge" in request.args:
- print(" * Nylas connected to the webhook!")
- return request.args["challenge"]
-
- # Alright, this is a POST request, which means it's a webhook notification.
- # The question is, is it genuine or fake? Check the signature to find out.
- is_genuine = verify_signature(
- message=request.data,
- key=app.config["NYLAS_OAUTH_CLIENT_SECRET"].encode('utf8'),
- signature=request.headers.get('X-Nylas-Signature'),
- )
- if not is_genuine:
- return "Signature verification failed!", 401
-
- # Alright, we have a genuine webhook notification from Nylas!
- # Let's find out what it says...
- data = request.get_json()
- for delta in data["deltas"]:
- # Processing the data might take awhile, or it might fail.
- # As a result, instead of processing it right now, we'll push a task
- # onto the Celery task queue, to handle it later. That way,
- # we've got the data saved, and we can return a response to the
- # Nylas webhook notification right now.
- process_delta.delay(delta)
-
- # Now that all the `process_delta` tasks have been queued, we can
- # return an HTTP response to Nylas, to let them know that we processed
- # the webhook notification successfully.
- return "Deltas have been queued", 200
-
-
-def verify_signature(message, key, signature):
- """
- This function will verify the authenticity of a digital signature.
- For security purposes, Nylas includes a digital signature in the headers
- of every webhook notification, so that clients can verify that the
- webhook request came from Nylas and no one else. The signing key
- is your OAuth client secret, which only you and Nylas know.
- """
- digest = hmac.new(key, msg=message, digestmod=hashlib.sha256).hexdigest()
- return digest == signature
-
-
-@celery.task
-def process_delta(delta):
- """
- This is the part of the code where you would process the information
- from the webhook notification. Each delta is one change that happened,
- and might require fetching message IDs, updating your database,
- and so on.
-
- However, because this is just an example project, we'll just print
- out information about the notification, so you can see what
- information is being sent.
- """
- kwargs = {
- "type": delta["type"],
- "date": datetime.datetime.utcfromtimestamp(delta["date"]),
- "object_id": delta["object_data"]["id"],
- }
- print(" * {type} at {date} with ID {object_id}".format(**kwargs))
-
-
-@app.route("/")
-def index():
- """
- This makes sure that when you visit the root of the website,
- you get a webpage rather than a 404 error.
- """
- return render_template("index.html", ngrok_url=ngrok_url())
-
-
-def ngrok_url():
- """
- If ngrok is running, it exposes an API on port 4040. We can use that
- to figure out what URL it has assigned, and suggest that to the user.
- https://ngrok.com/docs#list-tunnels
- """
- try:
- ngrok_resp = requests.get("http://localhost:4040/api/tunnels")
- except requests.ConnectionError:
- # I guess ngrok isn't running.
- return None
- ngrok_data = ngrok_resp.json()
- secure_urls = [
- tunnel['public_url'] for tunnel in ngrok_data['tunnels']
- if tunnel['proto'] == 'https'
- ]
- return secure_urls[0]
-
-
-# When this file is executed, this block of code will run.
-if __name__ == "__main__":
- if len(sys.argv) > 1 and sys.argv[1] == "--worker":
- # Run the celery worker, *instead* of running the Flask web server.
- celery.worker_main(sys.argv[1:])
- sys.exit()
-
- # If we get here, we're going to try to run the Flask web server.
- url = ngrok_url()
- if not url:
- print(
- "Looks like ngrok isn't running! Start it by running "
- "`ngrok http 5000` in a different terminal window, "
- "and then try running this example again.",
- file=sys.stderr,
- )
- sys.exit(1)
-
- print(" * Webhook URL: {url}/webhook".format(url=url))
-
- if app.config.get("CELERY_TASK_ALWAYS_EAGER"):
- print(" * Celery tasks will be run synchronously. No worker needed.")
- elif len(celery.control.inspect().stats().keys()) < 2:
- print(
- " * You need to run at least one Celery worker, otherwise "
- "the webhook notifications will never be processed.\n"
- " To do so, run `{arg0} --worker` in a different "
- "terminal window.".format(arg0=sys.argv[0])
- )
- app.run()
diff --git a/examples/webhooks/templates/base.html b/examples/webhooks/templates/base.html
deleted file mode 100644
index 3d6ac8a3..00000000
--- a/examples/webhooks/templates/base.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- Nylas Webhook Example
-
-
-
-
This example doesn't have anything to see in the browser. Set up your
- webhook on the
- Nylas Developer console,
- and then watch your terminal to see the webhook notifications come in.
-
-
-
Your webhook URL is:
- {{ ngrok_url }}{{ url_for("webhook") }}
-
-
-
Once you've received at least one webhook notification from Nylas,
- you might want to check out the
- ngrok web interface.
- That will allow you to see more information about the webhook notification,
- and replay it for testing purposes if you want.
-
-{% endblock %}
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 00000000..6e8dfce4
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,23 @@
+site_name: Nylas Python SDK Reference
+theme:
+ name: 'material'
+
+nav:
+ - Getting Started: index.md
+ - Code Reference: reference/
+ - Contributing: contributing.md
+ - License: license.md
+
+# Add plugins
+plugins:
+ - search
+ - mkdocstrings:
+ default_handler: python
+ handlers:
+ python:
+ paths: [ nylas ]
+ - gen-files:
+ scripts:
+ - scripts/generate-docs.py
+ - literate-nav:
+ nav_file: SUMMARY.md
diff --git a/nylas/__init__.py b/nylas/__init__.py
index 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 80fa7ff6..85e889a5 100644
--- a/nylas/_client_sdk_version.py
+++ b/nylas/_client_sdk_version.py
@@ -1 +1 @@
-__VERSION__ = "4.3.0"
+__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 84af48b3..00000000
--- a/nylas/client/client.py
+++ /dev/null
@@ -1,367 +0,0 @@
-from __future__ import print_function
-import sys
-from os import environ
-from base64 import b64encode
-import json
-
-import requests
-from urlobject import URLObject
-from six.moves.urllib.parse import urlencode
-from nylas._client_sdk_version import __VERSION__
-from nylas.client.errors import MessageRejectedError
-from nylas.client.restful_model_collection import RestfulModelCollection
-from nylas.client.restful_models import (
- Calendar, Contact, Event, Message, Thread, File,
- Account, APIAccount, SingletonAccount, Folder,
- Label, Draft
-)
-from nylas.utils import convert_datetimes_to_timestamps
-
-DEBUG = environ.get('NYLAS_CLIENT_DEBUG')
-API_SERVER = "https://api.nylas.com"
-
-
-def _validate(response):
- if DEBUG: # pragma: no cover
- print("{method} {url} ({body}) => {status}: {text}".format(
- method=response.request.method,
- url=response.request.url,
- body=response.request.body,
- status=response.status_code,
- text=response.text,
- ))
-
- if response.status_code == 402:
- # HTTP status code 402 normally means "Payment Required",
- # but when Nylas uses that status code, it means something different.
- # Usually it indicates an upstream error on the provider.
- # We let Requests handle most HTTP errors, but for this one,
- # we will handle it separate and handle a _different_ exception
- # so that users don't think they need to pay.
- raise MessageRejectedError(response)
-
- response.raise_for_status()
- return response
-
-
-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):
- if not api_server.startswith("https://"):
- raise Exception("When overriding the Nylas API server address, you"
- " must include https://")
- self.api_server = api_server
- self.authorize_url = api_server + '/oauth/authorize'
- self.access_token_url = api_server + '/oauth/token'
- self.revoke_url = api_server + '/oauth/revoke'
-
- 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 = None
- self.access_token = access_token
- self.auth_token = None
-
- # 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:
- b64_app_secret = b64encode((app_secret + ':').encode('utf8'))
- authorization = 'Basic {secret}'.format(
- secret=b64_app_secret.decode('utf8')
- )
- self.admin_session.headers = {
- 'Authorization': authorization,
- 'X-Nylas-API-Wrapper': 'python',
- 'User-Agent': version_header,
- }
- super(APIClient, self).__init__()
-
- @property
- def access_token(self):
- return self._access_token
-
- @access_token.setter
- def access_token(self, value):
- self._access_token = value
- if value:
- authorization = 'Bearer {token}'.format(token=value)
- self.session.headers['Authorization'] = authorization
- else:
- if 'Authorization' in self.session.headers:
- del self.session.headers['Authorization']
-
- def authentication_url(self, redirect_uri, login_hint='', state=''):
- args = {'redirect_uri': redirect_uri,
- 'client_id': self.app_id or 'None', # 'None' for back-compat
- 'response_type': 'code',
- 'scope': 'email',
- 'login_hint': login_hint,
- 'state': state}
-
- url = URLObject(self.authorize_url).add_query_params(args.items())
- return str(url)
-
- 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.access_token = resp[u'access_token']
- return self.access_token
-
- def is_opensource_api(self):
- if self.app_id is None and self.app_secret is None:
- return True
-
- return False
-
- def revoke_token(self):
- resp = self.session.post(self.revoke_url)
- _validate(resp)
- self.auth_token = None
- self.access_token = None
-
- @property
- def account(self):
- return self._get_resource(SingletonAccount, '')
-
- @property
- def accounts(self):
- if self.is_opensource_api():
- return RestfulModelCollection(APIAccount, self)
- return RestfulModelCollection(Account, self)
-
- @property
- def threads(self):
- return RestfulModelCollection(Thread, self)
-
- @property
- def folders(self):
- return RestfulModelCollection(Folder, self)
-
- @property
- def labels(self):
- return RestfulModelCollection(Label, self)
-
- @property
- def messages(self):
- return RestfulModelCollection(Message, self)
-
- @property
- def files(self):
- return RestfulModelCollection(File, self)
-
- @property
- def drafts(self):
- return RestfulModelCollection(Draft, self)
-
- @property
- def contacts(self):
- return RestfulModelCollection(Contact, self)
-
- @property
- def events(self):
- return RestfulModelCollection(Event, self)
-
- @property
- def 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
- return self.session
-
- def _get_resources(self, cls, extra=None, **filters):
- # FIXME @karim: remove this interim code when we've got rid
- # of the old accounts API.
- postfix = "/{}".format(extra) if extra else ''
- if cls.api_root != 'a':
- url = "{}/{}{}".format(
- self.api_server,
- cls.collection_name,
- postfix
- )
- else:
- url = "{}/a/{}/{}{}".format(
- self.api_server,
- self.app_id,
- cls.collection_name,
- postfix
- )
-
- converted_filters = convert_datetimes_to_timestamps(
- filters, cls.datetime_filter_attrs,
- )
- url = str(URLObject(url).add_query_params(converted_filters.items()))
- response = self._get_http_session(cls.api_root).get(url)
- results = _validate(response).json()
- return [
- cls.create(self, **x)
- for x in results
- if x is not None
- ]
-
- def _get_resource_raw(self, cls, id, extra=None,
- headers=None, stream=False, **filters):
- """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)
-
- converted_filters = convert_datetimes_to_timestamps(
- filters, cls.datetime_filter_attrs,
- )
- url = str(URLObject(url).add_query_params(converted_filters.items()))
-
- response = self._get_http_session(cls.api_root).get(
- url, headers=headers, stream=stream,
- )
- 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
-
- def _create_resource(self, cls, data, **kwargs):
- url = (
- URLObject(self.api_server)
- .with_path("/{name}/".format(name=cls.collection_name))
- .set_query_params(**kwargs)
- )
-
- session = self._get_http_session(cls.api_root)
-
- if cls == File:
- response = session.post(url, files=data)
- else:
- converted_data = convert_datetimes_to_timestamps(data, cls.datetime_attrs)
- headers = {'Content-Type': 'application/json'}
- headers.update(self.session.headers)
- response = session.post(url, json=converted_data, headers=headers)
-
- result = _validate(response).json()
- if cls.collection_name == 'send':
- return result
- return cls.create(self, **result)
-
- def _create_resources(self, cls, data):
- url = (
- URLObject(self.api_server)
- .with_path("/{name}/".format(name=cls.collection_name))
- )
- session = self._get_http_session(cls.api_root)
-
- if cls == File:
- response = session.post(url, files=data)
- else:
- converted_data = [
- convert_datetimes_to_timestamps(datum, cls.datetime_attrs)
- for datum in data
- ]
- headers = {'Content-Type': 'application/json'}
- headers.update(self.session.headers)
- response = session.post(url, json=converted_data, headers=headers)
-
- results = _validate(response).json()
- return [cls.create(self, **x) for x in results]
-
- def _delete_resource(self, cls, id, data=None, **kwargs):
- url = (
- URLObject(self.api_server)
- .with_path("/{name}/{id}".format(name=cls.collection_name, id=id))
- .set_query_params(**kwargs)
- )
- session = self._get_http_session(cls.api_root)
- if data:
- _validate(session.delete(url, json=data))
- else:
- _validate(session.delete(url))
-
- def _update_resource(self, cls, id, data, **kwargs):
- url = (
- URLObject(self.api_server)
- .with_path("/{name}/{id}".format(name=cls.collection_name, id=id))
- .set_query_params(**kwargs)
- )
-
- session = self._get_http_session(cls.api_root)
-
- converted_data = convert_datetimes_to_timestamps(data, cls.datetime_attrs)
- response = session.put(url, json=converted_data)
-
- result = _validate(response).json()
- return cls.create(self, **result)
-
- def _call_resource_method(self, cls, id, method_name, data):
- """POST a dictionary to an API method,
- for example /a/.../accounts/id/upgrade"""
-
- if cls.api_root != 'a':
- url_path = "/{name}/{id}/{method}".format(
- name=cls.collection_name, id=id, method=method_name
- )
- else:
- # Management method.
- url_path = "/a/{app_id}/{name}/{id}/{method}".format(
- app_id=self.app_id,
- name=cls.collection_name,
- id=id,
- method=method_name,
- )
-
- url = (
- URLObject(self.api_server)
- .with_path(url_path)
- )
- converted_data = convert_datetimes_to_timestamps(data, cls.datetime_attrs)
-
- session = self._get_http_session(cls.api_root)
- response = session.post(url, json=converted_data)
-
- result = _validate(response).json()
- return cls.create(self, **result)
diff --git a/nylas/client/errors.py b/nylas/client/errors.py
deleted file mode 100644
index 96f14bd2..00000000
--- a/nylas/client/errors.py
+++ /dev/null
@@ -1,15 +0,0 @@
-class NylasError(Exception):
- pass
-
-class MessageRejectedError(NylasError):
- pass
-
-class FileUploadError(NylasError):
- pass
-
-class UnSyncedError(NylasError):
- """
- HTTP Code 202
- The request was valid but the resource wasn't ready. Retry the request with exponential backoff.
- """
- pass
diff --git a/nylas/client/restful_model_collection.py b/nylas/client/restful_model_collection.py
deleted file mode 100644
index 54622284..00000000
--- a/nylas/client/restful_model_collection.py
+++ /dev/null
@@ -1,125 +0,0 @@
-from copy import copy
-
-CHUNK_SIZE = 50
-
-class RestfulModelCollection(object):
- def __init__(self, cls, api, filter=None, offset=0,
- **filters):
- if filter:
- filters.update(filter)
- from nylas.client import APIClient
- if not isinstance(api, APIClient):
- raise Exception("Provided api was not an APIClient.")
-
- filters.setdefault('offset', offset)
-
- self.model_class = cls
- self.filters = filters
- self.api = api
-
- def __iter__(self):
- return self.values()
-
- def values(self):
- offset = self.filters['offset']
- while True:
- models = self._get_model_collection(offset, CHUNK_SIZE)
- if not models:
- break
-
- for model in models:
- yield model
-
- if len(models) < CHUNK_SIZE:
- break
-
- offset += len(models)
-
- def first(self):
- results = self._get_model_collection(0, 1)
- if results:
- return results[0]
- return None
-
- def all(self, limit=float('infinity')):
- return self._range(self.filters['offset'], limit)
-
- def where(self, filter=None, **filters):
- # Some API parameters like "from" and "in" also are
- # Python reserved keywords. To work around this, we rename
- # them to "from_" and "in_". The API still needs them in
- # their correct form though.
- reserved_keywords = ['from', 'in']
- for keyword in reserved_keywords:
- escaped_keyword = '{}_'.format(keyword)
- if escaped_keyword in filters:
- filters[keyword] = filters.get(escaped_keyword)
- del filters[escaped_keyword]
-
- if filter:
- filters.update(filter)
- filters.setdefault('offset', 0)
- collection = copy(self)
- collection.filters = filters
- return collection
-
- def get(self, id):
- return self._get_model(id)
-
- def create(self, **kwargs):
- return self.model_class.create(self.api, **kwargs)
-
- def delete(self, id, data=None, **kwargs):
- return self.api._delete_resource(self.model_class, id, data=data, **kwargs)
-
- def search(self, q): # pylint: disable=invalid-name
- from nylas.client.restful_models import Message, Thread # pylint: disable=cyclic-import
- if self.model_class is Thread or self.model_class is Message:
- kwargs = {'q': q}
- return self.api._get_resources(self.model_class, extra="search", **kwargs)
- else:
- raise Exception("Searching is only allowed on Thread and Message models")
-
- def __getitem__(self, key):
- if isinstance(key, slice):
- if key.step is not None:
- raise ValueError("'step' not supported for slicing "
- "RestfulModelCollection objects "
- "(e.g. messages[::step])")
- elif key.start < 0 or key.stop < 0:
- raise ValueError("slice indices must be positive")
- elif key.stop - key.start < 0:
- raise ValueError("ending slice index cannot be less than "
- "starting index")
- return self._range(key.start, key.stop-key.start)
- else:
- return self._get_model_collection(key, 1)[0]
-
- # Private functions
-
- def _get_model_collection(self, offset=0, limit=CHUNK_SIZE):
- filters = copy(self.filters)
- filters['offset'] = offset
- if not filters.get('limit'):
- filters['limit'] = limit
-
- return self.api._get_resources(self.model_class,
- **filters)
-
- def _get_model(self, id):
- return self.api._get_resource(self.model_class, id,
- **self.filters)
-
- def _range(self, offset=0, limit=CHUNK_SIZE):
- accumulated = []
- while len(accumulated) < limit:
- to_fetch = min(limit-len(accumulated), CHUNK_SIZE)
- results = self._get_model_collection(offset + len(accumulated),
- to_fetch)
- accumulated.extend(results)
-
- # done if we run out of data to fetch
- if not results or len(results) < to_fetch:
- break
-
- return accumulated
diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py
deleted file mode 100644
index 4f9e61f4..00000000
--- a/nylas/client/restful_models.py
+++ /dev/null
@@ -1,593 +0,0 @@
-from datetime import datetime, date
-from collections import defaultdict
-
-from nylas.client.restful_model_collection import RestfulModelCollection
-from nylas.client.errors import FileUploadError, UnSyncedError
-from nylas.utils import timestamp_from_dt
-from six import StringIO
-
-# pylint: disable=attribute-defined-outside-init
-
-
-def typed_dict_attr(items, attr_name=None):
- if attr_name:
- pairs = [(item["type"], item[attr_name]) for item in items]
- else:
- pairs = [(item["type"], item) for item in items]
- dct = defaultdict(list)
- for key, value in pairs:
- dct[key].append(value)
- return dct
-
-
-class NylasAPIObject(dict):
- attrs = []
- date_attrs = {}
- datetime_attrs = {}
- datetime_filter_attrs = {}
- typed_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
- super(NylasAPIObject, self).__init__()
-
- __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) # pylint: disable=no-value-for-parameter
- obj.cls = cls
- for attr in cls.attrs:
- # Support attributes we want to override with properties where
- # the property names overlap with the JSON names (e.g. folders)
- attr_name = attr
- if attr_name.startswith('_'):
- attr = attr_name[1:]
- if attr in kwargs:
- obj[attr_name] = kwargs[attr]
- for date_attr, iso_attr in cls.date_attrs.items():
- if kwargs.get(iso_attr):
- obj[date_attr] = datetime.strptime(kwargs[iso_attr], "%Y-%m-%d").date()
- for dt_attr, ts_attr in cls.datetime_attrs.items():
- if kwargs.get(ts_attr):
- obj[dt_attr] = datetime.utcfromtimestamp(kwargs[ts_attr])
- for attr, value_attr_name in cls.typed_dict_attrs.items():
- obj[attr] = typed_dict_attr(kwargs.get(attr, []), attr_name=value_attr_name)
-
- if 'id' not in kwargs:
- obj['id'] = None
-
- return obj
-
- def as_json(self):
- dct = {}
- for attr in self.cls.attrs:
- if hasattr(self, attr):
- dct[attr] = getattr(self, attr)
- for date_attr, iso_attr in self.cls.date_attrs.items():
- if self.get(date_attr):
- dct[iso_attr] = self[date_attr].strftime("%Y-%m-%d")
- for dt_attr, ts_attr in self.cls.datetime_attrs.items():
- if self.get(dt_attr):
- dct[ts_attr] = timestamp_from_dt(self[dt_attr])
- for attr, value_attr in self.cls.typed_dict_attrs.items():
- typed_dict = getattr(self, attr)
- if value_attr:
- dct[attr] = []
- for key, values in typed_dict.items():
- for value in values:
- dct[attr].append({"type": key, value_attr: value})
- else:
- dct[attr] = []
- for values in typed_dict.values():
- for value in values:
- dct[attr].append(value)
- return dct
-
- 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",
- "headers"]
- datetime_attrs = {
- "received_at": "date",
- }
- datetime_filter_attrs = {
- "received_before": "received_before",
- "received_after": "received_after",
- }
- collection_name = 'messages'
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Message, api)
-
- @property
- def attachments(self):
- return self.child_collection(File, message_id=self.id)
-
- @property
- def folder(self):
- # Instantiate a Folder object from the API response
- if self._folder:
- return Folder.create(self.api, **self._folder)
-
- @property
- def labels(self):
- if self._labels:
- return [Label.create(self.api, **l)
- for l in self._labels]
- return []
-
- def update_folder(self, folder_id):
- update = {'folder': folder_id}
- new_obj = self.api._update_resource(self.cls, self.id, update)
- for attr in self.cls.attrs:
- if hasattr(new_obj, attr):
- setattr(self, attr, getattr(new_obj, attr))
- return self.folder
-
- def update_labels(self, label_ids=None):
- label_ids = label_ids or []
- update = {'labels': label_ids}
- new_obj = self.api._update_resource(self.cls, self.id, update)
- for attr in self.cls.attrs:
- if hasattr(new_obj, attr):
- setattr(self, attr, getattr(new_obj, attr))
- return self.labels
-
- def add_labels(self, label_ids=None):
- label_ids = label_ids or []
- labels = [l.id for l in self.labels]
- labels = list(set(labels).union(set(label_ids)))
- return self.update_labels(labels)
-
- def add_label(self, label_id):
- return self.add_labels([label_id])
-
- def remove_labels(self, label_ids=None):
- label_ids = label_ids or []
- labels = [l.id for l in self.labels]
- labels = list(set(labels) - set(label_ids))
- return self.update_labels(labels)
-
- def remove_label(self, label_id):
- return self.remove_labels([label_id])
-
- def mark_as_seen(self):
- self.mark_as_read()
-
- def mark_as_read(self):
- update = {'unread': False}
- self.api._update_resource(self.cls, self.id, update)
- self.unread = False
-
- def mark_as_unread(self):
- update = {'unread': True}
- self.api._update_resource(self.cls, self.id, update)
- self.unread = True
-
- def star(self):
- update = {'starred': True}
- self.api._update_resource(self.cls, self.id, update)
- self.starred = True
-
- def unstar(self):
- update = {'starred': False}
- self.api._update_resource(self.cls, self.id, update)
- self.starred = False
-
- @property
- def raw(self):
- headers = {"Accept": "message/rfc822"}
- response = self.api._get_resource_raw(Message, self.id, headers=headers)
- if response.status_code == 202:
- raise UnSyncedError(response.content)
- return response.content
-
-
-class Folder(NylasAPIObject):
- attrs = ["id", "display_name", "name", "object", "account_id"]
- collection_name = "folders"
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Folder, api)
-
- @property
- def threads(self):
- return self.child_collection(Thread, folder_id=self.id)
-
- @property
- def messages(self):
- return self.child_collection(Message, folder_id=self.id)
-
-
-class Label(NylasAPIObject):
- attrs = ["id", "display_name", "name", "object", "account_id"]
- collection_name = "labels"
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Label, api)
-
- @property
- def threads(self):
- return self.child_collection(Thread, label_id=self.id)
-
- @property
- def messages(self):
- return self.child_collection(Message, label_id=self.id)
-
-
-class Thread(NylasAPIObject):
- attrs = ["draft_ids", "id", "message_ids", "account_id", "object",
- "participants", "snippet", "subject", "subject_date",
- "last_message_timestamp", "first_message_timestamp",
- "last_message_received_timestamp", "last_message_sent_timestamp",
- "unread", "starred", "version", "_folders", "_labels",
- "received_recent_date"]
- datetime_attrs = {
- "first_message_at": "first_message_timestamp",
- "last_message_at": "last_message_timestamp",
- "last_message_received_at": "last_message_received_timestamp",
- "last_message_sent_at": "last_message_sent_timestamp",
- }
- datetime_filter_attrs = {
- "last_message_before": "last_message_before",
- "last_message_after": "last_message_after",
- "started_before": "started_before",
- "started_after": "started_after",
- }
- collection_name = 'threads'
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Thread, api)
-
- @property
- def messages(self):
- return self.child_collection(Message, thread_id=self.id)
-
- @property
- def drafts(self):
- return self.child_collection(Draft, thread_id=self.id)
-
- @property
- def folders(self):
- if self._folders:
- return [Folder.create(self.api, **f)
- for f in self._folders]
- return []
-
- @property
- def labels(self):
- if self._labels:
- return [Label.create(self.api, **l)
- for l in self._labels]
- return []
-
- def update_folder(self, folder_id):
- update = {'folder': folder_id}
- new_obj = self.api._update_resource(self.cls, self.id, update)
- for attr in self.cls.attrs:
- if hasattr(new_obj, attr):
- setattr(self, attr, getattr(new_obj, attr))
- return self.folder
-
- def update_labels(self, label_ids=None):
- label_ids = label_ids or []
- update = {'labels': label_ids}
- new_obj = self.api._update_resource(self.cls, self.id, update)
- for attr in self.cls.attrs:
- if hasattr(new_obj, attr):
- setattr(self, attr, getattr(new_obj, attr))
- return self.labels
-
- def add_labels(self, label_ids=None):
- label_ids = label_ids or []
- labels = [l.id for l in self.labels]
- labels = list(set(labels).union(set(label_ids)))
- return self.update_labels(labels)
-
- def add_label(self, label_id):
- return self.add_labels([label_id])
-
- def remove_labels(self, label_ids=None):
- label_ids = label_ids or []
- labels = [l.id for l in self.labels]
- labels = list(set(labels) - set(label_ids))
- return self.update_labels(labels)
-
- def remove_label(self, label_id):
- return self.remove_labels([label_id])
-
- def mark_as_seen(self):
- self.mark_as_read()
-
- def mark_as_read(self):
- update = {'unread': False}
- self.api._update_resource(self.cls, self.id, update)
- self.unread = False
-
- def mark_as_unread(self):
- update = {'unread': True}
- self.api._update_resource(self.cls, self.id, update)
- self.unread = True
-
- def star(self):
- update = {'starred': True}
- self.api._update_resource(self.cls, self.id, update)
- self.starred = True
-
- def unstar(self):
- update = {'starred': False}
- self.api._update_resource(self.cls, self.id, update)
- self.starred = False
-
- def create_reply(self):
- draft = self.drafts.create()
- draft.thread_id = self.id
- draft.subject = self.subject
- return draft
-
-# This is a dummy class that allows us to use the create_resource function
-# and pass in a 'Send' object that will translate into a 'send' endpoint.
-class Send(Message):
- collection_name = 'send'
-
- def __init__(self, api): # pylint: disable=super-init-not-called
- NylasAPIObject.__init__(self, Send, api) # pylint: disable=non-parent-init-called
-
-
-class Draft(Message):
- attrs = ["bcc", "cc", "body", "date", "files", "from", "id",
- "account_id", "object", "subject", "thread_id", "to",
- "unread", "version", "file_ids", "reply_to_message_id",
- "reply_to", "starred", "snippet", "tracking"]
- datetime_attrs = {
- "last_modified_at": "date",
- }
- collection_name = 'drafts'
-
- def __init__(self, api, thread_id=None): # pylint: disable=unused-argument
- Message.__init__(self, api)
- NylasAPIObject.__init__(self, Thread, api) # pylint: disable=non-parent-init-called
- self.file_ids = []
-
- def attach(self, file):
- if not file.id:
- file.save()
-
- self.file_ids.append(file.id)
-
- def detach(self, file):
- if file.id in self.file_ids:
- self.file_ids.remove(file.id)
-
- def send(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 is not None:
- data = {'version': self.version}
- self.api._delete_resource(self.cls, self.id, data=data)
-
-class File(NylasAPIObject):
- attrs = ["content_type", "filename", "id", "content_id",
- "account_id", "object", "size", "message_ids", ]
- collection_name = 'files'
-
- def save(self): # pylint: disable=arguments-differ
- stream = getattr(self, "stream", None)
- if not stream:
- data = getattr(self, "data", None)
- if data:
- stream = StringIO(data)
-
- if not stream:
- message = (
- "File object not properly formatted, "
- "must provide either a stream or data."
- )
- raise FileUploadError(message)
-
- file_info = (
- self.filename,
- stream,
- self.content_type,
- {}, # upload headers
- )
-
- new_obj = self.api._create_resources(File, {"file": file_info})
- new_obj = new_obj[0]
- for attr in self.attrs:
- if hasattr(new_obj, attr):
- setattr(self, attr, getattr(new_obj, attr))
-
- def download(self):
- if not self.id:
- message = "Can't download a file that hasn't been uploaded."
- raise FileUploadError(message)
-
- return self.api._get_resource_data(File, self.id,
- extra='download')
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, File, api)
-
-
-class Contact(NylasAPIObject):
- attrs = ["id", "object", "account_id", "given_name", "middle_name",
- "surname", "suffix", "nickname", "company_name",
- "job_title", "manager_name", "office_location", "notes",
- "picture_url"]
- date_attrs = {"birthday": "birthday"}
- typed_dict_attrs = {
- "email_addresses": "email",
- "im_addresses": "im_address",
- "physical_addresses": None,
- "phone_numbers": "number",
- "web_pages": "url",
- }
- collection_name = 'contacts'
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, Contact, api)
-
- def get_picture(self):
- if not self.get("picture_url", None):
- return None
-
- response = self.api._get_resource_raw(
- Contact, self.id, extra='picture', stream=True,
- )
- response.raise_for_status()
- return response.raw
-
-
-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", "message_id"]
- datetime_attrs = {
- "original_start_at": "original_start_time",
- }
- 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 dct.get('when'):
- # Currently, the event (self) and the dict (dct) share the same
- # reference to the `'when'` dict. We need to clone the dict so
- # that when we remove the object key, the original event's
- # `'when'` reference is unmodified.
- dct['when'] = dct['when'].copy()
- dct['when'].pop('object', None)
-
- 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', 'billing_state', 'email', 'id', 'namespace_id',
- 'sync_state', 'trial']
-
- 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):
- return self.api._call_resource_method(
- self, self.account_id, 'upgrade', None
- )
-
- def downgrade(self):
- return self.api._call_resource_method(
- self, self.account_id, 'downgrade', None
- )
-
- def delete(self):
- raise NotImplementedError
-
-
-class APIAccount(NylasAPIObject):
- attrs = ['account_id', 'email_address', 'id', 'name', 'object',
- 'organization_unit', 'provider', 'sync_state']
- datetime_attrs = {
- "linked_at": "linked_at",
- }
-
- collection_name = 'accounts'
-
- def __init__(self, api):
- NylasAPIObject.__init__(self, APIAccount, api)
-
- 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/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/tests/__init__.py b/nylas/handler/__init__.py
similarity index 100%
rename from tests/__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/nylas/models/__init__.py b/nylas/models/__init__.py
new file mode 100644
index 00000000..e69de29b
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.py b/nylas/utils.py
deleted file mode 100644
index 5c9b3891..00000000
--- a/nylas/utils.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from __future__ import division
-from datetime import datetime
-
-
-def timestamp_from_dt(dt, epoch=datetime(1970, 1, 1)):
- """
- Convert a datetime to a timestamp.
- https://stackoverflow.com/a/8778548/141395
- """
- delta = dt - epoch
- # return delta.total_seconds()
- return delta.seconds + delta.days * 86400
-
-
-def convert_datetimes_to_timestamps(data, datetime_attrs):
- """
- Given a dictionary of data, and a dictionary of datetime attributes,
- return a new dictionary that converts any datetime attributes that may
- be present to their timestamped equivalent.
- """
- if not data:
- return data
-
- new_data = {}
- for key, value in data.items():
- if key in datetime_attrs and isinstance(value, datetime):
- new_key = datetime_attrs[key]
- new_data[new_key] = timestamp_from_dt(value)
- else:
- new_data[key] = value
-
- return new_data
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/pylintrc b/pylintrc
deleted file mode 100644
index 1f4681c7..00000000
--- a/pylintrc
+++ /dev/null
@@ -1,445 +0,0 @@
-[MASTER]
-
-# A comma-separated list of package or module names from where C extensions may
-# be loaded. Extensions are loading into the active Python interpreter and may
-# run arbitrary code
-extension-pkg-whitelist=
-
-# Add files or directories to the blacklist. They should be base names, not
-# paths.
-ignore=examples
-
-# Add files or directories matching the regex patterns to the blacklist. The
-# regex matches against base names, not paths.
-ignore-patterns=
-
-# Python code to execute, usually for sys.path manipulation such as
-# pygtk.require().
-#init-hook=
-
-# Use multiple processes to speed up Pylint.
-jobs=1
-
-# List of plugins (as comma separated values of python modules names) to load,
-# usually to register additional checkers.
-load-plugins=
-
-# Pickle collected data for later comparisons.
-persistent=yes
-
-# Specify a configuration file.
-#rcfile=
-
-# Allow loading of arbitrary C extensions. Extensions are imported into the
-# active Python interpreter and may run arbitrary code.
-unsafe-load-any-extension=no
-
-
-[MESSAGES CONTROL]
-
-# Only show warnings with the listed confidence levels. Leave empty to show
-# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
-confidence=
-
-# Disable the message, report, category or checker with the given id(s). You
-# can either give multiple identifiers separated by comma (,) or put this
-# option multiple times (only on the command line, not in the configuration
-# file where it should appear only once).You can also use "--disable=all" to
-# disable everything first and then reenable specific checks. For example, if
-# you want to run only the similarities checker, you can use "--disable=all
-# --enable=similarities". If you want to run only the classes checker, but have
-# no Warning level messages displayed, use"--disable=all --enable=classes
-# --disable=W"
-disable=
- print-statement, parameter-unpacking, unpacking-in-except, old-raise-syntax,
- backtick, long-suffix, old-ne-operator, old-octal-literal,
- import-star-module-level, raw-checker-failed, bad-inline-option,
- locally-disabled, locally-enabled, file-ignored, suppressed-message,
- useless-suppression, deprecated-pragma, apply-builtin, basestring-builtin,
- buffer-builtin, cmp-builtin, coerce-builtin, execfile-builtin,
- file-builtin, long-builtin, raw_input-builtin, reduce-builtin,
- standarderror-builtin, unicode-builtin, xrange-builtin, coerce-method,
- delslice-method, getslice-method, setslice-method, no-absolute-import,
- old-division, dict-iter-method, dict-view-method, next-method-called,
- metaclass-assignment, indexing-exception, raising-string, reload-builtin,
- oct-method, hex-method, nonzero-method, cmp-method, input-builtin,
- round-builtin, intern-builtin, unichr-builtin, map-builtin-not-iterating,
- zip-builtin-not-iterating, range-builtin-not-iterating,
- filter-builtin-not-iterating, using-cmp-argument, eq-without-hash,
- div-method, idiv-method, rdiv-method, exception-message-attribute,
- invalid-str-codec, sys-max-int, bad-python3-import,
- deprecated-string-function, deprecated-str-translate-call, missing-docstring,
- fixme, import-error, redefined-builtin, protected-access,
- cyclic-import, duplicate-code
-
-# Enable the message, report, category or checker with the given id(s). You can
-# either give multiple identifier separated by comma (,) or put this option
-# multiple time (only on the command line, not in the configuration file where
-# it should appear only once). See also the "--disable" option for examples.
-enable=
-
-
-[REPORTS]
-
-# Python expression which should return a note less than 10 (10 is the highest
-# note). You have access to the variables errors warning, statement which
-# respectively contain the number of errors / warnings messages and the total
-# number of statements analyzed. This is used by the global evaluation report
-# (RP0004).
-evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
-
-# Template used to display messages. This is a python new-style format string
-# used to format the message information. See doc for all details
-#msg-template=
-
-# Set the output format. Available formats are text, parseable, colorized, json
-# and msvs (visual studio).You can also give a reporter class, eg
-# mypackage.mymodule.MyReporterClass.
-output-format=text
-
-# Tells whether to display a full report or only the messages
-reports=no
-
-# Activate the evaluation score.
-score=yes
-
-
-[REFACTORING]
-
-# Maximum number of nested blocks for function / method body
-max-nested-blocks=5
-
-
-[BASIC]
-
-# Naming hint for argument names
-argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct argument names
-argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Naming hint for attribute names
-attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct attribute names
-attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Bad variable names which should always be refused, separated by a comma
-bad-names=foo,bar,baz,toto,tutu,tata
-
-# Naming hint for class attribute names
-class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
-
-# Regular expression matching correct class attribute names
-class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
-
-# Naming hint for class names
-class-name-hint=[A-Z_][a-zA-Z0-9]+$
-
-# Regular expression matching correct class names
-class-rgx=[A-Z_][a-zA-Z0-9]+$
-
-# Naming hint for constant names
-const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$
-
-# Regular expression matching correct constant names
-const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
-
-# Minimum line length for functions/classes that require docstrings, shorter
-# ones are exempt.
-docstring-min-length=-1
-
-# Naming hint for function names
-function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct function names
-function-rgx=(([a-z][a-z0-9_]{2,50})|(_[a-z0-9_]*))$
-
-# Good variable names which should always be accepted, separated by a comma
-good-names=i,j,k,ex,Run,_,id,dt,db
-
-# Include a hint for the correct naming format with invalid-name
-include-naming-hint=no
-
-# Naming hint for inline iteration names
-inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$
-
-# Regular expression matching correct inline iteration names
-inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
-
-# Naming hint for method names
-method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct method names
-method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Naming hint for module names
-module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
-
-# Regular expression matching correct module names
-module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
-
-# Colon-delimited sets of names that determine each other's naming style when
-# the name regexes allow several styles.
-name-group=
-
-# Regular expression which should only match function or class names that do
-# not require a docstring.
-no-docstring-rgx=^_
-
-# List of decorators that produce properties, such as abc.abstractproperty. Add
-# to this list to register other decorators that produce valid properties.
-property-classes=abc.abstractproperty
-
-# Naming hint for variable names
-variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-# Regular expression matching correct variable names
-variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
-
-
-[FORMAT]
-
-# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
-expected-line-ending-format=
-
-# Regexp for a line that is allowed to be longer than the limit.
-ignore-long-lines=^\s*(# )??$
-
-# Number of spaces of indent required inside a hanging or continued line.
-indent-after-paren=4
-
-# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
-# tab).
-indent-string=' '
-
-# Maximum number of characters on a single line.
-max-line-length=100
-
-# Maximum number of lines in a module
-max-module-lines=1000
-
-# List of optional constructs for which whitespace checking is disabled. `dict-
-# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}.
-# `trailing-comma` allows a space between comma and closing bracket: (a, ).
-# `empty-line` allows space-only lines.
-no-space-check=trailing-comma,dict-separator
-
-# Allow the body of a class to be on the same line as the declaration if body
-# contains single statement.
-single-line-class-stmt=no
-
-# Allow the body of an if to be on the same line as the test if there is no
-# else.
-single-line-if-stmt=no
-
-
-[LOGGING]
-
-# Logging modules to check that the string format arguments are in logging
-# function parameter format
-logging-modules=logging
-
-
-[MISCELLANEOUS]
-
-# List of note tags to take in consideration, separated by a comma.
-notes=FIXME,XXX,TODO
-
-
-[SIMILARITIES]
-
-# Ignore comments when computing similarities.
-ignore-comments=yes
-
-# Ignore docstrings when computing similarities.
-ignore-docstrings=yes
-
-# Ignore imports when computing similarities.
-ignore-imports=no
-
-# Minimum lines number of a similarity.
-min-similarity-lines=4
-
-
-[SPELLING]
-
-# Spelling dictionary name. Available dictionaries: none. To make it working
-# install python-enchant package.
-spelling-dict=
-
-# List of comma separated words that should not be checked.
-spelling-ignore-words=
-
-# A path to a file that contains private dictionary; one word per line.
-spelling-private-dict-file=
-
-# Tells whether to store unknown words to indicated private dictionary in
-# --spelling-private-dict-file option instead of raising a message.
-spelling-store-unknown-words=no
-
-
-[TYPECHECK]
-
-# List of decorators that produce context managers, such as
-# contextlib.contextmanager. Add to this list to register other decorators that
-# produce valid context managers.
-contextmanager-decorators=contextlib.contextmanager
-
-# List of members which are set dynamically and missed by pylint inference
-# system, and so shouldn't trigger E1101 when accessed. Python regular
-# expressions are accepted.
-generated-members=
-
-# Tells whether missing members accessed in mixin class should be ignored. A
-# mixin class is detected if its name ends with "mixin" (case insensitive).
-ignore-mixin-members=yes
-
-# This flag controls whether pylint should warn about no-member and similar
-# checks whenever an opaque object is returned when inferring. The inference
-# can return multiple potential results while evaluating a Python object, but
-# some branches might not be evaluated, which results in partial inference. In
-# that case, it might be useful to still emit no-member and other checks for
-# the rest of the inferred objects.
-ignore-on-opaque-inference=yes
-
-# List of class names for which member attributes should not be checked (useful
-# for classes with dynamically set attributes). This supports the use of
-# qualified names.
-ignored-classes=optparse.Values,thread._local,_thread._local,responses
-
-# List of module names for which member attributes should not be checked
-# (useful for modules/projects where namespaces are manipulated during runtime
-# and thus existing member attributes cannot be deduced by static analysis. It
-# supports qualified module names, as well as Unix pattern matching.
-ignored-modules=
-
-# Show a hint with possible names when a member name was not found. The aspect
-# of finding the hint is based on edit distance.
-missing-member-hint=yes
-
-# The minimum edit distance a name should have in order to be considered a
-# similar match for a missing member name.
-missing-member-hint-distance=1
-
-# The total number of similar names that should be taken in consideration when
-# showing a hint for a missing member.
-missing-member-max-choices=1
-
-
-[VARIABLES]
-
-# List of additional names supposed to be defined in builtins. Remember that
-# you should avoid to define new builtins when possible.
-additional-builtins=
-
-# Tells whether unused global variables should be treated as a violation.
-allow-global-unused-variables=yes
-
-# List of strings which can identify a callback function by name. A callback
-# name must start or end with one of those strings.
-callbacks=cb_,_cb
-
-# A regular expression matching the name of dummy variables (i.e. expectedly
-# not used).
-dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
-
-# Argument names that match this expression will be ignored. Default to name
-# with leading underscore
-ignored-argument-names=_.*|^ignored_|^unused_
-
-# Tells whether we should check for unused import in __init__ files.
-init-import=no
-
-# List of qualified module names which can have objects that can redefine
-# builtins.
-redefining-builtins-modules=six.moves,future.builtins
-
-
-[CLASSES]
-
-# List of method names used to declare (i.e. assign) instance attributes.
-defining-attr-methods=__init__,__new__,setUp
-
-# List of member names, which should be excluded from the protected access
-# warning.
-exclude-protected=_asdict,_fields,_replace,_source,_make
-
-# List of valid names for the first argument in a class method.
-valid-classmethod-first-arg=cls
-
-# List of valid names for the first argument in a metaclass class method.
-valid-metaclass-classmethod-first-arg=mcs
-
-
-[DESIGN]
-
-# Maximum number of arguments for function / method
-max-args=6
-
-# Maximum number of attributes for a class (see R0902).
-max-attributes=12
-
-# Maximum number of boolean expressions in a if statement
-max-bool-expr=5
-
-# Maximum number of branch for function / method body
-max-branches=12
-
-# Maximum number of locals for function / method body
-max-locals=15
-
-# Maximum number of parents for a class (see R0901).
-max-parents=7
-
-# Maximum number of public methods for a class (see R0904).
-max-public-methods=20
-
-# Maximum number of return / yield for function / method body
-max-returns=6
-
-# Maximum number of statements in function / method body
-max-statements=50
-
-# Minimum number of public methods for a class (see R0903).
-min-public-methods=2
-
-
-[IMPORTS]
-
-# Allow wildcard imports from modules that define __all__.
-allow-wildcard-with-all=no
-
-# Analyse import fallback blocks. This can be used to support both Python 2 and
-# 3 compatible code, which means that the block might have code that exists
-# only in one or another interpreter, leading to false positives when analysed.
-analyse-fallback-blocks=no
-
-# Deprecated modules which should not be used, separated by a comma
-deprecated-modules=regsub,TERMIOS,Bastion,rexec
-
-# Create a graph of external dependencies in the given file (report RP0402 must
-# not be disabled)
-ext-import-graph=
-
-# Create a graph of every (i.e. internal and external) dependencies in the
-# given file (report RP0402 must not be disabled)
-import-graph=
-
-# Create a graph of internal dependencies in the given file (report RP0402 must
-# not be disabled)
-int-import-graph=
-
-# Force import order to recognize a module as part of the standard
-# compatibility libraries.
-known-standard-library=
-
-# Force import order to recognize a module as part of a third party library.
-known-third-party=enchant
-
-
-[EXCEPTIONS]
-
-# Exceptions that will emit a warning when being caught. Defaults to
-# "Exception"
-overgeneral-exceptions=Exception
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/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 e2fc4b7c..bbe31737 100644
--- a/setup.py
+++ b/setup.py
@@ -1,109 +1,134 @@
import os
+import shutil
import sys
import re
-from setuptools import setup, find_packages
-from setuptools.command.test import test as TestCommand
+import subprocess
+from setuptools import setup, find_packages, Command
-VERSION = ''
-with open('nylas/_client_sdk_version.py', 'r') as fd:
- VERSION = re.search(r'^__VERSION__\s*=\s*[\'"]([^\'"]*)[\'"]',
- fd.read(), re.MULTILINE).group(1)
+VERSION = ""
+with open("nylas/_client_sdk_version.py", "r") as fd:
+ VERSION = re.search(
+ r'^__VERSION__\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE
+ ).group(1)
+
+with open("README.md", "r", encoding="utf-8") as f:
+ README = f.read()
RUN_DEPENDENCIES = [
- "requests>=2.4.2",
- "six>=1.4.1",
- "bumpversion>=0.5.0",
- "pyOpenSSL", # needed for SNI support, required by api.nylas.com
- "ndg-httpsclient",
- "pyasn1",
- "urlobject",
+ "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 = [
- # These versions are the latest that still have python 2 support
- 'astroid==1.6.5',
- 'pylint==1.9.3',
- "pytest",
- "pytest-cov",
- "pytest-pylint",
- "responses==0.6.1",
+
+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(TestCommand):
- user_options = [
- ('pytest-args=', 'a', "Arguments to pass to pytest"),
- ('lint', None, "Enable linting with pylint"),
- ]
- boolean_options = ['lint']
+class PyTest(Command):
+ user_options = [("pytest-args=", "a", "Arguments to pass to pytest")]
def initialize_options(self):
- TestCommand.initialize_options(self)
# pylint: disable=attribute-defined-outside-init
- self.pytest_args = ['--cov', '--junitxml', './tests/output', 'tests/']
+ 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
- if self.lint:
- self.pytest_args.append("--pylint")
- 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] == "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':
+ 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,
+ python_requires=">=3.8",
packages=find_packages(),
install_requires=RUN_DEPENDENCIES,
dependency_links=[],
- tests_require=TEST_DEPENDENCIES,
- extras_require={'test': TEST_DEPENDENCIES},
- 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 contacts calendar",
- url='https://github.com/nylas/nylas-python',
- long_description_content_type='text/markdown',
- long_description='''
-# Nylas REST API Python bindings
-[](https://travis-ci.org/nylas/nylas-python)
-[](https://codecov.io/gh/nylas/nylas-python)
-
-Python bindings for the Nylas REST API. https://www.nylas.com/docs
-
-The Nylas APIs power applications with email, calendar, and contacts CRUD and bi-directional sync from any inbox in the world.
-
-Nylas is compatible with 100% of email service providers, so you only have to integrate once.
-No more headaches building unique integrations against archaic and outdated IMAP and SMTP protocols.''',
-
+ 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 2b46e8d7..54eaeb81 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,1220 +1,211 @@
-import os
-import re
-import json
-import copy
-import cgi
-import random
-import string
-import pytest
-import responses
-from nylas import APIClient
-
-# pylint: disable=redefined-outer-name,too-many-lines
-
+from unittest.mock import patch, Mock
-def generate_id(size=25, chars=string.ascii_letters + string.digits):
- return ''.join(random.choice(chars) for _ in range(size))
-
-
-@pytest.fixture
-def message_body():
- return {
- "busy": True,
- "calendar_id": "94rssh7bd3rmsxsp19kiocxze",
- "description": None,
- "id": "cv4ei7syx10uvsxbs21ccsezf",
- "location": "1 Infinite loop, Cupertino",
- "message_id": None,
- "namespace_id": "384uhp3aj8l7rpmv9s2y2rukn",
- "object": "event",
- "owner": None,
- "participants": [],
- "read_only": False,
- "status": "confirmed",
- "title": "The rain song",
- "when": {
- "end_time": 1441056790,
- "object": "timespan",
- "start_time": 1441053190
- }
- }
-
-@pytest.fixture
-def api_url():
- return 'https://localhost:2222'
-
-
-@pytest.fixture
-def account_id():
- return '4ennivvrcgsqytgybfk912dto'
-
-
-@pytest.fixture
-def app_id():
- return 'fake-app-id'
-
-
-@pytest.fixture
-def api_client(api_url):
- return APIClient(None, None, None, api_url)
+import pytest
+import requests
+from nylas.models.response import Response, ListResponse
+from nylas.handler.http_client import HttpClient
-@pytest.fixture
-def mocked_responses():
- rmock = responses.RequestsMock(assert_all_requests_are_fired=False)
- with rmock:
- yield rmock
+from nylas import Client
@pytest.fixture
-def mock_save_draft(mocked_responses, api_url):
- save_endpoint = re.compile(api_url + '/drafts/')
- response_body = json.dumps({
- "id": "4dl0ni6vxomazo73r5oydo16k",
- "version": "4dw0ni6txomazo33r5ozdo16j"
- })
- mocked_responses.add(
- responses.POST,
- save_endpoint,
- content_type='application/json',
- status=200,
- body=response_body,
- match_querystring=True
+def client():
+ return Client(
+ api_key="test-key",
)
@pytest.fixture
-def mock_account(mocked_responses, api_url, account_id):
- response_body = json.dumps(
- {
- "account_id": account_id,
- "email_address": "ben.bitdiddle1861@gmail.com",
- "id": account_id,
- "name": "Ben Bitdiddle",
- "object": "account",
- "provider": "gmail",
- "organization_unit": "label",
- "billing_state": "paid",
- "linked_at": 1500920299,
- "sync_state": "running",
- }
- )
- mocked_responses.add(
- responses.GET,
- re.compile(api_url + '/account/?'),
- content_type='application/json',
- status=200,
- body=response_body,
+def http_client():
+ return HttpClient(
+ api_server="https://test.nylas.com",
+ api_key="test-key",
+ timeout=30,
)
@pytest.fixture
-def mock_accounts(mocked_responses, api_url, account_id, app_id):
- response_body = json.dumps([
- {
- "account_id": account_id,
- "email_address": "ben.bitdiddle1861@gmail.com",
- "id": account_id,
- "name": "Ben Bitdiddle",
- "object": "account",
- "provider": "gmail",
- "organization_unit": "label",
- "billing_state": "paid",
- "linked_at": 1500920299,
- "sync_state": "running",
- }
- ])
- url_re = "{base}(/a/{app_id})?/accounts/?".format(base=api_url, app_id=app_id)
- mocked_responses.add(
- responses.GET,
- re.compile(url_re),
- content_type='application/json',
- status=200,
- body=response_body,
- )
+def patched_version_and_sys():
+ with patch("sys.version_info", (1, 2, 3, "final", 5)), patch(
+ "nylas.handler.http_client.__VERSION__", "2.0.0"
+ ):
+ yield
@pytest.fixture
-def mock_folder_account(mocked_responses, api_url, account_id):
- response_body = json.dumps(
- {
- "email_address": "ben.bitdiddle1861@office365.com",
- "id": account_id,
- "name": "Ben Bitdiddle",
- "account_id": account_id,
- "object": "account",
- "provider": "eas",
- "organization_unit": "folder"
- }
- )
- mocked_responses.add(
- responses.GET,
- api_url + '/account',
- content_type='application/json',
- status=200,
- body=response_body,
- match_querystring=True
- )
+def patched_request():
+ mock_response = Mock()
+ mock_response.content = b"mock data"
+ mock_response.json.return_value = {"foo": "bar"}
+ mock_response.status_code = 200
-
-@pytest.fixture
-def mock_labels(mocked_responses, api_url, account_id):
- response_body = json.dumps([
- {
- "display_name": "Important",
- "id": "anuep8pe5ugmxrucchrzba2o8",
- "name": "important",
- "account_id": account_id,
- "object": "label"
- },
- {
- "display_name": "Trash",
- "id": "f1xgowbgcehk235xiy3c3ek42",
- "name": "trash",
- "account_id": account_id,
- "object": "label"
- },
- {
- "display_name": "Sent Mail",
- "id": "ah14wp5fvypvjjnplh7nxgb4h",
- "name": "sent",
- "account_id": account_id,
- "object": "label"
- },
- {
- "display_name": "All Mail",
- "id": "ah14wp5fvypvjjnplh7nxgb4h",
- "name": "all",
- "account_id": account_id,
- "object": "label"
- },
- {
- "display_name": "Inbox",
- "id": "dc11kl3s9lj4760g6zb36spms",
- "name": "inbox",
- "account_id": account_id,
- "object": "label"
- }
- ])
- endpoint = re.compile(api_url + '/labels.*')
- mocked_responses.add(
- responses.GET,
- endpoint,
- content_type='application/json',
- status=200,
- body=response_body,
- )
+ with patch("requests.request", return_value=mock_response) as mock_request:
+ yield mock_request
@pytest.fixture
-def mock_label(mocked_responses, api_url, account_id):
- response_body = json.dumps(
- {
- "display_name": "Important",
- "id": "anuep8pe5ugmxrucchrzba2o8",
- "name": "important",
- "account_id": account_id,
- "object": "label"
- }
- )
- url = api_url + '/labels/anuep8pe5ugmxrucchrzba2o8'
- mocked_responses.add(
- responses.GET,
- url,
- content_type='application/json',
- status=200,
- body=response_body,
- )
+def mock_session_timeout():
+ with patch("requests.request", side_effect=requests.exceptions.Timeout):
+ yield
@pytest.fixture
-def mock_folder(mocked_responses, api_url, account_id):
- folder = {
- "display_name": "My Folder",
- "id": "anuep8pe5ug3xrupchwzba2o8",
- "name": None,
- "account_id": account_id,
- "object": "folder"
- }
- response_body = json.dumps(folder)
- url = api_url + '/folders/anuep8pe5ug3xrupchwzba2o8'
- mocked_responses.add(
- responses.GET,
- url,
- content_type='application/json',
- status=200,
- body=response_body,
- )
-
- def request_callback(request):
- payload = json.loads(request.body)
- if 'display_name' in payload:
- folder.update(payload)
- return (200, {}, json.dumps(folder))
-
- mocked_responses.add_callback(
- responses.PUT,
- url,
- content_type='application/json',
- callback=request_callback,
- )
+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_messages(mocked_responses, api_url, account_id):
- response_body = json.dumps([
- {
- "id": "1234",
- "subject": "Test Message",
- "account_id": account_id,
- "object": "message",
- "labels": [
- {
- "name": "inbox",
- "display_name": "Inbox",
- "id": "abcd"
- }
- ],
- "starred": False,
- "unread": True,
- "date": 1265077342,
- }, {
- "id": "1238",
- "subject": "Test Message 2",
- "account_id": account_id,
- "object": "message",
- "labels": [
- {
- "name": "inbox",
- "display_name": "Inbox",
- "id": "abcd"
- }
- ],
- "starred": False,
- "unread": True,
- "date": 1265085342,
- }, {
- "id": "12",
- "subject": "Test Message 3",
- "account_id": account_id,
- "object": "message",
- "labels": [
- {
- "name": "archive",
- "display_name": "Archive",
- "id": "gone"
- }
- ],
- "starred": False,
- "unread": False,
- "date": 1265093842,
- }
- ])
- endpoint = re.compile(api_url + '/messages')
- mocked_responses.add(
- responses.GET,
- endpoint,
- content_type='application/json',
- status=200,
- body=response_body
- )
+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 mock_message(mocked_responses, api_url, account_id):
- base_msg = {
- "id": "1234",
- "subject": "Test Message",
- "account_id": account_id,
- "object": "message",
- "labels": [
+def http_client_free_busy():
+ mock_http_client = Mock()
+ mock_http_client._execute.return_value = ({
+ "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5",
+ "data": [
{
- "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')
- mocked_responses.add(
- responses.GET,
- endpoint,
- content_type='application/json',
- status=200,
- body=response_body
- )
- mocked_responses.add_callback(
- responses.PUT,
- endpoint,
- content_type='application/json',
- callback=request_callback
- )
- mocked_responses.add(
- responses.DELETE,
- endpoint,
- content_type='application/json',
- status=200,
- body="",
- )
-
-
-@pytest.fixture
-def mock_threads(mocked_responses, api_url, account_id):
- response_body = json.dumps([
- {
- "id": "5678",
- "subject": "Test Thread",
- "account_id": account_id,
- "object": "thread",
- "folders": [{
- "name": "inbox",
- "display_name": "Inbox",
- "id": "abcd"
- }],
- "starred": True,
- "unread": False,
- "first_message_timestamp": 1451703845,
- "last_message_timestamp": 1483326245,
- "last_message_received_timestamp": 1483326245,
- "last_message_sent_timestamp": 1483232461,
- }
- ])
- endpoint = re.compile(api_url + '/threads')
- mocked_responses.add(
- responses.GET,
- endpoint,
- content_type='application/json',
- status=200,
- body=response_body
- )
-
-
-@pytest.fixture
-def mock_thread(mocked_responses, api_url, account_id):
- base_thrd = {
- "id": "5678",
- "subject": "Test Thread",
- "account_id": account_id,
- "object": "thread",
- "folders": [{
- "name": "inbox",
- "display_name": "Inbox",
- "id": "abcd"
- }],
- "starred": True,
- "unread": False,
- "first_message_timestamp": 1451703845,
- "last_message_timestamp": 1483326245,
- "last_message_received_timestamp": 1483326245,
- "last_message_sent_timestamp": 1483232461,
- }
- response_body = json.dumps(base_thrd)
-
- def request_callback(request):
- payload = json.loads(request.body)
- if 'folder' in payload:
- folder = {'name': 'test', 'display_name': 'test',
- 'id': payload['folder']}
- base_thrd['folders'] = [folder]
- return (200, {}, json.dumps(base_thrd))
-
- endpoint = re.compile(api_url + '/threads/5678')
- mocked_responses.add(
- responses.GET,
- endpoint,
- content_type='application/json',
- status=200,
- body=response_body
- )
- mocked_responses.add_callback(
- responses.PUT,
- endpoint,
- content_type='application/json',
- callback=request_callback,
- )
-
-
-@pytest.fixture
-def mock_labelled_thread(mocked_responses, api_url, account_id):
- base_thread = {
- "id": "111",
- "subject": "Labelled Thread",
- "account_id": account_id,
- "object": "thread",
- "folders": [{
- "name": "inbox",
- "display_name": "Inbox",
- "id": "abcd"
- }],
- "starred": True,
- "unread": False,
- "labels": [
+ "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",
+ },
{
- "display_name": "Important",
- "id": "anuep8pe5ugmxrucchrzba2o8",
- "name": "important",
- "account_id": account_id,
- "object": "label"
- }, {
- "display_name": "Existing",
- "id": "dfslhgy3rlijfhlsujnchefs3",
- "name": "existing",
- "account_id": account_id,
- "object": "label"
- }
+ "email": "user2@example.com",
+ "error": "Unable to resolve e-mail address user2@example.com to an Active Directory object.",
+ "object": "error",
+ },
],
- "first_message_timestamp": 1451703845,
- "last_message_timestamp": 1483326245,
- "last_message_received_timestamp": 1483326245,
- "last_message_sent_timestamp": 1483232461,
- }
- response_body = json.dumps(base_thread)
-
- def request_callback(request):
- payload = json.loads(request.body)
- if 'labels' in payload:
- existing_labels = {
- label["id"]: label
- for label in base_thread["labels"]
- }
- new_labels = []
- for label_id in payload['labels']:
- if label_id in existing_labels:
- new_labels.append(existing_labels[label_id])
- else:
- new_labels.append({
- "name": "updated",
- "display_name": "Updated",
- "id": label_id,
- "account_id": account_id,
- "object": "label",
- })
- copied = copy.copy(base_thread)
- copied["labels"] = new_labels
- return (200, {}, json.dumps(copied))
-
- endpoint = re.compile(api_url + '/threads/111')
- mocked_responses.add(
- responses.GET,
- endpoint,
- content_type='application/json',
- status=200,
- body=response_body
- )
- mocked_responses.add_callback(
- responses.PUT,
- endpoint,
- content_type='application/json',
- callback=request_callback,
- )
+ }, {"X-Test-Header": "test"})
+ return mock_http_client
@pytest.fixture
-def mock_drafts(mocked_responses, api_url):
- 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": [
+def http_client_list_scheduled_messages():
+ mock_http_client = Mock()
+ mock_http_client._execute.return_value = ({
+ "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5",
+ "data": [
{
- "email": "helena@nylas.com",
- "name": "Helena Handbasket"
- }
- ],
- "unread": False,
- "version": 0
- }])
-
- mocked_responses.add(
- responses.GET,
- api_url + '/drafts',
- content_type='application/json',
- status=200,
- body=response_body,
- )
-
-
-@pytest.fixture
-def mock_draft_saved_response(mocked_responses, api_url):
- draft_json = {
- "bcc": [],
- "body": "Cheers mate!",
- "cc": [],
- "date": 1438684486,
- "events": [],
- "files": [],
- "folder": None,
- "from": [],
- "id": "2h111aefv8pzwzfykrn7hercj",
- "namespace_id": "384uhp3aj8l7rpmv9s2y2rukn",
- "object": "draft",
- "reply_to": [],
- "reply_to_message_id": None,
- "snippet": "",
- "starred": False,
- "subject": "Here's an attachment",
- "thread_id": "clm33kapdxkposgltof845v9s",
- "to": [
+ "schedule_id": "8cd56334-6d95-432c-86d1-c5dab0ce98be",
+ "status": {
+ "code": "pending",
+ "description": "schedule send awaiting send at time",
+ },
+ },
{
- "email": "helena@nylas.com",
- "name": "Helena Handbasket"
- }
+ "schedule_id": "rb856334-6d95-432c-86d1-c5dab0ce98be",
+ "status": {"code": "success", "description": "schedule send succeeded"},
+ "close_time": 1690579819,
+ },
],
- "unread": False,
- "version": 0,
- }
-
- def create_callback(_request):
- return (200, {}, json.dumps(draft_json))
-
- def update_callback(request):
- try:
- payload = json.loads(request.body)
- except ValueError:
- return (200, {}, json.dumps(draft_json))
-
- stripped_payload = {
- key: value for key, value in payload.items() if value
- }
- updated_draft_json = copy.copy(draft_json)
- updated_draft_json.update(stripped_payload)
- updated_draft_json["version"] += 1
- return (200, {}, json.dumps(updated_draft_json))
-
- mocked_responses.add_callback(
- responses.POST,
- api_url + '/drafts/',
- content_type='application/json',
- callback=create_callback,
- )
-
- mocked_responses.add_callback(
- responses.PUT,
- api_url + '/drafts/2h111aefv8pzwzfykrn7hercj',
- content_type='application/json',
- callback=update_callback,
- )
-
-
-@pytest.fixture
-def mock_draft_deleted_response(mocked_responses, api_url):
- mocked_responses.add(
- responses.DELETE,
- api_url + '/drafts/2h111aefv8pzwzfykrn7hercj',
- content_type='application/json',
- status=200,
- body="",
- )
+ }, {"X-Test-Header": "test"})
+ return mock_http_client
@pytest.fixture
-def mock_draft_sent_response(mocked_responses, api_url):
- body = {
- "bcc": [],
- "body": "",
- "cc": [],
- "date": 1438684486,
- "events": [],
- "files": [],
- "folder": None,
- "from": [{'email': 'benb@nylas.com'}],
- "id": "2h111aefv8pzwzfykrn7hercj",
- "namespace_id": "384uhp3aj8l7rpmv9s2y2rukn",
- "object": "draft",
- "reply_to": [],
- "reply_to_message_id": None,
- "snippet": "",
- "starred": False,
- "subject": "Stay polish, stay hungary",
- "thread_id": "clm33kapdxkposgltof845v9s",
- "to": [
+def http_client_clean_messages():
+ mock_http_client = Mock()
+ mock_http_client._execute.return_value = ({
+ "request_id": "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5",
+ "data": [
{
- "email": "helena@nylas.com",
- "name": "Helena Handbasket"
- }
- ],
- "unread": False,
- "version": 0,
- }
-
- values = [(400, {}, "Couldn't send email"),
- (200, {}, json.dumps(body))]
-
- def callback(request):
- payload = json.loads(request.body)
- assert payload['draft_id'] == '2h111aefv8pzwzfykrn7hercj'
- return values.pop()
-
- mocked_responses.add_callback(
- responses.POST,
- api_url + '/send/',
- callback=callback,
- content_type='application/json'
- )
-
-
-@pytest.fixture
-def mock_files(mocked_responses, api_url, account_id):
- files_content = {
- "3qfe4k3siosfjtjpfdnon8zbn": b"Hello, World!",
- }
- files_metadata = {
- "3qfe4k3siosfjtjpfdnon8zbn": {
- "id": "3qfe4k3siosfjtjpfdnon8zbn",
- "content_type": "text/plain",
- "filename": "hello.txt",
- "account_id": account_id,
- "object": "file",
- "size": len(files_content["3qfe4k3siosfjtjpfdnon8zbn"])
- }
- }
- mocked_responses.add(
- responses.GET,
- api_url + '/files',
- body=json.dumps(list(files_metadata.values())),
- )
- for file_id in files_content:
- mocked_responses.add(
- responses.POST,
- "{base}/files/{file_id}".format(base=api_url, file_id=file_id),
- body=json.dumps(files_metadata[file_id]),
- )
- mocked_responses.add(
- responses.GET,
- "{base}/files/{file_id}/download".format(base=api_url, file_id=file_id),
- body=files_content[file_id],
- )
-
- def create_callback(request):
- uploaded_lines = request.body.decode('utf8').splitlines()
- content_disposition = uploaded_lines[1]
- _, params = cgi.parse_header(content_disposition)
- filename = params.get("filename", None)
- content = "".join(uploaded_lines[3:-1])
- size = len(content.encode('utf8'))
-
- body = [{
- "id": generate_id(),
- "content_type": "text/plain",
- "filename": filename,
- "account_id": account_id,
- "object": "file",
- "size": size,
- }]
- return (200, {}, json.dumps(body))
-
- mocked_responses.add_callback(
- responses.POST,
- api_url + '/files/',
- callback=create_callback,
- )
-
-
-@pytest.fixture
-def mock_event_create_response(mocked_responses, api_url, message_body):
- values = [(400, {}, ""),
- (200, {}, json.dumps(message_body))]
-
- def callback(_request):
- return values.pop()
-
- mocked_responses.add_callback(
- responses.POST,
- api_url + '/events/',
- callback=callback,
- )
-
- put_body = {'title': 'loaded from JSON', 'ignored': 'ignored'}
- mocked_responses.add(
- responses.PUT,
- api_url + '/events/cv4ei7syx10uvsxbs21ccsezf',
- body=json.dumps(put_body)
- )
-
-
-@pytest.fixture
-def mock_event_create_notify_response(mocked_responses, api_url, message_body):
- mocked_responses.add(
- responses.POST,
- api_url + '/events/?notify_participants=true&other_param=1',
- body=json.dumps(message_body),
- )
-
-
-
-@pytest.fixture
-def mock_thread_search_response(mocked_responses, api_url):
- snippet = (
- "Hey Helena, Looking forward to getting together for dinner on Friday. "
- "What can I bring? I have a couple bottles of wine or could put together"
- )
- response_body = json.dumps([
- {
- "id": "evh5uy0shhpm5d0le89goor17",
- "object": "thread",
- "account_id": "awa6ltos76vz5hvphkp8k17nt",
- "subject": "Dinner Party on Friday",
- "unread": False,
- "starred": False,
- "last_message_timestamp": 1398229259,
- "last_message_received_timestamp": 1398229259,
- "first_message_timestamp": 1298229259,
- "participants": [
- {
- "name": "Ben Bitdiddle",
- "email": "ben.bitdiddle@gmail.com"
- },
- ],
- "snippet": snippet,
- "folders": [
- {
- "name": "inbox",
- "display_name": "INBOX",
- "id": "f0idlvozkrpj3ihxze7obpivh"
- },
- ],
- "message_ids": [
- "251r594smznew6yhiocht2v29",
- "7upzl8ss738iz8xf48lm84q3e",
- "ah5wuphj3t83j260jqucm9a28"
- ],
- "draft_ids": [
- "251r594smznew6yhi12312saq"
- ],
- "version": 2
- }
- ])
-
- mocked_responses.add(
- responses.GET,
- api_url + '/threads/search?q=Helena',
- body=response_body,
- status=200,
- content_type='application/json',
- match_querystring=True
- )
-
-@pytest.fixture
-def mock_message_search_response(mocked_responses, api_url):
- snippet = (
- "Sounds good--that bottle of Pinot should go well with the meal. "
- "I'll also bring a surprise for dessert. :) "
- "Do you have ice cream? Looking fo"
- )
- response_body = json.dumps([
- {
- "id": "84umizq7c4jtrew491brpa6iu",
- "object": "message",
- "account_id": "14e5bn96uizyuhidhcw5rfrb0",
- "thread_id": "5vryyrki4fqt7am31uso27t3f",
- "subject": "Re: Dinner on Friday?",
- "from": [
- {
- "name": "Ben Bitdiddle",
- "email": "ben.bitdiddle@gmail.com"
- }
- ],
- "to": [
- {
- "name": "Bill Rogers",
- "email": "wbrogers@mit.edu"
- }
- ],
- "cc": [],
- "bcc": [],
- "reply_to": [],
- "date": 1370084645,
- "unread": True,
- "starred": False,
- "folder": {
- "name": "inbox",
- "display_name": "INBOX",
- "id": "f0idlvozkrpj3ihxze7obpivh"
+ "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",
},
- "snippet": snippet,
- "body": "....",
- "files": [],
- "events": []
- },
- {
- "id": "84umizq7asdf3aw491brpa6iu",
- "object": "message",
- "account_id": "14e5bakdsfljskidhcw5rfrb0",
- "thread_id": "5vryyralskdjfwlj1uso27t3f",
- "subject": "Re: Dinner on Friday?",
- "from": [
- {
- "name": "Ben Bitdiddle",
- "email": "ben.bitdiddle@gmail.com"
- }
- ],
- "to": [
- {
- "name": "Bill Rogers",
- "email": "wbrogers@mit.edu"
- }
- ],
- "cc": [],
- "bcc": [],
- "reply_to": [],
- "date": 1370084645,
- "unread": True,
- "starred": False,
- "folder": {
- "name": "inbox",
- "display_name": "INBOX",
- "id": "f0idlvozkrpj3ihxze7obpivh"
- },
- "snippet": snippet,
- "body": "....",
- "files": [],
- "events": []
- }
- ])
-
- mocked_responses.add(
- responses.GET,
- api_url + '/messages/search?q=Pinot',
- body=response_body,
- status=200,
- content_type='application/json',
- match_querystring=True
- )
-
-
-@pytest.fixture
-def mock_calendars(mocked_responses, api_url):
- response_body = json.dumps([
- {
- "id": "8765",
- "events": [
- {
- "title": "Pool party",
- "location": "Local Community Pool",
- "participants": [
- "Alice",
- "Bob",
- "Claire",
- "Dot",
- ]
- }
- ],
- }
- ])
- endpoint = re.compile(api_url + '/calendars')
- mocked_responses.add(
- responses.GET,
- endpoint,
- content_type='application/json',
- status=200,
- body=response_body
- )
-
-
-
-@pytest.fixture
-def mock_contacts(mocked_responses, account_id, api_url):
- contact1 = {
- 'id': '5x6b54whvcz1j22ggiyorhk9v',
- 'object': 'contact',
- 'account_id': account_id,
- 'given_name': 'Charlie',
- 'middle_name': None,
- 'surname': 'Bucket',
- 'birthday': "1964-10-05",
- 'suffix': None,
- 'nickname': None,
- 'company_name': None,
- 'job_title': "Student",
- 'manager_name': None,
- 'office_location': None,
- 'notes': None,
- 'picture_url': "{base}/contacts/{id}/picture".format(
- base=api_url, id='5x6b54whvcz1j22ggiyorhk9v'
- ),
- 'email_addresses': [{'email': 'charlie@gmail.com', 'type': None}],
- 'im_addresses': [],
- 'physical_addresses': [],
- 'phone_numbers': [],
- 'web_pages': [],
- }
- contact2 = {
- 'id': '4zqkfw8k1d12h0k784ipeh498',
- 'object': 'contact',
- 'account_id': account_id,
- 'given_name': 'William',
- 'middle_name': 'J',
- 'surname': 'Wonka',
- 'birthday': "1955-02-28",
- 'suffix': None,
- 'nickname': None,
- 'company_name': None,
- 'job_title': "Chocolate Artist",
- 'manager_name': None,
- 'office_location': "Willy Wonka Factory",
- 'notes': None,
- 'picture_url': None,
- 'email_addresses': [{'email': 'scrumptious@wonka.com', 'type': None}],
- 'im_addresses': [],
- 'physical_addresses': [],
- 'phone_numbers': [],
- 'web_pages': [{"type": "work", "url": "http://www.wonka.com"}],
- }
- contact3 = {
- 'id': '9fn1aoi2i00qv6h1zpag6b26w',
- 'object': 'contact',
- 'account_id': account_id,
- 'given_name': 'Oompa',
- 'middle_name': None,
- 'surname': 'Loompa',
- 'birthday': None,
- 'suffix': None,
- 'nickname': None,
- 'company_name': None,
- 'job_title': None,
- 'manager_name': None,
- 'office_location': "Willy Wonka Factory",
- 'notes': None,
- 'picture_url': None,
- 'email_addresses': [],
- 'im_addresses': [],
- 'physical_addresses': [],
- 'phone_numbers': [],
- 'web_pages': [],
- }
- contacts = [contact1, contact2, contact3]
-
- def create_callback(request):
- payload = json.loads(request.body)
- payload["id"] = generate_id()
- return (200, {}, json.dumps(payload))
-
- for contact in contacts:
- mocked_responses.add(
- responses.GET,
- re.compile(api_url + '/contacts/' + contact["id"]),
- content_type='application/json',
- status=200,
- body=json.dumps(contact)
- )
- if contact.get("picture_url"):
- mocked_responses.add(
- responses.GET,
- contact["picture_url"],
- content_type='image/jpeg',
- status=200,
- body=os.urandom(50),
- stream=True,
- )
- else:
- mocked_responses.add(
- responses.GET,
- "{base}/contacts/{id}/picture".format(
- base=api_url, id=contact["id"],
- ),
- status=404,
- body=""
- )
- mocked_responses.add(
- responses.GET,
- re.compile(api_url + '/contacts'),
- content_type='application/json',
- status=200,
- body=json.dumps(contacts)
- )
- mocked_responses.add_callback(
- responses.POST,
- api_url + '/contacts/',
- content_type='application/json',
- callback=create_callback,
- )
-
-
-@pytest.fixture
-def mock_contact(mocked_responses, account_id, api_url):
- contact = {
- 'id': '9hga75n6mdvq4zgcmhcn7hpys',
- 'object': 'contact',
- 'account_id': account_id,
- 'given_name': 'Given',
- 'middle_name': 'Middle',
- 'surname': 'Sur',
- 'birthday': "1964-10-05",
- 'suffix': 'Jr',
- 'nickname': 'Testy',
- 'company_name': "Test Data Inc",
- 'job_title': "QA Tester",
- 'manager_name': "George",
- 'office_location': "Over the Rainbow",
- 'notes': "This is a note",
- 'picture_url': "{base}/contacts/{id}/picture".format(
- base=api_url, id='9hga75n6mdvq4zgcmhcn7hpys'
- ),
- 'email_addresses': [
- {"type": "first", "email": "one@example.com"},
- {"type": "second", "email": "two@example.com"},
- {"type": "primary", "email": "abc@example.com"},
- {"type": "primary", "email": "xyz@example.com"},
- {"type": None, "email": "unknown@example.com"},
- ],
- 'im_addresses': [
- {"type": "aim", "im_address": "SmarterChild"},
- {"type": "gtalk", "im_address": "fake@gmail.com"},
- {"type": "gtalk", "im_address": "fake2@gmail.com"},
- ],
- 'physical_addresses': [
{
- "type": "home",
- "format": "structured",
- "street_address": "123 Awesome Street",
- "postal_code": "99989",
- "state": "CA",
- "country": "America",
- }
- ],
- 'phone_numbers': [
- {"type": "home", "number": "555-555-5555"},
- {"type": "mobile", "number": "555-555-5555"},
- {"type": "mobile", "number": "987654321"},
- ],
- 'web_pages': [
- {"type": "profile", "url": "http://www.facebook.com/abc"},
- {"type": "profile", "url": "http://www.twitter.com/abc"},
- {"type": None, "url": "http://example.com"},
+ "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",
+ },
],
- }
-
- def update_callback(request):
- try:
- payload = json.loads(request.body)
- except ValueError:
- return (200, {}, json.dumps(contact))
-
- stripped_payload = {
- key: value for key, value in payload.items() if value
- }
- updated_contact_json = copy.copy(contact)
- updated_contact_json.update(stripped_payload)
- return (200, {}, json.dumps(updated_contact_json))
-
- mocked_responses.add(
- responses.GET,
- "{base}/contacts/{id}".format(
- base=api_url, id=contact["id"]
- ),
- content_type='application/json',
- status=200,
- body=json.dumps(contact)
- )
- mocked_responses.add(
- responses.GET,
- contact["picture_url"],
- content_type='image/jpeg',
- status=200,
- body=os.urandom(50),
- stream=True,
- )
-
- mocked_responses.add_callback(
- responses.PUT,
- "{base}/contacts/{id}".format(
- base=api_url, id=contact["id"]
- ),
- content_type='application/json',
- callback=update_callback,
- )
-
-
-@pytest.fixture
-def mock_events(mocked_responses, api_url):
- response_body = json.dumps([
- {
- "title": "Pool party",
- "location": "Local Community Pool",
- "participants": [
- {
- "comment": None,
- "email": "kelly@nylas.com",
- "name": "Kelly Nylanaut",
- "status": "noreply",
- }, {
- "comment": None,
- "email": "sarah@nylas.com",
- "name": "Sarah Nylanaut",
- "status": "no",
- },
- ]
- }
- ])
- endpoint = re.compile(api_url + '/events')
- mocked_responses.add(
- responses.GET,
- endpoint,
- content_type='application/json',
- status=200,
- body=response_body
- )
-
-
-@pytest.fixture
-def mock_account_management(mocked_responses, api_url, account_id, app_id):
- account = {
- "account_id": account_id,
- "email_address": "ben.bitdiddle1861@gmail.com",
- "id": account_id,
- "name": "Ben Bitdiddle",
- "object": "account",
- "provider": "gmail",
- "organization_unit": "label",
- "billing_state": "paid",
- }
- paid_response = json.dumps(account)
- account["billing_state"] = "cancelled"
- cancelled_response = json.dumps(account)
-
- upgrade_url = "{base}/a/{app_id}/accounts/{id}/upgrade".format(
- base=api_url, id=account_id, app_id=app_id,
- )
- downgrade_url = "{base}/a/{app_id}/accounts/{id}/downgrade".format(
- base=api_url, id=account_id, app_id=app_id,
- )
- mocked_responses.add(
- responses.POST,
- upgrade_url,
- content_type='application/json',
- status=200,
- body=paid_response,
- )
- mocked_responses.add(
- responses.POST,
- downgrade_url,
- content_type='application/json',
- status=200,
- body=cancelled_response,
- )
+ }, {"X-Test-Header": "test"})
+ return mock_http_client
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/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/test_accounts.py b/tests/test_accounts.py
deleted file mode 100644
index 1594c2b6..00000000
--- a/tests/test_accounts.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from datetime import datetime
-import pytest
-from nylas.client.restful_models import Account, APIAccount, SingletonAccount
-
-
-def test_create_account(api_client, monkeypatch):
- monkeypatch.setattr(api_client, "is_opensource_api", lambda: False)
- account = api_client.accounts.create()
- assert isinstance(account, Account)
-
-
-def test_create_apiaccount(api_client, monkeypatch):
- monkeypatch.setattr(api_client, "is_opensource_api", lambda: True)
- account = api_client.accounts.create()
- assert isinstance(account, APIAccount)
-
-
-def test_account_json(api_client, monkeypatch):
- monkeypatch.setattr(api_client, "is_opensource_api", lambda: False)
- account = api_client.accounts.create()
- result = account.as_json()
- assert isinstance(result, dict)
-
-
-@pytest.mark.usefixtures("mock_account")
-def test_account_datetime(api_client):
- account = api_client.account
- assert account.linked_at == datetime(2017, 7, 24, 18, 18, 19)
-
-
-@pytest.mark.usefixtures("mock_accounts", "mock_account_management")
-def test_account_upgrade(api_client, app_id):
- api_client.app_id = app_id
- account = api_client.accounts.first()
- assert account.billing_state == "paid"
- account = account.downgrade()
- assert account.billing_state == "cancelled"
- account = account.upgrade()
- assert account.billing_state == "paid"
-
-
-def test_account_delete(api_client, monkeypatch):
- monkeypatch.setattr(api_client, "is_opensource_api", lambda: False)
- account = api_client.accounts.create()
- with pytest.raises(NotImplementedError):
- account.delete()
-
-
-@pytest.mark.usefixtures("mock_accounts", "mock_account")
-def test_account_access(api_client):
- account1 = api_client.account
- assert isinstance(account1, SingletonAccount)
- account2 = api_client.accounts[0]
- assert isinstance(account2, APIAccount)
- account3 = api_client.accounts.first()
- assert isinstance(account3, APIAccount)
- assert account1.as_json() == account2.as_json() == account3.as_json()
diff --git a/tests/test_client.py b/tests/test_client.py
index 3af21d62..9b81f338 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -1,222 +1,119 @@
-import re
-import json
-from six.moves.urllib.parse import parse_qs
-import pytest
-from urlobject import URLObject
-import responses
-from nylas.client import APIClient
-from nylas.client.restful_models import Contact
-
-
-def urls_equal(url1, url2):
- """
- Compare two URLObjects, without regard to the order of their query strings.
- """
- return (
- url1.without_query() == url2.without_query() and
- url1.query_dict == url2.query_dict
- )
-
-
-def test_custom_client():
- # Can specify API server
- custom = APIClient(api_server="https://example.com")
- assert custom.api_server == "https://example.com"
- # Must be a valid URL
- with pytest.raises(Exception) as exc:
- APIClient(api_server="invalid")
- assert exc.value.args[0] == (
- "When overriding the Nylas API server address, "
- "you must include https://"
- )
-
-
-def test_client_access_token():
- client = APIClient(access_token="foo")
- assert client.access_token == "foo"
- assert client.session.headers['Authorization'] == "Bearer foo"
- client.access_token = "bar"
- assert client.access_token == "bar"
- assert client.session.headers['Authorization'] == "Bearer bar"
- client.access_token = None
- assert client.access_token is None
- assert 'Authorization' not in client.session.headers
-
-
-def test_client_app_secret():
- client = APIClient(app_secret="foo")
- headers = client.admin_session.headers
- assert headers['Authorization'] == "Basic Zm9vOg=="
- assert headers['X-Nylas-API-Wrapper'] == "python"
- assert "Nylas Python SDK" in headers['User-Agent']
-
-
-def test_client_authentication_url(api_client, api_url):
- expected = (
- URLObject(api_url)
- .with_path("/oauth/authorize")
- .set_query_params([
- ('login_hint', ''),
- ('state', ''),
- ('redirect_uri', '/redirect'),
- ('response_type', 'code'),
- ('client_id', 'None'),
- ('scope', 'email'),
- ])
- )
- actual = URLObject(api_client.authentication_url("/redirect"))
- assert urls_equal(expected, actual)
-
- actual2 = URLObject(
- api_client.authentication_url("/redirect", login_hint="hint")
- )
- expected2 = expected.set_query_param("login_hint", "hint")
- assert urls_equal(expected2, actual2)
-
- actual3 = URLObject(
- api_client.authentication_url("/redirect", state="confusion")
- )
- expected3 = expected.set_query_param("state", "confusion")
- assert urls_equal(expected3, actual3)
-
-
-def test_client_token_for_code(mocked_responses, api_client, api_url):
- endpoint = re.compile(api_url + '/oauth/token')
- response_body = json.dumps({"access_token": "hooray"})
- mocked_responses.add(
- responses.POST,
- endpoint,
- content_type='application/json',
- status=200,
- body=response_body,
- )
-
- assert api_client.token_for_code("foo") == "hooray"
- assert len(mocked_responses.calls) == 1
- request = mocked_responses.calls[0].request
- body = parse_qs(request.body)
- assert body["grant_type"] == ["authorization_code"]
- assert body["code"] == ["foo"]
-
-
-def test_client_opensource_api(api_client):
- # pylint: disable=singleton-comparison
- assert api_client.is_opensource_api() == True
- api_client.app_id = "foo"
- api_client.app_secret = "super-sekrit"
- assert api_client.is_opensource_api() == False
- api_client.app_id = api_client.app_secret = None
- assert api_client.is_opensource_api() == True
-
-
-def test_client_revoke_token(mocked_responses, api_client, api_url):
- endpoint = re.compile(api_url + '/oauth/revoke')
- mocked_responses.add(
- responses.POST,
- endpoint,
- status=200,
- body="",
- )
-
- api_client.auth_token = "foo"
- api_client.access_token = "bar"
- api_client.revoke_token()
- assert api_client.auth_token is None
- assert api_client.access_token is None
- assert len(mocked_responses.calls) == 1
-
-
-def test_create_resources(mocked_responses, api_client, api_url):
- contacts_data = [
- {
- "id": 1,
- "name": "first",
- "email": "first@example.com",
- }, {
- "id": 2,
- "name": "second",
- "email": "second@example.com",
- }
- ]
- mocked_responses.add(
- responses.POST,
- api_url + "/contacts/",
- content_type='application/json',
- status=200,
- body=json.dumps(contacts_data),
- )
-
- post_data = list(contacts_data) # make a copy
- for contact in post_data:
- del contact["id"]
-
- contacts = api_client._create_resources(Contact, post_data)
- assert len(contacts) == 2
- assert all(isinstance(contact, Contact) for contact in contacts)
- assert len(mocked_responses.calls) == 1
-
-
-def test_call_resource_method(mocked_responses, api_client, api_url):
- contact_data = {
- "id": 1,
- "name": "first",
- "email": "first@example.com",
- }
- mocked_responses.add(
- responses.POST,
- api_url + "/contacts/1/remove_duplicates",
- content_type='application/json',
- status=200,
- body=json.dumps(contact_data),
- )
-
- contact = api_client._call_resource_method(
- Contact, 1, "remove_duplicates", {}
- )
- assert isinstance(contact, Contact)
- assert len(mocked_responses.calls) == 1
-
-
-def test_201_response(mocked_responses, api_client, api_url):
- contact_data = {
- "id": 1,
- "given_name": "Charlie",
- "surname": "Bucket",
- }
- mocked_responses.add(
- responses.POST,
- api_url + "/contacts/",
- content_type='application/json',
- status=201, # This HTTP status still indicates success,
- # even though it's not 200.
- body=json.dumps(contact_data),
- )
- contact = api_client.contacts.create()
- contact.save()
- assert len(mocked_responses.calls) == 1
-
-
-def test_301_response(mocked_responses, api_client, api_url):
- contact_data = {
- "id": 1,
- "given_name": "Charlie",
- "surname": "Bucket",
- }
- mocked_responses.add(
- responses.GET,
- api_url + "/contacts/first",
- status=301,
- headers={"Location": api_url + "/contacts/1"}
- )
- mocked_responses.add(
- responses.GET,
- api_url + "/contacts/1",
- content_type='application/json',
- status=200,
- body=json.dumps(contact_data),
- )
- contact = api_client.contacts.get("first")
- assert contact["id"] == 1
- assert contact["given_name"] == "Charlie"
- assert contact["surname"] == "Bucket"
- assert len(mocked_responses.calls) == 2
+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_contacts.py b/tests/test_contacts.py
deleted file mode 100644
index c4b946ee..00000000
--- a/tests/test_contacts.py
+++ /dev/null
@@ -1,171 +0,0 @@
-import json
-from datetime import date
-import pytest
-from six import binary_type
-from nylas.client.restful_models import Contact
-
-
-@pytest.mark.usefixtures("mock_contacts")
-def test_list_contacts(api_client):
- contacts = api_client.contacts
- contacts = [c for c in contacts]
- assert len(contacts) == 3
- assert all(isinstance(x, Contact) for x in contacts)
-
-
-@pytest.mark.usefixtures("mock_contact")
-def test_get_contact(api_client):
- contact = api_client.contacts.get('9hga75n6mdvq4zgcmhcn7hpys')
- assert contact is not None
- assert isinstance(contact, Contact)
- assert contact.given_name == 'Given'
- assert contact.surname == 'Sur'
- assert contact.birthday == date(1964, 10, 5)
-
-
-@pytest.mark.usefixtures("mock_contacts")
-def test_create_contact(api_client, mocked_responses):
- contact = api_client.contacts.create()
- contact.given_name = "Monkey"
- contact.surname = "Business"
- assert not mocked_responses.calls
- contact.save()
- assert len(mocked_responses.calls) == 1
- assert contact.id is not None
- assert contact.given_name == "Monkey"
- assert contact.surname == "Business"
-
-
-@pytest.mark.usefixtures("mock_contact")
-def test_update_contact(api_client, mocked_responses):
- contact = api_client.contacts.get('9hga75n6mdvq4zgcmhcn7hpys')
- assert len(mocked_responses.calls) == 1
- assert contact.job_title == "QA Tester"
- contact.job_title = "Factory Owner"
- contact.office_location = "Willy Wonka Factory"
- contact.save()
- assert len(mocked_responses.calls) == 2
- assert contact.id == '9hga75n6mdvq4zgcmhcn7hpys'
- assert contact.job_title == "Factory Owner"
- assert contact.office_location == "Willy Wonka Factory"
-
-
-@pytest.mark.usefixtures("mock_contact")
-def test_contact_picture(api_client, mocked_responses):
- contact = api_client.contacts.get('9hga75n6mdvq4zgcmhcn7hpys')
- assert len(mocked_responses.calls) == 1
- assert contact.picture_url
- picture = contact.get_picture()
- assert len(mocked_responses.calls) == 2
- picture_call = mocked_responses.calls[1]
- assert contact.picture_url == picture_call.request.url
- assert picture.headers["Content-Type"] == "image/jpeg"
- content = picture.read()
- assert isinstance(content, binary_type)
-
-
-@pytest.mark.usefixtures("mock_contacts")
-def test_contact_no_picture(api_client, mocked_responses):
- contact = api_client.contacts.get('4zqkfw8k1d12h0k784ipeh498')
- assert len(mocked_responses.calls) == 1
- assert not contact.picture_url
- picture = contact.get_picture()
- assert len(mocked_responses.calls) == 1
- assert not picture
-
-
-@pytest.mark.usefixtures("mock_contact")
-def test_contact_emails(api_client):
- contact = api_client.contacts.get('9hga75n6mdvq4zgcmhcn7hpys')
- assert isinstance(contact.email_addresses, dict)
- assert contact.email_addresses["first"] == ["one@example.com"]
- assert contact.email_addresses["second"] == ["two@example.com"]
- assert contact.email_addresses["primary"] == ["abc@example.com", "xyz@example.com"]
- assert contact.email_addresses[None] == ["unknown@example.com"]
- assert "absent" not in contact.email_addresses
-
-
-@pytest.mark.usefixtures("mock_contact")
-def test_contact_im_addresses(api_client):
- contact = api_client.contacts.get('9hga75n6mdvq4zgcmhcn7hpys')
- assert isinstance(contact.im_addresses, dict)
- assert contact.im_addresses["aim"] == ["SmarterChild"]
- assert contact.im_addresses["gtalk"] == ["fake@gmail.com", "fake2@gmail.com"]
- assert "absent" not in contact.im_addresses
-
-
-@pytest.mark.usefixtures("mock_contact")
-def test_contact_physical_addresses(api_client):
- contact = api_client.contacts.get('9hga75n6mdvq4zgcmhcn7hpys')
- assert isinstance(contact.physical_addresses, dict)
- addr = contact.physical_addresses["home"][0]
- assert isinstance(addr, dict)
- assert addr["format"] == "structured"
- assert addr["street_address"] == "123 Awesome Street"
- assert "absent" not in contact.physical_addresses
-
-
-@pytest.mark.usefixtures("mock_contact")
-def test_contact_phone_numbers(api_client):
- contact = api_client.contacts.get('9hga75n6mdvq4zgcmhcn7hpys')
- assert isinstance(contact.phone_numbers, dict)
- assert contact.phone_numbers["home"] == ["555-555-5555"]
- assert contact.phone_numbers["mobile"] == ["555-555-5555", "987654321"]
- assert "absent" not in contact.phone_numbers
-
-
-@pytest.mark.usefixtures("mock_contact")
-def test_contact_web_pages(api_client):
- contact = api_client.contacts.get('9hga75n6mdvq4zgcmhcn7hpys')
- assert isinstance(contact.web_pages, dict)
- profiles = ["http://www.facebook.com/abc", "http://www.twitter.com/abc"]
- assert contact.web_pages["profile"] == profiles
- assert contact.web_pages[None] == ["http://example.com"]
- assert "absent" not in contact.web_pages
-
-
-@pytest.mark.usefixtures("mock_contact")
-def test_update_contact_special_values(api_client, mocked_responses):
- contact = api_client.contacts.get('9hga75n6mdvq4zgcmhcn7hpys')
- assert len(mocked_responses.calls) == 1
- contact.birthday = date(1999, 3, 6)
- contact.email_addresses["absent"].append("absent@fake.com")
- contact.im_addresses["absent"].append("absent-im")
- contact.physical_addresses["absent"].append({
- "type": "absent",
- "format": "structured",
- "street_address": "123 Absent Street",
- })
- contact.phone_numbers["absent"].append("222-333-4444")
- contact.web_pages["absent"].append("http://absent.com/me")
- contact.save()
- assert len(mocked_responses.calls) == 2
- assert contact.id == '9hga75n6mdvq4zgcmhcn7hpys'
- assert contact.email_addresses["absent"] == ["absent@fake.com"]
- assert contact.im_addresses["absent"] == ["absent-im"]
- assert contact.physical_addresses["absent"] == [{
- "type": "absent",
- "format": "structured",
- "street_address": "123 Absent Street",
- }]
- assert contact.phone_numbers["absent"] == ["222-333-4444"]
- assert contact.web_pages["absent"] == ["http://absent.com/me"]
-
- request = mocked_responses.calls[-1].request
- req_body = json.loads(request.body)
- birthday = "1999-03-06"
- email_address = {"type": "absent", "email": "absent@fake.com"}
- im_address = {"type": "absent", "im_address": "absent-im"}
- physical_address = {
- "type": "absent",
- "format": "structured",
- "street_address": "123 Absent Street",
- }
- phone_number = {"type": "absent", "number": "222-333-4444"}
- web_page = {"type": "absent", "url": "http://absent.com/me"}
- assert req_body["birthday"] == birthday
- assert email_address in req_body["email_addresses"]
- assert im_address in req_body["im_addresses"]
- assert physical_address in req_body["physical_addresses"]
- assert phone_number in req_body["phone_numbers"]
- assert web_page in req_body["web_pages"]
diff --git a/tests/test_drafts.py b/tests/test_drafts.py
deleted file mode 100644
index 44a4a88b..00000000
--- a/tests/test_drafts.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from datetime import datetime
-
-import pytest
-from requests import RequestException
-from nylas.utils import timestamp_from_dt
-
-# pylint: disable=len-as-condition
-
-
-@pytest.mark.usefixtures("mock_drafts")
-def test_draft_attrs(api_client):
- draft = api_client.drafts.first()
- expected_modified = datetime(2015, 8, 4, 10, 34, 46)
- assert draft.last_modified_at == expected_modified
- assert draft.date == timestamp_from_dt(expected_modified)
-
-
-@pytest.mark.usefixtures(
- "mock_draft_saved_response", "mock_draft_sent_response"
-)
-def test_save_send_draft(api_client):
- draft = api_client.drafts.create()
- draft.to = [{'name': 'My Friend', 'email': 'my.friend@example.com'}]
- draft.subject = "Here's an attachment"
- draft.body = "Cheers mate!"
- draft.save()
-
- draft.subject = "Stay polish, stay hungary"
- draft.save()
- assert draft.subject == "Stay polish, stay hungary"
-
- msg = draft.send()
- assert msg['thread_id'] == 'clm33kapdxkposgltof845v9s'
-
- # Second time should throw an error
- with pytest.raises(RequestException):
- draft.send()
-
-
-@pytest.mark.usefixtures("mock_files")
-def test_draft_attachment(api_client):
- draft = api_client.drafts.create()
- attachment = api_client.files.create()
- attachment.filename = "dummy"
- attachment.data = "data"
-
- assert len(draft.file_ids) == 0
- draft.attach(attachment)
- assert len(draft.file_ids) == 1
- assert attachment.id in draft.file_ids
-
- unattached = api_client.files.create()
- unattached.filename = "unattached"
- unattached.data = "foo"
- draft.detach(unattached)
- assert len(draft.file_ids) == 1
- assert attachment.id in draft.file_ids
- assert unattached.id not in draft.file_ids
-
- draft.detach(attachment)
- assert len(draft.file_ids) == 0
-
-
-@pytest.mark.usefixtures(
- "mock_draft_saved_response", "mock_draft_deleted_response"
-)
-def test_delete_draft(api_client):
- draft = api_client.drafts.create()
- # Unsaved draft shouldn't throw an error on .delete(), but won't actually
- # delete anything.
- draft.delete()
- # Now save the draft...
- draft.save()
- # ... and delete it for real.
- draft.delete()
-
-
-@pytest.mark.usefixtures("mock_draft_saved_response")
-def test_draft_version(api_client):
- draft = api_client.drafts.create()
- assert 'version' not in draft
- draft.save()
- assert draft['version'] == 0
- draft.update()
- assert draft['version'] == 1
- draft.update()
- assert draft['version'] == 2
diff --git a/tests/test_events.py b/tests/test_events.py
deleted file mode 100644
index c329e029..00000000
--- a/tests/test_events.py
+++ /dev/null
@@ -1,49 +0,0 @@
-import pytest
-from urlobject import URLObject
-from requests import RequestException
-from nylas.client.restful_models import Event
-
-
-def blank_event(api_client):
- event = api_client.events.create()
- event.title = "Paris-Brest"
- event.calendar_id = 'calendar_id'
- event.when = {'start_time': 1409594400, 'end_time': 1409594400}
- return event
-
-
-@pytest.mark.usefixtures("mock_event_create_response")
-def test_event_crud(api_client):
- event1 = blank_event(api_client)
- event1.save()
- assert event1.id == 'cv4ei7syx10uvsxbs21ccsezf'
-
- event1.title = 'blah'
- event1.save()
- assert event1.title == 'loaded from JSON'
- assert event1.get('ignored') is None
-
- # Third time should fail.
- event2 = blank_event(api_client)
- with pytest.raises(RequestException):
- event2.save()
-
-
-@pytest.mark.usefixtures("mock_event_create_notify_response")
-def test_event_notify(mocked_responses, api_client):
- event1 = blank_event(api_client)
- event1.save(notify_participants='true', other_param='1')
- assert event1.id == 'cv4ei7syx10uvsxbs21ccsezf'
-
- url = mocked_responses.calls[-1].request.url
- query = URLObject(url).query_dict
- assert query['notify_participants'] == 'true'
- assert query['other_param'] == '1'
-
-
-@pytest.mark.usefixtures("mock_calendars", "mock_events")
-def test_calendar_events(api_client):
- calendar = api_client.calendars.first()
- assert calendar.events
- assert all(isinstance(event, Event)
- for event in calendar.events)
diff --git a/tests/test_files.py b/tests/test_files.py
deleted file mode 100644
index 536500b7..00000000
--- a/tests/test_files.py
+++ /dev/null
@@ -1,85 +0,0 @@
-import cgi
-from io import BytesIO
-import pytest
-from nylas.client.errors import FileUploadError
-
-
-@pytest.mark.usefixtures("mock_files")
-def test_file_upload_data(api_client, mocked_responses):
- data = "Hello, World!"
-
- myfile = api_client.files.create()
- myfile.filename = 'hello.txt'
- myfile.data = data
-
- assert not mocked_responses.calls
- myfile.save()
- assert len(mocked_responses.calls) == 1
-
- assert myfile.filename == 'hello.txt'
- assert myfile.size == 13
-
- upload_body = mocked_responses.calls[0].request.body
- upload_lines = upload_body.decode("utf8").splitlines()
-
- content_disposition = upload_lines[1]
- _, params = cgi.parse_header(content_disposition)
- assert params["filename"] == "hello.txt"
- assert "Hello, World!" in upload_lines
-
-
-@pytest.mark.usefixtures("mock_files")
-def test_file_upload_stream(api_client, mocked_responses):
- stream = BytesIO(b"Hello, World!")
- stream.name = "wacky.txt"
-
- myfile = api_client.files.create()
- myfile.filename = 'hello.txt'
- myfile.stream = stream
- assert not mocked_responses.calls
- myfile.save()
- assert len(mocked_responses.calls) == 1
-
- assert myfile.filename == 'hello.txt'
- assert myfile.size == 13
-
- upload_body = mocked_responses.calls[0].request.body
- upload_lines = upload_body.decode("utf8").splitlines()
-
- content_disposition = upload_lines[1]
- _, params = cgi.parse_header(content_disposition)
- assert params["filename"] == "hello.txt"
- assert "Hello, World!" in upload_lines
-
-
-@pytest.mark.usefixtures("mock_files")
-def test_file_download(api_client, mocked_responses):
- assert not mocked_responses.calls
- myfile = api_client.files.first()
- assert len(mocked_responses.calls) == 1
- data = myfile.download().decode()
- assert len(mocked_responses.calls) == 2
- assert data == "Hello, World!"
-
-
-def test_file_invalid_upload(api_client):
- myfile = api_client.files.create()
- with pytest.raises(FileUploadError) as exc:
- myfile.save()
-
- assert str(exc.value) == (
- "File object not properly formatted, "
- "must provide either a stream or data."
- )
-
-
-def test_file_upload_errors(api_client):
- myfile = api_client.files.create()
- myfile.filename = 'test.txt'
- myfile.data = "Hello World."
-
- with pytest.raises(FileUploadError) as exc:
- myfile.download()
-
- assert str(exc.value) == ("Can't download a file that "
- "hasn't been uploaded.")
diff --git a/tests/test_filter.py b/tests/test_filter.py
deleted file mode 100644
index 0981d92b..00000000
--- a/tests/test_filter.py
+++ /dev/null
@@ -1,79 +0,0 @@
-import json
-import random
-import responses
-from urlobject import URLObject
-
-
-def test_no_filter(mocked_responses, api_client, api_url, message_body):
- message_body_list_50 = [message_body for _ in range(1, 51)]
- message_body_list_22 = [message_body for _ in range(1, 23)]
-
- values = [
- (200, {}, json.dumps(message_body_list_22)),
- (200, {}, json.dumps(message_body_list_50)),
- ]
-
- def callback(_request):
- return values.pop()
-
- mocked_responses.add_callback(
- responses.GET,
- api_url + '/events',
- callback=callback,
- )
-
- events = api_client.events.all()
- assert len(events) == 72
- assert events[0].id == 'cv4ei7syx10uvsxbs21ccsezf'
-
-
-def test_two_filters(mocked_responses, api_client, api_url):
- mocked_responses.add(
- responses.GET,
- api_url + '/events?param1=a¶m2=b',
- body='[]',
- )
- events = api_client.events.where(param1='a', param2='b').all()
- assert len(events) == 0 # pylint: disable=len-as-condition
- url = mocked_responses.calls[-1].request.url
- query = URLObject(url).query_dict
- assert query['param1'] == 'a'
- assert query['param2'] == 'b'
-
-def test_no_offset(mocked_responses, api_client, api_url):
- mocked_responses.add(
- responses.GET,
- api_url + '/events?in=Nylas',
- body='[]',
- )
- list(api_client.events.where({'in': 'Nylas'}).values())
- url = mocked_responses.calls[-1].request.url
- query = URLObject(url).query_dict
- assert query['in'] == 'Nylas'
- assert query['offset'] == '0'
-
-def test_zero_offset(mocked_responses, api_client, api_url):
- mocked_responses.add(
- responses.GET,
- api_url + '/events?in=Nylas&offset=0',
- body='[]',
- )
- list(api_client.events.where({'in': 'Nylas', 'offset': 0}).values())
- url = mocked_responses.calls[-1].request.url
- query = URLObject(url).query_dict
- assert query['in'] == 'Nylas'
- assert query['offset'] == '0'
-
-def test_non_zero_offset(mocked_responses, api_client, api_url):
- offset = random.randint(1, 1000)
- mocked_responses.add(
- responses.GET,
- api_url + '/events?in=Nylas&offset=' + str(offset),
- body='[]',
- )
-
- list(api_client.events.where({'in': 'Nylas', 'offset': offset}).values())
- url = mocked_responses.calls[-1].request.url
- query = URLObject(url).query_dict
- assert query['in'] == 'Nylas'
- assert query['offset'] == str(offset)
diff --git a/tests/test_folders.py b/tests/test_folders.py
deleted file mode 100644
index 6263c71e..00000000
--- a/tests/test_folders.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import pytest
-from nylas.client.restful_models import Folder, Thread, Message
-
-
-@pytest.mark.usefixtures("mock_folder")
-def test_get_change_folder(api_client):
- folder = api_client.folders.get('anuep8pe5ug3xrupchwzba2o8')
- assert folder is not None
- assert isinstance(folder, Folder)
- assert folder.display_name == 'My Folder'
- folder.display_name = 'My New Folder'
- folder.save()
- assert folder.display_name == 'My New Folder'
-
-
-@pytest.mark.usefixtures("mock_folder", "mock_threads")
-def test_folder_threads(api_client):
- folder = api_client.folders.get('anuep8pe5ug3xrupchwzba2o8')
- assert folder.threads
- assert all(isinstance(thread, Thread)
- for thread in folder.threads)
-
-
-@pytest.mark.usefixtures("mock_folder", "mock_messages")
-def test_folder_messages(api_client):
- folder = api_client.folders.get('anuep8pe5ug3xrupchwzba2o8')
- assert folder.messages
- assert all(isinstance(message, Message)
- for message in folder.messages)
diff --git a/tests/test_labels.py b/tests/test_labels.py
deleted file mode 100644
index 84c6057a..00000000
--- a/tests/test_labels.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import pytest
-from nylas.client.restful_models import Label, Thread, Message
-
-
-@pytest.mark.usefixtures("mock_labels")
-def test_list_labels(api_client):
- labels = api_client.labels
- labels = [l for l in labels]
- assert len(labels) == 5
- assert all(isinstance(x, Label) for x in labels)
-
-
-@pytest.mark.usefixtures("mock_label")
-def test_get_label(api_client):
- label = api_client.labels.get('anuep8pe5ugmxrucchrzba2o8')
- assert label is not None
- assert isinstance(label, Label)
- assert label.display_name == 'Important'
-
-
-@pytest.mark.usefixtures("mock_label", "mock_threads")
-def test_label_threads(api_client):
- label = api_client.labels.get('anuep8pe5ugmxrucchrzba2o8')
- assert label.threads
- assert all(isinstance(thread, Thread)
- for thread in label.threads)
-
-
-@pytest.mark.usefixtures("mock_label", "mock_messages")
-def test_label_messages(api_client):
- label = api_client.labels.get('anuep8pe5ugmxrucchrzba2o8')
- assert label.messages
- assert all(isinstance(message, Message)
- for message in label.messages)
diff --git a/tests/test_messages.py b/tests/test_messages.py
deleted file mode 100644
index 6e76bf18..00000000
--- a/tests/test_messages.py
+++ /dev/null
@@ -1,120 +0,0 @@
-from datetime import datetime
-import json
-
-import six
-import pytest
-from urlobject import URLObject
-from nylas.client.restful_models import Message
-from nylas.utils import timestamp_from_dt
-
-
-@pytest.mark.usefixtures("mock_messages")
-def test_messages(api_client):
- message = api_client.messages.first()
- assert len(message.labels) == 1
- assert message.labels[0].display_name == 'Inbox'
- assert message.folder is None
- assert message.unread
- assert not message.starred
-
-
-@pytest.mark.usefixtures("mock_messages")
-def test_message_attrs(api_client):
- message = api_client.messages.first()
- expected_received = datetime(2010, 2, 2, 2, 22, 22)
- assert message.received_at == expected_received
- assert message.date == timestamp_from_dt(expected_received)
-
-
-@pytest.mark.usefixtures("mock_account", "mock_messages", "mock_message")
-def test_message_stars(api_client):
- message = api_client.messages.first()
- assert message.starred is False
- message.star()
- assert message.starred is True
- message.unstar()
- assert message.starred is False
-
-
-@pytest.mark.usefixtures("mock_account", "mock_messages", "mock_message")
-def test_message_read(api_client):
- message = api_client.messages.first()
- assert message.unread is True
- message.mark_as_read()
- assert message.unread is False
- message.mark_as_unread()
- assert message.unread is True
- # mark_as_seen() is a synonym for mark_as_read()
- message.mark_as_seen()
- assert message.unread is False
-
-
-@pytest.mark.usefixtures("mock_account", "mock_messages", "mock_message")
-def test_message_labels(api_client):
- message = api_client.messages.first()
- message.add_label('fghj')
- msg_labels = [l.id for l in message.labels]
- assert 'abcd' in msg_labels
- assert 'fghj' in msg_labels
- message.remove_label('fghj')
- msg_labels = [l.id for l in message.labels]
- assert 'abcd' in msg_labels
- assert 'fghj' not in msg_labels
-
- # Test that folders don't do anything when labels are in effect
- message.update_folder('zxcv')
- assert message.folder is None
-
-
-@pytest.mark.usefixtures("mock_account", "mock_message", "mock_messages")
-def test_message_raw(api_client, account_id):
- message = api_client.messages.first()
- assert isinstance(message.raw, six.binary_type)
- parsed = json.loads(message.raw)
- assert parsed == {
- "object": "message",
- "account_id": account_id,
- "labels": [{
- "display_name": "Inbox",
- "name": "inbox",
- "id": "abcd",
- }],
- "starred": False,
- "unread": True,
- "id": "1234",
- "subject": "Test Message"
- }
-
-
-@pytest.mark.usefixtures("mock_message")
-def test_message_delete_by_id(mocked_responses, api_client):
- api_client.messages.delete(1234, forceful=True)
- assert len(mocked_responses.calls) == 1
- request = mocked_responses.calls[0].request
- url = URLObject(request.url)
- assert url.query_dict["forceful"] == "True"
-
-
-@pytest.mark.usefixtures("mock_messages")
-def test_slice_messages(api_client):
- messages = api_client.messages[0:2]
- assert len(messages) == 3
- assert all(isinstance(message, Message) for message in messages)
-
-
-@pytest.mark.usefixtures("mock_messages")
-def test_filter_messages_dt(mocked_responses, api_client):
- api_client.messages.where(received_before=datetime(2010, 6, 1)).all()
- assert len(mocked_responses.calls) == 1
- request = mocked_responses.calls[0].request
- url = URLObject(request.url)
- assert url.query_dict["received_before"] == "1275350400"
-
-
-@pytest.mark.usefixtures("mock_messages")
-def test_filter_messages_ts(mocked_responses, api_client):
- api_client.messages.where(received_before=1275350400).all()
- assert len(mocked_responses.calls) == 1
- request = mocked_responses.calls[0].request
- url = URLObject(request.url)
- assert url.query_dict["received_before"] == "1275350400"
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_search.py b/tests/test_search.py
deleted file mode 100644
index bdac4b32..00000000
--- a/tests/test_search.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import pytest
-
-
-@pytest.mark.usefixtures("mock_thread_search_response")
-def test_search_threads(api_client):
- threads = api_client.threads.search("Helena")
- assert len(threads) == 1
- assert "Helena" in threads[0].snippet
-
-
-@pytest.mark.usefixtures("mock_message_search_response")
-def test_search_messages(api_client):
- messages = api_client.messages.search("Pinot")
- assert len(messages) == 2
- assert "Pinot" in messages[0].snippet
- assert "Pinot" in messages[1].snippet
-
-
-@pytest.mark.usefixtures("mock_message_search_response")
-def test_search_drafts(api_client):
- with pytest.raises(Exception):
- api_client.drafts.search("Pinot")
diff --git a/tests/test_send_error_handling.py b/tests/test_send_error_handling.py
deleted file mode 100644
index ae105075..00000000
--- a/tests/test_send_error_handling.py
+++ /dev/null
@@ -1,83 +0,0 @@
-import json
-import re
-import pytest
-import responses
-import six
-from requests import RequestException
-from nylas.client.errors import MessageRejectedError
-
-
-def mock_sending_error(http_code, message, mocked_responses, api_url, server_error=None):
- send_endpoint = re.compile(api_url + '/send')
- response_body = {
- "type": "api_error",
- "message": message
- }
-
- if six.PY2 and http_code == 429:
- # Python 2 `httplib` doesn't know about status code 429
- six.moves.http_client.responses[429] = "Too Many Requests"
-
- if server_error is not None:
- response_body['server_error'] = server_error
-
- response_body = json.dumps(response_body)
- mocked_responses.add(
- responses.POST,
- send_endpoint,
- content_type='application/json',
- status=http_code,
- body=response_body,
- )
-
-
-@pytest.mark.usefixtures("mock_account", "mock_save_draft")
-def test_handle_message_rejected(mocked_responses, api_client, api_url):
- draft = api_client.drafts.create()
- error_message = 'Sending to all recipients failed'
- mock_sending_error(402, error_message, mocked_responses, api_url=api_url)
- with pytest.raises(MessageRejectedError):
- draft.send()
-
-
-@pytest.mark.usefixtures("mock_account", "mock_save_draft")
-def test_handle_quota_exceeded(mocked_responses, api_client, api_url):
- draft = api_client.drafts.create()
- error_message = 'Daily sending quota exceeded'
- mock_sending_error(429, error_message, mocked_responses, api_url=api_url)
- with pytest.raises(RequestException) as exc:
- draft.send()
- assert "Too Many Requests" in str(exc.value)
-
-
-@pytest.mark.usefixtures("mock_account", "mock_save_draft")
-def test_handle_service_unavailable(mocked_responses, api_client, api_url):
- draft = api_client.drafts.create()
- error_message = 'The server unexpectedly closed the connection'
- mock_sending_error(503, error_message, mocked_responses, api_url=api_url)
- with pytest.raises(RequestException) as exc:
- draft.send()
- assert "Service Unavailable" in str(exc.value)
-
-
-@pytest.mark.usefixtures("mock_account", "mock_save_draft")
-def test_returns_server_error(mocked_responses, api_client, api_url):
- draft = api_client.drafts.create()
- error_message = 'The server unexpectedly closed the connection'
- reason = 'Rejected potential SPAM'
- mock_sending_error(503, error_message, mocked_responses, api_url=api_url,
- server_error=reason)
- with pytest.raises(RequestException) as exc:
- draft.send()
-
- assert "Service Unavailable" in str(exc.value)
-
-
-@pytest.mark.usefixtures("mock_account", "mock_save_draft")
-def test_doesnt_return_server_error_if_not_defined(mocked_responses, api_client, api_url):
- draft = api_client.drafts.create()
- error_message = 'The server unexpectedly closed the connection'
- mock_sending_error(503, error_message, mocked_responses, api_url=api_url)
- with pytest.raises(RequestException) as exc:
- draft.send()
- assert "Service Unavailable" in str(exc.value)
diff --git a/tests/test_threads.py b/tests/test_threads.py
deleted file mode 100644
index 161c12f3..00000000
--- a/tests/test_threads.py
+++ /dev/null
@@ -1,153 +0,0 @@
-from datetime import datetime
-
-import pytest
-from urlobject import URLObject
-from nylas.client.restful_models import Message, Draft, Label
-from nylas.utils import timestamp_from_dt
-
-
-@pytest.mark.usefixtures("mock_threads")
-def test_thread_attrs(api_client):
- thread = api_client.threads.first()
- expected_first = datetime(2016, 1, 2, 3, 4, 5)
- expected_last = datetime(2017, 1, 2, 3, 4, 5)
- expected_last_received = datetime(2017, 1, 2, 3, 4, 5)
- expected_last_sent = datetime(2017, 1, 1, 1, 1, 1)
-
- assert thread.first_message_timestamp == timestamp_from_dt(expected_first)
- assert thread.first_message_at == expected_first
- assert thread.last_message_timestamp == timestamp_from_dt(expected_last)
- assert thread.last_message_at == expected_last
- assert thread.last_message_received_timestamp == timestamp_from_dt(expected_last_received)
- assert thread.last_message_received_at == expected_last_received
- assert thread.last_message_sent_timestamp == timestamp_from_dt(expected_last_sent)
- assert thread.last_message_sent_at == expected_last_sent
-
-
-def test_update_thread_attrs(api_client):
- thread = api_client.threads.create()
- first = datetime(2017, 2, 3, 10, 0, 0)
- second = datetime(2016, 10, 5, 14, 30, 0)
- # timestamps and datetimes are handled totally separately
- thread.last_message_at = first
- thread.last_message_timestamp = timestamp_from_dt(second)
- assert thread.last_message_at == first
- assert thread.last_message_timestamp == timestamp_from_dt(second)
- # but datetimes overwrite timestamps when serializing to JSON
- assert thread.as_json()['last_message_timestamp'] == timestamp_from_dt(first)
-
-
-@pytest.mark.usefixtures("mock_threads")
-def test_thread_folder(api_client):
- thread = api_client.threads.first()
- assert len(thread.labels) == 0 # pylint: disable=len-as-condition
- assert len(thread.folders) == 1
- assert thread.folders[0].display_name == 'Inbox'
- assert not thread.unread
- assert thread.starred
-
-
-@pytest.mark.usefixtures("mock_folder_account", "mock_threads", "mock_thread")
-def test_thread_change(api_client):
- thread = api_client.threads.first()
-
- assert thread.starred
- thread.unstar()
- assert not thread.starred
- thread.star()
- assert thread.starred
-
- thread.update_folder('qwer')
- assert len(thread.folders) == 1
- assert thread.folders[0].id == 'qwer'
-
-
-@pytest.mark.usefixtures("mock_threads", "mock_messages")
-def test_thread_messages(api_client):
- thread = api_client.threads.first()
- assert thread.messages
- assert all(isinstance(message, Message)
- for message in thread.messages)
-
-
-@pytest.mark.usefixtures("mock_threads", "mock_drafts")
-def test_thread_drafts(api_client):
- thread = api_client.threads.first()
- assert thread.drafts
- assert all(isinstance(draft, Draft)
- for draft in thread.drafts)
-
-
-@pytest.mark.usefixtures("mock_labelled_thread", "mock_labels")
-def test_thread_label(api_client):
- thread = api_client.threads.get(111)
- assert len(thread.labels) == 2
- assert all(isinstance(label, Label)
- for label in thread.labels)
-
- returned = thread.add_label("fake1")
- assert len(thread.labels) == 3
- assert thread.labels == returned
-
- returned = thread.remove_label("fake1")
- assert len(thread.labels) == 2 # pylint: disable=len-as-condition
- assert thread.labels == returned
-
-
-@pytest.mark.usefixtures("mock_labelled_thread", "mock_labels")
-def test_thread_labels(api_client):
- thread = api_client.threads.get(111)
- assert len(thread.labels) == 2
- assert all(isinstance(label, Label)
- for label in thread.labels)
-
- returned = thread.add_labels(["fake1", "fake2"])
- assert len(thread.labels) == 4
- assert thread.labels == returned
-
- label_ids = [l.id for l in thread.labels]
- returned = thread.remove_labels(label_ids)
- assert len(thread.labels) == 0 # pylint: disable=len-as-condition
- assert thread.labels == returned
-
-
-@pytest.mark.usefixtures("mock_threads", "mock_thread")
-def test_thread_read(api_client):
- thread = api_client.threads.first()
- assert thread.unread is False
- thread.mark_as_unread()
- assert thread.unread is True
- thread.mark_as_read()
- assert thread.unread is False
- # mark_as_seen() is a synonym for mark_as_read()
- thread.mark_as_unread()
- assert thread.unread is True
- thread.mark_as_seen()
- assert thread.unread is False
-
-
-@pytest.mark.usefixtures("mock_threads")
-def test_thread_reply(api_client):
- thread = api_client.threads.first()
- draft = thread.create_reply()
- assert isinstance(draft, Draft)
- assert draft.thread_id == thread.id
- assert draft.subject == thread.subject
-
-
-@pytest.mark.usefixtures("mock_threads")
-def test_filter_threads_dt(mocked_responses, api_client):
- api_client.threads.where(started_before=datetime(2010, 6, 1)).all()
- assert len(mocked_responses.calls) == 1
- request = mocked_responses.calls[0].request
- url = URLObject(request.url)
- assert url.query_dict["started_before"] == "1275350400"
-
-
-@pytest.mark.usefixtures("mock_threads")
-def test_filter_threads_ts(mocked_responses, api_client):
- api_client.threads.where(started_before=1275350400).all()
- assert len(mocked_responses.calls) == 1
- request = mocked_responses.calls[0].request
- url = URLObject(request.url)
- assert url.query_dict["started_before"] == "1275350400"
diff --git a/tests/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 80e89c02..00000000
--- a/tox.ini
+++ /dev/null
@@ -1,7 +0,0 @@
-[tox]
-envlist = py27,pypy,py34
-
-[testenv]
-commands =
- pip install -e .[test]
- pytest