diff options
Diffstat (limited to 'services')
-rw-r--r-- | services/mailer/mail.go | 173 | ||||
-rw-r--r-- | services/mailer/mail_comment.go | 20 | ||||
-rw-r--r-- | services/mailer/mail_issue.go | 36 | ||||
-rw-r--r-- | services/mailer/mail_test.go | 131 |
4 files changed, 264 insertions, 96 deletions
diff --git a/services/mailer/mail.go b/services/mailer/mail.go index bc2aff7314..fc892f6076 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -9,7 +9,11 @@ import ( "bytes" "fmt" "html/template" + "mime" "path" + "regexp" + "strings" + texttmpl "text/template" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/base" @@ -28,18 +32,22 @@ const ( mailAuthResetPassword base.TplName = "auth/reset_passwd" mailAuthRegisterNotify base.TplName = "auth/register_notify" - mailIssueComment base.TplName = "issue/comment" - mailIssueMention base.TplName = "issue/mention" - mailIssueAssigned base.TplName = "issue/assigned" - mailNotifyCollaborator base.TplName = "notify/collaborator" + + // There's no actual limit for subject in RFC 5322 + mailMaxSubjectRunes = 256 ) -var templates *template.Template +var ( + bodyTemplates *template.Template + subjectTemplates *texttmpl.Template + subjectRemoveSpaces = regexp.MustCompile(`[\s]+`) +) // InitMailRender initializes the mail renderer -func InitMailRender(tmpls *template.Template) { - templates = tmpls +func InitMailRender(subjectTpl *texttmpl.Template, bodyTpl *template.Template) { + subjectTemplates = subjectTpl + bodyTemplates = bodyTpl } // SendTestMail sends a test mail @@ -58,7 +66,7 @@ func SendUserMail(language string, u *models.User, tpl base.TplName, code, subje var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(tpl), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil { log.Error("Template: %v", err) return } @@ -96,7 +104,7 @@ func SendActivateEmailMail(locale Locale, u *models.User, email *models.EmailAdd var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil { log.Error("Template: %v", err) return } @@ -121,7 +129,7 @@ func SendRegisterNotifyMail(locale Locale, u *models.User) { var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil { log.Error("Template: %v", err) return } @@ -145,7 +153,7 @@ func SendCollaboratorMail(u, doer *models.User, repo *models.Repository) { var content bytes.Buffer - if err := templates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil { + if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil { log.Error("Template: %v", err) return } @@ -156,40 +164,70 @@ func SendCollaboratorMail(u, doer *models.User, repo *models.Repository) { SendAsync(msg) } -func composeTplData(subject, body, link string) map[string]interface{} { - data := make(map[string]interface{}, 10) - data["Subject"] = subject - data["Body"] = body - data["Link"] = link - return data -} +func composeIssueCommentMessage(issue *models.Issue, doer *models.User, actionType models.ActionType, fromMention bool, + content string, comment *models.Comment, tos []string, info string) *Message { + + if err := issue.LoadPullRequest(); err != nil { + log.Error("LoadPullRequest: %v", err) + return nil + } + + var ( + subject string + link string + prefix string + // Fall back subject for bad templates, make sure subject is never empty + fallback string + ) -func composeIssueCommentMessage(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tplName base.TplName, tos []string, info string) *Message { - var subject string + commentType := models.CommentTypeComment if comment != nil { - subject = "Re: " + mailSubject(issue) + prefix = "Re: " + commentType = comment.Type + link = issue.HTMLURL() + "#" + comment.HashTag() } else { - subject = mailSubject(issue) - } - err := issue.LoadRepo() - if err != nil { - log.Error("LoadRepo: %v", err) + link = issue.HTMLURL() } + + fallback = prefix + fallbackMailSubject(issue) + + // This is the body of the new issue or comment, not the mail body body := string(markup.RenderByType(markdown.MarkupName, []byte(content), issue.Repo.HTMLURL(), issue.Repo.ComposeMetas())) - var data = make(map[string]interface{}, 10) - if comment != nil { - data = composeTplData(subject, body, issue.HTMLURL()+"#"+comment.HashTag()) + actType, actName, tplName := actionToTemplate(issue, actionType, commentType) + + mailMeta := map[string]interface{}{ + "FallbackSubject": fallback, + "Body": body, + "Link": link, + "Issue": issue, + "Comment": comment, + "IsPull": issue.IsPull, + "User": issue.Repo.MustOwner(), + "Repo": issue.Repo.FullName(), + "Doer": doer, + "IsMention": fromMention, + "SubjectPrefix": prefix, + "ActionType": actType, + "ActionName": actName, + } + + var mailSubject bytes.Buffer + if err := subjectTemplates.ExecuteTemplate(&mailSubject, string(tplName), mailMeta); err == nil { + subject = sanitizeSubject(mailSubject.String()) } else { - data = composeTplData(subject, body, issue.HTMLURL()) + log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/subject", err) + } + + if subject == "" { + subject = fallback } - data["Doer"] = doer - data["Issue"] = issue + mailMeta["Subject"] = subject var mailBody bytes.Buffer - if err := templates.ExecuteTemplate(&mailBody, string(tplName), data); err != nil { - log.Error("Template: %v", err) + if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplName), mailMeta); err != nil { + log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/body", err) } msg := NewMessageFrom(tos, doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String()) @@ -206,24 +244,81 @@ func composeIssueCommentMessage(issue *models.Issue, doer *models.User, content return msg } +func sanitizeSubject(subject string) string { + runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " "))) + if len(runes) > mailMaxSubjectRunes { + runes = runes[:mailMaxSubjectRunes] + } + // Encode non-ASCII characters + return mime.QEncoding.Encode("utf-8", string(runes)) +} + // SendIssueCommentMail composes and sends issue comment emails to target receivers. -func SendIssueCommentMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) { +func SendIssueCommentMail(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, tos []string) { if len(tos) == 0 { return } - SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueComment, tos, "issue comment")) + SendAsync(composeIssueCommentMessage(issue, doer, actionType, false, content, comment, tos, "issue comment")) } // SendIssueMentionMail composes and sends issue mention emails to target receivers. -func SendIssueMentionMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) { +func SendIssueMentionMail(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, tos []string) { if len(tos) == 0 { return } - SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueMention, tos, "issue mention")) + SendAsync(composeIssueCommentMessage(issue, doer, actionType, true, content, comment, tos, "issue mention")) +} + +// actionToTemplate returns the type and name of the action facing the user +// (slightly different from models.ActionType) and the name of the template to use (based on availability) +func actionToTemplate(issue *models.Issue, actionType models.ActionType, commentType models.CommentType) (typeName, name, template string) { + if issue.IsPull { + typeName = "pull" + } else { + typeName = "issue" + } + switch actionType { + case models.ActionCreateIssue, models.ActionCreatePullRequest: + name = "new" + case models.ActionCommentIssue: + name = "comment" + case models.ActionCloseIssue, models.ActionClosePullRequest: + name = "close" + case models.ActionReopenIssue, models.ActionReopenPullRequest: + name = "reopen" + case models.ActionMergePullRequest: + name = "merge" + default: + switch commentType { + case models.CommentTypeReview: + name = "review" + case models.CommentTypeCode: + name = "code" + case models.CommentTypeAssignees: + name = "assigned" + default: + name = "default" + } + } + + template = typeName + "/" + name + ok := bodyTemplates.Lookup(template) != nil + if !ok && typeName != "issue" { + template = "issue/" + name + ok = bodyTemplates.Lookup(template) != nil + } + if !ok { + template = typeName + "/default" + ok = bodyTemplates.Lookup(template) != nil + } + if !ok { + template = "issue/default" + } + return } // SendIssueAssignedMail composes and sends issue assigned email func SendIssueAssignedMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) { - SendAsync(composeIssueCommentMessage(issue, doer, content, comment, mailIssueAssigned, tos, "issue assigned")) + SendAsync(composeIssueCommentMessage(issue, doer, models.ActionType(0), false, content, comment, tos, "issue assigned")) } diff --git a/services/mailer/mail_comment.go b/services/mailer/mail_comment.go index d306c14f42..6469eb1fa1 100644 --- a/services/mailer/mail_comment.go +++ b/services/mailer/mail_comment.go @@ -31,24 +31,8 @@ func mailParticipantsComment(ctx models.DBContext, c *models.Comment, opType mod for i, u := range userMentions { mentions[i] = u.LowerName } - if len(c.Content) > 0 { - if err = mailIssueCommentToParticipants(issue, c.Poster, c.Content, c, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } + if err = mailIssueCommentToParticipants(issue, c.Poster, opType, c.Content, c, mentions); err != nil { + log.Error("mailIssueCommentToParticipants: %v", err) } - - switch opType { - case models.ActionCloseIssue: - ct := fmt.Sprintf("Closed #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, c.Poster, ct, c, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - case models.ActionReopenIssue: - ct := fmt.Sprintf("Reopened #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, c.Poster, ct, c, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - } - return nil } diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go index a5f3251807..32b21b1324 100644 --- a/services/mailer/mail_issue.go +++ b/services/mailer/mail_issue.go @@ -14,7 +14,7 @@ import ( "github.com/unknwon/com" ) -func mailSubject(issue *models.Issue) string { +func fallbackMailSubject(issue *models.Issue) string { return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index) } @@ -22,7 +22,7 @@ func mailSubject(issue *models.Issue) string { // This function sends two list of emails: // 1. Repository watchers and users who are participated in comments. // 2. Users who are not in 1. but get mentioned in current issue/comment. -func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, content string, comment *models.Comment, mentions []string) error { +func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, mentions []string) error { watchers, err := models.GetWatchers(issue.RepoID) if err != nil { @@ -89,7 +89,7 @@ func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, cont } for _, to := range tos { - SendIssueCommentMail(issue, doer, content, comment, []string{to}) + SendIssueCommentMail(issue, doer, actionType, content, comment, []string{to}) } // Mail mentioned people and exclude watchers. @@ -106,7 +106,7 @@ func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, cont emails := models.GetUserEmailsByNames(tos) for _, to := range emails { - SendIssueMentionMail(issue, doer, content, comment, []string{to}) + SendIssueMentionMail(issue, doer, actionType, content, comment, []string{to}) } return nil @@ -131,32 +131,8 @@ func mailParticipants(ctx models.DBContext, issue *models.Issue, doer *models.Us for i, u := range userMentions { mentions[i] = u.LowerName } - - if len(issue.Content) > 0 { - if err = mailIssueCommentToParticipants(issue, doer, issue.Content, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - } - - switch opType { - case models.ActionCreateIssue, models.ActionCreatePullRequest: - if len(issue.Content) == 0 { - ct := fmt.Sprintf("Created #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - } - case models.ActionCloseIssue, models.ActionClosePullRequest: - ct := fmt.Sprintf("Closed #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } - case models.ActionReopenIssue, models.ActionReopenPullRequest: - ct := fmt.Sprintf("Reopened #%d.", issue.Index) - if err = mailIssueCommentToParticipants(issue, doer, ct, nil, mentions); err != nil { - log.Error("mailIssueCommentToParticipants: %v", err) - } + if err = mailIssueCommentToParticipants(issue, doer, opType, issue.Content, nil, mentions); err != nil { + log.Error("mailIssueCommentToParticipants: %v", err) } - return nil } diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go index c7a84d6b33..a10507e0e4 100644 --- a/services/mailer/mail_test.go +++ b/services/mailer/mail_test.go @@ -5,8 +5,10 @@ package mailer import ( + "bytes" "html/template" "testing" + texttmpl "text/template" "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/setting" @@ -14,7 +16,11 @@ import ( "github.com/stretchr/testify/assert" ) -const tmpl = ` +const subjectTpl = ` +{{.SubjectPrefix}}[{{.Repo}}] @{{.Doer.Name}} #{{.Issue.Index}} - {{.Issue.Title}} +` + +const bodyTpl = ` <!DOCTYPE html> <html> <head> @@ -47,17 +53,19 @@ func TestComposeIssueCommentMessage(t *testing.T) { issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment) - email := template.Must(template.New("issue/comment").Parse(tmpl)) - InitMailRender(email) + stpl := texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl)) + btpl := template.Must(template.New("issue/comment").Parse(bodyTpl)) + InitMailRender(stpl, btpl) tos := []string{"test@gitea.com", "test2@gitea.com"} - msg := composeIssueCommentMessage(issue, doer, "test body", comment, mailIssueComment, tos, "issue comment") + msg := composeIssueCommentMessage(issue, doer, models.ActionCommentIssue, false, "test body", comment, tos, "issue comment") subject := msg.GetHeader("Subject") inreplyTo := msg.GetHeader("In-Reply-To") references := msg.GetHeader("References") - assert.Equal(t, subject[0], "Re: "+mailSubject(issue), "Comment reply subject should contain Re:") + assert.Equal(t, "Re: ", subject[0][:4], "Comment reply subject should contain Re:") + assert.Equal(t, "Re: [user2/repo1] @user2 #1 - issue1", subject[0]) assert.Equal(t, inreplyTo[0], "<user2/repo1/issues/1@localhost>", "In-Reply-To header doesn't match") assert.Equal(t, references[0], "<user2/repo1/issues/1@localhost>", "References header doesn't match") } @@ -75,17 +83,122 @@ func TestComposeIssueMessage(t *testing.T) { repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository) issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) - email := template.Must(template.New("issue/comment").Parse(tmpl)) - InitMailRender(email) + stpl := texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl)) + btpl := template.Must(template.New("issue/new").Parse(bodyTpl)) + InitMailRender(stpl, btpl) tos := []string{"test@gitea.com", "test2@gitea.com"} - msg := composeIssueCommentMessage(issue, doer, "test body", nil, mailIssueComment, tos, "issue create") + msg := composeIssueCommentMessage(issue, doer, models.ActionCreateIssue, false, "test body", nil, tos, "issue create") subject := msg.GetHeader("Subject") messageID := msg.GetHeader("Message-ID") - assert.Equal(t, subject[0], mailSubject(issue), "Subject not equal to issue.mailSubject()") + assert.Equal(t, "[user2/repo1] @user2 #1 - issue1", subject[0]) assert.Nil(t, msg.GetHeader("In-Reply-To")) assert.Nil(t, msg.GetHeader("References")) assert.Equal(t, messageID[0], "<user2/repo1/issues/1@localhost>", "Message-ID header doesn't match") } + +func TestTemplateSelection(t *testing.T) { + assert.NoError(t, models.PrepareTestDatabase()) + var mailService = setting.Mailer{ + From: "test@gitea.com", + } + + setting.MailService = &mailService + setting.Domain = "localhost" + + doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository) + issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) + tos := []string{"test@gitea.com"} + + stpl := texttmpl.Must(texttmpl.New("issue/default").Parse("issue/default/subject")) + texttmpl.Must(stpl.New("issue/new").Parse("issue/new/subject")) + texttmpl.Must(stpl.New("pull/comment").Parse("pull/comment/subject")) + texttmpl.Must(stpl.New("issue/close").Parse("")) // Must default to fallback subject + + btpl := template.Must(template.New("issue/default").Parse("issue/default/body")) + template.Must(btpl.New("issue/new").Parse("issue/new/body")) + template.Must(btpl.New("pull/comment").Parse("pull/comment/body")) + template.Must(btpl.New("issue/close").Parse("issue/close/body")) + + InitMailRender(stpl, btpl) + + expect := func(t *testing.T, msg *Message, expSubject, expBody string) { + subject := msg.GetHeader("Subject") + msgbuf := new(bytes.Buffer) + _, _ = msg.WriteTo(msgbuf) + wholemsg := msgbuf.String() + assert.Equal(t, []string{expSubject}, subject) + assert.Contains(t, wholemsg, expBody) + } + + msg := composeIssueCommentMessage(issue, doer, models.ActionCreateIssue, false, "test body", nil, tos, "TestTemplateSelection") + expect(t, msg, "issue/new/subject", "issue/new/body") + + comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment) + msg = composeIssueCommentMessage(issue, doer, models.ActionCommentIssue, false, "test body", comment, tos, "TestTemplateSelection") + expect(t, msg, "issue/default/subject", "issue/default/body") + + pull := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 2, Repo: repo, Poster: doer}).(*models.Issue) + comment = models.AssertExistsAndLoadBean(t, &models.Comment{ID: 4, Issue: pull}).(*models.Comment) + msg = composeIssueCommentMessage(pull, doer, models.ActionCommentIssue, false, "test body", comment, tos, "TestTemplateSelection") + expect(t, msg, "pull/comment/subject", "pull/comment/body") + + msg = composeIssueCommentMessage(issue, doer, models.ActionCloseIssue, false, "test body", nil, tos, "TestTemplateSelection") + expect(t, msg, "[user2/repo1] issue1 (#1)", "issue/close/body") +} + +func TestTemplateServices(t *testing.T) { + assert.NoError(t, models.PrepareTestDatabase()) + var mailService = setting.Mailer{ + From: "test@gitea.com", + } + + setting.MailService = &mailService + setting.Domain = "localhost" + + doer := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1, Owner: doer}).(*models.Repository) + issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1, Repo: repo, Poster: doer}).(*models.Issue) + comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment) + assert.NoError(t, issue.LoadRepo()) + + expect := func(t *testing.T, issue *models.Issue, comment *models.Comment, doer *models.User, + actionType models.ActionType, fromMention bool, tplSubject, tplBody, expSubject, expBody string) { + + stpl := texttmpl.Must(texttmpl.New("issue/default").Parse(tplSubject)) + btpl := template.Must(template.New("issue/default").Parse(tplBody)) + InitMailRender(stpl, btpl) + + tos := []string{"test@gitea.com"} + msg := composeIssueCommentMessage(issue, doer, actionType, fromMention, "test body", comment, tos, "TestTemplateServices") + + subject := msg.GetHeader("Subject") + msgbuf := new(bytes.Buffer) + _, _ = msg.WriteTo(msgbuf) + wholemsg := msgbuf.String() + + assert.Equal(t, []string{expSubject}, subject) + assert.Contains(t, wholemsg, "\r\n"+expBody+"\r\n") + } + + expect(t, issue, comment, doer, models.ActionCommentIssue, false, + "{{.SubjectPrefix}}[{{.Repo}}]: @{{.Doer.Name}} commented on #{{.Issue.Index}} - {{.Issue.Title}}", + "//{{.ActionType}},{{.ActionName}},{{if .IsMention}}norender{{end}}//", + "Re: [user2/repo1]: @user2 commented on #1 - issue1", + "//issue,comment,//") + + expect(t, issue, comment, doer, models.ActionCommentIssue, true, + "{{if .IsMention}}must render{{end}}", + "//subject is: {{.Subject}}//", + "must render", + "//subject is: must render//") + + expect(t, issue, comment, doer, models.ActionCommentIssue, true, + "{{.FallbackSubject}}", + "//{{.SubjectPrefix}}//", + "Re: [user2/repo1] issue1 (#1)", + "//Re: //") +} |