Skip to content

Send email on Workflow Run Success/Failure #34982

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 34 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c8b64c7
Refactor
NorthRealm Jul 6, 2025
027c9fe
MAILER
NorthRealm Jul 6, 2025
0081ef8
Merge branch 'main' into patch3
NorthRealm Jul 6, 2025
31d82a4
Merge branch 'main' into patch4
NorthRealm Jul 6, 2025
a630935
MAILER
NorthRealm Jul 7, 2025
1f6c68d
MAILER
NorthRealm Jul 7, 2025
bf468fe
MAILER
NorthRealm Jul 7, 2025
bf3559f
MAILER
NorthRealm Jul 7, 2025
bb5e10b
fmt
NorthRealm Jul 7, 2025
ed7b2bc
Refactor
NorthRealm Jul 7, 2025
0126656
Merge branch 'patch3' into patch4
NorthRealm Jul 7, 2025
4f3f1b9
Decoration
NorthRealm Jul 7, 2025
a0737b1
Decoration
NorthRealm Jul 7, 2025
53970ec
Refactor
NorthRealm Jul 8, 2025
8100f63
silly
NorthRealm Jul 8, 2025
204f7ca
stop if cannot render template
NorthRealm Jul 8, 2025
d41aee8
34982#discussion_r2191579495
NorthRealm Jul 8, 2025
2bac8d8
terrible
NorthRealm Jul 8, 2025
afe2dee
Merge branch 'patch3' into patch4
NorthRealm Jul 8, 2025
cc6c8d1
Any other recipient?
NorthRealm Jul 8, 2025
6a28be3
style
NorthRealm Jul 8, 2025
9da6b36
Merge branch 'main' into workflow-email
wxiaoguang Jul 9, 2025
2fa624a
fix merge
wxiaoguang Jul 9, 2025
0a92da5
sort
NorthRealm Jul 9, 2025
65b1f08
Merge remote-tracking branch 'upstream/main' into patch4
NorthRealm Jul 10, 2025
7a635dd
Merge remote-tracking branch 'upstream/main' into patch4
NorthRealm Jul 11, 2025
540c3f0
DATABASE
NorthRealm Jul 11, 2025
4f31cd8
UI
NorthRealm Jul 11, 2025
32ad9ba
update
NorthRealm Jul 11, 2025
07602a0
fix
NorthRealm Jul 11, 2025
d92ec01
locale
NorthRealm Jul 11, 2025
fed3ade
rewrite
NorthRealm Jul 11, 2025
985e641
update
NorthRealm Jul 13, 2025
628ace2
UPDATE
NorthRealm Jul 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions models/user/email_notification.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package user

// setting values
const (
EmailNotificationGiteaActionsAll = "all"
EmailNotificationGiteaActionsFailureOnly = "failureonly" // Default for actions email preference
EmailNotificationGiteaActionsDisabled = "disabled"
)
34 changes: 34 additions & 0 deletions models/user/email_notification_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package user

import (
"testing"

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"

"github.com/stretchr/testify/assert"
)

func TestNotificationSettings(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

u := unittest.AssertExistsAndLoadBean(t, &User{ID: 1})

assert.NoError(t, SetUserSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions, EmailNotificationGiteaActionsAll))
settings, err := GetSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions)
assert.NoError(t, err)
assert.Equal(t, EmailNotificationGiteaActionsAll, settings)

assert.NoError(t, SetUserSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions, EmailNotificationGiteaActionsDisabled))
settings, err = GetSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions)
assert.NoError(t, err)
assert.Equal(t, EmailNotificationGiteaActionsDisabled, settings)

assert.NoError(t, SetUserSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions, EmailNotificationGiteaActionsFailureOnly))
settings, err = GetSetting(db.DefaultContext, u.ID, SettingsEmailNotificationGiteaActions)
assert.NoError(t, err)
assert.Equal(t, EmailNotificationGiteaActionsFailureOnly, settings)
}
2 changes: 2 additions & 0 deletions models/user/setting_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ const (
SignupUserAgent = "signup.user_agent"

SettingsKeyCodeViewShowFileTree = "code_view.show_file_tree"

SettingsEmailNotificationGiteaActions = "email_notifications.actions"
)
4 changes: 4 additions & 0 deletions models/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,10 @@ func createUser(ctx context.Context, u *User, meta *Meta, createdByAdmin bool, o
return err
}

if err := SetUserSetting(ctx, u.ID, SettingsEmailNotificationGiteaActions, EmailNotificationGiteaActionsFailureOnly); err != nil {
return err
}

return committer.Commit()
}

Expand Down
2 changes: 2 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,8 @@ email_notifications.onmention = Only Email on Mention
email_notifications.disable = Disable Email Notifications
email_notifications.submit = Set Email Preference
email_notifications.andyourown = And Your Own Notifications
email_notifications.actions.desc = Notifications for workflow runs on repositories set up with <a target="_blank" rel="noopener noreferrer" href="%s">Gitea Actions</a>.
email_notifications.actions.failureonly = Only notify for failed workflow runs

visibility = User visibility
visibility.public = Public
Expand Down
2 changes: 1 addition & 1 deletion routers/web/devtest/mail_preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 40 additions & 1 deletion routers/web/user/setting/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"net/http"

"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
Expand All @@ -29,6 +30,19 @@ func Notifications(ctx *context.Context) {
ctx.Data["PageIsSettingsNotifications"] = true
ctx.Data["EmailNotificationsPreference"] = ctx.Doer.EmailNotificationsPreference

fineGrainedPreference, err := user_model.GetSettings(ctx, ctx.Doer.ID, []string{
user_model.SettingsEmailNotificationGiteaActions,
})
if err != nil {
ctx.ServerError("GetUserNotificationSettings", err)
return
}
actionsNotify := fineGrainedPreference[user_model.SettingsEmailNotificationGiteaActions].SettingValue
if actionsNotify == "" {
actionsNotify = user_model.EmailNotificationGiteaActionsFailureOnly
}
ctx.Data["ActionsEmailNotificationsPreference"] = actionsNotify

ctx.HTML(http.StatusOK, tplSettingsNotifications)
}

Expand All @@ -45,7 +59,7 @@ func NotificationsEmailPost(ctx *context.Context) {
preference == user_model.EmailNotificationsDisabled ||
preference == user_model.EmailNotificationsAndYourOwn) {
log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.Doer.Name)
ctx.ServerError("SetEmailPreference", errors.New("option unrecognized"))
ctx.ServerError("NotificationsEmailPost", errors.New("option unrecognized"))
return
}
opts := &user.UpdateOptions{
Expand All @@ -60,3 +74,28 @@ func NotificationsEmailPost(ctx *context.Context) {
ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success"))
ctx.Redirect(setting.AppSubURL + "/user/settings/notifications")
}

// NotificationsActionsEmailPost set user's email notification preference on Gitea Actions
func NotificationsActionsEmailPost(ctx *context.Context) {
if !setting.Actions.Enabled || unit.TypeActions.UnitGlobalDisabled() {
ctx.NotFound(nil)
return
}

preference := ctx.FormString("preference")
if !(preference == user_model.EmailNotificationGiteaActionsAll ||
preference == user_model.EmailNotificationGiteaActionsDisabled ||
preference == user_model.EmailNotificationGiteaActionsFailureOnly) {
log.Error("Actions Email notifications preference change returned unrecognized option %s: %s", preference, ctx.Doer.Name)
ctx.ServerError("NotificationsActionsEmailPost", errors.New("option unrecognized"))
return
}
if err := user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsEmailNotificationGiteaActions, preference); err != nil {
log.Error("Cannot set actions email notifications preference: %v", err)
ctx.ServerError("SetUserSetting", err)
return
}
log.Trace("Actions email notifications preference made %s: %s", preference, ctx.Doer.Name)
ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success"))
ctx.Redirect(setting.AppSubURL + "/user/settings/notifications")
}
1 change: 1 addition & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions services/mailer/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
48 changes: 16 additions & 32 deletions services/mailer/mail_issue_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"bytes"
"context"
"fmt"
"maps"
"strconv"
"strings"
"time"
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -302,35 +303,18 @@ func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.
return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
}

func generateAdditionalHeaders(ctx *mailComment, reason string, recipient *user_model.User) map[string]string {
func generateAdditionalHeadersForIssue(ctx *mailComment, reason string, recipient *user_model.User) map[string]string {
repo := ctx.Issue.Repo

return map[string]string{
// https://datatracker.ietf.org/doc/html/rfc2919
"List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),

// https://datatracker.ietf.org/doc/html/rfc2369
"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),

"X-Mailer": "Gitea",
"X-Gitea-Reason": reason,
"X-Gitea-Sender": ctx.Doer.Name,
"X-Gitea-Recipient": recipient.Name,
"X-Gitea-Recipient-Address": recipient.Email,
"X-Gitea-Repository": repo.Name,
"X-Gitea-Repository-Path": repo.FullName(),
"X-Gitea-Repository-Link": repo.HTMLURL(),
"X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10),
"X-Gitea-Issue-Link": ctx.Issue.HTMLURL(),

"X-GitHub-Reason": reason,
"X-GitHub-Sender": ctx.Doer.Name,
"X-GitHub-Recipient": recipient.Name,
"X-GitHub-Recipient-Address": recipient.Email,

"X-GitLab-NotificationReason": reason,
"X-GitLab-Project": repo.Name,
"X-GitLab-Project-Path": repo.FullName(),
"X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10),
}
issueID := strconv.FormatInt(ctx.Issue.Index, 10)
headers := generateMetadataHeaders(repo)

maps.Copy(headers, generateSenderRecipientHeaders(ctx.Doer, recipient))
maps.Copy(headers, generateReasonHeaders(reason))

headers["X-Gitea-Issue-ID"] = issueID
headers["X-Gitea-Issue-Link"] = ctx.Issue.HTMLURL()
headers["X-GitLab-Issue-IID"] = issueID

return headers
}
15 changes: 13 additions & 2 deletions services/mailer/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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: "[email protected]"}

headers := generateAdditionalHeaders(comment, "dummy-reason", recipient)
headers := generateAdditionalHeadersForIssue(comment, "dummy-reason", recipient)

expected := map[string]string{
"List-ID": "user2/repo1 <repo1.user2.localhost>",
Expand Down Expand Up @@ -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)
Expand Down
Loading