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  }