mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-11 00:10:38 -05:00
enhance: improve e-mail templates (#38396)
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
services/mailer/icons/status-cancelled.png
Normal file
BIN
services/mailer/icons/status-cancelled.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 682 B |
BIN
services/mailer/icons/status-failure.png
Normal file
BIN
services/mailer/icons/status-failure.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
services/mailer/icons/status-skipped.png
Normal file
BIN
services/mailer/icons/status-skipped.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
services/mailer/icons/status-success.png
Normal file
BIN
services/mailer/icons/status-success.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -261,18 +261,18 @@ func actionToTemplate(issue *issues_model.Issue, actionType activities_model.Act
|
||||
}
|
||||
}
|
||||
|
||||
template = "repo/" + typeName + "/" + name
|
||||
template = "mail/repo/" + typeName + "/" + name
|
||||
ok := LoadedTemplates().BodyTemplates.HasTemplate(template)
|
||||
if !ok && typeName != "issue" {
|
||||
template = "repo/issue/" + name
|
||||
template = "mail/repo/issue/" + name
|
||||
ok = LoadedTemplates().BodyTemplates.HasTemplate(template)
|
||||
}
|
||||
if !ok {
|
||||
template = "repo/" + typeName + "/default"
|
||||
template = "mail/repo/" + typeName + "/default"
|
||||
ok = LoadedTemplates().BodyTemplates.HasTemplate(template)
|
||||
}
|
||||
if !ok {
|
||||
template = "repo/issue/default"
|
||||
template = "mail/repo/issue/default"
|
||||
}
|
||||
return typeName, name, template
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
)
|
||||
|
||||
const tplNewReleaseMail templates.TplName = "repo/release"
|
||||
const tplNewReleaseMail templates.TplName = "mail/repo/release"
|
||||
|
||||
func generateMessageIDForRelease(release *repo_model.Release) string {
|
||||
return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain)
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
mailNotifyCollaborator templates.TplName = "repo/collaborator"
|
||||
mailRepoTransferNotify templates.TplName = "repo/transfer"
|
||||
mailNotifyCollaborator templates.TplName = "mail/repo/collaborator"
|
||||
mailRepoTransferNotify templates.TplName = "mail/repo/transfer"
|
||||
)
|
||||
|
||||
// SendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
)
|
||||
|
||||
const tplTeamInviteMail templates.TplName = "org/team_invite"
|
||||
const tplTeamInviteMail templates.TplName = "mail/org/team_invite"
|
||||
|
||||
// MailTeamInvite sends team invites
|
||||
func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_model.Team, invite *org_model.TeamInvite) error {
|
||||
|
||||
@@ -11,12 +11,13 @@ import (
|
||||
"html/template"
|
||||
"io"
|
||||
"mime/quotedprintable"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
texttmpl "text/template"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/asymkey"
|
||||
git_model "gitea.dev/models/git"
|
||||
@@ -52,11 +53,7 @@ const bodyTpl = `
|
||||
|
||||
<body>
|
||||
<p>{{.Body}}</p>
|
||||
<p>
|
||||
---
|
||||
<br>
|
||||
<a href="{{.Link}}">View it on Gitea</a>.
|
||||
</p>
|
||||
<p><a href="{{.Link}}">#{{.Issue.Index}}</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -114,7 +111,7 @@ func TestComposeIssueComment(t *testing.T) {
|
||||
})
|
||||
|
||||
defer test.MockVariableValue(&setting.IncomingEmail.Enabled, true)()
|
||||
defer mockMailTemplates("repo/issue/comment", subjectTpl, bodyTpl)()
|
||||
defer mockMailTemplates("mail/repo/issue/comment", subjectTpl, bodyTpl)()
|
||||
|
||||
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
|
||||
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
|
||||
@@ -159,7 +156,7 @@ func TestComposeIssueComment(t *testing.T) {
|
||||
func TestMailMentionsComment(t *testing.T) {
|
||||
doer, _, issue, comment := prepareMailerTest(t)
|
||||
comment.Poster = doer
|
||||
defer mockMailTemplates("repo/issue/comment", subjectTpl, bodyTpl)()
|
||||
defer mockMailTemplates("mail/repo/issue/comment", subjectTpl, bodyTpl)()
|
||||
mails := 0
|
||||
|
||||
defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) {
|
||||
@@ -174,7 +171,7 @@ func TestMailMentionsComment(t *testing.T) {
|
||||
func TestComposeIssueMessage(t *testing.T) {
|
||||
doer, _, issue, _ := prepareMailerTest(t)
|
||||
|
||||
defer mockMailTemplates("repo/issue/new", subjectTpl, bodyTpl)()
|
||||
defer mockMailTemplates("mail/repo/issue/new", subjectTpl, bodyTpl)()
|
||||
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
|
||||
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
|
||||
Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
|
||||
@@ -200,13 +197,41 @@ func TestComposeIssueMessage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTemplateSelection(t *testing.T) {
|
||||
t.Run("legacy custom template", func(t *testing.T) {
|
||||
restoreCustomPath := test.MockVariableValue(&setting.CustomPath, t.TempDir())
|
||||
t.Cleanup(func() {
|
||||
restoreCustomPath()
|
||||
require.NoError(t, templates.MailRendererReload())
|
||||
})
|
||||
templatePath := filepath.Join(setting.CustomPath, "templates/mail/repo/issue")
|
||||
require.NoError(t, os.MkdirAll(templatePath, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(templatePath, "default.tmpl"), []byte("custom subject\n---\ncustom body"), 0o644))
|
||||
require.NoError(t, templates.MailRendererReload())
|
||||
|
||||
for _, name := range []string{"mail/repo/issue/default", "repo/issue/default"} {
|
||||
var subject, body bytes.Buffer
|
||||
require.NoError(t, LoadedTemplates().SubjectTemplates.ExecuteTemplate(&subject, name, nil))
|
||||
require.NoError(t, LoadedTemplates().BodyTemplates.ExecuteTemplate(&body, name, nil))
|
||||
assert.Equal(t, "custom subject\n", subject.String())
|
||||
assert.Equal(t, "\ncustom body", body.String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, name := range []string{"base/footer", "base/head"} {
|
||||
assert.True(t, LoadedTemplates().BodyTemplates.HasTemplate("mail/"+name))
|
||||
assert.True(t, LoadedTemplates().BodyTemplates.HasTemplate(name))
|
||||
assert.NotContains(t, LoadedTemplates().TemplateNames, "mail/"+name)
|
||||
var rendered bytes.Buffer
|
||||
require.NoError(t, LoadedTemplates().BodyTemplates.ExecuteTemplate(&rendered, name, "test"))
|
||||
}
|
||||
|
||||
doer, repo, issue, comment := prepareMailerTest(t)
|
||||
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
|
||||
|
||||
defer mockMailTemplates("repo/issue/default", "repo/issue/default/subject", "repo/issue/default/body")()
|
||||
defer mockMailTemplates("repo/issue/new", "repo/issue/new/subject", "repo/issue/new/body")()
|
||||
defer mockMailTemplates("repo/pull/comment", "repo/pull/comment/subject", "repo/pull/comment/body")()
|
||||
defer mockMailTemplates("repo/issue/close", "", "repo/issue/close/body")() // Must default to a fallback subject
|
||||
defer mockMailTemplates("mail/repo/issue/default", "repo/issue/default/subject", "repo/issue/default/body")()
|
||||
defer mockMailTemplates("mail/repo/issue/new", "repo/issue/new/subject", "repo/issue/new/body")()
|
||||
defer mockMailTemplates("mail/repo/pull/comment", "repo/pull/comment/subject", "repo/pull/comment/body")()
|
||||
defer mockMailTemplates("mail/repo/issue/close", "", "repo/issue/close/body")() // Must default to a fallback subject
|
||||
|
||||
expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) {
|
||||
subject := msg.ToMessage().GetGenHeader("Subject")
|
||||
@@ -251,7 +276,7 @@ func TestTemplateServices(t *testing.T) {
|
||||
expect := func(t *testing.T, issue *issues_model.Issue, comment *issues_model.Comment, doer *user_model.User,
|
||||
actionType activities_model.ActionType, fromMention bool, tplSubject, tplBody, expSubject, expBody string,
|
||||
) {
|
||||
defer mockMailTemplates("repo/issue/default", tplSubject, tplBody)()
|
||||
defer mockMailTemplates("mail/repo/issue/default", tplSubject, tplBody)()
|
||||
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
|
||||
msg := testComposeIssueCommentMessage(t, &mailComment{
|
||||
Issue: issue, Doer: doer, ActionType: actionType,
|
||||
@@ -436,16 +461,6 @@ func TestGenerateMessageIDForRelease(t *testing.T) {
|
||||
assert.Equal(t, "<owner/repo/releases/1@localhost>", msgID)
|
||||
}
|
||||
|
||||
func TestGenerateMessageIDForActionsWorkflowRunStatusEmail(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 795, RepoID: repo.ID})
|
||||
assert.NoError(t, run.LoadAttributes(t.Context()))
|
||||
msgID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run)
|
||||
assert.Equal(t, "<user2/repo2/actions/runs/191@localhost>", msgID)
|
||||
}
|
||||
|
||||
func TestFromDisplayName(t *testing.T) {
|
||||
tmpl, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}")
|
||||
assert.NoError(t, err)
|
||||
@@ -518,7 +533,7 @@ func TestEmbedBase64Images(t *testing.T) {
|
||||
att2ImgBase64 := fmt.Sprintf(`<img src="%s"/>`, att2Base64)
|
||||
|
||||
t.Run("ComposeMessage", func(t *testing.T) {
|
||||
defer mockMailTemplates("repo/issue/new", subjectTpl, bodyTpl)()
|
||||
defer mockMailTemplates("mail/repo/issue/new", subjectTpl, bodyTpl)()
|
||||
|
||||
issue.Content = fmt.Sprintf(`MSG-BEFORE <image src="attachments/%s"> MSG-AFTER`, att1.UUID)
|
||||
require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "content"))
|
||||
|
||||
@@ -17,10 +17,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
mailAuthActivate templates.TplName = "user/auth/activate"
|
||||
mailAuthActivateEmail templates.TplName = "user/auth/activate_email"
|
||||
mailAuthResetPassword templates.TplName = "user/auth/reset_passwd"
|
||||
mailAuthRegisterNotify templates.TplName = "user/auth/register_notify"
|
||||
mailAuthActivate templates.TplName = "mail/user/auth/activate"
|
||||
mailAuthActivateEmail templates.TplName = "mail/user/auth/activate_email"
|
||||
mailAuthResetPassword templates.TplName = "mail/user/auth/reset_passwd"
|
||||
mailAuthRegisterNotify templates.TplName = "mail/user/auth/register_notify"
|
||||
)
|
||||
|
||||
// sendUserMail sends a mail to the user
|
||||
|
||||
@@ -5,38 +5,61 @@ package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/services/convert"
|
||||
"gitea.dev/modules/util"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
)
|
||||
|
||||
const tplWorkflowRun templates.TplName = "repo/actions/workflow_run"
|
||||
const tplWorkflowRun templates.TplName = "mail/repo/actions/workflow_run"
|
||||
|
||||
type convertedWorkflowJob struct {
|
||||
HTMLURL string
|
||||
Name string
|
||||
Status actions_model.Status
|
||||
Attempt int64
|
||||
Duration time.Duration
|
||||
//go:embed icons/*.png
|
||||
var iconsFS embed.FS
|
||||
|
||||
// LoadMailIcon returns an embedded mail icon.
|
||||
func LoadMailIcon(name string) ([]byte, error) {
|
||||
return iconsFS.ReadFile("icons/" + name)
|
||||
}
|
||||
|
||||
func generateMessageIDForActionsWorkflowRunStatusEmail(repo *repo_model.Repository, run *actions_model.ActionRun) string {
|
||||
return fmt.Sprintf("<%s/actions/runs/%d@%s>", repo.FullName(), run.Index, setting.Domain)
|
||||
type workflowRunMailJob struct {
|
||||
HTMLURL string
|
||||
Name string
|
||||
Status actions_model.Status
|
||||
StatusIconCID string
|
||||
StatusIconAlt string
|
||||
StatusClass string
|
||||
Attempt int64
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) error {
|
||||
func workflowRunJobStatusPresentation(status actions_model.Status) (icon, class string) {
|
||||
switch {
|
||||
case status.IsSuccess():
|
||||
return "status-success.png", "status-success"
|
||||
case status.IsCancelled():
|
||||
return "status-cancelled.png", "status-neutral"
|
||||
case status.IsSkipped():
|
||||
return "status-skipped.png", "status-neutral"
|
||||
default:
|
||||
return "status-failure.png", "status-failure"
|
||||
}
|
||||
}
|
||||
|
||||
func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, recipient *user_model.User) error {
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, repo.ID, run.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -48,104 +71,81 @@ func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo
|
||||
}
|
||||
}
|
||||
|
||||
var subjectTrString string
|
||||
switch run.Status {
|
||||
case actions_model.StatusFailure:
|
||||
subjectTrString = "mail.repo.actions.run.failed"
|
||||
case actions_model.StatusCancelled:
|
||||
subjectTrString = "mail.repo.actions.run.cancelled"
|
||||
case actions_model.StatusSuccess:
|
||||
subjectTrString = "mail.repo.actions.run.succeeded"
|
||||
}
|
||||
displayName := fromDisplayName(sender)
|
||||
messageID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run)
|
||||
metadataHeaders := generateMetadataHeaders(repo)
|
||||
locale := translation.NewLocale(recipient.Language)
|
||||
|
||||
sort.SliceStable(jobs, func(i, j int) bool {
|
||||
si, sj := jobs[i].Status, jobs[j].Status
|
||||
/*
|
||||
If both i and j are/are not success, leave it to si < sj.
|
||||
If i is success and j is not, since the desired is j goes "smaller" and i goes "bigger", this func should return false.
|
||||
If j is success and i is not, since the desired is i goes "smaller" and j goes "bigger", this func should return true.
|
||||
*/
|
||||
if si.IsSuccess() != sj.IsSuccess() {
|
||||
return !si.IsSuccess()
|
||||
slices.SortStableFunc(jobs, func(a, b *actions_model.ActionRunJob) int {
|
||||
if a.Status.IsSuccess() != b.Status.IsSuccess() {
|
||||
return util.Iif(a.Status.IsSuccess(), 1, -1)
|
||||
}
|
||||
return si < sj
|
||||
return cmp.Compare(a.Status, b.Status)
|
||||
})
|
||||
|
||||
convertedJobs := make([]convertedWorkflowJob, 0, len(jobs))
|
||||
mailJobs := make([]workflowRunMailJob, 0, len(jobs))
|
||||
var embeds []sender_service.EmbeddedFile
|
||||
embedded := make(container.Set[string])
|
||||
for _, job := range jobs {
|
||||
converted0, err := convert.ToActionWorkflowJob(ctx, repo, nil, job)
|
||||
if err != nil {
|
||||
log.Error("convert.ToActionWorkflowJob: %v", err)
|
||||
icon, class := workflowRunJobStatusPresentation(job.Status)
|
||||
contentID := fmt.Sprintf("%s.actions-run-%d@%s", icon, run.ID, setting.Domain)
|
||||
mailJobs = append(mailJobs, workflowRunMailJob{
|
||||
HTMLURL: fmt.Sprintf("%s/actions/runs/%d/jobs/%d", repo.HTMLURL(ctx), run.ID, job.ID),
|
||||
Name: job.Name,
|
||||
Status: job.Status,
|
||||
StatusIconCID: contentID,
|
||||
StatusIconAlt: job.Status.LocaleString(locale),
|
||||
StatusClass: class,
|
||||
Attempt: job.Attempt,
|
||||
Duration: job.Duration(),
|
||||
})
|
||||
if !embedded.Add(icon) {
|
||||
continue
|
||||
}
|
||||
convertedJobs = append(convertedJobs, convertedWorkflowJob{
|
||||
HTMLURL: converted0.HTMLURL,
|
||||
Name: converted0.Name,
|
||||
Status: job.Status,
|
||||
Attempt: converted0.RunAttempt,
|
||||
Duration: job.Duration(),
|
||||
})
|
||||
}
|
||||
|
||||
langMap := make(map[string][]*user_model.User)
|
||||
for _, user := range recipients {
|
||||
langMap[user.Language] = append(langMap[user.Language], user)
|
||||
}
|
||||
for lang, tos := range langMap {
|
||||
locale := translation.NewLocale(lang)
|
||||
var runStatusTrString string
|
||||
switch run.Status {
|
||||
case actions_model.StatusSuccess:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_succeeded"
|
||||
case actions_model.StatusFailure:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_failed"
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsFailure() {
|
||||
runStatusTrString = "mail.repo.actions.jobs.some_not_successful"
|
||||
break
|
||||
}
|
||||
}
|
||||
case actions_model.StatusCancelled:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_cancelled"
|
||||
}
|
||||
subject := fmt.Sprintf("%s: %s (%s)", locale.TrString(subjectTrString), run.WorkflowID, base.ShortSha(run.CommitSHA))
|
||||
var mailBody bytes.Buffer
|
||||
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplWorkflowRun), map[string]any{
|
||||
"Subject": subject,
|
||||
"Repo": repo,
|
||||
"Run": run,
|
||||
"RunStatusText": locale.TrString(runStatusTrString),
|
||||
"Jobs": convertedJobs,
|
||||
"locale": locale,
|
||||
}); err != nil {
|
||||
content, err := LoadMailIcon(icon)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgs := make([]*sender_service.Message, 0, len(tos))
|
||||
for _, rec := range tos {
|
||||
log.Trace("Sending actions email to %s (UID: %d)", rec.Name, rec.ID)
|
||||
msg := sender_service.NewMessageFrom(
|
||||
rec.Email,
|
||||
displayName,
|
||||
setting.MailService.FromEmail,
|
||||
subject,
|
||||
mailBody.String(),
|
||||
)
|
||||
msg.Info = subject
|
||||
for k, v := range generateSenderRecipientHeaders(sender, rec) {
|
||||
msg.SetHeader(k, v)
|
||||
}
|
||||
for k, v := range metadataHeaders {
|
||||
msg.SetHeader(k, v)
|
||||
}
|
||||
msg.SetHeader("Message-ID", messageID)
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
SendAsync(msgs...)
|
||||
embeds = append(embeds, sender_service.EmbeddedFile{Name: icon, ContentID: contentID, Content: content})
|
||||
}
|
||||
|
||||
var runStatusTrString string
|
||||
switch run.Status {
|
||||
case actions_model.StatusSuccess:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_succeeded"
|
||||
case actions_model.StatusFailure:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_failed"
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsFailure() {
|
||||
runStatusTrString = "mail.repo.actions.jobs.some_not_successful"
|
||||
break
|
||||
}
|
||||
}
|
||||
case actions_model.StatusCancelled:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_cancelled"
|
||||
}
|
||||
subject := fmt.Sprintf("[%s] %s: %s (%s - %s)", repo.FullName(), run.Status.LocaleString(locale), run.WorkflowID, run.PrettyRef(), base.ShortSha(run.CommitSHA))
|
||||
var mailBody bytes.Buffer
|
||||
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplWorkflowRun), map[string]any{
|
||||
"Subject": subject,
|
||||
"Repo": repo,
|
||||
"Run": run,
|
||||
"RunStatusText": locale.TrString(runStatusTrString),
|
||||
"Jobs": mailJobs,
|
||||
"locale": locale,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Trace("Sending actions email to %s (UID: %d)", recipient.Name, recipient.ID)
|
||||
msg := sender_service.NewMessageFrom(recipient.Email, fromDisplayName(recipient), setting.MailService.FromEmail, subject, mailBody.String())
|
||||
msg.Info = subject
|
||||
msg.Embeds = embeds
|
||||
for key, value := range generateSenderRecipientHeaders(recipient, recipient) {
|
||||
msg.SetHeader(key, value)
|
||||
}
|
||||
for key, value := range generateMetadataHeaders(repo) {
|
||||
msg.SetHeader(key, value)
|
||||
}
|
||||
msg.SetHeader("Message-ID", fmt.Sprintf("<%s/actions/runs/%d@%s>", repo.FullName(), run.Index, setting.Domain))
|
||||
SendAsync(msg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -175,5 +175,5 @@ func MailActionsTrigger(ctx context.Context, recipient *user_model.User, repo *r
|
||||
}
|
||||
|
||||
log.Debug("MailActionsTrigger: Initiate email composition")
|
||||
return composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, recipient, []*user_model.User{recipient})
|
||||
return composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, recipient)
|
||||
}
|
||||
|
||||
124
services/mailer/mail_workflow_run_test.go
Normal file
124
services/mailer/mail_workflow_run_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
10
templates/mail/base/footer.tmpl
Normal file
10
templates/mail/base/footer.tmpl
Normal 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;"> </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>
|
||||
17
templates/mail/base/head.tmpl
Normal file
17
templates/mail/base/head.tmpl
Normal 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>
|
||||
@@ -1,3 +1,5 @@
|
||||
Subject: Inviter Display Name has invited you to join the Organization Display Name organization
|
||||
|
||||
Inviter:
|
||||
DisplayName: Inviter Display Name
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
Subject: Collaborator added
|
||||
Subject: DoerName added you to user/repo
|
||||
Link: http://localhost
|
||||
RepoName: Repo/Name
|
||||
RepoName: user/repo
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
Subject: "Re: [user/repo] Issue Title (#1)"
|
||||
Link: http://localhost/issue/1
|
||||
CanReply: true
|
||||
Body: Issue body
|
||||
|
||||
Issue:
|
||||
Index: 1
|
||||
|
||||
@@ -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}} {{.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>
|
||||
|
||||
13
templates/mail/repo/release.devtest.yml
Normal file
13
templates/mail/repo/release.devtest.yml
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
Subject: Please activate your account
|
||||
DisplayName: User Display Name
|
||||
Code: The-Activation-Code
|
||||
ActiveCodeLives: 24h
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
Subject: Verify your email address
|
||||
DisplayName: User Display Name
|
||||
Code: The-Activation-Code
|
||||
Email: admin@example.com
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
Subject: Welcome to Gitea
|
||||
DisplayName: User Display Name
|
||||
Username: Username
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
Subject: Recover your account
|
||||
DisplayName: User Display Name
|
||||
Code: The-Reset-Token
|
||||
ResetPwdCodeLives: 24h
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}),
|
||||
|
||||
Reference in New Issue
Block a user