Skip to main content
This guide covers advanced Python SDK features. If you’re new to the SDK, start with the Python Quickstart.

Advanced Features

Multiple Recipients

Add multiple To, CC, and BCC recipients:
response = client.emails.send(
    from_email="notifications@yourdomain.com",
    to=["user1@example.com", "user2@example.com"],
    cc=["cc@example.com", "cc2@example.com"],
    bcc=["bcc@example.com"],
    subject="Team notification",
    html="<p>This email has multiple recipients.</p>",
)
BCC recipients are hidden from all other recipients. They receive the email but their addresses are not visible in the headers.

Reply-To Address

Specify a different reply-to address:
response = client.emails.send(
    from_email="notifications@yourdomain.com",
    reply_to="support@yourdomain.com",
    to=["user@example.com"],
    subject="Support notification",
    html="<p>Click reply to contact our support team.</p>",
)

Attachments

Add file attachments to your emails:
import base64

# Read file and encode to base64
with open("invoice.pdf", "rb") as f:
    file_data = f.read()
    encoded_content = base64.b64encode(file_data).decode("utf-8")

response = client.emails.send(
    from_email="billing@yourdomain.com",
    to=["customer@example.com"],
    subject="Your invoice",
    html="<p>Please find your invoice attached.</p>",
    attachments=[
        {
            "filename": "invoice.pdf",
            "content": encoded_content,
            "type": "application/pdf",
        }
    ],
)
Attach multiple files:
import base64

def encode_file(filepath):
    with open(filepath, "rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")

attachments = [
    {
        "filename": "document.pdf",
        "content": encode_file("document.pdf"),
        "type": "application/pdf",
    },
    {
        "filename": "logo.png",
        "content": encode_file("logo.png"),
        "type": "image/png",
    },
]

response = client.emails.send(
    from_email="sender@yourdomain.com",
    to=["recipient@example.com"],
    subject="Files attached",
    html="<p>See attached files.</p>",
    attachments=attachments,
)
Attachments must be base64-encoded. The total size of all attachments should not exceed 10MB. Larger files should be hosted and linked instead.

Templates

Send emails using Lettr-managed templates:
response = client.emails.send(
    from_email="notifications@yourdomain.com",
    to=["user@example.com"],
    template_id="welcome-email",
    merge_tags={
        "name": "John Doe",
        "company": "Acme Corp",
        "verify_url": "https://example.com/verify/abc123",
        "support_email": "support@yourdomain.com",
    },
)
Templates are managed in the Lettr dashboard. Use merge tags to personalize content without rebuilding HTML in your code.

Custom Headers

Add custom email headers:
response = client.emails.send(
    from_email="notifications@yourdomain.com",
    to=["user@example.com"],
    subject="Custom headers example",
    html="<p>This email has custom headers.</p>",
    headers={
        "X-Campaign-ID": "summer-2024",
        "X-Priority": "high",
        "X-Mailer": "Acme Mailer v1.0",
    },
)

Tracking

Enable open and click tracking:
response = client.emails.send(
    from_email="marketing@yourdomain.com",
    to=["user@example.com"],
    subject="Newsletter",
    html="<p>Check out <a href='https://example.com'>our website</a>!</p>",
    options=lettr.EmailOptions(
        click_tracking=True,
        open_tracking=True,
    ),
)
Open tracking works by embedding a transparent pixel image. Click tracking rewrites links to go through Lettr’s tracking domain. Both features respect user privacy and comply with email regulations.

Metadata

Attach custom metadata for tracking and filtering:
response = client.emails.send(
    from_email="notifications@yourdomain.com",
    to=["user@example.com"],
    subject="Order confirmation",
    html="<p>Your order has been confirmed.</p>",
    metadata={
        "user_id": "12345",
        "order_id": "ORD-98765",
        "campaign": "abandoned-cart",
        "environment": "production",
    },
)
Metadata is returned in webhook events and can be used to correlate emails with your application data.

Error Handling

The SDK provides structured exception types for different error scenarios:
from lettr.exceptions import (
    LettrException,
    LettrValidationException,
    LettrAuthException,
    LettrRateLimitException,
)

try:
    response = client.emails.send(
        from_email="invalid@unverified-domain.com",
        to=["user@example.com"],
        subject="Test",
        html="<p>Test</p>",
    )
    print(f"Email sent: {response.request_id}")
except LettrValidationException as e:
    # 422 validation errors
    print(f"Validation failed: {e.message}")
    for field, messages in e.errors.items():
        print(f"  {field}: {messages}")
except LettrAuthException as e:
    # 401 authentication errors
    print(f"Authentication failed: {e.message}")
except LettrRateLimitException as e:
    # 429 rate limit errors
    print(f"Rate limit exceeded: {e.message}")
except LettrException as e:
    # Other API errors (500, 503, etc.)
    print(f"API error: {e.message}")
except Exception as e:
    # Network or other errors
    print(f"Request failed: {e}")

Common Error Scenarios

Unverified domain:
  • Exception: LettrValidationException
  • Message: “The from address domain is not verified”
  • Solution: Verify your domain in the dashboard
Invalid API key:
  • Exception: LettrAuthException
  • Message: “Invalid API key”
  • Solution: Check your API key is correct and active
Rate limit exceeded:
  • Exception: LettrRateLimitException
  • Message: “Too many requests”
  • Solution: Implement exponential backoff retry logic

Async Support

The SDK provides full async support for non-blocking operations:
import asyncio
import lettr

async def send_emails():
    client = lettr.AsyncLettr(os.environ["LETTR_API_KEY"])

    # Send multiple emails concurrently
    tasks = []
    for recipient in ["user1@example.com", "user2@example.com", "user3@example.com"]:
        task = client.emails.send(
            from_email="notifications@yourdomain.com",
            to=[recipient],
            subject="Batch notification",
            html="<p>This is a batch email.</p>",
        )
        tasks.append(task)

    responses = await asyncio.gather(*tasks, return_exceptions=True)

    for i, response in enumerate(responses):
        if isinstance(response, Exception):
            print(f"Failed to send email {i + 1}: {response}")
        else:
            print(f"Sent email {i + 1}: {response.request_id}")

asyncio.run(send_emails())

With Semaphore for Rate Limiting

Limit concurrent requests using asyncio.Semaphore:
import asyncio
import lettr

async def send_batch(client, recipients, max_concurrent=10):
    semaphore = asyncio.Semaphore(max_concurrent)

    async def send_with_limit(recipient):
        async with semaphore:
            return await client.emails.send(
                from_email="notifications@yourdomain.com",
                to=[recipient],
                subject="Batch email",
                html="<p>Hello!</p>",
            )

    tasks = [send_with_limit(recipient) for recipient in recipients]
    responses = await asyncio.gather(*tasks, return_exceptions=True)

    for i, response in enumerate(responses):
        if isinstance(response, Exception):
            print(f"Failed: {response}")
        else:
            print(f"Sent to {recipients[i]}: {response.request_id}")

async def main():
    client = lettr.AsyncLettr(os.environ["LETTR_API_KEY"])
    recipients = ["user1@example.com", "user2@example.com", "user3@example.com"]
    await send_batch(client, recipients, max_concurrent=5)

asyncio.run(main())
For large batches, consider using a task queue like Celery or RQ to manage concurrent requests and handle failures gracefully.

Django Integration

Settings Configuration

Add Lettr configuration to your Django settings:
# settings.py
import os

LETTR_API_KEY = os.environ.get("LETTR_API_KEY")

Email Service

Create an email service module:
# services/email.py
from django.conf import settings
import lettr

client = lettr.Lettr(settings.LETTR_API_KEY)

def send_welcome_email(recipient, name):
    """Send a welcome email to a new user."""
    response = client.emails.send(
        from_email="notifications@yourdomain.com",
        to=[recipient],
        subject=f"Welcome to Acme, {name}!",
        html=f"<h1>Welcome, {name}!</h1><p>Thanks for signing up.</p>",
    )
    return response.request_id

Using in Views

Use the email service in your views:
# views.py
from django.http import JsonResponse
from .services.email import send_welcome_email

def register(request):
    # ... registration logic ...

    try:
        request_id = send_welcome_email(user.email, user.name)
        return JsonResponse({"success": True, "request_id": request_id})
    except Exception as e:
        return JsonResponse({"success": False, "error": str(e)}, status=500)

Best Practices

Use Environment Variables

Never hardcode API keys. Use environment variables or a secrets manager:
import os

api_key = os.environ.get("LETTR_API_KEY")
if not api_key:
    raise ValueError("LETTR_API_KEY is required")

client = lettr.Lettr(api_key)

Validate Before Sending

Validate email addresses before making API calls:
import re

def is_valid_email(email):
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return re.match(pattern, email) is not None

if not is_valid_email(recipient):
    print(f"Invalid email address: {recipient}")
    return

Log Request IDs

Always log the request_id from successful sends for tracking and debugging:
import logging

logger = logging.getLogger(__name__)

response = client.emails.send(...)
logger.info(
    "Email sent successfully",
    extra={
        "request_id": response.request_id,
        "accepted": response.accepted,
        "to": to,
    },
)

Handle Errors Gracefully

Implement retry logic for transient errors:
import time
from lettr.exceptions import LettrValidationException

def send_with_retry(client, email_params, max_retries=3):
    for attempt in range(max_retries + 1):
        try:
            response = client.emails.send(**email_params)
            print(f"Email sent: {response.request_id}")
            return response.request_id
        except LettrValidationException:
            # Don't retry validation errors
            raise
        except Exception as e:
            if attempt < max_retries:
                backoff = (attempt + 1) * 1.0
                print(f"Attempt {attempt + 1} failed, retrying in {backoff}s: {e}")
                time.sleep(backoff)
            else:
                raise

    raise Exception("Max retries exceeded")

Use Context Managers

Use context managers to ensure proper cleanup:
import lettr
import httpx

with httpx.Client(timeout=30.0) as http_client:
    client = lettr.Lettr(api_key, http_client=http_client)
    response = client.emails.send(...)

Reuse the Client

Create a single client instance and reuse it across requests:
# Good: Create once, reuse
import lettr

client = lettr.Lettr(os.environ["LETTR_API_KEY"])

def send_email(to, subject, html):
    return client.emails.send(
        from_email="notifications@yourdomain.com",
        to=[to],
        subject=subject,
        html=html,
    )
# Bad: Creating new client for each request
def send_email(to, subject, html):
    client = lettr.Lettr(os.environ["LETTR_API_KEY"])  # Don't do this
    return client.emails.send(...)

Type Hints

The SDK includes full type hints for better IDE support:
from typing import List, Dict, Optional
import lettr

def send_notification(
    client: lettr.Lettr,
    recipients: List[str],
    subject: str,
    html: str,
    metadata: Optional[Dict[str, str]] = None,
) -> str:
    """Send a notification email and return the request ID."""
    response = client.emails.send(
        from_email="notifications@yourdomain.com",
        to=recipients,
        subject=subject,
        html=html,
        metadata=metadata,
    )
    return response.request_id

Troubleshooting

If you see “The from address domain is not verified”:
  • Verify your domain in the Lettr dashboard
  • Ensure the from_email parameter uses the verified domain
  • Wait for DNS propagation (can take up to 48 hours)
  • See Domain Verification for help
If you see authentication errors:
  • Check your API key is correct and starts with lttr_
  • Verify the key is 68 characters total (prefix + 64 hex chars)
  • Ensure the key hasn’t been revoked in the dashboard
  • Confirm you’re reading from the correct environment variable
If requests timeout:
  • Increase the httpx client timeout
  • Check your network connectivity and firewall settings
  • Verify app.lettr.com is reachable
  • Use a custom httpx client with longer timeout settings
If you see “ModuleNotFoundError” or import errors:
  • Verify the package is installed: pip list | grep lettr
  • Reinstall the package: pip install --upgrade lettr
  • Check your Python version is 3.8 or later: python --version
  • Activate your virtual environment if using one
If you’re hitting rate limits:
  • Implement exponential backoff retry logic (see Best Practices)
  • Use asyncio.Semaphore for controlled concurrent sending
  • Consider upgrading your Lettr plan for higher limits
  • Spread requests over time instead of bursts
If you encounter async-related errors:
  • Use AsyncLettr for async code, not Lettr
  • Ensure you’re using await with async methods
  • Run async functions with asyncio.run() at the top level
  • Check that your async runtime is properly configured

What’s Next