diff --git a/models/user/email_notification.go b/models/user/email_notification.go new file mode 100644 index 0000000000000..c6faa2690960d --- /dev/null +++ b/models/user/email_notification.go @@ -0,0 +1,11 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +// setting values +const ( + EmailNotificationGiteaActionsAll = "all" + EmailNotificationGiteaActionsFailureOnly = "failureonly" // Default for actions email preference + EmailNotificationGiteaActionsDisabled = "disabled" +) diff --git a/models/user/email_notification_test.go b/models/user/email_notification_test.go new file mode 100644 index 0000000000000..a099e10b246df --- /dev/null +++ b/models/user/email_notification_test.go @@ -0,0 +1,34 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" +) + +func TestNotificationSettings(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + u := unittest.AssertExistsAndLoadBean(t, &User{ID: 1}) + + assert.NoError(t, SetUserSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions, EmailNotificationGiteaActionsAll)) + settings, err := GetSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions) + assert.NoError(t, err) + assert.Equal(t, EmailNotificationGiteaActionsAll, settings) + + assert.NoError(t, SetUserSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions, EmailNotificationGiteaActionsDisabled)) + settings, err = GetSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions) + assert.NoError(t, err) + assert.Equal(t, EmailNotificationGiteaActionsDisabled, settings) + + assert.NoError(t, SetUserSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions, EmailNotificationGiteaActionsFailureOnly)) + settings, err = GetSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions) + assert.NoError(t, err) + assert.Equal(t, EmailNotificationGiteaActionsFailureOnly, settings) +} diff --git a/models/user/setting_keys.go b/models/user/setting_keys.go index 2c2ed078beabb..1505c8aa3538d 100644 --- a/models/user/setting_keys.go +++ b/models/user/setting_keys.go @@ -21,4 +21,6 @@ const ( SignupUserAgent = "signup.user_agent" SettingsKeyCodeViewShowFileTree = "code_view.show_file_tree" + + SettingsEmailNotificationGiteaActions = "email_notifications.actions" ) diff --git a/models/user/user.go b/models/user/user.go index c362cbc6d2b55..f8d74924b5db5 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -798,6 +798,10 @@ func createUser(ctx context.Context, u *User, meta *Meta, createdByAdmin bool, o return err } + if err := SetUserSetting(ctx, u.ID, SettingsEmailNotificationGiteaActions, EmailNotificationGiteaActionsFailureOnly); err != nil { + return err + } + return committer.Commit() } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index ff32c94ff93e9..937eec14561c9 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1021,6 +1021,8 @@ email_notifications.onmention = Only Email on Mention email_notifications.disable = Disable Email Notifications email_notifications.submit = Set Email Preference email_notifications.andyourown = And Your Own Notifications +email_notifications.actions.desc = Notifications for workflow runs on repositories set up with Gitea Actions. +email_notifications.actions.failureonly = Only notify for failed workflow runs visibility = User visibility visibility.public = Public diff --git a/routers/web/devtest/mail_preview.go b/routers/web/devtest/mail_preview.go index 79dd441eab916..d6bade15d7add 100644 --- a/routers/web/devtest/mail_preview.go +++ b/routers/web/devtest/mail_preview.go @@ -16,7 +16,7 @@ import ( func MailPreviewRender(ctx *context.Context) { tmplName := ctx.PathParam("*") - mockDataContent, err := templates.AssetFS().ReadFile("mail/" + tmplName + ".mock.yml") + mockDataContent, err := templates.AssetFS().ReadFile("mail/" + tmplName + ".devtest.yml") mockData := map[string]any{} if err == nil { err = yaml.Unmarshal(mockDataContent, &mockData) diff --git a/routers/web/user/setting/notifications.go b/routers/web/user/setting/notifications.go index 16e58a0481a64..44f56959df0c7 100644 --- a/routers/web/user/setting/notifications.go +++ b/routers/web/user/setting/notifications.go @@ -7,6 +7,7 @@ import ( "errors" "net/http" + "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" @@ -29,6 +30,13 @@ func Notifications(ctx *context.Context) { ctx.Data["PageIsSettingsNotifications"] = true ctx.Data["EmailNotificationsPreference"] = ctx.Doer.EmailNotificationsPreference + actionsEmailPref, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsEmailNotificationGiteaActions, user_model.EmailNotificationGiteaActionsFailureOnly) + if err != nil { + ctx.ServerError("GetUserSetting", err) + return + } + ctx.Data["ActionsEmailNotificationsPreference"] = actionsEmailPref + ctx.HTML(http.StatusOK, tplSettingsNotifications) } @@ -45,7 +53,7 @@ func NotificationsEmailPost(ctx *context.Context) { preference == user_model.EmailNotificationsDisabled || preference == user_model.EmailNotificationsAndYourOwn) { log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.Doer.Name) - ctx.ServerError("SetEmailPreference", errors.New("option unrecognized")) + ctx.ServerError("NotificationsEmailPost", errors.New("option unrecognized")) return } opts := &user.UpdateOptions{ @@ -60,3 +68,28 @@ func NotificationsEmailPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success")) ctx.Redirect(setting.AppSubURL + "/user/settings/notifications") } + +// NotificationsActionsEmailPost set user's email notification preference on Gitea Actions +func NotificationsActionsEmailPost(ctx *context.Context) { + if !setting.Actions.Enabled || unit.TypeActions.UnitGlobalDisabled() { + ctx.NotFound(nil) + return + } + + preference := ctx.FormString("preference") + if !(preference == user_model.EmailNotificationGiteaActionsAll || + preference == user_model.EmailNotificationGiteaActionsDisabled || + preference == user_model.EmailNotificationGiteaActionsFailureOnly) { + log.Error("Actions Email notifications preference change returned unrecognized option %s: %s", preference, ctx.Doer.Name) + ctx.ServerError("NotificationsActionsEmailPost", errors.New("option unrecognized")) + return + } + if err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsEmailNotificationGiteaActions, preference); err != nil { + log.Error("Cannot set actions email notifications preference: %v", err) + ctx.ServerError("SetUserSetting", err) + return + } + log.Trace("Actions email notifications preference made %s: %s", preference, ctx.Doer.Name) + ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/notifications") +} diff --git a/routers/web/web.go b/routers/web/web.go index b9c7013f6395f..f8612db504dc5 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -598,6 +598,7 @@ func registerWebRoutes(m *web.Router) { m.Group("/notifications", func() { m.Get("", user_setting.Notifications) m.Post("/email", user_setting.NotificationsEmailPost) + m.Post("/actions", user_setting.NotificationsActionsEmailPost) }) m.Group("/security", func() { m.Get("", security.Security) diff --git a/services/mailer/mail.go b/services/mailer/mail.go index b7602e0321c55..d81b6d10afc12 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -174,3 +174,41 @@ func fromDisplayName(u *user_model.User) string { } return u.GetCompleteName() } + +func generateMetadataHeaders(repo *repo_model.Repository) map[string]string { + return map[string]string{ + // https://datatracker.ietf.org/doc/html/rfc2919 + "List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain), + + // https://datatracker.ietf.org/doc/html/rfc2369 + "List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()), + + "X-Mailer": "Gitea", + + "X-Gitea-Repository": repo.Name, + "X-Gitea-Repository-Path": repo.FullName(), + "X-Gitea-Repository-Link": repo.HTMLURL(), + + "X-GitLab-Project": repo.Name, + "X-GitLab-Project-Path": repo.FullName(), + } +} + +func generateSenderRecipientHeaders(doer, recipient *user_model.User) map[string]string { + return map[string]string{ + "X-Gitea-Sender": doer.Name, + "X-Gitea-Recipient": recipient.Name, + "X-Gitea-Recipient-Address": recipient.Email, + "X-GitHub-Sender": doer.Name, + "X-GitHub-Recipient": recipient.Name, + "X-GitHub-Recipient-Address": recipient.Email, + } +} + +func generateReasonHeaders(reason string) map[string]string { + return map[string]string{ + "X-Gitea-Reason": reason, + "X-GitHub-Reason": reason, + "X-GitLab-NotificationReason": reason, + } +} diff --git a/services/mailer/mail_issue_common.go b/services/mailer/mail_issue_common.go index 107f57772ca53..a34d8a68c97f3 100644 --- a/services/mailer/mail_issue_common.go +++ b/services/mailer/mail_issue_common.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "fmt" + "maps" "strconv" "strings" "time" @@ -29,7 +30,7 @@ import ( // Many e-mail service providers have limitations on the size of the email body, it's usually from 10MB to 25MB const maxEmailBodySize = 9_000_000 -func fallbackMailSubject(issue *issues_model.Issue) string { +func fallbackIssueMailSubject(issue *issues_model.Issue) string { return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index) } @@ -86,7 +87,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang if actName != "new" { prefix = "Re: " } - fallback = prefix + fallbackMailSubject(comment.Issue) + fallback = prefix + fallbackIssueMailSubject(comment.Issue) if comment.Comment != nil && comment.Comment.Review != nil { reviewComments = make([]*issues_model.Comment, 0, 10) @@ -202,7 +203,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang msg.SetHeader("References", references...) msg.SetHeader("List-Unsubscribe", listUnsubscribe...) - for key, value := range generateAdditionalHeaders(comment, actType, recipient) { + for key, value := range generateAdditionalHeadersForIssue(comment, actType, recipient) { msg.SetHeader(key, value) } @@ -302,35 +303,18 @@ func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model. return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain) } -func generateAdditionalHeaders(ctx *mailComment, reason string, recipient *user_model.User) map[string]string { +func generateAdditionalHeadersForIssue(ctx *mailComment, reason string, recipient *user_model.User) map[string]string { repo := ctx.Issue.Repo - return map[string]string{ - // https://datatracker.ietf.org/doc/html/rfc2919 - "List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain), - - // https://datatracker.ietf.org/doc/html/rfc2369 - "List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()), - - "X-Mailer": "Gitea", - "X-Gitea-Reason": reason, - "X-Gitea-Sender": ctx.Doer.Name, - "X-Gitea-Recipient": recipient.Name, - "X-Gitea-Recipient-Address": recipient.Email, - "X-Gitea-Repository": repo.Name, - "X-Gitea-Repository-Path": repo.FullName(), - "X-Gitea-Repository-Link": repo.HTMLURL(), - "X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10), - "X-Gitea-Issue-Link": ctx.Issue.HTMLURL(), - - "X-GitHub-Reason": reason, - "X-GitHub-Sender": ctx.Doer.Name, - "X-GitHub-Recipient": recipient.Name, - "X-GitHub-Recipient-Address": recipient.Email, - - "X-GitLab-NotificationReason": reason, - "X-GitLab-Project": repo.Name, - "X-GitLab-Project-Path": repo.FullName(), - "X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10), - } + issueID := strconv.FormatInt(ctx.Issue.Index, 10) + headers := generateMetadataHeaders(repo) + + maps.Copy(headers, generateSenderRecipientHeaders(ctx.Doer, recipient)) + maps.Copy(headers, generateReasonHeaders(reason)) + + headers["X-Gitea-Issue-ID"] = issueID + headers["X-Gitea-Issue-Link"] = ctx.Issue.HTMLURL() + headers["X-GitLab-Issue-IID"] = issueID + + return headers } diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index 3996796beb5e7..24f5d39d50d44 100644 --- a/services/mailer/mail_test.go +++ b/services/mailer/mail_test.go @@ -16,6 +16,7 @@ import ( "testing" texttmpl "text/template" + actions_model "code.gitea.io/gitea/models/actions" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" @@ -298,13 +299,13 @@ func testComposeIssueCommentMessage(t *testing.T, ctx *mailComment, recipients [ return msgs[0] } -func TestGenerateAdditionalHeaders(t *testing.T) { +func TestGenerateAdditionalHeadersForIssue(t *testing.T) { doer, _, issue, _ := prepareMailerTest(t) comment := &mailComment{Issue: issue, Doer: doer} recipient := &user_model.User{Name: "test", Email: "test@gitea.com"} - headers := generateAdditionalHeaders(comment, "dummy-reason", recipient) + headers := generateAdditionalHeadersForIssue(comment, "dummy-reason", recipient) expected := map[string]string{ "List-ID": "user2/repo1 ", @@ -441,6 +442,16 @@ func TestGenerateMessageIDForRelease(t *testing.T) { assert.Equal(t, "", msgID) } +func TestGenerateMessageIDForActionsWorkflowRunStatusEmail(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 795, RepoID: repo.ID}) + assert.NoError(t, run.LoadAttributes(db.DefaultContext)) + msgID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run) + assert.Equal(t, "", msgID) +} + func TestFromDisplayName(t *testing.T) { tmpl, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}") assert.NoError(t, err) diff --git a/services/mailer/mail_workflow_run.go b/services/mailer/mail_workflow_run.go new file mode 100644 index 0000000000000..892768c43c872 --- /dev/null +++ b/services/mailer/mail_workflow_run.go @@ -0,0 +1,166 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "context" + "fmt" + "sort" + + actions_model "code.gitea.io/gitea/models/actions" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/services/convert" + sender_service "code.gitea.io/gitea/services/mailer/sender" +) + +const tplWorkflowRun = "notify/workflow_run" + +type convertedWorkflowJob struct { + HTMLURL string + Status actions_model.Status + Name string + Attempt int64 +} + +func generateMessageIDForActionsWorkflowRunStatusEmail(repo *repo_model.Repository, run *actions_model.ActionRun) string { + return fmt.Sprintf("<%s/actions/runs/%d@%s>", repo.FullName(), run.Index, setting.Domain) +} + +func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) { + subject := "Run" + switch run.Status { + case actions_model.StatusFailure: + subject += " failed" + case actions_model.StatusCancelled: + subject += " cancelled" + case actions_model.StatusSuccess: + subject += " succeeded" + } + subject = fmt.Sprintf("%s: %s (%s)", subject, run.WorkflowID, base.ShortSha(run.CommitSHA)) + displayName := fromDisplayName(sender) + messageID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run) + metadataHeaders := generateMetadataHeaders(repo) + + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + log.Error("GetRunJobsByRunID: %v", err) + return + } + sort.SliceStable(jobs, func(i, j int) bool { + si, sj := jobs[i].Status, jobs[j].Status + /* + If both i and j are/are not success, leave it to si < sj. + If i is success and j is not, since the desired is j goes "smaller" and i goes "bigger", this func should return false. + If j is success and i is not, since the desired is i goes "smaller" and j goes "bigger", this func should return true. + */ + if si.IsSuccess() != sj.IsSuccess() { + return !si.IsSuccess() + } + return si < sj + }) + + convertedJobs := make([]convertedWorkflowJob, 0, len(jobs)) + for _, job := range jobs { + converted0, err := convert.ToActionWorkflowJob(ctx, repo, nil, job) + if err != nil { + log.Error("convert.ToActionWorkflowJob: %v", err) + continue + } + convertedJobs = append(convertedJobs, convertedWorkflowJob{ + HTMLURL: converted0.HTMLURL, + Name: converted0.Name, + Status: job.Status, + Attempt: converted0.RunAttempt, + }) + } + + langMap := make(map[string][]*user_model.User) + for _, user := range recipients { + langMap[user.Language] = append(langMap[user.Language], user) + } + for lang, tos := range langMap { + locale := translation.NewLocale(lang) + var runStatusText string + switch run.Status { + case actions_model.StatusSuccess: + runStatusText = "All jobs have succeeded" + case actions_model.StatusFailure: + runStatusText = "All jobs have failed" + for _, job := range jobs { + if !job.Status.IsFailure() { + runStatusText = "Some jobs were not successful" + break + } + } + case actions_model.StatusCancelled: + runStatusText = "All jobs have been cancelled" + } + var mailBody bytes.Buffer + if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tplWorkflowRun, map[string]any{ + "Subject": subject, + "Repo": repo, + "Run": run, + "RunStatusText": runStatusText, + "Jobs": convertedJobs, + "locale": locale, + "Language": locale.Language(), + }); err != nil { + log.Error("ExecuteTemplate [%s]: %v", tplWorkflowRun, err) + return + } + msgs := make([]*sender_service.Message, 0, len(tos)) + for _, rec := range tos { + msg := sender_service.NewMessageFrom( + rec.Email, + displayName, + setting.MailService.FromEmail, + subject, + mailBody.String(), + ) + msg.Info = subject + for k, v := range generateSenderRecipientHeaders(sender, rec) { + msg.SetHeader(k, v) + } + for k, v := range metadataHeaders { + msg.SetHeader(k, v) + } + msg.SetHeader("Message-ID", messageID) + msgs = append(msgs, msg) + } + SendAsync(msgs...) + } +} + +func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo_model.Repository, run *actions_model.ActionRun) { + if setting.MailService == nil { + return + } + if run.Status.IsSkipped() { + return + } + + recipients := make([]*user_model.User, 0) + + if !sender.IsGiteaActions() && !sender.IsGhost() && sender.IsMailable() { + notifyPref, err := user_model.GetUserSetting(ctx, sender.ID, + user_model.SettingsEmailNotificationGiteaActions, user_model.EmailNotificationGiteaActionsFailureOnly) + if err != nil { + log.Error("GetUserSetting: %v", err) + return + } + if notifyPref == user_model.EmailNotificationGiteaActionsAll || !run.Status.IsSuccess() && notifyPref != user_model.EmailNotificationGiteaActionsDisabled { + recipients = append(recipients, sender) + } + } + + if len(recipients) > 0 { + composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, sender, recipients) + } +} diff --git a/services/mailer/notify.go b/services/mailer/notify.go index 77c366fe3195d..c008685e131c2 100644 --- a/services/mailer/notify.go +++ b/services/mailer/notify.go @@ -7,6 +7,7 @@ import ( "context" "fmt" + actions_model "code.gitea.io/gitea/models/actions" activities_model "code.gitea.io/gitea/models/activities" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" @@ -205,3 +206,10 @@ func (m *mailNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner * log.Error("SendRepoTransferNotifyMail: %v", err) } } + +func (m *mailNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) { + if !run.Status.IsDone() { + return + } + MailActionsTrigger(ctx, sender, repo, run) +} diff --git a/templates/mail/auth/activate.mock.yml b/templates/mail/auth/activate.devtest.yml similarity index 100% rename from templates/mail/auth/activate.mock.yml rename to templates/mail/auth/activate.devtest.yml diff --git a/templates/mail/notify/workflow_run.devtest.yml b/templates/mail/notify/workflow_run.devtest.yml new file mode 100644 index 0000000000000..1e285be328ba2 --- /dev/null +++ b/templates/mail/notify/workflow_run.devtest.yml @@ -0,0 +1,18 @@ +RunStatusText: run status text .... + +Repo: + FullName: RepoName + +Run: + WorkflowID: WorkflowID + HTMLURL: http://localhost/run/1 + +Jobs: + - Name: Job-Name-1 + Status: success + Attempt: 1 + HTMLURL: http://localhost/job/1 + - Name: Job-Name-2 + Status: failed + Attempt: 2 + HTMLURL: http://localhost/job/2 diff --git a/templates/mail/notify/workflow_run.tmpl b/templates/mail/notify/workflow_run.tmpl new file mode 100644 index 0000000000000..f6dd8ad510e33 --- /dev/null +++ b/templates/mail/notify/workflow_run.tmpl @@ -0,0 +1,33 @@ + + + + + + {{.Subject}} + + + +

+ {{.Repo.FullName}} {{.Run.WorkflowID}}: {{.RunStatusText}} +

+ + + +
+ +
+ + {{.locale.Tr "mail.view_it_on" AppName}} + +
+ + + diff --git a/templates/user/settings/notifications.tmpl b/templates/user/settings/notifications.tmpl index 4694bbb30a7ae..a37747f391e07 100644 --- a/templates/user/settings/notifications.tmpl +++ b/templates/user/settings/notifications.tmpl @@ -29,6 +29,37 @@ + + {{if .EnableActions}} +

+ {{ctx.Locale.Tr "actions.actions"}} +

+
+
+
+
+ {{$.CsrfTokenHtml}} +
+ + +
+
+ +
+
+
+
+
+ {{end}} {{template "user/settings/layout_footer" .}}