Repository: getsentry/sentry

Issue:
Users who have disabled notifications for a project should not receive activity notifications for that project.
If a user has access to a project, but disables notifications for that project, they won't receive any `Event` notifications due to the [`NotificationPlugin.get_sendable_users` method](https://github.com/getsentry/sentry/blob/master/src/sentry/plugins/bases/notify.py#L96). `Activity` notifications, however, do not go through the same plugin abstraction and use the [`Activity.send_notification` method](https://github.com/getsentry/sentry/blob/master/src/sentry/models/activity.py#L125) instead, which does not respect the `mail:alert` user option that is used for `Event` notifications.

The responsibility of sending `Activity` notification emails should probably be pushed to the [`MailPlugin`](https://github.com/getsentry/sentry/blob/master/src/sentry/plugins/sentry_mail/models.py#L32), consolidating the settings behavior for both `Activity` and `Event` notifications.

This relates to (but does not resolve) #1143.



Discussion:

Resolving patch:
diff --git a/src/sentry/models/activity.py b/src/sentry/models/activity.py
--- a/src/sentry/models/activity.py
+++ b/src/sentry/models/activity.py
@@ -8,7 +8,6 @@
 from __future__ import absolute_import
 
 from django.conf import settings
-from django.core.urlresolvers import reverse
 from django.db import models
 from django.db.models import F
 from django.utils import timezone
@@ -17,7 +16,6 @@
     Model, BoundedPositiveIntegerField, FlexibleForeignKey, GzippedDictField,
     sane_repr
 )
-from sentry.utils.http import absolute_uri
 from sentry.tasks import activity
 
 
@@ -96,117 +94,5 @@ def delete(self, *args, **kwargs):
             if self.event:
                 self.event.update(num_comments=F('num_comments') - 1)
 
-    def get_recipients(self):
-        from sentry.models import UserOption
-
-        if self.type == Activity.ASSIGNED:
-            # dont email the user if they took the action
-            send_to = [self.data['assignee']]
-
-        else:
-            member_set = self.project.member_set.values_list('user', flat=True)
-
-            if not member_set:
-                return []
-
-            disabled = set(UserOption.objects.filter(
-                user__in=member_set,
-                key='subscribe_notes',
-                value=u'0',
-            ).values_list('user', flat=True))
-
-            send_to = [u for u in member_set if u not in disabled]
-
-        # never include the actor
-        send_to = [u for u in send_to if u != self.user_id]
-
-        return send_to
-
     def send_notification(self):
-        from sentry.models import Release
-        from sentry.utils.email import MessageBuilder, group_id_to_email
-
         activity.send_activity_notifications.delay(self.id)
-
-        if self.type not in (Activity.NOTE, Activity.ASSIGNED, Activity.RELEASE):
-            return
-
-        send_to = self.get_recipients()
-
-        if not send_to:
-            return
-
-        project = self.project
-        org = self.project.organization
-        group = self.group
-
-        if self.user:
-            author = self.user
-        else:
-            author = None
-
-        subject_prefix = self.project.get_option(
-            'subject_prefix', settings.EMAIL_SUBJECT_PREFIX)
-        if subject_prefix:
-            subject_prefix = subject_prefix.rstrip() + ' '
-
-        if self.group:
-            subject = '%s%s' % (subject_prefix, self.group.get_email_subject())
-        elif self.type == Activity.RELEASE:
-            subject = '%sRelease %s' % (subject_prefix, self.data['version'])
-        else:
-            raise NotImplementedError
-
-        headers = {}
-
-        context = {
-            'data': self.data,
-            'author': author,
-            'project': project,
-            'project_link': absolute_uri(reverse('sentry-stream', kwargs={
-                'organization_slug': org.slug,
-                'project_id': project.slug,
-            })),
-        }
-
-        if group:
-            group_link = absolute_uri('/{}/{}/group/{}/'.format(org.slug, project.slug, group.id))
-            activity_link = '{}activity/'.format(group_link)
-
-            headers.update({
-                'X-Sentry-Reply-To': group_id_to_email(group.id),
-            })
-
-            context.update({
-                'group': group,
-                'link': group_link,
-                'activity_link': activity_link,
-            })
-
-        # TODO(dcramer): abstract each activity email into its own helper class
-        if self.type == Activity.RELEASE:
-            context.update({
-                'release': Release.objects.get(
-                    version=self.data['version'],
-                    project=project,
-                ),
-                'release_link': absolute_uri(reverse('sentry-release-details', kwargs={
-                    'organization_slug': org.slug,
-                    'project_id': project.slug,
-                    'version': self.data['version'],
-                })),
-            })
-
-        template_name = self.get_type_display()
-
-        msg = MessageBuilder(
-            subject=subject,
-            context=context,
-            template='sentry/emails/activity/{}.txt'.format(template_name),
-            html_template='sentry/emails/activity/{}.html'.format(template_name),
-            headers=headers,
-            reference=self,
-            reply_reference=self.group,
-        )
-        msg.add_users(send_to, project=self.project)
-        msg.send_async()
diff --git a/src/sentry/plugins/sentry_mail/models.py b/src/sentry/plugins/sentry_mail/models.py
--- a/src/sentry/plugins/sentry_mail/models.py
+++ b/src/sentry/plugins/sentry_mail/models.py
@@ -8,6 +8,7 @@
 from __future__ import absolute_import
 
 import itertools
+import logging
 
 import sentry
 
@@ -19,6 +20,11 @@
 
 from sentry import features
 from sentry.digests.utilities import get_digest_metadata
+from sentry.models import (
+    Activity,
+    Release,
+    UserOption,
+)
 from sentry.plugins import register
 from sentry.plugins.base.structs import Notification
 from sentry.plugins.bases.notify import NotificationPlugin
@@ -29,6 +35,9 @@
 NOTSET = object()
 
 
+logger = logging.getLogger(__name__)
+
+
 class MailPlugin(NotificationPlugin):
     title = 'Mail'
     conf_key = 'mail'
@@ -101,22 +110,14 @@ def get_send_to(self, project=None):
         The results of this call can be fairly expensive to calculate, so the send_to list gets cached
         for 60 seconds.
         """
-        if project:
-            project_id = project.pk
-        else:
-            project_id = ''
-
         if not (project and project.team):
+            logger.debug('Tried to send notification to invalid project: %r', project)
             return []
 
-        conf_key = self.get_conf_key()
-        cache_key = '%s:send_to:%s' % (conf_key, project_id)
-
+        cache_key = '%s:send_to:%s' % (self.get_conf_key(), project.pk)
         send_to_list = cache.get(cache_key)
         if send_to_list is None:
-            send_to_list = self.get_sendable_users(project)
-
-            send_to_list = filter(bool, send_to_list)
+            send_to_list = filter(bool, self.get_sendable_users(project))
             cache.set(cache_key, send_to_list, 60)  # 1 minute cache
 
         return send_to_list
@@ -217,6 +218,102 @@ def notify_digest(self, project, digest):
         if features.has('projects:digests:deliver', project):
             message.send()
 
+    def notify_about_activity(self, activity):
+        if activity.type not in (Activity.NOTE, Activity.ASSIGNED, Activity.RELEASE):
+            return
+
+        candidate_ids = set(self.get_send_to(activity.project))
+
+        # Never send a notification to the user that performed the action.
+        candidate_ids.discard(activity.user_id)
+
+        if activity.type == Activity.ASSIGNED:
+            # Only notify the assignee, and only if they are in the candidate set.
+            recipient_ids = candidate_ids & set((activity.data['assignee'],))
+        elif activity.type == Activity.NOTE:
+            recipient_ids = candidate_ids - set(
+                UserOption.objects.filter(
+                    user__in=candidate_ids,
+                    key='subscribe_notes',
+                    value=u'0',
+                ).values_list('user', flat=True)
+            )
+        else:
+            recipient_ids = candidate_ids
+
+        if not recipient_ids:
+            return
+
+        project = activity.project
+        org = project.organization
+        group = activity.group
+
+        headers = {}
+
+        context = {
+            'data': activity.data,
+            'author': activity.user,
+            'project': project,
+            'project_link': absolute_uri(reverse('sentry-stream', kwargs={
+                'organization_slug': org.slug,
+                'project_id': project.slug,
+            })),
+        }
+
+        if group:
+            group_link = absolute_uri('/{}/{}/group/{}/'.format(org.slug, project.slug, group.id))
+            activity_link = '{}activity/'.format(group_link)
+
+            headers.update({
+                'X-Sentry-Reply-To': group_id_to_email(group.id),
+            })
+
+            context.update({
+                'group': group,
+                'link': group_link,
+                'activity_link': activity_link,
+            })
+
+        # TODO(dcramer): abstract each activity email into its own helper class
+        if activity.type == Activity.RELEASE:
+            context.update({
+                'release': Release.objects.get(
+                    version=activity.data['version'],
+                    project=project,
+                ),
+                'release_link': absolute_uri(reverse('sentry-release-details', kwargs={
+                    'organization_slug': org.slug,
+                    'project_id': project.slug,
+                    'version': activity.data['version'],
+                })),
+            })
+
+        template_name = activity.get_type_display()
+
+        # TODO: Everything below should instead use `_send_mail` for consistency.
+        subject_prefix = project.get_option('subject_prefix', settings.EMAIL_SUBJECT_PREFIX)
+        if subject_prefix:
+            subject_prefix = subject_prefix.rstrip() + ' '
+
+        if group:
+            subject = '%s%s' % (subject_prefix, group.get_email_subject())
+        elif activity.type == Activity.RELEASE:
+            subject = '%sRelease %s' % (subject_prefix, activity.data['version'])
+        else:
+            raise NotImplementedError
+
+        msg = MessageBuilder(
+            subject=subject,
+            context=context,
+            template='sentry/emails/activity/{}.txt'.format(template_name),
+            html_template='sentry/emails/activity/{}.html'.format(template_name),
+            headers=headers,
+            reference=activity,
+            reply_reference=group,
+        )
+        msg.add_users(recipient_ids, project=project)
+        msg.send()
+
 
 # Legacy compatibility
 MailProcessor = MailPlugin


Test patch:
diff --git a/tests/sentry/models/activity/tests.py b/tests/sentry/models/activity/tests.py
deleted file mode 100644
--- a/tests/sentry/models/activity/tests.py
+++ /dev/null
@@ -1,64 +0,0 @@
-from __future__ import absolute_import
-
-from django.core import mail
-
-from sentry.models import Activity, Release
-from sentry.testutils import TestCase
-
-
-class SendNotificationTest(TestCase):
-    def test_note(self):
-        user_foo = self.create_user('foo@example.com')
-
-        activity = Activity.objects.create(
-            project=self.project,
-            group=self.group,
-            type=Activity.NOTE,
-            user=user_foo,
-            event=self.create_event('a' * 32, group=self.group),
-            data={
-                'text': 'sup guise',
-            },
-        )
-
-        self.project.team.organization.member_set.create(user=user_foo)
-
-        with self.tasks():
-            activity.send_notification()
-
-        assert len(mail.outbox) == 1
-
-        msg = mail.outbox[0]
-
-        assert msg.subject == 'Re: [Sentry] [foo Bar] ERROR: Foo bar'
-        assert msg.to == [self.user.email]
-
-    def test_release(self):
-        user_foo = self.create_user('foo@example.com')
-
-        release = Release.objects.create(
-            project=self.project,
-            version='a' * 40,
-        )
-
-        activity = Activity.objects.create(
-            project=self.project,
-            type=Activity.RELEASE,
-            user=user_foo,
-            event=self.create_event('a' * 32, group=self.group),
-            data={
-                'version': release.version,
-            },
-        )
-
-        self.project.team.organization.member_set.create(user=user_foo)
-
-        with self.tasks():
-            activity.send_notification()
-
-        assert len(mail.outbox) == 1
-
-        msg = mail.outbox[0]
-
-        assert msg.subject == '[Sentry] Release %s' % (release.version,)
-        assert msg.to == [self.user.email]
diff --git a/tests/sentry/plugins/mail/tests.py b/tests/sentry/plugins/mail/tests.py
--- a/tests/sentry/plugins/mail/tests.py
+++ b/tests/sentry/plugins/mail/tests.py
@@ -16,7 +16,13 @@
 )
 from sentry.interfaces.stacktrace import Stacktrace
 from sentry.models import (
-    Event, Group, OrganizationMember, OrganizationMemberTeam, Rule
+    Activity,
+    Event,
+    Group,
+    OrganizationMember,
+    OrganizationMemberTeam,
+    Release,
+    Rule,
 )
 from sentry.plugins import Notification
 from sentry.plugins.sentry_mail.models import MailPlugin
@@ -267,3 +273,57 @@ def test_notify_digest_single_record(self, has, send, notify):
         self.plugin.notify_digest(project, digest)
         assert send.call_count is 1
         assert notify.call_count is 1
+
+    def test_note(self):
+        user_foo = self.create_user('foo@example.com')
+
+        activity = Activity.objects.create(
+            project=self.project,
+            group=self.group,
+            type=Activity.NOTE,
+            user=user_foo,
+            event=self.create_event('a' * 32, group=self.group),
+            data={
+                'text': 'sup guise',
+            },
+        )
+
+        self.project.team.organization.member_set.create(user=user_foo)
+
+        self.plugin.notify_about_activity(activity)
+
+        assert len(mail.outbox) == 1
+
+        msg = mail.outbox[0]
+
+        assert msg.subject == 'Re: [Sentry] [foo Bar] ERROR: Foo bar'
+        assert msg.to == [self.user.email]
+
+    def test_release(self):
+        user_foo = self.create_user('foo@example.com')
+
+        release = Release.objects.create(
+            project=self.project,
+            version='a' * 40,
+        )
+
+        activity = Activity.objects.create(
+            project=self.project,
+            type=Activity.RELEASE,
+            user=user_foo,
+            event=self.create_event('a' * 32, group=self.group),
+            data={
+                'version': release.version,
+            },
+        )
+
+        self.project.team.organization.member_set.create(user=user_foo)
+
+        self.plugin.notify_about_activity(activity)
+
+        assert len(mail.outbox) == 1
+
+        msg = mail.outbox[0]
+
+        assert msg.subject == '[Sentry] Release %s' % (release.version,)
+        assert msg.to == [self.user.email]