diff options
author | guillep2k <18600385+guillep2k@users.noreply.github.com> | 2019-11-07 10:34:28 -0300 |
---|---|---|
committer | Lunny Xiao <xiaolunwen@gmail.com> | 2019-11-07 21:34:28 +0800 |
commit | 1f90147f3942065e2a7f564e8a3c97d23d41e6c0 (patch) | |
tree | fd5fb0bda2d84deb179bf99f9172b28f736ba9fe | |
parent | d5b1e6bc51f87eb1be07a4682798428bf4bbb9ce (diff) | |
download | gitea-1f90147f3942065e2a7f564e8a3c97d23d41e6c0.tar.gz gitea-1f90147f3942065e2a7f564e8a3c97d23d41e6c0.zip |
Use templates for issue e-mail subject and body (#8329)
* Add template capability for issue mail subject
* Remove test string
* Fix trim subject length
* Add comment to template and run make fmt
* Add information for the template
* Rename defaultMailSubject() to fallbackMailSubject()
* General rewrite of the mail template code
* Fix .Doer name
* Use text/template for subject instead of html
* Fix subject Re: prefix
* Fix mail tests
* Fix static templates
* [skip ci] Updated translations via Crowdin
* Expose db.SetMaxOpenConns and allow non MySQL dbs to set conn pool params (#8528)
* Expose db.SetMaxOpenConns and allow other dbs to set their connection params
* Add note about port exhaustion
Co-Authored-By: guillep2k <18600385+guillep2k@users.noreply.github.com>
* Prevent .code-view from overriding font on icon fonts (#8614)
* Correct some outdated statements in the contributing guidelines (#8612)
* More information for drone-cli in CONTRIBUTING.md
* Increases the version of drone-cli to 1.2.0
* Adds a note for the Docker Toolbox on Windows
Signed-off-by: LukBukkit <luk.bukkit@gmail.com>
* Fix the url for the blog repository (now on gitea.com)
Signed-off-by: LukBukkit <luk.bukkit@gmail.com>
* Remove TrN due to lack of lang context
* Redo templates to match previous code
* Fix extra character in template
* Unify PR & Issue tempaltes, fix format
* Remove default subject
* Add template tests
* Fix template
* Remove replaced function
* Provide User as models.User for better consistency
* Add docs
* Fix doc inaccuracies, improve examples
* Change mail footer to math AppName
* Add test for mail subject/body template separation
* Add support for code review comments
* Update docs/content/doc/advanced/mail-templates-us.md
Co-Authored-By: 6543 <24977596+6543@users.noreply.github.com>
-rw-r--r-- | docs/content/doc/advanced/mail-templates-us.md | 272 | ||||
-rw-r--r-- | modules/templates/dynamic.go | 33 | ||||
-rw-r--r-- | modules/templates/helper.go | 130 | ||||
-rw-r--r-- | modules/templates/helper_test.go | 55 | ||||
-rw-r--r-- | modules/templates/static.go | 23 | ||||
-rw-r--r-- | services/mailer/mail.go | 173 | ||||
-rw-r--r-- | services/mailer/mail_comment.go | 20 | ||||
-rw-r--r-- | services/mailer/mail_issue.go | 36 | ||||
-rw-r--r-- | services/mailer/mail_test.go | 131 | ||||
-rw-r--r-- | templates/mail/issue/assigned.tmpl | 4 | ||||
-rw-r--r-- | templates/mail/issue/comment.tmpl | 16 | ||||
-rw-r--r-- | templates/mail/issue/default.tmpl | 31 | ||||
-rw-r--r-- | templates/mail/issue/mention.tmpl | 17 |
13 files changed, 780 insertions, 161 deletions
diff --git a/docs/content/doc/advanced/mail-templates-us.md b/docs/content/doc/advanced/mail-templates-us.md new file mode 100644 index 0000000000..ffe2d4a27b --- /dev/null +++ b/docs/content/doc/advanced/mail-templates-us.md @@ -0,0 +1,272 @@ +--- +date: "2019-10-23T17:00:00-03:00" +title: "Mail templates" +slug: "mail-templates" +weight: 45 +toc: true +draft: false +menu: + sidebar: + parent: "advanced" + name: "Mail templates" + weight: 45 + identifier: "mail-templates" +--- + +# Mail templates + +To craft the e-mail subject and contents for certain operations, Gitea can be customized by using templates. The templates +for these functions are located under the [`custom` directory](https://docs.gitea.io/en-us/customizing-gitea/). +Gitea has an internal template that serves as default in case there's no custom alternative. + +Custom templates are loaded when Gitea starts. Changes made to them are not recognized until Gitea is restarted again. + +## Mail notifications supporting templates + +Currently, the following notification events make use of templates: + +| Action name | Usage | +|---------------|--------------------------------------------------------------------------------------------------------------| +| `new` | A new issue or pull request was created. | +| `comment` | A new comment was created in an existing issue or pull request. | +| `close` | An issue or pull request was closed. | +| `reopen` | An issue or pull request was reopened. | +| `review` | The head comment of a review in a pull request. | +| `code` | A single comment on the code of a pull request. | +| `assigned` | Used was assigned to an issue or pull request. | +| `default` | Any action not included in the above categories, or when the corresponding category template is not present. | + +The path for the template of a particular message type is: + +``` +custom/templates/mail/{action type}/{action name}.tmpl +``` + +Where `{action type}` is one of `issue` or `pull` (for pull requests), and `{action name}` is one of the names listed above. + +For example, the specific template for a mail regarding a comment in a pull request is: +``` +custom/templates/mail/pull/comment.tmpl +``` + +However, creating templates for each and every action type/name combination is not required. +A fallback system is used to choose the appropriate template for an event. The _first existing_ +template on this list is used: + +* The specific template for the desired **action type** and **action name**. +* The template for action type `issue` and the desired **action name**. +* The template for the desired **action type**, action name `default`. +* The template for action type `issue`, action name `default`. + +The only mandatory template is action type `issue`, action name `default`, which is already embedded in Gitea +unless it's overridden by the user in the `custom` directory. + +## Template syntax + +Mail templates are UTF-8 encoded text files that need to follow one of the following formats: + +``` +Text and macros for the subject line +------------ +Text and macros for the mail body +``` + +or + +``` +Text and macros for the mail body +``` + +Specifying a _subject_ section is optional (and therefore also the dash line separator). When used, the separator between +_subject_ and _mail body_ templates requires at least three dashes; no other characters are allowed in the separator line. + + +_Subject_ and _mail body_ are parsed by [Golang's template engine](https://golang.org/pkg/text/template/) and +are provided with a _metadata context_ assembled for each notification. The context contains the following elements: + +| Name | Type | Available | Usage | +|--------------------|----------------|---------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `.FallbackSubject` | string | Always | A default subject line. See Below. | +| `.Subject` | string | Only in body | The _subject_, once resolved. | +| `.Body` | string | Always | The message of the issue, pull request or comment, parsed from Markdown into HTML and sanitized. Do not confuse with the _mail body_ | +| `.Link` | string | Always | The address of the originating issue, pull request or comment. | +| `.Issue` | models.Issue | Always | The issue (or pull request) originating the notification. To get data specific to a pull request (e.g. `HasMerged`), `.Issue.PullRequest` can be used, but care should be taken as this field will be `nil` if the issue is *not* a pull request. | +| `.Comment` | models.Comment | If applicable | If the notification is from a comment added to an issue or pull request, this will contain the information about the comment. | +| `.IsPull` | bool | Always | `true` if the mail notification is associated with a pull request (i.e. `.Issue.PullRequest` is not `nil`). | +| `.Repo` | string | Always | Name of the repository, including owner name (e.g. `mike/stuff`) | +| `.User` | models.User | Always | Owner of the repository from which the event originated. To get the user name (e.g. `mike`),`.User.Name` can be used. | +| `.Doer` | models.User | Always | User that executed the action triggering the notification event. To get the user name (e.g. `rhonda`), `.Doer.Name` can be used. | +| `.IsMention` | bool | Always | `true` if this notification was only generated because the user was mentioned in the comment, while not being subscribed to the source. It will be `false` if the recipient was subscribed to the issue or repository. | +| `.SubjectPrefix` | string | Always | `Re: ` if the notification is about other than issue or pull request creation; otherwise an empty string. | +| `.ActionType` | string | Always | `"issue"` or `"pull"`. Will correspond to the actual _action type_ independently of which template was selected. | +| `.ActionName` | string | Always | It will be one of the action types described above (`new`, `comment`, etc.), and will correspond to the actual _action name_ independently of which template was selected. | + +All names are case sensitive. + +### The _subject_ part of the template + +The template engine used for the mail _subject_ is golang's [`text/template`](https://golang.org/pkg/text/template/). +Please refer to the linked documentation for details about its syntax. + +The _subject_ is built using the following steps: + +* A template is selected according to the type of notification and to what templates are present. +* The template is parsed and resolved (e.g. `{{.Issue.Index}}` is converted to the number of the issue + or pull request). +* All space-like characters (e.g. `TAB`, `LF`, etc.) are converted to normal spaces. +* All leading, trailing and redundant spaces are removed. +* The string is truncated to its first 256 runes (characters). + +If the end result is an empty string, **or** no subject template was available (i.e. the selected template +did not include a subject part), Gitea's **internal default** will be used. + +The internal default (fallback) subject is the equivalent of: + +``` +{{.SubjectPrefix}}[{{.Repo}}] {{.Issue.Title}} (#.Issue.Index) +``` + +For example: `Re: [mike/stuff] New color palette (#38)` + +Gitea's default subject can also be found in the template _metadata_ as `.FallbackSubject` from any of +the two templates, even if a valid subject template is present. + +### The _mail body_ part of the template + +The template engine used for the _mail body_ is golang's [`html/template`](https://golang.org/pkg/html/template/). +Please refer to the linked documentation for details about its syntax. + +The _mail body_ is parsed after the mail subject, so there is an additional _metadata_ field which is +the actual rendered subject, after all considerations. + +The expected result is HTML (including structural elements like`<html>`, `<body>`, etc.). Styling +through `<style>` blocks, `class` and `style` attributes is possible. However, `html/template` +does some [automatic escaping](https://golang.org/pkg/html/template/#hdr-Contexts) that should be considered. + +Attachments (such as images or external style sheets) are not supported. However, other templates can +be referenced too, for example to provide the contents of a `<style>` element in a centralized fashion. +The external template must be placed under `custom/mail` and referenced relative to that directory. +For example, `custom/mail/styles/base.tmpl` can be included using `{{template styles/base}}`. + +The mail is sent with `Content-Type: multipart/alternative`, so the body is sent in both HTML +and text formats. The latter is obtained by stripping the HTML markup. + +## Troubleshooting + +How a mail is rendered is directly dependent on the capabilities of the mail application. Many mail +clients don't even support HTML, so they show the text version included in the generated mail. + +If the template fails to render, it will be noticed only at the moment the mail is sent. +A default subject is used if the subject template fails, and whatever was rendered successfully +from the the _mail body_ is used, disregarding the rest. + +Please check [Gitea's logs](https://docs.gitea.io/en-us/logging-configuration/) for error messages in case of trouble. + +## Example + +`custom/templates/mail/issue/default.tmpl`: + +``` +[{{.Repo}}] @{{.Doer.Name}} +{{if eq .ActionName "new"}} + created +{{else if eq .ActionName "comment"}} + commented on +{{else if eq .ActionName "close"}} + closed +{{else if eq .ActionName "reopen"}} + reopened +{{else}} + updated +{{end}} +{{if eq .ActionType "issue"}} + issue +{{else}} + pull request +{{end}} +#{{.Issue.Index}}: {{.Issue.Title}} +------------ +<!DOCTYPE html> +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title>{{.Subject}}</title> +</head> + +<body> + {{if .IsMention}} + <p> + You are receiving this because @{{.Doer.Name}} mentioned you. + </p> + {{end}} + <p> + <p> + <a href="{{AppURL}}/{{.Doer.LowerName}}">@{{.Doer.Name}}</a> + {{if not (eq .Doer.FullName "")}} + ({{.Doer.FullName}}) + {{end}} + {{if eq .ActionName "new"}} + created + {{else if eq .ActionName "close"}} + closed + {{else if eq .ActionName "reopen"}} + reopened + {{else}} + updated + {{end}} + <a href="{{.Link}}">{{.Repo}}#{{.Issue.Index}}</a>. + </p> + {{if not (eq .Body "")}} + <h3>Message content:</h3> + <hr> + {{.Body | Str2html}} + {{end}} + </p> + <hr> + <p> + <a href="{{.Link}}">View it on Gitea</a>. + </p> +</body> +</html> +``` + +This template produces something along these lines: + +#### Subject + +> [mike/stuff] @rhonda commented on pull request #38: New color palette + +#### Mail body + +> [@rhonda](#) (Rhonda Myers) updated [mike/stuff#38](#). +> +> #### Message content: +> +> \__________________________________________________________________ +> +> Mike, I think we should tone down the blues a little. +> \__________________________________________________________________ +> +> [View it on Gitea](#). + +## Advanced + +The template system contains several functions that can be used to further process and format +the messages. Here's a list of some of them: + +| Name | Parameters | Available | Usage | +|----------------------|-------------|-----------|---------------------------------------------------------------------| +| `AppUrl` | - | Any | Gitea's URL | +| `AppName` | - | Any | Set from `app.ini`, usually "Gitea" | +| `AppDomain` | - | Any | Gitea's host name | +| `EllipsisString` | string, int | Any | Truncates a string to the specified length; adds ellipsis as needed | +| `Str2html` | string | Body only | Sanitizes text by removing any HTML tags from it. | + +These are _functions_, not metadata, so they have to be used: + +``` +Like this: {{Str2html "Escape<my>text"}} +Or this: {{"Escape<my>text" | Str2html}} +Or this: {{AppUrl}} +But not like this: {{.AppUrl}} +``` diff --git a/modules/templates/dynamic.go b/modules/templates/dynamic.go index 6217f1c3b0..6153e8d027 100644 --- a/modules/templates/dynamic.go +++ b/modules/templates/dynamic.go @@ -11,6 +11,7 @@ import ( "io/ioutil" "path" "strings" + texttmpl "text/template" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -20,7 +21,8 @@ import ( ) var ( - templates = template.New("") + subjectTemplates = texttmpl.New("") + bodyTemplates = template.New("") ) // HTMLRenderer implements the macaron handler for serving HTML templates. @@ -59,9 +61,12 @@ func JSRenderer() macaron.Handler { } // Mailer provides the templates required for sending notification mails. -func Mailer() *template.Template { +func Mailer() (*texttmpl.Template, *template.Template) { + for _, funcs := range NewTextFuncMap() { + subjectTemplates.Funcs(funcs) + } for _, funcs := range NewFuncMap() { - templates.Funcs(funcs) + bodyTemplates.Funcs(funcs) } staticDir := path.Join(setting.StaticRootPath, "templates", "mail") @@ -84,15 +89,7 @@ func Mailer() *template.Template { continue } - _, err = templates.New( - strings.TrimSuffix( - filePath, - ".tmpl", - ), - ).Parse(string(content)) - if err != nil { - log.Warn("Failed to parse template %v", err) - } + buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, strings.TrimSuffix(filePath, ".tmpl"), content) } } } @@ -117,18 +114,10 @@ func Mailer() *template.Template { continue } - _, err = templates.New( - strings.TrimSuffix( - filePath, - ".tmpl", - ), - ).Parse(string(content)) - if err != nil { - log.Warn("Failed to parse template %v", err) - } + buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, strings.TrimSuffix(filePath, ".tmpl"), content) } } } - return templates + return subjectTemplates, bodyTemplates } diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 2d7a1aee9b..1347835b80 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -16,8 +16,10 @@ import ( "mime" "net/url" "path/filepath" + "regexp" "runtime" "strings" + texttmpl "text/template" "time" "unicode" @@ -34,6 +36,9 @@ import ( "github.com/editorconfig/editorconfig-core-go/v2" ) +// Used from static.go && dynamic.go +var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}[\s]*$`) + // NewFuncMap returns functions for injecting to templates func NewFuncMap() []template.FuncMap { return []template.FuncMap{map[string]interface{}{ @@ -261,6 +266,112 @@ func NewFuncMap() []template.FuncMap { }} } +// NewTextFuncMap returns functions for injecting to text templates +// It's a subset of those used for HTML and other templates +func NewTextFuncMap() []texttmpl.FuncMap { + return []texttmpl.FuncMap{map[string]interface{}{ + "GoVer": func() string { + return strings.Title(runtime.Version()) + }, + "AppName": func() string { + return setting.AppName + }, + "AppSubUrl": func() string { + return setting.AppSubURL + }, + "AppUrl": func() string { + return setting.AppURL + }, + "AppVer": func() string { + return setting.AppVer + }, + "AppBuiltWith": func() string { + return setting.AppBuiltWith + }, + "AppDomain": func() string { + return setting.Domain + }, + "TimeSince": timeutil.TimeSince, + "TimeSinceUnix": timeutil.TimeSinceUnix, + "RawTimeSince": timeutil.RawTimeSince, + "DateFmtLong": func(t time.Time) string { + return t.Format(time.RFC1123Z) + }, + "DateFmtShort": func(t time.Time) string { + return t.Format("Jan 02, 2006") + }, + "List": List, + "SubStr": func(str string, start, length int) string { + if len(str) == 0 { + return "" + } + end := start + length + if length == -1 { + end = len(str) + } + if len(str) < end { + return str + } + return str[start:end] + }, + "EllipsisString": base.EllipsisString, + "URLJoin": util.URLJoin, + "Dict": func(values ...interface{}) (map[string]interface{}, error) { + if len(values)%2 != 0 { + return nil, errors.New("invalid dict call") + } + dict := make(map[string]interface{}, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, errors.New("dict keys must be strings") + } + dict[key] = values[i+1] + } + return dict, nil + }, + "Printf": fmt.Sprintf, + "Escape": Escape, + "Sec2Time": models.SecToTime, + "ParseDeadline": func(deadline string) []string { + return strings.Split(deadline, "|") + }, + "dict": func(values ...interface{}) (map[string]interface{}, error) { + if len(values) == 0 { + return nil, errors.New("invalid dict call") + } + + dict := make(map[string]interface{}) + + for i := 0; i < len(values); i++ { + switch key := values[i].(type) { + case string: + i++ + if i == len(values) { + return nil, errors.New("specify the key for non array values") + } + dict[key] = values[i] + case map[string]interface{}: + m := values[i].(map[string]interface{}) + for i, v := range m { + dict[i] = v + } + default: + return nil, errors.New("dict values must be maps") + } + } + return dict, nil + }, + "percentage": func(n int, values ...int) float32 { + var sum = 0 + for i := 0; i < len(values); i++ { + sum += values[i] + } + return float32(n) * 100 / float32(sum) + }, + }} +} + // Safe render raw as HTML func Safe(raw string) template.HTML { return template.HTML(raw) @@ -551,3 +662,22 @@ func MigrationIcon(hostname string) string { return "fa-git-alt" } } + +func buildSubjectBodyTemplate(stpl *texttmpl.Template, btpl *template.Template, name string, content []byte) { + // Split template into subject and body + var subjectContent []byte + bodyContent := content + loc := mailSubjectSplit.FindIndex(content) + if loc != nil { + subjectContent = content[0:loc[0]] + bodyContent = content[loc[1]:] + } + if _, err := stpl.New(name). + Parse(string(subjectContent)); err != nil { + log.Warn("Failed to parse template [%s/subject]: %v", name, err) + } + if _, err := btpl.New(name). + Parse(string(bodyContent)); err != nil { + log.Warn("Failed to parse template [%s/body]: %v", name, err) + } +} diff --git a/modules/templates/helper_test.go b/modules/templates/helper_test.go new file mode 100644 index 0000000000..e2997cb853 --- /dev/null +++ b/modules/templates/helper_test.go @@ -0,0 +1,55 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package templates + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSubjectBodySeparator(t *testing.T) { + test := func(input, subject, body string) { + loc := mailSubjectSplit.FindIndex([]byte(input)) + if loc == nil { + assert.Empty(t, subject, "no subject found, but one expected") + assert.Equal(t, body, input) + } else { + assert.Equal(t, subject, string(input[0:loc[0]])) + assert.Equal(t, body, string(input[loc[1]:])) + } + } + + test("Simple\n---------------\nCase", + "Simple\n", + "\nCase") + test("Only\nBody", + "", + "Only\nBody") + test("Minimal\n---\nseparator", + "Minimal\n", + "\nseparator") + test("False --- separator", + "", + "False --- separator") + test("False\n--- separator", + "", + "False\n--- separator") + test("False ---\nseparator", + "", + "False ---\nseparator") + test("With extra spaces\n----- \t \nBody", + "With extra spaces\n", + "\nBody") + test("With leading spaces\n -------\nOnly body", + "", + "With leading spaces\n -------\nOnly body") + test("Multiple\n---\n-------\n---\nSeparators", + "Multiple\n", + "\n-------\n---\nSeparators") + test("Insuficient\n--\nSeparators", + "", + "Insuficient\n--\nSeparators") +} diff --git a/modules/templates/static.go b/modules/templates/static.go index f7e53ce887..435ccb1f95 100644 --- a/modules/templates/static.go +++ b/modules/templates/static.go @@ -14,6 +14,7 @@ import ( "io/ioutil" "path" "strings" + texttmpl "text/template" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -23,7 +24,8 @@ import ( ) var ( - templates = template.New("") + subjectTemplates = texttmpl.New("") + bodyTemplates = template.New("") ) type templateFileSystem struct { @@ -140,9 +142,12 @@ func JSRenderer() macaron.Handler { } // Mailer provides the templates required for sending notification mails. -func Mailer() *template.Template { +func Mailer() (*texttmpl.Template, *template.Template) { + for _, funcs := range NewTextFuncMap() { + subjectTemplates.Funcs(funcs) + } for _, funcs := range NewFuncMap() { - templates.Funcs(funcs) + bodyTemplates.Funcs(funcs) } for _, assetPath := range AssetNames() { @@ -161,7 +166,8 @@ func Mailer() *template.Template { continue } - templates.New( + buildSubjectBodyTemplate(subjectTemplates, + bodyTemplates, strings.TrimPrefix( strings.TrimSuffix( assetPath, @@ -169,7 +175,7 @@ func Mailer() *template.Template { ), "mail/", ), - ).Parse(string(content)) + content) } customDir := path.Join(setting.CustomPath, "templates", "mail") @@ -192,17 +198,18 @@ func Mailer() *template.Template { continue } - templates.New( + buildSubjectBodyTemplate(subjectTemplates, + bodyTemplates, strings.TrimSuffix( filePath, ".tmpl", ), - ).Parse(string(content)) + content) } } } - return templates + return subjectTemplates, bodyTemplates } func Asset(name string) ([]byte, error) { diff --git a/services/mailer/mail.go b/services/mailer/mail.go index bc2aff7314..fc892f6076 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -9,7 +9,11 @@ import ( "bytes" "fmt" "html/template" + "mime" "path" + "regexp" + "strings" + texttmpl "text/template" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/base" @@ -28,18 +32,22 @@ const ( mailAuthResetPassword base.TplName = "auth/reset_passwd" mailAuthRegisterNotify base.TplName = "auth/register_notify" - mailIssueComment base.TplName = "issue/comment" - mailIssueMention base.TplName = "issue/mention" - mailIssueAssigned base.TplName = "issue/assigned" - mailNotifyCollaborator base.TplName = "notify/collaborator" + + // There's no actual limit for subject in RFC 5322 + mailMaxSubjectRunes = 256 ) -var templates *template.Template +var ( + bodyTemplates *template.Template + subjectTemplates *texttmpl.Template + subjectRemoveSpaces = regexp.MustCompile(`[\s]+`) +) // InitMailRender initializes the mail renderer -func InitMailRender(tmpls *template.Template) { - templates = tmpls +func InitMailRender(subjectTpl *texttmpl.Template, bodyTpl *template.Template) { + subjectTemplates = subjectTpl + bodyTemplates = bodyTpl } // SendTestMail sends a test mail @@ -58,7 +66,7 @@ func SendUserMail(language string, u *models.User, tpl base.TplName, code, subje var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(tpl), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil { log.Error("Template: %v", err) return } @@ -96,7 +104,7 @@ func SendActivateEmailMail(locale Locale, u *models.User, email *models.EmailAdd var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil { log.Error("Template: %v", err) return } @@ -121,7 +129,7 @@ func SendRegisterNotifyMail(locale Locale, u *models.User) { var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil { log.Error("Template: %v", err) return } @@ -145,7 +153,7 @@ func SendCollaboratorMail(u, doer *models.User, repo *models.Repository) { var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil { log.Error("Template: %v", err) return } @@ -156,40 +164,70 @@ func SendCollaboratorMail(u, doer *models.User, repo *models.Repository) { SendAsync(msg) } -func composeTplData(subject, body, link string) map[string]interface{} { - data := make(map[string]interface{}, 10) - data["Subject"] = subject - data["Body"] = body - data["Link"] = link - return data -} +func composeIssueCommentMessage(issue *models.Issue, doer *models.User, actionType models.ActionType, fromMention bool, + content string, comment *models.Comment, tos []string, info string) *Message { + + if err := issue.LoadPullRequest(); err != nil { + log.Error("LoadPullRequest: %v", err) + return nil + } + + var ( + subject string + link string + prefix string + // Fall back subject for bad templates, make sure subject is never empty + fallback string + ) -func composeIssueCommentMessage(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tplName base.TplName, tos []string, info string) *Message { - var subject string + commentType := models.CommentTypeComment if comment != nil { - subject = "Re: " + mailSubject(issue) + prefix = "Re: " + commentType = comment.Type + link = issue.HTMLURL() + "#" + comment.HashTag() } else { - subject = mailSubject(issue) - } - err := issue.LoadRepo() - if err != nil { - log.Error("LoadRepo: %v", err) + link = issue.HTMLURL() } + + fallback = prefix + fallbackMailSubject(issue) + + // This is the body of the new issue or comment, not the mail body body := string(markup.RenderByType(markdown.MarkupName, []byte(content), issue.Repo.HTMLURL(), issue.Repo.ComposeMetas())) - var data = make(map[string]interface{}, 10) - if comment != nil { - data = composeTplData(subject, body, issue.HTMLURL()+"#"+comment.HashTag()) + actType, actName, tplName := actionToTemplate(issue, actionType, commentType) + + mailMeta := map[string]interface{}{ + "FallbackSubject": fallback, + "Body": body, + "Link": link, + "Issue": issue, + "Comment": comment, + "IsPull": issue.IsPull, + "User": issue.Repo.MustOwner(), + "Repo": issue.Repo.FullName(), + "Doer": doer, + "IsMention": fromMention, + "SubjectPrefix": prefix, + "ActionType": actType, + "ActionName": actName, + } + + var mailSubject bytes.Buffer + if err := subjectTemplates.ExecuteTemplate(&mailSubject, string(tplName), mailMeta); err == nil { + subject = sanitizeSubject(mailSubject.String()) } else { - data = composeTplData(subject, body, issue.HTMLURL()) + log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/subject", err) + } + + if subject == "" { + subject = fallback } - data["Doer"] = doer - data["Issue"] = issue + mailMeta["Subject"] = subject var mailBody bytes.Buffer - if err := templates.ExecuteTemplate(&mailBody, string(tplName), data); err != nil { - log.Error("Template: %v", err) + if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplName), mailMeta); err != nil { + log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/body", err) } msg := NewMessageFrom(tos, doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String()) @@ -206,24 +244,81 @@ func composeIssueCommentMessage(issue *models.Issue, doer *models.User, content return msg } +func sanitizeSubject(subject string) string { + runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " "))) + if len(runes) > mailMaxSubjectRunes { + runes = runes[:mailMaxSubjectRunes] + } + // Encode non-ASCII characters + return mime.QEncoding.Encode("utf-8", string(runes)) +} + // SendIssueCommentMail composes and sends issue comment emails to target receivers. -func SendIssueCommentMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) { +func SendIssueCommentMail(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, tos []string) { if len(tos) == 0 { return } - SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueComment, tos, "issue comment")) + SendAsync(composeIssueCommentMessage(issue, doer, actionType, false, content, comment, tos, "issue comment")) } // SendIssueMentionMail composes and sends issue mention emails to target receivers. -func SendIssueMentionMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) { +func SendIssueMentionMail(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, tos []string) { if len(tos) == 0 { return } - SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueMention, tos, "issue mention")) + SendAsync(composeIssueCommentMessage(issue, doer, actionType, true, content, comment, tos, "issue mention")) +} + +// actionToTemplate returns the type and name of the action facing the user +// (slightly different from models.ActionType) and the name of the template to use (based on availability) +func actionToTemplate(issue *models.Issue, actionType models.ActionType, commentType models.CommentType) (typeName, name, template string) { + if issue.IsPull { + typeName = "pull" + } else { + typeName = "issue" + } + switch actionType { + case models.ActionCreateIssue, models.ActionCreatePullRequest: + name = "new" + case models.ActionCommentIssue: + name = "comment" + case models.ActionCloseIssue, models.ActionClosePullRequest: + name = "close" + case models.ActionReopenIssue, models.ActionReopenPullRequest: + name = "reopen" + case models.ActionMergePullRequest: + name = "merge" + default: + switch commentType { + case models.CommentTypeReview: + name = "review" + case models.CommentTypeCode: + name = "code" + case models.CommentTypeAssignees: + name = "assigned" + default: + name = "default" + } + } + + template = typeName + "/" + name + ok := bodyTemplates.Lookup(template) != nil + if !ok && typeName != "issue" { + template = "issue/" + name + ok = bodyTemplates.Lookup(template) != nil + } + if !ok { + template = typeName + "/default" + ok = bodyTemplates.Lookup(template) != nil + } + if !ok { + template = "issue/default" + } + return } // SendIssueAssignedMail composes and sends issue assigned email func SendIssueAssignedMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) { - SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueAssigned, tos, "issue assigned")) + SendAsync(composeIssueCommentMessage(issue, doer, models.ActionType(0), false, content, comment, tos, "issue assigned")) } diff --git a/services/mailer/mail_comment.go b/services/mailer/mail_comment.go index d306c14f42..6469eb1fa1 100644 --- a/services/mailer/mail_comment.go +++ b/services/mailer/mail_comment.go @@ -31,24 +31,8 @@ func mailParticipantsComment(ctx models.DBContext, c *models.Comment, opType mod for i, u := range userMentions { mentions[i] = u.LowerName } - if len(c.Content) > 0 { - if err = mailIssueCommentToParticipants(issue, c.Poster, c.Content, c, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } + if err = mailIssueCommentToParticipants(issue, c.Poster, opType, c.Content, c, mentions); err != nil { + log.Error("mailIssueCommentToParticipants: %v", err) } - - switch opType { - case models.ActionCloseIssue: - ct := fmt.Sprintf("Closed #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, c.Poster, ct, c, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - case models.ActionReopenIssue: - ct := fmt.Sprintf("Reopened #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, c.Poster, ct, c, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - } - return nil } diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go index a5f3251807..32b21b1324 100644 --- a/services/mailer/mail_issue.go +++ b/services/mailer/mail_issue.go @@ -14,7 +14,7 @@ import ( "github.com/unknwon/com" ) -func mailSubject(issue *models.Issue) string { +func fallbackMailSubject(issue *models.Issue) string { return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index) } @@ -22,7 +22,7 @@ func mailSubject(issue *models.Issue) string { // This function sends two list of emails: // 1. Repository watchers and users who are participated in comments. // 2. Users who are not in 1. but get mentioned in current issue/comment. -func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, content string, comment *models.Comment, mentions []string) error { +func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, mentions []string) error { watchers, err := models.GetWatchers(issue.RepoID) if err != nil { @@ -89,7 +89,7 @@ func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, cont } for _, to := range tos { - SendIssueCommentMail(issue, doer, content, comment, []string{to}) + SendIssueCommentMail(issue, doer, actionType, content, comment, []string{to}) } // Mail mentioned people and exclude watchers. @@ -106,7 +106,7 @@ func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, cont emails := models.GetUserEmailsByNames(tos) for _, to := range emails { - SendIssueMentionMail(issue, doer, content, comment, []string{to}) + SendIssueMentionMail(issue, doer, actionType, content, comment, []string{to}) } return nil @@ -131,32 +131,8 @@ func mailParticipants(ctx models.DBContext, issue *models.Issue, doer *models.Us for i, u := range userMentions { mentions[i] = u.LowerName } - - if len(issue.Content) > 0 { - if err = mailIssueCommentToParticipants(issue, doer, issue.Content, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - } - - switch opType { - case models.ActionCreateIssue, models.ActionCreatePullRequest: - if len(issue.Content) == 0 { - ct := fmt.Sprintf("Created #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - } - case models.ActionCloseIssue, models.ActionClosePullRequest: - ct := fmt.Sprintf("Closed #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - case models.ActionReopenIssue, models.ActionReopenPullRequest: - ct := fmt.Sprintf("Reopened #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } + if err = mailIssueCommentToParticipants(issue, doer, opType, issue.Content, nil, mentions); err != nil { + log.Error("mailIssueCommentToParticipants: %v", err) } - return nil } diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index c7a84d6b33..a10507e0e4 100644 --- a/services/mailer/mail_test.go +++ b/services/mailer/mail_test.go @@ -5,8 +5,10 @@ package mailer import ( + "bytes" "html/template" "testing" + texttmpl "text/template" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/setting" @@ -14,7 +16,11 @@ import ( "github.com/stretchr/testify/assert" ) -const tmpl = ` +const subjectTpl = ` +{{.SubjectPrefix}}[{{.Repo}}] @{{.Doer.Name}} #{{.Issue.Index}} - {{.Issue.Title}} +` + +const bodyTpl = ` <!DOCTYPE html> <html> <head> @@ -47,17 +53,19 @@ func TestComposeIssueCommentMessage(t *testing.T) { issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment) - email := template.Must(template.New("issue/comment").Parse(tmpl)) - InitMailRender(email) + stpl := texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl)) + btpl := template.Must(template.New("issue/comment").Parse(bodyTpl)) + InitMailRender(stpl, btpl) tos := []string{"test@gitea.com", "test2@gitea.com"} - msg := composeIssueCommentMessage(issue, doer, "test body", comment, mailIssueComment, tos, "issue comment") + msg := composeIssueCommentMessage(issue, doer, models.ActionCommentIssue, false, "test body", comment, tos, "issue comment") subject := msg.GetHeader("Subject") inreplyTo := msg.GetHeader("In-Reply-To") references := msg.GetHeader("References") - assert.Equal(t, subject[0], "Re: "+mailSubject(issue), "Comment reply subject should contain Re:") + assert.Equal(t, "Re: ", subject[0][:4], "Comment reply subject should contain Re:") + assert.Equal(t, "Re: [user2/repo1] @user2 #1 - issue1", subject[0]) assert.Equal(t, inreplyTo[0], "<user2/repo1/issues/1@localhost>", "In-Reply-To header doesn't match") assert.Equal(t, references[0], "<user2/repo1/issues/1@localhost>", "References header doesn't match") } @@ -75,17 +83,122 @@ func TestComposeIssueMessage(t *testing.T) { repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository) issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) - email := template.Must(template.New("issue/comment").Parse(tmpl)) - InitMailRender(email) + stpl := texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl)) + btpl := template.Must(template.New("issue/new").Parse(bodyTpl)) + InitMailRender(stpl, btpl) tos := []string{"test@gitea.com", "test2@gitea.com"} - msg := composeIssueCommentMessage(issue, doer, "test body", nil, mailIssueComment, tos, "issue create") + msg := composeIssueCommentMessage(issue, doer, models.ActionCreateIssue, false, "test body", nil, tos, "issue create") subject := msg.GetHeader("Subject") messageID := msg.GetHeader("Message-ID") - assert.Equal(t, subject[0], mailSubject(issue), "Subject not equal to issue.mailSubject()") + assert.Equal(t, "[user2/repo1] @user2 #1 - issue1", subject[0]) assert.Nil(t, msg.GetHeader("In-Reply-To")) assert.Nil(t, msg.GetHeader("References")) assert.Equal(t, messageID[0], "<user2/repo1/issues/1@localhost>", "Message-ID header doesn't match") } + +func TestTemplateSelection(t *testing.T) { + assert.NoError(t, models.PrepareTestDatabase()) + var mailService = setting.Mailer{ + From: "test@gitea.com", + } + + setting.MailService = &mailService + setting.Domain = "localhost" + + doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository) + issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) + tos := []string{"test@gitea.com"} + + stpl := texttmpl.Must(texttmpl.New("issue/default").Parse("issue/default/subject")) + texttmpl.Must(stpl.New("issue/new").Parse("issue/new/subject")) + texttmpl.Must(stpl.New("pull/comment").Parse("pull/comment/subject")) + texttmpl.Must(stpl.New("issue/close").Parse("")) // Must default to fallback subject + + btpl := template.Must(template.New("issue/default").Parse("issue/default/body")) + template.Must(btpl.New("issue/new").Parse("issue/new/body")) + template.Must(btpl.New("pull/comment").Parse("pull/comment/body")) + template.Must(btpl.New("issue/close").Parse("issue/close/body")) + + InitMailRender(stpl, btpl) + + expect := func(t *testing.T, msg *Message, expSubject, expBody string) { + subject := msg.GetHeader("Subject") + msgbuf := new(bytes.Buffer) + _, _ = msg.WriteTo(msgbuf) + wholemsg := msgbuf.String() + assert.Equal(t, []string{expSubject}, subject) + assert.Contains(t, wholemsg, expBody) + } + + msg := composeIssueCommentMessage(issue, doer, models.ActionCreateIssue, false, "test body", nil, tos, "TestTemplateSelection") + expect(t, msg, "issue/new/subject", "issue/new/body") + + comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment) + msg = composeIssueCommentMessage(issue, doer, models.ActionCommentIssue, false, "test body", comment, tos, "TestTemplateSelection") + expect(t, msg, "issue/default/subject", "issue/default/body") + + pull := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 2, Repo: repo, Poster: doer}).(*models.Issue) + comment = models.AssertExistsAndLoadBean(t, &models.Comment{ID: 4, Issue: pull}).(*models.Comment) + msg = composeIssueCommentMessage(pull, doer, models.ActionCommentIssue, false, "test body", comment, tos, "TestTemplateSelection") + expect(t, msg, "pull/comment/subject", "pull/comment/body") + + msg = composeIssueCommentMessage(issue, doer, models.ActionCloseIssue, false, "test body", nil, tos, "TestTemplateSelection") + expect(t, msg, "[user2/repo1] issue1 (#1)", "issue/close/body") +} + +func TestTemplateServices(t *testing.T) { + assert.NoError(t, models.PrepareTestDatabase()) + var mailService = setting.Mailer{ + From: "test@gitea.com", + } + + setting.MailService = &mailService + setting.Domain = "localhost" + + doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository) + issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) + comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment) + assert.NoError(t, issue.LoadRepo()) + + expect := func(t *testing.T, issue *models.Issue, comment *models.Comment, doer *models.User, + actionType models.ActionType, fromMention bool, tplSubject, tplBody, expSubject, expBody string) { + + stpl := texttmpl.Must(texttmpl.New("issue/default").Parse(tplSubject)) + btpl := template.Must(template.New("issue/default").Parse(tplBody)) + InitMailRender(stpl, btpl) + + tos := []string{"test@gitea.com"} + msg := composeIssueCommentMessage(issue, doer, actionType, fromMention, "test body", comment, tos, "TestTemplateServices") + + subject := msg.GetHeader("Subject") + msgbuf := new(bytes.Buffer) + _, _ = msg.WriteTo(msgbuf) + wholemsg := msgbuf.String() + + assert.Equal(t, []string{expSubject}, subject) + assert.Contains(t, wholemsg, "\r\n"+expBody+"\r\n") + } + + expect(t, issue, comment, doer, models.ActionCommentIssue, false, + "{{.SubjectPrefix}}[{{.Repo}}]: @{{.Doer.Name}} commented on #{{.Issue.Index}} - {{.Issue.Title}}", + "//{{.ActionType}},{{.ActionName}},{{if .IsMention}}norender{{end}}//", + "Re: [user2/repo1]: @user2 commented on #1 - issue1", + "//issue,comment,//") + + expect(t, issue, comment, doer, models.ActionCommentIssue, true, + "{{if .IsMention}}must render{{end}}", + "//subject is: {{.Subject}}//", + "must render", + "//subject is: must render//") + + expect(t, issue, comment, doer, models.ActionCommentIssue, true, + "{{.FallbackSubject}}", + "//{{.SubjectPrefix}}//", + "Re: [user2/repo1] issue1 (#1)", + "//Re: //") +} diff --git a/templates/mail/issue/assigned.tmpl b/templates/mail/issue/assigned.tmpl index ab06ade1f4..997e2447fc 100644 --- a/templates/mail/issue/assigned.tmpl +++ b/templates/mail/issue/assigned.tmpl @@ -6,11 +6,11 @@ </head> <body> - <p>@{{.Doer.Name}} assigned you to the {{if eq .Issue.IsPull true}}pull request{{else}}issue{{end}} <a href="{{.Link}}">#{{.Issue.Index}}</a> in repository {{.Issue.Repo.FullName}}.</p> + <p>@{{.Doer.Name}} assigned you to the {{if .IsPull}}pull request{{else}}issue{{end}} <a href="{{.Link}}">#{{.Issue.Index}}</a> in repository {{.Repo}}.</p> <p> --- <br> - <a href="{{.Link}}">View it on Gitea</a>. + <a href="{{.Link}}">View it on {{AppName}}</a>. </p> </body> diff --git a/templates/mail/issue/comment.tmpl b/templates/mail/issue/comment.tmpl deleted file mode 100644 index cc86addaf0..0000000000 --- a/templates/mail/issue/comment.tmpl +++ /dev/null @@ -1,16 +0,0 @@ -<!DOCTYPE html> -<html> -<head> - <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> - <title>{{.Subject}}</title> -</head> - -<body> - <p>{{.Body | Str2html}}</p> - <p> - --- - <br> - <a href="{{.Link}}">View it on Gitea</a>. - </p> -</body> -</html> diff --git a/templates/mail/issue/default.tmpl b/templates/mail/issue/default.tmpl new file mode 100644 index 0000000000..ee15d6d8e1 --- /dev/null +++ b/templates/mail/issue/default.tmpl @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title>{{.Subject}}</title> +</head> + +<body> + {{if .IsMention}}<p>@{{.Doer.Name}} mentioned you:</p>{{end}} + <p> + {{- if eq .Body ""}} + {{if eq .ActionName "new"}} + Created #{{.Issue.Index}}. + {{else if eq .ActionName "close"}} + Closed #{{.Issue.Index}}. + {{else if eq .ActionName "reopen"}} + Reopened #{{.Issue.Index}}. + {{else}} + Empty comment on #{{.Issue.Index}}. + {{end}} + {{else}} + {{.Body | Str2html}} + {{end -}} + </p> + <p> + --- + <br> + <a href="{{.Link}}">View it on {{AppName}}</a>. + </p> +</body> +</html> diff --git a/templates/mail/issue/mention.tmpl b/templates/mail/issue/mention.tmpl deleted file mode 100644 index 032eea053d..0000000000 --- a/templates/mail/issue/mention.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -<!DOCTYPE html> -<html> -<head> - <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> - <title>{{.Subject}}</title> -</head> - -<body> - <p>@{{.Doer.Name}} mentioned you:</p> - <p>{{.Body | Str2html}}</p> - <p> - --- - <br> - <a href="{{.Link}}">View it on Gitea</a>. - </p> -</body> -</html> |