Skip to content

Commit 4b91f32

Browse files
реализована отпарвки электронных писем
1 parent 1006a9f commit 4b91f32

19 files changed

+1244
-6
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- [Валидация входящих данных](./docs/Валидация%20входящих%20данных.md)
1919
- [Фильтрация, пагинация, сортировка.md](docs/%D0%A4%D0%B8%D0%BB%D1%8C%D1%82%D1%80%D0%B0%D1%86%D0%B8%D1%8F%2C%20%D0%BF%D0%B0%D0%B3%D0%B8%D0%BD%D0%B0%D1%86%D0%B8%D1%8F%2C%20%D1%81%D0%BE%D1%80%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%BA%D0%B0.md)
2020
- [Логгирование](docs/Логирование.md)
21+
- [Отправка электронных писем](docs/%D0%9E%D1%82%D0%BF%D1%80%D0%B0%D0%B2%D0%BA%D0%B0%20%D1%8D%D0%BB%D0%B5%D0%BA%D1%82%D1%80%D0%BE%D0%BD%D0%BD%D1%8B%D1%85%20%D0%BF%D0%B8%D1%81%D0%B5%D0%BC.md)
2122
- [TODO](#todo)
2223

2324
## TODO
@@ -27,7 +28,7 @@
2728
и который является входной точкой в приложение)
2829
- [ ] прикинуть, какие еще консольные команды могут пригодиться (напр., миграции)
2930
- [x] репозитории
30-
- [ ] http-исключения
31+
- [x] http-исключения
3132
- [ ] разработать формат ошибок (ошибки для тоста, ошибки валидации)
3233
- [x] интегрировать https://github.com/albertalexandrov/django-like-repositories
3334
- [x] работа с БД не только в рамках апишки, но в рамках напр. асинхронных задач
@@ -40,3 +41,4 @@
4041
- [ ] расширяемость либы как в django
4142
- [x] Пермишены
4243
- [x] Аутентификация
44+
- [x] Отправка электронных писем (мультибекенды)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Отправка электронных писем
2+
3+
Реализована поддержка отправки электронных писем через множество провайдеров (которая заедет в Django 6,
4+
см. PR https://github.com/django/django/pull/18421/)
5+
6+
Во многом все как в Django. Много скопированого кода.
7+
8+
## Настройки
9+
10+
Настройки провайдеров хранятся в настройке `EMAIL_PROVIDERS`:
11+
12+
```python
13+
EMAIL_PROVIDERS = {
14+
'console': {
15+
'BACKEND': 'fastapi_django.mail.backends.console.EmailBackend',
16+
},
17+
'dummy': {
18+
'BACKEND': 'fastapi_django.mail.backends.dummy.EmailBackend',
19+
},
20+
'filebased': {
21+
'BACKEND': 'fastapi_django.mail.backends.filebased.EmailBackend',
22+
"OPTIONS": {
23+
"file_path": "mails",
24+
}
25+
},
26+
'locmem': {
27+
'BACKEND': 'fastapi_django.mail.backends.locmem.EmailBackend',
28+
},
29+
'smtpsrv': {
30+
'BACKEND': 'fastapi_django.mail.backends.smtp.EmailBackend',
31+
"HOST": env.str("SMTPSRV_HOST"),
32+
"PORT": env.str("SMTPSRV_PORT"),
33+
"OPTIONS": {
34+
"username": env.str("SMTPSRV_USERNAME"),
35+
"password": env.str("SMTPSRV_PASSWORD"),
36+
"from_email": env.str("SMTPSRV_FROM_EMAIL"),
37+
},
38+
},
39+
}
40+
```
41+
42+
## Отправка электронных писем
43+
44+
Для отправки элеектронных писем реализована функция `fastapi_django.mail.send_mail`. Чтобы отправить письмо через
45+
конкретный провайдер, необходимо передать его алиас в параметр `provider`:
46+
47+
```python
48+
from fastapi_django.mail import send_mail
49+
await send_mail(
50+
subject="Тема письма",
51+
body="Тело письма",
52+
recipient_list=["[email protected]"],
53+
provider="smtpsrv"
54+
)
55+
```
56+
57+
## Реализации в сторонних библиотеках
58+
59+
Чтобы написать свой бэкенд, необходимо отнаследоваться от базового класса `fastapi_django.mail.backends.base.BaseEmailBackend`
60+
и реализовать необходимые методы.
61+
62+
### Нестандартные способы отправки писем
63+
64+
См. библиотеки fastapi-django-kz и fastapi-django-nkz.
65+
66+
## TODO
67+
68+
1. Фикстуры для работы с почтой https://pytest-django.readthedocs.io/en/stable/helpers.html#clearing-of-mail-outbox
69+
2. Показалась интересной библиотека, которая реализует бекенд, который отправляет письмо в рамках серели таски https://pypi.org/project/django-celery-email/
70+
71+
## Ссылки
72+
73+
1. Поддержка множества бекендов отправки почты в Django https://code.djangoproject.com/ticket/35514#ticket, PR https://github.com/django/django/pull/18421
74+
2. https://pypi.org/project/django-celery-email/
75+
3. https://github.com/django-ses/django-ses/tree/main
76+
4.

fastapi_django/conf/global_settings.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,13 @@
3232

3333
MANAGEMENT: list[dict] = []
3434

35-
# Custom logging configuration.
3635
LOGGING: dict[str, Any] = {}
36+
37+
EMAIL_PROVIDERS: dict[str, Any] = {}
38+
39+
DEFAULT_CHARSET = "utf-8"
40+
41+
# Whether to send SMTP 'Date' header in the local time zone or in UTC.
42+
EMAIL_USE_LOCALTIME = False
43+
44+
DEFAULT_EMAIL_PROVIDER_ALIAS = "default"

fastapi_django/mail/__init__.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""
2+
Tools for sending email.
3+
"""
4+
from typing import Any, Iterable, Sequence
5+
6+
from fastapi_django.conf import settings
7+
# Imported for backwards compatibility and for the sake
8+
# of a cleaner namespace. These symbols used to be in
9+
# fastapi_django/core/mail.py before the introduction of email
10+
# backends and the subsequent reorganization (See #10355)
11+
from fastapi_django.mail.message import (
12+
DEFAULT_ATTACHMENT_MIME_TYPE,
13+
BadHeaderError,
14+
EmailAlternative,
15+
EmailAttachment,
16+
EmailMessage,
17+
EmailMultiAlternatives,
18+
SafeMIMEMultipart,
19+
SafeMIMEText,
20+
forbid_multi_line_headers,
21+
make_msgid,
22+
)
23+
from fastapi_django.mail.utils import DNS_NAME, CachedDnsName, get_provider, get_required, get_option
24+
25+
__all__ = [
26+
"CachedDnsName",
27+
"DNS_NAME",
28+
"EmailMessage",
29+
"EmailMultiAlternatives",
30+
"SafeMIMEText",
31+
"SafeMIMEMultipart",
32+
"DEFAULT_ATTACHMENT_MIME_TYPE",
33+
"make_msgid",
34+
"BadHeaderError",
35+
"forbid_multi_line_headers",
36+
"get_connection",
37+
"send_mail",
38+
"send_mass_mail",
39+
"EmailAlternative",
40+
"EmailAttachment",
41+
"outbox"
42+
]
43+
44+
from fastapi_django.utils.module_loading import import_string
45+
46+
outbox: list = []
47+
48+
49+
def get_connection(fail_silently: bool = False, provider: str = settings.DEFAULT_EMAIL_PROVIDER_ALIAS, **kw: Any):
50+
backend = get_required(provider, "BACKEND")
51+
klass = import_string(backend)
52+
return klass(fail_silently=fail_silently, provider=provider, **kw)
53+
54+
55+
async def send_mail(
56+
subject: str,
57+
body: str,
58+
recipient_list: Sequence[str],
59+
from_email: str | None = None,
60+
fail_silently: bool = False,
61+
html_message=None,
62+
connection=None,
63+
provider: str = settings.DEFAULT_EMAIL_PROVIDER_ALIAS,
64+
):
65+
"""
66+
Если передан connection, то using игнорируется
67+
"""
68+
connection = connection or get_connection(fail_silently=fail_silently, provider=provider)
69+
from_email = from_email or get_option(provider, "from_email")
70+
mail = EmailMultiAlternatives(
71+
subject, body, from_email, recipient_list, connection=connection
72+
)
73+
if html_message:
74+
mail.attach_alternative(html_message, "text/html")
75+
76+
return await mail.send()
77+
78+
79+
async def send_mass_mail(
80+
datatuple, fail_silently=False, connection=None, provider: str = settings.DEFAULT_EMAIL_PROVIDER_ALIAS,
81+
):
82+
"""
83+
Given a datatuple of (subject, message, from_email, recipient_list), send
84+
each message to each recipient list. Return the number of emails sent.
85+
86+
If from_email is None, use the DEFAULT_FROM_EMAIL setting.
87+
If auth_user and auth_password are set, use them to log in.
88+
If auth_user is None, use the EMAIL_HOST_USER setting.
89+
If auth_password is None, use the EMAIL_HOST_PASSWORD setting.
90+
91+
Note: The API for this method is frozen. New code wanting to extend the
92+
functionality should use the EmailMessage class directly.
93+
"""
94+
connection = connection or get_connection(fail_silently=fail_silently, provider=provider)
95+
messages = [
96+
EmailMessage(subject, body, sender, recipient, connection=connection, prodiver=provider)
97+
for subject, body, sender, recipient in datatuple
98+
]
99+
return await connection.send_messages(messages)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Mail backends shipped with Django.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Base email backend class."""
2+
from typing import Any
3+
4+
from fastapi_django.conf import settings
5+
from fastapi_django.mail import get_provider
6+
7+
8+
class BaseEmailBackend:
9+
"""
10+
Base class for email backend implementations.
11+
12+
Subclasses must at least overwrite send_messages().
13+
14+
open() and close() can be called indirectly by using a backend object as a
15+
context manager:
16+
17+
with backend as connection:
18+
# do something with connection
19+
pass
20+
"""
21+
22+
def __init__(self, fail_silently: bool = False, provider: str = settings.DEFAULT_EMAIL_PROVIDER_ALIAS, **kw: Any) -> None:
23+
self.fail_silently = fail_silently
24+
self.provider = provider
25+
26+
async def open(self):
27+
"""
28+
Open a network connection.
29+
30+
This method can be overwritten by backend implementations to
31+
open a network connection.
32+
33+
It's up to the backend implementation to track the status of
34+
a network connection if it's needed by the backend.
35+
36+
This method can be called by applications to force a single
37+
network connection to be used when sending mails. See the
38+
send_messages() method of the SMTP backend for a reference
39+
implementation.
40+
41+
The default implementation does nothing.
42+
"""
43+
pass
44+
45+
async def close(self):
46+
"""Close a network connection."""
47+
pass
48+
49+
async def __aenter__(self):
50+
try:
51+
await self.open()
52+
except Exception:
53+
await self.close()
54+
raise
55+
return self
56+
57+
async def __aexit__(self, exc_type, exc_value, traceback):
58+
await self.close()
59+
60+
async def send_messages(self, email_messages):
61+
"""
62+
Send one or more EmailMessage objects and return the number of email
63+
messages sent.
64+
"""
65+
raise NotImplementedError(
66+
"subclasses of BaseEmailBackend must override send_messages() method"
67+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
Email backend that writes messages to console instead of sending them.
3+
"""
4+
5+
import sys
6+
import threading
7+
8+
from fastapi_django.mail.backends.base import BaseEmailBackend
9+
10+
11+
class EmailBackend(BaseEmailBackend):
12+
"""
13+
TODO: нужно что то в асинхронное переделывать?
14+
"""
15+
def __init__(self, *args, **kwargs):
16+
super().__init__(*args, **kwargs)
17+
self.stream = kwargs.pop("stream", sys.stdout)
18+
self._lock = threading.RLock()
19+
20+
def write_message(self, message):
21+
msg = message.message()
22+
msg_data = msg.as_bytes()
23+
charset = (
24+
msg.get_charset().get_output_charset() if msg.get_charset() else "utf-8"
25+
)
26+
msg_data = msg_data.decode(charset)
27+
self.stream.write("%s\n" % msg_data)
28+
self.stream.write("-" * 79)
29+
self.stream.write("\n")
30+
31+
async def send_messages(self, email_messages):
32+
"""Write all messages to the stream in a thread-safe way."""
33+
if not email_messages:
34+
return
35+
msg_count = 0
36+
with self._lock:
37+
try:
38+
stream_created = await self.open()
39+
for message in email_messages:
40+
self.write_message(message)
41+
self.stream.flush() # flush after each message
42+
msg_count += 1
43+
if stream_created:
44+
await self.close()
45+
except Exception:
46+
if not self.fail_silently:
47+
raise
48+
return msg_count
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""
2+
Dummy email backend that does nothing.
3+
"""
4+
5+
from fastapi_django.mail.backends.base import BaseEmailBackend
6+
7+
8+
class EmailBackend(BaseEmailBackend):
9+
async def send_messages(self, email_messages):
10+
return len(list(email_messages))

0 commit comments

Comments
 (0)