diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..0020422 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Report something that isn't working as expected +title: "" +labels: bug +assignees: "" +--- + +**Describe the bug** +A clear description of what's wrong. + +**To reproduce** +Minimal code sample that reproduces the issue: + +```python + +``` + +**Expected behavior** +What you expected to happen instead. + +**Environment** +- `postmark-python` version: +- Python version: +- OS: + +**Additional context** +Anything else relevant (stack trace, `X-Request-Id` from an exception, etc.). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..291dbc0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/ActiveCampaign/postmark-python/security/policy + about: Please report security vulnerabilities privately per SECURITY.md, not as a public issue. + - name: Postmark API / account support + url: mailto:support@postmarkapp.com + about: For questions about the Postmark service itself, rather than this SDK. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b36749c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an addition or improvement to this SDK +title: "" +labels: enhancement +assignees: "" +--- + +**What are you trying to do?** +Describe the use case this would unlock. + +**Proposed solution** +What you'd like the SDK to support, e.g. a new method, client, or option. + +**Alternatives considered** +Any workarounds you're using today, or other approaches you considered. + +**Additional context** +Links to relevant [Postmark API docs](https://postmarkapp.com/developer), if applicable. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a1015c1..5fdb5cd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,6 +46,42 @@ jobs: run: | poetry run pytest --cov=postmark --cov-report=xml --cov-report=term --cov-fail-under=85 + django-tests: + runs-on: ubuntu-latest + strategy: + matrix: + # Currently supported Django release series. The main `test` job above + # already exercises tests/django_backend/ against the pinned dev version + # (Django 5.2) across every supported Python version; this job additionally + # checks the Django backend against the rest of the support matrix. + django-version: ["4.2", "5.2", "6.0", "6.1"] + + steps: + - uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + # 3.12 is the one Python version every entry in the matrix supports: + # Django 4.2 added 3.12 support in 4.2.8; Django 6.0/6.1 require 3.12+. + python-version: "3.12" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: 2.4.1 + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install dependencies + run: poetry install --no-interaction + + - name: Install Django ${{ matrix.django-version }} + run: poetry run pip install "django~=${{ matrix.django-version }}.0" + + - name: Run Django backend tests + run: poetry run pytest tests/django_backend/ -v + lint: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 3794e6d..e797c39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/). --- +## [Unreleased] + +### Added + +- Django email backend (`postmark.django.EmailBackend`), gated behind the new `django` extra (`pip install postmark-python[django]`). Supports Django 4.2 LTS, 5.2 LTS, 6.0, and 6.1 — including Django 6.0's ["modern email API" change](https://docs.djangoproject.com/en/6.0/releases/6.0/#adoption-of-python-s-modern-email-api). The Postmark payload is built from `EmailMessage`'s high-level attributes rather than `EmailMessage.message()`, so this backend is unaffected by that change. + - `postmark.django.PostmarkEmailMessage` / `PostmarkEmailMultiAlternatives` / `PostmarkEmailMixin` for setting `tag`, `metadata`, and `message_stream`. + - `postmark.django.pre_send` / `post_send` / `on_exception` signals. + - New settings: `POSTMARK_SERVER_TOKEN`, `POSTMARK_TEST_MODE`, `POSTMARK_TRACK_OPENS`, `POSTMARK_MESSAGE_STREAM`. + - See the [Django Backend wiki page](https://github.com/ActiveCampaign/postmark-python/wiki/Django-Backend) and `examples/django/`. + +--- + ## [0.3.7] - 2026-08-05 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7ca9745 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +Thanks for considering a contribution to `postmark-python`. + +## Getting started + +```bash +git clone https://github.com/ActiveCampaign/postmark-python.git +cd postmark-python +poetry install +poetry run pre-commit install +``` + +## Making a change + +1. Fork the repository and create a feature branch off `main`. +2. Make your change, keeping it scoped to a single concern. +3. Add or update tests under `tests/` — the suite follows a one-file-per-feature layout (e.g. `test_templates.py`, `test_bounces.py`) that mirrors `postmark/models/`. +4. Run the full check suite locally before opening a PR: + + ```bash + poetry run pytest + poetry run ruff check + poetry run ruff format --check + poetry run mypy postmark/ + poetry run pre-commit run --all-files + ``` + + CI enforces a minimum test coverage of 85% (`--cov-fail-under=85`), so new code needs tests to match. +5. Update `CHANGELOG.md` under an `Unreleased` heading (Keep a Changelog format). +6. Open a pull request describing the change and why it's needed. + +## Reporting bugs and requesting features + +Please open a [GitHub issue](https://github.com/ActiveCampaign/postmark-python/issues) using the appropriate template. For security vulnerabilities, follow the process in [SECURITY.md](SECURITY.md) instead of filing a public issue. + +## Code style + +- Formatting and linting are enforced by `ruff` (see `[tool.ruff]` in `pyproject.toml`). +- Type hints are required; `mypy postmark/` must pass cleanly. +- Request/response schemas use Pydantic v2 models under `postmark/models//schemas.py`. diff --git a/README.md b/README.md index 1e842f3..be80ea3 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,22 @@ client = postmark.ServerClient(os.environ["POSTMARK_SERVER_TOKEN"]) await client.close() ``` +## Django + +`postmark.django.EmailBackend` is a drop-in `EMAIL_BACKEND` for Django's `django.core.mail`, supporting Django 4.2 LTS through 6.1. Requires the `django` extra: + +```bash +pip install postmark-python[django] +``` + +```python +# settings.py +EMAIL_BACKEND = "postmark.django.EmailBackend" +POSTMARK_SERVER_TOKEN = "your-server-token" +``` + +See the [Django Backend wiki page](https://github.com/ActiveCampaign/postmark-python/wiki/Django-Backend) and [`examples/django/`](examples/django/) for tags, metadata, attachments, and signals. + ## Development ```bash @@ -124,11 +140,7 @@ poetry run pre-commit run --all-files ## Contributing -1. Fork the repository -2. Create a feature branch -3. Add tests for your changes -4. Ensure all checks pass (`poetry run pre-commit run --all-files`) -5. Open a Pull Request +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and PR guidelines. ## Support diff --git a/examples/django/send_batch.py b/examples/django/send_batch.py new file mode 100644 index 0000000..3dd3557 --- /dev/null +++ b/examples/django/send_batch.py @@ -0,0 +1,42 @@ +""" +Send several distinct messages in one call. django.core.mail.send_mass_mail +takes tuples of (subject, message, from_email, recipient_list) and sends them +through a single connection — the Django backend batches them into Postmark's +send_batch API (up to 500 per request) rather than one request per message. + +Run: + poetry run python examples/django/send_batch.py + python examples/django/send_batch.py # with venv active +""" + +import os + +import django +from django.conf import settings + +try: + from dotenv import load_dotenv + + load_dotenv() +except ImportError: + pass + +if not settings.configured: + settings.configure( + EMAIL_BACKEND="postmark.django.EmailBackend", + POSTMARK_SERVER_TOKEN=os.environ["POSTMARK_SERVER_TOKEN"], + ) + django.setup() + +from django.core.mail import send_mass_mail # noqa: E402 + +SENDER = os.environ["POSTMARK_SENDER_EMAIL"] + +sent_count = send_mass_mail( + ( + ("Batch 1", "Hello Receiver 1", SENDER, ["receiver1@example.com"]), + ("Batch 2", "Hello Receiver 2", SENDER, ["receiver2@example.com"]), + ) +) + +print(f"Sent: {sent_count}") diff --git a/examples/django/send_simple.py b/examples/django/send_simple.py new file mode 100644 index 0000000..a407d0f --- /dev/null +++ b/examples/django/send_simple.py @@ -0,0 +1,44 @@ +""" +Send a single email through Postmark's Django backend. + +Standalone script, not a full Django project — real projects configure +settings.py once (see settings_snippet.py) and just call +django.core.mail.send_mail(...) anywhere. + +Run: + poetry run python examples/django/send_simple.py + python examples/django/send_simple.py # with venv active +""" + +import os + +import django +from django.conf import settings + +try: + from dotenv import load_dotenv + + load_dotenv() +except ImportError: + pass + +if not settings.configured: + settings.configure( + EMAIL_BACKEND="postmark.django.EmailBackend", + POSTMARK_SERVER_TOKEN=os.environ["POSTMARK_SERVER_TOKEN"], + ) + django.setup() + +from django.core.mail import send_mail # noqa: E402 + +SENDER = os.environ["POSTMARK_SENDER_EMAIL"] + +send_mail( + subject="Hello from Postmark", + message="Sent with postmark.django.EmailBackend.", + from_email=SENDER, + recipient_list=["receiver@example.com"], + html_message="

Sent with postmark.django.EmailBackend.

", +) + +print("Sent.") diff --git a/examples/django/send_simple_with_attachment.py b/examples/django/send_simple_with_attachment.py new file mode 100644 index 0000000..35191fb --- /dev/null +++ b/examples/django/send_simple_with_attachment.py @@ -0,0 +1,49 @@ +""" +Send an email with an attachment through Postmark's Django backend. + +Attachment content is base64-encoded automatically — pass the raw +bytes/str you'd normally give EmailMessage.attach(), same as any other +Django email backend. + +Run: + poetry run python examples/django/send_simple_with_attachment.py + python examples/django/send_simple_with_attachment.py # with venv active +""" + +import os + +import django +from django.conf import settings + +try: + from dotenv import load_dotenv + + load_dotenv() +except ImportError: + pass + +if not settings.configured: + settings.configure( + EMAIL_BACKEND="postmark.django.EmailBackend", + POSTMARK_SERVER_TOKEN=os.environ["POSTMARK_SERVER_TOKEN"], + ) + django.setup() + +from django.core.mail import EmailMessage # noqa: E402 + +SENDER = os.environ["POSTMARK_SENDER_EMAIL"] + +message = EmailMessage( + subject="Your report and resources", + body="Please find your report attached.", + from_email=SENDER, + to=["receiver@example.com"], +) +message.attach("report.txt", "Q3 sales are up 12%.", "text/plain") + +with open("/path/to/book.pdf", "rb") as f: + message.attach("book.pdf", f.read(), "application/pdf") + +message.send() + +print("Sent.") diff --git a/examples/django/send_simple_with_custom_header.py b/examples/django/send_simple_with_custom_header.py new file mode 100644 index 0000000..ce38767 --- /dev/null +++ b/examples/django/send_simple_with_custom_header.py @@ -0,0 +1,54 @@ +""" +Send an email with custom headers through Postmark's Django backend. + +Custom headers are useful for: +- Threading replies (References, In-Reply-To) +- Passing internal tracking or correlation IDs +- Setting message priority +- Integrating with third-party systems that inspect headers + +Django's extra_headers dict maps directly onto Postmark's Headers field. + +Run: + poetry run python examples/django/send_simple_with_custom_header.py + python examples/django/send_simple_with_custom_header.py # with venv active +""" + +import os + +import django +from django.conf import settings + +try: + from dotenv import load_dotenv + + load_dotenv() +except ImportError: + pass + +if not settings.configured: + settings.configure( + EMAIL_BACKEND="postmark.django.EmailBackend", + POSTMARK_SERVER_TOKEN=os.environ["POSTMARK_SERVER_TOKEN"], + ) + django.setup() + +from django.core.mail import EmailMessage # noqa: E402 + +SENDER = os.environ["POSTMARK_SENDER_EMAIL"] + +message = EmailMessage( + subject="Invoice #1042", + body="Please find your invoice details below.", + from_email=SENDER, + to=["receiver@example.com"], + headers={ + "X-Correlation-ID": "order-1042-usr-9981", + "X-Priority": "1", + "References": "", + "In-Reply-To": "", + }, +) +message.send() + +print("Sent.") diff --git a/examples/django/send_with_external_image.py b/examples/django/send_with_external_image.py new file mode 100644 index 0000000..136e19b --- /dev/null +++ b/examples/django/send_with_external_image.py @@ -0,0 +1,62 @@ +""" +Send HTML email referencing an external image URL (e.g. a tracking pixel or a +logo hosted on your own server) — loaded by the recipient's email client at +open time, no attachment needed. + +Note: inline (Content-ID / cid:) images are NOT supported through this Django +backend, since Django only exposes that via passing a raw email.mime.base.MIMEBase +object to EmailMessage.attach() — a legacy path this backend intentionally +doesn't support (see the Django-Backend wiki page). For inline images, either +host the image externally as shown here, or send directly with +postmark.ServerClient / postmark.sync.ServerClient using an Attachment with +content_id set (see examples/async/outbound_messages/send_with_inline_and_external_images.py). + +Run: + poetry run python examples/django/send_with_external_image.py + python examples/django/send_with_external_image.py # with venv active +""" + +import os + +import django +from django.conf import settings + +try: + from dotenv import load_dotenv + + load_dotenv() +except ImportError: + pass + +if not settings.configured: + settings.configure( + EMAIL_BACKEND="postmark.django.EmailBackend", + POSTMARK_SERVER_TOKEN=os.environ["POSTMARK_SERVER_TOKEN"], + ) + django.setup() + +from django.core.mail import EmailMultiAlternatives # noqa: E402 + +SENDER = os.environ["POSTMARK_SENDER_EMAIL"] + +# Fetched by the recipient's email client at open time, so the server can +# record the open event. +TRACKING_PIXEL_URL = "https://track.example.com/pixel.png" + +html_body = f""" + +

Hello! Thanks for reading.

+ + +""" + +message = EmailMultiAlternatives( + subject="Hello — with tracking image", + body="Hello! Thanks for reading. (Open the HTML version to see the image.)", + from_email=SENDER, + to=["receiver@example.com"], +) +message.attach_alternative(html_body, "text/html") +message.send() + +print("Sent.") diff --git a/examples/django/send_with_tags_and_metadata.py b/examples/django/send_with_tags_and_metadata.py new file mode 100644 index 0000000..792fed2 --- /dev/null +++ b/examples/django/send_with_tags_and_metadata.py @@ -0,0 +1,45 @@ +""" +Tag, metadata, and message stream require PostmarkEmailMessage / +PostmarkEmailMultiAlternatives instead of Django's plain EmailMessage, since +those are Postmark-specific fields with no Django equivalent. + +Run: + poetry run python examples/django/send_with_tags_and_metadata.py + python examples/django/send_with_tags_and_metadata.py # with venv active +""" + +import os + +import django +from django.conf import settings + +try: + from dotenv import load_dotenv + + load_dotenv() +except ImportError: + pass + +if not settings.configured: + settings.configure( + EMAIL_BACKEND="postmark.django.EmailBackend", + POSTMARK_SERVER_TOKEN=os.environ["POSTMARK_SERVER_TOKEN"], + ) + django.setup() + +from postmark.django import PostmarkEmailMessage # noqa: E402 + +SENDER = os.environ["POSTMARK_SENDER_EMAIL"] + +message = PostmarkEmailMessage( + subject="Your invitation", + body="You're invited!", + from_email=SENDER, + to=["receiver@example.com"], + tag="invitation", + metadata={"user_id": "12345"}, + message_stream="outbound", +) +message.send() + +print("Sent.") diff --git a/examples/django/settings_snippet.py b/examples/django/settings_snippet.py new file mode 100644 index 0000000..af03f29 --- /dev/null +++ b/examples/django/settings_snippet.py @@ -0,0 +1,20 @@ +""" +Add this to your Django project's settings.py to send mail through Postmark. + +Requires the `django` extra: pip install postmark-python[django] +""" + +EMAIL_BACKEND = "postmark.django.EmailBackend" + +POSTMARK_SERVER_TOKEN = "" + +# Optional settings (all default to off/unset): +POSTMARK_TEST_MODE = ( + False # When True, sends with Postmark's POSTMARK_API_TEST token instead +) +POSTMARK_TRACK_OPENS = ( + False # Default TrackOpens for every message, unless overridden per-message +) +POSTMARK_MESSAGE_STREAM = ( + None # e.g. "broadcasts" — default message stream, unless overridden per-message +) diff --git a/poetry.lock b/poetry.lock index 8e32a97..f06f904 100644 --- a/poetry.lock +++ b/poetry.lock @@ -32,6 +32,26 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.32.0)"] +[[package]] +name = "asgiref" +version = "3.12.1" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094"}, + {file = "asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340"}, +] +markers = {main = "extra == \"django\""} + +[package.dependencies] +typing_extensions = {version = ">=4", markers = "python_version < \"3.11\""} + +[package.extras] +mypy = ["mypy (>=1.14.0)"] +tests = ["pytest", "pytest-asyncio"] + [[package]] name = "ast-serialize" version = "0.6.0" @@ -260,6 +280,28 @@ files = [ {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, ] +[[package]] +name = "django" +version = "5.2.17" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db"}, + {file = "django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f"}, +] +markers = {main = "extra == \"django\""} + +[package.dependencies] +asgiref = ">=3.8.1" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + [[package]] name = "dnspython" version = "2.8.0" @@ -1102,6 +1144,23 @@ files = [ {file = "ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982"}, ] +[[package]] +name = "sqlparse" +version = "0.5.5" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba"}, + {file = "sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e"}, +] +markers = {main = "extra == \"django\""} + +[package.extras] +dev = ["build"] +doc = ["sphinx"] + [[package]] name = "tenacity" version = "8.5.0" @@ -1203,6 +1262,19 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2026.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main", "dev"] +files = [ + {file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"}, + {file = "tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415"}, +] +markers = {main = "extra == \"django\" and sys_platform == \"win32\"", dev = "sys_platform == \"win32\""} + [[package]] name = "virtualenv" version = "21.2.3" @@ -1222,7 +1294,10 @@ platformdirs = ">=3.9.1,<5" python-discovery = ">=1.2.2" typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} +[extras] +django = ["django"] + [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "49018c992ad7299eda309c53c63a01f861fc25ffc6cfd5081b0e4ec1bd870da9" +content-hash = "e4cd399941932af9503476b15c25292eda4a60aff2994681b7abce5d21cf6c04" diff --git a/postmark/django/__init__.py b/postmark/django/__init__.py new file mode 100644 index 0000000..307ecdf --- /dev/null +++ b/postmark/django/__init__.py @@ -0,0 +1,19 @@ +"""Django integration for Postmark. Requires the ``django`` extra: pip install postmark-python[django]""" + +from .backend import EmailBackend +from .mixins import ( + PostmarkEmailMessage, + PostmarkEmailMixin, + PostmarkEmailMultiAlternatives, +) +from .signals import on_exception, post_send, pre_send + +__all__ = [ + "EmailBackend", + "PostmarkEmailMessage", + "PostmarkEmailMixin", + "PostmarkEmailMultiAlternatives", + "pre_send", + "post_send", + "on_exception", +] diff --git a/postmark/django/backend.py b/postmark/django/backend.py new file mode 100644 index 0000000..3fbb800 --- /dev/null +++ b/postmark/django/backend.py @@ -0,0 +1,172 @@ +""" +Django email backend for Postmark. + + EMAIL_BACKEND = "postmark.django.EmailBackend" + POSTMARK_SERVER_TOKEN = "..." + +Builds the Postmark payload from Django's high-level EmailMessage / +EmailMultiAlternatives attributes (to, cc, bcc, subject, body, alternatives, +attachments, extra_headers) rather than from EmailMessage.message(). Django 6.0 +changed message() to return a Python email.message.EmailMessage instead of the +legacy SafeMIMEText/SafeMIMEMultipart classes, which broke third-party backends +that introspected that MIME object directly — this backend never does that, so +it isn't affected. +""" + +import base64 +import logging + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.core.mail.backends.base import BaseEmailBackend + +from postmark.exceptions import PostmarkAPIException, get_exception_class +from postmark.models.outbound.schemas import Email +from postmark.sync import ServerClient as SyncServerClient + +from .signals import on_exception, post_send, pre_send + +logger = logging.getLogger(__name__) + +# Postmark's own publicly documented token for validating requests without +# delivering mail — not a secret. https://postmarkapp.com/developer/api/overview +TEST_SERVER_TOKEN = "POSTMARK_API_TEST" # nosec B105 + +_BATCH_LIMIT = 500 + + +class EmailBackend(BaseEmailBackend): + """Sends Django email through the Postmark API.""" + + def __init__(self, server_token=None, fail_silently=False, **kwargs): + super().__init__(fail_silently=fail_silently) + self.server_token = server_token or getattr( + settings, "POSTMARK_SERVER_TOKEN", None + ) + if not self.server_token: + raise ImproperlyConfigured( + "Set POSTMARK_SERVER_TOKEN in settings, or pass " + "server_token= when constructing the Postmark email backend." + ) + self.test_mode = getattr(settings, "POSTMARK_TEST_MODE", False) + self.default_track_opens = getattr(settings, "POSTMARK_TRACK_OPENS", None) + self.default_message_stream = getattr(settings, "POSTMARK_MESSAGE_STREAM", None) + self._client_kwargs = kwargs + self.client = None + + def open(self) -> bool: + """Create the underlying client if one doesn't exist. Returns True if created.""" + if self.client is not None: + return False + token = TEST_SERVER_TOKEN if self.test_mode else self.server_token + self.client = SyncServerClient(token, **self._client_kwargs) + return True + + def close(self) -> None: + if self.client is None: + return + try: + self.client.close() + finally: + self.client = None + + def send_messages(self, email_messages) -> int: + if not email_messages: + return 0 + + emails = [self._build_email(message) for message in email_messages] + + sent_count = 0 + try: + client_created = self.open() + for start in range(0, len(emails), _BATCH_LIMIT): + chunk = emails[start : start + _BATCH_LIMIT] + pre_send.send_robust(self.__class__, messages=chunk) + responses = self.client.outbound.send_batch(chunk) + post_send.send_robust( + self.__class__, messages=chunk, response=responses + ) + failures = [r for r in responses if not r.success] + sent_count += len(responses) - len(failures) + if failures: + self._raise_for_failures(failures) + if client_created: + self.close() + except Exception as exc: + on_exception.send_robust( + self.__class__, raw_messages=email_messages, exception=exc + ) + if not self.fail_silently: + raise + return sent_count + + @staticmethod + def _raise_for_failures(failures) -> None: + """Raise a typed exception for one or more failed items in a send_batch response.""" + if len(failures) == 1: + response = failures[0] + exception_class = get_exception_class(response.error_code, 200) + raise exception_class(response.message, response.error_code, 200) + summary = "; ".join(f"[{r.error_code}] {r.message}" for r in failures) + raise PostmarkAPIException(summary, failures[0].error_code, 200) + + def _build_email(self, message) -> Email: + """Convert a Django EmailMessage/EmailMultiAlternatives into a postmark.Email.""" + html_body = None + for content, mimetype in getattr(message, "alternatives", []): + if mimetype == "text/html": + html_body = content + else: + logger.warning( + "postmark.django: dropping unsupported alternative content " + "type %r (Postmark's Email API supports a single HTML body)", + mimetype, + ) + + track_opens = getattr(message, "track_opens", None) + if track_opens is None: + track_opens = self.default_track_opens + + message_stream = getattr(message, "message_stream", None) + if message_stream is None: + message_stream = self.default_message_stream + + return Email.model_validate( + { + "sender": message.from_email, + "to": ", ".join(message.to), + "cc": ", ".join(message.cc) or None, + "bcc": ", ".join(message.bcc) or None, + "reply_to": ", ".join(message.reply_to) or None, + "subject": message.subject, + "text_body": message.body, + "html_body": html_body, + "headers": [ + {"name": name, "value": value} + for name, value in message.extra_headers.items() + ], + "attachments": [ + self._build_attachment(attachment) + for attachment in message.attachments + ], + "tag": getattr(message, "tag", None), + "metadata": getattr(message, "metadata", None) or {}, + "message_stream": message_stream, + "track_opens": track_opens, + } + ) + + @staticmethod + def _build_attachment(attachment) -> dict[str, str]: + if not isinstance(attachment, tuple): + raise TypeError( + "postmark.django does not support legacy MIMEBase attachments; " + "use EmailMessage.attach(filename, content, mimetype) instead." + ) + filename, content, mimetype = attachment + content_bytes = content.encode("utf-8") if isinstance(content, str) else content + return { + "name": filename or "", + "content": base64.b64encode(content_bytes).decode("ascii"), + "content_type": mimetype or "application/octet-stream", + } diff --git a/postmark/django/mixins.py b/postmark/django/mixins.py new file mode 100644 index 0000000..22961f3 --- /dev/null +++ b/postmark/django/mixins.py @@ -0,0 +1,21 @@ +"""Django email classes that carry Postmark-specific fields (tag, metadata, message stream).""" + +from django.core.mail import EmailMessage, EmailMultiAlternatives + + +class PostmarkEmailMixin: + """Adds Postmark's tag/metadata/message_stream fields to a Django email class.""" + + def __init__(self, *args, tag=None, metadata=None, message_stream=None, **kwargs): + self.tag = tag + self.metadata = metadata + self.message_stream = message_stream + super().__init__(*args, **kwargs) + + +class PostmarkEmailMessage(PostmarkEmailMixin, EmailMessage): + pass + + +class PostmarkEmailMultiAlternatives(PostmarkEmailMixin, EmailMultiAlternatives): + pass diff --git a/postmark/django/signals.py b/postmark/django/signals.py new file mode 100644 index 0000000..3016db8 --- /dev/null +++ b/postmark/django/signals.py @@ -0,0 +1,12 @@ +"""Signals dispatched by postmark.django.EmailBackend.""" + +from django.dispatch import Signal + +#: Sent just before a batch is submitted to Postmark. kwargs: messages (list[postmark.Email]). +pre_send = Signal() + +#: Sent just after a batch is submitted successfully. kwargs: messages, response (list[postmark.SendResponse]). +post_send = Signal() + +#: Sent when send_messages() raises. kwargs: raw_messages (the original Django messages), exception. +on_exception = Signal() diff --git a/pyproject.toml b/pyproject.toml index 4676cae..f3a03cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "The Official Postmark Python SDK." authors = [ { name = "Greg Svoboda", email = "gsvoboda@activecampaign.com" }, ] +keywords = ["postmark", "email", "transactional-email", "sdk", "api-client"] license = "MIT" license-files = ["LICENSE"] readme = "README.md" @@ -29,6 +30,9 @@ dependencies = [ "tenacity>=8.2.3,<9.0.0", # resilience and retry logic ] +[project.optional-dependencies] +django = ["django>=4.2,<6.2"] + [project.urls] Homepage = "https://postmarkapp.com/developer/integration/official-libraries" Repository = "https://github.com/ActiveCampaign/postmark-python" @@ -48,6 +52,7 @@ mypy = "^2.3.0" ruff = "^0.16.0" pre-commit = "^4.6.1" python-dotenv = "^1.2.2" +django = "^5.2" # For running tests/django/ locally; CI matrix also covers 4.2, 6.0, 6.1 [tool.ruff] line-length = 88 @@ -66,7 +71,7 @@ exclude = ["^examples/"] [tool.bandit] skips = ["B101"] # assert statements are fine in tests -exclude_dirs = ["tests"] +exclude_dirs = ["tests", "examples"] # example snippets use placeholder tokens, not real secrets [tool.coverage.report] omit = ["*/enums.py"] diff --git a/tests/conftest.py b/tests/conftest.py index f09d157..c1cdd69 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,13 @@ from unittest.mock import AsyncMock, Mock import pytest + +try: + import django # noqa: F401 +except ImportError: + # The `django` extra is optional; don't let its absence break collection + # of the rest of the suite. + collect_ignore = ["django_backend"] from httpx import Response from postmark.models.bounces import BounceManager diff --git a/tests/django_backend/__init__.py b/tests/django_backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/django_backend/conftest.py b/tests/django_backend/conftest.py new file mode 100644 index 0000000..72edc50 --- /dev/null +++ b/tests/django_backend/conftest.py @@ -0,0 +1,78 @@ +"""Shared fixtures for the Django backend test suite.""" + +from datetime import datetime + +import django +import pytest +from django.conf import settings + +if not settings.configured: + settings.configure( + USE_TZ=True, + DEFAULT_CHARSET="utf-8", + EMAIL_BACKEND="postmark.django.EmailBackend", + POSTMARK_SERVER_TOKEN="test-token", + ) + django.setup() + +import postmark.django.backend as backend_module # noqa: E402 +from postmark import SendResponse # noqa: E402 + + +def _default_response(index: int, to: str) -> SendResponse: + return SendResponse( + To=to, + SubmittedAt=datetime(2024, 1, 1), + MessageID=f"id-{index}", + ErrorCode=0, + Message="OK", + ) + + +class FakeOutbound: + """Stands in for postmark.sync.ServerClient(...).outbound.""" + + def __init__(self): + self.calls: list[list] = [] + self.responses_queue: list[list[SendResponse]] = [] + + def send_batch(self, messages): + self.calls.append(messages) + if self.responses_queue: + return self.responses_queue.pop(0) + return [_default_response(i, m.to) for i, m in enumerate(messages)] + + +class FakeSyncClient: + """Stands in for postmark.sync.ServerClient.""" + + def __init__(self): + self.closed = False + self.outbound = FakeOutbound() + + def close(self): + self.closed = True + + +class RecordingFactory: + """Replaces backend_module.SyncServerClient; records constructor args.""" + + def __init__(self, instance: FakeSyncClient): + self.instance = instance + self.calls: list[tuple] = [] + + def __call__(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return self.instance + + +@pytest.fixture +def fake_sync_client(): + return FakeSyncClient() + + +@pytest.fixture +def sync_client_factory(monkeypatch, fake_sync_client): + factory = RecordingFactory(fake_sync_client) + monkeypatch.setattr(backend_module, "SyncServerClient", factory) + return factory diff --git a/tests/django_backend/test_backend.py b/tests/django_backend/test_backend.py new file mode 100644 index 0000000..2831f85 --- /dev/null +++ b/tests/django_backend/test_backend.py @@ -0,0 +1,413 @@ +"""Tests for postmark.django.EmailBackend.""" + +from email.mime.text import MIMEText + +import pytest +from django.core import mail +from django.core.exceptions import ImproperlyConfigured +from django.core.mail import EmailMessage, EmailMultiAlternatives, send_mail +from django.test import override_settings + +from postmark.django.backend import TEST_SERVER_TOKEN, EmailBackend +from postmark.django.mixins import PostmarkEmailMessage +from postmark.django.signals import on_exception, post_send, pre_send +from postmark.exceptions import PostmarkAPIException, ValidationException +from postmark.models.outbound.schemas import SendResponse + + +def test_send_mail_basic(sync_client_factory, fake_sync_client): + send_mail( + "Subject here", + "Here is the message.", + "sender@example.com", + ["receiver@example.com"], + ) + + calls = fake_sync_client.outbound.calls + assert len(calls) == 1 + [email] = calls[0] + assert email.sender == "sender@example.com" + assert email.to == "receiver@example.com" + assert email.subject == "Subject here" + assert email.text_body == "Here is the message." + assert email.cc is None + assert email.bcc is None + + +def test_cc_bcc_reply_to_together(sync_client_factory, fake_sync_client): + message = EmailMessage( + "Subject", + "Body", + "sender@example.com", + ["receiver@example.com"], + cc=["cc@example.com"], + bcc=["bcc@example.com"], + reply_to=["reply@example.com"], + ) + message.send() + + [email] = fake_sync_client.outbound.calls[0] + assert email.cc == "cc@example.com" + assert email.bcc == "bcc@example.com" + assert email.reply_to == "reply@example.com" + + +@pytest.mark.parametrize( + "field,addresses", + [ + ("cc", ["cc@example.com"]), + ("cc", ["cc1@example.com", "cc2@example.com"]), + ("bcc", ["bcc@example.com"]), + ("bcc", ["bcc1@example.com", "bcc2@example.com"]), + ("reply_to", ["reply@example.com"]), + ("reply_to", ["reply1@example.com", "reply2@example.com"]), + ], +) +def test_recipient_fields_are_comma_joined( + sync_client_factory, fake_sync_client, field, addresses +): + message = EmailMessage( + "Subject", + "Body", + "sender@example.com", + ["receiver@example.com"], + **{field: addresses}, + ) + message.send() + + [email] = fake_sync_client.outbound.calls[0] + assert getattr(email, field) == ", ".join(addresses) + + +@pytest.mark.parametrize("message_class", [EmailMessage, EmailMultiAlternatives]) +def test_basic_send_works_for_all_message_types( + sync_client_factory, fake_sync_client, message_class +): + message = message_class( + "Subject", "Body", "sender@example.com", ["receiver@example.com"] + ) + message.send() + + [email] = fake_sync_client.outbound.calls[0] + assert email.subject == "Subject" + assert email.text_body == "Body" + + +def test_unicode_subject_and_body_round_trip(sync_client_factory, fake_sync_client): + """ + Postmark's API is JSON/UTF-8, unlike raw SMTP, so non-ASCII content needs + no RFC 2047 header encoding — it should pass through unchanged. + """ + message = EmailMessage( + "Тест emoji 🎉", + "Héllo wörld — 日本語のテスト", + "Тест ", + ["Тест "], + ) + message.send() + + [email] = fake_sync_client.outbound.calls[0] + assert email.subject == "Тест emoji 🎉" + assert email.text_body == "Héllo wörld — 日本語のテスト" + assert email.sender == "Тест " + assert email.to == "Тест " + + +def test_html_alternative(sync_client_factory, fake_sync_client): + message = EmailMultiAlternatives( + "Subject", "text body", "sender@example.com", ["receiver@example.com"] + ) + message.attach_alternative("hi", "text/html") + message.send() + + [email] = fake_sync_client.outbound.calls[0] + assert email.html_body == "hi" + assert email.text_body == "text body" + + +def test_unsupported_alternative_mimetype_is_dropped_not_crashed( + sync_client_factory, fake_sync_client, caplog +): + message = EmailMultiAlternatives( + "Subject", "text body", "sender@example.com", ["receiver@example.com"] + ) + message.attach_alternative('{"not": "html"}', "application/json") + + message.send() # must not raise + + [email] = fake_sync_client.outbound.calls[0] + assert email.html_body is None + assert "application/json" in caplog.text + + +def test_attachment_is_base64_encoded(sync_client_factory, fake_sync_client): + message = EmailMessage( + "Subject", "Body", "sender@example.com", ["receiver@example.com"] + ) + message.attach("hello.txt", "Hello World", "text/plain") + message.send() + + [email] = fake_sync_client.outbound.calls[0] + assert email.attachments[0].name == "hello.txt" + assert email.attachments[0].content_type == "text/plain" + assert email.attachments[0].content == "SGVsbG8gV29ybGQ=" # base64("Hello World") + + +def test_legacy_mimebase_attachment_raises_clear_error( + sync_client_factory, fake_sync_client +): + message = EmailMessage( + "Subject", "Body", "sender@example.com", ["receiver@example.com"] + ) + mime_part = MIMEText("Hello World", "plain") + message.attachments.append(mime_part) + + with pytest.raises(TypeError, match="legacy MIMEBase"): + message.send() + + +def test_missing_token_raises_improperly_configured(): + with override_settings(POSTMARK_SERVER_TOKEN=None): + with pytest.raises(ImproperlyConfigured): + EmailBackend() + + +def test_server_token_kwarg_overrides_setting(sync_client_factory): + backend = EmailBackend(server_token="kwarg-token") + assert backend.server_token == "kwarg-token" + + +def test_test_mode_uses_test_server_token(sync_client_factory): + with override_settings(POSTMARK_TEST_MODE=True): + backend = EmailBackend() + backend.open() + + (args, _kwargs) = sync_client_factory.calls[0] + assert args[0] == TEST_SERVER_TOKEN + + +def test_track_opens_default_from_settings(sync_client_factory, fake_sync_client): + with override_settings(POSTMARK_TRACK_OPENS=True): + send_mail("Subject", "Body", "sender@example.com", ["receiver@example.com"]) + + [email] = fake_sync_client.outbound.calls[0] + assert email.track_opens is True + + +def test_message_stream_default_and_override(sync_client_factory, fake_sync_client): + with override_settings(POSTMARK_MESSAGE_STREAM="broadcasts"): + send_mail("Subject", "Body", "sender@example.com", ["receiver@example.com"]) + [default_email] = fake_sync_client.outbound.calls[-1] + assert default_email.message_stream == "broadcasts" + + PostmarkEmailMessage( + "Subject", + "Body", + "sender@example.com", + ["receiver@example.com"], + message_stream="outbound", + ).send() + [overridden_email] = fake_sync_client.outbound.calls[-1] + assert overridden_email.message_stream == "outbound" + + +def test_tag_and_metadata_via_mixin(sync_client_factory, fake_sync_client): + PostmarkEmailMessage( + "Subject", + "Body", + "sender@example.com", + ["receiver@example.com"], + tag="welcome", + metadata={"user_id": "42"}, + ).send() + + [email] = fake_sync_client.outbound.calls[0] + assert email.tag == "welcome" + assert email.metadata == {"user_id": "42"} + + +def test_fail_silently_true_swallows_and_counts_only_successes( + sync_client_factory, fake_sync_client +): + fake_sync_client.outbound.responses_queue.append( + [ + SendResponse( + To="ok@example.com", + SubmittedAt="2024-01-01T00:00:00", + MessageID="m1", + ErrorCode=0, + Message="OK", + ), + SendResponse( + To="bad@example.com", + SubmittedAt="2024-01-01T00:00:00", + MessageID="m2", + ErrorCode=300, + Message="Invalid 'To' address", + ), + ] + ) + + sent = mail.get_connection(fail_silently=True).send_messages( + [ + EmailMessage("S", "B", "sender@example.com", ["ok@example.com"]), + EmailMessage("S", "B", "sender@example.com", ["bad@example.com"]), + ] + ) + + assert sent == 1 + + +def test_fail_silently_false_raises_typed_exception( + sync_client_factory, fake_sync_client +): + fake_sync_client.outbound.responses_queue.append( + [ + SendResponse( + To="bad@example.com", + SubmittedAt="2024-01-01T00:00:00", + MessageID="m1", + ErrorCode=300, + Message="Invalid 'To' address", + ) + ] + ) + + with pytest.raises(ValidationException): + send_mail("S", "B", "sender@example.com", ["bad@example.com"]) + + +def test_multiple_failures_raise_generic_exception_with_combined_message( + sync_client_factory, fake_sync_client +): + fake_sync_client.outbound.responses_queue.append( + [ + SendResponse( + To="a@example.com", + SubmittedAt="2024-01-01T00:00:00", + MessageID="m1", + ErrorCode=300, + Message="bad a", + ), + SendResponse( + To="b@example.com", + SubmittedAt="2024-01-01T00:00:00", + MessageID="m2", + ErrorCode=300, + Message="bad b", + ), + ] + ) + + with pytest.raises(PostmarkAPIException) as exc_info: + mail.get_connection().send_messages( + [ + EmailMessage("S", "B", "sender@example.com", ["a@example.com"]), + EmailMessage("S", "B", "sender@example.com", ["b@example.com"]), + ] + ) + + assert "bad a" in str(exc_info.value) + assert "bad b" in str(exc_info.value) + + +def test_batches_larger_than_500_are_chunked(sync_client_factory, fake_sync_client): + messages = [ + EmailMessage("S", "B", "sender@example.com", [f"user{i}@example.com"]) + for i in range(501) + ] + + sent = mail.get_connection().send_messages(messages) + + assert sent == 501 + assert len(fake_sync_client.outbound.calls) == 2 + assert len(fake_sync_client.outbound.calls[0]) == 500 + assert len(fake_sync_client.outbound.calls[1]) == 1 + + +def test_pre_and_post_send_signals_fire(sync_client_factory, fake_sync_client): + pre_received = {} + post_received = {} + + def on_pre(sender, **kwargs): + pre_received.update(kwargs) + + def on_post(sender, **kwargs): + post_received.update(kwargs) + + pre_send.connect(on_pre) + post_send.connect(on_post) + try: + send_mail("Subject", "Body", "sender@example.com", ["receiver@example.com"]) + finally: + pre_send.disconnect(on_pre) + post_send.disconnect(on_post) + + assert len(pre_received["messages"]) == 1 + assert len(post_received["response"]) == 1 + + +def test_on_exception_signal_fires_with_original_messages( + sync_client_factory, fake_sync_client +): + fake_sync_client.outbound.responses_queue.append( + [ + SendResponse( + To="bad@example.com", + SubmittedAt="2024-01-01T00:00:00", + MessageID="m1", + ErrorCode=300, + Message="bad", + ) + ] + ) + received = {} + + def handler(sender, **kwargs): + received.update(kwargs) + + on_exception.connect(handler) + try: + send_mail( + "S", "B", "sender@example.com", ["bad@example.com"], fail_silently=True + ) + finally: + on_exception.disconnect(handler) + + assert isinstance(received["exception"], ValidationException) + assert len(received["raw_messages"]) == 1 + + +def test_never_calls_legacy_message_method( + monkeypatch, sync_client_factory, fake_sync_client +): + """ + Regression test: this backend must build the payload from EmailMessage's + high-level attributes, never from .message(). Django 6.0 changed what + .message() returns (see the "modern email API" release note), so relying + on it would be fragile across Django versions. + """ + + def boom(self): + raise AssertionError(".message() should never be called by postmark.django") + + monkeypatch.setattr(EmailMessage, "message", boom) + + send_mail( + "Subject", "Body", "sender@example.com", ["receiver@example.com"] + ) # must not raise + + +def test_context_manager_reuses_and_closes_connection( + sync_client_factory, fake_sync_client +): + with mail.get_connection() as connection: + EmailMessage( + "S1", "B1", "sender@example.com", ["a@example.com"], connection=connection + ).send() + EmailMessage( + "S2", "B2", "sender@example.com", ["b@example.com"], connection=connection + ).send() + + assert len(fake_sync_client.outbound.calls) == 2 + assert fake_sync_client.closed is True diff --git a/tests/django_backend/test_mixins.py b/tests/django_backend/test_mixins.py new file mode 100644 index 0000000..ff3b1e8 --- /dev/null +++ b/tests/django_backend/test_mixins.py @@ -0,0 +1,43 @@ +"""Tests for postmark.django.mixins.""" + +from django.core.mail import EmailMessage, EmailMultiAlternatives + +from postmark.django.mixins import PostmarkEmailMessage, PostmarkEmailMultiAlternatives + + +def test_postmark_email_message_defaults_to_none(): + message = PostmarkEmailMessage( + "Subject", "Body", "sender@example.com", ["r@example.com"] + ) + + assert message.tag is None + assert message.metadata is None + assert message.message_stream is None + assert isinstance(message, EmailMessage) + + +def test_postmark_email_message_accepts_postmark_fields(): + message = PostmarkEmailMessage( + "Subject", + "Body", + "sender@example.com", + ["r@example.com"], + tag="welcome", + metadata={"k": "v"}, + message_stream="outbound", + ) + + assert message.tag == "welcome" + assert message.metadata == {"k": "v"} + assert message.message_stream == "outbound" + + +def test_postmark_email_multi_alternatives_is_still_a_multi_alternatives(): + message = PostmarkEmailMultiAlternatives( + "Subject", "text", "sender@example.com", ["r@example.com"], tag="t" + ) + message.attach_alternative("

hi

", "text/html") + + assert isinstance(message, EmailMultiAlternatives) + assert message.tag == "t" + assert message.alternatives == [("

hi

", "text/html")]