letmesend.email Python SDK.
Project description
letmesend.email Python SDK
The official Python SDK for the letmesend.email API.
Full Documentation
See the comprehensive user manual for complete documentation of every resource, configuration option, retry behavior, error handling, webhook verification, and detailed examples.
Installation
pip install letmesendemail
Requires Python 3.9 or later.
Quick Start
from letmesendemail import LetMeSendEmail
client = LetMeSendEmail(api_key="lms_live_...")
email = client.emails.send(
from_="Acme <hello@acme.com>",
to=["person@example.com"],
subject="Welcome",
html="<p>Hello from letmesend.email</p>",
)
print(email.id, email.status)
Configuration
client = LetMeSendEmail(
api_key="lms_live_...",
base_url="https://letmesend.email/api/v1",
timeout_ms=30_000,
retries=0,
)
WithRetry Semantics
When retries > 0, the SDK automatically retries on:
- Network errors (connection refused, DNS failure)
- Timeouts
- HTTP 408, 500, 502, 503, 504
- HTTP 429 — only when
Retry-Afterheader is present, valid (positive integer ≤ 300s)
Retries use bounded exponential backoff with jitter:
- Base delay =
100 × 2^attemptmilliseconds - Actual delay =
base × (0.5 + random × 0.5)clamped to[0, 300]seconds - Network errors and timeouts use jittered backoff
- 429 uses the exact
Retry-Aftervalue (no jitter, capped at 300s)
Non-idempotent methods (POST, PUT, PATCH) are not retried unless an
Idempotency-Key header is provided.
Safe methods (GET, HEAD, OPTIONS, DELETE) are always retried.
IdempotencyKey
email = client.emails.send(
from_="...",
to=["..."],
subject="Hi",
idempotency_key="my-unique-key",
)
When an Idempotency-Key is provided on a POST, the request becomes eligible
for retries. The same idempotency key can be sent multiple times and the API
will only process it once.
Resources
Emails
Send
email = client.emails.send(
from_="Acme <hello@acme.com>",
to=["user@example.com"],
subject="Welcome",
html="<p>Hello</p>",
text="Hello", # optional plain text
type_="transactional", # optional
event_name="welcome", # optional
email_topic_id="topic_1", # optional
reply_to=["support@acme.com"], # optional
cc=["cc@example.com"], # optional
bcc=["bcc@example.com"], # optional
headers={"X-Custom": "value"}, # optional
idempotency_key="my-key", # optional
)
print(email.id, email.status)
Attachments — by URL:
from letmesendemail import SendAttachment
email = client.emails.send(
from_="...",
to=["..."],
subject="With attachment",
attachments=[
{"name": "report.pdf", "path": "https://storage.example.com/report.pdf"},
],
)
Attachments — by base64 content:
email = client.emails.send(
from_="...",
to=["..."],
subject="With inline image",
attachments=[
{
"name": "logo.png",
"content": "iVBORw0KGgo...",
"content_id": "logo_cid",
"content_disposition": "inline",
},
],
)
Send with Template
email = client.emails.send_with_template(
from_="...",
to=["..."],
template_id="tpl_1",
template_variables=[{"key": "name", "value": "Alice"}],
)
Verify
result = client.emails.verify("person@example.com")
print(result.status, result.score)
# "valid", 95
List (Cursor Pagination)
page = client.emails.list(per_page=20)
for item in page.data:
print(item.id, item.subject, item.status)
print(page.pagination.has_more)
# Next page
if page.pagination.has_more:
next_page = client.emails.list(
per_page=20,
after=page.data[-1].id,
)
Get
detail = client.emails.get("email_id")
print(detail.status, detail.recipients_count)
for r in detail.recipients:
print(r.email_address, r.status, r.open_count)
Domains
# List
domains = client.domains.list()
for d in domains.data:
print(d.domain_name, d.status)
# Get
domain = client.domains.get("domain_id")
# Verify
result = client.domains.verify("example.com")
print(result.status)
Contacts
# Create
contact = client.contacts.create(
email="user@example.com",
first_name="John",
last_name="Doe",
phone="+1234567890",
categories=["cat_id_1"],
email_topics=["topic_id_1"],
)
# List
page = client.contacts.list(per_page=20, after="cursor")
# Get
contact = client.contacts.get("contact_id")
# Update
result = client.contacts.update(
"contact_id",
first_name="Jane",
sync_categories=True,
)
# Delete
result = client.contacts.delete("contact_id")
print(result.status)
Contact Categories
# Create
cat = client.contact_categories.create("Newsletter", slug="newsletter")
# List
page = client.contact_categories.list()
# Get
cat = client.contact_categories.get("category_id")
# Update
cat = client.contact_categories.update("category_id", "Updates", slug="updates")
# Delete
result = client.contact_categories.delete("category_id")
Email Topics
# Create
topic = client.email_topics.create(
"Product Update",
"product-update",
auto_subscribe=True,
public=True,
domain_id="domain_id",
)
# List
page = client.email_topics.list()
# Get
topic = client.email_topics.get("topic_id")
# Update
topic = client.email_topics.update(
"topic_id",
name="Renamed",
slug="renamed",
public=True,
)
# Delete
result = client.email_topics.delete("topic_id")
Pagination
List responses return a data list and a pagination object:
page = client.emails.list(per_page=20, after="cursor_id")
page.pagination.has_more # bool — more results available
page.pagination.per_page # int — items per page
page.pagination.total # int — total items (may be approximate)
page.pagination.fetched # int — items in this response
Cursor-based pagination uses after (forward) and before (backward) cursors.
Error Handling
Every error is a subclass of LetMeSendEmailError:
| Exception | HTTP Status | When |
|---|---|---|
ValidationError |
400, 413, 422 | Request validation failed |
AuthenticationError |
401 | Invalid or missing API key |
AuthorizationError |
403 | Insufficient permissions |
NotFoundError |
404 | Resource not found |
ConflictError |
409 | Resource conflict |
RateLimitError |
429 | Too many requests |
ApiError |
5xx+ | Server-side error |
NetworkError |
— | Connection refused, DNS failure |
TimeoutError |
— | Request timed out |
from letmesendemail import RateLimitError, ValidationError, AuthenticationError, ApiError
try:
client.emails.send(...)
except ValidationError as e:
print(e.validation_errors) # dict of field → [messages]
print(e.api_code) # "validation_error"
print(e.request_id) # "req_..."
print(e.raw_body) # raw response text
print(e.response_headers) # lowercase headers dict
except AuthenticationError:
print("Check API key")
except RateLimitError as e:
print(f"Retry after {e.retry_after}s")
except ApiError as e:
print(f"Error {e.status_code}: {e.message}")
Webhooks
from letmesendemail import verify_webhook
event = verify_webhook(
payload=raw_body, # raw request body as string
headers=incoming_headers, # dict (case-insensitive, HTTP_ prefix supported)
secret="whsec_...", # base64-encoded or whsec_-prefixed secret
)
print(event)
The verification function:
- Validates all four required headers (
webhook-id,webhook-log-id,webhook-timestamp,webhook-signature) - Checks timestamp is within a 300-second tolerance (customizable via
tolerance) - Computes HMAC-SHA256 over
id.log.timestamp.payload - Accepts any matching
v1,signature entry - Parses payload as JSON and returns the parsed dict
event = verify_webhook(
payload=raw_body,
headers=incoming_headers,
secret="whsec_base64secret",
tolerance=60, # 60-second window
)
Testing
pip install -e ".[dev]"
pytest
The SDK does not include a Mail::fake() equivalent. For integration testing,
use httpx_mock via the pytest-httpx fixture:
def test_send_email(httpx_mock):
from letmesendemail import LetMeSendEmail
client = LetMeSendEmail(api_key="test", base_url="https://letmesend.email/api/v1")
httpx_mock.add_response(
method="POST",
url="https://letmesend.email/api/v1/emails",
json={"id": "...", "status": "accepted"},
)
email = client.emails.send(from_="...", to=["..."], subject="Hi")
assert email.status == "accepted"
Version Support
| Python | Supported |
|---|---|
| 3.9 | Yes |
| 3.10 | Yes |
| 3.11 | Yes |
| 3.12 | Yes |
| 3.13 | Yes |
| 3.14 | Yes |
Changelog
See CHANGELOG.md.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file letmesendemail-0.2.1.tar.gz.
File metadata
- Download URL: letmesendemail-0.2.1.tar.gz
- Upload date:
- Size: 32.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4093f03e4935ed227ebbda892c553209a20c023e0915a4cfeaf69b6fd43c825c
|
|
| MD5 |
9b1ff1f022ff4618f65d3cf1d7bb6c9c
|
|
| BLAKE2b-256 |
cfd101cdb9c3532a6ba0d75f3c054dd8cfafcf77f5c1f5fddf12d4afcc87094d
|
File details
Details for the file letmesendemail-0.2.1-py3-none-any.whl.
File metadata
- Download URL: letmesendemail-0.2.1-py3-none-any.whl
- Upload date:
- Size: 19.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d7f7bdcb0a1639db6230be905e5d6f3feeb26580262a710631245d593a3b144b
|
|
| MD5 |
a11d70fd06fc2da02b224fda2393f838
|
|
| BLAKE2b-256 |
69dec4f6977adf3a6ad94c2e67d4a97c674bd566c9bb0b2912edf78684c088c8
|