mirror of
				https://github.com/go-gitea/gitea
				synced 2025-11-04 13:28:25 +00:00 
			
		
		
		
	Send email on Workflow Run Success/Failure (#34982)
Closes #23725   /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>
This commit is contained in:
		@@ -21,4 +21,9 @@ const (
 | 
				
			|||||||
	SignupUserAgent = "signup.user_agent"
 | 
						SignupUserAgent = "signup.user_agent"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	SettingsKeyCodeViewShowFileTree = "code_view.show_file_tree"
 | 
						SettingsKeyCodeViewShowFileTree = "code_view.show_file_tree"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						SettingsKeyEmailNotificationGiteaActions        = "email_notification.gitea_actions"
 | 
				
			||||||
 | 
						SettingEmailNotificationGiteaActionsAll         = "all"
 | 
				
			||||||
 | 
						SettingEmailNotificationGiteaActionsFailureOnly = "failure-only" // Default for actions email preference
 | 
				
			||||||
 | 
						SettingEmailNotificationGiteaActionsDisabled    = "disabled"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -1021,6 +1021,8 @@ email_notifications.onmention = Only Email on Mention
 | 
				
			|||||||
email_notifications.disable = Disable Email Notifications
 | 
					email_notifications.disable = Disable Email Notifications
 | 
				
			||||||
email_notifications.submit = Set Email Preference
 | 
					email_notifications.submit = Set Email Preference
 | 
				
			||||||
email_notifications.andyourown = And Your Own Notifications
 | 
					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 = User visibility
 | 
				
			||||||
visibility.public = Public
 | 
					visibility.public = Public
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,7 @@ import (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
func MailPreviewRender(ctx *context.Context) {
 | 
					func MailPreviewRender(ctx *context.Context) {
 | 
				
			||||||
	tmplName := ctx.PathParam("*")
 | 
						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{}
 | 
						mockData := map[string]any{}
 | 
				
			||||||
	if err == nil {
 | 
						if err == nil {
 | 
				
			||||||
		err = yaml.Unmarshal(mockDataContent, &mockData)
 | 
							err = yaml.Unmarshal(mockDataContent, &mockData)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,11 +4,10 @@
 | 
				
			|||||||
package setting
 | 
					package setting
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"errors"
 | 
					 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"code.gitea.io/gitea/models/unit"
 | 
				
			||||||
	user_model "code.gitea.io/gitea/models/user"
 | 
						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/optional"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/setting"
 | 
						"code.gitea.io/gitea/modules/setting"
 | 
				
			||||||
	"code.gitea.io/gitea/modules/templates"
 | 
						"code.gitea.io/gitea/modules/templates"
 | 
				
			||||||
@@ -29,6 +28,13 @@ func Notifications(ctx *context.Context) {
 | 
				
			|||||||
	ctx.Data["PageIsSettingsNotifications"] = true
 | 
						ctx.Data["PageIsSettingsNotifications"] = true
 | 
				
			||||||
	ctx.Data["EmailNotificationsPreference"] = ctx.Doer.EmailNotificationsPreference
 | 
						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)
 | 
						ctx.HTML(http.StatusOK, tplSettingsNotifications)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -44,19 +50,40 @@ func NotificationsEmailPost(ctx *context.Context) {
 | 
				
			|||||||
		preference == user_model.EmailNotificationsOnMention ||
 | 
							preference == user_model.EmailNotificationsOnMention ||
 | 
				
			||||||
		preference == user_model.EmailNotificationsDisabled ||
 | 
							preference == user_model.EmailNotificationsDisabled ||
 | 
				
			||||||
		preference == user_model.EmailNotificationsAndYourOwn) {
 | 
							preference == user_model.EmailNotificationsAndYourOwn) {
 | 
				
			||||||
		log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.Doer.Name)
 | 
							ctx.Flash.Error(ctx.Tr("invalid_data", preference))
 | 
				
			||||||
		ctx.ServerError("SetEmailPreference", errors.New("option unrecognized"))
 | 
							ctx.Redirect(setting.AppSubURL + "/user/settings/notifications")
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	opts := &user.UpdateOptions{
 | 
						opts := &user.UpdateOptions{
 | 
				
			||||||
		EmailNotificationsPreference: optional.Some(preference),
 | 
							EmailNotificationsPreference: optional.Some(preference),
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil {
 | 
						if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil {
 | 
				
			||||||
		log.Error("Set Email Notifications failed: %v", err)
 | 
					 | 
				
			||||||
		ctx.ServerError("UpdateUser", err)
 | 
							ctx.ServerError("UpdateUser", err)
 | 
				
			||||||
		return
 | 
							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.Flash.Success(ctx.Tr("settings.email_preference_set_success"))
 | 
				
			||||||
	ctx.Redirect(setting.AppSubURL + "/user/settings/notifications")
 | 
						ctx.Redirect(setting.AppSubURL + "/user/settings/notifications")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -598,6 +598,7 @@ func registerWebRoutes(m *web.Router) {
 | 
				
			|||||||
		m.Group("/notifications", func() {
 | 
							m.Group("/notifications", func() {
 | 
				
			||||||
			m.Get("", user_setting.Notifications)
 | 
								m.Get("", user_setting.Notifications)
 | 
				
			||||||
			m.Post("/email", user_setting.NotificationsEmailPost)
 | 
								m.Post("/email", user_setting.NotificationsEmailPost)
 | 
				
			||||||
 | 
								m.Post("/actions", user_setting.NotificationsActionsEmailPost)
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
		m.Group("/security", func() {
 | 
							m.Group("/security", func() {
 | 
				
			||||||
			m.Get("", security.Security)
 | 
								m.Get("", security.Security)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -174,3 +174,41 @@ func fromDisplayName(u *user_model.User) string {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	return u.GetCompleteName()
 | 
						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,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,6 +7,7 @@ import (
 | 
				
			|||||||
	"bytes"
 | 
						"bytes"
 | 
				
			||||||
	"context"
 | 
						"context"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
 | 
						"maps"
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"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
 | 
					// 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
 | 
					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)
 | 
						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" {
 | 
						if actName != "new" {
 | 
				
			||||||
		prefix = "Re: "
 | 
							prefix = "Re: "
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	fallback = prefix + fallbackMailSubject(comment.Issue)
 | 
						fallback = prefix + fallbackIssueMailSubject(comment.Issue)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if comment.Comment != nil && comment.Comment.Review != nil {
 | 
						if comment.Comment != nil && comment.Comment.Review != nil {
 | 
				
			||||||
		reviewComments = make([]*issues_model.Comment, 0, 10)
 | 
							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("References", references...)
 | 
				
			||||||
		msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
 | 
							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)
 | 
								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)
 | 
						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
 | 
						repo := ctx.Issue.Repo
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return map[string]string{
 | 
						issueID := strconv.FormatInt(ctx.Issue.Index, 10)
 | 
				
			||||||
		// https://datatracker.ietf.org/doc/html/rfc2919
 | 
						headers := generateMetadataHeaders(repo)
 | 
				
			||||||
		"List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// https://datatracker.ietf.org/doc/html/rfc2369
 | 
						maps.Copy(headers, generateSenderRecipientHeaders(ctx.Doer, recipient))
 | 
				
			||||||
		"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
 | 
						maps.Copy(headers, generateReasonHeaders(reason))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		"X-Mailer":                  "Gitea",
 | 
						headers["X-Gitea-Issue-ID"] = issueID
 | 
				
			||||||
		"X-Gitea-Reason":            reason,
 | 
						headers["X-Gitea-Issue-Link"] = ctx.Issue.HTMLURL()
 | 
				
			||||||
		"X-Gitea-Sender":            ctx.Doer.Name,
 | 
						headers["X-GitLab-Issue-IID"] = issueID
 | 
				
			||||||
		"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,
 | 
						return headers
 | 
				
			||||||
		"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),
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,6 +16,7 @@ import (
 | 
				
			|||||||
	"testing"
 | 
						"testing"
 | 
				
			||||||
	texttmpl "text/template"
 | 
						texttmpl "text/template"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						actions_model "code.gitea.io/gitea/models/actions"
 | 
				
			||||||
	activities_model "code.gitea.io/gitea/models/activities"
 | 
						activities_model "code.gitea.io/gitea/models/activities"
 | 
				
			||||||
	"code.gitea.io/gitea/models/db"
 | 
						"code.gitea.io/gitea/models/db"
 | 
				
			||||||
	issues_model "code.gitea.io/gitea/models/issues"
 | 
						issues_model "code.gitea.io/gitea/models/issues"
 | 
				
			||||||
@@ -298,13 +299,13 @@ func testComposeIssueCommentMessage(t *testing.T, ctx *mailComment, recipients [
 | 
				
			|||||||
	return msgs[0]
 | 
						return msgs[0]
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func TestGenerateAdditionalHeaders(t *testing.T) {
 | 
					func TestGenerateAdditionalHeadersForIssue(t *testing.T) {
 | 
				
			||||||
	doer, _, issue, _ := prepareMailerTest(t)
 | 
						doer, _, issue, _ := prepareMailerTest(t)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	comment := &mailComment{Issue: issue, Doer: doer}
 | 
						comment := &mailComment{Issue: issue, Doer: doer}
 | 
				
			||||||
	recipient := &user_model.User{Name: "test", Email: "test@gitea.com"}
 | 
						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{
 | 
						expected := map[string]string{
 | 
				
			||||||
		"List-ID":                   "user2/repo1 <repo1.user2.localhost>",
 | 
							"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)
 | 
						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) {
 | 
					func TestFromDisplayName(t *testing.T) {
 | 
				
			||||||
	tmpl, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}")
 | 
						tmpl, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}")
 | 
				
			||||||
	assert.NoError(t, err)
 | 
						assert.NoError(t, err)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										165
									
								
								services/mailer/mail_workflow_run.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								services/mailer/mail_workflow_run.go
									
									
									
									
									
										Normal file
									
								
							@@ -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)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -7,6 +7,7 @@ import (
 | 
				
			|||||||
	"context"
 | 
						"context"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						actions_model "code.gitea.io/gitea/models/actions"
 | 
				
			||||||
	activities_model "code.gitea.io/gitea/models/activities"
 | 
						activities_model "code.gitea.io/gitea/models/activities"
 | 
				
			||||||
	issues_model "code.gitea.io/gitea/models/issues"
 | 
						issues_model "code.gitea.io/gitea/models/issues"
 | 
				
			||||||
	repo_model "code.gitea.io/gitea/models/repo"
 | 
						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)
 | 
							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)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										18
									
								
								templates/mail/notify/workflow_run.devtest.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								templates/mail/notify/workflow_run.devtest.yml
									
									
									
									
									
										Normal 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
 | 
				
			||||||
							
								
								
									
										33
									
								
								templates/mail/notify/workflow_run.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								templates/mail/notify/workflow_run.tmpl
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
@@ -29,6 +29,37 @@
 | 
				
			|||||||
				</div>
 | 
									</div>
 | 
				
			||||||
			</div>
 | 
								</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>
 | 
						</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{{template "user/settings/layout_footer" .}}
 | 
					{{template "user/settings/layout_footer" .}}
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user