Skip to content

Commit 5b47a0c

Browse files
committed
Add initial implementation of simple email sending
1 parent e98a68b commit 5b47a0c

File tree

12 files changed

+209
-0
lines changed

12 files changed

+209
-0
lines changed

config/django/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
'styleguide_example.testing_examples.apps.TestingExamplesConfig',
4141
'styleguide_example.integrations.apps.IntegrationsConfig',
4242
'styleguide_example.files.apps.FilesConfig',
43+
'styleguide_example.emails.apps.EmailsConfig',
4344
]
4445

4546
THIRD_PARTY_APPS = [
@@ -181,3 +182,4 @@
181182
from config.settings.sentry import * # noqa
182183

183184
from config.settings.files_and_storages import * # noqa
185+
from config.settings.email_sending import * # noqa

config/settings/email_sending.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from config.env import env, env_to_enum
2+
3+
from styleguide_example.emails.enums import EmailSendingStrategy
4+
5+
# local | mailtrap
6+
EMAIL_SENDING_STRATEGY = env_to_enum(
7+
EmailSendingStrategy,
8+
env("EMAIL_SENDING_STRATEGY", default="local")
9+
)
10+
11+
EMAIL_SENDING_FAILURE_TRIGGER = env.bool("EMAIL_SENDING_FAILURE_TRIGGER", default=False)
12+
EMAIL_SENDING_FAILURE_RATE = env.float("EMAIL_SENDING_FAILURE_RATE", default=0.2)
13+
14+
if EMAIL_SENDING_STRATEGY == EmailSendingStrategy.LOCAL:
15+
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
16+
17+
if EMAIL_SENDING_STRATEGY == EmailSendingStrategy.MAILTRAP:
18+
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
19+
EMAIL_HOST = env("MAILTRAP_EMAIL_HOST")
20+
EMAIL_HOST_USER = env("MAILTRAP_EMAIL_HOST_USER")
21+
EMAIL_HOST_PASSWORD = env("MAILTRAP_EMAIL_HOST_PASSWORD")
22+
EMAIL_PORT = env("MAILTRAP_EMAIL_PORT")

styleguide_example/emails/__init__.py

Whitespace-only changes.

styleguide_example/emails/admin.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.contrib import admin
2+
3+
from styleguide_example.emails.models import Email
4+
from styleguide_example.emails.services import email_send_all
5+
6+
7+
@admin.register(Email)
8+
class EmailAdmin(admin.ModelAdmin):
9+
list_display = ["id", "subject", "to", "status", "sent_at"]
10+
actions = ["send_email"]
11+
12+
def get_queryset(self, request):
13+
"""
14+
We want to defer the `html` and `plain_text` fields,
15+
since we are not showing them in the list & we don't need to fetch them.
16+
17+
Potentially, those fields can be quite heavy.
18+
"""
19+
queryset = super().get_queryset(request)
20+
return queryset.defer("html", "plain_text")
21+
22+
@admin.action(description="Send selected emails.")
23+
def send_email(self, request, queryset):
24+
email_send_all(queryset)

styleguide_example/emails/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class EmailsConfig(AppConfig):
5+
default_auto_field = 'django.db.models.BigAutoField'
6+
name = 'styleguide_example.emails'

styleguide_example/emails/enums.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from enum import Enum
2+
3+
4+
class EmailSendingStrategy(Enum):
5+
LOCAL = "local"
6+
MAILTRAP = "mailtrap"
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Generated by Django 3.2.13 on 2022-05-16 13:28
2+
3+
from django.db import migrations, models
4+
import django.utils.timezone
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
initial = True
10+
11+
dependencies = [
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='Email',
17+
fields=[
18+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
20+
('updated_at', models.DateTimeField(auto_now=True)),
21+
('status', models.CharField(choices=[('READY', 'Ready'), ('SENDING', 'Sending'), ('SENT', 'Sent'), ('FAILED', 'Failed')], db_index=True, default='READY', max_length=255)),
22+
('to', models.EmailField(max_length=254)),
23+
('html', models.TextField()),
24+
('plain_text', models.TextField()),
25+
('sent_at', models.DateTimeField(blank=True, null=True)),
26+
],
27+
options={
28+
'abstract': False,
29+
},
30+
),
31+
]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 3.2.13 on 2022-05-16 13:37
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('emails', '0001_initial'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='email',
15+
name='subject',
16+
field=models.CharField(default='', max_length=255),
17+
preserve_default=False,
18+
),
19+
]

styleguide_example/emails/migrations/__init__.py

Whitespace-only changes.

styleguide_example/emails/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from django.db import models
2+
3+
from styleguide_example.common.models import BaseModel
4+
5+
6+
class Email(BaseModel):
7+
class Status(models.TextChoices):
8+
READY = "READY", "Ready"
9+
SENDING = "SENDING", "Sending"
10+
SENT = "SENT", "Sent"
11+
FAILED = "FAILED", "Failed"
12+
13+
status = models.CharField(
14+
max_length=255,
15+
db_index=True,
16+
choices=Status.choices,
17+
default=Status.READY
18+
)
19+
20+
to = models.EmailField()
21+
subject = models.CharField(max_length=255)
22+
23+
html = models.TextField()
24+
plain_text = models.TextField()
25+
26+
sent_at = models.DateTimeField(blank=True, null=True)

styleguide_example/emails/services.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from django.db import transaction
2+
from django.db.models.query import QuerySet
3+
from django.core.mail import EmailMultiAlternatives
4+
from django.utils import timezone
5+
6+
from styleguide_example.core.exceptions import ApplicationError
7+
8+
from styleguide_example.common.services import model_update
9+
10+
from styleguide_example.emails.models import Email
11+
from styleguide_example.emails.tasks import email_send as email_send_task
12+
13+
14+
@transaction.atomic
15+
def email_send(email: Email) -> Email:
16+
if email.status != Email.Status.SENDING:
17+
raise ApplicationError(f"Cannot send non-ready emails. Current status is {email.status}")
18+
19+
subject = email.subject
20+
from_email = "styleguide-example@hacksoft.io"
21+
to = email.to
22+
23+
html = email.html
24+
plain_text = email.plain_text
25+
26+
msg = EmailMultiAlternatives(subject, plain_text, from_email, [to])
27+
msg.attach_alternative(html, "text/html")
28+
29+
msg.send()
30+
31+
email, _ = model_update(
32+
instance=email,
33+
fields=["status", "sent_at"],
34+
data={
35+
"status": Email.Status.SENT,
36+
"sent_at": timezone.now()
37+
}
38+
)
39+
return email
40+
41+
42+
def email_send_all(emails: QuerySet[Email]):
43+
"""
44+
This is a very specific service.
45+
46+
We don't want to decorate with @transaction.atomic,
47+
since we are executing updates, 1 by 1, in a separate atomic block,
48+
so we can trigger transaction.on_commit for each email.
49+
"""
50+
for email in emails:
51+
with transaction.atomic():
52+
Email.objects.filter(id=email.id).update(
53+
status=Email.Status.SENDING
54+
)
55+
56+
# Create a closure, to capture the proper value of each id
57+
transaction.on_commit(
58+
(
59+
lambda email_id: lambda: email_send_task.delay(email_id)
60+
)(email.id)
61+
)

styleguide_example/emails/tasks.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from celery import shared_task
2+
3+
from styleguide_example.emails.models import Email
4+
5+
6+
@shared_task
7+
def email_send(email_id):
8+
# TODO: Add error handling
9+
email = Email.objects.get(id=email_id)
10+
11+
from styleguide_example.emails.services import email_send
12+
email_send(email)

0 commit comments

Comments
 (0)