mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-29 10:57:44 +09: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