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