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 <gary@garykim.dev>

* Add Third Option for Only Email on Mention

Signed-off-by: Gary Kim <gary@garykim.dev>

* Readd NOT NULL to new preference string

Signed-off-by: Gary Kim <gary@garykim.dev>

* Add Tests and Rewrite Comment

Signed-off-by: Gary Kim <gary@garykim.dev>

* Allow admin to set default email frequency

Signed-off-by: Gary Kim <gary@garykim.dev>

* Add new config option to docs

Signed-off-by: Gary Kim <gary@garykim.dev>

* Fix a few mistakes

Signed-off-by: Gary Kim <gary@garykim.dev>

* Only update required columns

Signed-off-by: Gary Kim <gary@garykim.dev>

* Simplify an error check

Signed-off-by: Gary Kim <gary@garykim.dev>

* Make email_notification_preference column in DB be VARCHAR(20)

Signed-off-by: Gary Kim <gary@garykim.dev>

* Handle errors

Signed-off-by: Gary Kim <gary@garykim.dev>

* Update models/migrations/v93.go

Co-Authored-By: Lauris BH <lauris@nix.lv>
release/v1.10
Gary Kim 5 years ago committed by Lauris BH
parent 9ef1e5da27
commit f1c414882c

@ -306,6 +306,8 @@ MAX_FILE_SIZE = 1048576
[admin] [admin]
; Disallow regular (non-admin) users from creating organizations. ; Disallow regular (non-admin) users from creating organizations.
DISABLE_REGULAR_ORG_CREATION = false DISABLE_REGULAR_ORG_CREATION = false
; Default configuration for email notifications for users (user configurable). Options: enabled, onmention, disabled
DEFAULT_EMAIL_NOTIFICATIONS = enabled
[security] [security]
; Whether the installer is disabled ; Whether the installer is disabled

@ -184,6 +184,9 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
- `UPDATE_BUFFER_LEN`: **20**: Buffer length of index request. - `UPDATE_BUFFER_LEN`: **20**: Buffer length of index request.
- `MAX_FILE_SIZE`: **1048576**: Maximum size in bytes of files to be indexed. - `MAX_FILE_SIZE`: **1048576**: Maximum size in bytes of files to be indexed.
## Admin (`admin`)
- `DEFAULT_EMAIL_NOTIFICATIONS`: **enabled**: Default configuration for email notifications for users (user configurable). Options: enabled, onmention, disabled
## Security (`security`) ## Security (`security`)
- `INSTALL_LOCK`: **false**: Disallow access to the install page. - `INSTALL_LOCK`: **false**: Disallow access to the install page.

@ -6,6 +6,7 @@
name: user1 name: user1
full_name: User One full_name: User One
email: user1@example.com email: user1@example.com
email_notifications_preference: enabled
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
type: 0 # individual type: 0 # individual
salt: ZogKvWdyEx salt: ZogKvWdyEx
@ -22,6 +23,7 @@
full_name: " < U<se>r Tw<o > >< " full_name: " < U<se>r Tw<o > >< "
email: user2@example.com email: user2@example.com
keep_email_private: true keep_email_private: true
email_notifications_preference: enabled
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
type: 0 # individual type: 0 # individual
salt: ZogKvWdyEx salt: ZogKvWdyEx
@ -40,6 +42,7 @@
name: user3 name: user3
full_name: " <<<< >> >> > >> > >>> >> " full_name: " <<<< >> >> > >> > >>> >> "
email: user3@example.com email: user3@example.com
email_notifications_preference: onmention
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
type: 1 # organization type: 1 # organization
salt: ZogKvWdyEx salt: ZogKvWdyEx
@ -56,6 +59,7 @@
name: user4 name: user4
full_name: " " full_name: " "
email: user4@example.com email: user4@example.com
email_notifications_preference: onmention
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
type: 0 # individual type: 0 # individual
salt: ZogKvWdyEx salt: ZogKvWdyEx
@ -72,6 +76,7 @@
name: user5 name: user5
full_name: User Five full_name: User Five
email: user5@example.com email: user5@example.com
email_notifications_preference: enabled
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
type: 0 # individual type: 0 # individual
salt: ZogKvWdyEx salt: ZogKvWdyEx
@ -89,6 +94,7 @@
name: user6 name: user6
full_name: User Six full_name: User Six
email: user6@example.com email: user6@example.com
email_notifications_preference: enabled
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
type: 1 # organization type: 1 # organization
salt: ZogKvWdyEx salt: ZogKvWdyEx
@ -105,6 +111,7 @@
name: user7 name: user7
full_name: User Seven full_name: User Seven
email: user7@example.com email: user7@example.com
email_notifications_preference: disabled
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
type: 1 # organization type: 1 # organization
salt: ZogKvWdyEx salt: ZogKvWdyEx
@ -121,6 +128,7 @@
name: user8 name: user8
full_name: User Eight full_name: User Eight
email: user8@example.com email: user8@example.com
email_notifications_preference: enabled
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
type: 0 # individual type: 0 # individual
salt: ZogKvWdyEx salt: ZogKvWdyEx
@ -138,6 +146,7 @@
name: user9 name: user9
full_name: User Nine full_name: User Nine
email: user9@example.com email: user9@example.com
email_notifications_preference: onmention
passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password passwd: 7d93daa0d1e6f2305cc8fa496847d61dc7320bb16262f9c55dd753480207234cdd96a93194e408341971742f4701772a025a # password
type: 0 # individual type: 0 # individual
salt: ZogKvWdyEx salt: ZogKvWdyEx

@ -70,7 +70,7 @@ func mailIssueCommentToParticipants(e Engine, issue *Issue, doer *User, content
if err != nil { if err != nil {
return fmt.Errorf("GetUserByID [%d]: %v", watchers[i].UserID, err) return fmt.Errorf("GetUserByID [%d]: %v", watchers[i].UserID, err)
} }
if to.IsOrganization() { if to.IsOrganization() || to.EmailNotifications() != EmailNotificationsEnabled {
continue continue
} }
@ -78,9 +78,9 @@ func mailIssueCommentToParticipants(e Engine, issue *Issue, doer *User, content
names = append(names, to.Name) names = append(names, to.Name)
} }
for i := range participants { for i := range participants {
if participants[i].ID == doer.ID { if participants[i].ID == doer.ID ||
continue com.IsSliceContainsStr(names, participants[i].Name) ||
} else if com.IsSliceContainsStr(names, participants[i].Name) { participants[i].EmailNotifications() != EmailNotificationsEnabled {
continue continue
} }

@ -240,6 +240,8 @@ var migrations = []Migration{
NewMigration("add index on owner_id of repository and type, review_id of comment", addIndexOnRepositoryAndComment), NewMigration("add index on owner_id of repository and type, review_id of comment", addIndexOnRepositoryAndComment),
// v92 -> v93 // v92 -> v93
NewMigration("remove orphaned repository index statuses", removeLingeringIndexStatus), NewMigration("remove orphaned repository index statuses", removeLingeringIndexStatus),
// v93 -> v94
NewMigration("add email notification enabled preference to user", addEmailNotificationEnabledToUser),
} }
// Migrate database to current version // Migrate database to current version

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

@ -58,6 +58,13 @@ const (
algoScrypt = "scrypt" algoScrypt = "scrypt"
algoArgon2 = "argon2" algoArgon2 = "argon2"
algoPbkdf2 = "pbkdf2" algoPbkdf2 = "pbkdf2"
// EmailNotificationsEnabled indicates that the user would like to receive all email notifications
EmailNotificationsEnabled = "enabled"
// EmailNotificationsOnMention indicates that the user would like to be notified via email when mentioned.
EmailNotificationsOnMention = "onmention"
// EmailNotificationsDisabled indicates that the user would not like to be notified via email.
EmailNotificationsDisabled = "disabled"
) )
var ( var (
@ -87,10 +94,11 @@ type User struct {
Name string `xorm:"UNIQUE NOT NULL"` Name string `xorm:"UNIQUE NOT NULL"`
FullName string FullName string
// Email is the primary email address (to be used for communication) // Email is the primary email address (to be used for communication)
Email string `xorm:"NOT NULL"` Email string `xorm:"NOT NULL"`
KeepEmailPrivate bool KeepEmailPrivate bool
Passwd string `xorm:"NOT NULL"` EmailNotificationsPreference string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'enabled'"`
PasswdHashAlgo string `xorm:"NOT NULL DEFAULT 'pbkdf2'"` Passwd string `xorm:"NOT NULL"`
PasswdHashAlgo string `xorm:"NOT NULL DEFAULT 'pbkdf2'"`
// MustChangePassword is an attribute that determines if a user // MustChangePassword is an attribute that determines if a user
// is to change his/her password after registration. // is to change his/her password after registration.
@ -719,6 +727,21 @@ func (u *User) IsMailable() bool {
return u.IsActive return u.IsActive
} }
// EmailNotifications returns the User's email notification preference
func (u *User) EmailNotifications() string {
return u.EmailNotificationsPreference
}
// SetEmailNotifications sets the user's email notification preference
func (u *User) SetEmailNotifications(set string) error {
u.EmailNotificationsPreference = set
if err := UpdateUserCols(u, "email_notifications_preference"); err != nil {
log.Error("SetEmailNotifications: %v", err)
return err
}
return nil
}
func isUserExist(e Engine, uid int64, name string) (bool, error) { func isUserExist(e Engine, uid int64, name string) (bool, error) {
if len(name) == 0 { if len(name) == 0 {
return false, nil return false, nil
@ -868,6 +891,7 @@ func CreateUser(u *User) (err error) {
} }
u.HashPassword(u.Passwd) u.HashPassword(u.Passwd)
u.AllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization && !setting.Admin.DisableRegularOrgCreation u.AllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization && !setting.Admin.DisableRegularOrgCreation
u.EmailNotificationsPreference = setting.Admin.DefaultEmailNotification
u.MaxRepoCreation = -1 u.MaxRepoCreation = -1
u.Theme = setting.UI.DefaultTheme u.Theme = setting.UI.DefaultTheme
@ -1253,7 +1277,8 @@ func getUserByName(e Engine, name string) (*User, error) {
return u, nil return u, nil
} }
// GetUserEmailsByNames returns a list of e-mails corresponds to names. // GetUserEmailsByNames returns a list of e-mails corresponds to names of users
// that have their email notifications set to enabled or onmention.
func GetUserEmailsByNames(names []string) []string { func GetUserEmailsByNames(names []string) []string {
return getUserEmailsByNames(x, names) return getUserEmailsByNames(x, names)
} }
@ -1265,7 +1290,7 @@ func getUserEmailsByNames(e Engine, names []string) []string {
if err != nil { if err != nil {
continue continue
} }
if u.IsMailable() { if u.IsMailable() && u.EmailNotifications() != EmailNotificationsDisabled {
mails = append(mails, u.Email) mails = append(mails, u.Email)
} }
} }

@ -74,6 +74,8 @@ func TestGetUserEmailsByNames(t *testing.T) {
// ignore none active user email // ignore none active user email
assert.Equal(t, []string{"user8@example.com"}, GetUserEmailsByNames([]string{"user8", "user9"})) assert.Equal(t, []string{"user8@example.com"}, GetUserEmailsByNames([]string{"user8", "user9"}))
assert.Equal(t, []string{"user8@example.com", "user5@example.com"}, GetUserEmailsByNames([]string{"user8", "user5"})) assert.Equal(t, []string{"user8@example.com", "user5@example.com"}, GetUserEmailsByNames([]string{"user8", "user5"}))
assert.Equal(t, []string{"user8@example.com"}, GetUserEmailsByNames([]string{"user8", "user7"}))
} }
func TestUser_APIFormat(t *testing.T) { func TestUser_APIFormat(t *testing.T) {
@ -196,6 +198,37 @@ func TestDeleteUser(t *testing.T) {
test(11) test(11)
} }
func TestEmailNotificationPreferences(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())
for _, test := range []struct {
expected string
userID int64
}{
{EmailNotificationsEnabled, 1},
{EmailNotificationsEnabled, 2},
{EmailNotificationsOnMention, 3},
{EmailNotificationsOnMention, 4},
{EmailNotificationsEnabled, 5},
{EmailNotificationsEnabled, 6},
{EmailNotificationsDisabled, 7},
{EmailNotificationsEnabled, 8},
{EmailNotificationsOnMention, 9},
} {
user := AssertExistsAndLoadBean(t, &User{ID: test.userID}).(*User)
assert.Equal(t, test.expected, user.EmailNotifications())
// Try all possible settings
assert.NoError(t, user.SetEmailNotifications(EmailNotificationsEnabled))
assert.Equal(t, EmailNotificationsEnabled, user.EmailNotifications())
assert.NoError(t, user.SetEmailNotifications(EmailNotificationsOnMention))
assert.Equal(t, EmailNotificationsOnMention, user.EmailNotifications())
assert.NoError(t, user.SetEmailNotifications(EmailNotificationsDisabled))
assert.Equal(t, EmailNotificationsDisabled, user.EmailNotifications())
}
}
func TestHashPasswordDeterministic(t *testing.T) { func TestHashPasswordDeterministic(t *testing.T) {
b := make([]byte, 16) b := make([]byte, 16)
rand.Read(b) rand.Read(b)

@ -231,6 +231,7 @@ var (
// Admin settings // Admin settings
Admin struct { Admin struct {
DisableRegularOrgCreation bool DisableRegularOrgCreation bool
DefaultEmailNotification string
} }
// Picture settings // Picture settings
@ -754,6 +755,9 @@ func NewContext() {
} }
} }
sec = Cfg.Section("admin")
Admin.DefaultEmailNotification = sec.Key("DEFAULT_EMAIL_NOTIFICATIONS").MustString("enabled")
sec = Cfg.Section("security") sec = Cfg.Section("security")
InstallLock = sec.Key("INSTALL_LOCK").MustBool(false) InstallLock = sec.Key("INSTALL_LOCK").MustBool(false)
SecretKey = sec.Key("SECRET_KEY").MustString("!#@FDEWREWR&*(") SecretKey = sec.Key("SECRET_KEY").MustString("!#@FDEWREWR&*(")

@ -557,6 +557,11 @@ confirm_delete_account = Confirm Deletion
delete_account_title = Delete User Account delete_account_title = Delete User Account
delete_account_desc = Are you sure you want to permanently delete this user account? delete_account_desc = Are you sure you want to permanently delete this user account?
email_notifications.enable = Enable Email Notifications
email_notifications.onmention = Only Email on Mention
email_notifications.disable = Disable Email Notifications
email_notifications.submit = Set Email Preference
[repo] [repo]
owner = Owner owner = Owner
repo_name = Repository Name repo_name = Repository Name
@ -1126,6 +1131,10 @@ settings.basic_settings = Basic Settings
settings.mirror_settings = Mirror Settings settings.mirror_settings = Mirror Settings
settings.sync_mirror = Synchronize Now settings.sync_mirror = Synchronize Now
settings.mirror_sync_in_progress = Mirror synchronization is in progress. Check back in a minute. settings.mirror_sync_in_progress = Mirror synchronization is in progress. Check back in a minute.
settings.email_notifications.enable = Enable Email Notifications
settings.email_notifications.onmention = Only Email on Mention
settings.email_notifications.disable = Disable Email Notifications
settings.email_notifications.submit = Set Email Preference
settings.site = Website settings.site = Website
settings.update_settings = Update Settings settings.update_settings = Update Settings
settings.advanced_settings = Advanced Settings settings.advanced_settings = Advanced Settings

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

@ -2013,7 +2013,7 @@
.list { .list {
> .item { > .item {
.green { .green:not(.ui.button) {
color: #21ba45; color: #21ba45;
} }

@ -6,6 +6,8 @@
package setting package setting
import ( import (
"errors"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/auth"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
@ -24,6 +26,7 @@ func Account(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsAccount"] = true ctx.Data["PageIsSettingsAccount"] = true
ctx.Data["Email"] = ctx.User.Email ctx.Data["Email"] = ctx.User.Email
ctx.Data["EmailNotificationsPreference"] = ctx.User.EmailNotifications()
loadAccountData(ctx) loadAccountData(ctx)
@ -82,6 +85,25 @@ func EmailPost(ctx *context.Context, form auth.AddEmailForm) {
ctx.Redirect(setting.AppSubURL + "/user/settings/account") ctx.Redirect(setting.AppSubURL + "/user/settings/account")
return return
} }
// Set Email Notification Preference
if ctx.Query("_method") == "NOTIFICATION" {
preference := ctx.Query("preference")
if !(preference == models.EmailNotificationsEnabled ||
preference == models.EmailNotificationsOnMention ||
preference == models.EmailNotificationsDisabled) {
log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.User.Name)
ctx.ServerError("SetEmailPreference", errors.New("option unrecognized"))
return
}
if err := ctx.User.SetEmailNotifications(preference); err != nil {
log.Error("Set Email Notifications failed: %v", err)
ctx.ServerError("SetEmailNotifications", err)
return
}
log.Trace("Email notifications preference made %s: %s", preference, ctx.User.Name)
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
return
}
if ctx.HasError() { if ctx.HasError() {
loadAccountData(ctx) loadAccountData(ctx)

@ -43,7 +43,30 @@
<div class="ui attached segment"> <div class="ui attached segment">
<div class="ui email list"> <div class="ui email list">
<div class="item"> <div class="item">
{{.i18n.Tr "settings.email_desc"}} <form action="{{AppSubUrl}}/user/settings/account/email" class="ui form" method="post">
{{.i18n.Tr "settings.email_desc"}}
<div class="right floated content">
<div class="field">
<button class="ui green button">{{$.i18n.Tr "settings.email_notifications.submit"}}</button>
</div>
</div>
<div class="right floated content">
{{$.CsrfTokenHtml}}
<input name="_method" type="hidden" value="NOTIFICATION">
<div class="field">
<div class="ui selection dropdown" tabindex="0">
<input name="preference" type="hidden" value="{{.EmailNotificationsPreference}}">
<i class="dropdown icon"></i>
<div class="text">{{$.i18n.Tr "settings.email_notifications"}}</div>
<div class="menu">
<div data-value="enabled" class="{{if eq .EmailNotificationsPreference "enabled"}}active selected {{end}}item">{{$.i18n.Tr "settings.email_notifications.enable"}}</div>
<div data-value="onmention" class="{{if eq .EmailNotificationsPreference "onmention"}}active selected {{end}}item">{{$.i18n.Tr "settings.email_notifications.onmention"}}</div>
<div data-value="disabled" class="{{if eq .EmailNotificationsPreference "disabled"}}active selected {{end}}item">{{$.i18n.Tr "settings.email_notifications.disable"}}</div>
</div>
</div>
</div>
</div>
</form>
</div> </div>
{{range .Emails}} {{range .Emails}}
<div class="item"> <div class="item">
@ -103,7 +126,7 @@
<i class="dropdown icon"></i> <i class="dropdown icon"></i>
<div class="text"> <div class="text">
{{range $i,$a := .AllThemes}} {{range $i,$a := .AllThemes}}
{{if eq $.SignedUser.Theme $a}}{{$a}}{{end}} {{if eq $.SignedUser.Theme $a}}{{$a}}{{end}}
{{end}} {{end}}
</div> </div>

Loading…
Cancel
Save