code.gitea.io/gitea@v1.22.3/services/mailer/mail_test.go (about)

     1  // Copyright 2019 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package mailer
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"fmt"
    10  	"html/template"
    11  	"io"
    12  	"mime/quotedprintable"
    13  	"regexp"
    14  	"strings"
    15  	"testing"
    16  	texttmpl "text/template"
    17  
    18  	activities_model "code.gitea.io/gitea/models/activities"
    19  	"code.gitea.io/gitea/models/db"
    20  	issues_model "code.gitea.io/gitea/models/issues"
    21  	repo_model "code.gitea.io/gitea/models/repo"
    22  	"code.gitea.io/gitea/models/unittest"
    23  	user_model "code.gitea.io/gitea/models/user"
    24  	"code.gitea.io/gitea/modules/markup"
    25  	"code.gitea.io/gitea/modules/setting"
    26  
    27  	"github.com/stretchr/testify/assert"
    28  )
    29  
    30  const subjectTpl = `
    31  {{.SubjectPrefix}}[{{.Repo}}] @{{.Doer.Name}} #{{.Issue.Index}} - {{.Issue.Title}}
    32  `
    33  
    34  const bodyTpl = `
    35  <!DOCTYPE html>
    36  <html>
    37  <head>
    38  	<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    39  	<title>{{.Subject}}</title>
    40  </head>
    41  
    42  <body>
    43  	<p>{{.Body}}</p>
    44  	<p>
    45  		---
    46  		<br>
    47  		<a href="{{.Link}}">View it on Gitea</a>.
    48  	</p>
    49  </body>
    50  </html>
    51  `
    52  
    53  func prepareMailerTest(t *testing.T) (doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment) {
    54  	assert.NoError(t, unittest.PrepareTestDatabase())
    55  	mailService := setting.Mailer{
    56  		From: "test@gitea.com",
    57  	}
    58  
    59  	setting.MailService = &mailService
    60  	setting.Domain = "localhost"
    61  
    62  	doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
    63  	repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, Owner: doer})
    64  	issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1, Repo: repo, Poster: doer})
    65  	assert.NoError(t, issue.LoadRepo(db.DefaultContext))
    66  	comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2, Issue: issue})
    67  	return doer, repo, issue, comment
    68  }
    69  
    70  func TestComposeIssueCommentMessage(t *testing.T) {
    71  	doer, _, issue, comment := prepareMailerTest(t)
    72  
    73  	markup.Init(&markup.ProcessorHelper{
    74  		IsUsernameMentionable: func(ctx context.Context, username string) bool {
    75  			return username == doer.Name
    76  		},
    77  	})
    78  
    79  	setting.IncomingEmail.Enabled = true
    80  	defer func() { setting.IncomingEmail.Enabled = false }()
    81  
    82  	subjectTemplates = texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl))
    83  	bodyTemplates = template.Must(template.New("issue/comment").Parse(bodyTpl))
    84  
    85  	recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
    86  	msgs, err := composeIssueCommentMessages(&mailCommentContext{
    87  		Context: context.TODO(), // TODO: use a correct context
    88  		Issue:   issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
    89  		Content: fmt.Sprintf("test @%s %s#%d body", doer.Name, issue.Repo.FullName(), issue.Index),
    90  		Comment: comment,
    91  	}, "en-US", recipients, false, "issue comment")
    92  	assert.NoError(t, err)
    93  	assert.Len(t, msgs, 2)
    94  	gomailMsg := msgs[0].ToMessage()
    95  	replyTo := gomailMsg.GetHeader("Reply-To")[0]
    96  	subject := gomailMsg.GetHeader("Subject")[0]
    97  
    98  	assert.Len(t, gomailMsg.GetHeader("To"), 1, "exactly one recipient is expected in the To field")
    99  	tokenRegex := regexp.MustCompile(`\Aincoming\+(.+)@localhost\z`)
   100  	assert.Regexp(t, tokenRegex, replyTo)
   101  	token := tokenRegex.FindAllStringSubmatch(replyTo, 1)[0][1]
   102  	assert.Equal(t, "Re: ", subject[:4], "Comment reply subject should contain Re:")
   103  	assert.Equal(t, "Re: [user2/repo1] @user2 #1 - issue1", subject)
   104  	assert.Equal(t, "<user2/repo1/issues/1@localhost>", gomailMsg.GetHeader("In-Reply-To")[0], "In-Reply-To header doesn't match")
   105  	assert.ElementsMatch(t, []string{"<user2/repo1/issues/1@localhost>", "<reply-" + token + "@localhost>"}, gomailMsg.GetHeader("References"), "References header doesn't match")
   106  	assert.Equal(t, "<user2/repo1/issues/1/comment/2@localhost>", gomailMsg.GetHeader("Message-ID")[0], "Message-ID header doesn't match")
   107  	assert.Equal(t, "<mailto:"+replyTo+">", gomailMsg.GetHeader("List-Post")[0])
   108  	assert.Len(t, gomailMsg.GetHeader("List-Unsubscribe"), 2) // url + mailto
   109  
   110  	var buf bytes.Buffer
   111  	gomailMsg.WriteTo(&buf)
   112  
   113  	b, err := io.ReadAll(quotedprintable.NewReader(&buf))
   114  	assert.NoError(t, err)
   115  
   116  	// text/plain
   117  	assert.Contains(t, string(b), fmt.Sprintf(`( %s )`, doer.HTMLURL()))
   118  	assert.Contains(t, string(b), fmt.Sprintf(`( %s )`, issue.HTMLURL()))
   119  
   120  	// text/html
   121  	assert.Contains(t, string(b), fmt.Sprintf(`href="%s"`, doer.HTMLURL()))
   122  	assert.Contains(t, string(b), fmt.Sprintf(`href="%s"`, issue.HTMLURL()))
   123  }
   124  
   125  func TestComposeIssueMessage(t *testing.T) {
   126  	doer, _, issue, _ := prepareMailerTest(t)
   127  
   128  	subjectTemplates = texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl))
   129  	bodyTemplates = template.Must(template.New("issue/new").Parse(bodyTpl))
   130  
   131  	recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
   132  	msgs, err := composeIssueCommentMessages(&mailCommentContext{
   133  		Context: context.TODO(), // TODO: use a correct context
   134  		Issue:   issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
   135  		Content: "test body",
   136  	}, "en-US", recipients, false, "issue create")
   137  	assert.NoError(t, err)
   138  	assert.Len(t, msgs, 2)
   139  
   140  	gomailMsg := msgs[0].ToMessage()
   141  	mailto := gomailMsg.GetHeader("To")
   142  	subject := gomailMsg.GetHeader("Subject")
   143  	messageID := gomailMsg.GetHeader("Message-ID")
   144  	inReplyTo := gomailMsg.GetHeader("In-Reply-To")
   145  	references := gomailMsg.GetHeader("References")
   146  
   147  	assert.Len(t, mailto, 1, "exactly one recipient is expected in the To field")
   148  	assert.Equal(t, "[user2/repo1] @user2 #1 - issue1", subject[0])
   149  	assert.Equal(t, "<user2/repo1/issues/1@localhost>", inReplyTo[0], "In-Reply-To header doesn't match")
   150  	assert.Equal(t, "<user2/repo1/issues/1@localhost>", references[0], "References header doesn't match")
   151  	assert.Equal(t, "<user2/repo1/issues/1@localhost>", messageID[0], "Message-ID header doesn't match")
   152  	assert.Empty(t, gomailMsg.GetHeader("List-Post"))         // incoming mail feature disabled
   153  	assert.Len(t, gomailMsg.GetHeader("List-Unsubscribe"), 1) // url without mailto
   154  }
   155  
   156  func TestTemplateSelection(t *testing.T) {
   157  	doer, repo, issue, comment := prepareMailerTest(t)
   158  	recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
   159  
   160  	subjectTemplates = texttmpl.Must(texttmpl.New("issue/default").Parse("issue/default/subject"))
   161  	texttmpl.Must(subjectTemplates.New("issue/new").Parse("issue/new/subject"))
   162  	texttmpl.Must(subjectTemplates.New("pull/comment").Parse("pull/comment/subject"))
   163  	texttmpl.Must(subjectTemplates.New("issue/close").Parse("")) // Must default to fallback subject
   164  
   165  	bodyTemplates = template.Must(template.New("issue/default").Parse("issue/default/body"))
   166  	template.Must(bodyTemplates.New("issue/new").Parse("issue/new/body"))
   167  	template.Must(bodyTemplates.New("pull/comment").Parse("pull/comment/body"))
   168  	template.Must(bodyTemplates.New("issue/close").Parse("issue/close/body"))
   169  
   170  	expect := func(t *testing.T, msg *Message, expSubject, expBody string) {
   171  		subject := msg.ToMessage().GetHeader("Subject")
   172  		msgbuf := new(bytes.Buffer)
   173  		_, _ = msg.ToMessage().WriteTo(msgbuf)
   174  		wholemsg := msgbuf.String()
   175  		assert.Equal(t, []string{expSubject}, subject)
   176  		assert.Contains(t, wholemsg, expBody)
   177  	}
   178  
   179  	msg := testComposeIssueCommentMessage(t, &mailCommentContext{
   180  		Context: context.TODO(), // TODO: use a correct context
   181  		Issue:   issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
   182  		Content: "test body",
   183  	}, recipients, false, "TestTemplateSelection")
   184  	expect(t, msg, "issue/new/subject", "issue/new/body")
   185  
   186  	msg = testComposeIssueCommentMessage(t, &mailCommentContext{
   187  		Context: context.TODO(), // TODO: use a correct context
   188  		Issue:   issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
   189  		Content: "test body", Comment: comment,
   190  	}, recipients, false, "TestTemplateSelection")
   191  	expect(t, msg, "issue/default/subject", "issue/default/body")
   192  
   193  	pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2, Repo: repo, Poster: doer})
   194  	comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 4, Issue: pull})
   195  	msg = testComposeIssueCommentMessage(t, &mailCommentContext{
   196  		Context: context.TODO(), // TODO: use a correct context
   197  		Issue:   pull, Doer: doer, ActionType: activities_model.ActionCommentPull,
   198  		Content: "test body", Comment: comment,
   199  	}, recipients, false, "TestTemplateSelection")
   200  	expect(t, msg, "pull/comment/subject", "pull/comment/body")
   201  
   202  	msg = testComposeIssueCommentMessage(t, &mailCommentContext{
   203  		Context: context.TODO(), // TODO: use a correct context
   204  		Issue:   issue, Doer: doer, ActionType: activities_model.ActionCloseIssue,
   205  		Content: "test body", Comment: comment,
   206  	}, recipients, false, "TestTemplateSelection")
   207  	expect(t, msg, "Re: [user2/repo1] issue1 (#1)", "issue/close/body")
   208  }
   209  
   210  func TestTemplateServices(t *testing.T) {
   211  	doer, _, issue, comment := prepareMailerTest(t)
   212  	assert.NoError(t, issue.LoadRepo(db.DefaultContext))
   213  
   214  	expect := func(t *testing.T, issue *issues_model.Issue, comment *issues_model.Comment, doer *user_model.User,
   215  		actionType activities_model.ActionType, fromMention bool, tplSubject, tplBody, expSubject, expBody string,
   216  	) {
   217  		subjectTemplates = texttmpl.Must(texttmpl.New("issue/default").Parse(tplSubject))
   218  		bodyTemplates = template.Must(template.New("issue/default").Parse(tplBody))
   219  
   220  		recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
   221  		msg := testComposeIssueCommentMessage(t, &mailCommentContext{
   222  			Context: context.TODO(), // TODO: use a correct context
   223  			Issue:   issue, Doer: doer, ActionType: actionType,
   224  			Content: "test body", Comment: comment,
   225  		}, recipients, fromMention, "TestTemplateServices")
   226  
   227  		subject := msg.ToMessage().GetHeader("Subject")
   228  		msgbuf := new(bytes.Buffer)
   229  		_, _ = msg.ToMessage().WriteTo(msgbuf)
   230  		wholemsg := msgbuf.String()
   231  
   232  		assert.Equal(t, []string{expSubject}, subject)
   233  		assert.Contains(t, wholemsg, "\r\n"+expBody+"\r\n")
   234  	}
   235  
   236  	expect(t, issue, comment, doer, activities_model.ActionCommentIssue, false,
   237  		"{{.SubjectPrefix}}[{{.Repo}}]: @{{.Doer.Name}} commented on #{{.Issue.Index}} - {{.Issue.Title}}",
   238  		"//{{.ActionType}},{{.ActionName}},{{if .IsMention}}norender{{end}}//",
   239  		"Re: [user2/repo1]: @user2 commented on #1 - issue1",
   240  		"//issue,comment,//")
   241  
   242  	expect(t, issue, comment, doer, activities_model.ActionCommentIssue, true,
   243  		"{{if .IsMention}}must render{{end}}",
   244  		"//subject is: {{.Subject}}//",
   245  		"must render",
   246  		"//subject is: must render//")
   247  
   248  	expect(t, issue, comment, doer, activities_model.ActionCommentIssue, true,
   249  		"{{.FallbackSubject}}",
   250  		"//{{.SubjectPrefix}}//",
   251  		"Re: [user2/repo1] issue1 (#1)",
   252  		"//Re: //")
   253  }
   254  
   255  func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recipients []*user_model.User, fromMention bool, info string) *Message {
   256  	msgs, err := composeIssueCommentMessages(ctx, "en-US", recipients, fromMention, info)
   257  	assert.NoError(t, err)
   258  	assert.Len(t, msgs, 1)
   259  	return msgs[0]
   260  }
   261  
   262  func TestGenerateAdditionalHeaders(t *testing.T) {
   263  	doer, _, issue, _ := prepareMailerTest(t)
   264  
   265  	ctx := &mailCommentContext{Context: context.TODO() /* TODO: use a correct context */, Issue: issue, Doer: doer}
   266  	recipient := &user_model.User{Name: "test", Email: "test@gitea.com"}
   267  
   268  	headers := generateAdditionalHeaders(ctx, "dummy-reason", recipient)
   269  
   270  	expected := map[string]string{
   271  		"List-ID":                   "user2/repo1 <repo1.user2.localhost>",
   272  		"List-Archive":              "<https://try.gitea.io/user2/repo1>",
   273  		"X-Gitea-Reason":            "dummy-reason",
   274  		"X-Gitea-Sender":            "user2",
   275  		"X-Gitea-Recipient":         "test",
   276  		"X-Gitea-Recipient-Address": "test@gitea.com",
   277  		"X-Gitea-Repository":        "repo1",
   278  		"X-Gitea-Repository-Path":   "user2/repo1",
   279  		"X-Gitea-Repository-Link":   "https://try.gitea.io/user2/repo1",
   280  		"X-Gitea-Issue-ID":          "1",
   281  		"X-Gitea-Issue-Link":        "https://try.gitea.io/user2/repo1/issues/1",
   282  	}
   283  
   284  	for key, value := range expected {
   285  		if assert.Contains(t, headers, key) {
   286  			assert.Equal(t, value, headers[key])
   287  		}
   288  	}
   289  }
   290  
   291  func TestGenerateMessageIDForIssue(t *testing.T) {
   292  	_, _, issue, comment := prepareMailerTest(t)
   293  	_, _, pullIssue, _ := prepareMailerTest(t)
   294  	pullIssue.IsPull = true
   295  
   296  	type args struct {
   297  		issue      *issues_model.Issue
   298  		comment    *issues_model.Comment
   299  		actionType activities_model.ActionType
   300  	}
   301  	tests := []struct {
   302  		name   string
   303  		args   args
   304  		prefix string
   305  	}{
   306  		{
   307  			name: "Open Issue",
   308  			args: args{
   309  				issue:      issue,
   310  				actionType: activities_model.ActionCreateIssue,
   311  			},
   312  			prefix: fmt.Sprintf("<%s/issues/%d@%s>", issue.Repo.FullName(), issue.Index, setting.Domain),
   313  		},
   314  		{
   315  			name: "Open Pull",
   316  			args: args{
   317  				issue:      pullIssue,
   318  				actionType: activities_model.ActionCreatePullRequest,
   319  			},
   320  			prefix: fmt.Sprintf("<%s/pulls/%d@%s>", issue.Repo.FullName(), issue.Index, setting.Domain),
   321  		},
   322  		{
   323  			name: "Comment Issue",
   324  			args: args{
   325  				issue:      issue,
   326  				comment:    comment,
   327  				actionType: activities_model.ActionCommentIssue,
   328  			},
   329  			prefix: fmt.Sprintf("<%s/issues/%d/comment/%d@%s>", issue.Repo.FullName(), issue.Index, comment.ID, setting.Domain),
   330  		},
   331  		{
   332  			name: "Comment Pull",
   333  			args: args{
   334  				issue:      pullIssue,
   335  				comment:    comment,
   336  				actionType: activities_model.ActionCommentPull,
   337  			},
   338  			prefix: fmt.Sprintf("<%s/pulls/%d/comment/%d@%s>", issue.Repo.FullName(), issue.Index, comment.ID, setting.Domain),
   339  		},
   340  		{
   341  			name: "Close Issue",
   342  			args: args{
   343  				issue:      issue,
   344  				actionType: activities_model.ActionCloseIssue,
   345  			},
   346  			prefix: fmt.Sprintf("<%s/issues/%d/close/", issue.Repo.FullName(), issue.Index),
   347  		},
   348  		{
   349  			name: "Close Pull",
   350  			args: args{
   351  				issue:      pullIssue,
   352  				actionType: activities_model.ActionClosePullRequest,
   353  			},
   354  			prefix: fmt.Sprintf("<%s/pulls/%d/close/", issue.Repo.FullName(), issue.Index),
   355  		},
   356  		{
   357  			name: "Reopen Issue",
   358  			args: args{
   359  				issue:      issue,
   360  				actionType: activities_model.ActionReopenIssue,
   361  			},
   362  			prefix: fmt.Sprintf("<%s/issues/%d/reopen/", issue.Repo.FullName(), issue.Index),
   363  		},
   364  		{
   365  			name: "Reopen Pull",
   366  			args: args{
   367  				issue:      pullIssue,
   368  				actionType: activities_model.ActionReopenPullRequest,
   369  			},
   370  			prefix: fmt.Sprintf("<%s/pulls/%d/reopen/", issue.Repo.FullName(), issue.Index),
   371  		},
   372  		{
   373  			name: "Merge Pull",
   374  			args: args{
   375  				issue:      pullIssue,
   376  				actionType: activities_model.ActionMergePullRequest,
   377  			},
   378  			prefix: fmt.Sprintf("<%s/pulls/%d/merge/", issue.Repo.FullName(), issue.Index),
   379  		},
   380  		{
   381  			name: "Ready Pull",
   382  			args: args{
   383  				issue:      pullIssue,
   384  				actionType: activities_model.ActionPullRequestReadyForReview,
   385  			},
   386  			prefix: fmt.Sprintf("<%s/pulls/%d/ready/", issue.Repo.FullName(), issue.Index),
   387  		},
   388  	}
   389  	for _, tt := range tests {
   390  		t.Run(tt.name, func(t *testing.T) {
   391  			got := generateMessageIDForIssue(tt.args.issue, tt.args.comment, tt.args.actionType)
   392  			if !strings.HasPrefix(got, tt.prefix) {
   393  				t.Errorf("generateMessageIDForIssue() = %v, want %v", got, tt.prefix)
   394  			}
   395  		})
   396  	}
   397  }
   398  
   399  func TestGenerateMessageIDForRelease(t *testing.T) {
   400  	msgID := generateMessageIDForRelease(&repo_model.Release{
   401  		ID:   1,
   402  		Repo: &repo_model.Repository{OwnerName: "owner", Name: "repo"},
   403  	})
   404  	assert.Equal(t, "<owner/repo/releases/1@localhost>", msgID)
   405  }