* 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 errorsmaster
@@ -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, | |||||
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 ( | 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 | |||||
link = issue.HTMLURL() + "#" + comment.HashTag() | |||||
commentType = ctx.Comment.Type | |||||
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 { | |||||
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 | // 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, | |||||
"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, | "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()) | |||||
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 { | 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. | |||||
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 | // 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) | |||||
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 { | 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 { | 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 | 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 | |||||
} | |||||
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) | 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") | |||||
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: ", 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") | |||||
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.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.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") | |||||
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) { | 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 | |||||
} | |||||
}() | |||||
} |