Skip to content

Commit 55f3505

Browse files
authored
Refactor mail template and support preview (go-gitea#34990)
1 parent 2cc3368 commit 55f3505

File tree

14 files changed

+156
-49
lines changed

14 files changed

+156
-49
lines changed

modules/templates/mailer.go

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@ import (
99
"html/template"
1010
"regexp"
1111
"strings"
12+
"sync/atomic"
1213
texttmpl "text/template"
1314

1415
"code.gitea.io/gitea/modules/log"
1516
"code.gitea.io/gitea/modules/setting"
1617
"code.gitea.io/gitea/modules/util"
1718
)
1819

20+
type MailTemplates struct {
21+
TemplateNames []string
22+
BodyTemplates *template.Template
23+
SubjectTemplates *texttmpl.Template
24+
}
25+
1926
var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
2027

2128
// mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject
@@ -52,16 +59,17 @@ func buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template,
5259
return nil
5360
}
5461

55-
// Mailer provides the templates required for sending notification mails.
56-
func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
57-
subjectTemplates := texttmpl.New("")
58-
bodyTemplates := template.New("")
59-
60-
subjectTemplates.Funcs(mailSubjectTextFuncMap())
61-
bodyTemplates.Funcs(NewFuncMap())
62-
62+
// LoadMailTemplates provides the templates required for sending notification mails.
63+
func LoadMailTemplates(ctx context.Context, loadedTemplates *atomic.Pointer[MailTemplates]) {
6364
assetFS := AssetFS()
6465
refreshTemplates := func(firstRun bool) {
66+
var templateNames []string
67+
subjectTemplates := texttmpl.New("")
68+
bodyTemplates := template.New("")
69+
70+
subjectTemplates.Funcs(mailSubjectTextFuncMap())
71+
bodyTemplates.Funcs(NewFuncMap())
72+
6573
if !firstRun {
6674
log.Trace("Reloading mail templates")
6775
}
@@ -81,13 +89,20 @@ func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
8189
if firstRun {
8290
log.Trace("Adding mail template %s: %s by %s", tmplName, assetPath, layerName)
8391
}
92+
templateNames = append(templateNames, tmplName)
8493
if err = buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, tmplName, content); err != nil {
8594
if firstRun {
8695
log.Fatal("Failed to parse mail template, err: %v", err)
8796
}
8897
log.Error("Failed to parse mail template, err: %v", err)
8998
}
9099
}
100+
loaded := &MailTemplates{
101+
TemplateNames: templateNames,
102+
BodyTemplates: bodyTemplates,
103+
SubjectTemplates: subjectTemplates,
104+
}
105+
loadedTemplates.Store(loaded)
91106
}
92107

93108
refreshTemplates(true)
@@ -99,6 +114,4 @@ func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
99114
refreshTemplates(false)
100115
})
101116
}
102-
103-
return subjectTemplates, bodyTemplates
104117
}

routers/web/devtest/mail_preview.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package devtest
5+
6+
import (
7+
"net/http"
8+
"strings"
9+
10+
"code.gitea.io/gitea/modules/templates"
11+
"code.gitea.io/gitea/services/context"
12+
"code.gitea.io/gitea/services/mailer"
13+
14+
"gopkg.in/yaml.v3"
15+
)
16+
17+
func MailPreviewRender(ctx *context.Context) {
18+
tmplName := ctx.PathParam("*")
19+
mockDataContent, err := templates.AssetFS().ReadFile("mail/" + tmplName + ".mock.yml")
20+
mockData := map[string]any{}
21+
if err == nil {
22+
err = yaml.Unmarshal(mockDataContent, &mockData)
23+
if err != nil {
24+
http.Error(ctx.Resp, "Failed to parse mock data: "+err.Error(), http.StatusInternalServerError)
25+
return
26+
}
27+
}
28+
mockData["locale"] = ctx.Locale
29+
err = mailer.LoadedTemplates().BodyTemplates.ExecuteTemplate(ctx.Resp, tmplName, mockData)
30+
if err != nil {
31+
_, _ = ctx.Resp.Write([]byte(err.Error()))
32+
}
33+
}
34+
35+
func prepareMailPreviewRender(ctx *context.Context, tmplName string) {
36+
tmplSubject := mailer.LoadedTemplates().SubjectTemplates.Lookup(tmplName)
37+
if tmplSubject == nil {
38+
ctx.Data["RenderMailSubject"] = "default subject"
39+
} else {
40+
var buf strings.Builder
41+
err := tmplSubject.Execute(&buf, nil)
42+
if err != nil {
43+
ctx.Data["RenderMailSubject"] = err.Error()
44+
} else {
45+
ctx.Data["RenderMailSubject"] = buf.String()
46+
}
47+
}
48+
ctx.Data["RenderMailTemplateName"] = tmplName
49+
}
50+
51+
func MailPreview(ctx *context.Context) {
52+
ctx.Data["MailTemplateNames"] = mailer.LoadedTemplates().TemplateNames
53+
tmplName := ctx.FormString("tmpl")
54+
if tmplName != "" {
55+
prepareMailPreviewRender(ctx, tmplName)
56+
}
57+
ctx.HTML(http.StatusOK, "devtest/mail-preview")
58+
}

routers/web/web.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1659,6 +1659,8 @@ func registerWebRoutes(m *web.Router) {
16591659
m.Group("/devtest", func() {
16601660
m.Any("", devtest.List)
16611661
m.Any("/fetch-action-test", devtest.FetchActionTest)
1662+
m.Any("/mail-preview", devtest.MailPreview)
1663+
m.Any("/mail-preview/*", devtest.MailPreviewRender)
16621664
m.Any("/{sub}", devtest.TmplCommon)
16631665
m.Get("/repo-action-view/{run}/{job}", devtest.MockActionsView)
16641666
m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)

services/mailer/mail.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@ import (
1515
"mime"
1616
"regexp"
1717
"strings"
18-
texttmpl "text/template"
18+
"sync/atomic"
1919

2020
repo_model "code.gitea.io/gitea/models/repo"
2121
user_model "code.gitea.io/gitea/models/user"
2222
"code.gitea.io/gitea/modules/httplib"
2323
"code.gitea.io/gitea/modules/log"
2424
"code.gitea.io/gitea/modules/setting"
2525
"code.gitea.io/gitea/modules/storage"
26+
"code.gitea.io/gitea/modules/templates"
2627
"code.gitea.io/gitea/modules/typesniffer"
2728
sender_service "code.gitea.io/gitea/services/mailer/sender"
2829

@@ -31,11 +32,13 @@ import (
3132

3233
const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322
3334

34-
var (
35-
bodyTemplates *template.Template
36-
subjectTemplates *texttmpl.Template
37-
subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
38-
)
35+
var loadedTemplates atomic.Pointer[templates.MailTemplates]
36+
37+
var subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
38+
39+
func LoadedTemplates() *templates.MailTemplates {
40+
return loadedTemplates.Load()
41+
}
3942

4043
// SendTestMail sends a test mail
4144
func SendTestMail(email string) error {

services/mailer/mail_issue_common.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
119119
}
120120

121121
var mailSubject bytes.Buffer
122-
if err := subjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
122+
if err := LoadedTemplates().SubjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
123123
subject = sanitizeSubject(mailSubject.String())
124124
if subject == "" {
125125
subject = fallback
@@ -134,7 +134,7 @@ func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang
134134

135135
var mailBody bytes.Buffer
136136

137-
if err := bodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
137+
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
138138
log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
139139
}
140140

@@ -260,14 +260,14 @@ func actionToTemplate(issue *issues_model.Issue, actionType activities_model.Act
260260
}
261261

262262
template = typeName + "/" + name
263-
ok := bodyTemplates.Lookup(template) != nil
263+
ok := LoadedTemplates().BodyTemplates.Lookup(template) != nil
264264
if !ok && typeName != "issue" {
265265
template = "issue/" + name
266-
ok = bodyTemplates.Lookup(template) != nil
266+
ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
267267
}
268268
if !ok {
269269
template = typeName + "/default"
270-
ok = bodyTemplates.Lookup(template) != nil
270+
ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
271271
}
272272
if !ok {
273273
template = "issue/default"

services/mailer/mail_release.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func mailNewRelease(ctx context.Context, lang string, tos []*user_model.User, re
7979

8080
var mailBody bytes.Buffer
8181

82-
if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil {
82+
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil {
8383
log.Error("ExecuteTemplate [%s]: %v", string(tplNewReleaseMail)+"/body", err)
8484
return
8585
}

services/mailer/mail_repo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U
7878
"Destination": destination,
7979
}
8080

81-
if err := bodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil {
81+
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil {
8282
return err
8383
}
8484

services/mailer/mail_team_invite.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod
6262
}
6363

6464
var mailBody bytes.Buffer
65-
if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil {
65+
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil {
6666
log.Error("ExecuteTemplate [%s]: %v", string(tplTeamInviteMail)+"/body", err)
6767
return err
6868
}

services/mailer/mail_test.go

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"code.gitea.io/gitea/modules/markup"
2626
"code.gitea.io/gitea/modules/setting"
2727
"code.gitea.io/gitea/modules/storage"
28+
"code.gitea.io/gitea/modules/templates"
2829
"code.gitea.io/gitea/modules/test"
2930
"code.gitea.io/gitea/services/attachment"
3031
sender_service "code.gitea.io/gitea/services/mailer/sender"
@@ -95,6 +96,13 @@ func prepareMailerBase64Test(t *testing.T) (doer *user_model.User, repo *repo_mo
9596
return user, repo, issue, att1, att2
9697
}
9798

99+
func prepareMailTemplates(name, subjectTmpl, bodyTmpl string) {
100+
loadedTemplates.Store(&templates.MailTemplates{
101+
SubjectTemplates: texttmpl.Must(texttmpl.New(name).Parse(subjectTmpl)),
102+
BodyTemplates: template.Must(template.New(name).Parse(bodyTmpl)),
103+
})
104+
}
105+
98106
func TestComposeIssueComment(t *testing.T) {
99107
doer, _, issue, comment := prepareMailerTest(t)
100108

@@ -107,8 +115,7 @@ func TestComposeIssueComment(t *testing.T) {
107115
setting.IncomingEmail.Enabled = true
108116
defer func() { setting.IncomingEmail.Enabled = false }()
109117

110-
subjectTemplates = texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl))
111-
bodyTemplates = template.Must(template.New("issue/comment").Parse(bodyTpl))
118+
prepareMailTemplates("issue/comment", subjectTpl, bodyTpl)
112119

113120
recipients := []*user_model.User{{Name: "Test", Email: "[email protected]"}, {Name: "Test2", Email: "[email protected]"}}
114121
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
@@ -153,8 +160,7 @@ func TestComposeIssueComment(t *testing.T) {
153160
func TestMailMentionsComment(t *testing.T) {
154161
doer, _, issue, comment := prepareMailerTest(t)
155162
comment.Poster = doer
156-
subjectTemplates = texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl))
157-
bodyTemplates = template.Must(template.New("issue/comment").Parse(bodyTpl))
163+
prepareMailTemplates("issue/comment", subjectTpl, bodyTpl)
158164
mails := 0
159165

160166
defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) {
@@ -169,9 +175,7 @@ func TestMailMentionsComment(t *testing.T) {
169175
func TestComposeIssueMessage(t *testing.T) {
170176
doer, _, issue, _ := prepareMailerTest(t)
171177

172-
subjectTemplates = texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl))
173-
bodyTemplates = template.Must(template.New("issue/new").Parse(bodyTpl))
174-
178+
prepareMailTemplates("issue/new", subjectTpl, bodyTpl)
175179
recipients := []*user_model.User{{Name: "Test", Email: "[email protected]"}, {Name: "Test2", Email: "[email protected]"}}
176180
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
177181
Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
@@ -200,15 +204,14 @@ func TestTemplateSelection(t *testing.T) {
200204
doer, repo, issue, comment := prepareMailerTest(t)
201205
recipients := []*user_model.User{{Name: "Test", Email: "[email protected]"}}
202206

203-
subjectTemplates = texttmpl.Must(texttmpl.New("issue/default").Parse("issue/default/subject"))
204-
texttmpl.Must(subjectTemplates.New("issue/new").Parse("issue/new/subject"))
205-
texttmpl.Must(subjectTemplates.New("pull/comment").Parse("pull/comment/subject"))
206-
texttmpl.Must(subjectTemplates.New("issue/close").Parse("")) // Must default to fallback subject
207+
prepareMailTemplates("issue/default", "issue/default/subject", "issue/default/body")
207208

208-
bodyTemplates = template.Must(template.New("issue/default").Parse("issue/default/body"))
209-
template.Must(bodyTemplates.New("issue/new").Parse("issue/new/body"))
210-
template.Must(bodyTemplates.New("pull/comment").Parse("pull/comment/body"))
211-
template.Must(bodyTemplates.New("issue/close").Parse("issue/close/body"))
209+
texttmpl.Must(LoadedTemplates().SubjectTemplates.New("issue/new").Parse("issue/new/subject"))
210+
texttmpl.Must(LoadedTemplates().SubjectTemplates.New("pull/comment").Parse("pull/comment/subject"))
211+
texttmpl.Must(LoadedTemplates().SubjectTemplates.New("issue/close").Parse("")) // Must default to a fallback subject
212+
template.Must(LoadedTemplates().BodyTemplates.New("issue/new").Parse("issue/new/body"))
213+
template.Must(LoadedTemplates().BodyTemplates.New("pull/comment").Parse("pull/comment/body"))
214+
template.Must(LoadedTemplates().BodyTemplates.New("issue/close").Parse("issue/close/body"))
212215

213216
expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) {
214217
subject := msg.ToMessage().GetGenHeader("Subject")
@@ -253,9 +256,7 @@ func TestTemplateServices(t *testing.T) {
253256
expect := func(t *testing.T, issue *issues_model.Issue, comment *issues_model.Comment, doer *user_model.User,
254257
actionType activities_model.ActionType, fromMention bool, tplSubject, tplBody, expSubject, expBody string,
255258
) {
256-
subjectTemplates = texttmpl.Must(texttmpl.New("issue/default").Parse(tplSubject))
257-
bodyTemplates = template.Must(template.New("issue/default").Parse(tplBody))
258-
259+
prepareMailTemplates("issue/default", tplSubject, tplBody)
259260
recipients := []*user_model.User{{Name: "Test", Email: "[email protected]"}}
260261
msg := testComposeIssueCommentMessage(t, &mailComment{
261262
Issue: issue, Doer: doer, ActionType: actionType,
@@ -512,8 +513,7 @@ func TestEmbedBase64Images(t *testing.T) {
512513
att2ImgBase64 := fmt.Sprintf(`<img src="%s"/>`, att2Base64)
513514

514515
t.Run("ComposeMessage", func(t *testing.T) {
515-
subjectTemplates = texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl))
516-
bodyTemplates = template.Must(template.New("issue/new").Parse(bodyTpl))
516+
prepareMailTemplates("issue/new", subjectTpl, bodyTpl)
517517

518518
issue.Content = fmt.Sprintf(`MSG-BEFORE <image src="attachments/%s"> MSG-AFTER`, att1.UUID)
519519
require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "content"))

services/mailer/mail_user.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func sendUserMail(language string, u *user_model.User, tpl templates.TplName, co
3939

4040
var content bytes.Buffer
4141

42-
if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
42+
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
4343
log.Error("Template: %v", err)
4444
return
4545
}
@@ -90,7 +90,7 @@ func SendActivateEmailMail(u *user_model.User, email string) {
9090

9191
var content bytes.Buffer
9292

93-
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
93+
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
9494
log.Error("Template: %v", err)
9595
return
9696
}
@@ -118,7 +118,7 @@ func SendRegisterNotifyMail(u *user_model.User) {
118118

119119
var content bytes.Buffer
120120

121-
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
121+
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
122122
log.Error("Template: %v", err)
123123
return
124124
}
@@ -149,7 +149,7 @@ func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository)
149149

150150
var content bytes.Buffer
151151

152-
if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
152+
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
153153
log.Error("Template: %v", err)
154154
return
155155
}

0 commit comments

Comments
 (0)