DRY out email sending code and normalize behavior

This refactor removes duplicated logic for deciding whom the messages get sent to.
It also normalizes behavior ensuring that all CCed addresses are sent to in all cases that CCed individuals should be notified.
This commit is contained in:
Timothy Hobbs 2018-10-31 16:24:57 +01:00
parent 9a45d28c95
commit 6c37d73d4e
No known key found for this signature in database
GPG Key ID: 9CA9B3D779CEEDE7
7 changed files with 249 additions and 361 deletions

View File

@ -39,7 +39,7 @@ from django.utils.translation import ugettext as _
from django.utils import encoding, timezone
from helpdesk import settings
from helpdesk.lib import send_templated_mail, safe_template_context, process_attachments
from helpdesk.lib import safe_template_context, process_attachments
from helpdesk.models import Queue, Ticket, TicketCC, FollowUp, IgnoreEmail
from django.contrib.auth.models import User
@ -54,6 +54,7 @@ STRIPPED_SUBJECT_STRINGS = [
"Automatic reply: ",
]
def process_email(quiet=False):
for q in Queue.objects.filter(
email_box_type__isnull=False,
@ -463,46 +464,17 @@ def ticket_from_message(message, queue, logger):
context = safe_template_context(t)
if new:
if sender_email:
send_templated_mail(
'newticket_submitter',
context,
recipients=sender_email,
sender=queue.from_address,
fail_silently=True,
)
if queue.new_ticket_cc:
send_templated_mail(
'newticket_cc',
context,
recipients=queue.new_ticket_cc,
sender=queue.from_address,
fail_silently=True,
)
if queue.updated_ticket_cc and queue.updated_ticket_cc != queue.new_ticket_cc:
send_templated_mail(
'newticket_cc',
context,
recipients=queue.updated_ticket_cc,
sender=queue.from_address,
t.send(
{'submitter': ('newticket_submitter', context),
'new_ticket_cc': ('newticket_cc', context),
'ticket_cc': ('newticket_cc', context)},
fail_silently=True,
)
else:
context.update(comment=f.comment)
if t.assigned_to:
send_templated_mail(
'updated_owner',
context,
recipients=t.assigned_to.email,
sender=queue.from_address,
fail_silently=True,
)
if queue.updated_ticket_cc:
send_templated_mail(
'updated_cc',
context,
recipients=queue.updated_ticket_cc,
sender=queue.from_address,
t.send(
{'assigned_to': ('updated_owner', context),
'ticket_cc': ('updated_cc', context)},
fail_silently=True,
)

View File

@ -17,7 +17,7 @@ from django.utils.translation import ugettext_lazy as _
from django.contrib.auth import get_user_model
from django.utils import timezone
from helpdesk.lib import send_templated_mail, safe_template_context, process_attachments
from helpdesk.lib import safe_template_context, process_attachments
from helpdesk.models import (Ticket, Queue, FollowUp, Attachment, IgnoreEmail, TicketCC,
CustomField, TicketCustomFieldValue, TicketDependency, UserSettings)
from helpdesk import settings as helpdesk_settings
@ -238,53 +238,13 @@ class AbstractTicketForm(CustomFieldMixin, forms.Form):
context = safe_template_context(ticket)
context['comment'] = followup.comment
messages_sent_to = []
if ticket.submitter_email:
send_templated_mail(
'newticket_submitter',
context,
recipients=ticket.submitter_email,
sender=queue.from_address,
fail_silently=True,
files=files,
)
messages_sent_to.append(ticket.submitter_email)
if ticket.assigned_to and \
ticket.assigned_to != user and \
ticket.assigned_to.usersettings_helpdesk.email_on_ticket_assign and \
ticket.assigned_to.email and \
ticket.assigned_to.email not in messages_sent_to:
send_templated_mail(
'assigned_owner',
context,
recipients=ticket.assigned_to.email,
sender=queue.from_address,
fail_silently=True,
files=files,
)
messages_sent_to.append(ticket.assigned_to.email)
if queue.new_ticket_cc and queue.new_ticket_cc not in messages_sent_to:
send_templated_mail(
'newticket_cc',
context,
recipients=queue.new_ticket_cc,
sender=queue.from_address,
fail_silently=True,
files=files,
)
messages_sent_to.append(queue.new_ticket_cc)
if queue.updated_ticket_cc and \
queue.updated_ticket_cc != queue.new_ticket_cc and \
queue.updated_ticket_cc not in messages_sent_to:
send_templated_mail(
'newticket_cc',
context,
recipients=queue.updated_ticket_cc,
sender=queue.from_address,
roles = {'submitter': ('newticket_submitter', context),
'new_ticket_cc': ('newticket_cc', context),
'ticket_cc': ('newticket_cc', context)}
if ticket.assigned_to and ticket.assigned_to.usersettings_helpdesk.email_on_ticket_assign:
roles['assigned_to'] = ('assigned_owner', context)
ticket.send(
roles,
fail_silently=True,
files=files,
)

View File

@ -9,142 +9,22 @@ lib.py - Common functions (eg multipart e-mail)
import logging
import mimetypes
import os
from smtplib import SMTPException
from django.conf import settings
from django.db.models import Q
from django.utils import six
from django.utils.encoding import smart_text
from django.utils.safestring import mark_safe
from helpdesk.models import Attachment, EmailTemplate
import six
from model_utils import Choices
if six.PY3:
from base64 import encodebytes as b64encode
from base64 import decodebytes as b64decode
else:
from base64 import urlsafe_b64encode as b64encode
from base64 import urlsafe_b64decode as b64decode
logger = logging.getLogger('helpdesk')
def send_templated_mail(template_name,
context,
recipients,
sender=None,
bcc=None,
fail_silently=False,
files=None):
"""
send_templated_mail() is a wrapper around Django's e-mail routines that
allows us to easily send multipart (text/plain & text/html) e-mails using
templates that are stored in the database. This lets the admin provide
both a text and a HTML template for each message.
template_name is the slug of the template to use for this message (see
models.EmailTemplate)
context is a dictionary to be used when rendering the template
recipients can be either a string, eg 'a@b.com', or a list of strings.
sender should contain a string, eg 'My Site <me@z.com>'. If you leave it
blank, it'll use settings.DEFAULT_FROM_EMAIL as a fallback.
bcc is an optional list of addresses that will receive this message as a
blind carbon copy.
fail_silently is passed to Django's mail routine. Set to 'True' to ignore
any errors at send time.
files can be a list of tuples. Each tuple should be a filename to attach,
along with the File objects to be read. files can be blank.
"""
from django.core.mail import EmailMultiAlternatives
from django.template import engines
from_string = engines['django'].from_string
from helpdesk.models import EmailTemplate
from helpdesk.settings import HELPDESK_EMAIL_SUBJECT_TEMPLATE, \
HELPDESK_EMAIL_FALLBACK_LOCALE
locale = context['queue'].get('locale') or HELPDESK_EMAIL_FALLBACK_LOCALE
try:
t = EmailTemplate.objects.get(template_name__iexact=template_name, locale=locale)
except EmailTemplate.DoesNotExist:
try:
t = EmailTemplate.objects.get(template_name__iexact=template_name, locale__isnull=True)
except EmailTemplate.DoesNotExist:
logger.warning('template "%s" does not exist, no mail sent', template_name)
return # just ignore if template doesn't exist
subject_part = from_string(
HELPDESK_EMAIL_SUBJECT_TEMPLATE % {
"subject": t.subject
}).render(context).replace('\n', '').replace('\r', '')
footer_file = os.path.join('helpdesk', locale, 'email_text_footer.txt')
text_part = from_string(
"%s{%% include '%s' %%}" % (t.plain_text, footer_file)
).render(context)
email_html_base_file = os.path.join('helpdesk', locale, 'email_html_base.html')
# keep new lines in html emails
if 'comment' in context:
context['comment'] = mark_safe(context['comment'].replace('\r\n', '<br>'))
html_part = from_string(
"{%% extends '%s' %%}{%% block title %%}"
"%s"
"{%% endblock %%}{%% block content %%}%s{%% endblock %%}" %
(email_html_base_file, t.heading, t.html)
).render(context)
if isinstance(recipients, str):
if recipients.find(','):
recipients = recipients.split(',')
elif type(recipients) != list:
recipients = [recipients]
msg = EmailMultiAlternatives(subject_part, text_part,
sender or settings.DEFAULT_FROM_EMAIL,
recipients, bcc=bcc)
msg.attach_alternative(html_part, "text/html")
if files:
for filename, filefield in files:
mime = mimetypes.guess_type(filename)
if mime[0] is not None and mime[0] == "text/plain":
with open(filefield.path, 'r') as attachedfile:
content = attachedfile.read()
msg.attach(filename, content)
else:
if six.PY3:
msg.attach_file(filefield.path)
else:
with open(filefield.path, 'rb') as attachedfile:
content = attachedfile.read()
msg.attach(filename, content)
logger.debug('Sending email to: {!r}'.format(recipients))
try:
return msg.send()
except SMTPException as e:
logger.exception('SMTPException raised while sending email to {}'.format(recipients))
if not fail_silently:
raise e
return 0
def query_to_dict(results, descriptions):
"""
Replacement method for cursor.dictfetchall() as that method no longer

View File

@ -24,7 +24,7 @@ except ImportError:
from datetime import datetime as timezone
from helpdesk.models import Queue, Ticket, FollowUp, EscalationExclusion, TicketChange
from helpdesk.lib import send_templated_mail, safe_template_context
from helpdesk.lib import safe_template_context
class Command(BaseCommand):
@ -107,30 +107,10 @@ def escalate_tickets(queues, verbose):
context = safe_template_context(t)
if t.submitter_email:
send_templated_mail(
'escalated_submitter',
context,
recipients=t.submitter_email,
sender=t.queue.from_address,
fail_silently=True,
)
if t.queue.updated_ticket_cc:
send_templated_mail(
'escalated_cc',
context,
recipients=t.queue.updated_ticket_cc,
sender=t.queue.from_address,
fail_silently=True,
)
if t.assigned_to:
send_templated_mail(
'escalated_owner',
context,
recipients=t.assigned_to.email,
sender=t.queue.from_address,
t.send(
{'submitter': ('escalated_submitter', context),
'ticket_cc': ('escalated_cc', context),
'assigned_to': ('escalated_owner', context)}
fail_silently=True,
)

View File

@ -23,6 +23,8 @@ import re
import six
import uuid
from .templated_email import send_templated_mail
@python_2_unicode_compatible
class Queue(models.Model):
@ -491,6 +493,54 @@ class Ticket(models.Model):
default=mk_secret,
)
def send(self, roles, dont_send_to=None, **kwargs):
"""
Send notifications to everyone interested in this ticket.
The the roles argument is a dictionary mapping from roles to (template, context) pairs.
If a role is not present in the dictionary, users of that type will not recieve the notification.
The following roles exist:
- 'submitter'
- 'new_ticket_cc'
- 'ticket_cc'
- 'assigned_to'
Here is an example roles dictionary:
{
'submitter': (template_name, context),
'assigned_to': (template_name2, context),
}
**kwargs are passed to send_templated_mail defined in templated_mail.py
returns the set of email addresses the notification was delivered to.
"""
recipients = set()
if dont_send_to is not None:
recipients.update(dont_send_to)
def should_receive(email):
return email and email not in recipients
def send(role, recipient):
if recipient and recipient not in recipients and role in roles:
template, context = roles[role]
send_templated_mail(template, context, recipient, sender=self.queue.from_address, **kwargs)
recipients.add(recipient)
send('submitter', self.submitter_email)
send('new_ticket_cc', self.queue.new_ticket_cc)
if self.assigned_to and self.assigned_to.usersettings_helpdesk.email_on_ticket_assign:
send('assigned_to', self.assigned_to.email)
send('ticket_cc', self.queue.updated_ticket_cc)
for cc in self.ticketcc_set.all():
send('ticket_cc', cc.email_address)
return recipients
def _get_assigned_to(self):
""" Custom property to allow us to easily print 'Unassigned' if a
ticket has no owner, or the users name if it's assigned. If the user

114
helpdesk/templated_email.py Normal file
View File

@ -0,0 +1,114 @@
import os
import mimetypes
import logging
from smtplib import SMTPException
from django.utils.safestring import mark_safe
logger = logging.getLogger('helpdesk')
def send_templated_mail(template_name,
context,
recipients,
sender=None,
bcc=None,
fail_silently=False,
files=None):
"""
send_templated_mail() is a wrapper around Django's e-mail routines that
allows us to easily send multipart (text/plain & text/html) e-mails using
templates that are stored in the database. This lets the admin provide
both a text and a HTML template for each message.
template_name is the slug of the template to use for this message (see
models.EmailTemplate)
context is a dictionary to be used when rendering the template
recipients can be either a string, eg 'a@b.com', or a list of strings.
sender should contain a string, eg 'My Site <me@z.com>'. If you leave it
blank, it'll use settings.DEFAULT_FROM_EMAIL as a fallback.
bcc is an optional list of addresses that will receive this message as a
blind carbon copy.
fail_silently is passed to Django's mail routine. Set to 'True' to ignore
any errors at send time.
files can be a list of tuples. Each tuple should be a filename to attach,
along with the File objects to be read. files can be blank.
"""
from django.core.mail import EmailMultiAlternatives
from django.template import engines
from_string = engines['django'].from_string
from helpdesk.models import EmailTemplate
from helpdesk.settings import HELPDESK_EMAIL_SUBJECT_TEMPLATE, \
HELPDESK_EMAIL_FALLBACK_LOCALE
locale = context['queue'].get('locale') or HELPDESK_EMAIL_FALLBACK_LOCALE
try:
t = EmailTemplate.objects.get(template_name__iexact=template_name, locale=locale)
except EmailTemplate.DoesNotExist:
try:
t = EmailTemplate.objects.get(template_name__iexact=template_name, locale__isnull=True)
except EmailTemplate.DoesNotExist:
logger.warning('template "%s" does not exist, no mail sent', template_name)
return # just ignore if template doesn't exist
subject_part = from_string(
HELPDESK_EMAIL_SUBJECT_TEMPLATE % {
"subject": t.subject
}).render(context).replace('\n', '').replace('\r', '')
footer_file = os.path.join('helpdesk', locale, 'email_text_footer.txt')
text_part = from_string(
"%s{%% include '%s' %%}" % (t.plain_text, footer_file)
).render(context)
email_html_base_file = os.path.join('helpdesk', locale, 'email_html_base.html')
# keep new lines in html emails
if 'comment' in context:
context['comment'] = mark_safe(context['comment'].replace('\r\n', '<br>'))
html_part = from_string(
"{%% extends '%s' %%}{%% block title %%}"
"%s"
"{%% endblock %%}{%% block content %%}%s{%% endblock %%}" %
(email_html_base_file, t.heading, t.html)
).render(context)
if isinstance(recipients, str):
if recipients.find(','):
recipients = recipients.split(',')
elif type(recipients) != list:
recipients = [recipients]
msg = EmailMultiAlternatives(subject_part, text_part,
sender or settings.DEFAULT_FROM_EMAIL,
recipients, bcc=bcc)
msg.attach_alternative(html_part, "text/html")
if files:
for filename, filefield in files:
mime = mimetypes.guess_type(filename)
if mime[0] is not None and mime[0] == "text/plain":
with open(filefield.path, 'r') as attachedfile:
content = attachedfile.read()
msg.attach(filename, content)
else:
msg.attach_file(filefield.path)
logger.debug('Sending email to: {!r}'.format(recipients))
try:
return msg.send()
except SMTPException as e:
logger.exception('SMTPException raised while sending email to {}'.format(recipients))
if not fail_silently:
raise e
return 0

View File

@ -47,7 +47,7 @@ from helpdesk.forms import (
TicketCCEmailForm, TicketCCUserForm, EditFollowUpForm, TicketDependencyForm
)
from helpdesk.lib import (
send_templated_mail, query_to_dict, apply_query, safe_template_context,
query_to_dict, apply_query, safe_template_context,
process_attachments, queue_template_context,
)
from helpdesk.models import (
@ -578,8 +578,6 @@ def update_ticket(request, ticket_id, public=False):
if new_status == Ticket.RESOLVED_STATUS or ticket.resolution is None:
ticket.resolution = comment
messages_sent_to = []
# ticket might have changed above, so we re-instantiate context with the
# (possibly) updated ticket.
context = safe_template_context(ticket)
@ -588,6 +586,11 @@ def update_ticket(request, ticket_id, public=False):
comment=f.comment,
)
messages_sent_to = set()
try:
messages_sent_to.add(request.user.email)
except AttributeError:
pass
if public and (f.comment or (
f.new_status in (Ticket.RESOLVED_STATUS,
Ticket.CLOSED_STATUS))):
@ -598,40 +601,13 @@ def update_ticket(request, ticket_id, public=False):
else:
template = 'updated_'
template_suffix = 'submitter'
roles = {
'submitter': (template + 'submitter', context),
'ticket_cc': (template + 'cc', context),
'assigned_to': (template + 'cc', context),
}
messages_sent_to.update(ticket.send(roles, dont_send_to=messages_sent_to, fail_silently=True, files=files,))
if ticket.submitter_email:
send_templated_mail(
template + template_suffix,
context,
recipients=ticket.submitter_email,
sender=ticket.queue.from_address,
fail_silently=True,
files=files,
)
messages_sent_to.append(ticket.submitter_email)
template_suffix = 'cc'
for cc in ticket.ticketcc_set.all():
if cc.email_address not in messages_sent_to:
send_templated_mail(
template + template_suffix,
context,
recipients=cc.email_address,
sender=ticket.queue.from_address,
fail_silently=True,
files=files,
)
messages_sent_to.append(cc.email_address)
if ticket.assigned_to and \
request.user != ticket.assigned_to and \
ticket.assigned_to.email and \
ticket.assigned_to.email not in messages_sent_to:
# We only send e-mails to staff members if the ticket is updated by
# another user. The actual template varies, depending on what has been
# changed.
if reassigned:
template_staff = 'assigned_owner'
elif f.new_status == Ticket.RESOLVED_STATUS:
@ -641,23 +617,13 @@ def update_ticket(request, ticket_id, public=False):
else:
template_staff = 'updated_owner'
if (not reassigned or
(reassigned and
ticket.assigned_to.usersettings_helpdesk.email_on_ticket_assign)) or \
(not reassigned and
ticket.assigned_to.usersettings_helpdesk.email_on_ticket_change):
send_templated_mail(
template_staff,
context,
recipients=ticket.assigned_to.email,
sender=ticket.queue.from_address,
messages_sent_to.update(ticket.send(
{'assigned_to': (template_staff, context)},
dont_send_to=messages_sent_to,
fail_silently=True,
files=files,
)
messages_sent_to.append(ticket.assigned_to.email)
))
if ticket.queue.updated_ticket_cc and ticket.queue.updated_ticket_cc not in messages_sent_to:
if reassigned:
template_cc = 'assigned_cc'
elif f.new_status == Ticket.RESOLVED_STATUS:
@ -667,14 +633,12 @@ def update_ticket(request, ticket_id, public=False):
else:
template_cc = 'updated_cc'
send_templated_mail(
template_cc,
context,
recipients=ticket.queue.updated_ticket_cc,
sender=ticket.queue.from_address,
messages_sent_to.update(ticket.send(
{'ticket_cc': (template_cc, context)},
dont_send_to=messages_sent_to,
fail_silently=True,
files=files,
)
))
ticket.save()
@ -760,51 +724,19 @@ def mass_update(request):
context.update(resolution=t.resolution,
queue=queue_template_context(t.queue))
messages_sent_to = []
messages_sent_to = set()
try:
messages_sent_to.add(request.user.email)
except AttributeError:
pass
if t.submitter_email:
send_templated_mail(
'closed_submitter',
context,
recipients=t.submitter_email,
sender=t.queue.from_address,
messages_sent_to.update(t.send(
{'submitter': ('closed_submitter', context),
'ticket_cc': ('closed_cc', context),
'assigned_to': ('closded_owner', context)},
dont_send_to=messages_sent_to,
fail_silently=True,
)
messages_sent_to.append(t.submitter_email)
for cc in t.ticketcc_set.all():
if cc.email_address not in messages_sent_to:
send_templated_mail(
'closed_submitter',
context,
recipients=cc.email_address,
sender=t.queue.from_address,
fail_silently=True,
)
messages_sent_to.append(cc.email_address)
if t.assigned_to and \
request.user != t.assigned_to and \
t.assigned_to.email and \
t.assigned_to.email not in messages_sent_to:
send_templated_mail(
'closed_owner',
context,
recipients=t.assigned_to.email,
sender=t.queue.from_address,
fail_silently=True,
)
messages_sent_to.append(t.assigned_to.email)
if t.queue.updated_ticket_cc and \
t.queue.updated_ticket_cc not in messages_sent_to:
send_templated_mail(
'closed_cc',
context,
recipients=t.queue.updated_ticket_cc,
sender=t.queue.from_address,
fail_silently=True,
)
))
elif action == 'delete':
t.delete()