duva-django 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ include:
17
+ - { python: "3.10", django: "5.2.*" }
18
+ - { python: "3.13", django: "6.1.*" }
19
+ steps:
20
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
21
+ with:
22
+ persist-credentials: false
23
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
24
+ - name: Tests, types et style sur Django ${{ matrix.django }}
25
+ run: |
26
+ uv run --python ${{ matrix.python }} --extra dev --with "django==${{ matrix.django }}" pytest -q
27
+ uv run --python ${{ matrix.python }} --extra dev --with "django==${{ matrix.django }}" mypy
28
+ uv run --python ${{ matrix.python }} --extra dev ruff check .
29
+ uv run --python ${{ matrix.python }} --extra dev ruff format --check .
@@ -0,0 +1,75 @@
1
+ # Publication de `duva-django` sur PyPI.
2
+ #
3
+ # Déclencheur : l'étiquette Git `vX.Y.Z` (la version de pyproject.toml DOIT être celle de l'étiquette), ou « Run
4
+ # workflow » à la main, qui ne publie JAMAIS sauf si on décoche `dry_run` sur une étiquette.
5
+ #
6
+ # Authentification : « trusted publishing » de PyPI (OIDC, aucun jeton stocké). À déclarer UNE fois sur pypi.org
7
+ # (pour un projet qui n'existe pas encore : Account > Publishing > « Add a new pending publisher ») : projet
8
+ # duva-django, propriétaire `duva-mail`, dépôt `duva-django`, workflow `release.yml`, environnement `publication`.
9
+ name: Release
10
+
11
+ on:
12
+ push:
13
+ tags: ["v*"]
14
+ workflow_dispatch:
15
+ inputs:
16
+ dry_run:
17
+ description: "Construire et contrôler seulement (ne rien publier)"
18
+ type: boolean
19
+ default: true
20
+
21
+ permissions:
22
+ contents: read
23
+
24
+ jobs:
25
+ verify:
26
+ runs-on: ubuntu-latest
27
+ strategy:
28
+ matrix:
29
+ include:
30
+ - { python: "3.10", django: "5.2.*" }
31
+ - { python: "3.13", django: "6.1.*" }
32
+ steps:
33
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
34
+ with:
35
+ persist-credentials: false
36
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
37
+ - name: La version de pyproject.toml est celle de l'étiquette
38
+ if: startsWith(github.ref, 'refs/tags/v')
39
+ run: |
40
+ version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -n 1)
41
+ [ "v${version}" = "${GITHUB_REF_NAME}" ] || { echo "pyproject=${version}, étiquette=${GITHUB_REF_NAME}" >&2; exit 1; }
42
+ - name: Tests, types et style sur Django ${{ matrix.django }}
43
+ run: |
44
+ uv run --python ${{ matrix.python }} --extra dev --with "django==${{ matrix.django }}" pytest -q
45
+ uv run --python ${{ matrix.python }} --extra dev --with "django==${{ matrix.django }}" mypy
46
+ uv run --python ${{ matrix.python }} --extra dev ruff check .
47
+ uv run --python ${{ matrix.python }} --extra dev ruff format --check .
48
+
49
+ build:
50
+ needs: verify
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
54
+ with:
55
+ persist-credentials: false
56
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
57
+ - run: uv build
58
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
59
+ with:
60
+ name: dist
61
+ path: dist/
62
+
63
+ publish:
64
+ needs: build
65
+ if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || inputs.dry_run == false)
66
+ runs-on: ubuntu-latest
67
+ environment: publication
68
+ permissions:
69
+ id-token: write # trusted publishing
70
+ steps:
71
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
72
+ with:
73
+ name: dist
74
+ path: dist/
75
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .pytest_cache/
9
+ uv.lock
10
+ .DS_Store
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - First release: `duva_django.EmailBackend`, a Django email backend for Duva, built on `duva-mail`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 9573-4562 Québec inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.5
2
+ Name: duva-django
3
+ Version: 0.1.0
4
+ Summary: Django email backend for Duva, the transactional email API hosted in Canada.
5
+ Project-URL: Homepage, https://duva.ca
6
+ Project-URL: Documentation, https://duva.ca/en/docs
7
+ Project-URL: Repository, https://github.com/duva-mail/duva-django
8
+ Project-URL: Changelog, https://github.com/duva-mail/duva-django/blob/main/CHANGELOG.md
9
+ Author: 9573-4562 Québec inc.
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: django,duva,email,email-backend,transactional-email
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 5.2
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: django>=5.2
27
+ Requires-Dist: duva-mail<1,>=0.2
28
+ Provides-Extra: dev
29
+ Requires-Dist: django-stubs>=5.1; extra == 'dev'
30
+ Requires-Dist: mypy>=1.13; extra == 'dev'
31
+ Requires-Dist: pytest>=8.3; extra == 'dev'
32
+ Requires-Dist: ruff>=0.7; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # duva-django
36
+
37
+ Django email backend for [Duva](https://duva.ca), the transactional email API hosted in Canada. Use `send_mail()`,
38
+ `EmailMessage` and `EmailMultiAlternatives` as usual; the messages go out through Duva.
39
+
40
+ Requires Python 3.10+ and Django 5.2 or later (including the `MAILERS` setting of Django 6.1). Built on
41
+ [`duva-mail`](https://pypi.org/project/duva-mail/).
42
+
43
+ ```bash
44
+ pip install duva-django
45
+ # or: uv add duva-django
46
+ ```
47
+
48
+ You also need a Duva API key and a verified domain ([documentation](https://duva.ca/en/docs)).
49
+
50
+ ## Configure
51
+
52
+ Django 5.2 and later, with `EMAIL_BACKEND`:
53
+
54
+ ```python
55
+ # settings.py
56
+ EMAIL_BACKEND = "duva_django.EmailBackend"
57
+ DUVA_API_KEY = os.environ["DUVA_API_KEY"]
58
+ DUVA_DOMAIN = "example.com"
59
+ DEFAULT_FROM_EMAIL = "Example <notifications@example.com>"
60
+ ```
61
+
62
+ Django 6.1 and later, with `MAILERS` (`EMAIL_BACKEND` is deprecated there):
63
+
64
+ ```python
65
+ MAILERS = {
66
+ "default": {
67
+ "BACKEND": "duva_django.EmailBackend",
68
+ "OPTIONS": {"api_key": os.environ["DUVA_API_KEY"], "domain": "example.com"},
69
+ },
70
+ }
71
+ ```
72
+
73
+ Options (`OPTIONS`, or the setting of the same name in capitals prefixed by `DUVA_`): `api_key`, `domain`, `base_url`
74
+ (default `https://api.duva.ca`), `timeout` (seconds, default 10), `max_retries` (default 2). `DUVA_API_KEY` and
75
+ `DUVA_DOMAIN` can also come from the environment. The `From` address must belong to the configured domain.
76
+
77
+ ## What is sent
78
+
79
+ | Django | Duva |
80
+ |---|---|
81
+ | `from_email`, `to`, `cc`, `bcc`, `reply_to` | `from`, `to`, `cc`, `bcc`, `reply_to` |
82
+ | Plain body, `content_subtype = "html"`, `attach_alternative(html, "text/html")` | `text`, `html` |
83
+ | `attach()` files and inline images (`Content-ID`) | `attachments` (inline ones keep their `content_id`) |
84
+ | `message.tags = [...]` | `tags` |
85
+ | `message.metadata = {...}` | `metadata` |
86
+ | `List-Unsubscribe`, `List-Unsubscribe-Post`, `List-Id`, `In-Reply-To`, `References`, `Auto-Submitted`, `Precedence`, `Importance`, `Feedback-ID`, and `X-*` headers in `headers` | `headers` |
87
+
88
+ (`tags` and `metadata` follow the django-anymail convention, so code written for it keeps working.)
89
+
90
+ Notes:
91
+
92
+ - Other headers are not forwarded (Duva rejects them), nor are `X-Duva*`, `X-Kumo*`, `X-Tenant*` and `X-Campaign*`.
93
+ - A display name Duva refuses (it contains `@`, quotes, `<>`, control characters or an encoded word `=?…?=`) is dropped;
94
+ the address is kept.
95
+ - Delivery is asynchronous: a successful send means Duva accepted the message, not that it was delivered. After a send,
96
+ `message.duva_result` holds the `duva.SendMessageResult` (`id`, `status`, `replayed`). Use Duva's webhooks or events
97
+ for the outcome.
98
+ - A Duva error (`duva.DuvaError` and its subclasses) is raised, unless `fail_silently=True`, in which case the message is
99
+ not counted in the return value of `send_mail()`. A missing key or domain raises `ImproperlyConfigured`.
100
+
101
+ ### Idempotency
102
+
103
+ A retried task builds a new message, so it would be sent twice. Set a stable key for it (an order ID, for example):
104
+
105
+ ```python
106
+ EmailMessage(
107
+ subject,
108
+ body,
109
+ from_email,
110
+ [to],
111
+ headers={"X-Idempotency-Key": f"order-{order.id}"},
112
+ ).send()
113
+ ```
114
+
115
+ The header is read by the backend and never sent. Duva ignores a second message with the same key.
116
+
117
+ ## Testing your application
118
+
119
+ Django's test runner swaps the backend for the in-memory one, so `mail.outbox` works as usual. To exercise this backend
120
+ without a network, give it a client built on a fake HTTP transport:
121
+
122
+ ```python
123
+ import duva, httpx
124
+ from duva_django import EmailBackend
125
+
126
+ client = duva.Duva(
127
+ api_key="dv_test",
128
+ domain="example.com",
129
+ http_client=httpx.Client(transport=httpx.MockTransport(handler)),
130
+ )
131
+ EmailBackend(client=client).send_messages([message])
132
+ ```
133
+
134
+ ## License
135
+
136
+ MIT.
@@ -0,0 +1,102 @@
1
+ # duva-django
2
+
3
+ Django email backend for [Duva](https://duva.ca), the transactional email API hosted in Canada. Use `send_mail()`,
4
+ `EmailMessage` and `EmailMultiAlternatives` as usual; the messages go out through Duva.
5
+
6
+ Requires Python 3.10+ and Django 5.2 or later (including the `MAILERS` setting of Django 6.1). Built on
7
+ [`duva-mail`](https://pypi.org/project/duva-mail/).
8
+
9
+ ```bash
10
+ pip install duva-django
11
+ # or: uv add duva-django
12
+ ```
13
+
14
+ You also need a Duva API key and a verified domain ([documentation](https://duva.ca/en/docs)).
15
+
16
+ ## Configure
17
+
18
+ Django 5.2 and later, with `EMAIL_BACKEND`:
19
+
20
+ ```python
21
+ # settings.py
22
+ EMAIL_BACKEND = "duva_django.EmailBackend"
23
+ DUVA_API_KEY = os.environ["DUVA_API_KEY"]
24
+ DUVA_DOMAIN = "example.com"
25
+ DEFAULT_FROM_EMAIL = "Example <notifications@example.com>"
26
+ ```
27
+
28
+ Django 6.1 and later, with `MAILERS` (`EMAIL_BACKEND` is deprecated there):
29
+
30
+ ```python
31
+ MAILERS = {
32
+ "default": {
33
+ "BACKEND": "duva_django.EmailBackend",
34
+ "OPTIONS": {"api_key": os.environ["DUVA_API_KEY"], "domain": "example.com"},
35
+ },
36
+ }
37
+ ```
38
+
39
+ Options (`OPTIONS`, or the setting of the same name in capitals prefixed by `DUVA_`): `api_key`, `domain`, `base_url`
40
+ (default `https://api.duva.ca`), `timeout` (seconds, default 10), `max_retries` (default 2). `DUVA_API_KEY` and
41
+ `DUVA_DOMAIN` can also come from the environment. The `From` address must belong to the configured domain.
42
+
43
+ ## What is sent
44
+
45
+ | Django | Duva |
46
+ |---|---|
47
+ | `from_email`, `to`, `cc`, `bcc`, `reply_to` | `from`, `to`, `cc`, `bcc`, `reply_to` |
48
+ | Plain body, `content_subtype = "html"`, `attach_alternative(html, "text/html")` | `text`, `html` |
49
+ | `attach()` files and inline images (`Content-ID`) | `attachments` (inline ones keep their `content_id`) |
50
+ | `message.tags = [...]` | `tags` |
51
+ | `message.metadata = {...}` | `metadata` |
52
+ | `List-Unsubscribe`, `List-Unsubscribe-Post`, `List-Id`, `In-Reply-To`, `References`, `Auto-Submitted`, `Precedence`, `Importance`, `Feedback-ID`, and `X-*` headers in `headers` | `headers` |
53
+
54
+ (`tags` and `metadata` follow the django-anymail convention, so code written for it keeps working.)
55
+
56
+ Notes:
57
+
58
+ - Other headers are not forwarded (Duva rejects them), nor are `X-Duva*`, `X-Kumo*`, `X-Tenant*` and `X-Campaign*`.
59
+ - A display name Duva refuses (it contains `@`, quotes, `<>`, control characters or an encoded word `=?…?=`) is dropped;
60
+ the address is kept.
61
+ - Delivery is asynchronous: a successful send means Duva accepted the message, not that it was delivered. After a send,
62
+ `message.duva_result` holds the `duva.SendMessageResult` (`id`, `status`, `replayed`). Use Duva's webhooks or events
63
+ for the outcome.
64
+ - A Duva error (`duva.DuvaError` and its subclasses) is raised, unless `fail_silently=True`, in which case the message is
65
+ not counted in the return value of `send_mail()`. A missing key or domain raises `ImproperlyConfigured`.
66
+
67
+ ### Idempotency
68
+
69
+ A retried task builds a new message, so it would be sent twice. Set a stable key for it (an order ID, for example):
70
+
71
+ ```python
72
+ EmailMessage(
73
+ subject,
74
+ body,
75
+ from_email,
76
+ [to],
77
+ headers={"X-Idempotency-Key": f"order-{order.id}"},
78
+ ).send()
79
+ ```
80
+
81
+ The header is read by the backend and never sent. Duva ignores a second message with the same key.
82
+
83
+ ## Testing your application
84
+
85
+ Django's test runner swaps the backend for the in-memory one, so `mail.outbox` works as usual. To exercise this backend
86
+ without a network, give it a client built on a fake HTTP transport:
87
+
88
+ ```python
89
+ import duva, httpx
90
+ from duva_django import EmailBackend
91
+
92
+ client = duva.Duva(
93
+ api_key="dv_test",
94
+ domain="example.com",
95
+ http_client=httpx.Client(transport=httpx.MockTransport(handler)),
96
+ )
97
+ EmailBackend(client=client).send_messages([message])
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT.
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "duva-django"
7
+ version = "0.1.0"
8
+ description = "Django email backend for Duva, the transactional email API hosted in Canada."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "9573-4562 Québec inc." }]
14
+ keywords = ["duva", "django", "email", "email-backend", "transactional-email"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Framework :: Django",
18
+ "Framework :: Django :: 5.2",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = ["django>=5.2", "duva-mail>=0.2,<1"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://duva.ca"
33
+ Documentation = "https://duva.ca/en/docs"
34
+ Repository = "https://github.com/duva-mail/duva-django"
35
+ Changelog = "https://github.com/duva-mail/duva-django/blob/main/CHANGELOG.md"
36
+
37
+ [project.optional-dependencies]
38
+ dev = ["pytest>=8.3", "mypy>=1.13", "ruff>=0.7", "django-stubs>=5.1"]
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/duva_django"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+
46
+ [tool.mypy]
47
+ python_version = "3.10"
48
+ strict = true
49
+ packages = ["duva_django"]
50
+ mypy_path = "src"
51
+ explicit_package_bases = true
52
+ plugins = ["mypy_django_plugin.main"]
53
+
54
+ [tool.django-stubs]
55
+ django_settings_module = "tests.settings"
56
+
57
+ [tool.ruff]
58
+ line-length = 100
59
+ target-version = "py310"
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,6 @@
1
+ """Django email backend for Duva. Use `EMAIL_BACKEND = "duva_django.EmailBackend"`."""
2
+
3
+ from duva_django.backend import IDEMPOTENCY_HEADER, EmailBackend
4
+
5
+ __all__ = ["IDEMPOTENCY_HEADER", "EmailBackend"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,247 @@
1
+ """Django email backend for Duva: `duva_django.EmailBackend`.
2
+
3
+ Duva refuses a few things Django allows, and this backend translates rather than failing late: a
4
+ display name Duva would reject is dropped (the address is kept), and only the headers Duva accepts
5
+ are forwarded. `tags` and `metadata` attributes on the message (the django-anymail convention)
6
+ become Duva's `tags` and `metadata`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Sequence
13
+ from email.message import Message
14
+ from email.utils import parseaddr
15
+ from typing import Any
16
+
17
+ import duva
18
+ from django.conf import settings
19
+ from django.core.exceptions import ImproperlyConfigured
20
+ from django.core.mail import EmailMessage, EmailMultiAlternatives
21
+ from django.core.mail.backends.base import BaseEmailBackend
22
+
23
+ # Duva headers allowlist (docs/api.md); any other `X-*` is allowed except the reserved prefixes.
24
+ _ALLOWED_HEADERS = frozenset(
25
+ {
26
+ "list-unsubscribe",
27
+ "list-unsubscribe-post",
28
+ "list-id",
29
+ "in-reply-to",
30
+ "references",
31
+ "auto-submitted",
32
+ "precedence",
33
+ "importance",
34
+ "feedback-id",
35
+ }
36
+ )
37
+ _RESERVED_PREFIXES = ("x-kumo", "x-duva", "x-tenant", "x-campaign")
38
+ # A name Duva refuses: `@`, quotes, angle brackets, backslash, control characters, encoded words.
39
+ _UNSAFE_NAME = re.compile(r'[@"<>\\\x00-\x1f\x7f]|=\?')
40
+
41
+ #: Read by this backend, never forwarded: a stable key so a retried task sends only once.
42
+ IDEMPOTENCY_HEADER = "X-Idempotency-Key"
43
+
44
+
45
+ class EmailBackend(BaseEmailBackend):
46
+ """Sends `EmailMessage` objects through the Duva API.
47
+
48
+ Configuration, in order of precedence: the options given to the backend (`OPTIONS` of a
49
+ `MAILERS` entry, Django 6.1+), then the settings `DUVA_API_KEY`, `DUVA_DOMAIN`, `DUVA_BASE_URL`,
50
+ `DUVA_TIMEOUT`, `DUVA_MAX_RETRIES`, then the environment variables `DUVA_API_KEY` and
51
+ `DUVA_DOMAIN`. The options are `api_key`, `domain`, `base_url`, `timeout`, `max_retries`.
52
+
53
+ After a successful send, `message.duva_result` holds the `duva.SendMessageResult`
54
+ (`id`, `status`, `replayed`).
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ fail_silently: bool = False,
60
+ *,
61
+ alias: str | None = None,
62
+ api_key: str | None = None,
63
+ domain: str | None = None,
64
+ base_url: str | None = None,
65
+ timeout: float | None = None,
66
+ max_retries: int | None = None,
67
+ client: duva.Duva | None = None,
68
+ **kwargs: Any,
69
+ ) -> None:
70
+ # `alias` exists from Django 6.1 only, and `fail_silently` is deprecated there as a base
71
+ # argument: this backend keeps its own attribute.
72
+ extra: dict[str, Any] = {"alias": alias} if alias is not None else {}
73
+ super().__init__(**extra, **kwargs)
74
+ self.fail_silently = fail_silently
75
+ self._options = {
76
+ "api_key": api_key,
77
+ "domain": domain,
78
+ "base_url": base_url,
79
+ "timeout": timeout,
80
+ "max_retries": max_retries,
81
+ }
82
+ self._client = client
83
+ self._owns_client = client is None
84
+
85
+ def open(self) -> bool:
86
+ if self._client is not None:
87
+ return False
88
+ options = self._options
89
+
90
+ def pick(name: str, setting: str, default: Any = None) -> Any:
91
+ value = options[name]
92
+ return value if value is not None else getattr(settings, setting, default)
93
+
94
+ self._client = duva.Duva(
95
+ api_key=pick("api_key", "DUVA_API_KEY"),
96
+ domain=pick("domain", "DUVA_DOMAIN"),
97
+ base_url=pick("base_url", "DUVA_BASE_URL", "https://api.duva.ca"),
98
+ timeout=float(pick("timeout", "DUVA_TIMEOUT", 10.0)),
99
+ max_retries=int(pick("max_retries", "DUVA_MAX_RETRIES", 2)),
100
+ )
101
+ return True
102
+
103
+ def close(self) -> None:
104
+ if self._client is not None and self._owns_client:
105
+ self._client.close()
106
+ self._client = None
107
+
108
+ def send_messages(self, email_messages: Sequence[EmailMessage]) -> int:
109
+ if not email_messages:
110
+ return 0
111
+ opened = False
112
+ try:
113
+ if self._client is None:
114
+ try:
115
+ opened = self.open()
116
+ except (TypeError, ValueError) as error:
117
+ # Missing key or domain: a configuration error, not a delivery failure.
118
+ if not self.fail_silently:
119
+ raise ImproperlyConfigured(str(error)) from error
120
+ return 0
121
+ sent = 0
122
+ for message in email_messages:
123
+ if self._send(message):
124
+ sent += 1
125
+ return sent
126
+ finally:
127
+ if opened:
128
+ self.close()
129
+
130
+ def _send(self, message: EmailMessage) -> bool:
131
+ assert self._client is not None
132
+ try:
133
+ params = _params(message)
134
+ result = self._client.messages.send(**params)
135
+ except (duva.DuvaError, ValueError):
136
+ if not self.fail_silently:
137
+ raise
138
+ return False
139
+ message.duva_result = result # type: ignore[attr-defined]
140
+ return True
141
+
142
+
143
+ def _params(message: EmailMessage) -> dict[str, Any]:
144
+ to = _addresses(message.to)
145
+ cc = _addresses(message.cc)
146
+ bcc = _addresses(message.bcc)
147
+ if not (to or cc or bcc):
148
+ raise ValueError("Duva: the message has no recipient")
149
+ if not message.from_email:
150
+ raise ValueError("Duva: the message has no from address")
151
+
152
+ params: dict[str, Any] = {
153
+ "from_": _address(message.from_email),
154
+ # Duva requires `to`; a message with only cc/bcc is not accepted by the API either.
155
+ "to": to,
156
+ "subject": message.subject,
157
+ }
158
+ if cc:
159
+ params["cc"] = cc
160
+ if bcc:
161
+ params["bcc"] = bcc
162
+
163
+ html, text = _bodies(message)
164
+ if html is not None:
165
+ params["html"] = html
166
+ if text is not None:
167
+ params["text"] = text
168
+ if message.reply_to:
169
+ params["reply_to"] = _address(message.reply_to[0])
170
+
171
+ headers: dict[str, str] = {}
172
+ for name, value in message.extra_headers.items():
173
+ lower = name.lower()
174
+ if lower == IDEMPOTENCY_HEADER.lower():
175
+ params["idempotency_key"] = str(value)
176
+ elif _forwardable(lower):
177
+ headers[name] = str(value)
178
+ if headers:
179
+ params["headers"] = headers
180
+
181
+ tags = getattr(message, "tags", None)
182
+ if tags:
183
+ params["tags"] = [str(tag) for tag in tags]
184
+ metadata = getattr(message, "metadata", None)
185
+ if metadata:
186
+ params["metadata"] = {str(key): str(value) for key, value in metadata.items()}
187
+
188
+ attachments = [_attachment(item) for item in message.attachments]
189
+ if attachments:
190
+ params["attachments"] = attachments
191
+ return params
192
+
193
+
194
+ def _bodies(message: EmailMessage) -> tuple[str | None, str | None]:
195
+ """`(html, text)`. The body is HTML when `content_subtype == "html"`; a `text/html`
196
+ alternative (`EmailMultiAlternatives`) is the HTML otherwise."""
197
+ html: str | None = None
198
+ text: str | None = None
199
+ if message.content_subtype == "html":
200
+ html = str(message.body) or None
201
+ else:
202
+ text = str(message.body) or None
203
+ if isinstance(message, EmailMultiAlternatives):
204
+ for content, mimetype in message.alternatives:
205
+ if mimetype == "text/html" and html is None and isinstance(content, str):
206
+ html = content
207
+ return html, text
208
+
209
+
210
+ def _forwardable(lower_name: str) -> bool:
211
+ if lower_name in _ALLOWED_HEADERS:
212
+ return True
213
+ return lower_name.startswith("x-") and not lower_name.startswith(_RESERVED_PREFIXES)
214
+
215
+
216
+ def _address(value: str) -> str:
217
+ """`Name <address>`, or the bare address when Duva would refuse the name."""
218
+ name, address = parseaddr(value)
219
+ if not address:
220
+ return value.strip()
221
+ if not name or _UNSAFE_NAME.search(name):
222
+ return address
223
+ return duva.format_address(address, name)
224
+
225
+
226
+ def _addresses(values: Sequence[str]) -> list[str]:
227
+ return [_address(value) for value in values]
228
+
229
+
230
+ def _attachment(item: Any) -> duva.Attachment:
231
+ if isinstance(item, Message):
232
+ content = item.get_payload(decode=True)
233
+ if not isinstance(content, bytes):
234
+ raise ValueError("Duva: this attachment has no readable content")
235
+ content_id = item.get("Content-ID")
236
+ disposition = str(item.get("Content-Disposition", "")).lower()
237
+ return duva.Attachment.from_bytes(
238
+ item.get_filename() or "attachment",
239
+ content,
240
+ content_type=item.get_content_type(),
241
+ content_id=str(content_id).strip("<>")
242
+ if content_id and "inline" in disposition
243
+ else None,
244
+ )
245
+ filename, content, mimetype = item
246
+ data = content.encode() if isinstance(content, str) else bytes(content)
247
+ return duva.Attachment.from_bytes(filename or "attachment", data, content_type=mimetype)
File without changes
File without changes
@@ -0,0 +1,3 @@
1
+ import os
2
+
3
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
@@ -0,0 +1,6 @@
1
+ SECRET_KEY = "test"
2
+ USE_TZ = True
3
+ INSTALLED_APPS: list[str] = []
4
+ EMAIL_BACKEND = "duva_django.EmailBackend"
5
+ DUVA_API_KEY = "dv_test"
6
+ DUVA_DOMAIN = "example.com"
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from email.mime.image import MIMEImage
6
+ from typing import Any
7
+
8
+ import django
9
+ import duva
10
+ import httpx
11
+ import pytest
12
+ from django.core import mail
13
+ from django.core.exceptions import ImproperlyConfigured
14
+ from django.core.mail import EmailMessage, EmailMultiAlternatives, get_connection
15
+ from django.test import override_settings
16
+
17
+ from duva_django import EmailBackend
18
+
19
+
20
+ class Recorder:
21
+ """A fake Duva API: records every request, answers with `handler`."""
22
+
23
+ def __init__(self, handler: Any = None) -> None:
24
+ self.requests: list[httpx.Request] = []
25
+ self._handler = handler or (
26
+ lambda request: httpx.Response(
27
+ 202, json={"id": "msg_" + "0" * 31 + "1", "status": "queued"}
28
+ )
29
+ )
30
+
31
+ def __call__(self, request: httpx.Request) -> httpx.Response:
32
+ self.requests.append(request)
33
+ return self._handler(request)
34
+
35
+ @property
36
+ def body(self) -> dict[str, Any]:
37
+ assert len(self.requests) == 1
38
+ return json.loads(self.requests[0].content)
39
+
40
+ def backend(self, **kwargs: Any) -> EmailBackend:
41
+ client = duva.Duva(
42
+ api_key="dv_test",
43
+ domain="example.com",
44
+ max_retries=0,
45
+ http_client=httpx.Client(transport=httpx.MockTransport(self)),
46
+ )
47
+ return EmailBackend(client=client, **kwargs)
48
+
49
+
50
+ @pytest.fixture
51
+ def api() -> Recorder:
52
+ return Recorder()
53
+
54
+
55
+ def test_maps_a_message_to_the_duva_request(api: Recorder) -> None:
56
+ message = EmailMultiAlternatives(
57
+ "Order",
58
+ "Plain text",
59
+ "Shop <shop@example.com>",
60
+ ["Client <a@example.org>"],
61
+ cc=["cc@example.org"],
62
+ bcc=["hidden@example.org"],
63
+ reply_to=["Support <help@example.com>"],
64
+ )
65
+ message.attach_alternative("<p>Hi</p>", "text/html")
66
+
67
+ assert api.backend().send_messages([message]) == 1
68
+
69
+ request = api.requests[0]
70
+ assert request.method == "POST"
71
+ assert request.url.path == "/v1/example.com/messages"
72
+ assert request.headers["authorization"] == "Bearer dv_test"
73
+ assert api.body == {
74
+ "from": "Shop <shop@example.com>",
75
+ "to": ["Client <a@example.org>"],
76
+ "cc": ["cc@example.org"],
77
+ "bcc": ["hidden@example.org"],
78
+ "subject": "Order",
79
+ "text": "Plain text",
80
+ "html": "<p>Hi</p>",
81
+ "reply_to": "Support <help@example.com>",
82
+ }
83
+
84
+
85
+ def test_an_html_body_is_sent_as_html(api: Recorder) -> None:
86
+ message = EmailMessage("S", "<b>x</b>", "shop@example.com", ["a@example.org"])
87
+ message.content_subtype = "html"
88
+
89
+ api.backend().send_messages([message])
90
+
91
+ assert api.body["html"] == "<b>x</b>"
92
+ assert "text" not in api.body
93
+
94
+
95
+ def test_reports_duvas_result_on_the_message(api: Recorder) -> None:
96
+ message = EmailMessage("S", "x", "shop@example.com", ["a@example.org"])
97
+
98
+ api.backend().send_messages([message])
99
+
100
+ assert message.duva_result.id == "msg_" + "0" * 31 + "1" # type: ignore[attr-defined]
101
+ assert message.duva_result.status == "queued" # type: ignore[attr-defined]
102
+
103
+
104
+ def test_drops_a_display_name_duva_would_refuse_but_keeps_the_address(api: Recorder) -> None:
105
+ message = EmailMessage(
106
+ "S",
107
+ "x",
108
+ "shop@example.com",
109
+ ['"Mallory @ evil.com" <a@example.org>', "Foo <b@example.org>"],
110
+ cc=["=?UTF-8?B?Zm9v?= <c@example.org>"],
111
+ )
112
+
113
+ api.backend().send_messages([message])
114
+
115
+ assert api.body["to"] == ["a@example.org", "Foo <b@example.org>"]
116
+ assert api.body["cc"] == ["c@example.org"]
117
+
118
+
119
+ def test_forwards_tags_metadata_and_allowed_headers_only(api: Recorder) -> None:
120
+ message = EmailMessage(
121
+ "S",
122
+ "x",
123
+ "shop@example.com",
124
+ ["a@example.org"],
125
+ headers={
126
+ "List-Unsubscribe": "<https://example.com/u/1>",
127
+ "X-Custom": "yes",
128
+ "X-Duva-Secret": "no",
129
+ "Organization": "Acme",
130
+ "Message-ID": "<x@y>",
131
+ },
132
+ )
133
+ message.tags = ["welcome"] # type: ignore[attr-defined]
134
+ message.metadata = {"order_id": 42} # type: ignore[attr-defined]
135
+
136
+ api.backend().send_messages([message])
137
+
138
+ assert api.body["tags"] == ["welcome"]
139
+ assert api.body["metadata"] == {"order_id": "42"}
140
+ assert api.body["headers"] == {
141
+ "List-Unsubscribe": "<https://example.com/u/1>",
142
+ "X-Custom": "yes",
143
+ }
144
+
145
+
146
+ def test_the_idempotency_header_becomes_the_key_and_is_not_forwarded(api: Recorder) -> None:
147
+ message = EmailMessage(
148
+ "S", "x", "shop@example.com", ["a@example.org"], headers={"X-Idempotency-Key": "order-42"}
149
+ )
150
+
151
+ api.backend().send_messages([message])
152
+
153
+ assert api.requests[0].headers["idempotency-key"] == "order-42"
154
+ assert "headers" not in api.body
155
+
156
+
157
+ def test_attachments_and_inline_images(api: Recorder) -> None:
158
+ message = EmailMessage("S", "x", "shop@example.com", ["a@example.org"])
159
+ message.attach("note.txt", "hello", "text/plain")
160
+ image = MIMEImage(b"\x89PNG\r\n\x1a\n" + b"0" * 16, "png")
161
+ image.add_header("Content-ID", "<logo>")
162
+ image.add_header("Content-Disposition", "inline", filename="logo.png")
163
+ message.attach(image)
164
+
165
+ api.backend().send_messages([message])
166
+
167
+ by_name = {a["filename"]: a for a in api.body["attachments"]}
168
+ assert base64.b64decode(by_name["note.txt"]["content"]) == b"hello"
169
+ assert by_name["note.txt"]["content_type"] == "text/plain"
170
+ assert by_name["note.txt"]["content_id"] is None
171
+ assert by_name["logo.png"]["content_id"] == "logo"
172
+ assert by_name["logo.png"]["content_type"] == "image/png"
173
+
174
+
175
+ def test_a_duva_error_is_raised_unless_fail_silently() -> None:
176
+ rejected = Recorder(
177
+ lambda request: httpx.Response(
178
+ 422, json={"error": {"code": "invalid_request", "message": "Invalid message"}}
179
+ )
180
+ )
181
+ message = EmailMessage("S", "x", "shop@example.com", ["a@example.org"])
182
+
183
+ with pytest.raises(duva.ValidationError):
184
+ rejected.backend().send_messages([message])
185
+ assert rejected.backend(fail_silently=True).send_messages([message]) == 0
186
+
187
+
188
+ def test_counts_only_the_messages_that_were_accepted() -> None:
189
+ calls = {"n": 0}
190
+
191
+ def handler(request: httpx.Request) -> httpx.Response:
192
+ calls["n"] += 1
193
+ if calls["n"] == 1:
194
+ return httpx.Response(422, json={"error": {"code": "invalid_request", "message": "x"}})
195
+ return httpx.Response(202, json={"id": "msg_" + "0" * 32, "status": "queued"})
196
+
197
+ api = Recorder(handler)
198
+ messages = [EmailMessage("S", "x", "shop@example.com", [f"{i}@example.org"]) for i in range(2)]
199
+
200
+ assert api.backend(fail_silently=True).send_messages(messages) == 1
201
+
202
+
203
+ def test_a_message_without_recipient_is_refused() -> None:
204
+ api = Recorder()
205
+
206
+ with pytest.raises(ValueError, match="no recipient"):
207
+ api.backend().send_messages([EmailMessage("S", "x", "shop@example.com", [])])
208
+ assert api.requests == []
209
+
210
+
211
+ def test_django_send_mail_uses_the_configured_backend() -> None:
212
+ api = Recorder()
213
+ connection = get_connection("duva_django.EmailBackend", client=api.backend()._client)
214
+
215
+ sent = mail.send_mail(
216
+ "Hi", "Body", "shop@example.com", ["a@example.org"], connection=connection
217
+ )
218
+
219
+ assert sent == 1
220
+ assert api.body["subject"] == "Hi"
221
+
222
+
223
+ @override_settings(DUVA_API_KEY=None, DUVA_DOMAIN=None)
224
+ def test_missing_configuration_is_an_error_not_a_silent_loss(
225
+ monkeypatch: pytest.MonkeyPatch,
226
+ ) -> None:
227
+ monkeypatch.delenv("DUVA_API_KEY", raising=False)
228
+ monkeypatch.delenv("DUVA_DOMAIN", raising=False)
229
+ message = EmailMessage("S", "x", "shop@example.com", ["a@example.org"])
230
+
231
+ with pytest.raises(ImproperlyConfigured, match="API key"):
232
+ EmailBackend().send_messages([message])
233
+ assert EmailBackend(fail_silently=True).send_messages([message]) == 0
234
+
235
+
236
+ def test_options_win_over_settings_and_reach_the_client(monkeypatch: pytest.MonkeyPatch) -> None:
237
+ captured: dict[str, Any] = {}
238
+
239
+ class FakeDuva:
240
+ def __init__(self, **kwargs: Any) -> None:
241
+ captured.update(kwargs)
242
+ self.messages = self
243
+
244
+ def send(self, **params: Any) -> Any:
245
+ return duva.SendMessageResult(id="m", status="queued", replayed=False, location=None)
246
+
247
+ def close(self) -> None:
248
+ captured["closed"] = True
249
+
250
+ monkeypatch.setattr(duva, "Duva", FakeDuva)
251
+ backend = EmailBackend(api_key="dv_opt", domain="opt.example", timeout=3, max_retries=5)
252
+
253
+ assert backend.send_messages([EmailMessage("S", "x", "a@opt.example", ["b@example.org"])]) == 1
254
+ assert captured["api_key"] == "dv_opt" and captured["domain"] == "opt.example"
255
+ assert captured["timeout"] == 3.0 and captured["max_retries"] == 5
256
+ assert captured["base_url"] == "https://api.duva.ca"
257
+ assert captured["closed"] is True
258
+
259
+
260
+ @pytest.mark.skipif(django.VERSION < (6, 1), reason="MAILERS arrives in Django 6.1")
261
+ def test_works_as_a_mailers_entry() -> None:
262
+ from django.core.mail import mailers
263
+
264
+ with override_settings(
265
+ MAILERS={
266
+ "default": {
267
+ "BACKEND": "duva_django.EmailBackend",
268
+ "OPTIONS": {"api_key": "dv_x", "domain": "example.com"},
269
+ }
270
+ }
271
+ ):
272
+ backend = mailers["default"]
273
+
274
+ assert isinstance(backend, EmailBackend)
275
+ assert backend.alias == "default"