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