-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathbase.py
340 lines (286 loc) · 12.2 KB
/
base.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
import logging
from typing import Optional, Union
import html2text
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.db.models import QuerySet
from django.template.loader import render_to_string
from django.utils import translation
from django.utils.translation import gettext_lazy as _
from django_pony_express.errors import EmailServiceAttachmentError, EmailServiceConfigError
from django_pony_express.settings import PONY_LOG_RECIPIENTS, PONY_LOGGER_NAME
class BaseEmailServiceFactory:
"""
Factory for creating emails of the same type but with recipient-dependent content.
"""
_errors = []
service_class = None
recipient_email_list = []
def __init__(self, recipient_email_list: Union[list, tuple, QuerySet] = None, **kwargs) -> None:
"""
Initialisation takes optionally a list of recipients. Doesn't have to be a list of strings because
fetching the actual email from a complex data structure can be done in the method `get_email_from_recipient()`
"""
# Empty error list on initialisation
self._errors = []
super().__init__()
if recipient_email_list:
self.recipient_email_list = recipient_email_list
def is_valid(self, raise_exception: bool = True) -> bool:
"""
This function ensures that all required variables for the email object are set. Can be overridden and extended
but again, make sure that super() is called
"""
if not self.service_class:
self._errors.append(_("Email factory requires a mail service class."))
if not len(self.get_recipient_list()):
self._errors.append(_("Email factory requires a target mail address."))
if self._errors and raise_exception:
raise EmailServiceConfigError(self._errors)
return not bool(len(self._errors))
def get_recipient_list(self) -> list:
"""
Fetches the recipient list. Provided as a method to be able to customise it in the derived class.
"""
return self.recipient_email_list
def get_email_from_recipient(self, recipient) -> str:
"""
Fetches the email from the recipient. Sometimes a list of mail addresses is passed, so we just have to
return the current variable. But if we get a database object, we need to extract the email first.
For example: `return user.email`
"""
return recipient
def get_context_data(self) -> dict:
"""
Fetch context data required equally for every email created by the factory.
"""
return {}
def has_errors(self) -> bool:
"""
Check if any errors are stored inside this class instance
"""
return bool(len(self._errors))
@property
def errors(self) -> list:
"""
Getter for fetching the stored error messages.
Errors shall not be set manually, that's why we use a property here.
"""
return self._errors
def process(self, raise_exception: bool = True) -> int:
"""
Create an email of `self.service_class` for every recipient. Per-email logic like setting the salutation
is handled within each email class.
Returns the number of sent emails.
"""
counter = 0
if self.is_valid(raise_exception=raise_exception):
for recipient in self.get_recipient_list():
email_object = self.service_class(
recipient_email_list=[self.get_email_from_recipient(recipient)],
context_data={"recipient": recipient, **self.get_context_data()},
)
email_object.process()
counter += 1
return counter
class BaseEmailService:
"""
Class for wrapping all required things for email creation.
"""
SUBJECT_PREFIX = None
SUBJECT_DELIMITER = " - "
FROM_EMAIL = None
REPLY_TO_ADDRESS = []
_errors = []
_logger: logging.Logger = None
subject = None
template_name = None
template_txt_name = None
recipient_email_list = []
cc_email_list = []
bcc_email_list = []
attachment_list = []
def __init__(
self,
recipient_email_list: Optional[Union[list, tuple, str]] = None,
context_data: Optional[dict] = None,
attachment_list: Optional[list] = None,
**kwargs,
) -> None:
"""
Initialisation takes a single or list of email addresses and some context data. This context data
might be provided from the factory to avoid querying data more than necessary.
"""
# Empty error list on initialisation
self._errors = []
self._logger = self._get_logger()
super().__init__()
# Ensure that a single email address is wrapped in a list, so we can use it in the `to` kwarg.
if isinstance(recipient_email_list, str):
recipient_email_list = [recipient_email_list]
self.recipient_email_list = recipient_email_list if recipient_email_list else []
self.context_data = context_data if context_data else {}
self.attachment_list = attachment_list if attachment_list else []
def _get_logger(self) -> logging.Logger:
self._logger = logging.getLogger(PONY_LOGGER_NAME) if self._logger is None else self._logger
return self._logger
def get_context_data(self) -> dict:
"""
This method provides the required variables for the base email template. If more variables are required,
just override this method and make sure, super() is called
"""
return self.context_data
def get_subject(self) -> str:
"""
This method provides the subject of the email. Prefixes every subject to create a similar look and feel across
emails. Can be overridden if required.
"""
if self.SUBJECT_PREFIX:
return f"{self.SUBJECT_PREFIX}{self.SUBJECT_DELIMITER}{self.subject}"
return self.subject
def get_from_email(self) -> str:
"""
Use set `FROM_EMAIL` or the django base `DEFAULT_FROM_EMAIL` if it is not set
"""
return self.FROM_EMAIL if self.FROM_EMAIL else settings.DEFAULT_FROM_EMAIL
def get_cc_emails(self) -> list:
"""
Returns a list of emails as a string which will be used in the "CC" field of the generated email.
"""
return self.cc_email_list
def get_bcc_emails(self) -> list:
"""
Returns a list of emails as a string which will be used in the "BCC" field of the generated email.
"""
return self.bcc_email_list
def get_reply_to_emails(self) -> list:
"""
Ensure "reply to" is a list
"""
return [self.REPLY_TO_ADDRESS] if isinstance(self.REPLY_TO_ADDRESS, str) else self.REPLY_TO_ADDRESS
def get_translation(self) -> Union[str, None]:
"""
Tries to fetch the current translation from the django settings.
"""
language_str_length = 2
try:
return (
settings.LANGUAGE_CODE[:2]
if settings.LANGUAGE_CODE and len(settings.LANGUAGE_CODE) >= language_str_length
else None
)
except TypeError:
return None
def get_attachments(self) -> list:
"""
Method to be overwritten. Returns a list of file-paths which will be attached to the newly created email.
"""
return self.attachment_list
def _add_attachments(self, msg: EmailMultiAlternatives):
"""
Method to encapsulate logic of adding attachments to an email object.
"""
for attachment in self.get_attachments():
if isinstance(attachment, dict):
try:
msg.attach(attachment["filename"], attachment["file"], attachment.get("mimetype", None))
except KeyError as e:
raise EmailServiceAttachmentError(
_("Missing or mislabeled data provided for email attachment.")
) from e
else:
msg.attach_file(attachment)
return msg
def _build_mail_object(self) -> EmailMultiAlternatives:
"""
This method creates a mail object. It collects the required variables, sets the subject and makes sure that
a "reply_to" is set for maximum convenience during the runtime.
The plaintext part of the email is generated from the html to avoid maintaining duplicate templates.
"""
# Optionally set translation language for date formatting etc.
language = self.get_translation()
if language:
translation.activate(language)
# Gather variables
mail_attributes = self.get_context_data()
# Render HTML body content
html_content = render_to_string(self.template_name, mail_attributes)
# Render TXT body part if a template is explicitly set, otherwise convert HTML template to plain text
if not self.template_txt_name:
h = html2text.HTML2Text()
# Set body width to "infinite" to avoid weird line breaks
h.body_width = 0
text_content = h.handle(html_content)
else:
text_content = render_to_string(self.template_txt_name, mail_attributes)
# Build mail object
msg = EmailMultiAlternatives(
self.get_subject(),
text_content,
from_email=self.get_from_email(),
cc=self.get_cc_emails(),
bcc=self.get_bcc_emails(),
reply_to=self.get_reply_to_emails(),
to=self.recipient_email_list,
)
msg.attach_alternative(html_content, "text/html")
# Add attachments (if available)
msg = self._add_attachments(msg)
# Return mail object
return msg
def is_valid(self, raise_exception: bool = True) -> bool:
"""
This function ensures that all required variables for the email object are set. Can be overridden and extended
but again, make sure that super() is called.
"""
if not self.get_subject():
self._errors.append(_("Email service requires a subject."))
if not self.template_name:
self._errors.append(_("Email service requires a template."))
if not len(self.recipient_email_list):
self._errors.append(_("Email service requires a target mail address."))
if self._errors and raise_exception:
raise EmailServiceConfigError(self._errors)
return not bool(len(self._errors))
def has_errors(self) -> bool:
"""
Check if any errors are stored inside this class instance
"""
return bool(len(self._errors))
@property
def errors(self) -> list:
"""
Getter for fetching the stored error messages.
Errors shall not be set manually, that's why we use a property here.
"""
return self._errors
def _send_and_log_email(self, msg: EmailMultiAlternatives) -> bool:
"""
Method to be called by the thread. Enables logging since we won't have any sync return values.
"""
result = False
recipients_as_string = " ".join(self.recipient_email_list)
try:
result = msg.send()
if PONY_LOG_RECIPIENTS:
self._logger.info(_('Email "%s" successfully sent to %s.') % (msg.subject, recipients_as_string))
else:
self._logger.info(_('Email "%s" successfully sent.') % msg.subject)
except Exception:
if PONY_LOG_RECIPIENTS:
self._logger.exception(
_('An error occurred sending email "%s" to "%s".') % (msg.subject, recipients_as_string)
)
else:
self._logger.exception(_('An error occurred sending email "%s".') % msg.subject)
return result
def process(self, raise_exception: bool = True) -> bool:
"""
Public method which is called to actually send an email. Calls validation first and returns the result of
"msg.send()"
"""
result = False
if self.is_valid(raise_exception=raise_exception):
msg = self._build_mail_object()
result = self._send_and_log_email(msg=msg)
return result