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

View File

@@ -4,6 +4,7 @@
package templates
import (
"fmt"
"html/template"
"io"
"net/url"
@@ -114,6 +115,7 @@ func newMailRenderer() (*MailRender, error) {
}
assetFS := AssetFS()
aliases := map[string]string{}
renderer.tmplRenderer = &tmplRender{
collectTemplateNames: func() ([]string, error) {
@@ -125,13 +127,29 @@ func newMailRenderer() (*MailRender, error) {
return !strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
})
for i, name := range names {
names[i] = strings.TrimSuffix(strings.TrimPrefix(name, "mail/"), ".tmpl")
names[i] = strings.TrimSuffix(name, ".tmpl")
}
renderer.TemplateNames = names
return names, nil
renderer.TemplateNames = slices.DeleteFunc(slices.Clone(names), func(name string) bool {
return strings.HasPrefix(name, "mail/base/")
})
allNames := slices.Clone(names)
for _, name := range names {
alias := strings.TrimPrefix(name, "mail/")
if slices.Contains(names, alias) {
continue
}
aliases[alias] = name
allNames = append(allNames, alias)
}
return allNames, nil
},
readTemplateContent: func(name string) ([]byte, error) {
content, err := assetFS.ReadFile("mail/" + name + ".tmpl")
if target, ok := aliases[name]; ok {
content := fmt.Sprintf(`{{template %q .}}`, target)
_, err := renderer.SubjectTemplates.New(name).Parse(content)
return []byte(content), err
}
content, err := assetFS.ReadFile(name + ".tmpl")
if err != nil {
return nil, err
}

View File

@@ -458,6 +458,7 @@
"auth.back_to_sign_in": "Back to Sign In",
"mail.view_it_on": "View it on %s",
"mail.reply": "or reply to this email directly",
"mail.reply_directly": "Reply to this email directly",
"mail.link_not_working_do_paste": "Not working? Try copying and pasting it to your browser.",
"mail.hi_user_x": "Hi <b>%s</b>,",
"mail.activate_account": "Please activate your account",
@@ -476,24 +477,24 @@
"mail.reset_password.title": "%s, you have requested to recover your account",
"mail.reset_password.text": "Please click the following link to recover your account within <b>%s</b>:",
"mail.register_success": "Registration successful",
"mail.issue_assigned.pull": "@%[1]s assigned you to pull request %[2]s in repository %[3]s.",
"mail.issue_assigned.issue": "@%[1]s assigned you to issue %[2]s in repository %[3]s.",
"mail.issue.x_mentioned_you": "<b>@%s</b> mentioned you:",
"mail.issue_assigned.pull": "%[1]s assigned you to pull request %[2]s in repository %[3]s.",
"mail.issue_assigned.issue": "%[1]s assigned you to issue %[2]s in repository %[3]s.",
"mail.issue.x_mentioned_you": "<b>%s</b> mentioned you:",
"mail.issue.action.force_push": "<b>%[1]s</b> force-pushed the <b>%[2]s</b> from %[3]s to %[4]s.",
"mail.issue.action.push_1": "<b>@%[1]s</b> pushed %[3]d commit to %[2]s",
"mail.issue.action.push_n": "<b>@%[1]s</b> pushed %[3]d commits to %[2]s",
"mail.issue.action.close": "<b>@%[1]s</b> closed #%[2]d.",
"mail.issue.action.reopen": "<b>@%[1]s</b> reopened #%[2]d.",
"mail.issue.action.merge": "<b>@%[1]s</b> merged #%[2]d into %[3]s.",
"mail.issue.action.approve": "<b>@%[1]s</b> approved this pull request.",
"mail.issue.action.reject": "<b>@%[1]s</b> requested changes on this pull request.",
"mail.issue.action.review": "<b>@%[1]s</b> commented on this pull request.",
"mail.issue.action.review_dismissed": "<b>@%[1]s</b> dismissed last review from %[2]s for this pull request.",
"mail.issue.action.ready_for_review": "<b>@%[1]s</b> marked this pull request ready for review.",
"mail.issue.action.new": "<b>@%[1]s</b> created #%[2]d.",
"mail.issue.action.push_1": "<b>%[1]s</b> pushed %[3]d commit to %[2]s",
"mail.issue.action.push_n": "<b>%[1]s</b> pushed %[3]d commits to %[2]s",
"mail.issue.action.close": "<b>%[1]s</b> closed #%[2]d.",
"mail.issue.action.reopen": "<b>%[1]s</b> reopened #%[2]d.",
"mail.issue.action.merge": "<b>%[1]s</b> merged #%[2]d into %[3]s.",
"mail.issue.action.approve": "<b>%[1]s</b> approved this pull request.",
"mail.issue.action.reject": "<b>%[1]s</b> requested changes on this pull request.",
"mail.issue.action.review": "<b>%[1]s</b> commented on this pull request.",
"mail.issue.action.review_dismissed": "<b>%[1]s</b> dismissed last review from %[2]s for this pull request.",
"mail.issue.action.ready_for_review": "<b>%[1]s</b> marked this pull request ready for review.",
"mail.issue.action.new": "<b>%[1]s</b> created #%[2]d.",
"mail.issue.in_tree_path": "In %s:",
"mail.release.new.subject": "%s in %s released",
"mail.release.new.text": "<b>@%[1]s</b> released %[2]s in %[3]s",
"mail.release.new.text": "<b>%[1]s</b> released %[2]s in %[3]s",
"mail.release.title": "Title: %s",
"mail.release.note": "Note:",
"mail.release.downloads": "Downloads:",

View File

@@ -4,9 +4,13 @@
package devtest
import (
"errors"
"io/fs"
"net/http"
"regexp"
"strings"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/context"
@@ -15,28 +19,67 @@ import (
"go.yaml.in/yaml/v4"
)
var mailDarkSchemeQuery = regexp.MustCompile(`@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)`)
func mailPreviewMockData(tmplName string) (map[string]any, error) {
mockData := map[string]any{}
mockDataContent, err := templates.AssetFS().ReadFile(tmplName + ".devtest.yml")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return mockData, nil
}
return nil, err
}
return mockData, yaml.Unmarshal(mockDataContent, &mockData)
}
func MailPreviewRender(ctx *context.Context) {
tmplName := ctx.PathParam("*")
mockDataContent, err := templates.AssetFS().ReadFile("mail/" + tmplName + ".devtest.yml")
mockData := map[string]any{}
if err == nil {
err = yaml.Unmarshal(mockDataContent, &mockData)
if err != nil {
http.Error(ctx.Resp, "Failed to parse mock data: "+err.Error(), http.StatusInternalServerError)
return
}
mockData, err := mailPreviewMockData(tmplName)
if err != nil {
http.Error(ctx.Resp, "Failed to parse mock data: "+err.Error(), http.StatusInternalServerError)
return
}
mockData["locale"] = ctx.Locale
err = mailer.LoadedTemplates().BodyTemplates.ExecuteTemplate(ctx.Resp, tmplName, mockData)
if err != nil {
_, _ = ctx.Resp.Write([]byte(err.Error()))
var mailBody strings.Builder
if err := mailer.LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tmplName, mockData); err != nil {
http.Error(ctx.Resp, err.Error(), http.StatusInternalServerError)
return
}
body := mailBody.String()
// emulate mail clients, which resolve "cid:" URIs to the mail's inline attachments
body = strings.ReplaceAll(body, `src="cid:`, `src="`+setting.AppSubURL+`/devtest/mail-preview-embed/`)
previewStyle := "body {padding: 12px 16px}"
// a page can force "color-scheme" on an embedded document but never "prefers-color-scheme"
if scheme := ctx.FormString("scheme"); scheme == "light" || scheme == "dark" {
body = mailDarkSchemeQuery.ReplaceAllString(body, util.Iif(scheme == "dark", "@media all", "@media not all"))
previewStyle += "\n:root {color-scheme: " + scheme + "}"
}
body = strings.Replace(body, "</head>", "<style>"+previewStyle+"</style></head>", 1)
// fragment templates like "mail/base/head" would be sniffed as text/plain otherwise
ctx.Resp.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = ctx.Resp.Write([]byte(body))
}
func MailPreviewEmbed(ctx *context.Context) {
content, err := mailer.LoadMailIcon(ctx.PathParam("*"))
if err != nil {
ctx.NotFound(err)
return
}
ctx.Resp.Header().Set("Content-Type", "image/png")
_, _ = ctx.Resp.Write(content)
}
func prepareMailPreviewRender(ctx *context.Context, tmplName string) {
subject := "(default subject)"
if mockData, err := mailPreviewMockData(tmplName); err == nil {
if mockSubject, ok := mockData["Subject"].(string); ok {
subject = util.IfZero(mockSubject, subject)
}
}
tmplSubject := mailer.LoadedTemplates().SubjectTemplates.Lookup(tmplName)
// FIXME: MAIL-TEMPLATE-SUBJECT: only "issue" related messages support using subject from templates
subject := "(default subject)"
if tmplSubject != nil {
var buf strings.Builder
err := tmplSubject.Execute(&buf, nil)

View File

@@ -1781,6 +1781,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Any("/fetch-action-test", devtest.FetchActionTest)
m.Any("/mail-preview", devtest.MailPreview)
m.Any("/mail-preview/*", devtest.MailPreviewRender)
m.Any("/mail-preview-embed/*", devtest.MailPreviewEmbed)
m.Any("/{sub}", devtest.TmplCommon)
m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView)
m.Get("/repo-action-view/runs/{run}/attempts/{attempt}", devtest.MockActionsView)

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

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
}

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)

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

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 {

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"))

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

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)
}

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)
})
}

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 {

View File

@@ -12,10 +12,13 @@
<div class="tw-my-2">
<div>Preview of: {{.RenderMailTemplateName}}</div>
<div>Subject: {{.RenderMailSubject}}</div>
<iframe src="{{AppSubUrl}}/devtest/mail-preview/{{.RenderMailTemplateName}}" class="mail-preview-body"></iframe>
<div class="tw-flex tw-gap-4 tw-mt-2">
<iframe title="Light mail preview" src="{{AppSubUrl}}/devtest/mail-preview/{{.RenderMailTemplateName}}?scheme=light" class="mail-preview-body"></iframe>
<iframe title="Dark mail preview" src="{{AppSubUrl}}/devtest/mail-preview/{{.RenderMailTemplateName}}?scheme=dark" class="mail-preview-body"></iframe>
</div>
<style>
.mail-preview-body {
border: 1px solid #ccc;
border: 1px solid var(--color-secondary);
width: 100%;
height: 400px;
overflow: auto;

View File

@@ -0,0 +1,10 @@
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" class="mail-footer" style="width: 100%; border-collapse: collapse;">
<tr>
<td height="16" style="height: 16px; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<tr>
<td align="center" style="border-top: 1px solid #d0d7de; color: #59636e; font-size: 12px; padding: 8px 0 0; text-align: center;">
<a href="{{AppUrl}}">{{AppName}}</a>
</td>
</tr>
</table>

View File

@@ -0,0 +1,17 @@
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>{{.}}</title>
<style>
body {margin: 0; padding: 0}
body, td {font-family: -apple-system, "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif}
a, a:visited {color: #0969da; text-decoration: none}
a:hover {text-decoration: underline}
blockquote {padding-left: 1em; margin: 1em 0; border-left: 1px solid grey}
@media (prefers-color-scheme: dark) {
a, a:visited {color: #58a6ff}
.mail-footer td {border-top-color: #3f4248 !important; color: #8c959f !important}
}
</style>

View File

@@ -1,3 +1,5 @@
Subject: Inviter Display Name has invited you to join the Organization Display Name organization
Inviter:
DisplayName: Inviter Display Name

View File

@@ -1,15 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
{{template "mail/base/head" .Subject}}
</head>
<body>
<p>{{.locale.Tr "mail.team_invite.text_1" (DotEscape .Inviter.DisplayName) (DotEscape .Team.Name) (DotEscape .Organization.DisplayName)}}</p>
<p>{{.locale.Tr "mail.team_invite.text_2"}}</p><p><a href="{{.InviteURL}}">{{.InviteURL}}</a></p>
<p>{{.locale.Tr "mail.team_invite.text_2"}}</p><p style="word-break: break-all;"><a href="{{.InviteURL}}">{{.InviteURL}}</a></p>
<p>{{.locale.Tr "mail.link_not_working_do_paste"}}</p>
<p>{{.locale.Tr "mail.team_invite.text_3" .Invite.Email}}</p>
<p>© <a href="{{AppUrl}}">{{AppName}}</a></p>
{{template "mail/base/footer"}}
</body>
</html>

View File

@@ -1,20 +1,34 @@
RunStatusText: Jobs status aggregation
Repo:
FullName: Repo/Name
Subject: "[user/repo] Failure: workflow.yml (main - 1234567890)"
Run:
WorkflowID: workflow.yml
HTMLURL: http://localhost/run/1
Jobs:
- Name: Job-Name-1
Status: success
- Name: Build and test on Ubuntu with PostgreSQL and object storage
StatusIconCID: status-success.png
StatusIconAlt: Success
StatusClass: status-success
Attempt: 1
Duration: 1m23s
HTMLURL: http://localhost/job/1
Duration: 1h2m3s
- Name: Job-Name-2
Status: failure
- Name: Lint backend, frontend, templates, styles, and documentation
StatusIconCID: status-failure.png
StatusIconAlt: Failure
StatusClass: status-failure
Attempt: 2
Duration: 2m34s
HTMLURL: http://localhost/job/2
Duration: 1h2m3s
- Name: Publish release artifacts for Linux, macOS, and Windows
StatusIconCID: status-cancelled.png
StatusIconAlt: Cancelled
StatusClass: status-neutral
Attempt: 1
Duration: 3m45s
HTMLURL: http://localhost/job/3
- Name: Deploy documentation preview to the staging environment
StatusIconCID: status-skipped.png
StatusIconAlt: Skipped
StatusClass: status-neutral
Attempt: 1
Duration: 0s
HTMLURL: http://localhost/job/4

View File

@@ -1,33 +1,42 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
<title>{{.Subject}}</title>
{{template "mail/base/head" .Subject}}
<style>
.job-row td {border-top: 1px solid #d0d7de}
a.status-success {color: #2da44e !important}
a.status-failure {color: #e5534b !important}
a.status-neutral, .job-duration {color: #697077 !important}
img.status-neutral {filter: brightness(75%)}
@media (prefers-color-scheme: dark) {
.job-row td {border-top-color: #3f4248}
a.status-neutral, .job-duration {color: #afbac7 !important}
img.status-neutral {filter: brightness(125%)}
}
</style>
</head>
<body style="background-color: #f5f7fa; margin: 20px;">
<body>
<h2 style="color: #2c3e50; margin-bottom: 20px;">
{{.Repo.FullName}} {{.Run.WorkflowID}}: {{.RunStatusText}}
</h2>
<ul style="list-style: none; padding: 0; margin: 0 0 30px 0;">
{{range $job := .Jobs}}
<li style="background-color: #ffffff; border: 1px solid #ddd; border-radius: 6px; padding: 12px 16px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); transition: box-shadow 0.2s ease;">
<a href="{{$job.HTMLURL}}" style="color: #0073e6; text-decoration: none; font-weight: bold;">
{{$job.Status}}: {{$job.Name}}{{if gt $job.Attempt 1}}, Attempt #{{$job.Attempt}}{{end}}, {{$job.Duration}}
</a>
</li>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="width: 100%; border-collapse: collapse;"><tr><td style="padding: 0 8px;">
{{if .Jobs}}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="width: 100%; border-collapse: collapse;">
{{range $index, $job := .Jobs}}
<tr{{if $index}} class="job-row"{{end}}>
<td width="26" style="width: 26px; padding: 6px 8px 6px 0; vertical-align: middle;"><img class="{{$job.StatusClass}}" src="cid:{{$job.StatusIconCID}}" width="18" height="18" alt="{{$job.StatusIconAlt}}" style="display: block;"></td>
<td align="left" style="padding: 6px 0; text-align: left; vertical-align: middle; word-break: break-word;">
<a class="{{$job.StatusClass}}" href="{{$job.HTMLURL}}" style="font-weight: bold;"><span style="display: none;">{{$job.StatusIconAlt}}: </span>{{$job.Name}}</a>
{{if gt $job.Attempt 1}}({{$.locale.Tr "actions.runs.attempt"}} #{{$job.Attempt}}){{end}}
</td>
<td class="job-duration" width="1%" align="right" style="width: 1%; padding: 6px 0 6px 8px; text-align: right; vertical-align: middle; white-space: nowrap;">{{$job.Duration}}<span style="display: none;"><br></span></td>
</tr>
{{end}}
</ul>
</table>
{{else}}
<p><a href="{{.Run.HTMLURL}}">{{.Run.WorkflowID}}</a></p>
{{end}}
</td></tr></table>
<br/>
<div style="text-align: center; margin-top: 30px;">
<a href="{{.Run.HTMLURL}}" style="display: inline-block; background-color: #28a745; color: #ffffff !important; text-decoration: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; box-shadow: 0 2px 4px rgba(0,0,0,0.1); transition: background-color 0.3s ease;">
{{.locale.Tr "mail.view_it_on" AppName}}
</a>
</div>
{{template "mail/base/footer"}}
</body>
</html>

View File

@@ -1,3 +1,3 @@
Subject: Collaborator added
Subject: DoerName added you to user/repo
Link: http://localhost
RepoName: Repo/Name
RepoName: user/repo

View File

@@ -1,18 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>{{.Subject}}</title>
{{template "mail/base/head" .Subject}}
</head>
<body>
<p>{{.locale.Tr "mail.repo.collaborator.added.text"}} <code>{{.RepoName}}</code></p>
<div style="font-size:small; color:#666;">
<p>
---
<br>
<a href="{{.Link}}">{{.locale.Tr "mail.view_it_on" AppName}}</a>.
</p>
</div>
<p>{{.locale.Tr "mail.repo.collaborator.added.text"}} <a href="{{.Link}}">{{.RepoName}}</a></p>
{{template "mail/base/footer"}}
</body>
</html>

View File

@@ -1,10 +1,10 @@
Subject: Issue assigned
Subject: "[user/repo] Issue Title (#1)"
Link: http://localhost
Issue:
Index: 1
Repo:
FullName: Repo/Name
FullName: user/repo
HTMLURL: http://localhost/issue
Doer:

View File

@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>{{.Subject}}</title>
{{template "mail/base/head" .Subject}}
</head>
{{$repo_url := HTMLFormat "<a href='%s'>%s</a>" .Issue.Repo.HTMLURL .Issue.Repo.FullName}}
@@ -15,12 +14,7 @@
{{.locale.Tr "mail.issue_assigned.issue" .Doer.Name $link $repo_url}}
{{end}}
</p>
<div style="font-size:small; color:#666;">
<p>
---
<br>
<a href="{{.Link}}">{{.locale.Tr "mail.view_it_on" AppName}}</a>.
</p>
</div>
{{if .CanReply}}<p>{{.locale.Tr "mail.reply_directly"}}.</p>{{end}}
{{template "mail/base/footer"}}
</body>
</html>

View File

@@ -1 +1,7 @@
Subject: "Re: [user/repo] Issue Title (#1)"
Link: http://localhost/issue/1
CanReply: true
Body: Issue body
Issue:
Index: 1

View File

@@ -1,13 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>{{.Subject}}</title>
<style>
blockquote { padding-left: 1em; margin: 1em 0; border-left: 1px solid grey; color: #777}
</style>
{{template "mail/base/head" .Subject}}
</head>
<body>
@@ -75,12 +69,7 @@
{{end}}
</ul>
{{end}}
<div style="font-size:small; color:#666;">
<p>
---
<br>
<a href="{{.Link}}">{{.locale.Tr "mail.view_it_on" AppName}}</a>{{if .CanReply}}&nbsp;{{.locale.Tr "mail.reply"}}{{end}}.
</p>
</div>
<p><a href="{{.Link}}">#{{.Issue.Index}}</a>{{if .CanReply}} {{.locale.Tr "mail.reply"}}{{end}}.</p>
{{template "mail/base/footer"}}
</body>
</html>

View File

@@ -0,0 +1,13 @@
Subject: v1.0.0 in user/repo released
# no Attachments mock because "FileSize" requires int64 which yaml cannot produce
Release:
TagName: v1.0.0
Title: v1.0.0 - Great Release
HTMLURL: http://localhost/user/repo/releases/tag/v1.0.0
RenderedNote: This release fixes several bugs and improves performance.
Publisher:
Name: PublisherName
Repo:
FullName: user/repo
HTMLURL: http://localhost/user/repo

View File

@@ -1,13 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>{{.Subject}}</title>
<style>
blockquote { padding-left: 1em; margin: 1em 0; border-left: 1px solid grey; color: #777}
</style>
{{template "mail/base/head" .Subject}}
</head>
{{$release_url := HTMLFormat "<a href='%s'>%s</a>" .Release.HTMLURL .Release.TagName}}
@@ -17,18 +11,9 @@
{{.locale.Tr "mail.release.new.text" .Release.Publisher.Name $release_url $repo_url}}
</p>
<h4>{{.locale.Tr "mail.release.title" .Release.Title}}</h4>
<p>
{{.locale.Tr "mail.release.note"}}<br>
{{- if eq .Release.RenderedNote ""}}
{{else}}
{{.Release.RenderedNote}}
{{end -}}
</p>
<p style="margin-top: 2em;">
---
<br>
{{.locale.Tr "mail.release.downloads"}}
</p>
<p>{{.locale.Tr "mail.release.note"}}</p>
{{if ne .Release.RenderedNote ""}}<div>{{.Release.RenderedNote}}</div>{{end}}
<p>{{.locale.Tr "mail.release.downloads"}}</p>
<ul>
{{if not .DisableDownloadSourceArchives}}
<li>
@@ -38,22 +23,12 @@
<a href="{{.Release.Repo.HTMLURL}}/archive/{{.Release.TagName | PathEscapeSegments}}.tar.gz" rel="nofollow"><strong>{{.locale.Tr "mail.release.download.targz"}}</strong></a>
</li>
{{end}}
{{if .Release.Attachments}}
{{range .Release.Attachments}}
<li>
<a target="_blank" href="{{.DownloadURL}}">
<strong>{{.Name}} ({{.Size | FormatByteSize}})</strong>
</a>
</li>
{{end}}
{{range .Release.Attachments}}
<li>
<a target="_blank" href="{{.DownloadURL}}"><strong>{{.Name}} ({{.Size | FormatByteSize}})</strong></a>
</li>
{{end}}
</ul>
<div style="font-size:small; color:#666;">
<p>
---
<br>
<a href="{{.Link}}">{{.locale.Tr "mail.view_it_on" AppName}}</a>.
</p>
</div>
{{template "mail/base/footer"}}
</body>
</html>

View File

@@ -1,3 +1,3 @@
Subject: Repository transfer
Subject: DoerName would like to transfer "user/repo" to you
Link: http://localhost
Repo: Repo/Name
Repo: user/repo

View File

@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>{{.Subject}}</title>
{{template "mail/base/head" .Subject}}
</head>
{{$url := HTMLFormat "<a href='%[1]s'>%[2]s</a>" .Link .Repo}}
@@ -10,12 +9,6 @@
<p>{{.Subject}}.
{{.locale.Tr "mail.repo.transfer.body" $url}}
</p>
<div style="font-size:small; color:#666;">
<p>
---
<br>
<a href="{{.Link}}">{{.locale.Tr "mail.view_it_on" AppName}}</a>.
</p>
</div>
{{template "mail/base/footer"}}
</body>
</html>

View File

@@ -1,3 +1,4 @@
Subject: Please activate your account
DisplayName: User Display Name
Code: The-Activation-Code
ActiveCodeLives: 24h

View File

@@ -1,17 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
<title>{{.locale.Tr "mail.activate_account.title" (.DisplayName|DotEscape)}}</title>
{{template "mail/base/head" (.locale.Tr "mail.activate_account.title" (.DisplayName|DotEscape))}}
</head>
{{$activate_url := printf "%suser/activate?code=%s" AppUrl (QueryEscape .Code)}}
<body>
<p>{{.locale.Tr "mail.activate_account.text_1" (.DisplayName|DotEscape) AppName}}</p><br>
<p>{{.locale.Tr "mail.activate_account.text_2" .ActiveCodeLives}}</p><p><a href="{{$activate_url}}">{{$activate_url}}</a></p><br>
<p>{{.locale.Tr "mail.activate_account.text_1" (.DisplayName|DotEscape) AppName}}</p>
<p>{{.locale.Tr "mail.activate_account.text_2" .ActiveCodeLives}}</p><p style="word-break: break-all;"><a href="{{$activate_url}}">{{$activate_url}}</a></p>
<p>{{.locale.Tr "mail.link_not_working_do_paste"}}</p>
<p>© <a href="{{AppUrl}}">{{AppName}}</a></p>
{{template "mail/base/footer"}}
</body>
</html>

View File

@@ -1,3 +1,4 @@
Subject: Verify your email address
DisplayName: User Display Name
Code: The-Activation-Code
Email: admin@example.com

View File

@@ -1,17 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta Name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
<title>{{.locale.Tr "mail.activate_email.title" (.DisplayName|DotEscape)}}</title>
{{template "mail/base/head" (.locale.Tr "mail.activate_email.title" (.DisplayName|DotEscape))}}
</head>
{{$activate_url := printf "%suser/activate_email?code=%s&email=%s" AppUrl (QueryEscape .Code) (QueryEscape .Email)}}
<body>
<p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p><br>
<p>{{.locale.Tr "mail.activate_email.text" .ActiveCodeLives}}</p><p><a href="{{$activate_url}}">{{$activate_url}}</a></p><br>
<p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p>
<p>{{.locale.Tr "mail.activate_email.text" .ActiveCodeLives}}</p><p style="word-break: break-all;"><a href="{{$activate_url}}">{{$activate_url}}</a></p>
<p>{{.locale.Tr "mail.link_not_working_do_paste"}}</p>
<p>© <a href="{{AppUrl}}">{{AppName}}</a></p>
{{template "mail/base/footer"}}
</body>
</html>

View File

@@ -1,2 +1,3 @@
Subject: Welcome to Gitea
DisplayName: User Display Name
Username: Username

View File

@@ -1,18 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
<title>{{.locale.Tr "mail.register_notify.title" (.DisplayName|DotEscape) AppName}}</title>
{{template "mail/base/head" (.locale.Tr "mail.register_notify.title" (.DisplayName|DotEscape) AppName)}}
</head>
{{$set_pwd_url := printf "%[1]suser/forgot_password" AppUrl}}
<body>
<p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p><br>
<p>{{.locale.Tr "mail.register_notify.text_1" AppName}}</p><br>
<p>{{.locale.Tr "mail.register_notify.text_2" .Username}}</p><p><a href="{{AppUrl}}user/login">{{AppUrl}}user/login</a></p><br>
<p>{{.locale.Tr "mail.register_notify.text_3" $set_pwd_url}}</p><br>
<p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p>
<p>{{.locale.Tr "mail.register_notify.text_1" AppName}}</p>
<p>{{.locale.Tr "mail.register_notify.text_2" .Username}}</p><p><a href="{{AppUrl}}user/login">{{AppUrl}}user/login</a></p>
<p>{{.locale.Tr "mail.register_notify.text_3" $set_pwd_url}}</p>
<p>© <a href="{{AppUrl}}">{{AppName}}</a></p>
{{template "mail/base/footer"}}
</body>
</html>

View File

@@ -1,3 +1,4 @@
Subject: Recover your account
DisplayName: User Display Name
Code: The-Reset-Token
ResetPwdCodeLives: 24h

View File

@@ -1,17 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
<title>{{.locale.Tr "mail.reset_password.title" (.DisplayName|DotEscape)}}</title>
{{template "mail/base/head" (.locale.Tr "mail.reset_password.title" (.DisplayName|DotEscape))}}
</head>
{{$recover_url := printf "%suser/recover_account?code=%s" AppUrl (QueryEscape .Code)}}
<body>
<p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p><br>
<p>{{.locale.Tr "mail.reset_password.text" .ResetPwdCodeLives}}</p><p><a href="{{$recover_url}}">{{$recover_url}}</a></p><br>
<p>{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}</p>
<p>{{.locale.Tr "mail.reset_password.text" .ResetPwdCodeLives}}</p><p style="word-break: break-all;"><a href="{{$recover_url}}">{{$recover_url}}</a></p>
<p>{{.locale.Tr "mail.link_not_working_do_paste"}}</p>
<p>© <a href="{{AppUrl}}">{{AppName}}</a></p>
{{template "mail/base/footer"}}
</body>
</html>

View File

@@ -1,9 +1,20 @@
#!/usr/bin/env node
import {initWasm, Resvg} from '@resvg/resvg-wasm';
import {optimize} from 'svgo';
import {readFile, writeFile} from 'node:fs/promises';
import {mkdir, readFile, writeFile} from 'node:fs/promises';
import {argv, exit} from 'node:process';
async function generateMailIcon(icon: string, name: string, color: string) {
await generate(
(await readFile(
new URL(import.meta.resolve(`${icon.replace('octicon-', '@primer/octicons/build/svg/')}.svg`)),
'utf8',
)).replace('<svg ', `<svg fill="${color}" `),
`../services/mailer/icons/${name}.png`,
{size: 48},
);
}
async function generate(svg: string, path: string, {size, bg}: {size: number, bg?: boolean}) {
const outputFile = new URL(path, import.meta.url);
@@ -41,8 +52,13 @@ async function main() {
const logoSvg = await readFile(new URL('../assets/logo.svg', import.meta.url), 'utf8');
const faviconSvg = await readFile(new URL('../assets/favicon.svg', import.meta.url), 'utf8');
await initWasm(await readFile(new URL(import.meta.resolve('@resvg/resvg-wasm/index_bg.wasm'))));
await mkdir(new URL('../services/mailer/icons/', import.meta.url), {recursive: true});
await Promise.all([
generateMailIcon('octicon-check-circle-fill-16', 'status-success', '#2da44e'),
generateMailIcon('octicon-x-circle-fill-16', 'status-failure', '#e5534b'),
generateMailIcon('octicon-stop-16', 'status-cancelled', '#8c959f'),
generateMailIcon('octicon-skip-16', 'status-skipped', '#8c959f'),
generate(logoSvg, '../public/assets/img/logo.svg', {size: 32}),
generate(logoSvg, '../public/assets/img/logo.png', {size: 512}),
generate(faviconSvg, '../public/assets/img/favicon.svg', {size: 32}),