postmarker 2.0__tar.gz → 2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- postmarker-2.1/PKG-INFO +203 -0
- postmarker-2.1/README.md +149 -0
- {postmarker-2.0 → postmarker-2.1}/pyproject.toml +4 -2
- postmarker-2.1/src/postmarker/__init__.py +1 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/core.py +21 -1
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/django/backend.py +9 -3
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/emails.py +8 -1
- {postmarker-2.0 → postmarker-2.1}/test/django/test_backend.py +23 -2
- {postmarker-2.0 → postmarker-2.1}/test/models/test_emails.py +94 -2
- postmarker-2.0/PKG-INFO +0 -146
- postmarker-2.0/README.md +0 -95
- postmarker-2.0/src/postmarker/__init__.py +0 -1
- {postmarker-2.0 → postmarker-2.1}/.gitignore +0 -0
- {postmarker-2.0 → postmarker-2.1}/LICENSE +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/django/__init__.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/django/signals.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/exceptions.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/logging.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/__init__.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/base.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/bounces.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/domains.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/message_streams.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/messages.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/senders.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/server.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/servers.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/stats.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/status.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/suppressions.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/templates.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/triggers.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/py.typed +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/pytest.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/tornado.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/src/postmarker/utils.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/__init__.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/cassettes/test_exceptions/test_raise.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/cassettes/test_spamcheck/test_spamcheck_invalid.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/cassettes/test_spamcheck/test_spamcheck_valid.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/conftest.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/django/__init__.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/django/conftest.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/django/settings.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/django/test_mixins.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/__init__.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/attachments/readme.txt +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/attachments/report.blabla +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_load_all_bounces_multiple_calls[2-1-2].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_load_all_bounces_multiple_calls[2-50-1].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_load_all_bounces_multiple_calls[4-1-2].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_load_all_bounces_multiple_calls[None-1-2].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_manager_all.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_manager_deliverystats.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_manager_tags.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_message.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_model_activate.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_model_dump.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_model_get.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_model_repr.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_model_str.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_domains/test_manager_all.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_domains/test_manager_domain_methods.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_domains/test_model_get.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_domains/test_model_str.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_emails/test_simple_send_default.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_emails/test_simple_send_mime_text.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_emails/test_simple_send_minimum_mime.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_emails/test_simple_send_with_template.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_inbound_message_bypass.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_inbound_message_retry.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_inbound_messages_all.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_opens_model.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_outbound_message.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_outbound_message_get.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_outbound_message_get_dump.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_outbound_message_opens.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_senders/test_all.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_senders/test_all_methods.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_senders/test_edit.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_senders/test_get.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_server/test_edit.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_server/test_str.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[bounces-kwargs2-expected2].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[browserfamilies-kwargs10-expected10].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[clicks-kwargs9-expected9].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[clicks_platforms-kwargs11-expected11].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[emailclients-kwargs7-expected7].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[location-kwargs12-expected12].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[opens-kwargs5-expected5].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[opens_platforms-kwargs6-expected6].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[overview-kwargs0-expected0].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[readtimes-kwargs8-expected8].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[sends-kwargs1-expected1].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[spam-kwargs3-expected3].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[tracked-kwargs4-expected4].yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_availability.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_delivery.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_get_incident.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_incidents.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_last_incident.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_services.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_status.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_manager_all.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_manager_create.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_manager_edit.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_manager_validate.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_model_default.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_model_edit.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_model_get.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_model_repr.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_inbound_rules_all.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_inbound_rules_create.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_inbound_rules_delete.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_tags_all.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_tags_create.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_tags_delete.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_tags_edit.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_tags_get.yaml +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_base.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_bounces.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_clicks.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_domains.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_message_streams.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_messages.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_senders.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_server.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_servers.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_stats.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_status.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_suppressions.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_templates.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_triggers.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/models/test_webhooks.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/test_core.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/test_exceptions.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/test_pytest.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/test_spamcheck.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/test_utils.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/tornado/__init__.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/tornado/test_handlers.py +0 -0
- {postmarker-2.0 → postmarker-2.1}/test/typing_checks.py +0 -0
postmarker-2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: postmarker
|
|
3
|
+
Version: 2.1
|
|
4
|
+
Summary: Python client library for Postmark API
|
|
5
|
+
Project-URL: Documentation, https://postmarker.readthedocs.io/en/stable/
|
|
6
|
+
Project-URL: Changelog, https://github.com/Stranger6667/postmarker/blob/master/CHANGELOG.md
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/Stranger6667/postmarker/issues
|
|
8
|
+
Project-URL: Funding, https://github.com/sponsors/Stranger6667
|
|
9
|
+
Project-URL: Source Code, https://github.com/Stranger6667/postmarker
|
|
10
|
+
Author-email: Dmitry Dygalo <dmitry@dygalo.dev>
|
|
11
|
+
Maintainer-email: Dmitry Dygalo <dmitry@dygalo.dev>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: api,client,django,email,email backend,postmark,postmarkapp,transactional email
|
|
15
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Classifier: Environment :: Web Environment
|
|
18
|
+
Classifier: Framework :: Django
|
|
19
|
+
Classifier: Framework :: Django :: 5.2
|
|
20
|
+
Classifier: Framework :: Django :: 6.0
|
|
21
|
+
Classifier: Framework :: Django :: 6.1
|
|
22
|
+
Classifier: Framework :: Pytest
|
|
23
|
+
Classifier: Intended Audience :: Developers
|
|
24
|
+
Classifier: Operating System :: OS Independent
|
|
25
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
29
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
32
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
33
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
34
|
+
Classifier: Topic :: Communications :: Email
|
|
35
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
36
|
+
Classifier: Typing :: Typed
|
|
37
|
+
Requires-Python: >=3.10
|
|
38
|
+
Requires-Dist: requests>=2.20.0
|
|
39
|
+
Provides-Extra: css-inline
|
|
40
|
+
Requires-Dist: css-inline>=0.24; extra == 'css-inline'
|
|
41
|
+
Provides-Extra: docs
|
|
42
|
+
Requires-Dist: mkdocstrings[python]; extra == 'docs'
|
|
43
|
+
Requires-Dist: pymdown-extensions; extra == 'docs'
|
|
44
|
+
Requires-Dist: ruff; extra == 'docs'
|
|
45
|
+
Requires-Dist: zensical<0.2,>=0.1.1; extra == 'docs'
|
|
46
|
+
Provides-Extra: tests
|
|
47
|
+
Requires-Dist: coverage[toml]>=7.2; extra == 'tests'
|
|
48
|
+
Requires-Dist: css-inline>=0.24; extra == 'tests'
|
|
49
|
+
Requires-Dist: pytest-django; extra == 'tests'
|
|
50
|
+
Requires-Dist: pytest-recording>=0.14; extra == 'tests'
|
|
51
|
+
Requires-Dist: pytest>=8; extra == 'tests'
|
|
52
|
+
Requires-Dist: tornado; extra == 'tests'
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
|
|
55
|
+
# Postmarker
|
|
56
|
+
|
|
57
|
+
[](https://github.com/Stranger6667/postmarker/actions/workflows/build.yml) [](https://codecov.io/gh/Stranger6667/postmarker) [](https://pypi.org/project/postmarker/) [](https://pypi.org/project/postmarker/) [](https://postmarker.readthedocs.io/en/stable/) [](https://opensource.org/licenses/MIT)
|
|
58
|
+
|
|
59
|
+
Python client for the [Postmark](https://postmarkapp.com) email API.
|
|
60
|
+
|
|
61
|
+
Postmarker covers Postmark's API beyond sending: templates, bounces, suppressions, message streams, stats, servers and domains.
|
|
62
|
+
It also ships a Django email backend, Postmark webhook wrappers and a pytest fixture for your tests.
|
|
63
|
+
|
|
64
|
+
> [!NOTE]
|
|
65
|
+
> Postmarker keeps Postmark's field names: `From`, `HtmlBody`, `TemplateModel`, `MessageStream`.
|
|
66
|
+
> Copy any field from the [Postmark API docs](https://postmarkapp.com/developer) and pass it as is. There are no snake_case aliases.
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
uv pip install postmarker
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To inline `<style>` CSS into HTML emails, install the `css-inline` extra and create the client with `inline_css=True`:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
uv pip install 'postmarker[css-inline]'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Usage
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from postmarker.core import PostmarkClient
|
|
84
|
+
|
|
85
|
+
postmark = PostmarkClient(server_token="POSTMARK_API_TEST")
|
|
86
|
+
postmark.emails.send(
|
|
87
|
+
From="sender@example.com",
|
|
88
|
+
To="receiver@example.com",
|
|
89
|
+
Subject="Postmark test",
|
|
90
|
+
HtmlBody="<strong>Hello</strong> dear Postmark user.",
|
|
91
|
+
)
|
|
92
|
+
# {"ErrorCode": 0, "Message": "Test job accepted", "MessageID": "...", ...}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
With the `POSTMARK_API_TEST` token, Postmark validates the request without delivering it.
|
|
96
|
+
To send real emails, use the server token from your server's API Tokens tab in Postmark.
|
|
97
|
+
`From` must be a confirmed sender signature or an address on a verified domain.
|
|
98
|
+
|
|
99
|
+
Send with a template:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
postmark.emails.send_with_template(
|
|
103
|
+
TemplateAlias="welcome",
|
|
104
|
+
TemplateModel={"name": "Jane"},
|
|
105
|
+
From="sender@example.com",
|
|
106
|
+
To="receiver@example.com",
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Send a batch. Postmarker splits it into requests of 500 messages, Postmark's limit:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
messages = [
|
|
114
|
+
{"From": "sender@example.com", "To": to, "Subject": "Hi", "TextBody": "Hello"}
|
|
115
|
+
for to in ("first@example.com", "second@example.com")
|
|
116
|
+
]
|
|
117
|
+
responses = postmark.emails.send_batch(*messages)
|
|
118
|
+
# Postmark accepts or rejects each message separately
|
|
119
|
+
failed = [response for response in responses if response["ErrorCode"] != 0]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Add headers and attachments:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
email = postmark.emails.Email(
|
|
126
|
+
From="sender@example.com",
|
|
127
|
+
To="receiver@example.com",
|
|
128
|
+
Subject="Report",
|
|
129
|
+
TextBody="See the attached report.",
|
|
130
|
+
)
|
|
131
|
+
email["X-Accept-Language"] = "en-us, en"
|
|
132
|
+
email.attach("/home/user/report.pdf")
|
|
133
|
+
email.attach_binary(content=b"content", filename="readme.txt")
|
|
134
|
+
email.send()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A rejected request raises `ClientError` with Postmark's [error code](https://postmarkapp.com/developer/api/overview#error-codes):
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from postmarker.exceptions import ClientError
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
postmark.emails.send(
|
|
144
|
+
From="sender@example.com",
|
|
145
|
+
To="receiver@example.com",
|
|
146
|
+
TextBody="Hello",
|
|
147
|
+
)
|
|
148
|
+
except ClientError as exc:
|
|
149
|
+
print(exc.error_code) # For example, 406 for an inactive recipient
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Django
|
|
153
|
+
|
|
154
|
+
Django 6.1+:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
MAILERS = {
|
|
158
|
+
"default": {
|
|
159
|
+
"BACKEND": "postmarker.django.EmailBackend",
|
|
160
|
+
"OPTIONS": {"token": "<YOUR POSTMARK SERVER TOKEN>"},
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Django 5.2 and 6.0:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
EMAIL_BACKEND = "postmarker.django.EmailBackend"
|
|
169
|
+
POSTMARK = {"TOKEN": "<YOUR POSTMARK SERVER TOKEN>"}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Django then sends `send_mail`, `EmailMessage` and emails from third-party apps through Postmark.
|
|
173
|
+
The [Django guide](https://postmarker.readthedocs.io/en/stable/django/) covers tags, metadata, message streams and signals.
|
|
174
|
+
|
|
175
|
+
## Testing
|
|
176
|
+
|
|
177
|
+
Postmarker ships a `postmark_request` pytest fixture.
|
|
178
|
+
It mocks requests to Postmark and records what your code sent, through `PostmarkClient` or the Django backend:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
def test_welcome_email(postmark_request):
|
|
182
|
+
send_welcome_email("jane@example.com") # Your code that sends an email
|
|
183
|
+
assert postmark_request.call_args.kwargs["json"]["To"] == "jane@example.com"
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Features
|
|
187
|
+
|
|
188
|
+
- Server APIs: Emails, Templates with layouts, Bounces, Messages (inbound, outbound, opens and clicks), Message Streams, Suppressions, Server, Stats and Triggers.
|
|
189
|
+
- Account APIs: Domains, Sender signatures, Servers and pushing templates between servers.
|
|
190
|
+
- Status and Spam check APIs.
|
|
191
|
+
- Webhook wrappers: Inbound, Delivery, Open, Click, Bounce, Spam complaint and Subscription change.
|
|
192
|
+
- Sending `email.message.Message` instances.
|
|
193
|
+
- CSS inlining via [css-inline](https://github.com/Stranger6667/css-inline).
|
|
194
|
+
- Django email backend and Tornado helper.
|
|
195
|
+
- Type annotations, with a `py.typed` marker.
|
|
196
|
+
|
|
197
|
+
## Documentation
|
|
198
|
+
|
|
199
|
+
<https://postmarker.readthedocs.io/en/stable/>
|
|
200
|
+
|
|
201
|
+
## Python support
|
|
202
|
+
|
|
203
|
+
Postmarker supports CPython 3.10 to 3.15 and PyPy 3.11. The Django email backend supports Django 5.2, 6.0 and 6.1.
|
postmarker-2.1/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Postmarker
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Stranger6667/postmarker/actions/workflows/build.yml) [](https://codecov.io/gh/Stranger6667/postmarker) [](https://pypi.org/project/postmarker/) [](https://pypi.org/project/postmarker/) [](https://postmarker.readthedocs.io/en/stable/) [](https://opensource.org/licenses/MIT)
|
|
4
|
+
|
|
5
|
+
Python client for the [Postmark](https://postmarkapp.com) email API.
|
|
6
|
+
|
|
7
|
+
Postmarker covers Postmark's API beyond sending: templates, bounces, suppressions, message streams, stats, servers and domains.
|
|
8
|
+
It also ships a Django email backend, Postmark webhook wrappers and a pytest fixture for your tests.
|
|
9
|
+
|
|
10
|
+
> [!NOTE]
|
|
11
|
+
> Postmarker keeps Postmark's field names: `From`, `HtmlBody`, `TemplateModel`, `MessageStream`.
|
|
12
|
+
> Copy any field from the [Postmark API docs](https://postmarkapp.com/developer) and pass it as is. There are no snake_case aliases.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
uv pip install postmarker
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
To inline `<style>` CSS into HTML emails, install the `css-inline` extra and create the client with `inline_css=True`:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
uv pip install 'postmarker[css-inline]'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from postmarker.core import PostmarkClient
|
|
30
|
+
|
|
31
|
+
postmark = PostmarkClient(server_token="POSTMARK_API_TEST")
|
|
32
|
+
postmark.emails.send(
|
|
33
|
+
From="sender@example.com",
|
|
34
|
+
To="receiver@example.com",
|
|
35
|
+
Subject="Postmark test",
|
|
36
|
+
HtmlBody="<strong>Hello</strong> dear Postmark user.",
|
|
37
|
+
)
|
|
38
|
+
# {"ErrorCode": 0, "Message": "Test job accepted", "MessageID": "...", ...}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
With the `POSTMARK_API_TEST` token, Postmark validates the request without delivering it.
|
|
42
|
+
To send real emails, use the server token from your server's API Tokens tab in Postmark.
|
|
43
|
+
`From` must be a confirmed sender signature or an address on a verified domain.
|
|
44
|
+
|
|
45
|
+
Send with a template:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
postmark.emails.send_with_template(
|
|
49
|
+
TemplateAlias="welcome",
|
|
50
|
+
TemplateModel={"name": "Jane"},
|
|
51
|
+
From="sender@example.com",
|
|
52
|
+
To="receiver@example.com",
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Send a batch. Postmarker splits it into requests of 500 messages, Postmark's limit:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
messages = [
|
|
60
|
+
{"From": "sender@example.com", "To": to, "Subject": "Hi", "TextBody": "Hello"}
|
|
61
|
+
for to in ("first@example.com", "second@example.com")
|
|
62
|
+
]
|
|
63
|
+
responses = postmark.emails.send_batch(*messages)
|
|
64
|
+
# Postmark accepts or rejects each message separately
|
|
65
|
+
failed = [response for response in responses if response["ErrorCode"] != 0]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Add headers and attachments:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
email = postmark.emails.Email(
|
|
72
|
+
From="sender@example.com",
|
|
73
|
+
To="receiver@example.com",
|
|
74
|
+
Subject="Report",
|
|
75
|
+
TextBody="See the attached report.",
|
|
76
|
+
)
|
|
77
|
+
email["X-Accept-Language"] = "en-us, en"
|
|
78
|
+
email.attach("/home/user/report.pdf")
|
|
79
|
+
email.attach_binary(content=b"content", filename="readme.txt")
|
|
80
|
+
email.send()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A rejected request raises `ClientError` with Postmark's [error code](https://postmarkapp.com/developer/api/overview#error-codes):
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from postmarker.exceptions import ClientError
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
postmark.emails.send(
|
|
90
|
+
From="sender@example.com",
|
|
91
|
+
To="receiver@example.com",
|
|
92
|
+
TextBody="Hello",
|
|
93
|
+
)
|
|
94
|
+
except ClientError as exc:
|
|
95
|
+
print(exc.error_code) # For example, 406 for an inactive recipient
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Django
|
|
99
|
+
|
|
100
|
+
Django 6.1+:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
MAILERS = {
|
|
104
|
+
"default": {
|
|
105
|
+
"BACKEND": "postmarker.django.EmailBackend",
|
|
106
|
+
"OPTIONS": {"token": "<YOUR POSTMARK SERVER TOKEN>"},
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Django 5.2 and 6.0:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
EMAIL_BACKEND = "postmarker.django.EmailBackend"
|
|
115
|
+
POSTMARK = {"TOKEN": "<YOUR POSTMARK SERVER TOKEN>"}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Django then sends `send_mail`, `EmailMessage` and emails from third-party apps through Postmark.
|
|
119
|
+
The [Django guide](https://postmarker.readthedocs.io/en/stable/django/) covers tags, metadata, message streams and signals.
|
|
120
|
+
|
|
121
|
+
## Testing
|
|
122
|
+
|
|
123
|
+
Postmarker ships a `postmark_request` pytest fixture.
|
|
124
|
+
It mocks requests to Postmark and records what your code sent, through `PostmarkClient` or the Django backend:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
def test_welcome_email(postmark_request):
|
|
128
|
+
send_welcome_email("jane@example.com") # Your code that sends an email
|
|
129
|
+
assert postmark_request.call_args.kwargs["json"]["To"] == "jane@example.com"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Features
|
|
133
|
+
|
|
134
|
+
- Server APIs: Emails, Templates with layouts, Bounces, Messages (inbound, outbound, opens and clicks), Message Streams, Suppressions, Server, Stats and Triggers.
|
|
135
|
+
- Account APIs: Domains, Sender signatures, Servers and pushing templates between servers.
|
|
136
|
+
- Status and Spam check APIs.
|
|
137
|
+
- Webhook wrappers: Inbound, Delivery, Open, Click, Bounce, Spam complaint and Subscription change.
|
|
138
|
+
- Sending `email.message.Message` instances.
|
|
139
|
+
- CSS inlining via [css-inline](https://github.com/Stranger6667/css-inline).
|
|
140
|
+
- Django email backend and Tornado helper.
|
|
141
|
+
- Type annotations, with a `py.typed` marker.
|
|
142
|
+
|
|
143
|
+
## Documentation
|
|
144
|
+
|
|
145
|
+
<https://postmarker.readthedocs.io/en/stable/>
|
|
146
|
+
|
|
147
|
+
## Python support
|
|
148
|
+
|
|
149
|
+
Postmarker supports CPython 3.10 to 3.15 and PyPy 3.11. The Django email backend supports Django 5.2, 6.0 and 6.1.
|
|
@@ -39,7 +39,8 @@ classifiers = [
|
|
|
39
39
|
dependencies = ["requests>=2.20.0"]
|
|
40
40
|
|
|
41
41
|
[project.optional-dependencies]
|
|
42
|
-
|
|
42
|
+
css-inline = ["css-inline>=0.24"]
|
|
43
|
+
tests = ["postmarker[css-inline]", "coverage[toml]>=7.2", "pytest>=8", "pytest-django", "pytest-recording>=0.14", "tornado"]
|
|
43
44
|
docs = ["mkdocstrings[python]", "pymdown-extensions", "ruff", "zensical>=0.1.1,<0.2"]
|
|
44
45
|
|
|
45
46
|
[project.entry-points.pytest11]
|
|
@@ -76,7 +77,8 @@ source = ["src", ".tox/*/site-packages", ".tox/*/lib/*/site-packages", ".tox/*/L
|
|
|
76
77
|
[tool.coverage.report]
|
|
77
78
|
show_missing = true
|
|
78
79
|
precision = 2
|
|
79
|
-
|
|
80
|
+
exclude_also = ["if TYPE_CHECKING:"]
|
|
81
|
+
fail_under = 100
|
|
80
82
|
|
|
81
83
|
[tool.ruff]
|
|
82
84
|
line-length = 120
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.1"
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
4
|
from collections.abc import Mapping
|
|
5
|
-
from typing import Any, TextIO
|
|
5
|
+
from typing import TYPE_CHECKING, Any, TextIO
|
|
6
6
|
from urllib.parse import urljoin
|
|
7
7
|
|
|
8
8
|
import requests
|
|
@@ -26,6 +26,9 @@ from .models.templates import TemplateManager
|
|
|
26
26
|
from .models.triggers import TriggersManager
|
|
27
27
|
from .utils import get_args
|
|
28
28
|
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from css_inline import CSSInliner
|
|
31
|
+
|
|
29
32
|
DEFAULT_API = "https://api.postmarkapp.com/"
|
|
30
33
|
STATUS_API = "https://status.postmarkapp.com/api/1.0/"
|
|
31
34
|
SPAMCHECK_API = "https://spamcheck.postmarkapp.com/filter/"
|
|
@@ -33,6 +36,21 @@ USER_AGENT = f"Postmarker/{__version__}"
|
|
|
33
36
|
TEST_TOKEN = "POSTMARK_API_TEST"
|
|
34
37
|
|
|
35
38
|
|
|
39
|
+
def make_css_inliner(inline_css: bool | CSSInliner) -> CSSInliner | None:
|
|
40
|
+
if not inline_css:
|
|
41
|
+
return None
|
|
42
|
+
if inline_css is not True:
|
|
43
|
+
return inline_css
|
|
44
|
+
try:
|
|
45
|
+
import css_inline
|
|
46
|
+
except ImportError as exc:
|
|
47
|
+
raise ImportError(
|
|
48
|
+
"`inline_css` requires the `css-inline` package: pip install 'postmarker[css-inline]'"
|
|
49
|
+
) from exc
|
|
50
|
+
# Email HTML may carry user content; never fetch `<link>` URLs from it
|
|
51
|
+
return css_inline.CSSInliner(load_remote_stylesheets=False)
|
|
52
|
+
|
|
53
|
+
|
|
36
54
|
class PostmarkClient:
|
|
37
55
|
"""Basic class for API clients. Provides basic functionality to make requests."""
|
|
38
56
|
|
|
@@ -76,6 +94,7 @@ class PostmarkClient:
|
|
|
76
94
|
timeout: float | tuple[float, float] | None = None,
|
|
77
95
|
logs_stream: TextIO = sys.stdout,
|
|
78
96
|
root_api_url: str = DEFAULT_API,
|
|
97
|
+
inline_css: bool | CSSInliner = False,
|
|
79
98
|
) -> None:
|
|
80
99
|
assert server_token or account_token, "You have to provide server_token or account_token to use Postmark API"
|
|
81
100
|
self.server_token = server_token
|
|
@@ -84,6 +103,7 @@ class PostmarkClient:
|
|
|
84
103
|
self.timeout = timeout
|
|
85
104
|
self.logger = get_logger("Postmarker", verbosity, logs_stream)
|
|
86
105
|
self.root_api_url = root_api_url
|
|
106
|
+
self.css_inliner = make_css_inliner(inline_css)
|
|
87
107
|
self._setup_managers()
|
|
88
108
|
|
|
89
109
|
@classmethod
|
|
@@ -10,11 +10,11 @@ from django.core.mail.backends.base import BaseEmailBackend
|
|
|
10
10
|
from django.utils.encoding import force_str
|
|
11
11
|
from django.utils.functional import partition
|
|
12
12
|
|
|
13
|
-
from ..core import TEST_TOKEN, PostmarkClient
|
|
13
|
+
from ..core import TEST_TOKEN, PostmarkClient, make_css_inliner
|
|
14
14
|
from ..exceptions import ClientError
|
|
15
15
|
from .signals import on_exception, post_send, pre_send
|
|
16
16
|
|
|
17
|
-
DEFAULT_CONFIG = {"TEST_MODE": False, "TRACK_OPENS": False, "VERBOSITY": 0}
|
|
17
|
+
DEFAULT_CONFIG = {"TEST_MODE": False, "TRACK_OPENS": False, "VERBOSITY": 0, "INLINE_CSS": False}
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class EmailBackend(BaseEmailBackend):
|
|
@@ -31,6 +31,8 @@ class EmailBackend(BaseEmailBackend):
|
|
|
31
31
|
self.server_token = token or self.get_option("TOKEN")
|
|
32
32
|
if self.server_token is None:
|
|
33
33
|
raise ImproperlyConfigured("You should specify TOKEN to use Postmark email backend")
|
|
34
|
+
# Outside `send_messages`, so `fail_silently` can't hide a missing `css-inline`
|
|
35
|
+
self.inline_css = make_css_inliner(self.get_option("INLINE_CSS")) or False
|
|
34
36
|
|
|
35
37
|
@property
|
|
36
38
|
def config(self) -> dict[str, Any]:
|
|
@@ -42,7 +44,11 @@ class EmailBackend(BaseEmailBackend):
|
|
|
42
44
|
|
|
43
45
|
def open(self) -> bool:
|
|
44
46
|
if self.client is None:
|
|
45
|
-
self.client = PostmarkClient(
|
|
47
|
+
self.client = PostmarkClient(
|
|
48
|
+
server_token=self.server_token,
|
|
49
|
+
verbosity=self.get_option("VERBOSITY"),
|
|
50
|
+
inline_css=self.inline_css,
|
|
51
|
+
)
|
|
46
52
|
return True
|
|
47
53
|
return False
|
|
48
54
|
|
|
@@ -51,7 +51,7 @@ def prepare_attachments(attachment: AttachmentData) -> dict[str, Any]:
|
|
|
51
51
|
# Postmark requires base64 content for every attachment, including `message/rfc822`.
|
|
52
52
|
# A payload without `Content-Transfer-Encoding` is expected to be base64 already
|
|
53
53
|
if content_type == "message/rfc822" and not isinstance(payload, str):
|
|
54
|
-
payload = b64encode(payload[0].
|
|
54
|
+
payload = b64encode(payload[0].as_bytes()).decode()
|
|
55
55
|
elif "Content-Transfer-Encoding" in attachment:
|
|
56
56
|
payload = b64encode(cast(bytes, attachment.get_payload(decode=True))).decode()
|
|
57
57
|
result = {
|
|
@@ -234,6 +234,13 @@ class Email(BaseEmail):
|
|
|
234
234
|
MessageStream=message_stream,
|
|
235
235
|
)
|
|
236
236
|
|
|
237
|
+
def as_dict(self) -> dict[str, Any]:
|
|
238
|
+
data = super().as_dict()
|
|
239
|
+
inliner = self._manager and self._manager.client.css_inliner
|
|
240
|
+
if inliner and data.get("HtmlBody"):
|
|
241
|
+
data["HtmlBody"] = inliner.inline(data["HtmlBody"])
|
|
242
|
+
return data
|
|
243
|
+
|
|
237
244
|
def send(self) -> Any:
|
|
238
245
|
return self._manager._send(**self.as_dict())
|
|
239
246
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import sys
|
|
1
2
|
import warnings
|
|
2
3
|
from base64 import b64decode
|
|
3
4
|
from contextlib import contextmanager
|
|
@@ -234,8 +235,9 @@ def test_text_html_alternative_and_pdf_attachment_failure(postmark_request, mess
|
|
|
234
235
|
|
|
235
236
|
|
|
236
237
|
def test_message_rfc822(postmark_request, message):
|
|
237
|
-
|
|
238
|
-
|
|
238
|
+
# Headers of the attached message must survive, not only its body
|
|
239
|
+
message_content = "Subject: Hi\n\nFake message"
|
|
240
|
+
expected_content = "U3ViamVjdDogSGkKCkZha2UgbWVzc2FnZQ=="
|
|
239
241
|
message.attach_alternative(message_content, "message/rfc822")
|
|
240
242
|
message.send(fail_silently=False)
|
|
241
243
|
assert postmark_request.call_args[1]["json"][0] == {
|
|
@@ -550,3 +552,22 @@ def test_signals_on_exception(catch_signal):
|
|
|
550
552
|
assert kwargs["signal"] == on_exception
|
|
551
553
|
assert isinstance(kwargs["exception"], ConnectTimeout)
|
|
552
554
|
assert len(kwargs["raw_messages"]) == 1
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def test_inline_css(settings, postmark_request):
|
|
558
|
+
settings.POSTMARK = {**settings.POSTMARK, "INLINE_CSS": True}
|
|
559
|
+
send_mail(
|
|
560
|
+
html_message="<html><head><style>h1{color:red}</style></head><body><h1>Hi</h1></body></html>", **SEND_KWARGS
|
|
561
|
+
)
|
|
562
|
+
assert (
|
|
563
|
+
postmark_request.call_args[1]["json"][0]["HtmlBody"]
|
|
564
|
+
== '<html><head></head><body><h1 style="color: red;">Hi</h1></body></html>'
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def test_inline_css_missing_package(settings, monkeypatch):
|
|
569
|
+
# Raised outside `fail_silently`, otherwise every email is dropped without a trace
|
|
570
|
+
settings.POSTMARK = {**settings.POSTMARK, "INLINE_CSS": True}
|
|
571
|
+
monkeypatch.setitem(sys.modules, "css_inline", None)
|
|
572
|
+
with pytest.raises(ImportError, match=r"postmarker\[css-inline\]"):
|
|
573
|
+
EmailBackend(fail_silently=True)
|
|
@@ -2,14 +2,19 @@ import base64
|
|
|
2
2
|
import os
|
|
3
3
|
import platform
|
|
4
4
|
import re
|
|
5
|
+
import sys
|
|
5
6
|
from email import encoders
|
|
7
|
+
from email.message import EmailMessage
|
|
6
8
|
from email.mime.base import MIMEBase
|
|
7
9
|
from email.mime.image import MIMEImage
|
|
10
|
+
from email.mime.message import MIMEMessage
|
|
8
11
|
from email.mime.multipart import MIMEMultipart
|
|
9
12
|
from email.mime.text import MIMEText
|
|
10
13
|
|
|
14
|
+
import css_inline
|
|
11
15
|
import pytest
|
|
12
16
|
|
|
17
|
+
from postmarker.core import PostmarkClient
|
|
13
18
|
from postmarker.models.emails import Email
|
|
14
19
|
|
|
15
20
|
pytestmark = pytest.mark.vcr
|
|
@@ -267,7 +272,7 @@ def test_batch_send_multiple(postmark, email, postmark_request):
|
|
|
267
272
|
|
|
268
273
|
def test_batch_send_mime(postmark, postmark_request):
|
|
269
274
|
postmark.emails.send_batch(MIME_MESSAGE)
|
|
270
|
-
email = Email.from_mime(MIME_MESSAGE, postmark)
|
|
275
|
+
email = Email.from_mime(MIME_MESSAGE, postmark.emails)
|
|
271
276
|
assert postmark_request.call_args[1]["json"] == (email.as_dict(),)
|
|
272
277
|
|
|
273
278
|
|
|
@@ -364,7 +369,7 @@ def test_email_attach_binary(email, postmark_request):
|
|
|
364
369
|
|
|
365
370
|
|
|
366
371
|
def test_email_from_mime(postmark):
|
|
367
|
-
email = Email.from_mime(MIME_MESSAGE, postmark)
|
|
372
|
+
email = Email.from_mime(MIME_MESSAGE, postmark.emails)
|
|
368
373
|
assert email.TextBody == "Text"
|
|
369
374
|
assert email.From == MIME_MESSAGE["From"]
|
|
370
375
|
assert email.To == MIME_MESSAGE["To"]
|
|
@@ -485,3 +490,90 @@ def test_email_template_repr(email_template):
|
|
|
485
490
|
|
|
486
491
|
def test_email_template_batch_len(email_template_batch):
|
|
487
492
|
assert len(email_template_batch) == 1
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
STYLED_HTML = "<html><head><style>h1{color:red}</style></head><body><h1>Hi</h1></body></html>"
|
|
496
|
+
INLINED_HTML = '<html><head></head><body><h1 style="color: red;">Hi</h1></body></html>'
|
|
497
|
+
STYLED_EMAIL = {"From": "sender@example.com", "To": "receiver@example.com", "HtmlBody": STYLED_HTML}
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def sent_html_body(postmark_request):
|
|
501
|
+
payload = postmark_request.call_args[1]["json"]
|
|
502
|
+
# Batches send a tuple of emails
|
|
503
|
+
if isinstance(payload, tuple):
|
|
504
|
+
payload = payload[0]
|
|
505
|
+
return payload["HtmlBody"]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@pytest.mark.parametrize(
|
|
509
|
+
"send",
|
|
510
|
+
(
|
|
511
|
+
lambda client: client.emails.send(**STYLED_EMAIL),
|
|
512
|
+
lambda client: client.emails.send(message=get_mime_message("Text", STYLED_HTML, **DEFAULT_HEADERS)),
|
|
513
|
+
lambda client: client.emails.send_batch(STYLED_EMAIL),
|
|
514
|
+
lambda client: client.emails.send_batch(get_mime_message("Text", STYLED_HTML, **DEFAULT_HEADERS)),
|
|
515
|
+
),
|
|
516
|
+
ids=("send", "send-mime", "batch", "batch-mime"),
|
|
517
|
+
)
|
|
518
|
+
def test_inline_css(postmark_request, send):
|
|
519
|
+
send(PostmarkClient(server_token="SERVER_TOKEN", inline_css=True))
|
|
520
|
+
assert sent_html_body(postmark_request) == INLINED_HTML
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def test_inline_css_from_config(postmark_request):
|
|
524
|
+
client = PostmarkClient.from_config({"postmark_server_token": "SERVER_TOKEN", "postmark_inline_css": True})
|
|
525
|
+
client.emails.send(**STYLED_EMAIL)
|
|
526
|
+
assert sent_html_body(postmark_request) == INLINED_HTML
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def test_inline_css_custom_inliner(postmark_request):
|
|
530
|
+
client = PostmarkClient(server_token="SERVER_TOKEN", inline_css=css_inline.CSSInliner(keep_style_tags=True))
|
|
531
|
+
client.emails.send(**STYLED_EMAIL)
|
|
532
|
+
assert (
|
|
533
|
+
sent_html_body(postmark_request)
|
|
534
|
+
== '<html><head><style>h1{color:red}</style></head><body><h1 style="color: red;">Hi</h1></body></html>'
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def test_inline_css_skips_remote_stylesheets(postmark_request):
|
|
539
|
+
# Email HTML may carry user content; the default inliner must not fetch `<link>` URLs
|
|
540
|
+
html = '<html><head><link rel="stylesheet" href="http://127.0.0.1:9/x.css"></head><body>Hi</body></html>'
|
|
541
|
+
PostmarkClient(server_token="SERVER_TOKEN", inline_css=True).emails.send(**{**STYLED_EMAIL, "HtmlBody": html})
|
|
542
|
+
assert sent_html_body(postmark_request) == "<html><head></head><body>Hi</body></html>"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def test_inline_css_missing_package(monkeypatch):
|
|
546
|
+
monkeypatch.setitem(sys.modules, "css_inline", None)
|
|
547
|
+
with pytest.raises(ImportError, match=r"postmarker\[css-inline\]"):
|
|
548
|
+
PostmarkClient(server_token="SERVER_TOKEN", inline_css=True)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@pytest.mark.parametrize(
|
|
552
|
+
"inner", (MIMEText("Forwarded"), get_mime_message("Text", "<b>HTML</b>")), ids=("simple", "multipart")
|
|
553
|
+
)
|
|
554
|
+
def test_email_attach_forwarded_message(email, postmark_request, inner):
|
|
555
|
+
attachment = MIMEMessage(inner)
|
|
556
|
+
attachment.add_header("Content-Disposition", "attachment", filename="forwarded.eml")
|
|
557
|
+
email.attach(attachment)
|
|
558
|
+
email.send()
|
|
559
|
+
assert postmark_request.call_args[1]["json"]["Attachments"] == [
|
|
560
|
+
{
|
|
561
|
+
"Name": "forwarded.eml",
|
|
562
|
+
"Content": base64.b64encode(inner.as_bytes()).decode(),
|
|
563
|
+
"ContentType": "message/rfc822",
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def test_email_from_mime_encoded_headers(postmark):
|
|
569
|
+
message = MIMEText("Text")
|
|
570
|
+
# An encoded word followed by plain text decodes into parts with and without a charset
|
|
571
|
+
message["Subject"] = "=?utf-8?b?w6k=?= text"
|
|
572
|
+
assert Email.from_mime(message, postmark.emails).Subject == "\u00e9 text"
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def test_email_from_mime_set_content(postmark):
|
|
576
|
+
message = EmailMessage()
|
|
577
|
+
# `set_content` appends a newline the sender never wrote
|
|
578
|
+
message.set_content("Text")
|
|
579
|
+
assert Email.from_mime(message, postmark.emails).TextBody == "Text"
|