mirror of
https://github.com/go-gitea/gitea
synced 2024-12-23 17:14:27 +00:00
085f717529
I found myself wondering whether a PR I scheduled for automerge was actually merged. It was, but I didn't receive a mail notification for it - that makes sense considering I am the doer and usually don't want to receive such notifications. But ideally I want to receive a notification when a PR was merged because I scheduled it for automerge. This PR implements exactly that. The implementation works, but I wonder if there's a way to avoid passing the "This PR was automerged" state down so much. I tried solving this via the database (checking if there's an automerge scheduled for this PR when sending the notification) but that did not work reliably, probably because sending the notification happens async and the entry might have already been deleted. My implementation might be the most straightforward but maybe not the most elegant. Signed-off-by: Andrew Thornton <art27@cantab.net> Co-authored-by: Lauris BH <lauris@nix.lv> Co-authored-by: Andrew Thornton <art27@cantab.net> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
497 lines
16 KiB
Go
497 lines
16 KiB
Go
// Copyright 2016 The Gogs Authors. All rights reserved.
|
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
|
// Use of this source code is governed by a MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package mailer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"html/template"
|
|
"mime"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
texttmpl "text/template"
|
|
"time"
|
|
|
|
activities_model "code.gitea.io/gitea/models/activities"
|
|
"code.gitea.io/gitea/models/db"
|
|
issues_model "code.gitea.io/gitea/models/issues"
|
|
repo_model "code.gitea.io/gitea/models/repo"
|
|
user_model "code.gitea.io/gitea/models/user"
|
|
"code.gitea.io/gitea/modules/base"
|
|
"code.gitea.io/gitea/modules/emoji"
|
|
"code.gitea.io/gitea/modules/log"
|
|
"code.gitea.io/gitea/modules/markup"
|
|
"code.gitea.io/gitea/modules/markup/markdown"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
"code.gitea.io/gitea/modules/templates"
|
|
"code.gitea.io/gitea/modules/timeutil"
|
|
"code.gitea.io/gitea/modules/translation"
|
|
|
|
"gopkg.in/gomail.v2"
|
|
)
|
|
|
|
const (
|
|
mailAuthActivate base.TplName = "auth/activate"
|
|
mailAuthActivateEmail base.TplName = "auth/activate_email"
|
|
mailAuthResetPassword base.TplName = "auth/reset_passwd"
|
|
mailAuthRegisterNotify base.TplName = "auth/register_notify"
|
|
|
|
mailNotifyCollaborator base.TplName = "notify/collaborator"
|
|
|
|
mailRepoTransferNotify base.TplName = "notify/repo_transfer"
|
|
|
|
// There's no actual limit for subject in RFC 5322
|
|
mailMaxSubjectRunes = 256
|
|
)
|
|
|
|
var (
|
|
bodyTemplates *template.Template
|
|
subjectTemplates *texttmpl.Template
|
|
subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
|
|
)
|
|
|
|
// SendTestMail sends a test mail
|
|
func SendTestMail(email string) error {
|
|
if setting.MailService == nil {
|
|
// No mail service configured
|
|
return nil
|
|
}
|
|
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 *user_model.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, locale),
|
|
"ResetPwdCodeLives": timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, locale),
|
|
"Code": code,
|
|
"Language": locale.Language(),
|
|
// helper
|
|
"locale": locale,
|
|
"Str2html": templates.Str2html,
|
|
"DotEscape": templates.DotEscape,
|
|
}
|
|
|
|
var content bytes.Buffer
|
|
|
|
if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
|
|
log.Error("Template: %v", err)
|
|
return
|
|
}
|
|
|
|
msg := NewMessage([]string{u.Email}, subject, content.String())
|
|
msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info)
|
|
|
|
SendAsync(msg)
|
|
}
|
|
|
|
// SendActivateAccountMail sends an activation mail to the user (new user registration)
|
|
func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
|
|
if setting.MailService == nil {
|
|
// No mail service configured
|
|
return
|
|
}
|
|
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(u *user_model.User) {
|
|
if setting.MailService == nil {
|
|
// No mail service configured
|
|
return
|
|
}
|
|
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(u *user_model.User, email *user_model.EmailAddress) {
|
|
if setting.MailService == nil {
|
|
// No mail service configured
|
|
return
|
|
}
|
|
locale := translation.NewLocale(u.Language)
|
|
data := map[string]interface{}{
|
|
"DisplayName": u.DisplayName(),
|
|
"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
|
|
"Code": u.GenerateEmailActivateCode(email.Email),
|
|
"Email": email.Email,
|
|
"Language": locale.Language(),
|
|
// helper
|
|
"locale": locale,
|
|
"Str2html": templates.Str2html,
|
|
"DotEscape": templates.DotEscape,
|
|
}
|
|
|
|
var content bytes.Buffer
|
|
|
|
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
|
|
log.Error("Template: %v", err)
|
|
return
|
|
}
|
|
|
|
msg := NewMessage([]string{email.Email}, locale.Tr("mail.activate_email"), content.String())
|
|
msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
|
|
|
|
SendAsync(msg)
|
|
}
|
|
|
|
// SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
|
|
func SendRegisterNotifyMail(u *user_model.User) {
|
|
if setting.MailService == nil || !u.IsActive {
|
|
// No mail service configured OR user is inactive
|
|
return
|
|
}
|
|
locale := translation.NewLocale(u.Language)
|
|
|
|
data := map[string]interface{}{
|
|
"DisplayName": u.DisplayName(),
|
|
"Username": u.Name,
|
|
"Language": locale.Language(),
|
|
// helper
|
|
"locale": locale,
|
|
"Str2html": templates.Str2html,
|
|
"DotEscape": templates.DotEscape,
|
|
}
|
|
|
|
var content bytes.Buffer
|
|
|
|
if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
|
|
log.Error("Template: %v", err)
|
|
return
|
|
}
|
|
|
|
msg := NewMessage([]string{u.Email}, locale.Tr("mail.register_notify"), content.String())
|
|
msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID)
|
|
|
|
SendAsync(msg)
|
|
}
|
|
|
|
// SendCollaboratorMail sends mail notification to new collaborator.
|
|
func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) {
|
|
if setting.MailService == nil || !u.IsActive {
|
|
// No mail service configured OR the user is inactive
|
|
return
|
|
}
|
|
locale := translation.NewLocale(u.Language)
|
|
repoName := repo.FullName()
|
|
|
|
subject := locale.Tr("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
|
|
data := map[string]interface{}{
|
|
"Subject": subject,
|
|
"RepoName": repoName,
|
|
"Link": repo.HTMLURL(),
|
|
"Language": locale.Language(),
|
|
// helper
|
|
"locale": locale,
|
|
"Str2html": templates.Str2html,
|
|
"DotEscape": templates.DotEscape,
|
|
}
|
|
|
|
var content bytes.Buffer
|
|
|
|
if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
|
|
log.Error("Template: %v", err)
|
|
return
|
|
}
|
|
|
|
msg := NewMessage([]string{u.Email}, subject, content.String())
|
|
msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.ID)
|
|
|
|
SendAsync(msg)
|
|
}
|
|
|
|
func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*Message, error) {
|
|
var (
|
|
subject string
|
|
link string
|
|
prefix string
|
|
// Fall back subject for bad templates, make sure subject is never empty
|
|
fallback string
|
|
reviewComments []*issues_model.Comment
|
|
)
|
|
|
|
commentType := issues_model.CommentTypeComment
|
|
if ctx.Comment != nil {
|
|
commentType = ctx.Comment.Type
|
|
link = ctx.Issue.HTMLURL() + "#" + ctx.Comment.HashTag()
|
|
} else {
|
|
link = ctx.Issue.HTMLURL()
|
|
}
|
|
|
|
reviewType := issues_model.ReviewTypeComment
|
|
if ctx.Comment != nil && ctx.Comment.Review != nil {
|
|
reviewType = ctx.Comment.Review.Type
|
|
}
|
|
|
|
// This is the body of the new issue or comment, not the mail body
|
|
body, err := markdown.RenderString(&markup.RenderContext{
|
|
Ctx: ctx,
|
|
URLPrefix: ctx.Issue.Repo.HTMLURL(),
|
|
Metas: ctx.Issue.Repo.ComposeMetas(),
|
|
}, ctx.Content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
|
|
|
|
if actName != "new" {
|
|
prefix = "Re: "
|
|
}
|
|
fallback = prefix + fallbackMailSubject(ctx.Issue)
|
|
|
|
if ctx.Comment != nil && ctx.Comment.Review != nil {
|
|
reviewComments = make([]*issues_model.Comment, 0, 10)
|
|
for _, lines := range ctx.Comment.Review.CodeComments {
|
|
for _, comments := range lines {
|
|
reviewComments = append(reviewComments, comments...)
|
|
}
|
|
}
|
|
}
|
|
locale := translation.NewLocale(lang)
|
|
|
|
mailMeta := map[string]interface{}{
|
|
"FallbackSubject": fallback,
|
|
"Body": body,
|
|
"Link": link,
|
|
"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,
|
|
"SubjectPrefix": prefix,
|
|
"ActionType": actType,
|
|
"ActionName": actName,
|
|
"ReviewComments": reviewComments,
|
|
"Language": locale.Language(),
|
|
// helper
|
|
"locale": locale,
|
|
"Str2html": templates.Str2html,
|
|
"DotEscape": templates.DotEscape,
|
|
}
|
|
|
|
var mailSubject bytes.Buffer
|
|
if err := subjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
|
|
subject = sanitizeSubject(mailSubject.String())
|
|
if subject == "" {
|
|
subject = fallback
|
|
}
|
|
} else {
|
|
log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err)
|
|
}
|
|
|
|
subject = emoji.ReplaceAliases(subject)
|
|
|
|
mailMeta["Subject"] = subject
|
|
|
|
var mailBody bytes.Buffer
|
|
|
|
if err := bodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
|
|
log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
|
|
}
|
|
|
|
// Make sure to compose independent messages to avoid leaking user emails
|
|
msgID := createReference(ctx.Issue, ctx.Comment, ctx.ActionType)
|
|
reference := createReference(ctx.Issue, nil, activities_model.ActionType(0))
|
|
|
|
msgs := make([]*Message, 0, len(recipients))
|
|
for _, recipient := range recipients {
|
|
msg := NewMessageFrom([]string{recipient.Email}, ctx.Doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String())
|
|
msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
|
|
|
|
msg.SetHeader("Message-ID", "<"+msgID+">")
|
|
msg.SetHeader("In-Reply-To", "<"+reference+">")
|
|
msg.SetHeader("References", "<"+reference+">")
|
|
|
|
for key, value := range generateAdditionalHeaders(ctx, actType, recipient) {
|
|
msg.SetHeader(key, value)
|
|
}
|
|
|
|
msgs = append(msgs, msg)
|
|
}
|
|
|
|
return msgs, nil
|
|
}
|
|
|
|
func createReference(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
|
|
var path string
|
|
if issue.IsPull {
|
|
path = "pulls"
|
|
} else {
|
|
path = "issues"
|
|
}
|
|
|
|
var extra string
|
|
if comment != nil {
|
|
extra = fmt.Sprintf("/comment/%d", comment.ID)
|
|
} else {
|
|
switch actionType {
|
|
case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
|
|
extra = fmt.Sprintf("/close/%d", time.Now().UnixNano()/1e6)
|
|
case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
|
|
extra = fmt.Sprintf("/reopen/%d", time.Now().UnixNano()/1e6)
|
|
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
|
|
extra = fmt.Sprintf("/merge/%d", time.Now().UnixNano()/1e6)
|
|
case activities_model.ActionPullRequestReadyForReview:
|
|
extra = fmt.Sprintf("/ready/%d", time.Now().UnixNano()/1e6)
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("%s/%s/%d%s@%s", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
|
|
}
|
|
|
|
func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string {
|
|
repo := ctx.Issue.Repo
|
|
|
|
return map[string]string{
|
|
// https://datatracker.ietf.org/doc/html/rfc2919
|
|
"List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc2369
|
|
"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
|
|
//"List-Post": https://github.com/go-gitea/gitea/pull/13585
|
|
"List-Unsubscribe": ctx.Issue.HTMLURL(),
|
|
|
|
"X-Mailer": "Gitea",
|
|
"X-Gitea-Reason": reason,
|
|
"X-Gitea-Sender": ctx.Doer.DisplayName(),
|
|
"X-Gitea-Recipient": recipient.DisplayName(),
|
|
"X-Gitea-Recipient-Address": recipient.Email,
|
|
"X-Gitea-Repository": repo.Name,
|
|
"X-Gitea-Repository-Path": repo.FullName(),
|
|
"X-Gitea-Repository-Link": repo.HTMLURL(),
|
|
"X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10),
|
|
"X-Gitea-Issue-Link": ctx.Issue.HTMLURL(),
|
|
|
|
"X-GitHub-Reason": reason,
|
|
"X-GitHub-Sender": ctx.Doer.DisplayName(),
|
|
"X-GitHub-Recipient": recipient.DisplayName(),
|
|
"X-GitHub-Recipient-Address": recipient.Email,
|
|
|
|
"X-GitLab-NotificationReason": reason,
|
|
"X-GitLab-Project": repo.Name,
|
|
"X-GitLab-Project-Path": repo.FullName(),
|
|
"X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10),
|
|
}
|
|
}
|
|
|
|
func sanitizeSubject(subject string) string {
|
|
runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " ")))
|
|
if len(runes) > mailMaxSubjectRunes {
|
|
runes = runes[:mailMaxSubjectRunes]
|
|
}
|
|
// Encode non-ASCII characters
|
|
return mime.QEncoding.Encode("utf-8", string(runes))
|
|
}
|
|
|
|
// SendIssueAssignedMail composes and sends issue assigned email
|
|
func SendIssueAssignedMail(issue *issues_model.Issue, doer *user_model.User, content string, comment *issues_model.Comment, recipients []*user_model.User) error {
|
|
if setting.MailService == nil {
|
|
// No mail service configured
|
|
return nil
|
|
}
|
|
|
|
if err := issue.LoadRepo(db.DefaultContext); err != nil {
|
|
log.Error("Unable to load repo [%d] for issue #%d [%d]. Error: %v", issue.RepoID, issue.Index, issue.ID, err)
|
|
return err
|
|
}
|
|
|
|
langMap := make(map[string][]*user_model.User)
|
|
for _, user := range recipients {
|
|
if !user.IsActive {
|
|
// don't send emails to inactive users
|
|
continue
|
|
}
|
|
langMap[user.Language] = append(langMap[user.Language], user)
|
|
}
|
|
|
|
for lang, tos := range langMap {
|
|
msgs, err := composeIssueCommentMessages(&mailCommentContext{
|
|
Context: context.TODO(), // TODO: use a correct context
|
|
Issue: issue,
|
|
Doer: doer,
|
|
ActionType: activities_model.ActionType(0),
|
|
Content: content,
|
|
Comment: comment,
|
|
}, lang, tos, false, "issue assigned")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
SendAsyncs(msgs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// actionToTemplate returns the type and name of the action facing the user
|
|
// (slightly different from activities_model.ActionType) and the name of the template to use (based on availability)
|
|
func actionToTemplate(issue *issues_model.Issue, actionType activities_model.ActionType,
|
|
commentType issues_model.CommentType, reviewType issues_model.ReviewType,
|
|
) (typeName, name, template string) {
|
|
if issue.IsPull {
|
|
typeName = "pull"
|
|
} else {
|
|
typeName = "issue"
|
|
}
|
|
switch actionType {
|
|
case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest:
|
|
name = "new"
|
|
case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
|
|
name = "comment"
|
|
case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
|
|
name = "close"
|
|
case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
|
|
name = "reopen"
|
|
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
|
|
name = "merge"
|
|
case activities_model.ActionPullReviewDismissed:
|
|
name = "review_dismissed"
|
|
case activities_model.ActionPullRequestReadyForReview:
|
|
name = "ready_for_review"
|
|
default:
|
|
switch commentType {
|
|
case issues_model.CommentTypeReview:
|
|
switch reviewType {
|
|
case issues_model.ReviewTypeApprove:
|
|
name = "approve"
|
|
case issues_model.ReviewTypeReject:
|
|
name = "reject"
|
|
default:
|
|
name = "review"
|
|
}
|
|
case issues_model.CommentTypeCode:
|
|
name = "code"
|
|
case issues_model.CommentTypeAssignees:
|
|
name = "assigned"
|
|
case issues_model.CommentTypePullRequestPush:
|
|
name = "push"
|
|
default:
|
|
name = "default"
|
|
}
|
|
}
|
|
|
|
template = typeName + "/" + name
|
|
ok := bodyTemplates.Lookup(template) != nil
|
|
if !ok && typeName != "issue" {
|
|
template = "issue/" + name
|
|
ok = bodyTemplates.Lookup(template) != nil
|
|
}
|
|
if !ok {
|
|
template = typeName + "/default"
|
|
ok = bodyTemplates.Lookup(template) != nil
|
|
}
|
|
if !ok {
|
|
template = "issue/default"
|
|
}
|
|
return typeName, name, template
|
|
}
|