Skip to content

Add per-workspace user opt-out from email notifications#6750

Draft
rowanseymour wants to merge 1 commit into
mainfrom
rowan/stoic-torvalds-588e8f
Draft

Add per-workspace user opt-out from email notifications#6750
rowanseymour wants to merge 1 commit into
mainfrom
rowan/stoic-torvalds-588e8f

Conversation

@rowanseymour

Copy link
Copy Markdown
Member

Adds a per-membership setting that lets users opt out of unsolicited notification emails from a workspace, whilst still receiving them as UI notifications.

  • New OrgMembership.email_notifications field (default on). Role changes recreate memberships, so Org.add_user now carries the preference over; leaving and rejoining a workspace resets it.
  • New NotificationType.email_optional attribute marks which types users can opt out of by email: incident:started and invitation:accepted. Solicited emails like export:finished are unaffected.
  • Notification.create_all drops the email medium per-user for opted-out members, and skips creating the notification entirely when email was its only medium.
  • Account page gains a checkbox per workspace to control the setting.

Mailroom needs no changes since it only creates UI-medium notifications, but if it ever starts creating email-medium ones it will need to honor this preference.

Related to #6737 and #6738 as part of reducing notification email noise.

@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @rowanseymour's task in 3m 59s —— View job


Code Review

Design is straightforward and the split between email_optional (dropped for opted-out users) vs. always-sent solicited types is a nice line to draw. Tests cover the important cases — opt-out drops email but keeps UI, email-only types create no notification at all, add_user preserves preference across role changes but not across leave+rejoin, solicited exports aren't affected.

Only minor comments left inline; no blockers:

  • temba/notifications/models.py#L211-L215org.get_membership(user) in the loop is a per-user query when uncached; probably fine given typical admin counts, but easy to collapse into one in_bulk.
  • temba/orgs/models.py#L864-L870 — the existing := ...first() walrus guards against a case that can't occur given the surrounding has_user check.
  • temba/orgs/views/views.py#L395 — the .get(..., True) default is unreachable via the normal form path; if you did hit it (workspace added between GET and POST) defaulting to True would silently mutate a preference the user never saw.

Security/correctness look fine: Edit uses InferUserMixin so form.instance is always request.user and the loop only touches memberships that were queried at form-init time for that user. Migration is a plain AddField with default=True, safe to apply.

Comment on lines +211 to +215
# for optional email types, drop the email medium for users who have opted out in this workspace
if cls.MEDIUM_EMAIL in user_medium and email_optional:
membership = org.get_membership(user)
if membership and not membership.email_notifications:
user_medium = user_medium.replace(cls.MEDIUM_EMAIL, "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor perf: org.get_membership(user) runs once per user in the loop and, when uncached, hits the DB (OrgMembership.objects.filter(org=self, user=user).first()). For an incident with N admins that's up to N sequential queries per notification. Since notifications aren't sent very often and admin counts are usually small this is unlikely to matter in practice, but a single OrgMembership.objects.filter(org=org, user__in=users).in_bulk(field_name="user_id") would collapse it to one query if you'd like to preempt it.

Comment thread temba/orgs/models.py
Comment on lines +864 to 870
# role changes are implemented as remove + create so user preferences on the membership must be carried over
email_notifications = True

if self.has_user(user): # remove user from any existing roles
if existing := OrgMembership.objects.filter(org=self, user=user).first():
email_notifications = existing.email_notifications
self.remove_user(user)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing := ...first() walrus is defensive against a case that can't occur here — self.has_user(user) returning True already implies a matching OrgMembership row exists (that's the through table), so existing will always be truthy inside this branch. You could just do:

existing = OrgMembership.objects.get(org=self, user=user)
email_notifications = existing.email_notifications
self.remove_user(user)

Not a bug, just clearer about the invariant.

Comment thread temba/orgs/views/views.py
obj = super().post_save(obj)

for membership in self.form.memberships:
enabled = self.form.cleaned_data.get(f"email_notifications_{membership.org_id}", True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The True default here is unreachable: the form's __init__ always adds an email_notifications_{org_id} BooleanField(required=False) for every membership in self.form.memberships, so cleaned_data will always contain the key (as True or False after unchecked-box coercion). A missing key would mean the workspace was added between GET and POST, in which case defaulting to True is a somewhat surprising choice — you'd silently flip the freshly-created membership's setting even though the user never saw the checkbox. Safer to just do self.form.cleaned_data[f"email_notifications_{membership.org_id}"] or default to membership.email_notifications to be a no-op.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant