diff --git a/CHANGELOG.md b/CHANGELOG.md index f437221..4746969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ nylas-python Changelog ====================== +Unreleased +---------------- +* Added support for new message fields query parameter values: `include_tracking_options` and `raw_mime` +* Added `tracking_options` field to Message model for message tracking settings +* Added `raw_mime` field to Message model for Base64url-encoded message data +* Added TrackingOptions model for message tracking configuration +* Maintained backwards compatibility for existing message functionality + v6.9.0 ---------------- * Added support for for tentative_as_busy parameter to the availability request diff --git a/examples/message_fields_demo/README.md b/examples/message_fields_demo/README.md new file mode 100644 index 0000000..5b0a2f1 --- /dev/null +++ b/examples/message_fields_demo/README.md @@ -0,0 +1,99 @@ +# Message Fields Demo + +This example demonstrates the usage of the new message `fields` query parameter values (`include_tracking_options` and `raw_mime`) introduced in the Nylas API. These fields allow you to access tracking information and raw MIME data for messages. + +## Features Demonstrated + +1. **include_tracking_options Field**: Shows how to fetch messages with their tracking options (opens, thread_replies, links, and label). +2. **raw_mime Field**: Demonstrates how to retrieve the raw MIME content of messages as Base64url-encoded data. +3. **Backwards Compatibility**: Shows that existing code continues to work as expected without specifying the new fields. +4. **TrackingOptions Model**: Demonstrates working with the new TrackingOptions dataclass for serialization and deserialization. + +## API Fields Overview + +### include_tracking_options +When using `fields=include_tracking_options`, the API returns messages with their tracking settings: +- `opens`: Boolean indicating if message open tracking is enabled +- `thread_replies`: Boolean indicating if thread replied tracking is enabled +- `links`: Boolean indicating if link clicked tracking is enabled +- `label`: String label describing the message tracking purpose + +### raw_mime +When using `fields=raw_mime`, the API returns only essential fields plus the raw MIME content: +- `grant_id`: The grant identifier +- `object`: The object type ("message") +- `id`: The message identifier +- `raw_mime`: Base64url-encoded string containing the complete message data + +## 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" +``` + +3. Run the example from the repository root: +```bash +python examples/message_fields_demo/message_fields_example.py +``` + +## Example Output + +``` +Demonstrating Message Fields Usage +================================= + +=== Standard Message Fetching (Backwards Compatible) === +Fetching messages with standard fields... +✓ Found 2 messages with standard payload + +=== Include Tracking Options === +Fetching messages with tracking options... +✓ Found 2 messages with tracking data +Message tracking: opens=True, links=False, label="Campaign A" + +=== Raw MIME Content === +Fetching messages with raw MIME data... +✓ Found 2 messages with raw MIME content +Raw MIME length: 1245 characters + +=== TrackingOptions Model Demo === +Creating and serializing TrackingOptions... +✓ TrackingOptions serialization works correctly + +Example completed successfully! +``` + +## Use Cases + +### Tracking Options +- **Email Campaign Analytics**: Monitor open rates, link clicks, and thread engagement +- **Marketing Automation**: Track customer engagement with promotional emails +- **CRM Integration**: Feed tracking data into customer relationship management systems + +### Raw MIME +- **Email Archival**: Store complete email data including headers and formatting +- **Email Migration**: Transfer emails between systems with full fidelity +- **Security Analysis**: Examine email headers and structure for security purposes +- **Custom Email Parsing**: Build custom email processing pipelines + +## Error Handling + +The example includes proper error handling for: +- Missing environment variables +- API authentication errors +- Empty message collections +- Invalid field parameters + +## Documentation + +For more information about the Nylas Python SDK and message fields, visit: +- [Nylas Python SDK Documentation](https://developer.nylas.com/docs/sdks/python/) +- [Nylas API Messages Reference](https://developer.nylas.com/docs/api/v3/ecc/#tag--Messages) \ No newline at end of file diff --git a/examples/message_fields_demo/message_fields_example.py b/examples/message_fields_demo/message_fields_example.py new file mode 100644 index 0000000..5faa8d8 --- /dev/null +++ b/examples/message_fields_demo/message_fields_example.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +""" +Nylas SDK Example: Using Message Fields (include_tracking_options and raw_mime) + +This example demonstrates how to use the new 'fields' query parameter values +'include_tracking_options' and 'raw_mime' to access message tracking data and raw MIME content. + +Required Environment Variables: + NYLAS_API_KEY: Your Nylas API key + NYLAS_GRANT_ID: Your Nylas grant ID + +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" + python examples/message_fields_demo/message_fields_example.py +""" + +import os +import sys +import json +import base64 +from nylas import Client +from nylas.models.messages import TrackingOptions + + +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 print_separator(title: str) -> None: + """Print a formatted section separator.""" + print(f"\n=== {title} ===") + + +def demonstrate_standard_fields(client: Client, grant_id: str) -> None: + """Demonstrate backwards compatible message fetching (standard fields).""" + print_separator("Standard Message Fetching (Backwards Compatible)") + + try: + print("Fetching messages with standard fields...") + messages = client.messages.list( + identifier=grant_id, + query_params={"limit": 2} + ) + + if not messages.data: + print("⚠️ No messages found in this account") + return + + print(f"✓ Found {len(messages.data)} messages with standard payload") + + for i, message in enumerate(messages.data, 1): + print(f"\nMessage {i}:") + print(f" ID: {message.id}") + print(f" Subject: {message.subject or 'No subject'}") + print(f" From: {message.from_[0].email if message.from_ else 'Unknown'}") + print(f" Tracking Options: {message.tracking_options}") # Should be None + print(f" Raw MIME: {message.raw_mime}") # Should be None + + except Exception as e: + print(f"❌ Error fetching standard messages: {e}") + + +def demonstrate_tracking_options(client: Client, grant_id: str) -> None: + """Demonstrate fetching messages with tracking options.""" + print_separator("Include Tracking Options") + + try: + print("Fetching messages with tracking options...") + messages = client.messages.list( + identifier=grant_id, + query_params={ + "limit": 2, + "fields": "include_tracking_options" + } + ) + + if not messages.data: + print("⚠️ No messages found in this account") + return + + print(f"✓ Found {len(messages.data)} messages with tracking data") + + for i, message in enumerate(messages.data, 1): + print(f"\nMessage {i}:") + print(f" ID: {message.id}") + print(f" Subject: {message.subject or 'No subject'}") + + if message.tracking_options: + print(f" Tracking Options:") + print(f" Opens: {message.tracking_options.opens}") + print(f" Thread Replies: {message.tracking_options.thread_replies}") + print(f" Links: {message.tracking_options.links}") + print(f" Label: {message.tracking_options.label}") + else: + print(" Tracking Options: None (tracking not enabled for this message)") + + except Exception as e: + print(f"❌ Error fetching messages with tracking options: {e}") + + +def demonstrate_raw_mime(client: Client, grant_id: str) -> None: + """Demonstrate fetching messages with raw MIME content.""" + print_separator("Raw MIME Content") + + try: + print("Fetching messages with raw MIME data...") + messages = client.messages.list( + identifier=grant_id, + query_params={ + "limit": 2, + "fields": "raw_mime" + } + ) + + if not messages.data: + print("⚠️ No messages found in this account") + return + + print(f"✓ Found {len(messages.data)} messages with raw MIME content") + + for i, message in enumerate(messages.data, 1): + print(f"\nMessage {i}:") + print(f" ID: {message.id}") + print(f" Grant ID: {message.grant_id}") + print(f" Object: {message.object}") + + if message.raw_mime: + print(f" Raw MIME length: {len(message.raw_mime)} characters") + + # Decode a small portion to show it's real MIME data + try: + # Show first 200 characters of decoded MIME + decoded_sample = base64.urlsafe_b64decode( + message.raw_mime + '=' * (4 - len(message.raw_mime) % 4) + ).decode('utf-8', errors='ignore')[:200] + print(f" MIME preview: {decoded_sample}...") + except Exception as decode_error: + print(f" MIME preview: Unable to decode preview ({decode_error})") + else: + print(" Raw MIME: None") + + # Note: In raw_mime mode, most other fields should be None + print(f" Subject (should be None): {message.subject}") + print(f" Body (should be None): {getattr(message, 'body', 'N/A')}") + + except Exception as e: + print(f"❌ Error fetching messages with raw MIME: {e}") + + +def demonstrate_single_message_fields(client: Client, grant_id: str) -> None: + """Demonstrate fetching a single message with different field options.""" + print_separator("Single Message with Different Fields") + + try: + # First get a message ID + print("Finding a message to demonstrate single message field options...") + messages = client.messages.list( + identifier=grant_id, + query_params={"limit": 1} + ) + + if not messages.data: + print("⚠️ No messages found for single message demo") + return + + message_id = messages.data[0].id + print(f"Using message ID: {message_id}") + + # Fetch with tracking options + print("\nFetching single message with tracking options...") + message = client.messages.find( + identifier=grant_id, + message_id=message_id, + query_params={"fields": "include_tracking_options"} + ) + + print(f"✓ Message fetched with tracking: {message.tracking_options is not None}") + + # Fetch with raw MIME + print("\nFetching single message with raw MIME...") + message = client.messages.find( + identifier=grant_id, + message_id=message_id, + query_params={"fields": "raw_mime"} + ) + + print(f"✓ Message fetched with raw MIME: {message.raw_mime is not None}") + if message.raw_mime: + print(f" Raw MIME size: {len(message.raw_mime)} characters") + + except Exception as e: + print(f"❌ Error in single message demo: {e}") + + +def demonstrate_tracking_options_model() -> None: + """Demonstrate working with the TrackingOptions model directly.""" + print_separator("TrackingOptions Model Demo") + + try: + print("Creating TrackingOptions object...") + + # Create a TrackingOptions instance + tracking = TrackingOptions( + opens=True, + thread_replies=False, + links=True, + label="Marketing Campaign Demo" + ) + + print("✓ TrackingOptions created:") + print(f" Opens: {tracking.opens}") + print(f" Thread Replies: {tracking.thread_replies}") + print(f" Links: {tracking.links}") + print(f" Label: {tracking.label}") + + # Demonstrate serialization + print("\nSerializing to dict...") + tracking_dict = tracking.to_dict() + print(f"✓ Serialized: {json.dumps(tracking_dict, indent=2)}") + + # Demonstrate deserialization + print("\nDeserializing from dict...") + restored_tracking = TrackingOptions.from_dict(tracking_dict) + print(f"✓ Deserialized: opens={restored_tracking.opens}, label='{restored_tracking.label}'") + + # Demonstrate JSON serialization + print("\nJSON serialization...") + tracking_json = tracking.to_json() + print(f"✓ JSON: {tracking_json}") + + restored_from_json = TrackingOptions.from_json(tracking_json) + print(f"✓ From JSON: {restored_from_json.to_dict()}") + + except Exception as e: + print(f"❌ Error in TrackingOptions demo: {e}") + + +def main(): + """Main function demonstrating message fields usage.""" + # Get required environment variables + api_key = get_env_or_exit("NYLAS_API_KEY") + grant_id = get_env_or_exit("NYLAS_GRANT_ID") + + # Initialize Nylas client + client = Client(api_key=api_key) + + print("Demonstrating Message Fields Usage") + print("=================================") + print("This shows the new 'include_tracking_options' and 'raw_mime' field options") + + # Demonstrate different field options + demonstrate_standard_fields(client, grant_id) + demonstrate_tracking_options(client, grant_id) + demonstrate_raw_mime(client, grant_id) + demonstrate_single_message_fields(client, grant_id) + demonstrate_tracking_options_model() + + print("\n" + "="*50) + print("Example completed successfully!") + print("="*50) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/nylas/models/messages.py b/nylas/models/messages.py index ba1ea99..342df89 100644 --- a/nylas/models/messages.py +++ b/nylas/models/messages.py @@ -8,7 +8,7 @@ from nylas.models.events import EmailName -Fields = Literal["standard", "include_headers"] +Fields = Literal["standard", "include_headers", "include_tracking_options", "raw_mime"] """ Literal representing which headers to include with a message. """ @@ -27,6 +27,25 @@ class MessageHeader: value: str +@dataclass_json +@dataclass +class TrackingOptions: + """ + Message tracking options. + + Attributes: + opens: When true, shows that message open tracking is enabled. + thread_replies: When true, shows that thread replied tracking is enabled. + links: When true, shows that link clicked tracking is enabled. + label: A label describing the message tracking purpose. + """ + + opens: Optional[bool] = None + thread_replies: Optional[bool] = None + links: Optional[bool] = None + label: Optional[str] = None + + @dataclass_json @dataclass class Message: @@ -55,6 +74,8 @@ class Message: created_at: Unix timestamp of when the message was created. schedule_id: The ID of the scheduled email message. Nylas returns the schedule_id if send_at is set. send_at: Unix timestamp of when the message will be sent, if scheduled. + tracking_options: The tracking options for the message. + raw_mime: A Base64url-encoded string containing the message data (including the body content). """ grant_id: str @@ -81,6 +102,8 @@ class Message: schedule_id: Optional[str] = None send_at: Optional[int] = None metadata: Optional[Dict[str, Any]] = None + tracking_options: Optional[TrackingOptions] = None + raw_mime: Optional[str] = None # Need to use Functional typed dicts because "from" and "in" are Python @@ -124,7 +147,11 @@ class Message: 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. + fields: Specify which headers to include in the response. + - "standard" (default): Returns the standard message payload. + - "include_headers": Returns messages and their custom headers. + - "include_tracking_options": Returns messages and their tracking settings. + - "raw_mime": Returns the grant_id, object, id, and raw_mime fields for each message. search_query_native: A native provider search query for Google or Microsoft. select: Comma-separated list of fields to return in the response. This allows you to receive only the portion of object data that you're interested in. @@ -140,7 +167,11 @@ 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: Specify which headers to include in the response. + - "standard" (default): Returns the standard message payload. + - "include_headers": Returns messages and their custom headers. + - "include_tracking_options": Returns messages and their tracking settings. + - "raw_mime": Returns the grant_id, object, id, and raw_mime fields for each message. select: Comma-separated list of fields to return in the response. This allows you to receive only the portion of object data that you're interested in. """ diff --git a/tests/resources/test_messages.py b/tests/resources/test_messages.py index 4577bc7..50f071c 100644 --- a/tests/resources/test_messages.py +++ b/tests/resources/test_messages.py @@ -732,3 +732,161 @@ def test_find_message_select_param(self, http_client_response): # Make sure query params are properly serialized assert http_client_response._execute.call_args[0][3] == {"select": ["id", "subject", "from", "to"]} + + # New tests for tracking_options and raw_mime features + def test_message_deserialization_with_tracking_options(self): + """Test deserialization of message with tracking_options field.""" + message_json = { + "body": "Hello, I just sent a message using Nylas!", + "from": [{"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}], + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "5d3qmne77v32r8l4phyuksl2x", + "object": "message", + "subject": "Hello from Nylas!", + "tracking_options": { + "opens": True, + "thread_replies": False, + "links": True, + "label": "Marketing Campaign" + } + } + + message = Message.from_dict(message_json) + + assert message.tracking_options is not None + assert message.tracking_options.opens is True + assert message.tracking_options.thread_replies is False + assert message.tracking_options.links is True + assert message.tracking_options.label == "Marketing Campaign" + + def test_message_deserialization_with_raw_mime(self): + """Test deserialization of message with raw_mime field.""" + message_json = { + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "5d3qmne77v32r8l4phyuksl2x", + "object": "message", + "raw_mime": "TUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluOyBjaGFyc2V0PXV0Zi04CgpIZWxsbyBXb3JsZCE=" + } + + message = Message.from_dict(message_json) + + assert message.raw_mime == "TUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluOyBjaGFyc2V0PXV0Zi04CgpIZWxsbyBXb3JsZCE=" + + def test_message_deserialization_backwards_compatibility(self): + """Test that existing message deserialization still works (backwards compatibility).""" + message_json = { + "body": "Hello, I just sent a message using Nylas!", + "from": [{"name": "Daenerys Targaryen", "email": "daenerys.t@example.com"}], + "grant_id": "41009df5-bf11-4c97-aa18-b285b5f2e386", + "id": "5d3qmne77v32r8l4phyuksl2x", + "object": "message", + "subject": "Hello from Nylas!", + } + + message = Message.from_dict(message_json) + + # These new fields should be None when not provided + assert message.tracking_options is None + assert message.raw_mime is None + # Existing fields should still work + assert message.body == "Hello, I just sent a message using Nylas!" + assert message.subject == "Hello from Nylas!" + + def test_list_messages_with_include_tracking_options_field(self, http_client_list_response): + """Test listing messages with include_tracking_options field.""" + messages = Messages(http_client_list_response) + + messages.list( + identifier="abc-123", + query_params={"fields": "include_tracking_options"}, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages", + None, + {"fields": "include_tracking_options"}, + None, + overrides=None, + ) + + def test_list_messages_with_raw_mime_field(self, http_client_list_response): + """Test listing messages with raw_mime field.""" + messages = Messages(http_client_list_response) + + messages.list( + identifier="abc-123", + query_params={"fields": "raw_mime"}, + ) + + http_client_list_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages", + None, + {"fields": "raw_mime"}, + None, + overrides=None, + ) + + def test_find_message_with_include_tracking_options_field(self, http_client_response): + """Test finding a message with include_tracking_options field.""" + messages = Messages(http_client_response) + + messages.find( + identifier="abc-123", + message_id="message-123", + query_params={"fields": "include_tracking_options"}, + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/message-123", + None, + {"fields": "include_tracking_options"}, + None, + overrides=None, + ) + + def test_find_message_with_raw_mime_field(self, http_client_response): + """Test finding a message with raw_mime field.""" + messages = Messages(http_client_response) + + messages.find( + identifier="abc-123", + message_id="message-123", + query_params={"fields": "raw_mime"}, + ) + + http_client_response._execute.assert_called_once_with( + "GET", + "/v3/grants/abc-123/messages/message-123", + None, + {"fields": "raw_mime"}, + None, + overrides=None, + ) + + def test_tracking_options_serialization(self): + """Test that tracking_options can be serialized to JSON.""" + from nylas.models.messages import TrackingOptions + + tracking_options = TrackingOptions( + opens=True, + thread_replies=False, + links=True, + label="Test Campaign" + ) + + # Test serialization + json_data = tracking_options.to_dict() + assert json_data["opens"] is True + assert json_data["thread_replies"] is False + assert json_data["links"] is True + assert json_data["label"] == "Test Campaign" + + # Test deserialization + tracking_options_from_dict = TrackingOptions.from_dict(json_data) + assert tracking_options_from_dict.opens is True + assert tracking_options_from_dict.thread_replies is False + assert tracking_options_from_dict.links is True + assert tracking_options_from_dict.label == "Test Campaign" \ No newline at end of file