diff --git a/CHANGELOG.md b/CHANGELOG.md index 348b2302..be2411d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ nylas-python Changelog Unreleased ---------- * Fixed from field handling in messages.send() to properly map "from_" field to "from field +* Fixed content_id handling for large inline attachments to use content_id as field name instead of generic file{index} v6.12.0 ---------- diff --git a/examples/inline_attachment_demo/README.md b/examples/inline_attachment_demo/README.md new file mode 100644 index 00000000..5baaca0d --- /dev/null +++ b/examples/inline_attachment_demo/README.md @@ -0,0 +1,65 @@ +# Inline Attachment Example + +This example demonstrates how to send messages and drafts with inline attachments using the `content_id` field in the Nylas Python SDK. + +## What This Example Shows + +- How to create inline attachments with `content_id` for HTML emails +- How the SDK properly handles `content_id` for large attachments (>3MB) +- The difference between inline attachments and regular attachments +- How to reference inline attachments in HTML email bodies using `cid:` syntax + +## Key Features Demonstrated + +### Content ID Usage +When an attachment includes a `content_id` field, the SDK will use this as the field name in multipart form data instead of the generic `file{index}` pattern. This is crucial for inline attachments that need to be referenced in the email body. + +### HTML Email with Inline Images +The example shows how to: +1. Set the `content_id` field in the attachment +2. Reference the attachment in HTML using `src="cid:your-content-id"` +3. Set appropriate inline properties (`is_inline: True`, `content_disposition: "inline"`) + +### Large Attachment Handling +For attachments larger than 3MB, the SDK automatically switches from JSON to multipart form data. With this fix, the `content_id` is now properly respected in the form field names. + +## Running the Example + +1. Set your Nylas API key: + ```bash + export NYLAS_API_KEY='your-api-key-here' + ``` + +2. Update the grant ID and email addresses in the script + +3. Run the example: + ```bash + python inline_attachment_example.py + ``` + +## Important Notes + +- **Content ID Format**: Use a unique identifier for each inline attachment (e.g., `"image1@example.com"`, `"logo"`, `"banner-image"`) +- **HTML Reference**: Reference inline attachments in HTML using `src="cid:your-content-id"` +- **Backward Compatibility**: Attachments without `content_id` still work as before using `file{index}` naming +- **File Size Threshold**: The 3MB threshold determines whether JSON or form data is used for the request + +## Expected Behavior + +### Before the Fix (Problematic) +``` +Form data fields: +- message: (JSON payload) +- file0: (inline image - content_id ignored) +- file1: (regular attachment) +``` + +### After the Fix (Correct) +``` +Form data fields: +- message: (JSON payload) +- my-inline-image: (inline image - uses content_id) +- file1: (regular attachment - fallback to file{index}) +``` + +This ensures that email clients can properly display inline images by matching the `content_id` in the HTML `cid:` reference with the multipart form field name. diff --git a/examples/inline_attachment_demo/inline_attachment_example.py b/examples/inline_attachment_demo/inline_attachment_example.py new file mode 100644 index 00000000..21fc1bc0 --- /dev/null +++ b/examples/inline_attachment_demo/inline_attachment_example.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 + +import base64 +import io +import os +from nylas import Client + + +def send_message_with_inline_attachment(): + """ + This example demonstrates how to send a message with an inline attachment + that uses a content_id for referencing in HTML email bodies. + + This is particularly useful for embedding images directly in HTML emails + where the image is referenced using 'cid:' in the src attribute. + """ + + # Initialize the Nylas client + nylas = Client( + api_key=os.environ.get("NYLAS_API_KEY"), # Replace with your API key + ) + + # Get test email + test_email = os.environ.get("TEST_EMAIL") + + # Get grant + grant_id = os.environ.get("NYLAS_GRANT_ID") + + # Create a sample image content using base64 decoded data + # This is a small PNG image that can be used for demonstration + base64_image = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEQUlEQVRYhe2WW2wUVRyHv5nZS7vb3XbLdrfXjSZ9AxOqDRANGjDRSKLRKoIYNLENpEgJRQIKKvSi0tI28oImhtISqg9cTEzUhIgmSkAQY4UWQ2yALi3d7S4UaPcyO7MzPkwlJXG3S02sD5yH8zLJ//vO7/zPmSMsqR0MMIvDBHhnU0CcTfh9gf+FgOnfFlCTIKs6mg6iABaTgFn6DwQ0DUYnNBaVW3iyMpvcHImJmMaPvTG+75PxOkSkDPKdkUBC1bFZRb76sIhyn/WubyufyePaqEJdW5DAWBKrWUhb6557QFZ13E6Jb/eUUe6zcuzkOMu3DlG0/DLPbrrKl8dvUewxc6S1lJI5EqqWvp6wpHZQzxiu6HjyJI60lCKKsLkjwI1xjfdr3PiKLYyMKnQcvE7oVpIDjSWEx1QW1w1R5Ey9zowTkBUd7xR4fVsAm1Wgc0cxD5RY6P8zRrHHTPtbhZS6TXzxzU3cLhNVC7NJqKnXmJGArOgUuiQOtxrwjbsDOG0ijW96kRM6T2+4yorGIAuqB1FUnc2vu9lz9BYAC+ZmkVBT155WQFZ0ilwSh1tKEQXY0DqCK0ekYZ2HeEJn6Xo/t6MahU6RuKJztj9Kfq7EwA1j8y1mkXR7nFZASeoU50scai1FEKCuZQS3U2JHrYe4rLGgZpDmmjn4CiRkVcdmFaica2PsdpLyfKN0QtFIdw5SHkNdB6tZ4FBLKQDrd43gdUm8t9ZDTNaorPbzycYCHq+0Y8sSWdMe4sTeMswmgd1dYTZU5QJwpj+OJc1hT5uApsGAX6Zu1wiF+ZPwuMbD1X4+rTfgv5yPUtUU5MTeMhx2kY4DYQaDKquW5REeUzl6OobFlDqDlAKCYKSwuP4aZV4T767xEI1rVFT7+WxTAYsfsXPmfJTnGoL07fPhsIu0d4c5e1Gm54MSANa1BCiwp2+ztDfheFxny/MO6le7icY0Kmr8dG4u4LEKO6fPRXmhKciFTh8up8TurjC9AzKfT8Jf3T7M0PUkWdPchCkFlCQ8Mc9K/Wo3kZix8q4tBTw6387Pv0d4sXmUC50+8hwSrftDnLuUoKfZgK/aPszlUZXsaeCQZgskEa4EVH76NcKitX66txrwU70RXpoCb+kM0XcpwcEmA/7KtmGuZAhPm4AoQHhcY1lTkO+aCln4kI2Tv0VY8dEo/ft95OZI7NoX4o/BBAcm4Su3DeMPqdPGnpEAQHhC44fmQirnGfA32kL0d/pw5hixX7qm0N1owFe8M8TV8PR7nrFARNZpeM1lwHsjPLUzyLGdXpw5Em3dYT7+eoLjrUUAvPz2UEYN908j5d8woeosnZ/N3ActNPTcxOMQybOJlHkkTl1M4MgSUJNGryQ1HbN07/C0An9LKEmwW43img5JjTtPLn1yEmbGBqbpAYtJuOsaFQUQp7z3hDvTzMesv4rvC8y6gAmIzKbAX+u0pDGsEb6KAAAAAElFTkSuQmCC" + image_content = base64.b64decode(base64_image) + + # Create the message with inline attachment + message_request = { + "to": [{"email": test_email, "name": "Recipient Name"}], + "from": [{"email": test_email, "name": "Sender Name"}], + "subject": "Message with Inline Image", + "body": """ + + +

Hello!

+

This email contains an inline image:

+ Inline Image +

The image above is embedded directly in the email using content_id.

+ + + """, + "attachments": [ + { + "filename": "inline-image.png", + "content_type": "image/png", + "content": io.BytesIO(image_content), + "size": len(image_content), + "content_id": "my-inline-image", # This is the key for inline attachments + "is_inline": True, + "content_disposition": "inline" + }, + { + # Regular attachment without content_id for comparison + "filename": "regular-attachment.txt", + "content_type": "text/plain", + "content": io.BytesIO(b"This is a regular attachment"), + "size": 28, + # No content_id - this will use the default file{index} naming + } + ] + } + + try: + # Send the message + response = nylas.messages.send( + identifier=grant_id, # Replace with your grant ID + request_body=message_request + ) + + print("Message sent successfully!") + print(f"Message ID: {response.data.id}") + print(f"Thread ID: {response.data.thread_id}") + + # The inline attachment will be referenced by its content_id in the form data + # instead of a generic file{index} name, allowing proper inline display + + except Exception as e: + print(f"Error sending message: {e}") + + +def send_draft_with_inline_attachment(): + """ + This example demonstrates how to create and send a draft with an inline attachment. + """ + + # Initialize the Nylas client + nylas = Client( + api_key=os.environ.get("NYLAS_API_KEY"), # Replace with your API key + ) + + # Get test email + test_email = os.environ.get("TEST_EMAIL") + + # Get grant + grant_id = os.environ.get("NYLAS_GRANT_ID") + + # Create a larger image content to trigger form data usage (>3MB threshold) + # For demo purposes, we'll replicate the same image data multiple times + # In real usage, large images would automatically use the content_id functionality + base64_image = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEQUlEQVRYhe2WW2wUVRyHv5nZS7vb3XbLdrfXjSZ9AxOqDRANGjDRSKLRKoIYNLENpEgJRQIKKvSi0tI28oImhtISqg9cTEzUhIgmSkAQY4UWQ2yALi3d7S4UaPcyO7MzPkwlJXG3S02sD5yH8zLJ//vO7/zPmSMsqR0MMIvDBHhnU0CcTfh9gf+FgOnfFlCTIKs6mg6iABaTgFn6DwQ0DUYnNBaVW3iyMpvcHImJmMaPvTG+75PxOkSkDPKdkUBC1bFZRb76sIhyn/WubyufyePaqEJdW5DAWBKrWUhb6557QFZ13E6Jb/eUUe6zcuzkOMu3DlG0/DLPbrrKl8dvUewxc6S1lJI5EqqWvp6wpHZQzxiu6HjyJI60lCKKsLkjwI1xjfdr3PiKLYyMKnQcvE7oVpIDjSWEx1QW1w1R5Ey9zowTkBUd7xR4fVsAm1Wgc0cxD5RY6P8zRrHHTPtbhZS6TXzxzU3cLhNVC7NJqKnXmJGArOgUuiQOtxrwjbsDOG0ijW96kRM6T2+4yorGIAuqB1FUnc2vu9lz9BYAC+ZmkVBT155WQFZ0ilwSh1tKEQXY0DqCK0ekYZ2HeEJn6Xo/t6MahU6RuKJztj9Kfq7EwA1j8y1mkXR7nFZASeoU50scai1FEKCuZQS3U2JHrYe4rLGgZpDmmjn4CiRkVcdmFaica2PsdpLyfKN0QtFIdw5SHkNdB6tZ4FBLKQDrd43gdUm8t9ZDTNaorPbzycYCHq+0Y8sSWdMe4sTeMswmgd1dYTZU5QJwpj+OJc1hT5uApsGAX6Zu1wiF+ZPwuMbD1X4+rTfgv5yPUtUU5MTeMhx2kY4DYQaDKquW5REeUzl6OobFlDqDlAKCYKSwuP4aZV4T767xEI1rVFT7+WxTAYsfsXPmfJTnGoL07fPhsIu0d4c5e1Gm54MSANa1BCiwp2+ztDfheFxny/MO6le7icY0Kmr8dG4u4LEKO6fPRXmhKciFTh8up8TurjC9AzKfT8Jf3T7M0PUkWdPchCkFlCQ8Mc9K/Wo3kZix8q4tBTw6387Pv0d4sXmUC50+8hwSrftDnLuUoKfZgK/aPszlUZXsaeCQZgskEa4EVH76NcKitX66txrwU70RXpoCb+kM0XcpwcEmA/7KtmGuZAhPm4AoQHhcY1lTkO+aCln4kI2Tv0VY8dEo/ft95OZI7NoX4o/BBAcm4Su3DeMPqdPGnpEAQHhC44fmQirnGfA32kL0d/pw5hixX7qm0N1owFe8M8TV8PR7nrFARNZpeM1lwHsjPLUzyLGdXpw5Em3dYT7+eoLjrUUAvPz2UEYN908j5d8woeosnZ/N3ActNPTcxOMQybOJlHkkTl1M4MgSUJNGryQ1HbN07/C0An9LKEmwW43img5JjTtPLn1yEmbGBqbpAYtJuOsaFQUQp7z3hDvTzMesv4rvC8y6gAmIzKbAX+u0pDGsEb6KAAAAAElFTkSuQmCC" + large_image_content = base64.b64decode(base64_image) * 1000 # Replicated to make it large + + # Create the draft with inline attachment + draft_request = { + "to": [{"email": test_email, "name": "Recipient Name"}], + "from": [{"email": test_email, "name": "Sender Name"}], + "subject": "Draft with Inline Image", + "body": """ + + +

Draft Email

+

This draft contains an inline image:

+ Company Logo +

Best regards,
Your Team

+ + + """, + "attachments": [ + { + "filename": "company-logo.png", + "content_type": "image/png", + "content": io.BytesIO(large_image_content), + "size": len(large_image_content), + "content_id": "logo-image", # Content ID for inline reference + "is_inline": True, + "content_disposition": "inline" + } + ] + } + + try: + # Create the draft + draft_response = nylas.drafts.create( + identifier=grant_id, # Replace with your grant ID + request_body=draft_request + ) + + print("Draft created successfully!") + print(f"Draft ID: {draft_response.data.id}") + + # Send the draft + send_response = nylas.drafts.send( + identifier=grant_id, # Replace with your grant ID + draft_id=draft_response.data.id + ) + + print("Draft sent successfully!") + print(f"Message ID: {send_response.data.id}") + + except Exception as e: + print(f"Error with draft: {e}") + + +if __name__ == "__main__": + print("Inline Attachment Example") + print("=" * 50) + print() + + # Check if API key is set + if not os.environ.get("NYLAS_API_KEY"): + print("Please set the NYLAS_API_KEY environment variable") + print("export NYLAS_API_KEY='your-api-key-here'") + exit(1) + + # Check if grant ID is set + if not os.environ.get("NYLAS_GRANT_ID"): + print("Please set the NYLAS_GRANT_ID environment variable") + print("export NYLAS_GRANT_ID='your-grant-id-here'") + exit(1) + + # Check if test email is set + if not os.environ.get("TEST_EMAIL"): + print("Please set the TEST_EMAIL environment variable") + print("export TEST_EMAIL='your-test-email-here'") + exit(1) + + print("1. Sending message with inline attachment...") + send_message_with_inline_attachment() + + print("\n2. Creating and sending draft with inline attachment...") + send_draft_with_inline_attachment() + + print("\nNote: The content_id field ensures that large inline attachments") + print("are properly referenced in the multipart form data, allowing") + print("email clients to display them inline correctly.") diff --git a/nylas/utils/file_utils.py b/nylas/utils/file_utils.py index 69dd7dc3..ece1e659 100644 --- a/nylas/utils/file_utils.py +++ b/nylas/utils/file_utils.py @@ -70,7 +70,9 @@ def _build_form_request(request_body: dict) -> MultipartEncoder: # Create the multipart/form-data encoder fields = {"message": ("", message_payload, "application/json")} for index, attachment in enumerate(attachments): - fields[f"file{index}"] = ( + # Use content_id as field name if provided, otherwise fallback to file{index} + field_name = attachment.get("content_id", f"file{index}") + fields[field_name] = ( attachment["filename"], attachment["content"], attachment["content_type"], diff --git a/tests/utils/test_file_utils.py b/tests/utils/test_file_utils.py index 2058541a..4ad4ef7d 100644 --- a/tests/utils/test_file_utils.py +++ b/tests/utils/test_file_utils.py @@ -1,6 +1,6 @@ from unittest.mock import patch, mock_open -from nylas.utils.file_utils import attach_file_request_builder, _build_form_request +from nylas.utils.file_utils import attach_file_request_builder, _build_form_request, encode_stream_to_base64 class TestFileUtils: @@ -47,10 +47,110 @@ def test_build_form_request(self): == '{"to": [{"email": "test@gmail.com"}], "subject": "test subject", "body": "test body"}' ) assert request.fields["message"][2] == "application/json" - assert len(request.fields["file0"]) == 3 - assert request.fields["file0"][0] == "attachment.txt" - assert request.fields["file0"][1] == b"test data" + + def test_encode_stream_to_base64(self): + """Test that binary streams are properly encoded to base64.""" + import io + + # Create a binary stream with test data + test_data = b"Hello, World! This is test data." + binary_stream = io.BytesIO(test_data) + + # Move the stream position to simulate it being read + binary_stream.seek(10) + + # Encode to base64 + encoded = encode_stream_to_base64(binary_stream) + + # Verify the result + import base64 + expected = base64.b64encode(test_data).decode("utf-8") + assert encoded == expected + + # Verify the stream position was reset to 0 and read completely + assert binary_stream.tell() == len(test_data) + + def test_build_form_request_with_content_id(self): + """Test that content_id is used as field name when provided.""" + request_body = { + "to": [{"email": "test@gmail.com"}], + "subject": "test subject", + "body": "test body", + "attachments": [ + { + "filename": "inline_image.png", + "content_type": "image/png", + "content": b"image data", + "size": 1234, + "content_id": "image1@example.com", + }, + { + "filename": "regular_attachment.txt", + "content_type": "text/plain", + "content": b"text data", + "size": 5678, + # No content_id, should fallback to file{index} + } + ], + } + + request = _build_form_request(request_body) + + assert len(request.fields) == 3 + assert "message" in request.fields + assert "image1@example.com" in request.fields # Uses content_id + assert "file1" in request.fields # Falls back to file{index} for attachment without content_id + + # Verify the inline attachment with content_id + assert len(request.fields["image1@example.com"]) == 3 + assert request.fields["image1@example.com"][0] == "inline_image.png" + assert request.fields["image1@example.com"][1] == b"image data" + assert request.fields["image1@example.com"][2] == "image/png" + + # Verify the regular attachment without content_id + assert len(request.fields["file1"]) == 3 + assert request.fields["file1"][0] == "regular_attachment.txt" + assert request.fields["file1"][1] == b"text data" + assert request.fields["file1"][2] == "text/plain" + + def test_build_form_request_backwards_compatibility(self): + """Test that existing behavior is preserved when no content_id is provided.""" + request_body = { + "to": [{"email": "test@gmail.com"}], + "subject": "test subject", + "body": "test body", + "attachments": [ + { + "filename": "attachment1.txt", + "content_type": "text/plain", + "content": b"test data 1", + "size": 1234, + }, + { + "filename": "attachment2.txt", + "content_type": "text/plain", + "content": b"test data 2", + "size": 5678, + } + ], + } + + request = _build_form_request(request_body) + + assert len(request.fields) == 3 + assert "message" in request.fields + assert "file0" in request.fields # First attachment + assert "file1" in request.fields # Second attachment + + # Verify first attachment + assert request.fields["file0"][0] == "attachment1.txt" + assert request.fields["file0"][1] == b"test data 1" assert request.fields["file0"][2] == "text/plain" + + # Verify second attachment + assert request.fields["file1"][0] == "attachment2.txt" + assert request.fields["file1"][1] == b"test data 2" + assert request.fields["file1"][2] == "text/plain" def test_build_form_request_no_attachments(self): request_body = { @@ -70,3 +170,25 @@ def test_build_form_request_no_attachments(self): == '{"to": [{"email": "test@gmail.com"}], "subject": "test subject", "body": "test body"}' ) assert request.fields["message"][2] == "application/json" + + def test_encode_stream_to_base64(self): + """Test that binary streams are properly encoded to base64.""" + import io + + # Create a binary stream with test data + test_data = b"Hello, World! This is test data." + binary_stream = io.BytesIO(test_data) + + # Move the stream position to simulate it being read + binary_stream.seek(10) + + # Encode to base64 + encoded = encode_stream_to_base64(binary_stream) + + # Verify the result + import base64 + expected = base64.b64encode(test_data).decode("utf-8") + assert encoded == expected + + # Verify the stream position was reset to 0 and read completely + assert binary_stream.tell() == len(test_data)