--- /dev/null
+---
+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"
"path"
"strings"
+ texttmpl "text/template"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
var (
- templates = template.New("")
+ subjectTemplates = texttmpl.New("")
+ bodyTemplates = template.New("")
)
// HTMLRenderer implements the macaron handler for serving HTML templates.
}
// Mailer provides the templates required for sending notification mails.
-func Mailer() *template.Template {
+func Mailer() (*texttmpl.Template, *template.Template) {
+ for _, funcs := range NewTextFuncMap() {
+ subjectTemplates.Funcs(funcs)
+ }
for _, funcs := range NewFuncMap() {
- templates.Funcs(funcs)
+ bodyTemplates.Funcs(funcs)
}
staticDir := path.Join(setting.StaticRootPath, "templates", "mail")
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
}
- _, 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"
"net/url"
"path/filepath"
+ "regexp"
"runtime"
"strings"
+ texttmpl "text/template"
"time"
"unicode"
"github.com/editorconfig/editorconfig-core-go/v2"
)
+// Used from static.go && dynamic.go
+var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}[\s]*$`)
+
// NewFuncMap returns functions for injecting to templates
func NewFuncMap() []template.FuncMap {
return []template.FuncMap{map[string]interface{}{
}}
}
+// NewTextFuncMap returns functions for injecting to text templates
+// It's a subset of those used for HTML and other templates
+func NewTextFuncMap() []texttmpl.FuncMap {
+ return []texttmpl.FuncMap{map[string]interface{}{
+ "GoVer": func() string {
+ return strings.Title(runtime.Version())
+ },
+ "AppName": func() string {
+ return setting.AppName
+ },
+ "AppSubUrl": func() string {
+ return setting.AppSubURL
+ },
+ "AppUrl": func() string {
+ return setting.AppURL
+ },
+ "AppVer": func() string {
+ return setting.AppVer
+ },
+ "AppBuiltWith": func() string {
+ return setting.AppBuiltWith
+ },
+ "AppDomain": func() string {
+ return setting.Domain
+ },
+ "TimeSince": timeutil.TimeSince,
+ "TimeSinceUnix": timeutil.TimeSinceUnix,
+ "RawTimeSince": timeutil.RawTimeSince,
+ "DateFmtLong": func(t time.Time) string {
+ return t.Format(time.RFC1123Z)
+ },
+ "DateFmtShort": func(t time.Time) string {
+ return t.Format("Jan 02, 2006")
+ },
+ "List": List,
+ "SubStr": func(str string, start, length int) string {
+ if len(str) == 0 {
+ return ""
+ }
+ end := start + length
+ if length == -1 {
+ end = len(str)
+ }
+ if len(str) < end {
+ return str
+ }
+ return str[start:end]
+ },
+ "EllipsisString": base.EllipsisString,
+ "URLJoin": util.URLJoin,
+ "Dict": func(values ...interface{}) (map[string]interface{}, error) {
+ if len(values)%2 != 0 {
+ return nil, errors.New("invalid dict call")
+ }
+ dict := make(map[string]interface{}, len(values)/2)
+ for i := 0; i < len(values); i += 2 {
+ key, ok := values[i].(string)
+ if !ok {
+ return nil, errors.New("dict keys must be strings")
+ }
+ dict[key] = values[i+1]
+ }
+ return dict, nil
+ },
+ "Printf": fmt.Sprintf,
+ "Escape": Escape,
+ "Sec2Time": models.SecToTime,
+ "ParseDeadline": func(deadline string) []string {
+ return strings.Split(deadline, "|")
+ },
+ "dict": func(values ...interface{}) (map[string]interface{}, error) {
+ if len(values) == 0 {
+ return nil, errors.New("invalid dict call")
+ }
+
+ dict := make(map[string]interface{})
+
+ for i := 0; i < len(values); i++ {
+ switch key := values[i].(type) {
+ case string:
+ i++
+ if i == len(values) {
+ return nil, errors.New("specify the key for non array values")
+ }
+ dict[key] = values[i]
+ case map[string]interface{}:
+ m := values[i].(map[string]interface{})
+ for i, v := range m {
+ dict[i] = v
+ }
+ default:
+ return nil, errors.New("dict values must be maps")
+ }
+ }
+ return dict, nil
+ },
+ "percentage": func(n int, values ...int) float32 {
+ var sum = 0
+ for i := 0; i < len(values); i++ {
+ sum += values[i]
+ }
+ return float32(n) * 100 / float32(sum)
+ },
+ }}
+}
+
// Safe render raw as HTML
func Safe(raw string) template.HTML {
return template.HTML(raw)
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)
+ }
+}
--- /dev/null
+// 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"
"path"
"strings"
+ texttmpl "text/template"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
var (
- templates = template.New("")
+ subjectTemplates = texttmpl.New("")
+ bodyTemplates = template.New("")
)
type templateFileSystem struct {
}
// Mailer provides the templates required for sending notification mails.
-func Mailer() *template.Template {
+func Mailer() (*texttmpl.Template, *template.Template) {
+ for _, funcs := range NewTextFuncMap() {
+ subjectTemplates.Funcs(funcs)
+ }
for _, funcs := range NewFuncMap() {
- templates.Funcs(funcs)
+ bodyTemplates.Funcs(funcs)
}
for _, assetPath := range AssetNames() {
continue
}
- templates.New(
+ buildSubjectBodyTemplate(subjectTemplates,
+ bodyTemplates,
strings.TrimPrefix(
strings.TrimSuffix(
assetPath,
),
"mail/",
),
- ).Parse(string(content))
+ content)
}
customDir := path.Join(setting.CustomPath, "templates", "mail")
continue
}
- templates.New(
+ buildSubjectBodyTemplate(subjectTemplates,
+ bodyTemplates,
strings.TrimSuffix(
filePath,
".tmpl",
),
- ).Parse(string(content))
+ content)
}
}
}
- return templates
+ return subjectTemplates, bodyTemplates
}
func Asset(name string) ([]byte, error) {
"bytes"
"fmt"
"html/template"
+ "mime"
"path"
+ "regexp"
+ "strings"
+ texttmpl "text/template"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/base"
mailAuthResetPassword base.TplName = "auth/reset_passwd"
mailAuthRegisterNotify base.TplName = "auth/register_notify"
- mailIssueComment base.TplName = "issue/comment"
- mailIssueMention base.TplName = "issue/mention"
- mailIssueAssigned base.TplName = "issue/assigned"
-
mailNotifyCollaborator base.TplName = "notify/collaborator"
+
+ // There's no actual limit for subject in RFC 5322
+ mailMaxSubjectRunes = 256
)
-var templates *template.Template
+var (
+ bodyTemplates *template.Template
+ subjectTemplates *texttmpl.Template
+ subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
+)
// InitMailRender initializes the mail renderer
-func InitMailRender(tmpls *template.Template) {
- templates = tmpls
+func InitMailRender(subjectTpl *texttmpl.Template, bodyTpl *template.Template) {
+ subjectTemplates = subjectTpl
+ bodyTemplates = bodyTpl
}
// SendTestMail sends a test mail
var content bytes.Buffer
- if err := templates.ExecuteTemplate(&content, string(tpl), data); err != nil {
+ if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
log.Error("Template: %v", err)
return
}
var content bytes.Buffer
- if err := templates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
+ if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
log.Error("Template: %v", err)
return
}
var content bytes.Buffer
- if err := templates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
+ if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
log.Error("Template: %v", err)
return
}
var content bytes.Buffer
- if err := templates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
+ if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
log.Error("Template: %v", err)
return
}
SendAsync(msg)
}
-func composeTplData(subject, body, link string) map[string]interface{} {
- data := make(map[string]interface{}, 10)
- data["Subject"] = subject
- data["Body"] = body
- data["Link"] = link
- return data
-}
+func composeIssueCommentMessage(issue *models.Issue, doer *models.User, actionType models.ActionType, fromMention bool,
+ content string, comment *models.Comment, tos []string, info string) *Message {
+
+ if err := issue.LoadPullRequest(); err != nil {
+ log.Error("LoadPullRequest: %v", err)
+ return nil
+ }
+
+ var (
+ subject string
+ link string
+ prefix string
+ // Fall back subject for bad templates, make sure subject is never empty
+ fallback string
+ )
-func composeIssueCommentMessage(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tplName base.TplName, tos []string, info string) *Message {
- var subject string
+ commentType := models.CommentTypeComment
if comment != nil {
- subject = "Re: " + mailSubject(issue)
+ prefix = "Re: "
+ commentType = comment.Type
+ link = issue.HTMLURL() + "#" + comment.HashTag()
} else {
- subject = mailSubject(issue)
- }
- err := issue.LoadRepo()
- if err != nil {
- log.Error("LoadRepo: %v", err)
+ link = issue.HTMLURL()
}
+
+ fallback = prefix + fallbackMailSubject(issue)
+
+ // This is the body of the new issue or comment, not the mail body
body := string(markup.RenderByType(markdown.MarkupName, []byte(content), issue.Repo.HTMLURL(), issue.Repo.ComposeMetas()))
- var data = make(map[string]interface{}, 10)
- if comment != nil {
- data = composeTplData(subject, body, issue.HTMLURL()+"#"+comment.HashTag())
+ actType, actName, tplName := actionToTemplate(issue, actionType, commentType)
+
+ mailMeta := map[string]interface{}{
+ "FallbackSubject": fallback,
+ "Body": body,
+ "Link": link,
+ "Issue": issue,
+ "Comment": comment,
+ "IsPull": issue.IsPull,
+ "User": issue.Repo.MustOwner(),
+ "Repo": issue.Repo.FullName(),
+ "Doer": doer,
+ "IsMention": fromMention,
+ "SubjectPrefix": prefix,
+ "ActionType": actType,
+ "ActionName": actName,
+ }
+
+ var mailSubject bytes.Buffer
+ if err := subjectTemplates.ExecuteTemplate(&mailSubject, string(tplName), mailMeta); err == nil {
+ subject = sanitizeSubject(mailSubject.String())
} else {
- data = composeTplData(subject, body, issue.HTMLURL())
+ log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/subject", err)
+ }
+
+ if subject == "" {
+ subject = fallback
}
- data["Doer"] = doer
- data["Issue"] = issue
+ mailMeta["Subject"] = subject
var mailBody bytes.Buffer
- if err := templates.ExecuteTemplate(&mailBody, string(tplName), data); err != nil {
- log.Error("Template: %v", err)
+ if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplName), mailMeta); err != nil {
+ log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/body", err)
}
msg := NewMessageFrom(tos, doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String())
return msg
}
+func sanitizeSubject(subject string) string {
+ runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " ")))
+ if len(runes) > mailMaxSubjectRunes {
+ runes = runes[:mailMaxSubjectRunes]
+ }
+ // Encode non-ASCII characters
+ return mime.QEncoding.Encode("utf-8", string(runes))
+}
+
// SendIssueCommentMail composes and sends issue comment emails to target receivers.
-func SendIssueCommentMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) {
+func SendIssueCommentMail(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, tos []string) {
if len(tos) == 0 {
return
}
- SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueComment, tos, "issue comment"))
+ SendAsync(composeIssueCommentMessage(issue, doer, actionType, false, content, comment, tos, "issue comment"))
}
// SendIssueMentionMail composes and sends issue mention emails to target receivers.
-func SendIssueMentionMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) {
+func SendIssueMentionMail(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, tos []string) {
if len(tos) == 0 {
return
}
- SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueMention, tos, "issue mention"))
+ SendAsync(composeIssueCommentMessage(issue, doer, actionType, true, content, comment, tos, "issue mention"))
+}
+
+// actionToTemplate returns the type and name of the action facing the user
+// (slightly different from models.ActionType) and the name of the template to use (based on availability)
+func actionToTemplate(issue *models.Issue, actionType models.ActionType, commentType models.CommentType) (typeName, name, template string) {
+ if issue.IsPull {
+ typeName = "pull"
+ } else {
+ typeName = "issue"
+ }
+ switch actionType {
+ case models.ActionCreateIssue, models.ActionCreatePullRequest:
+ name = "new"
+ case models.ActionCommentIssue:
+ name = "comment"
+ case models.ActionCloseIssue, models.ActionClosePullRequest:
+ name = "close"
+ case models.ActionReopenIssue, models.ActionReopenPullRequest:
+ name = "reopen"
+ case models.ActionMergePullRequest:
+ name = "merge"
+ default:
+ switch commentType {
+ case models.CommentTypeReview:
+ name = "review"
+ case models.CommentTypeCode:
+ name = "code"
+ case models.CommentTypeAssignees:
+ name = "assigned"
+ default:
+ name = "default"
+ }
+ }
+
+ template = typeName + "/" + name
+ ok := bodyTemplates.Lookup(template) != nil
+ if !ok && typeName != "issue" {
+ template = "issue/" + name
+ ok = bodyTemplates.Lookup(template) != nil
+ }
+ if !ok {
+ template = typeName + "/default"
+ ok = bodyTemplates.Lookup(template) != nil
+ }
+ if !ok {
+ template = "issue/default"
+ }
+ return
}
// SendIssueAssignedMail composes and sends issue assigned email
func SendIssueAssignedMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) {
- SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueAssigned, tos, "issue assigned"))
+ SendAsync(composeIssueCommentMessage(issue, doer, models.ActionType(0), false, content, comment, tos, "issue assigned"))
}
for i, u := range userMentions {
mentions[i] = u.LowerName
}
- if len(c.Content) > 0 {
- if err = mailIssueCommentToParticipants(issue, c.Poster, c.Content, c, mentions); err != nil {
- log.Error("mailIssueCommentToParticipants: %v", err)
- }
+ if err = mailIssueCommentToParticipants(issue, c.Poster, opType, c.Content, c, mentions); err != nil {
+ log.Error("mailIssueCommentToParticipants: %v", err)
}
-
- switch opType {
- case models.ActionCloseIssue:
- ct := fmt.Sprintf("Closed #%d.", issue.Index)
- if err = mailIssueCommentToParticipants(issue, c.Poster, ct, c, mentions); err != nil {
- log.Error("mailIssueCommentToParticipants: %v", err)
- }
- case models.ActionReopenIssue:
- ct := fmt.Sprintf("Reopened #%d.", issue.Index)
- if err = mailIssueCommentToParticipants(issue, c.Poster, ct, c, mentions); err != nil {
- log.Error("mailIssueCommentToParticipants: %v", err)
- }
- }
-
return nil
}
"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)
}
// This function sends two list of emails:
// 1. Repository watchers and users who are participated in comments.
// 2. Users who are not in 1. but get mentioned in current issue/comment.
-func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, content string, comment *models.Comment, mentions []string) error {
+func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, mentions []string) error {
watchers, err := models.GetWatchers(issue.RepoID)
if err != nil {
}
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.
emails := models.GetUserEmailsByNames(tos)
for _, to := range emails {
- SendIssueMentionMail(issue, doer, content, comment, []string{to})
+ SendIssueMentionMail(issue, doer, actionType, content, comment, []string{to})
}
return nil
for i, u := range userMentions {
mentions[i] = u.LowerName
}
-
- if len(issue.Content) > 0 {
- if err = mailIssueCommentToParticipants(issue, doer, issue.Content, nil, mentions); err != nil {
- log.Error("mailIssueCommentToParticipants: %v", err)
- }
- }
-
- switch opType {
- case models.ActionCreateIssue, models.ActionCreatePullRequest:
- if len(issue.Content) == 0 {
- ct := fmt.Sprintf("Created #%d.", issue.Index)
- if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil {
- log.Error("mailIssueCommentToParticipants: %v", err)
- }
- }
- case models.ActionCloseIssue, models.ActionClosePullRequest:
- ct := fmt.Sprintf("Closed #%d.", issue.Index)
- if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil {
- log.Error("mailIssueCommentToParticipants: %v", err)
- }
- case models.ActionReopenIssue, models.ActionReopenPullRequest:
- ct := fmt.Sprintf("Reopened #%d.", issue.Index)
- if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil {
- log.Error("mailIssueCommentToParticipants: %v", err)
- }
+ if err = mailIssueCommentToParticipants(issue, doer, opType, issue.Content, nil, mentions); err != nil {
+ log.Error("mailIssueCommentToParticipants: %v", err)
}
-
return nil
}
package mailer
import (
+ "bytes"
"html/template"
"testing"
+ texttmpl "text/template"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert"
)
-const tmpl = `
+const subjectTpl = `
+{{.SubjectPrefix}}[{{.Repo}}] @{{.Doer.Name}} #{{.Issue.Index}} - {{.Issue.Title}}
+`
+
+const bodyTpl = `
<!DOCTYPE html>
<html>
<head>
issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue)
comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment)
- email := template.Must(template.New("issue/comment").Parse(tmpl))
- InitMailRender(email)
+ stpl := texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl))
+ btpl := template.Must(template.New("issue/comment").Parse(bodyTpl))
+ InitMailRender(stpl, btpl)
tos := []string{"test@gitea.com", "test2@gitea.com"}
- msg := composeIssueCommentMessage(issue, doer, "test body", comment, mailIssueComment, tos, "issue comment")
+ msg := composeIssueCommentMessage(issue, doer, models.ActionCommentIssue, false, "test body", comment, tos, "issue comment")
subject := msg.GetHeader("Subject")
inreplyTo := msg.GetHeader("In-Reply-To")
references := msg.GetHeader("References")
- assert.Equal(t, subject[0], "Re: "+mailSubject(issue), "Comment reply subject should contain Re:")
+ assert.Equal(t, "Re: ", subject[0][:4], "Comment reply subject should contain Re:")
+ assert.Equal(t, "Re: [user2/repo1] @user2 #1 - issue1", subject[0])
assert.Equal(t, inreplyTo[0], "<user2/repo1/issues/1@localhost>", "In-Reply-To header doesn't match")
assert.Equal(t, references[0], "<user2/repo1/issues/1@localhost>", "References header doesn't match")
}
repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository)
issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue)
- email := template.Must(template.New("issue/comment").Parse(tmpl))
- InitMailRender(email)
+ stpl := texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl))
+ btpl := template.Must(template.New("issue/new").Parse(bodyTpl))
+ InitMailRender(stpl, btpl)
tos := []string{"test@gitea.com", "test2@gitea.com"}
- msg := composeIssueCommentMessage(issue, doer, "test body", nil, mailIssueComment, tos, "issue create")
+ msg := composeIssueCommentMessage(issue, doer, models.ActionCreateIssue, false, "test body", nil, tos, "issue create")
subject := msg.GetHeader("Subject")
messageID := msg.GetHeader("Message-ID")
- assert.Equal(t, subject[0], mailSubject(issue), "Subject not equal to issue.mailSubject()")
+ assert.Equal(t, "[user2/repo1] @user2 #1 - issue1", subject[0])
assert.Nil(t, msg.GetHeader("In-Reply-To"))
assert.Nil(t, msg.GetHeader("References"))
assert.Equal(t, messageID[0], "<user2/repo1/issues/1@localhost>", "Message-ID header doesn't match")
}
+
+func TestTemplateSelection(t *testing.T) {
+ assert.NoError(t, models.PrepareTestDatabase())
+ var mailService = setting.Mailer{
+ From: "test@gitea.com",
+ }
+
+ setting.MailService = &mailService
+ setting.Domain = "localhost"
+
+ doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
+ repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository)
+ issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue)
+ tos := []string{"test@gitea.com"}
+
+ stpl := texttmpl.Must(texttmpl.New("issue/default").Parse("issue/default/subject"))
+ texttmpl.Must(stpl.New("issue/new").Parse("issue/new/subject"))
+ texttmpl.Must(stpl.New("pull/comment").Parse("pull/comment/subject"))
+ texttmpl.Must(stpl.New("issue/close").Parse("")) // Must default to fallback subject
+
+ btpl := template.Must(template.New("issue/default").Parse("issue/default/body"))
+ template.Must(btpl.New("issue/new").Parse("issue/new/body"))
+ template.Must(btpl.New("pull/comment").Parse("pull/comment/body"))
+ template.Must(btpl.New("issue/close").Parse("issue/close/body"))
+
+ InitMailRender(stpl, btpl)
+
+ expect := func(t *testing.T, msg *Message, expSubject, expBody string) {
+ subject := msg.GetHeader("Subject")
+ msgbuf := new(bytes.Buffer)
+ _, _ = msg.WriteTo(msgbuf)
+ wholemsg := msgbuf.String()
+ assert.Equal(t, []string{expSubject}, subject)
+ assert.Contains(t, wholemsg, expBody)
+ }
+
+ msg := composeIssueCommentMessage(issue, doer, models.ActionCreateIssue, false, "test body", nil, tos, "TestTemplateSelection")
+ expect(t, msg, "issue/new/subject", "issue/new/body")
+
+ comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment)
+ msg = composeIssueCommentMessage(issue, doer, models.ActionCommentIssue, false, "test body", comment, tos, "TestTemplateSelection")
+ expect(t, msg, "issue/default/subject", "issue/default/body")
+
+ pull := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 2, Repo: repo, Poster: doer}).(*models.Issue)
+ comment = models.AssertExistsAndLoadBean(t, &models.Comment{ID: 4, Issue: pull}).(*models.Comment)
+ msg = composeIssueCommentMessage(pull, doer, models.ActionCommentIssue, false, "test body", comment, tos, "TestTemplateSelection")
+ expect(t, msg, "pull/comment/subject", "pull/comment/body")
+
+ msg = composeIssueCommentMessage(issue, doer, models.ActionCloseIssue, false, "test body", nil, tos, "TestTemplateSelection")
+ expect(t, msg, "[user2/repo1] issue1 (#1)", "issue/close/body")
+}
+
+func TestTemplateServices(t *testing.T) {
+ assert.NoError(t, models.PrepareTestDatabase())
+ var mailService = setting.Mailer{
+ From: "test@gitea.com",
+ }
+
+ setting.MailService = &mailService
+ setting.Domain = "localhost"
+
+ doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
+ repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository)
+ issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue)
+ comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment)
+ assert.NoError(t, issue.LoadRepo())
+
+ expect := func(t *testing.T, issue *models.Issue, comment *models.Comment, doer *models.User,
+ actionType models.ActionType, fromMention bool, tplSubject, tplBody, expSubject, expBody string) {
+
+ stpl := texttmpl.Must(texttmpl.New("issue/default").Parse(tplSubject))
+ btpl := template.Must(template.New("issue/default").Parse(tplBody))
+ InitMailRender(stpl, btpl)
+
+ tos := []string{"test@gitea.com"}
+ msg := composeIssueCommentMessage(issue, doer, actionType, fromMention, "test body", comment, tos, "TestTemplateServices")
+
+ subject := msg.GetHeader("Subject")
+ msgbuf := new(bytes.Buffer)
+ _, _ = msg.WriteTo(msgbuf)
+ wholemsg := msgbuf.String()
+
+ assert.Equal(t, []string{expSubject}, subject)
+ assert.Contains(t, wholemsg, "\r\n"+expBody+"\r\n")
+ }
+
+ expect(t, issue, comment, doer, models.ActionCommentIssue, false,
+ "{{.SubjectPrefix}}[{{.Repo}}]: @{{.Doer.Name}} commented on #{{.Issue.Index}} - {{.Issue.Title}}",
+ "//{{.ActionType}},{{.ActionName}},{{if .IsMention}}norender{{end}}//",
+ "Re: [user2/repo1]: @user2 commented on #1 - issue1",
+ "//issue,comment,//")
+
+ expect(t, issue, comment, doer, models.ActionCommentIssue, true,
+ "{{if .IsMention}}must render{{end}}",
+ "//subject is: {{.Subject}}//",
+ "must render",
+ "//subject is: must render//")
+
+ expect(t, issue, comment, doer, models.ActionCommentIssue, true,
+ "{{.FallbackSubject}}",
+ "//{{.SubjectPrefix}}//",
+ "Re: [user2/repo1] issue1 (#1)",
+ "//Re: //")
+}
</head>
<body>
- <p>@{{.Doer.Name}} assigned you to the {{if eq .Issue.IsPull true}}pull request{{else}}issue{{end}} <a href="{{.Link}}">#{{.Issue.Index}}</a> in repository {{.Issue.Repo.FullName}}.</p>
+ <p>@{{.Doer.Name}} assigned you to the {{if .IsPull}}pull request{{else}}issue{{end}} <a href="{{.Link}}">#{{.Issue.Index}}</a> in repository {{.Repo}}.</p>
<p>
---
<br>
- <a href="{{.Link}}">View it on Gitea</a>.
+ <a href="{{.Link}}">View it on {{AppName}}</a>.
</p>
</body>
+++ /dev/null
-<!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>
--- /dev/null
+<!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>
+++ /dev/null
-<!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>