diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c49434ee..be145e5b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,9 @@ Added - **General** - Python v3.11 support (#1157) - Flake8 rule in ``Makefile`` (#1387) +- **Adminalerts** + - Admin alert email sending (#415) + - ``notify_email_alert`` app setting (#415) - **Filesfolders** - Optional pagination for REST API list views (#1313) - **Projectroles** @@ -29,6 +32,7 @@ Added - ``SODARPageNumberPagination`` pagination class (#1313) - Optional pagination for REST API list views (#1313) - Email notification opt-out settings (#1417) + - CC and BCC field support in sending generic emails (#415) - **Timeline** - ``sodar_uuid`` field in ``TimelineEventObjectRef`` model (#1415) - REST API views (#1350) diff --git a/adminalerts/forms.py b/adminalerts/forms.py index 75f6a510..cf6f341b 100644 --- a/adminalerts/forms.py +++ b/adminalerts/forms.py @@ -9,9 +9,20 @@ from adminalerts.models import AdminAlert +# Local constants +EMAIL_HELP_CREATE = 'Send alert as email to all users on this site' +EMAIL_HELP_UPDATE = 'Send updated alert as email to all users on this site' + + class AdminAlertForm(SODARModelForm): """Form for AdminAlert creation/updating""" + send_email = forms.BooleanField( + initial=True, + label='Send alert as email', + required=False, + ) + class Meta: model = AdminAlert fields = [ @@ -19,6 +30,7 @@ class Meta: 'date_expire', 'active', 'require_auth', + 'send_email', 'description', ] @@ -40,13 +52,17 @@ def __init__(self, current_user=None, *args, **kwargs): # Creation if not self.instance.pk: - self.fields['date_expire'].initial = ( - timezone.now() + timezone.timedelta(days=1) + self.initial['date_expire'] = timezone.now() + timezone.timedelta( + days=1 ) + self.fields['send_email'].help_text = EMAIL_HELP_CREATE # Updating else: # self.instance.pk # Set description value as raw markdown self.initial['description'] = self.instance.description.raw + self.fields['send_email'].help_text = EMAIL_HELP_UPDATE + # Sending email for update should be false by default + self.initial['send_email'] = False def clean(self): """Custom form validation and cleanup""" diff --git a/adminalerts/plugins.py b/adminalerts/plugins.py index 22d941e8..0e48682e 100644 --- a/adminalerts/plugins.py +++ b/adminalerts/plugins.py @@ -4,12 +4,17 @@ from django.utils import timezone # Projectroles dependency +from projectroles.models import SODAR_CONSTANTS from projectroles.plugins import SiteAppPluginPoint from adminalerts.models import AdminAlert from adminalerts.urls import urlpatterns +# SODAR constants +APP_SETTING_SCOPE_USER = SODAR_CONSTANTS['APP_SETTING_SCOPE_USER'] + + class SiteAppPlugin(SiteAppPluginPoint): """Projectroles plugin for registering the app""" @@ -22,6 +27,22 @@ class SiteAppPlugin(SiteAppPluginPoint): #: UI URLs urls = urlpatterns + #: App settings definition + app_settings = { + 'notify_email_alert': { + 'scope': APP_SETTING_SCOPE_USER, + 'type': 'BOOLEAN', + 'default': True, + 'label': 'Receive email for admin alerts', + 'description': ( + 'Receive email for important administrator alerts regarding ' + 'e.g. site downtime.' + ), + 'user_modifiable': True, + 'global': False, + } + } + #: Iconify icon icon = 'mdi:alert' diff --git a/adminalerts/tests/test_views.py b/adminalerts/tests/test_views.py index 38dce2e4..b9f24e60 100644 --- a/adminalerts/tests/test_views.py +++ b/adminalerts/tests/test_views.py @@ -1,34 +1,54 @@ """Tests for UI views in the adminalerts app""" +from django.conf import settings +from django.core import mail from django.urls import reverse from django.utils import timezone from test_plus.test import TestCase +# Projectroles dependency +from projectroles.app_settings import AppSettingAPI + from adminalerts.models import AdminAlert from adminalerts.tests.test_models import AdminAlertMixin +from adminalerts.views import EMAIL_SUBJECT + + +app_settings = AppSettingAPI() + + +# Local constants +APP_NAME = 'adminalerts' +ALERT_MSG = 'New alert' +ALERT_MSG_UPDATED = 'Updated alert' +ALERT_DESC = 'Description' +ALERT_DESC_UPDATED = 'Updated description' +ALERT_DESC_MARKDOWN = '## Description' +EMAIL_DESC_LEGEND = 'Additional details' class AdminalertsViewTestBase(AdminAlertMixin, TestCase): """Base class for adminalerts view testing""" + def _make_alert(self): + return self.make_alert( + message=ALERT_MSG, + user=self.superuser, + description=ALERT_DESC, + active=True, + require_auth=True, + ) + def setUp(self): # Create users self.superuser = self.make_user('superuser') self.superuser.is_superuser = True self.superuser.is_staff = True self.superuser.save() - self.regular_user = self.make_user('regular_user') + self.user_regular = self.make_user('user_regular') # No user self.anonymous = None - # Create alert - self.alert = self.make_alert( - message='alert', - user=self.superuser, - description='description', - active=True, - require_auth=True, - ) self.expiry_str = ( timezone.now() + timezone.timedelta(days=1) ).strftime('%Y-%m-%d') @@ -37,6 +57,10 @@ def setUp(self): class TestAdminAlertListView(AdminalertsViewTestBase): """Tests for AdminAlertListView""" + def setUp(self): + super().setUp() + self.alert = self._make_alert() + def test_get(self): """Test AdminAlertListView GET""" with self.login(self.superuser): @@ -49,6 +73,10 @@ def test_get(self): class TestAdminAlertDetailView(AdminalertsViewTestBase): """Tests for AdminAlertDetailView""" + def setUp(self): + super().setUp() + self.alert = self._make_alert() + def test_get(self): """Test AdminAlertDetailView GET""" with self.login(self.superuser): @@ -65,6 +93,18 @@ def test_get(self): class TestAdminAlertCreateView(AdminalertsViewTestBase): """Tests for AdminAlertCreateView""" + def _get_post_data(self, **kwargs): + ret = { + 'message': ALERT_MSG, + 'description': ALERT_DESC, + 'date_expire': self.expiry_str, + 'active': True, + 'require_auth': True, + 'send_email': True, + } + ret.update(**kwargs) + return ret + def setUp(self): super().setUp() self.url = reverse('adminalerts:create') @@ -77,44 +117,179 @@ def test_get(self): def test_post(self): """Test POST""" - self.assertEqual(AdminAlert.objects.all().count(), 1) - post_data = { - 'message': 'new alert', - 'description': 'description', - 'date_expire': self.expiry_str, - 'active': 1, - 'require_auth': 1, - } + self.assertEqual(AdminAlert.objects.all().count(), 0) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() with self.login(self.superuser): - response = self.client.post(self.url, post_data) + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, reverse('adminalerts:list')) - self.assertEqual(AdminAlert.objects.all().count(), 2) + self.assertEqual(AdminAlert.objects.all().count(), 1) + self.assertEqual(len(mail.outbox), 1) + self.assertIn( + EMAIL_SUBJECT.format(state='New', message=ALERT_MSG), + mail.outbox[0].subject, + ) + self.assertEqual( + mail.outbox[0].recipients(), + [settings.EMAIL_SENDER, self.user_regular.email], + ) + self.assertEqual(mail.outbox[0].to, [settings.EMAIL_SENDER]) + self.assertEqual(mail.outbox[0].bcc, [self.user_regular.email]) + self.assertIn(ALERT_MSG, mail.outbox[0].body) + self.assertIn(EMAIL_DESC_LEGEND, mail.outbox[0].body) + self.assertIn(ALERT_DESC, mail.outbox[0].body) + + def test_post_no_description(self): + """Test POST with no description""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(description='') + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertIn(ALERT_MSG, mail.outbox[0].body) + self.assertNotIn(EMAIL_DESC_LEGEND, mail.outbox[0].body) + + def test_post_markdown_description(self): + """Test POST with markdown description""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(description=ALERT_DESC_MARKDOWN) + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertIn(ALERT_MSG, mail.outbox[0].body) + self.assertIn(EMAIL_DESC_LEGEND, mail.outbox[0].body) + # Description should be provided in raw format + self.assertIn(ALERT_DESC_MARKDOWN, mail.outbox[0].body) + + def test_post_no_email(self): + """Test POST with no email to be sent""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(send_email=False) + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) + + def test_post_inactive(self): + """Test POST with inactive state""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(active=False) + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) + + def test_post_multiple_users(self): + """Test POST with multiple users""" + user_new = self.make_user('user_new') + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].recipients(), + [settings.EMAIL_SENDER, user_new.email, self.user_regular.email], + ) + self.assertIn(ALERT_MSG, mail.outbox[0].body) + self.assertIn(EMAIL_DESC_LEGEND, mail.outbox[0].body) + self.assertIn(ALERT_DESC, mail.outbox[0].body) + + def test_post_alt_email_regular_user(self): + """Test POST with alt emails on regular user""" + alt_email = 'alt@example.com' + alt_email2 = 'alt2@example.com' + app_settings.set( + 'projectroles', + 'user_email_additional', + ';'.join([alt_email, alt_email2]), + user=self.user_regular, + ) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].recipients(), + [ + settings.EMAIL_SENDER, + self.user_regular.email, + alt_email, + alt_email2, + ], + ) + + def test_post_alt_email_superuser(self): + """Test POST with alt emails on superuser""" + alt_email = 'alt@example.com' + alt_email2 = 'alt2@example.com' + app_settings.set( + 'projectroles', + 'user_email_additional', + ';'.join([alt_email, alt_email2]), + user=self.superuser, + ) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + # Superuser alt emails should not be included + self.assertEqual( + mail.outbox[0].recipients(), + [settings.EMAIL_SENDER, self.user_regular.email], + ) + + def test_post_email_disable(self): + """Test POST with email notifications disabled""" + app_settings.set( + APP_NAME, 'notify_email_alert', False, user=self.user_regular + ) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) def test_post_expired(self): """Test POST with old expiry date (should fail)""" - self.assertEqual(AdminAlert.objects.all().count(), 1) - expiry_fail = (timezone.now() + timezone.timedelta(days=-1)).strftime( + self.assertEqual(AdminAlert.objects.all().count(), 0) + expire_fail = (timezone.now() + timezone.timedelta(days=-1)).strftime( '%Y-%m-%d' ) - post_data = { - 'message': 'new alert', - 'description': 'description', - 'date_expire': expiry_fail, - 'active': 1, - 'require_auth': 1, - } + data = self._get_post_data(date_expire=expire_fail) with self.login(self.superuser): - response = self.client.post(self.url, post_data) + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 200) - self.assertEqual(AdminAlert.objects.all().count(), 1) + self.assertEqual(AdminAlert.objects.all().count(), 0) class TestAdminAlertUpdateView(AdminalertsViewTestBase): """Tests for AdminAlertUpdateView""" + def _get_post_data(self, **kwargs): + ret = { + 'message': ALERT_MSG_UPDATED, + 'description': ALERT_DESC_UPDATED, + 'date_expire': self.expiry_str, + 'active': False, + 'require_auth': True, + 'send_email': False, + } + ret.update(kwargs) + return ret + def setUp(self): super().setUp() + self.alert = self._make_alert() self.url = reverse( 'adminalerts:update', kwargs={'adminalert': self.alert.sodar_uuid}, @@ -129,35 +304,65 @@ def test_get(self): def test_post(self): """Test POST""" self.assertEqual(AdminAlert.objects.all().count(), 1) - post_data = { - 'message': 'updated alert', - 'description': 'updated description', - 'date_expire': self.expiry_str, - 'active': '', - } + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() with self.login(self.superuser): - response = self.client.post(self.url, post_data) + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, reverse('adminalerts:list')) self.assertEqual(AdminAlert.objects.all().count(), 1) self.alert.refresh_from_db() - self.assertEqual(self.alert.message, 'updated alert') - self.assertEqual(self.alert.description.raw, 'updated description') + self.assertEqual(self.alert.message, ALERT_MSG_UPDATED) + self.assertEqual(self.alert.description.raw, ALERT_DESC_UPDATED) self.assertEqual(self.alert.active, False) + self.assertEqual(len(mail.outbox), 0) + + def test_post_email(self): + """Test POST with email update enabled""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(active=True, send_email=True) + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertIn( + EMAIL_SUBJECT.format(state='Updated', message=ALERT_MSG_UPDATED), + mail.outbox[0].subject, + ) + self.assertEqual( + mail.outbox[0].recipients(), + [settings.EMAIL_SENDER, self.user_regular.email], + ) + + def test_post_email_inactive(self): + """Test POST with email update enabled and inactive alert""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(send_email=True) + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) # No email for inactive event + + def test_post_email_disable(self): + """Test POST with disabled email notifications""" + app_settings.set( + APP_NAME, 'notify_email_alert', False, user=self.user_regular + ) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(active=True, send_email=True) + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) def test_post_user(self): """Test POST by different user""" superuser2 = self.make_user('superuser2') superuser2.is_superuser = True superuser2.save() - post_data = { - 'message': 'updated alert', - 'description': 'updated description', - 'date_expire': self.expiry_str, - 'active': '', - } + data = self._get_post_data() with self.login(superuser2): - response = self.client.post(self.url, post_data) + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, reverse('adminalerts:list')) self.alert.refresh_from_db() @@ -169,6 +374,7 @@ class TestAdminAlertDeleteView(AdminalertsViewTestBase): def setUp(self): super().setUp() + self.alert = self._make_alert() self.url = reverse( 'adminalerts:delete', kwargs={'adminalert': self.alert.sodar_uuid}, diff --git a/adminalerts/tests/test_views_ajax.py b/adminalerts/tests/test_views_ajax.py index 688f1303..6e6473df 100644 --- a/adminalerts/tests/test_views_ajax.py +++ b/adminalerts/tests/test_views_ajax.py @@ -12,6 +12,7 @@ class TestAdminAlertActiveToggleAjaxView(AdminalertsViewTestBase): def setUp(self): super().setUp() + self.alert = self._make_alert() self.url = reverse( 'adminalerts:ajax_active_toggle', kwargs={'adminalert': self.alert.sodar_uuid}, diff --git a/adminalerts/views.py b/adminalerts/views.py index 0b46b8f9..d39690c7 100644 --- a/adminalerts/views.py +++ b/adminalerts/views.py @@ -1,7 +1,10 @@ """UI views for the adminalerts app""" +import logging + from django.conf import settings from django.contrib import messages +from django.contrib.auth import get_user_model from django.shortcuts import redirect from django.urls import reverse from django.views.generic import ( @@ -14,6 +17,8 @@ from django.views.generic.edit import ModelFormMixin # Projectroles dependency +from projectroles.app_settings import AppSettingAPI +from projectroles.email import get_email_user, send_generic_mail from projectroles.views import ( LoggedInPermissionMixin, HTTPRefererMixin, @@ -25,7 +30,26 @@ from adminalerts.models import AdminAlert +app_settings = AppSettingAPI() +logger = logging.getLogger(__name__) +User = get_user_model() + + +# Local constants +APP_NAME = 'adminalerts' DEFAULT_PAGINATION = 15 +EMAIL_SUBJECT = '{state} admin alert: {message}' +EMAIL_BODY = r''' +An admin alert has been {action}d by {issuer}: + +{message} +'''.lstrip() +EMAIL_BODY_DESCRIPTION = r''' +Additional details: +---------------------------------------- +{description} +---------------------------------------- +''' # Listing/details views -------------------------------------------------------- @@ -63,10 +87,88 @@ class AdminAlertDetailView( class AdminAlertModifyMixin(ModelFormMixin): + """Common modification methods for AdminAlert create/update views""" + + @classmethod + def _get_email_recipients(cls, alert): + """ + Return list of email addresses for alert email recipients, excluding the + alert issuer. + """ + ret = [] + users = User.objects.exclude(sodar_uuid=alert.user.sodar_uuid).order_by( + 'email' + ) + for u in users: + if not app_settings.get(APP_NAME, 'notify_email_alert', user=u): + continue + if not u.email: + logger.warning('No email set for user: {}'.format(u.username)) + continue + if u.email not in ret: + ret.append(u.email) + alt_emails = app_settings.get( + 'projectroles', 'user_email_additional', user=u + ) + if not alt_emails: + continue + for e in alt_emails.split(';'): + if e not in ret: + ret.append(e) + return ret + + def _send_email(self, alert, action): + """ + Send email alerts to all users except for the alert issuer. + + :param alert: AdminAlert object + :param action: "create" or "update" (string) + """ + subject = EMAIL_SUBJECT.format( + state='New' if action == 'create' else 'Updated', + message=alert.message, + ) + body = EMAIL_BODY.format( + action=action, + issuer=get_email_user(alert.user), + message=alert.message, + ) + if alert.description: + body += EMAIL_BODY_DESCRIPTION.format( + description=alert.description.raw + ) + recipients = self._get_email_recipients(alert) + # NOTE: Recipients go under bcc + # NOTE: If we have no recipients in bcc we cancel sending + if len(recipients) == 0: + return 0 + return send_generic_mail( + subject, + body, + [settings.EMAIL_SENDER], + self.request, + reply_to=None, + bcc=recipients, + ) + def form_valid(self, form): - form.save() form_action = 'update' if self.object else 'create' - messages.success(self.request, 'Alert {}d.'.format(form_action)) + self.object = form.save() + email_count = 0 + email_msg_suffix = '' + if ( + form.cleaned_data['send_email'] + and self.object.active + and settings.PROJECTROLES_SEND_EMAIL + ): + email_count = self._send_email(form.instance, form_action) + if email_count > 0: + email_msg_suffix = ', {} email{} sent'.format( + email_count, 's' if email_count != 1 else '' + ) + messages.success( + self.request, 'Alert {}d{}.'.format(form_action, email_msg_suffix) + ) return redirect(reverse('adminalerts:list')) diff --git a/config/settings/test.py b/config/settings/test.py index 19c89d2a..57f435eb 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -31,6 +31,7 @@ # In-memory email backend stores messages in django.core.mail.outbox # for unit testing purposes EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' +EMAIL_SENDER = 'noreply@example.com' # CACHING # ------------------------------------------------------------------------------ diff --git a/docs/source/_static/app_adminalerts/alert_form.png b/docs/source/_static/app_adminalerts/alert_form.png index 6a390ddb..431baeea 100644 Binary files a/docs/source/_static/app_adminalerts/alert_form.png and b/docs/source/_static/app_adminalerts/alert_form.png differ diff --git a/docs/source/app_adminalerts.rst b/docs/source/app_adminalerts.rst index a74907b9..8914f65c 100644 --- a/docs/source/app_adminalerts.rst +++ b/docs/source/app_adminalerts.rst @@ -120,6 +120,11 @@ Require Auth If set true, this alert will only be shown to users logged in to the site. If false, it will also appear in the login screen as well as for anonymous users if allowed on the site. +Send Alert as Email + Send alert as email to all users with email notifications enabled, with the + exception of the sender. This is enabled by default when creating an alert. + When updating an existing alert it is initially disabled to avoid redundant + emails when e.g. fixing a typo. Description A longer description, which can be accessed through the :guilabel:`Details` link in the alert element. Markdown syntax is supported. diff --git a/docs/source/app_userprofile.rst b/docs/source/app_userprofile.rst index 8c17a470..c51966fc 100644 --- a/docs/source/app_userprofile.rst +++ b/docs/source/app_userprofile.rst @@ -81,6 +81,8 @@ app plugins. User settings defined in the ``projectroles`` app, available for all SODAR Core using sites: +Receive Email for Admin Alerts + Receive email for :ref:`admin alerts `. Display Project UUID Copying Link If set true, display a link in the project title bar for copying the project UUID into the clipboard. diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index 1f99015f..1a865d06 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -22,11 +22,13 @@ Release Highlights - Add REST API versioning independent from repo/site versions - Add timeline REST API - Add optional pagination for REST API list views +- Add admin alert email sending to all users - Add user opt-out settings for email notifications - Add target site user UUID updating in remote sync - Add remote sync of existing target local users - Add remote sync of USER scope app settings - Add checkusers management command +- Add CC and BCC field support in sending generic emails - Rewrite sodarcache REST API views - Rename AppSettingAPI "app_name" arguments to "plugin_name" - Plugin API return data updates and deprecations diff --git a/projectroles/email.py b/projectroles/email.py index 55efc828..1155becb 100644 --- a/projectroles/email.py +++ b/projectroles/email.py @@ -408,7 +408,15 @@ def _validate(user, email): return ret -def send_mail(subject, message, recipient_list, request=None, reply_to=None): +def send_mail( + subject, + message, + recipient_list, + request=None, + reply_to=None, + cc=None, + bcc=None, +): """ Wrapper for send_mail() with logging and error messaging. @@ -417,6 +425,8 @@ def send_mail(subject, message, recipient_list, request=None, reply_to=None): :param recipient_list: Recipients of email (list) :param request: Request object (optional) :param reply_to: List of emails for the "reply-to" header (optional) + :param cc: List of emails for "cc" field (optional) + :param bcc: List of emails for "bcc" field (optional) :return: Amount of sent email (int) """ try: @@ -426,6 +436,8 @@ def send_mail(subject, message, recipient_list, request=None, reply_to=None): from_email=EMAIL_SENDER, to=recipient_list, reply_to=reply_to if isinstance(reply_to, list) else [], + cc=cc if isinstance(cc, list) else [], + bcc=bcc if isinstance(bcc, list) else [], ) ret = e.send(fail_silently=False) logger.debug( @@ -723,17 +735,25 @@ def send_project_archive_mail(project, action, request): def send_generic_mail( - subject_body, message_body, recipient_list, request=None, reply_to=None + subject_body, + message_body, + recipient_list, + request=None, + reply_to=None, + cc=None, + bcc=None, ): """ - Send a notification email to the issuer of an invitation when a user - attempts to accept an expired invitation. + Send a generic mail with standard header and footer and no-reply + notifications. :param subject_body: Subject body without prefix (string) :param message_body: Message body before header or footer (string) :param recipient_list: Recipients (list of User objects or email strings) - :param reply_to: List of emails for the "reply-to" header (optional) :param request: Request object (optional) + :param reply_to: List of emails for the "reply-to" header (optional) + :param cc: List of emails for "cc" field (optional) + :param bcc: List of emails for "bcc" field (optional) :return: Amount of mail sent (int) """ subject = SUBJECT_PREFIX + subject_body @@ -752,5 +772,7 @@ def send_generic_mail( if not reply_to and not settings.PROJECTROLES_EMAIL_SENDER_REPLY: message += NO_REPLY_NOTE message += get_email_footer() - ret += send_mail(subject, message, recp_addr, request, reply_to) + ret += send_mail( + subject, message, recp_addr, request, reply_to, cc, bcc + ) return ret