Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ disable=
too-many-instance-attributes,
unnecessary-pass,
too-many-arguments,
too-many-positional-arguments,
too-few-public-methods,

[TYPECHECK]
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ nylas-python Changelog
======================
Unreleased
----------
* Added Manage Domains (`Client.domains`, `/v3/admin/domains`): list, create, find, update, delete, `get_info`, and `verify` with models in `nylas.models.domains`; optional `ServiceAccountSigner` (`nylas.handler.service_account`) for service-account headers (`X-Nylas-Kid`, `X-Nylas-Nonce`, `X-Nylas-Timestamp`, `X-Nylas-Signature`) on each `Domains` method; new `cryptography` dependency, RSA signing, and `HttpClient` `serialized_json_body` so signed payloads match the wire body
* Added Transactional Send: `Client.transactional_send.send()` for `POST /v3/domains/{domain_name}/messages/send`, with `TransactionalSendMessageRequest` and `TransactionalTemplate` models (JSON and multipart send behavior aligned with grant `messages.send`)

v6.14.3
Expand Down
11 changes: 11 additions & 0 deletions nylas/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from nylas.resources.webhooks import Webhooks
from nylas.resources.contacts import Contacts
from nylas.resources.drafts import Drafts
from nylas.resources.domains import Domains
from nylas.resources.grants import Grants
from nylas.resources.scheduler import Scheduler
from nylas.resources.notetakers import Notetakers
Expand Down Expand Up @@ -113,6 +114,16 @@ def drafts(self) -> Drafts:
"""
return Drafts(self.http_client)

@property
def domains(self) -> Domains:
"""
Access the Manage Domains API.

Returns:
The Manage Domains API.
"""
return Domains(self.http_client)

@property
def events(self) -> Events:
"""
Expand Down
18 changes: 15 additions & 3 deletions nylas/handler/api_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,14 @@ def create(
query_params=None,
request_body=None,
overrides=None,
serialized_json_body=None,
) -> Response:

kwargs = {"overrides": overrides}
if serialized_json_body is not None:
kwargs["serialized_json_body"] = serialized_json_body
response_json, response_headers = self._http_client._execute(
"POST", path, headers, query_params, request_body, overrides=overrides
"POST", path, headers, query_params, request_body, **kwargs
)

return Response.from_dict(response_json, response_type, response_headers)
Expand All @@ -68,9 +72,13 @@ def update(
request_body=None,
method="PUT",
overrides=None,
serialized_json_body=None,
):
kwargs = {"overrides": overrides}
if serialized_json_body is not None:
kwargs["serialized_json_body"] = serialized_json_body
response_json, response_headers = self._http_client._execute(
method, path, headers, query_params, request_body, overrides=overrides
method, path, headers, query_params, request_body, **kwargs
)

return Response.from_dict(response_json, response_type, response_headers)
Expand All @@ -86,9 +94,13 @@ def patch(
request_body=None,
method="PATCH",
overrides=None,
serialized_json_body=None,
):
kwargs = {"overrides": overrides}
if serialized_json_body is not None:
kwargs["serialized_json_body"] = serialized_json_body
response_json, response_headers = self._http_client._execute(
method, path, headers, query_params, request_body, overrides=overrides
method, path, headers, query_params, request_body, **kwargs
)

return Response.from_dict(response_json, response_type, response_headers)
Expand Down
22 changes: 19 additions & 3 deletions nylas/handler/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,17 @@ def _execute(
request_body=None,
data=None,
overrides=None,
serialized_json_body=None,
) -> dict:
request = self._build_request(
method, path, headers, query_params, request_body, data, overrides
method,
path,
headers,
query_params,
request_body,
data,
overrides,
serialized_json_body=serialized_json_body,
)

timeout = self.timeout
Expand All @@ -93,8 +101,12 @@ def _execute(
# Serialize request_body to JSON with ensure_ascii=False to preserve UTF-8 characters
# and allow_nan=True to support NaN/Infinity values (matching default json.dumps behavior).
# Encode as UTF-8 bytes to avoid Latin-1 encoding errors with special characters.
# When serialized_json_body is set (e.g. Nylas service account signing), send those exact
# bytes so the wire body matches the payload that was signed.
json_data = None
if request_body is not None and data is None:
if serialized_json_body is not None and data is None:
json_data = serialized_json_body
elif request_body is not None and data is None:
json_data = json.dumps(request_body, ensure_ascii=False, allow_nan=True).encode("utf-8")
try:
response = requests.request(
Expand Down Expand Up @@ -151,14 +163,18 @@ def _build_request(
request_body=None,
data=None,
overrides=None,
serialized_json_body=None,
) -> dict:
api_server = self.api_server
if overrides and overrides.get("api_uri"):
api_server = overrides["api_uri"]

base_url = f"{api_server}{path}"
url = _build_query_params(base_url, query_params) if query_params else base_url
headers = self._build_headers(headers, request_body, data, overrides)
body_for_content_type = (
request_body if request_body is not None else serialized_json_body
)
headers = self._build_headers(headers, body_for_content_type, data, overrides)

return {
"method": method,
Expand Down
136 changes: 136 additions & 0 deletions nylas/handler/service_account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""
Nylas Service Account request signing for organization admin APIs.

See https://developer.nylas.com/docs/v3/auth/nylas-service-account/

If you set X-Nylas-* headers manually via RequestOverrides, the HTTP request body must be
byte-identical to the canonical JSON string used when computing the signature.
"""

from __future__ import annotations

import base64
import json
import secrets
import string
import time
from typing import Any, Dict, Optional, Tuple

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa

_NONCE_ALPHABET = string.ascii_letters + string.digits
_NONCE_LENGTH = 20


def canonical_json(data: Dict[str, Any]) -> str:
"""
Deterministic JSON with sorted keys at each object level, matching Nylas's reference
implementation for service account signing.
"""
keys = sorted(data.keys())
parts = []
for k in keys:
key_json = json.dumps(k, ensure_ascii=False, allow_nan=False)
v = data[k]
if isinstance(v, dict):
val_json = canonical_json(v)
else:
val_json = json.dumps(
v, ensure_ascii=False, allow_nan=False, separators=(",", ":")
)
parts.append(f"{key_json}:{val_json}")
return "{" + ",".join(parts) + "}"


def load_rsa_private_key_from_pem(pem: str) -> rsa.RSAPrivateKey:
"""Load an RSA private key from a PEM string (PKCS#1 or PKCS#8)."""
key_bytes = pem.encode("utf-8") if isinstance(pem, str) else pem
loaded = serialization.load_pem_private_key(key_bytes, password=None)
if not isinstance(loaded, rsa.RSAPrivateKey):
raise ValueError("Private key must be RSA")
return loaded


def _signing_envelope_bytes(
path: str,
method: str,
timestamp: int,
nonce: str,
body: Optional[Dict[str, Any]],
) -> bytes:
method_l = method.lower()
envelope: Dict[str, Any] = {
"method": method_l,
"nonce": nonce,
"path": path,
"timestamp": timestamp,
}
if method_l in ("post", "put", "patch") and body is not None:
envelope["payload"] = canonical_json(body)
canonical = canonical_json(envelope)
return canonical.encode("utf-8")


def sign_bytes(private_key: rsa.RSAPrivateKey, message: bytes) -> str:
"""RSA PKCS#1 v1.5 signature over SHA-256(message), Base64-encoded."""
signature = private_key.sign(message, padding.PKCS1v15(), hashes.SHA256())
return base64.b64encode(signature).decode("ascii")


def generate_nonce(length: int = _NONCE_LENGTH) -> str:
"""Cryptographically secure nonce (alphanumeric), default length 20."""
return "".join(secrets.choice(_NONCE_ALPHABET) for _ in range(length))


class ServiceAccountSigner:
"""
Builds the four required Nylas service account headers for a single request.

Args:
private_key_pem: RSA private key in PEM text form (from the service account JSON).
private_key_id: Value for X-Nylas-Kid (``private_key_id`` in the JSON credentials).
"""

def __init__(self, private_key_pem: str, private_key_id: str):
self._private_key = load_rsa_private_key_from_pem(private_key_pem)
self._private_key_id = private_key_id

def build_headers(
self,
method: str,
path: str,
body: Optional[Dict[str, Any]] = None,
*,
timestamp: Optional[int] = None,
nonce: Optional[str] = None,
) -> Tuple[Dict[str, str], Optional[bytes]]:
"""
Produce signing headers and optional canonical JSON body bytes.

For POST/PUT/PATCH, ``body`` must be the same dict that will be sent; returned bytes
should be passed to HttpClient as ``serialized_json_body`` so the wire body matches
the signed payload.

Returns:
(headers, serialized_json_body) where serialized_json_body is set for
POST/PUT/PATCH when body is not None, else None.
"""
ts = int(time.time()) if timestamp is None else int(timestamp)
n = generate_nonce() if nonce is None else nonce

serialized: Optional[bytes] = None
body_for_sign: Optional[Dict[str, Any]] = body
if method.lower() in ("post", "put", "patch") and body is not None:
serialized = canonical_json(body).encode("utf-8")

envelope = _signing_envelope_bytes(path, method, ts, n, body_for_sign)
signature_b64 = sign_bytes(self._private_key, envelope)

headers = {
"X-Nylas-Kid": self._private_key_id,
"X-Nylas-Nonce": n,
"X-Nylas-Timestamp": str(ts),
"X-Nylas-Signature": signature_b64,
}
return headers, serialized
100 changes: 100 additions & 0 deletions nylas/models/domains.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from dataclasses import dataclass, field
from typing import Any, Literal, Optional

from dataclasses_json import config, dataclass_json
from typing_extensions import TypedDict

from nylas.models.list_query_params import ListQueryParams

DomainVerificationType = Literal["ownership", "dkim", "spf", "feedback", "mx"]


class ListDomainsQueryParams(ListQueryParams):
"""
Query parameters for listing domains.

Attributes:
limit: Maximum number of objects to return.
page_token: Cursor for the next page (from ``next_cursor`` on the previous response).
"""

pass


class CreateDomainRequest(TypedDict):
"""Request body for registering a domain."""

name: str
domain_address: str


class UpdateDomainRequest(TypedDict, total=False):
"""Request body for updating a domain (currently only ``name`` is supported)."""

name: str


class GetDomainInfoRequest(TypedDict):
"""Request body for retrieving DNS records for a verification type."""

type: DomainVerificationType


class VerifyDomainRequest(TypedDict):
"""Request body for triggering DNS verification."""

type: DomainVerificationType


@dataclass_json
@dataclass
class Domain:
"""
A domain registered for Transactional Send or Nylas Inbound.
"""

id: str
name: str
branded: bool
domain_address: str
organization_id: str
region: str
verified_ownership: bool
verified_dkim: bool
verified_spf: bool
verified_mx: bool
verified_feedback: bool
verified_dmarc: bool
verified_arc: bool
created_at: int
updated_at: int


@dataclass_json
@dataclass
class DomainVerificationAttempt:
"""
DNS verification attempt or required records for a verification type.
"""

verification_type: Optional[str] = field(
default=None, metadata=config(field_name="type")
)
options: Optional[Any] = None
host: Optional[str] = None
value: Optional[str] = None
status: Optional[str] = None


@dataclass_json
@dataclass
class DomainVerificationDetails:
"""
Response data from get domain info or verify domain endpoints.
"""

domain_id: str
attempt: Optional[DomainVerificationAttempt] = None
created_at: Optional[int] = None
expires_at: Optional[int] = None
message: Optional[str] = None
Loading
Loading