Files
forge/services/mailer/mail_issue_common.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

322 lines
11 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package mailer
import (
"bytes"
"context"
"fmt"
"maps"
"strconv"
"strings"
"time"
activities_model "github.com/hanzoai/git/models/activities"
issues_model "github.com/hanzoai/git/models/issues"
"github.com/hanzoai/git/models/renderhelper"
user_model "github.com/hanzoai/git/models/user"
"github.com/hanzoai/git/modules/emoji"
"github.com/hanzoai/git/modules/log"
"github.com/hanzoai/git/modules/markup/markdown"
"github.com/hanzoai/git/modules/setting"
"github.com/hanzoai/git/modules/translation"
"github.com/hanzoai/git/modules/util"
incoming_payload "github.com/hanzoai/git/services/mailer/incoming/payload"
sender_service "github.com/hanzoai/git/services/mailer/sender"
"github.com/hanzoai/git/services/mailer/token"
)
// maxEmailBodySize is the approximate maximum size of an email body in bytes
// Many e-mail service providers have limitations on the size of the email body, it's usually from 10MB to 25MB
const maxEmailBodySize = 9_000_000
func fallbackIssueMailSubject(issue *issues_model.Issue) string {
return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
}
type mailComment struct {
Issue *issues_model.Issue
Doer *user_model.User
ActionType activities_model.ActionType
Content string
Comment *issues_model.Comment
ForceDoerNotification bool
}
func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*sender_service.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 comment.Comment != nil {
commentType = comment.Comment.Type
link = comment.Issue.HTMLURL(ctx) + "#" + comment.Comment.HashTag()
} else {
link = comment.Issue.HTMLURL(ctx)
}
reviewType := issues_model.ReviewTypeComment
if comment.Comment != nil && comment.Comment.Review != nil {
reviewType = comment.Comment.Review.Type
}
// This is the body of the new issue or comment, not the mail body
rctx := renderhelper.NewRenderContextRepoComment(ctx, comment.Issue.Repo).WithUseAbsoluteLink(true)
body, err := markdown.RenderString(rctx, comment.Content)
if err != nil {
return nil, err
}
if setting.MailService.EmbedAttachmentImages {
attEmbedder := newMailAttachmentBase64Embedder(comment.Doer, comment.Issue.Repo, maxEmailBodySize)
bodyAfterEmbedding, err := attEmbedder.Base64InlineImages(ctx, body)
if err != nil {
log.Error("Failed to embed images in mail body: %v", err)
} else {
body = bodyAfterEmbedding
}
}
actType, actName, tplName := actionToTemplate(comment.Issue, comment.ActionType, commentType, reviewType)
if actName != "new" {
prefix = "Re: "
}
fallback = prefix + fallbackIssueMailSubject(comment.Issue)
if comment.Comment != nil && comment.Comment.Review != nil {
reviewComments = make([]*issues_model.Comment, 0, 10)
for _, lines := range comment.Comment.Review.CodeComments {
for _, comments := range lines {
reviewComments = append(reviewComments, comments...)
}
}
}
locale := translation.NewLocale(lang)
if lang == "mock" {
locale = &translation.MockLocale{}
}
mailMeta := map[string]any{
"locale": locale,
"FallbackSubject": fallback,
"Body": body,
"Link": link,
"Issue": comment.Issue,
"Comment": comment.Comment,
"IsPull": comment.Issue.IsPull,
"User": comment.Issue.Repo.MustOwner(ctx),
"Repo": comment.Issue.Repo.FullName(),
"Doer": comment.Doer,
"IsMention": fromMention,
"SubjectPrefix": prefix,
"ActionType": actType,
"ActionName": actName,
"ReviewComments": reviewComments,
"Language": locale.Language(),
"CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush,
}
var mailSubject bytes.Buffer
if err := LoadedTemplates().SubjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
subject = sanitizeSubject(mailSubject.String())
subject = util.IfZero(subject, fallback)
} else {
log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err)
}
subject = emoji.ReplaceAliases(subject)
mailMeta["Subject"] = subject
var mailBody bytes.Buffer
if err := LoadedTemplates().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 := generateMessageIDForIssue(comment.Issue, comment.Comment, comment.ActionType)
reference := generateMessageIDForIssue(comment.Issue, nil, activities_model.ActionType(0))
var replyPayload []byte
if comment.Comment != nil {
if comment.Comment.Type.HasMailReplySupport() {
replyPayload, err = incoming_payload.CreateReferencePayload(comment.Comment)
}
} else {
replyPayload, err = incoming_payload.CreateReferencePayload(comment.Issue)
}
if err != nil {
return nil, err
}
unsubscribePayload, err := incoming_payload.CreateReferencePayload(comment.Issue)
if err != nil {
return nil, err
}
msgs := make([]*sender_service.Message, 0, len(recipients))
for _, recipient := range recipients {
msg := sender_service.NewMessageFrom(
recipient.Email,
fromDisplayName(comment.Doer),
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)
references := []string{reference}
listUnsubscribe := []string{"<" + comment.Issue.HTMLURL(ctx) + ">"}
if setting.IncomingEmail.Enabled {
if replyPayload != nil {
token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload)
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {
replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder, token, 1)
msg.ReplyTo = replyAddress
msg.SetHeader("List-Post", fmt.Sprintf("<mailto:%s>", replyAddress))
references = append(references, fmt.Sprintf("<reply-%s@%s>", token, setting.Domain))
}
}
token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload)
if err != nil {
log.Error("CreateToken failed: %v", err)
} else {
unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder, token, 1)
listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
}
}
msg.SetHeader("References", references...)
msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
for key, value := range generateAdditionalHeadersForIssue(ctx, comment, actType, recipient) {
msg.SetHeader(key, value)
}
msgs = append(msgs, msg)
}
return msgs, 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 = "repo/" + typeName + "/" + name
ok := LoadedTemplates().BodyTemplates.HasTemplate(template)
if !ok && typeName != "issue" {
template = "repo/issue/" + name
ok = LoadedTemplates().BodyTemplates.HasTemplate(template)
}
if !ok {
template = "repo/" + typeName + "/default"
ok = LoadedTemplates().BodyTemplates.HasTemplate(template)
}
if !ok {
template = "repo/issue/default"
}
return typeName, name, template
}
func generateMessageIDForIssue(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 generateAdditionalHeadersForIssue(ctx context.Context, comment *mailComment, reason string, recipient *user_model.User) map[string]string {
repo := comment.Issue.Repo
issueID := strconv.FormatInt(comment.Issue.Index, 10)
headers := generateMetadataHeaders(repo)
maps.Copy(headers, generateSenderRecipientHeaders(comment.Doer, recipient))
maps.Copy(headers, generateReasonHeaders(reason))
headers["X-Git-Issue-ID"] = issueID
headers["X-Git-Issue-Link"] = comment.Issue.HTMLURL(ctx)
headers["X-GitLab-Issue-IID"] = issueID
return headers
}