aboutsummaryrefslogtreecommitdiffstats
path: root/services/mailer
diff options
context:
space:
mode:
Diffstat (limited to 'services/mailer')
-rw-r--r--services/mailer/mail.go62
-rw-r--r--services/mailer/mail_issue_common.go58
-rw-r--r--services/mailer/mail_release.go2
-rw-r--r--services/mailer/mail_repo.go2
-rw-r--r--services/mailer/mail_team_invite.go7
-rw-r--r--services/mailer/mail_test.go61
-rw-r--r--services/mailer/mail_user.go8
-rw-r--r--services/mailer/mail_workflow_run.go165
-rw-r--r--services/mailer/mailer.go2
-rw-r--r--services/mailer/notify.go26
-rw-r--r--services/mailer/sender/message_test.go4
-rw-r--r--services/mailer/sender/smtp.go3
-rw-r--r--services/mailer/sender/smtp_auth.go3
13 files changed, 309 insertions, 94 deletions
diff --git a/services/mailer/mail.go b/services/mailer/mail.go
index f7e5b0c9f0..d81b6d10af 100644
--- a/services/mailer/mail.go
+++ b/services/mailer/mail.go
@@ -8,13 +8,14 @@ import (
"bytes"
"context"
"encoding/base64"
+ "errors"
"fmt"
"html/template"
"io"
"mime"
"regexp"
"strings"
- texttmpl "text/template"
+ "sync/atomic"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
@@ -22,6 +23,7 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/typesniffer"
sender_service "code.gitea.io/gitea/services/mailer/sender"
@@ -30,11 +32,13 @@ import (
const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322
-var (
- bodyTemplates *template.Template
- subjectTemplates *texttmpl.Template
- subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
-)
+var loadedTemplates atomic.Pointer[templates.MailTemplates]
+
+var subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
+
+func LoadedTemplates() *templates.MailTemplates {
+ return loadedTemplates.Load()
+}
// SendTestMail sends a test mail
func SendTestMail(email string) error {
@@ -117,7 +121,7 @@ func (b64embedder *mailAttachmentBase64Embedder) AttachmentSrcToBase64DataURI(ct
attachmentUUID, ok = strings.CutPrefix(parsedSrc.RepoSubPath, "/attachments/")
}
if !ok {
- return "", fmt.Errorf("not an attachment")
+ return "", errors.New("not an attachment")
}
}
attachment, err := repo_model.GetAttachmentByUUID(ctx, attachmentUUID)
@@ -126,10 +130,10 @@ func (b64embedder *mailAttachmentBase64Embedder) AttachmentSrcToBase64DataURI(ct
}
if attachment.RepoID != b64embedder.repo.ID {
- return "", fmt.Errorf("attachment does not belong to the repository")
+ return "", errors.New("attachment does not belong to the repository")
}
if attachment.Size+b64embedder.estimateSize > b64embedder.maxSize {
- return "", fmt.Errorf("total embedded images exceed max limit")
+ return "", errors.New("total embedded images exceed max limit")
}
fr, err := storage.Attachments.Open(attachment.RelativePath())
@@ -146,7 +150,7 @@ func (b64embedder *mailAttachmentBase64Embedder) AttachmentSrcToBase64DataURI(ct
mimeType := typesniffer.DetectContentType(content)
if !mimeType.IsImage() {
- return "", fmt.Errorf("not an image")
+ return "", errors.New("not an image")
}
encoded := base64.StdEncoding.EncodeToString(content)
@@ -170,3 +174,41 @@ func fromDisplayName(u *user_model.User) string {
}
return u.GetCompleteName()
}
+
+func generateMetadataHeaders(repo *repo_model.Repository) map[string]string {
+ return map[string]string{
+ // https://datatracker.ietf.org/doc/html/rfc2919
+ "List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
+
+ // https://datatracker.ietf.org/doc/html/rfc2369
+ "List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
+
+ "X-Mailer": "Gitea",
+
+ "X-Gitea-Repository": repo.Name,
+ "X-Gitea-Repository-Path": repo.FullName(),
+ "X-Gitea-Repository-Link": repo.HTMLURL(),
+
+ "X-GitLab-Project": repo.Name,
+ "X-GitLab-Project-Path": repo.FullName(),
+ }
+}
+
+func generateSenderRecipientHeaders(doer, recipient *user_model.User) map[string]string {
+ return map[string]string{
+ "X-Gitea-Sender": doer.Name,
+ "X-Gitea-Recipient": recipient.Name,
+ "X-Gitea-Recipient-Address": recipient.Email,
+ "X-GitHub-Sender": doer.Name,
+ "X-GitHub-Recipient": recipient.Name,
+ "X-GitHub-Recipient-Address": recipient.Email,
+ }
+}
+
+func generateReasonHeaders(reason string) map[string]string {
+ return map[string]string{
+ "X-Gitea-Reason": reason,
+ "X-GitHub-Reason": reason,
+ "X-GitLab-NotificationReason": reason,
+ }
+}
diff --git a/services/mailer/mail_issue_common.go b/services/mailer/mail_issue_common.go
index ebfd52162c..a34d8a68c9 100644
--- a/services/mailer/mail_issue_common.go
+++ b/services/mailer/mail_issue_common.go
@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"fmt"
+ "maps"
"strconv"
"strings"
"time"
@@ -29,7 +30,7 @@ import (
// Many e-mail service providers have limitations on the size of the email body, it's usually from 10MB to 25MB
const maxEmailBodySize = 9_000_000
-func fallbackMailSubject(issue *issues_model.Issue) string {
+func fallbackIssueMailSubject(issue *issues_model.Issue) string {
return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
}
@@ -86,7 +87,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
if actName != "new" {
prefix = "Re: "
}
- fallback = prefix + fallbackMailSubject(comment.Issue)
+ fallback = prefix + fallbackIssueMailSubject(comment.Issue)
if comment.Comment != nil && comment.Comment.Review != nil {
reviewComments = make([]*issues_model.Comment, 0, 10)
@@ -119,7 +120,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
}
var mailSubject bytes.Buffer
- if err := subjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
+ if err := LoadedTemplates().SubjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
subject = sanitizeSubject(mailSubject.String())
if subject == "" {
subject = fallback
@@ -134,7 +135,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
var mailBody bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
}
@@ -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)
}
@@ -260,14 +261,14 @@ func actionToTemplate(issue *issues_model.Issue, actionType activities_model.Act
}
template = typeName + "/" + name
- ok := bodyTemplates.Lookup(template) != nil
+ ok := LoadedTemplates().BodyTemplates.Lookup(template) != nil
if !ok && typeName != "issue" {
template = "issue/" + name
- ok = bodyTemplates.Lookup(template) != nil
+ ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
}
if !ok {
template = typeName + "/default"
- ok = bodyTemplates.Lookup(template) != nil
+ ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
}
if !ok {
template = "issue/default"
@@ -302,35 +303,18 @@ func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.
return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
}
-func generateAdditionalHeaders(ctx *mailComment, reason string, recipient *user_model.User) map[string]string {
+func generateAdditionalHeadersForIssue(ctx *mailComment, reason string, recipient *user_model.User) map[string]string {
repo := ctx.Issue.Repo
- return map[string]string{
- // https://datatracker.ietf.org/doc/html/rfc2919
- "List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
-
- // https://datatracker.ietf.org/doc/html/rfc2369
- "List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
-
- "X-Mailer": "Gitea",
- "X-Gitea-Reason": reason,
- "X-Gitea-Sender": ctx.Doer.Name,
- "X-Gitea-Recipient": recipient.Name,
- "X-Gitea-Recipient-Address": recipient.Email,
- "X-Gitea-Repository": repo.Name,
- "X-Gitea-Repository-Path": repo.FullName(),
- "X-Gitea-Repository-Link": repo.HTMLURL(),
- "X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10),
- "X-Gitea-Issue-Link": ctx.Issue.HTMLURL(),
-
- "X-GitHub-Reason": reason,
- "X-GitHub-Sender": ctx.Doer.Name,
- "X-GitHub-Recipient": recipient.Name,
- "X-GitHub-Recipient-Address": recipient.Email,
-
- "X-GitLab-NotificationReason": reason,
- "X-GitLab-Project": repo.Name,
- "X-GitLab-Project-Path": repo.FullName(),
- "X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10),
- }
+ issueID := strconv.FormatInt(ctx.Issue.Index, 10)
+ headers := generateMetadataHeaders(repo)
+
+ maps.Copy(headers, generateSenderRecipientHeaders(ctx.Doer, recipient))
+ maps.Copy(headers, generateReasonHeaders(reason))
+
+ headers["X-Gitea-Issue-ID"] = issueID
+ headers["X-Gitea-Issue-Link"] = ctx.Issue.HTMLURL()
+ headers["X-GitLab-Issue-IID"] = issueID
+
+ return headers
}
diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go
index bfff73c39c..fd97fb5312 100644
--- a/services/mailer/mail_release.go
+++ b/services/mailer/mail_release.go
@@ -79,7 +79,7 @@ func mailNewRelease(ctx context.Context, lang string, tos []*user_model.User, re
var mailBody bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil {
log.Error("ExecuteTemplate [%s]: %v", string(tplNewReleaseMail)+"/body", err)
return
}
diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go
index b6b2d5ca07..1ec7995ab9 100644
--- a/services/mailer/mail_repo.go
+++ b/services/mailer/mail_repo.go
@@ -78,7 +78,7 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U
"Destination": destination,
}
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil {
return err
}
diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go
index 1fbade7e23..034dc14e3d 100644
--- a/services/mailer/mail_team_invite.go
+++ b/services/mailer/mail_team_invite.go
@@ -6,6 +6,7 @@ package mailer
import (
"bytes"
"context"
+ "errors"
"fmt"
"net/url"
@@ -38,10 +39,10 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod
if err != nil && !user_model.IsErrUserNotExist(err) {
return err
} else if user != nil && user.ProhibitLogin {
- return fmt.Errorf("login is prohibited for the invited user")
+ return errors.New("login is prohibited for the invited user")
}
- inviteRedirect := url.QueryEscape(fmt.Sprintf("/org/invite/%s", invite.Token))
+ inviteRedirect := url.QueryEscape("/org/invite/" + invite.Token)
inviteURL := fmt.Sprintf("%suser/sign_up?redirect_to=%s", setting.AppURL, inviteRedirect)
if (err == nil && user != nil) || setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration {
@@ -61,7 +62,7 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod
}
var mailBody bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil {
log.Error("ExecuteTemplate [%s]: %v", string(tplTeamInviteMail)+"/body", err)
return err
}
diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go
index 6aced705f3..24f5d39d50 100644
--- a/services/mailer/mail_test.go
+++ b/services/mailer/mail_test.go
@@ -16,6 +16,7 @@ import (
"testing"
texttmpl "text/template"
+ actions_model "code.gitea.io/gitea/models/actions"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
@@ -25,6 +26,7 @@ import (
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/services/attachment"
sender_service "code.gitea.io/gitea/services/mailer/sender"
@@ -95,6 +97,13 @@ func prepareMailerBase64Test(t *testing.T) (doer *user_model.User, repo *repo_mo
return user, repo, issue, att1, att2
}
+func prepareMailTemplates(name, subjectTmpl, bodyTmpl string) {
+ loadedTemplates.Store(&templates.MailTemplates{
+ SubjectTemplates: texttmpl.Must(texttmpl.New(name).Parse(subjectTmpl)),
+ BodyTemplates: template.Must(template.New(name).Parse(bodyTmpl)),
+ })
+}
+
func TestComposeIssueComment(t *testing.T) {
doer, _, issue, comment := prepareMailerTest(t)
@@ -107,8 +116,7 @@ func TestComposeIssueComment(t *testing.T) {
setting.IncomingEmail.Enabled = true
defer func() { setting.IncomingEmail.Enabled = false }()
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl))
- bodyTemplates = template.Must(template.New("issue/comment").Parse(bodyTpl))
+ prepareMailTemplates("issue/comment", subjectTpl, bodyTpl)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
@@ -153,8 +161,7 @@ func TestComposeIssueComment(t *testing.T) {
func TestMailMentionsComment(t *testing.T) {
doer, _, issue, comment := prepareMailerTest(t)
comment.Poster = doer
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl))
- bodyTemplates = template.Must(template.New("issue/comment").Parse(bodyTpl))
+ prepareMailTemplates("issue/comment", subjectTpl, bodyTpl)
mails := 0
defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) {
@@ -169,9 +176,7 @@ func TestMailMentionsComment(t *testing.T) {
func TestComposeIssueMessage(t *testing.T) {
doer, _, issue, _ := prepareMailerTest(t)
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl))
- bodyTemplates = template.Must(template.New("issue/new").Parse(bodyTpl))
-
+ prepareMailTemplates("issue/new", subjectTpl, bodyTpl)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
@@ -200,15 +205,14 @@ func TestTemplateSelection(t *testing.T) {
doer, repo, issue, comment := prepareMailerTest(t)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/default").Parse("issue/default/subject"))
- texttmpl.Must(subjectTemplates.New("issue/new").Parse("issue/new/subject"))
- texttmpl.Must(subjectTemplates.New("pull/comment").Parse("pull/comment/subject"))
- texttmpl.Must(subjectTemplates.New("issue/close").Parse("")) // Must default to fallback subject
+ prepareMailTemplates("issue/default", "issue/default/subject", "issue/default/body")
- bodyTemplates = template.Must(template.New("issue/default").Parse("issue/default/body"))
- template.Must(bodyTemplates.New("issue/new").Parse("issue/new/body"))
- template.Must(bodyTemplates.New("pull/comment").Parse("pull/comment/body"))
- template.Must(bodyTemplates.New("issue/close").Parse("issue/close/body"))
+ texttmpl.Must(LoadedTemplates().SubjectTemplates.New("issue/new").Parse("issue/new/subject"))
+ texttmpl.Must(LoadedTemplates().SubjectTemplates.New("pull/comment").Parse("pull/comment/subject"))
+ texttmpl.Must(LoadedTemplates().SubjectTemplates.New("issue/close").Parse("")) // Must default to a fallback subject
+ template.Must(LoadedTemplates().BodyTemplates.New("issue/new").Parse("issue/new/body"))
+ template.Must(LoadedTemplates().BodyTemplates.New("pull/comment").Parse("pull/comment/body"))
+ template.Must(LoadedTemplates().BodyTemplates.New("issue/close").Parse("issue/close/body"))
expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) {
subject := msg.ToMessage().GetGenHeader("Subject")
@@ -253,9 +257,7 @@ func TestTemplateServices(t *testing.T) {
expect := func(t *testing.T, issue *issues_model.Issue, comment *issues_model.Comment, doer *user_model.User,
actionType activities_model.ActionType, fromMention bool, tplSubject, tplBody, expSubject, expBody string,
) {
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/default").Parse(tplSubject))
- bodyTemplates = template.Must(template.New("issue/default").Parse(tplBody))
-
+ prepareMailTemplates("issue/default", tplSubject, tplBody)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
msg := testComposeIssueCommentMessage(t, &mailComment{
Issue: issue, Doer: doer, ActionType: actionType,
@@ -297,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>",
@@ -440,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)
@@ -467,7 +479,7 @@ func TestFromDisplayName(t *testing.T) {
t.Run(tc.userDisplayName, func(t *testing.T) {
user := &user_model.User{FullName: tc.userDisplayName, Name: "tmp"}
got := fromDisplayName(user)
- assert.EqualValues(t, tc.fromDisplayName, got)
+ assert.Equal(t, tc.fromDisplayName, got)
})
}
@@ -484,7 +496,7 @@ func TestFromDisplayName(t *testing.T) {
setting.Domain = oldDomain
}()
- assert.EqualValues(t, "Mister X (by Code IT on [code.it])", fromDisplayName(&user_model.User{FullName: "Mister X", Name: "tmp"}))
+ assert.Equal(t, "Mister X (by Code IT on [code.it])", fromDisplayName(&user_model.User{FullName: "Mister X", Name: "tmp"}))
})
}
@@ -512,8 +524,7 @@ func TestEmbedBase64Images(t *testing.T) {
att2ImgBase64 := fmt.Sprintf(`<img src="%s"/>`, att2Base64)
t.Run("ComposeMessage", func(t *testing.T) {
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl))
- bodyTemplates = template.Must(template.New("issue/new").Parse(bodyTpl))
+ prepareMailTemplates("issue/new", subjectTpl, bodyTpl)
issue.Content = fmt.Sprintf(`MSG-BEFORE <image src="attachments/%s"> MSG-AFTER`, att1.UUID)
require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "content"))
@@ -528,7 +539,7 @@ func TestEmbedBase64Images(t *testing.T) {
require.NoError(t, err)
mailBody := msgs[0].Body
- assert.Regexp(t, `MSG-BEFORE <a[^>]+><img src="data:image/png;base64,iVBORw0KGgo="/></a> MSG-AFTER`, mailBody)
+ assert.Regexp(t, `MSG-BEFORE <a[^>]+><img src="data:image/png;base64,iVBORw0KGgo=".*/></a> MSG-AFTER`, mailBody)
})
t.Run("EmbedInstanceImageSkipExternalImage", func(t *testing.T) {
diff --git a/services/mailer/mail_user.go b/services/mailer/mail_user.go
index 5a200a5fa7..68df81f6a3 100644
--- a/services/mailer/mail_user.go
+++ b/services/mailer/mail_user.go
@@ -39,7 +39,7 @@ func sendUserMail(language string, u *user_model.User, tpl templates.TplName, co
var content bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
log.Error("Template: %v", err)
return
}
@@ -90,7 +90,7 @@ func SendActivateEmailMail(u *user_model.User, email string) {
var content bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
log.Error("Template: %v", err)
return
}
@@ -118,7 +118,7 @@ func SendRegisterNotifyMail(u *user_model.User) {
var content bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
log.Error("Template: %v", err)
return
}
@@ -149,7 +149,7 @@ func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository)
var content bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
log.Error("Template: %v", err)
return
}
diff --git a/services/mailer/mail_workflow_run.go b/services/mailer/mail_workflow_run.go
new file mode 100644
index 0000000000..29b3abda8e
--- /dev/null
+++ b/services/mailer/mail_workflow_run.go
@@ -0,0 +1,165 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package mailer
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "sort"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/services/convert"
+ sender_service "code.gitea.io/gitea/services/mailer/sender"
+)
+
+const tplWorkflowRun = "notify/workflow_run"
+
+type convertedWorkflowJob struct {
+ HTMLURL string
+ Status actions_model.Status
+ Name string
+ Attempt int64
+}
+
+func generateMessageIDForActionsWorkflowRunStatusEmail(repo *repo_model.Repository, run *actions_model.ActionRun) string {
+ return fmt.Sprintf("<%s/actions/runs/%d@%s>", repo.FullName(), run.Index, setting.Domain)
+}
+
+func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) {
+ subject := "Run"
+ switch run.Status {
+ case actions_model.StatusFailure:
+ subject += " failed"
+ case actions_model.StatusCancelled:
+ subject += " cancelled"
+ case actions_model.StatusSuccess:
+ subject += " succeeded"
+ }
+ subject = fmt.Sprintf("%s: %s (%s)", subject, run.WorkflowID, base.ShortSha(run.CommitSHA))
+ displayName := fromDisplayName(sender)
+ messageID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run)
+ metadataHeaders := generateMetadataHeaders(repo)
+
+ jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
+ if err != nil {
+ log.Error("GetRunJobsByRunID: %v", err)
+ return
+ }
+ sort.SliceStable(jobs, func(i, j int) bool {
+ si, sj := jobs[i].Status, jobs[j].Status
+ /*
+ If both i and j are/are not success, leave it to si < sj.
+ If i is success and j is not, since the desired is j goes "smaller" and i goes "bigger", this func should return false.
+ If j is success and i is not, since the desired is i goes "smaller" and j goes "bigger", this func should return true.
+ */
+ if si.IsSuccess() != sj.IsSuccess() {
+ return !si.IsSuccess()
+ }
+ return si < sj
+ })
+
+ convertedJobs := make([]convertedWorkflowJob, 0, len(jobs))
+ for _, job := range jobs {
+ converted0, err := convert.ToActionWorkflowJob(ctx, repo, nil, job)
+ if err != nil {
+ log.Error("convert.ToActionWorkflowJob: %v", err)
+ continue
+ }
+ convertedJobs = append(convertedJobs, convertedWorkflowJob{
+ HTMLURL: converted0.HTMLURL,
+ Name: converted0.Name,
+ Status: job.Status,
+ Attempt: converted0.RunAttempt,
+ })
+ }
+
+ langMap := make(map[string][]*user_model.User)
+ for _, user := range recipients {
+ langMap[user.Language] = append(langMap[user.Language], user)
+ }
+ for lang, tos := range langMap {
+ locale := translation.NewLocale(lang)
+ var runStatusText string
+ switch run.Status {
+ case actions_model.StatusSuccess:
+ runStatusText = "All jobs have succeeded"
+ case actions_model.StatusFailure:
+ runStatusText = "All jobs have failed"
+ for _, job := range jobs {
+ if !job.Status.IsFailure() {
+ runStatusText = "Some jobs were not successful"
+ break
+ }
+ }
+ case actions_model.StatusCancelled:
+ runStatusText = "All jobs have been cancelled"
+ }
+ var mailBody bytes.Buffer
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tplWorkflowRun, map[string]any{
+ "Subject": subject,
+ "Repo": repo,
+ "Run": run,
+ "RunStatusText": runStatusText,
+ "Jobs": convertedJobs,
+ "locale": locale,
+ }); err != nil {
+ log.Error("ExecuteTemplate [%s]: %v", tplWorkflowRun, err)
+ return
+ }
+ msgs := make([]*sender_service.Message, 0, len(tos))
+ for _, rec := range tos {
+ msg := sender_service.NewMessageFrom(
+ rec.Email,
+ displayName,
+ setting.MailService.FromEmail,
+ subject,
+ mailBody.String(),
+ )
+ msg.Info = subject
+ for k, v := range generateSenderRecipientHeaders(sender, rec) {
+ msg.SetHeader(k, v)
+ }
+ for k, v := range metadataHeaders {
+ msg.SetHeader(k, v)
+ }
+ msg.SetHeader("Message-ID", messageID)
+ msgs = append(msgs, msg)
+ }
+ SendAsync(msgs...)
+ }
+}
+
+func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo_model.Repository, run *actions_model.ActionRun) {
+ if setting.MailService == nil {
+ return
+ }
+ if run.Status.IsSkipped() {
+ return
+ }
+
+ recipients := make([]*user_model.User, 0)
+
+ if !sender.IsGiteaActions() && !sender.IsGhost() && sender.IsMailable() {
+ notifyPref, err := user_model.GetUserSetting(ctx, sender.ID,
+ user_model.SettingsKeyEmailNotificationGiteaActions, user_model.SettingEmailNotificationGiteaActionsFailureOnly)
+ if err != nil {
+ log.Error("GetUserSetting: %v", err)
+ return
+ }
+ if notifyPref == user_model.SettingEmailNotificationGiteaActionsAll || !run.Status.IsSuccess() && notifyPref != user_model.SettingEmailNotificationGiteaActionsDisabled {
+ recipients = append(recipients, sender)
+ }
+ }
+
+ if len(recipients) > 0 {
+ composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, sender, recipients)
+ }
+}
diff --git a/services/mailer/mailer.go b/services/mailer/mailer.go
index bcd4facca9..db00aac4f1 100644
--- a/services/mailer/mailer.go
+++ b/services/mailer/mailer.go
@@ -43,7 +43,7 @@ func NewContext(ctx context.Context) {
sender = &sender_service.SMTPSender{}
}
- subjectTemplates, bodyTemplates = templates.Mailer(ctx)
+ templates.LoadMailTemplates(ctx, &loadedTemplates)
mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*sender_service.Message) []*sender_service.Message {
for _, msg := range items {
diff --git a/services/mailer/notify.go b/services/mailer/notify.go
index a27177e8f5..c008685e13 100644
--- a/services/mailer/notify.go
+++ b/services/mailer/notify.go
@@ -7,6 +7,7 @@ import (
"context"
"fmt"
+ actions_model "code.gitea.io/gitea/models/actions"
activities_model "code.gitea.io/gitea/models/activities"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
@@ -31,15 +32,16 @@ func (m *mailNotifier) CreateIssueComment(ctx context.Context, doer *user_model.
issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User,
) {
var act activities_model.ActionType
- if comment.Type == issues_model.CommentTypeClose {
+ switch comment.Type {
+ case issues_model.CommentTypeClose:
act = activities_model.ActionCloseIssue
- } else if comment.Type == issues_model.CommentTypeReopen {
+ case issues_model.CommentTypeReopen:
act = activities_model.ActionReopenIssue
- } else if comment.Type == issues_model.CommentTypeComment {
+ case issues_model.CommentTypeComment:
act = activities_model.ActionCommentIssue
- } else if comment.Type == issues_model.CommentTypeCode {
+ case issues_model.CommentTypeCode:
act = activities_model.ActionCommentIssue
- } else if comment.Type == issues_model.CommentTypePullRequestPush {
+ case issues_model.CommentTypePullRequestPush:
act = 0
}
@@ -95,11 +97,12 @@ func (m *mailNotifier) NewPullRequest(ctx context.Context, pr *issues_model.Pull
func (m *mailNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, r *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) {
var act activities_model.ActionType
- if comment.Type == issues_model.CommentTypeClose {
+ switch comment.Type {
+ case issues_model.CommentTypeClose:
act = activities_model.ActionCloseIssue
- } else if comment.Type == issues_model.CommentTypeReopen {
+ case issues_model.CommentTypeReopen:
act = activities_model.ActionReopenIssue
- } else if comment.Type == issues_model.CommentTypeComment {
+ case issues_model.CommentTypeComment:
act = activities_model.ActionCommentPull
}
if err := MailParticipantsComment(ctx, comment, act, pr.Issue, mentions); err != nil {
@@ -203,3 +206,10 @@ func (m *mailNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *
log.Error("SendRepoTransferNotifyMail: %v", err)
}
}
+
+func (m *mailNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
+ if !run.Status.IsDone() {
+ return
+ }
+ MailActionsTrigger(ctx, sender, repo, run)
+}
diff --git a/services/mailer/sender/message_test.go b/services/mailer/sender/message_test.go
index 63d0bc349a..ae153ebf05 100644
--- a/services/mailer/sender/message_test.go
+++ b/services/mailer/sender/message_test.go
@@ -108,9 +108,9 @@ func extractMailHeaderAndContent(t *testing.T, mail string) (map[string]string,
}
content := strings.TrimSpace("boundary=" + parts[1])
- hParts := strings.Split(parts[0], "\n")
+ hParts := strings.SplitSeq(parts[0], "\n")
- for _, hPart := range hParts {
+ for hPart := range hParts {
parts := strings.SplitN(hPart, ":", 2)
hk := strings.TrimSpace(parts[0])
if hk != "" {
diff --git a/services/mailer/sender/smtp.go b/services/mailer/sender/smtp.go
index c53c3da997..8dc1b40b74 100644
--- a/services/mailer/sender/smtp.go
+++ b/services/mailer/sender/smtp.go
@@ -5,6 +5,7 @@ package sender
import (
"crypto/tls"
+ "errors"
"fmt"
"io"
"net"
@@ -99,7 +100,7 @@ func (s *SMTPSender) Send(from string, to []string, msg io.WriterTo) error {
canAuth, options := client.Extension("AUTH")
if len(opts.User) > 0 {
if !canAuth {
- return fmt.Errorf("SMTP server does not support AUTH, but credentials provided")
+ return errors.New("SMTP server does not support AUTH, but credentials provided")
}
var auth smtp.Auth
diff --git a/services/mailer/sender/smtp_auth.go b/services/mailer/sender/smtp_auth.go
index 260b12437b..c60e0dbfbb 100644
--- a/services/mailer/sender/smtp_auth.go
+++ b/services/mailer/sender/smtp_auth.go
@@ -4,6 +4,7 @@
package sender
import (
+ "errors"
"fmt"
"github.com/Azure/go-ntlmssp"
@@ -60,7 +61,7 @@ func (a *ntlmAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
func (a *ntlmAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
if len(fromServer) == 0 {
- return nil, fmt.Errorf("ntlm ChallengeMessage is empty")
+ return nil, errors.New("ntlm ChallengeMessage is empty")
}
authenticateMessage, err := ntlmssp.ProcessChallenge(fromServer, a.username, a.password, a.domainNeeded)
return authenticateMessage, err