From 80d6c6d7deb3f6a38ff6d09ec38ffb04de9da726 Mon Sep 17 00:00:00 2001
From: 6543 <6543@obermui.de>
Date: Fri, 2 Apr 2021 12:25:13 +0200
Subject: [PATCH] [refactor] mailer service (#15072)

* Unexport SendUserMail

* Instead of "[]*models.User" or "[]string" lists infent "[]*MailRecipient" for mailer

* adopt

* code format

* TODOs for "i18n"

* clean

* no fallback for lang -> just use english

* lint

* exec testComposeIssueCommentMessage per lang and use only emails

* rm MailRecipient

* Dont reload from users from db if you alredy have in ram

* nits

* minimize diff

Signed-off-by: 6543 <6543@obermui.de>

* localize subjects

* linter ...

* Tr extend

* start tmpl edit ...

* Apply suggestions from code review

* use translation.Locale

* improve mailIssueCommentBatch

Signed-off-by: Andrew Thornton <art27@cantab.net>

* add i18n to datas

Signed-off-by: Andrew Thornton <art27@cantab.net>

* a comment

Co-authored-by: Andrew Thornton <art27@cantab.net>
---
 models/user.go                           |   5 --
 modules/notification/mail/mail.go        |   6 +-
 options/locale/locale_en-US.ini          |   8 ++
 routers/admin/users.go                   |   2 +-
 routers/api/v1/admin/user.go             |   2 +-
 routers/user/auth.go                     |   2 +-
 routers/user/setting/account.go          |   4 +-
 services/mailer/mail.go                  |  79 ++++++++++-------
 services/mailer/mail_comment.go          |  21 +----
 services/mailer/mail_issue.go            | 107 +++++++++++------------
 services/mailer/mail_release.go          |  33 ++++---
 services/mailer/mail_repo.go             |  56 ++++++++----
 services/mailer/mail_test.go             |   6 +-
 services/mailer/mailer.go                |   9 +-
 templates/mail/notify/repo_transfer.tmpl |   2 +-
 15 files changed, 191 insertions(+), 151 deletions(-)

diff --git a/models/user.go b/models/user.go
index 098f6af2b3..aacf2957e3 100644
--- a/models/user.go
+++ b/models/user.go
@@ -331,11 +331,6 @@ func (u *User) GenerateEmailActivateCode(email string) string {
 	return code
 }
 
-// GenerateActivateCode generates an activate code based on user information.
-func (u *User) GenerateActivateCode() string {
-	return u.GenerateEmailActivateCode(u.Email)
-}
-
 // GetFollowers returns range of user's followers.
 func (u *User) GetFollowers(listOptions ListOptions) ([]*User, error) {
 	sess := x.
diff --git a/modules/notification/mail/mail.go b/modules/notification/mail/mail.go
index f7192f5a52..9c000da0f6 100644
--- a/modules/notification/mail/mail.go
+++ b/modules/notification/mail/mail.go
@@ -104,14 +104,14 @@ func (m *mailNotifier) NotifyIssueChangeAssignee(doer *models.User, issue *model
 	// mail only sent to added assignees and not self-assignee
 	if !removed && doer.ID != assignee.ID && assignee.EmailNotifications() == models.EmailNotificationsEnabled {
 		ct := fmt.Sprintf("Assigned #%d.", issue.Index)
-		mailer.SendIssueAssignedMail(issue, doer, ct, comment, []string{assignee.Email})
+		mailer.SendIssueAssignedMail(issue, doer, ct, comment, []*models.User{assignee})
 	}
 }
 
 func (m *mailNotifier) NotifyPullReviewRequest(doer *models.User, issue *models.Issue, reviewer *models.User, isRequest bool, comment *models.Comment) {
 	if isRequest && doer.ID != reviewer.ID && reviewer.EmailNotifications() == models.EmailNotificationsEnabled {
 		ct := fmt.Sprintf("Requested to review %s.", issue.HTMLURL())
-		mailer.SendIssueAssignedMail(issue, doer, ct, comment, []string{reviewer.Email})
+		mailer.SendIssueAssignedMail(issue, doer, ct, comment, []*models.User{reviewer})
 	}
 }
 
@@ -153,7 +153,7 @@ func (m *mailNotifier) NotifyPullRequestPushCommits(doer *models.User, pr *model
 }
 
 func (m *mailNotifier) NotifyPullRevieweDismiss(doer *models.User, review *models.Review, comment *models.Comment) {
-	if err := mailer.MailParticipantsComment(comment, models.ActionPullReviewDismissed, review.Issue, []*models.User{}); err != nil {
+	if err := mailer.MailParticipantsComment(comment, models.ActionPullReviewDismissed, review.Issue, nil); err != nil {
 		log.Error("MailParticipantsComment: %v", err)
 	}
 }
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 3be209ffef..c481414afb 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -320,6 +320,14 @@ reset_password = Recover your account
 register_success = Registration successful
 register_notify = Welcome to Gitea
 
+release.new.subject = %s in %s released
+
+repo.transfer.subject_to = %s would like to transfer "%s" to %s
+repo.transfer.subject_to_you = %s would like to transfer "%s" to you
+repo.transfer.to_you = you
+
+repo.collaborator.added.subject = %s added you to %s
+
 [modal]
 yes = Yes
 no = No
diff --git a/routers/admin/users.go b/routers/admin/users.go
index 2d40a883af..e3f5692030 100644
--- a/routers/admin/users.go
+++ b/routers/admin/users.go
@@ -154,7 +154,7 @@ func NewUserPost(ctx *context.Context) {
 
 	// Send email notification.
 	if form.SendNotify {
-		mailer.SendRegisterNotifyMail(ctx.Locale, u)
+		mailer.SendRegisterNotifyMail(u)
 	}
 
 	ctx.Flash.Success(ctx.Tr("admin.users.new_success", u.Name))
diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go
index cbb9f6b21c..5a74c6ccd5 100644
--- a/routers/api/v1/admin/user.go
+++ b/routers/api/v1/admin/user.go
@@ -114,7 +114,7 @@ func CreateUser(ctx *context.APIContext) {
 
 	// Send email notification.
 	if form.SendNotify {
-		mailer.SendRegisterNotifyMail(ctx.Locale, u)
+		mailer.SendRegisterNotifyMail(u)
 	}
 	ctx.JSON(http.StatusCreated, convert.ToUser(u, ctx.User))
 }
diff --git a/routers/user/auth.go b/routers/user/auth.go
index 37181c68e7..9217885519 100644
--- a/routers/user/auth.go
+++ b/routers/user/auth.go
@@ -1397,7 +1397,7 @@ func ForgotPasswdPost(ctx *context.Context) {
 		return
 	}
 
-	mailer.SendResetPasswordMail(ctx.Locale, u)
+	mailer.SendResetPasswordMail(u)
 
 	if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
 		log.Error("Set cache(MailResendLimit) fail: %v", err)
diff --git a/routers/user/setting/account.go b/routers/user/setting/account.go
index 4900bba14a..0bf6cf8b87 100644
--- a/routers/user/setting/account.go
+++ b/routers/user/setting/account.go
@@ -132,7 +132,7 @@ func EmailPost(ctx *context.Context) {
 				ctx.Redirect(setting.AppSubURL + "/user/settings/account")
 				return
 			}
-			mailer.SendActivateEmailMail(ctx.Locale, ctx.User, email)
+			mailer.SendActivateEmailMail(ctx.User, email)
 			address = email.Email
 		}
 
@@ -194,7 +194,7 @@ func EmailPost(ctx *context.Context) {
 
 	// Send confirmation email
 	if setting.Service.RegisterEmailConfirm {
-		mailer.SendActivateEmailMail(ctx.Locale, ctx.User, email)
+		mailer.SendActivateEmailMail(ctx.User, email)
 		if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil {
 			log.Error("Set cache(MailResendLimit) fail: %v", err)
 		}
diff --git a/services/mailer/mail.go b/services/mailer/mail.go
index 7d6214c742..c50795968a 100644
--- a/services/mailer/mail.go
+++ b/services/mailer/mail.go
@@ -22,6 +22,7 @@ import (
 	"code.gitea.io/gitea/modules/markup/markdown"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
+	"code.gitea.io/gitea/modules/translation"
 
 	"gopkg.in/gomail.v2"
 )
@@ -57,17 +58,21 @@ func SendTestMail(email string) error {
 	return gomail.Send(Sender, NewMessage([]string{email}, "Gitea Test Email!", "Gitea Test Email!").ToMessage())
 }
 
-// SendUserMail sends a mail to the user
-func SendUserMail(language string, u *models.User, tpl base.TplName, code, subject, info string) {
+// sendUserMail sends a mail to the user
+func sendUserMail(language string, u *models.User, tpl base.TplName, code, subject, info string) {
+	locale := translation.NewLocale(language)
 	data := map[string]interface{}{
 		"DisplayName":       u.DisplayName(),
 		"ActiveCodeLives":   timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, language),
 		"ResetPwdCodeLives": timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, language),
 		"Code":              code,
+		"i18n":              locale,
+		"Language":          locale.Language(),
 	}
 
 	var content bytes.Buffer
 
+	// TODO: i18n templates?
 	if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
 		log.Error("Template: %v", err)
 		return
@@ -79,33 +84,32 @@ func SendUserMail(language string, u *models.User, tpl base.TplName, code, subje
 	SendAsync(msg)
 }
 
-// Locale represents an interface to translation
-type Locale interface {
-	Language() string
-	Tr(string, ...interface{}) string
-}
-
 // SendActivateAccountMail sends an activation mail to the user (new user registration)
-func SendActivateAccountMail(locale Locale, u *models.User) {
-	SendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateActivateCode(), locale.Tr("mail.activate_account"), "activate account")
+func SendActivateAccountMail(locale translation.Locale, u *models.User) {
+	sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.Tr("mail.activate_account"), "activate account")
 }
 
 // SendResetPasswordMail sends a password reset mail to the user
-func SendResetPasswordMail(locale Locale, u *models.User) {
-	SendUserMail(locale.Language(), u, mailAuthResetPassword, u.GenerateActivateCode(), locale.Tr("mail.reset_password"), "recover account")
+func SendResetPasswordMail(u *models.User) {
+	locale := translation.NewLocale(u.Language)
+	sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.Tr("mail.reset_password"), "recover account")
 }
 
 // SendActivateEmailMail sends confirmation email to confirm new email address
-func SendActivateEmailMail(locale Locale, u *models.User, email *models.EmailAddress) {
+func SendActivateEmailMail(u *models.User, email *models.EmailAddress) {
+	locale := translation.NewLocale(u.Language)
 	data := map[string]interface{}{
 		"DisplayName":     u.DisplayName(),
 		"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale.Language()),
 		"Code":            u.GenerateEmailActivateCode(email.Email),
 		"Email":           email.Email,
+		"i18n":            locale,
+		"Language":        locale.Language(),
 	}
 
 	var content bytes.Buffer
 
+	// TODO: i18n templates?
 	if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
 		log.Error("Template: %v", err)
 		return
@@ -118,19 +122,19 @@ func SendActivateEmailMail(locale Locale, u *models.User, email *models.EmailAdd
 }
 
 // SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
-func SendRegisterNotifyMail(locale Locale, u *models.User) {
-	if setting.MailService == nil {
-		log.Warn("SendRegisterNotifyMail is being invoked but mail service hasn't been initialized")
-		return
-	}
+func SendRegisterNotifyMail(u *models.User) {
+	locale := translation.NewLocale(u.Language)
 
 	data := map[string]interface{}{
 		"DisplayName": u.DisplayName(),
 		"Username":    u.Name,
+		"i18n":        locale,
+		"Language":    locale.Language(),
 	}
 
 	var content bytes.Buffer
 
+	// TODO: i18n templates?
 	if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
 		log.Error("Template: %v", err)
 		return
@@ -144,17 +148,21 @@ func SendRegisterNotifyMail(locale Locale, u *models.User) {
 
 // SendCollaboratorMail sends mail notification to new collaborator.
 func SendCollaboratorMail(u, doer *models.User, repo *models.Repository) {
+	locale := translation.NewLocale(u.Language)
 	repoName := repo.FullName()
-	subject := fmt.Sprintf("%s added you to %s", doer.DisplayName(), repoName)
 
+	subject := locale.Tr("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
 	data := map[string]interface{}{
 		"Subject":  subject,
 		"RepoName": repoName,
 		"Link":     repo.HTMLURL(),
+		"i18n":     locale,
+		"Language": locale.Language(),
 	}
 
 	var content bytes.Buffer
 
+	// TODO: i18n templates?
 	if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
 		log.Error("Template: %v", err)
 		return
@@ -166,7 +174,7 @@ func SendCollaboratorMail(u, doer *models.User, repo *models.Repository) {
 	SendAsync(msg)
 }
 
-func composeIssueCommentMessages(ctx *mailCommentContext, tos []string, fromMention bool, info string) []*Message {
+func composeIssueCommentMessages(ctx *mailCommentContext, lang string, tos []string, fromMention bool, info string) []*Message {
 
 	var (
 		subject string
@@ -192,7 +200,6 @@ func composeIssueCommentMessages(ctx *mailCommentContext, tos []string, fromMent
 
 	// This is the body of the new issue or comment, not the mail body
 	body := string(markup.RenderByType(markdown.MarkupName, []byte(ctx.Content), ctx.Issue.Repo.HTMLURL(), ctx.Issue.Repo.ComposeMetas()))
-
 	actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
 
 	if actName != "new" {
@@ -208,6 +215,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, tos []string, fromMent
 			}
 		}
 	}
+	locale := translation.NewLocale(lang)
 
 	mailMeta := map[string]interface{}{
 		"FallbackSubject": fallback,
@@ -224,13 +232,16 @@ func composeIssueCommentMessages(ctx *mailCommentContext, tos []string, fromMent
 		"ActionType":      actType,
 		"ActionName":      actName,
 		"ReviewComments":  reviewComments,
+		"i18n":            locale,
+		"Language":        locale.Language(),
 	}
 
 	var mailSubject bytes.Buffer
+	// TODO: i18n templates?
 	if err := subjectTemplates.ExecuteTemplate(&mailSubject, string(tplName), mailMeta); err == nil {
 		subject = sanitizeSubject(mailSubject.String())
 	} else {
-		log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/subject", err)
+		log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err)
 	}
 
 	if subject == "" {
@@ -243,6 +254,7 @@ func composeIssueCommentMessages(ctx *mailCommentContext, tos []string, fromMent
 
 	var mailBody bytes.Buffer
 
+	// TODO: i18n templates?
 	if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplName), mailMeta); err != nil {
 		log.Error("ExecuteTemplate [%s]: %v", string(tplName)+"/body", err)
 	}
@@ -276,14 +288,21 @@ func sanitizeSubject(subject string) string {
 }
 
 // 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"))
+func SendIssueAssignedMail(issue *models.Issue, doer *models.User, content string, comment *models.Comment, recipients []*models.User) {
+	langMap := make(map[string][]string)
+	for _, user := range recipients {
+		langMap[user.Language] = append(langMap[user.Language], user.Email)
+	}
+
+	for lang, tos := range langMap {
+		SendAsyncs(composeIssueCommentMessages(&mailCommentContext{
+			Issue:      issue,
+			Doer:       doer,
+			ActionType: models.ActionType(0),
+			Content:    content,
+			Comment:    comment,
+		}, lang, tos, false, "issue assigned"))
+	}
 }
 
 // actionToTemplate returns the type and name of the action facing the user
diff --git a/services/mailer/mail_comment.go b/services/mailer/mail_comment.go
index 2f166720db..f73c9fb637 100644
--- a/services/mailer/mail_comment.go
+++ b/services/mailer/mail_comment.go
@@ -9,25 +9,16 @@ import (
 	"code.gitea.io/gitea/modules/log"
 )
 
-// MailParticipantsComment sends new comment emails to repository watchers
-// and mentioned people.
+// MailParticipantsComment sends new comment emails to repository watchers and mentioned people.
 func MailParticipantsComment(c *models.Comment, opType models.ActionType, issue *models.Issue, mentions []*models.User) error {
-	return mailParticipantsComment(c, opType, issue, mentions)
-}
-
-func mailParticipantsComment(c *models.Comment, opType models.ActionType, issue *models.Issue, mentions []*models.User) (err error) {
-	mentionedIDs := make([]int64, len(mentions))
-	for i, u := range mentions {
-		mentionedIDs[i] = u.ID
-	}
-	if err = mailIssueCommentToParticipants(
+	if err := mailIssueCommentToParticipants(
 		&mailCommentContext{
 			Issue:      issue,
 			Doer:       c.Poster,
 			ActionType: opType,
 			Content:    c.Content,
 			Comment:    c,
-		}, mentionedIDs); err != nil {
+		}, mentions); err != nil {
 		log.Error("mailIssueCommentToParticipants: %v", err)
 	}
 	return nil
@@ -35,10 +26,6 @@ func mailParticipantsComment(c *models.Comment, opType models.ActionType, issue
 
 // MailMentionsComment sends email to users mentioned in a code comment
 func MailMentionsComment(pr *models.PullRequest, c *models.Comment, mentions []*models.User) (err error) {
-	mentionedIDs := make([]int64, len(mentions))
-	for i, u := range mentions {
-		mentionedIDs[i] = u.ID
-	}
 	visited := make(map[int64]bool, len(mentions)+1)
 	visited[c.Poster.ID] = true
 	if err = mailIssueCommentBatch(
@@ -48,7 +35,7 @@ func MailMentionsComment(pr *models.PullRequest, c *models.Comment, mentions []*
 			ActionType: models.ActionCommentPull,
 			Content:    c.Content,
 			Comment:    c,
-		}, mentionedIDs, visited, true); err != nil {
+		}, mentions, visited, true); err != nil {
 		log.Error("mailIssueCommentBatch: %v", err)
 	}
 	return nil
diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go
index b600060a67..9786a06f62 100644
--- a/services/mailer/mail_issue.go
+++ b/services/mailer/mail_issue.go
@@ -23,11 +23,16 @@ type mailCommentContext struct {
 	Comment    *models.Comment
 }
 
+const (
+	// MailBatchSize set the batch size used in mailIssueCommentBatch
+	MailBatchSize = 100
+)
+
 // 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(ctx *mailCommentContext, mentions []int64) error {
+func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []*models.User) error {
 
 	// Required by the mail composer; make sure to load these before calling the async function
 	if err := ctx.Issue.LoadRepo(); err != nil {
@@ -94,78 +99,72 @@ func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []int64) e
 		visited[i] = true
 	}
 
-	if err = mailIssueCommentBatch(ctx, unfiltered, visited, false); err != nil {
+	unfilteredUsers, err := models.GetMaileableUsersByIDs(unfiltered, false)
+	if err != nil {
+		return err
+	}
+	if err = mailIssueCommentBatch(ctx, unfilteredUsers, visited, false); err != nil {
 		return fmt.Errorf("mailIssueCommentBatch(): %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, fromMention)
-		if err != nil {
-			return err
-		}
-
-		checkUnit := models.UnitTypeIssues
-		if ctx.Issue.IsPull {
-			checkUnit = models.UnitTypePullRequests
-		}
-		// Make sure all recipients can still see the issue
-		idx := 0
-		for _, r := range recipients {
-			if ctx.Issue.Repo.CheckUnitUser(r, checkUnit) {
-				recipients[idx] = r
-				idx++
-			}
-		}
-		recipients = recipients[:idx]
-
-		// 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"))
+func mailIssueCommentBatch(ctx *mailCommentContext, users []*models.User, visited map[int64]bool, fromMention bool) error {
+	checkUnit := models.UnitTypeIssues
+	if ctx.Issue.IsPull {
+		checkUnit = models.UnitTypePullRequests
 	}
+
+	langMap := make(map[string][]string)
+	for _, user := range users {
+		// At this point we exclude:
+		// user that don't have all mails enabled or users only get mail on mention and this is one ...
+		if !(user.EmailNotificationsPreference == models.EmailNotificationsEnabled ||
+			fromMention && user.EmailNotificationsPreference == models.EmailNotificationsOnMention) {
+			continue
+		}
+
+		// if we have already visited this user we exclude them
+		if _, ok := visited[user.ID]; ok {
+			continue
+		}
+
+		// now mark them as visited
+		visited[user.ID] = true
+
+		// test if this user is allowed to see the issue/pull
+		if !ctx.Issue.Repo.CheckUnitUser(user, checkUnit) {
+			continue
+		}
+
+		langMap[user.Language] = append(langMap[user.Language], user.Email)
+	}
+
+	for lang, receivers := range langMap {
+		// because we know that the len(receivers) > 0 and we don't care about the order particularly
+		// working backwards from the last (possibly) incomplete batch. If len(receivers) can be 0 this
+		// starting condition will need to be changed slightly
+		for i := ((len(receivers) - 1) / MailBatchSize) * MailBatchSize; i >= 0; i -= MailBatchSize {
+			SendAsyncs(composeIssueCommentMessages(ctx, lang, receivers[i:], fromMention, "issue comments"))
+			receivers = receivers[:i]
+		}
+	}
+
 	return nil
 }
 
 // MailParticipants sends new issue thread created emails to repository watchers
 // and mentioned people.
 func MailParticipants(issue *models.Issue, doer *models.User, opType models.ActionType, mentions []*models.User) error {
-	return mailParticipants(issue, doer, opType, mentions)
-}
-
-func mailParticipants(issue *models.Issue, doer *models.User, opType models.ActionType, mentions []*models.User) (err error) {
-	mentionedIDs := make([]int64, len(mentions))
-	for i, u := range mentions {
-		mentionedIDs[i] = u.ID
-	}
-	if err = mailIssueCommentToParticipants(
+	if err := mailIssueCommentToParticipants(
 		&mailCommentContext{
 			Issue:      issue,
 			Doer:       doer,
 			ActionType: opType,
 			Content:    issue.Content,
 			Comment:    nil,
-		}, mentionedIDs); err != nil {
+		}, mentions); err != nil {
 		log.Error("mailIssueCommentToParticipants: %v", err)
 	}
 	return nil
diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go
index f278c853ae..22efe2f046 100644
--- a/services/mailer/mail_release.go
+++ b/services/mailer/mail_release.go
@@ -6,13 +6,13 @@ package mailer
 
 import (
 	"bytes"
-	"fmt"
 
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/markup/markdown"
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/translation"
 )
 
 const (
@@ -33,29 +33,40 @@ func MailNewRelease(rel *models.Release) {
 		return
 	}
 
-	tos := make([]string, 0, len(recipients))
-	for _, to := range recipients {
-		if to.ID != rel.PublisherID {
-			tos = append(tos, to.Email)
+	langMap := make(map[string][]string)
+	for _, user := range recipients {
+		if user.ID != rel.PublisherID {
+			langMap[user.Language] = append(langMap[user.Language], user.Email)
 		}
 	}
 
-	rel.RenderedNote = markdown.RenderString(rel.Note, rel.Repo.Link(), rel.Repo.ComposeMetas())
-	subject := fmt.Sprintf("%s in %s released", rel.TagName, rel.Repo.FullName())
+	for lang, tos := range langMap {
+		mailNewRelease(lang, tos, rel)
+	}
+}
 
+func mailNewRelease(lang string, tos []string, rel *models.Release) {
+	locale := translation.NewLocale(lang)
+
+	rel.RenderedNote = markdown.RenderString(rel.Note, rel.Repo.Link(), rel.Repo.ComposeMetas())
+
+	subject := locale.Tr("mail.release.new.subject", rel.TagName, rel.Repo.FullName())
 	mailMeta := map[string]interface{}{
-		"Release": rel,
-		"Subject": subject,
+		"Release":  rel,
+		"Subject":  subject,
+		"i18n":     locale,
+		"Language": locale.Language(),
 	}
 
 	var mailBody bytes.Buffer
 
-	if err = bodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil {
+	// TODO: i18n templates?
+	if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil {
 		log.Error("ExecuteTemplate [%s]: %v", string(tplNewReleaseMail)+"/body", err)
 		return
 	}
 
-	msgs := make([]*Message, 0, len(recipients))
+	msgs := make([]*Message, 0, len(tos))
 	publisherName := rel.Publisher.DisplayName()
 	relURL := "<" + rel.HTMLURL() + ">"
 	for _, to := range tos {
diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go
index b9d24f4334..c742101ee1 100644
--- a/services/mailer/mail_repo.go
+++ b/services/mailer/mail_repo.go
@@ -9,42 +9,60 @@ import (
 	"fmt"
 
 	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/translation"
 )
 
 // SendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created
 func SendRepoTransferNotifyMail(doer, newOwner *models.User, repo *models.Repository) error {
-	var (
-		emails      []string
-		destination string
-		content     bytes.Buffer
-	)
-
 	if newOwner.IsOrganization() {
 		users, err := models.GetUsersWhoCanCreateOrgRepo(newOwner.ID)
 		if err != nil {
 			return err
 		}
 
-		for i := range users {
-			emails = append(emails, users[i].Email)
+		langMap := make(map[string][]string)
+		for _, user := range users {
+			langMap[user.Language] = append(langMap[user.Language], user.Email)
 		}
-		destination = newOwner.DisplayName()
-	} else {
-		emails = []string{newOwner.Email}
-		destination = "you"
+
+		for lang, tos := range langMap {
+			if err := sendRepoTransferNotifyMailPerLang(lang, newOwner, doer, tos, repo); err != nil {
+				return err
+			}
+		}
+
+		return nil
 	}
 
-	subject := fmt.Sprintf("%s would like to transfer \"%s\" to %s", doer.DisplayName(), repo.FullName(), destination)
-	data := map[string]interface{}{
-		"Doer":    doer,
-		"User":    repo.Owner,
-		"Repo":    repo.FullName(),
-		"Link":    repo.HTMLURL(),
-		"Subject": subject,
+	return sendRepoTransferNotifyMailPerLang(newOwner.Language, newOwner, doer, []string{newOwner.Email}, repo)
+}
 
+// sendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created for each language
+func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *models.User, emails []string, repo *models.Repository) error {
+	var (
+		locale  = translation.NewLocale(lang)
+		content bytes.Buffer
+	)
+
+	destination := locale.Tr("mail.repo.transfer.to_you")
+	subject := locale.Tr("mail.repo.transfer.subject_to_you", doer.DisplayName(), repo.FullName())
+	if newOwner.IsOrganization() {
+		destination = newOwner.DisplayName()
+		subject = locale.Tr("mail.repo.transfer.subject_to", doer.DisplayName(), repo.FullName(), destination)
+	}
+
+	data := map[string]interface{}{
+		"Doer":        doer,
+		"User":        repo.Owner,
+		"Repo":        repo.FullName(),
+		"Link":        repo.HTMLURL(),
+		"Subject":     subject,
+		"i18n":        locale,
+		"Language":    locale.Language(),
 		"Destination": destination,
 	}
 
+	// TODO: i18n templates?
 	if err := bodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil {
 		return err
 	}
diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go
index d7d02d9dee..9eef084408 100644
--- a/services/mailer/mail_test.go
+++ b/services/mailer/mail_test.go
@@ -59,7 +59,7 @@ func TestComposeIssueCommentMessage(t *testing.T) {
 
 	tos := []string{"test@gitea.com", "test2@gitea.com"}
 	msgs := composeIssueCommentMessages(&mailCommentContext{Issue: issue, Doer: doer, ActionType: models.ActionCommentIssue,
-		Content: "test body", Comment: comment}, tos, false, "issue comment")
+		Content: "test body", Comment: comment}, "en-US", tos, false, "issue comment")
 	assert.Len(t, msgs, 2)
 	gomailMsg := msgs[0].ToMessage()
 	mailto := gomailMsg.GetHeader("To")
@@ -93,7 +93,7 @@ func TestComposeIssueMessage(t *testing.T) {
 
 	tos := []string{"test@gitea.com", "test2@gitea.com"}
 	msgs := composeIssueCommentMessages(&mailCommentContext{Issue: issue, Doer: doer, ActionType: models.ActionCreateIssue,
-		Content: "test body"}, tos, false, "issue create")
+		Content: "test body"}, "en-US", tos, false, "issue create")
 	assert.Len(t, msgs, 2)
 
 	gomailMsg := msgs[0].ToMessage()
@@ -218,7 +218,7 @@ func TestTemplateServices(t *testing.T) {
 }
 
 func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, tos []string, fromMention bool, info string) *Message {
-	msgs := composeIssueCommentMessages(ctx, tos, fromMention, info)
+	msgs := composeIssueCommentMessages(ctx, "en-US", tos, fromMention, info)
 	assert.Len(t, msgs, 1)
 	return msgs[0]
 }
diff --git a/services/mailer/mailer.go b/services/mailer/mailer.go
index 2e7beffa15..6b86734bf8 100644
--- a/services/mailer/mailer.go
+++ b/services/mailer/mailer.go
@@ -337,13 +337,16 @@ func NewContext() {
 
 // SendAsync send mail asynchronously
 func SendAsync(msg *Message) {
-	go func() {
-		_ = mailQueue.Push(msg)
-	}()
+	SendAsyncs([]*Message{msg})
 }
 
 // SendAsyncs send mails asynchronously
 func SendAsyncs(msgs []*Message) {
+	if setting.MailService == nil {
+		log.Error("Mailer: SendAsyncs is being invoked but mail service hasn't been initialized")
+		return
+	}
+
 	go func() {
 		for _, msg := range msgs {
 			_ = mailQueue.Push(msg)
diff --git a/templates/mail/notify/repo_transfer.tmpl b/templates/mail/notify/repo_transfer.tmpl
index 68ceded116..e0dca8869d 100644
--- a/templates/mail/notify/repo_transfer.tmpl
+++ b/templates/mail/notify/repo_transfer.tmpl
@@ -11,7 +11,7 @@
 	<p>
 		---
 		<br>
-		<a href="{{.Link}}">View it on Gitea</a>.
+		<a href="{{.Link}}">View it on {{AppName}}</a>.
 	</p>
 </body>
 </html>