]> source.dussan.org Git - gitea.git/commitdiff
Use templates for issue e-mail subject and body (#8329)
authorguillep2k <18600385+guillep2k@users.noreply.github.com>
Thu, 7 Nov 2019 13:34:28 +0000 (10:34 -0300)
committerLunny Xiao <xiaolunwen@gmail.com>
Thu, 7 Nov 2019 13:34:28 +0000 (21:34 +0800)
* 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>
13 files changed:
docs/content/doc/advanced/mail-templates-us.md [new file with mode: 0644]
modules/templates/dynamic.go
modules/templates/helper.go
modules/templates/helper_test.go [new file with mode: 0644]
modules/templates/static.go
services/mailer/mail.go
services/mailer/mail_comment.go
services/mailer/mail_issue.go
services/mailer/mail_test.go
templates/mail/issue/assigned.tmpl
templates/mail/issue/comment.tmpl [deleted file]
templates/mail/issue/default.tmpl [new file with mode: 0644]
templates/mail/issue/mention.tmpl [deleted file]

diff --git a/docs/content/doc/advanced/mail-templates-us.md b/docs/content/doc/advanced/mail-templates-us.md
new file mode 100644 (file)
index 0000000..ffe2d4a
--- /dev/null
@@ -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}}
+```
index 6217f1c3b038f48ed42818e8a6c25be030f432af..6153e8d027334a10663320fe17d26dc5031bb3e8 100644 (file)
@@ -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
 }
index 2d7a1aee9b4f68ca2861c67fb311ada95bab307f..1347835b808ac2451119dd12b34186b12929f9b7 100644 (file)
@@ -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 (file)
index 0000000..e2997cb
--- /dev/null
@@ -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")
+}
index f7e53ce887ec1274f3a0fbc6f9142f6213fc80f9..435ccb1f956509a540e98207c774c9d8fb5ac3af 100644 (file)
@@ -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) {
index bc2aff7314f88eead055aadd9df90b340ff4e774..fc892f6076b1c1096ff1158f8d5241099d0ae121 100644 (file)
@@ -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"))
 }
index d306c14f42915d61fc4e6c3f442a390580c20db1..6469eb1fa1ea55ca4f196c460c87790c0adc2c44 100644 (file)
@@ -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
 }
index a5f3251807aaea712caf4c037cfd8c2fc6df5e92..32b21b132485635be181a0f6495804a0671df833 100644 (file)
@@ -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
 }
index c7a84d6b33e5f9198a4a92929f6a05cd8d1ba608..a10507e0e436e185b1c843b1427529033d88ad9e 100644 (file)
@@ -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: //")
+}
index ab06ade1f4cd5a91e60a1c3c8628e22fdf6717ed..997e2447fc25e22a914b87e96be61dd5f63dfa7f 100644 (file)
@@ -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 (file)
index cc86add..0000000
+++ /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 (file)
index 0000000..ee15d6d
--- /dev/null
@@ -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 (file)
index 032eea0..0000000
+++ /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>