mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
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]>
322 lines
11 KiB
Go
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
|
|
}
|