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.
- duva_django-0.1.0/.github/workflows/ci.yml +29 -0
- duva_django-0.1.0/.github/workflows/release.yml +75 -0
- duva_django-0.1.0/.gitignore +10 -0
- duva_django-0.1.0/CHANGELOG.md +5 -0
- duva_django-0.1.0/LICENSE +21 -0
- duva_django-0.1.0/PKG-INFO +136 -0
- duva_django-0.1.0/README.md +102 -0
- duva_django-0.1.0/pyproject.toml +62 -0
- duva_django-0.1.0/src/duva_django/__init__.py +6 -0
- duva_django-0.1.0/src/duva_django/backend.py +247 -0
- duva_django-0.1.0/src/duva_django/py.typed +0 -0
- duva_django-0.1.0/tests/__init__.py +0 -0
- duva_django-0.1.0/tests/conftest.py +3 -0
- duva_django-0.1.0/tests/settings.py +6 -0
- duva_django-0.1.0/tests/test_backend.py +275 -0
|
@@ -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,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,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,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"
|