Skip to content

Commit

Permalink
Add DjangoQBackend email backend
Browse files Browse the repository at this point in the history
  • Loading branch information
Alschn committed Feb 3, 2024
1 parent 08fe1a9 commit 0f7467a
Show file tree
Hide file tree
Showing 10 changed files with 288 additions and 9 deletions.
8 changes: 8 additions & 0 deletions accounts/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ def render_mail(
) -> EmailMultiAlternatives | EmailMessage:
return super().render_mail(template_prefix, email, context, headers)

def send_mail(
self,
template_prefix: str,
email: str,
context: dict
) -> None:
super().send_mail(template_prefix, email, context)


class SocialAccountAdapter(DefaultSocialAccountAdapter):
pass
22 changes: 13 additions & 9 deletions core/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
'accounts',
'links',
'tracks',
# 'emails',
'emails',
]

MIDDLEWARE = [
Expand Down Expand Up @@ -276,12 +276,14 @@

ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN = 3 * 60
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 1
ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN = env.int('ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN', default=1 * 60)
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = env.int('ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS', default=1)
ACCOUNT_EMAIL_CONFIRMATION_HMAC = True

ACCOUNT_LOGIN_ATTEMPTS_LIMIT = 5
ACCOUNT_LOGIN_ATTEMPTS_TIMEOUT = 5 * 60
ACCOUNT_MAX_EMAIL_ADDRESSES = None

ACCOUNT_LOGIN_ATTEMPTS_LIMIT = env.int('ACCOUNT_LOGIN_ATTEMPTS_LIMIT', default=5)
ACCOUNT_LOGIN_ATTEMPTS_TIMEOUT = env.int('ACCOUNT_LOGIN_ATTEMPTS_TIMEOUT', default=1 * 60)

OLD_PASSWORD_FIELD_ENABLED = True

Expand Down Expand Up @@ -393,17 +395,19 @@
USE_SMTP = env.bool('USE_SMTP', default=False)

if USE_SMTP:
# todo: setup email async backend
EMAIL_BACKEND = 'core.shared.email_backends.CeleryEmailBackend'
EMAIL_BACKEND = 'emails.backends.DjangoQBackend'
DJANGO_Q_EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'

EMAIL_HOST = env('EMAIL_HOST')
EMAIL_PORT = env('EMAIL_PORT', cast=int)
EMAIL_HOST_USER = env('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD')
EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS')
EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS', default=False)
EMAIL_USE_SSL = env.bool('EMAIL_USE_SSL', default=False)
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
else:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
EMAIL_BACKEND = 'emails.backends.DjangoQBackend'
DJANGO_Q_EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

# Custom user
# https://docs.djangoproject.com/en/4.2/topics/auth/customizing/
Expand Down
Empty file added emails/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions emails/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class EmailsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'emails'
53 changes: 53 additions & 0 deletions emails/backends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from typing import Any

from django.conf import settings
from django.core.mail import get_connection
from django.core.mail.backends.base import BaseEmailBackend
from django.utils.module_loading import import_string

from core.shared.tasks import create_async_task
from emails.serialization import email_to_dict, dict_to_email

DEFAULT_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_BACKEND = getattr(settings, 'DJANGO_Q_EMAIL_BACKEND', DEFAULT_BACKEND)
EMAIL_ERROR_HANDLER = getattr(settings, 'DJANGO_Q_EMAIL_ERROR_HANDLER', None)
DJANGO_Q_EMAIL_USE_DICTS = getattr(settings, 'DJANGO_Q_EMAIL_USE_DICTS', True)


class DjangoQBackend(BaseEmailBackend):
use_dicts = DJANGO_Q_EMAIL_USE_DICTS

def send_messages(self, email_messages: Any) -> int:
num_sent = 0
for email_message in email_messages:
if self.use_dicts:
email_message = email_to_dict(email_message)

create_async_task(send_message, email_message)

num_sent += 1
return num_sent


def send_message(email_message: Any) -> None:
"""
Sends the specified email synchronously.
See DjangoQBackend for sending in the background.
"""
try:
if isinstance(email_message, dict):
email_message = dict_to_email(email_message)

connection = email_message.connection
email_message.connection = get_connection(backend=EMAIL_BACKEND)
try:
email_message.send()
finally:
email_message.connection = connection

except Exception as ex:
if not EMAIL_ERROR_HANDLER:
raise

email_error_handler = import_string(EMAIL_ERROR_HANDLER)
email_error_handler(email_message, ex)
Empty file added emails/migrations/__init__.py
Empty file.
47 changes: 47 additions & 0 deletions emails/serialization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from typing import Any

from django.core.mail.message import EmailMessage, EmailMultiAlternatives


def email_to_dict(email_message: Any) -> dict:
"""
Converts the specified email message to a dictionary representation.
"""
if type(email_message) not in [EmailMessage, EmailMultiAlternatives, dict]:
raise ValueError(
'The email_message argument must be an instance of '
'EmailMessage, EmailMultiAlternatives or dict.'
)

if isinstance(email_message, dict):
return email_message

email_message_data = {
'subject': email_message.subject,
'body': email_message.body,
'from_email': email_message.from_email,
'to': email_message.to,
'bcc': email_message.bcc,
'attachments': email_message.attachments,
'headers': email_message.extra_headers,
'cc': None,
'reply_to': None,
}

if isinstance(email_message, EmailMultiAlternatives):
email_message_data['alternatives'] = email_message.alternatives

return email_message_data


def dict_to_email(email_message_data: dict) -> EmailMessage | EmailMultiAlternatives:
"""
Creates an EmailMessage or EmailMultiAlternatives instance from the
specified dictionary.
"""
kwargs = {**email_message_data}
alternatives = kwargs.pop('alternatives', None)
return (
EmailMessage(**kwargs) if not alternatives else
EmailMultiAlternatives(alternatives=alternatives, **kwargs)
)
Empty file added emails/tests/__init__.py
Empty file.
65 changes: 65 additions & 0 deletions emails/tests/test_email_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from unittest.mock import patch

from django.core.mail import EmailMessage, EmailMultiAlternatives
from django.test import TestCase

from emails.backends import DjangoQBackend, send_message
from emails.serialization import email_to_dict

patch_create_async_task = patch('emails.backends.create_async_task')


class DjangoQEmailBackendTests(TestCase):

@patch_create_async_task
def test_send_messages_creates_async_task(self, mock_create_async_task):
message = EmailMessage(
subject='Subject',
body='Body',
from_email='test@example.com',
to=['test1@example.com']
)
dict_message = email_to_dict(message)

backend = DjangoQBackend()
backend.use_dicts = True
backend.send_messages([message])

mock_create_async_task.assert_called_once_with(send_message, dict_message)

@patch_create_async_task
def test_send_messages_creates_multiple_async_tasks(self, mock_create_async_task):
message1 = EmailMessage(
subject='Subject',
body='Body',
from_email='test@example.com',
to=['test1@example.com']
)
message2 = EmailMultiAlternatives(
subject='Subject',
body='Body',
from_email='test@example.com',
to=['test1@example.com']
)
message2.attach_alternative('<body>Hello world!</body>', 'text/html')

backend = DjangoQBackend()
backend.use_dicts = True
backend.send_messages([message1, message2])

self.assertEqual(mock_create_async_task.call_count, 2)

@patch_create_async_task
def test_send_messages_creates_async_task_use_dicts_false(self, mock_create_async_task):
message = EmailMessage(
subject='Subject',
body='Body',
from_email='test@example.com',
to=['test1@example.com']
)

backend = DjangoQBackend()
backend.use_dicts = False
backend.send_messages([message])

mock_create_async_task.assert_called_once_with(send_message, message)
96 changes: 96 additions & 0 deletions emails/tests/test_serialization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import email
from unittest import TestCase

from django.conf import settings
from django.core.mail import EmailMessage, EmailMultiAlternatives

from emails.serialization import email_to_dict, dict_to_email


class EmailSerializationTests(TestCase):

def test_email_message_to_dict(self):
payload = EmailMessage(
subject='Test email',
body='This is a test email.',
from_email='test@example.com',
to=['test@example.com']
)
result = email_to_dict(payload)
self.assertEqual(result['subject'], 'Test email')
self.assertEqual(result['body'], 'This is a test email.')
self.assertEqual(result['from_email'], 'test@example.com')
self.assertEqual(result['to'], ['test@example.com'])

def test_email_multi_alternatives_to_dict(self):
message = EmailMultiAlternatives(
subject='Test email',
body='This is a test email.',
from_email='test@example.com',
to=['test@example.com']
)
message.attach_alternative(
'<p>This is a test email.</p>',
'text/html'
)
result = email_to_dict(message)
self.assertEqual(result['subject'], message.subject)
self.assertEqual(result['body'], message.body)
self.assertEqual(result['from_email'], message.from_email)
self.assertEqual(result['to'], message.to)
self.assertEqual(result['alternatives'], message.alternatives)

def test_email_dict_to_dict(self):
payload = {
'subject': 'Test email',
'body': 'This is a test email.',
'from_email': 'test@example.com'
}
result = email_to_dict(payload)
self.assertEqual(result, payload)

def test_email_to_dict_invalid_type(self):
with self.assertRaises(ValueError):
email_to_dict('invalid')

with self.assertRaises(ValueError):
email_to_dict(email.message.Message())

def test_dict_to_email(self):
payload = {
'subject': 'Test email',
'body': 'This is a test email.',
'from_email': 'test@example.com'
}
result = dict_to_email(payload)
self.assertTrue(isinstance(result, EmailMessage))
self.assertEqual(result.subject, payload['subject'])
self.assertEqual(result.body, payload['body'])
self.assertEqual(result.from_email, payload['from_email'])

def test_dict_to_email_with_alternatives(self):
payload = {
'subject': 'Test email',
'body': 'This is a test email.',
'from_email': 'test@example.com',
'alternatives': [('<p>This is a test email.</p>', 'text/html')]
}
result = dict_to_email(payload)
self.assertTrue(isinstance(result, EmailMultiAlternatives))
self.assertEqual(result.subject, payload['subject'])
self.assertEqual(result.body, payload['body'])
self.assertEqual(result.from_email, payload['from_email'])
self.assertEqual(result.alternatives, payload['alternatives'])

def test_dict_to_email_empty_dict(self):
payload = {}
result = dict_to_email(payload)
self.assertEqual(result.subject, '')
self.assertEqual(result.body, '')
self.assertEqual(result.from_email, settings.DEFAULT_FROM_EMAIL)
self.assertEqual(result.to, [])
self.assertEqual(result.bcc, [])
self.assertEqual(result.attachments, [])
self.assertEqual(result.extra_headers, {})
self.assertEqual(result.cc, [])
self.assertEqual(result.reply_to, [])

0 comments on commit 0f7467a

Please sign in to comment.