diff --git a/helpdesk/email.py b/helpdesk/email.py index 1b0df203..3a95d198 100644 --- a/helpdesk/email.py +++ b/helpdesk/email.py @@ -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,47 +464,18 @@ 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, - fail_silently=True, - ) + 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, - fail_silently=True, - ) + t.send( + {'assigned_to': ('updated_owner', context), + 'ticket_cc': ('updated_cc', context)}, + fail_silently=True, + ) return t diff --git a/helpdesk/forms.py b/helpdesk/forms.py index 4192e3e9..777587ea 100644 --- a/helpdesk/forms.py +++ b/helpdesk/forms.py @@ -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,56 +238,16 @@ 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, - fail_silently=True, - files=files, - ) + 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, + ) class TicketForm(AbstractTicketForm): diff --git a/helpdesk/lib.py b/helpdesk/lib.py index 0692213a..b5564b98 100644 --- a/helpdesk/lib.py +++ b/helpdesk/lib.py @@ -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 +from base64 import encodebytes as b64encode +from base64 import decodebytes 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 '. 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', '
')) - - 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 diff --git a/helpdesk/management/commands/escalate_tickets.py b/helpdesk/management/commands/escalate_tickets.py index b2788762..88d26e31 100644 --- a/helpdesk/management/commands/escalate_tickets.py +++ b/helpdesk/management/commands/escalate_tickets.py @@ -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,32 +107,12 @@ 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, - fail_silently=True, - ) + t.send( + {'submitter': ('escalated_submitter', context), + 'ticket_cc': ('escalated_cc', context), + 'assigned_to': ('escalated_owner', context)} + fail_silently=True, + ) if verbose: print(" - Esclating %s from %s>%s" % ( diff --git a/helpdesk/models.py b/helpdesk/models.py index 01ca72d5..fd425bb9 100644 --- a/helpdesk/models.py +++ b/helpdesk/models.py @@ -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 diff --git a/helpdesk/templated_email.py b/helpdesk/templated_email.py new file mode 100644 index 00000000..ab327bd6 --- /dev/null +++ b/helpdesk/templated_email.py @@ -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 '. 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', '
')) + + 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 diff --git a/helpdesk/views/staff.py b/helpdesk/views/staff.py index ec8b729a..df986c1c 100644 --- a/helpdesk/views/staff.py +++ b/helpdesk/views/staff.py @@ -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,83 +601,44 @@ 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) + if reassigned: + template_staff = 'assigned_owner' + elif f.new_status == Ticket.RESOLVED_STATUS: + template_staff = 'resolved_owner' + elif f.new_status == Ticket.CLOSED_STATUS: + template_staff = 'closed_owner' + else: + template_staff = 'updated_owner' - template_suffix = 'cc' + messages_sent_to.update(ticket.send( + {'assigned_to': (template_staff, context)}, + dont_send_to=messages_sent_to, + fail_silently=True, + files=files, + )) - 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 reassigned: + template_cc = 'assigned_cc' + elif f.new_status == Ticket.RESOLVED_STATUS: + template_cc = 'resolved_cc' + elif f.new_status == Ticket.CLOSED_STATUS: + template_cc = 'closed_cc' + else: + template_cc = 'updated_cc' - 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: - template_staff = 'resolved_owner' - elif f.new_status == Ticket.CLOSED_STATUS: - template_staff = 'closed_owner' - 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, - 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: - template_cc = 'resolved_cc' - elif f.new_status == Ticket.CLOSED_STATUS: - template_cc = 'closed_cc' - else: - template_cc = 'updated_cc' - - send_templated_mail( - template_cc, - context, - recipients=ticket.queue.updated_ticket_cc, - sender=ticket.queue.from_address, - fail_silently=True, - files=files, - ) + 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, - 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, - ) + 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, + )) elif action == 'delete': t.delete()