return issues, nil
}
+// GetParticipantsIDsByIssueID returns the IDs of all users who participated in comments of an issue,
+// but skips joining with `user` for performance reasons.
+// User permissions must be verified elsewhere if required.
+func GetParticipantsIDsByIssueID(issueID int64) ([]int64, error) {
+ userIDs := make([]int64, 0, 5)
+ return userIDs, x.Table("comment").
+ Cols("poster_id").
+ Where("issue_id = ?", issueID).
+ And("type in (?,?,?)", CommentTypeComment, CommentTypeCode, CommentTypeReview).
+ Distinct("poster_id").
+ Find(&userIDs)
+}
+
// GetParticipantsByIssueID returns all users who are participated in comments of an issue.
func GetParticipantsByIssueID(issueID int64) ([]*User, error) {
return getParticipantsByIssueID(x, issueID)
return
}
+// GetAssigneeIDsByIssue returns the IDs of users assigned to an issue
+// but skips joining with `user` for performance reasons.
+// User permissions must be verified elsewhere if required.
+func GetAssigneeIDsByIssue(issueID int64) ([]int64, error) {
+ userIDs := make([]int64, 0, 5)
+ return userIDs, x.Table("issue_assignees").
+ Cols("assignee_id").
+ Where("issue_id = ?", issueID).
+ Distinct("assignee_id").
+ Find(&userIDs)
+}
+
// GetAssigneesByIssue returns everyone assigned to that issue
func GetAssigneesByIssue(issue *Issue) (assignees []*User, err error) {
return getAssigneesByIssue(x, issue)
return
}
+// GetIssueWatchersIDs returns IDs of subscribers to a given issue id
+// but avoids joining with `user` for performance reasons
+// User permissions must be verified elsewhere if required
+func GetIssueWatchersIDs(issueID int64) ([]int64, error) {
+ ids := make([]int64, 0, 64)
+ return ids, x.Table("issue_watch").
+ Where("issue_id=?", issueID).
+ And("is_watching = ?", true).
+ Select("user_id").
+ Find(&ids)
+}
+
// GetIssueWatchers returns watchers/unwatchers of a given issue
func GetIssueWatchers(issueID int64) (IssueWatchList, error) {
return getIssueWatchers(x, issueID)
return getWatchers(x, repoID)
}
+// GetRepoWatchersIDs returns IDs of watchers for a given repo ID
+// but avoids joining with `user` for performance reasons
+// User permissions must be verified elsewhere if required
+func GetRepoWatchersIDs(repoID int64) ([]int64, error) {
+ ids := make([]int64, 0, 64)
+ return ids, x.Table("watch").
+ Where("watch.repo_id=?", repoID).
+ And("watch.mode<>?", RepoWatchModeDont).
+ Select("user_id").
+ Find(&ids)
+}
+
// GetWatchers returns range of users watching given repository.
func (repo *Repository) GetWatchers(page int) ([]*User, error) {
users := make([]*User, 0, ItemsPerPage)
return mails
}
+// GetMaileableUsersByIDs gets users from ids, but only if they can receive mails
+func GetMaileableUsersByIDs(ids []int64) ([]*User, error) {
+ if len(ids) == 0 {
+ return nil, nil
+ }
+ ous := make([]*User, 0, len(ids))
+ return ous, x.In("id", ids).
+ Where("`type` = ?", UserTypeIndividual).
+ And("`prohibit_login` = ?", false).
+ And("`is_active` = ?", true).
+ And("`email_notifications_preference` = ?", EmailNotificationsEnabled).
+ Find(&ous)
+}
+
// GetUsersByIDs returns all resolved users from a list of Ids.
func GetUsersByIDs(ids []int64) ([]*User, error) {
ous := make([]*User, 0, len(ids))
SendAsync(msg)
}
-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
- }
+func composeIssueCommentMessages(ctx *mailCommentContext, tos []string, fromMention bool, info string) []*Message {
var (
subject string
)
commentType := models.CommentTypeComment
- if comment != nil {
+ if ctx.Comment != nil {
prefix = "Re: "
- commentType = comment.Type
- link = issue.HTMLURL() + "#" + comment.HashTag()
+ commentType = ctx.Comment.Type
+ link = ctx.Issue.HTMLURL() + "#" + ctx.Comment.HashTag()
} else {
- link = issue.HTMLURL()
+ link = ctx.Issue.HTMLURL()
}
reviewType := models.ReviewTypeComment
- if comment != nil && comment.Review != nil {
- reviewType = comment.Review.Type
+ if ctx.Comment != nil && ctx.Comment.Review != nil {
+ reviewType = ctx.Comment.Review.Type
}
- fallback = prefix + fallbackMailSubject(issue)
+ fallback = prefix + fallbackMailSubject(ctx.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(ctx.Content), ctx.Issue.Repo.HTMLURL(), ctx.Issue.Repo.ComposeMetas()))
- actType, actName, tplName := actionToTemplate(issue, actionType, commentType, reviewType)
+ actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
- if comment != nil && comment.Review != nil {
+ if ctx.Comment != nil && ctx.Comment.Review != nil {
reviewComments = make([]*models.Comment, 0, 10)
- for _, lines := range comment.Review.CodeComments {
+ for _, lines := range ctx.Comment.Review.CodeComments {
for _, comments := range lines {
reviewComments = append(reviewComments, comments...)
}
"FallbackSubject": fallback,
"Body": body,
"Link": link,
- "Issue": issue,
- "Comment": comment,
- "IsPull": issue.IsPull,
- "User": issue.Repo.MustOwner(),
- "Repo": issue.Repo.FullName(),
- "Doer": doer,
+ "Issue": ctx.Issue,
+ "Comment": ctx.Comment,
+ "IsPull": ctx.Issue.IsPull,
+ "User": ctx.Issue.Repo.MustOwner(),
+ "Repo": ctx.Issue.Repo.FullName(),
+ "Doer": ctx.Doer,
"IsMention": fromMention,
"SubjectPrefix": prefix,
"ActionType": actType,
log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/body", err)
}
- msg := NewMessageFrom(tos, doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String())
- msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
-
- // Set Message-ID on first message so replies know what to reference
- if comment == nil {
- msg.SetHeader("Message-ID", "<"+issue.ReplyReference()+">")
- } else {
- msg.SetHeader("In-Reply-To", "<"+issue.ReplyReference()+">")
- msg.SetHeader("References", "<"+issue.ReplyReference()+">")
+ // Make sure to compose independent messages to avoid leaking user emails
+ msgs := make([]*Message, 0, len(tos))
+ for _, to := range tos {
+ msg := NewMessageFrom([]string{to}, ctx.Doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String())
+ msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
+
+ // Set Message-ID on first message so replies know what to reference
+ if ctx.Comment == nil {
+ msg.SetHeader("Message-ID", "<"+ctx.Issue.ReplyReference()+">")
+ } else {
+ msg.SetHeader("In-Reply-To", "<"+ctx.Issue.ReplyReference()+">")
+ msg.SetHeader("References", "<"+ctx.Issue.ReplyReference()+">")
+ }
+ msgs = append(msgs, msg)
}
- return msg
+ return msgs
}
func sanitizeSubject(subject string) string {
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, actionType models.ActionType, content string, comment *models.Comment, tos []string) {
- if len(tos) == 0 {
- return
- }
-
- 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, actionType models.ActionType, content string, comment *models.Comment, tos []string) {
- if len(tos) == 0 {
- return
- }
- SendAsync(composeIssueCommentMessage(issue, doer, actionType, true, content, comment, tos, "issue mention"))
+// SendIssueAssignedMail composes and sends issue assigned email
+func SendIssueAssignedMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) {
+ SendAsyncs(composeIssueCommentMessages(&mailCommentContext{
+ Issue: issue,
+ Doer: doer,
+ ActionType: models.ActionType(0),
+ Content: content,
+ Comment: comment,
+ }, tos, false, "issue assigned"))
}
// actionToTemplate returns the type and name of the action facing the user
}
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, models.ActionType(0), false, content, comment, tos, "issue assigned"))
-}
if err = models.UpdateIssueMentions(ctx, c.IssueID, userMentions); err != nil {
return fmt.Errorf("UpdateIssueMentions [%d]: %v", c.IssueID, err)
}
- mentions := make([]string, len(userMentions))
+ mentions := make([]int64, len(userMentions))
for i, u := range userMentions {
- mentions[i] = u.LowerName
+ mentions[i] = u.ID
}
- if err = mailIssueCommentToParticipants(issue, c.Poster, opType, c.Content, c, mentions); err != nil {
+ if err = mailIssueCommentToParticipants(
+ &mailCommentContext{
+ Issue: issue,
+ Doer: c.Poster,
+ ActionType: opType,
+ Content: c.Content,
+ Comment: c,
+ }, mentions); err != nil {
log.Error("mailIssueCommentToParticipants: %v", err)
}
return nil
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references"
-
- "github.com/unknwon/com"
)
func fallbackMailSubject(issue *models.Issue) string {
return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
}
+type mailCommentContext struct {
+ Issue *models.Issue
+ Doer *models.User
+ ActionType models.ActionType
+ Content string
+ Comment *models.Comment
+}
+
// mailIssueCommentToParticipants can be used for both new issue creation and comment.
// 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, actionType models.ActionType, content string, comment *models.Comment, mentions []string) error {
+func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []int64) error {
- watchers, err := models.GetWatchers(issue.RepoID)
- if err != nil {
- return fmt.Errorf("getWatchers [repo_id: %d]: %v", issue.RepoID, err)
+ // Required by the mail composer; make sure to load these before calling the async function
+ if err := ctx.Issue.LoadRepo(); err != nil {
+ return fmt.Errorf("LoadRepo(): %v", err)
}
- participants, err := models.GetParticipantsByIssueID(issue.ID)
- if err != nil {
- return fmt.Errorf("getParticipantsByIssueID [issue_id: %d]: %v", issue.ID, err)
+ if err := ctx.Issue.LoadPoster(); err != nil {
+ return fmt.Errorf("LoadPoster(): %v", err)
+ }
+ if err := ctx.Issue.LoadPullRequest(); err != nil {
+ return fmt.Errorf("LoadPullRequest(): %v", err)
}
- // In case the issue poster is not watching the repository and is active,
- // even if we have duplicated in watchers, can be safely filtered out.
- err = issue.LoadPoster()
+ // Enough room to avoid reallocations
+ unfiltered := make([]int64, 1, 64)
+
+ // =========== Original poster ===========
+ unfiltered[0] = ctx.Issue.PosterID
+
+ // =========== Assignees ===========
+ ids, err := models.GetAssigneeIDsByIssue(ctx.Issue.ID)
if err != nil {
- return fmt.Errorf("GetUserByID [%d]: %v", issue.PosterID, err)
- }
- if issue.PosterID != doer.ID && issue.Poster.IsActive && !issue.Poster.ProhibitLogin {
- participants = append(participants, issue.Poster)
+ return fmt.Errorf("GetAssigneeIDsByIssue(%d): %v", ctx.Issue.ID, err)
}
+ unfiltered = append(unfiltered, ids...)
- // Assignees must receive any communications
- assignees, err := models.GetAssigneesByIssue(issue)
+ // =========== Participants (i.e. commenters, reviewers) ===========
+ ids, err = models.GetParticipantsIDsByIssueID(ctx.Issue.ID)
if err != nil {
- return err
+ return fmt.Errorf("GetParticipantsIDsByIssueID(%d): %v", ctx.Issue.ID, err)
}
+ unfiltered = append(unfiltered, ids...)
- for _, assignee := range assignees {
- if assignee.ID != doer.ID {
- participants = append(participants, assignee)
- }
+ // =========== Issue watchers ===========
+ ids, err = models.GetIssueWatchersIDs(ctx.Issue.ID)
+ if err != nil {
+ return fmt.Errorf("GetIssueWatchersIDs(%d): %v", ctx.Issue.ID, err)
}
+ unfiltered = append(unfiltered, ids...)
- tos := make([]string, 0, len(watchers)) // List of email addresses.
- names := make([]string, 0, len(watchers))
- for i := range watchers {
- if watchers[i].UserID == doer.ID {
- continue
- }
-
- to, err := models.GetUserByID(watchers[i].UserID)
- if err != nil {
- return fmt.Errorf("GetUserByID [%d]: %v", watchers[i].UserID, err)
- }
- if to.IsOrganization() || to.EmailNotifications() != models.EmailNotificationsEnabled {
- continue
- }
-
- tos = append(tos, to.Email)
- names = append(names, to.Name)
+ // =========== Repo watchers ===========
+ // Make repo watchers last, since it's likely the list with the most users
+ ids, err = models.GetRepoWatchersIDs(ctx.Issue.RepoID)
+ if err != nil {
+ return fmt.Errorf("GetRepoWatchersIDs(%d): %v", ctx.Issue.RepoID, err)
}
- for i := range participants {
- if participants[i].ID == doer.ID ||
- com.IsSliceContainsStr(names, participants[i].Name) ||
- participants[i].EmailNotifications() != models.EmailNotificationsEnabled {
- continue
- }
+ unfiltered = append(ids, unfiltered...)
- tos = append(tos, participants[i].Email)
- names = append(names, participants[i].Name)
- }
+ visited := make(map[int64]bool, len(unfiltered)+len(mentions)+1)
- if err := issue.LoadRepo(); err != nil {
- return err
- }
+ // Avoid mailing the doer
+ visited[ctx.Doer.ID] = true
- for _, to := range tos {
- SendIssueCommentMail(issue, doer, actionType, content, comment, []string{to})
+ if err = mailIssueCommentBatch(ctx, unfiltered, visited, false); err != nil {
+ return fmt.Errorf("mailIssueCommentBatch(): %v", err)
}
- // Mail mentioned people and exclude watchers.
- names = append(names, doer.Name)
- tos = make([]string, 0, len(mentions)) // list of user names.
- for i := range mentions {
- if com.IsSliceContainsStr(names, mentions[i]) {
- continue
- }
-
- tos = append(tos, mentions[i])
+ // =========== Mentions ===========
+ if err = mailIssueCommentBatch(ctx, mentions, visited, true); err != nil {
+ return fmt.Errorf("mailIssueCommentBatch() mentions: %v", err)
}
- emails := models.GetUserEmailsByNames(tos)
+ return nil
+}
- for _, to := range emails {
- SendIssueMentionMail(issue, doer, actionType, content, comment, []string{to})
+func mailIssueCommentBatch(ctx *mailCommentContext, ids []int64, visited map[int64]bool, fromMention bool) error {
+ const batchSize = 100
+ for i := 0; i < len(ids); i += batchSize {
+ var last int
+ if i+batchSize < len(ids) {
+ last = i + batchSize
+ } else {
+ last = len(ids)
+ }
+ unique := make([]int64, 0, last-i)
+ for j := i; j < last; j++ {
+ id := ids[j]
+ if _, ok := visited[id]; !ok {
+ unique = append(unique, id)
+ visited[id] = true
+ }
+ }
+ recipients, err := models.GetMaileableUsersByIDs(unique)
+ if err != nil {
+ return err
+ }
+ // TODO: Check issue visibility for each user
+ // TODO: Separate recipients by language for i18n mail templates
+ tos := make([]string, len(recipients))
+ for i := range recipients {
+ tos[i] = recipients[i].Email
+ }
+ SendAsyncs(composeIssueCommentMessages(ctx, tos, fromMention, "issue comments"))
}
-
return nil
}
if err = models.UpdateIssueMentions(ctx, issue.ID, userMentions); err != nil {
return fmt.Errorf("UpdateIssueMentions [%d]: %v", issue.ID, err)
}
- mentions := make([]string, len(userMentions))
+ mentions := make([]int64, len(userMentions))
for i, u := range userMentions {
- mentions[i] = u.LowerName
- }
- if err = mailIssueCommentToParticipants(issue, doer, opType, issue.Content, nil, mentions); err != nil {
+ mentions[i] = u.ID
+ }
+ if err = mailIssueCommentToParticipants(
+ &mailCommentContext{
+ Issue: issue,
+ Doer: doer,
+ ActionType: opType,
+ Content: issue.Content,
+ Comment: nil,
+ }, mentions); err != nil {
log.Error("mailIssueCommentToParticipants: %v", err)
}
return nil
InitMailRender(stpl, btpl)
tos := []string{"test@gitea.com", "test2@gitea.com"}
- msg := composeIssueCommentMessage(issue, doer, models.ActionCommentIssue, false, "test body", comment, tos, "issue comment")
+ msgs := composeIssueCommentMessages(&mailCommentContext{Issue: issue, Doer: doer, ActionType: models.ActionCommentIssue,
+ Content: "test body", Comment: comment}, tos, false, "issue comment")
+ assert.Len(t, msgs, 2)
- subject := msg.GetHeader("Subject")
- inreplyTo := msg.GetHeader("In-Reply-To")
- references := msg.GetHeader("References")
+ mailto := msgs[0].GetHeader("To")
+ subject := msgs[0].GetHeader("Subject")
+ inreplyTo := msgs[0].GetHeader("In-Reply-To")
+ references := msgs[0].GetHeader("References")
+ assert.Len(t, mailto, 1, "exactly one recipient is expected in the To field")
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")
InitMailRender(stpl, btpl)
tos := []string{"test@gitea.com", "test2@gitea.com"}
- msg := composeIssueCommentMessage(issue, doer, models.ActionCreateIssue, false, "test body", nil, tos, "issue create")
+ msgs := composeIssueCommentMessages(&mailCommentContext{Issue: issue, Doer: doer, ActionType: models.ActionCreateIssue,
+ Content: "test body"}, tos, false, "issue create")
+ assert.Len(t, msgs, 2)
- subject := msg.GetHeader("Subject")
- messageID := msg.GetHeader("Message-ID")
+ mailto := msgs[0].GetHeader("To")
+ subject := msgs[0].GetHeader("Subject")
+ messageID := msgs[0].GetHeader("Message-ID")
+ assert.Len(t, mailto, 1, "exactly one recipient is expected in the To field")
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.Nil(t, msgs[0].GetHeader("In-Reply-To"))
+ assert.Nil(t, msgs[0].GetHeader("References"))
assert.Equal(t, messageID[0], "<user2/repo1/issues/1@localhost>", "Message-ID header doesn't match")
}
assert.Contains(t, wholemsg, expBody)
}
- msg := composeIssueCommentMessage(issue, doer, models.ActionCreateIssue, false, "test body", nil, tos, "TestTemplateSelection")
+ msg := testComposeIssueCommentMessage(t, &mailCommentContext{Issue: issue, Doer: doer, ActionType: models.ActionCreateIssue,
+ Content: "test body"}, tos, false, "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")
+ msg = testComposeIssueCommentMessage(t, &mailCommentContext{Issue: issue, Doer: doer, ActionType: models.ActionCommentIssue,
+ Content: "test body", Comment: comment}, tos, false, "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")
+ msg = testComposeIssueCommentMessage(t, &mailCommentContext{Issue: pull, Doer: doer, ActionType: models.ActionCommentIssue,
+ Content: "test body", Comment: comment}, tos, false, "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")
+ msg = testComposeIssueCommentMessage(t, &mailCommentContext{Issue: issue, Doer: doer, ActionType: models.ActionCloseIssue,
+ Content: "test body", Comment: comment}, tos, false, "TestTemplateSelection")
+ expect(t, msg, "Re: [user2/repo1] issue1 (#1)", "issue/close/body")
}
func TestTemplateServices(t *testing.T) {
InitMailRender(stpl, btpl)
tos := []string{"test@gitea.com"}
- msg := composeIssueCommentMessage(issue, doer, actionType, fromMention, "test body", comment, tos, "TestTemplateServices")
+ msg := testComposeIssueCommentMessage(t, &mailCommentContext{Issue: issue, Doer: doer, ActionType: actionType,
+ Content: "test body", Comment: comment}, tos, fromMention, "TestTemplateServices")
subject := msg.GetHeader("Subject")
msgbuf := new(bytes.Buffer)
"Re: [user2/repo1] issue1 (#1)",
"//Re: //")
}
+
+func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, tos []string, fromMention bool, info string) *Message {
+ msgs := composeIssueCommentMessages(ctx, tos, fromMention, info)
+ assert.Len(t, msgs, 1)
+ return msgs[0]
+}
go processMailQueue()
}
-// SendAsync send mail asynchronous
+// SendAsync send mail asynchronously
func SendAsync(msg *Message) {
go func() {
mailQueue <- msg
}()
}
+
+// SendAsyncs send mails asynchronously
+func SendAsyncs(msgs []*Message) {
+ go func() {
+ for _, msg := range msgs {
+ mailQueue <- msg
+ }
+ }()
+}