code.gitea.io/gitea@v1.21.7/services/mailer/mail_issue.go (about) 1 // Copyright 2019 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 package mailer 5 6 import ( 7 "context" 8 "fmt" 9 10 activities_model "code.gitea.io/gitea/models/activities" 11 issues_model "code.gitea.io/gitea/models/issues" 12 access_model "code.gitea.io/gitea/models/perm/access" 13 repo_model "code.gitea.io/gitea/models/repo" 14 "code.gitea.io/gitea/models/unit" 15 user_model "code.gitea.io/gitea/models/user" 16 "code.gitea.io/gitea/modules/container" 17 "code.gitea.io/gitea/modules/log" 18 "code.gitea.io/gitea/modules/setting" 19 ) 20 21 func fallbackMailSubject(issue *issues_model.Issue) string { 22 return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index) 23 } 24 25 type mailCommentContext struct { 26 context.Context 27 Issue *issues_model.Issue 28 Doer *user_model.User 29 ActionType activities_model.ActionType 30 Content string 31 Comment *issues_model.Comment 32 ForceDoerNotification bool 33 } 34 35 const ( 36 // MailBatchSize set the batch size used in mailIssueCommentBatch 37 MailBatchSize = 100 38 ) 39 40 // mailIssueCommentToParticipants can be used for both new issue creation and comment. 41 // This function sends two list of emails: 42 // 1. Repository watchers (except for WIP pull requests) and users who are participated in comments. 43 // 2. Users who are not in 1. but get mentioned in current issue/comment. 44 func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []*user_model.User) error { 45 // Required by the mail composer; make sure to load these before calling the async function 46 if err := ctx.Issue.LoadRepo(ctx); err != nil { 47 return fmt.Errorf("LoadRepo: %w", err) 48 } 49 if err := ctx.Issue.LoadPoster(ctx); err != nil { 50 return fmt.Errorf("LoadPoster: %w", err) 51 } 52 if err := ctx.Issue.LoadPullRequest(ctx); err != nil { 53 return fmt.Errorf("LoadPullRequest: %w", err) 54 } 55 56 // Enough room to avoid reallocations 57 unfiltered := make([]int64, 1, 64) 58 59 // =========== Original poster =========== 60 unfiltered[0] = ctx.Issue.PosterID 61 62 // =========== Assignees =========== 63 ids, err := issues_model.GetAssigneeIDsByIssue(ctx, ctx.Issue.ID) 64 if err != nil { 65 return fmt.Errorf("GetAssigneeIDsByIssue(%d): %w", ctx.Issue.ID, err) 66 } 67 unfiltered = append(unfiltered, ids...) 68 69 // =========== Participants (i.e. commenters, reviewers) =========== 70 ids, err = issues_model.GetParticipantsIDsByIssueID(ctx, ctx.Issue.ID) 71 if err != nil { 72 return fmt.Errorf("GetParticipantsIDsByIssueID(%d): %w", ctx.Issue.ID, err) 73 } 74 unfiltered = append(unfiltered, ids...) 75 76 // =========== Issue watchers =========== 77 ids, err = issues_model.GetIssueWatchersIDs(ctx, ctx.Issue.ID, true) 78 if err != nil { 79 return fmt.Errorf("GetIssueWatchersIDs(%d): %w", ctx.Issue.ID, err) 80 } 81 unfiltered = append(unfiltered, ids...) 82 83 // =========== Repo watchers =========== 84 // Make repo watchers last, since it's likely the list with the most users 85 if !(ctx.Issue.IsPull && ctx.Issue.PullRequest.IsWorkInProgress() && ctx.ActionType != activities_model.ActionCreatePullRequest) { 86 ids, err = repo_model.GetRepoWatchersIDs(ctx, ctx.Issue.RepoID) 87 if err != nil { 88 return fmt.Errorf("GetRepoWatchersIDs(%d): %w", ctx.Issue.RepoID, err) 89 } 90 unfiltered = append(ids, unfiltered...) 91 } 92 93 visited := make(container.Set[int64], len(unfiltered)+len(mentions)+1) 94 95 // Avoid mailing the doer 96 if ctx.Doer.EmailNotificationsPreference != user_model.EmailNotificationsAndYourOwn && !ctx.ForceDoerNotification { 97 visited.Add(ctx.Doer.ID) 98 } 99 100 // =========== Mentions =========== 101 if err = mailIssueCommentBatch(ctx, mentions, visited, true); err != nil { 102 return fmt.Errorf("mailIssueCommentBatch() mentions: %w", err) 103 } 104 105 // Avoid mailing explicit unwatched 106 ids, err = issues_model.GetIssueWatchersIDs(ctx, ctx.Issue.ID, false) 107 if err != nil { 108 return fmt.Errorf("GetIssueWatchersIDs(%d): %w", ctx.Issue.ID, err) 109 } 110 visited.AddMultiple(ids...) 111 112 unfilteredUsers, err := user_model.GetMaileableUsersByIDs(ctx, unfiltered, false) 113 if err != nil { 114 return err 115 } 116 if err = mailIssueCommentBatch(ctx, unfilteredUsers, visited, false); err != nil { 117 return fmt.Errorf("mailIssueCommentBatch(): %w", err) 118 } 119 120 return nil 121 } 122 123 func mailIssueCommentBatch(ctx *mailCommentContext, users []*user_model.User, visited container.Set[int64], fromMention bool) error { 124 checkUnit := unit.TypeIssues 125 if ctx.Issue.IsPull { 126 checkUnit = unit.TypePullRequests 127 } 128 129 langMap := make(map[string][]*user_model.User) 130 for _, user := range users { 131 if !user.IsActive { 132 // Exclude deactivated users 133 continue 134 } 135 // At this point we exclude: 136 // user that don't have all mails enabled or users only get mail on mention and this is one ... 137 if !(user.EmailNotificationsPreference == user_model.EmailNotificationsEnabled || 138 user.EmailNotificationsPreference == user_model.EmailNotificationsAndYourOwn || 139 fromMention && user.EmailNotificationsPreference == user_model.EmailNotificationsOnMention) { 140 continue 141 } 142 143 // if we have already visited this user we exclude them 144 if !visited.Add(user.ID) { 145 continue 146 } 147 148 // test if this user is allowed to see the issue/pull 149 if !access_model.CheckRepoUnitUser(ctx, ctx.Issue.Repo, user, checkUnit) { 150 continue 151 } 152 153 langMap[user.Language] = append(langMap[user.Language], user) 154 } 155 156 for lang, receivers := range langMap { 157 // because we know that the len(receivers) > 0 and we don't care about the order particularly 158 // working backwards from the last (possibly) incomplete batch. If len(receivers) can be 0 this 159 // starting condition will need to be changed slightly 160 for i := ((len(receivers) - 1) / MailBatchSize) * MailBatchSize; i >= 0; i -= MailBatchSize { 161 msgs, err := composeIssueCommentMessages(ctx, lang, receivers[i:], fromMention, "issue comments") 162 if err != nil { 163 return err 164 } 165 SendAsync(msgs...) 166 receivers = receivers[:i] 167 } 168 } 169 170 return nil 171 } 172 173 // MailParticipants sends new issue thread created emails to repository watchers 174 // and mentioned people. 175 func MailParticipants(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, opType activities_model.ActionType, mentions []*user_model.User) error { 176 if setting.MailService == nil { 177 // No mail service configured 178 return nil 179 } 180 181 content := issue.Content 182 if opType == activities_model.ActionCloseIssue || opType == activities_model.ActionClosePullRequest || 183 opType == activities_model.ActionReopenIssue || opType == activities_model.ActionReopenPullRequest || 184 opType == activities_model.ActionMergePullRequest || opType == activities_model.ActionAutoMergePullRequest { 185 content = "" 186 } 187 forceDoerNotification := opType == activities_model.ActionAutoMergePullRequest 188 if err := mailIssueCommentToParticipants( 189 &mailCommentContext{ 190 Context: ctx, 191 Issue: issue, 192 Doer: doer, 193 ActionType: opType, 194 Content: content, 195 Comment: nil, 196 ForceDoerNotification: forceDoerNotification, 197 }, mentions); err != nil { 198 log.Error("mailIssueCommentToParticipants: %v", err) 199 } 200 return nil 201 }