1import os 2 3from collections import namedtuple 4from uuid import uuid4 5from email.mime.nonmultipart import MIMENonMultipart 6 7from django.core.exceptions import ValidationError 8from django.core.mail import EmailMessage, EmailMultiAlternatives 9from django.db import models 10from django.utils.encoding import smart_str 11from django.utils.translation import pgettext_lazy, gettext_lazy as _ 12from django.utils import timezone 13from jsonfield import JSONField 14 15from post_office import cache 16from post_office.fields import CommaSeparatedEmailField 17 18from .connections import connections 19from .settings import context_field_class, get_log_level, get_template_engine, get_override_recipients 20from .validators import validate_email_with_name, validate_template_syntax 21 22 23PRIORITY = namedtuple('PRIORITY', 'low medium high now')._make(range(4)) 24STATUS = namedtuple('STATUS', 'sent failed queued requeued')._make(range(4)) 25 26 27class Email(models.Model): 28 """ 29 A model to hold email information. 30 """ 31 32 PRIORITY_CHOICES = [(PRIORITY.low, _("low")), (PRIORITY.medium, _("medium")), 33 (PRIORITY.high, _("high")), (PRIORITY.now, _("now"))] 34 STATUS_CHOICES = [(STATUS.sent, _("sent")), (STATUS.failed, _("failed")), 35 (STATUS.queued, _("queued")), (STATUS.requeued, _("requeued"))] 36 37 from_email = models.CharField(_("Email From"), max_length=254, 38 validators=[validate_email_with_name]) 39 to = CommaSeparatedEmailField(_("Email To")) 40 cc = CommaSeparatedEmailField(_("Cc")) 41 bcc = CommaSeparatedEmailField(_("Bcc")) 42 subject = models.CharField(_("Subject"), max_length=989, blank=True) 43 message = models.TextField(_("Message"), blank=True) 44 html_message = models.TextField(_("HTML Message"), blank=True) 45 """ 46 Emails with 'queued' status will get processed by ``send_queued`` command. 47 Status field will then be set to ``failed`` or ``sent`` depending on 48 whether it's successfully delivered. 49 """ 50 status = models.PositiveSmallIntegerField( 51 _("Status"), 52 choices=STATUS_CHOICES, db_index=True, 53 blank=True, null=True) 54 priority = models.PositiveSmallIntegerField(_("Priority"), 55 choices=PRIORITY_CHOICES, 56 blank=True, null=True) 57 created = models.DateTimeField(auto_now_add=True, db_index=True) 58 last_updated = models.DateTimeField(db_index=True, auto_now=True) 59 scheduled_time = models.DateTimeField(_("Scheduled Time"), 60 blank=True, null=True, db_index=True, 61 help_text=_("The scheduled sending time")) 62 expires_at = models.DateTimeField(_("Expires"), 63 blank=True, null=True, 64 help_text=_("Email won't be sent after this timestamp")) 65 message_id = models.CharField("Message-ID", null=True, max_length=255, editable=False) 66 number_of_retries = models.PositiveIntegerField(null=True, blank=True) 67 headers = JSONField(_('Headers'), blank=True, null=True) 68 template = models.ForeignKey('post_office.EmailTemplate', blank=True, 69 null=True, verbose_name=_("Email template"), 70 on_delete=models.CASCADE) 71 context = context_field_class(_('Context'), blank=True, null=True) 72 backend_alias = models.CharField(_("Backend alias"), blank=True, default='', 73 max_length=64) 74 75 class Meta: 76 app_label = 'post_office' 77 verbose_name = pgettext_lazy("Email address", "Email") 78 verbose_name_plural = pgettext_lazy("Email addresses", "Emails") 79 80 def __init__(self, *args, **kwargs): 81 super().__init__(*args, **kwargs) 82 self._cached_email_message = None 83 84 def __str__(self): 85 return '%s' % self.to 86 87 def email_message(self): 88 """ 89 Returns Django EmailMessage object for sending. 90 """ 91 if self._cached_email_message: 92 return self._cached_email_message 93 94 return self.prepare_email_message() 95 96 def prepare_email_message(self): 97 """ 98 Returns a django ``EmailMessage`` or ``EmailMultiAlternatives`` object, 99 depending on whether html_message is empty. 100 """ 101 if get_override_recipients(): 102 self.to = get_override_recipients() 103 104 if self.template is not None: 105 engine = get_template_engine() 106 subject = engine.from_string(self.template.subject).render(self.context) 107 plaintext_message = engine.from_string(self.template.content).render(self.context) 108 multipart_template = engine.from_string(self.template.html_content) 109 html_message = multipart_template.render(self.context) 110 111 else: 112 subject = smart_str(self.subject) 113 plaintext_message = self.message 114 multipart_template = None 115 html_message = self.html_message 116 117 connection = connections[self.backend_alias or 'default'] 118 if isinstance(self.headers, dict) or self.expires_at or self.message_id: 119 headers = dict(self.headers or {}) 120 if self.expires_at: 121 headers.update({'Expires': self.expires_at.strftime("%a, %-d %b %H:%M:%S %z")}) 122 if self.message_id: 123 headers.update({'Message-ID': self.message_id}) 124 else: 125 headers = None 126 127 if html_message: 128 if plaintext_message: 129 msg = EmailMultiAlternatives( 130 subject=subject, body=plaintext_message, from_email=self.from_email, 131 to=self.to, bcc=self.bcc, cc=self.cc, 132 headers=headers, connection=connection) 133 msg.attach_alternative(html_message, "text/html") 134 else: 135 msg = EmailMultiAlternatives( 136 subject=subject, body=html_message, from_email=self.from_email, 137 to=self.to, bcc=self.bcc, cc=self.cc, 138 headers=headers, connection=connection) 139 msg.content_subtype = 'html' 140 if hasattr(multipart_template, 'attach_related'): 141 multipart_template.attach_related(msg) 142 143 else: 144 msg = EmailMessage( 145 subject=subject, body=plaintext_message, from_email=self.from_email, 146 to=self.to, bcc=self.bcc, cc=self.cc, 147 headers=headers, connection=connection) 148 149 for attachment in self.attachments.all(): 150 if attachment.headers: 151 mime_part = MIMENonMultipart(*attachment.mimetype.split('/')) 152 mime_part.set_payload(attachment.file.read()) 153 for key, val in attachment.headers.items(): 154 try: 155 mime_part.replace_header(key, val) 156 except KeyError: 157 mime_part.add_header(key, val) 158 msg.attach(mime_part) 159 else: 160 msg.attach(attachment.name, attachment.file.read(), mimetype=attachment.mimetype or None) 161 attachment.file.close() 162 163 self._cached_email_message = msg 164 return msg 165 166 def dispatch(self, log_level=None, 167 disconnect_after_delivery=True, commit=True): 168 """ 169 Sends email and log the result. 170 """ 171 try: 172 self.email_message().send() 173 status = STATUS.sent 174 message = '' 175 exception_type = '' 176 except Exception as e: 177 status = STATUS.failed 178 message = str(e) 179 exception_type = type(e).__name__ 180 181 # If run in a bulk sending mode, reraise and let the outer 182 # layer handle the exception 183 if not commit: 184 raise 185 186 if commit: 187 self.status = status 188 self.save(update_fields=['status']) 189 190 if log_level is None: 191 log_level = get_log_level() 192 193 # If log level is 0, log nothing, 1 logs only sending failures 194 # and 2 means log both successes and failures 195 if log_level == 1: 196 if status == STATUS.failed: 197 self.logs.create(status=status, message=message, 198 exception_type=exception_type) 199 elif log_level == 2: 200 self.logs.create(status=status, message=message, 201 exception_type=exception_type) 202 203 return status 204 205 def clean(self): 206 if self.scheduled_time and self.expires_at and self.scheduled_time > self.expires_at: 207 raise ValidationError(_("The scheduled time may not be later than the expires time.")) 208 209 def save(self, *args, **kwargs): 210 self.full_clean() 211 return super().save(*args, **kwargs) 212 213 214class Log(models.Model): 215 """ 216 A model to record sending email sending activities. 217 """ 218 219 STATUS_CHOICES = [(STATUS.sent, _("sent")), (STATUS.failed, _("failed"))] 220 221 email = models.ForeignKey(Email, editable=False, related_name='logs', 222 verbose_name=_('Email address'), on_delete=models.CASCADE) 223 date = models.DateTimeField(auto_now_add=True) 224 status = models.PositiveSmallIntegerField(_('Status'), choices=STATUS_CHOICES) 225 exception_type = models.CharField(_('Exception type'), max_length=255, blank=True) 226 message = models.TextField(_('Message')) 227 228 class Meta: 229 app_label = 'post_office' 230 verbose_name = _("Log") 231 verbose_name_plural = _("Logs") 232 233 def __str__(self): 234 return str(self.date) 235 236 237class EmailTemplateManager(models.Manager): 238 def get_by_natural_key(self, name, language, default_template): 239 return self.get(name=name, language=language, default_template=default_template) 240 241 242class EmailTemplate(models.Model): 243 """ 244 Model to hold template information from db 245 """ 246 name = models.CharField(_('Name'), max_length=255, help_text=_("e.g: 'welcome_email'")) 247 description = models.TextField(_('Description'), blank=True, 248 help_text=_("Description of this template.")) 249 created = models.DateTimeField(auto_now_add=True) 250 last_updated = models.DateTimeField(auto_now=True) 251 subject = models.CharField(max_length=255, blank=True, 252 verbose_name=_("Subject"), validators=[validate_template_syntax]) 253 content = models.TextField(blank=True, 254 verbose_name=_("Content"), validators=[validate_template_syntax]) 255 html_content = models.TextField(blank=True, 256 verbose_name=_("HTML content"), validators=[validate_template_syntax]) 257 language = models.CharField(max_length=12, 258 verbose_name=_("Language"), 259 help_text=_("Render template in alternative language"), 260 default='', blank=True) 261 default_template = models.ForeignKey('self', related_name='translated_templates', 262 null=True, default=None, verbose_name=_('Default template'), on_delete=models.CASCADE) 263 264 objects = EmailTemplateManager() 265 266 class Meta: 267 app_label = 'post_office' 268 unique_together = ('name', 'language', 'default_template') 269 verbose_name = _("Email Template") 270 verbose_name_plural = _("Email Templates") 271 ordering = ['name'] 272 273 def __str__(self): 274 return '%s %s' % (self.name, self.language) 275 276 def natural_key(self): 277 return (self.name, self.language, self.default_template) 278 279 def save(self, *args, **kwargs): 280 # If template is a translation, use default template's name 281 if self.default_template and not self.name: 282 self.name = self.default_template.name 283 284 template = super().save(*args, **kwargs) 285 cache.delete(self.name) 286 return template 287 288 289def get_upload_path(instance, filename): 290 """Overriding to store the original filename""" 291 if not instance.name: 292 instance.name = filename # set original filename 293 date = timezone.now().date() 294 filename = '{name}.{ext}'.format(name=uuid4().hex, 295 ext=filename.split('.')[-1]) 296 297 return os.path.join('post_office_attachments', str(date.year), 298 str(date.month), str(date.day), filename) 299 300 301class Attachment(models.Model): 302 """ 303 A model describing an email attachment. 304 """ 305 file = models.FileField(_('File'), upload_to=get_upload_path) 306 name = models.CharField(_('Name'), max_length=255, help_text=_("The original filename")) 307 emails = models.ManyToManyField(Email, related_name='attachments', 308 verbose_name=_('Emails')) 309 mimetype = models.CharField(max_length=255, default='', blank=True) 310 headers = JSONField(_('Headers'), blank=True, null=True) 311 312 class Meta: 313 app_label = 'post_office' 314 verbose_name = _("Attachment") 315 verbose_name_plural = _("Attachments") 316 317 def __str__(self): 318 return self.name 319