Repository navigation
Email Sending
All email sending is done through ServerClient. You will need a server API token.
import postmark
client = postmark.ServerClient("your-server-token")Send a single email using either a dict or the Email model.
import asyncio
from postmark import Email
async def main():
# Using a dict
response = await client.outbound.send({
"sender": "you@example.com",
"to": "recipient@example.com",
"subject": "Hello from Postmark",
"text_body": "Hello!",
"html_body": "<p>Hello!</p>",
"message_stream": "outbound",
})
print(f"Message ID: {response.message_id}")
# Using the Email model (recommended)
response = await client.outbound.send(
Email(
sender="you@example.com",
to="recipient@example.com",
subject="Hello via Model",
text_body="Hello!",
metadata={"user_id": "12345"},
)
)
print(f"Message ID: {response.message_id}")
asyncio.run(main())Enable open and link tracking on outgoing messages.
response = await client.outbound.send(
Email(
sender="you@example.com",
to="recipient@example.com",
subject="Track me",
html_body="<p>Click <a href='https://example.com'>here</a>.</p>",
track_opens=True,
track_links="HtmlAndText", # "None", "HtmlAndText", "HtmlOnly", "TextOnly"
)
)Pass arbitrary email headers using the headers field.
from postmark.models.outbound import Email, Header
response = await client.outbound.send(
Email(
sender="you@example.com",
to="recipient@example.com",
subject="Custom headers",
text_body="Hello!",
headers=[
Header(name="X-Custom-Header", value="my-value"),
Header(name="Reply-To", value="reply@example.com"),
],
)
)Attachment content must be Base64-encoded. Use Python's standard base64 module.
import asyncio
import base64
from postmark.models.outbound import Attachment, Email
async def main():
# Attachment from in-memory content
report = Attachment(
name="report.txt",
content=base64.b64encode(b"Q3 sales are up 12%.").decode("utf-8"),
content_type="text/plain",
)
# Attachment from a file on disk
with open("/path/to/document.pdf", "rb") as f:
pdf = Attachment(
name="document.pdf",
content=base64.b64encode(f.read()).decode("utf-8"),
content_type="application/pdf",
)
response = await client.outbound.send(
Email(
sender="you@example.com",
to="recipient@example.com",
subject="Your files",
text_body="Please find your files attached.",
attachments=[report, pdf],
)
)
print(f"Sent: {response.message_id}")
asyncio.run(main())There are two ways to include images in an email, and they can be combined in the same message.
Set content_id on an Attachment and reference it in the HTML body as <img src="cid:...">. The image is embedded in the email itself, so it appears even when the recipient is offline or has blocked external content — ideal for logos and brand assets.
import base64
from postmark.models.outbound import Attachment, Email
with open("/path/to/logo.png", "rb") as f:
inline_logo = Attachment(
name="logo.png",
content=base64.b64encode(f.read()).decode("utf-8"),
content_type="image/png",
content_id="cid:logo", # referenced below as <img src="cid:logo">
)
response = await client.outbound.send(
Email(
sender="you@example.com",
to="recipient@example.com",
subject="Check out our logo",
text_body="Hello! See the HTML version for our logo.",
html_body='<p>Here is our logo:</p><img src="cid:logo" alt="Logo">',
attachments=[inline_logo],
)
)External images — including analytics tracking pixels — go directly in the html_body. No special SDK field is needed; the recipient's email client fetches the URL when the message is opened, which is what makes open-tracking pixels work.
from postmark.models.outbound import Email
response = await client.outbound.send(
Email(
sender="you@example.com",
to="recipient@example.com",
subject="Hello",
text_body="Hello!",
html_body=(
'<p>Hello!</p>'
'<img src="https://track.example.com/pixel.png" width="1" height="1" alt="">'
),
)
)You can mix both in the same email — inline for reliably-visible assets, external for tracking.
import base64
from postmark.models.outbound import Attachment, Email
TRACKING_PIXEL_URL = "https://track.example.com/pixel.png"
with open("/path/to/logo.png", "rb") as f:
inline_logo = Attachment(
name="logo.png",
content=base64.b64encode(f.read()).decode("utf-8"),
content_type="image/png",
content_id="cid:logo",
)
response = await client.outbound.send(
Email(
sender="you@example.com",
to="recipient@example.com",
subject="Hello — with inline logo and tracking",
text_body="Hello! Open the HTML version to see the logo.",
html_body=f"""
<p>Here is our logo:</p>
<img src="cid:logo" alt="Logo" />
<img src="{TRACKING_PIXEL_URL}" width="1" height="1" alt="" />
""",
attachments=[inline_logo],
)
)Send multiple distinct emails in a single API call.
Important: The API always returns HTTP 200, even when individual messages fail. Check
response.success(orresponse.error_code == 0) on each item — a truthy HTTP status does not mean every message was accepted. One bad entry never stops the others: the messages that are valid are still sent, and every entry gets its own result in the same order you sent them.
import asyncio
async def main():
responses = await client.outbound.send_batch([
{
"sender": "you@example.com",
"to": "alice@example.com",
"subject": "Hi Alice",
"text_body": "Hello Alice!",
},
{
"sender": "you@example.com",
"to": "bob@example.com",
"subject": "Hi Bob",
"text_body": "Hello Bob!",
},
])
for resp in responses:
if resp.success:
print(f" Sent: {resp.message_id}")
else:
print(f" Failed [error_code={resp.error_code}]: {resp.message}")
asyncio.run(main())A failed entry carries only error_code and message (for example 300 with Error parsing 'To': Illegal email address ...). Its to, message_id and submitted_at are None, so check success before using them.
Send the same message to many recipients, each with their own template variables. Uses BulkEmail and BulkRecipient.
import asyncio
from postmark.models.outbound import BulkEmail, BulkRecipient
async def main():
response = await client.outbound.send_bulk(
BulkEmail(
sender="you@example.com",
subject="Hello {{FirstName}}, your order is ready",
html_body="<p>Hi {{FirstName}}, your order <strong>{{OrderId}}</strong> is ready.</p>",
text_body="Hi {{FirstName}}, your order {{OrderId}} is ready.",
message_stream="broadcast",
messages=[
BulkRecipient(
to="alice@example.com",
template_model={"FirstName": "Alice", "OrderId": "ORD-001"},
),
BulkRecipient(
to="bob@example.com",
template_model={"FirstName": "Bob", "OrderId": "ORD-002"},
),
],
)
)
print(f"Bulk request ID: {response.id} Status: {response.status.value}")
# Poll for completion
status = await client.outbound.get_bulk_status(response.id)
print(f"{status.percentage_completed:.0f}% of {status.total_messages} messages sent")
print(f"released={status.released_count} failed={status.failed_count}")
asyncio.run(main())The response (and get_bulk_status) tells you how the request is going:
| Field | Meaning |
|---|---|
id |
The bulk request ID. Use it with get_bulk_status. |
status |
BulkJobStatus.ACCEPTED, PROCESSING, COMPLETED or CANCELLED. |
submitted_at, subject
|
When it was submitted and its subject line. |
total_messages, percentage_completed
|
Progress. |
released_count, failed_count
|
How many messages have been sent and how many failed. |
Bulk sending is meant for broadcast message streams (that is the default if you omit message_stream). Placeholders such as {{FirstName}} are filled in per recipient from template_model.
See the bulk requests you have submitted, newest first.
import asyncio
async def main():
page = await client.outbound.list_bulk_requests(count=20)
for req in page.items:
print(f"{req.id} {req.status.value} {req.subject} {req.total_messages} messages")
# This endpoint uses a cursor instead of offsets. next_key is None on the last page.
while page.next_key is not None:
page = await client.outbound.list_bulk_requests(count=20, pagination_key=page.next_key)
for req in page.items:
print(req.id)
asyncio.run(main())-
countmust be between 1 and 500. - Pass
next_keyback unchanged aspagination_key. Do not build or edit it; Postmark rejects altered values. - To walk through everything without managing the cursor yourself, use the streaming helper:
async def main():
async for req in client.outbound.stream_bulk_requests(batch_size=100, max_requests=500):
print(req.id, req.status.value)stream_bulk_requests() returns a complete list on the sync client.
- Templates — Send with pre-built Postmark templates
- Error Handling — Handle API errors gracefully
- Postmark API Reference