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