Skip to content

Commit f1c4148

Browse files
gary-kimlafriks
andcommitted
Add Ability for User to Customize Email Notification Frequency (#7813)
* Add Backend Logic for Toggling Email Notification This commit adds the backend logic for allowing users to enable or disable email notifications. The implementation ensures that only issue notification emails get disabled and important emails are still sent regardless of the setting. The UI to toggle this setting has not yet been implemented. * Add UI and complete user email notification enable This commit completes the functionality to allow users to disable their own email notifications. Signed-off-by: Gary Kim <[email protected]> * Add Third Option for Only Email on Mention Signed-off-by: Gary Kim <[email protected]> * Readd NOT NULL to new preference string Signed-off-by: Gary Kim <[email protected]> * Add Tests and Rewrite Comment Signed-off-by: Gary Kim <[email protected]> * Allow admin to set default email frequency Signed-off-by: Gary Kim <[email protected]> * Add new config option to docs Signed-off-by: Gary Kim <[email protected]> * Fix a few mistakes Signed-off-by: Gary Kim <[email protected]> * Only update required columns Signed-off-by: Gary Kim <[email protected]> * Simplify an error check Signed-off-by: Gary Kim <[email protected]> * Make email_notification_preference column in DB be VARCHAR(20) Signed-off-by: Gary Kim <[email protected]> * Handle errors Signed-off-by: Gary Kim <[email protected]> * Update models/migrations/v93.go Co-Authored-By: Lauris BH <[email protected]>
1 parent 9ef1e5d commit f1c4148

File tree

14 files changed

+162
-14
lines changed

14 files changed

+162
-14
lines changed

custom/conf/app.ini.sample

+2
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,8 @@ MAX_FILE_SIZE = 1048576
306306
[admin]
307307
; Disallow regular (non-admin) users from creating organizations.
308308
DISABLE_REGULAR_ORG_CREATION = false
309+
; Default configuration for email notifications for users (user configurable). Options: enabled, onmention, disabled
310+
DEFAULT_EMAIL_NOTIFICATIONS = enabled
309311

310312
[security]
311313
; Whether the installer is disabled

docs/content/doc/advanced/config-cheat-sheet.en-us.md

+3
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
184184
- `UPDATE_BUFFER_LEN`: **20**: Buffer length of index request.
185185
- `MAX_FILE_SIZE`: **1048576**: Maximum size in bytes of files to be indexed.
186186

187+
## Admin (`admin`)
188+
- `DEFAULT_EMAIL_NOTIFICATIONS`: **enabled**: Default configuration for email notifications for users (user configurable). Options: enabled, onmention, disabled
189+
187190
## Security (`security`)
188191

189192
- `INSTALL_LOCK`: **false**: Disallow access to the install page.

models/fixtures/user.yml

+9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
name: user1
77
full_name: User One
88
9+
email_notifications_preference: enabled
910
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
1011
type: 0 # individual
1112
salt: ZogKvWdyEx
@@ -22,6 +23,7 @@
2223
full_name: " < U<se>r Tw<o > >< "
2324
2425
keep_email_private: true
26+
email_notifications_preference: enabled
2527
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
2628
type: 0 # individual
2729
salt: ZogKvWdyEx
@@ -40,6 +42,7 @@
4042
name: user3
4143
full_name: " <<<< >> >> > >> > >>> >> "
4244
45+
email_notifications_preference: onmention
4346
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
4447
type: 1 # organization
4548
salt: ZogKvWdyEx
@@ -56,6 +59,7 @@
5659
name: user4
5760
full_name: " "
5861
62+
email_notifications_preference: onmention
5963
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
6064
type: 0 # individual
6165
salt: ZogKvWdyEx
@@ -72,6 +76,7 @@
7276
name: user5
7377
full_name: User Five
7478
79+
email_notifications_preference: enabled
7580
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
7681
type: 0 # individual
7782
salt: ZogKvWdyEx
@@ -89,6 +94,7 @@
8994
name: user6
9095
full_name: User Six
9196
97+
email_notifications_preference: enabled
9298
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
9399
type: 1 # organization
94100
salt: ZogKvWdyEx
@@ -105,6 +111,7 @@
105111
name: user7
106112
full_name: User Seven
107113
114+
email_notifications_preference: disabled
108115
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
109116
type: 1 # organization
110117
salt: ZogKvWdyEx
@@ -121,6 +128,7 @@
121128
name: user8
122129
full_name: User Eight
123130
131+
email_notifications_preference: enabled
124132
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
125133
type: 0 # individual
126134
salt: ZogKvWdyEx
@@ -138,6 +146,7 @@
138146
name: user9
139147
full_name: User Nine
140148
149+
email_notifications_preference: onmention
141150
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
142151
type: 0 # individual
143152
salt: ZogKvWdyEx

models/issue_mail.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,17 @@ func mailIssueCommentToParticipants(e Engine, issue *Issue, doer *User, content
7070
if err != nil {
7171
return fmt.Errorf("GetUserByID [%d]: %v", watchers[i].UserID, err)
7272
}
73-
if to.IsOrganization() {
73+
if to.IsOrganization() || to.EmailNotifications() != EmailNotificationsEnabled {
7474
continue
7575
}
7676

7777
tos = append(tos, to.Email)
7878
names = append(names, to.Name)
7979
}
8080
for i := range participants {
81-
if participants[i].ID == doer.ID {
82-
continue
83-
} else if com.IsSliceContainsStr(names, participants[i].Name) {
81+
if participants[i].ID == doer.ID ||
82+
com.IsSliceContainsStr(names, participants[i].Name) ||
83+
participants[i].EmailNotifications() != EmailNotificationsEnabled {
8484
continue
8585
}
8686

models/migrations/migrations.go

+2
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ var migrations = []Migration{
240240
NewMigration("add index on owner_id of repository and type, review_id of comment", addIndexOnRepositoryAndComment),
241241
// v92 -> v93
242242
NewMigration("remove orphaned repository index statuses", removeLingeringIndexStatus),
243+
// v93 -> v94
244+
NewMigration("add email notification enabled preference to user", addEmailNotificationEnabledToUser),
243245
}
244246

245247
// Migrate database to current version

models/migrations/v93.go

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2019 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package migrations
6+
7+
import "github.com/go-xorm/xorm"
8+
9+
func addEmailNotificationEnabledToUser(x *xorm.Engine) error {
10+
// User see models/user.go
11+
type User struct {
12+
EmailNotificationsPreference string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'enabled'"`
13+
}
14+
15+
return x.Sync2(new(User))
16+
}

models/user.go

+31-6
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ const (
5858
algoScrypt = "scrypt"
5959
algoArgon2 = "argon2"
6060
algoPbkdf2 = "pbkdf2"
61+
62+
// EmailNotificationsEnabled indicates that the user would like to receive all email notifications
63+
EmailNotificationsEnabled = "enabled"
64+
// EmailNotificationsOnMention indicates that the user would like to be notified via email when mentioned.
65+
EmailNotificationsOnMention = "onmention"
66+
// EmailNotificationsDisabled indicates that the user would not like to be notified via email.
67+
EmailNotificationsDisabled = "disabled"
6168
)
6269

6370
var (
@@ -87,10 +94,11 @@ type User struct {
8794
Name string `xorm:"UNIQUE NOT NULL"`
8895
FullName string
8996
// Email is the primary email address (to be used for communication)
90-
Email string `xorm:"NOT NULL"`
91-
KeepEmailPrivate bool
92-
Passwd string `xorm:"NOT NULL"`
93-
PasswdHashAlgo string `xorm:"NOT NULL DEFAULT 'pbkdf2'"`
97+
Email string `xorm:"NOT NULL"`
98+
KeepEmailPrivate bool
99+
EmailNotificationsPreference string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'enabled'"`
100+
Passwd string `xorm:"NOT NULL"`
101+
PasswdHashAlgo string `xorm:"NOT NULL DEFAULT 'pbkdf2'"`
94102

95103
// MustChangePassword is an attribute that determines if a user
96104
// is to change his/her password after registration.
@@ -719,6 +727,21 @@ func (u *User) IsMailable() bool {
719727
return u.IsActive
720728
}
721729

730+
// EmailNotifications returns the User's email notification preference
731+
func (u *User) EmailNotifications() string {
732+
return u.EmailNotificationsPreference
733+
}
734+
735+
// SetEmailNotifications sets the user's email notification preference
736+
func (u *User) SetEmailNotifications(set string) error {
737+
u.EmailNotificationsPreference = set
738+
if err := UpdateUserCols(u, "email_notifications_preference"); err != nil {
739+
log.Error("SetEmailNotifications: %v", err)
740+
return err
741+
}
742+
return nil
743+
}
744+
722745
func isUserExist(e Engine, uid int64, name string) (bool, error) {
723746
if len(name) == 0 {
724747
return false, nil
@@ -868,6 +891,7 @@ func CreateUser(u *User) (err error) {
868891
}
869892
u.HashPassword(u.Passwd)
870893
u.AllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization && !setting.Admin.DisableRegularOrgCreation
894+
u.EmailNotificationsPreference = setting.Admin.DefaultEmailNotification
871895
u.MaxRepoCreation = -1
872896
u.Theme = setting.UI.DefaultTheme
873897

@@ -1253,7 +1277,8 @@ func getUserByName(e Engine, name string) (*User, error) {
12531277
return u, nil
12541278
}
12551279

1256-
// GetUserEmailsByNames returns a list of e-mails corresponds to names.
1280+
// GetUserEmailsByNames returns a list of e-mails corresponds to names of users
1281+
// that have their email notifications set to enabled or onmention.
12571282
func GetUserEmailsByNames(names []string) []string {
12581283
return getUserEmailsByNames(x, names)
12591284
}
@@ -1265,7 +1290,7 @@ func getUserEmailsByNames(e Engine, names []string) []string {
12651290
if err != nil {
12661291
continue
12671292
}
1268-
if u.IsMailable() {
1293+
if u.IsMailable() && u.EmailNotifications() != EmailNotificationsDisabled {
12691294
mails = append(mails, u.Email)
12701295
}
12711296
}

models/user_test.go

+33
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ func TestGetUserEmailsByNames(t *testing.T) {
7474
// ignore none active user email
7575
assert.Equal(t, []string{"[email protected]"}, GetUserEmailsByNames([]string{"user8", "user9"}))
7676
assert.Equal(t, []string{"[email protected]", "[email protected]"}, GetUserEmailsByNames([]string{"user8", "user5"}))
77+
78+
assert.Equal(t, []string{"[email protected]"}, GetUserEmailsByNames([]string{"user8", "user7"}))
7779
}
7880

7981
func TestUser_APIFormat(t *testing.T) {
@@ -196,6 +198,37 @@ func TestDeleteUser(t *testing.T) {
196198
test(11)
197199
}
198200

201+
func TestEmailNotificationPreferences(t *testing.T) {
202+
assert.NoError(t, PrepareTestDatabase())
203+
for _, test := range []struct {
204+
expected string
205+
userID int64
206+
}{
207+
{EmailNotificationsEnabled, 1},
208+
{EmailNotificationsEnabled, 2},
209+
{EmailNotificationsOnMention, 3},
210+
{EmailNotificationsOnMention, 4},
211+
{EmailNotificationsEnabled, 5},
212+
{EmailNotificationsEnabled, 6},
213+
{EmailNotificationsDisabled, 7},
214+
{EmailNotificationsEnabled, 8},
215+
{EmailNotificationsOnMention, 9},
216+
} {
217+
user := AssertExistsAndLoadBean(t, &User{ID: test.userID}).(*User)
218+
assert.Equal(t, test.expected, user.EmailNotifications())
219+
220+
// Try all possible settings
221+
assert.NoError(t, user.SetEmailNotifications(EmailNotificationsEnabled))
222+
assert.Equal(t, EmailNotificationsEnabled, user.EmailNotifications())
223+
224+
assert.NoError(t, user.SetEmailNotifications(EmailNotificationsOnMention))
225+
assert.Equal(t, EmailNotificationsOnMention, user.EmailNotifications())
226+
227+
assert.NoError(t, user.SetEmailNotifications(EmailNotificationsDisabled))
228+
assert.Equal(t, EmailNotificationsDisabled, user.EmailNotifications())
229+
}
230+
}
231+
199232
func TestHashPasswordDeterministic(t *testing.T) {
200233
b := make([]byte, 16)
201234
rand.Read(b)

modules/setting/setting.go

+4
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ var (
231231
// Admin settings
232232
Admin struct {
233233
DisableRegularOrgCreation bool
234+
DefaultEmailNotification string
234235
}
235236

236237
// Picture settings
@@ -754,6 +755,9 @@ func NewContext() {
754755
}
755756
}
756757

758+
sec = Cfg.Section("admin")
759+
Admin.DefaultEmailNotification = sec.Key("DEFAULT_EMAIL_NOTIFICATIONS").MustString("enabled")
760+
757761
sec = Cfg.Section("security")
758762
InstallLock = sec.Key("INSTALL_LOCK").MustBool(false)
759763
SecretKey = sec.Key("SECRET_KEY").MustString("!#@FDEWREWR&*(")

options/locale/locale_en-US.ini

+9
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,11 @@ confirm_delete_account = Confirm Deletion
557557
delete_account_title = Delete User Account
558558
delete_account_desc = Are you sure you want to permanently delete this user account?
559559
560+
email_notifications.enable = Enable Email Notifications
561+
email_notifications.onmention = Only Email on Mention
562+
email_notifications.disable = Disable Email Notifications
563+
email_notifications.submit = Set Email Preference
564+
560565
[repo]
561566
owner = Owner
562567
repo_name = Repository Name
@@ -1126,6 +1131,10 @@ settings.basic_settings = Basic Settings
11261131
settings.mirror_settings = Mirror Settings
11271132
settings.sync_mirror = Synchronize Now
11281133
settings.mirror_sync_in_progress = Mirror synchronization is in progress. Check back in a minute.
1134+
settings.email_notifications.enable = Enable Email Notifications
1135+
settings.email_notifications.onmention = Only Email on Mention
1136+
settings.email_notifications.disable = Disable Email Notifications
1137+
settings.email_notifications.submit = Set Email Preference
11291138
settings.site = Website
11301139
settings.update_settings = Update Settings
11311140
settings.advanced_settings = Advanced Settings

public/css/index.css

+1-1
Original file line numberDiff line numberDiff line change
@@ -786,7 +786,7 @@ footer .ui.left,footer .ui.right{line-height:40px}
786786
.ui.form .dropzone .dz-error-message{top:140px}
787787
.settings .content{margin-top:2px}
788788
.settings .content .segment,.settings .content>.header{box-shadow:0 1px 2px 0 rgba(34,36,38,.15)}
789-
.settings .list>.item .green{color:#21ba45}
789+
.settings .list>.item .green:not(.ui.button){color:#21ba45}
790790
.settings .list>.item:not(:first-child){border-top:1px solid #eaeaea;padding:1rem;margin:15px -1rem -1rem -1rem}
791791
.settings .list>.item>.mega-octicon{display:table-cell}
792792
.settings .list>.item>.mega-octicon+.content{display:table-cell;padding:0 0 0 .5em;vertical-align:top}

public/less/_repository.less

+1-1
Original file line numberDiff line numberDiff line change
@@ -2013,7 +2013,7 @@
20132013

20142014
.list {
20152015
> .item {
2016-
.green {
2016+
.green:not(.ui.button) {
20172017
color: #21ba45;
20182018
}
20192019

routers/user/setting/account.go

+22
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
package setting
77

88
import (
9+
"errors"
10+
911
"code.gitea.io/gitea/models"
1012
"code.gitea.io/gitea/modules/auth"
1113
"code.gitea.io/gitea/modules/base"
@@ -24,6 +26,7 @@ func Account(ctx *context.Context) {
2426
ctx.Data["Title"] = ctx.Tr("settings")
2527
ctx.Data["PageIsSettingsAccount"] = true
2628
ctx.Data["Email"] = ctx.User.Email
29+
ctx.Data["EmailNotificationsPreference"] = ctx.User.EmailNotifications()
2730

2831
loadAccountData(ctx)
2932

@@ -82,6 +85,25 @@ func EmailPost(ctx *context.Context, form auth.AddEmailForm) {
8285
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
8386
return
8487
}
88+
// Set Email Notification Preference
89+
if ctx.Query("_method") == "NOTIFICATION" {
90+
preference := ctx.Query("preference")
91+
if !(preference == models.EmailNotificationsEnabled ||
92+
preference == models.EmailNotificationsOnMention ||
93+
preference == models.EmailNotificationsDisabled) {
94+
log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.User.Name)
95+
ctx.ServerError("SetEmailPreference", errors.New("option unrecognized"))
96+
return
97+
}
98+
if err := ctx.User.SetEmailNotifications(preference); err != nil {
99+
log.Error("Set Email Notifications failed: %v", err)
100+
ctx.ServerError("SetEmailNotifications", err)
101+
return
102+
}
103+
log.Trace("Email notifications preference made %s: %s", preference, ctx.User.Name)
104+
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
105+
return
106+
}
85107

86108
if ctx.HasError() {
87109
loadAccountData(ctx)

0 commit comments

Comments
 (0)