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.
-