| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 |
- // Copyright 2025 The Gitea Authors. All rights reserved.
- // SPDX-License-Identifier: MIT
-
- package mailer
-
- import (
- "bytes"
- "context"
- "fmt"
- "maps"
- "strconv"
- "strings"
- "time"
-
- activities_model "code.gitea.io/gitea/models/activities"
- issues_model "code.gitea.io/gitea/models/issues"
- "code.gitea.io/gitea/models/renderhelper"
- user_model "code.gitea.io/gitea/models/user"
- "code.gitea.io/gitea/modules/emoji"
- "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"
- incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
- sender_service "code.gitea.io/gitea/services/mailer/sender"
- "code.gitea.io/gitea/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)
-
- 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())
- 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 := 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.IncomingEmail.TokenPlaceholder, 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.IncomingEmail.TokenPlaceholder, token, 1)
- listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
- }
- }
-
- msg.SetHeader("References", references...)
- msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
-
- for key, value := range generateAdditionalHeadersForIssue(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.Lookup(template) != nil
- if !ok && typeName != "issue" {
- template = "repo/issue/" + name
- ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
- }
- if !ok {
- template = "repo/" + typeName + "/default"
- ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
- }
- 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 *mailComment, reason string, recipient *user_model.User) map[string]string {
- repo := ctx.Issue.Repo
-
- issueID := strconv.FormatInt(ctx.Issue.Index, 10)
- headers := generateMetadataHeaders(repo)
-
- maps.Copy(headers, generateSenderRecipientHeaders(ctx.Doer, recipient))
- maps.Copy(headers, generateReasonHeaders(reason))
-
- headers["X-Gitea-Issue-ID"] = issueID
- headers["X-Gitea-Issue-Link"] = ctx.Issue.HTMLURL(context.TODO()) // FIXME: use proper context
- headers["X-GitLab-Issue-IID"] = issueID
-
- return headers
- }
|