diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f96548..0b2bbd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ nylas-python Changelog ====================== Unreleased --------------- +---------------- * Add support for Scheduler APIs * Fixed attachment download response handling +* Add metadata field support for drafts and messages through CreateDraftRequest model v6.4.0 ---------------- diff --git a/examples/metadata_field_demo/README.md b/examples/metadata_field_demo/README.md new file mode 100644 index 0000000..f5310f3 --- /dev/null +++ b/examples/metadata_field_demo/README.md @@ -0,0 +1,67 @@ +# Metadata Field Example + +This example demonstrates how to use metadata fields when creating drafts and sending messages using the Nylas Python SDK. + +## Features + +- Create drafts with custom metadata fields +- Send messages with custom metadata fields +- Error handling and environment variable configuration +- Clear output and status messages + +## Prerequisites + +1. A Nylas account with API access +2. Python 3.x installed +3. Local installation of the Nylas Python SDK (this repository) + +## Setup + +1. Install the SDK in development mode from the repository root: +```bash +cd /path/to/nylas-python +pip install -e . +``` + +2. Set your environment variables: +```bash +export NYLAS_API_KEY="your_api_key" +export NYLAS_GRANT_ID="your_grant_id" +export TEST_EMAIL="recipient@example.com" # Optional +``` + +3. Run the example from the repository root: +```bash +python examples/metadata_field_demo/metadata_example.py +``` + +## Example Output + +``` +Demonstrating Metadata Field Usage +================================= + +1. Creating draft with metadata... +✓ Created draft with ID: draft-abc123 + Request ID: req-xyz789 + +2. Sending message with metadata... +✓ Sent message with ID: msg-def456 + Request ID: req-uvw321 + +Example completed successfully! +``` + +## Error Handling + +The example includes proper error handling for: +- Missing environment variables +- API authentication errors +- Draft creation failures +- Message sending failures + +## Documentation + +For more information about the Nylas Python SDK and its features, visit: +- [Nylas Python SDK Documentation](https://developer.nylas.com/docs/sdks/python/) +- [Nylas API Reference](https://developer.nylas.com/docs/api/) diff --git a/examples/metadata_field_demo/metadata_example.py b/examples/metadata_field_demo/metadata_example.py new file mode 100644 index 0000000..737dbaf --- /dev/null +++ b/examples/metadata_field_demo/metadata_example.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Using Metadata Fields with Drafts and Messages + +This example demonstrates how to use metadata fields when creating drafts +and sending messages using the Nylas Python SDK. + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID + TEST_EMAIL: Email address for sending test messages (optional) + +Usage: + First, install the SDK in development mode: + cd /path/to/nylas-python + pip install -e . + + Then set environment variables and run: + export NYLAS_API_KEY="your_api_key" + export NYLAS_GRANT_ID="your_grant_id" + export TEST_EMAIL="recipient@example.com" + python examples/metadata_field_demo/metadata_example.py +""" + +import os +import sys +from typing import Dict, Any, Optional + +# Import from local nylas package +from nylas import Client +from nylas.models.errors import NylasApiError + + +def get_env_or_exit(var_name: str, required: bool = True) -> Optional[str]: + """Get an environment variable or exit if required and not found.""" + value = os.getenv(var_name) + if required and not value: + print(f"Error: {var_name} environment variable is required") + sys.exit(1) + return value + + +def create_draft_with_metadata( + client: Client, grant_id: str, metadata: Dict[str, Any], recipient: str +) -> str: + """Create a draft message with metadata fields.""" + try: + draft_request = { + "subject": "Test Draft with Metadata", + "to": [{"email": recipient}], + "body": "This is a test draft with metadata fields.", + "metadata": metadata + } + + draft, request_id = client.drafts.create( + identifier=grant_id, + request_body=draft_request + ) + print(f"✓ Created draft with ID: {draft.id}") + print(f" Request ID: {request_id}") + return draft.id + except NylasApiError as e: + print(f"✗ Failed to create draft: {e}") + sys.exit(1) + + +def send_message_with_metadata( + client: Client, grant_id: str, metadata: Dict[str, Any], recipient: str +) -> str: + """Send a message directly with metadata fields.""" + try: + message_request = { + "subject": "Test Message with Metadata", + "to": [{"email": recipient}], + "body": "This is a test message with metadata fields.", + "metadata": metadata + } + + message, request_id = client.messages.send( + identifier=grant_id, + request_body=message_request + ) + print(f"✓ Sent message with ID: {message.id}") + print(f" Request ID: {request_id}") + + return message.id + except NylasApiError as e: + print(f"✗ Failed to send message: {e}") + sys.exit(1) + + +def main(): + """Main function demonstrating metadata field usage.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + recipient = get_env_or_exit("TEST_EMAIL", required=False) or "recipient@example.com" + + # Initialize Nylas client + client = Client( + api_key=api_key, + ) + + # Example metadata + metadata = { + "campaign_id": "example-123", + "user_id": "user-456", + "custom_field": "test-value" + } + + print("\nDemonstrating Metadata Field Usage") + print("=================================") + + # Create a draft with metadata + print("\n1. Creating draft with metadata...") + draft_id = create_draft_with_metadata(client, grant_id, metadata, recipient) + + # Send a message with metadata + print("\n2. Sending message with metadata...") + message_id = send_message_with_metadata(client, grant_id, metadata, recipient) + + print("\nExample completed successfully!") + + # Get the draft and message to demonstrate metadata retrieval + draft = client.drafts.find(identifier=grant_id, draft_id=draft_id) + message = client.messages.find(identifier=grant_id, message_id=message_id) + + print("\nRetrieved Draft Metadata:") + print("-------------------------") + print(draft.data) + + print("\nRetrieved Message Metadata:") + print("---------------------------") + print(message.data) + +if __name__ == "__main__": + main() diff --git a/nylas/models/drafts.py b/nylas/models/drafts.py index f77a125..f6e3803 100644 --- a/nylas/models/drafts.py +++ b/nylas/models/drafts.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, get_type_hints +from typing import List, Dict, Any, get_type_hints from dataclasses_json import dataclass_json from typing_extensions import TypedDict, NotRequired @@ -87,6 +87,7 @@ class CreateDraftRequest(TypedDict): reply_to_message_id: The ID of the message that you are replying to. tracking_options: Options for tracking opens, links, and thread replies. custom_headers: Custom headers to add to the message. + metadata: A dictionary of key-value pairs storing additional data. """ body: NotRequired[str] @@ -101,6 +102,7 @@ class CreateDraftRequest(TypedDict): reply_to_message_id: NotRequired[str] tracking_options: NotRequired[TrackingOptions] custom_headers: NotRequired[List[CustomHeader]] + metadata: NotRequired[Dict[str, Any]] UpdateDraftRequest = CreateDraftRequest diff --git a/nylas/models/messages.py b/nylas/models/messages.py index 7d5d405..b6f0bbd 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -80,6 +80,7 @@ class Message: date: Optional[int] = None schedule_id: Optional[str] = None send_at: Optional[int] = None + metadata: Optional[Dict[str, Any]] = None # Need to use Functional typed dicts because "from" and "in" are Python diff --git a/tests/resources/test_drafts.py b/tests/resources/test_drafts.py index ecd51eb..8613a2f 100644 --- a/tests/resources/test_drafts.py +++ b/tests/resources/test_drafts.py @@ -2,6 +2,7 @@ from nylas.models.drafts import Draft from nylas.resources.drafts import Drafts +from nylas.resources.messages import Messages class TestDraft: @@ -143,6 +144,27 @@ def test_create_draft(self, http_client_response): overrides=None, ) + def test_create_draft_with_metadata(self, http_client_response): + drafts = Drafts(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + "metadata": {"custom_field": "value", "another_field": 123} + } + + drafts.create(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/grants/abc-123/drafts", + None, + None, + request_body, + overrides=None, + ) + def test_create_draft_small_attachment(self, http_client_response): drafts = Drafts(http_client_response) request_body = { @@ -349,6 +371,25 @@ def test_send_draft(self, http_client_response): method="POST", path="/v3/grants/abc-123/drafts/draft-123", overrides=None ) + def test_send_message_with_metadata(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "body": "This is the body of my message.", + "metadata": {"custom_field": "value", "another_field": 123} + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + def test_send_draft_encoded_id(self, http_client_response): drafts = Drafts(http_client_response) diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 1af9a95..5e9b372 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -37,6 +37,7 @@ def test_message_deserialization(self): "thread_id": "1t8tv3890q4vgmwq6pmdwm8qgsaer", "to": [{"name": "Jon Snow", "email": "j.snow@example.com"}], "unread": True, + "metadata": {"custom_field": "value", "another_field": 123}, } message = Message.from_dict(message_json) @@ -64,6 +65,341 @@ def test_message_deserialization(self): assert message.thread_id == "1t8tv3890q4vgmwq6pmdwm8qgsaer" assert message.to == [{"name": "Jon Snow", "email": "j.snow@example.com"}] assert message.unread is True + assert message.metadata == {"custom_field": "value", "another_field": 123} + + def test_list_messages(self, http_client_list_response): + messages = Messages(http_client_list_response) + + messages.list(identifier="abc-123") + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/grants/abc-123/messages", None, None, None, overrides=None + ) + + def test_list_messages_with_query_params(self, http_client_list_response): + messages = Messages(http_client_list_response) + + messages.list( + identifier="abc-123", + query_params={ + "subject": "Hello from Nylas!", + }, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages", + None, + { + "subject": "Hello from Nylas!", + }, + None, + overrides=None, + ) + + def test_find_message(self, http_client_response): + messages = Messages(http_client_response) + + messages.find(identifier="abc-123", message_id="message-123") + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/message-123", + None, + None, + None, + overrides=None, + ) + + def test_find_message_encoded_id(self, http_client_response): + messages = Messages(http_client_response) + + messages.find( + identifier="abc-123", + message_id="", + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + None, + overrides=None, + ) + + def test_find_message_with_query_params(self, http_client_response): + messages = Messages(http_client_response) + + messages.find( + identifier="abc-123", + message_id="message-123", + query_params={"fields": "standard"}, + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/message-123", + None, + {"fields": "standard"}, + None, + overrides=None, + ) + + def test_update_message(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "starred": True, + "unread": False, + "folders": ["folder-123"], + "metadata": {"foo": "bar"}, + } + + messages.update( + identifier="abc-123", + message_id="message-123", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/messages/message-123", + None, + None, + request_body, + overrides=None, + ) + + def test_update_message_encoded_id(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "starred": True, + "unread": False, + "folders": ["folder-123"], + "metadata": {"foo": "bar"}, + } + + messages.update( + identifier="abc-123", + message_id="", + request_body=request_body, + ) + + http_client_response._execute.assert_called_once_with( + "PUT", + "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + request_body, + overrides=None, + ) + + def test_destroy_message(self, http_client_delete_response): + messages = Messages(http_client_delete_response) + + messages.destroy(identifier="abc-123", message_id="message-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/messages/message-123", + None, + None, + None, + overrides=None, + ) + + def test_destroy_message_encoded_id(self, http_client_delete_response): + messages = Messages(http_client_delete_response) + + messages.destroy( + identifier="abc-123", + message_id="", + ) + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", + "/v3/grants/abc-123/messages/%3C%21%26%21AAAAAAAAAAAuAAAAAAAAABQ%2FwHZyqaNCptfKg5rnNAoBAMO2jhD3dRHOtM0AqgC7tuYAAAAAAA4AABAAAACTn3BxdTQ%2FT4N%2F0BgqPmf%2BAQAAAAA%3D%40example.com%3E", + None, + None, + None, + overrides=None, + ) + + def test_send_message(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + "metadata": {"custom_field": "value", "another_field": 123}, + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + + def test_send_message_small_attachment(self, http_client_response): + messages = Messages(http_client_response) + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3, + }, + ], + "metadata": {"custom_field": "value", "another_field": 123}, + } + + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=request_body, + data=None, + overrides=None, + ) + + def test_send_message_large_attachment(self, http_client_response): + messages = Messages(http_client_response) + mock_encoder = Mock() + request_body = { + "subject": "Hello from Nylas!", + "to": [{"name": "Jon Snow", "email": "jsnow@gmail.com"}], + "cc": [{"name": "Arya Stark", "email": "astark@gmail.com"}], + "body": "This is the body of my draft message.", + "attachments": [ + { + "filename": "file1.txt", + "content_type": "text/plain", + "content": "this is a file", + "size": 3 * 1024 * 1024, + }, + ], + "metadata": {"custom_field": "value", "another_field": 123}, + } + + with patch( + "nylas.resources.messages._build_form_request", return_value=mock_encoder + ): + messages.send(identifier="abc-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/grants/abc-123/messages/send", + request_body=None, + data=mock_encoder, + overrides=None, + ) + + def test_list_scheduled_messages(self, http_client_list_scheduled_messages): + messages = Messages(http_client_list_scheduled_messages) + + res = messages.list_scheduled_messages(identifier="abc-123") + + http_client_list_scheduled_messages._execute.assert_called_once_with( + method="GET", path="/v3/grants/abc-123/messages/schedules", overrides=None + ) + assert res.request_id == "dd3ec9a2-8f15-403d-b269-32b1f1beb9f5" + assert len(res.data) == 2 + assert res.data[0].schedule_id == "8cd56334-6d95-432c-86d1-c5dab0ce98be" + assert res.data[0].status.code == "pending" + assert res.data[0].status.description == "schedule send awaiting send at time" + assert res.data[1].schedule_id == "rb856334-6d95-432c-86d1-c5dab0ce98be" + assert res.data[1].status.code == "success" + assert res.data[1].status.description == "schedule send succeeded" + assert res.data[1].close_time == 1690579819 + + def test_find_scheduled_message(self, http_client_response): + messages = Messages(http_client_response) + + messages.find_scheduled_message( + identifier="abc-123", schedule_id="schedule-123" + ) + + http_client_response._execute.assert_called_once_with( + method="GET", + path="/v3/grants/abc-123/messages/schedules/schedule-123", + overrides=None, + ) + + def test_stop_scheduled_message(self, http_client_response): + messages = Messages(http_client_response) + + messages.stop_scheduled_message( + identifier="abc-123", schedule_id="schedule-123" + ) + + http_client_response._execute.assert_called_once_with( + method="DELETE", + path="/v3/grants/abc-123/messages/schedules/schedule-123", + overrides=None, + ) + + def test_clean_messages(self, http_client_clean_messages): + messages = Messages(http_client_clean_messages) + request_body = { + "message_id": ["message-1", "message-2"], + "ignore_images": True, + "ignore_links": True, + "ignore_tables": True, + "images_as_markdown": True, + "remove_conclusion_phrases": True, + } + + response = messages.clean_messages( + identifier="abc-123", + request_body=request_body, + ) + + http_client_clean_messages._execute.assert_called_once_with( + method="PUT", + path="/v3/grants/abc-123/messages/clean", + request_body=request_body, + overrides=None, + ) + + # Assert the conversation field, and the typical message fields serialize properly + assert len(response.data) == 2 + assert response.data[0].body == "Hello, I just sent a message using Nylas!" + assert response.data[0].from_ == [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ] + assert response.data[0].object == "message" + assert response.data[0].id == "message-1" + assert response.data[0].grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert response.data[0].conversation == "cleaned example" + assert response.data[1].conversation == "another example" + assert message.folders[0] == "8l6c4d11y1p4dm4fxj52whyr9" + assert message.folders[1] == "d9zkcr2tljpu3m4qpj7l2hbr0" + assert message.from_ == [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ] + assert message.grant_id == "41009df5-bf11-4c97-aa18-b285b5f2e386" + assert message.id == "5d3qmne77v32r8l4phyuksl2x" + assert message.object == "message" + assert message.reply_to == [ + {"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"} + ] + assert message.snippet == "Hello, I just sent a message using Nylas!" + assert message.starred is True + assert message.subject == "Hello from Nylas!" + assert message.thread_id == "1t8tv3890q4vgmwq6pmdwm8qgsaer" + assert message.to == [{"name": "Jon Snow", "email": "j.snow@example.com"}] + assert message.unread is True def test_list_messages(self, http_client_list_response): messages = Messages(http_client_list_response)