aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNorthRealm <155140859+NorthRealm@users.noreply.github.com>2025-07-16 09:54:31 +0800
committerGitHub <noreply@github.com>2025-07-15 18:54:31 -0700
commit0d00ec7eedec52072e2ae87d2c1608df3494dd47 (patch)
treeda27a9d510888f39fd04287c5836ba61bb7da4ec
parentcd3fb95d4cf39236133264e8fa2b2d59112b57e0 (diff)
downloadgitea-0d00ec7eedec52072e2ae87d2c1608df3494dd47.tar.gz
gitea-0d00ec7eedec52072e2ae87d2c1608df3494dd47.zip
Send email on Workflow Run Success/Failure (#34982)
Closes #23725 ![1](https://github.com/user-attachments/assets/9bfa76ea-8c45-4155-a5d4-dc2f0667faa8) ![2](https://github.com/user-attachments/assets/49be7402-e5d5-486e-a1c2-8d3222540b13) /claim #23725 --------- Signed-off-by: NorthRealm <155140859+NorthRealm@users.noreply.github.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: ChristopherHX <christopher.homberger@web.de>
-rw-r--r--models/user/setting_options.go (renamed from models/user/setting_keys.go)5
-rw-r--r--options/locale/locale_en-US.ini2
-rw-r--r--routers/web/devtest/mail_preview.go2
-rw-r--r--routers/web/user/setting/notifications.go39
-rw-r--r--routers/web/web.go1
-rw-r--r--services/mailer/mail.go38
-rw-r--r--services/mailer/mail_issue_common.go48
-rw-r--r--services/mailer/mail_test.go15
-rw-r--r--services/mailer/mail_workflow_run.go165
-rw-r--r--services/mailer/notify.go8
-rw-r--r--templates/mail/auth/activate.devtest.yml (renamed from templates/mail/auth/activate.mock.yml)0
-rw-r--r--templates/mail/notify/workflow_run.devtest.yml18
-rw-r--r--templates/mail/notify/workflow_run.tmpl33
-rw-r--r--templates/user/settings/notifications.tmpl31
14 files changed, 364 insertions, 41 deletions
diff --git a/models/user/setting_keys.go b/models/user/setting_options.go
index 2c2ed078be..7be5039329 100644
--- a/models/user/setting_keys.go
+++ b/models/user/setting_options.go
@@ -21,4 +21,9 @@ const (
SignupUserAgent = "signup.user_agent"
SettingsKeyCodeViewShowFileTree = "code_view.show_file_tree"
+
+ SettingsKeyEmailNotificationGiteaActions = "email_notification.gitea_actions"
+ SettingEmailNotificationGiteaActionsAll = "all"
+ SettingEmailNotificationGiteaActionsFailureOnly = "failure-only" // Default for actions email preference
+ SettingEmailNotificationGiteaActionsDisabled = "disabled"
)
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index f319b1de3f..c1a3d37037 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 <a target="_blank" href="%s">Gitea Actions</a>.
+email_notifications.actions.failure_only = 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 79dd441eab..d6bade15d7 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 16e58a0481..8ff6f1d941 100644
--- a/routers/web/user/setting/notifications.go
+++ b/routers/web/user/setting/notifications.go
@@ -4,11 +4,10 @@
package setting
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"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
@@ -29,6 +28,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.SettingsKeyEmailNotificationGiteaActions, user_model.SettingEmailNotificationGiteaActionsFailureOnly)
+ if err != nil {
+ ctx.ServerError("GetUserSetting", err)
+ return
+ }
+ ctx.Data["ActionsEmailNotificationsPreference"] = actionsEmailPref
+
ctx.HTML(http.StatusOK, tplSettingsNotifications)
}
@@ -44,19 +50,40 @@ func NotificationsEmailPost(ctx *context.Context) {
preference == user_model.EmailNotificationsOnMention ||
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.Flash.Error(ctx.Tr("invalid_data", preference))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/notifications")
return
}
opts := &user.UpdateOptions{
EmailNotificationsPreference: optional.Some(preference),
}
if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil {
- log.Error("Set Email Notifications failed: %v", err)
ctx.ServerError("UpdateUser", err)
return
}
- log.Trace("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")
+}
+
+// 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.SettingEmailNotificationGiteaActionsAll ||
+ preference == user_model.SettingEmailNotificationGiteaActionsDisabled ||
+ preference == user_model.SettingEmailNotificationGiteaActionsFailureOnly) {
+ ctx.Flash.Error(ctx.Tr("invalid_data", preference))
+ ctx.Redirect(setting.AppSubURL + "/user/settings/notifications")
+ return
+ }
+ if err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyEmailNotificationGiteaActions, preference); err != nil {
+ ctx.ServerError("SetUserSetting", err)
+ return
+ }
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 b9c7013f63..f8612db504 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 b7602e0321..d81b6d10af 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 107f57772c..a34d8a68c9 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 3996796beb..24f5d39d50 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 <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)
diff --git a/services/mailer/mail_workflow_run.go b/services/mailer/mail_workflow_run.go
new file mode 100644
index 0000000000..29b3abda8e
--- /dev/null
+++ b/services/mailer/mail_workflow_run.go
@@ -0,0 +1,165 @@
+// 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,
+ }); 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.SettingsKeyEmailNotificationGiteaActions, user_model.SettingEmailNotificationGiteaActionsFailureOnly)
+ if err != nil {
+ log.Error("GetUserSetting: %v", err)
+ return
+ }
+ if notifyPref == user_model.SettingEmailNotificationGiteaActionsAll || !run.Status.IsSuccess() && notifyPref != user_model.SettingEmailNotificationGiteaActionsDisabled {
+ 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 77c366fe31..c008685e13 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
index f5519a6f6c..f5519a6f6c 100644
--- a/templates/mail/auth/activate.mock.yml
+++ b/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 0000000000..1e285be328
--- /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 0000000000..f6dd8ad510
--- /dev/null
+++ b/templates/mail/notify/workflow_run.tmpl
@@ -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>
diff --git a/templates/user/settings/notifications.tmpl b/templates/user/settings/notifications.tmpl
index 4694bbb30a..40094aab4c 100644
--- a/templates/user/settings/notifications.tmpl
+++ b/templates/user/settings/notifications.tmpl
@@ -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="item">{{ctx.Locale.Tr "all"}}</div>
+ <div data-value="failure-only" class="item">{{ctx.Locale.Tr "settings.email_notifications.actions.failure_only"}}</div>
+ <div data-value="disabled" class="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" .}}