mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-17 05:52:13 +09:00
enhance: improve e-mail templates (#38396)
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 682 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -261,18 +261,18 @@ func actionToTemplate(issue *issues_model.Issue, actionType activities_model.Act
|
||||
}
|
||||
}
|
||||
|
||||
template = "repo/" + typeName + "/" + name
|
||||
template = "mail/repo/" + typeName + "/" + name
|
||||
ok := LoadedTemplates().BodyTemplates.HasTemplate(template)
|
||||
if !ok && typeName != "issue" {
|
||||
template = "repo/issue/" + name
|
||||
template = "mail/repo/issue/" + name
|
||||
ok = LoadedTemplates().BodyTemplates.HasTemplate(template)
|
||||
}
|
||||
if !ok {
|
||||
template = "repo/" + typeName + "/default"
|
||||
template = "mail/repo/" + typeName + "/default"
|
||||
ok = LoadedTemplates().BodyTemplates.HasTemplate(template)
|
||||
}
|
||||
if !ok {
|
||||
template = "repo/issue/default"
|
||||
template = "mail/repo/issue/default"
|
||||
}
|
||||
return typeName, name, template
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
)
|
||||
|
||||
const tplNewReleaseMail templates.TplName = "repo/release"
|
||||
const tplNewReleaseMail templates.TplName = "mail/repo/release"
|
||||
|
||||
func generateMessageIDForRelease(release *repo_model.Release) string {
|
||||
return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain)
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
mailNotifyCollaborator templates.TplName = "repo/collaborator"
|
||||
mailRepoTransferNotify templates.TplName = "repo/transfer"
|
||||
mailNotifyCollaborator templates.TplName = "mail/repo/collaborator"
|
||||
mailRepoTransferNotify templates.TplName = "mail/repo/transfer"
|
||||
)
|
||||
|
||||
// SendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
)
|
||||
|
||||
const tplTeamInviteMail templates.TplName = "org/team_invite"
|
||||
const tplTeamInviteMail templates.TplName = "mail/org/team_invite"
|
||||
|
||||
// MailTeamInvite sends team invites
|
||||
func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_model.Team, invite *org_model.TeamInvite) error {
|
||||
|
||||
@@ -11,12 +11,13 @@ import (
|
||||
"html/template"
|
||||
"io"
|
||||
"mime/quotedprintable"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
texttmpl "text/template"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/asymkey"
|
||||
git_model "gitea.dev/models/git"
|
||||
@@ -52,11 +53,7 @@ const bodyTpl = `
|
||||
|
||||
<body>
|
||||
<p>{{.Body}}</p>
|
||||
<p>
|
||||
---
|
||||
<br>
|
||||
<a href="{{.Link}}">View it on Gitea</a>.
|
||||
</p>
|
||||
<p><a href="{{.Link}}">#{{.Issue.Index}}</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -114,7 +111,7 @@ func TestComposeIssueComment(t *testing.T) {
|
||||
})
|
||||
|
||||
defer test.MockVariableValue(&setting.IncomingEmail.Enabled, true)()
|
||||
defer mockMailTemplates("repo/issue/comment", subjectTpl, bodyTpl)()
|
||||
defer mockMailTemplates("mail/repo/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{
|
||||
@@ -159,7 +156,7 @@ func TestComposeIssueComment(t *testing.T) {
|
||||
func TestMailMentionsComment(t *testing.T) {
|
||||
doer, _, issue, comment := prepareMailerTest(t)
|
||||
comment.Poster = doer
|
||||
defer mockMailTemplates("repo/issue/comment", subjectTpl, bodyTpl)()
|
||||
defer mockMailTemplates("mail/repo/issue/comment", subjectTpl, bodyTpl)()
|
||||
mails := 0
|
||||
|
||||
defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) {
|
||||
@@ -174,7 +171,7 @@ func TestMailMentionsComment(t *testing.T) {
|
||||
func TestComposeIssueMessage(t *testing.T) {
|
||||
doer, _, issue, _ := prepareMailerTest(t)
|
||||
|
||||
defer mockMailTemplates("repo/issue/new", subjectTpl, bodyTpl)()
|
||||
defer mockMailTemplates("mail/repo/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,13 +197,41 @@ func TestComposeIssueMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTemplateSelection(t *testing.T) {
|
||||
t.Run("legacy custom template", func(t *testing.T) {
|
||||
restoreCustomPath := test.MockVariableValue(&setting.CustomPath, t.TempDir())
|
||||
t.Cleanup(func() {
|
||||
restoreCustomPath()
|
||||
require.NoError(t, templates.MailRendererReload())
|
||||
})
|
||||
templatePath := filepath.Join(setting.CustomPath, "templates/mail/repo/issue")
|
||||
require.NoError(t, os.MkdirAll(templatePath, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(templatePath, "default.tmpl"), []byte("custom subject\n---\ncustom body"), 0o644))
|
||||
require.NoError(t, templates.MailRendererReload())
|
||||
|
||||
for _, name := range []string{"mail/repo/issue/default", "repo/issue/default"} {
|
||||
var subject, body bytes.Buffer
|
||||
require.NoError(t, LoadedTemplates().SubjectTemplates.ExecuteTemplate(&subject, name, nil))
|
||||
require.NoError(t, LoadedTemplates().BodyTemplates.ExecuteTemplate(&body, name, nil))
|
||||
assert.Equal(t, "custom subject\n", subject.String())
|
||||
assert.Equal(t, "\ncustom body", body.String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, name := range []string{"base/footer", "base/head"} {
|
||||
assert.True(t, LoadedTemplates().BodyTemplates.HasTemplate("mail/"+name))
|
||||
assert.True(t, LoadedTemplates().BodyTemplates.HasTemplate(name))
|
||||
assert.NotContains(t, LoadedTemplates().TemplateNames, "mail/"+name)
|
||||
var rendered bytes.Buffer
|
||||
require.NoError(t, LoadedTemplates().BodyTemplates.ExecuteTemplate(&rendered, name, "test"))
|
||||
}
|
||||
|
||||
doer, repo, issue, comment := prepareMailerTest(t)
|
||||
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
|
||||
|
||||
defer mockMailTemplates("repo/issue/default", "repo/issue/default/subject", "repo/issue/default/body")()
|
||||
defer mockMailTemplates("repo/issue/new", "repo/issue/new/subject", "repo/issue/new/body")()
|
||||
defer mockMailTemplates("repo/pull/comment", "repo/pull/comment/subject", "repo/pull/comment/body")()
|
||||
defer mockMailTemplates("repo/issue/close", "", "repo/issue/close/body")() // Must default to a fallback subject
|
||||
defer mockMailTemplates("mail/repo/issue/default", "repo/issue/default/subject", "repo/issue/default/body")()
|
||||
defer mockMailTemplates("mail/repo/issue/new", "repo/issue/new/subject", "repo/issue/new/body")()
|
||||
defer mockMailTemplates("mail/repo/pull/comment", "repo/pull/comment/subject", "repo/pull/comment/body")()
|
||||
defer mockMailTemplates("mail/repo/issue/close", "", "repo/issue/close/body")() // Must default to a fallback subject
|
||||
|
||||
expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) {
|
||||
subject := msg.ToMessage().GetGenHeader("Subject")
|
||||
@@ -251,7 +276,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,
|
||||
) {
|
||||
defer mockMailTemplates("repo/issue/default", tplSubject, tplBody)()
|
||||
defer mockMailTemplates("mail/repo/issue/default", tplSubject, tplBody)()
|
||||
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
|
||||
msg := testComposeIssueCommentMessage(t, &mailComment{
|
||||
Issue: issue, Doer: doer, ActionType: actionType,
|
||||
@@ -436,16 +461,6 @@ 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(t.Context()))
|
||||
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)
|
||||
@@ -518,7 +533,7 @@ func TestEmbedBase64Images(t *testing.T) {
|
||||
att2ImgBase64 := fmt.Sprintf(`<img src="%s"/>`, att2Base64)
|
||||
|
||||
t.Run("ComposeMessage", func(t *testing.T) {
|
||||
defer mockMailTemplates("repo/issue/new", subjectTpl, bodyTpl)()
|
||||
defer mockMailTemplates("mail/repo/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"))
|
||||
|
||||
@@ -17,10 +17,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
mailAuthActivate templates.TplName = "user/auth/activate"
|
||||
mailAuthActivateEmail templates.TplName = "user/auth/activate_email"
|
||||
mailAuthResetPassword templates.TplName = "user/auth/reset_passwd"
|
||||
mailAuthRegisterNotify templates.TplName = "user/auth/register_notify"
|
||||
mailAuthActivate templates.TplName = "mail/user/auth/activate"
|
||||
mailAuthActivateEmail templates.TplName = "mail/user/auth/activate_email"
|
||||
mailAuthResetPassword templates.TplName = "mail/user/auth/reset_passwd"
|
||||
mailAuthRegisterNotify templates.TplName = "mail/user/auth/register_notify"
|
||||
)
|
||||
|
||||
// sendUserMail sends a mail to the user
|
||||
|
||||
@@ -5,38 +5,61 @@ package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/services/convert"
|
||||
"gitea.dev/modules/util"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
)
|
||||
|
||||
const tplWorkflowRun templates.TplName = "repo/actions/workflow_run"
|
||||
const tplWorkflowRun templates.TplName = "mail/repo/actions/workflow_run"
|
||||
|
||||
type convertedWorkflowJob struct {
|
||||
HTMLURL string
|
||||
Name string
|
||||
Status actions_model.Status
|
||||
Attempt int64
|
||||
Duration time.Duration
|
||||
//go:embed icons/*.png
|
||||
var iconsFS embed.FS
|
||||
|
||||
// LoadMailIcon returns an embedded mail icon.
|
||||
func LoadMailIcon(name string) ([]byte, error) {
|
||||
return iconsFS.ReadFile("icons/" + name)
|
||||
}
|
||||
|
||||
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)
|
||||
type workflowRunMailJob struct {
|
||||
HTMLURL string
|
||||
Name string
|
||||
Status actions_model.Status
|
||||
StatusIconCID string
|
||||
StatusIconAlt string
|
||||
StatusClass string
|
||||
Attempt int64
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) error {
|
||||
func workflowRunJobStatusPresentation(status actions_model.Status) (icon, class string) {
|
||||
switch {
|
||||
case status.IsSuccess():
|
||||
return "status-success.png", "status-success"
|
||||
case status.IsCancelled():
|
||||
return "status-cancelled.png", "status-neutral"
|
||||
case status.IsSkipped():
|
||||
return "status-skipped.png", "status-neutral"
|
||||
default:
|
||||
return "status-failure.png", "status-failure"
|
||||
}
|
||||
}
|
||||
|
||||
func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, recipient *user_model.User) error {
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, repo.ID, run.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -48,104 +71,81 @@ func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo
|
||||
}
|
||||
}
|
||||
|
||||
var subjectTrString string
|
||||
switch run.Status {
|
||||
case actions_model.StatusFailure:
|
||||
subjectTrString = "mail.repo.actions.run.failed"
|
||||
case actions_model.StatusCancelled:
|
||||
subjectTrString = "mail.repo.actions.run.cancelled"
|
||||
case actions_model.StatusSuccess:
|
||||
subjectTrString = "mail.repo.actions.run.succeeded"
|
||||
}
|
||||
displayName := fromDisplayName(sender)
|
||||
messageID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run)
|
||||
metadataHeaders := generateMetadataHeaders(repo)
|
||||
locale := translation.NewLocale(recipient.Language)
|
||||
|
||||
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()
|
||||
slices.SortStableFunc(jobs, func(a, b *actions_model.ActionRunJob) int {
|
||||
if a.Status.IsSuccess() != b.Status.IsSuccess() {
|
||||
return util.Iif(a.Status.IsSuccess(), 1, -1)
|
||||
}
|
||||
return si < sj
|
||||
return cmp.Compare(a.Status, b.Status)
|
||||
})
|
||||
|
||||
convertedJobs := make([]convertedWorkflowJob, 0, len(jobs))
|
||||
mailJobs := make([]workflowRunMailJob, 0, len(jobs))
|
||||
var embeds []sender_service.EmbeddedFile
|
||||
embedded := make(container.Set[string])
|
||||
for _, job := range jobs {
|
||||
converted0, err := convert.ToActionWorkflowJob(ctx, repo, nil, job)
|
||||
if err != nil {
|
||||
log.Error("convert.ToActionWorkflowJob: %v", err)
|
||||
icon, class := workflowRunJobStatusPresentation(job.Status)
|
||||
contentID := fmt.Sprintf("%s.actions-run-%d@%s", icon, run.ID, setting.Domain)
|
||||
mailJobs = append(mailJobs, workflowRunMailJob{
|
||||
HTMLURL: fmt.Sprintf("%s/actions/runs/%d/jobs/%d", repo.HTMLURL(ctx), run.ID, job.ID),
|
||||
Name: job.Name,
|
||||
Status: job.Status,
|
||||
StatusIconCID: contentID,
|
||||
StatusIconAlt: job.Status.LocaleString(locale),
|
||||
StatusClass: class,
|
||||
Attempt: job.Attempt,
|
||||
Duration: job.Duration(),
|
||||
})
|
||||
if !embedded.Add(icon) {
|
||||
continue
|
||||
}
|
||||
convertedJobs = append(convertedJobs, convertedWorkflowJob{
|
||||
HTMLURL: converted0.HTMLURL,
|
||||
Name: converted0.Name,
|
||||
Status: job.Status,
|
||||
Attempt: converted0.RunAttempt,
|
||||
Duration: job.Duration(),
|
||||
})
|
||||
}
|
||||
|
||||
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 runStatusTrString string
|
||||
switch run.Status {
|
||||
case actions_model.StatusSuccess:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_succeeded"
|
||||
case actions_model.StatusFailure:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_failed"
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsFailure() {
|
||||
runStatusTrString = "mail.repo.actions.jobs.some_not_successful"
|
||||
break
|
||||
}
|
||||
}
|
||||
case actions_model.StatusCancelled:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_cancelled"
|
||||
}
|
||||
subject := fmt.Sprintf("%s: %s (%s)", locale.TrString(subjectTrString), run.WorkflowID, base.ShortSha(run.CommitSHA))
|
||||
var mailBody bytes.Buffer
|
||||
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplWorkflowRun), map[string]any{
|
||||
"Subject": subject,
|
||||
"Repo": repo,
|
||||
"Run": run,
|
||||
"RunStatusText": locale.TrString(runStatusTrString),
|
||||
"Jobs": convertedJobs,
|
||||
"locale": locale,
|
||||
}); err != nil {
|
||||
content, err := LoadMailIcon(icon)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgs := make([]*sender_service.Message, 0, len(tos))
|
||||
for _, rec := range tos {
|
||||
log.Trace("Sending actions email to %s (UID: %d)", rec.Name, rec.ID)
|
||||
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...)
|
||||
embeds = append(embeds, sender_service.EmbeddedFile{Name: icon, ContentID: contentID, Content: content})
|
||||
}
|
||||
|
||||
var runStatusTrString string
|
||||
switch run.Status {
|
||||
case actions_model.StatusSuccess:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_succeeded"
|
||||
case actions_model.StatusFailure:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_failed"
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsFailure() {
|
||||
runStatusTrString = "mail.repo.actions.jobs.some_not_successful"
|
||||
break
|
||||
}
|
||||
}
|
||||
case actions_model.StatusCancelled:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_cancelled"
|
||||
}
|
||||
subject := fmt.Sprintf("[%s] %s: %s (%s - %s)", repo.FullName(), run.Status.LocaleString(locale), run.WorkflowID, run.PrettyRef(), base.ShortSha(run.CommitSHA))
|
||||
var mailBody bytes.Buffer
|
||||
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplWorkflowRun), map[string]any{
|
||||
"Subject": subject,
|
||||
"Repo": repo,
|
||||
"Run": run,
|
||||
"RunStatusText": locale.TrString(runStatusTrString),
|
||||
"Jobs": mailJobs,
|
||||
"locale": locale,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Trace("Sending actions email to %s (UID: %d)", recipient.Name, recipient.ID)
|
||||
msg := sender_service.NewMessageFrom(recipient.Email, fromDisplayName(recipient), setting.MailService.FromEmail, subject, mailBody.String())
|
||||
msg.Info = subject
|
||||
msg.Embeds = embeds
|
||||
for key, value := range generateSenderRecipientHeaders(recipient, recipient) {
|
||||
msg.SetHeader(key, value)
|
||||
}
|
||||
for key, value := range generateMetadataHeaders(repo) {
|
||||
msg.SetHeader(key, value)
|
||||
}
|
||||
msg.SetHeader("Message-ID", fmt.Sprintf("<%s/actions/runs/%d@%s>", repo.FullName(), run.Index, setting.Domain))
|
||||
SendAsync(msg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -175,5 +175,5 @@ func MailActionsTrigger(ctx context.Context, recipient *user_model.User, repo *r
|
||||
}
|
||||
|
||||
log.Debug("MailActionsTrigger: Initiate email composition")
|
||||
return composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, recipient, []*user_model.User{recipient})
|
||||
return composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, recipient)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/translation"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
gomail "github.com/wneessen/go-mail"
|
||||
)
|
||||
|
||||
func TestWorkflowRunMail(t *testing.T) {
|
||||
t.Run("StatusPresentation", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
status actions_model.Status
|
||||
icon string
|
||||
class string
|
||||
}{
|
||||
{actions_model.StatusSuccess, "status-success.png", "status-success"},
|
||||
{actions_model.StatusFailure, "status-failure.png", "status-failure"},
|
||||
{actions_model.StatusCancelled, "status-cancelled.png", "status-neutral"},
|
||||
{actions_model.StatusSkipped, "status-skipped.png", "status-neutral"},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.status.String(), func(t *testing.T) {
|
||||
icon, class := workflowRunJobStatusPresentation(testCase.status)
|
||||
assert.Equal(t, testCase.icon, icon)
|
||||
assert.Equal(t, testCase.class, class)
|
||||
content, err := LoadMailIcon(icon)
|
||||
require.NoError(t, err)
|
||||
config, err := png.DecodeConfig(bytes.NewReader(content))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 48, config.Width)
|
||||
assert.Equal(t, 48, config.Height)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Compose", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Langs, []string{"en-US"})()
|
||||
defer test.MockVariableValue(&setting.Names, []string{"English"})()
|
||||
translation.InitLocales(t.Context())
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
defer test.MockVariableValue(&setting.MailService, &setting.Mailer{FromEmail: "gitea@localhost"})()
|
||||
defer test.MockVariableValue(&setting.Domain, "localhost")()
|
||||
defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/")()
|
||||
|
||||
recipient := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 795})
|
||||
run.Repo = repo
|
||||
var messages []*sender_service.Message
|
||||
defer test.MockVariableValue(&SendAsync, func(sent ...*sender_service.Message) {
|
||||
messages = append(messages, sent...)
|
||||
})()
|
||||
|
||||
require.NoError(t, composeAndSendActionsWorkflowRunStatusEmail(t.Context(), repo, run, recipient))
|
||||
require.Len(t, messages, 1)
|
||||
message := messages[0]
|
||||
assert.Equal(t, "[user2/repo2] Failure: test.yaml (test - c2d72f5484)", message.Subject)
|
||||
assert.Equal(t, []string{"<user2/repo2/actions/runs/191@localhost>"}, message.Headers["Message-ID"])
|
||||
assert.NotContains(t, message.Body, "Some jobs were not successful")
|
||||
require.Contains(t, message.Body, ">job_1</a>")
|
||||
require.Contains(t, message.Body, ">job_2</a>")
|
||||
assert.Less(t, strings.Index(message.Body, ">job_2</a>"), strings.Index(message.Body, ">job_1</a>"))
|
||||
assert.Contains(t, message.Body, `<a class="status-success" href="http://localhost:3000/user2/repo2/actions/runs/795/jobs/198"`)
|
||||
assert.Contains(t, message.Body, `<a class="status-failure" href="http://localhost:3000/user2/repo2/actions/runs/795/jobs/199"`)
|
||||
assert.Equal(t, 1, strings.Count(message.Body, `<tr class="job-row">`))
|
||||
assert.Equal(t, 2, strings.Count(message.Body, "1m38s"))
|
||||
assert.Equal(t, 2, strings.Count(message.Body, `width="18" height="18"`))
|
||||
|
||||
mailMessage := message.ToMessage()
|
||||
require.Len(t, mailMessage.GetParts(), 2)
|
||||
plainBody, err := mailMessage.GetParts()[0].GetContent()
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(plainBody), "Failure: job_2")
|
||||
assert.Contains(t, string(plainBody), "Success: job_1")
|
||||
|
||||
var serialized bytes.Buffer
|
||||
_, err = mailMessage.WriteTo(&serialized)
|
||||
require.NoError(t, err)
|
||||
parsed, err := gomail.EMLToMsgFromReader(&serialized)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, parsed.GetEmbeds(), 2)
|
||||
contentIDs := make([]string, 0, len(parsed.GetEmbeds()))
|
||||
for _, embed := range parsed.GetEmbeds() {
|
||||
contentID := embed.Header.Get(gomail.HeaderContentID.String())
|
||||
contentIDs = append(contentIDs, contentID)
|
||||
assert.Contains(t, message.Body, "cid:"+strings.Trim(contentID, "<>"))
|
||||
}
|
||||
assert.ElementsMatch(t, []string{
|
||||
"<status-failure.png.actions-run-795@localhost>",
|
||||
"<status-success.png.actions-run-795@localhost>",
|
||||
}, contentIDs)
|
||||
|
||||
var emptyBody bytes.Buffer
|
||||
require.NoError(t, LoadedTemplates().BodyTemplates.ExecuteTemplate(&emptyBody, string(tplWorkflowRun), map[string]any{
|
||||
"Run": run,
|
||||
"Jobs": nil,
|
||||
"locale": translation.NewLocale("en-US"),
|
||||
}))
|
||||
assert.Contains(t, emptyBody.String(), `href="http://localhost:3000/user2/repo2/actions/runs/795">test.yaml</a>`)
|
||||
|
||||
defer mockMailTemplates(string(tplWorkflowRun), "", `{{.Repo.FullName}}|{{.RunStatusText}}|{{range .Jobs}}{{.Status}} {{end}}`)()
|
||||
messages = nil
|
||||
require.NoError(t, composeAndSendActionsWorkflowRunStatusEmail(t.Context(), repo, run, recipient))
|
||||
require.Len(t, messages, 1)
|
||||
assert.Equal(t, "user2/repo2|Some jobs were not successful|failure success ", messages[0].Body)
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net/mail"
|
||||
@@ -29,6 +30,14 @@ type Message struct {
|
||||
Date time.Time
|
||||
Body string
|
||||
Headers map[string][]string
|
||||
Embeds []EmbeddedFile
|
||||
}
|
||||
|
||||
// EmbeddedFile is an inline attachment referenced by its ContentID.
|
||||
type EmbeddedFile struct {
|
||||
Name string
|
||||
ContentID string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// ToMessage converts a Message to gomail.Message
|
||||
@@ -53,14 +62,16 @@ func (m *Message) ToMessage() *gomail.Msg {
|
||||
msg.SetGenHeader("X-Auto-Response-Suppress", "All")
|
||||
|
||||
plainBody, err := html2text.FromString(m.Body)
|
||||
msg.SetBodyString("text/plain", plainBody)
|
||||
if err != nil || setting.MailService.SendAsPlainText {
|
||||
if strings.Contains(util.TruncateRunes(m.Body, 100), "<html>") {
|
||||
log.Warn("Mail contains HTML but configured to send as plain text.")
|
||||
}
|
||||
msg.SetBodyString("text/plain", plainBody)
|
||||
} else {
|
||||
msg.SetBodyString("text/plain", plainBody)
|
||||
msg.AddAlternativeString("text/html", m.Body)
|
||||
for _, embed := range m.Embeds {
|
||||
msg.EmbedReadSeeker(embed.Name, bytes.NewReader(embed.Content), gomail.WithFileContentID("<"+embed.ContentID+">"))
|
||||
}
|
||||
}
|
||||
|
||||
if len(msg.GetGenHeader("Message-ID")) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user