diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ba46d..df5a447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ---------- diff --git a/examples/raw_mime_send_demo/README.md b/examples/raw_mime_send_demo/README.md new file mode 100644 index 0000000..9edda6e --- /dev/null +++ b/examples/raw_mime_send_demo/README.md @@ -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 \r\n" + b"To: Recipient \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()`. diff --git a/examples/raw_mime_send_demo/raw_mime_send_example.py b/examples/raw_mime_send_demo/raw_mime_send_example.py new file mode 100644 index 0000000..d159a45 --- /dev/null +++ b/examples/raw_mime_send_demo/raw_mime_send_example.py @@ -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( + "

Hello from a raw MIME message!

", 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() diff --git a/nylas/models/messages.py b/nylas/models/messages.py index beae725..e77e26f 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -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 @@ -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): diff --git a/nylas/resources/messages.py b/nylas/resources/messages.py index 1e0b1fb..cbaf9b6 100644 --- a/nylas/resources/messages.py +++ b/nylas/resources/messages.py @@ -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, ) @@ -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]]: diff --git a/nylas/utils/file_utils.py b/nylas/utils/file_utils.py index ece1e65..7df0f0c 100644 --- a/nylas/utils/file_utils.py +++ b/nylas/utils/file_utils.py @@ -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")}) diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 5d765b9..965d68e 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -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) diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py index 4394b52..76435ad 100644 --- a/tests/utils/test_file_utils.py +++ b/tests/utils/test_file_utils.py @@ -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: @@ -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()