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.
Files changed (142) hide show
  1. postmarker-2.1/PKG-INFO +203 -0
  2. postmarker-2.1/README.md +149 -0
  3. {postmarker-2.0 → postmarker-2.1}/pyproject.toml +4 -2
  4. postmarker-2.1/src/postmarker/__init__.py +1 -0
  5. {postmarker-2.0 → postmarker-2.1}/src/postmarker/core.py +21 -1
  6. {postmarker-2.0 → postmarker-2.1}/src/postmarker/django/backend.py +9 -3
  7. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/emails.py +8 -1
  8. {postmarker-2.0 → postmarker-2.1}/test/django/test_backend.py +23 -2
  9. {postmarker-2.0 → postmarker-2.1}/test/models/test_emails.py +94 -2
  10. postmarker-2.0/PKG-INFO +0 -146
  11. postmarker-2.0/README.md +0 -95
  12. postmarker-2.0/src/postmarker/__init__.py +0 -1
  13. {postmarker-2.0 → postmarker-2.1}/.gitignore +0 -0
  14. {postmarker-2.0 → postmarker-2.1}/LICENSE +0 -0
  15. {postmarker-2.0 → postmarker-2.1}/src/postmarker/django/__init__.py +0 -0
  16. {postmarker-2.0 → postmarker-2.1}/src/postmarker/django/signals.py +0 -0
  17. {postmarker-2.0 → postmarker-2.1}/src/postmarker/exceptions.py +0 -0
  18. {postmarker-2.0 → postmarker-2.1}/src/postmarker/logging.py +0 -0
  19. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/__init__.py +0 -0
  20. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/base.py +0 -0
  21. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/bounces.py +0 -0
  22. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/domains.py +0 -0
  23. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/message_streams.py +0 -0
  24. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/messages.py +0 -0
  25. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/senders.py +0 -0
  26. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/server.py +0 -0
  27. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/servers.py +0 -0
  28. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/stats.py +0 -0
  29. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/status.py +0 -0
  30. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/suppressions.py +0 -0
  31. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/templates.py +0 -0
  32. {postmarker-2.0 → postmarker-2.1}/src/postmarker/models/triggers.py +0 -0
  33. {postmarker-2.0 → postmarker-2.1}/src/postmarker/py.typed +0 -0
  34. {postmarker-2.0 → postmarker-2.1}/src/postmarker/pytest.py +0 -0
  35. {postmarker-2.0 → postmarker-2.1}/src/postmarker/tornado.py +0 -0
  36. {postmarker-2.0 → postmarker-2.1}/src/postmarker/utils.py +0 -0
  37. {postmarker-2.0 → postmarker-2.1}/test/__init__.py +0 -0
  38. {postmarker-2.0 → postmarker-2.1}/test/cassettes/test_exceptions/test_raise.yaml +0 -0
  39. {postmarker-2.0 → postmarker-2.1}/test/cassettes/test_spamcheck/test_spamcheck_invalid.yaml +0 -0
  40. {postmarker-2.0 → postmarker-2.1}/test/cassettes/test_spamcheck/test_spamcheck_valid.yaml +0 -0
  41. {postmarker-2.0 → postmarker-2.1}/test/conftest.py +0 -0
  42. {postmarker-2.0 → postmarker-2.1}/test/django/__init__.py +0 -0
  43. {postmarker-2.0 → postmarker-2.1}/test/django/conftest.py +0 -0
  44. {postmarker-2.0 → postmarker-2.1}/test/django/settings.py +0 -0
  45. {postmarker-2.0 → postmarker-2.1}/test/django/test_mixins.py +0 -0
  46. {postmarker-2.0 → postmarker-2.1}/test/models/__init__.py +0 -0
  47. {postmarker-2.0 → postmarker-2.1}/test/models/attachments/readme.txt +0 -0
  48. {postmarker-2.0 → postmarker-2.1}/test/models/attachments/report.blabla +0 -0
  49. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_load_all_bounces_multiple_calls[2-1-2].yaml +0 -0
  50. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_load_all_bounces_multiple_calls[2-50-1].yaml +0 -0
  51. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_load_all_bounces_multiple_calls[4-1-2].yaml +0 -0
  52. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_load_all_bounces_multiple_calls[None-1-2].yaml +0 -0
  53. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_manager_all.yaml +0 -0
  54. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_manager_deliverystats.yaml +0 -0
  55. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_manager_tags.yaml +0 -0
  56. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_message.yaml +0 -0
  57. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_model_activate.yaml +0 -0
  58. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_model_dump.yaml +0 -0
  59. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_model_get.yaml +0 -0
  60. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_model_repr.yaml +0 -0
  61. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_bounces/test_model_str.yaml +0 -0
  62. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_domains/test_manager_all.yaml +0 -0
  63. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_domains/test_manager_domain_methods.yaml +0 -0
  64. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_domains/test_model_get.yaml +0 -0
  65. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_domains/test_model_str.yaml +0 -0
  66. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_emails/test_simple_send_default.yaml +0 -0
  67. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_emails/test_simple_send_mime_text.yaml +0 -0
  68. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_emails/test_simple_send_minimum_mime.yaml +0 -0
  69. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_emails/test_simple_send_with_template.yaml +0 -0
  70. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_inbound_message_bypass.yaml +0 -0
  71. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_inbound_message_retry.yaml +0 -0
  72. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_inbound_messages_all.yaml +0 -0
  73. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_opens_model.yaml +0 -0
  74. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_outbound_message.yaml +0 -0
  75. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_outbound_message_get.yaml +0 -0
  76. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_outbound_message_get_dump.yaml +0 -0
  77. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_messages/test_outbound_message_opens.yaml +0 -0
  78. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_senders/test_all.yaml +0 -0
  79. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_senders/test_all_methods.yaml +0 -0
  80. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_senders/test_edit.yaml +0 -0
  81. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_senders/test_get.yaml +0 -0
  82. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_server/test_edit.yaml +0 -0
  83. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_server/test_str.yaml +0 -0
  84. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[bounces-kwargs2-expected2].yaml +0 -0
  85. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[browserfamilies-kwargs10-expected10].yaml +0 -0
  86. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[clicks-kwargs9-expected9].yaml +0 -0
  87. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[clicks_platforms-kwargs11-expected11].yaml +0 -0
  88. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[emailclients-kwargs7-expected7].yaml +0 -0
  89. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[location-kwargs12-expected12].yaml +0 -0
  90. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[opens-kwargs5-expected5].yaml +0 -0
  91. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[opens_platforms-kwargs6-expected6].yaml +0 -0
  92. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[overview-kwargs0-expected0].yaml +0 -0
  93. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[readtimes-kwargs8-expected8].yaml +0 -0
  94. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[sends-kwargs1-expected1].yaml +0 -0
  95. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[spam-kwargs3-expected3].yaml +0 -0
  96. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_stats/test_methods[tracked-kwargs4-expected4].yaml +0 -0
  97. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_availability.yaml +0 -0
  98. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_delivery.yaml +0 -0
  99. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_get_incident.yaml +0 -0
  100. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_incidents.yaml +0 -0
  101. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_last_incident.yaml +0 -0
  102. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_services.yaml +0 -0
  103. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_status/test_status.yaml +0 -0
  104. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_manager_all.yaml +0 -0
  105. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_manager_create.yaml +0 -0
  106. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_manager_edit.yaml +0 -0
  107. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_manager_validate.yaml +0 -0
  108. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_model_default.yaml +0 -0
  109. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_model_edit.yaml +0 -0
  110. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_model_get.yaml +0 -0
  111. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_templates/test_model_repr.yaml +0 -0
  112. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_inbound_rules_all.yaml +0 -0
  113. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_inbound_rules_create.yaml +0 -0
  114. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_inbound_rules_delete.yaml +0 -0
  115. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_tags_all.yaml +0 -0
  116. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_tags_create.yaml +0 -0
  117. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_tags_delete.yaml +0 -0
  118. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_tags_edit.yaml +0 -0
  119. {postmarker-2.0 → postmarker-2.1}/test/models/cassettes/test_triggers/test_tags_get.yaml +0 -0
  120. {postmarker-2.0 → postmarker-2.1}/test/models/test_base.py +0 -0
  121. {postmarker-2.0 → postmarker-2.1}/test/models/test_bounces.py +0 -0
  122. {postmarker-2.0 → postmarker-2.1}/test/models/test_clicks.py +0 -0
  123. {postmarker-2.0 → postmarker-2.1}/test/models/test_domains.py +0 -0
  124. {postmarker-2.0 → postmarker-2.1}/test/models/test_message_streams.py +0 -0
  125. {postmarker-2.0 → postmarker-2.1}/test/models/test_messages.py +0 -0
  126. {postmarker-2.0 → postmarker-2.1}/test/models/test_senders.py +0 -0
  127. {postmarker-2.0 → postmarker-2.1}/test/models/test_server.py +0 -0
  128. {postmarker-2.0 → postmarker-2.1}/test/models/test_servers.py +0 -0
  129. {postmarker-2.0 → postmarker-2.1}/test/models/test_stats.py +0 -0
  130. {postmarker-2.0 → postmarker-2.1}/test/models/test_status.py +0 -0
  131. {postmarker-2.0 → postmarker-2.1}/test/models/test_suppressions.py +0 -0
  132. {postmarker-2.0 → postmarker-2.1}/test/models/test_templates.py +0 -0
  133. {postmarker-2.0 → postmarker-2.1}/test/models/test_triggers.py +0 -0
  134. {postmarker-2.0 → postmarker-2.1}/test/models/test_webhooks.py +0 -0
  135. {postmarker-2.0 → postmarker-2.1}/test/test_core.py +0 -0
  136. {postmarker-2.0 → postmarker-2.1}/test/test_exceptions.py +0 -0
  137. {postmarker-2.0 → postmarker-2.1}/test/test_pytest.py +0 -0
  138. {postmarker-2.0 → postmarker-2.1}/test/test_spamcheck.py +0 -0
  139. {postmarker-2.0 → postmarker-2.1}/test/test_utils.py +0 -0
  140. {postmarker-2.0 → postmarker-2.1}/test/tornado/__init__.py +0 -0
  141. {postmarker-2.0 → postmarker-2.1}/test/tornado/test_handlers.py +0 -0
  142. {postmarker-2.0 → postmarker-2.1}/test/typing_checks.py +0 -0
@@ -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
+ [![Build](https://github.com/Stranger6667/postmarker/actions/workflows/build.yml/badge.svg)](https://github.com/Stranger6667/postmarker/actions/workflows/build.yml) [![Coverage](https://codecov.io/gh/Stranger6667/postmarker/branch/master/graph/badge.svg)](https://codecov.io/gh/Stranger6667/postmarker) [![Version](https://img.shields.io/pypi/v/postmarker.svg)](https://pypi.org/project/postmarker/) [![Python versions](https://img.shields.io/pypi/pyversions/postmarker.svg)](https://pypi.org/project/postmarker/) [![Docs](https://readthedocs.org/projects/postmarker/badge/?version=stable)](https://postmarker.readthedocs.io/en/stable/) [![License](https://img.shields.io/pypi/l/postmarker.svg)](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.
@@ -0,0 +1,149 @@
1
+ # Postmarker
2
+
3
+ [![Build](https://github.com/Stranger6667/postmarker/actions/workflows/build.yml/badge.svg)](https://github.com/Stranger6667/postmarker/actions/workflows/build.yml) [![Coverage](https://codecov.io/gh/Stranger6667/postmarker/branch/master/graph/badge.svg)](https://codecov.io/gh/Stranger6667/postmarker) [![Version](https://img.shields.io/pypi/v/postmarker.svg)](https://pypi.org/project/postmarker/) [![Python versions](https://img.shields.io/pypi/pyversions/postmarker.svg)](https://pypi.org/project/postmarker/) [![Docs](https://readthedocs.org/projects/postmarker/badge/?version=stable)](https://postmarker.readthedocs.io/en/stable/) [![License](https://img.shields.io/pypi/l/postmarker.svg)](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
- tests = ["coverage[toml]>=7", "pytest>=8", "pytest-django", "pytest-recording>=0.14", "tornado"]
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
- exclude_lines = ["raise NotImplementedError"]
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(server_token=self.server_token, verbosity=self.get_option("VERBOSITY"))
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].get_payload(decode=True)).decode()
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
- message_content = "Fake message"
238
- expected_content = "RmFrZSBtZXNzYWdl"
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"