diff --git a/modules/templates/mail.go b/modules/templates/mail.go index 8fa643d38a9..4383a0a3d33 100644 --- a/modules/templates/mail.go +++ b/modules/templates/mail.go @@ -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 } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 605363ae700..b187b1d5dc1 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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 %s,", "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 %s:", "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": "@%s 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": "%s mentioned you:", "mail.issue.action.force_push": "%[1]s force-pushed the %[2]s from %[3]s to %[4]s.", - "mail.issue.action.push_1": "@%[1]s pushed %[3]d commit to %[2]s", - "mail.issue.action.push_n": "@%[1]s pushed %[3]d commits to %[2]s", - "mail.issue.action.close": "@%[1]s closed #%[2]d.", - "mail.issue.action.reopen": "@%[1]s reopened #%[2]d.", - "mail.issue.action.merge": "@%[1]s merged #%[2]d into %[3]s.", - "mail.issue.action.approve": "@%[1]s approved this pull request.", - "mail.issue.action.reject": "@%[1]s requested changes on this pull request.", - "mail.issue.action.review": "@%[1]s commented on this pull request.", - "mail.issue.action.review_dismissed": "@%[1]s dismissed last review from %[2]s for this pull request.", - "mail.issue.action.ready_for_review": "@%[1]s marked this pull request ready for review.", - "mail.issue.action.new": "@%[1]s created #%[2]d.", + "mail.issue.action.push_1": "%[1]s pushed %[3]d commit to %[2]s", + "mail.issue.action.push_n": "%[1]s pushed %[3]d commits to %[2]s", + "mail.issue.action.close": "%[1]s closed #%[2]d.", + "mail.issue.action.reopen": "%[1]s reopened #%[2]d.", + "mail.issue.action.merge": "%[1]s merged #%[2]d into %[3]s.", + "mail.issue.action.approve": "%[1]s approved this pull request.", + "mail.issue.action.reject": "%[1]s requested changes on this pull request.", + "mail.issue.action.review": "%[1]s commented on this pull request.", + "mail.issue.action.review_dismissed": "%[1]s dismissed last review from %[2]s for this pull request.", + "mail.issue.action.ready_for_review": "%[1]s marked this pull request ready for review.", + "mail.issue.action.new": "%[1]s created #%[2]d.", "mail.issue.in_tree_path": "In %s:", "mail.release.new.subject": "%s in %s released", - "mail.release.new.text": "@%[1]s released %[2]s in %[3]s", + "mail.release.new.text": "%[1]s released %[2]s in %[3]s", "mail.release.title": "Title: %s", "mail.release.note": "Note:", "mail.release.downloads": "Downloads:", diff --git a/routers/web/devtest/mail_preview.go b/routers/web/devtest/mail_preview.go index 82a84ec03cb..81df0657f10 100644 --- a/routers/web/devtest/mail_preview.go +++ b/routers/web/devtest/mail_preview.go @@ -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, "", "", 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) diff --git a/routers/web/web.go b/routers/web/web.go index 7588b53092a..f3ecc8ca2d3 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/mailer/icons/status-cancelled.png b/services/mailer/icons/status-cancelled.png new file mode 100644 index 00000000000..b5e546f9171 Binary files /dev/null and b/services/mailer/icons/status-cancelled.png differ diff --git a/services/mailer/icons/status-failure.png b/services/mailer/icons/status-failure.png new file mode 100644 index 00000000000..a553c704f1f Binary files /dev/null and b/services/mailer/icons/status-failure.png differ diff --git a/services/mailer/icons/status-skipped.png b/services/mailer/icons/status-skipped.png new file mode 100644 index 00000000000..9b9adea3489 Binary files /dev/null and b/services/mailer/icons/status-skipped.png differ diff --git a/services/mailer/icons/status-success.png b/services/mailer/icons/status-success.png new file mode 100644 index 00000000000..8bb12dc8af3 Binary files /dev/null and b/services/mailer/icons/status-success.png differ diff --git a/services/mailer/mail_issue_common.go b/services/mailer/mail_issue_common.go index 11979691761..8bd5929cc10 100644 --- a/services/mailer/mail_issue_common.go +++ b/services/mailer/mail_issue_common.go @@ -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 } diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go index 8b19916a069..92f30fb4bc5 100644 --- a/services/mailer/mail_release.go +++ b/services/mailer/mail_release.go @@ -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) diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go index 2631a5c6e23..7dde1b293e5 100644 --- a/services/mailer/mail_repo.go +++ b/services/mailer/mail_repo.go @@ -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 diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go index 6d016ae81bf..a7aa6b11bbf 100644 --- a/services/mailer/mail_team_invite.go +++ b/services/mailer/mail_team_invite.go @@ -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 { diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index 2925328307e..23214f12758 100644 --- a/services/mailer/mail_test.go +++ b/services/mailer/mail_test.go @@ -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}}

-

- --- -
- View it on Gitea. -

+

#{{.Issue.Index}}.

` @@ -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, "", 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, "", 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(``, 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 MSG-AFTER`, att1.UUID) require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "content")) diff --git a/services/mailer/mail_user.go b/services/mailer/mail_user.go index 504b62e00ba..2eb896104e7 100644 --- a/services/mailer/mail_user.go +++ b/services/mailer/mail_user.go @@ -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 diff --git a/services/mailer/mail_workflow_run.go b/services/mailer/mail_workflow_run.go index aa2827bfa46..1ae8b544189 100644 --- a/services/mailer/mail_workflow_run.go +++ b/services/mailer/mail_workflow_run.go @@ -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) } diff --git a/services/mailer/mail_workflow_run_test.go b/services/mailer/mail_workflow_run_test.go new file mode 100644 index 00000000000..54f8d3b2300 --- /dev/null +++ b/services/mailer/mail_workflow_run_test.go @@ -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{""}, message.Headers["Message-ID"]) + assert.NotContains(t, message.Body, "Some jobs were not successful") + require.Contains(t, message.Body, ">job_1") + require.Contains(t, message.Body, ">job_2") + assert.Less(t, strings.Index(message.Body, ">job_2"), strings.Index(message.Body, ">job_1")) + assert.Contains(t, message.Body, ``)) + 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{ + "", + "", + }, 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`) + + 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) + }) +} diff --git a/services/mailer/sender/message.go b/services/mailer/sender/message.go index bf22ddef112..a223aee71d1 100644 --- a/services/mailer/sender/message.go +++ b/services/mailer/sender/message.go @@ -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), "") { 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 { diff --git a/templates/devtest/mail-preview.tmpl b/templates/devtest/mail-preview.tmpl index 9a3d7929048..4475f2a1c9a 100644 --- a/templates/devtest/mail-preview.tmpl +++ b/templates/devtest/mail-preview.tmpl @@ -12,10 +12,13 @@
Preview of: {{.RenderMailTemplateName}}
Subject: {{.RenderMailSubject}}
- +
+ + +
diff --git a/templates/mail/org/team_invite.devtest.yml b/templates/mail/org/team_invite.devtest.yml index dc51a74d641..d13f9439688 100644 --- a/templates/mail/org/team_invite.devtest.yml +++ b/templates/mail/org/team_invite.devtest.yml @@ -1,3 +1,5 @@ +Subject: Inviter Display Name has invited you to join the Organization Display Name organization + Inviter: DisplayName: Inviter Display Name diff --git a/templates/mail/org/team_invite.tmpl b/templates/mail/org/team_invite.tmpl index a531e8c3b55..ff1b466e7c8 100644 --- a/templates/mail/org/team_invite.tmpl +++ b/templates/mail/org/team_invite.tmpl @@ -1,15 +1,14 @@ - - + {{template "mail/base/head" .Subject}}

{{.locale.Tr "mail.team_invite.text_1" (DotEscape .Inviter.DisplayName) (DotEscape .Team.Name) (DotEscape .Organization.DisplayName)}}

-

{{.locale.Tr "mail.team_invite.text_2"}}

{{.InviteURL}}

+

{{.locale.Tr "mail.team_invite.text_2"}}

{{.InviteURL}}

{{.locale.Tr "mail.link_not_working_do_paste"}}

{{.locale.Tr "mail.team_invite.text_3" .Invite.Email}}

-

© {{AppName}}

+ {{template "mail/base/footer"}} diff --git a/templates/mail/repo/actions/workflow_run.devtest.yml b/templates/mail/repo/actions/workflow_run.devtest.yml index a45b26a6eef..1d209cd5ee4 100644 --- a/templates/mail/repo/actions/workflow_run.devtest.yml +++ b/templates/mail/repo/actions/workflow_run.devtest.yml @@ -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 diff --git a/templates/mail/repo/actions/workflow_run.tmpl b/templates/mail/repo/actions/workflow_run.tmpl index 619ee6fa20f..c4bb4ae6d3c 100644 --- a/templates/mail/repo/actions/workflow_run.tmpl +++ b/templates/mail/repo/actions/workflow_run.tmpl @@ -1,33 +1,42 @@ - - - {{.Subject}} + {{template "mail/base/head" .Subject}} + - + -

- {{.Repo.FullName}} {{.Run.WorkflowID}}: {{.RunStatusText}} -

- -
    - {{range $job := .Jobs}} -
  • - - {{$job.Status}}: {{$job.Name}}{{if gt $job.Attempt 1}}, Attempt #{{$job.Attempt}}{{end}}, {{$job.Duration}} - -
  • +
    + {{if .Jobs}} + + {{range $index, $job := .Jobs}} + + + + + {{end}} - +
    {{$job.StatusIconAlt}} + {{$job.StatusIconAlt}}: {{$job.Name}} + {{if gt $job.Attempt 1}}({{$.locale.Tr "actions.runs.attempt"}} #{{$job.Attempt}}){{end}} + {{$job.Duration}}
    + {{else}} +

    {{.Run.WorkflowID}}

    + {{end}} +
    -
    - - + {{template "mail/base/footer"}} diff --git a/templates/mail/repo/collaborator.devtest.yml b/templates/mail/repo/collaborator.devtest.yml index 8d8f2b27333..efb9d619896 100644 --- a/templates/mail/repo/collaborator.devtest.yml +++ b/templates/mail/repo/collaborator.devtest.yml @@ -1,3 +1,3 @@ -Subject: Collaborator added +Subject: DoerName added you to user/repo Link: http://localhost -RepoName: Repo/Name +RepoName: user/repo diff --git a/templates/mail/repo/collaborator.tmpl b/templates/mail/repo/collaborator.tmpl index 3fe490e2211..f52597dc663 100644 --- a/templates/mail/repo/collaborator.tmpl +++ b/templates/mail/repo/collaborator.tmpl @@ -1,18 +1,11 @@ - - {{.Subject}} + {{template "mail/base/head" .Subject}} -

    {{.locale.Tr "mail.repo.collaborator.added.text"}} {{.RepoName}}

    - +

    {{.locale.Tr "mail.repo.collaborator.added.text"}} {{.RepoName}}

    + {{template "mail/base/footer"}} diff --git a/templates/mail/repo/issue/assigned.devtest.yml b/templates/mail/repo/issue/assigned.devtest.yml index 69a5cce1b01..f5be9fdb96f 100644 --- a/templates/mail/repo/issue/assigned.devtest.yml +++ b/templates/mail/repo/issue/assigned.devtest.yml @@ -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: diff --git a/templates/mail/repo/issue/assigned.tmpl b/templates/mail/repo/issue/assigned.tmpl index cdba7a0ad07..950d7b5cc49 100644 --- a/templates/mail/repo/issue/assigned.tmpl +++ b/templates/mail/repo/issue/assigned.tmpl @@ -1,8 +1,7 @@ - - {{.Subject}} + {{template "mail/base/head" .Subject}} {{$repo_url := HTMLFormat "%s" .Issue.Repo.HTMLURL .Issue.Repo.FullName}} @@ -15,12 +14,7 @@ {{.locale.Tr "mail.issue_assigned.issue" .Doer.Name $link $repo_url}} {{end}}

    - + {{if .CanReply}}

    {{.locale.Tr "mail.reply_directly"}}.

    {{end}} + {{template "mail/base/footer"}} diff --git a/templates/mail/repo/issue/default.devtest.yml b/templates/mail/repo/issue/default.devtest.yml index 5a7953639bb..28ad656feb2 100644 --- a/templates/mail/repo/issue/default.devtest.yml +++ b/templates/mail/repo/issue/default.devtest.yml @@ -1 +1,7 @@ +Subject: "Re: [user/repo] Issue Title (#1)" +Link: http://localhost/issue/1 CanReply: true +Body: Issue body + +Issue: + Index: 1 diff --git a/templates/mail/repo/issue/default.tmpl b/templates/mail/repo/issue/default.tmpl index 74b407a7792..917740b83a7 100644 --- a/templates/mail/repo/issue/default.tmpl +++ b/templates/mail/repo/issue/default.tmpl @@ -1,13 +1,7 @@ - - {{.Subject}} - - - + {{template "mail/base/head" .Subject}} @@ -75,12 +69,7 @@ {{end}}
{{end}} -
-

- --- -
- {{.locale.Tr "mail.view_it_on" AppName}}{{if .CanReply}} {{.locale.Tr "mail.reply"}}{{end}}. -

-
+

#{{.Issue.Index}}{{if .CanReply}} {{.locale.Tr "mail.reply"}}{{end}}.

+ {{template "mail/base/footer"}} diff --git a/templates/mail/repo/release.devtest.yml b/templates/mail/repo/release.devtest.yml new file mode 100644 index 00000000000..10ee4f6c899 --- /dev/null +++ b/templates/mail/repo/release.devtest.yml @@ -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 diff --git a/templates/mail/repo/release.tmpl b/templates/mail/repo/release.tmpl index 533d8ea825d..c35a58177ab 100644 --- a/templates/mail/repo/release.tmpl +++ b/templates/mail/repo/release.tmpl @@ -1,13 +1,7 @@ - - {{.Subject}} - - - + {{template "mail/base/head" .Subject}} {{$release_url := HTMLFormat "%s" .Release.HTMLURL .Release.TagName}} @@ -17,18 +11,9 @@ {{.locale.Tr "mail.release.new.text" .Release.Publisher.Name $release_url $repo_url}}

{{.locale.Tr "mail.release.title" .Release.Title}}

-

- {{.locale.Tr "mail.release.note"}}
- {{- if eq .Release.RenderedNote ""}} - {{else}} - {{.Release.RenderedNote}} - {{end -}} -

-

- --- -
- {{.locale.Tr "mail.release.downloads"}} -

+

{{.locale.Tr "mail.release.note"}}

+ {{if ne .Release.RenderedNote ""}}
{{.Release.RenderedNote}}
{{end}} +

{{.locale.Tr "mail.release.downloads"}}

-
-

- --- -
- {{.locale.Tr "mail.view_it_on" AppName}}. -

-
+ {{template "mail/base/footer"}} diff --git a/templates/mail/repo/transfer.devtest.yml b/templates/mail/repo/transfer.devtest.yml index e2ec67c6429..0a60ce3d6e8 100644 --- a/templates/mail/repo/transfer.devtest.yml +++ b/templates/mail/repo/transfer.devtest.yml @@ -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 diff --git a/templates/mail/repo/transfer.tmpl b/templates/mail/repo/transfer.tmpl index 43083b97b73..84716935614 100644 --- a/templates/mail/repo/transfer.tmpl +++ b/templates/mail/repo/transfer.tmpl @@ -1,8 +1,7 @@ - - {{.Subject}} + {{template "mail/base/head" .Subject}} {{$url := HTMLFormat "%[2]s" .Link .Repo}} @@ -10,12 +9,6 @@

{{.Subject}}. {{.locale.Tr "mail.repo.transfer.body" $url}}

-
-

- --- -
- {{.locale.Tr "mail.view_it_on" AppName}}. -

-
+ {{template "mail/base/footer"}} diff --git a/templates/mail/user/auth/activate.devtest.yml b/templates/mail/user/auth/activate.devtest.yml index f5519a6f6c0..b5e6abdc462 100644 --- a/templates/mail/user/auth/activate.devtest.yml +++ b/templates/mail/user/auth/activate.devtest.yml @@ -1,3 +1,4 @@ +Subject: Please activate your account DisplayName: User Display Name Code: The-Activation-Code ActiveCodeLives: 24h diff --git a/templates/mail/user/auth/activate.tmpl b/templates/mail/user/auth/activate.tmpl index 2ec9515dc08..aa5a1952667 100644 --- a/templates/mail/user/auth/activate.tmpl +++ b/templates/mail/user/auth/activate.tmpl @@ -1,17 +1,15 @@ - - - {{.locale.Tr "mail.activate_account.title" (.DisplayName|DotEscape)}} + {{template "mail/base/head" (.locale.Tr "mail.activate_account.title" (.DisplayName|DotEscape))}} {{$activate_url := printf "%suser/activate?code=%s" AppUrl (QueryEscape .Code)}} -

{{.locale.Tr "mail.activate_account.text_1" (.DisplayName|DotEscape) AppName}}


-

{{.locale.Tr "mail.activate_account.text_2" .ActiveCodeLives}}

{{$activate_url}}


+

{{.locale.Tr "mail.activate_account.text_1" (.DisplayName|DotEscape) AppName}}

+

{{.locale.Tr "mail.activate_account.text_2" .ActiveCodeLives}}

{{$activate_url}}

{{.locale.Tr "mail.link_not_working_do_paste"}}

-

© {{AppName}}

+ {{template "mail/base/footer"}} diff --git a/templates/mail/user/auth/activate_email.devtest.yml b/templates/mail/user/auth/activate_email.devtest.yml index 41d1ae029f6..bf232d61dfb 100644 --- a/templates/mail/user/auth/activate_email.devtest.yml +++ b/templates/mail/user/auth/activate_email.devtest.yml @@ -1,3 +1,4 @@ +Subject: Verify your email address DisplayName: User Display Name Code: The-Activation-Code Email: admin@example.com diff --git a/templates/mail/user/auth/activate_email.tmpl b/templates/mail/user/auth/activate_email.tmpl index 3805558361e..b54f50f091e 100644 --- a/templates/mail/user/auth/activate_email.tmpl +++ b/templates/mail/user/auth/activate_email.tmpl @@ -1,17 +1,15 @@ - - - {{.locale.Tr "mail.activate_email.title" (.DisplayName|DotEscape)}} + {{template "mail/base/head" (.locale.Tr "mail.activate_email.title" (.DisplayName|DotEscape))}} {{$activate_url := printf "%suser/activate_email?code=%s&email=%s" AppUrl (QueryEscape .Code) (QueryEscape .Email)}} -

{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}


-

{{.locale.Tr "mail.activate_email.text" .ActiveCodeLives}}

{{$activate_url}}


+

{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}

+

{{.locale.Tr "mail.activate_email.text" .ActiveCodeLives}}

{{$activate_url}}

{{.locale.Tr "mail.link_not_working_do_paste"}}

-

© {{AppName}}

+ {{template "mail/base/footer"}} diff --git a/templates/mail/user/auth/register_notify.devtest.yml b/templates/mail/user/auth/register_notify.devtest.yml index 8c9d6837d45..381b0f1ddee 100644 --- a/templates/mail/user/auth/register_notify.devtest.yml +++ b/templates/mail/user/auth/register_notify.devtest.yml @@ -1,2 +1,3 @@ +Subject: Welcome to Gitea DisplayName: User Display Name Username: Username diff --git a/templates/mail/user/auth/register_notify.tmpl b/templates/mail/user/auth/register_notify.tmpl index e4f6c1ac928..70ac39e1f1d 100644 --- a/templates/mail/user/auth/register_notify.tmpl +++ b/templates/mail/user/auth/register_notify.tmpl @@ -1,18 +1,16 @@ - - - {{.locale.Tr "mail.register_notify.title" (.DisplayName|DotEscape) AppName}} + {{template "mail/base/head" (.locale.Tr "mail.register_notify.title" (.DisplayName|DotEscape) AppName)}} {{$set_pwd_url := printf "%[1]suser/forgot_password" AppUrl}} -

{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}


-

{{.locale.Tr "mail.register_notify.text_1" AppName}}


-

{{.locale.Tr "mail.register_notify.text_2" .Username}}

{{AppUrl}}user/login


-

{{.locale.Tr "mail.register_notify.text_3" $set_pwd_url}}


+

{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}

+

{{.locale.Tr "mail.register_notify.text_1" AppName}}

+

{{.locale.Tr "mail.register_notify.text_2" .Username}}

{{AppUrl}}user/login

+

{{.locale.Tr "mail.register_notify.text_3" $set_pwd_url}}

-

© {{AppName}}

+ {{template "mail/base/footer"}} diff --git a/templates/mail/user/auth/reset_passwd.devtest.yml b/templates/mail/user/auth/reset_passwd.devtest.yml index f4269ebeb7d..21c68bceb70 100644 --- a/templates/mail/user/auth/reset_passwd.devtest.yml +++ b/templates/mail/user/auth/reset_passwd.devtest.yml @@ -1,3 +1,4 @@ +Subject: Recover your account DisplayName: User Display Name Code: The-Reset-Token ResetPwdCodeLives: 24h diff --git a/templates/mail/user/auth/reset_passwd.tmpl b/templates/mail/user/auth/reset_passwd.tmpl index 314cc61646a..4de76287e32 100644 --- a/templates/mail/user/auth/reset_passwd.tmpl +++ b/templates/mail/user/auth/reset_passwd.tmpl @@ -1,17 +1,15 @@ - - - {{.locale.Tr "mail.reset_password.title" (.DisplayName|DotEscape)}} + {{template "mail/base/head" (.locale.Tr "mail.reset_password.title" (.DisplayName|DotEscape))}} {{$recover_url := printf "%suser/recover_account?code=%s" AppUrl (QueryEscape .Code)}} -

{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}


-

{{.locale.Tr "mail.reset_password.text" .ResetPwdCodeLives}}

{{$recover_url}}


+

{{.locale.Tr "mail.hi_user_x" (.DisplayName|DotEscape)}}

+

{{.locale.Tr "mail.reset_password.text" .ResetPwdCodeLives}}

{{$recover_url}}

{{.locale.Tr "mail.link_not_working_do_paste"}}

-

© {{AppName}}

+ {{template "mail/base/footer"}} diff --git a/tools/generate-images.ts b/tools/generate-images.ts index b123dcf4cd8..59d24566fbb 100755 --- a/tools/generate-images.ts +++ b/tools/generate-images.ts @@ -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('