You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

mail.go 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  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.Tr("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.Tr("mail.reset_password"), "recover account")
  91. }
  92. // SendActivateEmailMail sends confirmation email to confirm new email address
  93. func SendActivateEmailMail(u *user_model.User, email *user_model.EmailAddress) {
  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.Email),
  104. "Email": 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.Email, locale.Tr("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.Tr("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.Tr("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. Base: ctx.Issue.Repo.HTMLURL(),
  188. },
  189. Metas: ctx.Issue.Repo.ComposeMetas(),
  190. }, ctx.Content)
  191. if err != nil {
  192. return nil, err
  193. }
  194. actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
  195. if actName != "new" {
  196. prefix = "Re: "
  197. }
  198. fallback = prefix + fallbackMailSubject(ctx.Issue)
  199. if ctx.Comment != nil && ctx.Comment.Review != nil {
  200. reviewComments = make([]*issues_model.Comment, 0, 10)
  201. for _, lines := range ctx.Comment.Review.CodeComments {
  202. for _, comments := range lines {
  203. reviewComments = append(reviewComments, comments...)
  204. }
  205. }
  206. }
  207. locale := translation.NewLocale(lang)
  208. mailMeta := map[string]any{
  209. "locale": locale,
  210. "FallbackSubject": fallback,
  211. "Body": body,
  212. "Link": link,
  213. "Issue": ctx.Issue,
  214. "Comment": ctx.Comment,
  215. "IsPull": ctx.Issue.IsPull,
  216. "User": ctx.Issue.Repo.MustOwner(ctx),
  217. "Repo": ctx.Issue.Repo.FullName(),
  218. "Doer": ctx.Doer,
  219. "IsMention": fromMention,
  220. "SubjectPrefix": prefix,
  221. "ActionType": actType,
  222. "ActionName": actName,
  223. "ReviewComments": reviewComments,
  224. "Language": locale.Language(),
  225. "CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush,
  226. }
  227. var mailSubject bytes.Buffer
  228. if err := subjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
  229. subject = sanitizeSubject(mailSubject.String())
  230. if subject == "" {
  231. subject = fallback
  232. }
  233. } else {
  234. log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err)
  235. }
  236. subject = emoji.ReplaceAliases(subject)
  237. mailMeta["Subject"] = subject
  238. var mailBody bytes.Buffer
  239. if err := bodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
  240. log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
  241. }
  242. // Make sure to compose independent messages to avoid leaking user emails
  243. msgID := createReference(ctx.Issue, ctx.Comment, ctx.ActionType)
  244. reference := createReference(ctx.Issue, nil, activities_model.ActionType(0))
  245. var replyPayload []byte
  246. if ctx.Comment != nil && ctx.Comment.Type == issues_model.CommentTypeCode {
  247. replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Comment)
  248. } else {
  249. replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Issue)
  250. }
  251. if err != nil {
  252. return nil, err
  253. }
  254. unsubscribePayload, err := incoming_payload.CreateReferencePayload(ctx.Issue)
  255. if err != nil {
  256. return nil, err
  257. }
  258. msgs := make([]*Message, 0, len(recipients))
  259. for _, recipient := range recipients {
  260. msg := NewMessageFrom(recipient.Email, ctx.Doer.DisplayName(), setting.MailService.FromEmail, subject, mailBody.String())
  261. msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
  262. msg.SetHeader("Message-ID", msgID)
  263. msg.SetHeader("In-Reply-To", reference)
  264. references := []string{reference}
  265. listUnsubscribe := []string{"<" + ctx.Issue.HTMLURL() + ">"}
  266. if setting.IncomingEmail.Enabled {
  267. if ctx.Comment != nil {
  268. token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload)
  269. if err != nil {
  270. log.Error("CreateToken failed: %v", err)
  271. } else {
  272. replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
  273. msg.ReplyTo = replyAddress
  274. msg.SetHeader("List-Post", fmt.Sprintf("<mailto:%s>", replyAddress))
  275. references = append(references, fmt.Sprintf("<reply-%s@%s>", token, setting.Domain))
  276. }
  277. }
  278. token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload)
  279. if err != nil {
  280. log.Error("CreateToken failed: %v", err)
  281. } else {
  282. unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
  283. listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
  284. }
  285. }
  286. msg.SetHeader("References", references...)
  287. msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
  288. for key, value := range generateAdditionalHeaders(ctx, actType, recipient) {
  289. msg.SetHeader(key, value)
  290. }
  291. msgs = append(msgs, msg)
  292. }
  293. return msgs, nil
  294. }
  295. func createReference(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
  296. var path string
  297. if issue.IsPull {
  298. path = "pulls"
  299. } else {
  300. path = "issues"
  301. }
  302. var extra string
  303. if comment != nil {
  304. extra = fmt.Sprintf("/comment/%d", comment.ID)
  305. } else {
  306. switch actionType {
  307. case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
  308. extra = fmt.Sprintf("/close/%d", time.Now().UnixNano()/1e6)
  309. case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
  310. extra = fmt.Sprintf("/reopen/%d", time.Now().UnixNano()/1e6)
  311. case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
  312. extra = fmt.Sprintf("/merge/%d", time.Now().UnixNano()/1e6)
  313. case activities_model.ActionPullRequestReadyForReview:
  314. extra = fmt.Sprintf("/ready/%d", time.Now().UnixNano()/1e6)
  315. }
  316. }
  317. return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
  318. }
  319. func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string {
  320. repo := ctx.Issue.Repo
  321. return map[string]string{
  322. // https://datatracker.ietf.org/doc/html/rfc2919
  323. "List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
  324. // https://datatracker.ietf.org/doc/html/rfc2369
  325. "List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
  326. "X-Mailer": "Gitea",
  327. "X-Gitea-Reason": reason,
  328. "X-Gitea-Sender": ctx.Doer.DisplayName(),
  329. "X-Gitea-Recipient": recipient.DisplayName(),
  330. "X-Gitea-Recipient-Address": recipient.Email,
  331. "X-Gitea-Repository": repo.Name,
  332. "X-Gitea-Repository-Path": repo.FullName(),
  333. "X-Gitea-Repository-Link": repo.HTMLURL(),
  334. "X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10),
  335. "X-Gitea-Issue-Link": ctx.Issue.HTMLURL(),
  336. "X-GitHub-Reason": reason,
  337. "X-GitHub-Sender": ctx.Doer.DisplayName(),
  338. "X-GitHub-Recipient": recipient.DisplayName(),
  339. "X-GitHub-Recipient-Address": recipient.Email,
  340. "X-GitLab-NotificationReason": reason,
  341. "X-GitLab-Project": repo.Name,
  342. "X-GitLab-Project-Path": repo.FullName(),
  343. "X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10),
  344. }
  345. }
  346. func sanitizeSubject(subject string) string {
  347. runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " ")))
  348. if len(runes) > mailMaxSubjectRunes {
  349. runes = runes[:mailMaxSubjectRunes]
  350. }
  351. // Encode non-ASCII characters
  352. return mime.QEncoding.Encode("utf-8", string(runes))
  353. }
  354. // SendIssueAssignedMail composes and sends issue assigned email
  355. func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string, comment *issues_model.Comment, recipients []*user_model.User) error {
  356. if setting.MailService == nil {
  357. // No mail service configured
  358. return nil
  359. }
  360. if err := issue.LoadRepo(ctx); err != nil {
  361. log.Error("Unable to load repo [%d] for issue #%d [%d]. Error: %v", issue.RepoID, issue.Index, issue.ID, err)
  362. return err
  363. }
  364. langMap := make(map[string][]*user_model.User)
  365. for _, user := range recipients {
  366. if !user.IsActive {
  367. // don't send emails to inactive users
  368. continue
  369. }
  370. langMap[user.Language] = append(langMap[user.Language], user)
  371. }
  372. for lang, tos := range langMap {
  373. msgs, err := composeIssueCommentMessages(&mailCommentContext{
  374. Context: ctx,
  375. Issue: issue,
  376. Doer: doer,
  377. ActionType: activities_model.ActionType(0),
  378. Content: content,
  379. Comment: comment,
  380. }, lang, tos, false, "issue assigned")
  381. if err != nil {
  382. return err
  383. }
  384. SendAsync(msgs...)
  385. }
  386. return nil
  387. }
  388. // actionToTemplate returns the type and name of the action facing the user
  389. // (slightly different from activities_model.ActionType) and the name of the template to use (based on availability)
  390. func actionToTemplate(issue *issues_model.Issue, actionType activities_model.ActionType,
  391. commentType issues_model.CommentType, reviewType issues_model.ReviewType,
  392. ) (typeName, name, template string) {
  393. if issue.IsPull {
  394. typeName = "pull"
  395. } else {
  396. typeName = "issue"
  397. }
  398. switch actionType {
  399. case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest:
  400. name = "new"
  401. case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
  402. name = "comment"
  403. case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
  404. name = "close"
  405. case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
  406. name = "reopen"
  407. case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
  408. name = "merge"
  409. case activities_model.ActionPullReviewDismissed:
  410. name = "review_dismissed"
  411. case activities_model.ActionPullRequestReadyForReview:
  412. name = "ready_for_review"
  413. default:
  414. switch commentType {
  415. case issues_model.CommentTypeReview:
  416. switch reviewType {
  417. case issues_model.ReviewTypeApprove:
  418. name = "approve"
  419. case issues_model.ReviewTypeReject:
  420. name = "reject"
  421. default:
  422. name = "review"
  423. }
  424. case issues_model.CommentTypeCode:
  425. name = "code"
  426. case issues_model.CommentTypeAssignees:
  427. name = "assigned"
  428. case issues_model.CommentTypePullRequestPush:
  429. name = "push"
  430. default:
  431. name = "default"
  432. }
  433. }
  434. template = typeName + "/" + name
  435. ok := bodyTemplates.Lookup(template) != nil
  436. if !ok && typeName != "issue" {
  437. template = "issue/" + name
  438. ok = bodyTemplates.Lookup(template) != nil
  439. }
  440. if !ok {
  441. template = typeName + "/default"
  442. ok = bodyTemplates.Lookup(template) != nil
  443. }
  444. if !ok {
  445. template = "issue/default"
  446. }
  447. return typeName, name, template
  448. }