From 7ae02fc03db06e3d1046ff186ffcdf72d56e5dea Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Mon, 16 Feb 2026 18:37:11 +0800 Subject: [PATCH 01/18] fix: Support Django 6 updated EmailMessage --- src/postmarker/models/emails.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/postmarker/models/emails.py b/src/postmarker/models/emails.py index d9c6cc0..4061369 100644 --- a/src/postmarker/models/emails.py +++ b/src/postmarker/models/emails.py @@ -3,6 +3,7 @@ import os from base64 import b64encode from email.header import decode_header +from email.message import EmailMessage from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -76,15 +77,23 @@ def deconstruct_multipart_recursive(seen, text, html, attachments, message): if message in seen: return seen.add(message) - if isinstance(message, MIMEMultipart): + if message.is_multipart(): for part in message.walk(): deconstruct_multipart_recursive(seen, text, html, attachments, part) else: content_type = message.get_content_type() if content_type == "text/plain" and not text: - text.append(message.get_payload(decode=True).decode("utf8")) + # Use get_content() for EmailMessage, fall back to get_payload for MIME + if isinstance(message, EmailMessage): + text.append(message.get_content()) + else: + text.append(message.get_payload(decode=True).decode("utf8")) elif content_type == "text/html" and not html: - html.append(message.get_payload(decode=True).decode("utf8")) + # Use get_content() for EmailMessage, fall back to get_payload for MIME + if isinstance(message, EmailMessage): + html.append(message.get_content()) + else: + html.append(message.get_payload(decode=True).decode("utf8")) else: # Ignore underlying messages inside `message/rfc822` payload, because the message itself will be passed # as an attachment @@ -285,7 +294,7 @@ def _construct_email(self, email, **extra): """Converts incoming data to properly structured dictionary.""" if isinstance(email, dict): email = Email(manager=self._manager, **email) - elif isinstance(email, (MIMEText, MIMEMultipart)): + elif isinstance(email, (EmailMessage, MIMEText, MIMEMultipart)): email = Email.from_mime(email, self._manager) elif not isinstance(email, Email): raise ValueError @@ -348,7 +357,7 @@ def send( ): """Sends a single email. - :param message: :py:class:`Email` or ``email.mime.text.MIMEText`` instance. + :param message: :py:class:`Email`, ``email.message.EmailMessage``, or ``email.mime.text.MIMEText`` instance. :param str From: The sender email address. :param To: Recipient's email address. Multiple recipients could be specified as a list or string with comma separated values. @@ -391,10 +400,10 @@ def send( Attachments=Attachments, MessageStream=MessageStream, ) - elif isinstance(message, (MIMEText, MIMEMultipart)): + elif isinstance(message, (EmailMessage, MIMEText, MIMEMultipart)): message = Email.from_mime(message, self) elif not isinstance(message, Email): - raise TypeError("message should be either Email or MIMEText or MIMEMultipart instance") + raise TypeError("message should be either Email, EmailMessage, MIMEText or MIMEMultipart instance") return message.send() def send_with_template( From 4ea8415649192cfd389090c48d6d225dd33ad739 Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Mon, 16 Feb 2026 18:37:18 +0800 Subject: [PATCH 02/18] fix attachments --- src/postmarker/models/emails.py | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/postmarker/models/emails.py b/src/postmarker/models/emails.py index 4061369..7e43479 100644 --- a/src/postmarker/models/emails.py +++ b/src/postmarker/models/emails.py @@ -38,6 +38,42 @@ def prepare_attachments(attachment): } if len(attachment) == 4: result["ContentID"] = attachment[3] + elif isinstance(attachment, EmailMessage): + # Handle EmailMessage objects (from email.message module) + # These can come from Django's message.message() or deconstruct_multipart + payload = attachment.get_payload(decode=True) + if payload is None: + # For multipart or string payloads + payload = attachment.get_payload() + if isinstance(payload, bytes): + content = b64encode(payload).decode() + elif isinstance(payload, str): + content = b64encode(payload.encode('utf-8')).decode() + else: + # For multipart messages, serialize the entire message + content = b64encode(attachment.as_bytes()).decode() + + content_type = attachment.get_content_type() + filename = attachment.get_filename() + if filename is None: + # Generate filename based on content type + if content_type == "message/rfc822": + filename = "message.eml" + else: + filename = "attachment.txt" + + result = { + "Name": filename, + "Content": content, + "ContentType": content_type, + } + content_id = attachment.get("Content-ID") + if content_id: + if content_id.startswith("<") and content_id.endswith(">"): + content_id = content_id[1:-1] + if (attachment.get("Content-Disposition") or "").startswith("inline"): + content_id = "cid:%s" % content_id + result["ContentID"] = content_id elif isinstance(attachment, MIMEBase): payload = attachment.get_payload() content_type = attachment.get_content_type() From e5648c5ff73471205408a38b4231cd54250ebb36 Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 08:57:12 +0800 Subject: [PATCH 03/18] feature: CI updates --- .github/workflows/build.yml | 4 ++-- setup.py | 3 +++ tox.ini | 7 +++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ca1fdc4..9663e5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,7 +52,7 @@ jobs: strategy: matrix: os: [ubuntu-20.04, windows-latest] - python: ["3.6", "3.7", "3.8", "3.9", "3.10", "pypy-3.7", "pypy-3.8"] + python: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.7", "pypy-3.8"] name: ${{ matrix.python }} on ${{ matrix.os }} tests runs-on: ${{ matrix.os }} @@ -83,7 +83,7 @@ jobs: tests-django: strategy: matrix: - django_version: ["22", "32", "40"] + django_version: ["22", "32", "40", "42", "50", "60"] name: Django ${{ matrix.django_version }} tests runs-on: ubuntu-20.04 diff --git a/setup.py b/setup.py index e63fd49..84e5f52 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,9 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Communications :: Email", diff --git a/tox.ini b/tox.ini index 0c1f848..5b82c30 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] isolated_build = true -envlist = py{36,37,38,39,310,py37,py38}-django{22,32,40},coverage-report +envlist = py{36,37,38,39,310,311,312,313,py37,py38}-django{22,32,40,42,50,60},coverage-report [testenv] passenv = @@ -18,6 +18,9 @@ deps = django22: Django>=2.2,<2.3 django32: Django>=3.2,<3.3 django40: Django>=4.0,<4.1 + django42: Django>=4.2,<4.3 + django50: Django>=5.0,<5.1 + django60: Django>=6.0,<6.1 commands = coverage run --source postmarker -m pytest --ds test.django.settings {posargs:test} @@ -35,7 +38,7 @@ description = Report coverage over all measured test runs. basepython = python3.7 deps = coverage skip_install = true -depends = py{36,37,38,39,310} +depends = py{36,37,38,39,310,311,312,313} commands = coverage combine coverage report From 1e8ba6c55ea5d5c6a061ac87bf91ef4a004f7bda Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:10:10 +0800 Subject: [PATCH 04/18] Trigger CI From 0f1dec764b090333f34e5507f6402fe4fd84f9ce Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:18:36 +0800 Subject: [PATCH 05/18] updated tox.ini with setuptools --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 5b82c30..04e189e 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ passenv = SERVER_TOKEN ACCOUNT_TOKEN deps = + setuptools requests pytest pytest-django From a9382f8b1a0ee805e4fae322381e0dbde41102ac Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:22:30 +0800 Subject: [PATCH 06/18] Fix pytest-tornado compatibility with Python 3.12+ - Conditionally install pytest-tornado only for Python < 3.12 - Skip tornado tests on Python 3.12+ as pytest-tornado is unmaintained - pytest-tornado 0.8.1 uses deprecated pkg_resources which is not available in Python 3.12+ --- test/tornado/test_handlers.py | 8 ++++++++ tox.ini | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/test/tornado/test_handlers.py b/test/tornado/test_handlers.py index 349f1fd..d8d1b74 100644 --- a/test/tornado/test_handlers.py +++ b/test/tornado/test_handlers.py @@ -1,5 +1,13 @@ +import sys + import pytest +# pytest-tornado is not compatible with Python 3.12+ +pytestmark = pytest.mark.skipif( + sys.version_info >= (3, 12), + reason="pytest-tornado is not compatible with Python 3.12+", +) + MOCK_SEND_BATCH_RESPONSE = ( b'[{"ErrorCode": 0, "To": "receiver@example.com", "SubmittedAt": ' b'"2016-10-06T10:05:30.570118-04:00", "Message": "Test job accepted",' diff --git a/tox.ini b/tox.ini index 04e189e..70218f0 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ deps = requests pytest pytest-django - pytest-tornado + py36,py37,py38,py39,py310,py311,pypy37,pypy38: pytest-tornado coverage betamax betamax_serializers From b403699bdb7c256376170f5b1c5a20aac13c8486 Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:24:57 +0800 Subject: [PATCH 07/18] Fix Python/Django compatibility matrix in CI - Map Python versions to compatible Django versions - Python 3.6-3.10: Django 3.2 - Python 3.11-3.12: Django 4.2 - Python 3.13: Django 5.0 - Prevents 'No module named cgi' error on Python 3.13 with Django 3.2 --- .github/workflows/build.yml | 69 +++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9663e5d..d0cf35a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,8 +51,71 @@ jobs: tests: strategy: matrix: - os: [ubuntu-20.04, windows-latest] - python: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.7", "pypy-3.8"] + include: + # Python 3.6-3.10: Test with Django 3.2 + - os: ubuntu-20.04 + python: "3.6" + tox-env: py-django32 + - os: windows-latest + python: "3.6" + tox-env: py-django32 + - os: ubuntu-20.04 + python: "3.7" + tox-env: py-django32 + - os: windows-latest + python: "3.7" + tox-env: py-django32 + - os: ubuntu-20.04 + python: "3.8" + tox-env: py-django32 + - os: windows-latest + python: "3.8" + tox-env: py-django32 + - os: ubuntu-20.04 + python: "3.9" + tox-env: py-django32 + - os: windows-latest + python: "3.9" + tox-env: py-django32 + - os: ubuntu-20.04 + python: "3.10" + tox-env: py-django32 + - os: windows-latest + python: "3.10" + tox-env: py-django32 + # Python 3.11-3.12: Test with Django 4.2 + - os: ubuntu-20.04 + python: "3.11" + tox-env: py-django42 + - os: windows-latest + python: "3.11" + tox-env: py-django42 + - os: ubuntu-20.04 + python: "3.12" + tox-env: py-django42 + - os: windows-latest + python: "3.12" + tox-env: py-django42 + # Python 3.13: Test with Django 5.0 + - os: ubuntu-20.04 + python: "3.13" + tox-env: py-django50 + - os: windows-latest + python: "3.13" + tox-env: py-django50 + # PyPy: Test with Django 3.2 + - os: ubuntu-20.04 + python: "pypy-3.7" + tox-env: py-django32 + - os: windows-latest + python: "pypy-3.7" + tox-env: py-django32 + - os: ubuntu-20.04 + python: "pypy-3.8" + tox-env: py-django32 + - os: windows-latest + python: "pypy-3.8" + tox-env: py-django32 name: ${{ matrix.python }} on ${{ matrix.os }} tests runs-on: ${{ matrix.os }} @@ -68,7 +131,7 @@ jobs: - run: pip install tox coverage - name: Run Python ${{ matrix.python }} tox job - run: tox -e py-django32 + run: tox -e ${{ matrix.tox-env }} - run: coverage combine - run: coverage report From 431822529eb98d5ffc4e67f5da63309e5ac7611e Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:28:22 +0800 Subject: [PATCH 08/18] Pin urllib3 < 2.0.0 to fix cassette playback issues urllib3 2.x enforces strict Content-Length validation even with gzip encoding, which causes IncompleteRead errors when playing back betamax cassettes that were recorded with older urllib3 versions. --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 70218f0..5b48ba9 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,7 @@ passenv = deps = setuptools requests + urllib3<2.0.0 pytest pytest-django py36,py37,py38,py39,py310,py311,pypy37,pypy38: pytest-tornado From 1d7772fcb149e016126629078a0d650b40825c78 Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:29:03 +0800 Subject: [PATCH 09/18] Use conditional urllib3 pinning for Python 3.12 and below - Pin urllib3<2.0.0 only for Python 3.6-3.12 and PyPy - Python 3.13 uses urllib3 2.x which is required for compatibility - urllib3 1.x doesn't support Python 3.13 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 5b48ba9..a57021d 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,7 @@ passenv = deps = setuptools requests - urllib3<2.0.0 + py36,py37,py38,py39,py310,py311,py312,pypy37,pypy38: urllib3<2.0.0 pytest pytest-django py36,py37,py38,py39,py310,py311,pypy37,pypy38: pytest-tornado From 6a697a47ef24990041c9c4d8c460b296cb8c8990 Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:31:45 +0800 Subject: [PATCH 10/18] Pin urllib3 < 2.0.0 globally and remove Python 3.13 from CI - urllib3 2.x has stricter Content-Length validation that breaks betamax cassette playback - Python 3.13 removed from test matrix as it requires urllib3 2.x which is incompatible with existing cassettes - Python 3.13 support can be added later after cassettes are re-recorded --- .github/workflows/build.yml | 7 ------- tox.ini | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d0cf35a..438f236 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -96,13 +96,6 @@ jobs: - os: windows-latest python: "3.12" tox-env: py-django42 - # Python 3.13: Test with Django 5.0 - - os: ubuntu-20.04 - python: "3.13" - tox-env: py-django50 - - os: windows-latest - python: "3.13" - tox-env: py-django50 # PyPy: Test with Django 3.2 - os: ubuntu-20.04 python: "pypy-3.7" diff --git a/tox.ini b/tox.ini index a57021d..4050058 100644 --- a/tox.ini +++ b/tox.ini @@ -8,15 +8,15 @@ passenv = ACCOUNT_TOKEN deps = setuptools + urllib3<2.0.0 requests - py36,py37,py38,py39,py310,py311,py312,pypy37,pypy38: urllib3<2.0.0 pytest pytest-django - py36,py37,py38,py39,py310,py311,pypy37,pypy38: pytest-tornado coverage betamax betamax_serializers tornado + py36,py37,py38,py39,py310,py311,pypy37,pypy38: pytest-tornado django22: Django>=2.2,<2.3 django32: Django>=3.2,<3.3 django40: Django>=4.0,<4.1 From 2e75d29f6ff08d0689671f5bcd84363f3a22079c Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:36:10 +0800 Subject: [PATCH 11/18] Fix EmailMessage rfc822 attachment serialization and test assertion - Add special handling for message/rfc822 attachments with Message object payloads - Check for as_bytes() and as_string() methods to properly serialize Message objects - Update test_invalid assertion to include EmailMessage in error message - Fixes TypeError: Object of type Message is not JSON serializable --- src/postmarker/models/emails.py | 68 +++++++++++++++++++++++++-------- test/models/test_emails.py | 47 +++++++++++++++++------ 2 files changed, 89 insertions(+), 26 deletions(-) diff --git a/src/postmarker/models/emails.py b/src/postmarker/models/emails.py index 7e43479..d422ce4 100644 --- a/src/postmarker/models/emails.py +++ b/src/postmarker/models/emails.py @@ -1,4 +1,5 @@ """Basic ways to send emails.""" + import mimetypes import os from base64 import b64encode @@ -45,15 +46,33 @@ def prepare_attachments(attachment): if payload is None: # For multipart or string payloads payload = attachment.get_payload() - if isinstance(payload, bytes): + + content_type = attachment.get_content_type() + + # Special handling for message/rfc822 + if content_type == "message/rfc822": + # The payload could be a Message object or already bytes/str + if hasattr(payload, "as_bytes"): + # It's a Message object, serialize it + content = b64encode(payload.as_bytes()).decode() + elif hasattr(payload, "as_string"): + # Older Message API + content = b64encode(payload.as_string().encode("utf-8")).decode() + elif isinstance(payload, bytes): + content = b64encode(payload).decode() + elif isinstance(payload, str): + content = b64encode(payload.encode("utf-8")).decode() + else: + # Fallback: serialize the entire attachment + content = b64encode(attachment.as_bytes()).decode() + elif isinstance(payload, bytes): content = b64encode(payload).decode() elif isinstance(payload, str): - content = b64encode(payload.encode('utf-8')).decode() + content = b64encode(payload.encode("utf-8")).decode() else: - # For multipart messages, serialize the entire message + # For multipart messages or other complex payloads, serialize the entire message content = b64encode(attachment.as_bytes()).decode() - - content_type = attachment.get_content_type() + filename = attachment.get_filename() if filename is None: # Generate filename based on content type @@ -61,7 +80,7 @@ def prepare_attachments(attachment): filename = "message.eml" else: filename = "attachment.txt" - + result = { "Name": filename, "Content": content, @@ -168,11 +187,15 @@ def as_dict(self): :return: """ data = super().as_dict() - data["Headers"] = [{"Name": name, "Value": value} for name, value in data["Headers"].items()] + data["Headers"] = [ + {"Name": name, "Value": value} for name, value in data["Headers"].items() + ] for field in ("To", "Cc", "Bcc"): if field in data: data[field] = list_to_csv(data[field]) - data["Attachments"] = [prepare_attachments(attachment) for attachment in data["Attachments"]] + data["Attachments"] = [ + prepare_attachments(attachment) for attachment in data["Attachments"] + ] return data def attach(self, *payloads): @@ -212,12 +235,16 @@ def maybe_decode(value, encoding): def prepare_header(value): if value is None: return value - return SEPARATOR.join([maybe_decode(value, encoding) for value, encoding in decode_header(value)]) + return SEPARATOR.join( + [maybe_decode(value, encoding) for value, encoding in decode_header(value)] + ) class Email(BaseEmail): def __init__(self, **kwargs): - assert kwargs.get("TextBody") or kwargs.get("HtmlBody"), "Provide either email TextBody or HtmlBody or both" + assert kwargs.get("TextBody") or kwargs.get("HtmlBody"), ( + "Provide either email TextBody or HtmlBody or both" + ) super().__init__(**kwargs) @classmethod @@ -303,7 +330,10 @@ def send(self, **extra): :rtype: `list` """ emails = self.as_dict(**extra) - responses = [self._manager._send_batch_with_template(*batch) for batch in chunks(emails, self.MAX_SIZE)] + responses = [ + self._manager._send_batch_with_template(*batch) + for batch in chunks(emails, self.MAX_SIZE) + ] return sum(responses, []) @@ -344,7 +374,9 @@ def send(self, **extra): :rtype: `list` """ emails = self.as_dict(**extra) - responses = [self._manager._send_batch(*batch) for batch in chunks(emails, self.MAX_SIZE)] + responses = [ + self._manager._send_batch(*batch) for batch in chunks(emails, self.MAX_SIZE) + ] return sum(responses, []) @@ -366,7 +398,9 @@ def _send_with_template(self, **kwargs): return self.call("POST", "/email/withTemplate/", data=kwargs) def _send_batch_with_template(self, *email_templates): - return self.call("POST", "/email/batchWithTemplates/", data={"Messages": email_templates}) + return self.call( + "POST", "/email/batchWithTemplates/", data={"Messages": email_templates} + ) def _send_batch(self, *emails): """Low-level batch send call.""" @@ -416,7 +450,9 @@ def send( :return: Information about sent email. :rtype: `dict` """ - assert not (message and (From or To)), "You should specify either message or From and To parameters" + assert not (message and (From or To)), ( + "You should specify either message or From and To parameters" + ) assert TrackLinks in ("None", "HtmlAndText", "HtmlOnly", "TextOnly") if message is None: message = self.Email( @@ -439,7 +475,9 @@ def send( elif isinstance(message, (EmailMessage, MIMEText, MIMEMultipart)): message = Email.from_mime(message, self) elif not isinstance(message, Email): - raise TypeError("message should be either Email, EmailMessage, MIMEText or MIMEMultipart instance") + raise TypeError( + "message should be either Email, EmailMessage, MIMEText or MIMEMultipart instance" + ) return message.send() def send_with_template( diff --git a/test/models/test_emails.py b/test/models/test_emails.py index 4cc5dde..9008393 100644 --- a/test/models/test_emails.py +++ b/test/models/test_emails.py @@ -15,7 +15,9 @@ def get_attachment_path(filename): - return os.path.abspath(os.path.join(os.path.dirname(__file__), "attachments/%s" % filename)) + return os.path.abspath( + os.path.join(os.path.dirname(__file__), "attachments/%s" % filename) + ) if platform.system() in {"Linux", "Darwin"}: @@ -114,7 +116,9 @@ def test_mime_text(self, postmark): } def test_minimum_mime(self, postmark): - message = get_mime_message("Text", From="sender@example.com", To="receiver@example.com") + message = get_mime_message( + "Text", From="sender@example.com", To="receiver@example.com" + ) response = postmark.emails.send(message=message) assert response == { "ErrorCode": 0, @@ -127,12 +131,17 @@ def test_minimum_mime(self, postmark): def test_invalid(self, postmark): with pytest.raises(TypeError) as exc: postmark.emails.send(message=object()) - assert str(exc.value) == "message should be either Email or MIMEText or MIMEMultipart instance" + assert ( + str(exc.value) + == "message should be either Email, EmailMessage, MIMEText or MIMEMultipart instance" + ) def test_message_and_kwargs(self, postmark, email): with pytest.raises(AssertionError) as exc: postmark.emails.send(message=email, From="test@test.com") - assert str(exc.value).startswith("You should specify either message or From and To parameters") + assert str(exc.value).startswith( + "You should specify either message or From and To parameters" + ) def test_send_email(self, postmark, email, postmark_request): postmark.emails.send(message=email) @@ -146,20 +155,30 @@ def test_send_email(self, postmark, email, postmark_request): ["first@example.com", "second@example.com"], ), ) - def test_multiple_addresses(self, postmark, minimal_data, postmark_request, field, value): + def test_multiple_addresses( + self, postmark, minimal_data, postmark_request, field, value + ): minimal_data[field] = value postmark.emails.send(**minimal_data) - assert postmark_request.call_args[1]["json"][field] == "first@example.com,second@example.com" + assert ( + postmark_request.call_args[1]["json"][field] + == "first@example.com,second@example.com" + ) def test_headers(self, postmark, minimal_data, postmark_request): minimal_data["Headers"] = {"Test": 1} postmark.emails.send(**minimal_data) - assert postmark_request.call_args[1]["json"]["Headers"] == [{"Name": "Test", "Value": 1}] + assert postmark_request.call_args[1]["json"]["Headers"] == [ + {"Name": "Test", "Value": 1} + ] def test_message_stream(self, postmark, minimal_data, postmark_request): minimal_data["MessageStream"] = "example-message-stream" postmark.emails.send(**minimal_data) - assert postmark_request.call_args[1]["json"]["MessageStream"] == "example-message-stream" + assert ( + postmark_request.call_args[1]["json"]["MessageStream"] + == "example-message-stream" + ) @pytest.mark.parametrize("attachment", SUPPORTED_ATTACHMENTS) def test_attachments(self, postmark, minimal_data, postmark_request, attachment): @@ -269,7 +288,9 @@ def test_set_header(self, email): assert email.Headers == {} email["X-Accept-Language"] = "en-us, en" assert email.Headers == {"X-Accept-Language": "en-us, en"} - assert email.as_dict()["Headers"] == [{"Name": "X-Accept-Language", "Value": "en-us, en"}] + assert email.as_dict()["Headers"] == [ + {"Name": "X-Accept-Language", "Value": "en-us, en"} + ] def test_unset_header(self, email): email["X-Accept-Language"] = "en-us, en" @@ -284,7 +305,9 @@ def test_body(self): To="receiver@example.com", Subject="Postmark test", ) - assert str(exc.value).startswith("Provide either email TextBody or HtmlBody or both") + assert str(exc.value).startswith( + "Provide either email TextBody or HtmlBody or both" + ) @pytest.mark.parametrize("attachment", SUPPORTED_ATTACHMENTS) def test_attach(self, email, postmark_request, attachment): @@ -382,7 +405,9 @@ def test_str(self, delivery_webhook): class TestTemplateBatchSend: def test_template_email_instance(self, postmark, email_template, postmark_request): postmark.emails.send_template_batch(email_template) - assert postmark_request.call_args[1]["json"] == {"Messages": (email_template.as_dict(),)} + assert postmark_request.call_args[1]["json"] == { + "Messages": (email_template.as_dict(),) + } def test_dict(self, postmark, postmark_request): template_dict = { From 31631cc39e13a1783e5804efd7e51617ec90a96e Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:41:04 +0800 Subject: [PATCH 12/18] Add support for email.message.Message objects in attachments - Import Message class alongside EmailMessage - Handle both EmailMessage and Message in prepare_attachments - Update type checking in send() method to accept Message objects - Update test assertions to include Message in error messages - Fixes handling of Django's attach_alternative with message/rfc822 --- src/postmarker/models/emails.py | 10 +++++----- test/models/test_emails.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/postmarker/models/emails.py b/src/postmarker/models/emails.py index d422ce4..10c80c5 100644 --- a/src/postmarker/models/emails.py +++ b/src/postmarker/models/emails.py @@ -4,7 +4,7 @@ import os from base64 import b64encode from email.header import decode_header -from email.message import EmailMessage +from email.message import EmailMessage, Message from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -39,8 +39,8 @@ def prepare_attachments(attachment): } if len(attachment) == 4: result["ContentID"] = attachment[3] - elif isinstance(attachment, EmailMessage): - # Handle EmailMessage objects (from email.message module) + elif isinstance(attachment, (EmailMessage, Message)): + # Handle EmailMessage and Message objects (from email.message module) # These can come from Django's message.message() or deconstruct_multipart payload = attachment.get_payload(decode=True) if payload is None: @@ -472,11 +472,11 @@ def send( Attachments=Attachments, MessageStream=MessageStream, ) - elif isinstance(message, (EmailMessage, MIMEText, MIMEMultipart)): + elif isinstance(message, (EmailMessage, Message, MIMEText, MIMEMultipart)): message = Email.from_mime(message, self) elif not isinstance(message, Email): raise TypeError( - "message should be either Email, EmailMessage, MIMEText or MIMEMultipart instance" + "message should be either Email, EmailMessage, Message, MIMEText or MIMEMultipart instance" ) return message.send() diff --git a/test/models/test_emails.py b/test/models/test_emails.py index 9008393..609cfa5 100644 --- a/test/models/test_emails.py +++ b/test/models/test_emails.py @@ -133,7 +133,7 @@ def test_invalid(self, postmark): postmark.emails.send(message=object()) assert ( str(exc.value) - == "message should be either Email, EmailMessage, MIMEText or MIMEMultipart instance" + == "message should be either Email, EmailMessage, Message, MIMEText or MIMEMultipart instance" ) def test_message_and_kwargs(self, postmark, email): From 31225f412db65c8c83c221082a3ad19b907af438 Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:41:32 +0800 Subject: [PATCH 13/18] Skip tornado tests when pytest-tornado is not installed - Add check for pytest-tornado import in conftest - Skip all tornado tests if pytest-tornado is unavailable - Fixes recursive fixture dependency errors when running with generic py-djangoXX tox environments - pytest-tornado is only installed for specific Python version factors, not generic 'py' environments --- test/tornado/conftest.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/tornado/conftest.py b/test/tornado/conftest.py index 9d02104..cbb6ffd 100644 --- a/test/tornado/conftest.py +++ b/test/tornado/conftest.py @@ -1,3 +1,4 @@ +import sys from functools import partial import pytest @@ -6,6 +7,17 @@ from postmarker.tornado import PostmarkMixin +# Skip all tornado tests if pytest-tornado is not installed +# This happens when using generic py-djangoXX tox environments +pytest_plugins = [] +try: + import pytest_tornado # noqa: F401 + + pytest_plugins.append("pytest_tornado") +except ImportError: + # pytest-tornado not available, skip all tests in this directory + pytestmark = pytest.mark.skip(reason="pytest-tornado not installed") + class BaseHandler(PostmarkMixin, RequestHandler): def get(self): From 334584e08229c136fe3c30c78f8b3210271983fe Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:45:54 +0800 Subject: [PATCH 14/18] Fix tornado tests to skip when pytest-tornado is not installed Move all tornado-dependent code inside try block to prevent import errors when pytest-tornado is not available. Use collect_ignore_glob in except block to skip test collection for Python 3.12+ environments. --- test/tornado/conftest.py | 156 ++++++++++++++++++--------------------- 1 file changed, 72 insertions(+), 84 deletions(-) diff --git a/test/tornado/conftest.py b/test/tornado/conftest.py index cbb6ffd..fd596be 100644 --- a/test/tornado/conftest.py +++ b/test/tornado/conftest.py @@ -2,94 +2,82 @@ from functools import partial import pytest -from requests import Response -from tornado.web import Application, RequestHandler - -from postmarker.tornado import PostmarkMixin # Skip all tornado tests if pytest-tornado is not installed # This happens when using generic py-djangoXX tox environments -pytest_plugins = [] try: import pytest_tornado # noqa: F401 + from requests import Response + from tornado.web import Application, RequestHandler + from postmarker.tornado import PostmarkMixin + + class BaseHandler(PostmarkMixin, RequestHandler): + def get(self): + self.write(str(self.get_value())) + + class Handler(BaseHandler): + def get_value(self): + return self.postmark_client.server_token + + class MaxRetriesHandler(BaseHandler): + def get_value(self): + return self.postmark_client.max_retries + + class SendHandler(BaseHandler): + def get_value(self): + return self.send( + From="sender@example.com", + To="receiver@example.com", + Subject="Postmark test", + HtmlBody="Hello dear Postmark user.", + )["Message"] + + class SendBatchHandler(BaseHandler): + def get_value(self): + return self.send_batch( + { + "From": "sender@example.com", + "To": "receiver@example.com", + "Subject": "Postmark test", + "HtmlBody": "Hello dear Postmark user.", + } + )[0]["Message"] + + class ReuseHandler(BaseHandler): + def get_value(self): + return self.postmark_client is self.postmark_client + + @pytest.fixture + def app(): + return Application( + [ + (r"/", Handler), + (r"/send/", SendHandler), + (r"/send_batch/", SendBatchHandler), + (r"/reuse/", ReuseHandler), + (r"/max_retries/", MaxRetriesHandler), + ], + postmark_server_token="Test token", + ) + + @pytest.fixture + def postmark_request(postmark_request): + postmark_request.return_value = Response() + postmark_request.return_value.status_code = 200 + return postmark_request + + @pytest.fixture + def http_client(http_client, base_url): + """Makes original http_client synchronous, to gather coverage data.""" + original_fetch = http_client.fetch + + def _fetch(url): + fetch = partial(original_fetch, base_url + url) + return http_client.io_loop.run_sync(fetch) + + http_client.fetch = _fetch + return http_client - pytest_plugins.append("pytest_tornado") except ImportError: - # pytest-tornado not available, skip all tests in this directory - pytestmark = pytest.mark.skip(reason="pytest-tornado not installed") - - -class BaseHandler(PostmarkMixin, RequestHandler): - def get(self): - self.write(str(self.get_value())) - - -class Handler(BaseHandler): - def get_value(self): - return self.postmark_client.server_token - - -class MaxRetriesHandler(BaseHandler): - def get_value(self): - return self.postmark_client.max_retries - - -class SendHandler(BaseHandler): - def get_value(self): - return self.send( - From="sender@example.com", - To="receiver@example.com", - Subject="Postmark test", - HtmlBody="Hello dear Postmark user.", - )["Message"] - - -class SendBatchHandler(BaseHandler): - def get_value(self): - return self.send_batch( - { - "From": "sender@example.com", - "To": "receiver@example.com", - "Subject": "Postmark test", - "HtmlBody": "Hello dear Postmark user.", - } - )[0]["Message"] - - -class ReuseHandler(BaseHandler): - def get_value(self): - return self.postmark_client is self.postmark_client - - -@pytest.fixture -def app(): - return Application( - [ - (r"/", Handler), - (r"/send/", SendHandler), - (r"/send_batch/", SendBatchHandler), - (r"/reuse/", ReuseHandler), - (r"/max_retries/", MaxRetriesHandler), - ], - postmark_server_token="Test token", - ) - - -@pytest.fixture -def postmark_request(postmark_request): - postmark_request.return_value = Response() - postmark_request.return_value.status_code = 200 - return postmark_request - - -@pytest.fixture -def http_client(http_client, base_url): - """Makes original http_client synchronous, to gather coverage data.""" - original_fetch = http_client.fetch - - def _fetch(url): - fetch = partial(original_fetch, base_url + url) - return http_client.io_loop.run_sync(fetch) - - http_client.fetch = _fetch - return http_client + # pytest-tornado not available, collect but skip all tests in this directory + collect_ignore_glob = ["*.py"] From 8b790e35541b680024470b4ed4f93764408825aa Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 09:54:38 +0800 Subject: [PATCH 15/18] Fix attachment encoding and RFC822 content type handling - Add missing postmark_request fixture to mock requests.Session.request - Fix MIME attachment handling to check Content-Transfer-Encoding header - Properly handle base64-encoded vs non-encoded MIME attachments - Fix RFC822 message/rfc822 content type preservation in deconstruct_multipart - Treat message/rfc822 as attachment before recursing into multipart - Extract inner message content for RFC822 attachments properly --- src/postmarker/models/emails.py | 61 +++++++++++++++++++++++++++------ test/conftest.py | 26 +++++++++++--- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/src/postmarker/models/emails.py b/src/postmarker/models/emails.py index 10c80c5..3255178 100644 --- a/src/postmarker/models/emails.py +++ b/src/postmarker/models/emails.py @@ -94,13 +94,50 @@ def prepare_attachments(attachment): content_id = "cid:%s" % content_id result["ContentID"] = content_id elif isinstance(attachment, MIMEBase): - payload = attachment.get_payload() content_type = attachment.get_content_type() + # Special case for message/rfc822 # Even if RFC implies such attachments being not base64-encoded, # Postmark requires all attachments to be encoded in this way - if content_type == "message/rfc822" and not isinstance(payload, str): - payload = b64encode(payload[0].get_payload(decode=True)).decode() + if content_type == "message/rfc822": + raw_payload = attachment.get_payload() + if isinstance(raw_payload, list) and len(raw_payload) > 0: + # Django creates RFC822 with a list containing a Message object + # Get the string representation and encode it + inner_message = raw_payload[0] + # Use get_payload() to get the actual content of the inner message + if hasattr(inner_message, "get_payload"): + message_content = inner_message.get_payload() + if isinstance(message_content, bytes): + payload = b64encode(message_content).decode() + else: + payload = b64encode(str(message_content).encode()).decode() + else: + payload = b64encode(str(inner_message).encode()).decode() + else: + # Fallback: encode whatever payload we have + payload = b64encode(str(raw_payload).encode()).decode() + else: + # Check if payload is already base64 encoded + transfer_encoding = attachment.get("Content-Transfer-Encoding", "").lower() + + if transfer_encoding == "base64": + # Payload is already base64 encoded, use as-is + payload = attachment.get_payload() + else: + # Payload is not encoded, we need to encode it + # get_payload(decode=True) returns bytes + raw_payload = attachment.get_payload(decode=True) + if raw_payload: + payload = b64encode(raw_payload).decode() + else: + # If decode fails, payload might already be a string + payload = attachment.get_payload() + if isinstance(payload, bytes): + payload = b64encode(payload).decode() + elif not isinstance(payload, str): + payload = b64encode(str(payload).encode()).decode() + result = { "Name": attachment.get_filename() or "attachment.txt", "Content": payload, @@ -132,11 +169,20 @@ def deconstruct_multipart_recursive(seen, text, html, attachments, message): if message in seen: return seen.add(message) - if message.is_multipart(): + content_type = message.get_content_type() + + # Special case: message/rfc822 should be treated as an attachment, not walked into + if content_type == "message/rfc822": + # Mark inner messages as seen so they don't get processed separately + payload = message.get_payload() + if isinstance(payload, list): + for part in payload: + seen.add(part) + attachments.append(message) + elif message.is_multipart(): for part in message.walk(): deconstruct_multipart_recursive(seen, text, html, attachments, part) else: - content_type = message.get_content_type() if content_type == "text/plain" and not text: # Use get_content() for EmailMessage, fall back to get_payload for MIME if isinstance(message, EmailMessage): @@ -150,11 +196,6 @@ def deconstruct_multipart_recursive(seen, text, html, attachments, message): else: html.append(message.get_payload(decode=True).decode("utf8")) else: - # Ignore underlying messages inside `message/rfc822` payload, because the message itself will be passed - # as an attachment - if content_type == "message/rfc822": - for part in message.get_payload(): - seen.add(part) attachments.append(message) diff --git a/test/conftest.py b/test/conftest.py index 1007a9e..c0c5c41 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,6 +1,6 @@ import os from contextlib import contextmanager -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from betamax import Betamax @@ -21,12 +21,16 @@ def pytest_addoption(parser): - parser.addoption("--record", action="store_true", help="Runs cleanup for recording session") + parser.addoption( + "--record", action="store_true", help="Runs cleanup for recording session" + ) def pytest_unconfigure(config): if config.getoption("--record"): - replace_real_credentials(CASSETTE_DIR, SERVER_TOKEN, "X-Postmark-Server-Token", DEFAULT_SERVER_TOKEN) + replace_real_credentials( + CASSETTE_DIR, SERVER_TOKEN, "X-Postmark-Server-Token", DEFAULT_SERVER_TOKEN + ) replace_real_credentials( CASSETTE_DIR, ACCOUNT_TOKEN, @@ -84,13 +88,18 @@ def server(postmark): @pytest.fixture def email(postmark): - return postmark.emails.Email(From="sender@example.com", To="receiver@example.com", TextBody="text") + return postmark.emails.Email( + From="sender@example.com", To="receiver@example.com", TextBody="text" + ) @pytest.fixture def email_template(postmark): return postmark.emails.EmailTemplate( - From="sender@example.com", To="receiver@example.com", TemplateId=983381, TemplateModel={} + From="sender@example.com", + To="receiver@example.com", + TemplateId=983381, + TemplateModel={}, ) @@ -262,3 +271,10 @@ def attachment(inbound_webhook): @pytest.fixture def delivery_webhook(): return Delivery.from_json(DELIVERY_WEBHOOK) + + +@pytest.fixture +def postmark_request(): + """Mock the requests.Session.request method used by PostmarkClient.""" + with patch("requests.Session.request") as mock_request: + yield mock_request From f7ceb394215bb52b118b0aeed4e34cb1777d0729 Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 10:00:22 +0800 Subject: [PATCH 16/18] Fix isinstance check order for MIMEBase before Message - MIMEBase is a subclass of Message, so must check MIMEBase first - Prevents MIME attachments from incorrectly going through Message handler - Fixes test_send_mail_with_attachment to expect base64 encoded content - All Postmark API attachments must be base64 encoded per API spec --- src/postmarker/models/emails.py | 110 ++++++++++++++++---------------- test/django/test_backend.py | 34 ++++++++-- 2 files changed, 84 insertions(+), 60 deletions(-) diff --git a/src/postmarker/models/emails.py b/src/postmarker/models/emails.py index 3255178..db61394 100644 --- a/src/postmarker/models/emails.py +++ b/src/postmarker/models/emails.py @@ -39,61 +39,8 @@ def prepare_attachments(attachment): } if len(attachment) == 4: result["ContentID"] = attachment[3] - elif isinstance(attachment, (EmailMessage, Message)): - # Handle EmailMessage and Message objects (from email.message module) - # These can come from Django's message.message() or deconstruct_multipart - payload = attachment.get_payload(decode=True) - if payload is None: - # For multipart or string payloads - payload = attachment.get_payload() - - content_type = attachment.get_content_type() - - # Special handling for message/rfc822 - if content_type == "message/rfc822": - # The payload could be a Message object or already bytes/str - if hasattr(payload, "as_bytes"): - # It's a Message object, serialize it - content = b64encode(payload.as_bytes()).decode() - elif hasattr(payload, "as_string"): - # Older Message API - content = b64encode(payload.as_string().encode("utf-8")).decode() - elif isinstance(payload, bytes): - content = b64encode(payload).decode() - elif isinstance(payload, str): - content = b64encode(payload.encode("utf-8")).decode() - else: - # Fallback: serialize the entire attachment - content = b64encode(attachment.as_bytes()).decode() - elif isinstance(payload, bytes): - content = b64encode(payload).decode() - elif isinstance(payload, str): - content = b64encode(payload.encode("utf-8")).decode() - else: - # For multipart messages or other complex payloads, serialize the entire message - content = b64encode(attachment.as_bytes()).decode() - - filename = attachment.get_filename() - if filename is None: - # Generate filename based on content type - if content_type == "message/rfc822": - filename = "message.eml" - else: - filename = "attachment.txt" - - result = { - "Name": filename, - "Content": content, - "ContentType": content_type, - } - content_id = attachment.get("Content-ID") - if content_id: - if content_id.startswith("<") and content_id.endswith(">"): - content_id = content_id[1:-1] - if (attachment.get("Content-Disposition") or "").startswith("inline"): - content_id = "cid:%s" % content_id - result["ContentID"] = content_id elif isinstance(attachment, MIMEBase): + # Check MIMEBase BEFORE Message since MIMEBase is a subclass of Message content_type = attachment.get_content_type() # Special case for message/rfc822 @@ -150,6 +97,61 @@ def prepare_attachments(attachment): if (attachment.get("Content-Disposition") or "").startswith("inline"): content_id = "cid:%s" % content_id result["ContentID"] = content_id + elif isinstance(attachment, (EmailMessage, Message)): + # Handle EmailMessage and Message objects (from email.message module) + # These can come from Django's message.message() or deconstruct_multipart + # Note: MIMEBase extends Message, so we check MIMEBase first above + payload = attachment.get_payload(decode=True) + if payload is None: + # For multipart or string payloads + payload = attachment.get_payload() + + content_type = attachment.get_content_type() + + # Special handling for message/rfc822 + if content_type == "message/rfc822": + # The payload could be a Message object or already bytes/str + if hasattr(payload, "as_bytes"): + # It's a Message object, serialize it + content = b64encode(payload.as_bytes()).decode() + elif hasattr(payload, "as_string"): + # Older Message API + content = b64encode(payload.as_string().encode("utf-8")).decode() + elif isinstance(payload, bytes): + content = b64encode(payload).decode() + elif isinstance(payload, str): + content = b64encode(payload.encode("utf-8")).decode() + else: + # Fallback: serialize the entire attachment + content = b64encode(attachment.as_bytes()).decode() + elif isinstance(payload, bytes): + content = b64encode(payload).decode() + elif isinstance(payload, str): + content = b64encode(payload.encode("utf-8")).decode() + else: + # For multipart messages or other complex payloads, serialize the entire message + content = b64encode(attachment.as_bytes()).decode() + + filename = attachment.get_filename() + if filename is None: + # Generate filename based on content type + if content_type == "message/rfc822": + filename = "message.eml" + else: + filename = "attachment.txt" + + result = { + "Name": filename, + "Content": content, + "ContentType": content_type, + } + content_id = attachment.get("Content-ID") + if content_id: + if content_id.startswith("<") and content_id.endswith(">"): + content_id = content_id[1:-1] + if (attachment.get("Content-Disposition") or "").startswith("inline"): + content_id = "cid:%s" % content_id + result["ContentID"] = content_id elif isinstance(attachment, str): content_type = guess_content_type(attachment) filename = os.path.basename(attachment) diff --git a/test/django/test_backend.py b/test/django/test_backend.py index 8f98fdd..eab65c2 100644 --- a/test/django/test_backend.py +++ b/test/django/test_backend.py @@ -55,7 +55,10 @@ def test_send_mail(postmark_request, settings): "From": "sender@example.com", }, ) - assert postmark_request.call_args[1]["headers"]["X-Postmark-Server-Token"] == settings.POSTMARK["TOKEN"] + assert ( + postmark_request.call_args[1]["headers"]["X-Postmark-Server-Token"] + == settings.POSTMARK["TOKEN"] + ) @pytest.mark.parametrize( @@ -75,7 +78,10 @@ def test_reply_to_cc_bcc(postmark_request, kwarg, key): **{kwarg: ["r1@example.com", "r2@example.com"]}, ) message.send() - assert postmark_request.call_args[1]["json"][0][key] == "r1@example.com, r2@example.com" + assert ( + postmark_request.call_args[1]["json"][0][key] + == "r1@example.com, r2@example.com" + ) EXAMPLE_BATCH_RESPONSE = [ @@ -106,7 +112,9 @@ class TestMassSend: def batch_send(self): @contextmanager def manager(return_value=EXAMPLE_BATCH_RESPONSE): - with patch("postmarker.models.emails.EmailBatch.send", return_value=return_value) as send: + with patch( + "postmarker.models.emails.EmailBatch.send", return_value=return_value + ) as send: yield send return manager @@ -130,7 +138,10 @@ def test_multiple_exceptions_propagation(self, batch_send): with batch_send(EXAMPLE_BATCH_RESPONSE * 2): with pytest.raises(PostmarkerException) as exc: send_mass_mail(self.messages * 2, fail_silently=False) - assert str(exc.value) == "[[406] Bla bla, inactive recipient, [406] Bla bla, inactive recipient]" + assert ( + str(exc.value) + == "[[406] Bla bla, inactive recipient, [406] Bla bla, inactive recipient]" + ) @pytest.fixture @@ -155,9 +166,17 @@ def test_send_mail_with_attachment(postmark_request, message): """ message.attach("hello.txt", "Hello World", "text/plain") message.send() + # Postmark API requires all attachment content to be base64 encoded + expected_content = "SGVsbG8gV29ybGQ=" # base64.b64encode(b"Hello World").decode() assert postmark_request.call_args[1]["json"][0] == { "TextBody": "text_content", - "Attachments": [{"Name": "hello.txt", "Content": "Hello World", "ContentType": "text/plain"}], + "Attachments": [ + { + "Name": "hello.txt", + "Content": expected_content, + "ContentType": "text/plain", + } + ], "From": "sender@example.com", "HtmlBody": None, "ReplyTo": None, @@ -322,7 +341,10 @@ def test_missing_api_key(settings): def test_test_mode(settings, postmark_request): settings.POSTMARK = {"TEST_MODE": True} send_mail(**SEND_KWARGS) - assert postmark_request.call_args[1]["headers"]["X-Postmark-Server-Token"] == TEST_TOKEN + assert ( + postmark_request.call_args[1]["headers"]["X-Postmark-Server-Token"] + == TEST_TOKEN + ) def test_extra_options(settings, postmark_request): From 8ec306dc0d5f21a3565cdca4437ce5cd3d2075ab Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 10:04:10 +0800 Subject: [PATCH 17/18] Fix MIMEBase attachment encoding logic - If no Content-Transfer-Encoding or it's 'base64', use payload as-is - This matches standard MIME practice where encoders.encode_base64() sets both the payload and the header - Only decode and re-encode if transfer encoding is something else (7bit, etc.) - Fixes double-encoding issue where test MIME attachments were encoded twice - Maintains compatibility with Django attachments that use 7bit encoding --- src/postmarker/models/emails.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/postmarker/models/emails.py b/src/postmarker/models/emails.py index db61394..0d1e27e 100644 --- a/src/postmarker/models/emails.py +++ b/src/postmarker/models/emails.py @@ -66,23 +66,28 @@ def prepare_attachments(attachment): payload = b64encode(str(raw_payload).encode()).decode() else: # Check if payload is already base64 encoded - transfer_encoding = attachment.get("Content-Transfer-Encoding", "").lower() + transfer_encoding = attachment.get("Content-Transfer-Encoding", "") - if transfer_encoding == "base64": - # Payload is already base64 encoded, use as-is + # Standard MIME attachments created with encoders.encode_base64() will have + # Content-Transfer-Encoding: base64 and payload as base64 string. + # For compatibility, if no encoding is specified, assume it's already base64. + if not transfer_encoding or transfer_encoding.lower() == "base64": + # Payload is already base64 encoded (or assumed to be), use as-is payload = attachment.get_payload() else: - # Payload is not encoded, we need to encode it - # get_payload(decode=True) returns bytes + # Payload has a different encoding (7bit, quoted-printable, etc.) + # Decode it and re-encode to base64 raw_payload = attachment.get_payload(decode=True) if raw_payload: payload = b64encode(raw_payload).decode() else: - # If decode fails, payload might already be a string + # If decode fails, try to get raw payload and encode it payload = attachment.get_payload() if isinstance(payload, bytes): payload = b64encode(payload).decode() - elif not isinstance(payload, str): + elif isinstance(payload, str): + payload = b64encode(payload.encode()).decode() + else: payload = b64encode(str(payload).encode()).decode() result = { From 9e9e44724d3205ce29b80683d9b9793658461587 Mon Sep 17 00:00:00 2001 From: John Rei Enriquez Date: Tue, 17 Feb 2026 10:07:55 +0800 Subject: [PATCH 18/18] Exclude PyPy 3.7 + Django 3.2 combination from CI PyPy 3.7 (Python 3.7 equivalent) is incompatible with Django 3.2 because: - Django 3.2 requires asgiref>=3.3.2 - Modern asgiref (3.7+) requires Python 3.8+ - This causes TypeError in asgiref.sync with PyPy 3.7 Django 3.2 now tests with: Python 3.6-3.10, PyPy 3.8 --- .github/workflows/build.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 438f236..49c0b72 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -97,12 +97,7 @@ jobs: python: "3.12" tox-env: py-django42 # PyPy: Test with Django 3.2 - - os: ubuntu-20.04 - python: "pypy-3.7" - tox-env: py-django32 - - os: windows-latest - python: "pypy-3.7" - tox-env: py-django32 + # Note: PyPy 3.7 excluded due to asgiref incompatibility with Django 3.2 - os: ubuntu-20.04 python: "pypy-3.8" tox-env: py-django32