mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Rewrite delivery of issue and comment mails (#9009)
* Mail issue subscribers, rework the function * Simplify a little more * Fix unused variable * Refactor mail delivery to avoid heavy load on server * Avoid splitting into too many goroutines * Fix comments and optimize GetMaileableUsersByIDs() * Fix return on errors
This commit is contained in:
		| @@ -1219,6 +1219,19 @@ func Issues(opts *IssuesOptions) ([]*Issue, error) { | |||||||
| 	return issues, nil | 	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. | // GetParticipantsByIssueID returns all users who are participated in comments of an issue. | ||||||
| func GetParticipantsByIssueID(issueID int64) ([]*User, error) { | func GetParticipantsByIssueID(issueID int64) ([]*User, error) { | ||||||
| 	return getParticipantsByIssueID(x, issueID) | 	return getParticipantsByIssueID(x, issueID) | ||||||
|   | |||||||
| @@ -41,6 +41,18 @@ func (issue *Issue) loadAssignees(e Engine) (err error) { | |||||||
| 	return | 	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 | // GetAssigneesByIssue returns everyone assigned to that issue | ||||||
| func GetAssigneesByIssue(issue *Issue) (assignees []*User, err error) { | func GetAssigneesByIssue(issue *Issue) (assignees []*User, err error) { | ||||||
| 	return getAssigneesByIssue(x, issue) | 	return getAssigneesByIssue(x, issue) | ||||||
|   | |||||||
| @@ -60,6 +60,18 @@ func getIssueWatch(e Engine, userID, issueID int64) (iw *IssueWatch, exists bool | |||||||
| 	return | 	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 | // GetIssueWatchers returns watchers/unwatchers of a given issue | ||||||
| func GetIssueWatchers(issueID int64) (IssueWatchList, error) { | func GetIssueWatchers(issueID int64) (IssueWatchList, error) { | ||||||
| 	return getIssueWatchers(x, issueID) | 	return getIssueWatchers(x, issueID) | ||||||
|   | |||||||
| @@ -140,6 +140,18 @@ func GetWatchers(repoID int64) ([]*Watch, error) { | |||||||
| 	return getWatchers(x, repoID) | 	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. | // GetWatchers returns range of users watching given repository. | ||||||
| func (repo *Repository) GetWatchers(page int) ([]*User, error) { | func (repo *Repository) GetWatchers(page int) ([]*User, error) { | ||||||
| 	users := make([]*User, 0, ItemsPerPage) | 	users := make([]*User, 0, ItemsPerPage) | ||||||
|   | |||||||
| @@ -1307,6 +1307,20 @@ func getUserEmailsByNames(e Engine, names []string) []string { | |||||||
| 	return mails | 	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. | // GetUsersByIDs returns all resolved users from a list of Ids. | ||||||
| func GetUsersByIDs(ids []int64) ([]*User, error) { | func GetUsersByIDs(ids []int64) ([]*User, error) { | ||||||
| 	ous := make([]*User, 0, len(ids)) | 	ous := make([]*User, 0, len(ids)) | ||||||
|   | |||||||
| @@ -164,13 +164,7 @@ func SendCollaboratorMail(u, doer *models.User, repo *models.Repository) { | |||||||
| 	SendAsync(msg) | 	SendAsync(msg) | ||||||
| } | } | ||||||
|  |  | ||||||
| func composeIssueCommentMessage(issue *models.Issue, doer *models.User, actionType models.ActionType, fromMention bool, | func composeIssueCommentMessages(ctx *mailCommentContext, tos []string, fromMention bool, info string) []*Message { | ||||||
| 	content string, comment *models.Comment, tos []string, info string) *Message { |  | ||||||
|  |  | ||||||
| 	if err := issue.LoadPullRequest(); err != nil { |  | ||||||
| 		log.Error("LoadPullRequest: %v", err) |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	var ( | 	var ( | ||||||
| 		subject string | 		subject string | ||||||
| @@ -182,29 +176,29 @@ func composeIssueCommentMessage(issue *models.Issue, doer *models.User, actionTy | |||||||
| 	) | 	) | ||||||
|  |  | ||||||
| 	commentType := models.CommentTypeComment | 	commentType := models.CommentTypeComment | ||||||
| 	if comment != nil { | 	if ctx.Comment != nil { | ||||||
| 		prefix = "Re: " | 		prefix = "Re: " | ||||||
| 		commentType = comment.Type | 		commentType = ctx.Comment.Type | ||||||
| 		link = issue.HTMLURL() + "#" + comment.HashTag() | 		link = ctx.Issue.HTMLURL() + "#" + ctx.Comment.HashTag() | ||||||
| 	} else { | 	} else { | ||||||
| 		link = issue.HTMLURL() | 		link = ctx.Issue.HTMLURL() | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	reviewType := models.ReviewTypeComment | 	reviewType := models.ReviewTypeComment | ||||||
| 	if comment != nil && comment.Review != nil { | 	if ctx.Comment != nil && ctx.Comment.Review != nil { | ||||||
| 		reviewType = comment.Review.Type | 		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 | 	// 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) | 		reviewComments = make([]*models.Comment, 0, 10) | ||||||
| 		for _, lines := range comment.Review.CodeComments { | 		for _, lines := range ctx.Comment.Review.CodeComments { | ||||||
| 			for _, comments := range lines { | 			for _, comments := range lines { | ||||||
| 				reviewComments = append(reviewComments, comments...) | 				reviewComments = append(reviewComments, comments...) | ||||||
| 			} | 			} | ||||||
| @@ -215,12 +209,12 @@ func composeIssueCommentMessage(issue *models.Issue, doer *models.User, actionTy | |||||||
| 		"FallbackSubject": fallback, | 		"FallbackSubject": fallback, | ||||||
| 		"Body":            body, | 		"Body":            body, | ||||||
| 		"Link":            link, | 		"Link":            link, | ||||||
| 		"Issue":           issue, | 		"Issue":           ctx.Issue, | ||||||
| 		"Comment":         comment, | 		"Comment":         ctx.Comment, | ||||||
| 		"IsPull":          issue.IsPull, | 		"IsPull":          ctx.Issue.IsPull, | ||||||
| 		"User":            issue.Repo.MustOwner(), | 		"User":            ctx.Issue.Repo.MustOwner(), | ||||||
| 		"Repo":            issue.Repo.FullName(), | 		"Repo":            ctx.Issue.Repo.FullName(), | ||||||
| 		"Doer":            doer, | 		"Doer":            ctx.Doer, | ||||||
| 		"IsMention":       fromMention, | 		"IsMention":       fromMention, | ||||||
| 		"SubjectPrefix":   prefix, | 		"SubjectPrefix":   prefix, | ||||||
| 		"ActionType":      actType, | 		"ActionType":      actType, | ||||||
| @@ -246,18 +240,23 @@ func composeIssueCommentMessage(issue *models.Issue, doer *models.User, actionTy | |||||||
| 		log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/body", err) | 		log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/body", err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	msg := NewMessageFrom(tos, doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String()) | 	// 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) | 		msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info) | ||||||
|  |  | ||||||
| 		// Set Message-ID on first message so replies know what to reference | 		// Set Message-ID on first message so replies know what to reference | ||||||
| 	if comment == nil { | 		if ctx.Comment == nil { | ||||||
| 		msg.SetHeader("Message-ID", "<"+issue.ReplyReference()+">") | 			msg.SetHeader("Message-ID", "<"+ctx.Issue.ReplyReference()+">") | ||||||
| 		} else { | 		} else { | ||||||
| 		msg.SetHeader("In-Reply-To", "<"+issue.ReplyReference()+">") | 			msg.SetHeader("In-Reply-To", "<"+ctx.Issue.ReplyReference()+">") | ||||||
| 		msg.SetHeader("References", "<"+issue.ReplyReference()+">") | 			msg.SetHeader("References", "<"+ctx.Issue.ReplyReference()+">") | ||||||
|  | 		} | ||||||
|  | 		msgs = append(msgs, msg) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return msg | 	return msgs | ||||||
| } | } | ||||||
|  |  | ||||||
| func sanitizeSubject(subject string) string { | func sanitizeSubject(subject string) string { | ||||||
| @@ -269,21 +268,15 @@ func sanitizeSubject(subject string) string { | |||||||
| 	return mime.QEncoding.Encode("utf-8", string(runes)) | 	return mime.QEncoding.Encode("utf-8", string(runes)) | ||||||
| } | } | ||||||
|  |  | ||||||
| // SendIssueCommentMail composes and sends issue comment emails to target receivers. | // SendIssueAssignedMail composes and sends issue assigned email | ||||||
| func SendIssueCommentMail(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, tos []string) { | func SendIssueAssignedMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, tos []string) { | ||||||
| 	if len(tos) == 0 { | 	SendAsyncs(composeIssueCommentMessages(&mailCommentContext{ | ||||||
| 		return | 		Issue:      issue, | ||||||
| 	} | 		Doer:       doer, | ||||||
|  | 		ActionType: models.ActionType(0), | ||||||
| 	SendAsync(composeIssueCommentMessage(issue, doer, actionType, false, content, comment, tos, "issue comment")) | 		Content:    content, | ||||||
| } | 		Comment:    comment, | ||||||
|  | 	}, tos, false, "issue assigned")) | ||||||
| // 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")) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // actionToTemplate returns the type and name of the action facing the user | // actionToTemplate returns the type and name of the action facing the user | ||||||
| @@ -341,8 +334,3 @@ func actionToTemplate(issue *models.Issue, actionType models.ActionType, | |||||||
| 	} | 	} | ||||||
| 	return | 	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")) |  | ||||||
| } |  | ||||||
|   | |||||||
| @@ -27,11 +27,18 @@ func mailParticipantsComment(ctx models.DBContext, c *models.Comment, opType mod | |||||||
| 	if err = models.UpdateIssueMentions(ctx, c.IssueID, userMentions); err != nil { | 	if err = models.UpdateIssueMentions(ctx, c.IssueID, userMentions); err != nil { | ||||||
| 		return fmt.Errorf("UpdateIssueMentions [%d]: %v", c.IssueID, err) | 		return fmt.Errorf("UpdateIssueMentions [%d]: %v", c.IssueID, err) | ||||||
| 	} | 	} | ||||||
| 	mentions := make([]string, len(userMentions)) | 	mentions := make([]int64, len(userMentions)) | ||||||
| 	for i, u := range 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) | 		log.Error("mailIssueCommentToParticipants: %v", err) | ||||||
| 	} | 	} | ||||||
| 	return nil | 	return nil | ||||||
|   | |||||||
| @@ -10,105 +10,118 @@ import ( | |||||||
| 	"code.gitea.io/gitea/models" | 	"code.gitea.io/gitea/models" | ||||||
| 	"code.gitea.io/gitea/modules/log" | 	"code.gitea.io/gitea/modules/log" | ||||||
| 	"code.gitea.io/gitea/modules/references" | 	"code.gitea.io/gitea/modules/references" | ||||||
|  |  | ||||||
| 	"github.com/unknwon/com" |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func fallbackMailSubject(issue *models.Issue) string { | func fallbackMailSubject(issue *models.Issue) string { | ||||||
| 	return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index) | 	return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | 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. | // mailIssueCommentToParticipants can be used for both new issue creation and comment. | ||||||
| // This function sends two list of emails: | // This function sends two list of emails: | ||||||
| // 1. Repository watchers and users who are participated in comments. | // 1. Repository watchers and users who are participated in comments. | ||||||
| // 2. Users who are not in 1. but get mentioned in current issue/comment. | // 2. Users who are not in 1. but get mentioned in current issue/comment. | ||||||
| func mailIssueCommentToParticipants(issue *models.Issue, doer *models.User, actionType models.ActionType, content string, comment *models.Comment, mentions []string) error { | func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []int64) error { | ||||||
|  |  | ||||||
| 	watchers, err := models.GetWatchers(issue.RepoID) | 	// Required by the mail composer; make sure to load these before calling the async function | ||||||
| 	if err != nil { | 	if err := ctx.Issue.LoadRepo(); err != nil { | ||||||
| 		return fmt.Errorf("getWatchers [repo_id: %d]: %v", issue.RepoID, err) | 		return fmt.Errorf("LoadRepo(): %v", err) | ||||||
| 	} | 	} | ||||||
| 	participants, err := models.GetParticipantsByIssueID(issue.ID) | 	if err := ctx.Issue.LoadPoster(); err != nil { | ||||||
| 	if err != nil { | 		return fmt.Errorf("LoadPoster(): %v", err) | ||||||
| 		return fmt.Errorf("getParticipantsByIssueID [issue_id: %d]: %v", issue.ID, 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, | 	// Enough room to avoid reallocations | ||||||
| 	// even if we have duplicated in watchers, can be safely filtered out. | 	unfiltered := make([]int64, 1, 64) | ||||||
| 	err = issue.LoadPoster() |  | ||||||
|  | 	// =========== Original poster =========== | ||||||
|  | 	unfiltered[0] = ctx.Issue.PosterID | ||||||
|  |  | ||||||
|  | 	// =========== Assignees =========== | ||||||
|  | 	ids, err := models.GetAssigneeIDsByIssue(ctx.Issue.ID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("GetUserByID [%d]: %v", issue.PosterID, err) | 		return fmt.Errorf("GetAssigneeIDsByIssue(%d): %v", ctx.Issue.ID, err) | ||||||
| 	} | 	} | ||||||
| 	if issue.PosterID != doer.ID && issue.Poster.IsActive && !issue.Poster.ProhibitLogin { | 	unfiltered = append(unfiltered, ids...) | ||||||
| 		participants = append(participants, issue.Poster) |  | ||||||
|  | 	// =========== Participants (i.e. commenters, reviewers) =========== | ||||||
|  | 	ids, err = models.GetParticipantsIDsByIssueID(ctx.Issue.ID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("GetParticipantsIDsByIssueID(%d): %v", ctx.Issue.ID, err) | ||||||
|  | 	} | ||||||
|  | 	unfiltered = append(unfiltered, ids...) | ||||||
|  |  | ||||||
|  | 	// =========== 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...) | ||||||
|  |  | ||||||
|  | 	// =========== 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) | ||||||
|  | 	} | ||||||
|  | 	unfiltered = append(ids, unfiltered...) | ||||||
|  |  | ||||||
|  | 	visited := make(map[int64]bool, len(unfiltered)+len(mentions)+1) | ||||||
|  |  | ||||||
|  | 	// Avoid mailing the doer | ||||||
|  | 	visited[ctx.Doer.ID] = true | ||||||
|  |  | ||||||
|  | 	if err = mailIssueCommentBatch(ctx, unfiltered, visited, false); err != nil { | ||||||
|  | 		return fmt.Errorf("mailIssueCommentBatch(): %v", err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Assignees must receive any communications | 	// =========== Mentions =========== | ||||||
| 	assignees, err := models.GetAssigneesByIssue(issue) | 	if err = mailIssueCommentBatch(ctx, mentions, visited, true); err != nil { | ||||||
|  | 		return fmt.Errorf("mailIssueCommentBatch() mentions: %v", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
|  | 		// TODO: Check issue visibility for each user | ||||||
| 	for _, assignee := range assignees { | 		// TODO: Separate recipients by language for i18n mail templates | ||||||
| 		if assignee.ID != doer.ID { | 		tos := make([]string, len(recipients)) | ||||||
| 			participants = append(participants, assignee) | 		for i := range recipients { | ||||||
|  | 			tos[i] = recipients[i].Email | ||||||
| 		} | 		} | ||||||
|  | 		SendAsyncs(composeIssueCommentMessages(ctx, tos, fromMention, "issue comments")) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	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) |  | ||||||
| 	} |  | ||||||
| 	for i := range participants { |  | ||||||
| 		if participants[i].ID == doer.ID || |  | ||||||
| 			com.IsSliceContainsStr(names, participants[i].Name) || |  | ||||||
| 			participants[i].EmailNotifications() != models.EmailNotificationsEnabled { |  | ||||||
| 			continue |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		tos = append(tos, participants[i].Email) |  | ||||||
| 		names = append(names, participants[i].Name) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	if err := issue.LoadRepo(); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	for _, to := range tos { |  | ||||||
| 		SendIssueCommentMail(issue, doer, actionType, content, comment, []string{to}) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// 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]) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	emails := models.GetUserEmailsByNames(tos) |  | ||||||
|  |  | ||||||
| 	for _, to := range emails { |  | ||||||
| 		SendIssueMentionMail(issue, doer, actionType, content, comment, []string{to}) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -127,11 +140,18 @@ func mailParticipants(ctx models.DBContext, issue *models.Issue, doer *models.Us | |||||||
| 	if err = models.UpdateIssueMentions(ctx, issue.ID, userMentions); err != nil { | 	if err = models.UpdateIssueMentions(ctx, issue.ID, userMentions); err != nil { | ||||||
| 		return fmt.Errorf("UpdateIssueMentions [%d]: %v", issue.ID, err) | 		return fmt.Errorf("UpdateIssueMentions [%d]: %v", issue.ID, err) | ||||||
| 	} | 	} | ||||||
| 	mentions := make([]string, len(userMentions)) | 	mentions := make([]int64, len(userMentions)) | ||||||
| 	for i, u := range userMentions { | 	for i, u := range userMentions { | ||||||
| 		mentions[i] = u.LowerName | 		mentions[i] = u.ID | ||||||
| 	} | 	} | ||||||
| 	if err = mailIssueCommentToParticipants(issue, doer, opType, issue.Content, nil, mentions); err != nil { | 	if err = mailIssueCommentToParticipants( | ||||||
|  | 		&mailCommentContext{ | ||||||
|  | 			Issue:      issue, | ||||||
|  | 			Doer:       doer, | ||||||
|  | 			ActionType: opType, | ||||||
|  | 			Content:    issue.Content, | ||||||
|  | 			Comment:    nil, | ||||||
|  | 		}, mentions); err != nil { | ||||||
| 		log.Error("mailIssueCommentToParticipants: %v", err) | 		log.Error("mailIssueCommentToParticipants: %v", err) | ||||||
| 	} | 	} | ||||||
| 	return nil | 	return nil | ||||||
|   | |||||||
| @@ -58,12 +58,16 @@ func TestComposeIssueCommentMessage(t *testing.T) { | |||||||
| 	InitMailRender(stpl, btpl) | 	InitMailRender(stpl, btpl) | ||||||
|  |  | ||||||
| 	tos := []string{"test@gitea.com", "test2@gitea.com"} | 	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") | 	mailto := msgs[0].GetHeader("To") | ||||||
| 	inreplyTo := msg.GetHeader("In-Reply-To") | 	subject := msgs[0].GetHeader("Subject") | ||||||
| 	references := msg.GetHeader("References") | 	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: ", subject[0][:4], "Comment reply subject should contain Re:") | ||||||
| 	assert.Equal(t, "Re: [user2/repo1] @user2 #1 - issue1", subject[0]) | 	assert.Equal(t, "Re: [user2/repo1] @user2 #1 - issue1", subject[0]) | ||||||
| 	assert.Equal(t, inreplyTo[0], "<user2/repo1/issues/1@localhost>", "In-Reply-To header doesn't match") | 	assert.Equal(t, inreplyTo[0], "<user2/repo1/issues/1@localhost>", "In-Reply-To header doesn't match") | ||||||
| @@ -88,14 +92,18 @@ func TestComposeIssueMessage(t *testing.T) { | |||||||
| 	InitMailRender(stpl, btpl) | 	InitMailRender(stpl, btpl) | ||||||
|  |  | ||||||
| 	tos := []string{"test@gitea.com", "test2@gitea.com"} | 	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") | 	mailto := msgs[0].GetHeader("To") | ||||||
| 	messageID := msg.GetHeader("Message-ID") | 	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.Equal(t, "[user2/repo1] @user2 #1 - issue1", subject[0]) | ||||||
| 	assert.Nil(t, msg.GetHeader("In-Reply-To")) | 	assert.Nil(t, msgs[0].GetHeader("In-Reply-To")) | ||||||
| 	assert.Nil(t, msg.GetHeader("References")) | 	assert.Nil(t, msgs[0].GetHeader("References")) | ||||||
| 	assert.Equal(t, messageID[0], "<user2/repo1/issues/1@localhost>", "Message-ID header doesn't match") | 	assert.Equal(t, messageID[0], "<user2/repo1/issues/1@localhost>", "Message-ID header doesn't match") | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -134,20 +142,24 @@ func TestTemplateSelection(t *testing.T) { | |||||||
| 		assert.Contains(t, wholemsg, expBody) | 		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") | 	expect(t, msg, "issue/new/subject", "issue/new/body") | ||||||
|  |  | ||||||
| 	comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2, Issue: issue}).(*models.Comment) | 	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") | 	expect(t, msg, "issue/default/subject", "issue/default/body") | ||||||
|  |  | ||||||
| 	pull := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 2, Repo: repo, Poster: doer}).(*models.Issue) | 	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) | 	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") | 	expect(t, msg, "pull/comment/subject", "pull/comment/body") | ||||||
|  |  | ||||||
| 	msg = composeIssueCommentMessage(issue, doer, models.ActionCloseIssue, false, "test body", nil, tos, "TestTemplateSelection") | 	msg = testComposeIssueCommentMessage(t, &mailCommentContext{Issue: issue, Doer: doer, ActionType: models.ActionCloseIssue, | ||||||
| 	expect(t, msg, "[user2/repo1] issue1 (#1)", "issue/close/body") | 		Content: "test body", Comment: comment}, tos, false, "TestTemplateSelection") | ||||||
|  | 	expect(t, msg, "Re: [user2/repo1] issue1 (#1)", "issue/close/body") | ||||||
| } | } | ||||||
|  |  | ||||||
| func TestTemplateServices(t *testing.T) { | func TestTemplateServices(t *testing.T) { | ||||||
| @@ -173,7 +185,8 @@ func TestTemplateServices(t *testing.T) { | |||||||
| 		InitMailRender(stpl, btpl) | 		InitMailRender(stpl, btpl) | ||||||
|  |  | ||||||
| 		tos := []string{"test@gitea.com"} | 		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") | 		subject := msg.GetHeader("Subject") | ||||||
| 		msgbuf := new(bytes.Buffer) | 		msgbuf := new(bytes.Buffer) | ||||||
| @@ -202,3 +215,9 @@ func TestTemplateServices(t *testing.T) { | |||||||
| 		"Re: [user2/repo1] issue1 (#1)", | 		"Re: [user2/repo1] issue1 (#1)", | ||||||
| 		"//Re: //") | 		"//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] | ||||||
|  | } | ||||||
|   | |||||||
| @@ -295,9 +295,18 @@ func NewContext() { | |||||||
| 	go processMailQueue() | 	go processMailQueue() | ||||||
| } | } | ||||||
|  |  | ||||||
| // SendAsync send mail asynchronous | // SendAsync send mail asynchronously | ||||||
| func SendAsync(msg *Message) { | func SendAsync(msg *Message) { | ||||||
| 	go func() { | 	go func() { | ||||||
| 		mailQueue <- msg | 		mailQueue <- msg | ||||||
| 	}() | 	}() | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // SendAsyncs send mails asynchronously | ||||||
|  | func SendAsyncs(msgs []*Message) { | ||||||
|  | 	go func() { | ||||||
|  | 		for _, msg := range msgs { | ||||||
|  | 			mailQueue <- msg | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
|  | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 guillep2k
					guillep2k