diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c65c44..371f593 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,6 +23,7 @@ repos: hooks: - id: mypy args: [--ignore-missing-imports] + files: ^(postmark|tests)/ additional_dependencies: - pydantic - httpx diff --git a/example.env b/example.env index 6679eba..43ed908 100644 --- a/example.env +++ b/example.env @@ -1,5 +1,5 @@ -export POSTMARK_SERVER_TOKEN=111-YOUR-SERVER-TOKEN-0000-000000 -export POSTMARK_ACCOUNT_TOKEN=222-YOUR-ACCOUNT-TOKEN-0000-000000 +export POSTMARK_SERVER_TOKEN=xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx +export POSTMARK_ACCOUNT_TOKEN=xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx export POSTMARK_SENDER_EMAIL=test@example.com export POSTMARK_TEST_MODE=false export POSTMARK_TRACK_OPENS=true diff --git a/examples/bounces/activate_bounce.py b/examples/async/bounces/activate_bounce.py similarity index 100% rename from examples/bounces/activate_bounce.py rename to examples/async/bounces/activate_bounce.py diff --git a/examples/bounces/get_bounce.py b/examples/async/bounces/get_bounce.py similarity index 100% rename from examples/bounces/get_bounce.py rename to examples/async/bounces/get_bounce.py diff --git a/examples/bounces/get_bounce_dump.py b/examples/async/bounces/get_bounce_dump.py similarity index 100% rename from examples/bounces/get_bounce_dump.py rename to examples/async/bounces/get_bounce_dump.py diff --git a/examples/bounces/get_delivery_stats.py b/examples/async/bounces/get_delivery_stats.py similarity index 100% rename from examples/bounces/get_delivery_stats.py rename to examples/async/bounces/get_delivery_stats.py diff --git a/examples/bounces/list_bounces.py b/examples/async/bounces/list_bounces.py similarity index 100% rename from examples/bounces/list_bounces.py rename to examples/async/bounces/list_bounces.py diff --git a/examples/bounces/list_bounces_filtered.py b/examples/async/bounces/list_bounces_filtered.py similarity index 100% rename from examples/bounces/list_bounces_filtered.py rename to examples/async/bounces/list_bounces_filtered.py diff --git a/examples/bounces/stream_bounces.py b/examples/async/bounces/stream_bounces.py similarity index 100% rename from examples/bounces/stream_bounces.py rename to examples/async/bounces/stream_bounces.py diff --git a/examples/data_removals/create_data_removal.py b/examples/async/data_removals/create_data_removal.py similarity index 100% rename from examples/data_removals/create_data_removal.py rename to examples/async/data_removals/create_data_removal.py diff --git a/examples/data_removals/get_data_removal.py b/examples/async/data_removals/get_data_removal.py similarity index 100% rename from examples/data_removals/get_data_removal.py rename to examples/async/data_removals/get_data_removal.py diff --git a/examples/domains/create_domain.py b/examples/async/domains/create_domain.py similarity index 100% rename from examples/domains/create_domain.py rename to examples/async/domains/create_domain.py diff --git a/examples/domains/delete_domain.py b/examples/async/domains/delete_domain.py similarity index 100% rename from examples/domains/delete_domain.py rename to examples/async/domains/delete_domain.py diff --git a/examples/domains/edit_domain.py b/examples/async/domains/edit_domain.py similarity index 100% rename from examples/domains/edit_domain.py rename to examples/async/domains/edit_domain.py diff --git a/examples/domains/get_domain.py b/examples/async/domains/get_domain.py similarity index 100% rename from examples/domains/get_domain.py rename to examples/async/domains/get_domain.py diff --git a/examples/domains/list_domains.py b/examples/async/domains/list_domains.py similarity index 100% rename from examples/domains/list_domains.py rename to examples/async/domains/list_domains.py diff --git a/examples/domains/rotate_dkim.py b/examples/async/domains/rotate_dkim.py similarity index 100% rename from examples/domains/rotate_dkim.py rename to examples/async/domains/rotate_dkim.py diff --git a/examples/domains/verify_dkim.py b/examples/async/domains/verify_dkim.py similarity index 100% rename from examples/domains/verify_dkim.py rename to examples/async/domains/verify_dkim.py diff --git a/examples/domains/verify_return_path.py b/examples/async/domains/verify_return_path.py similarity index 100% rename from examples/domains/verify_return_path.py rename to examples/async/domains/verify_return_path.py diff --git a/examples/inbound_messages/bypass_inbound.py b/examples/async/inbound_messages/bypass_inbound.py similarity index 100% rename from examples/inbound_messages/bypass_inbound.py rename to examples/async/inbound_messages/bypass_inbound.py diff --git a/examples/inbound_messages/get_inbound_by_id.py b/examples/async/inbound_messages/get_inbound_by_id.py similarity index 100% rename from examples/inbound_messages/get_inbound_by_id.py rename to examples/async/inbound_messages/get_inbound_by_id.py diff --git a/examples/inbound_messages/list_inbound.py b/examples/async/inbound_messages/list_inbound.py similarity index 100% rename from examples/inbound_messages/list_inbound.py rename to examples/async/inbound_messages/list_inbound.py diff --git a/examples/inbound_messages/retry_inbound.py b/examples/async/inbound_messages/retry_inbound.py similarity index 100% rename from examples/inbound_messages/retry_inbound.py rename to examples/async/inbound_messages/retry_inbound.py diff --git a/examples/inbound_rules/create_inbound_rule.py b/examples/async/inbound_rules/create_inbound_rule.py similarity index 100% rename from examples/inbound_rules/create_inbound_rule.py rename to examples/async/inbound_rules/create_inbound_rule.py diff --git a/examples/inbound_rules/delete_inbound_rule.py b/examples/async/inbound_rules/delete_inbound_rule.py similarity index 100% rename from examples/inbound_rules/delete_inbound_rule.py rename to examples/async/inbound_rules/delete_inbound_rule.py diff --git a/examples/inbound_rules/list_inbound_rules.py b/examples/async/inbound_rules/list_inbound_rules.py similarity index 100% rename from examples/inbound_rules/list_inbound_rules.py rename to examples/async/inbound_rules/list_inbound_rules.py diff --git a/examples/message_streams/archive_stream.py b/examples/async/message_streams/archive_stream.py similarity index 100% rename from examples/message_streams/archive_stream.py rename to examples/async/message_streams/archive_stream.py diff --git a/examples/message_streams/create_stream.py b/examples/async/message_streams/create_stream.py similarity index 100% rename from examples/message_streams/create_stream.py rename to examples/async/message_streams/create_stream.py diff --git a/examples/message_streams/edit_stream.py b/examples/async/message_streams/edit_stream.py similarity index 100% rename from examples/message_streams/edit_stream.py rename to examples/async/message_streams/edit_stream.py diff --git a/examples/message_streams/get_stream.py b/examples/async/message_streams/get_stream.py similarity index 100% rename from examples/message_streams/get_stream.py rename to examples/async/message_streams/get_stream.py diff --git a/examples/message_streams/list_streams.py b/examples/async/message_streams/list_streams.py similarity index 100% rename from examples/message_streams/list_streams.py rename to examples/async/message_streams/list_streams.py diff --git a/examples/message_streams/unarchive_stream.py b/examples/async/message_streams/unarchive_stream.py similarity index 100% rename from examples/message_streams/unarchive_stream.py rename to examples/async/message_streams/unarchive_stream.py diff --git a/examples/outbound_messages/get_outbound_dump.py b/examples/async/outbound_messages/get_outbound_dump.py similarity index 100% rename from examples/outbound_messages/get_outbound_dump.py rename to examples/async/outbound_messages/get_outbound_dump.py diff --git a/examples/outbound_messages/list_and_stream_outbound.py b/examples/async/outbound_messages/list_and_stream_outbound.py similarity index 100% rename from examples/outbound_messages/list_and_stream_outbound.py rename to examples/async/outbound_messages/list_and_stream_outbound.py diff --git a/examples/outbound_messages/list_outbound_clicks.py b/examples/async/outbound_messages/list_outbound_clicks.py similarity index 100% rename from examples/outbound_messages/list_outbound_clicks.py rename to examples/async/outbound_messages/list_outbound_clicks.py diff --git a/examples/outbound_messages/list_outbound_opens.py b/examples/async/outbound_messages/list_outbound_opens.py similarity index 100% rename from examples/outbound_messages/list_outbound_opens.py rename to examples/async/outbound_messages/list_outbound_opens.py diff --git a/examples/outbound_messages/send_outbound_batch.py b/examples/async/outbound_messages/send_outbound_batch.py similarity index 100% rename from examples/outbound_messages/send_outbound_batch.py rename to examples/async/outbound_messages/send_outbound_batch.py diff --git a/examples/outbound_messages/send_outbound_bulk.py b/examples/async/outbound_messages/send_outbound_bulk.py similarity index 100% rename from examples/outbound_messages/send_outbound_bulk.py rename to examples/async/outbound_messages/send_outbound_bulk.py diff --git a/examples/outbound_messages/send_outbound_simple.py b/examples/async/outbound_messages/send_outbound_simple.py similarity index 100% rename from examples/outbound_messages/send_outbound_simple.py rename to examples/async/outbound_messages/send_outbound_simple.py diff --git a/examples/outbound_messages/send_outbound_simple_with_attachment.py b/examples/async/outbound_messages/send_outbound_simple_with_attachment.py similarity index 100% rename from examples/outbound_messages/send_outbound_simple_with_attachment.py rename to examples/async/outbound_messages/send_outbound_simple_with_attachment.py diff --git a/examples/outbound_messages/send_outbound_simple_with_custom_header.py b/examples/async/outbound_messages/send_outbound_simple_with_custom_header.py similarity index 100% rename from examples/outbound_messages/send_outbound_simple_with_custom_header.py rename to examples/async/outbound_messages/send_outbound_simple_with_custom_header.py diff --git a/examples/outbound_messages/send_with_inline_and_external_images.py b/examples/async/outbound_messages/send_with_inline_and_external_images.py similarity index 100% rename from examples/outbound_messages/send_with_inline_and_external_images.py rename to examples/async/outbound_messages/send_with_inline_and_external_images.py diff --git a/examples/servers/create_server.py b/examples/async/servers/create_server.py similarity index 100% rename from examples/servers/create_server.py rename to examples/async/servers/create_server.py diff --git a/examples/servers/delete_server.py b/examples/async/servers/delete_server.py similarity index 100% rename from examples/servers/delete_server.py rename to examples/async/servers/delete_server.py diff --git a/examples/servers/edit_server.py b/examples/async/servers/edit_server.py similarity index 100% rename from examples/servers/edit_server.py rename to examples/async/servers/edit_server.py diff --git a/examples/servers/get_server.py b/examples/async/servers/get_server.py similarity index 100% rename from examples/servers/get_server.py rename to examples/async/servers/get_server.py diff --git a/examples/servers/get_server_by_id.py b/examples/async/servers/get_server_by_id.py similarity index 100% rename from examples/servers/get_server_by_id.py rename to examples/async/servers/get_server_by_id.py diff --git a/examples/servers/list_servers.py b/examples/async/servers/list_servers.py similarity index 100% rename from examples/servers/list_servers.py rename to examples/async/servers/list_servers.py diff --git a/examples/signatures/create_signature.py b/examples/async/signatures/create_signature.py similarity index 100% rename from examples/signatures/create_signature.py rename to examples/async/signatures/create_signature.py diff --git a/examples/signatures/delete_signature.py b/examples/async/signatures/delete_signature.py similarity index 100% rename from examples/signatures/delete_signature.py rename to examples/async/signatures/delete_signature.py diff --git a/examples/signatures/edit_signature.py b/examples/async/signatures/edit_signature.py similarity index 100% rename from examples/signatures/edit_signature.py rename to examples/async/signatures/edit_signature.py diff --git a/examples/signatures/get_signature.py b/examples/async/signatures/get_signature.py similarity index 100% rename from examples/signatures/get_signature.py rename to examples/async/signatures/get_signature.py diff --git a/examples/signatures/list_signatures.py b/examples/async/signatures/list_signatures.py similarity index 100% rename from examples/signatures/list_signatures.py rename to examples/async/signatures/list_signatures.py diff --git a/examples/signatures/resend_confirmation.py b/examples/async/signatures/resend_confirmation.py similarity index 100% rename from examples/signatures/resend_confirmation.py rename to examples/async/signatures/resend_confirmation.py diff --git a/examples/stats/bounce_counts.py b/examples/async/stats/bounce_counts.py similarity index 100% rename from examples/stats/bounce_counts.py rename to examples/async/stats/bounce_counts.py diff --git a/examples/stats/browser_platform_usage.py b/examples/async/stats/browser_platform_usage.py similarity index 100% rename from examples/stats/browser_platform_usage.py rename to examples/async/stats/browser_platform_usage.py diff --git a/examples/stats/browser_usage.py b/examples/async/stats/browser_usage.py similarity index 100% rename from examples/stats/browser_usage.py rename to examples/async/stats/browser_usage.py diff --git a/examples/stats/click_counts.py b/examples/async/stats/click_counts.py similarity index 100% rename from examples/stats/click_counts.py rename to examples/async/stats/click_counts.py diff --git a/examples/stats/click_location.py b/examples/async/stats/click_location.py similarity index 100% rename from examples/stats/click_location.py rename to examples/async/stats/click_location.py diff --git a/examples/stats/email_client_usage.py b/examples/async/stats/email_client_usage.py similarity index 100% rename from examples/stats/email_client_usage.py rename to examples/async/stats/email_client_usage.py diff --git a/examples/stats/open_counts.py b/examples/async/stats/open_counts.py similarity index 100% rename from examples/stats/open_counts.py rename to examples/async/stats/open_counts.py diff --git a/examples/stats/outbound_overview.py b/examples/async/stats/outbound_overview.py similarity index 100% rename from examples/stats/outbound_overview.py rename to examples/async/stats/outbound_overview.py diff --git a/examples/stats/platform_usage.py b/examples/async/stats/platform_usage.py similarity index 100% rename from examples/stats/platform_usage.py rename to examples/async/stats/platform_usage.py diff --git a/examples/stats/read_times.py b/examples/async/stats/read_times.py similarity index 100% rename from examples/stats/read_times.py rename to examples/async/stats/read_times.py diff --git a/examples/stats/sent_counts.py b/examples/async/stats/sent_counts.py similarity index 100% rename from examples/stats/sent_counts.py rename to examples/async/stats/sent_counts.py diff --git a/examples/stats/spam_counts.py b/examples/async/stats/spam_counts.py similarity index 100% rename from examples/stats/spam_counts.py rename to examples/async/stats/spam_counts.py diff --git a/examples/stats/tracked_counts.py b/examples/async/stats/tracked_counts.py similarity index 100% rename from examples/stats/tracked_counts.py rename to examples/async/stats/tracked_counts.py diff --git a/examples/suppressions/create_suppression.py b/examples/async/suppressions/create_suppression.py similarity index 100% rename from examples/suppressions/create_suppression.py rename to examples/async/suppressions/create_suppression.py diff --git a/examples/suppressions/delete_suppression.py b/examples/async/suppressions/delete_suppression.py similarity index 100% rename from examples/suppressions/delete_suppression.py rename to examples/async/suppressions/delete_suppression.py diff --git a/examples/suppressions/dump_suppressions.py b/examples/async/suppressions/dump_suppressions.py similarity index 100% rename from examples/suppressions/dump_suppressions.py rename to examples/async/suppressions/dump_suppressions.py diff --git a/examples/templates/create_template.py b/examples/async/templates/create_template.py similarity index 100% rename from examples/templates/create_template.py rename to examples/async/templates/create_template.py diff --git a/examples/templates/delete_template.py b/examples/async/templates/delete_template.py similarity index 100% rename from examples/templates/delete_template.py rename to examples/async/templates/delete_template.py diff --git a/examples/templates/edit_template.py b/examples/async/templates/edit_template.py similarity index 100% rename from examples/templates/edit_template.py rename to examples/async/templates/edit_template.py diff --git a/examples/templates/get_template.py b/examples/async/templates/get_template.py similarity index 100% rename from examples/templates/get_template.py rename to examples/async/templates/get_template.py diff --git a/examples/templates/list_templates.py b/examples/async/templates/list_templates.py similarity index 100% rename from examples/templates/list_templates.py rename to examples/async/templates/list_templates.py diff --git a/examples/templates/push_templates.py b/examples/async/templates/push_templates.py similarity index 100% rename from examples/templates/push_templates.py rename to examples/async/templates/push_templates.py diff --git a/examples/templates/send_batch_with_templates.py b/examples/async/templates/send_batch_with_templates.py similarity index 100% rename from examples/templates/send_batch_with_templates.py rename to examples/async/templates/send_batch_with_templates.py diff --git a/examples/templates/send_with_template.py b/examples/async/templates/send_with_template.py similarity index 100% rename from examples/templates/send_with_template.py rename to examples/async/templates/send_with_template.py diff --git a/examples/templates/validate_template.py b/examples/async/templates/validate_template.py similarity index 100% rename from examples/templates/validate_template.py rename to examples/async/templates/validate_template.py diff --git a/examples/webhooks/create_webhook.py b/examples/async/webhooks/create_webhook.py similarity index 100% rename from examples/webhooks/create_webhook.py rename to examples/async/webhooks/create_webhook.py diff --git a/examples/webhooks/delete_webhook.py b/examples/async/webhooks/delete_webhook.py similarity index 100% rename from examples/webhooks/delete_webhook.py rename to examples/async/webhooks/delete_webhook.py diff --git a/examples/webhooks/edit_webhook.py b/examples/async/webhooks/edit_webhook.py similarity index 100% rename from examples/webhooks/edit_webhook.py rename to examples/async/webhooks/edit_webhook.py diff --git a/examples/webhooks/get_webhook.py b/examples/async/webhooks/get_webhook.py similarity index 100% rename from examples/webhooks/get_webhook.py rename to examples/async/webhooks/get_webhook.py diff --git a/examples/webhooks/list_webhooks.py b/examples/async/webhooks/list_webhooks.py similarity index 100% rename from examples/webhooks/list_webhooks.py rename to examples/async/webhooks/list_webhooks.py diff --git a/examples/sync/bounces/activate_bounce.py b/examples/sync/bounces/activate_bounce.py new file mode 100644 index 0000000..99bc5c2 --- /dev/null +++ b/examples/sync/bounces/activate_bounce.py @@ -0,0 +1,9 @@ +import postmark + +# Bounce ID's that can be activated show "can_activate" -> True. +bounce_id = 692560173 + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + result = client.bounces.activate(bounce_id) + print(f"Response: {result.message}") + print(f"Inactive after activation: {result.bounce.inactive}") diff --git a/examples/sync/bounces/get_bounce.py b/examples/sync/bounces/get_bounce.py new file mode 100644 index 0000000..b749b34 --- /dev/null +++ b/examples/sync/bounces/get_bounce.py @@ -0,0 +1,16 @@ +import postmark + +bounce_id = 692560173 + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + bounce = client.bounces.get(bounce_id) + + print(f"ID: {bounce.id}") + print(f"Type: {bounce.type}") + print(f"Email: {bounce.email}") + print(f"Subject: {bounce.subject}") + print(f"Bounced at: {bounce.bounced_at:%Y-%m-%d %H:%M:%S}") + print(f"Description: {bounce.description}") + print(f"Inactive: {bounce.inactive}") + print(f"Can activate: {bounce.can_activate}") + print(f"Dump available: {bounce.dump_available}") diff --git a/examples/sync/bounces/get_bounce_dump.py b/examples/sync/bounces/get_bounce_dump.py new file mode 100644 index 0000000..b96a9c9 --- /dev/null +++ b/examples/sync/bounces/get_bounce_dump.py @@ -0,0 +1,12 @@ +import postmark + +# Bounce ID must have "dump_available" -> True. +bounce_id = 692560173 +# Postmark retains raw SMTP dumps for ~30 days after the bounce. + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + dump = client.bounces.get_dump(bounce_id) + if dump.body: + print(dump.body) + else: + print("Dump not available (may have expired after 30 days).") diff --git a/examples/sync/bounces/get_delivery_stats.py b/examples/sync/bounces/get_delivery_stats.py new file mode 100644 index 0000000..64797e7 --- /dev/null +++ b/examples/sync/bounces/get_delivery_stats.py @@ -0,0 +1,9 @@ +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + stats = client.bounces.get_delivery_stats() + + print(f"Inactive addresses: {stats.inactive_mails}") + + for entry in stats.bounces: + print(f" {entry.name}: {entry.count}") diff --git a/examples/sync/bounces/list_bounces.py b/examples/sync/bounces/list_bounces.py new file mode 100644 index 0000000..7dc324e --- /dev/null +++ b/examples/sync/bounces/list_bounces.py @@ -0,0 +1,11 @@ +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # Filter to a specific bounce type; omit `type` to list all. + result = client.bounces.list() + print(f"{result.total} hard bounce(s) on server, showing {len(result.items)}") + for b in result.items: + print( + f" [{b.id}] {b.email} bounced={b.bounced_at:%Y-%m-%d}" + f" inactive={b.inactive}" + ) diff --git a/examples/sync/bounces/list_bounces_filtered.py b/examples/sync/bounces/list_bounces_filtered.py new file mode 100644 index 0000000..31690a4 --- /dev/null +++ b/examples/sync/bounces/list_bounces_filtered.py @@ -0,0 +1,16 @@ +from datetime import datetime + +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # Narrow results to inactive addresses within a date range on a specific stream. + result = client.bounces.list( + count=25, + inactive=True, + from_date=datetime(2024, 1, 1), + to_date=datetime(2024, 12, 31), + message_stream="outbound", + ) + print(f"{result.total} matching bounce(s), showing {len(result.items)}") + for b in result.items: + print(f" [{b.id}] {b.email} type={b.type} bounced={b.bounced_at:%Y-%m-%d}") diff --git a/examples/sync/bounces/stream_bounces.py b/examples/sync/bounces/stream_bounces.py new file mode 100644 index 0000000..3c93dbe --- /dev/null +++ b/examples/sync/bounces/stream_bounces.py @@ -0,0 +1,9 @@ +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # stream() paginates automatically; adjust max_bounces as needed (max 10,000). + count = 0 + for b in client.bounces.stream(max_bounces=200): + print(f"[{b.id}] {b.email} type={b.type}") + count += 1 + print(f"Streamed {count} bounce(s)") diff --git a/examples/sync/data_removals/create_data_removal.py b/examples/sync/data_removals/create_data_removal.py new file mode 100644 index 0000000..1df1683 --- /dev/null +++ b/examples/sync/data_removals/create_data_removal.py @@ -0,0 +1,11 @@ +import postmark + +with postmark.sync.AccountClient("xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx") as account: + result = account.data_removals.create( + requested_by="admin@example.com", + requested_for="user@example.com", + notify_when_completed=True, + ) + + print(f"ID: {result.id}") + print(f"Status: {result.status}") diff --git a/examples/sync/data_removals/get_data_removal.py b/examples/sync/data_removals/get_data_removal.py new file mode 100644 index 0000000..8258fe1 --- /dev/null +++ b/examples/sync/data_removals/get_data_removal.py @@ -0,0 +1,7 @@ +import postmark + +with postmark.sync.AccountClient("xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx") as account: + result = account.data_removals.get(42) + + print(f"ID: {result.id}") + print(f"Status: {result.status}") diff --git a/examples/sync/domains/create_domain.py b/examples/sync/domains/create_domain.py new file mode 100644 index 0000000..1dfd2f1 --- /dev/null +++ b/examples/sync/domains/create_domain.py @@ -0,0 +1,15 @@ +import postmark + +with postmark.sync.AccountClient("xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx") as account: + domain = account.domain.create( + name="example.com", + return_path_domain="pm-bounces.example.com", + ) + + print("Created domain:") + print(f" ID: {domain.id}") + print(f" Name: {domain.name}") + print(f" DKIM host: {domain.dkim_host}") + print(f" DKIM text value: {domain.dkim_text_value}") + print(f" Return-Path domain: {domain.return_path_domain}") + print(f" Return-Path CNAME: {domain.return_path_domain_cname_value}") diff --git a/examples/sync/domains/delete_domain.py b/examples/sync/domains/delete_domain.py new file mode 100644 index 0000000..7827f1c --- /dev/null +++ b/examples/sync/domains/delete_domain.py @@ -0,0 +1,7 @@ +import postmark + +domain_id = 0 # Replace with the ID of the domain to delete + +with postmark.sync.AccountClient("xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx") as account: + result = account.domain.delete(domain_id) + print(result.message) diff --git a/examples/sync/domains/edit_domain.py b/examples/sync/domains/edit_domain.py new file mode 100644 index 0000000..0ccc2d0 --- /dev/null +++ b/examples/sync/domains/edit_domain.py @@ -0,0 +1,15 @@ +import postmark + +domain_id = 0 # Replace with the ID of the domain to update + +with postmark.sync.AccountClient("xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx") as account: + domain = account.domain.edit( + domain_id, + return_path_domain="pm-bounces.example.com", + ) + + print("Updated domain:") + print(f" ID: {domain.id}") + print(f" Name: {domain.name}") + print(f" Return-Path domain: {domain.return_path_domain}") + print(f" Return-Path verified: {domain.return_path_domain_verified}") diff --git a/examples/sync/domains/get_domain.py b/examples/sync/domains/get_domain.py new file mode 100644 index 0000000..0430270 --- /dev/null +++ b/examples/sync/domains/get_domain.py @@ -0,0 +1,14 @@ +import postmark + +domain_id = 0 # Replace with the ID of the domain to retrieve + +with postmark.sync.AccountClient("xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx") as account: + domain = account.domain.get(domain_id) + + print(f"Domain: {domain.name}") + print(f" ID: {domain.id}") + print(f" DKIM verified: {domain.dkim_verified}") + print(f" DKIM host: {domain.dkim_host}") + print(f" Return-Path domain: {domain.return_path_domain}") + print(f" Return-Path verified: {domain.return_path_domain_verified}") + print(f" DKIM update status: {domain.dkim_update_status}") diff --git a/examples/sync/domains/list_domains.py b/examples/sync/domains/list_domains.py new file mode 100644 index 0000000..13566e9 --- /dev/null +++ b/examples/sync/domains/list_domains.py @@ -0,0 +1,13 @@ +import postmark + +with postmark.sync.AccountClient("xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx") as account: + result = account.domain.list() + + print(f"Total domains: {result.total}") + print() + + for domain in result.items: + print(f" [{domain.id}] {domain.name}") + print(f" DKIM verified: {domain.dkim_verified}") + print(f" Return-Path verified: {domain.return_path_domain_verified}") + print("----------------------------------------") diff --git a/examples/sync/domains/rotate_dkim.py b/examples/sync/domains/rotate_dkim.py new file mode 100644 index 0000000..06bd75c --- /dev/null +++ b/examples/sync/domains/rotate_dkim.py @@ -0,0 +1,12 @@ +import postmark + +domain_id = 0 # Replace with the ID of the domain to rotate DKIM keys for + +with postmark.sync.AccountClient("xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx") as account: + domain = account.domain.rotate_dkim(domain_id) + + print(f"Domain: {domain.name}") + print(f" DKIM update status: {domain.dkim_update_status}") + print(f" Current DKIM host: {domain.dkim_host}") + print(f" Pending DKIM host: {domain.dkim_pending_host}") + print(f" Pending DKIM text value: {domain.dkim_pending_text_value}") diff --git a/examples/sync/domains/verify_dkim.py b/examples/sync/domains/verify_dkim.py new file mode 100644 index 0000000..a8ea4d0 --- /dev/null +++ b/examples/sync/domains/verify_dkim.py @@ -0,0 +1,11 @@ +import postmark + +domain_id = 0 # Replace with the ID of the domain to verify + +with postmark.sync.AccountClient("xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx") as account: + domain = account.domain.verify_dkim(domain_id) + + print(f"Domain: {domain.name}") + print(f" DKIM verified: {domain.dkim_verified}") + print(f" DKIM update status: {domain.dkim_update_status}") + print(f" Weak DKIM: {domain.weak_dkim}") diff --git a/examples/sync/domains/verify_return_path.py b/examples/sync/domains/verify_return_path.py new file mode 100644 index 0000000..a26b964 --- /dev/null +++ b/examples/sync/domains/verify_return_path.py @@ -0,0 +1,11 @@ +import postmark + +domain_id = 0 # Replace with the ID of the domain to verify + +with postmark.sync.AccountClient("xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx") as account: + domain = account.domain.verify_return_path(domain_id) + + print(f"Domain: {domain.name}") + print(f" Return-Path domain: {domain.return_path_domain}") + print(f" Return-Path verified: {domain.return_path_domain_verified}") + print(f" Return-Path CNAME: {domain.return_path_domain_cname_value}") diff --git a/examples/sync/inbound_messages/bypass_inbound.py b/examples/sync/inbound_messages/bypass_inbound.py new file mode 100644 index 0000000..0316b2f --- /dev/null +++ b/examples/sync/inbound_messages/bypass_inbound.py @@ -0,0 +1,9 @@ +import postmark + +MESSAGE_ID = "your-blocked-message-id-here" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + result = client.inbound.bypass(MESSAGE_ID) + + print(f"Error code: {result.error_code}") + print(f"Message: {result.message}") diff --git a/examples/sync/inbound_messages/get_inbound_by_id.py b/examples/sync/inbound_messages/get_inbound_by_id.py new file mode 100644 index 0000000..5269073 --- /dev/null +++ b/examples/sync/inbound_messages/get_inbound_by_id.py @@ -0,0 +1,18 @@ +import postmark + +MESSAGE_ID = "your-message-id-here" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + msg = client.inbound.get(MESSAGE_ID) + + print(f"ID: {msg.message_id}") + print(f"From: {msg.from_email} ({msg.from_name})") + print(f"To: {msg.to}") + print(f"Subject: {msg.subject}") + print(f"Status: {msg.status}") + print(f"Date: {msg.date}") + if msg.text_body: + print(f"Body: {msg.text_body[:100]}") + if msg.blocked_reason: + print(f"Blocked: {msg.blocked_reason}") + print(f"Headers: {len(msg.headers)}") diff --git a/examples/sync/inbound_messages/list_inbound.py b/examples/sync/inbound_messages/list_inbound.py new file mode 100644 index 0000000..f6b4b70 --- /dev/null +++ b/examples/sync/inbound_messages/list_inbound.py @@ -0,0 +1,14 @@ +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + result = client.inbound.list(count=10) + + print(f"Total inbound messages: {result.total}") + print() + + for msg in result.items: + print(f" [{msg.message_id}] {msg.subject}") + print(f" From: {msg.from_email}") + print(f" Status: {msg.status}") + print(f" Date: {msg.date}") + print("----------------------------------------") diff --git a/examples/sync/inbound_messages/retry_inbound.py b/examples/sync/inbound_messages/retry_inbound.py new file mode 100644 index 0000000..2e3ccf9 --- /dev/null +++ b/examples/sync/inbound_messages/retry_inbound.py @@ -0,0 +1,9 @@ +import postmark + +MESSAGE_ID = "id-of-a-failed-message" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + result = client.inbound.retry(MESSAGE_ID) + + print(f"Error code: {result.error_code}") + print(f"Message: {result.message}") diff --git a/examples/sync/inbound_rules/create_inbound_rule.py b/examples/sync/inbound_rules/create_inbound_rule.py new file mode 100644 index 0000000..81a7506 --- /dev/null +++ b/examples/sync/inbound_rules/create_inbound_rule.py @@ -0,0 +1,8 @@ +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as server: + rule = server.inbound_rules.create("spam@example.com") + + print("Created inbound rule:") + print(f" ID: {rule.id}") + print(f" Rule: {rule.rule}") diff --git a/examples/sync/inbound_rules/delete_inbound_rule.py b/examples/sync/inbound_rules/delete_inbound_rule.py new file mode 100644 index 0000000..de871c0 --- /dev/null +++ b/examples/sync/inbound_rules/delete_inbound_rule.py @@ -0,0 +1,7 @@ +import postmark + +trigger_id = 0 # Replace with the ID of the inbound rule to delete + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as server: + result = server.inbound_rules.delete(trigger_id) + print(result.message) diff --git a/examples/sync/inbound_rules/list_inbound_rules.py b/examples/sync/inbound_rules/list_inbound_rules.py new file mode 100644 index 0000000..7fada26 --- /dev/null +++ b/examples/sync/inbound_rules/list_inbound_rules.py @@ -0,0 +1,9 @@ +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as server: + result = server.inbound_rules.list() + + print(f"Total inbound rules: {result.total}\n") + + for rule in result.items: + print(f" [{rule.id}] {rule.rule}") diff --git a/examples/sync/message_streams/archive_stream.py b/examples/sync/message_streams/archive_stream.py new file mode 100644 index 0000000..af90f37 --- /dev/null +++ b/examples/sync/message_streams/archive_stream.py @@ -0,0 +1,9 @@ +import postmark + +STREAM_ID = "my-broadcasts" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + result = client.stream.archive(STREAM_ID) + + print(f"Archived stream: {result.id}") + print(f"Expected purge date: {result.expected_purge_date}") diff --git a/examples/sync/message_streams/create_stream.py b/examples/sync/message_streams/create_stream.py new file mode 100644 index 0000000..464ef81 --- /dev/null +++ b/examples/sync/message_streams/create_stream.py @@ -0,0 +1,20 @@ +import postmark +from postmark.models.streams import MessageStreamType, UnsubscribeHandlingType + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + stream = client.stream.create( + id="my-broadcasts", + name="My Broadcast Stream", + message_stream_type=MessageStreamType.BROADCASTS, + description="Used for newsletters and announcements", + unsubscribe_handling_type=UnsubscribeHandlingType.POSTMARK, + ) + + print("Created stream:") + print(f" ID: {stream.id}") + print(f" Name: {stream.name}") + print(f" Type: {stream.message_stream_type.value}") + print(f" Description: {stream.description}") + print( + f" Unsubscribe: {stream.subscription_management_configuration.unsubscribe_handling_type.value}" + ) diff --git a/examples/sync/message_streams/edit_stream.py b/examples/sync/message_streams/edit_stream.py new file mode 100644 index 0000000..10161a7 --- /dev/null +++ b/examples/sync/message_streams/edit_stream.py @@ -0,0 +1,20 @@ +import postmark +from postmark.models.streams import UnsubscribeHandlingType + +STREAM_ID = "my-broadcasts" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + stream = client.stream.edit( + STREAM_ID, + name="Updated Broadcast Stream", + description="Newsletters and product updates", + unsubscribe_handling_type=UnsubscribeHandlingType.POSTMARK, + ) + + print("Updated stream:") + print(f" ID: {stream.id}") + print(f" Name: {stream.name}") + print(f" Description: {stream.description}") + print( + f" Unsubscribe: {stream.subscription_management_configuration.unsubscribe_handling_type.value}" + ) diff --git a/examples/sync/message_streams/get_stream.py b/examples/sync/message_streams/get_stream.py new file mode 100644 index 0000000..bbc995e --- /dev/null +++ b/examples/sync/message_streams/get_stream.py @@ -0,0 +1,16 @@ +import postmark + +STREAM_ID = "outbound" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + stream = client.stream.get(STREAM_ID) + + print(f"ID: {stream.id}") + print(f"Name: {stream.name}") + print(f"Type: {stream.message_stream_type.value}") + print(f"Description: {stream.description}") + print(f"Created at: {stream.created_at}") + print(f"Updated at: {stream.updated_at}") + print( + f"Unsubscribe: {stream.subscription_management_configuration.unsubscribe_handling_type.value}" + ) diff --git a/examples/sync/message_streams/list_streams.py b/examples/sync/message_streams/list_streams.py new file mode 100644 index 0000000..e8e32ac --- /dev/null +++ b/examples/sync/message_streams/list_streams.py @@ -0,0 +1,14 @@ +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + result = client.stream.list() + + print(f"Total streams: {result.total}") + print() + + for stream in result.items: + print(f" [{stream.id}] {stream.name}") + print(f" Type: {stream.message_stream_type.value}") + print(f" Created at: {stream.created_at}") + print(f" Archived: {stream.archived_at is not None}") + print("----------------------------------------") diff --git a/examples/sync/message_streams/unarchive_stream.py b/examples/sync/message_streams/unarchive_stream.py new file mode 100644 index 0000000..13ad609 --- /dev/null +++ b/examples/sync/message_streams/unarchive_stream.py @@ -0,0 +1,11 @@ +import postmark + +STREAM_ID = "my-broadcasts" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + stream = client.stream.unarchive(STREAM_ID) + + print(f"Unarchived stream: {stream.id}") + print(f"Name: {stream.name}") + print(f"Type: {stream.message_stream_type.value}") + print(f"Archived at: {stream.archived_at}") diff --git a/examples/sync/outbound_messages/get_outbound_dump.py b/examples/sync/outbound_messages/get_outbound_dump.py new file mode 100644 index 0000000..17dbbdf --- /dev/null +++ b/examples/sync/outbound_messages/get_outbound_dump.py @@ -0,0 +1,7 @@ +import postmark + +MESSAGE_ID = "your-message-id-here" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + dump = client.outbound.get_dump(MESSAGE_ID) + print(dump.body) diff --git a/examples/sync/outbound_messages/list_and_stream_outbound.py b/examples/sync/outbound_messages/list_and_stream_outbound.py new file mode 100644 index 0000000..67dabba --- /dev/null +++ b/examples/sync/outbound_messages/list_and_stream_outbound.py @@ -0,0 +1,28 @@ +""" +Examples for retrieving sent messages. + + python examples/sync/outbound_messages/list_and_stream_outbound.py +""" + +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # --- List --- + result = client.outbound.list(count=10) + print(f"List: {result.total} total on server, showing {len(result.items)}") + for msg in result.items: + print(f" {msg.received_at:%Y-%m-%d} {msg.subject} → {msg.recipients}") + + # --- Stream (auto-paginated) --- + print("\nStream: first 50 messages") + for msg in client.outbound.stream(max_messages=50): + print(f" {msg.message_id} {msg.subject}") + + # --- Get full detail for the first message from the list --- + if result.items: + print(f"\nDetail for message: {result.items[0].message_id}") + detail = client.outbound.get(result.items[0].message_id) + print(f" Status: {detail.status}") + print(f" Events: {[e.type for e in detail.message_events]}") + else: + print("No messages found to fetch details for.") diff --git a/examples/sync/outbound_messages/list_outbound_clicks.py b/examples/sync/outbound_messages/list_outbound_clicks.py new file mode 100644 index 0000000..35b5a8e --- /dev/null +++ b/examples/sync/outbound_messages/list_outbound_clicks.py @@ -0,0 +1,22 @@ +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # All clicks across messages + result = client.outbound.list_clicks(count=10) + + print(f"Total clicks: {result.total}") + print() + + for event in result.items: + print(f" [{event.message_id}] {event.recipient}") + print(f" Link: {event.original_link}") + print(f" Location: {event.click_location}") + print(f" At: {event.received_at}") + + print() + + # Clicks for a specific message + if result.items: + msg_id = result.items[0].message_id + msg_result = client.outbound.list_message_clicks(msg_id) + print(f"Clicks for {msg_id}: {msg_result.total}") diff --git a/examples/sync/outbound_messages/list_outbound_opens.py b/examples/sync/outbound_messages/list_outbound_opens.py new file mode 100644 index 0000000..71b7539 --- /dev/null +++ b/examples/sync/outbound_messages/list_outbound_opens.py @@ -0,0 +1,22 @@ +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # All opens across messages + result = client.outbound.list_opens(count=10) + + print(f"Total opens: {result.total}") + print() + + for event in result.items: + print(f" [{event.message_id}] {event.recipient}") + print(f" Platform: {event.platform}") + print(f" Client: {event.client.name}") + print(f" At: {event.received_at}") + + print() + + # Opens for a specific message + if result.items: + msg_id = result.items[0].message_id + msg_result = client.outbound.list_message_opens(msg_id) + print(f"Opens for {msg_id}: {msg_result.total}") diff --git a/examples/sync/outbound_messages/send_outbound_batch.py b/examples/sync/outbound_messages/send_outbound_batch.py new file mode 100644 index 0000000..bcfcae9 --- /dev/null +++ b/examples/sync/outbound_messages/send_outbound_batch.py @@ -0,0 +1,25 @@ +import postmark + +SENDER = "sender@example.com" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # --- Send batch --- + responses = client.outbound.send_batch( + [ + { + "sender": SENDER, + "to": "receiver1@example.com", + "subject": "Batch 1", + "text_body": "Hello Receiver 1", + }, + { + "sender": SENDER, + "to": "receiver2@example.com", + "subject": "Batch 2", + "text_body": "Hello Receiver 2", + }, + ] + ) + print(f"Batch: {len(responses)} sent") + for i, resp in enumerate(responses, start=1): + print(f" {i}: {resp.message_id}") diff --git a/examples/sync/outbound_messages/send_outbound_bulk.py b/examples/sync/outbound_messages/send_outbound_bulk.py new file mode 100644 index 0000000..61cc161 --- /dev/null +++ b/examples/sync/outbound_messages/send_outbound_bulk.py @@ -0,0 +1,73 @@ +import postmark +from postmark.models.outbound import BulkEmail, BulkRecipient + +SENDER = "sender@example.com" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # --- Recommended: use BulkRecipient models for improved type safety. --- + response = client.outbound.send_bulk( + BulkEmail( + sender=SENDER, + subject="Hello {{FirstName}}, your order is ready", + html_body=( + "
Hi {{FirstName}}, your order" + " {{OrderId}} is ready.
" + ), + text_body="Hi {{FirstName}}, your order {{OrderId}} is ready.", + message_stream="broadcast", + messages=[ + BulkRecipient( + to="bob@example.com", + template_model={"FirstName": "Bob", "OrderId": "ORD-001"}, + ), + BulkRecipient( + to="frieda@example.com", + template_model={"FirstName": "Frieda", "OrderId": "ORD-002"}, + ), + BulkRecipient( + to="elijah@example.com", + template_model={"FirstName": "Elijah", "OrderId": "ORD-003"}, + ), + ], + ) + ) + print(f"Bulk request accepted — ID: {response.id} Status: {response.status}") + + # --- ...or send bulk using dict(s) --- + response = client.outbound.send_bulk( + { + "sender": SENDER, + "subject": "Hello {{FirstName}}, your order is ready", + "html_body": ( + "Hi {{FirstName}}, your order" + " {{OrderId}} is ready.
" + ), + "text_body": "Hi {{FirstName}}, your order {{OrderId}} is ready.", + "message_stream": "broadcast", + "track_opens": True, + "messages": [ + { + "to": "bob@example.com", + "template_model": {"FirstName": "Bob", "OrderId": "ORD-001"}, + }, + { + "to": "frieda@example.com", + "template_model": {"FirstName": "Frieda", "OrderId": "ORD-002"}, + }, + { + "to": "elijah@example.com", + "template_model": {"FirstName": "Elijah", "OrderId": "ORD-003"}, + "cc": "manager@example.com", + }, + ], + } + ) + print(f"Bulk request accepted — ID: {response.id} Status: {response.status}") + + # --- Poll for completion --- + status = client.outbound.get_bulk_status(response.id) + print( + f"Status: {status.status} (" + f"{status.percentage_completed:.0f}% of" + f" {status.total_messages} messages sent)" + ) diff --git a/examples/sync/outbound_messages/send_outbound_simple.py b/examples/sync/outbound_messages/send_outbound_simple.py new file mode 100644 index 0000000..26e6ab8 --- /dev/null +++ b/examples/sync/outbound_messages/send_outbound_simple.py @@ -0,0 +1,39 @@ +""" +Examples for sending messages. + + python examples/sync/outbound_messages/send_outbound_simple.py +""" + +import postmark + +SENDER = "sender@example.com" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # --- Send via dict --- + response = client.outbound.send( + { + "sender": SENDER, + "to": "receiver@adjkshfjkadshfjkash.com", + "subject": "Hello from Postmark Python SDK", + "text_body": "This is a test email sent using the Python SDK.", + "html_body": ( + "Hello" + " from Postmark Python SDK." + ), + "message_stream": "outbound", + } + ) + # print(f"Sent (using dict): {response.message_id}") + print(f"\nFull Response: {response}") + + # --- Send via Email model (recommended, offering better type safety) --- + response = client.outbound.send( + postmark.Email( + sender=SENDER, + to="receiver@example.com", + subject="Hello via Model", + text_body="This email was built using the Pydantic model.", + metadata={"user_id": "12345"}, + ) + ) + print(f"Sent (model): {response.message_id}") diff --git a/examples/sync/outbound_messages/send_outbound_simple_with_attachment.py b/examples/sync/outbound_messages/send_outbound_simple_with_attachment.py new file mode 100644 index 0000000..f5f9f0f --- /dev/null +++ b/examples/sync/outbound_messages/send_outbound_simple_with_attachment.py @@ -0,0 +1,54 @@ +""" +Example for sending emails with attachments. + +Attachment content must be Base64-encoded before sending. +The standard library's base64 module handles this for both +in-memory content and files read from disk. + +""" + +import base64 + +import postmark +from postmark.models.outbound import Attachment, Email + +SENDER = "sender@example.com" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # Building an attachment from a string (in-memory content) + report_txt = Attachment( + name="report.txt", + content=base64.b64encode(b"Q3 sales are up 12%.").decode("utf-8"), + content_type="text/plain", + ) + + # Building an attachments from a file, on disk + with open("/path/to/book.pdf", "rb") as f: + book_pdf = Attachment( + name="book.pdf", + content=base64.b64encode(f.read()).decode("utf-8"), + content_type="application/pdf", + ) + + # Building an attachment from an inline image + 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", # reference in html_body asPlease find your report attached.
Sent with postmark.sync — no async required.
", + } + ) + print(f"Message ID: {response.message_id}") + print(f"Accepted: {response.success}") diff --git a/examples/sync/outbound_messages/send_with_inline_and_external_images.py b/examples/sync/outbound_messages/send_with_inline_and_external_images.py new file mode 100644 index 0000000..7125a7e --- /dev/null +++ b/examples/sync/outbound_messages/send_with_inline_and_external_images.py @@ -0,0 +1,59 @@ +""" +Example: combining inline (base64) images and external image URLs in one email. + +Use case: + - Inline images (content_id / cid:) are embedded in the email itself. + The recipient's email client displays them without making any external HTTP + request, which is ideal for logos or brand assets you always want visible. + - External image URLs are loaded from a remote server at open time. + This is the standard technique for tracking pixels and analytics because + the server can record who fetched the image and when. + +Both techniques can appear in the same html_body. +""" + +import base64 + +import postmark +from postmark.models.outbound import Attachment, Email + +SENDER = "sender@example.com" + +# External tracking pixel URL — loaded by the recipient's email client at open +# time, so the server can record the open event. +TRACKING_PIXEL_URL = "https://track.example.com/pixel.png" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # Inline logo — embedded in the email as base64, no external request. + 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 asHello! Thanks for reading.
+ + +Hi {{name}}, thanks for joining!
", + "TextBody": "Hi {{name}}, thanks for joining!", + } + ) + print(f"Created (via dict): id={result.template_id} alias={result.alias}") + + # --- Create via CreateTemplateRequest model --- + result = client.templates.create( + CreateTemplateRequest( + name="Password Reset", + alias="password-reset", + subject="Reset your password", + html_body=( + "Click here to reset your password.
" + ), + text_body="Reset your password: {{reset_url}}", + ) + ) + + print(f"Created (model): id={result.template_id} alias={result.alias}") diff --git a/examples/sync/templates/delete_template.py b/examples/sync/templates/delete_template.py new file mode 100644 index 0000000..c65028d --- /dev/null +++ b/examples/sync/templates/delete_template.py @@ -0,0 +1,12 @@ +import postmark + +template_id = 12345 + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # --- Delete by numeric ID --- + result = client.templates.delete(template_id) + print(f"Deleted (ID): code={result.error_code} message={result.message}") + + # --- Delete by alias --- + result = client.templates.delete("old-promo-email") + print(f"Deleted (alias): code={result.error_code} message={result.message}") diff --git a/examples/sync/templates/edit_template.py b/examples/sync/templates/edit_template.py new file mode 100644 index 0000000..aa46096 --- /dev/null +++ b/examples/sync/templates/edit_template.py @@ -0,0 +1,25 @@ +import postmark +from postmark.models.templates import EditTemplateRequest + +template_id = 12345 + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # --- Edit by numeric ID via dict --- + result = client.templates.edit( + template_id, + { + "Subject": "Welcome back, {{name}}!", + "HtmlBody": ( + "Hi {{name}}, we updated our terms." + " Read more.
" + ), + }, + ) + print(f"Edited (ID): {result.name} active={result.active}") + + # --- Edit by alias via EditTemplateRequest model --- + result = client.templates.edit( + "welcome-email", + EditTemplateRequest(name="Welcome Email v2"), + ) + print(f"Edited (alias): {result.name} id={result.template_id}") diff --git a/examples/sync/templates/get_template.py b/examples/sync/templates/get_template.py new file mode 100644 index 0000000..d62295e --- /dev/null +++ b/examples/sync/templates/get_template.py @@ -0,0 +1,19 @@ +import postmark + +template_id = 12345 + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # --- Get by numeric ID --- + template = client.templates.get(template_id) + + print(f"ID: {template.template_id}") + print(f"Name: {template.name}") + print(f"Alias: {template.alias}") + print(f"Type: {template.template_type}") + print(f"Active: {template.active}") + print(f"Subject: {template.subject}") + + # --- Get by alias --- + template = client.templates.get("welcome-email") + + print(f"\nFetched by alias: {template.name} (id={template.template_id})") diff --git a/examples/sync/templates/list_templates.py b/examples/sync/templates/list_templates.py new file mode 100644 index 0000000..41edf2b --- /dev/null +++ b/examples/sync/templates/list_templates.py @@ -0,0 +1,29 @@ +import postmark +from postmark.models.templates import TemplateTypeFilter + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # --- List all templates (default: count=100, offset=0) --- + result = client.templates.list() + print(f"Total templates: {result.total}, showing {len(result.items)}") + for t in result.items: + print( + f" [{t.template_id:>6}] {t.name:<30}" + f" type={t.template_type} active={t.active}" + ) + + # --- List only Standard templates --- + result = client.templates.list( + count=50, + template_type=TemplateTypeFilter.STANDARD, + ) + print(f"\nStandard templates: {result.total} total, showing {len(result.items)}") + + # --- List only Layout templates --- + result = client.templates.list( + template_type=TemplateTypeFilter.LAYOUT, + ) + print(f"Layout templates: {result.total} total, showing {len(result.items)}") + + # --- Paginate: second result of 10 --- + result = client.templates.list(count=10, offset=10) + print(f"\nresult 2 (offset=10): {len(result.items)} template(s)") diff --git a/examples/sync/templates/push_templates.py b/examples/sync/templates/push_templates.py new file mode 100644 index 0000000..aebcf01 --- /dev/null +++ b/examples/sync/templates/push_templates.py @@ -0,0 +1,35 @@ +import postmark +from postmark.models.templates import PushTemplatesRequest + +""" +This is an example of pushing all templates with changes to another server. +If the template already exists on the destination server, the template will +be updated. If the template does not exist on the destination server, it will be +created and assigned the alias of the template on the source server. +""" + +SOURCE_SERVER_ID = "id-of-the-source-server" +DESTINATION_SERVER_ID = "id-of-the-destination-server" + +with postmark.sync.AccountClient("xxx-YOUR-ACCOUNT-TOKEN-xxxx-xxxxxxx") as client: + # --- Dry run: preview what would change without applying --- + result = client.templates.push( + { + "SourceServerID": SOURCE_SERVER_ID, + "DestinationServerID": DESTINATION_SERVER_ID, + "PerformChanges": False, + } + ) + print(f"Dry run — {result.total_count} template(s) would be affected:") + for t in result.templates: + print(f" action={t.action:<6} [{t.template_id}] {t.name} alias={t.alias}") + + # --- Apply: push templates for real --- + result = client.templates.push( + PushTemplatesRequest( + SourceServerID=SOURCE_SERVER_ID, + DestinationServerID=DESTINATION_SERVER_ID, + PerformChanges=True, + ) + ) + print(f"\nPushed — {result.total_count} template(s) updated.") diff --git a/examples/sync/templates/send_batch_with_templates.py b/examples/sync/templates/send_batch_with_templates.py new file mode 100644 index 0000000..c8a3ae9 --- /dev/null +++ b/examples/sync/templates/send_batch_with_templates.py @@ -0,0 +1,27 @@ +import postmark + +SENDER = "sender@example.com" + +# Each message can use a different template and model — up to 500 per batch. +RECIPIENTS = [ + {"name": "Alice", "email": "alice@example.com"}, + {"name": "Bob", "email": "bob@example.com"}, + {"name": "Carol", "email": "carol@example.com"}, +] + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + messages = [ + { + "From": SENDER, + "To": r["email"], + "TemplateAlias": "welcome-email", + "TemplateModel": {"name": r["name"]}, + } + for r in RECIPIENTS + ] + + responses = client.outbound.send_batch_with_template(messages) + + print(f"Batch: {len(responses)} sent") + for resp, r in zip(responses, RECIPIENTS): + print(f" {r['name']:10} id={resp.message_id} code={resp.error_code}") diff --git a/examples/sync/templates/send_with_template.py b/examples/sync/templates/send_with_template.py new file mode 100644 index 0000000..dbcaf61 --- /dev/null +++ b/examples/sync/templates/send_with_template.py @@ -0,0 +1,45 @@ +import postmark +from postmark.models.templates import TemplateEmail + +SENDER = "sender@example.com" + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # --- Send via model(using template ID) --- + response = client.outbound.send_with_template( + TemplateEmail( + sender=SENDER, + to="recipient@example.com", + template_id=12345, + template_model={ + "name": "Alice", + "action_url": "https://example.com/confirm", + }, + ) + ) + print(f"Sent (dict, ID): {response.message_id}") + + # --- Send via dict (using template alias) --- + response = client.outbound.send_with_template( + { + "From": SENDER, + "To": "recipient@example.com", + "TemplateAlias": "welcome-email", + "TemplateModel": { + "name": "Bob", + "action_url": "https://example.com/confirm", + }, + } + ) + print(f"Sent (dict, alias): {response.message_id}") + + # --- Send via TemplateEmail model (recommended, offering better type safety) --- + response = client.outbound.send_with_template( + TemplateEmail( + sender=SENDER, + to="recipient@example.com", + template_alias="welcome-email", + template_model={"name": "Carol"}, + message_stream="outbound", + ) + ) + print(f"Sent (model): {response.message_id}") diff --git a/examples/sync/templates/validate_template.py b/examples/sync/templates/validate_template.py new file mode 100644 index 0000000..1f95358 --- /dev/null +++ b/examples/sync/templates/validate_template.py @@ -0,0 +1,45 @@ +import postmark +from postmark.models.templates import ValidateTemplateRequest + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as client: + # --- Validate via dict with a test render model --- + print("-----------VALID TEMPLATE EXAMPLE-----------") + result = client.templates.validate( + { + "Subject": "Hello, {{name}}!", + "HtmlBody": "Hi {{name}}, your code is {{code}}.
", + "TextBody": "Hi {{name}}, your code is {{code}}.", + "TestRenderModel": {"name": "Alice", "code": "ABC-123"}, + } + ) + print(f"All valid: {result.all_content_is_valid}") + print("Subject ", result.subject) + print("HtmlBody", result.html_body) + print("TextBody", result.text_body) + if result.suggested_template_model: + print(f" Suggested model: {result.suggested_template_model}") + + # --- Validate a layout template missing the required {{{@content}}} placeholder --- + print("-----------INVALID TEMPLATE EXAMPLE-----------") + result = client.templates.validate( + ValidateTemplateRequest( + **{ + "TemplateType": "Layout", + "HtmlBody": "No content placeholder here", + "TextBody": "No content placeholder here", + } + ) + ) + print(f"\nAll valid: {result.all_content_is_valid}") + for field_name, field in [ + ("HtmlBody", result.html_body), + ("TextBody", result.text_body), + ]: + if field and not field.content_is_valid: + for err in field.validation_errors: + location = ( + f" (line {err.line}, col {err.character_position})" + if err.line + else "" + ) + print(f" {field_name} error: {err.message}{location}") diff --git a/examples/sync/webhooks/create_webhook.py b/examples/sync/webhooks/create_webhook.py new file mode 100644 index 0000000..ab8621d --- /dev/null +++ b/examples/sync/webhooks/create_webhook.py @@ -0,0 +1,18 @@ +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as server: + wh = server.webhooks.create( + url="https://example.com/webhook", + message_stream="outbound", + triggers={ + "Open": {"Enabled": True, "PostFirstOpenOnly": False}, + "Bounce": {"Enabled": True, "IncludeContent": False}, + }, + ) + + print("Created webhook:") + print(f" ID: {wh.id}") + print(f" URL: {wh.url}") + print(f" Stream: {wh.message_stream}") + print(f" Opens: {wh.triggers.open.enabled}") + print(f" Bounces: {wh.triggers.bounce.enabled}") diff --git a/examples/sync/webhooks/delete_webhook.py b/examples/sync/webhooks/delete_webhook.py new file mode 100644 index 0000000..4d42e7f --- /dev/null +++ b/examples/sync/webhooks/delete_webhook.py @@ -0,0 +1,7 @@ +import postmark + +webhook_id = 0 # Replace with the ID of the webhook to delete + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as server: + result = server.webhooks.delete(webhook_id) + print(result.message) diff --git a/examples/sync/webhooks/edit_webhook.py b/examples/sync/webhooks/edit_webhook.py new file mode 100644 index 0000000..7d47d19 --- /dev/null +++ b/examples/sync/webhooks/edit_webhook.py @@ -0,0 +1,18 @@ +import postmark + +webhook_id = 0 # Replace with the ID of the webhook to update + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as server: + wh = server.webhooks.edit( + webhook_id, + triggers={ + "Open": {"Enabled": False, "PostFirstOpenOnly": False}, + "Bounce": {"Enabled": True, "IncludeContent": True}, + }, + ) + + print("Updated webhook:") + print(f" ID: {wh.id}") + print(f" URL: {wh.url}") + print(f" Opens: {wh.triggers.open.enabled}") + print(f" Bounces: {wh.triggers.bounce.enabled}") diff --git a/examples/sync/webhooks/get_webhook.py b/examples/sync/webhooks/get_webhook.py new file mode 100644 index 0000000..ba02a69 --- /dev/null +++ b/examples/sync/webhooks/get_webhook.py @@ -0,0 +1,15 @@ +import postmark + +WEBHOOK_ID = 1 + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as server: + wh = server.webhooks.get(WEBHOOK_ID) + + print(f"ID: {wh.id}") + print(f"URL: {wh.url}") + print(f"Stream: {wh.message_stream}") + print() + print("Triggers:") + print(f" Opens: {wh.triggers.open.enabled}") + print(f" Clicks: {wh.triggers.click.enabled}") + print(f" Bounces: {wh.triggers.bounce.enabled}") diff --git a/examples/sync/webhooks/list_webhooks.py b/examples/sync/webhooks/list_webhooks.py new file mode 100644 index 0000000..dc06955 --- /dev/null +++ b/examples/sync/webhooks/list_webhooks.py @@ -0,0 +1,14 @@ +import postmark + +with postmark.sync.ServerClient("xxx-YOUR-SERVER-TOKEN-xxxx-xxxxxxx") as server: + webhooks = server.webhooks.list() + + print(f"Total webhooks: {len(webhooks)}") + print() + + for wh in webhooks: + print(f" [{wh.id}] {wh.url}") + print(f" Stream: {wh.message_stream}") + print(f" Opens: {wh.triggers.open.enabled}") + print(f" Bounces: {wh.triggers.bounce.enabled}") + print("----------------------------------------") diff --git a/postmark/__init__.py b/postmark/__init__.py index eb77d38..7e56373 100644 --- a/postmark/__init__.py +++ b/postmark/__init__.py @@ -6,6 +6,7 @@ except PackageNotFoundError: # running from checkout / editable without metadata __version__ = "0.0.0" +from . import sync from .clients.account_client import AccountClient from .clients.server_client import ServerClient from .exceptions import ( @@ -29,6 +30,7 @@ logger.addHandler(logging.NullHandler()) __all__ = [ + "sync", "ServerClient", "AccountClient", "Email", diff --git a/postmark/sync.py b/postmark/sync.py new file mode 100644 index 0000000..7412951 --- /dev/null +++ b/postmark/sync.py @@ -0,0 +1,185 @@ +""" +postmark.sync — synchronous wrapper around the async Postmark clients. + +Runs all API calls on a background event loop thread, blocking until each +call completes. Works in plain scripts, Flask apps, and Jupyter notebooks +(where asyncio.run() would fail with RuntimeError). + +Example:: + + import postmark.sync + + with postmark.sync.ServerClient(token) as client: + response = client.outbound.send({ + "sender": "you@example.com", + "to": "recipient@example.com", + "subject": "Hello", + "text_body": "Hello from postmark.sync!", + }) + print(response.message_id) +""" + +import asyncio +import inspect +import threading +from typing import Optional + +from postmark.clients.account_client import AccountClient as _AsyncAccountClient +from postmark.clients.server_client import ServerClient as _AsyncServerClient + + +class _EventLoopThread: + """Persistent background thread running a dedicated event loop.""" + + def __init__(self): + self._loop = asyncio.new_event_loop() + t = threading.Thread(target=self._loop.run_forever, daemon=True) + t.start() + + def run(self, coro): + """Submit a coroutine and block the calling thread until it returns or raises.""" + return asyncio.run_coroutine_threadsafe(coro, self._loop).result() + + +_loop = _EventLoopThread() + + +class _SyncProxy: + """ + Wraps an async manager, exposing coroutine methods as regular blocking calls. + + AsyncGenerator methods are collected into a list on the background thread + (all pages fetched upfront) and returned synchronously. + Non-async attributes are passed through unchanged. + """ + + def __init__(self, async_manager): + self._async = async_manager + + def __getattr__(self, name): + attr = getattr(self._async, name) + if inspect.isasyncgenfunction(attr): + + def sync_collect(*args, **kwargs): + async def _collect(): + return [item async for item in attr(*args, **kwargs)] + + return _loop.run(_collect()) + + return sync_collect + if inspect.iscoroutinefunction(attr): + + def sync_method(*args, **kwargs): + return _loop.run(attr(*args, **kwargs)) + + return sync_method + return attr + + +class SyncServerClient: + """ + Synchronous wrapper around ServerClient. + + All async manager methods are available as regular blocking calls. + Use as a context manager (recommended) or call .close() explicitly. + + Example:: + + with postmark.sync.ServerClient(os.environ["POSTMARK_SERVER_TOKEN"]) as client: + response = client.outbound.send({ + "sender": "you@example.com", + "to": "recipient@example.com", + "subject": "Hello", + "text_body": "Hello from postmark.sync!", + }) + print(response.message_id) + """ + + _SERVER_MANAGERS = [ + "outbound", + "inbound", + "inbound_rules", + "bounces", + "templates", + "server", + "stream", + "stats", + "webhooks", + "suppressions", + ] + + def __init__( + self, + server_token: str, + retries: int = 3, + timeout: float = 5.0, + base_url: Optional[str] = None, + ): + self._async = _AsyncServerClient( + server_token, retries=retries, timeout=timeout, base_url=base_url + ) + for name in self._SERVER_MANAGERS: + setattr(self, name, _SyncProxy(getattr(self._async, name))) + + def close(self): + """Close the underlying HTTP connection pool.""" + _loop.run(self._async.close()) + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + +ServerClient = SyncServerClient + + +class SyncAccountClient: + """ + Synchronous wrapper around AccountClient. + + All async manager methods are available as regular blocking calls. + Use as a context manager (recommended) or call .close() explicitly. + + Example:: + + with postmark.sync.AccountClient(os.environ["POSTMARK_ACCOUNT_TOKEN"]) as client: + domains = client.domain.list() + for domain in domains.domains: + print(domain.name) + """ + + _ACCOUNT_MANAGERS = [ + "server", + "domain", + "signature", + "data_removals", + "templates", + ] + + def __init__( + self, + account_token: str, + retries: int = 3, + timeout: float = 30.0, + base_url: Optional[str] = None, + ): + self._async = _AsyncAccountClient( + account_token, retries=retries, timeout=timeout, base_url=base_url + ) + for name in self._ACCOUNT_MANAGERS: + setattr(self, name, _SyncProxy(getattr(self._async, name))) + + def close(self): + """Close the underlying HTTP connection pool.""" + _loop.run(self._async.close()) + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + +AccountClient = SyncAccountClient diff --git a/pyproject.toml b/pyproject.toml index 8e5b245..9da37cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ asyncio_mode = "auto" [tool.mypy] ignore_missing_imports = true +exclude = ["^examples/"] [tool.bandit] skips = ["B101"] # assert statements are fine in tests diff --git a/tests/test_account_client.py b/tests/test_account_client.py index e629fd5..94854db 100644 --- a/tests/test_account_client.py +++ b/tests/test_account_client.py @@ -118,11 +118,6 @@ async def test_request_success(self, client, mock_ok_response): "GET", "/servers", headers={"X-Postmark-Correlation-Id": ANY} ) - def test_ssl_verify_passed_to_httpx(self): - with patch.dict(os.environ, {"POSTMARK_SSL_VERIFY": "false"}): - client = AccountClient(account_token="test-account-token") - assert client.verify_ssl is False - def test_account_token_header_on_persistent_client(self, client): assert ( client._http_client.headers["x-postmark-account-token"] diff --git a/tests/test_bounces.py b/tests/test_bounces.py index aa2c83f..edce504 100644 --- a/tests/test_bounces.py +++ b/tests/test_bounces.py @@ -271,15 +271,7 @@ async def test_get_success(self, bounces): assert bounce.inactive is True assert bounce.can_activate is True assert bounce.dump_available is True - - @pytest.mark.asyncio - async def test_get_calls_correct_endpoint(self, bounces): - manager, fake = bounces - fake.mock_get_response(BOUNCE) - - await manager.get(999) - - fake.get.assert_called_once_with("/bounces/999") + fake.get.assert_called_once_with("/bounces/123") @pytest.mark.asyncio async def test_get_optional_fields_are_none(self, bounces): @@ -310,6 +302,7 @@ async def test_get_dump_success(self, bounces): dump = await manager.get_dump(123) assert dump.body == raw + fake.get.assert_called_once_with("/bounces/123/dump") @pytest.mark.asyncio async def test_get_dump_empty_body_when_unavailable(self, bounces): @@ -320,15 +313,6 @@ async def test_get_dump_empty_body_when_unavailable(self, bounces): assert dump.body == "" - @pytest.mark.asyncio - async def test_get_dump_calls_correct_endpoint(self, bounces): - manager, fake = bounces - fake.mock_get_response({"Body": ""}) - - await manager.get_dump(456) - - fake.get.assert_called_once_with("/bounces/456/dump") - # --------------------------------------------------------------------------- # Activate bounce diff --git a/tests/test_domains.py b/tests/test_domains.py index 5c81d55..65e8fd3 100644 --- a/tests/test_domains.py +++ b/tests/test_domains.py @@ -164,15 +164,6 @@ async def test_get_calls_correct_endpoint(self, domains): fake.get.assert_called_once_with("/domains/10") - @pytest.mark.asyncio - async def test_get_different_domain_id(self, domains): - manager, fake = domains - fake.mock_get_response(_make_domain(ID=999)) - - await manager.get(999) - - fake.get.assert_called_once_with("/domains/999") - # --------------------------------------------------------------------------- # Create domain @@ -268,23 +259,7 @@ async def test_delete_success(self, domains): assert result.error_code == 0 assert result.message == "Domain removed." - - @pytest.mark.asyncio - async def test_delete_calls_correct_endpoint(self, domains): - manager, fake = domains - fake.mock_delete_response(DELETE_RESPONSE) - - await manager.delete(10) - fake.delete.assert_called_once_with("/domains/10") - - @pytest.mark.asyncio - async def test_delete_does_not_call_other_methods(self, domains): - manager, fake = domains - fake.mock_delete_response(DELETE_RESPONSE) - - await manager.delete(10) - fake.get.assert_not_called() fake.post.assert_not_called() fake.put.assert_not_called() diff --git a/tests/test_retrieve_messages.py b/tests/test_retrieve_messages.py index 25b2b76..01aec8d 100644 --- a/tests/test_retrieve_messages.py +++ b/tests/test_retrieve_messages.py @@ -249,132 +249,140 @@ async def test_get_dump_calls_correct_endpoint(self, outbound): fake.get.assert_called_once_with("/messages/outbound/msg-123/dump") -class TestListOpens: +@pytest.mark.parametrize( + "list_method,msg_method,response_key,event_data,unique_field,unique_value", + [ + ( + "list_opens", + "list_message_opens", + "Opens", + OPEN_EVENT, + "platform", + "Desktop", + ), + ( + "list_clicks", + "list_message_clicks", + "Clicks", + CLICK_EVENT, + "click_location", + "HTML", + ), + ], +) +class TestTrackingEvents: @pytest.mark.asyncio - async def test_list_opens_success(self, outbound): + async def test_list_success( + self, + outbound, + list_method, + msg_method, + response_key, + event_data, + unique_field, + unique_value, + ): manager, fake = outbound - fake.mock_get_response({"TotalCount": 1, "Opens": [OPEN_EVENT]}) + fake.mock_get_response({"TotalCount": 1, response_key: [event_data]}) - result = await manager.list_opens() + result = await getattr(manager, list_method)() assert result.total == 1 assert result.items[0].message_id == "msg-123" - assert result.items[0].recipient == "user@example.com" - assert result.items[0].platform == "Desktop" - assert result.items[0].geo.country == "United States" + assert getattr(result.items[0], unique_field) == unique_value @pytest.mark.asyncio - async def test_list_opens_default_params(self, outbound): + async def test_list_default_params( + self, + outbound, + list_method, + msg_method, + response_key, + event_data, + unique_field, + unique_value, + ): manager, fake = outbound - fake.mock_get_response({"TotalCount": 0, "Opens": []}) + fake.mock_get_response({"TotalCount": 0, response_key: []}) - await manager.list_opens() + await getattr(manager, list_method)() params = fake.get.call_args[1]["params"] assert params["count"] == 100 assert params["offset"] == 0 @pytest.mark.asyncio - async def test_list_opens_with_filters(self, outbound): + async def test_list_with_filters( + self, + outbound, + list_method, + msg_method, + response_key, + event_data, + unique_field, + unique_value, + ): manager, fake = outbound - fake.mock_get_response({"TotalCount": 0, "Opens": []}) + fake.mock_get_response({"TotalCount": 0, response_key: []}) - await manager.list_opens(tag="welcome", recipient="user@example.com") - - params = fake.get.call_args[1]["params"] - assert params["tag"] == "welcome" - assert params["recipient"] == "user@example.com" - - @pytest.mark.asyncio - async def test_list_opens_count_validation(self, outbound): - manager, fake = outbound - - with pytest.raises(ValueError, match="Count cannot exceed 500"): - await manager.list_opens(count=501) - - @pytest.mark.asyncio - async def test_list_message_opens_calls_correct_endpoint(self, outbound): - manager, fake = outbound - fake.mock_get_response({"TotalCount": 0, "Opens": []}) - - await manager.list_message_opens("msg-123") - - fake.get.assert_called_once_with( - "/messages/outbound/opens/msg-123", - params={"count": 100, "offset": 0}, - ) - - @pytest.mark.asyncio - async def test_list_message_opens_success(self, outbound): - manager, fake = outbound - fake.mock_get_response({"TotalCount": 1, "Opens": [OPEN_EVENT]}) - - result = await manager.list_message_opens("msg-123") - - assert result.total == 1 - assert result.items[0].message_id == "msg-123" - - -class TestListClicks: - @pytest.mark.asyncio - async def test_list_clicks_success(self, outbound): - manager, fake = outbound - fake.mock_get_response({"TotalCount": 1, "Clicks": [CLICK_EVENT]}) - - result = await manager.list_clicks() - - assert result.total == 1 - assert result.items[0].message_id == "msg-123" - assert result.items[0].original_link == "https://example.com" - assert result.items[0].click_location == "HTML" - - @pytest.mark.asyncio - async def test_list_clicks_default_params(self, outbound): - manager, fake = outbound - fake.mock_get_response({"TotalCount": 0, "Clicks": []}) - - await manager.list_clicks() - - params = fake.get.call_args[1]["params"] - assert params["count"] == 100 - assert params["offset"] == 0 - - @pytest.mark.asyncio - async def test_list_clicks_with_filters(self, outbound): - manager, fake = outbound - fake.mock_get_response({"TotalCount": 0, "Clicks": []}) - - await manager.list_clicks(tag="promo", recipient="user@example.com") + await getattr(manager, list_method)(tag="promo", recipient="user@example.com") params = fake.get.call_args[1]["params"] assert params["tag"] == "promo" assert params["recipient"] == "user@example.com" @pytest.mark.asyncio - async def test_list_clicks_count_validation(self, outbound): + async def test_list_count_validation( + self, + outbound, + list_method, + msg_method, + response_key, + event_data, + unique_field, + unique_value, + ): manager, fake = outbound with pytest.raises(ValueError, match="Count cannot exceed 500"): - await manager.list_clicks(count=501) + await getattr(manager, list_method)(count=501) @pytest.mark.asyncio - async def test_list_message_clicks_calls_correct_endpoint(self, outbound): + async def test_list_message_endpoint( + self, + outbound, + list_method, + msg_method, + response_key, + event_data, + unique_field, + unique_value, + ): manager, fake = outbound - fake.mock_get_response({"TotalCount": 0, "Clicks": []}) + fake.mock_get_response({"TotalCount": 0, response_key: []}) - await manager.list_message_clicks("msg-123") + await getattr(manager, msg_method)("msg-123") fake.get.assert_called_once_with( - "/messages/outbound/clicks/msg-123", + f"/messages/outbound/{response_key.lower()}/msg-123", params={"count": 100, "offset": 0}, ) @pytest.mark.asyncio - async def test_list_message_clicks_success(self, outbound): + async def test_list_message_success( + self, + outbound, + list_method, + msg_method, + response_key, + event_data, + unique_field, + unique_value, + ): manager, fake = outbound - fake.mock_get_response({"TotalCount": 1, "Clicks": [CLICK_EVENT]}) + fake.mock_get_response({"TotalCount": 1, response_key: [event_data]}) - result = await manager.list_message_clicks("msg-123") + result = await getattr(manager, msg_method)("msg-123") assert result.total == 1 - assert result.items[0].original_link == "https://example.com" + assert result.items[0].message_id == "msg-123" diff --git a/tests/test_server_client.py b/tests/test_server_client.py index 31d7646..53e6c22 100644 --- a/tests/test_server_client.py +++ b/tests/test_server_client.py @@ -103,12 +103,6 @@ async def test_request_success(self, client, mock_ok_response): "GET", "/test", headers={"X-Postmark-Correlation-Id": ANY} ) - def test_ssl_verify_passed_to_httpx(self): - """Confirm verify_ssl reaches the underlying httpx.AsyncClient.""" - with patch.dict(os.environ, {"POSTMARK_SSL_VERIFY": "false"}): - client = ServerClient(server_token="test-token") - assert client.verify_ssl is False - def test_server_token_header_on_persistent_client(self, client): assert client._http_client.headers["x-postmark-server-token"] == "test-token" diff --git a/tests/test_stats.py b/tests/test_stats.py index 1402b53..39b00fa 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -130,44 +130,23 @@ async def test_no_params(self, stats): fake.get.assert_called_once_with("/stats/outbound", params={}) @pytest.mark.asyncio - async def test_tag_param(self, stats): + @pytest.mark.parametrize( + "kwargs,expected_key,expected_val", + [ + ({"tag": "welcome"}, "tag", "welcome"), + ({"from_date": date(2024, 1, 1)}, "fromdate", "2024-01-01"), + ({"to_date": date(2024, 1, 31)}, "todate", "2024-01-31"), + ({"message_stream": "outbound"}, "messagestream", "outbound"), + ], + ) + async def test_single_param(self, stats, kwargs, expected_key, expected_val): manager, fake = stats fake.mock_get_response(OVERVIEW) - await manager.overview(tag="welcome") + await manager.overview(**kwargs) params = fake.get.call_args[1]["params"] - assert params["tag"] == "welcome" - - @pytest.mark.asyncio - async def test_from_date_param(self, stats): - manager, fake = stats - fake.mock_get_response(OVERVIEW) - - await manager.overview(from_date=date(2024, 1, 1)) - - params = fake.get.call_args[1]["params"] - assert params["fromdate"] == "2024-01-01" - - @pytest.mark.asyncio - async def test_to_date_param(self, stats): - manager, fake = stats - fake.mock_get_response(OVERVIEW) - - await manager.overview(to_date=date(2024, 1, 31)) - - params = fake.get.call_args[1]["params"] - assert params["todate"] == "2024-01-31" - - @pytest.mark.asyncio - async def test_message_stream_param(self, stats): - manager, fake = stats - fake.mock_get_response(OVERVIEW) - - await manager.overview(message_stream="outbound") - - params = fake.get.call_args[1]["params"] - assert params["messagestream"] == "outbound" + assert params[expected_key] == expected_val @pytest.mark.asyncio async def test_all_params(self, stats): @@ -199,99 +178,41 @@ class TestEndpoints: """Each method hits the correct URL.""" @pytest.mark.asyncio - async def test_overview(self, stats): - manager, fake = stats - fake.mock_get_response(OVERVIEW) - await manager.overview() - fake.get.assert_called_once_with("/stats/outbound", params={}) - - @pytest.mark.asyncio - async def test_sent_counts(self, stats): - manager, fake = stats - fake.mock_get_response(SENT_COUNTS) - await manager.sent_counts() - fake.get.assert_called_once_with("/stats/outbound/sends", params={}) - - @pytest.mark.asyncio - async def test_bounce_counts(self, stats): - manager, fake = stats - fake.mock_get_response(BOUNCE_COUNTS) - await manager.bounce_counts() - fake.get.assert_called_once_with("/stats/outbound/bounces", params={}) - - @pytest.mark.asyncio - async def test_spam_counts(self, stats): - manager, fake = stats - fake.mock_get_response(SPAM_COMPLAINTS) - await manager.spam_counts() - fake.get.assert_called_once_with("/stats/outbound/spam", params={}) - - @pytest.mark.asyncio - async def test_tracked_counts(self, stats): - manager, fake = stats - fake.mock_get_response(TRACKED_COUNTS) - await manager.tracked_counts() - fake.get.assert_called_once_with("/stats/outbound/tracked", params={}) - - @pytest.mark.asyncio - async def test_open_counts(self, stats): - manager, fake = stats - fake.mock_get_response(OPEN_COUNTS) - await manager.open_counts() - fake.get.assert_called_once_with("/stats/outbound/opens", params={}) - - @pytest.mark.asyncio - async def test_platform_usage(self, stats): - manager, fake = stats - fake.mock_get_response(PLATFORM_USAGE) - await manager.platform_usage() - fake.get.assert_called_once_with("/stats/outbound/opens/platforms", params={}) - - @pytest.mark.asyncio - async def test_email_client_usage(self, stats): - manager, fake = stats - fake.mock_get_response(EMAIL_CLIENT_USAGE) - await manager.email_client_usage() - fake.get.assert_called_once_with( - "/stats/outbound/opens/emailclients", params={} - ) - - @pytest.mark.asyncio - async def test_click_counts(self, stats): - manager, fake = stats - fake.mock_get_response(CLICK_COUNTS) - await manager.click_counts() - fake.get.assert_called_once_with("/stats/outbound/clicks", params={}) - - @pytest.mark.asyncio - async def test_browser_usage(self, stats): - manager, fake = stats - fake.mock_get_response(BROWSER_USAGE) - await manager.browser_usage() - fake.get.assert_called_once_with( - "/stats/outbound/clicks/browserfamilies", params={} - ) - - @pytest.mark.asyncio - async def test_browser_platform_usage(self, stats): - manager, fake = stats - fake.mock_get_response(BROWSER_PLATFORM_USAGE) - await manager.browser_platform_usage() - fake.get.assert_called_once_with("/stats/outbound/clicks/platforms", params={}) - - @pytest.mark.asyncio - async def test_click_location(self, stats): - manager, fake = stats - fake.mock_get_response(CLICK_LOCATION) - await manager.click_location() - fake.get.assert_called_once_with("/stats/outbound/clicks/location", params={}) - - @pytest.mark.asyncio - async def test_read_times(self, stats): + @pytest.mark.parametrize( + "method,endpoint,data", + [ + ("overview", "/stats/outbound", OVERVIEW), + ("sent_counts", "/stats/outbound/sends", SENT_COUNTS), + ("bounce_counts", "/stats/outbound/bounces", BOUNCE_COUNTS), + ("spam_counts", "/stats/outbound/spam", SPAM_COMPLAINTS), + ("tracked_counts", "/stats/outbound/tracked", TRACKED_COUNTS), + ("open_counts", "/stats/outbound/opens", OPEN_COUNTS), + ("platform_usage", "/stats/outbound/opens/platforms", PLATFORM_USAGE), + ( + "email_client_usage", + "/stats/outbound/opens/emailclients", + EMAIL_CLIENT_USAGE, + ), + ("click_counts", "/stats/outbound/clicks", CLICK_COUNTS), + ("browser_usage", "/stats/outbound/clicks/browserfamilies", BROWSER_USAGE), + ( + "browser_platform_usage", + "/stats/outbound/clicks/platforms", + BROWSER_PLATFORM_USAGE, + ), + ("click_location", "/stats/outbound/clicks/location", CLICK_LOCATION), + ( + "read_times", + "/stats/outbound/opens/readTimes", + {"Days": [], "read_2s": 0}, + ), + ], + ) + async def test_endpoint_routing(self, stats, method, endpoint, data): manager, fake = stats - fake.mock_get_response({"Days": [], "read_2s": 0}) - await manager.read_times() - fake.get.assert_called_once_with("/stats/outbound/opens/readTimes", params={}) + fake.mock_get_response(data) + await getattr(manager, method)() + fake.get.assert_called_once_with(endpoint, params={}) # --------------------------------------------------------------------------- diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py new file mode 100644 index 0000000..171e80b --- /dev/null +++ b/tests/test_sync_client.py @@ -0,0 +1,264 @@ +"""Tests for postmark.sync — synchronous wrapper around the async clients.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import postmark.sync +from postmark.sync import ( + SyncAccountClient, + SyncServerClient, + _EventLoopThread, + _SyncProxy, +) + + +class TestEventLoopThread: + def test_run_coroutine_returns_value(self): + loop = _EventLoopThread() + + async def add(a, b): + return a + b + + assert loop.run(add(2, 3)) == 5 + + def test_run_coroutine_propagates_exception(self): + loop = _EventLoopThread() + + async def boom(): + raise ValueError("oops") + + with pytest.raises(ValueError, match="oops"): + loop.run(boom()) + + def test_multiple_calls_on_same_loop(self): + loop = _EventLoopThread() + + async def identity(x): + return x + + results = [loop.run(identity(i)) for i in range(5)] + assert results == list(range(5)) + + +class TestSyncProxy: + def test_wraps_coroutine_as_sync(self): + class AsyncThing: + async def compute(self, x): + return x * 2 + + proxy = _SyncProxy(AsyncThing()) + assert proxy.compute(5) == 10 + + def test_wraps_coroutine_with_kwargs(self): + class AsyncThing: + async def greet(self, name="world"): + return f"hello {name}" + + proxy = _SyncProxy(AsyncThing()) + assert proxy.greet(name="postmark") == "hello postmark" + + def test_passthrough_plain_method(self): + class AsyncThing: + def plain(self): + return "sync" + + proxy = _SyncProxy(AsyncThing()) + assert proxy.plain() == "sync" + + def test_passthrough_plain_attribute(self): + class AsyncThing: + value = 42 + + proxy = _SyncProxy(AsyncThing()) + assert proxy.value == 42 + + def test_async_generator_returns_list(self): + class AsyncThing: + async def stream(self): + yield 1 + yield 2 + yield 3 + + proxy = _SyncProxy(AsyncThing()) + result = proxy.stream() + assert result == [1, 2, 3] + + def test_async_generator_respects_kwargs(self): + class AsyncThing: + async def stream(self, limit=10): + for i in range(limit): + yield i + + proxy = _SyncProxy(AsyncThing()) + result = proxy.stream(limit=2) + assert result == [0, 1] + + def test_missing_attribute_raises_attribute_error(self): + class AsyncThing: + pass + + proxy = _SyncProxy(AsyncThing()) + with pytest.raises(AttributeError): + _ = proxy.nonexistent + + def test_coroutine_exception_propagates(self): + class AsyncThing: + async def fail(self): + raise RuntimeError("network error") + + proxy = _SyncProxy(AsyncThing()) + with pytest.raises(RuntimeError, match="network error"): + proxy.fail() + + +@pytest.fixture +def patched_httpx(): + """Prevent real httpx.AsyncClient creation during client instantiation.""" + with patch("httpx.AsyncClient") as mock_httpx: + mock_httpx.return_value = MagicMock() + yield mock_httpx + + +class TestSyncServerClient: + def test_instantiates_with_valid_token(self, patched_httpx): + client = SyncServerClient("test-token") + assert client._async.server_token == "test-token" + + def test_accepts_all_constructor_kwargs(self, patched_httpx): + client = SyncServerClient( + "tok", retries=1, timeout=10.0, base_url="http://mock" + ) + assert client._async.retries == 1 + assert client._async.timeout == 10.0 + + def test_has_all_manager_proxies(self, patched_httpx): + client = SyncServerClient("test-token") + for name in SyncServerClient._SERVER_MANAGERS: + proxy = getattr(client, name) + assert isinstance(proxy, _SyncProxy), f"expected _SyncProxy for '{name}'" + + def test_context_manager_calls_close(self, patched_httpx): + with patch.object(SyncServerClient, "close") as mock_close: + with SyncServerClient("test-token"): + pass + mock_close.assert_called_once() + + def test_context_manager_calls_close_on_exception(self, patched_httpx): + with patch.object(SyncServerClient, "close") as mock_close: + with pytest.raises(ValueError): + with SyncServerClient("test-token"): + raise ValueError("boom") + mock_close.assert_called_once() + + def test_send_proxies_to_async_send(self, patched_httpx): + expected = MagicMock() + async_send = AsyncMock(return_value=expected) + + client = SyncServerClient("test-token") + client.outbound._async.send = async_send + + result = client.outbound.send( + {"sender": "a@b.com", "to": "c@d.com", "subject": "hi", "text_body": "hi"} + ) + + assert result is expected + async_send.assert_called_once() + + def test_send_batch_proxies_to_async(self, patched_httpx): + expected = [MagicMock(), MagicMock()] + async_send_batch = AsyncMock(return_value=expected) + + client = SyncServerClient("test-token") + client.outbound._async.send_batch = async_send_batch + + result = client.outbound.send_batch([]) + assert result is expected + + def test_stream_returns_list(self, patched_httpx): + async def fake_stream(): + yield "a" + yield "b" + + client = SyncServerClient("test-token") + client.outbound._async.stream = fake_stream + result = client.outbound.stream() + assert result == ["a", "b"] + + def test_invalid_empty_token_raises(self, patched_httpx): + from postmark.exceptions import PostmarkException + + with pytest.raises(PostmarkException): + SyncServerClient("") + + def test_negative_retries_raises(self, patched_httpx): + from postmark.exceptions import PostmarkException + + with pytest.raises(PostmarkException): + SyncServerClient("tok", retries=-1) + + def test_zero_timeout_raises(self, patched_httpx): + from postmark.exceptions import PostmarkException + + with pytest.raises(PostmarkException): + SyncServerClient("tok", timeout=0) + + +class TestSyncAccountClient: + def test_instantiates_with_valid_token(self, patched_httpx): + client = SyncAccountClient("test-token") + assert client._async.account_token == "test-token" + + def test_accepts_all_constructor_kwargs(self, patched_httpx): + client = SyncAccountClient( + "tok", retries=0, timeout=60.0, base_url="http://mock" + ) + assert client._async.retries == 0 + assert client._async.timeout == 60.0 + + def test_has_all_manager_proxies(self, patched_httpx): + client = SyncAccountClient("test-token") + for name in SyncAccountClient._ACCOUNT_MANAGERS: + proxy = getattr(client, name) + assert isinstance(proxy, _SyncProxy), f"expected _SyncProxy for '{name}'" + + def test_context_manager_calls_close(self, patched_httpx): + with patch.object(SyncAccountClient, "close") as mock_close: + with SyncAccountClient("test-token"): + pass + mock_close.assert_called_once() + + def test_domain_list_proxies_to_async(self, patched_httpx): + expected = MagicMock() + async_list = AsyncMock(return_value=expected) + + client = SyncAccountClient("test-token") + client.domain._async.list = async_list + + result = client.domain.list() + assert result is expected + async_list.assert_called_once() + + def test_invalid_empty_token_raises(self, patched_httpx): + from postmark.exceptions import PostmarkException + + with pytest.raises(PostmarkException): + SyncAccountClient("") + + def test_negative_retries_raises(self, patched_httpx): + from postmark.exceptions import PostmarkException + + with pytest.raises(PostmarkException): + SyncAccountClient("tok", retries=-1) + + +class TestModuleLevelLoop: + def test_module_loop_is_shared(self): + assert postmark.sync._loop is postmark.sync._loop + + def test_module_loop_can_run_coroutines(self): + async def echo(x): + return x + + result = postmark.sync._loop.run(echo("hello")) + assert result == "hello"