diff --git a/.arcconfig b/.arcconfig deleted file mode 100644 index 0df938ee..00000000 --- a/.arcconfig +++ /dev/null @@ -1,3 +0,0 @@ -{ - "conduit_uri" : "https://phab.nylas.com/" -} diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c796aed1..0808f5a9 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 5.14.1 +current_version = 6.0.0 [bumpversion:file:nylas/_client_sdk_version.py] diff --git a/.coveragerc b/.coveragerc index 8460a941..01586ee0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,3 @@ [run] source = nylas +omit = tests/* diff --git a/.github/workflows/sdk-reference.yml b/.github/workflows/sdk-reference.yml new file mode 100644 index 00000000..bf16e9ff --- /dev/null +++ b/.github/workflows/sdk-reference.yml @@ -0,0 +1,43 @@ +name: sdk-reference + +on: + push: + branches: + - main + pull_request: + +jobs: + docs: + runs-on: ubuntu-latest + environment: + name: sdk-reference + url: ${{ steps.deploy.outputs.url }} + steps: + - uses: actions/checkout@v2 + - name: Setup Nodejs + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install dependencies and build + run: pip install .[docs] + - name: Build docs + run: python setup.py build-docs + - name: Set env BRANCH + run: echo "BRANCH=$(echo $GITHUB_REF | cut -d'/' -f 3)" >> $GITHUB_ENV + - name: Set env CLOUDFLARE_BRANCH + run: | + if [[ $BRANCH == 'main' && $GITHUB_EVENT_NAME == 'push' ]]; then + echo "CLOUDFLARE_BRANCH=main" >> "$GITHUB_ENV" + else + echo "CLOUDFLARE_BRANCH=$BRANCH" >> "$GITHUB_ENV" + fi + - name: Publish to Cloudflare Pages + uses: cloudflare/pages-action@v1 + id: deploy + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: nylas-python-sdk-reference + directory: site + wranglerVersion: "3" + branch: ${{ env.CLOUDFLARE_BRANCH }} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1337be5d..f7763d52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,12 +14,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["2.x", "3.x"] + python-version: ["3.8", "3.x"] name: Python ${{ matrix.python-version }} steps: - uses: actions/checkout@v2 - name: Setup python - uses: MatteoH2O1999/setup-python@v1.4.1 + uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} @@ -35,16 +35,22 @@ jobs: black: runs-on: ubuntu-latest - name: Black + name: Pylint and Black steps: - uses: actions/checkout@v2 - name: Setup python uses: actions/setup-python@v2 with: - python-version: "3.x" + python-version: "3.8" + + - name: Install dependencies + run: pip install . + + - name: Install pylint and black + run: pip install pylint black - - name: Install black - run: pip install black + - name: Run pylint + run: pylint nylas - name: Run black - run: black --check --extend-exclude="/examples" . + run: black . diff --git a/.gitignore b/.gitignore index ade358a5..1e7c9032 100644 --- a/.gitignore +++ b/.gitignore @@ -63,5 +63,9 @@ local/ pip-selfcheck.json tests/output local.env -examples/test.py +test.py .env + +# Documentation +site +docs diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..2db5495e --- /dev/null +++ b/.pylintrc @@ -0,0 +1,31 @@ +[FORMAT] +good-names=id,k,v,cc,to,ip +max-line-length=120 + +[MESSAGES CONTROL] +disable= + missing-module-docstring, + arguments-differ, + protected-access, + duplicate-code, + too-many-instance-attributes, + unnecessary-pass, + too-many-arguments, + too-few-public-methods, + +[TYPECHECK] + +generated-members= + Message.from_dict, + Draft.from_dict, + Time.from_dict, + Timespan.from_dict, + Date.from_dict, + Datespan.from_dict, + Details.from_dict, + Autocreate.from_dict, + RequestIdOnlyResponse.from_dict, + TokenInfoResponse.from_dict, + CodeExchangeResponse.from_dict, + NylasApiErrorResponse.from_dict, + NylasOAuthErrorResponse.from_dict, diff --git a/CHANGELOG.md b/CHANGELOG.md index a9c651aa..618c2b84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,25 @@ nylas-python Changelog ====================== -Unreleased +v6.0.0 +---------------- +* **BREAKING CHANGE**: Python SDK v6 supports the Nylas API v3 exclusively, dropping support for any endpoints that are not available in v3 +* **BREAKING CHANGE**: Drop support for Python < v3.8 +* **BREAKING CHANGE**: Dropped the use of 'Collections' in favor of 'Resources' +* **BREAKING CHANGE**: Removed all REST calls from models and moved them directly into resources +* **BREAKING CHANGE**: Models no longer inherit from `dict` but instead either are a `dataclass` or inherit from `TypedDict` +* **BREAKING CHANGE**: Renamed the SDK entrypoint from `APIClient` to `Client` +* **REMOVED**: Local Webhook development support is removed due to incompatibility +* Rewrote the majority of SDK to be more intuitive, explicit, and efficient +* Created models for all API resources and endpoints, for all HTTP methods to reduce confusion on which fields are available for each endpoint +* Created error classes for the different API errors as well as SDK-specific errors + +v5.14.1 +---------------- +* Fix error when trying to iterate on list after calling count +* Fix error when setting participant status on create event + +v5.14.0 ---------------- * Add support for `view` parameter in `Threads.search()` diff --git a/README.md b/README.md index f10e035d..f9d33072 100644 --- a/README.md +++ b/README.md @@ -4,31 +4,34 @@ # Nylas Python SDK -[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/nylas/nylas-python/Test)](https://github.com/nylas/nylas-python/actions/workflows/test.yml) +[![PyPI - Version](https://img.shields.io/pypi/v/nylas)](https://pypi.org/project/nylas/) [![codecov](https://codecov.io/gh/nylas/nylas-python/branch/main/graph/badge.svg?token=HyxGAn5bJR)](https://codecov.io/gh/nylas/nylas-python) -This is the GitHub repository for the Nylas Python SDK and this repo is primarily for anyone who wants to make contributions to the SDK or install it from source. If you are looking to use Python to access the Nylas Email, Calendar, or Contacts API you should refer to our official [Python SDK Quickstart Guide](https://docs.nylas.com/docs/quickstart-python). +This is the GitHub repository for the Nylas Python SDK. The repo is primarily for anyone who wants to install the SDK from source or make contributions to it. -The Nylas Communications Platform provides REST APIs for [Email](https://docs.nylas.com/docs/quickstart-email), [Calendar](https://docs.nylas.com/docs/quickstart-calendar), and [Contacts](https://docs.nylas.com/docs/quickstart-contacts), and the Python SDK is the quickest way to build your integration using Python +If you're looking to use Python to access the Nylas Email, Calendar, or Contacts APIs, see our [Python SDK Quickstart guide](https://docs.nylas.com/docs/quickstart-python). + +The Nylas platform provides REST APIs for [Email](https://docs.nylas.com/docs/quickstart-email), [Calendar](https://docs.nylas.com/docs/quickstart-calendar), and [Contacts](https://docs.nylas.com/docs/quickstart-contacts), and the Python SDK is the quickest way to build your integration using Python. Here are some resources to help you get started: -- [Nylas SDK Tutorials](https://docs.nylas.com/docs/tutorials) -- [Get Started with the Nylas Communications Platform](https://docs.nylas.com/docs/getting-started) -- [Sign up for your Nylas developer account.](https://nylas.com/register) -- [Nylas API Reference](https://docs.nylas.com/reference) +- [Sign up for a free Nylas account](https://dashboard.nylas.com/register). +- Follow the [Nylas API v3 Quickstart guide](https://developer.nylas.com/docs/v3-beta/v3-quickstart/). +- Browse the [Nylas SDK reference docs](https://nylas-python-sdk-reference.pages.dev/). +- Browse the [Nylas API reference docs](https://developer.nylas.com/docs/api/). +- See our code samples in the [Nylas Samples repo](https://github.com/orgs/nylas-samples/repositories?q=&type=all&language=python). -If you have a question about the Nylas Communications Platform, please reach out to support@nylas.com to get help. +If you have any questions about the Nylas platform, please reach out to support@nylas.com. ## ⚙️ Install The Nylas Python SDK is available via pip: ```bash -pip install nylas +pip install nylas --pre ``` -To install the SDK from source, clone this repo and run the install script. +To install the SDK from source, clone this repo and run the install script: ```bash git clone https://github.com/nylas/nylas-python.git && cd nylas-python @@ -37,38 +40,83 @@ python setup.py install ## ⚡️ Usage -To use this SDK, you first need to [sign up for a free Nylas developer account](https://nylas.com/register). +Before you use the Nylas Python SDK, you must first [create a Nylas account](https://dashboard.nylas.com/register). Then, follow our [API v3 Quickstart guide](https://developer.nylas.com/docs/v3-beta/v3-quickstart/) to set up your first app and get your API keys. + +For code samples and example applications, take a look at our [Python repos in the Nylas Samples collection](https://github.com/orgs/nylas-samples/repositories?q=&type=all&language=python). -Then, follow our guide to [setup your first app and get your API access keys](https://docs.nylas.com/docs/get-your-developer-api-keys). +### 🚀 Make your first request -Next, in your python script, import the `APIClient` class from the `nylas` package, and create a new instance of this class, passing the variables you gathered when you got your developer API keys. In the following example, replace `CLIENT_ID`, `CLIENT_SECRET`, and `ACCESS_TOKEN` with your values. +After you've installed and set up the Nylas Python SDK, you can make your first API request. To do so, use the `Client` class from the `nylas` package. +The SDK is organized into different resources, each of which has methods to make requests to the Nylas API. Each resource is available through the `Client` object that you configured with your API key. For example, you can use this code to get a list of Calendars: ```python -from nylas import APIClient +from nylas import Client -nylas = APIClient( - CLIENT_ID, - CLIENT_SECRET, - ACCESS_TOKEN +nylas = Client( + api_key="API_KEY", ) -``` -Now, you can use `nylas` to access full email, calendar, and contacts functionality. For example, here is how you would print the subject line for the most recent email message to the console. +calendars, request_id, next_cursor = nylas.calendars.list("GRANT_ID") + +event, request_id = nylas.events.create( + identifier="GRANT_ID", + request_body={ + "title": "test title", + "description": "test description", + "when": { + "start_time": start_unix_timestamp, + "end_time": end_unix_timestamp, + } + }, + query_params={"calendar_id": "primary", "notify_participants": True}, + ) +) +event, request_id = nylas.events.find( + identifier="GRANT_ID", + event_id=event.id, + query_params={ + "calendar_id": "primary", + }, +) + +nylas.events.destroy("GRANT_ID", event.id, {"calendar_id": "primary"}) -```python -message = nylas.messages.first() -print(message.subject) ``` -To learn more about how to use the Nylas Python SDK, please refer to our [Python SDK QuickStart Guide](https://docs.nylas.com/docs/quickstart-python) and our [Python tutorials](https://docs.nylas.com/docs/tutorials). +## 📚 Documentation + +This SDK makes heavy use of [Python 3 dataclasses](https://realpython.com/python-data-classes/) to define the REST resources and request/response schemas of the Nylas APIs. The Client object is a wrapper around all of these resources and is used to interact with the corresponding APIs. Basic CRUD operations are handled by the `create()`, `find()`, `list()`, `update()`, and `destroy()` methods on each resource. Resources may also have other methods which are all detailed in the [reference guide for the Python SDK](https://nylas-python-sdk-reference.pages.dev/). In the code reference, start at `client`, and then `resources` will give more info on available API call methods. `models` is the place to find schemas for requests, responses, and all Nylas object types. + +While most resources are accessed via the top-level Client object, note that `auth` contains the sub-resource `grants` as well as a collection of other auth-related API calls. -## 💙 Contributing +You'll want to catch `nylas.models.errors.NylasAPIError` to handle errors. + +Have fun!! + +## ✨ Upgrade from v5.x + +See [UPGRADE.md](UPGRADE.md) for instructions on upgrading from v5.x to v6.x. + +## 💙 Contribute Please refer to [Contributing](Contributing.md) for information about how to make contributions to this project. We welcome questions, bug reports, and pull requests. -Taking part in Hacktoberfest 2023 (i.e. issue is tagged with `hacktoberfest`)? Read our [Nylas Hacktoberfest 2023 contribution guidelines](https://github.com/nylas-samples/nylas-hacktoberfest-2023/blob/main/readme.md). +## 🛠️ Debugging + +It can sometimes be helpful to turn on request logging during development. Adding the following snippet to your code that calls the SDK should get you sorted: + +``` +import logging +import requests + +# Set up logging to print out HTTP request information +logging.basicConfig(level=logging.DEBUG) +requests_log = logging.getLogger("requests.packages.urllib3") +requests_log.setLevel(logging.DEBUG) +requests_log.propagate = True +``` ## 📝 License diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 00000000..4283f36e --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,178 @@ +# Upgrade to the Nylas Python SDK v6.0 + +The Nylas Python SDK has been rewritten to prepare for the upcoming release of the Nylas API v3. The changes make the SDK more idiomatic and easier to use. We've also included [function and model documentation](https://nylas-python-sdk-reference.pages.dev/), so you can easily find the implementation details that you need. + +This guide will help you upgrade your environment to use the new SDK. + +## Initial setup + +To upgrade to the new Python SDK, you must update your dependencies to use the new version. You can do this by installing the newest version of the SDK using pip: + +```bash +pip install nylas --pre +``` + +**Note**: The minimum Python version is now the lowest supported LTS: Python v3.8. + +The first step to using the new SDK is to initialize a new `nylas` instance. You can do this by passing your API key to the constructor: + +```python +from nylas import Client + +nylas = Client( + api_key="API_KEY", +) +``` + +Note that the SDK's entry point has changed to `Client`. + +From here, you can use the Nylas `Client` instance to make API requests by accessing the different resources configured with your API key. + +## Models + +Models have completely changed in the new version of the Nylas Python SDK. First, the SDK now includes a specific model for each request and response to/from the Nylas API. Let's take a Calendar object, for example. In the previous version of the SDK, there was only one `Calendar` object representing a Calendar in three states: + +- It is to be created. +- It is to be updated. +- It is to be retrieved. + +This meant that all models had to be configured with _all_ possible fields that could be used in any of these scenarios, making the object very large and difficult to anticipate as a developer. + +The new SDK has split the `Calendar` model into three separate models, one for each of the previous scenarios: + +- `Calendar`: Retrieve a Calendar. +- `CreateCalendarRequest`: Create a Calendar. +- `UpdateCalendarRequest`: Update a Calendar. + +Because the new version of the SDK drops support for Python versions lower than v3.8, our models now take advantage of some new Python features. For the models that represent response objects, we now use [dataclasses](https://docs.python.org/3/library/dataclasses.html) to make them more readable, easier to use, and to provide some type hinting and in-IDE hinting. Response objects also implement [the `dataclasses-json` library](https://pypi.org/project/dataclasses-json/), which provides utility functions such as `to_dict()` and `to_json()` that allow you to use your data in a variety of formats. + +For models that represent request objects, we're using [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict) to provide a seamless guided experience to building objects for outgoing requests. Both sets of classes are fully typed as well, ensuring that you have all the information you need to make a successful API request. + +## Make requests to the Nylas API + +To make requests to the Nylas API, you use the `nylas` instance that you configured earlier. + +The Python SDK is organized into different resources corresponding to each of the Nylas APIs. Each resource includes all of the available methods to make requests to its respective API. For example, you can use this code to get a list of Calendars: + +```python +from nylas import Client + +nylas = Client( + api_key="API_KEY", +) + +response = nylas.calendars.list(identifier="GRANT_ID") +``` + +This may look very similar to how you would get a list of Calendars in previous versions of the SDK, but there are some key differences that we'll cover in the following sections. + +### Response objects + +The Nylas API v3 has standard response objects for all requests, with the exception of OAuth endpoints. There are generally two main types of response objects: + +- `Response`: Used for requests that return a single object, such as requests to retrieve a single Calendar. This returns a parameterized object of the type that you requested (for example, `Calendar`) and a string representing the request ID. +- `ListResponse`: Used for requests that return a list of objects, such as requests to retrieve a _list_ of Calendars. This returns a list of parameterized objects of the type that you requested (for example, `Calendar`), a string representing the request ID, and a string representing the token of the next page for paginating the request. + +Both classes also support destructuring. This means you can use code like this to manipulate the data: + +```python +from nylas import Client + +nylas = Client( + api_key="API_KEY", +) + +response = nylas.calendars.list(identifier="GRANT_ID") +calendars = response.data # The list of calendars + +# Or + +calendars, request_id = nylas.calendars.list(identifier="CALENDAR_ID") # The list of calendars and the request ID +``` + +### Pagination + +The Nylas API v3 uses a new way to paginate responses by returning a `next_cursor` parameter in `ListResponse` objects. The `next_cursor` points to the next page, if one exists. + +Currently, the Nylas Python SDK doesn't support pagination out of the box, but this is something we're looking to add in the future. Instead, you can use `next_cursor` to make a request to the next page: + +```python +from nylas import Client + +nylas = Client( + api_key="API_KEY", +) + +response = nylas.calendars.list(identifier="GRANT_ID") +all_calendars = list(response) + +while response.next_cursor: + response = nylas.calendars.list(identifier="GRANT_ID", query_params={"page_token": response.next_cursor}) + all_calendars.extend(response) +``` + +### Error objects + +Similar to response objects, the Nylas API v3 has standard error objects for all requests, with the exception of OAuth endpoints. There are two superclass error classes: + +- `AbstractNylasApiError`: Used for errors returned by the Nylas API. +- `AbstractNylasSdkError`: Used for errors returned by the Python SDK. + +The `AbstractNylasApiError` superclass includes two subclasses: + +- `NylasOAuthError`: Used for Nylas API errors returned from OAuth endpoints. +- `NylasApiError`: Used for all other Nylas API errors. + +The Python SDK extracts error details from the response and stores them in the error object, along with the request ID and HTTP status code. + +Currently, there is only one type of `AbstractNylasSdkError` that we return: the `NylasSdkTimeoutError`, which is thrown when a request times out. + +## Authentication + +The Nylas Python SDK's authentication methods reflect [those available in the Nylas API v3](https://developer.nylas.com/docs/developer-guide/v3-authentication/). + +While you can only create and manage your application's connectors (formerly called "integrations") in the Dashboard, you can manage almost everything else directly from the Python SDK. This includes managing Grants, redirect URIs, OAuth tokens, and authenticating your users. + +There are two main methods to focus on when authenticating users to your app: + +- `Auth#url_for_oath2`: Returns the URL that you should direct your users to in order to authenticate them with OAuth 2.0. +- `Auth#exchange_code_for_token`: Exchanges the code Nylas returns from the authentication redirect for an access token from the OAuth provider. Nylas' response to this request returns both the access token and information about the new Grant. + +Note that you don't need to use the `grant_id` to make requests. Instead, you can use the authenticated email address associated with the Grant as the identifier. If you prefer to use the `grant_id`, you can extract it from the `CodeExchangeResponse`. + +This code demonstrates how to authenticate a user into a Nylas app: + +```python +from nylas import Client + +nylas = Client( + api_key="API_KEY", +) + +# Build the URL for authentication +auth_url = nylas.auth.url_for_oauth2({ + "client_id": "CLIENT_ID", + "redirect_uri": "abc", + "login_hint": "example@email.com" +}) + +# Write code here to redirect the user to the url and parse the code +... + +# Exchange the code for an access token + +code_exchange_response = nylas.auth.exchange_code_for_token({ + "client_id": "CLIENT_ID", + "client_secret": "CLIENT_SECRET", + "code": "CODE", + "redirect_uri": "abc" +}) + +# Now you can either use the email address that was authenticated or the grant ID in the response as the identifier + +response_with_email = nylas.calendars.list(identifier="example@email.com") + +# Or + +response_with_grant = nylas.calendars.list(identifier=code_exchange_response.grant_id) +``` diff --git a/examples/hosted-oauth/README.md b/examples/hosted-oauth/README.md deleted file mode 100644 index a53b74e7..00000000 --- a/examples/hosted-oauth/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# Example: Hosted OAuth - -This is an example project that demonstrates how to connect to Nylas via -OAuth, using [Nylas' hosted OAuth flow](https://docs.nylas.com/reference#oauth). - -This example uses the [Flask](http://flask.pocoo.org/) web framework to make -a small website, and uses the [Flask-Dance](http://flask-dance.rtfd.org/) -extension to handle the tricky bits of implementing the -[OAuth protocol](https://oauth.net/). -Once the OAuth communication is in place, this example website will contact -the Nylas API to learn some basic information about the current user, -such as the user's name and email address. It will display that information -on the page, just to prove that it can fetch it correctly. - -In order to successfully run this example, you need to do the following things: - -## Get a client ID & client secret from Nylas - -To do this, make a [Nylas Developer](https://developer.nylas.com/) account. -You should see your client ID and client secret on the dashboard, -once you've logged in on the -[Nylas Developer](https://developer.nylas.com/) website. - -## Update the `config.py` File - -Open the `config.py` file in this directory, and replace the example -client ID and client secret with the real values that you got from the Nylas -Developer dashboard. You'll also need to replace the example secret key with -any random string of letters and numbers: a keyboard mash will do. - -## Set Up HTTPS - -The OAuth protocol requires that all communication occur via the secure HTTPS -connections, rather than insecure HTTP connections. There are several ways -to set up HTTPS on your computer, but perhaps the simplest is to use -[ngrok](https://ngrok.com), a tool that lets you create a secure tunnel -from the ngrok website to your computer. Install it from the website, and -then run the following command: - -``` -ngrok http 5000 -``` - -Notice that ngrok will show you two "forwarding" URLs, which may look something -like `http://ed90abe7.ngrok.io` and `https://ed90abe7.ngrok.io`. (The hash -subdomain will be different for you.) You'll be using the second URL, which -starts with `https`. - -Alternatively, you can set the `OAUTHLIB_INSECURE_TRANSPORT` environment -variable in your shell, to disable the HTTPS check. That way, you'll be -able to use `localhost` to refer to your app, instead of an ngrok URL. -However, be aware that you won't be able to do this when you deploy -your app to production, so it's usually a better idea to set up HTTPS properly. - -## Set the Nylas Callback URL - -Once you have a HTTPS URL that points to your computer, you'll need to tell -Nylas about it. On the [Nylas Dashboard](https://dashboard.nylas.com) -click on the Application Dropdown Menu on the left, then "View all Applications". -From there, select "Edit" for the app you'd like to use and select the -"Application Callbacks" tab. Paste your HTTPS URL into the text field, and add -`/login/nylas/authorized` after it. For example, if your HTTPS URL is -`https://ad172180.ngrok.io`, then you would put `https://ad172180.ngrok.io/login/nylas/authorized` -into the text field in the "Application Callbacks" tab. - -Then click the "Add Callback" button to save. - -## Install the Dependencies - -This project depends on a few third-party Python modules, like Flask. -These dependencies are listed in the `requirements.txt` file in this directory. -To install them, use the `pip` tool, like this: - -``` -pip install -r requirements.txt -``` - -## Run the Example - -Finally, run the example project like this: - -``` -python server.py -``` - -Once the server is running, visit the ngrok URL in your browser to test it out! diff --git a/examples/hosted-oauth/config.py b/examples/hosted-oauth/config.py deleted file mode 100644 index 12da98a2..00000000 --- a/examples/hosted-oauth/config.py +++ /dev/null @@ -1,3 +0,0 @@ -SECRET_KEY = ("replace me with a random string",) -NYLAS_OAUTH_CLIENT_ID = ("replace me with the client ID from Nylas",) -NYLAS_OAUTH_CLIENT_SECRET = "replace me with the client secret from Nylas" diff --git a/examples/hosted-oauth/requirements.txt b/examples/hosted-oauth/requirements.txt deleted file mode 100644 index e87c213b..00000000 --- a/examples/hosted-oauth/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Flask>=0.11 -Flask-Dance>=0.11.1 -requests diff --git a/examples/hosted-oauth/server.py b/examples/hosted-oauth/server.py deleted file mode 100644 index cd640531..00000000 --- a/examples/hosted-oauth/server.py +++ /dev/null @@ -1,127 +0,0 @@ -# Imports from the Python standard library -from __future__ import print_function -import os -import sys -import textwrap - -# Imports from third-party modules that this project depends on -try: - import requests - from flask import Flask, render_template - from werkzeug.middleware.proxy_fix import ProxyFix - from flask_dance.contrib.nylas import make_nylas_blueprint, nylas -except ImportError: - message = textwrap.dedent( - """ - You need to install the dependencies for this project. - To do so, run this command: - - pip install -r requirements.txt - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -try: - from nylas import APIClient -except ImportError: - message = textwrap.dedent( - """ - You need to install the Nylas SDK for this project. - To do so, run this command: - - pip install nylas - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -# This example uses Flask, a micro web framework written in Python. -# For more information, check out the documentation: http://flask.pocoo.org -# Create a Flask app, and load the configuration file. -app = Flask(__name__) -app.config.from_pyfile("config.py") - -# Check for dummy configuration values. -# If you are building your own application based on this example, -# you can remove this check from your code. -cfg_needs_replacing = [ - key - for key, value in app.config.items() - if isinstance(value, str) and value.startswith("replace me") -] -if cfg_needs_replacing: - message = textwrap.dedent( - """ - This example will only work if you replace the fake configuration - values in `config.json` with real configuration values. - The following config values need to be replaced: - {keys} - Consult the README.md file in this directory for more information. - """ - ).format(keys=", ".join(cfg_needs_replacing)) - print(message, file=sys.stderr) - sys.exit(1) - -# Use Flask-Dance to automatically set up the OAuth endpoints for Nylas. -# For more information, check out the documentation: http://flask-dance.rtfd.org -nylas_bp = make_nylas_blueprint() -app.register_blueprint(nylas_bp, url_prefix="/login") - -# Teach Flask how to find out that it's behind an ngrok proxy -app.wsgi_app = ProxyFix(app.wsgi_app) - -# Define what Flask should do when someone visits the root URL of this website. -@app.route("/") -def index(): - # If the user has already connected to Nylas via OAuth, - # `nylas.authorized` will be True. Otherwise, it will be False. - if not nylas.authorized: - # OAuth requires HTTPS. The template will display a handy warning, - # unless we've overridden the check. - return render_template( - "before_authorized.html", - insecure_override=os.environ.get("OAUTHLIB_INSECURE_TRANSPORT"), - ) - - # If we've gotten to this point, then the user has already connected - # to Nylas via OAuth. Let's set up the SDK client with the OAuth token: - client = APIClient( - client_id=app.config["NYLAS_OAUTH_CLIENT_ID"], - client_secret=app.config["NYLAS_OAUTH_CLIENT_SECRET"], - access_token=nylas.access_token, - ) - - # We'll use the Nylas client to fetch information from Nylas - # about the current user, and pass that to the template. - account = client.account - return render_template("after_authorized.html", account=account) - - -def ngrok_url(): - """ - If ngrok is running, it exposes an API on port 4040. We can use that - to figure out what URL it has assigned, and suggest that to the user. - https://ngrok.com/docs#list-tunnels - """ - try: - ngrok_resp = requests.get("http://localhost:4040/api/tunnels") - except requests.ConnectionError: - # I guess ngrok isn't running. - return None - ngrok_data = ngrok_resp.json() - secure_urls = [ - tunnel["public_url"] - for tunnel in ngrok_data["tunnels"] - if tunnel["proto"] == "https" - ] - return secure_urls[0] - - -# When this file is executed, run the Flask web server. -if __name__ == "__main__": - url = ngrok_url() - if url: - print(" * Visit {url} to view this Nylas example".format(url=url)) - - app.run() diff --git a/examples/hosted-oauth/templates/after_authorized.html b/examples/hosted-oauth/templates/after_authorized.html deleted file mode 100644 index 04e010a0..00000000 --- a/examples/hosted-oauth/templates/after_authorized.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} -{% block body %} -
You've successfully connected to Nylas via hosted OAuth! Here's some - information that I got from the Nylas API, to prove it:
- -{{ key }} | -{{ value }} | -
---|
If you want to test this OAuth flow again, clear your browser cookies - or open a new browser in incognito mode.
-{% endblock %} diff --git a/examples/hosted-oauth/templates/base.html b/examples/hosted-oauth/templates/base.html deleted file mode 100644 index e3f8e610..00000000 --- a/examples/hosted-oauth/templates/base.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - -Thanks for giving Nylas a try! Next, you need to click this button - to connect to Nylas via hosted OAuth.
-Connect to Nylas - - -{% endblock %} diff --git a/examples/native-authentication-exchange/README.md b/examples/native-authentication-exchange/README.md deleted file mode 100644 index 09ef1d0d..00000000 --- a/examples/native-authentication-exchange/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Example: Native Authentication (Exchange) - -This is an example project that demonstrates how to connect to Nylas using the -[Native Authentication](https://docs.nylas.com/reference#native-authentication-1) -flow. Note that different email providers have different native authentication -processes; this example project *only* works with Microsoft Exchange. - -This example uses the [Flask](http://flask.pocoo.org/) web framework to make -a small website, and uses the [Flask-WTF](https://flask-wtf.readthedocs.io/) -extension to implement an HTML form so the user can type in their -Exchange account information. -Once the native authentication has been set up, this example website will contact -the Nylas API to learn some basic information about the current user, -such as the user's name and email address. It will display that information -on the page, just to prove that it can fetch it correctly. - -In order to successfully run this example, you need to do the following things: - -## Get a client ID & client secret from Nylas - -To do this, make a [Nylas Developer](https://developer.nylas.com/) account. -You should see your client ID and client secret on the dashboard, -once you've logged in on the -[Nylas Developer](https://developer.nylas.com/) website. - -## Update the `config.json` File - -Open the `config.json` file in this directory, and replace the example -values with the real values. This is where you'll need the client ID and -client secret fron Nylas. You'll also need to replace the example secret key with -any random string of letters and numbers: a keyboard mash will do. - -## Install the Dependencies - -This project depends on a few third-party Python modules, like Flask. -These dependencies are listed in the `requirements.txt` file in this directory. -To install them, use the `pip` tool, like this: - -``` -pip install -r requirements.txt -``` - -## Run the Example - -Finally, run the example project like this: - -``` -python server.py -``` - -Once the server is running, visit `http://127.0.0.1:5000/` in your browser -to test it out! diff --git a/examples/native-authentication-exchange/config.json b/examples/native-authentication-exchange/config.json deleted file mode 100644 index 9b54e3df..00000000 --- a/examples/native-authentication-exchange/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "SECRET_KEY": "replace me with a random string", - "NYLAS_OAUTH_CLIENT_ID": "replace me with the client ID from Nylas", - "NYLAS_OAUTH_CLIENT_SECRET": "replace me with the client secret from Nylas" -} diff --git a/examples/native-authentication-exchange/requirements.txt b/examples/native-authentication-exchange/requirements.txt deleted file mode 100644 index f03e8299..00000000 --- a/examples/native-authentication-exchange/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Flask>=0.11 -Flask-WTF -requests diff --git a/examples/native-authentication-exchange/server.py b/examples/native-authentication-exchange/server.py deleted file mode 100644 index 3fefd926..00000000 --- a/examples/native-authentication-exchange/server.py +++ /dev/null @@ -1,170 +0,0 @@ -# Imports from the Python standard library -from __future__ import print_function -import os -import sys -import textwrap - -# Imports from third-party modules that this project depends on -try: - import requests - from flask import Flask, render_template, session, redirect, url_for - from flask_wtf import FlaskForm - from wtforms.fields import StringField, PasswordField - from wtforms.fields.html5 import EmailField - from wtforms.validators import DataRequired -except ImportError: - message = textwrap.dedent( - """ - You need to install the dependencies for this project. - To do so, run this command: - - pip install -r requirements.txt - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -try: - from nylas import APIClient -except ImportError: - message = textwrap.dedent( - """ - You need to install the Nylas SDK for this project. - To do so, run this command: - - pip install nylas - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -# This example uses Flask, a micro web framework written in Python. -# For more information, check out the documentation: http://flask.pocoo.org -# Create a Flask app, and load the configuration file. -app = Flask(__name__) -app.config.from_json("config.json") - -# Check for dummy configuration values. -# If you are building your own application based on this example, -# you can remove this check from your code. -cfg_needs_replacing = [ - key - for key, value in app.config.items() - if isinstance(value, str) and value.startswith("replace me") -] -if cfg_needs_replacing: - message = textwrap.dedent( - """ - This example will only work if you replace the fake configuration - values in `config.json` with real configuration values. - The following config values need to be replaced: - {keys} - Consult the README.md file in this directory for more information. - """ - ).format(keys=", ".join(cfg_needs_replacing)) - print(message, file=sys.stderr) - sys.exit(1) - - -class ExchangeCredentialsForm(FlaskForm): - name = StringField("Name", validators=[DataRequired()]) - email = EmailField("Email Address", validators=[DataRequired()]) - password = PasswordField("Password", validators=[DataRequired()]) - server_host = StringField("Server Host", render_kw={"placeholder": "(optional)"}) - - -class APIError(Exception): - pass - - -# Define what Flask should do when someone visits the root URL of this website. -@app.route("/", methods=("GET", "POST")) -def index(): - form = ExchangeCredentialsForm() - api_error = None - if form.validate_on_submit(): - try: - return pass_creds_to_nylas( - name=form.name.data, - email=form.email.data, - password=form.password.data, - server_host=form.server_host.data, - ) - except APIError as err: - api_error = err.args[0] - return render_template("index.html", form=form, api_error=api_error) - - -def pass_creds_to_nylas(name, email, password, server_host=None): - """ - Passes Exchange credentials to Nylas, to set up native authentication. - """ - # Start the connection process by looking up all the information that - # Nylas needs in order to connect, and sending it to the authorize API. - nylas_authorize_data = { - "client_id": app.config["NYLAS_OAUTH_CLIENT_ID"], - "name": name, - "email_address": email, - "provider": "exchange", - "settings": {"username": email, "password": password}, - } - if server_host: - nylas_authorize_data["settings"]["eas_server_host"] = server_host - - nylas_authorize_resp = requests.post( - "https://api.nylas.com/connect/authorize", json=nylas_authorize_data - ) - if not nylas_authorize_resp.ok: - message = nylas_authorize_resp.json()["message"] - raise APIError(message) - - nylas_code = nylas_authorize_resp.json()["code"] - - # Now that we've got the `code` from the authorize response, - # pass it to the token response to complete the connection. - nylas_token_data = { - "client_id": app.config["NYLAS_OAUTH_CLIENT_ID"], - "client_secret": app.config["NYLAS_OAUTH_CLIENT_SECRET"], - "code": nylas_code, - } - nylas_token_resp = requests.post( - "https://api.nylas.com/connect/token", json=nylas_token_data - ) - if not nylas_token_resp.ok: - message = nylas_token_resp.json()["message"] - raise APIError(message) - - nylas_access_token = nylas_token_resp.json()["access_token"] - - # Great, we've connected the Exchange account to Nylas! - # In the process, Nylas gave us an OAuth access token, which we'll need - # in order to make API requests to Nylas in the future. - # We'll save that access token in the Flask session, so we can pick it up - # later and use it when we need it. - session["nylas_access_token"] = nylas_access_token - - # We're all done here. Redirect the user back to the success page, - # which will pick up the access token we just saved. - return redirect(url_for("success")) - - -@app.route("/success") -def success(): - if "nylas_access_token" not in session: - return render_template("missing_token.html") - - client = APIClient( - client_id=app.config["NYLAS_OAUTH_CLIENT_ID"], - client_secret=app.config["NYLAS_OAUTH_CLIENT_SECRET"], - access_token=session["nylas_access_token"], - ) - - # We'll use the Nylas client to fetch information from Nylas - # about the current user, and pass that to the template. - account = client.account - return render_template("success.html", account=account) - - -# When this file is executed, run the Flask web server. -if __name__ == "__main__": - app.run() diff --git a/examples/native-authentication-exchange/templates/base.html b/examples/native-authentication-exchange/templates/base.html deleted file mode 100644 index b0683cbb..00000000 --- a/examples/native-authentication-exchange/templates/base.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - -Thanks for giving Nylas a try! - This example is set up to work with Exchange, so it will only work if you - have an Exchange account.
- - -{% endblock %} diff --git a/examples/native-authentication-exchange/templates/missing_token.html b/examples/native-authentication-exchange/templates/missing_token.html deleted file mode 100644 index 7b194ca5..00000000 --- a/examples/native-authentication-exchange/templates/missing_token.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends "base.html" %} -{% block body %} -You don't seem to have an OAuth access token for Nylas. Try going back to the home page to make one.
-{% endblock %} diff --git a/examples/native-authentication-exchange/templates/success.html b/examples/native-authentication-exchange/templates/success.html deleted file mode 100644 index b9a78636..00000000 --- a/examples/native-authentication-exchange/templates/success.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html" %} -{% block body %} -You've successfully connected to Nylas via native authentication! - Here's some information that I got from the Nylas API, to prove it:
- -{{ key }} | -{{ value }} | -
---|
You've successfully connected to Nylas via native authentication! - Here's some information that I got from the Nylas API, to prove it:
- -{{ key }} | -{{ value }} | -
---|---|
Access Token: | -{{ client.access_token }} | -
Great, you've succesfully connected with Google! The next step is to - hand those authentication credentials over to Nylas, so that Nylas - can connect with Google on your behalf. - Please click this link to do that.
-Pass Credentials to Nylas - -{% endblock %} diff --git a/examples/native-authentication-gmail/templates/base.html b/examples/native-authentication-gmail/templates/base.html deleted file mode 100644 index 9a26d102..00000000 --- a/examples/native-authentication-gmail/templates/base.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - -Thanks for giving Nylas a try! Native Authentication is a two-step process, - where the first step is connecting with the native email provider. - This example is set up to work with Google, so it will only work if you - have a Gmail account.
-First, please click this button to connect with Google.
-Connect with Google - - -{% endblock %} diff --git a/examples/webhooks/README.md b/examples/webhooks/README.md deleted file mode 100644 index ac9483a1..00000000 --- a/examples/webhooks/README.md +++ /dev/null @@ -1,125 +0,0 @@ -# Example: Webhooks - -This is an example project that demonstrates how to use -[the webhooks feature on Nylas](https://docs.nylas.com/reference#webhooks). -When you run the app and set up a webhook with Nylas, it will print out -some information every time you receive a webhook notification from Nylas. - -In order to successfully run this example, you need to do the following things: - -## Install and run redis - -[Redis](https://redis.io/) is an in-memory data store. This example uses it -as a message broker for the Celery task queue. You'll need to have it running -on your local computer in order to use the task queue. - -If you're using macOS, you can install redis from [Homebrew](https://brew.sh/), -like this: - -``` -brew install redis -brew services start redis -``` - -If you're unable to install and run redis, you can still run this example -without the task queue -- keep reading. - -## Get a client ID & client secret from Nylas - -To do this, make a [Nylas Developer](https://developer.nylas.com/) account. -You should see your client ID and client secret on the dashboard, -once you've logged in on the -[Nylas Developer](https://developer.nylas.com/) website. - -## Update the `config.json` File - -Open the `config.json` file in this directory, and replace the example -client ID and client secret with the real values that you got from the Nylas -Developer dashboard. You'll also need to replace the example secret key with -any random string of letters and numbers: a keyboard mash will do. - -The config file also has two options related to Celery, which you probably -don't need to modify. `CELERY_BROKER_URL` should point to your running redis -server: if you've got it running on your local computer, you're all set. -However, if you haven't managed to get redis running on your computer, you -can change `CELERY_TASK_ALWAYS_EAGER` to `true`. This will disable the task -queue, and cause all Celery tasks to be run immediately rather than queuing -them for later. - -## Set Up HTTPS - -Nylas requires that all webhooks be delivered to the secure HTTPS endpoints, -rather than insecure HTTP endpoints. There are several ways -to set up HTTPS on your computer, but perhaps the simplest is to use -[ngrok](https://ngrok.com), a tool that lets you create a secure tunnel -from the ngrok website to your computer. Install it from the website, and -then run the following command: - -``` -ngrok http 5000 -``` - -Notice that ngrok will show you two "forwarding" URLs, which may look something -like `http://ed90abe7.ngrok.io` and `https://ed90abe7.ngrok.io`. (The hash -subdomain will be different for you.) You'll be using the second URL, which -starts with `https`. - -## Install the Dependencies - -This project depends on a few third-party Python modules. -These dependencies are listed in the `requirements.txt` file in this directory. -To install them, use the `pip` tool, like this: - -``` -pip install -r requirements.txt -``` - -## Run the Celery worker (if you're using redis) - -The Celery worker will continuously check the task queue to see if there are -any new tasks to be run, and it will run any tasks that it finds. Without at -least one worker running, tasks on the task queue will sit there unfinished -forever. To run a celery worker, pass the `--worker` argument to the `server.py` -script, like this: - -``` -python server.py --worker -``` - -Note that if you're not using redis, you don't need to run a Celery worker, -because the tasks will be run immediately rather than put on the task queue. - -## Run the Example - -While the Celery worker is running, open a new terminal window and run the -Flask web server, like this: - -``` -python server.py -``` - -You should see the ngrok URL in the console, and the web server will start -on port 5000. - -## Set the Nylas Callback URL - -Now that your webhook is all set up and running, you need to tell -Nylas about it. On the [Nylas Developer](https://developer.nylas.com) console, -click on the "Webhooks" tab on the left side, then click the "Add Webhook" -button. -Paste your HTTPS URL into text field, and add `/webhook` -after it. For example, if your HTTPS URL is `https://ad172180.ngrok.io`, then -you would put `https://ad172180.ngrok.io/webhook` into the text field. - -Then click the "Create Webhook" button to save. - -## Trigger events and see webhook notifications! - -Send an email on an account that's connected to Nylas. In a minute or two, -you'll get a webhook notification with information about the event that just -happened! - -If you're using redis, you should see the information about the event in the -terminal window where your Celery worker is running. If you're not using -redis, you should see the information about the event in the terminal window -where your Flask web server is running. diff --git a/examples/webhooks/config.json b/examples/webhooks/config.json deleted file mode 100644 index 04e9a645..00000000 --- a/examples/webhooks/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "SECRET_KEY": "replace me with a random string", - "NYLAS_OAUTH_CLIENT_ID": "replace me with the client ID from Nylas", - "NYLAS_OAUTH_CLIENT_SECRET": "replace me with the client secret from Nylas", - "CELERY_BROKER_URL": "redis://localhost", - "CELERY_TASK_ALWAYS_EAGER": false -} diff --git a/examples/webhooks/requirements.txt b/examples/webhooks/requirements.txt deleted file mode 100644 index 38e90172..00000000 --- a/examples/webhooks/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Flask>=0.11 -celery[redis]>=4.0.0 -requests diff --git a/examples/webhooks/server.py b/examples/webhooks/server.py deleted file mode 100755 index db1deb95..00000000 --- a/examples/webhooks/server.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python - -# Imports from the Python standard library -from __future__ import print_function -import os -import sys -import datetime -import textwrap -import hmac -import hashlib - -# Imports from third-party modules that this project depends on -try: - import requests - from flask import Flask, request, render_template - from werkzeug.middleware.proxy_fix import ProxyFix - from celery import Celery -except ImportError: - message = textwrap.dedent( - """ - You need to install the dependencies for this project. - To do so, run this command: - - pip install -r requirements.txt - """ - ) - print(message, file=sys.stderr) - sys.exit(1) - -# This example uses Flask, a micro web framework written in Python. -# For more information, check out the documentation: http://flask.pocoo.org -# Create a Flask app, and load the configuration file. -app = Flask(__name__) -app.config.from_json("config.json") - -# Check for dummy configuration values. -# If you are building your own application based on this example, -# you can remove this check from your code. -cfg_needs_replacing = [ - key - for key, value in app.config.items() - if isinstance(value, str) and value.startswith("replace me") -] -if cfg_needs_replacing: - message = textwrap.dedent( - """ - This example will only work if you replace the fake configuration - values in `config.json` with real configuration values. - The following config values need to be replaced: - {keys} - Consult the README.md file in this directory for more information. - """ - ).format(keys=", ".join(cfg_needs_replacing)) - print(message, file=sys.stderr) - sys.exit(1) - -# Teach Flask how to find out that it's behind an ngrok proxy -app.wsgi_app = ProxyFix(app.wsgi_app) - -# This example also uses Celery, a task queue framework written in Python. -# For more information, check out the documentation: http://docs.celeryproject.org -# Create a Celery instance, and load its configuration from Flask. -celery = Celery(app.import_name) -celery.config_from_object(app.config, namespace="CELERY") - - -@app.route("/webhook", methods=["GET", "POST"]) -def webhook(): - """ - When the Flask server gets a request at the `/webhook` URL, it will run - this function. Most of the time, that request will be a genuine webhook - notification from Nylas. However, it's possible that the request could - be a fake notification from someone else, trying to fool our app. This - function needs to verify that the webhook is genuine! - """ - # When you first tell Nylas about your webhook, it will test that webhook - # URL with a GET request to make sure that it responds correctly. - # We just need to return the `challenge` parameter to indicate that this - # is a valid webhook URL. - if request.method == "GET" and "challenge" in request.args: - print(" * Nylas connected to the webhook!") - return request.args["challenge"] - - # Alright, this is a POST request, which means it's a webhook notification. - # The question is, is it genuine or fake? Check the signature to find out. - is_genuine = verify_signature( - message=request.data, - key=app.config["NYLAS_OAUTH_CLIENT_SECRET"].encode("utf8"), - signature=request.headers.get("X-Nylas-Signature"), - ) - if not is_genuine: - return "Signature verification failed!", 401 - - # Alright, we have a genuine webhook notification from Nylas! - # Let's find out what it says... - data = request.get_json() - for delta in data["deltas"]: - # Processing the data might take awhile, or it might fail. - # As a result, instead of processing it right now, we'll push a task - # onto the Celery task queue, to handle it later. That way, - # we've got the data saved, and we can return a response to the - # Nylas webhook notification right now. - process_delta.delay(delta) - - # Now that all the `process_delta` tasks have been queued, we can - # return an HTTP response to Nylas, to let them know that we processed - # the webhook notification successfully. - return "Deltas have been queued", 200 - - -def verify_signature(message, key, signature): - """ - This function will verify the authenticity of a digital signature. - For security purposes, Nylas includes a digital signature in the headers - of every webhook notification, so that clients can verify that the - webhook request came from Nylas and no one else. The signing key - is your OAuth client secret, which only you and Nylas know. - """ - digest = hmac.new(key, msg=message, digestmod=hashlib.sha256).hexdigest() - return hmac.compare_digest(digest, signature) - - -@celery.task -def process_delta(delta): - """ - This is the part of the code where you would process the information - from the webhook notification. Each delta is one change that happened, - and might require fetching message IDs, updating your database, - and so on. - - However, because this is just an example project, we'll just print - out information about the notification, so you can see what - information is being sent. - """ - kwargs = { - "type": delta["type"], - "date": datetime.datetime.utcfromtimestamp(delta["date"]), - "object_id": delta["object_data"]["id"], - } - print(" * {type} at {date} with ID {object_id}".format(**kwargs)) - - -@app.route("/") -def index(): - """ - This makes sure that when you visit the root of the website, - you get a webpage rather than a 404 error. - """ - return render_template("index.html", ngrok_url=ngrok_url()) - - -def ngrok_url(): - """ - If ngrok is running, it exposes an API on port 4040. We can use that - to figure out what URL it has assigned, and suggest that to the user. - https://ngrok.com/docs#list-tunnels - """ - try: - ngrok_resp = requests.get("http://localhost:4040/api/tunnels") - except requests.ConnectionError: - # I guess ngrok isn't running. - return None - ngrok_data = ngrok_resp.json() - secure_urls = [ - tunnel["public_url"] - for tunnel in ngrok_data["tunnels"] - if tunnel["proto"] == "https" - ] - return secure_urls[0] - - -# When this file is executed, this block of code will run. -if __name__ == "__main__": - if len(sys.argv) > 1 and sys.argv[1] == "--worker": - # Run the celery worker, *instead* of running the Flask web server. - celery.worker_main(sys.argv[1:]) - sys.exit() - - # If we get here, we're going to try to run the Flask web server. - url = ngrok_url() - if not url: - print( - "Looks like ngrok isn't running! Start it by running " - "`ngrok http 5000` in a different terminal window, " - "and then try running this example again.", - file=sys.stderr, - ) - sys.exit(1) - - print(" * Webhook URL: {url}/webhook".format(url=url)) - - if app.config.get("CELERY_TASK_ALWAYS_EAGER"): - print(" * Celery tasks will be run synchronously. No worker needed.") - elif len(celery.control.inspect().stats().keys()) < 2: - print( - " * You need to run at least one Celery worker, otherwise " - "the webhook notifications will never be processed.\n" - " To do so, run `{arg0} --worker` in a different " - "terminal window.".format(arg0=sys.argv[0]) - ) - app.run() diff --git a/examples/webhooks/templates/base.html b/examples/webhooks/templates/base.html deleted file mode 100644 index 3d6ac8a3..00000000 --- a/examples/webhooks/templates/base.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - -This example doesn't have anything to see in the browser. Set up your - webhook on the - Nylas Developer console, - and then watch your terminal to see the webhook notifications come in. -
- -Your webhook URL is:
- {{ ngrok_url }}{{ url_for("webhook") }}
-
Once you've received at least one webhook notification from Nylas, - you might want to check out the - ngrok web interface. - That will allow you to see more information about the webhook notification, - and replay it for testing purposes if you want. -
-{% endblock %} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..6e8dfce4 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,23 @@ +site_name: Nylas Python SDK Reference +theme: + name: 'material' + +nav: + - Getting Started: index.md + - Code Reference: reference/ + - Contributing: contributing.md + - License: license.md + +# Add plugins +plugins: + - search + - mkdocstrings: + default_handler: python + handlers: + python: + paths: [ nylas ] + - gen-files: + scripts: + - scripts/generate-docs.py + - literate-nav: + nav_file: SUMMARY.md diff --git a/nylas/__init__.py b/nylas/__init__.py index da9b836f..2befcf83 100644 --- a/nylas/__init__.py +++ b/nylas/__init__.py @@ -1,6 +1,3 @@ -from pkgutil import extend_path -from .client.client import APIClient +from nylas.client import Client -# Allow out-of-tree submodules. -__path__ = extend_path(__path__, __name__) -__all__ = ["APIClient"] +__all__ = ["Client"] diff --git a/nylas/_client_sdk_version.py b/nylas/_client_sdk_version.py index 53988afb..8171cf01 100644 --- a/nylas/_client_sdk_version.py +++ b/nylas/_client_sdk_version.py @@ -1 +1 @@ -__VERSION__ = "5.14.1" +__VERSION__ = "6.0.0" diff --git a/nylas/client.py b/nylas/client.py new file mode 100644 index 00000000..6ce8e9b5 --- /dev/null +++ b/nylas/client.py @@ -0,0 +1,171 @@ +from nylas.config import DEFAULT_SERVER_URL +from nylas.handler.http_client import HttpClient +from nylas.resources.applications import Applications +from nylas.resources.attachments import Attachments +from nylas.resources.auth import Auth +from nylas.resources.calendars import Calendars +from nylas.resources.connectors import Connectors +from nylas.resources.events import Events +from nylas.resources.folders import Folders +from nylas.resources.messages import Messages +from nylas.resources.threads import Threads +from nylas.resources.webhooks import Webhooks +from nylas.resources.contacts import Contacts +from nylas.resources.drafts import Drafts +from nylas.resources.grants import Grants + + +class Client: + """ + API client for the Nylas API. + + Attributes: + api_key: The Nylas API key to use for authentication + api_uri: The URL to use for communicating with the Nylas API + http_client: The HTTP client to use for requests to the Nylas API + """ + + def __init__( + self, api_key: str, api_uri: str = DEFAULT_SERVER_URL, timeout: int = 30 + ): + """ + Initialize the Nylas API client. + + Args: + api_key: The Nylas API key to use for authentication + api_uri: The URL to use for communicating with the Nylas API + timeout: The timeout for requests to the Nylas API, in seconds + """ + self.api_key = api_key + self.api_uri = api_uri + self.http_client = HttpClient(self.api_uri, self.api_key, timeout) + + @property + def auth(self) -> Auth: + """ + Access the Auth API. + + Returns: + The Auth API. + """ + return Auth(self.http_client) + + @property + def applications(self) -> Applications: + """ + Access the Applications API. + + Returns: + The Applications API. + """ + return Applications(self.http_client) + + @property + def attachments(self) -> Attachments: + """ + Access the Attachments API. + + Returns: + The Attachments API. + """ + return Attachments(self.http_client) + + @property + def connectors(self) -> Connectors: + """ + Access the Connectors API. + + Returns: + The Connectors API. + """ + return Connectors(self.http_client) + + @property + def calendars(self) -> Calendars: + """ + Access the Calendars API. + + Returns: + The Calendars API. + """ + return Calendars(self.http_client) + + @property + def contacts(self) -> Contacts: + """ + Access the Contacts API. + + Returns: + The Contacts API. + """ + return Contacts(self.http_client) + + @property + def drafts(self) -> Drafts: + """ + Access the Drafts API. + + Returns: + The Drafts API. + """ + return Drafts(self.http_client) + + @property + def events(self) -> Events: + """ + Access the Events API. + + Returns: + The Events API. + """ + return Events(self.http_client) + + @property + def folders(self) -> Folders: + """ + Access the Folders API. + + Returns: + The Folders API. + """ + return Folders(self.http_client) + + @property + def grants(self) -> Grants: + """ + Access the Grants API. + + Returns: + The Grants API. + """ + return Grants(self.http_client) + + @property + def messages(self) -> Messages: + """ + Access the Messages API. + + Returns: + The Messages API. + """ + return Messages(self.http_client) + + @property + def threads(self) -> Threads: + """ + Access the Threads API. + + Returns: + The Threads API. + """ + return Threads(self.http_client) + + @property + def webhooks(self) -> Webhooks: + """ + Access the Webhooks API. + + Returns: + The Webhooks API. + """ + return Webhooks(self.http_client) diff --git a/nylas/client/__init__.py b/nylas/client/__init__.py deleted file mode 100644 index 57d8f800..00000000 --- a/nylas/client/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from pkgutil import extend_path -from .client import APIClient - -__path__ = extend_path(__path__, __name__) -__all__ = ["APIClient"] diff --git a/nylas/client/authentication_models.py b/nylas/client/authentication_models.py deleted file mode 100644 index 3e634906..00000000 --- a/nylas/client/authentication_models.py +++ /dev/null @@ -1,350 +0,0 @@ -from copy import copy - -from nylas.client.restful_model_collection import RestfulModelCollection, CHUNK_SIZE -from nylas.client.restful_models import NylasAPIObject -from nylas.utils import AuthMethod -from enum import Enum - - -class Integration(NylasAPIObject): - attrs = ( - "name", - "provider", - "expires_in", - "settings", - "redirect_uris", - "scope", - "id", - ) - read_only_attrs = {"provider", "id"} - auth_method = AuthMethod.BASIC_CLIENT_ID_AND_SECRET - collection_name = "connect/integrations" - - def __init__(self, api): - NylasAPIObject.__init__(self, Integration, api) - self.settings = {} - self.scope = [] - - def set_client_id(self, client_id): - """ - Set the client ID of the OAuth provider - - Args: - client_id (str): Client ID of the OAuth provider - """ - self.settings["client_id"] = client_id - - def set_client_secret(self, client_secret): - """ - Set the client secret of the OAuth provider - - Args: - client_secret (str): Client secret of the OAuth provider - """ - self.settings["client_secret"] = client_secret - - @classmethod - def create(cls, api, **kwargs): - if "data" in kwargs: - kwargs = kwargs.get("data") - obj = super(Integration, cls).create(api, **kwargs) - if "provider" in kwargs: - obj["id"] = kwargs.get("provider") - - return obj - - def as_json(self, enforce_read_only=True): - dct = super(Integration, self).as_json(enforce_read_only) - if enforce_read_only is False: - return dct - - if not self.id: - if isinstance(self.provider, Authentication.Provider): - dct["provider"] = self.provider.value - else: - dct["provider"] = self.provider - - return dct - - def _update_resource(self, **kwargs): - provider = self.id or self.provider - return self.api._patch_resource(self.cls, provider, self.as_json(), **kwargs) - - -class Grant(NylasAPIObject): - attrs = ( - "id", - "provider", - "state", - "email", - "ip", - "grant_status", - "user_agent", - "created_at", - "updated_at", - "settings", - "metadata", - "scope", - ) - read_only_attrs = { - "id", - "email", - "ip", - "grant_status", - "user_agent", - "created_at", - "updated_at", - } - auth_method = AuthMethod.BASIC_CLIENT_ID_AND_SECRET - collection_name = "connect/grants" - - def __init__(self, api): - NylasAPIObject.__init__(self, Grant, api) - self.settings = {} - self.metadata = {} - self.scope = [] - - @classmethod - def create(cls, api, **kwargs): - if "data" in kwargs: - kwargs = kwargs.get("data") - obj = super(Grant, cls).create(api, **kwargs) - return obj - - def as_json(self, enforce_read_only=True): - dct = super(Grant, self).as_json(enforce_read_only) - if enforce_read_only is False: - return dct - - # provider and state can not be updated - if self.id: - del dct["provider"] - del dct["state"] - else: - if isinstance(self.provider, Authentication.Provider): - dct["provider"] = self.provider.value - else: - dct["provider"] = self.provider - - return dct - - def _update_resource(self, **kwargs): - return self.api._patch_resource(self.cls, self.id, self.as_json(), **kwargs) - - -class Authentication(object): - def __init__(self, api): - self._app_name = "beta" - self._region = Authentication.Region.US - # Make a copy of the API as we need to change the base url for Integration calls - self.api = copy(api) - self._set_integrations_api_url() - - @property - def app_name(self): - return self._app_name - - @app_name.setter - def app_name(self, value): - """ - Set the name of the application to prefix the URL for all integration calls for this instance - - Args: - value (str): The name of the application - """ - self._app_name = value - self._set_integrations_api_url() - - @property - def region(self): - return self._region - - @region.setter - def region(self, value): - """ - Set the region to prefix the URL for all integration calls for this instance - - Args: - value (Integration.Region): The region - """ - self._region = value - self._set_integrations_api_url() - - @property - def integrations(self): - """ - Integrations API for integrating a provider to the Nylas application - - Returns: - IntegrationRestfulModelCollection: The Integration API configured with the app_name and region - """ - return IntegrationRestfulModelCollection(self.api) - - @property - def grants(self): - """ - Native Authentication for the integrated provider - - Returns: - GrantRestfulModelCollection: The Grants API configured with the app_name and region - """ - return GrantRestfulModelCollection(self.api) - - def hosted_authentication( - self, - provider, - redirect_uri, - grant_id=None, - login_hint=None, - state=None, - expires_in=None, - settings=None, - metadata=None, - scope=None, - ): - """ - Hosted Authentication for the integrated provider - - Args: - provider (Authentication.Provider): OAuth provider - redirect_uri (str): The URI for the final redirect - grant_id (str): Existing Grant ID to trigger a re-authentication - login_hint (str): Hint to simplify the login flow - state (str): State value to return after authentication flow is completed - expires_in (int): How long this request (and the attached login) ID will remain valid before the link expires - settings (dict[str, str]): Settings required by provider - metadata (dict[str, any]): Metadata to store as part of the grant - scope (list[str]): OAuth provider-specific scopes - - Returns: - dict[str, any]: The login information - """ - request = {"provider": provider, "redirect_uri": redirect_uri} - if grant_id: - request["grant_id"] = grant_id - if login_hint: - request["login_hint"] = login_hint - if state: - request["state"] = state - if expires_in: - request["expires_in"] = expires_in - if settings: - request["settings"] = settings - if metadata: - request["metadata"] = metadata - if scope: - request["scope"] = scope - - response = self.api._post_resource(Grant, "auth", None, request, path="connect") - if "data" in response: - response = response["data"] - - return response - - def _set_integrations_api_url(self): - self.api.api_server = "https://{app_name}.{region}.nylas.com".format( - app_name=self.app_name, region=self.region.value - ) - - def _hosted_authentication_enhanced_events( - self, provider, redirect_uri, account_id - ): - request = { - "provider": provider, - "redirect_uri": redirect_uri, - "account_id": account_id, - } - response = self.api._post_resource(Grant, "auth", None, request, path="connect") - if "data" in response: - response = response["data"] - - return response - - class Region(str, Enum): - """ - This is an Enum the regions supported by the Integrations API - """ - - US = "us" - EU = "eu" - - class Provider(str, Enum): - """ - This is an Enum representing all the available providers for integrations - """ - - GOOGLE = "google" - MICROSOFT = "microsoft" - IMAP = "imap" - ZOOM = "zoom" - - -class AuthenticationRestfulModelCollection(RestfulModelCollection): - def __init__(self, model_class, api): - RestfulModelCollection.__init__(self, model_class, api) - - def _get_model_collection(self, offset=0, limit=CHUNK_SIZE): - filters = copy(self.filters) - filters["offset"] = offset - if not filters.get("limit"): - filters["limit"] = limit - - response = self.api._get_resource_raw(self.model_class, None, **filters).json() - if "data" not in response or response["data"] is None: - return [] - - return [ - self.model_class.create(self, **x) - for x in response["data"] - if x is not None - ] - - -class IntegrationRestfulModelCollection(AuthenticationRestfulModelCollection): - def __init__(self, api): - AuthenticationRestfulModelCollection.__init__(self, Integration, api) - - def get(self, provider): - """ - Get an existing integration for a provider - - Args: - provider (Authentication.Provider): The provider - - Returns: - Integration: The existing integration - """ - return super(IntegrationRestfulModelCollection, self).get(provider.value) - - def delete(self, provider, data=None, **kwargs): - """ - Deletes an existing integration for a provider - - Args: - provider (Authentication.Provider): The provider - """ - super(IntegrationRestfulModelCollection, self).delete( - provider.value, data=data, **kwargs - ) - - -class GrantRestfulModelCollection(AuthenticationRestfulModelCollection): - def __init__(self, api): - AuthenticationRestfulModelCollection.__init__(self, Grant, api) - - def on_demand_sync(self, grant_id, sync_from=None): - """ - Trigger a grant sync on demand - - Args: - grant_id (str): The grant ID to sync - sync_from (int): Epoch timestamp when the sync starts from - - Returns: - Grant: The grant after triggering the sync - """ - path = "sync" - if sync_from: - path = path + "?sync_from={}".format(sync_from) - response = self.api._post_resource(Grant, grant_id, path, data=None) - return self.model_class.create(self, **response) diff --git a/nylas/client/client.py b/nylas/client/client.py deleted file mode 100644 index 9a480517..00000000 --- a/nylas/client/client.py +++ /dev/null @@ -1,866 +0,0 @@ -from __future__ import print_function - -import sys -from os import environ -from base64 import b64encode -import json -from datetime import datetime, timedelta -from itertools import chain - -import requests -from urlobject import URLObject -import six -from six.moves.urllib.parse import urlencode -from nylas._client_sdk_version import __VERSION__ -from nylas.client.delta_collection import DeltaCollection -from nylas.client.errors import MessageRejectedError, NylasApiError, RateLimitError -from nylas.client.outbox_models import Outbox -from nylas.client.restful_model_collection import RestfulModelCollection -from nylas.client.restful_models import ( - Calendar, - Contact, - Event, - RoomResource, - Message, - Thread, - File, - Account, - APIAccount, - SingletonAccount, - Folder, - Label, - Draft, - Component, - JobStatus, - Webhook, - Send, -) -from nylas.client.neural_api_models import Neural -from nylas.client.scheduler_restful_model_collection import ( - SchedulerRestfulModelCollection, -) -from nylas.client.authentication_models import Authentication -from nylas.utils import timestamp_from_dt, create_request_body, AuthMethod, HttpMethod - -DEBUG = environ.get("NYLAS_CLIENT_DEBUG") -API_SERVER = "https://api.nylas.com" -SUPPORTED_API_VERSION = "2.5" - - -def _validate(response): - if DEBUG: # pragma: no cover - print( - "{method} {url} ({body}) => {status}: {text}".format( - method=response.request.method, - url=response.request.url, - body=response.request.body, - status=response.status_code, - text=response.text, - ) - ) - - if response.status_code == 402: - # HTTP status code 402 normally means "Payment Required", - # but when Nylas uses that status code, it means something different. - # Usually it indicates an upstream error on the provider. - # We let Requests handle most HTTP errors, but for this one, - # we will handle it separate and handle a _different_ exception - # so that users don't think they need to pay. - raise MessageRejectedError(response) - elif response.status_code == 429: - raise RateLimitError(response) - elif response.status_code >= 400: - raise NylasApiError(response) - - return response - - -def _validate_availability_query(query): - if (query.get("emails", None) is None or len(query["emails"]) == 0) and ( - query.get("calendars", None) is None or len(query["calendars"]) == 0 - ): - raise ValueError("Must set either 'emails' or 'calendars' in the query.") - - -class APIClient(json.JSONEncoder): - """API client for the Nylas API.""" - - def __init__( - self, - client_id=environ.get("NYLAS_CLIENT_ID"), - client_secret=environ.get("NYLAS_CLIENT_SECRET"), - access_token=environ.get("NYLAS_ACCESS_TOKEN"), - api_server=API_SERVER, - api_version=SUPPORTED_API_VERSION, - ): - if not api_server.startswith("https://"): - raise Exception( - "When overriding the Nylas API server address, you" - " must include https://" - ) - self.api_server = api_server - self.api_version = api_version - self.authorize_url = api_server + "/oauth/authorize" - self.access_token_url = api_server + "/oauth/token" - self.revoke_url = api_server + "/oauth/revoke" - self.application_url = api_server + "/a/{client_id}" - self.revoke_all_url = self.application_url + "/accounts/{account_id}/revoke-all" - self.ip_addresses_url = api_server + "/a/{client_id}/ip_addresses" - self.token_info_url = self.application_url + "/accounts/{account_id}/token-info" - - self.client_secret = client_secret - self.client_id = client_id - - self.session = requests.Session() - self.version = __VERSION__ - major, minor, revision, _, __ = sys.version_info - version_header = "Nylas Python SDK {} - {}.{}.{}".format( - self.version, major, minor, revision - ) - self.session.headers = { - "X-Nylas-API-Wrapper": "python", - "X-Nylas-Client-Id": self.client_id, - "Nylas-API-Version": self.api_version, - "User-Agent": version_header, - } - self._access_token = None - self.access_token = access_token - self.auth_token = None - - # Requests to the /a/ namespace don't use an auth token but - # the client_secret. Set up a specific session for this. - self.admin_session = requests.Session() - - if client_secret is not None: - self.admin_session.headers = { - "X-Nylas-API-Wrapper": "python", - "X-Nylas-Client-Id": self.client_id, - "Nylas-API-Version": self.api_version, - "User-Agent": version_header, - } - self.admin_session.headers.update(self._add_auth_header(AuthMethod.BASIC)) - super(APIClient, self).__init__() - - @property - def access_token(self): - return self._access_token - - @access_token.setter - def access_token(self, value): - self._access_token = value - - def authentication_url( - self, - redirect_uri, - login_hint="", - state="", - scopes=("email", "calendar", "contacts"), - provider="", - redirect_on_error=None, - ): - args = { - "redirect_uri": redirect_uri, - "client_id": self.client_id or "None", # 'None' for back-compat - "response_type": "code", - "login_hint": login_hint, - "state": state, - } - - if scopes: - if isinstance(scopes, str): - scopes = [scopes] - args["scopes"] = ",".join(scopes) - if provider and provider in [ - "icloud", - "gmail", - "office365", - "exchange", - "imap", - ]: - args["provider"] = provider - if redirect_on_error is not None and isinstance(redirect_on_error, bool): - args["redirect_on_error"] = "true" if redirect_on_error is True else "false" - - url = URLObject(self.authorize_url).add_query_params(args.items()) - return str(url) - - def send_authorization(self, code): - """ - Exchanges an authorization code for an access token. - - Args: - code (str): The authorization code returned from authenticating the user - - Returns: - dict: The response from the API containing the access token - """ - args = { - "client_id": self.client_id, - "client_secret": self.client_secret, - "grant_type": "authorization_code", - "code": code, - } - - headers = { - "Content-type": "application/x-www-form-urlencoded", - "Accept": "application/json", - } - - resp = self._request( - HttpMethod.POST, - self.access_token_url, - headers=headers, - data=urlencode(args), - ) - results = _validate(resp).json() - - self.access_token = results["access_token"] - return results - - def token_for_code(self, code): - """ - Exchange an authorization code for an access token - - Args: - code (str): One-time authorization code from Nylas - - Returns: - str: The access token - """ - self.send_authorization(code) - return self.access_token - - def is_opensource_api(self): - if self.client_id is None and self.client_secret is None: - return True - - return False - - def application_details(self): - application_details_url = self.application_url.format(client_id=self.client_id) - resp = self.admin_session.get(application_details_url) - _validate(resp).json() - return resp.json() - - def update_application_details( - self, application_name=None, icon_url=None, redirect_uris=None - ): - application_details_url = self.application_url.format(client_id=self.client_id) - data = {} - if application_name is not None: - data["application_name"] = application_name - if icon_url is not None: - data["icon_url"] = icon_url - if redirect_uris is not None: - data["redirect_uris"] = redirect_uris - - headers = {"Content-Type": "application/json"} - headers.update(self.admin_session.headers) - resp = self.admin_session.put( - application_details_url, json=data, headers=headers - ) - return _validate(resp).json() - - def revoke_token(self): - resp = requests.post(self.revoke_url, auth=(self.access_token, None)) - _validate(resp) - self.auth_token = None - self.access_token = None - - def revoke_all_tokens(self, keep_access_token=None): - revoke_all_url = self.revoke_all_url.format( - client_id=self.client_id, account_id=self.account.id - ) - data = {} - if keep_access_token is not None: - data["keep_access_token"] = keep_access_token - - headers = {"Content-Type": "application/json"} - headers.update(self.admin_session.headers) - resp = self.admin_session.post(revoke_all_url, json=data, headers=headers) - _validate(resp).json() - if keep_access_token != self.access_token: - self.auth_token = None - self.access_token = None - - def ip_addresses(self): - ip_addresses_url = self.ip_addresses_url.format(client_id=self.client_id) - resp = self.admin_session.get(ip_addresses_url) - _validate(resp).json() - return resp.json() - - def token_info(self, account_id=None): - token_info_url = "" - if account_id is not None: - token_info_url = self.token_info_url.format( - client_id=self.client_id, account_id=account_id - ) - else: - token_info_url = self.token_info_url.format( - client_id=self.client_id, account_id=self.account.id - ) - headers = {"Content-Type": "application/json"} - headers.update(self.admin_session.headers) - resp = self.admin_session.post( - token_info_url, headers=headers, json={"access_token": self.access_token} - ) - _validate(resp).json() - return resp.json() - - def free_busy(self, emails, start_at, end_at, calendars=None): - if isinstance(emails, six.string_types): - emails = [emails] - if isinstance(start_at, datetime): - start_time = timestamp_from_dt(start_at) - else: - start_time = start_at - if isinstance(end_at, datetime): - end_time = timestamp_from_dt(end_at) - else: - end_time = end_at - url = "{api_server}/calendars/free-busy".format(api_server=self.api_server) - data = { - "emails": emails, - "start_time": start_time, - "end_time": end_time, - } - if calendars is not None and len(calendars) > 0: - data["calendars"] = calendars - - _validate_availability_query(data) - resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) - _validate(resp) - return resp.json() - - def open_hours(self, emails, days, timezone, start, end): - if isinstance(emails, six.string_types): - emails = [emails] - if isinstance(days, int): - days = [days] - if isinstance(start, datetime): - start = "{hour}:{minute}".format(hour=start.hour, minute=start.minute) - if isinstance(start, datetime): - end = "{hour}:{minute}".format(hour=end.hour, minute=end.minute) - return { - "emails": emails, - "days": days, - "timezone": timezone, - "start": start, - "end": end, - "object_type": "open_hours", - } - - def availability( - self, - emails, - duration, - interval, - start_at, - end_at, - event_collection_id=None, - buffer=None, - round_robin=None, - free_busy=None, - open_hours=None, - calendars=None, - ): - if isinstance(emails, six.string_types): - emails = [emails] - if isinstance(duration, timedelta): - duration_minutes = int(duration.total_seconds() // 60) - else: - duration_minutes = int(duration) - if isinstance(interval, timedelta): - interval_minutes = int(interval.total_seconds() // 60) - else: - interval_minutes = int(interval) - if isinstance(start_at, datetime): - start_time = timestamp_from_dt(start_at) - else: - start_time = start_at - if isinstance(end_at, datetime): - end_time = timestamp_from_dt(end_at) - else: - end_time = end_at - if open_hours is not None: - self._validate_open_hours(emails, open_hours, free_busy) - - url = "{api_server}/calendars/availability".format(api_server=self.api_server) - data = { - "emails": emails, - "duration_minutes": duration_minutes, - "interval_minutes": interval_minutes, - "start_time": start_time, - "end_time": end_time, - "free_busy": free_busy or [], - "open_hours": open_hours or [], - } - if buffer is not None: - data["buffer"] = buffer - if round_robin is not None: - data["round_robin"] = round_robin - if event_collection_id is not None: - data["event_collection_id"] = event_collection_id - if calendars is not None and len(calendars) > 0: - data["calendars"] = calendars - - _validate_availability_query(data) - resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) - _validate(resp) - return resp.json() - - def consecutive_availability( - self, - emails, - duration, - interval, - start_at, - end_at, - buffer=None, - free_busy=None, - open_hours=None, - calendars=None, - ): - if isinstance(emails, six.string_types): - emails = [[emails]] - elif len(emails) > 0 and isinstance(emails[0], list) is False: - raise ValueError("'emails' must be a list of lists.") - if isinstance(duration, timedelta): - duration_minutes = int(duration.total_seconds() // 60) - else: - duration_minutes = int(duration) - if isinstance(interval, timedelta): - interval_minutes = int(interval.total_seconds() // 60) - else: - interval_minutes = int(interval) - if isinstance(start_at, datetime): - start_time = timestamp_from_dt(start_at) - else: - start_time = start_at - if isinstance(end_at, datetime): - end_time = timestamp_from_dt(end_at) - else: - end_time = end_at - if open_hours is not None: - self._validate_open_hours(emails, open_hours, free_busy) - - url = "{api_server}/calendars/availability/consecutive".format( - api_server=self.api_server - ) - data = { - "emails": emails, - "duration_minutes": duration_minutes, - "interval_minutes": interval_minutes, - "start_time": start_time, - "end_time": end_time, - "free_busy": free_busy or [], - "open_hours": open_hours or [], - } - if buffer is not None: - data["buffer"] = buffer - if calendars is not None and len(calendars) > 0: - data["calendars"] = calendars - - _validate_availability_query(data) - resp = self._request(HttpMethod.POST, url, json=data, cls=Calendar) - _validate(resp) - return resp.json() - - @property - def account(self): - return self._get_resource(SingletonAccount, "") - - @property - def accounts(self): - if self.is_opensource_api(): - return RestfulModelCollection(APIAccount, self) - return RestfulModelCollection(Account, self) - - @property - def threads(self): - return RestfulModelCollection(Thread, self) - - @property - def folders(self): - return RestfulModelCollection(Folder, self) - - @property - def labels(self): - return RestfulModelCollection(Label, self) - - @property - def messages(self): - return RestfulModelCollection(Message, self) - - @property - def files(self): - return RestfulModelCollection(File, self) - - @property - def drafts(self): - return RestfulModelCollection(Draft, self) - - @property - def contacts(self): - return RestfulModelCollection(Contact, self) - - @property - def events(self): - return RestfulModelCollection(Event, self) - - @property - def room_resources(self): - return RestfulModelCollection(RoomResource, self) - - @property - def calendars(self): - return RestfulModelCollection(Calendar, self) - - @property - def job_statuses(self): - return RestfulModelCollection(JobStatus, self) - - @property - def scheduler(self): - return SchedulerRestfulModelCollection(self) - - @property - def components(self): - return RestfulModelCollection(Component, self) - - @property - def deltas(self): - return DeltaCollection(self) - - @property - def webhooks(self): - return RestfulModelCollection(Webhook, self) - - @property - def neural(self): - return Neural(self) - - @property - def outbox(self): - return Outbox(self) - - @property - def authentication(self): - return Authentication(self) - - ########################################################## - # Private functions used by Restful Model Collection # - ########################################################## - - def _get_http_session(self, api_root): - # Is this a request for a resource under the accounts/billing/admin - # namespace (/a)? If the latter, pass the client_secret - # instead of the secret_token - if api_root: - return self.admin_session - return self.session - - def _get_resources(self, cls, extra=None, **filters): - # FIXME @karim: remove this interim code when we've got rid - # of the old accounts API. - postfix = "/{}".format(extra) if extra else "" - path = "/{}".format(cls.collection_name) if cls.collection_name else "" - if not cls.api_root: - url = "{server}{path}{postfix}".format( - server=self.api_server, path=path, postfix=postfix - ) - else: - url = "{server}/{prefix}/{client_id}{path}{postfix}".format( - server=self.api_server, - prefix=cls.api_root, - client_id=self.client_id, - path=path, - postfix=postfix, - ) - - converted_data = create_request_body(filters, cls.datetime_filter_attrs) - url = str(URLObject(url).add_query_params(converted_data.items())) - response = self._request(HttpMethod.GET, url, cls=cls) - results = _validate(response).json() - return [cls.create(self, **x) for x in results if x is not None] - - def _get_resource_raw( - self, - cls, - resource_id, - extra=None, - headers=None, - stream=False, - path=None, - stream_timeout=None, - **filters - ): - """Get an individual REST resource""" - if path is None: - path = cls.collection_name - postfix = "/{}".format(extra) if extra else "" - path = "/{}".format(path) if path else "" - resource_id = "/{}".format(resource_id) if resource_id else "" - if not cls.api_root: - url = "{server}{path}{id}{postfix}".format( - server=self.api_server, path=path, id=resource_id, postfix=postfix - ) - else: - url = "{server}/{prefix}/{client_id}{path}{id}{postfix}".format( - server=self.api_server, - prefix=cls.api_root, - client_id=self.client_id, - path=path, - id=resource_id, - postfix=postfix, - ) - - converted_data = create_request_body(filters, cls.datetime_filter_attrs) - url = str(URLObject(url).add_query_params(converted_data.items())) - - response = self._request( - HttpMethod.GET, - url, - cls=cls, - headers=headers, - stream=stream, - timeout=stream_timeout, - ) - return _validate(response) - - def _get_resource(self, cls, resource_id, **filters): - response = self._get_resource_raw(cls, resource_id, **filters) - result = response.json() - if isinstance(result, list): - result = result[0] - return cls.create(self, **result) - - def _get_resource_data(self, cls, resource_id, extra=None, headers=None, **filters): - response = self._get_resource_raw( - cls, resource_id, extra=extra, headers=headers, **filters - ) - return response.content - - def _create_resource(self, cls, data, **kwargs): - name = "{prefix}{path}".format( - prefix="/{}/{}".format(cls.api_root, self.client_id) - if cls.api_root - else "", - path="/{}".format(cls.collection_name) if cls.collection_name else "", - ) - url = ( - URLObject(self.api_server) - .with_path("{name}".format(name=name)) - .set_query_params(**kwargs) - ) - - if cls == File: - response = self._request(HttpMethod.POST, url, cls=cls, files=data) - elif cls == Send and type(data) is not dict: - headers = {"Content-Type": "message/rfc822"} - response = self._request( - HttpMethod.POST, url, cls=cls, headers=headers, data=data - ) - else: - converted_data = create_request_body(data, cls.datetime_attrs) - headers = {"Content-Type": "application/json"} - response = self._request( - HttpMethod.POST, url, cls=cls, headers=headers, json=converted_data - ) - - result = _validate(response).json() - if cls.collection_name == "send": - return result - return cls.create(self, **result) - - def _create_resources(self, cls, data): - name = "{prefix}{path}".format( - prefix="/{}/{}".format(cls.api_root, self.client_id) - if cls.api_root - else "", - path="/{}".format(cls.collection_name) if cls.collection_name else "", - ) - url = URLObject(self.api_server).with_path("{name}".format(name=name)) - - if cls == File: - response = self._request(HttpMethod.POST, url, cls=cls, files=data) - else: - converted_data = [ - create_request_body(datum, cls.datetime_attrs) for datum in data - ] - headers = {"Content-Type": "application/json"} - response = self._request( - HttpMethod.POST, url, cls=cls, headers=headers, json=converted_data - ) - - results = _validate(response).json() - return [cls.create(self, **x) for x in results] - - def _delete_resource(self, cls, resource_id, data=None, **kwargs): - name = "{prefix}{path}".format( - prefix="/{}/{}".format(cls.api_root, self.client_id) - if cls.api_root - else "", - path="/{}".format(cls.collection_name) if cls.collection_name else "", - ) - url = ( - URLObject(self.api_server) - .with_path("{name}/{id}".format(name=name, id=resource_id)) - .set_query_params(**kwargs) - ) - if data: - _validate(self._request(HttpMethod.DELETE, url, cls=cls, json=data)) - else: - _validate(self._request(HttpMethod.DELETE, url, cls=cls)) - - def _request_update_resource( - self, method, cls, resource_id, data, extra=None, path=None, **kwargs - ): - if path is None: - path = cls.collection_name - name = "{prefix}{path}".format( - prefix="/{}/{}".format(cls.api_root, self.client_id) - if cls.api_root - else "", - path="/{}".format(path) if path else "", - ) - - postfix = "/{}".format(extra) if extra else "" - url = ( - URLObject(self.api_server) - .with_path( - "{name}/{id}{postfix}".format( - name=name, id=resource_id, postfix=postfix - ) - ) - .set_query_params(**kwargs) - ) - converted_data = create_request_body(data, cls.datetime_attrs) - - response = self._request(method, url, cls=cls, json=converted_data) - - result = _validate(response) - return result.json() - - def _patch_resource(self, cls, resource_id, data, extra=None, path=None, **kwargs): - return self._request_update_resource( - HttpMethod.PATCH, cls, resource_id, data, extra=extra, path=path, **kwargs - ) - - def _put_resource(self, cls, resource_id, data, extra=None, path=None, **kwargs): - return self._request_update_resource( - HttpMethod.PUT, cls, resource_id, data, extra=extra, path=path, **kwargs - ) - - def _update_resource(self, cls, resource_id, data, **kwargs): - result = self._put_resource(cls, resource_id, data, **kwargs) - return cls.create(self, **result) - - def _post_resource(self, cls, resource_id, method_name, data, path=None): - if path is None: - path = cls.collection_name - path = "/{}".format(path) if path else "" - resource_id = "/{}".format(resource_id) if resource_id else "" - method = "/{}".format(method_name) if method_name else "" - if not cls.api_root: - url_path = "{name}{id}{method}".format( - name=path, id=resource_id, method=method - ) - else: - # Management method. - url_path = "/{prefix}/{client_id}{path}{id}{method}".format( - prefix=cls.api_root, - client_id=self.client_id, - path=path, - id=resource_id, - method=method, - ) - - url = URLObject(self.api_server).with_path(url_path) - converted_data = create_request_body(data, cls.datetime_attrs) - - response = self._request(HttpMethod.POST, url, cls=cls, json=converted_data) - return _validate(response).json() - - def _call_resource_method(self, cls, resource_id, method_name, data): - """POST a dictionary to an API method, - for example /a/.../accounts/id/upgrade""" - - result = self._post_resource(cls, resource_id, method_name, data) - return cls.create(self, **result) - - def _request_neural_resource(self, cls, data, path=None, method=None): - if path is None: - path = cls.collection_name - if method is None: - method = HttpMethod.PUT - url = URLObject(self.api_server).with_path("/neural/{name}".format(name=path)) - - converted_data = create_request_body(data, cls.datetime_attrs) - response = self._request(method, url, cls=cls, json=converted_data) - - result = _validate(response).json() - if isinstance(result, list): - object_list = [] - for obj in result: - object_list.append(cls.create(self, **obj)) - return object_list - - return cls.create(self, **result) - - def _validate_open_hours(self, emails, open_hours, free_busy): - if isinstance(open_hours, list) is False: - raise ValueError("'open_hours' must be an array.") - open_hours_emails = list( - chain.from_iterable([oh["emails"] for oh in open_hours]) - ) - free_busy_emails = ( - [fb["email"] for fb in free_busy] if free_busy is not None else [] - ) - if isinstance(emails[0], list) is True: - emails = list(chain.from_iterable(emails)) - for email in open_hours_emails: - if (email in emails) is False and (email in free_busy_emails) is False: - raise ValueError( - "Open Hours cannot contain an email not present in the main email list or the free busy email list." - ) - - def _request(self, method, url, cls=None, headers=None, **kwargs): - api_root = None - auth_method = None - if cls: - api_root = cls.api_root - auth_method = cls.auth_method - - session = self._get_http_session(api_root) - headers = headers or {} - headers.update(session.headers) - headers.update(self._add_auth_header(auth_method)) - return session.request(method.name, url, headers=headers, **kwargs) - - def _add_auth_header(self, auth_method): - authorization = None - if auth_method == AuthMethod.BEARER: - authorization = ( - "Bearer {token}".format(token=self.access_token) - if self.access_token - else None - ) - elif auth_method == AuthMethod.BASIC_CLIENT_ID_AND_SECRET: - if self.client_id and self.client_secret: - credential = "{client_id}:{client_secret}".format( - client_id=self.client_id, client_secret=self.client_secret - ) - authorization = "Basic {credential}".format( - credential=b64encode(credential.encode("utf8")).decode("utf8") - ) - else: - if self.client_secret: - b64_client_secret = b64encode( - ("{}:".format(self.client_secret)).encode("utf8") - ) - authorization = "Basic {secret}".format( - secret=b64_client_secret.decode("utf8") - ) - - return {"Authorization": authorization} if authorization else {} diff --git a/nylas/client/delta_collection.py b/nylas/client/delta_collection.py deleted file mode 100644 index f01c29ad..00000000 --- a/nylas/client/delta_collection.py +++ /dev/null @@ -1,195 +0,0 @@ -import json - -from requests import ReadTimeout - -from nylas.client.delta_models import Delta, Deltas - - -class DeltaCollection: - path = "delta" - - def __init__(self, api): - self.api = api - - def latest_cursor(self): - """ - Returns the latest delta cursor - - Returns: - str: The latest cursor - - Raises: - RuntimeError: If the server returns an object without a cursor - """ - - response = self.api._post_resource( - Delta, "latest_cursor", None, None, path=self.path - ) - if "cursor" not in response: - raise RuntimeError( - "Unexpected response from the API server. Returned 200 but no 'cursor' string found." - ) - - return response["cursor"] - - def since(self, cursor, view=None, include_types=None, excluded_types=None): - """ - Get a list of delta cursors since a specified cursor - - Args: - cursor (str): The first cursor to request from - view (str): Value representing if delta expands thread and message objects. - include_types (list[str] | str): The objects to exclusively include in the returned deltas. Note you cannot set both included and excluded types. - excluded_types (list[str] | str): The objects to exclude in the returned deltas. Note you cannot set both included and excluded types. - - Returns: - Deltas: The API response containing the list of deltas - - Raises: - ValueError: If both include_types and excluded_types are set - """ - - include_types, excluded_types = _validate_types(include_types, excluded_types) - response = self.api._get_resource_raw( - Delta, - None, - path=self.path, - cursor=cursor, - view=view, - include_types=include_types, - excluded_types=excluded_types, - ).json() - return Deltas.create(self.api, **response) - - def stream( - self, - cursor, - callback=None, - timeout=None, - view=None, - include_types=None, - excluded_types=None, - ): - """ - Stream deltas - - Args: - cursor (str): The cursor to stream from - callback: A callable function to invoke on each delta received. No callback is set by default. - timeout (int): The number of seconds to stream for before timing out. No timeout is set by default. - view (str): Value representing if delta expands thread and message objects. - include_types (list[str] | str): The objects to exclusively include in the returned deltas. Note you cannot set both included and excluded types. - excluded_types (list[str] | str): The objects to exclude in the returned deltas. Note you cannot set both included and excluded types. - - Returns: - list[Delta]: The list of streamed deltas - - Raises: - ValueError: If both include_types and excluded_types are set - """ - - deltas = [] - include_types, excluded_types = _validate_types(include_types, excluded_types) - emit_deltas = False - if callback and callable(callback): - emit_deltas = True - - try: - response = self.api._get_resource_raw( - Delta, - "streaming", - stream=True, - path=self.path, - stream_timeout=timeout, - cursor=cursor, - view=view, - include_types=include_types, - excluded_types=excluded_types, - ) - for raw_rsp in response.iter_lines(): - if raw_rsp: - response_json = json.loads(raw_rsp) - delta = Delta.create(self.api, **response_json) - deltas.append(delta) - if emit_deltas: - callback(delta) - except ReadTimeout: - pass - - return deltas - - def longpoll( - self, - cursor, - timeout, - callback=None, - view=None, - include_types=None, - excluded_types=None, - ): - """ - Long-poll for deltas - - Args: - cursor (str): The cursor to poll from - timeout (int): The number of seconds to poll for before timing out - callback: A callable function to invoke on each delta received. No callback is set by default. - view (str): Value representing if delta expands thread and message objects. - include_types (list[str] | str): The objects to exclusively include in the returned deltas. Note you cannot set both included and excluded types. - excluded_types (list[str] | str): The objects to exclude in the returned deltas. Note you cannot set both included and excluded types. - - Returns: - Deltas: The API response containing the list of deltas - - Raises: - ValueError: If both include_types and excluded_types are set - """ - - delta = {} - include_types, excluded_types = _validate_types(include_types, excluded_types) - emit_deltas = False - if callback and callable(callback): - emit_deltas = True - - buffer = bytearray() - response = self.api._get_resource_raw( - Delta, - "longpoll", - stream=True, - path=self.path, - timeout=timeout, - cursor=cursor, - view=view, - include_types=include_types, - excluded_types=excluded_types, - ) - for raw_rsp in response.iter_lines(): - if raw_rsp: - buffer.extend(raw_rsp) - try: - buffer_json = json.loads(buffer.decode()) - delta = Deltas.create(self.api, **buffer_json) - if emit_deltas: - callback(delta) - except ValueError: - continue - - return delta - - -# Helper functions for validating type inputs -def _validate_types(include_types, excluded_types): - if include_types and excluded_types: - raise ValueError("You cannot set both include_types and excluded_types") - - return _join_types(include_types), _join_types(excluded_types) - - -def _join_types(types): - if types: - if isinstance(types, str): - return types - try: - return ",".join(types) - except TypeError: - return None diff --git a/nylas/client/delta_models.py b/nylas/client/delta_models.py deleted file mode 100644 index c5b104a8..00000000 --- a/nylas/client/delta_models.py +++ /dev/null @@ -1,73 +0,0 @@ -from nylas.client.restful_models import ( - RestfulModel, - NylasAPIObject, - Contact, - File, - Message, - Draft, - Thread, - Event, - Folder, - Label, -) - - -class Deltas(RestfulModel): - attrs = ( - "cursor_start", - "cursor_end", - "_deltas", - ) - read_only_attrs = tuple(attrs) - - def __init__(self, api): - RestfulModel.__init__(self, Deltas, api) - - @property - def deltas(self): - """ - Instantiate a Delta object from the API response - - Returns: - list[Delta]: List of Delta instantiated objects - """ - if self._deltas: - deltas = [] - for delta in self._deltas: - deltas.append(Delta.create(self.api, **delta)) - return deltas - - -class Delta(RestfulModel): - attrs = ( - "id", - "cursor", - "event", - "object", - "_attributes", - ) - read_only_attrs = tuple(attrs) - class_mapping = { - "contact": Contact, - "file": File, - "message": Message, - "draft": Draft, - "thread": Thread, - "event": Event, - "folder": Folder, - "label": Label, - } - - def __init__(self, api): - RestfulModel.__init__(self, Delta, api) - - @property - def attributes(self): - """ - Instantiate the object provided in the Delta - - Returns: - NylasAPIObject: The object of NylasAPIObject type represented in the Delta - """ - if self._attributes and self.object and self.object in self.class_mapping: - return self.class_mapping[self.object].create(self.api, **self._attributes) diff --git a/nylas/client/errors.py b/nylas/client/errors.py deleted file mode 100644 index b8d7ef24..00000000 --- a/nylas/client/errors.py +++ /dev/null @@ -1,62 +0,0 @@ -import json - -from requests import HTTPError - - -class NylasError(Exception): - pass - - -class MessageRejectedError(NylasError): - pass - - -class FileUploadError(NylasError): - pass - - -class UnSyncedError(NylasError): - """ - HTTP Code 202 - The request was valid but the resource wasn't ready. Retry the request with exponential backoff. - """ - - pass - - -class NylasApiError(HTTPError): - """ - Error class for Nylas API Errors - This class provides more information to the user sent from the server, if present - """ - - def __init__(self, response): - try: - response_json = json.loads(response.text) - error_message = "%s %s. Reason: %s. Nylas Error Type: %s" % ( - response.status_code, - response.reason, - response_json["message"], - response_json["type"], - ) - super(NylasApiError, self).__init__(error_message, response=response) - except (ValueError, KeyError): - super(NylasApiError, self).__init__(response.text, response=response) - - -class RateLimitError(NylasApiError): - """ - Error class for 429 rate limit errors - This class provides details about the rate limit returned from the server - """ - - RATE_LIMIT_LIMIT_HEADER = "X-RateLimit-Limit" - RATE_LIMIT_RESET_HEADER = "X-RateLimit-Reset" - - def __init__(self, response): - try: - self.rate_limit = int(response.headers[self.RATE_LIMIT_LIMIT_HEADER]) - self.rate_limit_reset = int(response.headers[self.RATE_LIMIT_RESET_HEADER]) - super(RateLimitError, self).__init__(response) - except (ValueError, KeyError): - super(RateLimitError, self).__init__(response) diff --git a/nylas/client/neural_api_models.py b/nylas/client/neural_api_models.py deleted file mode 100644 index e4aa8ce2..00000000 --- a/nylas/client/neural_api_models.py +++ /dev/null @@ -1,184 +0,0 @@ -from nylas.client.restful_models import RestfulModel, Message, File, Contact -from nylas.utils import HttpMethod -import re - - -def _add_options_to_body(body, options): - options_dict = options.__dict__ - # Only append set options to body to prevent a 400 error - options_filtered = {k: v for k, v in options_dict.items() if v is not None} - return body.update(options_filtered) - - -class Neural(RestfulModel): - def __init__(self, api): - RestfulModel.__init__(self, Neural, api) - - def sentiment_analysis_message(self, message_ids): - body = {"message_id": message_ids} - return self.api._request_neural_resource(NeuralSentimentAnalysis, body) - - def sentiment_analysis_text(self, text): - body = {"text": text} - return self.api._request_neural_resource(NeuralSentimentAnalysis, body) - - def extract_signature(self, message_ids, parse_contacts=None, options=None): - body = {"message_id": message_ids} - if parse_contacts is not None and isinstance(parse_contacts, bool): - body["parse_contacts"] = parse_contacts - if options is not None and isinstance(options, NeuralMessageOptions): - _add_options_to_body(body, options) - signatures = self.api._request_neural_resource(NeuralSignatureExtraction, body) - if parse_contacts is not False: - for sig in signatures: - sig.contacts = NeuralSignatureContact.create(self.api, **sig.contacts) - return signatures - - def ocr_request(self, file_id, pages=None): - body = {"file_id": file_id} - if pages is not None and isinstance(pages, list): - body["pages"] = pages - return self.api._request_neural_resource(NeuralOcr, body) - - def categorize(self, message_ids): - body = {"message_id": message_ids} - categorized = self.api._request_neural_resource(NeuralCategorizer, body) - for message in categorized: - message.categorizer = Categorize.create(self.api, **message.categorizer) - return categorized - - def clean_conversation(self, message_ids, options=None): - body = {"message_id": message_ids} - if options is not None and isinstance(options, NeuralMessageOptions): - _add_options_to_body(body, options) - return self.api._request_neural_resource(NeuralCleanConversation, body) - - -class NeuralMessageOptions: - def __init__( - self, - ignore_links=None, - ignore_images=None, - ignore_tables=None, - remove_conclusion_phrases=None, - images_as_markdowns=None, - ): - self.ignore_links = ignore_links - self.ignore_images = ignore_images - self.ignore_tables = ignore_tables - self.remove_conclusion_phrases = remove_conclusion_phrases - self.images_as_markdowns = images_as_markdowns - - -class NeuralSentimentAnalysis(RestfulModel): - attrs = [ - "account_id", - "sentiment", - "sentiment_score", - "processed_length", - "text", - ] - collection_name = "sentiment" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralSentimentAnalysis, api) - - -class NeuralSignatureExtraction(Message): - attrs = Message.attrs + ["signature", "model_version", "contacts"] - collection_name = "signature" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralSignatureExtraction, api) - - -class NeuralSignatureContact(RestfulModel): - attrs = ["job_titles", "links", "phone_numbers", "emails", "names"] - collection_name = "signature_contact" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralSignatureContact, api) - - def to_contact_object(self): - contact = {} - if self.names is not None: - contact["given_name"] = self.names[0]["first_name"] - contact["surname"] = self.names[0]["last_name"] - if self.job_titles is not None: - contact["job_title"] = self.job_titles[0] - if self.emails is not None: - contact["emails"] = [] - for email in self.emails: - contact["emails"].append({"type": "personal", "email": email}) - if self.phone_numbers is not None: - contact["phone_numbers"] = [] - for number in self.phone_numbers: - contact["phone_numbers"].append({"type": "mobile", "number": number}) - if self.links is not None: - contact["web_pages"] = [] - for url in self.links: - description = url["description"] if url["description"] else "homepage" - contact["web_pages"].append({"type": description, "url": url["url"]}) - - return Contact.create(self.api, **contact) - - -class NeuralCategorizer(Message): - attrs = Message.attrs + ["categorizer"] - collection_name = "categorize" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralCategorizer, api) - - def recategorize(self, category): - data = {"message_id": self.id, "category": category} - self.api._request_neural_resource( - NeuralCategorizer, data, "categorize/feedback", method=HttpMethod.POST - ) - data = {"message_id": self.id} - response = self.api._request_neural_resource(NeuralCategorizer, data) - categorize = response[0] - if categorize.categorizer: - categorize.categorizer = Categorize.create( - self.api, **categorize.categorizer - ) - return categorize - - -class Categorize(RestfulModel): - attrs = ["category", "categorized_at", "model_version", "subcategories"] - datetime_attrs = {"categorized_at": "categorized_at"} - collection_name = "category" - - def __init__(self, api): - RestfulModel.__init__(self, Categorize, api) - - -class NeuralCleanConversation(Message): - attrs = Message.attrs + [ - "conversation", - "model_version", - ] - collection_name = "conversation" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralCleanConversation, api) - - def extract_images(self): - pattern = r"[\(']cid:(.*?)[\)']" - file_ids = re.findall(pattern, self.conversation) - files = [] - for match in file_ids: - files.append(self.api.files.get(match)) - return files - - -class NeuralOcr(File): - attrs = File.attrs + [ - "ocr", - "processed_pages", - ] - collection_name = "ocr" - - def __init__(self, api): - RestfulModel.__init__(self, NeuralOcr, api) diff --git a/nylas/client/outbox_models.py b/nylas/client/outbox_models.py deleted file mode 100644 index f5731ca3..00000000 --- a/nylas/client/outbox_models.py +++ /dev/null @@ -1,178 +0,0 @@ -from datetime import datetime - -from nylas.client.restful_models import RestfulModel, Draft -from nylas.utils import timestamp_from_dt - - -class OutboxMessage(RestfulModel): - attrs = Draft.attrs + [ - "send_at", - "retry_limit_datetime", - "original_send_at", - ] - datetime_attrs = { - "send_at": "send_at", - "retry_limit_datetime": "retry_limit_datetime", - "original_send_at": "original_send_at", - } - read_only_attrs = {"send_at", "retry_limit_datetime", "original_send_at"} - collection_name = "v2/outbox" - - def __init__(self, api): - RestfulModel.__init__(self, OutboxMessage, api) - - -class OutboxJobStatus(RestfulModel): - attrs = [ - "account_id", - "job_status_id", - "status", - "original_data", - ] - collection_name = "v2/outbox" - - def __init__(self, api): - RestfulModel.__init__(self, OutboxJobStatus, api) - - -class SendGridVerifiedStatus(RestfulModel): - attrs = [ - "domain_verified", - "sender_verified", - ] - collection_name = "v2/outbox/onboard" - - def __init__(self, api): - RestfulModel.__init__(self, SendGridVerifiedStatus, api) - - -class Outbox: - def __init__(self, api): - self.api = api - - def send(self, draft, send_at, retry_limit_datetime=None): - """ - Send a message via Outbox - - Args: - draft (Draft | OutboxMessage): The message to send - send_at (datetime | int): The date and time to send the message. If set to 0, Outbox will send this message immediately. - retry_limit_datetime (datetime | int): Optional date and time to stop retry attempts for a message. - - Returns: - OutboxJobStatus: The Outbox message job status - - Raises: - ValueError: If the date and times provided are not valid - """ - draft_json = draft.as_json() - send_at, retry_limit_datetime = self._validate_and_format_datetime( - send_at, retry_limit_datetime - ) - - draft_json["send_at"] = send_at - if retry_limit_datetime is not None: - draft_json["retry_limit_datetime"] = retry_limit_datetime - - return self.api._create_resource(OutboxJobStatus, draft_json) - - def update( - self, job_status_id, draft=None, send_at=None, retry_limit_datetime=None - ): - """ - Update a scheduled Outbox message - - Args: - job_status_id (str): The ID of the outbox job status - draft (Draft | OutboxMessage): The message object with updated values - send_at (datetime | int): The date and time to send the message. If set to 0, Outbox will send this message immediately. - retry_limit_datetime (datetime | int): Optional date and time to stop retry attempts for a message. - - Returns: - OutboxJobStatus: The updated Outbox message job status - - Raises: - ValueError: If the date and times provided are not valid - """ - payload = {} - if draft: - payload = draft.as_json() - send_at, retry_limit_datetime = self._validate_and_format_datetime( - send_at, retry_limit_datetime - ) - - if send_at is not None: - payload["send_at"] = send_at - if retry_limit_datetime is not None: - payload["retry_limit_datetime"] = retry_limit_datetime - - response = self.api._patch_resource(OutboxJobStatus, job_status_id, payload) - return OutboxJobStatus.create(self.api, **response) - - def delete(self, job_status_id): - """ - Delete a scheduled Outbox message - - Args: - job_status_id (str): The ID of the outbox job status to delete - """ - - self.api._delete_resource(OutboxJobStatus, job_status_id) - - def send_grid_verification_status(self): - """ - SendGrid - Check Authentication and Verification Status - - Returns: - SendGridVerifiedStatus: The status of the domain authentication and the single sender verification for SendGrid integrations - - Raises: - RuntimeError: If the server returns an object without results - """ - response = self.api._get_resource_raw( - SendGridVerifiedStatus, None, extra="verified_status" - ) - response_body = response.json() - if "results" not in response_body: - raise RuntimeError( - "Unexpected response from the API server. Returned 200 but no 'ics' string found." - ) - return SendGridVerifiedStatus.create(self.api, **response_body["results"]) - - def delete_send_grid_sub_user(self, email_address): - """ - SendGrid - Delete SendGrid Subuser and UAS Grant - - Args: - email_address (str): Email address for SendGrid subuser to delete - """ - payload = {"email": email_address} - - self.api._delete_resource(SendGridVerifiedStatus, "subuser", data=payload) - - def _validate_and_format_datetime(self, send_at, retry_limit_datetime): - send_at_epoch = ( - timestamp_from_dt(send_at) if isinstance(send_at, datetime) else send_at - ) - retry_limit_datetime_epoch = ( - timestamp_from_dt(retry_limit_datetime) - if isinstance(retry_limit_datetime, datetime) - else retry_limit_datetime - ) - now_epoch = timestamp_from_dt(datetime.today()) - - if send_at_epoch and send_at_epoch != 0 and send_at_epoch < now_epoch: - raise ValueError( - "Cannot set message to be sent at a time before the current time." - ) - - if retry_limit_datetime_epoch and retry_limit_datetime_epoch != 0: - current_send_at = ( - send_at_epoch if send_at_epoch and send_at_epoch != 0 else now_epoch - ) - if retry_limit_datetime_epoch < current_send_at: - raise ValueError( - "Cannot set message to stop retrying before time to send at." - ) - - return send_at_epoch, retry_limit_datetime_epoch diff --git a/nylas/client/restful_model_collection.py b/nylas/client/restful_model_collection.py deleted file mode 100644 index 408e4e09..00000000 --- a/nylas/client/restful_model_collection.py +++ /dev/null @@ -1,175 +0,0 @@ -from copy import copy -from nylas.utils import convert_metadata_pairs_to_array - -CHUNK_SIZE = 50 - - -class RestfulModelCollection(object): - def __init__(self, cls, api, filter=None, offset=0, **filters): - if filter: - filters.update(filter) - from nylas.client import APIClient - - if not isinstance(api, APIClient): - raise Exception("Provided api was not an APIClient.") - - filters.setdefault("offset", offset) - - self.model_class = cls - self.filters = filters - self.api = api - - def __iter__(self): - return self.values() - - def values(self): - limit = self.filters.get("limit") - offset = self.filters["offset"] - fetched = 0 - # Currently, the Nylas API handles pagination poorly: API responses do not expose - # any information about pagination, so the client does not know whether there is - # another page of data or not. For example, if the client sends an API request - # without a limit specified, and the response contains 100 items, how can it tell - # if there are 100 items in total, or if there more items to fetch on the next page? - # It can't! The only way to know is to ask for the next page (by repeating the API - # request with `offset=100`), and see if you get more items or not. - # If it does not receive more items, it can assume that it has retrieved all the data. - while True: - if limit: - if fetched >= limit: - break - - req_limit = min(CHUNK_SIZE, limit - fetched) - else: - req_limit = CHUNK_SIZE - - models = self._get_model_collection(offset + fetched, req_limit) - if not models: - break - - for model in models: - yield model - - fetched += len(models) - - def first(self): - results = self._get_model_collection(0, 1) - if results: - return results[0] - return None - - def all(self, limit=float("infinity")): - if "limit" in self.filters and self.filters["limit"] is not None: - limit = self.filters["limit"] - return self._range(self.filters["offset"], limit) - - def count(self): - """ - Get the number of objects in the collection being queried - - Returns: - int: The number of objects in the collection being queried - """ - filters = self.filters.copy() - filters["view"] = "count" - response = self.api._get_resource_raw( - self.model_class, resource_id=None, **self.filters - ).json() - return response["count"] - - def where(self, filter=None, **filters): - # Some API parameters like "from" and "in" also are - # Python reserved keywords. To work around this, we rename - # them to "from_" and "in_". The API still needs them in - # their correct form though. - reserved_keywords = ["from", "in"] - for keyword in reserved_keywords: - escaped_keyword = "{}_".format(keyword) - if escaped_keyword in filters: - filters[keyword] = filters.get(escaped_keyword) - del filters[escaped_keyword] - - if filter: - filters.update(filter) - filters.setdefault("offset", 0) - - if "metadata_pair" in filters: - pairs = convert_metadata_pairs_to_array(filters["metadata_pair"]) - filters["metadata_pair"] = pairs - - collection = copy(self) - collection.filters = filters - return collection - - def get(self, id): - return self._get_model(id) - - def create(self, **kwargs): - return self.model_class.create(self.api, **kwargs) - - def delete(self, id, data=None, **kwargs): - return self.api._delete_resource(self.model_class, id, data=data, **kwargs) - - def search( - self, q, limit=None, offset=None, view=None - ): # pylint: disable=invalid-name - from nylas.client.restful_models import ( - Message, - Thread, - ) # pylint: disable=cyclic-import - - if self.model_class is Thread or self.model_class is Message: - kwargs = {"q": q} - if limit is not None: - kwargs["limit"] = limit - if offset is not None: - kwargs["offset"] = offset - if view is not None and self.model_class is Thread: - kwargs["view"] = view - return self.api._get_resources(self.model_class, extra="search", **kwargs) - else: - raise Exception("Searching is only allowed on Thread and Message models") - - def __getitem__(self, key): - if isinstance(key, slice): - if key.step is not None: - raise ValueError( - "'step' not supported for slicing " - "RestfulModelCollection objects " - "(e.g. messages[::step])" - ) - elif key.start < 0 or key.stop < 0: - raise ValueError("slice indices must be positive") - elif key.stop - key.start < 0: - raise ValueError( - "ending slice index cannot be less than " "starting index" - ) - return self._range(key.start, key.stop - key.start) - else: - return self._get_model_collection(key, 1)[0] - - # Private functions - - def _get_model_collection(self, offset=0, limit=CHUNK_SIZE): - filters = copy(self.filters) - filters["offset"] = offset - if not filters.get("limit"): - filters["limit"] = limit - - return self.api._get_resources(self.model_class, **filters) - - def _get_model(self, id): - return self.api._get_resource(self.model_class, id, **self.filters) - - def _range(self, offset=0, limit=CHUNK_SIZE): - accumulated = [] - while len(accumulated) < limit: - to_fetch = min(limit - len(accumulated), CHUNK_SIZE) - results = self._get_model_collection(offset + len(accumulated), to_fetch) - accumulated.extend(results) - - # done if we run out of data to fetch - if not results or len(results) < to_fetch: - break - - return accumulated diff --git a/nylas/client/restful_models.py b/nylas/client/restful_models.py deleted file mode 100644 index b6539e78..00000000 --- a/nylas/client/restful_models.py +++ /dev/null @@ -1,1122 +0,0 @@ -import hashlib -import hmac -from datetime import datetime -from collections import defaultdict -from enum import Enum - -from six import StringIO -from nylas.client.restful_model_collection import RestfulModelCollection -from nylas.client.errors import FileUploadError, UnSyncedError, NylasApiError -from nylas.utils import timestamp_from_dt, AuthMethod - -# pylint: disable=attribute-defined-outside-init - - -def typed_dict_attr(items, attr_name=None): - if attr_name: - pairs = [(item["type"], item[attr_name]) for item in items] - else: - pairs = [(item["type"], item) for item in items] - dct = defaultdict(list) - for key, value in pairs: - dct[key].append(value) - return dct - - -def _is_subclass(cls, parent): - for base in cls.__bases__: - if base.__name__.lower() == parent: - return True - return False - - -class RestfulModel(dict): - attrs = [] - date_attrs = {} - datetime_attrs = {} - datetime_filter_attrs = {} - typed_dict_attrs = {} - read_only_attrs = {} - auth_method = AuthMethod.BEARER - # The Nylas API holds most objects for an account directly under '/', - # but some of them are under '/a' (mostly the account-management - # and billing code). api_root is a tiny metaprogramming hack to let - # us use the same code for both. - api_root = None - - def __init__(self, cls, api): - self.id = None - self.cls = cls - self.api = api - super(RestfulModel, self).__init__() - - __setattr__ = dict.__setitem__ - __delattr__ = dict.__delitem__ - __getattr__ = dict.get - - @classmethod - def create(cls, api, **kwargs): - object_type = kwargs.get("object") - cls_object_type = getattr(cls, "object_type", cls.__name__.lower()) - # These are classes that should bypass the check below because they - # often represent other types (e.g. a delta's object type might be event) - class_check_whitelist = ["jobstatus", "delta"] - if ( - object_type - and object_type != cls_object_type - and object_type != "account" - and cls_object_type not in class_check_whitelist - and not _is_subclass(cls, object_type) - ): - # We were given a specific object type and we're trying to - # instantiate something different; abort. (Relevant for folders - # and labels API.) - # We need a special case for accounts because the /accounts API - # is different between the open source and hosted API. - # And a special case for job status because the object refers to - # the type of objects' job status - return - obj = cls(api) # pylint: disable=no-value-for-parameter - obj.cls = cls - for attr in cls.attrs: - # Support attributes we want to override with properties where - # the property names overlap with the JSON names (e.g. folders) - attr_name = attr - if attr_name.startswith("_"): - attr = attr_name[1:] - if attr in kwargs: - obj[attr_name] = kwargs[attr] - if attr_name == "from": - obj["from_"] = kwargs[attr] - for date_attr, iso_attr in cls.date_attrs.items(): - if kwargs.get(iso_attr): - obj[date_attr] = datetime.strptime(kwargs[iso_attr], "%Y-%m-%d").date() - for dt_attr, ts_attr in cls.datetime_attrs.items(): - if kwargs.get(ts_attr): - try: - obj[dt_attr] = datetime.utcfromtimestamp(kwargs[ts_attr]) - except TypeError: - # If the datetime format is in the format of ISO8601 - obj[dt_attr] = datetime.strptime( - kwargs[ts_attr], "%Y-%m-%dT%H:%M:%S.%fZ" - ) - for attr, value_attr_name in cls.typed_dict_attrs.items(): - obj[attr] = typed_dict_attr(kwargs.get(attr, []), attr_name=value_attr_name) - - if "id" not in kwargs: - obj["id"] = None - - return obj - - def as_json(self, enforce_read_only=True): - dct = {} - # Some API parameters like "from" and "in" also are - # Python reserved keywords. To work around this, we rename - # them to "from_" and "in_". The API still needs them in - # their correct form though. - reserved_keywords = ["from", "in"] - for attr in self.cls.attrs: - if attr in self.read_only_attrs and enforce_read_only is True: - continue - if hasattr(self, attr): - if attr in reserved_keywords: - attr_value = getattr(self, "{}_".format(attr)) - else: - attr_value = getattr(self, attr) - if attr_value is not None: - if attr.startswith("_"): - attr = attr[1:] - dct[attr] = attr_value - for date_attr, iso_attr in self.cls.date_attrs.items(): - if date_attr in self.read_only_attrs and enforce_read_only is True: - continue - if self.get(date_attr): - dct[iso_attr] = self[date_attr].strftime("%Y-%m-%d") - for dt_attr, ts_attr in self.cls.datetime_attrs.items(): - if dt_attr in self.read_only_attrs and enforce_read_only is True: - continue - if self.get(dt_attr): - dct[ts_attr] = timestamp_from_dt(self[dt_attr]) - for attr, value_attr in self.cls.typed_dict_attrs.items(): - if attr in self.read_only_attrs and enforce_read_only is True: - continue - typed_dict = getattr(self, attr) - if value_attr: - dct[attr] = [] - for key, values in typed_dict.items(): - for value in values: - dct[attr].append({"type": key, value_attr: value}) - else: - dct[attr] = [] - for values in typed_dict.values(): - for value in values: - dct[attr].append(value) - return dct - - -class NylasAPIObject(RestfulModel): - read_only_attrs = {"id", "account_id", "object", "job_status_id"} - - def __init__(self, cls, api): - RestfulModel.__init__(self, cls, api) - - def child_collection(self, cls, **filters): - return RestfulModelCollection(cls, self.api, **filters) - - def save(self, **kwargs): - if self.id: - new_obj = self._update_resource(**kwargs) - else: - new_obj = self._create_resource(**kwargs) - self._update_values(new_obj) - - def update(self): - new_obj = self._update_resource() - self._update_values(new_obj) - - def _create_resource(self, **kwargs): - return self.api._create_resource(self.cls, self.as_json(), **kwargs) - - def _update_resource(self, **kwargs): - return self.api._update_resource(self.cls, self.id, self.as_json(), **kwargs) - - def _update_values(self, new_obj): - for attr in self.cls.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - - -class Message(NylasAPIObject): - attrs = [ - "bcc", - "body", - "cc", - "date", - "events", - "files", - "from", - "id", - "account_id", - "object", - "snippet", - "starred", - "subject", - "thread_id", - "job_status_id", - "to", - "unread", - "starred", - "metadata", - "_folder", - "_labels", - "headers", - "reply_to", - ] - datetime_attrs = {"received_at": "date"} - datetime_filter_attrs = { - "received_before": "received_before", - "received_after": "received_after", - } - collection_name = "messages" - - def __init__(self, api): - NylasAPIObject.__init__(self, Message, api) - - @property - def attachments(self): - return self.child_collection(File, message_id=self.id) - - @property - def folder(self): - # Instantiate a Folder object from the API response - if self._folder: - return Folder.create(self.api, **self._folder) - - @property - def labels(self): - if self._labels: - return [Label.create(self.api, **l) for l in self._labels] - return [] - - def update_folder(self, folder_id): - update = {"folder": folder_id} - new_obj = self.api._update_resource(self.cls, self.id, update) - for attr in self.cls.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - return self.folder - - def update_labels(self, label_ids=None): - label_ids = label_ids or [] - update = {"labels": label_ids} - new_obj = self.api._update_resource(self.cls, self.id, update) - for attr in self.cls.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - return self.labels - - def add_labels(self, label_ids=None): - label_ids = label_ids or [] - labels = [l.id for l in self.labels] - labels = list(set(labels).union(set(label_ids))) - return self.update_labels(labels) - - def add_label(self, label_id): - return self.add_labels([label_id]) - - def remove_labels(self, label_ids=None): - label_ids = label_ids or [] - labels = [l.id for l in self.labels] - labels = list(set(labels) - set(label_ids)) - return self.update_labels(labels) - - def remove_label(self, label_id): - return self.remove_labels([label_id]) - - def mark_as_seen(self): - self.mark_as_read() - - def mark_as_read(self): - update = {"unread": False} - self.api._update_resource(self.cls, self.id, update) - self.unread = False - - def mark_as_unread(self): - update = {"unread": True} - self.api._update_resource(self.cls, self.id, update) - self.unread = True - - def star(self): - update = {"starred": True} - self.api._update_resource(self.cls, self.id, update) - self.starred = True - - def unstar(self): - update = {"starred": False} - self.api._update_resource(self.cls, self.id, update) - self.starred = False - - @property - def raw(self): - headers = {"Accept": "message/rfc822"} - response = self.api._get_resource_raw(Message, self.id, headers=headers) - if response.status_code == 202: - raise UnSyncedError(response.content) - return response.content - - -class Folder(NylasAPIObject): - attrs = ["id", "display_name", "name", "object", "account_id", "job_status_id"] - collection_name = "folders" - - def __init__(self, api): - NylasAPIObject.__init__(self, Folder, api) - - @property - def threads(self): - return self.child_collection(Thread, folder_id=self.id) - - @property - def messages(self): - return self.child_collection(Message, folder_id=self.id) - - -class Label(NylasAPIObject): - attrs = ["id", "display_name", "name", "object", "account_id", "job_status_id"] - collection_name = "labels" - - def __init__(self, api): - NylasAPIObject.__init__(self, Label, api) - - @property - def threads(self): - return self.child_collection(Thread, label_id=self.id) - - @property - def messages(self): - return self.child_collection(Message, label_id=self.id) - - -class Thread(NylasAPIObject): - attrs = [ - "draft_ids", - "id", - "message_ids", - "account_id", - "object", - "participants", - "snippet", - "subject", - "subject_date", - "last_message_timestamp", - "first_message_timestamp", - "last_message_received_timestamp", - "last_message_sent_timestamp", - "unread", - "starred", - "version", - "_folders", - "_labels", - "_messages", - "received_recent_date", - "has_attachments", - ] - datetime_attrs = { - "first_message_at": "first_message_timestamp", - "last_message_at": "last_message_timestamp", - "last_message_received_at": "last_message_received_timestamp", - "last_message_sent_at": "last_message_sent_timestamp", - } - datetime_filter_attrs = { - "last_message_before": "last_message_before", - "last_message_after": "last_message_after", - "started_before": "started_before", - "started_after": "started_after", - } - collection_name = "threads" - - def __init__(self, api): - NylasAPIObject.__init__(self, Thread, api) - - @property - def messages(self): - if self._messages: - return [Message.create(self.api, **f) for f in self._messages] - return self.child_collection(Message, thread_id=self.id) - - @property - def drafts(self): - return self.child_collection(Draft, thread_id=self.id) - - @property - def folders(self): - if self._folders: - return [Folder.create(self.api, **f) for f in self._folders] - return [] - - @property - def labels(self): - if self._labels: - return [Label.create(self.api, **l) for l in self._labels] - return [] - - def update_folder(self, folder_id): - update = {"folder": folder_id} - new_obj = self.api._update_resource(self.cls, self.id, update) - for attr in self.cls.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - return self.folder - - def update_labels(self, label_ids=None): - label_ids = label_ids or [] - update = {"labels": label_ids} - new_obj = self.api._update_resource(self.cls, self.id, update) - for attr in self.cls.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - return self.labels - - def add_labels(self, label_ids=None): - label_ids = label_ids or [] - labels = [l.id for l in self.labels] - labels = list(set(labels).union(set(label_ids))) - return self.update_labels(labels) - - def add_label(self, label_id): - return self.add_labels([label_id]) - - def remove_labels(self, label_ids=None): - label_ids = label_ids or [] - labels = [l.id for l in self.labels] - labels = list(set(labels) - set(label_ids)) - return self.update_labels(labels) - - def remove_label(self, label_id): - return self.remove_labels([label_id]) - - def mark_as_seen(self): - self.mark_as_read() - - def mark_as_read(self): - update = {"unread": False} - self.api._update_resource(self.cls, self.id, update) - self.unread = False - - def mark_as_unread(self): - update = {"unread": True} - self.api._update_resource(self.cls, self.id, update) - self.unread = True - - def star(self): - update = {"starred": True} - self.api._update_resource(self.cls, self.id, update) - self.starred = True - - def unstar(self): - update = {"starred": False} - self.api._update_resource(self.cls, self.id, update) - self.starred = False - - def create_reply(self): - draft = self.drafts.create() - draft.thread_id = self.id - draft.subject = self.subject - return draft - - -# This is a dummy class that allows us to use the create_resource function -# and pass in a 'Send' object that will translate into a 'send' endpoint. -class Send(Message): - collection_name = "send" - - def __init__(self, api): # pylint: disable=super-init-not-called - NylasAPIObject.__init__( - self, Send, api - ) # pylint: disable=non-parent-init-called - - -class Draft(Message): - attrs = [ - "bcc", - "cc", - "body", - "date", - "files", - "from", - "id", - "account_id", - "object", - "subject", - "thread_id", - "to", - "job_status_id", - "unread", - "version", - "file_ids", - "reply_to_message_id", - "reply_to", - "starred", - "snippet", - "tracking", - "metadata", - ] - datetime_attrs = {"last_modified_at": "date"} - collection_name = "drafts" - - def __init__(self, api, thread_id=None): # pylint: disable=unused-argument - Message.__init__(self, api) - NylasAPIObject.__init__( - self, Thread, api - ) # pylint: disable=non-parent-init-called - self.file_ids = [] - - def attach(self, file): - if not file.id: - file.save() - - self.file_ids.append(file.id) - - def detach(self, file): - if file.id in self.file_ids: - self.file_ids.remove(file.id) - - def send_raw(self, mime_message): - """ - Send a raw MIME message - - Args: - mime_message (str): The raw MIME message to send - - Returns: - Message: The sent message - """ - return self.api._create_resource(Send, mime_message) - - def send(self): - if not self.id: - data = self.as_json() - else: - data = {"draft_id": self.id} - if hasattr(self, "version"): - data["version"] = self.version - if hasattr(self, "tracking") and self.tracking is not None: - data["tracking"] = self.tracking - - msg = self.api._create_resource(Send, data) - if msg: - return msg - - def delete(self): - if self.id and self.version is not None: - data = {"version": self.version} - self.api._delete_resource(self.cls, self.id, data=data) - - -class File(NylasAPIObject): - attrs = [ - "content_type", - "filename", - "id", - "content_id", - "account_id", - "object", - "size", - "message_ids", - ] - collection_name = "files" - - def save(self): # pylint: disable=arguments-differ - stream = getattr(self, "stream", None) - if not stream: - data = getattr(self, "data", None) - if data: - stream = StringIO(data) - - if not stream: - message = ( - "File object not properly formatted, " - "must provide either a stream or data." - ) - raise FileUploadError(message) - - file_info = (self.filename, stream, self.content_type, {}) # upload headers - - new_obj = self.api._create_resources(File, {"file": file_info}) - new_obj = new_obj[0] - for attr in self.attrs: - if hasattr(new_obj, attr): - setattr(self, attr, getattr(new_obj, attr)) - - def download(self): - if not self.id: - message = "Can't download a file that hasn't been uploaded." - raise FileUploadError(message) - - return self.api._get_resource_data(File, self.id, extra="download") - - def __init__(self, api): - NylasAPIObject.__init__(self, File, api) - - -class Contact(NylasAPIObject): - attrs = [ - "id", - "object", - "account_id", - "given_name", - "middle_name", - "surname", - "suffix", - "nickname", - "company_name", - "job_title", - "job_status_id", - "manager_name", - "office_location", - "source", - "notes", - "picture_url", - ] - date_attrs = {"birthday": "birthday"} - typed_dict_attrs = { - "emails": "email", - "im_addresses": "im_address", - "physical_addresses": None, - "phone_numbers": "number", - "web_pages": "url", - } - collection_name = "contacts" - - def __init__(self, api): - NylasAPIObject.__init__(self, Contact, api) - - def get_picture(self): - if not self.get("picture_url", None): - return None - - response = self.api._get_resource_raw( - Contact, self.id, extra="picture", stream=True - ) - if response.status_code >= 400: - raise NylasApiError(response) - return response.raw - - -class Calendar(NylasAPIObject): - attrs = [ - "id", - "account_id", - "name", - "description", - "hex_color", - "job_status_id", - "metadata", - "read_only", - "is_primary", - "object", - ] - collection_name = "calendars" - - def __init__(self, api): - NylasAPIObject.__init__(self, Calendar, api) - self.read_only_attrs.update({"is_primary", "read_only", "hex_color"}) - - @property - def events(self): - return self.child_collection(Event, calendar_id=self.id) - - -class Event(NylasAPIObject): - attrs = [ - "id", - "account_id", - "title", - "description", - "conferencing", - "location", - "read_only", - "when", - "busy", - "participants", - "calendar_id", - "recurrence", - "status", - "master_event_id", - "job_status_id", - "owner", - "original_start_time", - "object", - "message_id", - "ical_uid", - "metadata", - "notifications", - "event_collection_id", - "capacity", - "round_robin_order", - "visibility", - ] - datetime_attrs = {"original_start_at": "original_start_time"} - collection_name = "events" - - def __init__(self, api): - NylasAPIObject.__init__(self, Event, api) - self.read_only_attrs.update( - { - "ical_uid", - "message_id", - "owner", - "status", - "master_event_id", - "original_start_time", - } - ) - - def as_json(self, enforce_read_only=True): - dct = NylasAPIObject.as_json(self, enforce_read_only) - if enforce_read_only is False: - return dct - - # Filter some parameters we got from the API - if dct.get("when"): - # Currently, the event (self) and the dict (dct) share the same - # reference to the `'when'` dict. We need to clone the dict so - # that when we remove the object key, the original event's - # `'when'` reference is unmodified. - dct["when"] = dct["when"].copy() - dct["when"].pop("object", None) - - if ( - dct.get("participants") - and isinstance(dct.get("participants"), list) - and self.id - ): - # The status of a participant cannot be updated and, if the key is - # included, it will return an error from the API - for participant in dct.get("participants"): - participant.pop("status", None) - - return dct - - def rsvp(self, status, comment=None): - if not self.message_id: - raise ValueError( - "This event was not imported from an iCalendar invite, and so it is not possible to RSVP via Nylas" - ) - if status not in {"yes", "no", "maybe"}: - raise ValueError("invalid status: {status}".format(status=status)) - - url = "{api_server}/send-rsvp".format(api_server=self.api.api_server) - data = { - "event_id": self.id, - "status": status, - "comment": comment, - } - response = self.api.session.post(url, json=data) - if response.status_code >= 400: - raise NylasApiError(response) - result = response.json() - return Event.create(self, **result) - - def generate_ics(self, ical_uid=None, method=None, prodid=None): - """ - Generate an ICS file server-side, from an Event - - Args: - ical_uid (str): Unique identifier used events across calendaring systems - method (str): Description of invitation and response methods for attendees - prodid (str): Company-specific unique product identifier - - Returns: - str: String for writing directly into an ICS file - - Raises: - ValueError: If the event does not have calendar_id or when set - RuntimeError: If the server returns an object without an ics string - """ - if not self.calendar_id or not self.when: - raise ValueError( - "Cannot generate an ICS file for an event without a Calendar ID or when set" - ) - - payload = {} - ics_options = {} - if self.id: - payload["event_id"] = self.id - else: - payload = self.as_json() - - if ical_uid: - ics_options["ical_uid"] = ical_uid - if method: - ics_options["method"] = method - if prodid: - ics_options["prodid"] = prodid - - if ics_options: - payload["ics_options"] = ics_options - - response = self.api._post_resource(Event, None, "to-ics", payload) - if "ics" in response: - return response["ics"] - raise RuntimeError( - "Unexpected response from the API server. Returned 200 but no 'ics' string found." - ) - - def validate(self): - if ( - self.conferencing - and "details" in self.conferencing - and "autocreate" in self.conferencing - ): - raise ValueError( - "Cannot set both 'details' and 'autocreate' in conferencing object." - ) - if ( - self.capacity - and self.capacity != -1 - and self.participants - and len(self.participants) > self.capacity - ): - raise ValueError( - "The number of participants in the event exceeds the set capacity." - ) - - def save(self, **kwargs): - self.validate() - - super(Event, self).save(**kwargs) - - -class RoomResource(NylasAPIObject): - attrs = [ - "object", - "email", - "name", - "capacity", - "building", - "floor_name", - "floor_number", - ] - object_type = "room_resource" - collection_name = "resources" - - def __init__(self, api): - NylasAPIObject.__init__(self, RoomResource, api) - - -class JobStatus(NylasAPIObject): - attrs = [ - "id", - "account_id", - "job_status_id", - "action", - "object", - "status", - "original_data", - "metadata", - ] - datetime_attrs = {"created_at": "created_at"} - collection_name = "job-statuses" - - def __init__(self, api): - NylasAPIObject.__init__(self, JobStatus, api) - self.read_only_attrs.update( - { - "action", - "status", - "original_data", - } - ) - - def is_successful(self): - return self.status == "successful" - - -class Scheduler(NylasAPIObject): - attrs = [ - "id", - "access_tokens", - "app_client_id", - "app_organization_id", - "config", - "edit_token", - "name", - "slug", - ] - date_attrs = { - "created_at": "created_at", - "modified_at": "modified_at", - } - collection_name = "manage/pages" - - def __init__(self, api): - NylasAPIObject.__init__(self, Scheduler, api) - - def get_available_calendars(self): - if not self.id: - raise ValueError("Cannot get calendars for a page without an ID") - - response = self.api._get_resource_raw(Scheduler, self.id, extra="calendars") - response_body = response.json() - for body in response_body: - for i in range(len(body["calendars"])): - body["calendars"][i] = Calendar.create(self.api, **body["calendars"][i]) - - return response_body - - def upload_image(self, content_type, object_name): - if not self.id: - raise ValueError("Cannot upload an image to a page without an ID") - - data = {"contentType": content_type, "objectName": object_name} - response = self.api._put_resource( - Scheduler, self.id, data, extra="upload-image" - ) - return response - - -class Component(NylasAPIObject): - attrs = [ - "id", - "account_id", - "name", - "type", - "action", - "active", - "settings", - "public_account_id", - "public_token_id", - "public_application_id", - "access_token", - "allowed_domains", - ] - datetime_attrs = { - "created_at": "created_at", - "updated_at": "updated_at", - } - - collection_name = None - api_root = "component" - - def __init__(self, api): - NylasAPIObject.__init__(self, Component, api) - self.read_only_attrs.update( - { - "public_application_id", - "created_at", - "updated_at", - } - ) - - def as_json(self, enforce_read_only=True): - dct = NylasAPIObject.as_json(self, enforce_read_only) - if enforce_read_only is False: - return dct - - # "type" cannot be modified after created - if self.id: - dct.pop("type") - return dct - - -class Webhook(NylasAPIObject): - attrs = ( - "id", - "callback_url", - "state", - "triggers", - "application_id", - "version", - ) - - collection_name = "webhooks" - api_root = "a" - - def __init__(self, api): - NylasAPIObject.__init__(self, Webhook, api) - self.read_only_attrs.update({"application_id", "version"}) - - def as_json(self, enforce_read_only=True): - dct = {} - # Only 'state' can get updated - if self.id and enforce_read_only is True: - dct["state"] = self.state - else: - dct = NylasAPIObject.as_json(self, enforce_read_only) - return dct - - @staticmethod - def verify_webhook_signature(nylas_signature, raw_body, client_secret): - """ - Verify incoming webhook signature came from Nylas - - Args: - nylas_signature (str): The signature to verify - raw_body (bytes | bytearray): The raw body from the payload - client_secret (str): Client secret of the app receiving the webhook - - Returns: - bool: True if the webhook signature was verified from Nylas - """ - digest = hmac.new( - str.encode(client_secret), msg=raw_body, digestmod=hashlib.sha256 - ).hexdigest() - return hmac.compare_digest(digest, nylas_signature) - - class Trigger(str, Enum): - """ - This is an Enum representing all the possible webhook triggers - - see more: https://developer.nylas.com/docs/developer-tools/webhooks/available-webhooks - """ - - ACCOUNT_CONNECTED = "account.connected" - ACCOUNT_RUNNING = "account.running" - ACCOUNT_STOPPED = "account.stopped" - ACCOUNT_INVALID = "account.invalid" - ACCOUNT_SYNC_ERROR = "account.sync_error" - MESSAGE_CREATED = "message.created" - MESSAGE_OPENED = "message.opened" - MESSAGE_UPDATED = "message.updated" - MESSAGE_LINK_CLICKED = "message.link_clicked" - THREAD_REPLIED = "thread.replied" - CONTACT_CREATED = "contact.created" - CONTACT_UPDATED = "contact.updated" - CONTACT_DELETED = "contact.deleted" - CALENDAR_CREATED = "calendar.created" - CALENDAR_UPDATED = "calendar.updated" - CALENDAR_DELETED = "calendar.deleted" - EVENT_CREATED = "event.created" - EVENT_UPDATED = "event.updated" - EVENT_DELETED = "event.deleted" - JOB_SUCCESSFUL = "job.successful" - JOB_FAILED = "job.failed" - - class State(str, Enum): - """ - This is an Enum representing all the possible webhook states - - see more: https://developer.nylas.com/docs/developer-tools/webhooks/#enable-and-disable-webhooks - """ - - ACTIVE = "active" - INACTIVE = "inactive" - - -class Namespace(NylasAPIObject): - attrs = [ - "account", - "email_address", - "id", - "account_id", - "object", - "provider", - "name", - "organization_unit", - ] - collection_name = "n" - - def __init__(self, api): - NylasAPIObject.__init__(self, Namespace, api) - - def child_collection(self, cls, **filters): - return RestfulModelCollection(cls, self.api, self.id, **filters) - - -class Account(NylasAPIObject): - api_root = "a" - - attrs = [ - "account_id", - "billing_state", - "email", - "id", - "namespace_id", - "provider", - "sync_state", - "authentication_type", - "trial", - "metadata", - ] - - collection_name = "accounts" - - def __init__(self, api): - NylasAPIObject.__init__(self, Account, api) - - def as_json(self, enforce_read_only=True): - if enforce_read_only is False: - return NylasAPIObject.as_json(self, enforce_read_only) - else: - return {"metadata": self.metadata} - - def upgrade(self): - return self.api._call_resource_method(self, self.account_id, "upgrade", None) - - def downgrade(self): - return self.api._call_resource_method(self, self.account_id, "downgrade", None) - - -class APIAccount(NylasAPIObject): - attrs = [ - "account_id", - "email_address", - "id", - "name", - "object", - "organization_unit", - "provider", - "sync_state", - ] - datetime_attrs = {"linked_at": "linked_at"} - - collection_name = "accounts" - - def __init__(self, api): - NylasAPIObject.__init__(self, APIAccount, api) - - -class SingletonAccount(APIAccount): - # This is an APIAccount that lives under /account. - collection_name = "account" diff --git a/nylas/client/scheduler_models.py b/nylas/client/scheduler_models.py deleted file mode 100644 index 0d19e5d5..00000000 --- a/nylas/client/scheduler_models.py +++ /dev/null @@ -1,59 +0,0 @@ -from nylas.client.restful_models import RestfulModel - - -class SchedulerTimeSlot(RestfulModel): - attrs = ["account_id", "calendar_id", "host_name", "emails"] - datetime_attrs = {"start": "start", "end": "end"} - - def __init__(self, api): - RestfulModel.__init__(self, SchedulerTimeSlot, api) - - -class SchedulerBookingConfirmation(RestfulModel): - attrs = [ - "id", - "account_id", - "additional_field_values", - "calendar_event_id", - "calendar_id", - "edit_hash", - "is_confirmed", - "location", - "recipient_email", - "recipient_locale", - "recipient_name", - "recipient_tz", - "title", - ] - datetime_attrs = {"start_time": "start_time", "end_time": "end_time"} - - def __init__(self, api): - RestfulModel.__init__(self, SchedulerBookingConfirmation, api) - - -class SchedulerBookingRequest(RestfulModel): - attrs = [ - "additional_values", - "additional_emails", - "email", - "locale", - "name", - "page_hostname", - "replaces_booking_hash", - "timezone", - "slot", - ] - - def __init__(self, api): - RestfulModel.__init__(self, SchedulerBookingRequest, api) - - def as_json(self, enforce_read_only=True): - dct = RestfulModel.as_json(self) - if "additional_values" not in dct or dct["additional_values"] is None: - dct["additional_values"] = {} - if "additional_emails" not in dct or dct["additional_emails"] is None: - dct["additional_emails"] = [] - if "slot" in dct and isinstance(dct["slot"], SchedulerTimeSlot): - dct["slot"] = dct["slot"].as_json() - - return dct diff --git a/nylas/client/scheduler_restful_model_collection.py b/nylas/client/scheduler_restful_model_collection.py deleted file mode 100644 index 4e5071a6..00000000 --- a/nylas/client/scheduler_restful_model_collection.py +++ /dev/null @@ -1,65 +0,0 @@ -import copy - -from nylas.client.restful_model_collection import RestfulModelCollection -from nylas.client.restful_models import Scheduler -from nylas.client.scheduler_models import ( - SchedulerTimeSlot, - SchedulerBookingConfirmation, -) - - -class SchedulerRestfulModelCollection(RestfulModelCollection): - def __init__(self, api): - # Make a copy of the API as we need to change the base url for Scheduler calls - scheduler_api = copy.copy(api) - scheduler_api.api_server = "https://api.schedule.nylas.com" - RestfulModelCollection.__init__(self, Scheduler, scheduler_api) - - def get_google_availability(self): - return self._execute_provider_availability("google") - - def get_office_365_availability(self): - return self._execute_provider_availability("o365") - - def get_page_slug(self, slug): - page_response = self.api._get_resource_raw( - self.model_class, slug, extra="info", path="schedule" - ).json() - return Scheduler.create(self.api, **page_response) - - def get_available_time_slots(self, slug): - response = self.api._get_resource_raw( - self.model_class, slug, extra="timeslots", path="schedule" - ).json() - return [ - SchedulerTimeSlot.create(self.api, **x) for x in response if x is not None - ] - - def book_time_slot(self, slug, timeslot): - response = self.api._post_resource( - self.model_class, slug, "timeslots", timeslot.as_json(), path="schedule" - ) - return SchedulerBookingConfirmation.create(self.api, **response) - - def cancel_booking(self, slug, edit_hash, reason): - return self.api._post_resource( - self.model_class, - slug, - "{}/cancel".format(edit_hash), - {"reason": reason}, - path="schedule", - ) - - def confirm_booking(self, slug, edit_hash): - booking_response = self.api._post_resource( - self.model_class, slug, "{}/confirm".format(edit_hash), {}, path="schedule" - ) - return SchedulerBookingConfirmation.create(self.api, **booking_response) - - def _execute_provider_availability(self, provider): - return self.api._get_resource_raw( - self.model_class, - None, - extra="availability/{}".format(provider), - path="schedule", - ).json() diff --git a/nylas/config.py b/nylas/config.py index e7cab9fd..4a888623 100644 --- a/nylas/config.py +++ b/nylas/config.py @@ -7,7 +7,21 @@ class Region(str, Enum): """ US = "us" - IRELAND = "ireland" + EU = "eu" DEFAULT_REGION = Region.US +""" The default Nylas API region. """ + +REGION_CONFIG = { + Region.US: { + "nylasApiUrl": "https://api.us.nylas.com", + }, + Region.EU: { + "nylasApiUrl": "https://api.eu.nylas.com", + }, +} +""" The available preset configuration values for each Nylas API region. """ + +DEFAULT_SERVER_URL = REGION_CONFIG[DEFAULT_REGION]["nylasApiUrl"] +""" The default Nylas API URL. """ diff --git a/nylas/services/__init__.py b/nylas/handler/__init__.py similarity index 100% rename from nylas/services/__init__.py rename to nylas/handler/__init__.py diff --git a/nylas/handler/api_resources.py b/nylas/handler/api_resources.py new file mode 100644 index 00000000..469b4667 --- /dev/null +++ b/nylas/handler/api_resources.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from nylas.models.response import Response, ListResponse, DeleteResponse +from nylas.resources.resource import Resource + +# pylint: disable=too-few-public-methods,missing-class-docstring,missing-function-docstring + + +class ListableApiResource(Resource): + def list( + self, path, response_type, headers=None, query_params=None, request_body=None + ) -> ListResponse: + response_json = self._http_client._execute( + "GET", path, headers, query_params, request_body + ) + + return ListResponse.from_dict(response_json, response_type) + + +class FindableApiResource(Resource): + def find( + self, path, response_type, headers=None, query_params=None, request_body=None + ) -> Response: + response_json = self._http_client._execute( + "GET", path, headers, query_params, request_body + ) + + return Response.from_dict(response_json, response_type) + + +class CreatableApiResource(Resource): + def create( + self, path, response_type, headers=None, query_params=None, request_body=None + ) -> Response: + response_json = self._http_client._execute( + "POST", path, headers, query_params, request_body + ) + + return Response.from_dict(response_json, response_type) + + +class UpdatableApiResource(Resource): + def update( + self, + path, + response_type, + headers=None, + query_params=None, + request_body=None, + method="PUT", + ): + response_json = self._http_client._execute( + method, path, headers, query_params, request_body + ) + + return Response.from_dict(response_json, response_type) + + +class DestroyableApiResource(Resource): + def destroy( + self, + path, + response_type=None, + headers=None, + query_params=None, + request_body=None, + ): + if response_type is None: + response_type = DeleteResponse + + response_json = self._http_client._execute( + "DELETE", path, headers, query_params, request_body + ) + return response_type.from_dict(response_json) diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py new file mode 100644 index 00000000..b4c2fcce --- /dev/null +++ b/nylas/handler/http_client.py @@ -0,0 +1,169 @@ +import sys +from typing import Union +from urllib.parse import urlparse, quote + +import requests +from requests import Response + +from nylas._client_sdk_version import __VERSION__ +from nylas.models.errors import ( + NylasApiError, + NylasApiErrorResponse, + NylasSdkTimeoutError, + NylasOAuthError, + NylasOAuthErrorResponse, + NylasApiErrorResponseData, +) + + +def _validate_response(response: Response) -> dict: + json = response.json() + if response.status_code >= 400: + parsed_url = urlparse(response.url) + try: + if ( + "connect/token" in parsed_url.path + or "connect/revoke" in parsed_url.path + ): + parsed_error = NylasOAuthErrorResponse.from_dict(json) + raise NylasOAuthError(parsed_error, response.status_code) + + parsed_error = NylasApiErrorResponse.from_dict(json) + raise NylasApiError(parsed_error, response.status_code) + except (KeyError, TypeError) as exc: + request_id = json.get("request_id", None) + raise NylasApiError( + NylasApiErrorResponse( + request_id, + NylasApiErrorResponseData( + type="unknown", + message=json, + ), + ), + status_code=response.status_code, + ) from exc + + return json + + +def _build_query_params(base_url: str, query_params: dict = None) -> str: + query_param_parts = [] + for key, value in query_params.items(): + if isinstance(value, list): + for item in value: + query_param_parts.append(f"{key}={quote(str(item))}") + elif isinstance(value, dict): + for k, v in value.items(): + query_param_parts.append(f"{key}={k}:{quote(str(v))}") + else: + query_param_parts.append(f"{key}={quote(str(value))}") + + query_string = "&".join(query_param_parts) + return f"{base_url}?{query_string}" + + +# pylint: disable=too-few-public-methods +class HttpClient: + """HTTP client for the Nylas API.""" + + def __init__(self, api_server, api_key, timeout): + self.api_server = api_server + self.api_key = api_key + self.timeout = timeout + self.session = requests.Session() + + def _execute( + self, + method, + path, + headers=None, + query_params=None, + request_body=None, + data=None, + ) -> dict: + request = self._build_request( + method, path, headers, query_params, request_body, data + ) + try: + response = self.session.request( + request["method"], + request["url"], + headers=request["headers"], + json=request_body, + timeout=self.timeout, + data=data, + ) + except requests.exceptions.Timeout as exc: + raise NylasSdkTimeoutError( + url=request["url"], timeout=self.timeout + ) from exc + + return _validate_response(response) + + def _execute_download_request( + self, + path, + headers=None, + query_params=None, + stream=False, + ) -> Union[bytes, Response]: + request = self._build_request("GET", path, headers, query_params) + try: + response = self.session.request( + request["method"], + request["url"], + headers=request["headers"], + timeout=self.timeout, + stream=stream, + ) + + # If we stream an iterator for streaming the content, otherwise return the entire byte array + if stream: + return response + + return response.content if response.content else None + except requests.exceptions.Timeout as exc: + raise NylasSdkTimeoutError( + url=request["url"], timeout=self.timeout + ) from exc + + def _build_request( + self, + method: str, + path: str, + headers: dict = None, + query_params: dict = None, + request_body=None, + data=None, + ) -> dict: + base_url = f"{self.api_server}{path}" + url = _build_query_params(base_url, query_params) if query_params else base_url + headers = self._build_headers(headers, request_body, data) + + return { + "method": method, + "url": url, + "headers": headers, + } + + def _build_headers( + self, extra_headers: dict = None, response_body=None, data=None + ) -> dict: + if extra_headers is None: + extra_headers = {} + + major, minor, revision, _, __ = sys.version_info + user_agent_header = ( + f"Nylas Python SDK {__VERSION__} - {major}.{minor}.{revision}" + ) + headers = { + "X-Nylas-API-Wrapper": "python", + "User-Agent": user_agent_header, + "Authorization": f"Bearer {self.api_key}", + } + if data is not None and data.content_type is not None: + headers["Content-type"] = data.content_type + elif response_body is not None: + headers["Content-type"] = "application/json" + + return {**headers, **extra_headers} diff --git a/tests/__init__.py b/nylas/models/__init__.py similarity index 100% rename from tests/__init__.py rename to nylas/models/__init__.py diff --git a/nylas/models/application_details.py b/nylas/models/application_details.py new file mode 100644 index 00000000..daf79493 --- /dev/null +++ b/nylas/models/application_details.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from typing import Literal, Optional, List + +from dataclasses_json import dataclass_json + +from nylas.models.redirect_uri import RedirectUri + +Region = Literal["us", "eu"] +""" Literal representing the available Nylas API regions. """ + +Environment = Literal["production", "staging"] +""" Literal representing the different Nylas API environments. """ + + +@dataclass_json +@dataclass +class Branding: + """ + Class representation of branding details for the application. + + Attributes: + name: Name of the application. + icon_url: URL pointing to the application icon. + website_url: Application/publisher website URL. + description: Description of the application. + """ + + name: str + icon_url: Optional[str] = None + website_url: Optional[str] = None + description: Optional[str] = None + + +@dataclass_json +@dataclass +class HostedAuthentication: + """ + Class representation of hosted authentication branding details. + + Attributes: + background_image_url: URL pointing to the background image. + alignment: Alignment of the background image. + color_primary: Primary color of the hosted authentication page. + color_secondary: Secondary color of the hosted authentication page. + title: Title of the hosted authentication page. + subtitle: Subtitle for the hosted authentication page. + background_color: Background color of the hosted authentication page. + spacing: CSS spacing attribute in px. + """ + + background_image_url: str + alignment: Optional[str] = None + color_primary: Optional[str] = None + color_secondary: Optional[str] = None + title: Optional[str] = None + subtitle: Optional[str] = None + background_color: Optional[str] = None + spacing: Optional[int] = None + + +@dataclass_json +@dataclass +class ApplicationDetails: + """ + Class representation of a Nylas application details object. + + Attributes: + application_id: Public application ID. + organization_id: ID representing the organization. + region: Region identifier. + environment: Environment identifier. + branding: Branding details for the application. + hosted_authentication: Hosted authentication branding details. + callback_uris: List of redirect URIs. + """ + + application_id: str + organization_id: str + region: Region + environment: Environment + branding: Branding + hosted_authentication: Optional[HostedAuthentication] = None + callback_uris: List[RedirectUri] = None diff --git a/nylas/models/attachments.py b/nylas/models/attachments.py new file mode 100644 index 00000000..59c2d52b --- /dev/null +++ b/nylas/models/attachments.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass +from typing import Optional, Union, BinaryIO + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + + +@dataclass_json +@dataclass +class Attachment: + """ + An attachment on a message. + + Attributes: + id: Globally unique object identifier. + grant_id: The grant ID of the attachment. + size: Size of the attachment in bytes. + filename: Name of the attachment. + content_type: MIME type of the attachment. + content_id: The content ID of the attachment. + content_disposition: The content disposition of the attachment. + is_inline: Whether the attachment is inline. + """ + + id: str + grant_id: Optional[str] = None + filename: Optional[str] = None + content_type: Optional[str] = None + size: Optional[int] = None + content_id: Optional[str] = None + content_disposition: Optional[str] = None + is_inline: Optional[bool] = None + + +class CreateAttachmentRequest(TypedDict): + """ + A request to create an attachment. + + You can use `attach_file_request_builder()` to build this request. + + Attributes: + filename: Name of the attachment. + content_type: MIME type of the attachment. + content: Either a Base64 encoded content of the attachment or a pointer to a file. + size: Size of the attachment in bytes. + content_id: The content ID of the attachment. + content_disposition: The content disposition of the attachment. + is_inline: Whether the attachment is inline. + """ + + filename: str + content_type: str + content: Union[str, BinaryIO] + size: int + content_id: NotRequired[str] + content_disposition: NotRequired[str] + is_inline: NotRequired[bool] + + +class FindAttachmentQueryParams(TypedDict): + """ + Interface of the query parameters for finding an attachment. + + Attributes: + message_id: Message ID to find the attachment in. + """ + + message_id: str diff --git a/nylas/models/auth.py b/nylas/models/auth.py new file mode 100644 index 00000000..7aa45260 --- /dev/null +++ b/nylas/models/auth.py @@ -0,0 +1,198 @@ +from dataclasses import dataclass +from typing import Optional, List, Literal + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +AccessType = Literal["online", "offline"] +""" Literal for the access type of the authentication URL. """ + +Provider = Literal["google", "imap", "microsoft", "virtual-calendar"] +""" Literal for the different authentication providers. """ + +Prompt = Literal[ + "select_provider", "detect", "select_provider,detect", "detect,select_provider" +] +""" Literal for the different supported OAuth prompts. """ + + +class URLForAuthenticationConfig(TypedDict): + """ + Configuration for generating a URL for OAuth 2.0 authentication. + + Attributes: + client_id: The client ID of your application. + redirect_uri: Redirect URI of the integration. + provider: The integration provider type that you already had set up with Nylas for this application. + If not set, the user is directed to the Hosted Login screen and prompted to select a provider. + access_type: If the exchange token should return a refresh token too. + Not suitable for client side or JavaScript apps. + prompt: The prompt parameter is used to force the consent screen to be displayed even if the user + has already given consent to your application. + scope: A space-delimited list of scopes that identify the resources that your application + could access on the user's behalf. + If no scope is given, all of the default integration's scopes are used. + include_grant_scopes: If set to true, the scopes granted to the application will be included in the response. + state: Optional state to be returned after authentication + login_hint: Prefill the login name (usually email) during authorization flow. + If a Grant for the provided email already exists, a Grant's re-auth will automatically be initiated. + """ + + client_id: str + redirect_uri: str + provider: NotRequired[Provider] + access_type: NotRequired[AccessType] + prompt: NotRequired[Prompt] + scope: NotRequired[List[str]] + include_grant_scopes: NotRequired[bool] + state: NotRequired[str] + login_hint: NotRequired[str] + + +class URLForAdminConsentConfig(URLForAuthenticationConfig): + """ + Configuration for generating a URL for admin consent authentication for Microsoft. + + Attributes: + credential_id: The credential ID for the Microsoft account + """ + + credential_id: str + + +class CodeExchangeRequest(TypedDict): + """ + Interface of a Nylas code exchange request + + Attributes: + redirect_uri: Should match the same redirect URI that was used for getting the code during the initial + authorization request. + code: OAuth 2.0 code fetched from the previous step. + client_id: Client ID of the application. + client_secret: Client secret of the application. If not provided, the API Key will be used instead. + code_verifier: The original plain text code verifier (code_challenge) used in the initial + authorization request (PKCE). + """ + + redirect_uri: str + code: str + client_id: str + client_secret: NotRequired[str] + code_verifier: NotRequired[str] + + +class TokenExchangeRequest(TypedDict): + """ + Interface of a Nylas token exchange request + + Attributes: + redirect_uri: Should match the same redirect URI that was used for getting the code during the initial + authorization request. + refresh_token: Token to refresh/request your short-lived access token + client_id: Client ID of the application. + client_secret: Client secret of the application. If not provided, the API Key will be used instead. + """ + + redirect_uri: str + refresh_token: str + client_id: str + client_secret: NotRequired[str] + + +@dataclass_json +@dataclass +class CodeExchangeResponse: + """ + Class representation of a Nylas code exchange response. + + Attributes: + access_token: Supports exchanging the Nylas code for an access token, or refreshing an access token. + grant_id: ID representing the new Grant. + scope: List of scopes associated with the token. + expires_in: The remaining lifetime of the access token, in seconds. + refresh_token: Returned only if the code is requested using "access_type=offline". + id_token: A JWT that contains identity information about the user. Digitally signed by Nylas. + token_type: Always "Bearer". + """ + + access_token: str + grant_id: str + scope: str + expires_in: int + refresh_token: Optional[str] = None + id_token: Optional[str] = None + token_type: Optional[str] = None + + +@dataclass_json +@dataclass +class TokenInfoResponse: + """ + Class representation of a Nylas token information response. + + Attributes: + iss: The issuer of the token. + aud: The token's audience. + iat: The time that the token was issued. + exp: The time that the token expires. + sub: The token's subject. + email: The email address of the Grant belonging to the user's token. + """ + + iss: str + aud: str + iat: int + exp: int + sub: Optional[str] = None + email: Optional[str] = None + + +@dataclass_json +@dataclass +class PkceAuthUrl: + """ + Class representing the object containing the OAuth 2.0 URL and the hashed secret. + + Attributes: + secret: Server-side challenge used in the OAuth 2.0 flow. + secret_hash: SHA-256 hash of the secret. + url: The URL for hosted authentication. + """ + + secret: str + secret_hash: str + url: str + + +class ProviderDetectParams(TypedDict): + """ + Interface representing the object used to set parameters for detecting a provider. + + Attributes: + email: Email address to detect the provider for. + client_id: Client ID of the Nylas application. + all_provider_types: Search by all providers regardless of created integrations. If unset, defaults to false. + """ + + email: str + client_id: str + all_provider_types: NotRequired[bool] + + +@dataclass_json +@dataclass +class ProviderDetectResponse: + """ + Interface representing the Nylas provider detect response. + + Attributes: + email_address: Email provided for autodetection + detected: Whether the provider was detected + provider: Detected provider + type: Provider type (if IMAP provider detected displays the IMAP provider) + """ + + email_address: str + detected: bool + provider: Optional[str] = None + type: Optional[str] = None diff --git a/nylas/models/availability.py b/nylas/models/availability.py new file mode 100644 index 00000000..4d491afb --- /dev/null +++ b/nylas/models/availability.py @@ -0,0 +1,140 @@ +from dataclasses import dataclass, field +from typing import List, Literal + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +AvailabilityMethod = Literal["max-fairness", "max-availability"] +""" Literal representing the method used to determine availability for a meeting. """ + + +@dataclass_json +@dataclass +class TimeSlot: + """ + Interface for a Nylas availability time slot + + Attributes: + emails: The emails of the participants who are available for the time slot. + start_time: Unix timestamp for the start of the slot. + end_time: Unix timestamp for the end of the slot. + """ + + emails: List[str] + start_time: int + end_time: int + + +@dataclass_json +@dataclass +class GetAvailabilityResponse: + """ + Interface for a Nylas get availability response + + Attributes: + order: This property is only populated for round-robin events. + It will contain the order in which the accounts would be next in line to attend the proposed meeting. + time_slots: The available time slots where a new meeting can be created for the requested preferences. + """ + + time_slots: List[TimeSlot] + order: List[str] = field(default_factory=list) + + +class MeetingBuffer(TypedDict): + """ + Interface for the meeting buffer object within an availability request. + + Attributes: + before: The amount of buffer time in increments of 5 minutes to add before existing meetings. + Defaults to 0. + after: The amount of buffer time in increments of 5 minutes to add after existing meetings. + Defaults to 0. + """ + + before: int + after: int + + +class OpenHours(TypedDict): + """ + Interface of a participant's open hours. + + Attributes: + days: The days of the week that the open hour settings will be applied to. + Sunday corresponds to 0 and Saturday corresponds to 6. + timezone: IANA time zone database formatted string (e.g. America/New_York). + start: Start time in 24-hour time format. Leading 0's are left off. + end: End time in 24-hour time format. Leading 0's are left off. + extdates: A list of dates that will be excluded from the open hours. + Dates should be formatted as YYYY-MM-DD. + """ + + days: List[int] + timezone: str + start: str + end: str + exdates: NotRequired[List[str]] + + +class AvailabilityRules(TypedDict): + """ + Interface for the availability rules for a Nylas calendar. + + Attributes: + availability_method: The method used to determine availability for a meeting. + buffer: The buffer to add to the start and end of a meeting. + default_open_hours: A default set of open hours to apply to all participants. + You can overwrite these open hours for individual participants by specifying open_hours on + the participant object. + round_robin_event_id: The ID on events that Nylas considers when calculating the order of + round-robin participants. + This is used for both max-fairness and max-availability methods. + """ + + availability_method: NotRequired[AvailabilityMethod] + buffer: NotRequired[MeetingBuffer] + default_open_hours: NotRequired[List[OpenHours]] + round_robin_event_id: NotRequired[str] + + +class AvailabilityParticipant(TypedDict): + """ + Interface of participant details to check availability for. + + Attributes: + email: The email address of the participant. + calendar_ids: An optional list of the calendar IDs associated with each participant's email address. + If not provided, Nylas uses the primary calendar ID. + open_hours: Open hours for this participant. The endpoint searches for free time slots during these open hours. + """ + + email: str + calendar_ids: NotRequired[List[str]] + open_hours: NotRequired[List[OpenHours]] + + +class GetAvailabilityRequest(TypedDict): + """ + Interface for a Nylas get availability request + + Attributes: + start_time: Unix timestamp for the start time to check availability for. + end_time: Unix timestamp for the end time to check availability for. + participants: Participant details to check availability for. + duration_minutes: The total number of minutes the event should last. + interval_minutes: Nylas checks from the nearest interval of the passed start time. + For example, to schedule 30-minute meetings with 15 minutes between them. + If you have a meeting starting at 9:59, the API returns times starting at 10:00. 10:00-10:30, 10:15-10:45. + round_to_30_minutes: When set to true, the availability time slots will start at 30 minutes past or on the hour. + For example, a free slot starting at 16:10 is considered available only from 16:30. + availability_rules: The rules to apply when checking availability. + """ + + start_time: int + end_time: int + participants: List[AvailabilityParticipant] + duration_minutes: int + interval_minutes: NotRequired[int] + round_to_30_minutes: NotRequired[bool] + availability_rules: NotRequired[AvailabilityRules] diff --git a/nylas/models/calendars.py b/nylas/models/calendars.py new file mode 100644 index 00000000..9b3468a3 --- /dev/null +++ b/nylas/models/calendars.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass +from typing import Dict, Any, Optional + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +from nylas.models.list_query_params import ListQueryParams + + +@dataclass_json +@dataclass +class Calendar: + """ + Class representation of a Nylas Calendar object. + + Attributes: + id: Globally unique object identifier. + grant_id: Grant ID representing the user's account. + name: Name of the Calendar. + timezone: IANA time zone database-formatted string (for example, "America/New_York"). + This value is only supported for Google and Virtual Calendars. + read_only: If the event participants are able to edit the Event. + is_owned_by_user: If the Calendar is owned by the user account. + object: The type of object. + description: Description of the Calendar. + location: Geographic location of the Calendar as free-form text. + hex_color: The background color of the calendar in the hexadecimal format (for example, "#0099EE"). + If not defined, the default color is used. + hex_foreground_color: The background color of the calendar in the hexadecimal format (for example, "#0099EE"). + If not defined, the default color is used (Google only). + is_primary: If the Calendar is the account's primary calendar. + metadata: A list of key-value pairs storing additional data. + """ + + id: str + grant_id: str + name: str + read_only: bool + is_owned_by_user: bool + object: str = "calendar" + timezone: Optional[str] = None + description: Optional[str] = None + location: Optional[str] = None + hex_color: Optional[str] = None + hex_foreground_color: Optional[str] = None + is_primary: Optional[bool] = None + metadata: Optional[Dict[str, Any]] = None + + +class ListCalendarsQueryParams(ListQueryParams): + """ + Interface of the query parameters for listing calendars. + + Attributes: + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + metadata_pair: Pass in your metadata key-value pair to search for metadata. + """ + + metadata_pair: NotRequired[Dict[str, str]] + + +class CreateCalendarRequest(TypedDict): + """ + Interface of a Nylas create calendar request + + Attributes: + name: Name of the Calendar. + description: Description of the calendar. + location: Geographic location of the calendar as free-form text. + timezone: IANA time zone database formatted string (e.g. America/New_York). + metadata: A list of key-value pairs storing additional data. + """ + + name: str + description: NotRequired[str] + location: NotRequired[str] + timezone: NotRequired[str] + metadata: NotRequired[Dict[str, str]] + + +class UpdateCalendarRequest(CreateCalendarRequest): + """ + Interface of a Nylas update calendar request + + Attributes: + hexColor: The background color of the calendar in the hexadecimal format (e.g. #0099EE). + Empty indicates default color. + hexForegroundColor: The background color of the calendar in the hexadecimal format (e.g. #0099EE). + Empty indicates default color. (Google only) + """ + + hexColor: NotRequired[str] + hexForegroundColor: NotRequired[str] diff --git a/nylas/models/connectors.py b/nylas/models/connectors.py new file mode 100644 index 00000000..6ffd5ef0 --- /dev/null +++ b/nylas/models/connectors.py @@ -0,0 +1,157 @@ +from dataclasses import dataclass +from typing import Dict, Any, List, Optional, Union +from typing_extensions import TypedDict, NotRequired + +from dataclasses_json import dataclass_json + +from nylas.models.auth import Provider +from nylas.models.list_query_params import ListQueryParams + + +@dataclass_json +@dataclass +class Connector: + """ + Interface representing the Nylas connector response. + + Attributes: + provider: The provider type + settings: Optional settings from provider + scope: Default scopes for the connector + """ + + provider: Provider + settings: Optional[Dict[str, Any]] = None + scope: Optional[List[str]] = None + + +class BaseCreateConnectorRequest(TypedDict): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + provider: The provider type + """ + + provider: Provider + + +class GoogleCreateConnectorSettings(TypedDict): + """ + Interface representing a Google connector creation request. + + Attributes: + client_id: The Google Client ID + client_secret: The Google Client Secret + topic_name: The Google Pub/Sub topic name + """ + + client_id: str + client_secret: str + topic_name: NotRequired[str] + + +class MicrosoftCreateConnectorSettings(TypedDict): + """ + Interface representing a Microsoft connector creation request. + + Attributes: + client_id: The Google Client ID + client_secret: The Google Client Secret + tenant: The Microsoft tenant ID + """ + + client_id: str + client_secret: str + tenant: NotRequired[str] + + +class GoogleCreateConnectorRequest(BaseCreateConnectorRequest): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + provider (Provider): The provider type, should be Google + settings: The Google OAuth provider credentials and settings + scope: The Google OAuth scopes + """ + + settings: GoogleCreateConnectorSettings + scope: NotRequired[List[str]] + + +class MicrosoftCreateConnectorRequest(BaseCreateConnectorRequest): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + name (str): Custom name of the connector + provider (Provider): The provider type, should be Google + settings: The Microsoft OAuth provider credentials and settings + scope: The Microsoft OAuth scopes + """ + + settings: MicrosoftCreateConnectorSettings + scope: NotRequired[List[str]] + + +class ImapCreateConnectorRequest(BaseCreateConnectorRequest): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + name (str): Custom name of the connector + provider (Provider): The provider type, should be IMAP + """ + + pass + + +class VirtualCalendarsCreateConnectorRequest(BaseCreateConnectorRequest): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + name (str): Custom name of the connector + provider (Provider): The provider type + """ + + pass + + +CreateConnectorRequest = Union[ + GoogleCreateConnectorRequest, + MicrosoftCreateConnectorRequest, + ImapCreateConnectorRequest, + VirtualCalendarsCreateConnectorRequest, +] +""" The type of the Nylas connector creation request. """ + + +class UpdateConnectorRequest(TypedDict): + """ + Interface representing the base Nylas connector creation request. + + Attributes: + name: Custom name of the connector + settings: The OAuth provider credentials and settings + scope: The OAuth scopes + """ + + name: NotRequired[str] + settings: NotRequired[Dict[str, Any]] + scope: NotRequired[List[str]] + + +class ListConnectorQueryParams(ListQueryParams): + """ + Interface of the query parameters for listing connectors. + + Attributes: + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + """ + + pass diff --git a/nylas/models/contacts.py b/nylas/models/contacts.py new file mode 100644 index 00000000..507c0592 --- /dev/null +++ b/nylas/models/contacts.py @@ -0,0 +1,387 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional, List +from typing_extensions import TypedDict, NotRequired + +from dataclasses_json import dataclass_json + +from nylas.models.list_query_params import ListQueryParams + + +class SourceType(str, Enum): + """Enum representing the different types of sources for a contact.""" + + ADDRESS_BOOK = "address_book" + INBOX = "inbox" + DOMAIN = "domain" + + +@dataclass_json +@dataclass +class PhoneNumber: + """ + A phone number for a contact. + + Attributes: + number: The phone number. + type: The type of phone number. + """ + + number: Optional[str] = None + type: Optional[str] = None + + +@dataclass_json +@dataclass +class PhysicalAddress: + """ + A physical address for a contact. + + Attributes: + format: The format of the address. + street_address: The street address of the contact. + city: The city of the contact. + postal_code: The postal code of the contact. + state: The state of the contact. + country: The country of the contact. + type: The type of address. + """ + + format: Optional[str] = None + street_address: Optional[str] = None + city: Optional[str] = None + postal_code: Optional[str] = None + state: Optional[str] = None + country: Optional[str] = None + type: Optional[str] = None + + +@dataclass_json +@dataclass +class WebPage: + """ + A web page for a contact. + + Attributes: + url: The URL of the web page. + type: The type of web page. + """ + + url: Optional[str] = None + type: Optional[str] = None + + +@dataclass_json +@dataclass +class ContactEmail: + """ + An email address for a contact. + + Attributes: + email: The email address. + type: The type of email address. + """ + + email: Optional[str] = None + type: Optional[str] = None + + +@dataclass_json +@dataclass +class ContactGroupId: + """ + A contact group ID for a contact. + + Attributes: + id: The contact group ID. + """ + + id: str + + +@dataclass_json +@dataclass +class InstantMessagingAddress: + """ + An instant messaging address for a contact. + + Attributes: + im_address: The instant messaging address. + type: The type of instant messaging address. + """ + + im_address: Optional[str] = None + type: Optional[str] = None + + +@dataclass_json +@dataclass +class Contact: + """ + Class representation of a Nylas contact object. + + Attributes: + id: Globally unique object identifier. + grant_id: Grant ID representing the user's account. + object: The type of object. + birthday: The contact's birthday. + company_name: The contact's company name. + display_name: The contact's display name. + emails: The contact's email addresses. + im_addresses: The contact's instant messaging addresses. + given_name: The contact's given name. + job_title: The contact's job title. + manager_name: The contact's manager name. + middle_name: The contact's middle name. + nickname: The contact's nickname. + notes: The contact's notes. + office_location: The contact's office location. + picture_url: The contact's picture URL. + picture: The contact's picture. + suffix: The contact's suffix. + surname: The contact's surname. + source: The contact's source. + phone_numbers: The contact's phone numbers. + physical_addresses: The contact's physical addresses. + web_pages: The contact's web pages. + groups: The contact's groups. + """ + + id: str + grant_id: str + object: str = "contact" + birthday: Optional[str] = None + company_name: Optional[str] = None + display_name: Optional[str] = None + emails: Optional[List[ContactEmail]] = None + im_addresses: Optional[List[InstantMessagingAddress]] = None + given_name: Optional[str] = None + job_title: Optional[str] = None + manager_name: Optional[str] = None + middle_name: Optional[str] = None + nickname: Optional[str] = None + notes: Optional[str] = None + office_location: Optional[str] = None + picture_url: Optional[str] = None + picture: Optional[str] = None + suffix: Optional[str] = None + surname: Optional[str] = None + source: Optional[SourceType] = None + phone_numbers: Optional[List[PhoneNumber]] = None + physical_addresses: Optional[List[PhysicalAddress]] = None + web_pages: Optional[List[WebPage]] = None + groups: Optional[List[ContactGroupId]] = None + + +class WriteablePhoneNumber(TypedDict): + """ + A phone number for a contact. + + Attributes: + number: The phone number. + type: The type of phone number. + """ + + number: NotRequired[str] + type: NotRequired[str] + + +class WriteablePhysicalAddress(TypedDict): + """ + A physical address for a contact. + + Attributes: + format: The format of the address. + street_address: The street address of the contact. + city: The city of the contact. + postal_code: The postal code of the contact. + state: The state of the contact. + country: The country of the contact. + type: The type of address. + """ + + format: NotRequired[str] + street_address: NotRequired[str] + city: NotRequired[str] + postal_code: NotRequired[str] + state: NotRequired[str] + country: NotRequired[str] + type: NotRequired[str] + + +class WriteableWebPage(TypedDict): + """ + A web page for a contact. + + Attributes: + url: The URL of the web page. + type: The type of web page. + """ + + url: NotRequired[str] + type: NotRequired[str] + + +class WriteableContactEmail(TypedDict): + """ + An email address for a contact. + + Attributes: + email: The email address. + type: The type of email address. + """ + + email: NotRequired[str] + type: NotRequired[str] + + +class WriteableContactGroupId(TypedDict): + """ + A contact group ID for a contact. + + Attributes: + id: The contact group ID. + """ + + id: str + + +class WriteableInstantMessagingAddress(TypedDict): + """ + An instant messaging address for a contact. + + Attributes: + im_address: The instant messaging address. + type: The type of instant messaging address. + """ + + im_address: NotRequired[str] + type: NotRequired[str] + + +class CreateContactRequest(TypedDict): + """ + Interface for creating a Nylas contact. + + Attributes: + birthday: The contact's birthday. + company_name: The contact's company name. + display_name: The contact's display name. + emails: The contact's email addresses. + im_addresses: The contact's instant messaging addresses. + given_name: The contact's given name. + job_title: The contact's job title. + manager_name: The contact's manager name. + middle_name: The contact's middle name. + nickname: The contact's nickname. + notes: The contact's notes. + office_location: The contact's office location. + picture_url: The contact's picture URL. + picture: The contact's picture. + suffix: The contact's suffix. + surname: The contact's surname. + source: The contact's source. + phone_numbers: The contact's phone numbers. + physical_addresses: The contact's physical addresses. + web_pages: The contact's web pages. + groups: The contact's groups. + """ + + birthday: NotRequired[str] + company_name: NotRequired[str] + display_name: NotRequired[str] + emails: NotRequired[List[WriteableContactEmail]] + im_addresses: NotRequired[List[WriteableInstantMessagingAddress]] + given_name: NotRequired[str] + job_title: NotRequired[str] + manager_name: NotRequired[str] + middle_name: NotRequired[str] + nickname: NotRequired[str] + notes: NotRequired[str] + office_location: NotRequired[str] + picture_url: NotRequired[str] + picture: NotRequired[str] + suffix: NotRequired[str] + surname: NotRequired[str] + source: NotRequired[SourceType] + phone_numbers: NotRequired[List[WriteablePhoneNumber]] + physical_addresses: NotRequired[List[WriteablePhysicalAddress]] + web_pages: NotRequired[List[WriteableWebPage]] + groups: NotRequired[List[WriteableContactGroupId]] + + +UpdateContactRequest = CreateContactRequest +"""Interface for updating a Nylas contact.""" + + +class ListContactsQueryParams(ListQueryParams): + """ + Interface of the query parameters for listing calendars. + + Attributes: + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + email: Returns the contacts matching the exact contact's email. + phone_number: Returns the contacts matching the contact's exact phone number + source: Returns the contacts matching from the address book or auto-generated contacts from emails. + For example of contacts only from the address book: /contacts?source=address_bookor + for only autogenerated contacts:/contacts?source=inbox` + group: Returns the contacts belonging to the Contact Group matching this ID + recurse: When set to true, returns the contacts also within the specified Contact Group subgroups, + if the group parameter is set. + """ + + email: NotRequired[str] + phone_number: NotRequired[str] + source: NotRequired[SourceType] + group: NotRequired[str] + recurse: NotRequired[bool] + + +class FindContactQueryParams(TypedDict): + """ + The available query parameters for finding a contact. + + Attributes: + profile_picture: If true and picture_url is present, the response includes a Base64 binary data blob that + you can use to view information as an image file. + """ + + profile_picture: NotRequired[bool] + + +class GroupType(str, Enum): + """Enum representing the different types of contact groups.""" + + USER = "user" + SYSTEM = "system" + OTHER = "other" + + +@dataclass_json +@dataclass +class ContactGroup: + """ + Class representation of a Nylas contact group object. + + Attributes: + id: Globally unique object identifier. + grant_id: Grant ID representing the user's account. + object: The type of object. + group_type: The type of contact group. + name: The name of the contact group. + path: The path of the contact group. + """ + + id: str + grant_id: str + object: str = "contact_group" + group_type: Optional[GroupType] = None + name: Optional[str] = None + path: Optional[str] = None + + +ListContactGroupsQueryParams = ListQueryParams +"""The available query parameters for listing contact groups.""" diff --git a/nylas/models/credentials.py b/nylas/models/credentials.py new file mode 100644 index 00000000..39dd65ff --- /dev/null +++ b/nylas/models/credentials.py @@ -0,0 +1,111 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Literal, Union + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, Protocol, NotRequired + +CredentialType = Literal["adminconsent", "serviceaccount", "connector"] +"""The alias for the different types of credentials that can be created.""" + + +@dataclass_json +@dataclass +class Credential: + """ + Interface representing a Nylas Credential object. + Attributes + id: Globally unique object identifier; + name: Name of the credential + credential_type: The type of credential + hashed_data: Hashed value of the credential that you created + created_at: Timestamp of when the credential was created + updated_at: Timestamp of when the credential was updated; + """ + + id: str + name: str + credential_type: Optional[CredentialType] = None + hashed_data: Optional[str] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None + + +class MicrosoftAdminConsentSettings(Protocol): + """ + Interface representing the data required to create a Microsoft Admin Consent credential. + + Attributes: + client_id: The client ID of the Azure AD application + client_secret: The client secret of the Azure AD application + """ + + client_id: str + client_secret: str + + +class GoogleServiceAccountCredential(Protocol): + """ + Interface representing the data required to create a Google Service Account credential. + + Attributes: + private_key_id: The private key ID of the service account + private_key: The private key of the service account + client_email: The client email of the service account + """ + + private_key_id: str + private_key: str + client_email: str + + +CredentialData = Union[ + MicrosoftAdminConsentSettings, GoogleServiceAccountCredential, Dict[str, any] +] +"""The alias for the different types of credential data that can be used to create a credential.""" + + +class CredentialRequest(TypedDict): + """ + Interface representing a request to create a credential. + + Attributes: + name: Name of the credential + credential_type: Type of credential you want to create. + credential_data: The data required to successfully create the credential object + """ + + name: Optional[str] + credential_type: CredentialType + credential_data: CredentialData + + +class UpdateCredentialRequest(TypedDict): + """ + Interface representing a request to update a credential. + + Attributes: + name: Name of the credential + credential_data: The data required to successfully create the credential object + """ + + name: Optional[str] + credential_data: Optional[CredentialData] + + +class ListCredentialQueryParams(TypedDict): + """ + Interface representing the query parameters for credentials . + + Attributes: + offset: Offset results + sort_by: Sort entries by field name + order_by: Order results by the specified field. + Currently only start is supported. + limit: The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + """ + + limit: NotRequired[int] + offset: NotRequired[int] + order_by: NotRequired[str] + sort_by: NotRequired[str] diff --git a/nylas/models/drafts.py b/nylas/models/drafts.py new file mode 100644 index 00000000..18900fa5 --- /dev/null +++ b/nylas/models/drafts.py @@ -0,0 +1,154 @@ +from dataclasses import dataclass +from typing import List, get_type_hints + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +from nylas.models.attachments import CreateAttachmentRequest +from nylas.models.events import EmailName +from nylas.models.list_query_params import ListQueryParams +from nylas.models.messages import Message + + +@dataclass_json +@dataclass +class Draft(Message): + """ + A Draft object. + + Attributes: + id (str): Globally unique object identifier. + grant_id (str): The grant that this message belongs to. + from_ (List[EmailName]): The sender of the message. + date (int): The date the message was received. + object: The type of object. + thread_id (Optional[str]): The thread that this message belongs to. + subject (Optional[str]): The subject of the message. + to (Optional[List[EmailName]]): The recipients of the message. + cc (Optional[List[EmailName]]): The CC recipients of the message. + bcc (Optional[List[EmailName]]): The BCC recipients of the message. + reply_to (Optional[List[EmailName]]): The reply-to recipients of the message. + unread (Optional[bool]): Whether the message is unread. + starred (Optional[bool]): Whether the message is starred. + snippet (Optional[str]): A snippet of the message body. + body (Optional[str]): The body of the message. + attachments (Optional[List[Attachment]]): The attachments on the message. + folders (Optional[List[str]]): The folders that the message is in. + created_at (Optional[int]): Unix timestamp of when the message was created. + """ + + object: str = "draft" + + +class TrackingOptions(TypedDict): + """ + The different tracking options for when a message is sent. + + Attributes: + label: The label to apply to tracked messages. + links: Whether to track links. + opens: Whether to track opens. + thread_replies: Whether to track thread replies. + """ + + label: NotRequired[str] + links: NotRequired[bool] + opens: NotRequired[bool] + thread_replies: NotRequired[bool] + + +class CreateDraftRequest(TypedDict): + """ + A request to create a draft. + + Attributes: + subject: The subject of the message. + to: The recipients of the message. + cc: The CC recipients of the message. + bcc: The BCC recipients of the message. + reply_to: The reply-to recipients of the message. + starred: Whether the message is starred. + body: The body of the message. + attachments: The attachments on the message. + send_at: Unix timestamp to send the message at. + reply_to_message_id: The ID of the message that you are replying to. + tracking_options: Options for tracking opens, links, and thread replies. + """ + + body: NotRequired[str] + subject: NotRequired[str] + to: NotRequired[List[EmailName]] + bcc: NotRequired[List[EmailName]] + cc: NotRequired[List[EmailName]] + reply_to: NotRequired[List[EmailName]] + attachments: NotRequired[List[CreateAttachmentRequest]] + starred: NotRequired[bool] + send_at: NotRequired[int] + reply_to_message_id: NotRequired[str] + tracking_options: NotRequired[TrackingOptions] + + +UpdateDraftRequest = CreateDraftRequest +""" A request to update a draft. """ + + +# Need to use Functional typed dicts because "from" and "in" are Python +# keywords, and can't be declared using the declarative syntax +ListDraftsQueryParams = TypedDict( + "ListDraftsQueryParams", + { + **get_type_hints(ListQueryParams), + "subject": NotRequired[str], + "any_email": NotRequired[List[str]], + "from": NotRequired[List[str]], + "to": NotRequired[List[str]], + "cc": NotRequired[List[str]], + "bcc": NotRequired[List[str]], + "in": NotRequired[List[str]], + "unread": NotRequired[bool], + "starred": NotRequired[bool], + "thread_id": NotRequired[str], + "has_attachment": NotRequired[bool], + }, +) +""" +Query parameters for listing drafts. + +Attributes: + subject: Return messages with matching subject. + any_email: Return messages that have been sent or received by this comma-separated list of email addresses. + from: Return messages sent from this email address. + to: Return messages sent to this email address. + cc: Return messages cc'd to this email address. + bcc: Return messages bcc'd to this email address. + in: Return messages in this specific folder or label, specified by ID. + unread: Filter messages by unread status. + starred: Filter messages by starred status. + has_attachment: Filter messages by whether they have an attachment. + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. +""" + + +class SendMessageRequest(CreateDraftRequest): + """ + A request to send a message. + + Attributes: + subject (NotRequired[str]): The subject of the message. + to (NotRequired[List[EmailName]]): The recipients of the message. + cc (NotRequired[List[EmailName]]): The CC recipients of the message. + bcc (NotRequired[List[EmailName]]): The BCC recipients of the message. + reply_to (NotRequired[List[EmailName]]): The reply-to recipients of the message. + starred (NotRequired[bool]): Whether the message is starred. + body (NotRequired[str]): The body of the message. + attachments (NotRequired[List[CreateAttachmentRequest]]): The attachments on the message. + send_at (NotRequired[int]): Unix timestamp to send the message at. + reply_to_message_id (NotRequired[str]): The ID of the message that you are replying to. + tracking_options (NotRequired[TrackingOptions]): Options for tracking opens, links, and thread replies. + use_draft: Whether or not to use draft support. This is primarily used when dealing with large attachments. + """ + + use_draft: NotRequired[bool] diff --git a/nylas/models/errors.py b/nylas/models/errors.py new file mode 100644 index 00000000..d59b61ba --- /dev/null +++ b/nylas/models/errors.py @@ -0,0 +1,163 @@ +from dataclasses import dataclass +from typing import Optional + +from dataclasses_json import dataclass_json + + +class AbstractNylasApiError(Exception): + """ + Base class for all Nylas API errors. + + Attributes: + request_id: The unique identifier of the request. + status_code: The HTTP status code of the error response. + """ + + def __init__( + self, + message: str, + request_id: Optional[str] = None, + status_code: Optional[int] = None, + ): + """ + Args: + request_id: The unique identifier of the request. + status_code: The HTTP status code of the error response. + message: The error message. + """ + self.request_id: str = request_id + self.status_code: int = status_code + super().__init__(message) + + +class AbstractNylasSdkError(Exception): + """ + Base class for all Nylas SDK errors. + """ + + pass + + +@dataclass_json +@dataclass +class NylasApiErrorResponseData: + """ + Interface representing the error data within the response object. + + Attributes: + type: The type of error. + message: The error message. + provider_error: The provider error if there is one. + """ + + type: str + message: str + provider_error: Optional[dict] = None + + +@dataclass_json +@dataclass +class NylasApiErrorResponse: + """ + Interface representing the error response from the Nylas API. + + Attributes: + request_id: The unique identifier of the request. + error: The error data. + """ + + request_id: str + error: NylasApiErrorResponseData + + +@dataclass_json +@dataclass +class NylasOAuthErrorResponse: + """ + Interface representing an OAuth error returned by the Nylas API. + + Attributes: + error: Error type. + error_code: Error code used for referencing the docs, logs, and data stream. + error_description: Human readable error description. + error_uri: URL to the related documentation and troubleshooting regarding this error. + """ + + error: str + error_code: int + error_description: str + error_uri: str + + +class NylasApiError(AbstractNylasApiError): + """ + Class representation of a general Nylas API error. + + Attributes: + type: Error type. + provider_error: Provider Error. + """ + + def __init__( + self, + api_error: NylasApiErrorResponse, + status_code: Optional[int] = None, + ): + """ + Args: + api_error: The error details from the API. + status_code: The HTTP status code of the error response. + """ + super().__init__(api_error.error.message, api_error.request_id, status_code) + self.type: str = api_error.error.type + self.provider_error: Optional[dict] = api_error.error.provider_error + + +class NylasOAuthError(AbstractNylasApiError): + """ + Class representation of an OAuth error returned by the Nylas API. + + Attributes: + error: Error type. + error_code: Error code used for referencing the docs, logs, and data stream. + error_description: Human readable error description. + error_uri: URL to the related documentation and troubleshooting regarding this error. + """ + + def __init__( + self, + oauth_error: NylasOAuthErrorResponse, + status_code: Optional[int] = None, + ): + """ + Args: + oauth_error: The error details from the API. + status_code: The HTTP status code of the error response. + """ + super().__init__(oauth_error.error_description, status_code) + self.error: str = oauth_error.error + self.error_code: int = oauth_error.error_code + self.error_description: str = oauth_error.error_description + self.error_uri: str = oauth_error.error_uri + + +class NylasSdkTimeoutError(AbstractNylasSdkError): + """ + Error thrown when the Nylas SDK times out before receiving a response from the server. + + Attributes: + url: The URL that timed out. + timeout: The timeout value set in the Nylas SDK, in seconds. + """ + + def __init__(self, url: str, timeout: int): + """ + Args: + url: The URL that timed out. + timeout: The timeout value set in the Nylas SDK, in seconds. + """ + super().__init__( + "Nylas SDK timed out before receiving a response from the server." + ) + self.url: str = url + self.timeout: int = timeout diff --git a/nylas/models/events.py b/nylas/models/events.py new file mode 100644 index 00000000..873a51c8 --- /dev/null +++ b/nylas/models/events.py @@ -0,0 +1,792 @@ +from dataclasses import dataclass, field +from typing import Dict, Any, List, Optional, Union, Literal + +from dataclasses_json import dataclass_json, config +from typing_extensions import TypedDict, NotRequired + +from nylas.models.list_query_params import ListQueryParams + +Status = Literal["confirmed", "tentative", "cancelled"] +""" Literal representing the status of an Event. """ + +Visibility = Literal["default", "public", "private"] +""" Literal representation of visibility of the Event. """ + +ParticipantStatus = Literal["noreply", "yes", "no", "maybe"] +""" Literal representing the status of an Event participant. """ + +SendRsvpStatus = Literal["yes", "no", "maybe"] +""" Literal representing the status of an RSVP. """ + + +@dataclass_json +@dataclass +class Participant: + """ + Interface representing an Event participant. + + Attributes: + email: Participant's email address. + name: Participant's name. + status: Participant's status. + comment: Comment by the participant. + phone_number: Participant's phone number. + """ + + email: str + status: ParticipantStatus + name: Optional[str] = None + comment: Optional[str] = None + phone_number: Optional[str] = None + + +class EmailName(TypedDict): + """ + Interface representing an email address and optional name. + + Attributes: + email: Email address. + name: Full name. + """ + + email: str + name: NotRequired[str] + + +@dataclass_json +@dataclass +class Time: + """ + Class representation of a specific point in time. + A meeting at 2pm would be represented as a time subobject. + + Attributes: + time: A UNIX timestamp representing the time of occurrence. + timezone: If timezone is present, then the value for time will be read with timezone. + Timezone using IANA formatted string. (e.g. "America/New_York") + """ + + time: int + timezone: Optional[str] = None + object: str = "time" + + +@dataclass_json +@dataclass +class Timespan: + """ + Class representation of a time span with start and end times. + An hour lunch meeting would be represented as timespan subobjects. + + Attributes: + start_time: The Event's start time. + end_time: The Event's end time. + start_timezone: The timezone of the start time, represented by an IANA-formatted string + (for example, "America/New_York"). + end_timezone: The timezone of the end time, represented by an IANA-formatted string + (for example, "America/New_York"). + """ + + start_time: int + end_time: int + start_timezone: Optional[str] = None + end_timezone: Optional[str] = None + object: str = "timespan" + + +@dataclass_json +@dataclass +class Date: + """ + Class representation of an entire day spans without specific times. + Your birthday and holidays would be represented as date subobjects. + + Attributes: + date: Date of occurrence in ISO 8601 format. + """ + + date: str + object: str = "date" + + +@dataclass_json +@dataclass +class Datespan: + """ + Class representation of a specific dates without clock-based start or end times. + A business quarter or academic semester would be represented as datespan subobjects. + + Attributes: + start_date: The start date in ISO 8601 format. + end_date: The end date in ISO 8601 format. + """ + + start_date: str + end_date: str + object: str = "datespan" + + +When = Union[Time, Timespan, Date, Datespan] +""" Union type representing the different types of Event time configurations. """ + + +def _decode_when(when: dict) -> When: + """ + Decode a when object into a When object. + + Args: + when: The when object to decode. + + Returns: + The decoded When object. + """ + if "object" not in when: + raise ValueError("Invalid when object, no 'object' field found.") + + if when["object"] == "time": + return Time.from_dict(when) + + if when["object"] == "timespan": + return Timespan.from_dict(when) + + if when["object"] == "date": + return Date.from_dict(when) + + if when["object"] == "datespan": + return Datespan.from_dict(when) + + raise ValueError( + f"Invalid when object, unknown 'object' field found: {when['object']}" + ) + + +ConferencingProvider = Literal[ + "Google Meet", "Zoom Meeting", "Microsoft Teams", "GoToMeeting", "WebEx" +] +""" Literal for the different conferencing providers. """ + + +@dataclass_json +@dataclass +class DetailsConfig: + """ + Class representation of a conferencing details config object + + Attributes: + meeting_code: The conferencing meeting code. Used for Zoom. + password: The conferencing meeting password. Used for Zoom. + url: The conferencing meeting url. + pin: The conferencing meeting pin. Used for Google Meet. + phone: The conferencing meeting phone numbers. Used for Google Meet. + """ + + meeting_code: Optional[str] = None + password: Optional[str] = None + url: Optional[str] = None + pin: Optional[str] = None + phone: Optional[List[str]] = None + + +@dataclass_json +@dataclass +class Details: + """ + Class representation of a conferencing details object + + Attributes: + provider: The conferencing provider + details: The conferencing details + """ + + provider: ConferencingProvider + details: Dict[str, Any] + + +@dataclass_json +@dataclass +class Autocreate: + """ + Class representation of a conferencing autocreate object + + Attributes: + provider: The conferencing provider + autocreate: Empty dict to indicate an intention to autocreate a video link. + Additional provider settings may be included in autocreate.settings, but Nylas does not validate these. + """ + + provider: ConferencingProvider + autocreate: Dict[str, Any] + + +Conferencing = Union[Details, Autocreate] +""" Union type representing the different types of conferencing configurations. """ + + +def _decode_conferencing(conferencing: dict) -> Union[Conferencing, None]: + """ + Decode a when object into a When object. + + Args: + when: The when object to decode. + + Returns: + The decoded When object. + """ + if not conferencing: + return None + + if "details" in conferencing: + return Details.from_dict(conferencing) + + if "autocreate" in conferencing: + return Autocreate.from_dict(conferencing) + + raise ValueError(f"Invalid conferencing object, unknown type found: {conferencing}") + + +@dataclass_json +@dataclass +class ReminderOverride: + """ + Class representation of a reminder override object. + + Attributes: + reminder_minutes: The user's preferred Event reminder time, in minutes. + Reminder minutes are in the following format: "[20]". + reminder_method: The user's preferred method for Event reminders (Google only). + """ + + reminder_minutes: Optional[int] = None + reminder_method: Optional[str] = None + + +@dataclass_json +@dataclass +class Reminders: + """ + Class representation of a reminder object. + + Attributes: + use_default: Whether to use the default reminder settings for the calendar. + overrides: A list of reminders for the event if use_default is set to false. + If left empty or omitted while use_default is set to false, the event will have no reminders. + """ + + use_default: bool + overrides: Optional[List[ReminderOverride]] = None + + +@dataclass_json +@dataclass +class Event: + """ + Class representation of a Nylas Event object. + + Attributes: + id: Globally unique object identifier. + grant_id: Grant ID representing the user's account. + calendar_id: The Event's Calendar ID. + busy: Whether to show this Event's time block as available on shared or public calendars. + read_only: If the Event's participants are able to edit the Event. + created_at: Unix timestamp representing the Event's creation time. + updated_at: Unix timestamp representing the time when the Event was last updated. + participants: List of participants invited to the Event. Participants may be people, rooms, or resources. + when: Representation of an Event's time and duration. + conferencing: Representation of an Event's conferencing details. + object: The type of object. + description: The Event's description. + location: The Event's location (for example, a physical address or a meeting room). + ical_uid: Unique ID for iCalendar standard, allowing you to identify events across calendaring systems. + Recurring events may share the same value. Can be "null" for events synced before the year 2020. + title: The Event's title. + html_link: A link to the Event in the provider's UI. + hide_participants: Whether participants of the Event should be hidden. + metadata: List of key-value pairs storing additional data. + creator: The user who created the Event. + organizer: The organizer of the Event. + recurrence: A list of RRULE and EXDATE strings. + reminders: List of reminders for the Event. + status: The Event's status. + visibility: The Event's visibility (private or public). + capacity: Sets the maximum number of participants that may attend the event. + """ + + id: str + grant_id: str + calendar_id: str + busy: bool + created_at: int + updated_at: int + participants: List[Participant] + visibility: Visibility + when: When = field(metadata=config(decoder=_decode_when)) + conferencing: Optional[Conferencing] = field( + default=None, metadata=config(decoder=_decode_conferencing) + ) + object: str = "event" + read_only: Optional[bool] = None + description: Optional[str] = None + location: Optional[str] = None + ical_uid: Optional[str] = None + title: Optional[str] = None + html_link: Optional[str] = None + hide_participants: Optional[bool] = None + metadata: Optional[Dict[str, Any]] = None + creator: Optional[EmailName] = None + organizer: Optional[EmailName] = None + recurrence: Optional[List[str]] = None + reminders: Optional[Reminders] = None + status: Optional[Status] = None + capacity: Optional[int] = None + + +class CreateParticipant(TypedDict): + """ + Interface representing a participant for event creation. + + Attributes: + email: Participant's email address. + name: Participant's name. + comment: Comment by the participant. + phone_number: Participant's phone number. + """ + + email: str + name: NotRequired[str] + comment: NotRequired[str] + phone_number: NotRequired[str] + + +class UpdateParticipant(TypedDict): + """ + Interface representing a participant for updating an event. + + Attributes: + email: Participant's email address. + name: Participant's name. + comment: Comment by the participant. + phoneNumber: Participant's phone number. + """ + + email: NotRequired[str] + name: NotRequired[str] + comment: NotRequired[str] + phoneNumber: NotRequired[str] + + +class WritableDetailsConfig(TypedDict): + """ + Interface representing a writable conferencing details config object + + Attributes: + meeting_code: The conferencing meeting code. Used for Zoom. + password: The conferencing meeting password. Used for Zoom. + url: The conferencing meeting url. + pin: The conferencing meeting pin. Used for Google Meet. + phone: The conferencing meeting phone numbers. Used for Google Meet. + """ + + meeting_code: NotRequired[str] + password: NotRequired[str] + url: NotRequired[str] + pin: NotRequired[str] + phone: NotRequired[List[str]] + + +class WriteableReminderOverride(TypedDict): + """ + Interface representing a writable reminder override object. + + Attributes: + reminder_minutes: The user's preferred Event reminder time, in minutes. + Reminder minutes are in the following format: "[20]". + reminder_method: The user's preferred method for Event reminders (Google only). + """ + + reminder_minutes: NotRequired[int] + reminder_method: NotRequired[str] + + +class CreateReminders(TypedDict): + """ + Interface representing a reminder object for event creation. + + Attributes: + use_default: Whether to use the default reminder settings for the calendar. + overrides: A list of reminders for the event if use_default is set to false. + If left empty or omitted while use_default is set to false, the event will have no reminders. + """ + + use_default: bool + overrides: NotRequired[List[WriteableReminderOverride]] + + +class UpdateReminders(TypedDict): + """ + Interface representing a reminder object for updating an event. + + Attributes: + use_default: Whether to use the default reminder settings for the calendar. + overrides: A list of reminders for the event if use_default is set to false. + If left empty or omitted while use_default is set to false, the event will have no reminders. + """ + + use_default: NotRequired[bool] + overrides: NotRequired[List[WriteableReminderOverride]] + + +class CreateDetails(TypedDict): + """ + Interface representing a conferencing details object for event creation + + Attributes: + provider: The conferencing provider + details: The conferencing details + """ + + provider: ConferencingProvider + details: WritableDetailsConfig + + +class UpdateDetails(TypedDict): + """ + Interface representing a conferencing details object for updating an event + + Attributes: + provider: The conferencing provider + details: The conferencing details + """ + + provider: NotRequired[ConferencingProvider] + details: NotRequired[WritableDetailsConfig] + + +class CreateAutocreate(TypedDict): + """ + Interface representing a conferencing autocreate object for event creation + + Attributes: + provider: The conferencing provider + autocreate: Empty dict to indicate an intention to autocreate a video link. + Additional provider settings may be included in autocreate.settings, but Nylas does not validate these. + """ + + provider: ConferencingProvider + autocreate: Dict[str, Any] + + +class UpdateAutocreate(TypedDict): + """ + Interface representing a conferencing autocreate object for event creation + + Attributes: + provider: The conferencing provider + autocreate: Empty dict to indicate an intention to autocreate a video link. + Additional provider settings may be included in autocreate.settings, but Nylas does not validate these. + """ + + provider: NotRequired[ConferencingProvider] + autocreate: NotRequired[Dict[str, Any]] + + +CreateConferencing = Union[CreateDetails, CreateAutocreate] +""" Union type representing the different types of conferencing configurations for Event creation. """ + +UpdateConferencing = Union[UpdateDetails, UpdateAutocreate] +""" Union type representing the different types of conferencing configurations for updating an Event.""" + + +# When +class CreateTime(TypedDict): + """ + Interface representing a specific point in time for event creation. + A meeting at 2pm would be represented as a time subobject. + + Attributes: + time: A UNIX timestamp representing the time of occurrence. + timezone: If timezone is present, then the value for time will be read with timezone. + Timezone using IANA formatted string. (e.g. "America/New_York") + """ + + time: int + timezone: NotRequired[str] + + +class UpdateTime(TypedDict): + """ + Interface representing a specific point in time for updating an event. + A meeting at 2pm would be represented as a time subobject. + + Attributes: + time: A UNIX timestamp representing the time of occurrence. + timezone: If timezone is present, then the value for time will be read with timezone. + Timezone using IANA formatted string. (e.g. "America/New_York") + """ + + time: NotRequired[int] + timezone: NotRequired[str] + + +class CreateTimespan(TypedDict): + """ + Interface representing a time span with start and end times for event creation. + An hour lunch meeting would be represented as timespan subobjects. + + Attributes: + start_time: The start time of the event. + end_time: The end time of the event. + start_timezone: The timezone of the start time. Timezone using IANA formatted string. (e.g. "America/New_York") + end_timezone: The timezone of the end time. Timezone using IANA formatted string. (e.g. "America/New_York") + """ + + start_time: int + end_time: int + start_timezone: NotRequired[str] + end_timezone: NotRequired[str] + + +class UpdateTimespan(TypedDict): + """ + Interface representing a time span with start and end times for updating an event. + An hour lunch meeting would be represented as timespan subobjects. + + Attributes: + start_time: The start time of the event. + end_time: The end time of the event. + start_timezone: The timezone of the start time. Timezone using IANA formatted string. (e.g. "America/New_York") + end_timezone: The timezone of the end time. Timezone using IANA formatted string. (e.g. "America/New_York") + """ + + start_time: NotRequired[int] + end_time: NotRequired[int] + start_timezone: NotRequired[str] + end_timezone: NotRequired[str] + + +class CreateDate(TypedDict): + """ + Interface representing an entire day spans without specific times for event creation. + Your birthday and holidays would be represented as date subobjects. + + Attributes: + date: Date of occurrence in ISO 8601 format. + """ + + date: str + + +class UpdateDate(TypedDict): + """ + Interface representing an entire day spans without specific times for updating an event. + Your birthday and holidays would be represented as date subobjects. + + Attributes: + date: Date of occurrence in ISO 8601 format. + """ + + date: NotRequired[str] + + +class CreateDatespan(TypedDict): + """ + Interface representing a specific dates without clock-based start or end times for event creation. + A business quarter or academic semester would be represented as datespan subobjects. + + Attributes: + start_date: The start date in ISO 8601 format. + end_date: The end date in ISO 8601 format. + """ + + start_date: str + end_date: str + + +class UpdateDatespan(TypedDict): + """ + Interface representing a specific dates without clock-based start or end times for updating an event. + A business quarter or academic semester would be represented as datespan subobjects. + + Attributes: + start_date: The start date in ISO 8601 format. + end_date: The end date in ISO 8601 format. + """ + + start_date: NotRequired[str] + end_date: NotRequired[str] + + +CreateWhen = Union[CreateTime, CreateTimespan, CreateDate, CreateDatespan] +""" Union type representing the different types of event time configurations for Event creation. """ + +UpdateWhen = Union[UpdateTime, UpdateTimespan, UpdateDate, UpdateDatespan] +""" Union type representing the different types of event time configurations for updating an Event.""" + + +class CreateEventRequest(TypedDict): + """ + Interface representing a request to create an event. + + Attributes: + when: When the event occurs. + title: The title of the event. + busy: Whether the event is busy or free. + description: The description of the event. + location: The location of the event. + conferencing: The conferencing details of the event. + reminders: A list of reminders to send for the event. + If left empty or omitted, the event uses the provider defaults. + metadata: Metadata associated with the event. + participants: The participants of the event. + recurrence: The recurrence rules of the event. + visibility: The visibility of the event. + capacity: The capacity of the event. + hide_participants: Whether to hide participants of the event. + """ + + when: CreateWhen + title: NotRequired[str] + busy: NotRequired[bool] + description: NotRequired[str] + location: NotRequired[str] + conferencing: NotRequired[CreateConferencing] + reminders: NotRequired[CreateReminders] + metadata: NotRequired[Dict[str, Any]] + participants: NotRequired[List[CreateParticipant]] + recurrence: NotRequired[List[str]] + visibility: NotRequired[Visibility] + capacity: NotRequired[int] + hide_participants: NotRequired[bool] + + +class UpdateEventRequest(TypedDict): + """ + Interface representing a request to update an event. + + Attributes: + when: When the event occurs. + title: The title of the event. + busy: Whether the event is busy or free. + description: The description of the event. + location: The location of the event. + conferencing: The conferencing details of the event. + reminders: A list of reminders to send for the event. + metadata: Metadata associated with the event. + participants: The participants of the event. + recurrence: The recurrence rules of the event. + visibility: The visibility of the event. + capacity: The capacity of the event. + hide_participants: Whether to hide participants of the event. + """ + + when: NotRequired[UpdateWhen] + title: NotRequired[str] + busy: NotRequired[bool] + description: NotRequired[str] + location: NotRequired[str] + conferencing: NotRequired[UpdateConferencing] + reminders: NotRequired[UpdateReminders] + metadata: NotRequired[Dict[str, Any]] + participants: NotRequired[List[UpdateParticipant]] + recurrence: NotRequired[List[str]] + visibility: NotRequired[Visibility] + capacity: NotRequired[int] + hide_participants: NotRequired[bool] + + +class ListEventQueryParams(ListQueryParams): + """ + Interface representing the query parameters for listing events. + + Attributes: + show_cancelled: Return events that have a status of cancelled. + If an event is recurring, then it returns no matter the value set. + Different providers have different semantics for cancelled events. + calendar_id: Specify calendar ID of the event. "primary" is a supported value + indicating the user's primary calendar. + title: Return events matching the specified title. + description: Return events matching the specified description. + location: Return events matching the specified location. + start: Return events starting after the specified unix timestamp. + Defaults to the current timestamp. Not respected by metadata filtering. + end: Return events ending before the specified unix timestamp. + Defaults to a month from now. Not respected by metadata filtering. + metadata_pair: Pass in your metadata key and value pair to search for metadata. + expand_recurring: If true, the response will include an event for each occurrence of a recurring event within + the requested time range. + If false, only a single primary event will be returned for each recurring event. + Cannot be used when filtering on metadata. Defaults to false. + busy: Returns events with a busy status of true. + order_by: Order results by the specified field. + Currently only start is supported. + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + """ + + calendar_id: str + show_cancelled: NotRequired[bool] + title: NotRequired[str] + description: NotRequired[str] + location: NotRequired[str] + start: NotRequired[int] + end: NotRequired[int] + metadata_pair: NotRequired[Dict[str, Any]] + expand_recurring: NotRequired[bool] + busy: NotRequired[bool] + order_by: NotRequired[str] + + +class CreateEventQueryParams(TypedDict): + """ + Interface representing of the query parameters for creating an event. + + Attributes: + calendar_id: The ID of the calendar to create the event in. + notify_participants: Email notifications containing the calendar event is sent to all event participants. + """ + + calendar_id: str + notify_participants: NotRequired[bool] + + +class FindEventQueryParams(TypedDict): + """ + Interface representing of the query parameters for finding an event. + + Attributes: + calendar_id: Calendar ID to find the event in. + "primary" is a supported value indicating the user's primary calendar. + """ + + calendar_id: str + + +UpdateEventQueryParams = CreateEventQueryParams +""" Interface representing of the query parameters for updating an Event. """ + +DestroyEventQueryParams = CreateEventQueryParams +""" Interface representing of the query parameters for destroying an Event. """ + + +class SendRsvpQueryParams(TypedDict): + """ + Interface representing of the query parameters for an event. + + Attributes: + calendar_id: Calendar ID to find the event in. + "primary" is a supported value indicating the user's primary calendar. + """ + + calendar_id: str + + +class SendRsvpRequest(TypedDict): + """ + Interface representing a request to send an RSVP. + + Attributes: + status: The status of the RSVP. + """ + + status: SendRsvpStatus diff --git a/nylas/models/folders.py b/nylas/models/folders.py new file mode 100644 index 00000000..3b160a37 --- /dev/null +++ b/nylas/models/folders.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass +from typing import Optional + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + + +@dataclass_json +@dataclass +class Folder: + """ + Class representing a Nylas folder. + + Attributes: + id: A globally unique object identifier. + grant_id: A Grant ID of the Nylas account. + name: Folder name + object: The type of object. + parent_id: ID of the parent folder. (Microsoft only) + background_color: Folder background color. (Google only) + text_color: Folder text color. (Google only) + system_folder: Indicates if the folder is user created or system created. (Google Only) + child_count: The number of immediate child folders in the current folder. (Microsoft only) + unread_count: The number of unread items inside of a folder. + total_count: The number of items inside of a folder. + """ + + id: str + grant_id: str + name: str + object: str = "folder" + parent_id: Optional[str] = None + background_color: Optional[str] = None + text_color: Optional[str] = None + system_folder: Optional[bool] = None + child_count: Optional[int] = None + unread_count: Optional[int] = None + total_count: Optional[int] = None + + +class CreateFolderRequest(TypedDict): + """ + Class representation of the Nylas folder creation request. + + Attributes: + name: The name of the folder. + parent_id: The parent ID of the folder. (Microsoft only) + background_color: The background color of the folder. (Google only) + text_color: The text color of the folder. (Google only) + """ + + name: str + parent_id: NotRequired[str] + background_color: NotRequired[str] + text_color: NotRequired[str] + + +class UpdateFolderRequest(TypedDict): + """ + Class representation of the Nylas folder update request. + + Attributes: + name: The name of the folder. + parent_id: The parent ID of the folder. (Microsoft only) + background_color: The background color of the folder. (Google only) + text_color: The text color of the folder. (Google only) + """ + + name: NotRequired[str] + parent_id: NotRequired[str] + background_color: NotRequired[str] + text_color: NotRequired[str] diff --git a/nylas/models/free_busy.py b/nylas/models/free_busy.py new file mode 100644 index 00000000..680ddd2e --- /dev/null +++ b/nylas/models/free_busy.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass +from typing import List, Union + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict + + +@dataclass_json +@dataclass +class FreeBusyError: + """ + Interface for a Nylas free/busy call error + + Attributes: + email: The email address of the participant who had an error. + error: The provider's error message. + """ + + email: str + error: str + + +@dataclass_json +@dataclass +class TimeSlot: + """ + Interface for a Nylas free/busy time slot + + Attributes: + start_time: Unix timestamp for the start of the slot. + end_time: Unix timestamp for the end of the slot. + status: The status of the slot. Typically "busy" + """ + + start_time: int + end_time: int + status: str + + +@dataclass_json +@dataclass +class FreeBusy: + """ + Interface for an individual Nylas free/busy response + + Attributes: + email: The email address of the participant. + time_slots: List of time slots for the participant. + """ + + email: str + time_slots: List[TimeSlot] + + +GetFreeBusyResponse = List[Union[FreeBusy, FreeBusyError]] +""" Interface for a Nylas get free/busy response """ + + +class GetFreeBusyRequest(TypedDict): + """ + Interface for a Nylas get free/busy request + + Attributes: + start_time: Unix timestamp for the start time to check free/busy for. + end_time: Unix timestamp for the end time to check free/busy for. + emails: List of email addresses to check free/busy for. + """ + + start_time: int + end_time: int + emails: List[str] diff --git a/nylas/models/grants.py b/nylas/models/grants.py new file mode 100644 index 00000000..e9be3ff8 --- /dev/null +++ b/nylas/models/grants.py @@ -0,0 +1,102 @@ +from dataclasses import dataclass, field +from typing import List, Any, Dict, Optional + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +from nylas.models.auth import Provider + + +@dataclass_json +@dataclass +class Grant: + """ + Interface representing a Nylas Grant object. + + Attributes: + id: Globally unique object identifier. + provider: OAuth provider that the user authenticated with. + scope: Scopes specified for the grant. + created_at: Unix timestamp when the grant was created. + grant_status: Status of the grant, if it is still valid or if the user needs to re-authenticate. + email: Email address associated with the grant. + user_agent: End user's client user agent. + ip: End user's client IP address. + state: Initial state that was sent as part of the OAuth request. + updated_at: Unix timestamp when the grant was updated. + provider_user_id: Provider's ID for the user this grant is associated with. + settings: Settings required by the provider that were sent as part of the OAuth request. + """ + + id: str + provider: str + scope: List[str] = field(default_factory=list) + grant_status: Optional[str] = None + email: Optional[str] = None + user_agent: Optional[str] = None + ip: Optional[str] = None + state: Optional[str] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None + provider_user_id: Optional[str] = None + settings: Optional[Dict[str, Any]] = None + + +class CreateGrantRequest(TypedDict): + """ + Interface representing a request to create a grant. + + Attributes: + provider: OAuth provider. + settings: Settings required by provider. + state: Optional state value to return to developer's website after authentication flow is completed. + scope: Optional list of scopes to request. If not specified it will use the integration default scopes. + """ + + provider: Provider + settings: Dict[str, Any] + state: NotRequired[str] + scope: NotRequired[List[str]] + + +class UpdateGrantRequest(TypedDict): + """ + Interface representing a request to update a grant. + + Attributes: + settings: Settings required by provider. + scope: List of integration scopes for the grant. + """ + + settings: NotRequired[Dict[str, Any]] + scope: NotRequired[List[str]] + + +class ListGrantsQueryParams(TypedDict): + """ + Interface representing the query parameters for listing grants. + + Attributes: + limit: The maximum number of objects to return. + This field defaults to 10. The maximum allowed value is 200. + offset: Offset grant results by this number. + sortBy: Sort entries by field name + orderBy: Specify ascending or descending order. + since: Scope grants from a specific point in time by Unix timestamp. + before: Scope grants to a specific point in time by Unix timestamp. + email: Filtering your query based on grant email address (if applicable) + grantStatus: Filtering your query based on grant email status (if applicable) + ip: Filtering your query based on grant IP address + provider: Filtering your query based on OAuth provider + """ + + limit: NotRequired[int] + offset: NotRequired[int] + sortBy: NotRequired[str] + orderBy: NotRequired[str] + since: NotRequired[int] + before: NotRequired[int] + email: NotRequired[str] + grantStatus: NotRequired[str] + ip: NotRequired[str] + provider: NotRequired[Provider] diff --git a/nylas/models/list_query_params.py b/nylas/models/list_query_params.py new file mode 100644 index 00000000..e865d9cc --- /dev/null +++ b/nylas/models/list_query_params.py @@ -0,0 +1,16 @@ +from typing_extensions import TypedDict, NotRequired + + +class ListQueryParams(TypedDict): + """ + Interface of the query parameters for listing resources. + + Attributes: + limit: The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token: An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. + """ + + limit: NotRequired[int] + page_token: NotRequired[str] diff --git a/nylas/models/messages.py b/nylas/models/messages.py new file mode 100644 index 00000000..bc7f5313 --- /dev/null +++ b/nylas/models/messages.py @@ -0,0 +1,213 @@ +from dataclasses import dataclass, field +from typing import List, Literal, Optional, Dict, Any +from dataclasses_json import dataclass_json, config +from typing_extensions import TypedDict, NotRequired, get_type_hints + +from nylas.models.attachments import Attachment +from nylas.models.list_query_params import ListQueryParams +from nylas.models.events import EmailName + + +Fields = Literal["standard", "include_headers"] +""" Literal representing which headers to include with a message. """ + + +@dataclass_json +@dataclass +class MessageHeader: + """ + A message header. + + Attributes: + name: The header name. + value: The header value. + """ + + name: str + value: str + + +@dataclass_json +@dataclass +class Message: + """ + A Message object. + + Attributes: + id: Globally unique object identifier. + grant_id: The grant that this message belongs to. + thread_id: The thread that this message belongs to. + subject: The subject of the message. + from_: The sender of the message. + object: The type of object. + to: The recipients of the message. + cc: The CC recipients of the message. + bcc: The BCC recipients of the message. + reply_to: The reply-to recipients of the message. + date: The date the message was received. + unread: Whether the message is unread. + starred: Whether the message is starred. + snippet: A snippet of the message body. + body: The body of the message. + attachments: The attachments on the message. + folders: The folders that the message is in. + headers: The headers of the message. + created_at: Unix timestamp of when the message was created. + """ + + grant_id: str + from_: List[EmailName] = field(metadata=config(field_name="from")) + object: str = "message" + id: Optional[str] = None + body: Optional[str] = None + thread_id: Optional[str] = None + subject: Optional[str] = None + snippet: Optional[str] = None + to: Optional[List[EmailName]] = None + bcc: Optional[List[EmailName]] = None + cc: Optional[List[EmailName]] = None + reply_to: Optional[List[EmailName]] = None + attachments: Optional[List[Attachment]] = None + folders: Optional[List[str]] = None + headers: Optional[List[MessageHeader]] = None + unread: Optional[bool] = None + starred: Optional[bool] = None + created_at: Optional[int] = None + date: Optional[int] = None + + +# Need to use Functional typed dicts because "from" and "in" are Python +# keywords, and can't be declared using the declarative syntax +ListMessagesQueryParams = TypedDict( + "ListMessagesQueryParams", + { + **get_type_hints(ListQueryParams), # Inherit fields from ListQueryParams + "subject": NotRequired[str], + "any_email": NotRequired[List[str]], + "from": NotRequired[List[str]], + "to": NotRequired[List[str]], + "cc": NotRequired[List[str]], + "bcc": NotRequired[List[str]], + "in": NotRequired[List[str]], + "unread": NotRequired[bool], + "starred": NotRequired[bool], + "thread_id": NotRequired[str], + "received_before": NotRequired[int], + "received_after": NotRequired[int], + "has_attachment": NotRequired[bool], + "fields": NotRequired[Fields], + "search_query_native": NotRequired[str], + }, +) +""" +Query parameters for listing messages. + +Attributes: + subject: Return messages with matching subject. + any_email: Return messages that have been sent or received by this comma-separated list of email addresses. + from: Return messages sent from this email address. + to: Return messages sent to this email address. + cc: Return messages cc'd to this email address. + bcc: Return messages bcc'd to this email address. + in: Return messages in this specific folder or label, specified by ID. + unread: Filter messages by unread status. + starred: Filter messages by starred status. + thread_id: Filter messages by thread_id. + received_before: Return messages with received dates before received_before. + received_after: Return messages with received dates after received_after. + has_attachment: Filter messages by whether they have an attachment. + fields: Specify "include_headers" to include headers in the response. "standard" is the default. + search_query_native: A native provider search query for Google or Microsoft. + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. +""" + + +class FindMessageQueryParams(TypedDict): + + """ + Query parameters for finding a message. + + Attributes: + fields: Specify "include_headers" to include headers in the response. "standard" is the default. + """ + + fields: NotRequired[Fields] + + +class UpdateMessageRequest(TypedDict): + + """ + Request payload for updating a message. + + Attributes: + starred: The message's starred status + unread: The message's unread status + folders: The message's folders + metadata: A list of key-value pairs storing additional data + """ + + unread: NotRequired[bool] + starred: NotRequired[bool] + folders: NotRequired[List[str]] + metadata: NotRequired[Dict[str, Any]] + + +@dataclass_json +@dataclass +class ScheduledMessageStatus: + """ + The status of a scheduled message. + + Attributes: + code: The status code the describes the state of the scheduled message. + description: A description of the status of the scheduled message. + """ + + code: str + description: str + + +@dataclass_json +@dataclass +class ScheduledMessage: + """ + A scheduled message. + + Attributes: + schedule_id: The unique identifier for the scheduled message. + status: The status of the scheduled message. + close_time: The time the message was sent or failed to send, in epoch time. + """ + + schedule_id: int + status: ScheduledMessageStatus + close_time: Optional[int] = None + + +@dataclass_json +@dataclass +class ScheduledMessagesList: + """ + A list of scheduled messages. + + Attributes: + schedules: The list of scheduled messages. + """ + + schedules: List[ScheduledMessage] + + +@dataclass_json +@dataclass +class StopScheduledMessageResponse: + """ + The response from stopping a scheduled message. + + Attributes: + message: A message describing the result of the request. + """ + + message: str diff --git a/nylas/models/redirect_uri.py b/nylas/models/redirect_uri.py new file mode 100644 index 00000000..21894027 --- /dev/null +++ b/nylas/models/redirect_uri.py @@ -0,0 +1,98 @@ +from dataclasses import dataclass +from typing import Optional + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + + +@dataclass_json +@dataclass +class RedirectUriSettings: + """ + Configuration settings for a Redirect URI object. + + Attributes: + origin: Related to JS platform. + bundle_id: Related to iOS platform. + app_store_id: Related to iOS platform. + team_id: Related to iOS platform. + package_name: Related to Android platform. + sha1_certificate_fingerprint: Related to Android platform. + """ + + origin: Optional[str] = None + bundle_id: Optional[str] = None + app_store_id: Optional[str] = None + team_id: Optional[str] = None + package_name: Optional[str] = None + sha1_certificate_fingerprint: Optional[str] = None + + +@dataclass_json +@dataclass +class RedirectUri: + """ + Class representing a Redirect URI object. + + Attributes: + id: Globally unique object identifier. + url: Redirect URL. + platform: Platform identifier. + settings: Configuration settings. + """ + + id: str + url: str + platform: str + settings: Optional[RedirectUriSettings] = None + + +class WritableRedirectUriSettings(TypedDict): + """ + Class representing redirect uri settings to be provided for a create/update call. + + Attributes: + origin: Optional origin for the redirect uri. + bundle_id: Optional bundle id for the redirect uri. + app_store_id: Optional app store id for the redirect uri. + team_id: Optional team id for the redirect uri. + package_name: Optional package name for the redirect uri. + sha1_certificate_fingerprint: Optional sha1 certificate fingerprint for the redirect uri. + """ + + origin: NotRequired[str] + bundle_id: NotRequired[str] + app_store_id: NotRequired[str] + team_id: NotRequired[str] + package_name: NotRequired[str] + sha1_certificate_fingerprint: NotRequired[str] + + +class CreateRedirectUriRequest(TypedDict): + """ + Class representing a request to create a redirect uri. + + Attributes: + url: Redirect URL. + platform: Platform identifier. + settings: Optional settings for the redirect uri. + """ + + url: str + platform: str + settings: NotRequired[WritableRedirectUriSettings] + + +class UpdateRedirectUriRequest(TypedDict): + """ + Class representing a request to update a redirect uri. + + Attributes: + url: Redirect URL. + platform: Platform identifier. + settings: Optional settings for the redirect uri. + """ + + url: NotRequired[str] + platform: NotRequired[str] + settings: NotRequired[WritableRedirectUriSettings] diff --git a/nylas/models/response.py b/nylas/models/response.py new file mode 100644 index 00000000..d00798c5 --- /dev/null +++ b/nylas/models/response.py @@ -0,0 +1,129 @@ +from dataclasses import dataclass +from typing import TypeVar, Generic, Optional, List + +from dataclasses_json import DataClassJsonMixin, dataclass_json + +T = TypeVar("T", bound=DataClassJsonMixin) + + +class Response(tuple, Generic[T]): + """ + Response object returned from the Nylas API. + + Attributes: + data: The requested data object. + request_id: The request ID. + """ + + data: T + request_id: str + + def __new__(cls, data: T, request_id: str): + """ + Initialize the response object. + + Args: + data: The requested data object. + request_id: The request ID. + """ + # Initialize the tuple for destructuring support + instance = super().__new__(cls, (data, request_id)) + + instance.data = data + instance.request_id = request_id + + return instance + + @classmethod + def from_dict(cls, resp: dict, generic_type): + """ + Convert a dictionary to a response object. + + Args: + resp: The dictionary to convert. + generic_type: The type to deserialize the data object into. + """ + + return cls( + data=generic_type.from_dict(resp["data"]), + request_id=resp["request_id"], + ) + + +class ListResponse(tuple, Generic[T]): + """ + List response object returned from the Nylas API. + + Attributes: + data: The list of requested data objects. + request_id: The request ID. + next_cursor: The cursor to use to get the next page of data. + """ + + data: List[T] + request_id: str + next_cursor: Optional[str] = None + + def __new__(cls, data: List[T], request_id: str, next_cursor: Optional[str] = None): + """ + Initialize the response object. + + Args: + data: The list of requested data objects. + request_id: The request ID. + next_cursor: The cursor to use to get the next page of data. + """ + # Initialize the tuple for destructuring support + instance = super().__new__(cls, (data, request_id, next_cursor)) + + instance.data = data + instance.request_id = request_id + instance.next_cursor = next_cursor + + return instance + + @classmethod + def from_dict(cls, resp: dict, generic_type): + """ + Convert a dictionary to a response object. + + Args: + resp: The dictionary to convert. + generic_type: The type to deserialize the data objects into. + """ + + converted_data = [] + for item in resp["data"]: + converted_data.append(generic_type.from_dict(item)) + + return cls( + data=converted_data, + request_id=resp["request_id"], + next_cursor=resp.get("next_cursor", None), + ) + + +@dataclass_json +@dataclass +class DeleteResponse: + """ + Delete response object returned from the Nylas API. + + Attributes: + request_id: The request ID returned from the API. + """ + + request_id: str + + +@dataclass_json +@dataclass +class RequestIdOnlyResponse: + """ + Response object returned from the Nylas API that only contains a request ID. + + Attributes: + request_id: The request ID returned from the API. + """ + + request_id: str diff --git a/nylas/models/smart_compose.py b/nylas/models/smart_compose.py new file mode 100644 index 00000000..05fa2a3a --- /dev/null +++ b/nylas/models/smart_compose.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from typing import TypedDict + +from dataclasses_json import dataclass_json + + +class ComposeMessageRequest(TypedDict): + """ + A request to compose a message. + + Attributes: + prompt: The prompt that smart compose will use to generate a message suggestion. + """ + + prompt: str + + +@dataclass_json +@dataclass +class ComposeMessageResponse: + """ + A response from composing a message. + + Attributes: + suggestion: The message suggestion generated by smart compose. + """ + + suggestion: str diff --git a/nylas/models/threads.py b/nylas/models/threads.py new file mode 100644 index 00000000..1485d3b0 --- /dev/null +++ b/nylas/models/threads.py @@ -0,0 +1,143 @@ +from dataclasses import dataclass, field +from typing import List, Optional, get_type_hints, Union +from typing_extensions import TypedDict, NotRequired + +from dataclasses_json import dataclass_json, config + +from nylas.models.drafts import Draft +from nylas.models.events import EmailName +from nylas.models.list_query_params import ListQueryParams + +from nylas.models.messages import Message + + +def _decode_draft_or_message(json: dict) -> Union[Message, Draft]: + """ + Decode a message/draft object into a python object. + + Args: + json: The message/draft object to decode. + + Returns: + The decoded message/draft object. + """ + if "object" not in json: + raise ValueError("Invalid when object, no 'object' field found.") + + if json["object"] == "draft": + return Draft.from_dict(json) + + if json["object"] == "message": + return Message.from_dict(json) + + raise ValueError(f"Invalid object, unknown 'object' field found: {json['object']}") + + +@dataclass_json +@dataclass +class Thread: + """ + A Thread object. + + Attributes: + id: Globally unique object identifier. + grant_id: The grant that this thread belongs to. + latest_draft_or_message: The latest draft or message in the thread. + has_attachment: Whether the thread has an attachment. + has_drafts: Whether the thread has drafts. + starred: A boolean indicating whether the thread is starred or not + unread: A boolean indicating whether the thread is read or not. + earliest_message_date: Unix timestamp of the earliest or first message in the thread. + latest_message_received_date: Unix timestamp of the most recent message received in the thread. + latest_message_sent_date: Unix timestamp of the most recent message sent in the thread. + participant: An array of participants in the thread. + message_ids: An array of message IDs in the thread. + draft_ids: An array of draft IDs in the thread. + folders: An array of folder IDs the thread appears in. + object: The type of object. + snippet: A short snippet of the last received message/draft body. + This is the first 100 characters of the message body, with any HTML tags removed. + subject: The subject of the thread. + """ + + id: str + grant_id: str + has_drafts: bool + starred: bool + unread: bool + earliest_message_date: int + message_ids: List[str] + folders: List[str] + latest_draft_or_message: Union[Message, Draft] = field( + metadata=config(decoder=_decode_draft_or_message) + ) + object: str = "thread" + latest_message_received_date: Optional[int] = None + draft_ids: Optional[List[str]] = None + snippet: Optional[str] = None + subject: Optional[str] = None + participants: Optional[List[EmailName]] = None + latest_message_sent_date: Optional[int] = None + has_attachments: Optional[bool] = None + + +class UpdateThreadRequest(TypedDict): + """ + A request to update a thread. + + Attributes: + starred: Sets all messages in the thread as starred or unstarred. + unread: Sets all messages in the thread as read or unread. + folders: The IDs of the folders to apply, overwriting all previous folders for all messages in the thread. + """ + + starred: NotRequired[bool] + unread: NotRequired[bool] + folders: NotRequired[List[str]] + + +# Need to use Functional typed dicts because "from" and "in" are Python +# keywords, and can't be declared using the declarative syntax +ListThreadsQueryParams = TypedDict( + "ListThreadsQueryParams", + { + **get_type_hints(ListQueryParams), # Inherit fields from ListQueryParams + "subject": NotRequired[str], + "any_email": NotRequired[str], + "from": NotRequired[str], + "to": NotRequired[str], + "cc": NotRequired[str], + "bcc": NotRequired[str], + "in": NotRequired[str], + "unread": NotRequired[bool], + "starred": NotRequired[bool], + "thread_id": NotRequired[str], + "latest_message_before": NotRequired[int], + "latest_message_after": NotRequired[int], + "has_attachment": NotRequired[bool], + "search_query_native": NotRequired[str], + }, +) +""" +Query parameters for listing threads. + +Attributes: + subject: Return threads with matching subject. + any_email: Return threads that have been sent or received by this comma-separated list of email addresses. + from: Return threads sent from this email address. + to: Return threads sent to this email address. + cc: Return threads cc'd to this email address. + bcc: Return threads bcc'd to this email address. + in: Return threads in this specific folder or label, specified by ID. + unread: Filter threads by unread status. + starred: Filter threads by starred status. + thread_id: Filter threads by thread_id. + latest_message_before: Return threads whose most recent message was received before this Unix timestamp. + latest_message_after: Return threads whose most recent message was received after this Unix timestamp. + has_attachment: Filter threads by whether they have an attachment. + search_query_native: A native provider search query for Google or Microsoft. + limit (NotRequired[int]): The maximum number of objects to return. + This field defaults to 50. The maximum allowed value is 200. + page_token (NotRequired[str]): An identifier that specifies which page of data to return. + This value should be taken from a ListResponse object's next_cursor parameter. +""" diff --git a/nylas/models/webhooks.py b/nylas/models/webhooks.py new file mode 100644 index 00000000..9ff6e3b0 --- /dev/null +++ b/nylas/models/webhooks.py @@ -0,0 +1,143 @@ +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional, Literal + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + +WebhookStatus = Literal["active", "failing", "failed", "pause"] +""" Literals representing the possible webhook statuses. """ + + +class WebhookTriggers(str, Enum): + """Enum representing the available webhook triggers.""" + + CALENDAR_CREATED = "calendar.created" + CALENDAR_UPDATED = "calendar.updated" + CALENDAR_DELETED = "calendar.deleted" + EVENT_CREATED = "event.created" + EVENT_UPDATED = "event.updated" + EVENT_DELETED = "event.deleted" + GRANT_CREATED = "grant.created" + GRANT_UPDATED = "grant.updated" + GRANT_DELETED = "grant.deleted" + GRANT_EXPIRED = "grant.expired" + MESSAGE_SEND_SUCCESS = "message.send_success" + MESSAGE_SEND_FAILED = "message.send_failed" + + +@dataclass_json +@dataclass +class Webhook: + """ + Class representing a Nylas webhook. + + Attributes: + id: Globally unique object identifier. + trigger_types: The event that triggers the webhook. + webhook_url: The URL to send webhooks to. + status: The status of the new destination. + notification_email_addresses: The email addresses that Nylas notifies when a webhook is down for a while. + status_updated_at: The time when the status field was last updated, represented as a Unix timestamp in seconds. + created_at: The time when the status field was created, represented as a Unix timestamp in seconds. + updated_at: The time when the status field was last updated, represented as a Unix timestamp in seconds. + description: A human-readable description of the webhook destination. + """ + + id: str + trigger_types: List[WebhookTriggers] + webhook_url: str + status: WebhookStatus + notification_email_addresses: List[str] + status_updated_at: int + created_at: int + updated_at: int + description: Optional[str] = None + + +class WebhookWithSecret(Webhook): + """ + Class representing a Nylas webhook with secret. + + Attributes: + webhook_secret: A secret value used to encode the X-Nylas-Signature header on webhook requests. + """ + + webhook_secret: str + + +@dataclass_json +@dataclass +class WebhookDeleteData: + """ + Class representing the object enclosing the webhook deletion status. + + Attributes: + status: The status of the webhook deletion. + """ + + status: str + + +@dataclass_json +@dataclass +class WebhookDeleteResponse: + """ + Class representing a Nylas webhook delete response. + + Attributes: + request_id: The request's ID. + data: Object containing the webhook deletion status. + """ + + request_id: str + data: Optional[WebhookDeleteData] = None + + +@dataclass_json +@dataclass +class WebhookIpAddressesResponse: + """ + Class representing the response for getting a list of webhook IP addresses. + + Attributes: + ip_addresses: The IP addresses that Nylas send your webhook from. + updated_at: Unix timestamp representing the time when Nylas last updated the list of IP addresses. + """ + + ip_addresses: List[str] + updated_at: int + + +class CreateWebhookRequest(TypedDict): + """ + Class representation of a Nylas create webhook request. + + Attributes: + trigger_types: List of events that triggers the webhook. + webhook_url: The url to send webhooks to. + description: A human-readable description of the webhook destination. + notification_email_addresses: The email addresses that Nylas notifies when a webhook is down for a while. + """ + + trigger_types: List[WebhookTriggers] + webhook_url: str + description: NotRequired[str] + notification_email_addresses: NotRequired[List[str]] + + +class UpdateWebhookRequest(TypedDict): + """ + Class representation of a Nylas update webhook request. + + Attributes: + trigger_types: List of events that triggers the webhook. + webhook_url: The url to send webhooks to. + description: A human-readable description of the webhook destination. + notification_email_addresses: The email addresses that Nylas notifies when a webhook is down for a while. + """ + + trigger_types: NotRequired[List[WebhookTriggers]] + webhook_url: NotRequired[str] + description: NotRequired[str] + notification_email_addresses: NotRequired[List[str]] diff --git a/nylas/resources/__init__.py b/nylas/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nylas/resources/applications.py b/nylas/resources/applications.py new file mode 100644 index 00000000..7f127276 --- /dev/null +++ b/nylas/resources/applications.py @@ -0,0 +1,36 @@ +from nylas.models.application_details import ApplicationDetails +from nylas.models.response import Response +from nylas.resources.redirect_uris import RedirectUris +from nylas.resources.resource import Resource + + +class Applications(Resource): + """ + Nylas Applications API + + The Nylas Applications API allows you to get information about your Nylas application. + You can also manage the redirect URIs associated with your application. + """ + + @property + def redirect_uris(self) -> RedirectUris: + """ + Manage Redirect URIs for your Nylas Application. + + Returns: + RedirectUris: The redirect URIs associated with your Nylas Application. + """ + return RedirectUris(self._http_client) + + def info(self) -> Response[ApplicationDetails]: + """ + Get the application information. + + Returns: + Response: The application information. + """ + + json_response = self._http_client._execute( + method="GET", path="/v3/applications" + ) + return Response.from_dict(json_response, ApplicationDetails) diff --git a/nylas/resources/attachments.py b/nylas/resources/attachments.py new file mode 100644 index 00000000..034307fb --- /dev/null +++ b/nylas/resources/attachments.py @@ -0,0 +1,104 @@ +from requests import Response + +from nylas.handler.api_resources import ( + FindableApiResource, +) +from nylas.models.attachments import Attachment, FindAttachmentQueryParams +from nylas.models.response import Response as NylasResponse + + +class Attachments( + FindableApiResource, +): + """ + Nylas Attachments API + + The Nylas Attachments API allows you to get metadata ot, and download attachments from messages. + """ + + def find( + self, + identifier: str, + attachment_id: str, + query_params: FindAttachmentQueryParams, + ) -> NylasResponse[Attachment]: + """ + Return metadata of an attachment. + + Args: + identifier: The identifier of the Grant to act upon. + attachment_id: The id of the attachment to retrieve. + query_params: The query parameters to include in the request. + + Returns: + The attachment metadata. + """ + return super().find( + path=f"/v3/grants/{identifier}/attachments/{attachment_id}", + response_type=Attachment, + query_params=query_params, + ) + + def download( + self, + identifier: str, + attachment_id: str, + query_params: FindAttachmentQueryParams, + ) -> Response: + """ + Download the attachment data. + + This function returns a raw response object to allow you the ability + to stream the file contents. The response object should be closed + after use to ensure the connection is closed. + + Args: + identifier: The identifier of the Grant to act upon. + attachment_id: The id of the attachment to download. + query_params: The query parameters to include in the request. + + Returns: + The Response object containing the file data. + + Example: + Here is an example of how to use this function when streaming: + + ```python + response = execute_request_raw_response(url, method, stream=True) + try: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + # Process each chunk + pass + finally: + response.close() # Ensure the response is closed + ``` + """ + return self._http_client._execute_download_request( + path=f"/v3/grants/{identifier}/attachments/{attachment_id}/download", + query_params=query_params, + stream=True, + ) + + def download_bytes( + self, + identifier: str, + attachment_id: str, + query_params: FindAttachmentQueryParams, + ) -> bytes: + """ + Download the attachment as a byte array. + + Args: + identifier: The identifier of the Grant to act upon. + attachment_id: The id of the attachment to download. + query_params: The query parameters to include in the request. + + Returns: + The raw file data. + """ + return self._http_client._execute_download_request( + path=f"/v3/grants/{identifier}/attachments/{attachment_id}/download", + query_params=query_params, + stream=False, + ) diff --git a/nylas/resources/auth.py b/nylas/resources/auth.py new file mode 100644 index 00000000..f5a55191 --- /dev/null +++ b/nylas/resources/auth.py @@ -0,0 +1,258 @@ +import base64 +import hashlib +import uuid + +from nylas.handler.http_client import _build_query_params +from nylas.models.grants import CreateGrantRequest, Grant + +from nylas.models.auth import ( + CodeExchangeResponse, + PkceAuthUrl, + TokenInfoResponse, + CodeExchangeRequest, + TokenExchangeRequest, + ProviderDetectResponse, + ProviderDetectParams, + URLForAuthenticationConfig, + URLForAdminConsentConfig, +) +from nylas.models.response import Response +from nylas.resources.resource import Resource + + +def _hash_pkce_secret(secret: str) -> str: + sha256_hash = hashlib.sha256(secret.encode()).hexdigest() + return base64.b64encode(sha256_hash.encode()).decode().rstrip("=") + + +def _build_query(config: dict) -> dict: + config["response_type"] = "code" + + if "access_type" not in config: + config["access_type"] = "online" + + if "scope" in config: + config["scope"] = " ".join(config["scope"]) + + return config + + +def _build_query_with_pkce(config: dict, secret_hash: str) -> dict: + params = _build_query(config) + + params["code_challenge"] = secret_hash + params["code_challenge_method"] = "s256" + + return params + + +def _build_query_with_admin_consent(config: dict) -> dict: + params = _build_query(config) + + params["response_type"] = "adminconsent" + + if "credential_id" in config: + params["credential_id"] = config["credential_id"] + + return params + + +class Auth(Resource): + """ + A collection of authentication related API endpoints + + These endpoints allow for various functionality related to authentication. + """ + + def url_for_oauth2(self, config: URLForAuthenticationConfig) -> str: + """ + Build the URL for authenticating users to your application via Hosted Authentication. + + Args: + config: The configuration for building the URL. + + Returns: + The URL for hosted authentication. + """ + query = _build_query(config) + + return self._url_auth_builder(query) + + def exchange_code_for_token( + self, request: CodeExchangeRequest + ) -> CodeExchangeResponse: + """ + Exchange an authorization code for an access token. + + Args: + request: The request parameters for the code exchange + + Returns: + Information about the Nylas application + """ + if "client_secret" not in request: + request["client_secret"] = self._http_client.api_key + + request_body = dict(request) + request_body["grant_type"] = "authorization_code" + + return self._get_token(request_body) + + def custom_authentication( + self, request_body: CreateGrantRequest + ) -> Response[Grant]: + """ + Create a Grant via Custom Authentication. + + Args: + request_body: The values to create the Grant with. + + Returns: + The created Grant. + """ + + json_response = self._http_client._execute( + method="POST", + path="/v3/connect/custom", + request_body=request_body, + ) + return Response.from_dict(json_response, Grant) + + def refresh_access_token( + self, request: TokenExchangeRequest + ) -> CodeExchangeResponse: + """ + Refresh an access token. + + Args: + request: The refresh token request. + + Returns: + The response containing the new access token. + """ + if "client_secret" not in request: + request["client_secret"] = self._http_client.api_key + + request_body = dict(request) + request_body["grant_type"] = "refresh_token" + + return self._get_token(request_body) + + def id_token_info(self, id_token: str) -> TokenInfoResponse: + """ + Get info about an ID token. + + Args: + id_token: The ID token to query. + + Returns: + The API response with the token information. + """ + + query_params = { + "id_token": id_token, + } + + return self._get_token_info(query_params) + + def validate_access_token(self, access_token: str) -> TokenInfoResponse: + """ + Get info about an access token. + + Args: + access_token: The access token to query. + + Returns: + The API response with the token information. + """ + + query_params = { + "access_token": access_token, + } + + return self._get_token_info(query_params) + + def url_for_oauth2_pkce(self, config: URLForAuthenticationConfig) -> PkceAuthUrl: + """ + Build the URL for authenticating users to your application via Hosted Authentication with PKCE. + + IMPORTANT: YOU WILL NEED TO STORE THE 'secret' returned to use it inside the CodeExchange flow + + Args: + config: The configuration for the authentication request. + + Returns: + The URL for hosted authentication with secret & hashed secret. + """ + secret = str(uuid.uuid4()) + secret_hash = _hash_pkce_secret(secret) + query = _build_query_with_pkce(config, secret_hash) + + return PkceAuthUrl(secret, secret_hash, self._url_auth_builder(query)) + + def url_for_admin_consent(self, config: URLForAdminConsentConfig) -> str: + """Build the URL for admin consent authentication for Microsoft. + + Args: + config: The configuration for the authentication request. + + Returns: + The URL for hosted authentication. + """ + config_with_provider = {"provider": "microsoft", **config} + query = _build_query_with_admin_consent(config_with_provider) + + return self._url_auth_builder(query) + + def revoke(self, token: str) -> True: + """Revoke a single access token. + + Args: + token: The access token to revoke. + + Returns: + True: If the token was revoked successfully. + """ + self._http_client._execute( + method="POST", + path="/v3/connect/revoke", + query_params={"token": token}, + ) + + return True + + def detect_provider( + self, params: ProviderDetectParams + ) -> Response[ProviderDetectResponse]: + """ + Detect provider from email address. + + Args: + params: The parameters to include in the request + + Returns: + The detected provider, if found. + """ + + json_response = self._http_client._execute( + method="POST", + path="/v3/providers/detect", + query_params=params, + ) + return Response.from_dict(json_response, ProviderDetectResponse) + + def _url_auth_builder(self, query: dict) -> str: + base = f"{self._http_client.api_server}/v3/connect/auth" + return _build_query_params(base, query) + + def _get_token(self, request_body: dict) -> CodeExchangeResponse: + json_response = self._http_client._execute( + method="POST", path="/v3/connect/token", request_body=request_body + ) + return CodeExchangeResponse.from_dict(json_response) + + def _get_token_info(self, query_params: dict) -> TokenInfoResponse: + json_response = self._http_client._execute( + method="GET", path="/v3/connect/tokeninfo", query_params=query_params + ) + return TokenInfoResponse.from_dict(json_response) diff --git a/nylas/resources/calendars.py b/nylas/resources/calendars.py new file mode 100644 index 00000000..af276614 --- /dev/null +++ b/nylas/resources/calendars.py @@ -0,0 +1,165 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.availability import GetAvailabilityResponse, GetAvailabilityRequest +from nylas.models.free_busy import GetFreeBusyResponse, GetFreeBusyRequest +from nylas.models.calendars import ( + Calendar, + CreateCalendarRequest, + UpdateCalendarRequest, + ListCalendarsQueryParams, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class Calendars( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Calendar API + + The Nylas calendar API allows you to create new calendars or manage existing ones, as well as getting + free/busy information for a calendar and getting availability for a calendar. + + A calendar can be accessed by one, or several people, and can contain events. + """ + + def list( + self, identifier: str, query_params: ListCalendarsQueryParams = None + ) -> ListResponse[Calendar]: + """ + Return all Calendars. + + Args: + identifier: The identifier of the Grant to act upon. + query_params: The query parameters to include in the request. + + Returns: + The list of Calendars. + """ + + return super().list( + path=f"/v3/grants/{identifier}/calendars", + query_params=query_params, + response_type=Calendar, + ) + + def find(self, identifier: str, calendar_id: str) -> Response[Calendar]: + """ + Return a Calendar. + + Args: + identifier: The identifier of the Grant to act upon. + calendar_id: The ID of the Calendar to retrieve. + Use "primary" to refer to the primary Calendar associated with the Grant. + + Returns: + The Calendar. + """ + return super().find( + path=f"/v3/grants/{identifier}/calendars/{calendar_id}", + response_type=Calendar, + ) + + def create( + self, identifier: str, request_body: CreateCalendarRequest + ) -> Response[Calendar]: + """ + Create a Calendar. + + Args: + identifier: The identifier of the Grant to act upon. + request_body: The values to create the Calendar with. + + Returns: + The created Calendar. + """ + return super().create( + path=f"/v3/grants/{identifier}/calendars", + response_type=Calendar, + request_body=request_body, + ) + + def update( + self, identifier: str, calendar_id: str, request_body: UpdateCalendarRequest + ) -> Response[Calendar]: + """ + Update a Calendar. + + Args: + identifier: The identifier of the Grant to act upon. + calendar_id: The ID of the Calendar to update. + Use "primary" to refer to the primary Calendar associated with the Grant. + request_body: The values to update the Calendar with. + + Returns: + The updated Calendar. + """ + return super().update( + path=f"/v3/grants/{identifier}/calendars/{calendar_id}", + response_type=Calendar, + request_body=request_body, + ) + + def destroy(self, identifier: str, calendar_id: str) -> DeleteResponse: + """ + Delete a Calendar. + + Args: + identifier: The identifier of the Grant to act upon. + calendar_id: The ID of the Calendar to delete. + Use "primary" to refer to the primary Calendar associated with the Grant. + + Returns: + The deletion response. + """ + return super().destroy(path=f"/v3/grants/{identifier}/calendars/{calendar_id}") + + def get_availability( + self, request_body: GetAvailabilityRequest + ) -> Response[GetAvailabilityResponse]: + """ + Get availability for a Calendar. + + Args: + request_body: The request body to send to the API. + + Returns: + Response: The availability response from the API. + """ + json_response = self._http_client._execute( + method="POST", + path="/v3/calendars/availability", + request_body=request_body, + ) + + return Response.from_dict(json_response, GetAvailabilityResponse) + + def get_free_busy( + self, identifier: str, request_body: GetFreeBusyRequest + ) -> Response[GetFreeBusyResponse]: + """ + Get free/busy info for a Calendar. + + Args: + identifier: The grant ID or email account to get free/busy for. + request_body: The request body to send to the API. + + Returns: + Response: The free/busy response from the API. + """ + json_response = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/calendars/free-busy", + request_body=request_body, + ) + + return Response.from_dict(json_response, GetFreeBusyResponse) diff --git a/nylas/resources/connectors.py b/nylas/resources/connectors.py new file mode 100644 index 00000000..72bacc70 --- /dev/null +++ b/nylas/resources/connectors.py @@ -0,0 +1,123 @@ +from nylas.resources.credentials import Credentials + +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.auth import Provider +from nylas.models.connectors import ( + ListConnectorQueryParams, + Connector, + CreateConnectorRequest, + UpdateConnectorRequest, +) +from nylas.models.response import ListResponse, Response, DeleteResponse + + +class Connectors( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Connectors API + + The Nylas Connectors API allows you to create new connectors or manage existing ones. + In Nylas, a connector (formerly called an "integration") stores information that allows your Nylas application + to connect to a third party services + """ + + @property + def credentials(self) -> Credentials: + """ + Access the Credentials API. + + Returns: + The Credentials API. + """ + return Credentials(self._http_client) + + def list( + self, query_params: ListConnectorQueryParams = None + ) -> ListResponse[Connector]: + """ + Return all Connectors. + + Args: + query_params: The query parameters to include in the request. + + Returns: + The list of Connectors. + """ + + return super().list( + path="/v3/connectors", response_type=Connector, query_params=query_params + ) + + def find(self, provider: Provider) -> Response[Connector]: + """ + Return a connector associated with the provider. + + Args: + provider: The provider associated to the connector to retrieve. + + Returns: + The Connector. + """ + return super().find( + path=f"/v3/connectors/{provider}", + response_type=Connector, + ) + + def create(self, request_body: CreateConnectorRequest) -> Response[Connector]: + """ + Create a connector. + + Args: + request_body: The values to create the connector with. + + Returns: + The created connector. + """ + return super().create( + path="/v3/connectors", + request_body=request_body, + response_type=Connector, + ) + + def update( + self, provider: Provider, request_body: UpdateConnectorRequest + ) -> Response[Connector]: + """ + Create a connector. + + Args: + provider: The provider associated to the connector to update. + request_body: The values to update the connector with. + + Returns: + The created connector. + """ + return super().update( + path=f"/v3/connectors/{provider}", + request_body=request_body, + response_type=Connector, + method="PATCH", + ) + + def destroy(self, provider: Provider) -> DeleteResponse: + """ + Delete a connector. + + Args: + provider: The provider associated to the connector to delete. + + Returns: + The deleted connector. + """ + return super().destroy(path=f"/v3/connectors/{provider}") diff --git a/nylas/resources/contacts.py b/nylas/resources/contacts.py new file mode 100644 index 00000000..6e11411e --- /dev/null +++ b/nylas/resources/contacts.py @@ -0,0 +1,149 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.contacts import ( + Contact, + CreateContactRequest, + UpdateContactRequest, + ListContactsQueryParams, + FindContactQueryParams, + ListContactGroupsQueryParams, + ContactGroup, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class Contacts( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Contacts API + + The Contacts API allows you to manage contacts and contact groups for a user. + """ + + def list( + self, identifier: str, query_params: ListContactsQueryParams = None + ) -> ListResponse[Contact]: + """ + Return all Contacts. + + Attributes: + identifier: The identifier of the Grant to act upon. + query_params: The query parameters to include in the request. + + Returns: + The list of contacts. + """ + + return super().list( + path=f"/v3/grants/{identifier}/contacts", + query_params=query_params, + response_type=Contact, + ) + + def find( + self, + identifier: str, + contact_id: str, + query_params: FindContactQueryParams = None, + ) -> Response[Contact]: + """ + Return a Contact. + + Attributes: + identifier: The identifier of the Grant to act upon. + contact_id: The ID of the contact to retrieve. + query_params: The query parameters to include in the request. + + Returns: + The contact. + """ + return super().find( + path=f"/v3/grants/{identifier}/contacts/{contact_id}", + response_type=Contact, + query_params=query_params, + ) + + def create( + self, identifier: str, request_body: CreateContactRequest + ) -> Response[Contact]: + """ + Create a Contact. + + Attributes: + identifier: The identifier of the Grant to act upon. + request_body: The values to create the Contact with. + + Returns: + The created contact. + """ + return super().create( + path=f"/v3/grants/{identifier}/contacts", + response_type=Contact, + request_body=request_body, + ) + + def update( + self, identifier: str, contact_id: str, request_body: UpdateContactRequest + ) -> Response[Contact]: + """ + Update a Contact. + + Attributes: + identifier: The identifier of the Grant to act upon. + contact_id: The ID of the Contact to update. + Use "primary" to refer to the primary Contact associated with the Grant. + request_body: The values to update the Contact with. + + Returns: + The updated contact. + """ + return super().update( + path=f"/v3/grants/{identifier}/contacts/{contact_id}", + response_type=Contact, + request_body=request_body, + ) + + def destroy(self, identifier: str, contact_id: str) -> DeleteResponse: + """ + Delete a Contact. + + Attributes: + identifier: The identifier of the Grant to act upon. + contact_id: The ID of the Contact to delete. + Use "primary" to refer to the primary Contact associated with the Grant. + + Returns: + The deletion response. + """ + return super().destroy(path=f"/v3/grants/{identifier}/contacts/{contact_id}") + + def list_groups( + self, identifier: str, query_params: ListContactGroupsQueryParams = None + ) -> ListResponse[ContactGroup]: + """ + Return all contact groups. + + Attributes: + identifier: The identifier of the Grant to act upon. + query_params: The query parameters to include in the request. + + Returns: + The list of contact groups. + """ + json_response = self._http_client._execute( + method="GET", + path=f"/v3/grants/{identifier}/contacts/groups", + query_params=query_params, + ) + + return ListResponse.from_dict(json_response, ContactGroup) diff --git a/nylas/resources/credentials.py b/nylas/resources/credentials.py new file mode 100644 index 00000000..52e6797a --- /dev/null +++ b/nylas/resources/credentials.py @@ -0,0 +1,127 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.auth import Provider +from nylas.models.credentials import ( + Credential, + CredentialRequest, + ListCredentialQueryParams, + UpdateCredentialRequest, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class Credentials( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Credentials API + + + A Nylas connector credential is a special type of record that securely stores information + that allows you to connect using an administrator account + """ + + def list( + self, provider: Provider, query_params: ListCredentialQueryParams = None + ) -> ListResponse[Credential]: + """ + Return all credentials for a particular provider. + + Args: + provider: The provider. + query_params: The query parameters to include in the request. + + Returns: + The list of credentials. + """ + + return super().list( + path=f"/v3/connectors/{provider}/creds", + response_type=Credential, + query_params=query_params, + ) + + def find(self, provider: Provider, credential_id: str) -> Response[Credential]: + """ + Return a credential. + + Args: + provider: The provider of the credential. + credential_id: The ID of the credential to retrieve. + + Returns: + The Credential. + """ + + return super().find( + path=f"/v3/connectors/{provider}/creds/{credential_id}", + response_type=Credential, + ) + + def create( + self, provider: Provider, request_body: CredentialRequest + ) -> Response[Credential]: + """ + Create a credential for a particular provider. + + Args: + provider: The provider. + request_body: The values to create the Credential with. + + Returns: + The created Credential. + """ + + return super().create( + path=f"/v3/connectors/{provider}/creds", + response_type=Credential, + request_body=request_body, + ) + + def update( + self, + provider: Provider, + credential_id: str, + request_body: UpdateCredentialRequest, + ) -> Response[Credential]: + """ + Update a credential. + + Args: + provider: The provider. + credential_id: The ID of the credential to update. + request_body: The values to update the credential with. + + Returns: + The updated credential. + """ + + return super().update( + path=f"/v3/connectors/{provider}/creds/{credential_id}", + response_type=Credential, + request_body=request_body, + method="PATCH", + ) + + def destroy(self, provider: Provider, credential_id: str) -> DeleteResponse: + """ + Delete a credential. + + Args: + provider: the provider for the grant + credential_id: The ID of the credential to delete. + + Returns: + The deletion response. + """ + + return super().destroy(path=f"/v3/connectors/{provider}/creds/{credential_id}") diff --git a/nylas/resources/drafts.py b/nylas/resources/drafts.py new file mode 100644 index 00000000..439d581d --- /dev/null +++ b/nylas/resources/drafts.py @@ -0,0 +1,147 @@ +from typing import Optional + +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, + CreatableApiResource, +) +from nylas.models.drafts import ( + ListDraftsQueryParams, + Draft, + UpdateDraftRequest, + CreateDraftRequest, +) +from nylas.models.messages import Message +from nylas.models.response import ListResponse, Response, DeleteResponse +from nylas.utils.file_utils import _build_form_request + + +class Drafts( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Draft API + + The Drafts API allows you to create, read, update, and delete drafts and send them as messages. + """ + + def list( + self, identifier: str, query_params: Optional[ListDraftsQueryParams] = None + ) -> ListResponse[Draft]: + """ + Return all Drafts. + + Args: + identifier: The identifier of the grant to get drafts for. + query_params: The query parameters to filter drafts by. + + Returns: + A list of Drafts. + """ + return super().list( + path=f"/v3/grants/{identifier}/drafts", + response_type=Draft, + query_params=query_params, + ) + + def find( + self, + identifier: str, + draft_id: str, + ) -> Response[Draft]: + """ + Return a Draft. + + Args: + identifier: The identifier of the grant to get the draft for. + draft_id: The identifier of the draft to get. + + Returns: + The requested Draft. + """ + return super().find( + path=f"/v3/grants/{identifier}/drafts/{draft_id}", + response_type=Draft, + ) + + def create( + self, identifier: str, request_body: CreateDraftRequest + ) -> Response[Draft]: + """ + Create a Draft. + + Args: + identifier: The identifier of the grant to send the message for. + request_body: The request body to create a draft with. + + Returns: + The newly created Draft. + """ + json_response = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/drafts", + data=_build_form_request(request_body), + ) + + return Response.from_dict(json_response, Draft) + + def update( + self, + identifier: str, + draft_id: str, + request_body: UpdateDraftRequest, + ) -> Response[Draft]: + """ + Update a Draft. + + Args: + identifier: The identifier of the grant to update the draft for. + draft_id: The identifier of the draft to update. + request_body: The request body to update the draft with. + + Returns: + The updated Draft. + """ + json_response = self._http_client._execute( + method="PUT", + path=f"/v3/grants/{identifier}/drafts/{draft_id}", + data=_build_form_request(request_body), + ) + + return Response.from_dict(json_response, Draft) + + def destroy(self, identifier: str, draft_id: str) -> DeleteResponse: + """ + Delete a Draft. + + Args: + identifier: The identifier of the grant to delete the draft for. + draft_id: The identifier of the draft to delete. + + Returns: + The deletion response. + """ + return super().destroy( + path=f"/v3/grants/{identifier}/drafts/{draft_id}", + ) + + def send(self, identifier: str, draft_id: str) -> Response[Message]: + """ + Send a Draft. + + Args: + identifier: The identifier of the grant to send the draft for. + draft_id: The identifier of the draft to send. + """ + json_response = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/drafts/{draft_id}", + ) + + return Response.from_dict(json_response, Message) diff --git a/nylas/resources/events.py b/nylas/resources/events.py new file mode 100644 index 00000000..fe23650d --- /dev/null +++ b/nylas/resources/events.py @@ -0,0 +1,179 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.events import ( + Event, + UpdateEventRequest, + CreateEventRequest, + FindEventQueryParams, + ListEventQueryParams, + CreateEventQueryParams, + UpdateEventQueryParams, + DestroyEventQueryParams, + SendRsvpQueryParams, + SendRsvpRequest, +) +from nylas.models.response import ( + Response, + ListResponse, + DeleteResponse, + RequestIdOnlyResponse, +) + + +class Events( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Events API + + The Events API allows you to find, create, update, and delete events on any calendar on your Nylas account. + """ + + def list( + self, identifier: str, query_params: ListEventQueryParams + ) -> ListResponse[Event]: + """ + Return all Events. + + Args: + identifier: The identifier of the Grant to act upon. + query_params: The query parameters to include in the request. + + Returns: + The list of Events. + """ + + return super().list( + path=f"/v3/grants/{identifier}/events", + response_type=Event, + query_params=query_params, + ) + + def find( + self, identifier: str, event_id: str, query_params: FindEventQueryParams + ) -> Response[Event]: + """ + Return an Event. + + Args: + identifier: The identifier of the Grant to act upon. + event_id: The ID of the Event to retrieve. + query_params: The query parameters to include in the request. + + Returns: + The Event. + """ + + return super().find( + path=f"/v3/grants/{identifier}/events/{event_id}", + response_type=Event, + query_params=query_params, + ) + + def create( + self, + identifier: str, + request_body: CreateEventRequest, + query_params: CreateEventQueryParams, + ) -> Response[Event]: + """ + Create an Event. + + Args: + identifier: The identifier of the Grant to act upon. + request_body: The values to create the Event with. + query_params: The query parameters to include in the request. + + Returns: + The created Event. + """ + + return super().create( + path=f"/v3/grants/{identifier}/events", + response_type=Event, + request_body=request_body, + query_params=query_params, + ) + + def update( + self, + identifier: str, + event_id: str, + request_body: UpdateEventRequest, + query_params: UpdateEventQueryParams, + ) -> Response[Event]: + """ + Update an Event. + + Args: + identifier: The identifier of the Grant to act upon. + event_id: The ID of the Event to update. + request_body: The values to update the Event with. + query_params: The query parameters to include in the request. + + Returns: + The updated Event. + """ + + return super().update( + path=f"/v3/grants/{identifier}/events/{event_id}", + response_type=Event, + request_body=request_body, + query_params=query_params, + ) + + def destroy( + self, identifier: str, event_id: str, query_params: DestroyEventQueryParams + ) -> DeleteResponse: + """ + Delete an Event. + + Args: + identifier: The identifier of the Grant to act upon. + event_id: The ID of the Event to delete. + query_params: The query parameters to include in the request. + + Returns: + The deletion response. + """ + + return super().destroy( + path=f"/v3/grants/{identifier}/events/{event_id}", + query_params=query_params, + ) + + def send_rsvp( + self, + identifier: str, + event_id: str, + request_body: SendRsvpRequest, + query_params: SendRsvpQueryParams, + ) -> RequestIdOnlyResponse: + """Send RSVP for an event. + + Args: + identifier: The grant ID or email account to send RSVP for. + event_id: The event ID to send RSVP for. + query_params: The query parameters to send to the API. + request_body: The request body to send to the API. + + Returns: + Response: The RSVP response from the API. + """ + json_response = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/events/{event_id}/send-rsvp", + query_params=query_params, + request_body=request_body, + ) + + return RequestIdOnlyResponse.from_dict(json_response) diff --git a/nylas/resources/folders.py b/nylas/resources/folders.py new file mode 100644 index 00000000..9517a64a --- /dev/null +++ b/nylas/resources/folders.py @@ -0,0 +1,111 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.folders import ( + Folder, + CreateFolderRequest, + UpdateFolderRequest, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class Folders( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Folders API + + The Nylas folders API allows you to create new folders or manage existing ones. + """ + + def list(self, identifier: str) -> ListResponse[Folder]: + """ + Return all Folders. + + Args: + identifier: The identifier of the Grant to act upon. + + Returns: + The list of Folders. + """ + + return super().list( + path=f"/v3/grants/{identifier}/folders", + response_type=Folder, + ) + + def find(self, identifier: str, folder_id: str) -> Response[Folder]: + """ + Return a Folder. + + Args: + identifier: The identifier of the Grant to act upon. + folder_id: The ID of the Folder to retrieve. + + Returns: + The Folder. + """ + return super().find( + path=f"/v3/grants/{identifier}/folders/{folder_id}", + response_type=Folder, + ) + + def create( + self, identifier: str, request_body: CreateFolderRequest + ) -> Response[Folder]: + """ + Create a Folder. + + Args: + identifier: The identifier of the Grant to act upon. + request_body: The values to create the Folder with. + + Returns: + The created Folder. + """ + return super().create( + path=f"/v3/grants/{identifier}/folders", + response_type=Folder, + request_body=request_body, + ) + + def update( + self, identifier: str, folder_id: str, request_body: UpdateFolderRequest + ) -> Response[Folder]: + """ + Update a Folder. + + Args: + identifier: The identifier of the Grant to act upon. + folder_id: The ID of the Folder to update. + request_body: The values to update the Folder with. + + Returns: + The updated Folder. + """ + return super().update( + path=f"/v3/grants/{identifier}/folders/{folder_id}", + response_type=Folder, + request_body=request_body, + ) + + def destroy(self, identifier: str, folder_id: str) -> DeleteResponse: + """ + Delete a Folder. + + Args: + identifier: The identifier of the Grant to act upon. + folder_id: The ID of the Folder to delete. + + Returns: + The deletion response. + """ + return super().destroy(path=f"/v3/grants/{identifier}/folders/{folder_id}") diff --git a/nylas/resources/grants.py b/nylas/resources/grants.py new file mode 100644 index 00000000..6571fce2 --- /dev/null +++ b/nylas/resources/grants.py @@ -0,0 +1,89 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.grants import ( + Grant, + ListGrantsQueryParams, + UpdateGrantRequest, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class Grants( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Grants API + + The Grants API allows you to find and manage existing grants for your Nylas application. + + Grants represent a specific set of permissions ("scopes") that a specific end user granted Nylas + for a specific service provider + """ + + def list(self, query_params: ListGrantsQueryParams = None) -> ListResponse[Grant]: + """ + Return all Grants. + + Args: + query_params: The query parameters to include in the request. + + Returns: + A list of Grants. + """ + + return super().list( + path="/v3/grants", response_type=Grant, query_params=query_params + ) + + def find(self, grant_id: str) -> Response[Grant]: + """ + Return a Grant. + + Args: + grant_id: The ID of the Grant to retrieve. + + Returns: + The Grant. + """ + + return super().find(path=f"/v3/grants/{grant_id}", response_type=Grant) + + def update( + self, grant_id: str, request_body: UpdateGrantRequest + ) -> Response[Grant]: + """ + Update a Grant. + + Args: + grant_id: The ID of the Grant to update. + request_body: The values to update the Grant with. + + Returns: + The updated Grant. + """ + + return super().update( + path=f"/v3/grants/{grant_id}", + response_type=Grant, + request_body=request_body, + ) + + def destroy(self, grant_id: str) -> DeleteResponse: + """ + Delete a Grant. + + Args: + grant_id: The ID of the Grant to delete. + + Returns: + The deletion response. + """ + + return super().destroy(path=f"/v3/grants/{grant_id}") diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py new file mode 100644 index 00000000..a0fc0c86 --- /dev/null +++ b/nylas/resources/messages.py @@ -0,0 +1,206 @@ +from typing import Optional + +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.drafts import SendMessageRequest +from nylas.models.messages import ( + Message, + ListMessagesQueryParams, + FindMessageQueryParams, + UpdateMessageRequest, + ScheduledMessagesList, + ScheduledMessage, + StopScheduledMessageResponse, +) +from nylas.models.response import Response, ListResponse, DeleteResponse +from nylas.resources.smart_compose import SmartCompose +from nylas.utils.file_utils import _build_form_request + + +class Messages( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Messages API + + The messages API allows you to send, find, update, and delete messages. + You can also use the messages API to schedule messages to be sent at a later time. + The Smart Compose API, allowing you to generate email content using machine learning, is also available. + """ + + @property + def smart_compose(self) -> SmartCompose: + """ + Access the Smart Compose collection of endpoints. + + Returns: + The Smart Compose collection of endpoints. + """ + return SmartCompose(self._http_client) + + def list( + self, identifier: str, query_params: Optional[ListMessagesQueryParams] = None + ) -> ListResponse[Message]: + """ + Return all Messages. + + Args: + identifier: The identifier of the grant to get messages for. + query_params: The query parameters to filter messages by. + + Returns: + A list of Messages. + """ + return super().list( + path=f"/v3/grants/{identifier}/messages", + response_type=Message, + query_params=query_params, + ) + + def find( + self, + identifier: str, + message_id: str, + query_params: Optional[FindMessageQueryParams] = None, + ) -> Response[Message]: + """ + Return a Message. + + Args: + identifier: The identifier of the grant to get the message for. + message_id: The identifier of the message to get. + query_params: The query parameters to include in the request. + + Returns: + The requested Message. + """ + return super().find( + path=f"/v3/grants/{identifier}/messages/{message_id}", + response_type=Message, + query_params=query_params, + ) + + def update( + self, + identifier: str, + message_id: str, + request_body: UpdateMessageRequest, + ) -> Response[Message]: + """ + Update a Message. + + Args: + identifier: The identifier of the grant to update the message for. + message_id: The identifier of the message to update. + request_body: The request body to update the message with. + + Returns: + The updated Message. + """ + return super().update( + path=f"/v3/grants/{identifier}/messages/{message_id}", + response_type=Message, + request_body=request_body, + ) + + def destroy(self, identifier: str, message_id: str) -> DeleteResponse: + """ + Delete a Message. + + Args: + identifier: The identifier of the grant to delete the message for. + message_id: The identifier of the message to delete. + + Returns: + The deletion response. + """ + return super().destroy( + path=f"/v3/grants/{identifier}/messages/{message_id}", + ) + + def send( + self, identifier: str, request_body: SendMessageRequest + ) -> Response[Message]: + """ + Send a Message. + + Args: + identifier: The identifier of the grant to send the message for. + request_body: The request body to send the message with. + + Returns: + The sent message. + """ + json_response = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/messages/send", + data=_build_form_request(request_body), + ) + + return Response.from_dict(json_response, Message) + + def list_scheduled_messages( + self, identifier: str + ) -> Response[ScheduledMessagesList]: + """ + Retrieve your scheduled messages. + + Args: + identifier: The identifier of the grant to delete the message for. + + Returns: + Response: The list of scheduled messages. + """ + json_response = self._http_client._execute( + method="GET", + path=f"/v3/grants/{identifier}/messages/schedules", + ) + + return Response.from_dict(json_response, ScheduledMessagesList) + + def find_scheduled_message( + self, identifier: str, schedule_id: str + ) -> Response[ScheduledMessage]: + """ + Retrieve your scheduled messages. + + Args: + identifier: The identifier of the grant to delete the message for. + schedule_id: The id of the scheduled message to retrieve. + + Returns: + Response: The scheduled message. + """ + json_response = self._http_client._execute( + method="GET", + path=f"/v3/grants/{identifier}/messages/schedules/{schedule_id}", + ) + + return Response.from_dict(json_response, ScheduledMessage) + + def stop_scheduled_message( + self, identifier: str, schedule_id: str + ) -> Response[StopScheduledMessageResponse]: + """ + Stop a scheduled message. + + Args: + identifier: The identifier of the grant to delete the message for. + schedule_id: The id of the scheduled message to stop. + + Returns: + Response: The confirmation of the stopped scheduled message. + """ + json_response = self._http_client._execute( + method="DELETE", + path=f"/v3/grants/{identifier}/messages/schedules/{schedule_id}", + ) + + return Response.from_dict(json_response, StopScheduledMessageResponse) diff --git a/nylas/resources/redirect_uris.py b/nylas/resources/redirect_uris.py new file mode 100644 index 00000000..82f47117 --- /dev/null +++ b/nylas/resources/redirect_uris.py @@ -0,0 +1,105 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.redirect_uri import ( + RedirectUri, + CreateRedirectUriRequest, + UpdateRedirectUriRequest, +) +from nylas.models.response import Response, ListResponse, DeleteResponse + + +class RedirectUris( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Manage Redirect URIs for your Nylas Application. + + These endpoints allow you to create, update, and delete Redirect URIs for your Nylas Application. + """ + + def list(self) -> ListResponse[RedirectUri]: + """ + Return all Redirect URIs. + + Returns: + The list of Redirect URIs. + """ + + return super().list( + path="/v3/applications/redirect-uris", response_type=RedirectUri + ) + + def find(self, redirect_uri_id: str) -> Response[RedirectUri]: + """ + Return a Redirect URI. + + Args: + redirect_uri_id: The ID of the Redirect URI to retrieve. + + Returns: + The Redirect URI. + """ + + return super().find( + path=f"/v3/applications/redirect-uris/{redirect_uri_id}", + response_type=RedirectUri, + ) + + def create(self, request_body: CreateRedirectUriRequest) -> Response[RedirectUri]: + """ + Create a Redirect URI. + + Args: + request_body: The values to create the Redirect URI with. + + Returns: + The created Redirect URI. + """ + + return super().create( + path="/v3/applications/redirect-uris", + request_body=request_body, + response_type=RedirectUri, + ) + + def update( + self, redirect_uri_id: str, request_body: UpdateRedirectUriRequest + ) -> Response[RedirectUri]: + """ + Update a Redirect URI. + + Args: + redirect_uri_id: The ID of the Redirect URI to update. + request_body: The values to update the Redirect URI with. + + Returns: + The updated Redirect URI. + """ + + return super().update( + path=f"/v3/applications/redirect-uris/{redirect_uri_id}", + request_body=request_body, + response_type=RedirectUri, + ) + + def destroy(self, redirect_uri_id: str) -> DeleteResponse: + """ + Delete a Redirect URI. + + Args: + redirect_uri_id: The ID of the Redirect URI to delete. + + Returns: + The deletion response. + """ + + return super().destroy(path=f"/v3/applications/redirect-uris/{redirect_uri_id}") diff --git a/nylas/resources/resource.py b/nylas/resources/resource.py new file mode 100644 index 00000000..5880fef7 --- /dev/null +++ b/nylas/resources/resource.py @@ -0,0 +1,8 @@ +from nylas.handler.http_client import HttpClient + + +class Resource: + """Base class for all Nylas API resources.""" + + def __init__(self, http_client: HttpClient): + self._http_client = http_client diff --git a/nylas/resources/smart_compose.py b/nylas/resources/smart_compose.py new file mode 100644 index 00000000..c1f6ba7b --- /dev/null +++ b/nylas/resources/smart_compose.py @@ -0,0 +1,55 @@ +from nylas.models.response import Response + +from nylas.models.smart_compose import ComposeMessageRequest, ComposeMessageResponse +from nylas.resources.resource import Resource + + +class SmartCompose(Resource): + """ + A collection of Smart Compose related API endpoints. + + These endpoints allow for the generation of message suggestions. + """ + + def compose_message( + self, identifier: str, request_body: ComposeMessageRequest + ) -> Response[ComposeMessageResponse]: + """ + Compose a message. + + Args: + identifier: The identifier of the grant to generate a message suggestion for. + request_body: The prompt that smart compose will use to generate a message suggestion. + + Returns: + The generated message. + """ + res = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/messages/smart-compose", + request_body=request_body, + ) + + return Response.from_dict(res, ComposeMessageResponse) + + def compose_message_reply( + self, identifier: str, message_id: str, request_body: ComposeMessageRequest + ) -> ComposeMessageResponse: + """ + Compose a message reply. + + Args: + identifier: The identifier of the grant to generate a message suggestion for. + message_id: The id of the message to reply to. + request_body: The prompt that smart compose will use to generate a message reply suggestion. + + Returns: + The generated message reply. + """ + res = self._http_client._execute( + method="POST", + path=f"/v3/grants/{identifier}/messages/{message_id}/smart-compose", + request_body=request_body, + ) + + return Response.from_dict(res, ComposeMessageResponse) diff --git a/nylas/resources/threads.py b/nylas/resources/threads.py new file mode 100644 index 00000000..2ff49adc --- /dev/null +++ b/nylas/resources/threads.py @@ -0,0 +1,94 @@ +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.response import ListResponse, Response, DeleteResponse +from nylas.models.threads import ListThreadsQueryParams, Thread, UpdateThreadRequest + + +class Threads( + ListableApiResource, + FindableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Threads API + + The threads API allows you to find, update, and delete threads. + """ + + def list( + self, identifier: str, query_params: ListThreadsQueryParams = None + ) -> ListResponse[Thread]: + """ + Return all Threads. + + Args: + identifier: The identifier of the grant to get threads for. + query_params: The query parameters to filter threads by. + + Returns: + A list of Threads. + """ + return super().list( + path=f"/v3/grants/{identifier}/threads", + response_type=Thread, + query_params=query_params, + ) + + def find(self, identifier: str, thread_id: str) -> Response[Thread]: + """ + Return a Thread. + + Args: + identifier: The identifier of the grant to get the thread for. + thread_id: The identifier of the thread to get. + + Returns: + The requested Thread. + """ + return super().find( + path=f"/v3/grants/{identifier}/threads/{thread_id}", + response_type=Thread, + ) + + def update( + self, + identifier: str, + thread_id: str, + request_body: UpdateThreadRequest, + ) -> Response[Thread]: + """ + Update a Thread. + + Args: + identifier: The identifier of the grant to update the thread for. + thread_id: The identifier of the thread to update. + request_body: The request body to update the thread with. + + Returns: + The updated Thread. + """ + return super().update( + path=f"/v3/grants/{identifier}/threads/{thread_id}", + response_type=Thread, + request_body=request_body, + ) + + def destroy(self, identifier: str, thread_id: str) -> DeleteResponse: + """ + Delete a Thread. + + Args: + identifier: The identifier of the grant to delete the thread for. + thread_id: The identifier of the thread to delete. + + Returns: + The deletion response. + """ + return super().destroy( + path=f"/v3/grants/{identifier}/threads/{thread_id}", + ) diff --git a/nylas/resources/webhooks.py b/nylas/resources/webhooks.py new file mode 100644 index 00000000..0d1fb621 --- /dev/null +++ b/nylas/resources/webhooks.py @@ -0,0 +1,148 @@ +import urllib.parse + +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +) +from nylas.models.response import Response, ListResponse +from nylas.models.webhooks import ( + Webhook, + WebhookWithSecret, + WebhookDeleteResponse, + WebhookIpAddressesResponse, + CreateWebhookRequest, + UpdateWebhookRequest, +) + + +class Webhooks( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatableApiResource, + DestroyableApiResource, +): + """ + Nylas Webhooks API + + The Nylas webhooks API allows you to manage webhook destinations for your Nylas application. + """ + + def list(self) -> ListResponse[Webhook]: + """ + List all webhook destinations + + Returns: + The list of webhook destinations + """ + return super().list(path="/v3/webhooks", response_type=Webhook) + + def find(self, webhook_id: str) -> Response[Webhook]: + """ + Get a webhook destination + + Parameters: + webhook_id: The ID of the webhook destination to get + + Returns: + The webhook destination + """ + return super().find(path=f"/v3/webhooks/{webhook_id}", response_type=Webhook) + + def create(self, request_body: CreateWebhookRequest) -> Response[WebhookWithSecret]: + """ + Create a webhook destination + + Parameters: + request_body: The request body to create the webhook destination + + Returns: + The created webhook destination + """ + return super().create( + path="/v3/webhooks", + request_body=request_body, + response_type=WebhookWithSecret, + ) + + def update( + self, webhook_id: str, request_body: UpdateWebhookRequest + ) -> Response[Webhook]: + """ + Update a webhook destination + + Parameters: + webhook_id: The ID of the webhook destination to update + request_body: The request body to update the webhook destination + + Returns: + The updated webhook destination + """ + return super().update( + path=f"/v3/webhooks/{webhook_id}", + request_body=request_body, + response_type=Webhook, + ) + + def destroy(self, webhook_id: str) -> WebhookDeleteResponse: + """ + Delete a webhook destination + + Parameters: + webhook_id: The ID of the webhook destination to delete + + Returns: + The response from deleting the webhook destination + """ + return super().destroy( + path=f"/v3/webhooks/{webhook_id}", response_type=WebhookDeleteResponse + ) + + def rotate_secret(self, webhook_id: str) -> Response[WebhookWithSecret]: + """ + Update the webhook secret value for a destination + + Parameters: + webhook_id: The ID of the webhook destination to update + + Returns: + The updated webhook destination + """ + res = self._http_client._execute( + method="PUT", + path=f"/v3/webhooks/{webhook_id}/rotate-secret", + request_body={}, + ) + return Response.from_dict(res, WebhookWithSecret) + + def ip_addresses(self) -> Response[WebhookIpAddressesResponse]: + """ + Get the current list of IP addresses that Nylas sends webhooks from + + Returns: + The list of IP addresses that Nylas sends webhooks from + """ + res = self._http_client._execute(method="GET", path="/v3/webhooks/ip-addresses") + return Response.from_dict(res, WebhookIpAddressesResponse) + + +def extract_challenge_parameter(url: str) -> str: + """ + Extract the challenge parameter from a URL + + Parameters: + url: The URL sent by Nylas containing the challenge parameter + + Returns: + The challenge parameter + """ + url_object = urllib.parse.urlparse(url) + query = urllib.parse.parse_qs(url_object.query) + challenge_parameter = query.get("challenge") + if not challenge_parameter: + raise ValueError("Invalid URL or no challenge parameter found.") + + return challenge_parameter[0] diff --git a/nylas/services/tunnel.py b/nylas/services/tunnel.py deleted file mode 100644 index 356ad0d4..00000000 --- a/nylas/services/tunnel.py +++ /dev/null @@ -1,96 +0,0 @@ -import uuid - -import websocket -import json -from threading import Thread - -from nylas.client import APIClient -from nylas.client.restful_models import Webhook -from nylas.config import DEFAULT_REGION - - -def open_webhook_tunnel(api, config): - """ - Open a webhook tunnel and register it with the Nylas API - 1. Creates a UUID - 2. Opens a websocket connection to Nylas' webhook forwarding service, - with the UUID as a header - 3. Creates a new webhook pointed at the forwarding service with the UUID as the path - - When an event is received by the forwarding service, it will push directly to this websocket - connection - - Args: - api (APIClient): The configured Nylas API client - config (dict[str, any]): Configuration for the webhook tunnel, including callback functions, region, - and events to subscribe to - """ - - ws = _build_webhook_tunnel(api, config) - ws_run = Thread(target=_run_webhook_tunnel, args=(ws,)) - ws_run.start() - - -def _run_webhook_tunnel(ws): - ws.run_forever() - - -def _register_webhook(api, callback_domain, tunnel_id, triggers): - webhook = api.webhooks.create() - webhook.callback_url = "https://{}/{}".format(callback_domain, tunnel_id) - webhook.triggers = triggers - webhook.state = Webhook.State.ACTIVE.value - webhook.save() - - -def _build_webhook_tunnel(api, config): - ws_domain = "wss://tunnel.nylas.com" - callback_domain = "cb.nylas.com" - # This UUID will map our websocket to a webhook in the forwarding server - tunnel_id = str(uuid.uuid4()) - - region = config.get("region", DEFAULT_REGION) - triggers = config.get("triggers", [e.value for e in Webhook.Trigger]) - - usr_on_message = config.get("on_message", None) - on_open = config.get("on_open", None) - on_error = config.get("on_error", None) - on_close = config.get("on_close", None) - on_ping = config.get("on_ping", None) - on_pong = config.get("on_pong", None) - on_cont_message = config.get("on_cont_message", None) - on_data = config.get("on_data", None) - - def on_message(wsapp, message): - deltas = _parse_deltas(message) - for delta in deltas: - usr_on_message(delta) - - ws = websocket.WebSocketApp( - ws_domain, - header={ - "Client-Id": api.client_id, - "Client-Secret": api.client_secret, - "Tunnel-Id": tunnel_id, - "Region": region.value, - }, - on_open=on_open, - on_message=on_message if usr_on_message else None, - on_error=on_error, - on_close=on_close, - on_ping=on_ping, - on_pong=on_pong, - on_cont_message=on_cont_message, - on_data=on_data, - ) - - # Register the webhook to the Nylas application - _register_webhook(api, callback_domain, tunnel_id, triggers) - - return ws - - -def _parse_deltas(message): - parsed_message = json.loads(message) - parsed_body = json.loads(parsed_message["body"]) - return parsed_body["deltas"] diff --git a/nylas/utils.py b/nylas/utils.py deleted file mode 100644 index cfeb9ff0..00000000 --- a/nylas/utils.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import division -from datetime import datetime, timedelta -from enum import Enum - - -def timestamp_from_dt(dt, epoch=datetime(1970, 1, 1)): - """ - Convert a datetime to a timestamp. - https://stackoverflow.com/a/8778548/141395 - """ - # For offset-aware datetime objects, convert them first before performing delta - if dt.tzinfo is not None and dt.utcoffset() is not None: - dt = dt.replace(tzinfo=None) - dt.utcoffset() - - delta = dt - epoch - - return int(delta.total_seconds() / timedelta(seconds=1).total_seconds()) - - -def create_request_body(data, datetime_attrs): - """ - Given a dictionary of data, and a dictionary of datetime attributes, - return a new dictionary that is suitable for a request. It converts - any datetime attributes that may be present to their timestamped - equivalent, and it filters out any attributes set to "None". - """ - if not data: - return data - - new_data = {} - for key, value in data.items(): - if key in datetime_attrs and isinstance(value, datetime): - new_key = datetime_attrs[key] - new_data[new_key] = timestamp_from_dt(value) - elif value is not None: - new_data[key] = value - - return new_data - - -def convert_metadata_pairs_to_array(data): - """ - Given a dictionary of metadata pairs, convert it to key-value pairs - in the format the Nylas API expects: "events?metadata_pair=