aboutsummaryrefslogtreecommitdiffstats
path: root/services/mailer
diff options
context:
space:
mode:
Diffstat (limited to 'services/mailer')
-rw-r--r--services/mailer/mail.go587
-rw-r--r--services/mailer/mail_comment.go10
-rw-r--r--services/mailer/mail_issue.go111
-rw-r--r--services/mailer/mail_issue_common.go320
-rw-r--r--services/mailer/mail_release.go15
-rw-r--r--services/mailer/mail_repo.go5
-rw-r--r--services/mailer/mail_team_invite.go11
-rw-r--r--services/mailer/mail_test.go232
-rw-r--r--services/mailer/mail_user.go161
-rw-r--r--services/mailer/mail_workflow_run.go165
-rw-r--r--services/mailer/mailer.go2
-rw-r--r--services/mailer/notify.go29
-rw-r--r--services/mailer/sender/message.go4
-rw-r--r--services/mailer/sender/message_test.go4
-rw-r--r--services/mailer/sender/smtp.go3
-rw-r--r--services/mailer/sender/smtp_auth.go3
16 files changed, 1062 insertions, 600 deletions
diff --git a/services/mailer/mail.go b/services/mailer/mail.go
index a6763e4f03..d81b6d10af 100644
--- a/services/mailer/mail.go
+++ b/services/mailer/mail.go
@@ -7,51 +7,38 @@ package mailer
import (
"bytes"
"context"
+ "encoding/base64"
+ "errors"
"fmt"
"html/template"
+ "io"
"mime"
"regexp"
- "strconv"
"strings"
- texttmpl "text/template"
- "time"
+ "sync/atomic"
- activities_model "code.gitea.io/gitea/models/activities"
- issues_model "code.gitea.io/gitea/models/issues"
- "code.gitea.io/gitea/models/renderhelper"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
- "code.gitea.io/gitea/modules/emoji"
+ "code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
- "code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/templates"
- "code.gitea.io/gitea/modules/timeutil"
- "code.gitea.io/gitea/modules/translation"
- incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
+ "code.gitea.io/gitea/modules/typesniffer"
sender_service "code.gitea.io/gitea/services/mailer/sender"
- "code.gitea.io/gitea/services/mailer/token"
-)
-const (
- mailAuthActivate templates.TplName = "auth/activate"
- mailAuthActivateEmail templates.TplName = "auth/activate_email"
- mailAuthResetPassword templates.TplName = "auth/reset_passwd"
- mailAuthRegisterNotify templates.TplName = "auth/register_notify"
+ "golang.org/x/net/html"
+)
- mailNotifyCollaborator templates.TplName = "notify/collaborator"
+const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322
- mailRepoTransferNotify templates.TplName = "notify/repo_transfer"
+var loadedTemplates atomic.Pointer[templates.MailTemplates]
- // There's no actual limit for subject in RFC 5322
- mailMaxSubjectRunes = 256
-)
+var subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
-var (
- bodyTemplates *template.Template
- subjectTemplates *texttmpl.Template
- subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
-)
+func LoadedTemplates() *templates.MailTemplates {
+ return loadedTemplates.Load()
+}
// SendTestMail sends a test mail
func SendTestMail(email string) error {
@@ -62,334 +49,133 @@ func SendTestMail(email string) error {
return sender_service.Send(sender, sender_service.NewMessage(email, "Gitea Test Email!", "Gitea Test Email!"))
}
-// sendUserMail sends a mail to the user
-func sendUserMail(language string, u *user_model.User, tpl templates.TplName, code, subject, info string) {
- locale := translation.NewLocale(language)
- data := map[string]any{
- "locale": locale,
- "DisplayName": u.DisplayName(),
- "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
- "ResetPwdCodeLives": timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, locale),
- "Code": code,
- "Language": locale.Language(),
- }
-
- var content bytes.Buffer
-
- if err := bodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
- log.Error("Template: %v", err)
- return
- }
-
- msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
- msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info)
-
- SendAsync(msg)
-}
-
-// SendActivateAccountMail sends an activation mail to the user (new user registration)
-func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
- if setting.MailService == nil {
- // No mail service configured
- return
+func sanitizeSubject(subject string) string {
+ runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " ")))
+ if len(runes) > mailMaxSubjectRunes {
+ runes = runes[:mailMaxSubjectRunes]
}
- sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.activate_account"), "activate account")
+ // Encode non-ASCII characters
+ return mime.QEncoding.Encode("utf-8", string(runes))
}
-// SendResetPasswordMail sends a password reset mail to the user
-func SendResetPasswordMail(u *user_model.User) {
- if setting.MailService == nil {
- // No mail service configured
- return
- }
- locale := translation.NewLocale(u.Language)
- sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.reset_password"), "recover account")
+type mailAttachmentBase64Embedder struct {
+ doer *user_model.User
+ repo *repo_model.Repository
+ maxSize int64
+ estimateSize int64
}
-// SendActivateEmailMail sends confirmation email to confirm new email address
-func SendActivateEmailMail(u *user_model.User, email string) {
- if setting.MailService == nil {
- // No mail service configured
- return
- }
- locale := translation.NewLocale(u.Language)
- data := map[string]any{
- "locale": locale,
- "DisplayName": u.DisplayName(),
- "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
- "Code": u.GenerateEmailActivateCode(email),
- "Email": email,
- "Language": locale.Language(),
- }
-
- var content bytes.Buffer
-
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
- log.Error("Template: %v", err)
- return
- }
-
- msg := sender_service.NewMessage(email, locale.TrString("mail.activate_email"), content.String())
- msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
-
- SendAsync(msg)
+func newMailAttachmentBase64Embedder(doer *user_model.User, repo *repo_model.Repository, maxSize int64) *mailAttachmentBase64Embedder {
+ return &mailAttachmentBase64Embedder{doer: doer, repo: repo, maxSize: maxSize}
}
-// SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
-func SendRegisterNotifyMail(u *user_model.User) {
- if setting.MailService == nil || !u.IsActive {
- // No mail service configured OR user is inactive
- return
- }
- locale := translation.NewLocale(u.Language)
-
- data := map[string]any{
- "locale": locale,
- "DisplayName": u.DisplayName(),
- "Username": u.Name,
- "Language": locale.Language(),
- }
-
- var content bytes.Buffer
-
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
- log.Error("Template: %v", err)
- return
- }
-
- msg := sender_service.NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String())
- msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID)
-
- SendAsync(msg)
-}
-
-// SendCollaboratorMail sends mail notification to new collaborator.
-func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) {
- if setting.MailService == nil || !u.IsActive {
- // No mail service configured OR the user is inactive
- return
- }
- locale := translation.NewLocale(u.Language)
- repoName := repo.FullName()
-
- subject := locale.TrString("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
- data := map[string]any{
- "locale": locale,
- "Subject": subject,
- "RepoName": repoName,
- "Link": repo.HTMLURL(),
- "Language": locale.Language(),
- }
-
- var content bytes.Buffer
-
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
- log.Error("Template: %v", err)
- return
- }
-
- msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
- msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.ID)
-
- SendAsync(msg)
-}
-
-func composeIssueCommentMessages(ctx *mailCommentContext, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*sender_service.Message, error) {
- var (
- subject string
- link string
- prefix string
- // Fall back subject for bad templates, make sure subject is never empty
- fallback string
- reviewComments []*issues_model.Comment
- )
-
- commentType := issues_model.CommentTypeComment
- if ctx.Comment != nil {
- commentType = ctx.Comment.Type
- link = ctx.Issue.HTMLURL() + "#" + ctx.Comment.HashTag()
- } else {
- link = ctx.Issue.HTMLURL()
- }
-
- reviewType := issues_model.ReviewTypeComment
- if ctx.Comment != nil && ctx.Comment.Review != nil {
- reviewType = ctx.Comment.Review.Type
- }
-
- // This is the body of the new issue or comment, not the mail body
- rctx := renderhelper.NewRenderContextRepoComment(ctx.Context, ctx.Issue.Repo).WithUseAbsoluteLink(true)
- body, err := markdown.RenderString(rctx,
- ctx.Content)
+func (b64embedder *mailAttachmentBase64Embedder) Base64InlineImages(ctx context.Context, body template.HTML) (template.HTML, error) {
+ doc, err := html.Parse(strings.NewReader(string(body)))
if err != nil {
- return nil, err
+ return "", fmt.Errorf("html.Parse failed: %w", err)
+ }
+
+ b64embedder.estimateSize = int64(len(string(body)))
+
+ var processNode func(*html.Node)
+ processNode = func(n *html.Node) {
+ if n.Type == html.ElementNode {
+ if n.Data == "img" {
+ for i, attr := range n.Attr {
+ if attr.Key == "src" {
+ attachmentSrc := attr.Val
+ dataURI, err := b64embedder.AttachmentSrcToBase64DataURI(ctx, attachmentSrc)
+ if err != nil {
+ // Not an error, just skip. This is probably an image from outside the gitea instance.
+ log.Trace("Unable to embed attachment %q to mail body: %v", attachmentSrc, err)
+ } else {
+ n.Attr[i].Val = dataURI
+ }
+ break
+ }
+ }
+ }
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ processNode(c)
+ }
}
- actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType)
+ processNode(doc)
- if actName != "new" {
- prefix = "Re: "
+ var buf bytes.Buffer
+ err = html.Render(&buf, doc)
+ if err != nil {
+ return "", fmt.Errorf("html.Render failed: %w", err)
}
- fallback = prefix + fallbackMailSubject(ctx.Issue)
+ return template.HTML(buf.String()), nil
+}
- if ctx.Comment != nil && ctx.Comment.Review != nil {
- reviewComments = make([]*issues_model.Comment, 0, 10)
- for _, lines := range ctx.Comment.Review.CodeComments {
- for _, comments := range lines {
- reviewComments = append(reviewComments, comments...)
- }
+func (b64embedder *mailAttachmentBase64Embedder) AttachmentSrcToBase64DataURI(ctx context.Context, attachmentSrc string) (string, error) {
+ parsedSrc := httplib.ParseGiteaSiteURL(ctx, attachmentSrc)
+ var attachmentUUID string
+ if parsedSrc != nil {
+ var ok bool
+ attachmentUUID, ok = strings.CutPrefix(parsedSrc.RoutePath, "/attachments/")
+ if !ok {
+ attachmentUUID, ok = strings.CutPrefix(parsedSrc.RepoSubPath, "/attachments/")
+ }
+ if !ok {
+ return "", errors.New("not an attachment")
}
}
- locale := translation.NewLocale(lang)
-
- mailMeta := map[string]any{
- "locale": locale,
- "FallbackSubject": fallback,
- "Body": body,
- "Link": link,
- "Issue": ctx.Issue,
- "Comment": ctx.Comment,
- "IsPull": ctx.Issue.IsPull,
- "User": ctx.Issue.Repo.MustOwner(ctx),
- "Repo": ctx.Issue.Repo.FullName(),
- "Doer": ctx.Doer,
- "IsMention": fromMention,
- "SubjectPrefix": prefix,
- "ActionType": actType,
- "ActionName": actName,
- "ReviewComments": reviewComments,
- "Language": locale.Language(),
- "CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush,
+ attachment, err := repo_model.GetAttachmentByUUID(ctx, attachmentUUID)
+ if err != nil {
+ return "", err
}
- var mailSubject bytes.Buffer
- if err := subjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
- subject = sanitizeSubject(mailSubject.String())
- if subject == "" {
- subject = fallback
- }
- } else {
- log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err)
+ if attachment.RepoID != b64embedder.repo.ID {
+ return "", errors.New("attachment does not belong to the repository")
}
-
- subject = emoji.ReplaceAliases(subject)
-
- mailMeta["Subject"] = subject
-
- var mailBody bytes.Buffer
-
- if err := bodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
- log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
+ if attachment.Size+b64embedder.estimateSize > b64embedder.maxSize {
+ return "", errors.New("total embedded images exceed max limit")
}
- // Make sure to compose independent messages to avoid leaking user emails
- msgID := generateMessageIDForIssue(ctx.Issue, ctx.Comment, ctx.ActionType)
- reference := generateMessageIDForIssue(ctx.Issue, nil, activities_model.ActionType(0))
-
- var replyPayload []byte
- if ctx.Comment != nil {
- if ctx.Comment.Type.HasMailReplySupport() {
- replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Comment)
- }
- } else {
- replyPayload, err = incoming_payload.CreateReferencePayload(ctx.Issue)
- }
+ fr, err := storage.Attachments.Open(attachment.RelativePath())
if err != nil {
- return nil, err
+ return "", err
}
+ defer fr.Close()
- unsubscribePayload, err := incoming_payload.CreateReferencePayload(ctx.Issue)
+ lr := &io.LimitedReader{R: fr, N: b64embedder.maxSize + 1}
+ content, err := io.ReadAll(lr)
if err != nil {
- return nil, err
+ return "", fmt.Errorf("LimitedReader ReadAll: %w", err)
}
- msgs := make([]*sender_service.Message, 0, len(recipients))
- for _, recipient := range recipients {
- msg := sender_service.NewMessageFrom(
- recipient.Email,
- fromDisplayName(ctx.Doer),
- setting.MailService.FromEmail,
- subject,
- mailBody.String(),
- )
- msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
-
- msg.SetHeader("Message-ID", msgID)
- msg.SetHeader("In-Reply-To", reference)
-
- references := []string{reference}
- listUnsubscribe := []string{"<" + ctx.Issue.HTMLURL() + ">"}
-
- if setting.IncomingEmail.Enabled {
- if replyPayload != nil {
- token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload)
- if err != nil {
- log.Error("CreateToken failed: %v", err)
- } else {
- replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
- msg.ReplyTo = replyAddress
- msg.SetHeader("List-Post", fmt.Sprintf("<mailto:%s>", replyAddress))
-
- references = append(references, fmt.Sprintf("<reply-%s@%s>", token, setting.Domain))
- }
- }
-
- token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload)
- if err != nil {
- log.Error("CreateToken failed: %v", err)
- } else {
- unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
- listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
- }
- }
-
- msg.SetHeader("References", references...)
- msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
-
- for key, value := range generateAdditionalHeaders(ctx, actType, recipient) {
- msg.SetHeader(key, value)
- }
-
- msgs = append(msgs, msg)
+ mimeType := typesniffer.DetectContentType(content)
+ if !mimeType.IsImage() {
+ return "", errors.New("not an image")
}
- return msgs, nil
+ encoded := base64.StdEncoding.EncodeToString(content)
+ dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType.GetMimeType(), encoded)
+ b64embedder.estimateSize += int64(len(dataURI))
+ return dataURI, nil
}
-func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
- var path string
- if issue.IsPull {
- path = "pulls"
- } else {
- path = "issues"
- }
-
- var extra string
- if comment != nil {
- extra = fmt.Sprintf("/comment/%d", comment.ID)
- } else {
- switch actionType {
- case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
- extra = fmt.Sprintf("/close/%d", time.Now().UnixNano()/1e6)
- case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
- extra = fmt.Sprintf("/reopen/%d", time.Now().UnixNano()/1e6)
- case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
- extra = fmt.Sprintf("/merge/%d", time.Now().UnixNano()/1e6)
- case activities_model.ActionPullRequestReadyForReview:
- extra = fmt.Sprintf("/ready/%d", time.Now().UnixNano()/1e6)
+func fromDisplayName(u *user_model.User) string {
+ if setting.MailService.FromDisplayNameFormatTemplate != nil {
+ var ctx bytes.Buffer
+ err := setting.MailService.FromDisplayNameFormatTemplate.Execute(&ctx, map[string]any{
+ "DisplayName": u.DisplayName(),
+ "AppName": setting.AppName,
+ "Domain": setting.Domain,
+ })
+ if err == nil {
+ return mime.QEncoding.Encode("utf-8", ctx.String())
}
+ log.Error("fromDisplayName: %w", err)
}
-
- return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
-}
-
-func generateMessageIDForRelease(release *repo_model.Release) string {
- return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain)
+ return u.GetCompleteName()
}
-func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string {
- repo := ctx.Issue.Repo
-
+func generateMetadataHeaders(repo *repo_model.Repository) map[string]string {
return map[string]string{
// https://datatracker.ietf.org/doc/html/rfc2919
"List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
@@ -397,151 +183,32 @@ func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient
// https://datatracker.ietf.org/doc/html/rfc2369
"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
- "X-Mailer": "Gitea",
- "X-Gitea-Reason": reason,
- "X-Gitea-Sender": ctx.Doer.Name,
- "X-Gitea-Recipient": recipient.Name,
- "X-Gitea-Recipient-Address": recipient.Email,
- "X-Gitea-Repository": repo.Name,
- "X-Gitea-Repository-Path": repo.FullName(),
- "X-Gitea-Repository-Link": repo.HTMLURL(),
- "X-Gitea-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10),
- "X-Gitea-Issue-Link": ctx.Issue.HTMLURL(),
-
- "X-GitHub-Reason": reason,
- "X-GitHub-Sender": ctx.Doer.Name,
- "X-GitHub-Recipient": recipient.Name,
- "X-GitHub-Recipient-Address": recipient.Email,
+ "X-Mailer": "Gitea",
- "X-GitLab-NotificationReason": reason,
- "X-GitLab-Project": repo.Name,
- "X-GitLab-Project-Path": repo.FullName(),
- "X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10),
- }
-}
+ "X-Gitea-Repository": repo.Name,
+ "X-Gitea-Repository-Path": repo.FullName(),
+ "X-Gitea-Repository-Link": repo.HTMLURL(),
-func sanitizeSubject(subject string) string {
- runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " ")))
- if len(runes) > mailMaxSubjectRunes {
- runes = runes[:mailMaxSubjectRunes]
+ "X-GitLab-Project": repo.Name,
+ "X-GitLab-Project-Path": repo.FullName(),
}
- // Encode non-ASCII characters
- return mime.QEncoding.Encode("utf-8", string(runes))
}
-// SendIssueAssignedMail composes and sends issue assigned email
-func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string, comment *issues_model.Comment, recipients []*user_model.User) error {
- if setting.MailService == nil {
- // No mail service configured
- return nil
- }
-
- if err := issue.LoadRepo(ctx); err != nil {
- log.Error("Unable to load repo [%d] for issue #%d [%d]. Error: %v", issue.RepoID, issue.Index, issue.ID, err)
- return err
- }
-
- langMap := make(map[string][]*user_model.User)
- for _, user := range recipients {
- if !user.IsActive {
- // don't send emails to inactive users
- continue
- }
- langMap[user.Language] = append(langMap[user.Language], user)
- }
-
- for lang, tos := range langMap {
- msgs, err := composeIssueCommentMessages(&mailCommentContext{
- Context: ctx,
- Issue: issue,
- Doer: doer,
- ActionType: activities_model.ActionType(0),
- Content: content,
- Comment: comment,
- }, lang, tos, false, "issue assigned")
- if err != nil {
- return err
- }
- SendAsync(msgs...)
- }
- return nil
-}
-
-// actionToTemplate returns the type and name of the action facing the user
-// (slightly different from activities_model.ActionType) and the name of the template to use (based on availability)
-func actionToTemplate(issue *issues_model.Issue, actionType activities_model.ActionType,
- commentType issues_model.CommentType, reviewType issues_model.ReviewType,
-) (typeName, name, template string) {
- if issue.IsPull {
- typeName = "pull"
- } else {
- typeName = "issue"
- }
- switch actionType {
- case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest:
- name = "new"
- case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
- name = "comment"
- case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
- name = "close"
- case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
- name = "reopen"
- case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
- name = "merge"
- case activities_model.ActionPullReviewDismissed:
- name = "review_dismissed"
- case activities_model.ActionPullRequestReadyForReview:
- name = "ready_for_review"
- default:
- switch commentType {
- case issues_model.CommentTypeReview:
- switch reviewType {
- case issues_model.ReviewTypeApprove:
- name = "approve"
- case issues_model.ReviewTypeReject:
- name = "reject"
- default:
- name = "review"
- }
- case issues_model.CommentTypeCode:
- name = "code"
- case issues_model.CommentTypeAssignees:
- name = "assigned"
- case issues_model.CommentTypePullRequestPush:
- name = "push"
- default:
- name = "default"
- }
- }
-
- template = typeName + "/" + name
- ok := bodyTemplates.Lookup(template) != nil
- if !ok && typeName != "issue" {
- template = "issue/" + name
- ok = bodyTemplates.Lookup(template) != nil
- }
- if !ok {
- template = typeName + "/default"
- ok = bodyTemplates.Lookup(template) != nil
- }
- if !ok {
- template = "issue/default"
+func generateSenderRecipientHeaders(doer, recipient *user_model.User) map[string]string {
+ return map[string]string{
+ "X-Gitea-Sender": doer.Name,
+ "X-Gitea-Recipient": recipient.Name,
+ "X-Gitea-Recipient-Address": recipient.Email,
+ "X-GitHub-Sender": doer.Name,
+ "X-GitHub-Recipient": recipient.Name,
+ "X-GitHub-Recipient-Address": recipient.Email,
}
- return typeName, name, template
}
-func fromDisplayName(u *user_model.User) string {
- if setting.MailService.FromDisplayNameFormatTemplate != nil {
- var ctx bytes.Buffer
- err := setting.MailService.FromDisplayNameFormatTemplate.Execute(&ctx, map[string]any{
- "DisplayName": u.DisplayName(),
- "AppName": setting.AppName,
- "Domain": setting.Domain,
- })
- if err == nil {
- return mime.QEncoding.Encode("utf-8", ctx.String())
- }
- log.Error("fromDisplayName: %w", err)
+func generateReasonHeaders(reason string) map[string]string {
+ return map[string]string{
+ "X-Gitea-Reason": reason,
+ "X-GitHub-Reason": reason,
+ "X-GitLab-NotificationReason": reason,
}
- return u.GetCompleteName()
}
diff --git a/services/mailer/mail_comment.go b/services/mailer/mail_comment.go
index 1812441d5a..e8d12e429d 100644
--- a/services/mailer/mail_comment.go
+++ b/services/mailer/mail_comment.go
@@ -25,9 +25,8 @@ func MailParticipantsComment(ctx context.Context, c *issues_model.Comment, opTyp
if c.Type == issues_model.CommentTypePullRequestPush {
content = ""
}
- if err := mailIssueCommentToParticipants(
- &mailCommentContext{
- Context: ctx,
+ if err := mailIssueCommentToParticipants(ctx,
+ &mailComment{
Issue: issue,
Doer: c.Poster,
ActionType: opType,
@@ -48,9 +47,8 @@ func MailMentionsComment(ctx context.Context, pr *issues_model.PullRequest, c *i
visited := make(container.Set[int64], len(mentions)+1)
visited.Add(c.Poster.ID)
- if err = mailIssueCommentBatch(
- &mailCommentContext{
- Context: ctx,
+ if err = mailIssueCommentBatch(ctx,
+ &mailComment{
Issue: pr.Issue,
Doer: c.Poster,
ActionType: activities_model.ActionCommentPull,
diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go
index fab3315be2..b854d61a1a 100644
--- a/services/mailer/mail_issue.go
+++ b/services/mailer/mail_issue.go
@@ -18,38 +18,21 @@ import (
"code.gitea.io/gitea/modules/setting"
)
-func fallbackMailSubject(issue *issues_model.Issue) string {
- return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
-}
-
-type mailCommentContext struct {
- context.Context
- Issue *issues_model.Issue
- Doer *user_model.User
- ActionType activities_model.ActionType
- Content string
- Comment *issues_model.Comment
- ForceDoerNotification bool
-}
-
-const (
- // MailBatchSize set the batch size used in mailIssueCommentBatch
- MailBatchSize = 100
-)
+const MailBatchSize = 100 // batch size used in mailIssueCommentBatch
// mailIssueCommentToParticipants can be used for both new issue creation and comment.
// This function sends two list of emails:
// 1. Repository watchers (except for WIP pull requests) and users who are participated in comments.
// 2. Users who are not in 1. but get mentioned in current issue/comment.
-func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []*user_model.User) error {
+func mailIssueCommentToParticipants(ctx context.Context, comment *mailComment, mentions []*user_model.User) error {
// Required by the mail composer; make sure to load these before calling the async function
- if err := ctx.Issue.LoadRepo(ctx); err != nil {
+ if err := comment.Issue.LoadRepo(ctx); err != nil {
return fmt.Errorf("LoadRepo: %w", err)
}
- if err := ctx.Issue.LoadPoster(ctx); err != nil {
+ if err := comment.Issue.LoadPoster(ctx); err != nil {
return fmt.Errorf("LoadPoster: %w", err)
}
- if err := ctx.Issue.LoadPullRequest(ctx); err != nil {
+ if err := comment.Issue.LoadPullRequest(ctx); err != nil {
return fmt.Errorf("LoadPullRequest: %w", err)
}
@@ -57,35 +40,35 @@ func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []*user_mo
unfiltered := make([]int64, 1, 64)
// =========== Original poster ===========
- unfiltered[0] = ctx.Issue.PosterID
+ unfiltered[0] = comment.Issue.PosterID
// =========== Assignees ===========
- ids, err := issues_model.GetAssigneeIDsByIssue(ctx, ctx.Issue.ID)
+ ids, err := issues_model.GetAssigneeIDsByIssue(ctx, comment.Issue.ID)
if err != nil {
- return fmt.Errorf("GetAssigneeIDsByIssue(%d): %w", ctx.Issue.ID, err)
+ return fmt.Errorf("GetAssigneeIDsByIssue(%d): %w", comment.Issue.ID, err)
}
unfiltered = append(unfiltered, ids...)
// =========== Participants (i.e. commenters, reviewers) ===========
- ids, err = issues_model.GetParticipantsIDsByIssueID(ctx, ctx.Issue.ID)
+ ids, err = issues_model.GetParticipantsIDsByIssueID(ctx, comment.Issue.ID)
if err != nil {
- return fmt.Errorf("GetParticipantsIDsByIssueID(%d): %w", ctx.Issue.ID, err)
+ return fmt.Errorf("GetParticipantsIDsByIssueID(%d): %w", comment.Issue.ID, err)
}
unfiltered = append(unfiltered, ids...)
// =========== Issue watchers ===========
- ids, err = issues_model.GetIssueWatchersIDs(ctx, ctx.Issue.ID, true)
+ ids, err = issues_model.GetIssueWatchersIDs(ctx, comment.Issue.ID, true)
if err != nil {
- return fmt.Errorf("GetIssueWatchersIDs(%d): %w", ctx.Issue.ID, err)
+ return fmt.Errorf("GetIssueWatchersIDs(%d): %w", comment.Issue.ID, err)
}
unfiltered = append(unfiltered, ids...)
// =========== Repo watchers ===========
// Make repo watchers last, since it's likely the list with the most users
- if !(ctx.Issue.IsPull && ctx.Issue.PullRequest.IsWorkInProgress(ctx) && ctx.ActionType != activities_model.ActionCreatePullRequest) {
- ids, err = repo_model.GetRepoWatchersIDs(ctx, ctx.Issue.RepoID)
+ if !(comment.Issue.IsPull && comment.Issue.PullRequest.IsWorkInProgress(ctx) && comment.ActionType != activities_model.ActionCreatePullRequest) {
+ ids, err = repo_model.GetRepoWatchersIDs(ctx, comment.Issue.RepoID)
if err != nil {
- return fmt.Errorf("GetRepoWatchersIDs(%d): %w", ctx.Issue.RepoID, err)
+ return fmt.Errorf("GetRepoWatchersIDs(%d): %w", comment.Issue.RepoID, err)
}
unfiltered = append(ids, unfiltered...)
}
@@ -93,36 +76,36 @@ func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []*user_mo
visited := make(container.Set[int64], len(unfiltered)+len(mentions)+1)
// Avoid mailing the doer
- if ctx.Doer.EmailNotificationsPreference != user_model.EmailNotificationsAndYourOwn && !ctx.ForceDoerNotification {
- visited.Add(ctx.Doer.ID)
+ if comment.Doer.EmailNotificationsPreference != user_model.EmailNotificationsAndYourOwn && !comment.ForceDoerNotification {
+ visited.Add(comment.Doer.ID)
}
// =========== Mentions ===========
- if err = mailIssueCommentBatch(ctx, mentions, visited, true); err != nil {
+ if err = mailIssueCommentBatch(ctx, comment, mentions, visited, true); err != nil {
return fmt.Errorf("mailIssueCommentBatch() mentions: %w", err)
}
// Avoid mailing explicit unwatched
- ids, err = issues_model.GetIssueWatchersIDs(ctx, ctx.Issue.ID, false)
+ ids, err = issues_model.GetIssueWatchersIDs(ctx, comment.Issue.ID, false)
if err != nil {
- return fmt.Errorf("GetIssueWatchersIDs(%d): %w", ctx.Issue.ID, err)
+ return fmt.Errorf("GetIssueWatchersIDs(%d): %w", comment.Issue.ID, err)
}
visited.AddMultiple(ids...)
- unfilteredUsers, err := user_model.GetMaileableUsersByIDs(ctx, unfiltered, false)
+ unfilteredUsers, err := user_model.GetMailableUsersByIDs(ctx, unfiltered, false)
if err != nil {
return err
}
- if err = mailIssueCommentBatch(ctx, unfilteredUsers, visited, false); err != nil {
+ if err = mailIssueCommentBatch(ctx, comment, unfilteredUsers, visited, false); err != nil {
return fmt.Errorf("mailIssueCommentBatch(): %w", err)
}
return nil
}
-func mailIssueCommentBatch(ctx *mailCommentContext, users []*user_model.User, visited container.Set[int64], fromMention bool) error {
+func mailIssueCommentBatch(ctx context.Context, comment *mailComment, users []*user_model.User, visited container.Set[int64], fromMention bool) error {
checkUnit := unit.TypeIssues
- if ctx.Issue.IsPull {
+ if comment.Issue.IsPull {
checkUnit = unit.TypePullRequests
}
@@ -146,7 +129,7 @@ func mailIssueCommentBatch(ctx *mailCommentContext, users []*user_model.User, vi
}
// test if this user is allowed to see the issue/pull
- if !access_model.CheckRepoUnitUser(ctx, ctx.Issue.Repo, user, checkUnit) {
+ if !access_model.CheckRepoUnitUser(ctx, comment.Issue.Repo, user, checkUnit) {
continue
}
@@ -158,7 +141,7 @@ func mailIssueCommentBatch(ctx *mailCommentContext, users []*user_model.User, vi
// working backwards from the last (possibly) incomplete batch. If len(receivers) can be 0 this
// starting condition will need to be changed slightly
for i := ((len(receivers) - 1) / MailBatchSize) * MailBatchSize; i >= 0; i -= MailBatchSize {
- msgs, err := composeIssueCommentMessages(ctx, lang, receivers[i:], fromMention, "issue comments")
+ msgs, err := composeIssueCommentMessages(ctx, comment, lang, receivers[i:], fromMention, "issue comments")
if err != nil {
return err
}
@@ -185,9 +168,8 @@ func MailParticipants(ctx context.Context, issue *issues_model.Issue, doer *user
content = ""
}
forceDoerNotification := opType == activities_model.ActionAutoMergePullRequest
- if err := mailIssueCommentToParticipants(
- &mailCommentContext{
- Context: ctx,
+ if err := mailIssueCommentToParticipants(ctx,
+ &mailComment{
Issue: issue,
Doer: doer,
ActionType: opType,
@@ -199,3 +181,40 @@ func MailParticipants(ctx context.Context, issue *issues_model.Issue, doer *user
}
return nil
}
+
+// SendIssueAssignedMail composes and sends issue assigned email
+func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string, comment *issues_model.Comment, recipients []*user_model.User) error {
+ if setting.MailService == nil {
+ // No mail service configured
+ return nil
+ }
+
+ if err := issue.LoadRepo(ctx); err != nil {
+ log.Error("Unable to load repo [%d] for issue #%d [%d]. Error: %v", issue.RepoID, issue.Index, issue.ID, err)
+ return err
+ }
+
+ langMap := make(map[string][]*user_model.User)
+ for _, user := range recipients {
+ if !user.IsActive {
+ // don't send emails to inactive users
+ continue
+ }
+ langMap[user.Language] = append(langMap[user.Language], user)
+ }
+
+ for lang, tos := range langMap {
+ msgs, err := composeIssueCommentMessages(ctx, &mailComment{
+ Issue: issue,
+ Doer: doer,
+ ActionType: activities_model.ActionType(0),
+ Content: content,
+ Comment: comment,
+ }, lang, tos, false, "issue assigned")
+ if err != nil {
+ return err
+ }
+ SendAsync(msgs...)
+ }
+ return nil
+}
diff --git a/services/mailer/mail_issue_common.go b/services/mailer/mail_issue_common.go
new file mode 100644
index 0000000000..a34d8a68c9
--- /dev/null
+++ b/services/mailer/mail_issue_common.go
@@ -0,0 +1,320 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package mailer
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "maps"
+ "strconv"
+ "strings"
+ "time"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/renderhelper"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/emoji"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+ incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
+ sender_service "code.gitea.io/gitea/services/mailer/sender"
+ "code.gitea.io/gitea/services/mailer/token"
+)
+
+// maxEmailBodySize is the approximate maximum size of an email body in bytes
+// Many e-mail service providers have limitations on the size of the email body, it's usually from 10MB to 25MB
+const maxEmailBodySize = 9_000_000
+
+func fallbackIssueMailSubject(issue *issues_model.Issue) string {
+ return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
+}
+
+type mailComment struct {
+ Issue *issues_model.Issue
+ Doer *user_model.User
+ ActionType activities_model.ActionType
+ Content string
+ Comment *issues_model.Comment
+ ForceDoerNotification bool
+}
+
+func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*sender_service.Message, error) {
+ var (
+ subject string
+ link string
+ prefix string
+ // Fall back subject for bad templates, make sure subject is never empty
+ fallback string
+ reviewComments []*issues_model.Comment
+ )
+
+ commentType := issues_model.CommentTypeComment
+ if comment.Comment != nil {
+ commentType = comment.Comment.Type
+ link = comment.Issue.HTMLURL() + "#" + comment.Comment.HashTag()
+ } else {
+ link = comment.Issue.HTMLURL()
+ }
+
+ reviewType := issues_model.ReviewTypeComment
+ if comment.Comment != nil && comment.Comment.Review != nil {
+ reviewType = comment.Comment.Review.Type
+ }
+
+ // This is the body of the new issue or comment, not the mail body
+ rctx := renderhelper.NewRenderContextRepoComment(ctx, comment.Issue.Repo).WithUseAbsoluteLink(true)
+ body, err := markdown.RenderString(rctx, comment.Content)
+ if err != nil {
+ return nil, err
+ }
+
+ if setting.MailService.EmbedAttachmentImages {
+ attEmbedder := newMailAttachmentBase64Embedder(comment.Doer, comment.Issue.Repo, maxEmailBodySize)
+ bodyAfterEmbedding, err := attEmbedder.Base64InlineImages(ctx, body)
+ if err != nil {
+ log.Error("Failed to embed images in mail body: %v", err)
+ } else {
+ body = bodyAfterEmbedding
+ }
+ }
+ actType, actName, tplName := actionToTemplate(comment.Issue, comment.ActionType, commentType, reviewType)
+
+ if actName != "new" {
+ prefix = "Re: "
+ }
+ fallback = prefix + fallbackIssueMailSubject(comment.Issue)
+
+ if comment.Comment != nil && comment.Comment.Review != nil {
+ reviewComments = make([]*issues_model.Comment, 0, 10)
+ for _, lines := range comment.Comment.Review.CodeComments {
+ for _, comments := range lines {
+ reviewComments = append(reviewComments, comments...)
+ }
+ }
+ }
+ locale := translation.NewLocale(lang)
+
+ mailMeta := map[string]any{
+ "locale": locale,
+ "FallbackSubject": fallback,
+ "Body": body,
+ "Link": link,
+ "Issue": comment.Issue,
+ "Comment": comment.Comment,
+ "IsPull": comment.Issue.IsPull,
+ "User": comment.Issue.Repo.MustOwner(ctx),
+ "Repo": comment.Issue.Repo.FullName(),
+ "Doer": comment.Doer,
+ "IsMention": fromMention,
+ "SubjectPrefix": prefix,
+ "ActionType": actType,
+ "ActionName": actName,
+ "ReviewComments": reviewComments,
+ "Language": locale.Language(),
+ "CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush,
+ }
+
+ var mailSubject bytes.Buffer
+ if err := LoadedTemplates().SubjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
+ subject = sanitizeSubject(mailSubject.String())
+ if subject == "" {
+ subject = fallback
+ }
+ } else {
+ log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err)
+ }
+
+ subject = emoji.ReplaceAliases(subject)
+
+ mailMeta["Subject"] = subject
+
+ var mailBody bytes.Buffer
+
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
+ log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
+ }
+
+ // Make sure to compose independent messages to avoid leaking user emails
+ msgID := generateMessageIDForIssue(comment.Issue, comment.Comment, comment.ActionType)
+ reference := generateMessageIDForIssue(comment.Issue, nil, activities_model.ActionType(0))
+
+ var replyPayload []byte
+ if comment.Comment != nil {
+ if comment.Comment.Type.HasMailReplySupport() {
+ replyPayload, err = incoming_payload.CreateReferencePayload(comment.Comment)
+ }
+ } else {
+ replyPayload, err = incoming_payload.CreateReferencePayload(comment.Issue)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ unsubscribePayload, err := incoming_payload.CreateReferencePayload(comment.Issue)
+ if err != nil {
+ return nil, err
+ }
+
+ msgs := make([]*sender_service.Message, 0, len(recipients))
+ for _, recipient := range recipients {
+ msg := sender_service.NewMessageFrom(
+ recipient.Email,
+ fromDisplayName(comment.Doer),
+ setting.MailService.FromEmail,
+ subject,
+ mailBody.String(),
+ )
+ msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
+
+ msg.SetHeader("Message-ID", msgID)
+ msg.SetHeader("In-Reply-To", reference)
+
+ references := []string{reference}
+ listUnsubscribe := []string{"<" + comment.Issue.HTMLURL() + ">"}
+
+ if setting.IncomingEmail.Enabled {
+ if replyPayload != nil {
+ token, err := token.CreateToken(token.ReplyHandlerType, recipient, replyPayload)
+ if err != nil {
+ log.Error("CreateToken failed: %v", err)
+ } else {
+ replyAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
+ msg.ReplyTo = replyAddress
+ msg.SetHeader("List-Post", fmt.Sprintf("<mailto:%s>", replyAddress))
+
+ references = append(references, fmt.Sprintf("<reply-%s@%s>", token, setting.Domain))
+ }
+ }
+
+ token, err := token.CreateToken(token.UnsubscribeHandlerType, recipient, unsubscribePayload)
+ if err != nil {
+ log.Error("CreateToken failed: %v", err)
+ } else {
+ unsubAddress := strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)
+ listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
+ }
+ }
+
+ msg.SetHeader("References", references...)
+ msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
+
+ for key, value := range generateAdditionalHeadersForIssue(comment, actType, recipient) {
+ msg.SetHeader(key, value)
+ }
+
+ msgs = append(msgs, msg)
+ }
+
+ return msgs, nil
+}
+
+// actionToTemplate returns the type and name of the action facing the user
+// (slightly different from activities_model.ActionType) and the name of the template to use (based on availability)
+func actionToTemplate(issue *issues_model.Issue, actionType activities_model.ActionType,
+ commentType issues_model.CommentType, reviewType issues_model.ReviewType,
+) (typeName, name, template string) {
+ if issue.IsPull {
+ typeName = "pull"
+ } else {
+ typeName = "issue"
+ }
+ switch actionType {
+ case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest:
+ name = "new"
+ case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
+ name = "comment"
+ case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
+ name = "close"
+ case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
+ name = "reopen"
+ case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
+ name = "merge"
+ case activities_model.ActionPullReviewDismissed:
+ name = "review_dismissed"
+ case activities_model.ActionPullRequestReadyForReview:
+ name = "ready_for_review"
+ default:
+ switch commentType {
+ case issues_model.CommentTypeReview:
+ switch reviewType {
+ case issues_model.ReviewTypeApprove:
+ name = "approve"
+ case issues_model.ReviewTypeReject:
+ name = "reject"
+ default:
+ name = "review"
+ }
+ case issues_model.CommentTypeCode:
+ name = "code"
+ case issues_model.CommentTypeAssignees:
+ name = "assigned"
+ case issues_model.CommentTypePullRequestPush:
+ name = "push"
+ default:
+ name = "default"
+ }
+ }
+
+ template = typeName + "/" + name
+ ok := LoadedTemplates().BodyTemplates.Lookup(template) != nil
+ if !ok && typeName != "issue" {
+ template = "issue/" + name
+ ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
+ }
+ if !ok {
+ template = typeName + "/default"
+ ok = LoadedTemplates().BodyTemplates.Lookup(template) != nil
+ }
+ if !ok {
+ template = "issue/default"
+ }
+ return typeName, name, template
+}
+
+func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
+ var path string
+ if issue.IsPull {
+ path = "pulls"
+ } else {
+ path = "issues"
+ }
+
+ var extra string
+ if comment != nil {
+ extra = fmt.Sprintf("/comment/%d", comment.ID)
+ } else {
+ switch actionType {
+ case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
+ extra = fmt.Sprintf("/close/%d", time.Now().UnixNano()/1e6)
+ case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
+ extra = fmt.Sprintf("/reopen/%d", time.Now().UnixNano()/1e6)
+ case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
+ extra = fmt.Sprintf("/merge/%d", time.Now().UnixNano()/1e6)
+ case activities_model.ActionPullRequestReadyForReview:
+ extra = fmt.Sprintf("/ready/%d", time.Now().UnixNano()/1e6)
+ }
+ }
+
+ return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
+}
+
+func generateAdditionalHeadersForIssue(ctx *mailComment, reason string, recipient *user_model.User) map[string]string {
+ repo := ctx.Issue.Repo
+
+ issueID := strconv.FormatInt(ctx.Issue.Index, 10)
+ headers := generateMetadataHeaders(repo)
+
+ maps.Copy(headers, generateSenderRecipientHeaders(ctx.Doer, recipient))
+ maps.Copy(headers, generateReasonHeaders(reason))
+
+ headers["X-Gitea-Issue-ID"] = issueID
+ headers["X-Gitea-Issue-Link"] = ctx.Issue.HTMLURL()
+ headers["X-GitLab-Issue-IID"] = issueID
+
+ return headers
+}
diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go
index 796d63d27a..fd97fb5312 100644
--- a/services/mailer/mail_release.go
+++ b/services/mailer/mail_release.go
@@ -6,6 +6,7 @@ package mailer
import (
"bytes"
"context"
+ "fmt"
"code.gitea.io/gitea/models/renderhelper"
repo_model "code.gitea.io/gitea/models/repo"
@@ -18,9 +19,11 @@ import (
sender_service "code.gitea.io/gitea/services/mailer/sender"
)
-const (
- tplNewReleaseMail templates.TplName = "release"
-)
+const tplNewReleaseMail templates.TplName = "release"
+
+func generateMessageIDForRelease(release *repo_model.Release) string {
+ return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain)
+}
// MailNewRelease send new release notify to all repo watchers.
func MailNewRelease(ctx context.Context, rel *repo_model.Release) {
@@ -35,9 +38,9 @@ func MailNewRelease(ctx context.Context, rel *repo_model.Release) {
return
}
- recipients, err := user_model.GetMaileableUsersByIDs(ctx, watcherIDList, false)
+ recipients, err := user_model.GetMailableUsersByIDs(ctx, watcherIDList, false)
if err != nil {
- log.Error("user_model.GetMaileableUsersByIDs: %v", err)
+ log.Error("user_model.GetMailableUsersByIDs: %v", err)
return
}
@@ -76,7 +79,7 @@ func mailNewRelease(ctx context.Context, lang string, tos []*user_model.User, re
var mailBody bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil {
log.Error("ExecuteTemplate [%s]: %v", string(tplNewReleaseMail)+"/body", err)
return
}
diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go
index 5f80654bcd..1ec7995ab9 100644
--- a/services/mailer/mail_repo.go
+++ b/services/mailer/mail_repo.go
@@ -12,10 +12,13 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/translation"
sender_service "code.gitea.io/gitea/services/mailer/sender"
)
+const mailRepoTransferNotify templates.TplName = "notify/repo_transfer"
+
// SendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created
func SendRepoTransferNotifyMail(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) error {
if setting.MailService == nil {
@@ -75,7 +78,7 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U
"Destination": destination,
}
- if err := bodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil {
return err
}
diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go
index 5ca44442f3..034dc14e3d 100644
--- a/services/mailer/mail_team_invite.go
+++ b/services/mailer/mail_team_invite.go
@@ -6,6 +6,7 @@ package mailer
import (
"bytes"
"context"
+ "errors"
"fmt"
"net/url"
@@ -18,9 +19,7 @@ import (
sender_service "code.gitea.io/gitea/services/mailer/sender"
)
-const (
- tplTeamInviteMail templates.TplName = "team_invite"
-)
+const tplTeamInviteMail templates.TplName = "team_invite"
// MailTeamInvite sends team invites
func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_model.Team, invite *org_model.TeamInvite) error {
@@ -40,10 +39,10 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod
if err != nil && !user_model.IsErrUserNotExist(err) {
return err
} else if user != nil && user.ProhibitLogin {
- return fmt.Errorf("login is prohibited for the invited user")
+ return errors.New("login is prohibited for the invited user")
}
- inviteRedirect := url.QueryEscape(fmt.Sprintf("/org/invite/%s", invite.Token))
+ inviteRedirect := url.QueryEscape("/org/invite/" + invite.Token)
inviteURL := fmt.Sprintf("%suser/sign_up?redirect_to=%s", setting.AppURL, inviteRedirect)
if (err == nil && user != nil) || setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration {
@@ -63,7 +62,7 @@ func MailTeamInvite(ctx context.Context, inviter *user_model.User, team *org_mod
}
var mailBody bytes.Buffer
- if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil {
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil {
log.Error("ExecuteTemplate [%s]: %v", string(tplTeamInviteMail)+"/body", err)
return err
}
diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go
index 185b72f069..24f5d39d50 100644
--- a/services/mailer/mail_test.go
+++ b/services/mailer/mail_test.go
@@ -6,6 +6,7 @@ package mailer
import (
"bytes"
"context"
+ "encoding/base64"
"fmt"
"html/template"
"io"
@@ -15,6 +16,7 @@ import (
"testing"
texttmpl "text/template"
+ actions_model "code.gitea.io/gitea/models/actions"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
@@ -23,9 +25,14 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/services/attachment"
sender_service "code.gitea.io/gitea/services/mailer/sender"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
const subjectTpl = `
@@ -53,22 +60,51 @@ const bodyTpl = `
func prepareMailerTest(t *testing.T) (doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment) {
assert.NoError(t, unittest.PrepareTestDatabase())
- mailService := setting.Mailer{
- From: "test@gitea.com",
- }
-
- setting.MailService = &mailService
+ setting.MailService = &setting.Mailer{From: "test@gitea.com"}
setting.Domain = "localhost"
+ setting.AppURL = "https://try.gitea.io/"
doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, Owner: doer})
issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1, Repo: repo, Poster: doer})
- assert.NoError(t, issue.LoadRepo(db.DefaultContext))
comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2, Issue: issue})
+ require.NoError(t, issue.LoadRepo(db.DefaultContext))
return doer, repo, issue, comment
}
-func TestComposeIssueCommentMessage(t *testing.T) {
+func prepareMailerBase64Test(t *testing.T) (doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, att1, att2 *repo_model.Attachment) {
+ user, repo, issue, comment := prepareMailerTest(t)
+ setting.MailService.EmbedAttachmentImages = true
+
+ att1, err := attachment.NewAttachment(t.Context(), &repo_model.Attachment{
+ RepoID: repo.ID,
+ IssueID: issue.ID,
+ UploaderID: user.ID,
+ CommentID: comment.ID,
+ Name: "test.png",
+ }, bytes.NewReader([]byte("\x89\x50\x4e\x47\x0d\x0a\x1a\x0a")), 8)
+ require.NoError(t, err)
+
+ att2, err = attachment.NewAttachment(t.Context(), &repo_model.Attachment{
+ RepoID: repo.ID,
+ IssueID: issue.ID,
+ UploaderID: user.ID,
+ CommentID: comment.ID,
+ Name: "test.png",
+ }, bytes.NewReader([]byte("\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"+strings.Repeat("\x00", 1024))), 8+1024)
+ require.NoError(t, err)
+
+ return user, repo, issue, att1, att2
+}
+
+func prepareMailTemplates(name, subjectTmpl, bodyTmpl string) {
+ loadedTemplates.Store(&templates.MailTemplates{
+ SubjectTemplates: texttmpl.Must(texttmpl.New(name).Parse(subjectTmpl)),
+ BodyTemplates: template.Must(template.New(name).Parse(bodyTmpl)),
+ })
+}
+
+func TestComposeIssueComment(t *testing.T) {
doer, _, issue, comment := prepareMailerTest(t)
markup.Init(&markup.RenderHelperFuncs{
@@ -80,13 +116,11 @@ func TestComposeIssueCommentMessage(t *testing.T) {
setting.IncomingEmail.Enabled = true
defer func() { setting.IncomingEmail.Enabled = false }()
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl))
- bodyTemplates = template.Must(template.New("issue/comment").Parse(bodyTpl))
+ prepareMailTemplates("issue/comment", subjectTpl, bodyTpl)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
- msgs, err := composeIssueCommentMessages(&mailCommentContext{
- Context: context.TODO(), // TODO: use a correct context
- Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
+ msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
+ Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
Content: fmt.Sprintf("test @%s %s#%d body", doer.Name, issue.Repo.FullName(), issue.Index),
Comment: comment,
}, "en-US", recipients, false, "issue comment")
@@ -109,7 +143,8 @@ func TestComposeIssueCommentMessage(t *testing.T) {
assert.Len(t, gomailMsg.GetGenHeader("List-Unsubscribe"), 2) // url + mailto
var buf bytes.Buffer
- gomailMsg.WriteTo(&buf)
+ _, err = gomailMsg.WriteTo(&buf)
+ require.NoError(t, err)
b, err := io.ReadAll(quotedprintable.NewReader(&buf))
assert.NoError(t, err)
@@ -123,16 +158,28 @@ func TestComposeIssueCommentMessage(t *testing.T) {
assert.Contains(t, string(b), fmt.Sprintf(`href="%s"`, issue.HTMLURL()))
}
+func TestMailMentionsComment(t *testing.T) {
+ doer, _, issue, comment := prepareMailerTest(t)
+ comment.Poster = doer
+ prepareMailTemplates("issue/comment", subjectTpl, bodyTpl)
+ mails := 0
+
+ defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) {
+ mails = len(msgs)
+ })()
+
+ err := MailParticipantsComment(t.Context(), comment, activities_model.ActionCommentIssue, issue, []*user_model.User{})
+ require.NoError(t, err)
+ assert.Equal(t, 3, mails)
+}
+
func TestComposeIssueMessage(t *testing.T) {
doer, _, issue, _ := prepareMailerTest(t)
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/new").Parse(subjectTpl))
- bodyTemplates = template.Must(template.New("issue/new").Parse(bodyTpl))
-
+ prepareMailTemplates("issue/new", subjectTpl, bodyTpl)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
- msgs, err := composeIssueCommentMessages(&mailCommentContext{
- Context: context.TODO(), // TODO: use a correct context
- Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
+ msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
+ Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
Content: "test body",
}, "en-US", recipients, false, "issue create")
assert.NoError(t, err)
@@ -158,15 +205,14 @@ func TestTemplateSelection(t *testing.T) {
doer, repo, issue, comment := prepareMailerTest(t)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/default").Parse("issue/default/subject"))
- texttmpl.Must(subjectTemplates.New("issue/new").Parse("issue/new/subject"))
- texttmpl.Must(subjectTemplates.New("pull/comment").Parse("pull/comment/subject"))
- texttmpl.Must(subjectTemplates.New("issue/close").Parse("")) // Must default to fallback subject
+ prepareMailTemplates("issue/default", "issue/default/subject", "issue/default/body")
- bodyTemplates = template.Must(template.New("issue/default").Parse("issue/default/body"))
- template.Must(bodyTemplates.New("issue/new").Parse("issue/new/body"))
- template.Must(bodyTemplates.New("pull/comment").Parse("pull/comment/body"))
- template.Must(bodyTemplates.New("issue/close").Parse("issue/close/body"))
+ texttmpl.Must(LoadedTemplates().SubjectTemplates.New("issue/new").Parse("issue/new/subject"))
+ texttmpl.Must(LoadedTemplates().SubjectTemplates.New("pull/comment").Parse("pull/comment/subject"))
+ texttmpl.Must(LoadedTemplates().SubjectTemplates.New("issue/close").Parse("")) // Must default to a fallback subject
+ template.Must(LoadedTemplates().BodyTemplates.New("issue/new").Parse("issue/new/body"))
+ template.Must(LoadedTemplates().BodyTemplates.New("pull/comment").Parse("pull/comment/body"))
+ template.Must(LoadedTemplates().BodyTemplates.New("issue/close").Parse("issue/close/body"))
expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) {
subject := msg.ToMessage().GetGenHeader("Subject")
@@ -177,32 +223,28 @@ func TestTemplateSelection(t *testing.T) {
assert.Contains(t, wholemsg, expBody)
}
- msg := testComposeIssueCommentMessage(t, &mailCommentContext{
- Context: context.TODO(), // TODO: use a correct context
- Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
+ msg := testComposeIssueCommentMessage(t, &mailComment{
+ Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
Content: "test body",
}, recipients, false, "TestTemplateSelection")
expect(t, msg, "issue/new/subject", "issue/new/body")
- msg = testComposeIssueCommentMessage(t, &mailCommentContext{
- Context: context.TODO(), // TODO: use a correct context
- Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
+ msg = testComposeIssueCommentMessage(t, &mailComment{
+ Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
Content: "test body", Comment: comment,
}, recipients, false, "TestTemplateSelection")
expect(t, msg, "issue/default/subject", "issue/default/body")
pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2, Repo: repo, Poster: doer})
comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 4, Issue: pull})
- msg = testComposeIssueCommentMessage(t, &mailCommentContext{
- Context: context.TODO(), // TODO: use a correct context
- Issue: pull, Doer: doer, ActionType: activities_model.ActionCommentPull,
+ msg = testComposeIssueCommentMessage(t, &mailComment{
+ Issue: pull, Doer: doer, ActionType: activities_model.ActionCommentPull,
Content: "test body", Comment: comment,
}, recipients, false, "TestTemplateSelection")
expect(t, msg, "pull/comment/subject", "pull/comment/body")
- msg = testComposeIssueCommentMessage(t, &mailCommentContext{
- Context: context.TODO(), // TODO: use a correct context
- Issue: issue, Doer: doer, ActionType: activities_model.ActionCloseIssue,
+ msg = testComposeIssueCommentMessage(t, &mailComment{
+ Issue: issue, Doer: doer, ActionType: activities_model.ActionCloseIssue,
Content: "test body", Comment: comment,
}, recipients, false, "TestTemplateSelection")
expect(t, msg, "Re: [user2/repo1] issue1 (#1)", "issue/close/body")
@@ -215,13 +257,10 @@ func TestTemplateServices(t *testing.T) {
expect := func(t *testing.T, issue *issues_model.Issue, comment *issues_model.Comment, doer *user_model.User,
actionType activities_model.ActionType, fromMention bool, tplSubject, tplBody, expSubject, expBody string,
) {
- subjectTemplates = texttmpl.Must(texttmpl.New("issue/default").Parse(tplSubject))
- bodyTemplates = template.Must(template.New("issue/default").Parse(tplBody))
-
+ prepareMailTemplates("issue/default", tplSubject, tplBody)
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
- msg := testComposeIssueCommentMessage(t, &mailCommentContext{
- Context: context.TODO(), // TODO: use a correct context
- Issue: issue, Doer: doer, ActionType: actionType,
+ msg := testComposeIssueCommentMessage(t, &mailComment{
+ Issue: issue, Doer: doer, ActionType: actionType,
Content: "test body", Comment: comment,
}, recipients, fromMention, "TestTemplateServices")
@@ -253,20 +292,20 @@ func TestTemplateServices(t *testing.T) {
"//Re: //")
}
-func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recipients []*user_model.User, fromMention bool, info string) *sender_service.Message {
- msgs, err := composeIssueCommentMessages(ctx, "en-US", recipients, fromMention, info)
+func testComposeIssueCommentMessage(t *testing.T, ctx *mailComment, recipients []*user_model.User, fromMention bool, info string) *sender_service.Message {
+ msgs, err := composeIssueCommentMessages(t.Context(), ctx, "en-US", recipients, fromMention, info)
assert.NoError(t, err)
assert.Len(t, msgs, 1)
return msgs[0]
}
-func TestGenerateAdditionalHeaders(t *testing.T) {
+func TestGenerateAdditionalHeadersForIssue(t *testing.T) {
doer, _, issue, _ := prepareMailerTest(t)
- ctx := &mailCommentContext{Context: context.TODO() /* TODO: use a correct context */, Issue: issue, Doer: doer}
+ comment := &mailComment{Issue: issue, Doer: doer}
recipient := &user_model.User{Name: "test", Email: "test@gitea.com"}
- headers := generateAdditionalHeaders(ctx, "dummy-reason", recipient)
+ headers := generateAdditionalHeadersForIssue(comment, "dummy-reason", recipient)
expected := map[string]string{
"List-ID": "user2/repo1 <repo1.user2.localhost>",
@@ -390,9 +429,7 @@ func TestGenerateMessageIDForIssue(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generateMessageIDForIssue(tt.args.issue, tt.args.comment, tt.args.actionType)
- if !strings.HasPrefix(got, tt.prefix) {
- t.Errorf("generateMessageIDForIssue() = %v, want %v", got, tt.prefix)
- }
+ assert.True(t, strings.HasPrefix(got, tt.prefix), "%v, want %v", got, tt.prefix)
})
}
}
@@ -405,10 +442,20 @@ func TestGenerateMessageIDForRelease(t *testing.T) {
assert.Equal(t, "<owner/repo/releases/1@localhost>", msgID)
}
+func TestGenerateMessageIDForActionsWorkflowRunStatusEmail(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+ run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 795, RepoID: repo.ID})
+ assert.NoError(t, run.LoadAttributes(db.DefaultContext))
+ msgID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run)
+ assert.Equal(t, "<user2/repo2/actions/runs/191@localhost>", msgID)
+}
+
func TestFromDisplayName(t *testing.T) {
- template, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}")
+ tmpl, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}")
assert.NoError(t, err)
- setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: template}
+ setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: tmpl}
defer func() { setting.MailService = nil }()
tests := []struct {
@@ -432,14 +479,14 @@ func TestFromDisplayName(t *testing.T) {
t.Run(tc.userDisplayName, func(t *testing.T) {
user := &user_model.User{FullName: tc.userDisplayName, Name: "tmp"}
got := fromDisplayName(user)
- assert.EqualValues(t, tc.fromDisplayName, got)
+ assert.Equal(t, tc.fromDisplayName, got)
})
}
t.Run("template with all available vars", func(t *testing.T) {
- template, err = texttmpl.New("mailFrom").Parse("{{ .DisplayName }} (by {{ .AppName }} on [{{ .Domain }}])")
+ tmpl, err = texttmpl.New("mailFrom").Parse("{{ .DisplayName }} (by {{ .AppName }} on [{{ .Domain }}])")
assert.NoError(t, err)
- setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: template}
+ setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: tmpl}
oldAppName := setting.AppName
setting.AppName = "Code IT"
oldDomain := setting.Domain
@@ -449,6 +496,73 @@ func TestFromDisplayName(t *testing.T) {
setting.Domain = oldDomain
}()
- assert.EqualValues(t, "Mister X (by Code IT on [code.it])", fromDisplayName(&user_model.User{FullName: "Mister X", Name: "tmp"}))
+ assert.Equal(t, "Mister X (by Code IT on [code.it])", fromDisplayName(&user_model.User{FullName: "Mister X", Name: "tmp"}))
+ })
+}
+
+func TestEmbedBase64Images(t *testing.T) {
+ user, repo, issue, att1, att2 := prepareMailerBase64Test(t)
+ // comment := &mailComment{Issue: issue, Doer: user}
+
+ imgExternalURL := "https://via.placeholder.com/10"
+ imgExternalImg := fmt.Sprintf(`<img src="%s"/>`, imgExternalURL)
+
+ att1URL := setting.AppURL + repo.Owner.Name + "/" + repo.Name + "/attachments/" + att1.UUID
+ att1Img := fmt.Sprintf(`<img src="%s"/>`, att1URL)
+ att1Base64 := "data:image/png;base64,iVBORw0KGgo="
+ att1ImgBase64 := fmt.Sprintf(`<img src="%s"/>`, att1Base64)
+
+ att2URL := setting.AppURL + repo.Owner.Name + "/" + repo.Name + "/attachments/" + att2.UUID
+ att2Img := fmt.Sprintf(`<img src="%s"/>`, att2URL)
+ att2File, err := storage.Attachments.Open(att2.RelativePath())
+ require.NoError(t, err)
+ defer att2File.Close()
+ att2Bytes, err := io.ReadAll(att2File)
+ require.NoError(t, err)
+ require.Greater(t, len(att2Bytes), 1024)
+ att2Base64 := "data:image/png;base64," + base64.StdEncoding.EncodeToString(att2Bytes)
+ att2ImgBase64 := fmt.Sprintf(`<img src="%s"/>`, att2Base64)
+
+ t.Run("ComposeMessage", func(t *testing.T) {
+ prepareMailTemplates("issue/new", subjectTpl, bodyTpl)
+
+ issue.Content = fmt.Sprintf(`MSG-BEFORE <image src="attachments/%s"> MSG-AFTER`, att1.UUID)
+ require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "content"))
+
+ recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
+ msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
+ Issue: issue,
+ Doer: user,
+ ActionType: activities_model.ActionCreateIssue,
+ Content: issue.Content,
+ }, "en-US", recipients, false, "issue create")
+ require.NoError(t, err)
+
+ mailBody := msgs[0].Body
+ assert.Regexp(t, `MSG-BEFORE <a[^>]+><img src="data:image/png;base64,iVBORw0KGgo=".*/></a> MSG-AFTER`, mailBody)
+ })
+
+ t.Run("EmbedInstanceImageSkipExternalImage", func(t *testing.T) {
+ mailBody := "<html><head></head><body><p>Test1</p>" + imgExternalImg + "<p>Test2</p>" + att1Img + "<p>Test3</p></body></html>"
+ expectedMailBody := "<html><head></head><body><p>Test1</p>" + imgExternalImg + "<p>Test2</p>" + att1ImgBase64 + "<p>Test3</p></body></html>"
+ b64embedder := newMailAttachmentBase64Embedder(user, repo, 1024)
+ resultMailBody, err := b64embedder.Base64InlineImages(t.Context(), template.HTML(mailBody))
+ require.NoError(t, err)
+ assert.Equal(t, expectedMailBody, string(resultMailBody))
+ })
+
+ t.Run("LimitedEmailBodySize", func(t *testing.T) {
+ mailBody := fmt.Sprintf("<html><head></head><body>%s%s</body></html>", att1Img, att2Img)
+ b64embedder := newMailAttachmentBase64Embedder(user, repo, 1024)
+ resultMailBody, err := b64embedder.Base64InlineImages(t.Context(), template.HTML(mailBody))
+ require.NoError(t, err)
+ expected := fmt.Sprintf("<html><head></head><body>%s%s</body></html>", att1ImgBase64, att2Img)
+ assert.Equal(t, expected, string(resultMailBody))
+
+ b64embedder = newMailAttachmentBase64Embedder(user, repo, 4096)
+ resultMailBody, err = b64embedder.Base64InlineImages(t.Context(), template.HTML(mailBody))
+ require.NoError(t, err)
+ expected = fmt.Sprintf("<html><head></head><body>%s%s</body></html>", att1ImgBase64, att2ImgBase64)
+ assert.Equal(t, expected, string(resultMailBody))
})
}
diff --git a/services/mailer/mail_user.go b/services/mailer/mail_user.go
new file mode 100644
index 0000000000..68df81f6a3
--- /dev/null
+++ b/services/mailer/mail_user.go
@@ -0,0 +1,161 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package mailer
+
+import (
+ "bytes"
+ "fmt"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/translation"
+ sender_service "code.gitea.io/gitea/services/mailer/sender"
+)
+
+const (
+ mailAuthActivate templates.TplName = "auth/activate"
+ mailAuthActivateEmail templates.TplName = "auth/activate_email"
+ mailAuthResetPassword templates.TplName = "auth/reset_passwd"
+ mailAuthRegisterNotify templates.TplName = "auth/register_notify"
+ mailNotifyCollaborator templates.TplName = "notify/collaborator"
+)
+
+// sendUserMail sends a mail to the user
+func sendUserMail(language string, u *user_model.User, tpl templates.TplName, code, subject, info string) {
+ locale := translation.NewLocale(language)
+ data := map[string]any{
+ "locale": locale,
+ "DisplayName": u.DisplayName(),
+ "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
+ "ResetPwdCodeLives": timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, locale),
+ "Code": code,
+ "Language": locale.Language(),
+ }
+
+ var content bytes.Buffer
+
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
+ log.Error("Template: %v", err)
+ return
+ }
+
+ msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
+ msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info)
+
+ SendAsync(msg)
+}
+
+// SendActivateAccountMail sends an activation mail to the user (new user registration)
+func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
+ if setting.MailService == nil {
+ // No mail service configured
+ return
+ }
+ opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}
+ sendUserMail(locale.Language(), u, mailAuthActivate, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.activate_account"), "activate account")
+}
+
+// SendResetPasswordMail sends a password reset mail to the user
+func SendResetPasswordMail(u *user_model.User) {
+ if setting.MailService == nil {
+ // No mail service configured
+ return
+ }
+ locale := translation.NewLocale(u.Language)
+ opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}
+ sendUserMail(u.Language, u, mailAuthResetPassword, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.reset_password"), "recover account")
+}
+
+// SendActivateEmailMail sends confirmation email to confirm new email address
+func SendActivateEmailMail(u *user_model.User, email string) {
+ if setting.MailService == nil {
+ // No mail service configured
+ return
+ }
+ locale := translation.NewLocale(u.Language)
+ opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateEmail, NewEmail: email}
+ data := map[string]any{
+ "locale": locale,
+ "DisplayName": u.DisplayName(),
+ "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
+ "Code": user_model.GenerateUserTimeLimitCode(opts, u),
+ "Email": email,
+ "Language": locale.Language(),
+ }
+
+ var content bytes.Buffer
+
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
+ log.Error("Template: %v", err)
+ return
+ }
+
+ msg := sender_service.NewMessage(email, locale.TrString("mail.activate_email"), content.String())
+ msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
+
+ SendAsync(msg)
+}
+
+// SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
+func SendRegisterNotifyMail(u *user_model.User) {
+ if setting.MailService == nil || !u.IsActive {
+ // No mail service configured OR user is inactive
+ return
+ }
+ locale := translation.NewLocale(u.Language)
+
+ data := map[string]any{
+ "locale": locale,
+ "DisplayName": u.DisplayName(),
+ "Username": u.Name,
+ "Language": locale.Language(),
+ }
+
+ var content bytes.Buffer
+
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
+ log.Error("Template: %v", err)
+ return
+ }
+
+ msg := sender_service.NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String())
+ msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID)
+
+ SendAsync(msg)
+}
+
+// SendCollaboratorMail sends mail notification to new collaborator.
+func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) {
+ if setting.MailService == nil || !u.IsActive {
+ // No mail service configured OR the user is inactive
+ return
+ }
+ locale := translation.NewLocale(u.Language)
+ repoName := repo.FullName()
+
+ subject := locale.TrString("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
+ data := map[string]any{
+ "locale": locale,
+ "Subject": subject,
+ "RepoName": repoName,
+ "Link": repo.HTMLURL(),
+ "Language": locale.Language(),
+ }
+
+ var content bytes.Buffer
+
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
+ log.Error("Template: %v", err)
+ return
+ }
+
+ msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
+ msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.ID)
+
+ SendAsync(msg)
+}
diff --git a/services/mailer/mail_workflow_run.go b/services/mailer/mail_workflow_run.go
new file mode 100644
index 0000000000..29b3abda8e
--- /dev/null
+++ b/services/mailer/mail_workflow_run.go
@@ -0,0 +1,165 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package mailer
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "sort"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/services/convert"
+ sender_service "code.gitea.io/gitea/services/mailer/sender"
+)
+
+const tplWorkflowRun = "notify/workflow_run"
+
+type convertedWorkflowJob struct {
+ HTMLURL string
+ Status actions_model.Status
+ Name string
+ Attempt int64
+}
+
+func generateMessageIDForActionsWorkflowRunStatusEmail(repo *repo_model.Repository, run *actions_model.ActionRun) string {
+ return fmt.Sprintf("<%s/actions/runs/%d@%s>", repo.FullName(), run.Index, setting.Domain)
+}
+
+func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) {
+ subject := "Run"
+ switch run.Status {
+ case actions_model.StatusFailure:
+ subject += " failed"
+ case actions_model.StatusCancelled:
+ subject += " cancelled"
+ case actions_model.StatusSuccess:
+ subject += " succeeded"
+ }
+ subject = fmt.Sprintf("%s: %s (%s)", subject, run.WorkflowID, base.ShortSha(run.CommitSHA))
+ displayName := fromDisplayName(sender)
+ messageID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run)
+ metadataHeaders := generateMetadataHeaders(repo)
+
+ jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID)
+ if err != nil {
+ log.Error("GetRunJobsByRunID: %v", err)
+ return
+ }
+ sort.SliceStable(jobs, func(i, j int) bool {
+ si, sj := jobs[i].Status, jobs[j].Status
+ /*
+ If both i and j are/are not success, leave it to si < sj.
+ If i is success and j is not, since the desired is j goes "smaller" and i goes "bigger", this func should return false.
+ If j is success and i is not, since the desired is i goes "smaller" and j goes "bigger", this func should return true.
+ */
+ if si.IsSuccess() != sj.IsSuccess() {
+ return !si.IsSuccess()
+ }
+ return si < sj
+ })
+
+ convertedJobs := make([]convertedWorkflowJob, 0, len(jobs))
+ for _, job := range jobs {
+ converted0, err := convert.ToActionWorkflowJob(ctx, repo, nil, job)
+ if err != nil {
+ log.Error("convert.ToActionWorkflowJob: %v", err)
+ continue
+ }
+ convertedJobs = append(convertedJobs, convertedWorkflowJob{
+ HTMLURL: converted0.HTMLURL,
+ Name: converted0.Name,
+ Status: job.Status,
+ Attempt: converted0.RunAttempt,
+ })
+ }
+
+ langMap := make(map[string][]*user_model.User)
+ for _, user := range recipients {
+ langMap[user.Language] = append(langMap[user.Language], user)
+ }
+ for lang, tos := range langMap {
+ locale := translation.NewLocale(lang)
+ var runStatusText string
+ switch run.Status {
+ case actions_model.StatusSuccess:
+ runStatusText = "All jobs have succeeded"
+ case actions_model.StatusFailure:
+ runStatusText = "All jobs have failed"
+ for _, job := range jobs {
+ if !job.Status.IsFailure() {
+ runStatusText = "Some jobs were not successful"
+ break
+ }
+ }
+ case actions_model.StatusCancelled:
+ runStatusText = "All jobs have been cancelled"
+ }
+ var mailBody bytes.Buffer
+ if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tplWorkflowRun, map[string]any{
+ "Subject": subject,
+ "Repo": repo,
+ "Run": run,
+ "RunStatusText": runStatusText,
+ "Jobs": convertedJobs,
+ "locale": locale,
+ }); err != nil {
+ log.Error("ExecuteTemplate [%s]: %v", tplWorkflowRun, err)
+ return
+ }
+ msgs := make([]*sender_service.Message, 0, len(tos))
+ for _, rec := range tos {
+ msg := sender_service.NewMessageFrom(
+ rec.Email,
+ displayName,
+ setting.MailService.FromEmail,
+ subject,
+ mailBody.String(),
+ )
+ msg.Info = subject
+ for k, v := range generateSenderRecipientHeaders(sender, rec) {
+ msg.SetHeader(k, v)
+ }
+ for k, v := range metadataHeaders {
+ msg.SetHeader(k, v)
+ }
+ msg.SetHeader("Message-ID", messageID)
+ msgs = append(msgs, msg)
+ }
+ SendAsync(msgs...)
+ }
+}
+
+func MailActionsTrigger(ctx context.Context, sender *user_model.User, repo *repo_model.Repository, run *actions_model.ActionRun) {
+ if setting.MailService == nil {
+ return
+ }
+ if run.Status.IsSkipped() {
+ return
+ }
+
+ recipients := make([]*user_model.User, 0)
+
+ if !sender.IsGiteaActions() && !sender.IsGhost() && sender.IsMailable() {
+ notifyPref, err := user_model.GetUserSetting(ctx, sender.ID,
+ user_model.SettingsKeyEmailNotificationGiteaActions, user_model.SettingEmailNotificationGiteaActionsFailureOnly)
+ if err != nil {
+ log.Error("GetUserSetting: %v", err)
+ return
+ }
+ if notifyPref == user_model.SettingEmailNotificationGiteaActionsAll || !run.Status.IsSuccess() && notifyPref != user_model.SettingEmailNotificationGiteaActionsDisabled {
+ recipients = append(recipients, sender)
+ }
+ }
+
+ if len(recipients) > 0 {
+ composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, sender, recipients)
+ }
+}
diff --git a/services/mailer/mailer.go b/services/mailer/mailer.go
index bcd4facca9..db00aac4f1 100644
--- a/services/mailer/mailer.go
+++ b/services/mailer/mailer.go
@@ -43,7 +43,7 @@ func NewContext(ctx context.Context) {
sender = &sender_service.SMTPSender{}
}
- subjectTemplates, bodyTemplates = templates.Mailer(ctx)
+ templates.LoadMailTemplates(ctx, &loadedTemplates)
mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*sender_service.Message) []*sender_service.Message {
for _, msg := range items {
diff --git a/services/mailer/notify.go b/services/mailer/notify.go
index e48b5d399d..c008685e13 100644
--- a/services/mailer/notify.go
+++ b/services/mailer/notify.go
@@ -7,11 +7,13 @@ import (
"context"
"fmt"
+ actions_model "code.gitea.io/gitea/models/actions"
activities_model "code.gitea.io/gitea/models/activities"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
+ issue_service "code.gitea.io/gitea/services/issue"
notify_service "code.gitea.io/gitea/services/notify"
)
@@ -30,15 +32,16 @@ func (m *mailNotifier) CreateIssueComment(ctx context.Context, doer *user_model.
issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User,
) {
var act activities_model.ActionType
- if comment.Type == issues_model.CommentTypeClose {
+ switch comment.Type {
+ case issues_model.CommentTypeClose:
act = activities_model.ActionCloseIssue
- } else if comment.Type == issues_model.CommentTypeReopen {
+ case issues_model.CommentTypeReopen:
act = activities_model.ActionReopenIssue
- } else if comment.Type == issues_model.CommentTypeComment {
+ case issues_model.CommentTypeComment:
act = activities_model.ActionCommentIssue
- } else if comment.Type == issues_model.CommentTypeCode {
+ case issues_model.CommentTypeCode:
act = activities_model.ActionCommentIssue
- } else if comment.Type == issues_model.CommentTypePullRequestPush {
+ case issues_model.CommentTypePullRequestPush:
act = 0
}
@@ -94,11 +97,12 @@ func (m *mailNotifier) NewPullRequest(ctx context.Context, pr *issues_model.Pull
func (m *mailNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, r *issues_model.Review, comment *issues_model.Comment, mentions []*user_model.User) {
var act activities_model.ActionType
- if comment.Type == issues_model.CommentTypeClose {
+ switch comment.Type {
+ case issues_model.CommentTypeClose:
act = activities_model.ActionCloseIssue
- } else if comment.Type == issues_model.CommentTypeReopen {
+ case issues_model.CommentTypeReopen:
act = activities_model.ActionReopenIssue
- } else if comment.Type == issues_model.CommentTypeComment {
+ case issues_model.CommentTypeComment:
act = activities_model.ActionCommentPull
}
if err := MailParticipantsComment(ctx, comment, act, pr.Issue, mentions); err != nil {
@@ -169,7 +173,7 @@ func (m *mailNotifier) PullRequestPushCommits(ctx context.Context, doer *user_mo
log.Error("comment.Issue.PullRequest.LoadBaseRepo: %v", err)
return
}
- if err := comment.LoadPushCommits(ctx); err != nil {
+ if err := issue_service.LoadCommentPushCommits(ctx, comment); err != nil {
log.Error("comment.LoadPushCommits: %v", err)
}
m.CreateIssueComment(ctx, doer, comment.Issue.Repo, comment.Issue, comment, nil)
@@ -202,3 +206,10 @@ func (m *mailNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *
log.Error("SendRepoTransferNotifyMail: %v", err)
}
}
+
+func (m *mailNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
+ if !run.Status.IsDone() {
+ return
+ }
+ MailActionsTrigger(ctx, sender, repo, run)
+}
diff --git a/services/mailer/sender/message.go b/services/mailer/sender/message.go
index db20675572..55f03e4f7e 100644
--- a/services/mailer/sender/message.go
+++ b/services/mailer/sender/message.go
@@ -10,9 +10,9 @@ import (
"strings"
"time"
- "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
"github.com/jaytaylor/html2text"
gomail "github.com/wneessen/go-mail"
@@ -54,7 +54,7 @@ func (m *Message) ToMessage() *gomail.Msg {
plainBody, err := html2text.FromString(m.Body)
if err != nil || setting.MailService.SendAsPlainText {
- if strings.Contains(base.TruncateString(m.Body, 100), "<html>") {
+ if strings.Contains(util.TruncateRunes(m.Body, 100), "<html>") {
log.Warn("Mail contains HTML but configured to send as plain text.")
}
msg.SetBodyString("text/plain", plainBody)
diff --git a/services/mailer/sender/message_test.go b/services/mailer/sender/message_test.go
index 63d0bc349a..ae153ebf05 100644
--- a/services/mailer/sender/message_test.go
+++ b/services/mailer/sender/message_test.go
@@ -108,9 +108,9 @@ func extractMailHeaderAndContent(t *testing.T, mail string) (map[string]string,
}
content := strings.TrimSpace("boundary=" + parts[1])
- hParts := strings.Split(parts[0], "\n")
+ hParts := strings.SplitSeq(parts[0], "\n")
- for _, hPart := range hParts {
+ for hPart := range hParts {
parts := strings.SplitN(hPart, ":", 2)
hk := strings.TrimSpace(parts[0])
if hk != "" {
diff --git a/services/mailer/sender/smtp.go b/services/mailer/sender/smtp.go
index c53c3da997..8dc1b40b74 100644
--- a/services/mailer/sender/smtp.go
+++ b/services/mailer/sender/smtp.go
@@ -5,6 +5,7 @@ package sender
import (
"crypto/tls"
+ "errors"
"fmt"
"io"
"net"
@@ -99,7 +100,7 @@ func (s *SMTPSender) Send(from string, to []string, msg io.WriterTo) error {
canAuth, options := client.Extension("AUTH")
if len(opts.User) > 0 {
if !canAuth {
- return fmt.Errorf("SMTP server does not support AUTH, but credentials provided")
+ return errors.New("SMTP server does not support AUTH, but credentials provided")
}
var auth smtp.Auth
diff --git a/services/mailer/sender/smtp_auth.go b/services/mailer/sender/smtp_auth.go
index 260b12437b..c60e0dbfbb 100644
--- a/services/mailer/sender/smtp_auth.go
+++ b/services/mailer/sender/smtp_auth.go
@@ -4,6 +4,7 @@
package sender
import (
+ "errors"
"fmt"
"github.com/Azure/go-ntlmssp"
@@ -60,7 +61,7 @@ func (a *ntlmAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
func (a *ntlmAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
if len(fromServer) == 0 {
- return nil, fmt.Errorf("ntlm ChallengeMessage is empty")
+ return nil, errors.New("ntlm ChallengeMessage is empty")
}
authenticateMessage, err := ntlmssp.ProcessChallenge(fromServer, a.username, a.password, a.domainNeeded)
return authenticateMessage, err