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: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Unreleased
----------
* Added optional `tracking_options.domain_name` support for custom link and open tracking hostnames in message sends, scheduled sends, drafts, and Transactional Send
* Added Contact metadata request/response models and `metadata_pair` filtering, with contact webhook compatibility documentation
* Added `messages.send_raw_mime()` to send a raw RFC 822 MIME message via `POST /v3/grants/{grant_id}/messages/send?type=mime`, with the `SendRawMimeRequest` model

v6.17.0
----------
Expand Down
65 changes: 65 additions & 0 deletions examples/raw_mime_send_demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Raw MIME Send Demo

This example demonstrates how to send a message as raw RFC 822 MIME data using `messages.send_raw_mime()`.

## When to Use It

The standard `messages.send()` method builds the MIME message for you from structured fields. Use `send_raw_mime()` when you need full control over the outgoing message, for example to:

- Set headers that the structured send request doesn't expose, such as `Importance: High` and `X-Priority: 1`.
- Control the exact encoding and MIME structure of the message.
- Send a message that another system has already built as MIME.

## Usage

```python
from nylas import Client

client = Client(api_key="NYLAS_API_KEY")

mime = (
b"MIME-Version: 1.0\r\n"
b"From: Sender <sender@example.com>\r\n"
b"To: Recipient <recipient@example.com>\r\n"
b"Subject: Hello\r\n"
b"Importance: High\r\n"
b"X-Priority: 1\r\n"
b"Content-Type: text/plain; charset=\"UTF-8\"\r\n"
b"\r\n"
b"Hello from a raw MIME message!\r\n"
)

response = client.messages.send_raw_mime(
identifier="NYLAS_GRANT_ID",
request_body={"mime": mime},
)
print(response.data.id)
```

`mime` can be a `str` or `bytes`. Pass `bytes` to send the message exactly as encoded. Strings are encoded as UTF-8.

The SDK sends the message to `POST /v3/grants/{grant_id}/messages/send?type=mime` as a `multipart/form-data` request, with the message in a `mime` file part.

## Setup

1. Install the SDK in development mode from the repository root:
```bash
cd /path/to/nylas-python
pip install -e .
```

2. Set your environment variables:
```bash
export NYLAS_API_KEY="your_api_key"
export NYLAS_GRANT_ID="your_grant_id"
export NYLAS_RECIPIENT_EMAIL="recipient@example.com"
export NYLAS_API_URI="https://api.us.nylas.com" # Optional, defaults to US
```

## Running the Example

```bash
python examples/raw_mime_send_demo/raw_mime_send_example.py
```

The example builds a high-importance `multipart/alternative` message with Python's `email` package, then sends it with `send_raw_mime()`.
88 changes: 88 additions & 0 deletions examples/raw_mime_send_demo/raw_mime_send_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
Nylas SDK Example: Sending a Raw MIME Message

This example demonstrates how to use messages.send_raw_mime() to send a message
built as raw RFC 822 MIME data. This gives you full control over the outgoing
message, including headers such as Importance and X-Priority.

Required Environment Variables:
NYLAS_API_KEY: Your Nylas API key
NYLAS_GRANT_ID: Your Nylas grant ID
NYLAS_RECIPIENT_EMAIL: The email address to send the test message to

Optional Environment Variables:
NYLAS_API_URI: The Nylas API URI (defaults to https://api.us.nylas.com)

Usage:
First, install the SDK in development mode:
cd /path/to/nylas-python
pip install -e .

Then set environment variables and run:
export NYLAS_API_KEY="your_api_key"
export NYLAS_GRANT_ID="your_grant_id"
export NYLAS_RECIPIENT_EMAIL="recipient@example.com"
python examples/raw_mime_send_demo/raw_mime_send_example.py
"""

import os
import sys
from email.message import EmailMessage
from email.policy import SMTP

from nylas import Client


def get_env_or_exit(var_name: str) -> str:
"""Get an environment variable or exit if not found."""
value = os.getenv(var_name)
if not value:
print(f"Error: {var_name} environment variable is required")
sys.exit(1)
return value


def build_high_importance_mime(sender: str, recipient: str) -> bytes:
"""Build a multipart/alternative MIME message marked as high importance."""
message = EmailMessage()
message["From"] = sender
message["To"] = recipient
message["Subject"] = "Raw MIME send from the Nylas Python SDK"
message["Importance"] = "High"
message["X-Priority"] = "1"
message.set_content("Hello from a raw MIME message!")
message.add_alternative(
"<p>Hello from a <strong>raw MIME</strong> message!</p>", subtype="html"
)

# The SMTP policy uses CRLF line endings, as required by RFC 822.
return message.as_bytes(policy=SMTP)


def main() -> None:
"""Send a raw MIME message and print the result."""
api_key = get_env_or_exit("NYLAS_API_KEY")
grant_id = get_env_or_exit("NYLAS_GRANT_ID")
recipient = get_env_or_exit("NYLAS_RECIPIENT_EMAIL")

client = Client(
api_key=api_key,
api_uri=os.environ.get("NYLAS_API_URI", "https://api.us.nylas.com"),
)

grant = client.grants.find(grant_id=grant_id)
mime = build_high_importance_mime(sender=grant.data.email, recipient=recipient)

print("Sending raw MIME message...")
response = client.messages.send_raw_mime(
identifier=grant_id,
request_body={"mime": mime},
)

print(f"✓ Message sent! ID: {response.data.id}")
print(f" Request ID: {response.request_id}")


if __name__ == "__main__":
main()
14 changes: 13 additions & 1 deletion nylas/models/messages.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from dataclasses import dataclass, field
from typing import List, Literal, Optional, Dict, Any
from typing import List, Literal, Optional, Dict, Any, Union
from dataclasses_json import dataclass_json, config
from typing_extensions import TypedDict, NotRequired, get_type_hints

Expand Down Expand Up @@ -271,6 +271,18 @@ class CleanMessagesRequest(TypedDict):
remove_conclusion_phrases: NotRequired[bool]


class SendRawMimeRequest(TypedDict):
"""
A request to send a message as raw MIME data.

Attributes:
mime: The complete RFC 822 MIME message, including all headers and body parts.
Pass bytes to send the message exactly as encoded; strings are encoded as UTF-8.
"""

mime: Union[str, bytes]


@dataclass_json
@dataclass
class CleanMessagesResponse(Message):
Expand Down
32 changes: 32 additions & 0 deletions nylas/resources/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
StopScheduledMessageResponse,
CleanMessagesRequest,
CleanMessagesResponse,
SendRawMimeRequest,
)
from nylas.models.response import Response, ListResponse, DeleteResponse
from nylas.resources.smart_compose import SmartCompose
from nylas.utils.file_utils import (
_build_form_request,
_build_raw_mime_form_request,
MAXIMUM_JSON_ATTACHMENT_SIZE,
encode_stream_to_base64,
)
Expand Down Expand Up @@ -205,6 +207,36 @@ def send(

return Response.from_dict(json_response, Message, headers)

def send_raw_mime(
self,
identifier: str,
request_body: SendRawMimeRequest,
overrides: RequestOverrides = None,
) -> Response[Message]:
"""
Send a Message using raw MIME data.

Use this when you need full control over the outgoing message, such as setting
headers (e.g. Importance or X-Priority) that the structured send request doesn't expose.

Args:
identifier: The identifier of the grant to send the message for.
request_body: The request body containing the raw MIME message.
overrides: The request overrides to apply to the request.

Returns:
The sent message.
"""
json_response, headers = self._http_client._execute(
method="POST",
path=f"/v3/grants/{identifier}/messages/send",
query_params={"type": "mime"},
data=_build_raw_mime_form_request(request_body["mime"]),
overrides=overrides,
)

return Response.from_dict(json_response, Message, headers)

def list_scheduled_messages(
self, identifier: str, overrides: RequestOverrides = None
) -> Response[List[ScheduledMessage]]:
Expand Down
16 changes: 16 additions & 0 deletions nylas/utils/file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,19 @@ def _build_form_request(request_body: dict) -> MultipartEncoder:
)

return MultipartEncoder(fields=fields)


def _build_raw_mime_form_request(mime) -> MultipartEncoder:
"""
Build a form-data request for sending a raw MIME message.

The MIME is sent as a file part so the API streams it to disk instead of
holding it in memory, which allows messages with large attachments.

Attributes:
mime: The raw MIME message, as a string or bytes.

Returns:
The multipart/form-data request.
"""
return MultipartEncoder(fields={"mime": ("message.eml", mime, "message/rfc822")})
54 changes: 54 additions & 0 deletions tests/resources/test_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,60 @@ def test_send_message_large_attachment(self, http_client_response):
overrides=None,
)

def test_send_raw_mime(self, http_client_response):
messages = Messages(http_client_response)
mock_encoder = Mock()
mime = "MIME-Version: 1.0\r\nImportance: High\r\nX-Priority: 1\r\nSubject: Hi\r\n\r\nHello"

with patch(
"nylas.resources.messages._build_raw_mime_form_request",
return_value=mock_encoder,
) as mock_build:
messages.send_raw_mime(identifier="abc-123", request_body={"mime": mime})

mock_build.assert_called_once_with(mime)
http_client_response._execute.assert_called_once_with(
method="POST",
path="/v3/grants/abc-123/messages/send",
query_params={"type": "mime"},
data=mock_encoder,
overrides=None,
)

def test_send_raw_mime_bytes(self, http_client_response):
messages = Messages(http_client_response)
mime = b"MIME-Version: 1.0\r\nSubject: =?UTF-8?B?w6k=?=\r\n\r\n\xc3\xa9"

with patch(
"nylas.resources.messages._build_raw_mime_form_request"
) as mock_build:
messages.send_raw_mime(identifier="abc-123", request_body={"mime": mime})

mock_build.assert_called_once_with(mime)

def test_send_raw_mime_with_overrides(self, http_client_response):
messages = Messages(http_client_response)
mock_encoder = Mock()
overrides = {"timeout": 60}

with patch(
"nylas.resources.messages._build_raw_mime_form_request",
return_value=mock_encoder,
):
messages.send_raw_mime(
identifier="abc-123",
request_body={"mime": "MIME-Version: 1.0\r\n\r\nHi"},
overrides=overrides,
)

http_client_response._execute.assert_called_once_with(
method="POST",
path="/v3/grants/abc-123/messages/send",
query_params={"type": "mime"},
data=mock_encoder,
overrides=overrides,
)

def test_list_scheduled_messages(self, http_client_list_scheduled_messages):
messages = Messages(http_client_list_scheduled_messages)

Expand Down
26 changes: 25 additions & 1 deletion tests/utils/test_file_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from unittest.mock import patch, mock_open

from nylas.utils.file_utils import attach_file_request_builder, _build_form_request, encode_stream_to_base64
from nylas.utils.file_utils import (
attach_file_request_builder,
_build_form_request,
_build_raw_mime_form_request,
encode_stream_to_base64,
)


class TestFileUtils:
Expand Down Expand Up @@ -194,3 +199,22 @@ def test_build_form_request_encoding_comparison(self):
# Both should decode to the same value
assert json.loads(encoded_with_ascii)["subject"] == test_subject
assert json.loads(encoded_without_ascii)["subject"] == test_subject

def test_build_raw_mime_form_request(self):
mime = "MIME-Version: 1.0\r\nImportance: High\r\nSubject: Hi\r\n\r\nHello"

request = _build_raw_mime_form_request(mime)

assert request.fields == {"mime": ("message.eml", mime, "message/rfc822")}
assert request.content_type.startswith("multipart/form-data; boundary=")
body = request.to_string()
assert b'Content-Disposition: form-data; name="mime"; filename="message.eml"' in body
assert b"Content-Type: message/rfc822" in body
assert b"Importance: High" in body

def test_build_raw_mime_form_request_bytes_preserved(self):
mime = b"MIME-Version: 1.0\r\nSubject: Hi\r\n\r\n\xc3\xa9\xff"

request = _build_raw_mime_form_request(mime)

assert mime in request.to_string()
Loading