您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. // Copyright 2016 The Gogs Authors. All rights reserved.
  2. // Copyright 2019 The Gitea Authors. All rights reserved.
  3. // SPDX-License-Identifier: MIT
  4. package mailer
  5. import (
  6. "bytes"
  7. "context"
  8. "fmt"
  9. "html/template"
  10. "mime"
  11. "regexp"
  12. "strconv"
  13. "strings"
  14. texttmpl "text/template"
  15. "time"
  16. activities_model "code.gitea.io/gitea/models/activities"
  17. issues_model "code.gitea.io/gitea/models/issues"
  18. repo_model "code.gitea.io/gitea/models/repo"
  19. user_model "code.gitea.io/gitea/models/user"
  20. "code.gitea.io/gitea/modules/base"
  21. "code.gitea.io/gitea/modules/emoji"
  22. "code.gitea.io/gitea/modules/log"
  23. "code.gitea.io/gitea/modules/markup"
  24. "code.gitea.io/gitea/modules/markup/markdown"
  25. "code.gitea.io/gitea/modules/setting"
  26. "code.gitea.io/gitea/modules/timeutil"
  27. "code.gitea.io/gitea/modules/translation"
  28. incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
  29. "code.gitea.io/gitea/services/mailer/token"
  30. "gopkg.in/gomail.v2"
  31. )
  32. const (
  33. mailAuthActivate base.TplName = "auth/activate"
  34. mailAuthActivateEmail base.TplName = "auth/activate_email"
  35. mailAuthResetPassword base.TplName = "auth/reset_passwd"
  36. mailAuthRegisterNotify base.TplName = "auth/register_notify"
  37. mailNotifyCollaborator base.TplName = "notify/collaborator"
  38. mailRepoTransferNotify base.TplName = "notify/repo_transfer"
  39. // There's no actual limit for subject in RFC 5322
  40. mailMaxSubjectRunes = 256
  41. )
  42. var (
  43. bodyTemplates *template.Template
  44. subjectTemplates *texttmpl.Template
  45. subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
  46. )
  47. // SendTestMail sends a test mail
  48. func SendTestMail(email string) error {
  49. if setting.MailService == nil {
  50. // No mail service configured
  51. return nil
  52. }
  53. return gomail.Send(Sender, NewMessage(email, "Gitea Test Email!", "Gitea Test Email!").ToMessage())
  54. }
  55. // sendUserMail sends a mail to the user
  56. func sendUserMail(language string, u *user_model.User, tpl base.TplName, code, subject, info string) {
  57. locale := translation.NewLocale(language)
  58. data := map[string]any{
  59. "locale": locale,
  60. "DisplayName": u.DisplayName(),
  61. "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
  62. "ResetPwdCodeLives": timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, locale),
  63. "Code": code,
  64. "Language": locale.Language(),
  65. }
  66. var content bytes.Buffer
  67. if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
  68. log.Error("Template: %v", err)
  69. return
  70. }
  71. msg := NewMessage(u.Email, subject, content.String())
  72. msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info)
  73. SendAsync(msg)
  74. }
  75. // SendActivateAccountMail sends an activation mail to the user (new user registration)
  76. func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
  77. if setting.MailService == nil {
  78. // No mail service configured
  79. return
  80. }
  81. sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.activate_account"), "activate account")
  82. }
  83. // SendResetPasswordMail sends a password reset mail to the user
  84. func SendResetPasswordMail(u *user_model.User) {
  85. if setting.MailService == nil {
  86. // No mail service configured
  87. return
  88. }
  89. locale := translation.NewLocale(u.Language)
  90. sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.reset_password"), "recover account")
  91. }
  92. // SendActivateEmailMail sends confirmation email to confirm new email address
  93. func SendActivateEmailMail(u *user_model.User, email string) {
  94. if setting.MailService == nil {
  95. // No mail service configured
  96. return
  97. }
  98. locale := translation.NewLocale(u.Language)
  99. data := map[string]any{
  100. "locale": locale,
  101. "DisplayName": u.DisplayName(),
  102. "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
  103. "Code": u.GenerateEmailActivateCode(email),
  104. "Email": email,
  105. "Language": locale.Language(),
  106. }
  107. var content bytes.Buffer
  108. if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
  109. log.Error("Template: %v", err)
  110. return
  111. }
  112. msg := NewMessage(email, locale.TrString("mail.activate_email"), content.String())
  113. msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
  114. SendAsync(msg)
  115. }
  116. // SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
  117. func SendRegisterNotifyMail(u *user_model.User) {
  118. if setting.MailService == nil || !u.IsActive {
  119. // No mail service configured OR user is inactive
  120. return
  121. }
  122. locale := translation.NewLocale(u.Language)
  123. data := map[string]any{
  124. "locale": locale,
  125. "DisplayName": u.DisplayName(),
  126. "Username": u.Name,
  127. "Language": locale.Language(),
  128. }
  129. var content bytes.Buffer
  130. if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
  131. log.Error("Template: %v", err)
  132. return
  133. }
  134. msg := NewMessage(u.Email, locale.TrString("mail.register_notify"), content.String())
  135. msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID)
  136. SendAsync(msg)
  137. }
  138. // SendCollaboratorMail sends mail notification to new collaborator.
  139. func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) {
  140. if setting.MailService == nil || !u.IsActive {
  141. // No mail service configured OR the user is inactive
  142. return
  143. }
  144. locale := translation.NewLocale(u.Language)
  145. repoName := repo.FullName()
  146. subject := locale.TrString("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
  147. data := map[string]any{
  148. "locale": locale,
  149. "Subject": subject,
  150. "RepoName": repoName,
  151. "Link": repo.HTMLURL(),
  152. "Language": locale.Language(),
  153. }
  154. var content bytes.Buffer
  155. if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
  156. log.Error("Template: %v", err)
  157. return
  158. }
  159. msg := NewMessage(u.Email, subject, content.String())
  160. msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.ID)
  161. SendAsync(msg)
  162. }
  163. func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*Message, error) {
  164. var (
  165. subject string
  166. link string
  167. prefix string
  168. // Fall back subject for bad templates, make sure subject is never empty
  169. fallback string
  170. reviewComments []*issues_model.Comment
  171. )
  172. commentType := issues_model.CommentTypeComment
  173. if ctx.Comment != nil {
  174. commentType = ctx.Comment.Type
  175. link = ctx.Issue.HTMLURL() + "#" + ctx.Comment.HashTag()
  176. } else {
  177. link = ctx.Issue.HTMLURL()
  178. }
  179. reviewType := issues_model.ReviewTypeComment
  180. if ctx.Comment != nil && ctx.Comment.Review != nil {
  181. reviewType = ctx.Comment.Review.Type
  182. }
  183. // This is the body of the new issue or comment, not the mail body
  184. body, err := markdown.RenderString(&markup.RenderContext{
  185. Ctx: ctx,
  186. Links: markup.Links{
  187. AbsolutePrefix: true,
  188. Base: ctx.Issue.Repo.HTMLURL(),
  189. },
  190. Metas: ctx.Issue.Repo.ComposeMetas(ctx),
  191. }, ctx.Content)
  192. if err != nil {
  193. return nil, err
  194. }
  195. actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
  196. if actName != "new" {
  197. prefix = "Re: "
  198. }
  199. fallback = prefix + fallbackMailSubject(ctx.Issue)
  200. if ctx.Comment != nil && ctx.Comment.Review != nil {
  201. reviewComments = make([]*issues_model.Comment, 0, 10)
  202. for _, lines := range ctx.Comment.Review.CodeComments {
  203. for _, comments := range lines {
  204. reviewComments = append(reviewComments, comments...)
  205. }
  206. }
  207. }
  208. locale := translation.NewLocale(lang)
  209. mailMeta := map[string]any{
  210. "locale": locale,
  211. "FallbackSubject": fallback,
  212. "Body": body,
  213. "Link": link,
  214. "Issue": ctx.Issue,
  215. "Comment": ctx.Comment,
  216. "IsPull": ctx.Issue.IsPull,
  217. "User": ctx.Issue.Repo.MustOwner(ctx),
  218. "Repo": ctx.Issue.Repo.FullName(),
  219. "Doer": ctx.Doer,
  220. "IsMention": fromMention,
  221. "SubjectPrefix": prefix,
  222. "ActionType": actType,
  223. "ActionName": actName,
  224. "ReviewComments": reviewComments,
  225. "Language": locale.Language(),
  226. "CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush,
  227. }
  228. var mailSubject bytes.Buffer
  229. if err := subjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
  230. subject = sanitizeSubject(mailSubject.String())
  231. if subject == "" {
  232. subject = fallback
  233. }
  234. } else {
  235. log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err)
  236. }
  237. subject = emoji.ReplaceAliases(subject)
  238. mailMeta["Subject"] = subject
  239. var mailBody bytes.Buffer
  240. if err := bodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
  241. log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
  242. }
  243. // Make sure to compose independent messages to avoid leaking user emails
  244. msgID := createReference(ctx.Issue, ctx.Comment, ctx.ActionType)
  245. reference := createReference(ctx.Issue, nil, activities_model.ActionType(0))
  246. var replyPayload []byte
  247. if ctx.Comment != nil {
  248. if ctx.Comment.Type.HasMailReplySupport() {
  249. replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Comment)
  250. }
  251. } else {
  252. replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Issue)
  253. }
  254. if err != nil {
  255. return nil, err
  256. }
  257. unsubscribePayload, err := incoming_payload.CreateReferencePayload(ctx.Issue)
  258. if err != nil {
  259. return nil, err
  260. }
  261. msgs := make([]*Message, 0, len(recipients))
  262. for _, recipient := range recipients {
  263. msg := NewMessageFrom(
  264. recipient.Email,
  265. ctx.Doer.GetCompleteName(),
  266. setting.MailService.FromEmail,
  267. subject,
  268. mailBody.String(),
  269. )
  270. msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
  271. msg.SetHeader("Message-ID", msgID)
  272. msg.SetHeader("In-Reply-To", reference)
  273. references := []string{reference}
  274. listUnsubscribe := []string{"<" + ctx.Issue.HTMLURL() + ">"}
  275. if setting.IncomingEmail.Enabled {
  276. if replyPayload != nil {
  277. token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload)
  278. if err != nil {
  279. log.Error("CreateToken failed: %v", err)
  280. } else {
  281. replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
  282. msg.ReplyTo = replyAddress
  283. msg.SetHeader("List-Post", fmt.Sprintf("<mailto:%s>", replyAddress))
  284. references = append(references, fmt.Sprintf("<reply-%s@%s>", token, setting.Domain))
  285. }
  286. }
  287. token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload)
  288. if err != nil {
  289. log.Error("CreateToken failed: %v", err)
  290. } else {
  291. unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
  292. listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
  293. }
  294. }
  295. msg.SetHeader("References", references...)
  296. msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
  297. for key, value := range generateAdditionalHeaders(ctx, actType, recipient) {
  298. msg.SetHeader(key, value)
  299. }
  300. msgs = append(msgs, msg)
  301. }
  302. return msgs, nil
  303. }
  304. func createReference(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
  305. var path string
  306. if issue.IsPull {
  307. path = "pulls"
  308. } else {
  309. path = "issues"
  310. }
  311. var extra string
  312. if comment != nil {
  313. extra = fmt.Sprintf("/comment/%d", comment.ID)
  314. } else {
  315. switch actionType {
  316. case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
  317. extra = fmt.Sprintf("/close/%d", time.Now().UnixNano()/1e6)
  318. case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
  319. extra = fmt.Sprintf("/reopen/%d", time.Now().UnixNano()/1e6)
  320. case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
  321. extra = fmt.Sprintf("/merge/%d", time.Now().UnixNano()/1e6)
  322. case activities_model.ActionPullRequestReadyForReview:
  323. extra = fmt.Sprintf("/ready/%d", time.Now().UnixNano()/1e6)
  324. }
  325. }
  326. return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
  327. }
  328. func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string {
  329. repo := ctx.Issue.Repo
  330. return map[string]string{
  331. // https://datatracker.ietf.org/doc/html/rfc2919
  332. "List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
  333. // https://datatracker.ietf.org/doc/html/rfc2369
  334. "List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
  335. "X-Mailer": "Gitea",
  336. "X-Gitea-Reason": reason,
  337. "X-Gitea-Sender": ctx.Doer.Name,
  338. "X-Gitea-Recipient": recipient.Name,
  339. "X-Gitea-Recipient-Address": recipient.Email,
  340. "X-Gitea-Repository": repo.Name,
  341. "X-Gitea-Repository-Path": repo.FullName(),
  342. "X-Gitea-Repository-Link": repo.HTMLURL(),
  343. "X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10),
  344. "X-Gitea-Issue-Link": ctx.Issue.HTMLURL(),
  345. "X-GitHub-Reason": reason,
  346. "X-GitHub-Sender": ctx.Doer.Name,
  347. "X-GitHub-Recipient": recipient.Name,
  348. "X-GitHub-Recipient-Address": recipient.Email,
  349. "X-GitLab-NotificationReason": reason,
  350. "X-GitLab-Project": repo.Name,
  351. "X-GitLab-Project-Path": repo.FullName(),
  352. "X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10),
  353. }
  354. }
  355. func sanitizeSubject(subject string) string {
  356. runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " ")))
  357. if len(runes) > mailMaxSubjectRunes {
  358. runes = runes[:mailMaxSubjectRunes]
  359. }
  360. // Encode non-ASCII characters
  361. return mime.QEncoding.Encode("utf-8", string(runes))
  362. }
  363. // SendIssueAssignedMail composes and sends issue assigned email
  364. func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string, comment *issues_model.Comment, recipients []*user_model.User) error {
  365. if setting.MailService == nil {
  366. // No mail service configured
  367. return nil
  368. }
  369. if err := issue.LoadRepo(ctx); err != nil {
  370. log.Error("Unable to load repo [%d] for issue #%d [%d]. Error: %v", issue.RepoID, issue.Index, issue.ID, err)
  371. return err
  372. }
  373. langMap := make(map[string][]*user_model.User)
  374. for _, user := range recipients {
  375. if !user.IsActive {
  376. // don't send emails to inactive users
  377. continue
  378. }
  379. langMap[user.Language] = append(langMap[user.Language], user)
  380. }
  381. for lang, tos := range langMap {
  382. msgs, err := composeIssueCommentMessages(&mailCommentContext{
  383. Context: ctx,
  384. Issue: issue,
  385. Doer: doer,
  386. ActionType: activities_model.ActionType(0),
  387. Content: content,
  388. Comment: comment,
  389. }, lang, tos, false, "issue assigned")
  390. if err != nil {
  391. return err
  392. }
  393. SendAsync(msgs...)
  394. }
  395. return nil
  396. }
  397. // actionToTemplate returns the type and name of the action facing the user
  398. // (slightly different from activities_model.ActionType) and the name of the template to use (based on availability)
  399. func actionToTemplate(issue *issues_model.Issue, actionType activities_model.ActionType,
  400. commentType issues_model.CommentType, reviewType issues_model.ReviewType,
  401. ) (typeName, name, template string) {
  402. if issue.IsPull {
  403. typeName = "pull"
  404. } else {
  405. typeName = "issue"
  406. }
  407. switch actionType {
  408. case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest:
  409. name = "new"
  410. case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
  411. name = "comment"
  412. case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
  413. name = "close"
  414. case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
  415. name = "reopen"
  416. case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
  417. name = "merge"
  418. case activities_model.ActionPullReviewDismissed:
  419. name = "review_dismissed"
  420. case activities_model.ActionPullRequestReadyForReview:
  421. name = "ready_for_review"
  422. default:
  423. switch commentType {
  424. case issues_model.CommentTypeReview:
  425. switch reviewType {
  426. case issues_model.ReviewTypeApprove:
  427. name = "approve"
  428. case issues_model.ReviewTypeReject:
  429. name = "reject"
  430. default:
  431. name = "review"
  432. }
  433. case issues_model.CommentTypeCode:
  434. name = "code"
  435. case issues_model.CommentTypeAssignees:
  436. name = "assigned"
  437. case issues_model.CommentTypePullRequestPush:
  438. name = "push"
  439. default:
  440. name = "default"
  441. }
  442. }
  443. template = typeName + "/" + name
  444. ok := bodyTemplates.Lookup(template) != nil
  445. if !ok && typeName != "issue" {
  446. template = "issue/" + name
  447. ok = bodyTemplates.Lookup(template) != nil
  448. }
  449. if !ok {
  450. template = typeName + "/default"
  451. ok = bodyTemplates.Lookup(template) != nil
  452. }
  453. if !ok {
  454. template = "issue/default"
  455. }
  456. return typeName, name, template
  457. }