Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -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.).
8 changes: 8 additions & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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/<feature>/schemas.py`.
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
42 changes: 42 additions & 0 deletions examples/django/send_batch.py
Original file line number Diff line number Diff line change
@@ -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}")
44 changes: 44 additions & 0 deletions examples/django/send_simple.py
Original file line number Diff line number Diff line change
@@ -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="<p>Sent with <b>postmark.django.EmailBackend</b>.</p>",
)

print("Sent.")
49 changes: 49 additions & 0 deletions examples/django/send_simple_with_attachment.py
Original file line number Diff line number Diff line change
@@ -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.")
54 changes: 54 additions & 0 deletions examples/django/send_simple_with_custom_header.py
Original file line number Diff line number Diff line change
@@ -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": "<original-message-id@example.com>",
"In-Reply-To": "<original-message-id@example.com>",
},
)
message.send()

print("Sent.")
Loading