Skip to content

Commit 8fedeeb

Browse files
committed
Add bulk sending api endpoints, attachment example
1 parent 07516b0 commit 8fedeeb

8 files changed

Lines changed: 622 additions & 29 deletions

File tree

examples/send_attachment.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""
2+
Example for sending emails with attachments.
3+
4+
Attachment content must be Base64-encoded before sending.
5+
The standard library's base64 module handles this for both
6+
in-memory content and files read from disk.
7+
8+
"""
9+
10+
import asyncio
11+
import base64
12+
import os
13+
14+
import postmark
15+
from postmark.models.messages import Attachment, Email
16+
from dotenv import load_dotenv
17+
18+
load_dotenv()
19+
client = postmark.ServerClient(os.environ["POSTMARK_SERVER_TOKEN"])
20+
SENDER = os.environ["POSTMARK_SENDER_EMAIL"]
21+
22+
23+
async def main():
24+
# Building an attachment from a string (in-memory content)
25+
report_txt = Attachment(
26+
name="report.txt",
27+
content=base64.b64encode(b"Q3 sales are up 12%.").decode("utf-8"),
28+
content_type="text/plain",
29+
)
30+
31+
# Building an attachments from a file, on disk
32+
with open("/path/to/book.pdf", "rb") as f:
33+
book_pdf = Attachment(
34+
name="book.pdf",
35+
content=base64.b64encode(f.read()).decode("utf-8"),
36+
content_type="application/pdf",
37+
)
38+
39+
# Building an attachment from an inline image
40+
with open("/path/to/logo.png", "rb") as f:
41+
inline_logo = Attachment(
42+
name="logo.png",
43+
content=base64.b64encode(f.read()).decode("utf-8"),
44+
content_type="image/png",
45+
content_id="cid:logo", # reference this in html_body as <img src="cid:logo">
46+
)
47+
48+
# --- Send ---
49+
response = await client.outbound.send(
50+
Email(
51+
sender=SENDER,
52+
to="receiver@example.com",
53+
subject="Your report and resources",
54+
text_body="Please find your report and resources attached.",
55+
html_body='<p>Please find your report attached.</p><img src="cid:logo">',
56+
attachments=[report_txt, book_pdf, inline_logo],
57+
)
58+
)
59+
60+
print(f"Sent: {response.message_id}")
61+
62+
63+
asyncio.run(main())

examples/send_batch.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import asyncio
2+
import os
3+
4+
import postmark
5+
from postmark import Email
6+
from dotenv import load_dotenv
7+
8+
load_dotenv()
9+
client = postmark.ServerClient(os.environ["POSTMARK_SERVER_TOKEN"])
10+
SENDER = os.environ["POSTMARK_SENDER_EMAIL"]
11+
12+
13+
async def main():
14+
# --- Send batch ---
15+
responses = await client.outbound.send_batch(
16+
[
17+
{
18+
"sender": SENDER,
19+
"to": "receiver1@example.com",
20+
"subject": "Batch 1",
21+
"text_body": "Hello Receiver 1",
22+
},
23+
{
24+
"sender": SENDER,
25+
"to": "receiver2@example.com",
26+
"subject": "Batch 2",
27+
"text_body": "Hello Receiver 2",
28+
},
29+
]
30+
)
31+
print(f"Batch: {len(responses)} sent")
32+
for i, resp in enumerate(responses, start=1):
33+
print(f" {i}: {resp.message_id}")
34+
35+
36+
asyncio.run(main())

examples/send_bulk.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import asyncio
2+
import os
3+
4+
import postmark
5+
from postmark.models.messages import BulkEmail, BulkRecipient
6+
from dotenv import load_dotenv
7+
8+
load_dotenv()
9+
client = postmark.ServerClient(os.environ["POSTMARK_SERVER_TOKEN"])
10+
SENDER = os.environ["POSTMARK_SENDER_EMAIL"]
11+
12+
13+
async def main():
14+
# --- It's recommended sending using BulkRecipient class models, for improved type safety. ---
15+
response = await client.outbound.send_bulk(
16+
BulkEmail(
17+
sender=SENDER,
18+
subject="Hello {{FirstName}}, your order is ready",
19+
html_body="<p>Hi {{FirstName}}, your order <strong>{{OrderId}}</strong> is ready.</p>",
20+
text_body="Hi {{FirstName}}, your order {{OrderId}} is ready.",
21+
message_stream="broadcast",
22+
messages=[
23+
BulkRecipient(
24+
to="bob@example.com",
25+
template_model={"FirstName": "Bob", "OrderId": "ORD-001"},
26+
),
27+
BulkRecipient(
28+
to="frieda@example.com",
29+
template_model={"FirstName": "Frieda", "OrderId": "ORD-002"},
30+
),
31+
BulkRecipient(
32+
to="elijah@example.com",
33+
template_model={"FirstName": "Elijah", "OrderId": "ORD-003"},
34+
),
35+
],
36+
)
37+
)
38+
print(f"Bulk request accepted — ID: {response.id} Status: {response.status}")
39+
40+
# --- ...or send bulk via dict ---
41+
response = await client.outbound.send_bulk(
42+
{
43+
"sender": SENDER,
44+
"subject": "Hello {{FirstName}}, your order is ready",
45+
"html_body": "<p>Hi {{FirstName}}, your order <strong>{{OrderId}}</strong> is ready.</p>",
46+
"text_body": "Hi {{FirstName}}, your order {{OrderId}} is ready.",
47+
"message_stream": "broadcast",
48+
"track_opens": True,
49+
"messages": [
50+
{
51+
"to": "bob@example.com",
52+
"template_model": {"FirstName": "Bob", "OrderId": "ORD-001"},
53+
},
54+
{
55+
"to": "frieda@example.com",
56+
"template_model": {"FirstName": "Frieda", "OrderId": "ORD-002"},
57+
},
58+
{
59+
"to": "elijah@example.com",
60+
"template_model": {"FirstName": "Elijah", "OrderId": "ORD-003"},
61+
"cc": "manager@example.com",
62+
},
63+
],
64+
}
65+
)
66+
print(f"Bulk request accepted — ID: {response.id} Status: {response.status}")
67+
68+
# --- Poll for completion ---
69+
status = await client.outbound.get_bulk_status(response.id)
70+
print(
71+
f"Status: {status.status} ({status.percentage_completed:.0f}% of {status.total_messages} messages sent)"
72+
)
73+
74+
75+
asyncio.run(main())

examples/send_messages.py

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -42,26 +42,5 @@ async def main():
4242
)
4343
print(f"Sent (model): {response.message_id}")
4444

45-
# --- Send batch ---
46-
responses = await client.outbound.send_batch(
47-
[
48-
{
49-
"sender": SENDER,
50-
"to": "receiver1@example.com",
51-
"subject": "Batch 1",
52-
"text_body": "Hello Receiver 1",
53-
},
54-
{
55-
"sender": SENDER,
56-
"to": "receiver2@example.com",
57-
"subject": "Batch 2",
58-
"text_body": "Hello Receiver 2",
59-
},
60-
]
61-
)
62-
print(f"Batch: {len(responses)} sent")
63-
for i, resp in enumerate(responses, start=1):
64-
print(f" {i}: {resp.message_id}")
65-
6645

6746
asyncio.run(main())
Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,40 @@
11
from .enums import MessageStatus, TrackLinksOption, MessageEventType, Platform
22
from .schemas import (
3+
# Single / batch
34
Email,
45
SendResponse,
56
Outbound,
67
OutboundMessageDetails,
78
Attachment,
89
Header,
910
EmailAddress,
11+
# Bulk
12+
BulkEmail,
13+
BulkRecipient,
14+
BulkSendResponse,
15+
BulkSendStatus,
1016
)
11-
from .manager import OutboundManager # InboundManager
17+
from .manager import OutboundManager
1218

1319
__all__ = [
14-
# Schemas
20+
# Schemas — single / batch
1521
"Email",
1622
"SendResponse",
1723
"Outbound",
1824
"OutboundMessageDetails",
1925
"Attachment",
2026
"Header",
2127
"EmailAddress",
28+
# Schemas — bulk
29+
"BulkEmail",
30+
"BulkRecipient",
31+
"BulkSendResponse",
32+
"BulkSendStatus",
2233
# Enums
2334
"MessageStatus",
2435
"TrackLinksOption",
2536
"MessageEventType",
2637
"Platform",
2738
# Managers
2839
"OutboundManager",
29-
# "InboundManager,",
3040
]

postmark/models/messages/manager.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,23 @@
77
from postmark.exceptions import InvalidEmailPayloadException
88
from postmark.utils.types import HTTPClient
99

10-
from .schemas import Email, Outbound, OutboundMessageDetails, SendResponse
10+
from .schemas import (
11+
BulkEmail,
12+
BulkSendResponse,
13+
BulkSendStatus,
14+
Email,
15+
Outbound,
16+
OutboundMessageDetails,
17+
SendResponse,
18+
)
1119

1220
logger = logging.getLogger(__name__)
1321

1422

1523
def _parse_email(message: Union[Email, Dict[str, Any]]) -> Email:
1624
"""
1725
Coerce a dict to an Email model using snake_case field names,
18-
raising InvalidEmailPayloadException on validation failure.
19-
Passes through an Email instance unchanged.
26+
or raise InvalidEmailPayloadException
2027
"""
2128
if isinstance(message, Email):
2229
return message
@@ -26,10 +33,27 @@ def _parse_email(message: Union[Email, Dict[str, Any]]) -> Email:
2633
raise InvalidEmailPayloadException(e.errors()) from e
2734

2835

36+
def _parse_bulk_email(message: Union[BulkEmail, Dict[str, Any]]) -> BulkEmail:
37+
"""
38+
Coerce a dict to a BulkEmail model, raising InvalidEmailPayloadException
39+
on validation failure. Passes through a BulkEmail instance unchanged.
40+
"""
41+
if isinstance(message, BulkEmail):
42+
return message
43+
try:
44+
return BulkEmail.model_validate(message)
45+
except ValidationError as e:
46+
raise InvalidEmailPayloadException(e.errors()) from e
47+
48+
2949
class OutboundManager:
3050
def __init__(self, client: HTTPClient):
3151
self.client = client
3252

53+
# -------------------------------------------------------------------------
54+
# Single send
55+
# -------------------------------------------------------------------------
56+
3357
async def send(self, message: Union[Email, Dict[str, Any]]) -> SendResponse:
3458
"""Send a single email."""
3559
email_payload = _parse_email(message)
@@ -41,14 +65,21 @@ async def send(self, message: Union[Email, Dict[str, Any]]) -> SendResponse:
4165
)
4266
return SendResponse(**response.json())
4367

68+
# -------------------------------------------------------------------------
69+
# Batch send — different messages, one request (max 500)
70+
# -------------------------------------------------------------------------
71+
4472
async def send_batch(
4573
self, messages: List[Union[Email, Dict[str, Any]]]
4674
) -> List[SendResponse]:
47-
"""Send different emails in a single batch (max 500)."""
75+
"""
76+
Send up to 500 different emails in a single request.
77+
Use this when each recipient needs a **completely different** message.
78+
For sending the **same message** to many recipients, use send_bulk().
79+
"""
4880
if len(messages) > 500:
4981
raise ValueError("Batch size cannot exceed 500 messages")
5082

51-
# Validate all messages up front so we fail before making any HTTP call
5283
payload = []
5384
for i, msg in enumerate(messages):
5485
try:
@@ -68,6 +99,41 @@ async def send_batch(
6899
response = await self.client.post("/email/batch", json=payload)
69100
return [SendResponse(**item) for item in response.json()]
70101

102+
# -------------------------------------------------------------------------
103+
# Bulk send — same message, many recipients, one request
104+
# -------------------------------------------------------------------------
105+
106+
async def send_bulk(
107+
self, message: Union[BulkEmail, Dict[str, Any]]
108+
) -> BulkSendResponse:
109+
"""
110+
Send the **same message** to multiple recipients in a single request.
111+
"""
112+
bulk_payload = _parse_bulk_email(message)
113+
114+
if not bulk_payload.messages:
115+
raise ValueError(
116+
"Bulk email must include at least one recipient in messages"
117+
)
118+
119+
logger.debug(f"Sending bulk email to {len(bulk_payload.messages)} recipients")
120+
response = await self.client.post(
121+
"/email/bulk",
122+
json=bulk_payload.model_dump(by_alias=True, exclude_none=True),
123+
)
124+
return BulkSendResponse(**response.json())
125+
126+
async def get_bulk_status(self, bulk_id: str) -> BulkSendStatus:
127+
"""
128+
Poll the status of a bulk send request.
129+
"""
130+
response = await self.client.get(f"/email/bulk/{bulk_id}")
131+
return BulkSendStatus(**response.json())
132+
133+
# -------------------------------------------------------------------------
134+
# List / stream / get
135+
# -------------------------------------------------------------------------
136+
71137
async def list(
72138
self,
73139
count: int = 100,

0 commit comments

Comments
 (0)