1
+ import base64
1
2
import uuid
2
3
3
4
from django .conf import settings
4
5
from django .contrib .auth import get_user_model
5
6
from django .db import models
7
+ from django .dispatch import receiver
6
8
from django .template .loader import render_to_string
7
9
from django .utils import translation
10
+ from django_ses import signals
8
11
from django .utils .translation import gettext_lazy as _
9
12
10
13
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
12
15
13
16
14
17
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'
15
26
16
27
id = models .UUIDField (primary_key = True , default = uuid .uuid4 , editable = False )
17
28
@@ -21,6 +32,15 @@ class SentEmail(models.Model):
21
32
subject = models .TextField ()
22
33
html = models .FileField (upload_to = sent_email_upload_path )
23
34
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
+
24
44
creation_datetime = models .DateTimeField (auto_now_add = True )
25
45
update_datetime = models .DateTimeField (auto_now = True )
26
46
@@ -31,13 +51,107 @@ class Meta:
31
51
def __str__ (self ):
32
52
return f"{ self .subject } to { self .to_email } "
33
53
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
+
34
148
35
149
class SendEmailMixin (object ):
36
150
37
151
def get_to_email (self ):
38
152
return self .email
39
153
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 ):
41
155
from .tasks import send_email
42
156
if not DJANGO_SES_PLUS_SETTINGS ["SEND_EMAIL" ]:
43
157
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=
50
164
if language :
51
165
translation .activate (language )
52
166
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
+
53
180
html_message = render_to_string (template_path , context )
54
181
send_email .delay (
55
182
subject = subject ,
56
183
to_email = self .get_to_email (),
57
184
html_message = html_message ,
185
+ attachments = attachments ,
58
186
from_email = from_email ,
59
187
recipient_id = recipient_id
60
188
)
0 commit comments