Skip to content

Commit f2e9531

Browse files
omerfarukabacigokselcobantaylanpince
authored
Email attachment feature (#7)
* Implement email attachment feature * Bump version to 1.0.0 * Refactor email attachments feature * Fix typo * Email monitoring and tracking (#8) * add email monitoring and tracking fields * add bounce type fields * add migration * add events extra requirements * add monitoring fields to SentEmailAdmin * send_email method returns sent_email * add ordering to SentEmailAdmin * if the email is opened, clicked or complained, it is also delivered * message_id may not be exists in extra_headers for testing * task required to return a serializable * Update version --------- Co-authored-by: Goksel Coban <goksel326@gmail.com> Co-authored-by: Taylan Pince <taylanpince@gmail.com>
1 parent d600fbe commit f2e9531

9 files changed

+269
-12
lines changed

.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ coverage.xml
1212
.coverage
1313
__pycache__
1414
.DS_Store
15-
*.sqlite*
15+
*.sqlite*
16+
.vscode

django_ses_plus/admin.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ class SentEmailAdmin(admin.ModelAdmin):
77
fieldsets = (
88
("Details", {"fields": ("id", "subject", "from_email", "to_email", "creation_datetime",)}),
99
("Content", {"fields": ("html",)}),
10+
("Monitoring", {"fields": ("message_id", "status", "bounce_type", "bounce_sub_type", "is_opened", "is_clicked", "is_complained")}),
1011
)
11-
readonly_fields = ("id", "subject", "from_email", "to_email", "creation_datetime", "html")
12-
list_display = ("id", "to_email", "subject", "creation_datetime")
12+
readonly_fields = ("id", "subject", "from_email", "to_email", "creation_datetime", "html", "message_id", "status", "bounce_type", "bounce_sub_type", "is_opened", "is_clicked", "is_complained")
13+
list_display = ("id", "to_email", "subject", "status", "creation_datetime")
14+
list_filter = ("status", "is_opened", "is_clicked", "is_complained")
1315
search_fields = ("to_email", "subject", )
16+
ordering = ("-creation_datetime",)
17+
date_hierarchy = "creation_datetime"
1418

1519
def get_actions(self, request):
1620
actions = super().get_actions(request)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 3.1.1 on 2021-01-26 14:20
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import django_ses_plus.utils
6+
import uuid
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('django_ses_plus', '0001_initial'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='SentEmailAttachment',
18+
fields=[
19+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
20+
('filename', models.CharField(max_length=255)),
21+
('content', models.FileField(upload_to=django_ses_plus.utils.sent_email_attachment_upload_path)),
22+
('mimetype', models.CharField(help_text='e.g. text/html, application/pdf, image/png...', max_length=255)),
23+
('creation_datetime', models.DateTimeField(auto_now_add=True)),
24+
('update_datetime', models.DateTimeField(auto_now=True)),
25+
('sent_email', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='django_ses_plus.sentemail')),
26+
],
27+
options={
28+
'verbose_name': 'Sent Email Attachment',
29+
'verbose_name_plural': 'Sent Email Attachments',
30+
},
31+
),
32+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Generated by Django 3.1.3 on 2021-01-27 16:36
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('django_ses_plus', '0002_add_sent_email_attachment_model'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='sentemail',
15+
name='bounce_sub_type',
16+
field=models.CharField(blank=True, max_length=50),
17+
),
18+
migrations.AddField(
19+
model_name='sentemail',
20+
name='bounce_type',
21+
field=models.CharField(blank=True, max_length=50),
22+
),
23+
migrations.AddField(
24+
model_name='sentemail',
25+
name='is_clicked',
26+
field=models.BooleanField(default=False),
27+
),
28+
migrations.AddField(
29+
model_name='sentemail',
30+
name='is_complained',
31+
field=models.BooleanField(default=False),
32+
),
33+
migrations.AddField(
34+
model_name='sentemail',
35+
name='is_opened',
36+
field=models.BooleanField(default=False),
37+
),
38+
migrations.AddField(
39+
model_name='sentemail',
40+
name='message_id',
41+
field=models.CharField(blank=True, max_length=100),
42+
),
43+
migrations.AddField(
44+
model_name='sentemail',
45+
name='status',
46+
field=models.CharField(choices=[('unknown', 'Unknown'), ('sent', 'Sent'), ('delivered', 'Delivered'), ('bounced', 'Bounced')], default='unknown', max_length=50),
47+
),
48+
]

django_ses_plus/models.py

+130-2
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
1+
import base64
12
import uuid
23

34
from django.conf import settings
45
from django.contrib.auth import get_user_model
56
from django.db import models
7+
from django.dispatch import receiver
68
from django.template.loader import render_to_string
79
from django.utils import translation
10+
from django_ses import signals
811
from django.utils.translation import gettext_lazy as _
912

1013
from django_ses_plus.settings import DJANGO_SES_PLUS_SETTINGS
11-
from .utils import sent_email_upload_path
14+
from .utils import sent_email_upload_path, sent_email_attachment_upload_path
1215

1316

1417
class SentEmail(models.Model):
18+
class Status(models.TextChoices):
19+
UNKNOWN = 'unknown', 'Unknown'
20+
SENT = 'sent', 'Sent'
21+
DELIVERED = 'delivered', 'Delivered'
22+
BOUNCED = 'bounced', 'Bounced'
23+
# Don't support rejects and rendering failures for now.
24+
# REJECTED = 'rejected', 'Rejected'
25+
# RENDERING_FAILURE = 'rendering-failure', 'Rendering Failure'
1526

1627
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
1728

@@ -21,6 +32,15 @@ class SentEmail(models.Model):
2132
subject = models.TextField()
2233
html = models.FileField(upload_to=sent_email_upload_path)
2334

35+
# tracking & monitoring
36+
message_id = models.CharField(max_length=100, blank=True)
37+
status = models.CharField(choices=Status.choices, default=Status.UNKNOWN, max_length=50)
38+
bounce_type = models.CharField(max_length=50, blank=True)
39+
bounce_sub_type = models.CharField(max_length=50, blank=True)
40+
is_opened = models.BooleanField(default=False)
41+
is_clicked = models.BooleanField(default=False)
42+
is_complained = models.BooleanField(default=False)
43+
2444
creation_datetime = models.DateTimeField(auto_now_add=True)
2545
update_datetime = models.DateTimeField(auto_now=True)
2646

@@ -31,13 +51,107 @@ class Meta:
3151
def __str__(self):
3252
return f"{self.subject} to {self.to_email}"
3353

54+
@staticmethod
55+
@receiver(signals.send_received)
56+
def send_handler(sender, mail_obj, send_obj, **kwargs):
57+
message_id = mail_obj['messageId']
58+
SentEmail.objects.filter(
59+
message_id=message_id,
60+
status=SentEmail.Status.UNKNOWN,
61+
).update(
62+
status=SentEmail.Status.SENT
63+
)
64+
65+
@staticmethod
66+
@receiver(signals.delivery_received)
67+
def delivery_handler(sender, mail_obj, delivery_obj, **kwargs):
68+
message_id = mail_obj['messageId']
69+
recipients = delivery_obj['recipients']
70+
SentEmail.objects.filter(
71+
message_id=message_id,
72+
to_email__in=recipients,
73+
).update(
74+
status=SentEmail.Status.DELIVERED
75+
)
76+
77+
@staticmethod
78+
@receiver(signals.bounce_received)
79+
def bounce_handler(sender, mail_obj, bounce_obj, **kwargs):
80+
message_id = mail_obj['messageId']
81+
recipients = [r['emailAddress'] for r in bounce_obj['bouncedRecipients']]
82+
SentEmail.objects.filter(
83+
message_id=message_id,
84+
to_email__in=recipients,
85+
).update(
86+
status=SentEmail.Status.BOUNCED,
87+
bounce_type=bounce_obj['bounceType'],
88+
bounce_sub_type=bounce_obj['bounceSubType'],
89+
)
90+
91+
@staticmethod
92+
@receiver(signals.open_received)
93+
def open_handler(sender, mail_obj, open_obj, **kwargs):
94+
# open_obj does not provide recipient emails.
95+
message_id = mail_obj['messageId']
96+
SentEmail.objects.filter(
97+
message_id=message_id,
98+
).update(
99+
status=SentEmail.Status.DELIVERED,
100+
is_opened=True
101+
)
102+
103+
@staticmethod
104+
@receiver(signals.click_received)
105+
def click_handler(sender, mail_obj, click_obj, **kwargs):
106+
# click_obj does not provide recipient emails.
107+
message_id = mail_obj['messageId']
108+
SentEmail.objects.filter(
109+
message_id=message_id,
110+
).update(
111+
status=SentEmail.Status.DELIVERED,
112+
is_clicked=True
113+
)
114+
115+
@staticmethod
116+
@receiver(signals.complaint_received)
117+
def compliant_handler(sender, mail_obj, complaint_obj, **kwargs):
118+
message_id = mail_obj['messageId']
119+
recipients = complaint_obj['complainedRecipients']
120+
SentEmail.objects.filter(
121+
message_id=message_id,
122+
to_email__in=recipients,
123+
).update(
124+
status=SentEmail.Status.DELIVERED,
125+
is_complained=True
126+
)
127+
128+
129+
class SentEmailAttachment(models.Model):
130+
131+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
132+
133+
sent_email = models.ForeignKey(to="django_ses_plus.SentEmail", related_name="attachments", on_delete=models.CASCADE)
134+
filename = models.CharField(max_length=255)
135+
content = models.FileField(upload_to=sent_email_attachment_upload_path)
136+
mimetype = models.CharField(help_text="e.g. text/html, application/pdf, image/png...", max_length=255)
137+
138+
creation_datetime = models.DateTimeField(auto_now_add=True)
139+
update_datetime = models.DateTimeField(auto_now=True)
140+
141+
class Meta:
142+
verbose_name = _("Sent Email Attachment")
143+
verbose_name_plural = _("Sent Email Attachments")
144+
145+
def __str__(self):
146+
return f"{self.filename} ({self.mimetype})"
147+
34148

35149
class SendEmailMixin(object):
36150

37151
def get_to_email(self):
38152
return self.email
39153

40-
def send_email(self, subject, template_path, context, from_email=None, language=None):
154+
def send_email(self, subject, template_path, context, from_email=None, language=None, attachments=None):
41155
from .tasks import send_email
42156
if not DJANGO_SES_PLUS_SETTINGS["SEND_EMAIL"]:
43157
return _("Email cannot be sent due to SEND_EMAIL flag in project settings.")
@@ -50,11 +164,25 @@ def send_email(self, subject, template_path, context, from_email=None, language=
50164
if language:
51165
translation.activate(language)
52166

167+
if attachments is not None:
168+
assert isinstance(attachments, list), "Attachments should be a `list` of `dict` objects."
169+
170+
for attachment in attachments:
171+
assert all([key in attachment for key in ["filename", "content", "mimetype"]]), "Attachments should contain `filename`, `content` and `mimetype`."
172+
173+
if isinstance(attachment["content"], bytes):
174+
# Since celery only accepts JSON serializable types and `bytes` is not JSON serializable,
175+
# Base64 encoding is used to be able to pass attachment content to the celery task,
176+
attachment["content"] = base64.b64encode(attachment["content"]).decode("utf-8")
177+
else:
178+
assert False, f"Attachment contents should be `bytes`, not {type(attachment['content'])}."
179+
53180
html_message = render_to_string(template_path, context)
54181
send_email.delay(
55182
subject=subject,
56183
to_email=self.get_to_email(),
57184
html_message=html_message,
185+
attachments=attachments,
58186
from_email=from_email,
59187
recipient_id=recipient_id
60188
)

django_ses_plus/tasks.py

+19-5
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,55 @@
1+
import base64
12
from uuid import uuid4
23

34
from celery import shared_task
45

56
from django_ses_plus import logger
6-
from .settings import DJANGO_SES_PLUS_SETTINGS
77
from django.core.files.base import ContentFile
88
from django.core.mail import send_mail
99
from django.utils.translation import gettext_lazy as _
1010

11-
from .models import SentEmail
11+
from .models import SentEmail, SentEmailAttachment
12+
from .settings import DJANGO_SES_PLUS_SETTINGS
13+
from .utils import send_mail
1214

1315

1416
@shared_task(retry_kwargs=DJANGO_SES_PLUS_SETTINGS["CELERY_TASK_RETRY_KWARGS"])
15-
def send_email(subject, to_email, html_message, from_email=None, message=None, recipient_id=None):
17+
def send_email(subject, to_email, html_message, from_email=None, message=None, recipient_id=None, attachments=None):
1618
if not DJANGO_SES_PLUS_SETTINGS["SEND_EMAIL"]:
1719
return _("Email cannot be sent due to SEND_EMAIL flag in project settings.")
1820

1921
if not from_email:
2022
from_email = DJANGO_SES_PLUS_SETTINGS["DEFAULT_FROM_EMAIL"]
2123

22-
send_mail(
24+
num_sent, mail = send_mail(
2325
subject=subject,
2426
message=message,
2527
html_message=html_message,
28+
attachments=attachments,
2629
from_email=from_email,
2730
recipient_list=[to_email],
2831
fail_silently=False,
2932
)
3033

3134
try:
32-
SentEmail.objects.create(
35+
sent_email = SentEmail.objects.create(
36+
message_id=mail.extra_headers.get('message_id', ''),
3337
recipient_id=recipient_id,
3438
subject=subject,
3539
html=ContentFile(content=bytes(html_message, encoding="utf8"), name="{}.html".format(uuid4())),
3640
from_email=from_email,
3741
to_email=to_email,
3842
)
43+
if attachments:
44+
for attachment in attachments:
45+
SentEmailAttachment.objects.create(
46+
sent_email=sent_email,
47+
filename=attachment["filename"],
48+
content=ContentFile(content=base64.b64decode(attachment["content"]), name=attachment["filename"]),
49+
mimetype=attachment["mimetype"]
50+
)
3951
except Exception as e:
4052
# Do not retry if object creation fails.
4153
logger.error(str(e), exc_info=e, extra={'trace': True})
54+
else:
55+
return sent_email.id

django_ses_plus/utils.py

+27
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
1+
import base64
2+
from os import path
13
from time import strftime
4+
from uuid import uuid4
5+
6+
from django.core.mail import get_connection
7+
from django.core.mail.message import EmailMultiAlternatives
28

39

410
def sent_email_upload_path(sent_email, filename):
511
filename = filename.split(".")[0]
612
return strftime("emails/%Y/%m/%d/%Y-%m-%d-%H-%M-%S-{}.html".format(filename))
13+
14+
15+
def sent_email_attachment_upload_path(sent_email_attachment, filename):
16+
extension = path.splitext(filename)[1]
17+
return strftime(f"email-attachments/%Y/%m/%d/{uuid4().hex}{extension}")
18+
19+
20+
def send_mail(subject, message, from_email, recipient_list, fail_silently=False, html_message=None, attachments=None):
21+
connection = get_connection(fail_silently=fail_silently)
22+
23+
mail = EmailMultiAlternatives(subject, message, from_email, recipient_list, connection=connection)
24+
25+
if html_message:
26+
mail.attach_alternative(html_message, 'text/html')
27+
28+
if attachments:
29+
for attachment in attachments:
30+
mail.attach(attachment["filename"], base64.b64decode(attachment["content"]), attachment["mimetype"])
31+
32+
num_sent = mail.send()
33+
return num_sent, mail

requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
Django >= 3.2
22
celery >= 4
3-
django-ses >= 1.0.0
3+
django-ses >= 2.1.1

0 commit comments

Comments
 (0)