Repository: PostHog/posthog
Pull request: Allow "new member" email opt-out
## Changes

Resolves #5359 by adding opt-out for the "new member" email.

![Notification Preferences](https://user-images.githubusercontent.com/4550621/128060159-f967af30-751c-4939-a731-f98e613f15cf.png)

## Checklist

- [x] Django backend tests
- [ ] Jest frontend tests
- [ ] Cypress end-to-end tests
- [x] Migrations are safe to run at scale (e.g. PostHog Cloud) – present proof if not obvious
- [x] New/changed UI is decent on smartphones (viewport width around 360px)


Problem statement:
Allow for "x user has joined your organization" emails to be disabled
## Is your feature request related to a problem?

*Please describe.*

Users are asking to turn them off because there is quite a few hitting their inbox - they'd rather turn these off than filter them in their inbox.

## Describe the solution you'd like


## Describe alternatives you've considered



## Additional context



#### *Thank you* for your feature request – we love each and every one!

Hints from issue discussion:

Commit message: Improve wording

Patch to review:
diff --git a/frontend/src/scenes/organization/Settings/index.tsx b/frontend/src/scenes/organization/Settings/index.tsx
index 89c490bad0f1d..0c594cd2da766 100644
--- a/frontend/src/scenes/organization/Settings/index.tsx
+++ b/frontend/src/scenes/organization/Settings/index.tsx
@@ -1,5 +1,5 @@
 import React, { useEffect, useState } from 'react'
-import { Button, Card, Input, Divider, Select, Skeleton } from 'antd'
+import { Button, Card, Input, Divider, Select, Skeleton, Switch } from 'antd'
 import { UserType } from '~/types'
 import { PageHeader } from 'lib/components/PageHeader'
 import { Invites } from './Invites'
@@ -103,6 +103,39 @@ function DomainWhitelist({ isRestricted }: RestrictedComponentProps): JSX.Elemen
     )
 }
 
+function EmailPreferences({ isRestricted }: RestrictedComponentProps): JSX.Element {
+    const { currentOrganization, currentOrganizationLoading } = useValues(organizationLogic)
+    const { updateOrganization } = useActions(organizationLogic)
+
+    return (
+        <div>
+            <h2 id="name" className="subtitle">
+                Notification Preferences
+            </h2>
+            <div>
+                <Switch
+                    // @ts-expect-error - id works just fine despite not being in CompoundedComponent
+                    id="is-member-joined-email-enabled"
+                    onChange={(checked) => {
+                        updateOrganization({ is_member_join_email_enabled: checked })
+                    }}
+                    checked={currentOrganization?.is_member_join_email_enabled}
+                    loading={currentOrganizationLoading}
+                    disabled={isRestricted || !currentOrganization}
+                />
+                <label
+                    style={{
+                        marginLeft: '10px',
+                    }}
+                    htmlFor="is-member-joined-email-enabled"
+                >
+                    Email all members when a new member joins
+                </label>
+            </div>
+        </div>
+    )
+}
+
 export function OrganizationSettings({ user }: { user: UserType }): JSX.Element {
     const { preflight } = useValues(preflightLogic)
     return (
@@ -127,6 +160,8 @@ export function OrganizationSettings({ user }: { user: UserType }): JSX.Element
                 <Divider />
                 <Members user={user} />
                 <Divider />
+                <RestrictedArea Component={EmailPreferences} minimumAccessLevel={OrganizationMembershipLevel.Admin} />
+                <Divider />
                 <RestrictedArea Component={DangerZone} minimumAccessLevel={OrganizationMembershipLevel.Owner} />
             </Card>
         </>
diff --git a/frontend/src/scenes/organizationLogic.tsx b/frontend/src/scenes/organizationLogic.tsx
index a07f31089f573..1c7f97dfb4bf5 100644
--- a/frontend/src/scenes/organizationLogic.tsx
+++ b/frontend/src/scenes/organizationLogic.tsx
@@ -1,15 +1,13 @@
 import { kea } from 'kea'
 import api from 'lib/api'
 import { organizationLogicType } from './organizationLogicType'
-import { OrganizationType, PersonalizationData } from '~/types'
+import { OrganizationType } from '~/types'
 import { toast } from 'react-toastify'
 import { userLogic } from './userLogic'
 
-interface OrganizationUpdatePayload {
-    name?: string
-    personalization?: PersonalizationData
-    domain_whitelist?: string[]
-}
+type OrganizationUpdatePayload = Partial<
+    Pick<OrganizationType, 'name' | 'personalization' | 'domain_whitelist' | 'is_member_join_email_enabled'>
+>
 
 export const organizationLogic = kea<organizationLogicType<OrganizationUpdatePayload>>({
     actions: {
diff --git a/frontend/src/scenes/project/Settings/SessionRecording.tsx b/frontend/src/scenes/project/Settings/SessionRecording.tsx
index 28e8816f704d2..e5058e17c3103 100644
--- a/frontend/src/scenes/project/Settings/SessionRecording.tsx
+++ b/frontend/src/scenes/project/Settings/SessionRecording.tsx
@@ -17,6 +17,8 @@ export function SessionRecording(): JSX.Element {
         <div style={{ marginBottom: 16 }}>
             <div style={{ marginBottom: 8 }}>
                 <Switch
+                    // @ts-expect-error - id works just fine despite not being in CompoundedComponent
+                    id="opt-in-session-recording-switch"
                     data-attr="opt-in-session-recording-switch"
                     onChange={(checked) => {
                         updateCurrentTeam({ session_recording_opt_in: checked })
@@ -27,6 +29,7 @@ export function SessionRecording(): JSX.Element {
                     style={{
                         marginLeft: '10px',
                     }}
+                    htmlFor="opt-in-session-recording-switch"
                 >
                     Record user sessions on Permitted Domains
                 </label>
diff --git a/frontend/src/scenes/project/Settings/ToolbarSettings.tsx b/frontend/src/scenes/project/Settings/ToolbarSettings.tsx
index 9aaf9d3a4f0c5..bd5c48c8ac2fd 100644
--- a/frontend/src/scenes/project/Settings/ToolbarSettings.tsx
+++ b/frontend/src/scenes/project/Settings/ToolbarSettings.tsx
@@ -13,6 +13,8 @@ export function ToolbarSettings(): JSX.Element {
             <Row style={{ flexFlow: 'row' }}>
                 <Col>
                     <Switch
+                        // @ts-expect-error - id works just fine despite not being in CompoundedComponent
+                        id="posthog-toolbar-switch"
                         onChange={() => {
                             updateUser({
                                 toolbar_mode: user?.toolbar_mode === 'disabled' ? 'toolbar' : 'disabled',
@@ -28,9 +30,10 @@ export function ToolbarSettings(): JSX.Element {
                         style={{
                             marginLeft: '10px',
                         }}
+                        htmlFor="posthog-toolbar-switch"
                     >
-                        Enable the PostHog Toolbar, which gives access to heatmaps, stats and allows you to create
-                        actions, without ever leaving your own website or app!
+                        Enable PostHog Toolbar, which gives access to heatmaps, stats and allows you to create actions,
+                        right there on your website!
                     </label>
                 </Col>
             </Row>
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index d7dd2a4c6da25..447807cfda2ae 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -92,6 +92,7 @@ export interface OrganizationType extends OrganizationBasicType {
     teams: TeamBasicType[] | null
     available_features: AvailableFeatures[]
     domain_whitelist: string[]
+    is_member_join_email_enabled: boolean
 }
 
 export interface OrganizationMemberType {
diff --git a/latest_migrations.manifest b/latest_migrations.manifest
index 94226b7114805..cbc7a3f28f0f4 100644
--- a/latest_migrations.manifest
+++ b/latest_migrations.manifest
@@ -3,7 +3,7 @@ auth: 0012_alter_user_first_name_max_length
 axes: 0006_remove_accesslog_trusted
 contenttypes: 0002_remove_content_type_name
 ee: 0004_enterpriseeventdefinition_enterprisepropertydefinition
-posthog: 0161_property_defs_search
+posthog: 0162_organization_is_member_join_email_enabled
 rest_hooks: 0002_swappable_hook_model
 sessions: 0001_initial
 social_django: 0010_uid_db_index
diff --git a/posthog/api/organization.py b/posthog/api/organization.py
index ebd5c206c71ba..006bd907fda09 100644
--- a/posthog/api/organization.py
+++ b/posthog/api/organization.py
@@ -75,6 +75,7 @@ class Meta:
             "teams",
             "available_features",
             "domain_whitelist",
+            "is_member_join_email_enabled",
         ]
         read_only_fields = [
             "id",
diff --git a/posthog/api/test/test_signup.py b/posthog/api/test/test_signup.py
index e586351d92249..7aff440ce8d3f 100644
--- a/posthog/api/test/test_signup.py
+++ b/posthog/api/test/test_signup.py
@@ -642,6 +642,26 @@ def test_api_invite_sign_up_member_joined_email_is_sent_for_next_members(self):
         self.assertEqual(len(mail.outbox), 1)
         self.assertListEqual(mail.outbox[0].to, [initial_user.email])
 
+    @patch("posthog.api.organization.settings.EE_AVAILABLE", False)
+    def test_api_invite_sign_up_member_joined_email_is_not_sent_if_disabled(self):
+        self.organization.is_member_join_email_enabled = False
+        self.organization.save()
+
+        initial_user = User.objects.create_and_join(self.organization, "test+420@posthog.com", None)
+
+        invite: OrganizationInvite = OrganizationInvite.objects.create(
+            target_email="test+100@posthog.com", organization=self.organization,
+        )
+
+        with self.settings(EMAIL_ENABLED=True, EMAIL_HOST="localhost", SITE_URL="http://test.posthog.com"):
+            response = self.client.post(
+                f"/api/signup/{invite.id}/", {"first_name": "Alice", "password": "test_password", "email_opt_in": True},
+            )
+
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        self.assertEqual(len(mail.outbox), 0)
+
     @patch("posthoganalytics.identify")
     @patch("posthoganalytics.capture")
     @patch("posthog.api.organization.settings.EE_AVAILABLE", False)
diff --git a/posthog/migrations/0162_organization_is_member_join_email_enabled.py b/posthog/migrations/0162_organization_is_member_join_email_enabled.py
new file mode 100644
index 0000000000000..cf30b3dbce063
--- /dev/null
+++ b/posthog/migrations/0162_organization_is_member_join_email_enabled.py
@@ -0,0 +1,16 @@
+# Generated by Django 3.1.8 on 2021-08-03 17:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("posthog", "0161_property_defs_search"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="organization", name="is_member_join_email_enabled", field=models.BooleanField(default=True),
+        ),
+    ]
diff --git a/posthog/models/organization.py b/posthog/models/organization.py
index 5bc9a0ff56aed..2ab9db42c74e0 100644
--- a/posthog/models/organization.py
+++ b/posthog/models/organization.py
@@ -87,6 +87,7 @@ class PluginsAccessLevel(models.IntegerChoices):
     )
     available_features = ArrayField(models.CharField(max_length=64, blank=False), blank=True, default=list)
     for_internal_metrics: models.BooleanField = models.BooleanField(default=False)
+    is_member_join_email_enabled: models.BooleanField = models.BooleanField(default=True)
 
     objects: OrganizationManager = OrganizationManager()
 
@@ -276,7 +277,7 @@ def use(self, user: Any, *, prevalidated: bool = False) -> None:
         if not prevalidated:
             self.validate(user=user)
         user.join(organization=self.organization)
-        if is_email_available(with_absolute_urls=True):
+        if is_email_available(with_absolute_urls=True) and self.organization.is_member_join_email_enabled:
             from posthog.tasks.email import send_member_join
 
             send_member_join.apply_async(kwargs={"invitee_uuid": user.uuid, "organization_id": self.organization.id})


Code review comments:
diff_hunk: @@ -103,6 +103,39 @@ function DomainWhitelist({ isRestricted }: RestrictedComponentProps): JSX.Elemen
     )
 }
 
+function EmailPreferences({ isRestricted }: RestrictedComponentProps): JSX.Element {
+    const { currentOrganization, currentOrganizationLoading } = useValues(organizationLogic)
+    const { updateOrganization } = useActions(organizationLogic)
+
+    return (
+        <div>
+            <h2 id="name" className="subtitle">
+                Notification Preferences
+            </h2>
+            <div>
+                <Switch
+                    // @ts-expect-error - id works just fine despite not being in CompoundedComponent
+                    id="is-member-joined-email-enabled"
+                    onChange={(checked) => {
+                        updateOrganization({ is_member_join_email_enabled: checked })
+                    }}
+                    checked={currentOrganization?.is_member_join_email_enabled}
+                    loading={currentOrganizationLoading}
+                    disabled={isRestricted || !currentOrganization}
+                />
+                <label
+                    style={{
+                        marginLeft: '10px',
+                    }}
+                    htmlFor="is-member-joined-email-enabled"
path: frontend/src/scenes/organization/Settings/index.tsx
text: @user2:
Q: Would nesting this in the `<Switch>` work? This would get rid of the `id` requirement / `@user1-expect-error`

@author:
Unfortunately `<Switch>` does not support `children`.