* 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>tags/v1.21.12.1
| @@ -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}} | |||
| ``` | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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") | |||
| } | |||
| @@ -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) { | |||
| @@ -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")) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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: //") | |||
| } | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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> | |||