1import logging 2 3from django.conf import settings 4from django.contrib.auth import get_user_model 5from django.core.mail import get_connection 6from django.core.mail.message import EmailMultiAlternatives 7from django.template.loader import render_to_string 8from django.utils.translation import override 9 10from wagtail.admin.auth import users_with_page_permission 11from wagtail.core.models import GroupApprovalTask, TaskState, WorkflowState 12from wagtail.core.utils import camelcase_to_underscore 13from wagtail.users.models import UserProfile 14 15 16logger = logging.getLogger('wagtail.admin') 17 18 19class OpenedConnection: 20 """Context manager for mail connections to ensure they are closed when manually opened""" 21 def __init__(self, connection): 22 self.connection = connection 23 24 def __enter__(self): 25 self.connection.open() 26 return self.connection 27 28 def __exit__(self, type, value, traceback): 29 self.connection.close() 30 return self.connection 31 32 33def send_mail(subject, message, recipient_list, from_email=None, **kwargs): 34 """ 35 Wrapper around Django's EmailMultiAlternatives as done in send_mail(). 36 Custom from_email handling and special Auto-Submitted header. 37 """ 38 if not from_email: 39 if hasattr(settings, 'WAGTAILADMIN_NOTIFICATION_FROM_EMAIL'): 40 from_email = settings.WAGTAILADMIN_NOTIFICATION_FROM_EMAIL 41 elif hasattr(settings, 'DEFAULT_FROM_EMAIL'): 42 from_email = settings.DEFAULT_FROM_EMAIL 43 else: 44 from_email = 'webmaster@localhost' 45 46 connection = kwargs.get('connection', False) or get_connection( 47 username=kwargs.get('auth_user', None), 48 password=kwargs.get('auth_password', None), 49 fail_silently=kwargs.get('fail_silently', None), 50 ) 51 multi_alt_kwargs = { 52 'connection': connection, 53 'headers': { 54 'Auto-Submitted': 'auto-generated', 55 } 56 } 57 mail = EmailMultiAlternatives(subject, message, from_email, recipient_list, **multi_alt_kwargs) 58 html_message = kwargs.get('html_message', None) 59 if html_message: 60 mail.attach_alternative(html_message, 'text/html') 61 62 return mail.send() 63 64 65def send_moderation_notification(revision, notification, excluded_user=None): 66 # Get list of recipients 67 if notification == 'submitted': 68 # Get list of publishers 69 include_superusers = getattr(settings, 'WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS', True) 70 recipient_users = users_with_page_permission(revision.page, 'publish', include_superusers) 71 elif notification in ['rejected', 'approved']: 72 # Get submitter 73 recipient_users = [revision.user] 74 else: 75 return False 76 77 if excluded_user: 78 recipient_users = [user for user in recipient_users if user != excluded_user] 79 80 return send_notification(recipient_users, notification, {'revision': revision}) 81 82 83def send_notification(recipient_users, notification, extra_context): 84 # Get list of email addresses 85 email_recipients = [ 86 recipient for recipient in recipient_users 87 if recipient.email and getattr( 88 UserProfile.get_for_user(recipient), 89 notification + '_notifications' 90 ) 91 ] 92 93 # Return if there are no email addresses 94 if not email_recipients: 95 return True 96 97 # Get template 98 template_subject = 'wagtailadmin/notifications/' + notification + '_subject.txt' 99 template_text = 'wagtailadmin/notifications/' + notification + '.txt' 100 template_html = 'wagtailadmin/notifications/' + notification + '.html' 101 102 # Common context to template 103 context = { 104 "settings": settings, 105 } 106 context.update(extra_context) 107 108 connection = get_connection() 109 110 with OpenedConnection(connection) as open_connection: 111 112 # Send emails 113 sent_count = 0 114 for recipient in email_recipients: 115 try: 116 # update context with this recipient 117 context["user"] = recipient 118 119 # Translate text to the recipient language settings 120 with override(recipient.wagtail_userprofile.get_preferred_language()): 121 # Get email subject and content 122 email_subject = render_to_string(template_subject, context).strip() 123 email_content = render_to_string(template_text, context).strip() 124 125 kwargs = {} 126 if getattr(settings, 'WAGTAILADMIN_NOTIFICATION_USE_HTML', False): 127 kwargs['html_message'] = render_to_string(template_html, context) 128 129 # Send email 130 send_mail(email_subject, email_content, [recipient.email], connection=open_connection, **kwargs) 131 sent_count += 1 132 except Exception: 133 logger.exception( 134 "Failed to send notification email '%s' to %s", 135 email_subject, recipient.email 136 ) 137 138 return sent_count == len(email_recipients) 139 140 141class Notifier: 142 """Generic class for sending event notifications: callable, intended to be connected to a signal to send 143 notifications using rendered templates. """ 144 145 notification = '' 146 template_directory = 'wagtailadmin/notifications/' 147 148 def __init__(self, valid_classes): 149 # the classes of the calling instance that the notifier can handle 150 self.valid_classes = valid_classes 151 152 def can_handle(self, instance, **kwargs): 153 """Returns True if the Notifier can handle sending the notification from the instance, otherwise False""" 154 return isinstance(instance, self.valid_classes) 155 156 def get_valid_recipients(self, instance, **kwargs): 157 """Returns a set of the final list of recipients for the notification message""" 158 return set() 159 160 def get_template_base_prefix(self, instance, **kwargs): 161 return camelcase_to_underscore(type(instance).__name__) + '_' 162 163 def get_context(self, instance, **kwargs): 164 return {'settings': settings} 165 166 def get_template_set(self, instance, **kwargs): 167 """Return a dictionary of template paths for the templates: by default, a text message""" 168 template_base = self.get_template_base_prefix(instance) + self.notification 169 170 template_text = self.template_directory + template_base + '.txt' 171 172 return { 173 'text': template_text, 174 } 175 176 def send_notifications(self, template_set, context, recipients, **kwargs): 177 raise NotImplementedError 178 179 def __call__(self, instance=None, **kwargs): 180 """Send notifications from an instance (intended to be the signal sender), returning True if all sent correctly 181 and False otherwise""" 182 183 if not self.can_handle(instance, **kwargs): 184 return False 185 186 recipients = self.get_valid_recipients(instance, **kwargs) 187 188 if not recipients: 189 return True 190 191 template_set = self.get_template_set(instance, **kwargs) 192 193 context = self.get_context(instance, **kwargs) 194 195 return self.send_notifications(template_set, context, recipients, **kwargs) 196 197 198class EmailNotificationMixin: 199 """Mixin for sending email notifications upon events""" 200 201 def get_recipient_users(self, instance, **kwargs): 202 """Gets the ideal set of recipient users, without accounting for notification preferences or missing email addresses""" 203 204 return set() 205 206 def get_valid_recipients(self, instance, **kwargs): 207 """Filters notification recipients to those allowing the notification type on their UserProfile, and those 208 with an email address""" 209 210 return {recipient for recipient in self.get_recipient_users(instance, **kwargs) if recipient.email and getattr( 211 UserProfile.get_for_user(recipient), 212 self.notification + '_notifications' 213 )} 214 215 def get_template_set(self, instance, **kwargs): 216 """Return a dictionary of template paths for the templates for the email subject and the text and html 217 alternatives""" 218 template_base = self.get_template_base_prefix(instance) + self.notification 219 220 template_subject = self.template_directory + template_base + '_subject.txt' 221 template_text = self.template_directory + template_base + '.txt' 222 template_html = self.template_directory + template_base + '.html' 223 224 return { 225 'subject': template_subject, 226 'text': template_text, 227 'html': template_html, 228 } 229 230 def send_emails(self, template_set, context, recipients, **kwargs): 231 232 connection = get_connection() 233 sent_count = 0 234 try: 235 with OpenedConnection(connection) as open_connection: 236 237 # Send emails 238 for recipient in recipients: 239 try: 240 241 # update context with this recipient 242 context["user"] = recipient 243 244 # Translate text to the recipient language settings 245 with override(recipient.wagtail_userprofile.get_preferred_language()): 246 # Get email subject and content 247 email_subject = render_to_string(template_set['subject'], context).strip() 248 email_content = render_to_string(template_set['text'], context).strip() 249 250 kwargs = {} 251 if getattr(settings, 'WAGTAILADMIN_NOTIFICATION_USE_HTML', False): 252 kwargs['html_message'] = render_to_string(template_set['html'], context) 253 254 # Send email 255 send_mail(email_subject, email_content, [recipient.email], connection=open_connection, **kwargs) 256 sent_count += 1 257 except Exception: 258 logger.exception( 259 "Failed to send notification email '%s' to %s", 260 email_subject, recipient.email 261 ) 262 except (TimeoutError, ConnectionError): 263 logger.exception("Mail connection error, notification sending skipped") 264 265 return sent_count == len(recipients) 266 267 def send_notifications(self, template_set, context, recipients, **kwargs): 268 return self.send_emails(template_set, context, recipients, **kwargs) 269 270 271class BaseWorkflowStateEmailNotifier(EmailNotificationMixin, Notifier): 272 """A base notifier to send email updates for WorkflowState events""" 273 274 def __init__(self): 275 super().__init__((WorkflowState,)) 276 277 def get_context(self, workflow_state, **kwargs): 278 context = super().get_context(workflow_state, **kwargs) 279 context['page'] = workflow_state.page 280 context['workflow'] = workflow_state.workflow 281 return context 282 283 284class WorkflowStateApprovalEmailNotifier(BaseWorkflowStateEmailNotifier): 285 """A notifier to send email updates for WorkflowState approval events""" 286 287 notification = 'approved' 288 289 def get_recipient_users(self, workflow_state, **kwargs): 290 triggering_user = kwargs.get('user', None) 291 recipients = {} 292 requested_by = workflow_state.requested_by 293 if requested_by != triggering_user: 294 recipients = {requested_by} 295 296 return recipients 297 298 299class WorkflowStateRejectionEmailNotifier(BaseWorkflowStateEmailNotifier): 300 """A notifier to send email updates for WorkflowState rejection events""" 301 302 notification = 'rejected' 303 304 def get_recipient_users(self, workflow_state, **kwargs): 305 triggering_user = kwargs.get('user', None) 306 recipients = {} 307 requested_by = workflow_state.requested_by 308 if requested_by != triggering_user: 309 recipients = {requested_by} 310 311 return recipients 312 313 def get_context(self, workflow_state, **kwargs): 314 context = super().get_context(workflow_state, **kwargs) 315 task_state = workflow_state.current_task_state.specific 316 context['task'] = task_state.task 317 context['task_state'] = task_state 318 context['comment'] = task_state.get_comment() 319 return context 320 321 322class WorkflowStateSubmissionEmailNotifier(BaseWorkflowStateEmailNotifier): 323 """A notifier to send email updates for WorkflowState submission events""" 324 325 notification = 'submitted' 326 327 def get_recipient_users(self, workflow_state, **kwargs): 328 triggering_user = kwargs.get('user', None) 329 recipients = get_user_model().objects.none() 330 include_superusers = getattr(settings, 'WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS', True) 331 if include_superusers: 332 recipients = get_user_model().objects.filter(is_superuser=True) 333 if triggering_user: 334 recipients.exclude(pk=triggering_user.pk) 335 336 return recipients 337 338 def get_context(self, workflow_state, **kwargs): 339 context = super().get_context(workflow_state, **kwargs) 340 context['requested_by'] = workflow_state.requested_by 341 return context 342 343 344class BaseGroupApprovalTaskStateEmailNotifier(EmailNotificationMixin, Notifier): 345 """A base notifier to send email updates for GroupApprovalTask events""" 346 347 def __init__(self): 348 super().__init__((TaskState,)) 349 350 def can_handle(self, instance, **kwargs): 351 if super().can_handle(instance, **kwargs) and isinstance(instance.task.specific, GroupApprovalTask): 352 return True 353 return False 354 355 def get_context(self, task_state, **kwargs): 356 context = super().get_context(task_state, **kwargs) 357 context['page'] = task_state.workflow_state.page 358 context['task'] = task_state.task.specific 359 return context 360 361 def get_recipient_users(self, task_state, **kwargs): 362 triggering_user = kwargs.get('user', None) 363 364 group_members = get_user_model().objects.filter(groups__in=task_state.task.specific.groups.all()) 365 366 recipients = group_members 367 368 include_superusers = getattr(settings, 'WAGTAILADMIN_NOTIFICATION_INCLUDE_SUPERUSERS', True) 369 if include_superusers: 370 superusers = get_user_model().objects.filter(is_superuser=True) 371 recipients = recipients | superusers 372 373 if triggering_user: 374 recipients = recipients.exclude(pk=triggering_user.pk) 375 376 return recipients 377 378 379class GroupApprovalTaskStateSubmissionEmailNotifier(BaseGroupApprovalTaskStateEmailNotifier): 380 """A notifier to send email updates for GroupApprovalTask submission events""" 381 382 notification = 'submitted' 383