* 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.11.0-rc1
--- | |||||
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}} | |||||
``` |
"io/ioutil" | "io/ioutil" | ||||
"path" | "path" | ||||
"strings" | "strings" | ||||
texttmpl "text/template" | |||||
"code.gitea.io/gitea/modules/log" | "code.gitea.io/gitea/modules/log" | ||||
"code.gitea.io/gitea/modules/setting" | "code.gitea.io/gitea/modules/setting" | ||||
) | ) | ||||
var ( | var ( | ||||
templates = template.New("") | |||||
subjectTemplates = texttmpl.New("") | |||||
bodyTemplates = template.New("") | |||||
) | ) | ||||
// HTMLRenderer implements the macaron handler for serving HTML templates. | // HTMLRenderer implements the macaron handler for serving HTML templates. | ||||
} | } | ||||
// Mailer provides the templates required for sending notification mails. | // 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() { | for _, funcs := range NewFuncMap() { | ||||
templates.Funcs(funcs) | |||||
bodyTemplates.Funcs(funcs) | |||||
} | } | ||||
staticDir := path.Join(setting.StaticRootPath, "templates", "mail") | staticDir := path.Join(setting.StaticRootPath, "templates", "mail") | ||||
continue | 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) | |||||
} | } | ||||
} | } | ||||
} | } | ||||
continue | 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 | |||||
} | } |
"mime" | "mime" | ||||
"net/url" | "net/url" | ||||
"path/filepath" | "path/filepath" | ||||
"regexp" | |||||
"runtime" | "runtime" | ||||
"strings" | "strings" | ||||
texttmpl "text/template" | |||||
"time" | "time" | ||||
"unicode" | "unicode" | ||||
"github.com/editorconfig/editorconfig-core-go/v2" | "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 | // NewFuncMap returns functions for injecting to templates | ||||
func NewFuncMap() []template.FuncMap { | func NewFuncMap() []template.FuncMap { | ||||
return []template.FuncMap{map[string]interface{}{ | return []template.FuncMap{map[string]interface{}{ | ||||
}} | }} | ||||
} | } | ||||
// 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 | // Safe render raw as HTML | ||||
func Safe(raw string) template.HTML { | func Safe(raw string) template.HTML { | ||||
return template.HTML(raw) | return template.HTML(raw) | ||||
return "fa-git-alt" | 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) | |||||
} | |||||
} |
// 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") | |||||
} |
"io/ioutil" | "io/ioutil" | ||||
"path" | "path" | ||||
"strings" | "strings" | ||||
texttmpl "text/template" | |||||
"code.gitea.io/gitea/modules/log" | "code.gitea.io/gitea/modules/log" | ||||
"code.gitea.io/gitea/modules/setting" | "code.gitea.io/gitea/modules/setting" | ||||
) | ) | ||||
var ( | var ( | ||||
templates = template.New("") | |||||
subjectTemplates = texttmpl.New("") | |||||
bodyTemplates = template.New("") | |||||
) | ) | ||||
type templateFileSystem struct { | type templateFileSystem struct { | ||||
} | } | ||||
// Mailer provides the templates required for sending notification mails. | // 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() { | for _, funcs := range NewFuncMap() { | ||||
templates.Funcs(funcs) | |||||
bodyTemplates.Funcs(funcs) | |||||
} | } | ||||
for _, assetPath := range AssetNames() { | for _, assetPath := range AssetNames() { | ||||
continue | continue | ||||
} | } | ||||
templates.New( | |||||
buildSubjectBodyTemplate(subjectTemplates, | |||||
bodyTemplates, | |||||
strings.TrimPrefix( | strings.TrimPrefix( | ||||
strings.TrimSuffix( | strings.TrimSuffix( | ||||
assetPath, | assetPath, | ||||
), | ), | ||||
"mail/", | "mail/", | ||||
), | ), | ||||
).Parse(string(content)) | |||||
content) | |||||
} | } | ||||
customDir := path.Join(setting.CustomPath, "templates", "mail") | customDir := path.Join(setting.CustomPath, "templates", "mail") | ||||
continue | continue | ||||
} | } | ||||
templates.New( | |||||
buildSubjectBodyTemplate(subjectTemplates, | |||||
bodyTemplates, | |||||
strings.TrimSuffix( | strings.TrimSuffix( | ||||
filePath, | filePath, | ||||
".tmpl", | ".tmpl", | ||||
), | ), | ||||
).Parse(string(content)) | |||||
content) | |||||
} | } | ||||
} | } | ||||
} | } | ||||
return templates | |||||
return subjectTemplates, bodyTemplates | |||||
} | } | ||||
func Asset(name string) ([]byte, error) { | func Asset(name string) ([]byte, error) { |
"bytes" | "bytes" | ||||
"fmt" | "fmt" | ||||
"html/template" | "html/template" | ||||
"mime" | |||||
"path" | "path" | ||||
"regexp" | |||||
"strings" | |||||
texttmpl "text/template" | |||||
"code.gitea.io/gitea/models" | "code.gitea.io/gitea/models" | ||||
"code.gitea.io/gitea/modules/base" | "code.gitea.io/gitea/modules/base" | ||||
mailAuthResetPassword base.TplName = "auth/reset_passwd" | mailAuthResetPassword base.TplName = "auth/reset_passwd" | ||||
mailAuthRegisterNotify base.TplName = "auth/register_notify" | 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" | 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 | // 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 | // SendTestMail sends a test mail | ||||
var content bytes.Buffer | 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) | log.Error("Template: %v", err) | ||||
return | return | ||||
} | } | ||||
var content bytes.Buffer | 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) | log.Error("Template: %v", err) | ||||
return | return | ||||
} | } | ||||
var content bytes.Buffer | 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) | log.Error("Template: %v", err) | ||||
return | return | ||||
} | } | ||||
var content bytes.Buffer | 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) | log.Error("Template: %v", err) | ||||
return | return | ||||
} | } | ||||
SendAsync(msg) | 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 { | if comment != nil { | ||||
subject = "Re: " + mailSubject(issue) | |||||
prefix = "Re: " | |||||
commentType = comment.Type | |||||
link = issue.HTMLURL() + "#" + comment.HashTag() | |||||
} else { | } 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())) | 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 { | } 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 | 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()) | msg := NewMessageFrom(tos, doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String()) | ||||
return msg | 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. | // 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 { | if len(tos) == 0 { | ||||
return | 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. | // 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 { | if len(tos) == 0 { | ||||
return | 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 | // SendIssueAssignedMail composes and sends issue assigned email | ||||
func SendIssueAssignedMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) { | 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")) | |||||
} | } |
for i, u := range userMentions { | for i, u := range userMentions { | ||||
mentions[i] = u.LowerName | 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 | return nil | ||||
} | } |
"github.com/unknwon/com" | "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) | return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index) | ||||
} | } | ||||
// This function sends two list of emails: | // This function sends two list of emails: | ||||
// 1. Repository watchers and users who are participated in comments. | // 1. Repository watchers and users who are participated in comments. | ||||
// 2. Users who are not in 1. but get mentioned in current issue/comment. | // 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) | watchers, err := models.GetWatchers(issue.RepoID) | ||||
if err != nil { | if err != nil { | ||||
} | } | ||||
for _, to := range tos { | 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. | // Mail mentioned people and exclude watchers. | ||||
emails := models.GetUserEmailsByNames(tos) | emails := models.GetUserEmailsByNames(tos) | ||||
for _, to := range emails { | for _, to := range emails { | ||||
SendIssueMentionMail(issue, doer, content, comment, []string{to}) | |||||
SendIssueMentionMail(issue, doer, actionType, content, comment, []string{to}) | |||||
} | } | ||||
return nil | return nil | ||||
for i, u := range userMentions { | for i, u := range userMentions { | ||||
mentions[i] = u.LowerName | 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 | return nil | ||||
} | } |
package mailer | package mailer | ||||
import ( | import ( | ||||
"bytes" | |||||
"html/template" | "html/template" | ||||
"testing" | "testing" | ||||
texttmpl "text/template" | |||||
"code.gitea.io/gitea/models" | "code.gitea.io/gitea/models" | ||||
"code.gitea.io/gitea/modules/setting" | "code.gitea.io/gitea/modules/setting" | ||||
"github.com/stretchr/testify/assert" | "github.com/stretchr/testify/assert" | ||||
) | ) | ||||
const tmpl = ` | |||||
const subjectTpl = ` | |||||
{{.SubjectPrefix}}[{{.Repo}}] @{{.Doer.Name}} #{{.Issue.Index}} - {{.Issue.Title}} | |||||
` | |||||
const bodyTpl = ` | |||||
<!DOCTYPE html> | <!DOCTYPE html> | ||||
<html> | <html> | ||||
<head> | <head> | ||||
issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) | 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) | 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"} | 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") | subject := msg.GetHeader("Subject") | ||||
inreplyTo := msg.GetHeader("In-Reply-To") | inreplyTo := msg.GetHeader("In-Reply-To") | ||||
references := msg.GetHeader("References") | 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, 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") | assert.Equal(t, references[0], "<user2/repo1/issues/1@localhost>", "References header doesn't match") | ||||
} | } | ||||
repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository) | 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) | 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"} | 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") | subject := msg.GetHeader("Subject") | ||||
messageID := msg.GetHeader("Message-ID") | 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("In-Reply-To")) | ||||
assert.Nil(t, msg.GetHeader("References")) | assert.Nil(t, msg.GetHeader("References")) | ||||
assert.Equal(t, messageID[0], "<user2/repo1/issues/1@localhost>", "Message-ID header doesn't match") | 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: //") | |||||
} |
</head> | </head> | ||||
<body> | <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> | <p> | ||||
--- | --- | ||||
<br> | <br> | ||||
<a href="{{.Link}}">View it on Gitea</a>. | |||||
<a href="{{.Link}}">View it on {{AppName}}</a>. | |||||
</p> | </p> | ||||
</body> | </body> |
<!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> |
<!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> |
<!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> |