0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-07-17 13:12:53 +02:00

Merge fed3ade25446d6a0938e92f7d1fb18d70255631a into 6599efb3b1400ac06d06e1c8b68ae6037fbb7952

This commit is contained in:
NorthRealm 2025-07-12 15:13:16 +08:00 committed by GitHub
commit 072e5bfc40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 419 additions and 33 deletions

View File

@ -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"
)

View File

@ -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)
}

View File

@ -21,4 +21,6 @@ const (
SignupUserAgent = "signup.user_agent"
SettingsKeyCodeViewShowFileTree = "code_view.show_file_tree"
SettingsEmailNotificationGiteaActions = "email_notifications.actions"
)

View File

@ -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()
}

View File

@ -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 <a target="_blank" rel="noopener noreferrer" href="%s">Gitea Actions</a>.
email_notifications.actions.failureonly = Only notify for failed workflow runs
visibility = User visibility
visibility.public = Public

View File

@ -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)

View File

@ -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,19 @@ func Notifications(ctx *context.Context) {
ctx.Data["PageIsSettingsNotifications"] = true
ctx.Data["EmailNotificationsPreference"] = ctx.Doer.EmailNotificationsPreference
fineGrainedPreference, err := user_model.GetSettings(ctx, ctx.Doer.ID, []string{
user_model.SettingsEmailNotificationGiteaActions,
})
if err != nil {
ctx.ServerError("GetUserNotificationSettings", err)
return
}
actionsNotify := fineGrainedPreference[user_model.SettingsEmailNotificationGiteaActions].SettingValue
if actionsNotify == "" {
actionsNotify = user_model.EmailNotificationGiteaActionsFailureOnly
}
ctx.Data["ActionsEmailNotificationsPreference"] = actionsNotify
ctx.HTML(http.StatusOK, tplSettingsNotifications)
}
@ -45,7 +59,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 +74,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")
}

View File

@ -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)

View File

@ -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,
}
}

View File

@ -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),
issueID := strconv.FormatInt(ctx.Issue.Index, 10)
headers := generateMetadataHeaders(repo)
// https://datatracker.ietf.org/doc/html/rfc2369
"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
maps.Copy(headers, generateSenderRecipientHeaders(ctx.Doer, recipient))
maps.Copy(headers, generateReasonHeaders(reason))
"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(),
headers["X-Gitea-Issue-ID"] = issueID
headers["X-Gitea-Issue-Link"] = ctx.Issue.HTMLURL()
headers["X-GitLab-Issue-IID"] = issueID
"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),
}
return headers
}

View File

@ -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 <repo1.user2.localhost>",
@ -441,6 +442,16 @@ func TestGenerateMessageIDForRelease(t *testing.T) {
assert.Equal(t, "<owner/repo/releases/1@localhost>", 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, "<user2/repo2/actions/runs/191@localhost>", msgID)
}
func TestFromDisplayName(t *testing.T) {
tmpl, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}")
assert.NoError(t, err)

View File

@ -0,0 +1,170 @@
// 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 sendActionsWorkflowRunStatusEmail(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 SendActionsWorkflowRunStatusEmail(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 run.Status.IsSuccess() {
if notifyPref == user_model.EmailNotificationGiteaActionsAll {
recipients = append(recipients, sender)
}
sendActionsWorkflowRunStatusEmail(ctx, repo, run, sender, recipients)
return
} else if notifyPref != user_model.EmailNotificationGiteaActionsDisabled {
recipients = append(recipients, sender)
}
}
sendActionsWorkflowRunStatusEmail(ctx, repo, run, sender, recipients)
}

View File

@ -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
}
SendActionsWorkflowRunStatusEmail(ctx, sender, repo, run)
}

View File

@ -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

View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
<title>{{.Subject}}</title>
</head>
<body style="background-color: #f5f7fa; margin: 20px;">
<h2 style="color: #2c3e50; margin-bottom: 20px;">
{{.Repo.FullName}} {{.Run.WorkflowID}}: {{.RunStatusText}}
</h2>
<ul style="list-style: none; padding: 0; margin: 0 0 30px 0;">
{{range $job := .Jobs}}
<li style="background-color: #ffffff; border: 1px solid #ddd; border-radius: 6px; padding: 12px 16px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); transition: box-shadow 0.2s ease;">
<a href="{{$job.HTMLURL}}" style="color: #0073e6; text-decoration: none; font-weight: bold;">
{{$job.Status}}: {{$job.Name}}{{if gt $job.Attempt 1}}, Attempt #{{$job.Attempt}}{{end}}
</a>
</li>
{{end}}
</ul>
<br/>
<div style="text-align: center; margin-top: 30px;">
<a href="{{.Run.HTMLURL}}" style="display: inline-block; background-color: #28a745; color: #ffffff !important; text-decoration: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; box-shadow: 0 2px 4px rgba(0,0,0,0.1); transition: background-color 0.3s ease;">
{{.locale.Tr "mail.view_it_on" AppName}}
</a>
</div>
</body>
</html>

View File

@ -29,6 +29,37 @@
</div>
</div>
</div>
{{if .EnableActions}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "actions.actions"}}
</h4>
<div class="ui attached segment">
<div class="ui list flex-items-block">
<div class="item">
<form class="ui form tw-w-full" action="{{AppSubUrl}}/user/settings/notifications/actions" method="post">
{{$.CsrfTokenHtml}}
<div class="field">
<label>{{ctx.Locale.Tr "settings.email_notifications.actions.desc" "https://docs.gitea.com/usage/actions/overview/"}}</label>
<div class="ui selection dropdown">
<input name="preference" type="hidden" value="{{.ActionsEmailNotificationsPreference}}">
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="text"></div>
<div class="menu">
<div data-value="all" class="{{if eq .ActionsEmailNotificationsPreference "all"}}active selected {{end}}item">{{ctx.Locale.Tr "all"}}</div>
<div data-value="failureonly" class="{{if eq .ActionsEmailNotificationsPreference "failureonly"}}active selected {{end}}item">{{ctx.Locale.Tr "settings.email_notifications.actions.failureonly"}}</div>
<div data-value="disabled" class="{{if eq .ActionsEmailNotificationsPreference "disabled"}}active selected {{end}}item">{{ctx.Locale.Tr "disabled"}}</div>
</div>
</div>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "settings.email_notifications.submit"}}</button>
</div>
</form>
</div>
</div>
</div>
{{end}}
</div>
{{template "user/settings/layout_footer" .}}