Use templates for issue e-mail subject and body (#8329)
* Add template capability for issue mail subject * Remove test string * Fix trim subject length * Add comment to template and run make fmt * Add information for the template * Rename defaultMailSubject() to fallbackMailSubject() * General rewrite of the mail template code * Fix .Doer name * Use text/template for subject instead of html * Fix subject Re: prefix * Fix mail tests * Fix static templates * [skip ci] Updated translations via Crowdin * Expose db.SetMaxOpenConns and allow non MySQL dbs to set conn pool params (#8528) * Expose db.SetMaxOpenConns and allow other dbs to set their connection params * Add note about port exhaustion Co-Authored-By: guillep2k <18600385+guillep2k@users.noreply.github.com> * Prevent .code-view from overriding font on icon fonts (#8614) * Correct some outdated statements in the contributing guidelines (#8612) * More information for drone-cli in CONTRIBUTING.md * Increases the version of drone-cli to 1.2.0 * Adds a note for the Docker Toolbox on Windows Signed-off-by: LukBukkit <luk.bukkit@gmail.com> * Fix the url for the blog repository (now on gitea.com) Signed-off-by: LukBukkit <luk.bukkit@gmail.com> * Remove TrN due to lack of lang context * Redo templates to match previous code * Fix extra character in template * Unify PR & Issue tempaltes, fix format * Remove default subject * Add template tests * Fix template * Remove replaced function * Provide User as models.User for better consistency * Add docs * Fix doc inaccuracies, improve examples * Change mail footer to math AppName * Add test for mail subject/body template separation * Add support for code review comments * Update docs/content/doc/advanced/mail-templates-us.md Co-Authored-By: 6543 <24977596+6543@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									d5b1e6bc51
								
							
						
					
					
						commit
						1f90147f39
					
				
					 13 changed files with 781 additions and 162 deletions
				
			
		
							
								
								
									
										272
									
								
								docs/content/doc/advanced/mail-templates-us.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										272
									
								
								docs/content/doc/advanced/mail-templates-us.md
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,272 @@ | ||||||
|  | --- | ||||||
|  | date: "2019-10-23T17:00:00-03:00" | ||||||
|  | title: "Mail templates" | ||||||
|  | slug: "mail-templates" | ||||||
|  | weight: 45 | ||||||
|  | toc: true | ||||||
|  | draft: false | ||||||
|  | menu: | ||||||
|  |   sidebar: | ||||||
|  |     parent: "advanced" | ||||||
|  |     name: "Mail templates" | ||||||
|  |     weight: 45 | ||||||
|  |     identifier: "mail-templates" | ||||||
|  | --- | ||||||
|  | 
 | ||||||
|  | # Mail templates | ||||||
|  | 
 | ||||||
|  | To craft the e-mail subject and contents for certain operations, Gitea can be customized by using templates. The templates | ||||||
|  | for these functions are located under the [`custom` directory](https://docs.gitea.io/en-us/customizing-gitea/). | ||||||
|  | Gitea has an internal template that serves as default in case there's no custom alternative. | ||||||
|  | 
 | ||||||
|  | Custom templates are loaded when Gitea starts. Changes made to them are not recognized until Gitea is restarted again. | ||||||
|  | 
 | ||||||
|  | ## Mail notifications supporting templates | ||||||
|  | 
 | ||||||
|  | Currently, the following notification events make use of templates: | ||||||
|  | 
 | ||||||
|  | | Action name   | Usage                                                                                                        | | ||||||
|  | |---------------|--------------------------------------------------------------------------------------------------------------| | ||||||
|  | | `new`         | A new issue or pull request was created.                                                                     | | ||||||
|  | | `comment`     | A new comment was created in an existing issue or pull request.                                              | | ||||||
|  | | `close`       | An issue or pull request was closed.                                                                         | | ||||||
|  | | `reopen`      | An issue or pull request was reopened.                                                                       | | ||||||
|  | | `review`      | The head comment of a review in a pull request.                                                              | | ||||||
|  | | `code`        | A single comment on the code of a pull request.                                                              | | ||||||
|  | | `assigned`    | Used was assigned to an issue or pull request.                                                               | | ||||||
|  | | `default`     | Any action not included in the above categories, or when the corresponding category template is not present. | | ||||||
|  | 
 | ||||||
|  | The path for the template of a particular message type is: | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | custom/templates/mail/{action type}/{action name}.tmpl | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | Where `{action type}` is one of `issue` or `pull` (for pull requests), and `{action name}` is one of the names listed above. | ||||||
|  | 
 | ||||||
|  | For example, the specific template for a mail regarding a comment in a pull request is: | ||||||
|  | ``` | ||||||
|  | custom/templates/mail/pull/comment.tmpl | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | However, creating templates for each and every action type/name combination is not required. | ||||||
|  | A fallback system is used to choose the appropriate template for an event. The _first existing_ | ||||||
|  | template on this list is used: | ||||||
|  | 
 | ||||||
|  | * The specific template for the desired **action type** and **action name**. | ||||||
|  | * The template for action type `issue` and the desired **action name**. | ||||||
|  | * The template for the desired **action type**, action name `default`. | ||||||
|  | * The template for action type `issue`, action name `default`. | ||||||
|  | 
 | ||||||
|  | The only mandatory template is action type `issue`, action name `default`, which is already embedded in Gitea | ||||||
|  | unless it's overridden by the user in the `custom` directory. | ||||||
|  | 
 | ||||||
|  | ## Template syntax | ||||||
|  | 
 | ||||||
|  | Mail templates are UTF-8 encoded text files that need to follow one of the following formats: | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | Text and macros for the subject line | ||||||
|  | ------------ | ||||||
|  | Text and macros for the mail body | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | or | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | Text and macros for the mail body | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | Specifying a _subject_ section is optional (and therefore also the dash line separator). When used, the separator between | ||||||
|  | _subject_ and _mail body_ templates requires at least three dashes; no other characters are allowed in the separator line. | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | _Subject_ and _mail body_ are parsed by [Golang's template engine](https://golang.org/pkg/text/template/) and | ||||||
|  | are provided with a _metadata context_ assembled for each notification. The context contains the following elements: | ||||||
|  | 
 | ||||||
|  | | Name               | Type           | Available     | Usage                                                                                                                                                                                                                                             | | ||||||
|  | |--------------------|----------------|---------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ||||||
|  | | `.FallbackSubject` | string         | Always        | A default subject line. See Below.                                                                                                                                                                                                                | | ||||||
|  | | `.Subject`         | string         | Only in body  | The _subject_, once resolved.                                                                                                                                                                                                                     | | ||||||
|  | | `.Body`            | string         | Always        | The message of the issue, pull request or comment, parsed from Markdown into HTML and sanitized. Do not confuse with the _mail body_                                                                                                              | | ||||||
|  | | `.Link`            | string         | Always        | The address of the originating issue, pull request or comment.                                                                                                                                                                                    | | ||||||
|  | | `.Issue`           | models.Issue   | Always        | The issue (or pull request) originating the notification. To get data specific to a pull request (e.g. `HasMerged`), `.Issue.PullRequest` can be used, but care should be taken as this field will be `nil` if the issue is *not* a pull request. | | ||||||
|  | | `.Comment`         | models.Comment | If applicable | If the notification is from a comment added to an issue or pull request, this will contain the information about the comment.                                                                                                                     | | ||||||
|  | | `.IsPull`          | bool           | Always        | `true` if the mail notification is associated with a pull request (i.e. `.Issue.PullRequest` is not `nil`).                                                                                                                                       | | ||||||
|  | | `.Repo`            | string         | Always        | Name of the repository, including owner name (e.g. `mike/stuff`)                                                                                                                                                                                  | | ||||||
|  | | `.User`            | models.User    | Always        | Owner of the repository from which the event originated. To get the user name (e.g. `mike`),`.User.Name` can be used.                                                                                                                             | | ||||||
|  | | `.Doer`            | models.User    | Always        | User that executed the action triggering the notification event. To get the user name (e.g. `rhonda`), `.Doer.Name` can be used.                                                                                                                  | | ||||||
|  | | `.IsMention`       | bool           | Always        | `true` if this notification was only generated because the user was mentioned in the comment, while not being subscribed to the source. It will be `false` if the recipient was subscribed to the issue or repository.                            | | ||||||
|  | | `.SubjectPrefix`   | string         | Always        | `Re: ` if the notification is about other than issue or pull request creation; otherwise an empty string.                                                                                                                                         | | ||||||
|  | | `.ActionType`      | string         | Always        | `"issue"` or `"pull"`. Will correspond to the actual _action type_ independently of which template was selected.                                                                                                                                  | | ||||||
|  | | `.ActionName`      | string         | Always        | It will be one of the action types described above (`new`, `comment`, etc.), and will correspond to the actual _action name_ independently of which template was selected.                                                                        | | ||||||
|  | 
 | ||||||
|  | All names are case sensitive. | ||||||
|  | 
 | ||||||
|  | ### The _subject_ part of the template | ||||||
|  | 
 | ||||||
|  | The template engine used for the mail _subject_ is golang's [`text/template`](https://golang.org/pkg/text/template/). | ||||||
|  | Please refer to the linked documentation for details about its syntax. | ||||||
|  | 
 | ||||||
|  | The _subject_ is built using the following steps: | ||||||
|  | 
 | ||||||
|  | * A template is selected according to the type of notification and to what templates are present. | ||||||
|  | * The template is parsed and resolved (e.g. `{{.Issue.Index}}` is converted to the number of the issue | ||||||
|  |   or pull request). | ||||||
|  | * All space-like characters (e.g. `TAB`, `LF`, etc.) are converted to normal spaces. | ||||||
|  | * All leading, trailing and redundant spaces are removed. | ||||||
|  | * The string is truncated to its first 256 runes (characters). | ||||||
|  | 
 | ||||||
|  | If the end result is an empty string, **or** no subject template was available (i.e. the selected template | ||||||
|  | did not include a subject part), Gitea's **internal default** will be used. | ||||||
|  | 
 | ||||||
|  | The internal default (fallback) subject is the equivalent of: | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | {{.SubjectPrefix}}[{{.Repo}}] {{.Issue.Title}} (#.Issue.Index) | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | For example: `Re: [mike/stuff] New color palette (#38)` | ||||||
|  | 
 | ||||||
|  | Gitea's default subject can also be found in the template _metadata_ as `.FallbackSubject` from any of | ||||||
|  | the two templates, even if a valid subject template is present. | ||||||
|  | 
 | ||||||
|  | ### The _mail body_ part of the template | ||||||
|  | 
 | ||||||
|  | The template engine used for the _mail body_ is golang's [`html/template`](https://golang.org/pkg/html/template/). | ||||||
|  | Please refer to the linked documentation for details about its syntax. | ||||||
|  | 
 | ||||||
|  | The _mail body_ is parsed after the mail subject, so there is an additional _metadata_ field which is | ||||||
|  | the actual rendered subject, after all considerations. | ||||||
|  | 
 | ||||||
|  | The expected result is HTML (including structural elements like`<html>`, `<body>`, etc.). Styling | ||||||
|  | through `<style>` blocks, `class` and `style` attributes is possible. However, `html/template` | ||||||
|  | does some [automatic escaping](https://golang.org/pkg/html/template/#hdr-Contexts) that should be considered. | ||||||
|  | 
 | ||||||
|  | Attachments (such as images or external style sheets) are not supported. However, other templates can | ||||||
|  | be referenced too, for example to provide the contents of a `<style>` element in a centralized fashion. | ||||||
|  | The external template must be placed under `custom/mail` and referenced relative to that directory. | ||||||
|  | For example, `custom/mail/styles/base.tmpl` can be included using `{{template styles/base}}`. | ||||||
|  | 
 | ||||||
|  | The mail is sent with `Content-Type: multipart/alternative`, so the body is sent in both HTML | ||||||
|  | and text formats. The latter is obtained by stripping the HTML markup. | ||||||
|  | 
 | ||||||
|  | ## Troubleshooting | ||||||
|  | 
 | ||||||
|  | How a mail is rendered is directly dependent on the capabilities of the mail application. Many mail | ||||||
|  | clients don't even support HTML, so they show the text version included in the generated mail. | ||||||
|  | 
 | ||||||
|  | If the template fails to render, it will be noticed only at the moment the mail is sent. | ||||||
|  | A default subject is used if the subject template fails, and whatever was rendered successfully | ||||||
|  | from the the _mail body_ is used, disregarding the rest. | ||||||
|  | 
 | ||||||
|  | Please check [Gitea's logs](https://docs.gitea.io/en-us/logging-configuration/) for error messages in case of trouble. | ||||||
|  | 
 | ||||||
|  | ## Example | ||||||
|  | 
 | ||||||
|  | `custom/templates/mail/issue/default.tmpl`: | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | [{{.Repo}}] @{{.Doer.Name}} | ||||||
|  | {{if eq .ActionName "new"}} | ||||||
|  |     created | ||||||
|  | {{else if eq .ActionName "comment"}} | ||||||
|  |     commented on | ||||||
|  | {{else if eq .ActionName "close"}} | ||||||
|  |     closed | ||||||
|  | {{else if eq .ActionName "reopen"}} | ||||||
|  |     reopened | ||||||
|  | {{else}} | ||||||
|  |     updated | ||||||
|  | {{end}} | ||||||
|  | {{if eq .ActionType "issue"}} | ||||||
|  |     issue | ||||||
|  | {{else}} | ||||||
|  |     pull request | ||||||
|  | {{end}} | ||||||
|  | #{{.Issue.Index}}: {{.Issue.Title}} | ||||||
|  | ------------ | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html> | ||||||
|  | <head> | ||||||
|  |     <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | ||||||
|  |     <title>{{.Subject}}</title> | ||||||
|  | </head> | ||||||
|  | 
 | ||||||
|  | <body> | ||||||
|  |     {{if .IsMention}} | ||||||
|  |     <p> | ||||||
|  |         You are receiving this because @{{.Doer.Name}} mentioned you. | ||||||
|  |     </p> | ||||||
|  |     {{end}} | ||||||
|  |     <p> | ||||||
|  |         <p> | ||||||
|  |         <a href="{{AppURL}}/{{.Doer.LowerName}}">@{{.Doer.Name}}</a> | ||||||
|  |         {{if not (eq .Doer.FullName "")}} | ||||||
|  |             ({{.Doer.FullName}}) | ||||||
|  |         {{end}} | ||||||
|  |         {{if eq .ActionName "new"}} | ||||||
|  |             created | ||||||
|  |         {{else if eq .ActionName "close"}} | ||||||
|  |             closed | ||||||
|  |         {{else if eq .ActionName "reopen"}} | ||||||
|  |             reopened | ||||||
|  |         {{else}} | ||||||
|  |             updated | ||||||
|  |         {{end}} | ||||||
|  |         <a href="{{.Link}}">{{.Repo}}#{{.Issue.Index}}</a>. | ||||||
|  |         </p> | ||||||
|  |         {{if not (eq .Body "")}} | ||||||
|  |             <h3>Message content:</h3> | ||||||
|  |             <hr> | ||||||
|  |             {{.Body | Str2html}} | ||||||
|  |         {{end}} | ||||||
|  |     </p> | ||||||
|  |     <hr> | ||||||
|  |     <p> | ||||||
|  |         <a href="{{.Link}}">View it on Gitea</a>. | ||||||
|  |     </p> | ||||||
|  | </body> | ||||||
|  | </html> | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | This template produces something along these lines: | ||||||
|  | 
 | ||||||
|  | #### Subject | ||||||
|  | 
 | ||||||
|  | > [mike/stuff] @rhonda commented on pull request #38: New color palette | ||||||
|  | 
 | ||||||
|  | #### Mail body | ||||||
|  | 
 | ||||||
|  | > [@rhonda](#) (Rhonda Myers) updated [mike/stuff#38](#). | ||||||
|  | > | ||||||
|  | > #### Message content: | ||||||
|  | > | ||||||
|  | > \__________________________________________________________________ | ||||||
|  | > | ||||||
|  | > Mike, I think we should tone down the blues a little.   | ||||||
|  | > \__________________________________________________________________ | ||||||
|  | >  | ||||||
|  | > [View it on Gitea](#). | ||||||
|  | 
 | ||||||
|  | ## Advanced | ||||||
|  | 
 | ||||||
|  | The template system contains several functions that can be used to further process and format | ||||||
|  | the messages. Here's a list of some of them: | ||||||
|  | 
 | ||||||
|  | | Name                 | Parameters  | Available | Usage                                                               | | ||||||
|  | |----------------------|-------------|-----------|---------------------------------------------------------------------| | ||||||
|  | | `AppUrl`             | -           | Any       | Gitea's URL                                                         | | ||||||
|  | | `AppName`            | -           | Any       | Set from `app.ini`, usually "Gitea"                                 | | ||||||
|  | | `AppDomain`          | -           | Any       | Gitea's host name                                                   | | ||||||
|  | | `EllipsisString`     | string, int | Any       | Truncates a string to the specified length; adds ellipsis as needed | | ||||||
|  | | `Str2html`           | string      | Body only | Sanitizes text by removing any HTML tags from it.                   | | ||||||
|  | 
 | ||||||
|  | These are _functions_, not metadata, so they have to be used: | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | Like this:         {{Str2html "Escape<my>text"}} | ||||||
|  | Or this:           {{"Escape<my>text" | Str2html}} | ||||||
|  | Or this:           {{AppUrl}} | ||||||
|  | But not like this: {{.AppUrl}} | ||||||
|  | ``` | ||||||
|  | @ -11,6 +11,7 @@ import ( | ||||||
| 	"io/ioutil" | 	"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" | ||||||
|  | @ -20,7 +21,8 @@ import ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| 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.
 | ||||||
|  | @ -59,9 +61,12 @@ func JSRenderer() macaron.Handler { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // 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") | ||||||
|  | @ -84,15 +89,7 @@ func Mailer() *template.Template { | ||||||
| 					continue | 					continue | ||||||
| 				} | 				} | ||||||
| 
 | 
 | ||||||
| 				_, err = templates.New( | 				buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, strings.TrimSuffix(filePath, ".tmpl"), content) | ||||||
| 					strings.TrimSuffix( |  | ||||||
| 						filePath, |  | ||||||
| 						".tmpl", |  | ||||||
| 					), |  | ||||||
| 				).Parse(string(content)) |  | ||||||
| 				if err != nil { |  | ||||||
| 					log.Warn("Failed to parse template %v", err) |  | ||||||
| 				} |  | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | @ -117,18 +114,10 @@ func Mailer() *template.Template { | ||||||
| 					continue | 					continue | ||||||
| 				} | 				} | ||||||
| 
 | 
 | ||||||
| 				_, err = templates.New( | 				buildSubjectBodyTemplate(subjectTemplates, bodyTemplates, strings.TrimSuffix(filePath, ".tmpl"), content) | ||||||
| 					strings.TrimSuffix( |  | ||||||
| 						filePath, |  | ||||||
| 						".tmpl", |  | ||||||
| 					), |  | ||||||
| 				).Parse(string(content)) |  | ||||||
| 				if err != nil { |  | ||||||
| 					log.Warn("Failed to parse template %v", err) |  | ||||||
| 				} |  | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return templates | 	return subjectTemplates, bodyTemplates | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -16,8 +16,10 @@ import ( | ||||||
| 	"mime" | 	"mime" | ||||||
| 	"net/url" | 	"net/url" | ||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
|  | 	"regexp" | ||||||
| 	"runtime" | 	"runtime" | ||||||
| 	"strings" | 	"strings" | ||||||
|  | 	texttmpl "text/template" | ||||||
| 	"time" | 	"time" | ||||||
| 	"unicode" | 	"unicode" | ||||||
| 
 | 
 | ||||||
|  | @ -34,6 +36,9 @@ import ( | ||||||
| 	"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{}{ | ||||||
|  | @ -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
 | // 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) | ||||||
|  | @ -551,3 +662,22 @@ func MigrationIcon(hostname string) string { | ||||||
| 		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) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
							
								
								
									
										55
									
								
								modules/templates/helper_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								modules/templates/helper_test.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,55 @@ | ||||||
|  | // Copyright 2019 The Gitea Authors. All rights reserved.
 | ||||||
|  | // Use of this source code is governed by a MIT-style
 | ||||||
|  | // license that can be found in the LICENSE file.
 | ||||||
|  | 
 | ||||||
|  | package templates | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestSubjectBodySeparator(t *testing.T) { | ||||||
|  | 	test := func(input, subject, body string) { | ||||||
|  | 		loc := mailSubjectSplit.FindIndex([]byte(input)) | ||||||
|  | 		if loc == nil { | ||||||
|  | 			assert.Empty(t, subject, "no subject found, but one expected") | ||||||
|  | 			assert.Equal(t, body, input) | ||||||
|  | 		} else { | ||||||
|  | 			assert.Equal(t, subject, string(input[0:loc[0]])) | ||||||
|  | 			assert.Equal(t, body, string(input[loc[1]:])) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	test("Simple\n---------------\nCase", | ||||||
|  | 		"Simple\n", | ||||||
|  | 		"\nCase") | ||||||
|  | 	test("Only\nBody", | ||||||
|  | 		"", | ||||||
|  | 		"Only\nBody") | ||||||
|  | 	test("Minimal\n---\nseparator", | ||||||
|  | 		"Minimal\n", | ||||||
|  | 		"\nseparator") | ||||||
|  | 	test("False --- separator", | ||||||
|  | 		"", | ||||||
|  | 		"False --- separator") | ||||||
|  | 	test("False\n--- separator", | ||||||
|  | 		"", | ||||||
|  | 		"False\n--- separator") | ||||||
|  | 	test("False ---\nseparator", | ||||||
|  | 		"", | ||||||
|  | 		"False ---\nseparator") | ||||||
|  | 	test("With extra spaces\n-----   \t   \nBody", | ||||||
|  | 		"With extra spaces\n", | ||||||
|  | 		"\nBody") | ||||||
|  | 	test("With leading spaces\n   -------\nOnly body", | ||||||
|  | 		"", | ||||||
|  | 		"With leading spaces\n   -------\nOnly body") | ||||||
|  | 	test("Multiple\n---\n-------\n---\nSeparators", | ||||||
|  | 		"Multiple\n", | ||||||
|  | 		"\n-------\n---\nSeparators") | ||||||
|  | 	test("Insuficient\n--\nSeparators", | ||||||
|  | 		"", | ||||||
|  | 		"Insuficient\n--\nSeparators") | ||||||
|  | } | ||||||
|  | @ -14,6 +14,7 @@ import ( | ||||||
| 	"io/ioutil" | 	"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" | ||||||
|  | @ -23,7 +24,8 @@ import ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| var ( | var ( | ||||||
| 	templates = template.New("") | 	subjectTemplates = texttmpl.New("") | ||||||
|  | 	bodyTemplates    = template.New("") | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type templateFileSystem struct { | type templateFileSystem struct { | ||||||
|  | @ -140,9 +142,12 @@ func JSRenderer() macaron.Handler { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // 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() { | ||||||
|  | @ -161,7 +166,8 @@ func Mailer() *template.Template { | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		templates.New( | 		buildSubjectBodyTemplate(subjectTemplates, | ||||||
|  | 			bodyTemplates, | ||||||
| 			strings.TrimPrefix( | 			strings.TrimPrefix( | ||||||
| 				strings.TrimSuffix( | 				strings.TrimSuffix( | ||||||
| 					assetPath, | 					assetPath, | ||||||
|  | @ -169,7 +175,7 @@ func Mailer() *template.Template { | ||||||
| 				), | 				), | ||||||
| 				"mail/", | 				"mail/", | ||||||
| 			), | 			), | ||||||
| 		).Parse(string(content)) | 			content) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	customDir := path.Join(setting.CustomPath, "templates", "mail") | 	customDir := path.Join(setting.CustomPath, "templates", "mail") | ||||||
|  | @ -192,17 +198,18 @@ func Mailer() *template.Template { | ||||||
| 					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) { | ||||||
|  |  | ||||||
|  | @ -9,7 +9,11 @@ import ( | ||||||
| 	"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" | ||||||
|  | @ -28,18 +32,22 @@ const ( | ||||||
| 	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) { | func InitMailRender(subjectTpl *texttmpl.Template, bodyTpl *template.Template) { | ||||||
| 	templates = tmpls | 	subjectTemplates = subjectTpl | ||||||
|  | 	bodyTemplates = bodyTpl | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // SendTestMail sends a test mail
 | // 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 | 	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 | ||||||
| 	} | 	} | ||||||
|  | @ -96,7 +104,7 @@ func SendActivateEmailMail(locale Locale, u *models.User, email *models.EmailAdd | ||||||
| 
 | 
 | ||||||
| 	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 | ||||||
| 	} | 	} | ||||||
|  | @ -121,7 +129,7 @@ func SendRegisterNotifyMail(locale Locale, u *models.User) { | ||||||
| 
 | 
 | ||||||
| 	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 | ||||||
| 	} | 	} | ||||||
|  | @ -145,7 +153,7 @@ func SendCollaboratorMail(u, doer *models.User, repo *models.Repository) { | ||||||
| 
 | 
 | ||||||
| 	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 | ||||||
| 	} | 	} | ||||||
|  | @ -156,40 +164,70 @@ func SendCollaboratorMail(u, doer *models.User, repo *models.Repository) { | ||||||
| 	SendAsync(msg) | 	SendAsync(msg) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func composeTplData(subject, body, link string) map[string]interface{} { | func composeIssueCommentMessage(issue *models.Issue, doer *models.User, actionType models.ActionType, fromMention bool, | ||||||
| 	data := make(map[string]interface{}, 10) | 	content string, comment *models.Comment, tos []string, info string) *Message { | ||||||
| 	data["Subject"] = subject |  | ||||||
| 	data["Body"] = body |  | ||||||
| 	data["Link"] = link |  | ||||||
| 	return data |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| func composeIssueCommentMessage(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tplName base.TplName, tos []string, info string) *Message { | 	if err := issue.LoadPullRequest(); err != nil { | ||||||
| 	var subject string | 		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 | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	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) | 		link = issue.HTMLURL() | ||||||
| 	} |  | ||||||
| 	err := issue.LoadRepo() |  | ||||||
| 	if err != nil { |  | ||||||
| 		log.Error("LoadRepo: %v", err) |  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	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) | 	actType, actName, tplName := actionToTemplate(issue, actionType, commentType) | ||||||
| 	if comment != nil { | 
 | ||||||
| 		data = composeTplData(subject, body, issue.HTMLURL()+"#"+comment.HashTag()) | 	mailMeta := map[string]interface{}{ | ||||||
| 	} else { | 		"FallbackSubject": fallback, | ||||||
| 		data = composeTplData(subject, body, issue.HTMLURL()) | 		"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, | ||||||
| 	} | 	} | ||||||
| 	data["Doer"] = doer | 
 | ||||||
| 	data["Issue"] = issue | 	var mailSubject bytes.Buffer | ||||||
|  | 	if err := subjectTemplates.ExecuteTemplate(&mailSubject, string(tplName), mailMeta); err == nil { | ||||||
|  | 		subject = sanitizeSubject(mailSubject.String()) | ||||||
|  | 	} else { | ||||||
|  | 		log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/subject", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if subject == "" { | ||||||
|  | 		subject = fallback | ||||||
|  | 	} | ||||||
|  | 	mailMeta["Subject"] = subject | ||||||
| 
 | 
 | ||||||
| 	var mailBody bytes.Buffer | 	var mailBody bytes.Buffer | ||||||
| 
 | 
 | ||||||
| 	if err := templates.ExecuteTemplate(&mailBody, string(tplName), data); err != nil { | 	if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplName), mailMeta); err != nil { | ||||||
| 		log.Error("Template: %v", err) | 		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()) | ||||||
|  | @ -206,24 +244,81 @@ func composeIssueCommentMessage(issue *models.Issue, doer *models.User, content | ||||||
| 	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")) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -31,24 +31,8 @@ func mailParticipantsComment(ctx models.DBContext, c *models.Comment, opType mod | ||||||
| 	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, opType, c.Content, c, mentions); err != nil { | ||||||
| 		if err = mailIssueCommentToParticipants(issue, c.Poster, c.Content, c, mentions); err != nil { |  | ||||||
| 		log.Error("mailIssueCommentToParticipants: %v", err) | 		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 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -14,7 +14,7 @@ import ( | ||||||
| 	"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) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -22,7 +22,7 @@ func mailSubject(issue *models.Issue) string { | ||||||
| // 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 { | ||||||
|  | @ -89,7 +89,7 @@ func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, cont | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	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.
 | ||||||
|  | @ -106,7 +106,7 @@ func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, cont | ||||||
| 	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 | ||||||
|  | @ -131,32 +131,8 @@ func mailParticipants(ctx models.DBContext, issue *models.Issue, doer *models.Us | ||||||
| 	for i, u := range userMentions { | 	for i, u := range userMentions { | ||||||
| 		mentions[i] = u.LowerName | 		mentions[i] = u.LowerName | ||||||
| 	} | 	} | ||||||
| 
 | 	if err = mailIssueCommentToParticipants(issue, doer, opType, issue.Content, nil, mentions); err != nil { | ||||||
| 	if len(issue.Content) > 0 { |  | ||||||
| 		if err = mailIssueCommentToParticipants(issue, doer, issue.Content, nil, mentions); err != nil { |  | ||||||
| 		log.Error("mailIssueCommentToParticipants: %v", err) | 		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) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -5,8 +5,10 @@ | ||||||
| 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" | ||||||
|  | @ -14,7 +16,11 @@ import ( | ||||||
| 	"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> | ||||||
|  | @ -47,17 +53,19 @@ func TestComposeIssueCommentMessage(t *testing.T) { | ||||||
| 	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)) | 	stpl := texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl)) | ||||||
| 	InitMailRender(email) | 	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") | ||||||
| } | } | ||||||
|  | @ -75,17 +83,122 @@ func TestComposeIssueMessage(t *testing.T) { | ||||||
| 	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)) | 	stpl := texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl)) | ||||||
| 	InitMailRender(email) | 	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: //") | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -6,11 +6,11 @@ | ||||||
| </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> | ||||||
|  |  | ||||||
|  | @ -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> |  | ||||||
							
								
								
									
										31
									
								
								templates/mail/issue/default.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								templates/mail/issue/default.tmpl
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,31 @@ | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html> | ||||||
|  | <head> | ||||||
|  | 	<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> | ||||||
|  | 	<title>{{.Subject}}</title> | ||||||
|  | </head> | ||||||
|  | 
 | ||||||
|  | <body> | ||||||
|  | 	{{if .IsMention}}<p>@{{.Doer.Name}} mentioned you:</p>{{end}} | ||||||
|  | 	<p> | ||||||
|  | 		{{- if eq .Body ""}} | ||||||
|  | 			{{if eq .ActionName "new"}} | ||||||
|  | 				Created #{{.Issue.Index}}. | ||||||
|  | 			{{else if eq .ActionName "close"}} | ||||||
|  | 				Closed #{{.Issue.Index}}. | ||||||
|  | 			{{else if eq .ActionName "reopen"}} | ||||||
|  | 				Reopened #{{.Issue.Index}}. | ||||||
|  | 			{{else}} | ||||||
|  | 				Empty comment on #{{.Issue.Index}}. | ||||||
|  | 			{{end}} | ||||||
|  | 		{{else}} | ||||||
|  | 			{{.Body | Str2html}} | ||||||
|  | 		{{end -}} | ||||||
|  | 	</p> | ||||||
|  | 	<p> | ||||||
|  | 		--- | ||||||
|  | 		<br> | ||||||
|  | 		<a href="{{.Link}}">View it on {{AppName}}</a>. | ||||||
|  | 	</p> | ||||||
|  | </body> | ||||||
|  | </html> | ||||||
|  | @ -1,17 +0,0 @@ | ||||||
| <!DOCTYPE html> |  | ||||||
| <html> |  | ||||||
| <head> |  | ||||||
| 	<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> |  | ||||||
| 	<title>{{.Subject}}</title> |  | ||||||
| </head> |  | ||||||
| 
 |  | ||||||
| <body> |  | ||||||
| 	<p>@{{.Doer.Name}} mentioned you:</p> |  | ||||||
| 	<p>{{.Body | Str2html}}</p> |  | ||||||
| 	<p> |  | ||||||
| 		--- |  | ||||||
| 		<br> |  | ||||||
| 		<a href="{{.Link}}">View it on Gitea</a>. |  | ||||||
| 	</p> |  | ||||||
| </body> |  | ||||||
| </html> |  | ||||||
		Loading…
	
		Reference in a new issue