mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +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" | ||||
| 
 | ||||
| 	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.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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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") | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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, | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"maps" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| @@ -29,7 +30,7 @@ import ( | ||||
| // Many e-mail service providers have limitations on the size of the email body, it's usually from 10MB to 25MB | ||||
| const maxEmailBodySize = 9_000_000 | ||||
|  | ||||
| func fallbackMailSubject(issue *issues_model.Issue) string { | ||||
| func fallbackIssueMailSubject(issue *issues_model.Issue) string { | ||||
| 	return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index) | ||||
| } | ||||
|  | ||||
| @@ -86,7 +87,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang | ||||
| 	if actName != "new" { | ||||
| 		prefix = "Re: " | ||||
| 	} | ||||
| 	fallback = prefix + fallbackMailSubject(comment.Issue) | ||||
| 	fallback = prefix + fallbackIssueMailSubject(comment.Issue) | ||||
|  | ||||
| 	if comment.Comment != nil && comment.Comment.Review != nil { | ||||
| 		reviewComments = make([]*issues_model.Comment, 0, 10) | ||||
| @@ -202,7 +203,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang | ||||
| 		msg.SetHeader("References", references...) | ||||
| 		msg.SetHeader("List-Unsubscribe", listUnsubscribe...) | ||||
|  | ||||
| 		for key, value := range generateAdditionalHeaders(comment, actType, recipient) { | ||||
| 		for key, value := range generateAdditionalHeadersForIssue(comment, actType, recipient) { | ||||
| 			msg.SetHeader(key, value) | ||||
| 		} | ||||
|  | ||||
| @@ -302,35 +303,18 @@ func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model. | ||||
| 	return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain) | ||||
| } | ||||
|  | ||||
| func generateAdditionalHeaders(ctx *mailComment, reason string, recipient *user_model.User) map[string]string { | ||||
| func generateAdditionalHeadersForIssue(ctx *mailComment, reason string, recipient *user_model.User) map[string]string { | ||||
| 	repo := ctx.Issue.Repo | ||||
|  | ||||
| 	return map[string]string{ | ||||
| 		// https://datatracker.ietf.org/doc/html/rfc2919 | ||||
| 		"List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain), | ||||
| 	issueID := strconv.FormatInt(ctx.Issue.Index, 10) | ||||
| 	headers := generateMetadataHeaders(repo) | ||||
|  | ||||
| 		// https://datatracker.ietf.org/doc/html/rfc2369 | ||||
| 		"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()), | ||||
| 	maps.Copy(headers, generateSenderRecipientHeaders(ctx.Doer, recipient)) | ||||
| 	maps.Copy(headers, generateReasonHeaders(reason)) | ||||
|  | ||||
| 		"X-Mailer":                  "Gitea", | ||||
| 		"X-Gitea-Reason":            reason, | ||||
| 		"X-Gitea-Sender":            ctx.Doer.Name, | ||||
| 		"X-Gitea-Recipient":         recipient.Name, | ||||
| 		"X-Gitea-Recipient-Address": recipient.Email, | ||||
| 		"X-Gitea-Repository":        repo.Name, | ||||
| 		"X-Gitea-Repository-Path":   repo.FullName(), | ||||
| 		"X-Gitea-Repository-Link":   repo.HTMLURL(), | ||||
| 		"X-Gitea-Issue-ID":          strconv.FormatInt(ctx.Issue.Index, 10), | ||||
| 		"X-Gitea-Issue-Link":        ctx.Issue.HTMLURL(), | ||||
| 	headers["X-Gitea-Issue-ID"] = issueID | ||||
| 	headers["X-Gitea-Issue-Link"] = ctx.Issue.HTMLURL() | ||||
| 	headers["X-GitLab-Issue-IID"] = issueID | ||||
|  | ||||
| 		"X-GitHub-Reason":            reason, | ||||
| 		"X-GitHub-Sender":            ctx.Doer.Name, | ||||
| 		"X-GitHub-Recipient":         recipient.Name, | ||||
| 		"X-GitHub-Recipient-Address": recipient.Email, | ||||
|  | ||||
| 		"X-GitLab-NotificationReason": reason, | ||||
| 		"X-GitLab-Project":            repo.Name, | ||||
| 		"X-GitLab-Project-Path":       repo.FullName(), | ||||
| 		"X-GitLab-Issue-IID":          strconv.FormatInt(ctx.Issue.Index, 10), | ||||
| 	} | ||||
| 	return headers | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										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" | ||||
| 	"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) | ||||
| } | ||||
|   | ||||
							
								
								
									
										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> | ||||
|  | ||||
| 		{{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" .}} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user