Skip to content

Email Sending

Greg Svoboda edited this page Oct 28, 2026 · 3 revisions

Email Sending

All email sending is done through ServerClient. You will need a server API token.

import postmark

client = postmark.ServerClient("your-server-token")

Simple Email

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())

Tracking Options

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"
    )
)

Custom Headers

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"),
        ],
    )
)

Attachments

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())

Embedding Images in Emails

There are two ways to include images in an email, and they can be combined in the same message.

Inline images (base64, always visible)

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 image URLs (loaded at open time)

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="">'
        ),
    )
)

Combining inline and external images

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],
    )
)

Batch Sending

Send multiple distinct emails in a single API call.

Important: The API always returns HTTP 200, even when individual messages fail. Check response.success (or response.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.

Bulk Sending

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.

List Bulk Requests

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())
  • count must be between 1 and 500.
  • Pass next_key back unchanged as pagination_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.

Further Reading

Clone this wiki locally