enhance: improve e-mail templates (#38396)

Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
silverwind
2026-09-09 19:58:27 +02:00
committed by GitHub
parent 92f2f6161b
commit 81dee529ed
43 changed files with 561 additions and 319 deletions
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

+4 -4
View File
@@ -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
}
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 {
+40 -25
View File
@@ -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"))
+4 -4
View File
@@ -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
+100 -100
View File
@@ -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)
}
+124
View File
@@ -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)
})
}
+13 -2
View File
@@ -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 {