gitea源码

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. // Copyright 2025 The Gitea Authors. All rights reserved.
  2. // SPDX-License-Identifier: MIT
  3. package mailer
  4. import (
  5. "bytes"
  6. "context"
  7. "fmt"
  8. "maps"
  9. "strconv"
  10. "strings"
  11. "time"
  12. activities_model "code.gitea.io/gitea/models/activities"
  13. issues_model "code.gitea.io/gitea/models/issues"
  14. "code.gitea.io/gitea/models/renderhelper"
  15. user_model "code.gitea.io/gitea/models/user"
  16. "code.gitea.io/gitea/modules/emoji"
  17. "code.gitea.io/gitea/modules/log"
  18. "code.gitea.io/gitea/modules/markup/markdown"
  19. "code.gitea.io/gitea/modules/setting"
  20. "code.gitea.io/gitea/modules/translation"
  21. incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
  22. sender_service "code.gitea.io/gitea/services/mailer/sender"
  23. "code.gitea.io/gitea/services/mailer/token"
  24. )
  25. // maxEmailBodySize is the approximate maximum size of an email body in bytes
  26. // Many e-mail service providers have limitations on the size of the email body, it's usually from 10MB to 25MB
  27. const maxEmailBodySize = 9_000_000
  28. func fallbackIssueMailSubject(issue *issues_model.Issue) string {
  29. return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
  30. }
  31. type mailComment struct {
  32. Issue *issues_model.Issue
  33. Doer *user_model.User
  34. ActionType activities_model.ActionType
  35. Content string
  36. Comment *issues_model.Comment
  37. ForceDoerNotification bool
  38. }
  39. func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*sender_service.Message, error) {
  40. var (
  41. subject string
  42. link string
  43. prefix string
  44. // Fall back subject for bad templates, make sure subject is never empty
  45. fallback string
  46. reviewComments []*issues_model.Comment
  47. )
  48. commentType := issues_model.CommentTypeComment
  49. if comment.Comment != nil {
  50. commentType = comment.Comment.Type
  51. link = comment.Issue.HTMLURL(ctx) + "#" + comment.Comment.HashTag()
  52. } else {
  53. link = comment.Issue.HTMLURL(ctx)
  54. }
  55. reviewType := issues_model.ReviewTypeComment
  56. if comment.Comment != nil && comment.Comment.Review != nil {
  57. reviewType = comment.Comment.Review.Type
  58. }
  59. // This is the body of the new issue or comment, not the mail body
  60. rctx := renderhelper.NewRenderContextRepoComment(ctx, comment.Issue.Repo).WithUseAbsoluteLink(true)
  61. body, err := markdown.RenderString(rctx, comment.Content)
  62. if err != nil {
  63. return nil, err
  64. }
  65. if setting.MailService.EmbedAttachmentImages {
  66. attEmbedder := newMailAttachmentBase64Embedder(comment.Doer, comment.Issue.Repo, maxEmailBodySize)
  67. bodyAfterEmbedding, err := attEmbedder.Base64InlineImages(ctx, body)
  68. if err != nil {
  69. log.Error("Failed to embed images in mail body: %v", err)
  70. } else {
  71. body = bodyAfterEmbedding
  72. }
  73. }
  74. actType, actName, tplName := actionToTemplate(comment.Issue, comment.ActionType, commentType, reviewType)
  75. if actName != "new" {
  76. prefix = "Re: "
  77. }
  78. fallback = prefix + fallbackIssueMailSubject(comment.Issue)
  79. if comment.Comment != nil && comment.Comment.Review != nil {
  80. reviewComments = make([]*issues_model.Comment, 0, 10)
  81. for _, lines := range comment.Comment.Review.CodeComments {
  82. for _, comments := range lines {
  83. reviewComments = append(reviewComments, comments...)
  84. }
  85. }
  86. }
  87. locale := translation.NewLocale(lang)
  88. mailMeta := map[string]any{
  89. "locale": locale,
  90. "FallbackSubject": fallback,
  91. "Body": body,
  92. "Link": link,
  93. "Issue": comment.Issue,
  94. "Comment": comment.Comment,
  95. "IsPull": comment.Issue.IsPull,
  96. "User": comment.Issue.Repo.MustOwner(ctx),
  97. "Repo": comment.Issue.Repo.FullName(),
  98. "Doer": comment.Doer,
  99. "IsMention": fromMention,
  100. "SubjectPrefix": prefix,
  101. "ActionType": actType,
  102. "ActionName": actName,
  103. "ReviewComments": reviewComments,
  104. "Language": locale.Language(),
  105. "CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush,
  106. }
  107. var mailSubject bytes.Buffer
  108. if err := LoadedTemplates().SubjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
  109. subject = sanitizeSubject(mailSubject.String())
  110. if subject == "" {
  111. subject = fallback
  112. }
  113. } else {
  114. log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err)
  115. }
  116. subject = emoji.ReplaceAliases(subject)
  117. mailMeta["Subject"] = subject
  118. var mailBody bytes.Buffer
  119. if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
  120. log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
  121. }
  122. // Make sure to compose independent messages to avoid leaking user emails
  123. msgID := generateMessageIDForIssue(comment.Issue, comment.Comment, comment.ActionType)
  124. reference := generateMessageIDForIssue(comment.Issue, nil, activities_model.ActionType(0))
  125. var replyPayload []byte
  126. if comment.Comment != nil {
  127. if comment.Comment.Type.HasMailReplySupport() {
  128. replyPayload, err = incoming_payload.CreateReferencePayload(comment.Comment)
  129. }
  130. } else {
  131. replyPayload, err = incoming_payload.CreateReferencePayload(comment.Issue)
  132. }
  133. if err != nil {
  134. return nil, err
  135. }
  136. unsubscribePayload, err := incoming_payload.CreateReferencePayload(comment.Issue)
  137. if err != nil {
  138. return nil, err
  139. }
  140. msgs := make([]*sender_service.Message, 0, len(recipients))
  141. for _, recipient := range recipients {
  142. msg := sender_service.NewMessageFrom(
  143. recipient.Email,
  144. fromDisplayName(comment.Doer),
  145. setting.MailService.FromEmail,
  146. subject,
  147. mailBody.String(),
  148. )
  149. msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
  150. msg.SetHeader("Message-ID", msgID)
  151. msg.SetHeader("In-Reply-To", reference)
  152. references := []string{reference}
  153. listUnsubscribe := []string{"<" + comment.Issue.HTMLURL(ctx) + ">"}
  154. if setting.IncomingEmail.Enabled {
  155. if replyPayload != nil {
  156. token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload)
  157. if err != nil {
  158. log.Error("CreateToken failed: %v", err)
  159. } else {
  160. replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
  161. msg.ReplyTo = replyAddress
  162. msg.SetHeader("List-Post", fmt.Sprintf("<mailto:%s>", replyAddress))
  163. references = append(references, fmt.Sprintf("<reply-%s@%s>", token, setting.Domain))
  164. }
  165. }
  166. token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload)
  167. if err != nil {
  168. log.Error("CreateToken failed: %v", err)
  169. } else {
  170. unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
  171. listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
  172. }
  173. }
  174. msg.SetHeader("References", references...)
  175. msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
  176. for key, value := range generateAdditionalHeadersForIssue(comment, actType, recipient) {
  177. msg.SetHeader(key, value)
  178. }
  179. msgs = append(msgs, msg)
  180. }
  181. return msgs, nil
  182. }
  183. // actionToTemplate returns the type and name of the action facing the user
  184. // (slightly different from activities_model.ActionType) and the name of the template to use (based on availability)
  185. func actionToTemplate(issue *issues_model.Issue, actionType activities_model.ActionType,
  186. commentType issues_model.CommentType, reviewType issues_model.ReviewType,
  187. ) (typeName, name, template string) {
  188. if issue.IsPull {
  189. typeName = "pull"
  190. } else {
  191. typeName = "issue"
  192. }
  193. switch actionType {
  194. case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest:
  195. name = "new"
  196. case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
  197. name = "comment"
  198. case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
  199. name = "close"
  200. case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
  201. name = "reopen"
  202. case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
  203. name = "merge"
  204. case activities_model.ActionPullReviewDismissed:
  205. name = "review_dismissed"
  206. case activities_model.ActionPullRequestReadyForReview:
  207. name = "ready_for_review"
  208. default:
  209. switch commentType {
  210. case issues_model.CommentTypeReview:
  211. switch reviewType {
  212. case issues_model.ReviewTypeApprove:
  213. name = "approve"
  214. case issues_model.ReviewTypeReject:
  215. name = "reject"
  216. default:
  217. name = "review"
  218. }
  219. case issues_model.CommentTypeCode:
  220. name = "code"
  221. case issues_model.CommentTypeAssignees:
  222. name = "assigned"
  223. case issues_model.CommentTypePullRequestPush:
  224. name = "push"
  225. default:
  226. name = "default"
  227. }
  228. }
  229. template = "repo/" + typeName + "/" + name
  230. ok := LoadedTemplates().BodyTemplates.Lookup(template) != nil
  231. if !ok && typeName != "issue" {
  232. template = "repo/issue/" + name
  233. ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
  234. }
  235. if !ok {
  236. template = "repo/" + typeName + "/default"
  237. ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
  238. }
  239. if !ok {
  240. template = "repo/issue/default"
  241. }
  242. return typeName, name, template
  243. }
  244. func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
  245. var path string
  246. if issue.IsPull {
  247. path = "pulls"
  248. } else {
  249. path = "issues"
  250. }
  251. var extra string
  252. if comment != nil {
  253. extra = fmt.Sprintf("/comment/%d", comment.ID)
  254. } else {
  255. switch actionType {
  256. case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
  257. extra = fmt.Sprintf("/close/%d", time.Now().UnixNano()/1e6)
  258. case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
  259. extra = fmt.Sprintf("/reopen/%d", time.Now().UnixNano()/1e6)
  260. case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
  261. extra = fmt.Sprintf("/merge/%d", time.Now().UnixNano()/1e6)
  262. case activities_model.ActionPullRequestReadyForReview:
  263. extra = fmt.Sprintf("/ready/%d", time.Now().UnixNano()/1e6)
  264. }
  265. }
  266. return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
  267. }
  268. func generateAdditionalHeadersForIssue(ctx *mailComment, reason string, recipient *user_model.User) map[string]string {
  269. repo := ctx.Issue.Repo
  270. issueID := strconv.FormatInt(ctx.Issue.Index, 10)
  271. headers := generateMetadataHeaders(repo)
  272. maps.Copy(headers, generateSenderRecipientHeaders(ctx.Doer, recipient))
  273. maps.Copy(headers, generateReasonHeaders(reason))
  274. headers["X-Gitea-Issue-ID"] = issueID
  275. headers["X-Gitea-Issue-Link"] = ctx.Issue.HTMLURL(context.TODO()) // FIXME: use proper context
  276. headers["X-GitLab-Issue-IID"] = issueID
  277. return headers
  278. }