diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /services/mailer | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r-- | services/mailer/incoming/incoming.go | 394 | ||||
-rw-r--r-- | services/mailer/incoming/incoming_handler.go | 187 | ||||
-rw-r--r-- | services/mailer/incoming/incoming_test.go | 191 | ||||
-rw-r--r-- | services/mailer/incoming/payload/payload.go | 70 | ||||
-rw-r--r-- | services/mailer/mail.go | 751 | ||||
-rw-r--r-- | services/mailer/mail_admin_new_user.go | 78 | ||||
-rw-r--r-- | services/mailer/mail_admin_new_user_test.go | 79 | ||||
-rw-r--r-- | services/mailer/mail_auth_test.go | 62 | ||||
-rw-r--r-- | services/mailer/mail_comment.go | 63 | ||||
-rw-r--r-- | services/mailer/mail_issue.go | 201 | ||||
-rw-r--r-- | services/mailer/mail_release.go | 98 | ||||
-rw-r--r-- | services/mailer/mail_repo.go | 89 | ||||
-rw-r--r-- | services/mailer/mail_team_invite.go | 76 | ||||
-rw-r--r-- | services/mailer/mail_test.go | 540 | ||||
-rw-r--r-- | services/mailer/mailer.go | 448 | ||||
-rw-r--r-- | services/mailer/mailer_test.go | 128 | ||||
-rw-r--r-- | services/mailer/main_test.go | 48 | ||||
-rw-r--r-- | services/mailer/notify.go | 208 | ||||
-rw-r--r-- | services/mailer/token/token.go | 138 |
19 files changed, 3849 insertions, 0 deletions
diff --git a/services/mailer/incoming/incoming.go b/services/mailer/incoming/incoming.go new file mode 100644 index 0000000..ac6f32c --- /dev/null +++ b/services/mailer/incoming/incoming.go @@ -0,0 +1,394 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package incoming + +import ( + "context" + "crypto/tls" + "fmt" + net_mail "net/mail" + "regexp" + "strings" + "time" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/mailer/token" + + "code.forgejo.org/forgejo/reply" + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + "github.com/jhillyerd/enmime" +) + +var ( + addressTokenRegex *regexp.Regexp + referenceTokenRegex *regexp.Regexp +) + +func Init(ctx context.Context) error { + if !setting.IncomingEmail.Enabled { + return nil + } + + var err error + addressTokenRegex, err = regexp.Compile( + fmt.Sprintf( + `\A%s\z`, + strings.Replace(regexp.QuoteMeta(setting.IncomingEmail.ReplyToAddress), regexp.QuoteMeta(setting.IncomingEmail.TokenPlaceholder), "(.+)", 1), + ), + ) + if err != nil { + return err + } + referenceTokenRegex, err = regexp.Compile(fmt.Sprintf(`\Areply-(.+)@%s\z`, regexp.QuoteMeta(setting.Domain))) + if err != nil { + return err + } + + go func() { + ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Incoming Email", process.SystemProcessType, true) + defer finished() + + // This background job processes incoming emails. It uses the IMAP IDLE command to get notified about incoming emails. + // The following loop restarts the processing logic after errors until ctx indicates to stop. + + for { + select { + case <-ctx.Done(): + return + default: + if err := processIncomingEmails(ctx); err != nil { + log.Error("Error while processing incoming emails: %v", err) + } + select { + case <-ctx.Done(): + return + case <-time.NewTimer(10 * time.Second).C: + } + } + } + }() + + return nil +} + +// processIncomingEmails is the "main" method with the wait/process loop +func processIncomingEmails(ctx context.Context) error { + server := fmt.Sprintf("%s:%d", setting.IncomingEmail.Host, setting.IncomingEmail.Port) + + var c *client.Client + var err error + if setting.IncomingEmail.UseTLS { + c, err = client.DialTLS(server, &tls.Config{InsecureSkipVerify: setting.IncomingEmail.SkipTLSVerify}) + } else { + c, err = client.Dial(server) + } + if err != nil { + return fmt.Errorf("could not connect to server '%s': %w", server, err) + } + + if err := c.Login(setting.IncomingEmail.Username, setting.IncomingEmail.Password); err != nil { + return fmt.Errorf("could not login: %w", err) + } + defer func() { + if err := c.Logout(); err != nil { + log.Error("Logout from incoming email server failed: %v", err) + } + }() + + if _, err := c.Select(setting.IncomingEmail.Mailbox, false); err != nil { + return fmt.Errorf("selecting box '%s' failed: %w", setting.IncomingEmail.Mailbox, err) + } + + // The following loop processes messages. If there are no messages available, IMAP IDLE is used to wait for new messages. + // This process is repeated until an IMAP error occurs or ctx indicates to stop. + + for { + select { + case <-ctx.Done(): + return nil + default: + if err := processMessages(ctx, c); err != nil { + return fmt.Errorf("could not process messages: %w", err) + } + if err := waitForUpdates(ctx, c); err != nil { + return fmt.Errorf("wait for updates failed: %w", err) + } + select { + case <-ctx.Done(): + return nil + case <-time.NewTimer(time.Second).C: + } + } + } +} + +// waitForUpdates uses IMAP IDLE to wait for new emails +func waitForUpdates(ctx context.Context, c *client.Client) error { + updates := make(chan client.Update, 1) + + c.Updates = updates + defer func() { + c.Updates = nil + }() + + errs := make(chan error, 1) + stop := make(chan struct{}) + go func() { + errs <- c.Idle(stop, nil) + }() + + stopped := false + for { + select { + case update := <-updates: + switch update.(type) { + case *client.MailboxUpdate: + if !stopped { + close(stop) + stopped = true + } + default: + } + case err := <-errs: + if err != nil { + return fmt.Errorf("imap idle failed: %w", err) + } + return nil + case <-ctx.Done(): + return nil + } + } +} + +// processMessages searches unread mails and processes them. +func processMessages(ctx context.Context, c *client.Client) error { + criteria := imap.NewSearchCriteria() + criteria.WithoutFlags = []string{imap.SeenFlag} + criteria.Smaller = setting.IncomingEmail.MaximumMessageSize + ids, err := c.Search(criteria) + if err != nil { + return fmt.Errorf("imap search failed: %w", err) + } + + if len(ids) == 0 { + return nil + } + + seqset := new(imap.SeqSet) + seqset.AddNum(ids...) + messages := make(chan *imap.Message, 10) + + section := &imap.BodySectionName{} + + errs := make(chan error, 1) + go func() { + errs <- c.Fetch( + seqset, + []imap.FetchItem{section.FetchItem()}, + messages, + ) + }() + + handledSet := new(imap.SeqSet) +loop: + for { + select { + case <-ctx.Done(): + break loop + case msg, ok := <-messages: + if !ok { + if setting.IncomingEmail.DeleteHandledMessage && !handledSet.Empty() { + if err := c.Store( + handledSet, + imap.FormatFlagsOp(imap.AddFlags, true), + []any{imap.DeletedFlag}, + nil, + ); err != nil { + return fmt.Errorf("imap store failed: %w", err) + } + + if err := c.Expunge(nil); err != nil { + return fmt.Errorf("imap expunge failed: %w", err) + } + } + return nil + } + + err := func() error { + if isAlreadyHandled(handledSet, msg) { + log.Debug("Skipping already handled message") + return nil + } + + r := msg.GetBody(section) + if r == nil { + return fmt.Errorf("could not get body from message: %w", err) + } + + env, err := enmime.ReadEnvelope(r) + if err != nil { + return fmt.Errorf("could not read envelope: %w", err) + } + + if isAutomaticReply(env) { + log.Debug("Skipping automatic email reply") + return nil + } + + t := searchTokenInHeaders(env) + if t == "" { + log.Debug("Incoming email token not found in headers") + return nil + } + + handlerType, user, payload, err := token.ExtractToken(ctx, t) + if err != nil { + if _, ok := err.(*token.ErrToken); ok { + log.Info("Invalid incoming email token: %v", err) + return nil + } + return err + } + + handler, ok := handlers[handlerType] + if !ok { + return fmt.Errorf("unexpected handler type: %v", handlerType) + } + + content := getContentFromMailReader(env) + + if err := handler.Handle(ctx, content, user, payload); err != nil { + return fmt.Errorf("could not handle message: %w", err) + } + + handledSet.AddNum(msg.SeqNum) + + return nil + }() + if err != nil { + log.Error("Error while processing incoming email[%v]: %v", msg.Uid, err) + } + } + } + + if err := <-errs; err != nil { + return fmt.Errorf("imap fetch failed: %w", err) + } + + return nil +} + +// isAlreadyHandled tests if the message was already handled +func isAlreadyHandled(handledSet *imap.SeqSet, msg *imap.Message) bool { + return handledSet.Contains(msg.SeqNum) +} + +// isAutomaticReply tests if the headers indicate an automatic reply +func isAutomaticReply(env *enmime.Envelope) bool { + autoSubmitted := env.GetHeader("Auto-Submitted") + if autoSubmitted != "" && autoSubmitted != "no" { + return true + } + autoReply := env.GetHeader("X-Autoreply") + if autoReply == "yes" { + return true + } + autoRespond := env.GetHeader("X-Autorespond") + return autoRespond != "" +} + +// searchTokenInHeaders looks for the token in To, Delivered-To and References +func searchTokenInHeaders(env *enmime.Envelope) string { + if addressTokenRegex != nil { + to, _ := env.AddressList("To") + + token := searchTokenInAddresses(to) + if token != "" { + return token + } + + deliveredTo, _ := env.AddressList("Delivered-To") + + token = searchTokenInAddresses(deliveredTo) + if token != "" { + return token + } + } + + references := env.GetHeader("References") + for { + begin := strings.IndexByte(references, '<') + if begin == -1 { + break + } + begin++ + + end := strings.IndexByte(references, '>') + if end == -1 || begin > end { + break + } + + match := referenceTokenRegex.FindStringSubmatch(references[begin:end]) + if len(match) == 2 { + return match[1] + } + + references = references[end+1:] + } + + return "" +} + +// searchTokenInAddresses looks for the token in an address +func searchTokenInAddresses(addresses []*net_mail.Address) string { + for _, address := range addresses { + match := addressTokenRegex.FindStringSubmatch(address.Address) + if len(match) != 2 { + continue + } + + return match[1] + } + + return "" +} + +type MailContent struct { + Content string + Attachments []*Attachment +} + +type Attachment struct { + Name string + Content []byte +} + +// getContentFromMailReader grabs the plain content and the attachments from the mail. +// A potential reply/signature gets stripped from the content. +func getContentFromMailReader(env *enmime.Envelope) *MailContent { + attachments := make([]*Attachment, 0, len(env.Attachments)) + for _, attachment := range env.Attachments { + attachments = append(attachments, &Attachment{ + Name: attachment.FileName, + Content: attachment.Content, + }) + } + inlineAttachments := make([]*Attachment, 0, len(env.Inlines)) + for _, inline := range env.Inlines { + if inline.FileName != "" && inline.ContentType != "text/plain" { + inlineAttachments = append(inlineAttachments, &Attachment{ + Name: inline.FileName, + Content: inline.Content, + }) + } + } + + return &MailContent{ + Content: reply.FromText(env.Text), + Attachments: append(attachments, inlineAttachments...), + } +} diff --git a/services/mailer/incoming/incoming_handler.go b/services/mailer/incoming/incoming_handler.go new file mode 100644 index 0000000..dc3c4ec --- /dev/null +++ b/services/mailer/incoming/incoming_handler.go @@ -0,0 +1,187 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package incoming + +import ( + "bytes" + "context" + "fmt" + + issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" + 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/util" + attachment_service "code.gitea.io/gitea/services/attachment" + "code.gitea.io/gitea/services/context/upload" + issue_service "code.gitea.io/gitea/services/issue" + incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" + "code.gitea.io/gitea/services/mailer/token" + pull_service "code.gitea.io/gitea/services/pull" +) + +type MailHandler interface { + Handle(ctx context.Context, content *MailContent, doer *user_model.User, payload []byte) error +} + +var handlers = map[token.HandlerType]MailHandler{ + token.ReplyHandlerType: &ReplyHandler{}, + token.UnsubscribeHandlerType: &UnsubscribeHandler{}, +} + +// ReplyHandler handles incoming emails to create a reply from them +type ReplyHandler struct{} + +func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *user_model.User, payload []byte) error { + if doer == nil { + return util.NewInvalidArgumentErrorf("doer can't be nil") + } + + ref, err := incoming_payload.GetReferenceFromPayload(ctx, payload) + if err != nil { + return err + } + + var issue *issues_model.Issue + + switch r := ref.(type) { + case *issues_model.Issue: + issue = r + case *issues_model.Comment: + comment := r + + if err := comment.LoadIssue(ctx); err != nil { + return err + } + + issue = comment.Issue + default: + return util.NewInvalidArgumentErrorf("unsupported reply reference: %v", ref) + } + + if err := issue.LoadRepo(ctx); err != nil { + return err + } + + perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) + if err != nil { + return err + } + + // Locked issues require write permissions + if issue.IsLocked && !perm.CanWriteIssuesOrPulls(issue.IsPull) && !doer.IsAdmin { + log.Debug("can't write issue or pull") + return nil + } + + if !perm.CanReadIssuesOrPulls(issue.IsPull) { + log.Debug("can't read issue or pull") + return nil + } + + log.Trace("incoming mail related to %T", ref) + + attachmentIDs := make([]string, 0, len(content.Attachments)) + if setting.Attachment.Enabled { + for _, attachment := range content.Attachments { + a, err := attachment_service.UploadAttachment(ctx, bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, int64(len(attachment.Content)), &repo_model.Attachment{ + Name: attachment.Name, + UploaderID: doer.ID, + RepoID: issue.Repo.ID, + }) + if err != nil { + if upload.IsErrFileTypeForbidden(err) { + log.Info("Skipping disallowed attachment type: %s", attachment.Name) + continue + } + return err + } + attachmentIDs = append(attachmentIDs, a.UUID) + } + } + + if content.Content == "" && len(attachmentIDs) == 0 { + log.Trace("incoming mail has no content and no attachment", ref) + return nil + } + + switch r := ref.(type) { + case *issues_model.Issue: + _, err = issue_service.CreateIssueComment(ctx, doer, issue.Repo, issue, content.Content, attachmentIDs) + if err != nil { + return fmt.Errorf("CreateIssueComment failed: %w", err) + } + case *issues_model.Comment: + comment := r + + switch comment.Type { + case issues_model.CommentTypeComment, issues_model.CommentTypeReview: + _, err = issue_service.CreateIssueComment(ctx, doer, issue.Repo, issue, content.Content, attachmentIDs) + if err != nil { + return fmt.Errorf("CreateIssueComment failed: %w", err) + } + case issues_model.CommentTypeCode: + _, err := pull_service.CreateCodeComment( + ctx, + doer, + nil, + issue, + comment.Line, + content.Content, + comment.TreePath, + false, // not pending review but a single review + comment.ReviewID, + "", + attachmentIDs, + ) + if err != nil { + return fmt.Errorf("CreateCodeComment failed: %w", err) + } + default: + log.Trace("incoming mail related to comment of type %v is ignored", comment.Type) + } + default: + log.Trace("incoming mail related to %T is ignored", ref) + } + return nil +} + +// UnsubscribeHandler handles unwatching issues/pulls +type UnsubscribeHandler struct{} + +func (h *UnsubscribeHandler) Handle(ctx context.Context, _ *MailContent, doer *user_model.User, payload []byte) error { + if doer == nil { + return util.NewInvalidArgumentErrorf("doer can't be nil") + } + + ref, err := incoming_payload.GetReferenceFromPayload(ctx, payload) + if err != nil { + return err + } + + switch r := ref.(type) { + case *issues_model.Issue: + issue := r + + if err := issue.LoadRepo(ctx); err != nil { + return err + } + + perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) + if err != nil { + return err + } + + if !perm.CanReadIssuesOrPulls(issue.IsPull) { + log.Debug("can't read issue or pull") + return nil + } + + return issues_model.CreateOrUpdateIssueWatch(ctx, doer.ID, issue.ID, false) + default: + return fmt.Errorf("unsupported unsubscribe reference: %v", ref) + } +} diff --git a/services/mailer/incoming/incoming_test.go b/services/mailer/incoming/incoming_test.go new file mode 100644 index 0000000..1ff12d0 --- /dev/null +++ b/services/mailer/incoming/incoming_test.go @@ -0,0 +1,191 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package incoming + +import ( + "strings" + "testing" + + "github.com/emersion/go-imap" + "github.com/jhillyerd/enmime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNotHandleTwice(t *testing.T) { + handledSet := new(imap.SeqSet) + msg := imap.NewMessage(90, []imap.FetchItem{imap.FetchBody}) + + handled := isAlreadyHandled(handledSet, msg) + assert.False(t, handled) + + handledSet.AddNum(msg.SeqNum) + + handled = isAlreadyHandled(handledSet, msg) + assert.True(t, handled) +} + +func TestIsAutomaticReply(t *testing.T) { + cases := []struct { + Headers map[string]string + Expected bool + }{ + { + Headers: map[string]string{}, + Expected: false, + }, + { + Headers: map[string]string{ + "Auto-Submitted": "no", + }, + Expected: false, + }, + { + Headers: map[string]string{ + "Auto-Submitted": "yes", + }, + Expected: true, + }, + { + Headers: map[string]string{ + "X-Autoreply": "no", + }, + Expected: false, + }, + { + Headers: map[string]string{ + "X-Autoreply": "yes", + }, + Expected: true, + }, + { + Headers: map[string]string{ + "X-Autorespond": "yes", + }, + Expected: true, + }, + } + + for _, c := range cases { + b := enmime.Builder(). + From("Dummy", "dummy@gitea.io"). + To("Dummy", "dummy@gitea.io") + for k, v := range c.Headers { + b = b.Header(k, v) + } + root, err := b.Build() + require.NoError(t, err) + env, err := enmime.EnvelopeFromPart(root) + require.NoError(t, err) + + assert.Equal(t, c.Expected, isAutomaticReply(env)) + } +} + +func TestGetContentFromMailReader(t *testing.T) { + mailString := "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + + "\r\n" + + "--message-boundary\r\n" + + "Content-Type: multipart/alternative; boundary=text-boundary\r\n" + + "\r\n" + + "--text-boundary\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Disposition: inline\r\n" + + "\r\n" + + "mail content\r\n" + + "--text-boundary--\r\n" + + "--message-boundary\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Disposition: attachment; filename=attachment.txt\r\n" + + "\r\n" + + "attachment content\r\n" + + "--message-boundary--\r\n" + + env, err := enmime.ReadEnvelope(strings.NewReader(mailString)) + require.NoError(t, err) + content := getContentFromMailReader(env) + assert.Equal(t, "mail content", content.Content) + assert.Len(t, content.Attachments, 1) + assert.Equal(t, "attachment.txt", content.Attachments[0].Name) + assert.Equal(t, []byte("attachment content"), content.Attachments[0].Content) + + mailString = "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + + "\r\n" + + "--message-boundary\r\n" + + "Content-Type: multipart/alternative; boundary=text-boundary\r\n" + + "\r\n" + + "--text-boundary\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Disposition: inline\r\n" + + "\r\n" + + "mail content\r\n" + + "--text-boundary--\r\n" + + "--message-boundary\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Disposition: inline; filename=attachment.txt\r\n" + + "\r\n" + + "attachment content\r\n" + + "--message-boundary\r\n" + + "Content-Type: text/html\r\n" + + "Content-Disposition: inline; filename=attachment.html\r\n" + + "\r\n" + + "<p>html attachment content</p>\r\n" + + "--message-boundary\r\n" + + "Content-Type: image/png\r\n" + + "Content-Disposition: inline; filename=attachment.png\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "\r\n" + + "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII\r\n" + + "--message-boundary--\r\n" + + env, err = enmime.ReadEnvelope(strings.NewReader(mailString)) + require.NoError(t, err) + content = getContentFromMailReader(env) + assert.Equal(t, "mail content\n--\nattachment content", content.Content) + assert.Len(t, content.Attachments, 2) + assert.Equal(t, "attachment.html", content.Attachments[0].Name) + assert.Equal(t, []byte("<p>html attachment content</p>"), content.Attachments[0].Content) + assert.Equal(t, "attachment.png", content.Attachments[1].Name) + + mailString = "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + + "\r\n" + + "--message-boundary\r\n" + + "Content-Type: multipart/alternative; boundary=text-boundary\r\n" + + "\r\n" + + "--text-boundary\r\n" + + "Content-Type: text/html\r\n" + + "Content-Disposition: inline\r\n" + + "\r\n" + + "<p>mail content</p>\r\n" + + "--text-boundary--\r\n" + + "--message-boundary--\r\n" + + env, err = enmime.ReadEnvelope(strings.NewReader(mailString)) + require.NoError(t, err) + content = getContentFromMailReader(env) + assert.Equal(t, "mail content", content.Content) + assert.Empty(t, content.Attachments) + + mailString = "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + + "\r\n" + + "--message-boundary\r\n" + + "Content-Type: multipart/alternative; boundary=text-boundary\r\n" + + "\r\n" + + "--text-boundary\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Disposition: inline\r\n" + + "\r\n" + + "mail content without signature\r\n" + + "----\r\n" + + "signature\r\n" + + "--text-boundary--\r\n" + + "--message-boundary--\r\n" + + env, err = enmime.ReadEnvelope(strings.NewReader(mailString)) + require.NoError(t, err) + content = getContentFromMailReader(env) + require.NoError(t, err) + assert.Equal(t, "mail content without signature", content.Content) + assert.Empty(t, content.Attachments) +} diff --git a/services/mailer/incoming/payload/payload.go b/services/mailer/incoming/payload/payload.go new file mode 100644 index 0000000..00ada78 --- /dev/null +++ b/services/mailer/incoming/payload/payload.go @@ -0,0 +1,70 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package payload + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/util" +) + +const replyPayloadVersion1 byte = 1 + +type payloadReferenceType byte + +const ( + payloadReferenceIssue payloadReferenceType = iota + payloadReferenceComment +) + +// CreateReferencePayload creates data which GetReferenceFromPayload resolves to the reference again. +func CreateReferencePayload(reference any) ([]byte, error) { + var refType payloadReferenceType + var refID int64 + + switch r := reference.(type) { + case *issues_model.Issue: + refType = payloadReferenceIssue + refID = r.ID + case *issues_model.Comment: + refType = payloadReferenceComment + refID = r.ID + default: + return nil, util.NewInvalidArgumentErrorf("unsupported reference type: %T", r) + } + + payload, err := util.PackData(refType, refID) + if err != nil { + return nil, err + } + + return append([]byte{replyPayloadVersion1}, payload...), nil +} + +// GetReferenceFromPayload resolves the reference from the payload +func GetReferenceFromPayload(ctx context.Context, payload []byte) (any, error) { + if len(payload) < 1 { + return nil, util.NewInvalidArgumentErrorf("payload to small") + } + + if payload[0] != replyPayloadVersion1 { + return nil, util.NewInvalidArgumentErrorf("unsupported payload version") + } + + var ref payloadReferenceType + var id int64 + if err := util.UnpackData(payload[1:], &ref, &id); err != nil { + return nil, err + } + + switch ref { + case payloadReferenceIssue: + return issues_model.GetIssueByID(ctx, id) + case payloadReferenceComment: + return issues_model.GetCommentByID(ctx, id) + default: + return nil, util.NewInvalidArgumentErrorf("unsupported reference type: %T", ref) + } +} diff --git a/services/mailer/mail.go b/services/mailer/mail.go new file mode 100644 index 0000000..bfede28 --- /dev/null +++ b/services/mailer/mail.go @@ -0,0 +1,751 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "context" + "fmt" + "html/template" + "mime" + "regexp" + "strconv" + "strings" + texttmpl "text/template" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + auth_model "code.gitea.io/gitea/models/auth" + 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/base" + "code.gitea.io/gitea/modules/emoji" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "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/services/mailer/token" + + "gopkg.in/gomail.v2" +) + +const ( + mailAuthActivate base.TplName = "auth/activate" + mailAuthActivateEmail base.TplName = "auth/activate_email" + mailAuthResetPassword base.TplName = "auth/reset_passwd" + mailAuthRegisterNotify base.TplName = "auth/register_notify" + mailAuthPasswordChange base.TplName = "auth/password_change" + mailAuthPrimaryMailChange base.TplName = "auth/primary_mail_change" + mailAuth2faDisabled base.TplName = "auth/2fa_disabled" + mailAuthRemovedSecurityKey base.TplName = "auth/removed_security_key" + mailAuthTOTPEnrolled base.TplName = "auth/totp_enrolled" + + mailNotifyCollaborator base.TplName = "notify/collaborator" + + mailRepoTransferNotify base.TplName = "notify/repo_transfer" + + // There's no actual limit for subject in RFC 5322 + mailMaxSubjectRunes = 256 +) + +var ( + bodyTemplates *template.Template + subjectTemplates *texttmpl.Template + subjectRemoveSpaces = regexp.MustCompile(`[\s]+`) +) + +// SendTestMail sends a test mail +func SendTestMail(email string) error { + if setting.MailService == nil { + // No mail service configured + return nil + } + return gomail.Send(Sender, NewMessage(email, "Forgejo Test Email!", "Forgejo Test Email!").ToMessage()) +} + +// sendUserMail sends a mail to the user +func sendUserMail(language string, u *user_model.User, tpl base.TplName, code, subject, info string) error { + 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 { + return err + } + + msg := NewMessage(u.EmailTo(), subject, content.String()) + msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info) + + SendAsync(msg) + return nil +} + +// SendActivateAccountMail sends an activation mail to the user (new user registration) +func SendActivateAccountMail(ctx context.Context, u *user_model.User) error { + if setting.MailService == nil { + // No mail service configured + return nil + } + + locale := translation.NewLocale(u.Language) + code, err := u.GenerateEmailAuthorizationCode(ctx, auth_model.UserActivation) + if err != nil { + return err + } + + return sendUserMail(locale.Language(), u, mailAuthActivate, code, locale.TrString("mail.activate_account"), "activate account") +} + +// SendResetPasswordMail sends a password reset mail to the user +func SendResetPasswordMail(ctx context.Context, u *user_model.User) error { + if setting.MailService == nil { + // No mail service configured + return nil + } + + locale := translation.NewLocale(u.Language) + code, err := u.GenerateEmailAuthorizationCode(ctx, auth_model.PasswordReset) + if err != nil { + return err + } + + return sendUserMail(u.Language, u, mailAuthResetPassword, code, locale.TrString("mail.reset_password"), "recover account") +} + +// SendActivateEmailMail sends confirmation email to confirm new email address +func SendActivateEmailMail(ctx context.Context, u *user_model.User, email string) error { + if setting.MailService == nil { + // No mail service configured + return nil + } + + locale := translation.NewLocale(u.Language) + code, err := u.GenerateEmailAuthorizationCode(ctx, auth_model.EmailActivation(email)) + if err != nil { + return err + } + + data := map[string]any{ + "locale": locale, + "DisplayName": u.DisplayName(), + "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), + "Code": code, + "Email": email, + "Language": locale.Language(), + } + + var content bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil { + return err + } + + msg := NewMessage(email, locale.TrString("mail.activate_email"), content.String()) + msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID) + + SendAsync(msg) + return nil +} + +// 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 := 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 := 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) ([]*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 + body, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + Links: markup.Links{ + AbsolutePrefix: true, + Base: ctx.Issue.Repo.HTMLURL(), + }, + Metas: ctx.Issue.Repo.ComposeMetas(ctx), + }, ctx.Content) + if err != nil { + return nil, err + } + + actType, actName, tplName := actionToTemplate(ctx.Issue, ctx.ActionType, commentType, reviewType) + + if actName != "new" { + prefix = "Re: " + } + fallback = prefix + fallbackMailSubject(ctx.Issue) + + 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...) + } + } + } + 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, + } + + 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) + } + + 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) + } + + // Make sure to compose independent messages to avoid leaking user emails + msgID := createReference(ctx.Issue, ctx.Comment, ctx.ActionType) + reference := createReference(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) + } + if err != nil { + return nil, err + } + + unsubscribePayload, err := incoming_payload.CreateReferencePayload(ctx.Issue) + if err != nil { + return nil, err + } + + msgs := make([]*Message, 0, len(recipients)) + for _, recipient := range recipients { + msg := 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) + } + + return msgs, nil +} + +func createReference(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 createMessageIDForRelease(rel *repo_model.Release) string { + return fmt.Sprintf("<%s/releases/%d@%s>", rel.Repo.FullName(), rel.ID, setting.Domain) +} + +func generateAdditionalHeaders(ctx *mailCommentContext, reason string, recipient *user_model.User) map[string]string { + repo := ctx.Issue.Repo + + 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), + + // https://datatracker.ietf.org/doc/html/rfc2369 + "List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()), + + "X-Mailer": "Forgejo", + "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-Forgejo-Reason": reason, + "X-Forgejo-Sender": ctx.Doer.Name, + "X-Forgejo-Recipient": recipient.Name, + "X-Forgejo-Recipient-Address": recipient.Email, + "X-Forgejo-Repository": repo.Name, + "X-Forgejo-Repository-Path": repo.FullName(), + "X-Forgejo-Repository-Link": repo.HTMLURL(), + "X-Forgejo-Issue-ID": strconv.FormatInt(ctx.Issue.Index, 10), + "X-Forgejo-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-GitLab-NotificationReason": reason, + "X-GitLab-Project": repo.Name, + "X-GitLab-Project-Path": repo.FullName(), + "X-GitLab-Issue-IID": strconv.FormatInt(ctx.Issue.Index, 10), + } +} + +func sanitizeSubject(subject string) string { + runes := []rune(strings.TrimSpace(subjectRemoveSpaces.ReplaceAllLiteralString(subject, " "))) + if len(runes) > mailMaxSubjectRunes { + runes = runes[:mailMaxSubjectRunes] + } + // 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" // TODO: there is no activities_model.Action* when sending a review comment, this is deadcode and should be removed + } + 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" + } + 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) + } + return u.GetCompleteName() +} + +// SendPasswordChange informs the user on their primary email address that +// their password was changed. +func SendPasswordChange(u *user_model.User) error { + if setting.MailService == nil { + return nil + } + 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(mailAuthPasswordChange), data); err != nil { + return err + } + + msg := NewMessage(u.EmailTo(), locale.TrString("mail.password_change.subject"), content.String()) + msg.Info = fmt.Sprintf("UID: %d, password change notification", u.ID) + + SendAsync(msg) + return nil +} + +// SendPrimaryMailChange informs the user on their old primary email address +// that it's no longer used as primary mail and will no longer receive +// notification on that email address. +func SendPrimaryMailChange(u *user_model.User, oldPrimaryEmail string) error { + if setting.MailService == nil { + return nil + } + locale := translation.NewLocale(u.Language) + + data := map[string]any{ + "locale": locale, + "NewPrimaryMail": u.Email, + "DisplayName": u.DisplayName(), + "Username": u.Name, + "Language": locale.Language(), + } + + var content bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthPrimaryMailChange), data); err != nil { + return err + } + + msg := NewMessage(u.EmailTo(oldPrimaryEmail), locale.TrString("mail.primary_mail_change.subject"), content.String()) + msg.Info = fmt.Sprintf("UID: %d, primary email change notification", u.ID) + + SendAsync(msg) + return nil +} + +// SendDisabledTOTP informs the user that their totp has been disabled. +func SendDisabledTOTP(ctx context.Context, u *user_model.User) error { + if setting.MailService == nil { + return nil + } + locale := translation.NewLocale(u.Language) + + hasWebAuthn, err := auth_model.HasWebAuthnRegistrationsByUID(ctx, u.ID) + if err != nil { + return err + } + + data := map[string]any{ + "locale": locale, + "HasWebAuthn": hasWebAuthn, + "DisplayName": u.DisplayName(), + "Username": u.Name, + "Language": locale.Language(), + } + + var content bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuth2faDisabled), data); err != nil { + return err + } + + msg := NewMessage(u.EmailTo(), locale.TrString("mail.totp_disabled.subject"), content.String()) + msg.Info = fmt.Sprintf("UID: %d, 2fa disabled notification", u.ID) + + SendAsync(msg) + return nil +} + +// SendRemovedWebAuthn informs the user that one of their security keys has been removed. +func SendRemovedSecurityKey(ctx context.Context, u *user_model.User, securityKeyName string) error { + if setting.MailService == nil { + return nil + } + locale := translation.NewLocale(u.Language) + + hasWebAuthn, err := auth_model.HasWebAuthnRegistrationsByUID(ctx, u.ID) + if err != nil { + return err + } + hasTOTP, err := auth_model.HasTwoFactorByUID(ctx, u.ID) + if err != nil { + return err + } + + data := map[string]any{ + "locale": locale, + "HasWebAuthn": hasWebAuthn, + "HasTOTP": hasTOTP, + "SecurityKeyName": securityKeyName, + "DisplayName": u.DisplayName(), + "Username": u.Name, + "Language": locale.Language(), + } + + var content bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthRemovedSecurityKey), data); err != nil { + return err + } + + msg := NewMessage(u.EmailTo(), locale.TrString("mail.removed_security_key.subject"), content.String()) + msg.Info = fmt.Sprintf("UID: %d, security key removed notification", u.ID) + + SendAsync(msg) + return nil +} + +// SendTOTPEnrolled informs the user that they've been enrolled into TOTP. +func SendTOTPEnrolled(ctx context.Context, u *user_model.User) error { + if setting.MailService == nil { + return nil + } + locale := translation.NewLocale(u.Language) + + hasWebAuthn, err := auth_model.HasWebAuthnRegistrationsByUID(ctx, u.ID) + if err != nil { + return err + } + + data := map[string]any{ + "locale": locale, + "HasWebAuthn": hasWebAuthn, + "DisplayName": u.DisplayName(), + "Username": u.Name, + "Language": locale.Language(), + } + + var content bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&content, string(mailAuthTOTPEnrolled), data); err != nil { + return err + } + + msg := NewMessage(u.EmailTo(), locale.TrString("mail.totp_enrolled.subject"), content.String()) + msg.Info = fmt.Sprintf("UID: %d, enrolled into TOTP notification", u.ID) + + SendAsync(msg) + return nil +} diff --git a/services/mailer/mail_admin_new_user.go b/services/mailer/mail_admin_new_user.go new file mode 100644 index 0000000..0713de8 --- /dev/null +++ b/services/mailer/mail_admin_new_user.go @@ -0,0 +1,78 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package mailer + +import ( + "bytes" + "context" + "strconv" + + 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/templates" + "code.gitea.io/gitea/modules/translation" +) + +const ( + tplNewUserMail base.TplName = "notify/admin_new_user" +) + +// MailNewUser sends notification emails on new user registrations to all admins +func MailNewUser(ctx context.Context, u *user_model.User) { + if !setting.Admin.SendNotificationEmailOnNewUser { + return + } + if setting.MailService == nil { + // No mail service configured + return + } + + recipients, err := user_model.GetAllAdmins(ctx) + if err != nil { + log.Error("user_model.GetAllAdmins: %v", err) + return + } + + langMap := make(map[string][]string) + for _, r := range recipients { + langMap[r.Language] = append(langMap[r.Language], r.Email) + } + + for lang, tos := range langMap { + mailNewUser(ctx, u, lang, tos) + } +} + +func mailNewUser(_ context.Context, u *user_model.User, lang string, tos []string) { + locale := translation.NewLocale(lang) + + manageUserURL := setting.AppURL + "admin/users/" + strconv.FormatInt(u.ID, 10) + subject := locale.TrString("mail.admin.new_user.subject", u.Name) + body := locale.TrString("mail.admin.new_user.text", manageUserURL) + mailMeta := map[string]any{ + "NewUser": u, + "NewUserUrl": u.HTMLURL(), + "Subject": subject, + "Body": body, + "Language": locale.Language(), + "Locale": locale, + "SanitizeHTML": templates.SanitizeHTML, + } + + var mailBody bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplNewUserMail), mailMeta); err != nil { + log.Error("ExecuteTemplate [%s]: %v", string(tplNewUserMail)+"/body", err) + return + } + + msgs := make([]*Message, 0, len(tos)) + for _, to := range tos { + msg := NewMessage(to, subject, mailBody.String()) + msg.Info = subject + msgs = append(msgs, msg) + } + SendAsync(msgs...) +} diff --git a/services/mailer/mail_admin_new_user_test.go b/services/mailer/mail_admin_new_user_test.go new file mode 100644 index 0000000..f7f2783 --- /dev/null +++ b/services/mailer/mail_admin_new_user_test.go @@ -0,0 +1,79 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "context" + "strconv" + "testing" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getTestUsers(t *testing.T) []*user_model.User { + t.Helper() + admin := new(user_model.User) + admin.Name = "testadmin" + admin.IsAdmin = true + admin.Language = "en_US" + admin.Email = "admin@example.com" + require.NoError(t, user_model.CreateUser(db.DefaultContext, admin)) + + newUser := new(user_model.User) + newUser.Name = "new_user" + newUser.Language = "en_US" + newUser.IsAdmin = false + newUser.Email = "new_user@example.com" + newUser.LastLoginUnix = 1693648327 + newUser.CreatedUnix = 1693648027 + require.NoError(t, user_model.CreateUser(db.DefaultContext, newUser)) + + return []*user_model.User{admin, newUser} +} + +func cleanUpUsers(ctx context.Context, users []*user_model.User) { + for _, u := range users { + db.DeleteByID[user_model.User](ctx, u.ID) + } +} + +func TestAdminNotificationMail_test(t *testing.T) { + ctx := context.Background() + + users := getTestUsers(t) + + t.Run("SendNotificationEmailOnNewUser_true", func(t *testing.T) { + defer test.MockVariableValue(&setting.Admin.SendNotificationEmailOnNewUser, true)() + + called := false + defer MockMailSettings(func(msgs ...*Message) { + assert.Len(t, msgs, 1, "Test provides only one admin user, so only one email must be sent") + assert.Equal(t, msgs[0].To, users[0].Email, "checks if the recipient is the admin of the instance") + manageUserURL := setting.AppURL + "admin/users/" + strconv.FormatInt(users[1].ID, 10) + assert.Contains(t, msgs[0].Body, manageUserURL) + assert.Contains(t, msgs[0].Body, users[1].HTMLURL()) + assert.Contains(t, msgs[0].Body, users[1].Name, "user name of the newly created user") + AssertTranslatedLocale(t, msgs[0].Body, "mail.admin", "admin.users") + called = true + })() + MailNewUser(ctx, users[1]) + assert.True(t, called) + }) + + t.Run("SendNotificationEmailOnNewUser_false", func(t *testing.T) { + defer test.MockVariableValue(&setting.Admin.SendNotificationEmailOnNewUser, false)() + defer MockMailSettings(func(msgs ...*Message) { + assert.Equal(t, 1, 0, "this shouldn't execute. MailNewUser must exit early since SEND_NOTIFICATION_EMAIL_ON_NEW_USER is disabled") + })() + MailNewUser(ctx, users[1]) + }) + + cleanUpUsers(ctx, users) +} diff --git a/services/mailer/mail_auth_test.go b/services/mailer/mail_auth_test.go new file mode 100644 index 0000000..38e3721 --- /dev/null +++ b/services/mailer/mail_auth_test.go @@ -0,0 +1,62 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/services/mailer" + user_service "code.gitea.io/gitea/services/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPasswordChangeMail(t *testing.T) { + defer require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + called := false + defer mailer.MockMailSettings(func(msgs ...*mailer.Message) { + assert.Len(t, msgs, 1) + assert.Equal(t, user.EmailTo(), msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.password_change.subject"), msgs[0].Subject) + mailer.AssertTranslatedLocale(t, msgs[0].Body, "mail.password_change.text_1", "mail.password_change.text_2", "mail.password_change.text_3") + called = true + })() + + require.NoError(t, user_service.UpdateAuth(db.DefaultContext, user, &user_service.UpdateAuthOptions{Password: optional.Some("NewPasswordYolo!")})) + assert.True(t, called) +} + +func TestPrimaryMailChange(t *testing.T) { + defer require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + firstEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 3, UID: user.ID, IsPrimary: true}) + secondEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}, "is_primary = false") + + called := false + defer mailer.MockMailSettings(func(msgs ...*mailer.Message) { + assert.False(t, called) + assert.Len(t, msgs, 1) + assert.Equal(t, user.EmailTo(firstEmail.Email), msgs[0].To) + assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.primary_mail_change.subject"), msgs[0].Subject) + assert.Contains(t, msgs[0].Body, secondEmail.Email) + assert.Contains(t, msgs[0].Body, setting.AppURL) + mailer.AssertTranslatedLocale(t, msgs[0].Body, "mail.primary_mail_change.text_1", "mail.primary_mail_change.text_2", "mail.primary_mail_change.text_3") + called = true + })() + + require.NoError(t, user_service.MakeEmailAddressPrimary(db.DefaultContext, user, secondEmail, true)) + assert.True(t, called) + + require.NoError(t, user_service.MakeEmailAddressPrimary(db.DefaultContext, user, firstEmail, false)) +} diff --git a/services/mailer/mail_comment.go b/services/mailer/mail_comment.go new file mode 100644 index 0000000..1812441 --- /dev/null +++ b/services/mailer/mail_comment.go @@ -0,0 +1,63 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "context" + + activities_model "code.gitea.io/gitea/models/activities" + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +// MailParticipantsComment sends new comment emails to repository watchers and mentioned people. +func MailParticipantsComment(ctx context.Context, c *issues_model.Comment, opType activities_model.ActionType, issue *issues_model.Issue, mentions []*user_model.User) error { + if setting.MailService == nil { + // No mail service configured + return nil + } + + content := c.Content + if c.Type == issues_model.CommentTypePullRequestPush { + content = "" + } + if err := mailIssueCommentToParticipants( + &mailCommentContext{ + Context: ctx, + Issue: issue, + Doer: c.Poster, + ActionType: opType, + Content: content, + Comment: c, + }, mentions); err != nil { + log.Error("mailIssueCommentToParticipants: %v", err) + } + return nil +} + +// MailMentionsComment sends email to users mentioned in a code comment +func MailMentionsComment(ctx context.Context, pr *issues_model.PullRequest, c *issues_model.Comment, mentions []*user_model.User) (err error) { + if setting.MailService == nil { + // No mail service configured + return nil + } + + visited := make(container.Set[int64], len(mentions)+1) + visited.Add(c.Poster.ID) + if err = mailIssueCommentBatch( + &mailCommentContext{ + Context: ctx, + Issue: pr.Issue, + Doer: c.Poster, + ActionType: activities_model.ActionCommentPull, + Content: c.Content, + Comment: c, + }, mentions, visited, true); err != nil { + log.Error("mailIssueCommentBatch: %v", err) + } + return nil +} diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go new file mode 100644 index 0000000..fab3315 --- /dev/null +++ b/services/mailer/mail_issue.go @@ -0,0 +1,201 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "context" + "fmt" + + activities_model "code.gitea.io/gitea/models/activities" + issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" + "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 +) + +// 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 { + // Required by the mail composer; make sure to load these before calling the async function + if err := ctx.Issue.LoadRepo(ctx); err != nil { + return fmt.Errorf("LoadRepo: %w", err) + } + if err := ctx.Issue.LoadPoster(ctx); err != nil { + return fmt.Errorf("LoadPoster: %w", err) + } + if err := ctx.Issue.LoadPullRequest(ctx); err != nil { + return fmt.Errorf("LoadPullRequest: %w", err) + } + + // Enough room to avoid reallocations + unfiltered := make([]int64, 1, 64) + + // =========== Original poster =========== + unfiltered[0] = ctx.Issue.PosterID + + // =========== Assignees =========== + ids, err := issues_model.GetAssigneeIDsByIssue(ctx, ctx.Issue.ID) + if err != nil { + return fmt.Errorf("GetAssigneeIDsByIssue(%d): %w", ctx.Issue.ID, err) + } + unfiltered = append(unfiltered, ids...) + + // =========== Participants (i.e. commenters, reviewers) =========== + ids, err = issues_model.GetParticipantsIDsByIssueID(ctx, ctx.Issue.ID) + if err != nil { + return fmt.Errorf("GetParticipantsIDsByIssueID(%d): %w", ctx.Issue.ID, err) + } + unfiltered = append(unfiltered, ids...) + + // =========== Issue watchers =========== + ids, err = issues_model.GetIssueWatchersIDs(ctx, ctx.Issue.ID, true) + if err != nil { + return fmt.Errorf("GetIssueWatchersIDs(%d): %w", ctx.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 err != nil { + return fmt.Errorf("GetRepoWatchersIDs(%d): %w", ctx.Issue.RepoID, err) + } + unfiltered = append(ids, unfiltered...) + } + + 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) + } + + // =========== Mentions =========== + if err = mailIssueCommentBatch(ctx, 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) + if err != nil { + return fmt.Errorf("GetIssueWatchersIDs(%d): %w", ctx.Issue.ID, err) + } + visited.AddMultiple(ids...) + + unfilteredUsers, err := user_model.GetMaileableUsersByIDs(ctx, unfiltered, false) + if err != nil { + return err + } + if err = mailIssueCommentBatch(ctx, 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 { + checkUnit := unit.TypeIssues + if ctx.Issue.IsPull { + checkUnit = unit.TypePullRequests + } + + langMap := make(map[string][]*user_model.User) + for _, user := range users { + if !user.IsActive { + // Exclude deactivated users + continue + } + // At this point we exclude: + // user that don't have all mails enabled or users only get mail on mention and this is one ... + if !(user.EmailNotificationsPreference == user_model.EmailNotificationsEnabled || + user.EmailNotificationsPreference == user_model.EmailNotificationsAndYourOwn || + fromMention && user.EmailNotificationsPreference == user_model.EmailNotificationsOnMention) { + continue + } + + // if we have already visited this user we exclude them + if !visited.Add(user.ID) { + continue + } + + // test if this user is allowed to see the issue/pull + if !access_model.CheckRepoUnitUser(ctx, ctx.Issue.Repo, user, checkUnit) { + continue + } + + langMap[user.Language] = append(langMap[user.Language], user) + } + + for lang, receivers := range langMap { + // because we know that the len(receivers) > 0 and we don't care about the order particularly + // 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") + if err != nil { + return err + } + SendAsync(msgs...) + receivers = receivers[:i] + } + } + + return nil +} + +// MailParticipants sends new issue thread created emails to repository watchers +// and mentioned people. +func MailParticipants(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, opType activities_model.ActionType, mentions []*user_model.User) error { + if setting.MailService == nil { + // No mail service configured + return nil + } + + content := issue.Content + if opType == activities_model.ActionCloseIssue || opType == activities_model.ActionClosePullRequest || + opType == activities_model.ActionReopenIssue || opType == activities_model.ActionReopenPullRequest || + opType == activities_model.ActionMergePullRequest || opType == activities_model.ActionAutoMergePullRequest { + content = "" + } + forceDoerNotification := opType == activities_model.ActionAutoMergePullRequest + if err := mailIssueCommentToParticipants( + &mailCommentContext{ + Context: ctx, + Issue: issue, + Doer: doer, + ActionType: opType, + Content: content, + Comment: nil, + ForceDoerNotification: forceDoerNotification, + }, mentions); err != nil { + log.Error("mailIssueCommentToParticipants: %v", err) + } + return nil +} diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go new file mode 100644 index 0000000..0b8b97e --- /dev/null +++ b/services/mailer/mail_release.go @@ -0,0 +1,98 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "context" + + 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/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/translation" +) + +const ( + tplNewReleaseMail base.TplName = "release" +) + +// MailNewRelease send new release notify to all repo watchers. +func MailNewRelease(ctx context.Context, rel *repo_model.Release) { + if setting.MailService == nil { + // No mail service configured + return + } + + watcherIDList, err := repo_model.GetRepoWatchersIDs(ctx, rel.RepoID) + if err != nil { + log.Error("GetRepoWatchersIDs(%d): %v", rel.RepoID, err) + return + } + + recipients, err := user_model.GetMaileableUsersByIDs(ctx, watcherIDList, false) + if err != nil { + log.Error("user_model.GetMaileableUsersByIDs: %v", err) + return + } + + langMap := make(map[string][]*user_model.User) + for _, user := range recipients { + if user.ID != rel.PublisherID { + langMap[user.Language] = append(langMap[user.Language], user) + } + } + + for lang, tos := range langMap { + mailNewRelease(ctx, lang, tos, rel) + } +} + +func mailNewRelease(ctx context.Context, lang string, tos []*user_model.User, rel *repo_model.Release) { + locale := translation.NewLocale(lang) + + var err error + rel.RenderedNote, err = markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + Links: markup.Links{ + Base: rel.Repo.HTMLURL(), + }, + Metas: rel.Repo.ComposeMetas(ctx), + }, rel.Note) + if err != nil { + log.Error("markdown.RenderString(%d): %v", rel.RepoID, err) + return + } + + subject := locale.TrString("mail.release.new.subject", rel.TagName, rel.Repo.FullName()) + mailMeta := map[string]any{ + "locale": locale, + "Release": rel, + "Subject": subject, + "Language": locale.Language(), + "Link": rel.HTMLURL(), + } + + var mailBody bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil { + log.Error("ExecuteTemplate [%s]: %v", string(tplNewReleaseMail)+"/body", err) + return + } + + msgs := make([]*Message, 0, len(tos)) + publisherName := fromDisplayName(rel.Publisher) + msgID := createMessageIDForRelease(rel) + for _, to := range tos { + msg := NewMessageFrom(to.EmailTo(), publisherName, setting.MailService.FromEmail, subject, mailBody.String()) + msg.Info = subject + msg.SetHeader("Message-ID", msgID) + msgs = append(msgs, msg) + } + + SendAsync(msgs...) +} diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go new file mode 100644 index 0000000..7003584 --- /dev/null +++ b/services/mailer/mail_repo.go @@ -0,0 +1,89 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "context" + "fmt" + + "code.gitea.io/gitea/models/organization" + 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/translation" +) + +// 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 { + // No mail service configured + return nil + } + + if newOwner.IsOrganization() { + users, err := organization.GetUsersWhoCanCreateOrgRepo(ctx, newOwner.ID) + if err != nil { + return err + } + + langMap := make(map[string][]*user_model.User) + for _, user := range users { + if !user.IsActive { + // don't send emails to inactive users + continue + } + langMap[user.Language] = append(langMap[user.Language], user) + } + + for lang, tos := range langMap { + if err := sendRepoTransferNotifyMailPerLang(lang, newOwner, doer, tos, repo); err != nil { + return err + } + } + + return nil + } + + return sendRepoTransferNotifyMailPerLang(newOwner.Language, newOwner, doer, []*user_model.User{newOwner}, repo) +} + +// sendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created for each language +func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.User, emailTos []*user_model.User, repo *repo_model.Repository) error { + var ( + locale = translation.NewLocale(lang) + content bytes.Buffer + ) + + destination := locale.TrString("mail.repo.transfer.to_you") + subject := locale.TrString("mail.repo.transfer.subject_to_you", doer.DisplayName(), repo.FullName()) + if newOwner.IsOrganization() { + destination = newOwner.DisplayName() + subject = locale.TrString("mail.repo.transfer.subject_to", doer.DisplayName(), repo.FullName(), destination) + } + + data := map[string]any{ + "locale": locale, + "Doer": doer, + "User": repo.Owner, + "Repo": repo.FullName(), + "Link": repo.HTMLURL(), + "Subject": subject, + "Language": locale.Language(), + "Destination": destination, + } + + if err := bodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil { + return err + } + + for _, to := range emailTos { + msg := NewMessageFrom(to.EmailTo(), fromDisplayName(doer), setting.MailService.FromEmail, subject, content.String()) + msg.Info = fmt.Sprintf("UID: %d, repository pending transfer notification", newOwner.ID) + + SendAsync(msg) + } + + return nil +} diff --git a/services/mailer/mail_team_invite.go b/services/mailer/mail_team_invite.go new file mode 100644 index 0000000..ceecefa --- /dev/null +++ b/services/mailer/mail_team_invite.go @@ -0,0 +1,76 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "context" + "fmt" + "net/url" + + org_model "code.gitea.io/gitea/models/organization" + 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" +) + +const ( + tplTeamInviteMail base.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 { + if setting.MailService == nil { + return nil + } + + org, err := user_model.GetUserByID(ctx, team.OrgID) + if err != nil { + return err + } + + locale := translation.NewLocale(inviter.Language) + + // check if a user with this email already exists + user, err := user_model.GetUserByEmail(ctx, invite.Email) + 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") + } + + inviteRedirect := url.QueryEscape(fmt.Sprintf("/org/invite/%s", 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 { + // user account exists or registration disabled + inviteURL = fmt.Sprintf("%suser/login?redirect_to=%s", setting.AppURL, inviteRedirect) + } + + subject := locale.TrString("mail.team_invite.subject", inviter.DisplayName(), org.DisplayName()) + mailMeta := map[string]any{ + "locale": locale, + "Inviter": inviter, + "Organization": org, + "Team": team, + "Invite": invite, + "Subject": subject, + "InviteURL": inviteURL, + } + + var mailBody bytes.Buffer + if err := bodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil { + log.Error("ExecuteTemplate [%s]: %v", string(tplTeamInviteMail)+"/body", err) + return err + } + + msg := NewMessage(invite.Email, subject, mailBody.String()) + msg.Info = subject + + SendAsync(msg) + + return nil +} diff --git a/services/mailer/mail_test.go b/services/mailer/mail_test.go new file mode 100644 index 0000000..1a9bbc9 --- /dev/null +++ b/services/mailer/mail_test.go @@ -0,0 +1,540 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "context" + "fmt" + "html/template" + "io" + "mime/quotedprintable" + "regexp" + "strings" + "testing" + texttmpl "text/template" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + 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/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const subjectTpl = ` +{{.SubjectPrefix}}[{{.Repo}}] @{{.Doer.Name}} #{{.Issue.Index}} - {{.Issue.Title}} +` + +const bodyTpl = ` +<!DOCTYPE html> +<html> +<head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> +</head> + +<body> + <p>{{.Body}}</p> + <p> + --- + <br> + <a href="{{.Link}}">View it on Gitea</a>. + </p> +</body> +</html> +` + +func prepareMailerTest(t *testing.T) (doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment) { + require.NoError(t, unittest.PrepareTestDatabase()) + + 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}) + require.NoError(t, issue.LoadRepo(db.DefaultContext)) + comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2, Issue: issue}) + return doer, repo, issue, comment +} + +func TestComposeIssueCommentMessage(t *testing.T) { + defer MockMailSettings(nil)() + doer, _, issue, comment := prepareMailerTest(t) + + markup.Init(&markup.ProcessorHelper{ + IsUsernameMentionable: func(ctx context.Context, username string) bool { + return username == doer.Name + }, + }) + + defer test.MockVariableValue(&setting.IncomingEmail.Enabled, true)() + + subjectTemplates = texttmpl.Must(texttmpl.New("issue/comment").Parse(subjectTpl)) + bodyTemplates = template.Must(template.New("issue/comment").Parse(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, + Content: fmt.Sprintf("test @%s %s#%d body", doer.Name, issue.Repo.FullName(), issue.Index), + Comment: comment, + }, "en-US", recipients, false, "issue comment") + require.NoError(t, err) + assert.Len(t, msgs, 2) + gomailMsg := msgs[0].ToMessage() + replyTo := gomailMsg.GetHeader("Reply-To")[0] + subject := gomailMsg.GetHeader("Subject")[0] + + assert.Len(t, gomailMsg.GetHeader("To"), 1, "exactly one recipient is expected in the To field") + tokenRegex := regexp.MustCompile(`\Aincoming\+(.+)@localhost\z`) + assert.Regexp(t, tokenRegex, replyTo) + token := tokenRegex.FindAllStringSubmatch(replyTo, 1)[0][1] + assert.Equal(t, "Re: ", subject[:4], "Comment reply subject should contain Re:") + assert.Equal(t, "Re: [user2/repo1] @user2 #1 - issue1", subject) + assert.Equal(t, "<user2/repo1/issues/1@localhost>", gomailMsg.GetHeader("In-Reply-To")[0], "In-Reply-To header doesn't match") + assert.ElementsMatch(t, []string{"<user2/repo1/issues/1@localhost>", "<reply-" + token + "@localhost>"}, gomailMsg.GetHeader("References"), "References header doesn't match") + assert.Equal(t, "<user2/repo1/issues/1/comment/2@localhost>", gomailMsg.GetHeader("Message-ID")[0], "Message-ID header doesn't match") + assert.Equal(t, "<mailto:"+replyTo+">", gomailMsg.GetHeader("List-Post")[0]) + assert.Len(t, gomailMsg.GetHeader("List-Unsubscribe"), 2) // url + mailto + + var buf bytes.Buffer + gomailMsg.WriteTo(&buf) + + b, err := io.ReadAll(quotedprintable.NewReader(&buf)) + require.NoError(t, err) + + // text/plain + assert.Contains(t, string(b), fmt.Sprintf(`( %s )`, doer.HTMLURL())) + assert.Contains(t, string(b), fmt.Sprintf(`( %s )`, issue.HTMLURL())) + + // text/html + assert.Contains(t, string(b), fmt.Sprintf(`href="%s"`, doer.HTMLURL())) + assert.Contains(t, string(b), fmt.Sprintf(`href="%s"`, issue.HTMLURL())) +} + +func TestComposeIssueMessage(t *testing.T) { + defer MockMailSettings(nil)() + doer, _, issue, _ := prepareMailerTest(t) + + 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, + Content: "test body", + }, "en-US", recipients, false, "issue create") + require.NoError(t, err) + assert.Len(t, msgs, 2) + + gomailMsg := msgs[0].ToMessage() + mailto := gomailMsg.GetHeader("To") + subject := gomailMsg.GetHeader("Subject") + messageID := gomailMsg.GetHeader("Message-ID") + inReplyTo := gomailMsg.GetHeader("In-Reply-To") + references := gomailMsg.GetHeader("References") + + assert.Len(t, mailto, 1, "exactly one recipient is expected in the To field") + assert.Equal(t, "[user2/repo1] issue1 (#1)", subject[0]) + assert.Equal(t, "<user2/repo1/issues/1@localhost>", inReplyTo[0], "In-Reply-To header doesn't match") + assert.Equal(t, "<user2/repo1/issues/1@localhost>", references[0], "References header doesn't match") + assert.Equal(t, "<user2/repo1/issues/1@localhost>", messageID[0], "Message-ID header doesn't match") + assert.Empty(t, gomailMsg.GetHeader("List-Post")) // incoming mail feature disabled + assert.Len(t, gomailMsg.GetHeader("List-Unsubscribe"), 1) // url without mailto +} + +func TestMailerIssueTemplate(t *testing.T) { + defer MockMailSettings(nil)() + require.NoError(t, unittest.PrepareTestDatabase()) + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + expect := func(t *testing.T, msg *Message, issue *issues_model.Issue, expected ...string) { + subject := msg.ToMessage().GetHeader("Subject") + msgbuf := new(bytes.Buffer) + _, _ = msg.ToMessage().WriteTo(msgbuf) + wholemsg := msgbuf.String() + assert.Contains(t, subject[0], fallbackMailSubject(issue)) + for _, s := range expected { + assert.Contains(t, wholemsg, s) + } + AssertTranslatedLocale(t, wholemsg, "mail.issue") + } + + testCompose := func(t *testing.T, ctx *mailCommentContext) *Message { + t.Helper() + recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}} + + ctx.Context = context.Background() + fromMention := false + msgs, err := composeIssueCommentMessages(ctx, "en-US", recipients, fromMention, "TestMailerIssueTemplate") + require.NoError(t, err) + assert.Len(t, msgs, 1) + return msgs[0] + } + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + require.NoError(t, issue.LoadRepo(db.DefaultContext)) + + msg := testCompose(t, &mailCommentContext{ + Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue, + Content: issue.Content, + }) + expect(t, msg, issue, issue.Content) + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2, Issue: issue}) + + msg = testCompose(t, &mailCommentContext{ + Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue, + Content: comment.Content, Comment: comment, + }) + expect(t, msg, issue, comment.Content) + + msg = testCompose(t, &mailCommentContext{ + Issue: issue, Doer: doer, ActionType: activities_model.ActionCloseIssue, + Content: comment.Content, Comment: comment, + }) + expect(t, msg, issue, comment.Content) + + msg = testCompose(t, &mailCommentContext{ + Issue: issue, Doer: doer, ActionType: activities_model.ActionReopenIssue, + Content: comment.Content, Comment: comment, + }) + expect(t, msg, issue, comment.Content) + + pull := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) + require.NoError(t, pull.LoadAttributes(db.DefaultContext)) + pullComment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 4, Issue: pull}) + + msg = testCompose(t, &mailCommentContext{ + Issue: pull, Doer: doer, ActionType: activities_model.ActionCommentPull, + Content: pullComment.Content, Comment: pullComment, + }) + expect(t, msg, pull, pullComment.Content) + + msg = testCompose(t, &mailCommentContext{ + Issue: pull, Doer: doer, ActionType: activities_model.ActionMergePullRequest, + Content: pullComment.Content, Comment: pullComment, + }) + expect(t, msg, pull, pullComment.Content, pull.PullRequest.BaseBranch) + + reviewComment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 9}) + require.NoError(t, reviewComment.LoadReview(db.DefaultContext)) + + approveComment := reviewComment + approveComment.Review.Type = issues_model.ReviewTypeApprove + msg = testCompose(t, &mailCommentContext{ + Issue: pull, Doer: doer, ActionType: activities_model.ActionApprovePullRequest, + Content: approveComment.Content, Comment: approveComment, + }) + expect(t, msg, pull, approveComment.Content) + + rejectComment := reviewComment + rejectComment.Review.Type = issues_model.ReviewTypeReject + msg = testCompose(t, &mailCommentContext{ + Issue: pull, Doer: doer, ActionType: activities_model.ActionRejectPullRequest, + Content: rejectComment.Content, Comment: rejectComment, + }) + expect(t, msg, pull, rejectComment.Content) +} + +func TestTemplateSelection(t *testing.T) { + defer MockMailSettings(nil)() + 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 + + 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")) + + expect := func(t *testing.T, msg *Message, expSubject, expBody string) { + subject := msg.ToMessage().GetHeader("Subject") + msgbuf := new(bytes.Buffer) + _, _ = msg.ToMessage().WriteTo(msgbuf) + wholemsg := msgbuf.String() + assert.Equal(t, []string{expSubject}, subject) + 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, + 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, + 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, + 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, + Content: "test body", Comment: comment, + }, recipients, false, "TestTemplateSelection") + expect(t, msg, "Re: [user2/repo1] issue1 (#1)", "issue/close/body") +} + +func TestTemplateServices(t *testing.T) { + defer MockMailSettings(nil)() + doer, _, issue, comment := prepareMailerTest(t) + require.NoError(t, issue.LoadRepo(db.DefaultContext)) + + 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)) + + 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, + Content: "test body", Comment: comment, + }, recipients, fromMention, "TestTemplateServices") + + subject := msg.ToMessage().GetHeader("Subject") + msgbuf := new(bytes.Buffer) + _, _ = msg.ToMessage().WriteTo(msgbuf) + wholemsg := msgbuf.String() + + assert.Equal(t, []string{expSubject}, subject) + assert.Contains(t, wholemsg, "\r\n"+expBody+"\r\n") + } + + expect(t, issue, comment, doer, activities_model.ActionCommentIssue, false, + "{{.SubjectPrefix}}[{{.Repo}}]: @{{.Doer.Name}} commented on #{{.Issue.Index}} - {{.Issue.Title}}", + "//{{.ActionType}},{{.ActionName}},{{if .IsMention}}norender{{end}}//", + "Re: [user2/repo1]: @user2 commented on #1 - issue1", + "//issue,comment,//") + + expect(t, issue, comment, doer, activities_model.ActionCommentIssue, true, + "{{if .IsMention}}must render{{end}}", + "//subject is: {{.Subject}}//", + "must render", + "//subject is: must render//") + + expect(t, issue, comment, doer, activities_model.ActionCommentIssue, true, + "{{.FallbackSubject}}", + "//{{.SubjectPrefix}}//", + "Re: [user2/repo1] issue1 (#1)", + "//Re: //") +} + +func testComposeIssueCommentMessage(t *testing.T, ctx *mailCommentContext, recipients []*user_model.User, fromMention bool, info string) *Message { + msgs, err := composeIssueCommentMessages(ctx, "en-US", recipients, fromMention, info) + require.NoError(t, err) + assert.Len(t, msgs, 1) + return msgs[0] +} + +func TestGenerateAdditionalHeaders(t *testing.T) { + defer MockMailSettings(nil)() + doer, _, issue, _ := prepareMailerTest(t) + + ctx := &mailCommentContext{Context: context.TODO() /* TODO: use a correct context */, Issue: issue, Doer: doer} + recipient := &user_model.User{Name: "test", Email: "test@gitea.com"} + + headers := generateAdditionalHeaders(ctx, "dummy-reason", recipient) + + expected := map[string]string{ + "List-ID": "user2/repo1 <repo1.user2.localhost>", + "List-Archive": "<https://try.gitea.io/user2/repo1>", + "X-Mailer": "Forgejo", + "X-Gitea-Reason": "dummy-reason", + "X-Gitea-Sender": "user2", + "X-Gitea-Recipient": "test", + "X-Gitea-Recipient-Address": "test@gitea.com", + "X-Gitea-Repository": "repo1", + "X-Gitea-Repository-Path": "user2/repo1", + "X-Gitea-Repository-Link": "https://try.gitea.io/user2/repo1", + "X-Gitea-Issue-ID": "1", + "X-Gitea-Issue-Link": "https://try.gitea.io/user2/repo1/issues/1", + "X-Forgejo-Sender": "user2", + "X-Forgejo-Recipient": "test", + } + + for key, value := range expected { + if assert.Contains(t, headers, key) { + assert.Equal(t, value, headers[key]) + } + } +} + +func Test_createReference(t *testing.T) { + defer MockMailSettings(nil)() + _, _, issue, comment := prepareMailerTest(t) + _, _, pullIssue, _ := prepareMailerTest(t) + pullIssue.IsPull = true + + type args struct { + issue *issues_model.Issue + comment *issues_model.Comment + actionType activities_model.ActionType + } + tests := []struct { + name string + args args + prefix string + }{ + { + name: "Open Issue", + args: args{ + issue: issue, + actionType: activities_model.ActionCreateIssue, + }, + prefix: fmt.Sprintf("<%s/issues/%d@%s>", issue.Repo.FullName(), issue.Index, setting.Domain), + }, + { + name: "Open Pull", + args: args{ + issue: pullIssue, + actionType: activities_model.ActionCreatePullRequest, + }, + prefix: fmt.Sprintf("<%s/pulls/%d@%s>", issue.Repo.FullName(), issue.Index, setting.Domain), + }, + { + name: "Comment Issue", + args: args{ + issue: issue, + comment: comment, + actionType: activities_model.ActionCommentIssue, + }, + prefix: fmt.Sprintf("<%s/issues/%d/comment/%d@%s>", issue.Repo.FullName(), issue.Index, comment.ID, setting.Domain), + }, + { + name: "Comment Pull", + args: args{ + issue: pullIssue, + comment: comment, + actionType: activities_model.ActionCommentPull, + }, + prefix: fmt.Sprintf("<%s/pulls/%d/comment/%d@%s>", issue.Repo.FullName(), issue.Index, comment.ID, setting.Domain), + }, + { + name: "Close Issue", + args: args{ + issue: issue, + actionType: activities_model.ActionCloseIssue, + }, + prefix: fmt.Sprintf("<%s/issues/%d/close/", issue.Repo.FullName(), issue.Index), + }, + { + name: "Close Pull", + args: args{ + issue: pullIssue, + actionType: activities_model.ActionClosePullRequest, + }, + prefix: fmt.Sprintf("<%s/pulls/%d/close/", issue.Repo.FullName(), issue.Index), + }, + { + name: "Reopen Issue", + args: args{ + issue: issue, + actionType: activities_model.ActionReopenIssue, + }, + prefix: fmt.Sprintf("<%s/issues/%d/reopen/", issue.Repo.FullName(), issue.Index), + }, + { + name: "Reopen Pull", + args: args{ + issue: pullIssue, + actionType: activities_model.ActionReopenPullRequest, + }, + prefix: fmt.Sprintf("<%s/pulls/%d/reopen/", issue.Repo.FullName(), issue.Index), + }, + { + name: "Merge Pull", + args: args{ + issue: pullIssue, + actionType: activities_model.ActionMergePullRequest, + }, + prefix: fmt.Sprintf("<%s/pulls/%d/merge/", issue.Repo.FullName(), issue.Index), + }, + { + name: "Ready Pull", + args: args{ + issue: pullIssue, + actionType: activities_model.ActionPullRequestReadyForReview, + }, + prefix: fmt.Sprintf("<%s/pulls/%d/ready/", issue.Repo.FullName(), issue.Index), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := createReference(tt.args.issue, tt.args.comment, tt.args.actionType) + if !strings.HasPrefix(got, tt.prefix) { + t.Errorf("createReference() = %v, want %v", got, tt.prefix) + } + }) + } +} + +func TestFromDisplayName(t *testing.T) { + template, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}") + require.NoError(t, err) + setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: template} + defer func() { setting.MailService = nil }() + + tests := []struct { + userDisplayName string + fromDisplayName string + }{{ + userDisplayName: "test", + fromDisplayName: "test", + }, { + userDisplayName: "Hi Its <Mee>", + fromDisplayName: "Hi Its <Mee>", + }, { + userDisplayName: "Æsir", + fromDisplayName: "=?utf-8?q?=C3=86sir?=", + }, { + userDisplayName: "new😀user", + fromDisplayName: "=?utf-8?q?new=F0=9F=98=80user?=", + }} + + for _, tc := range tests { + 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) + }) + } + + t.Run("template with all available vars", func(t *testing.T) { + template, err = texttmpl.New("mailFrom").Parse("{{ .DisplayName }} (by {{ .AppName }} on [{{ .Domain }}])") + require.NoError(t, err) + setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: template} + oldAppName := setting.AppName + setting.AppName = "Code IT" + oldDomain := setting.Domain + setting.Domain = "code.it" + defer func() { + setting.AppName = oldAppName + setting.Domain = oldDomain + }() + + assert.EqualValues(t, "Mister X (by Code IT on [code.it])", fromDisplayName(&user_model.User{FullName: "Mister X", Name: "tmp"})) + }) +} diff --git a/services/mailer/mailer.go b/services/mailer/mailer.go new file mode 100644 index 0000000..0a723f9 --- /dev/null +++ b/services/mailer/mailer.go @@ -0,0 +1,448 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "hash/fnv" + "io" + "net" + "net/smtp" + "os" + "os/exec" + "strings" + "time" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + notify_service "code.gitea.io/gitea/services/notify" + + ntlmssp "github.com/Azure/go-ntlmssp" + "github.com/jaytaylor/html2text" + "gopkg.in/gomail.v2" +) + +// Message mail body and log info +type Message struct { + Info string // Message information for log purpose. + FromAddress string + FromDisplayName string + To string // Use only one recipient to prevent leaking of addresses + ReplyTo string + Subject string + Date time.Time + Body string + Headers map[string][]string +} + +// ToMessage converts a Message to gomail.Message +func (m *Message) ToMessage() *gomail.Message { + msg := gomail.NewMessage() + msg.SetAddressHeader("From", m.FromAddress, m.FromDisplayName) + msg.SetHeader("To", m.To) + if m.ReplyTo != "" { + msg.SetHeader("Reply-To", m.ReplyTo) + } + for header := range m.Headers { + msg.SetHeader(header, m.Headers[header]...) + } + + if setting.MailService.SubjectPrefix != "" { + msg.SetHeader("Subject", setting.MailService.SubjectPrefix+" "+m.Subject) + } else { + msg.SetHeader("Subject", m.Subject) + } + msg.SetDateHeader("Date", m.Date) + msg.SetHeader("X-Auto-Response-Suppress", "All") + + plainBody, err := html2text.FromString(m.Body) + if err != nil || setting.MailService.SendAsPlainText { + if strings.Contains(base.TruncateString(m.Body, 100), "<html>") { + log.Warn("Mail contains HTML but configured to send as plain text.") + } + msg.SetBody("text/plain", plainBody) + } else { + msg.SetBody("text/plain", plainBody) + msg.AddAlternative("text/html", m.Body) + } + + if len(msg.GetHeader("Message-ID")) == 0 { + msg.SetHeader("Message-ID", m.generateAutoMessageID()) + } + + for k, v := range setting.MailService.OverrideHeader { + if len(msg.GetHeader(k)) != 0 { + log.Debug("Mailer override header '%s' as per config", k) + } + msg.SetHeader(k, v...) + } + + return msg +} + +// SetHeader adds additional headers to a message +func (m *Message) SetHeader(field string, value ...string) { + m.Headers[field] = value +} + +func (m *Message) generateAutoMessageID() string { + dateMs := m.Date.UnixNano() / 1e6 + h := fnv.New64() + if len(m.To) > 0 { + _, _ = h.Write([]byte(m.To)) + } + _, _ = h.Write([]byte(m.Subject)) + _, _ = h.Write([]byte(m.Body)) + return fmt.Sprintf("<autogen-%d-%016x@%s>", dateMs, h.Sum64(), setting.Domain) +} + +// NewMessageFrom creates new mail message object with custom From header. +func NewMessageFrom(to, fromDisplayName, fromAddress, subject, body string) *Message { + log.Trace("NewMessageFrom (body):\n%s", body) + + return &Message{ + FromAddress: fromAddress, + FromDisplayName: fromDisplayName, + To: to, + Subject: subject, + Date: time.Now(), + Body: body, + Headers: map[string][]string{}, + } +} + +// NewMessage creates new mail message object with default From header. +func NewMessage(to, subject, body string) *Message { + return NewMessageFrom(to, setting.MailService.FromName, setting.MailService.FromEmail, subject, body) +} + +type loginAuth struct { + username, password string +} + +// LoginAuth SMTP AUTH LOGIN Auth Handler +func LoginAuth(username, password string) smtp.Auth { + return &loginAuth{username, password} +} + +// Start start SMTP login auth +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte{}, nil +} + +// Next next step of SMTP login auth +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch string(fromServer) { + case "Username:": + return []byte(a.username), nil + case "Password:": + return []byte(a.password), nil + default: + return nil, fmt.Errorf("unknown fromServer: %s", string(fromServer)) + } + } + return nil, nil +} + +type ntlmAuth struct { + username, password, domain string + domainNeeded bool +} + +// NtlmAuth SMTP AUTH NTLM Auth Handler +func NtlmAuth(username, password string) smtp.Auth { + user, domain, domainNeeded := ntlmssp.GetDomain(username) + return &ntlmAuth{user, password, domain, domainNeeded} +} + +// Start starts SMTP NTLM Auth +func (a *ntlmAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + negotiateMessage, err := ntlmssp.NewNegotiateMessage(a.domain, "") + return "NTLM", negotiateMessage, err +} + +// Next next step of SMTP ntlm auth +func (a *ntlmAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + if len(fromServer) == 0 { + return nil, fmt.Errorf("ntlm ChallengeMessage is empty") + } + authenticateMessage, err := ntlmssp.ProcessChallenge(fromServer, a.username, a.password, a.domainNeeded) + return authenticateMessage, err + } + return nil, nil +} + +// Sender SMTP mail sender +type smtpSender struct{} + +// Send send email +func (s *smtpSender) Send(from string, to []string, msg io.WriterTo) error { + opts := setting.MailService + + var network string + var address string + if opts.Protocol == "smtp+unix" { + network = "unix" + address = opts.SMTPAddr + } else { + network = "tcp" + address = net.JoinHostPort(opts.SMTPAddr, opts.SMTPPort) + } + + conn, err := net.Dial(network, address) + if err != nil { + return fmt.Errorf("failed to establish network connection to SMTP server: %w", err) + } + defer conn.Close() + + var tlsconfig *tls.Config + if opts.Protocol == "smtps" || opts.Protocol == "smtp+starttls" { + tlsconfig = &tls.Config{ + InsecureSkipVerify: opts.ForceTrustServerCert, + ServerName: opts.SMTPAddr, + } + + if opts.UseClientCert { + cert, err := tls.LoadX509KeyPair(opts.ClientCertFile, opts.ClientKeyFile) + if err != nil { + return fmt.Errorf("could not load SMTP client certificate: %w", err) + } + tlsconfig.Certificates = []tls.Certificate{cert} + } + } + + if opts.Protocol == "smtps" { + conn = tls.Client(conn, tlsconfig) + } + + host := "localhost" + if opts.Protocol == "smtp+unix" { + host = opts.SMTPAddr + } + client, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("could not initiate SMTP session: %w", err) + } + + if opts.EnableHelo { + hostname := opts.HeloHostname + if len(hostname) == 0 { + hostname, err = os.Hostname() + if err != nil { + return fmt.Errorf("could not retrieve system hostname: %w", err) + } + } + + if err = client.Hello(hostname); err != nil { + return fmt.Errorf("failed to issue HELO command: %w", err) + } + } + + if opts.Protocol == "smtp+starttls" { + hasStartTLS, _ := client.Extension("STARTTLS") + if hasStartTLS { + if err = client.StartTLS(tlsconfig); err != nil { + return fmt.Errorf("failed to start TLS connection: %w", err) + } + } else { + log.Warn("StartTLS requested, but SMTP server does not support it; falling back to regular SMTP") + } + } + + canAuth, options := client.Extension("AUTH") + if len(opts.User) > 0 { + if !canAuth { + return fmt.Errorf("SMTP server does not support AUTH, but credentials provided") + } + + var auth smtp.Auth + + if strings.Contains(options, "CRAM-MD5") { + auth = smtp.CRAMMD5Auth(opts.User, opts.Passwd) + } else if strings.Contains(options, "PLAIN") { + auth = smtp.PlainAuth("", opts.User, opts.Passwd, host) + } else if strings.Contains(options, "LOGIN") { + // Patch for AUTH LOGIN + auth = LoginAuth(opts.User, opts.Passwd) + } else if strings.Contains(options, "NTLM") { + auth = NtlmAuth(opts.User, opts.Passwd) + } + + if auth != nil { + if err = client.Auth(auth); err != nil { + return fmt.Errorf("failed to authenticate SMTP: %w", err) + } + } + } + + if opts.OverrideEnvelopeFrom { + if err = client.Mail(opts.EnvelopeFrom); err != nil { + return fmt.Errorf("failed to issue MAIL command: %w", err) + } + } else { + if err = client.Mail(from); err != nil { + return fmt.Errorf("failed to issue MAIL command: %w", err) + } + } + + for _, rec := range to { + if err = client.Rcpt(rec); err != nil { + return fmt.Errorf("failed to issue RCPT command: %w", err) + } + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("failed to issue DATA command: %w", err) + } else if _, err = msg.WriteTo(w); err != nil { + return fmt.Errorf("SMTP write failed: %w", err) + } else if err = w.Close(); err != nil { + return fmt.Errorf("SMTP close failed: %w", err) + } + + return client.Quit() +} + +// Sender sendmail mail sender +type sendmailSender struct{} + +// Send send email +func (s *sendmailSender) Send(from string, to []string, msg io.WriterTo) error { + var err error + var closeError error + var waitError error + + envelopeFrom := from + if setting.MailService.OverrideEnvelopeFrom { + envelopeFrom = setting.MailService.EnvelopeFrom + } + + args := []string{"-f", envelopeFrom, "-i"} + args = append(args, setting.MailService.SendmailArgs...) + args = append(args, to...) + log.Trace("Sending with: %s %v", setting.MailService.SendmailPath, args) + + desc := fmt.Sprintf("SendMail: %s %v", setting.MailService.SendmailPath, args) + + ctx, _, finished := process.GetManager().AddContextTimeout(graceful.GetManager().HammerContext(), setting.MailService.SendmailTimeout, desc) + defer finished() + + cmd := exec.CommandContext(ctx, setting.MailService.SendmailPath, args...) + pipe, err := cmd.StdinPipe() + if err != nil { + return err + } + process.SetSysProcAttribute(cmd) + + if err = cmd.Start(); err != nil { + _ = pipe.Close() + return err + } + + if setting.MailService.SendmailConvertCRLF { + buf := &strings.Builder{} + _, err = msg.WriteTo(buf) + if err == nil { + _, err = strings.NewReplacer("\r\n", "\n").WriteString(pipe, buf.String()) + } + } else { + _, err = msg.WriteTo(pipe) + } + + // we MUST close the pipe or sendmail will hang waiting for more of the message + // Also we should wait on our sendmail command even if something fails + closeError = pipe.Close() + waitError = cmd.Wait() + if err != nil { + return err + } else if closeError != nil { + return closeError + } + return waitError +} + +type dummySender struct{} + +func (s *dummySender) Send(from string, to []string, msg io.WriterTo) error { + buf := bytes.Buffer{} + if _, err := msg.WriteTo(&buf); err != nil { + return err + } + log.Info("Mail From: %s To: %v Body: %s", from, to, buf.String()) + return nil +} + +var mailQueue *queue.WorkerPoolQueue[*Message] + +// Sender sender for sending mail synchronously +var Sender gomail.Sender + +// NewContext start mail queue service +func NewContext(ctx context.Context) { + // Need to check if mailQueue is nil because in during reinstall (user had installed + // before but switched install lock off), this function will be called again + // while mail queue is already processing tasks, and produces a race condition. + if setting.MailService == nil || mailQueue != nil { + return + } + + if setting.Service.EnableNotifyMail { + notify_service.RegisterNotifier(NewNotifier()) + } + + switch setting.MailService.Protocol { + case "sendmail": + Sender = &sendmailSender{} + case "dummy": + Sender = &dummySender{} + default: + Sender = &smtpSender{} + } + + subjectTemplates, bodyTemplates = templates.Mailer(ctx) + + mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*Message) []*Message { + for _, msg := range items { + gomailMsg := msg.ToMessage() + log.Trace("New e-mail sending request %s: %s", gomailMsg.GetHeader("To"), msg.Info) + if err := gomail.Send(Sender, gomailMsg); err != nil { + log.Error("Failed to send emails %s: %s - %v", gomailMsg.GetHeader("To"), msg.Info, err) + } else { + log.Trace("E-mails sent %s: %s", gomailMsg.GetHeader("To"), msg.Info) + } + } + return nil + }) + if mailQueue == nil { + log.Fatal("Unable to create mail queue") + } + go graceful.GetManager().RunWithCancel(mailQueue) +} + +// SendAsync send emails asynchronously (make it mockable) +var SendAsync = sendAsync + +func sendAsync(msgs ...*Message) { + if setting.MailService == nil { + log.Error("Mailer: SendAsync is being invoked but mail service hasn't been initialized") + return + } + + go func() { + for _, msg := range msgs { + _ = mailQueue.Push(msg) + } + }() +} diff --git a/services/mailer/mailer_test.go b/services/mailer/mailer_test.go new file mode 100644 index 0000000..045701f --- /dev/null +++ b/services/mailer/mailer_test.go @@ -0,0 +1,128 @@ +// Copyright 2021 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "strings" + "testing" + "time" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateMessageID(t *testing.T) { + defer test.MockVariableValue(&setting.MailService, &setting.Mailer{ + From: "test@gitea.com", + })() + defer test.MockVariableValue(&setting.Domain, "localhost")() + + date := time.Date(2000, 1, 2, 3, 4, 5, 6, time.UTC) + m := NewMessageFrom("", "display-name", "from-address", "subject", "body") + m.Date = date + gm := m.ToMessage() + assert.Equal(t, "<autogen-946782245000-41e8fc54a8ad3a3f@localhost>", gm.GetHeader("Message-ID")[0]) + + m = NewMessageFrom("a@b.com", "display-name", "from-address", "subject", "body") + m.Date = date + gm = m.ToMessage() + assert.Equal(t, "<autogen-946782245000-cc88ce3cfe9bd04f@localhost>", gm.GetHeader("Message-ID")[0]) + + m = NewMessageFrom("a@b.com", "display-name", "from-address", "subject", "body") + m.SetHeader("Message-ID", "<msg-d@domain.com>") + gm = m.ToMessage() + assert.Equal(t, "<msg-d@domain.com>", gm.GetHeader("Message-ID")[0]) +} + +func TestGenerateMessageIDForRelease(t *testing.T) { + defer test.MockVariableValue(&setting.Domain, "localhost")() + + rel := repo_model.Release{ + ID: 42, + Repo: &repo_model.Repository{ + OwnerName: "test", + Name: "tag-test", + }, + } + m := createMessageIDForRelease(&rel) + assert.Equal(t, "<test/tag-test/releases/42@localhost>", m) +} + +func TestToMessage(t *testing.T) { + defer test.MockVariableValue(&setting.MailService, &setting.Mailer{ + From: "test@gitea.com", + })() + defer test.MockVariableValue(&setting.Domain, "localhost")() + + m1 := Message{ + Info: "info", + FromAddress: "test@gitea.com", + FromDisplayName: "Test Gitea", + To: "a@b.com", + Subject: "Issue X Closed", + Body: "Some Issue got closed by Y-Man", + } + + buf := &strings.Builder{} + _, err := m1.ToMessage().WriteTo(buf) + require.NoError(t, err) + header, _ := extractMailHeaderAndContent(t, buf.String()) + assert.EqualValues(t, map[string]string{ + "Content-Type": "multipart/alternative;", + "Date": "Mon, 01 Jan 0001 00:00:00 +0000", + "From": "\"Test Gitea\" <test@gitea.com>", + "Message-ID": "<autogen--6795364578871-69c000786adc60dc@localhost>", + "Mime-Version": "1.0", + "Subject": "Issue X Closed", + "To": "a@b.com", + "X-Auto-Response-Suppress": "All", + }, header) + + setting.MailService.OverrideHeader = map[string][]string{ + "Message-ID": {""}, // delete message id + "Auto-Submitted": {"auto-generated"}, // suppress auto replay + } + + buf = &strings.Builder{} + _, err = m1.ToMessage().WriteTo(buf) + require.NoError(t, err) + header, _ = extractMailHeaderAndContent(t, buf.String()) + assert.EqualValues(t, map[string]string{ + "Content-Type": "multipart/alternative;", + "Date": "Mon, 01 Jan 0001 00:00:00 +0000", + "From": "\"Test Gitea\" <test@gitea.com>", + "Message-ID": "", + "Mime-Version": "1.0", + "Subject": "Issue X Closed", + "To": "a@b.com", + "X-Auto-Response-Suppress": "All", + "Auto-Submitted": "auto-generated", + }, header) +} + +func extractMailHeaderAndContent(t *testing.T, mail string) (map[string]string, string) { + header := make(map[string]string) + + parts := strings.SplitN(mail, "boundary=", 2) + if !assert.Len(t, parts, 2) { + return nil, "" + } + content := strings.TrimSpace("boundary=" + parts[1]) + + hParts := strings.Split(parts[0], "\n") + + for _, hPart := range hParts { + parts := strings.SplitN(hPart, ":", 2) + hk := strings.TrimSpace(parts[0]) + if hk != "" { + header[hk] = strings.TrimSpace(parts[1]) + } + } + + return header, content +} diff --git a/services/mailer/main_test.go b/services/mailer/main_test.go new file mode 100644 index 0000000..908976e --- /dev/null +++ b/services/mailer/main_test.go @@ -0,0 +1,48 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "context" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/translation" + + _ "code.gitea.io/gitea/models/actions" + + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} + +func AssertTranslatedLocale(t *testing.T, message string, prefixes ...string) { + t.Helper() + for _, prefix := range prefixes { + assert.NotContains(t, message, prefix, "there is an untranslated locale prefix") + } +} + +func MockMailSettings(send func(msgs ...*Message)) func() { + translation.InitLocales(context.Background()) + subjectTemplates, bodyTemplates = templates.Mailer(context.Background()) + mailService := setting.Mailer{ + From: "test@gitea.com", + } + cleanups := []func(){ + test.MockVariableValue(&setting.MailService, &mailService), + test.MockVariableValue(&setting.Domain, "localhost"), + test.MockVariableValue(&SendAsync, send), + } + return func() { + for _, cleanup := range cleanups { + cleanup() + } + } +} diff --git a/services/mailer/notify.go b/services/mailer/notify.go new file mode 100644 index 0000000..54ab80a --- /dev/null +++ b/services/mailer/notify.go @@ -0,0 +1,208 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package mailer + +import ( + "context" + "fmt" + + 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" + notify_service "code.gitea.io/gitea/services/notify" +) + +type mailNotifier struct { + notify_service.NullNotifier +} + +var _ notify_service.Notifier = &mailNotifier{} + +// NewNotifier create a new mailNotifier notifier +func NewNotifier() notify_service.Notifier { + return &mailNotifier{} +} + +func (m *mailNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, + issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User, +) { + var act activities_model.ActionType + if comment.Type == issues_model.CommentTypeClose { + act = activities_model.ActionCloseIssue + } else if comment.Type == issues_model.CommentTypeReopen { + act = activities_model.ActionReopenIssue + } else if comment.Type == issues_model.CommentTypeComment { + act = activities_model.ActionCommentIssue + } else if comment.Type == issues_model.CommentTypeCode { + act = activities_model.ActionCommentIssue + } else if comment.Type == issues_model.CommentTypePullRequestPush { + act = 0 + } + + if err := MailParticipantsComment(ctx, comment, act, issue, mentions); err != nil { + log.Error("MailParticipantsComment: %v", err) + } +} + +func (m *mailNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { + if err := MailParticipants(ctx, issue, issue.Poster, activities_model.ActionCreateIssue, mentions); err != nil { + log.Error("MailParticipants: %v", err) + } +} + +func (m *mailNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) { + var actionType activities_model.ActionType + if issue.IsPull { + if isClosed { + actionType = activities_model.ActionClosePullRequest + } else { + actionType = activities_model.ActionReopenPullRequest + } + } else { + if isClosed { + actionType = activities_model.ActionCloseIssue + } else { + actionType = activities_model.ActionReopenIssue + } + } + + if err := MailParticipants(ctx, issue, doer, actionType, nil); err != nil { + log.Error("MailParticipants: %v", err) + } +} + +func (m *mailNotifier) IssueChangeTitle(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldTitle string) { + if err := issue.LoadPullRequest(ctx); err != nil { + log.Error("issue.LoadPullRequest: %v", err) + return + } + if issue.IsPull && issues_model.HasWorkInProgressPrefix(oldTitle) && !issue.PullRequest.IsWorkInProgress(ctx) { + if err := MailParticipants(ctx, issue, doer, activities_model.ActionPullRequestReadyForReview, nil); err != nil { + log.Error("MailParticipants: %v", err) + } + } +} + +func (m *mailNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) { + if err := MailParticipants(ctx, pr.Issue, pr.Issue.Poster, activities_model.ActionCreatePullRequest, mentions); err != nil { + log.Error("MailParticipants: %v", err) + } +} + +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 { + act = activities_model.ActionCloseIssue + } else if comment.Type == issues_model.CommentTypeReopen { + act = activities_model.ActionReopenIssue + } else if comment.Type == issues_model.CommentTypeComment { + act = activities_model.ActionCommentPull + } + if err := MailParticipantsComment(ctx, comment, act, pr.Issue, mentions); err != nil { + log.Error("MailParticipantsComment: %v", err) + } +} + +func (m *mailNotifier) PullRequestCodeComment(ctx context.Context, pr *issues_model.PullRequest, comment *issues_model.Comment, mentions []*user_model.User) { + if err := MailMentionsComment(ctx, pr, comment, mentions); err != nil { + log.Error("MailMentionsComment: %v", err) + } +} + +func (m *mailNotifier) IssueChangeAssignee(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, assignee *user_model.User, removed bool, comment *issues_model.Comment) { + // mail only sent to added assignees and not self-assignee + if !removed && doer.ID != assignee.ID && assignee.EmailNotificationsPreference != user_model.EmailNotificationsDisabled { + ct := fmt.Sprintf("Assigned #%d.", issue.Index) + if err := SendIssueAssignedMail(ctx, issue, doer, ct, comment, []*user_model.User{assignee}); err != nil { + log.Error("Error in SendIssueAssignedMail for issue[%d] to assignee[%d]: %v", issue.ID, assignee.ID, err) + } + } +} + +func (m *mailNotifier) PullRequestReviewRequest(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, reviewer *user_model.User, isRequest bool, comment *issues_model.Comment) { + if isRequest && doer.ID != reviewer.ID && reviewer.EmailNotificationsPreference != user_model.EmailNotificationsDisabled { + ct := fmt.Sprintf("Requested to review %s.", issue.HTMLURL()) + if err := SendIssueAssignedMail(ctx, issue, doer, ct, comment, []*user_model.User{reviewer}); err != nil { + log.Error("Error in SendIssueAssignedMail for issue[%d] to reviewer[%d]: %v", issue.ID, reviewer.ID, err) + } + } +} + +func (m *mailNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + if err := pr.LoadIssue(ctx); err != nil { + log.Error("LoadIssue: %v", err) + return + } + if err := MailParticipants(ctx, pr.Issue, doer, activities_model.ActionMergePullRequest, nil); err != nil { + log.Error("MailParticipants: %v", err) + } +} + +func (m *mailNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + if err := pr.LoadIssue(ctx); err != nil { + log.Error("pr.LoadIssue: %v", err) + return + } + if err := MailParticipants(ctx, pr.Issue, doer, activities_model.ActionAutoMergePullRequest, nil); err != nil { + log.Error("MailParticipants: %v", err) + } +} + +func (m *mailNotifier) PullRequestPushCommits(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, comment *issues_model.Comment) { + var err error + if err = comment.LoadIssue(ctx); err != nil { + log.Error("comment.LoadIssue: %v", err) + return + } + if err = comment.Issue.LoadRepo(ctx); err != nil { + log.Error("comment.Issue.LoadRepo: %v", err) + return + } + if err = comment.Issue.LoadPullRequest(ctx); err != nil { + log.Error("comment.Issue.LoadPullRequest: %v", err) + return + } + if err = comment.Issue.PullRequest.LoadBaseRepo(ctx); err != nil { + log.Error("comment.Issue.PullRequest.LoadBaseRepo: %v", err) + return + } + if err := comment.LoadPushCommits(ctx); err != nil { + log.Error("comment.LoadPushCommits: %v", err) + } + m.CreateIssueComment(ctx, doer, comment.Issue.Repo, comment.Issue, comment, nil) +} + +func (m *mailNotifier) PullReviewDismiss(ctx context.Context, doer *user_model.User, review *issues_model.Review, comment *issues_model.Comment) { + if err := comment.Review.LoadReviewer(ctx); err != nil { + log.Error("Error in PullReviewDismiss while loading reviewer for issue[%d], review[%d] and reviewer[%d]: %v", review.Issue.ID, comment.Review.ID, comment.Review.ReviewerID, err) + } + if err := MailParticipantsComment(ctx, comment, activities_model.ActionPullReviewDismissed, review.Issue, nil); err != nil { + log.Error("MailParticipantsComment: %v", err) + } +} + +func (m *mailNotifier) NewRelease(ctx context.Context, rel *repo_model.Release) { + if err := rel.LoadAttributes(ctx); err != nil { + log.Error("LoadAttributes: %v", err) + return + } + + if rel.IsDraft || rel.IsPrerelease { + return + } + + MailNewRelease(ctx, rel) +} + +func (m *mailNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) { + if err := SendRepoTransferNotifyMail(ctx, doer, newOwner, repo); err != nil { + log.Error("SendRepoTransferNotifyMail: %v", err) + } +} + +func (m *mailNotifier) NewUserSignUp(ctx context.Context, newUser *user_model.User) { + MailNewUser(ctx, newUser) +} diff --git a/services/mailer/token/token.go b/services/mailer/token/token.go new file mode 100644 index 0000000..1a52bce --- /dev/null +++ b/services/mailer/token/token.go @@ -0,0 +1,138 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package token + +import ( + "context" + crypto_hmac "crypto/hmac" + "crypto/sha256" + "encoding/base32" + "fmt" + "time" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" +) + +// A token is a verifiable container describing an action. +// +// A token has a dynamic length depending on the contained data and has the following structure: +// | Token Version | User ID | HMAC | Payload | +// +// The payload is verifiable by the generated HMAC using the user secret. It contains: +// | Timestamp | Action/Handler Type | Action/Handler Data | +// +// +// Version changelog +// +// v1 -> v2: +// Use 128 instead of 80 bits of the HMAC-SHA256 output. + +const ( + tokenVersion1 byte = 1 + tokenVersion2 byte = 2 + tokenLifetimeInYears int = 1 +) + +type HandlerType byte + +const ( + UnknownHandlerType HandlerType = iota + ReplyHandlerType + UnsubscribeHandlerType +) + +var encodingWithoutPadding = base32.StdEncoding.WithPadding(base32.NoPadding) + +type ErrToken struct { + context string +} + +func (err *ErrToken) Error() string { + return "invalid email token: " + err.context +} + +func (err *ErrToken) Unwrap() error { + return util.ErrInvalidArgument +} + +// CreateToken creates a token for the action/user tuple +func CreateToken(ht HandlerType, user *user_model.User, data []byte) (string, error) { + payload, err := util.PackData( + time.Now().AddDate(tokenLifetimeInYears, 0, 0).Unix(), + ht, + data, + ) + if err != nil { + return "", err + } + + packagedData, err := util.PackData( + user.ID, + generateHmac([]byte(user.Rands), payload), + payload, + ) + if err != nil { + return "", err + } + + return encodingWithoutPadding.EncodeToString(append([]byte{tokenVersion2}, packagedData...)), nil +} + +// ExtractToken extracts the action/user tuple from the token and verifies the content +func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.User, []byte, error) { + data, err := encodingWithoutPadding.DecodeString(token) + if err != nil { + return UnknownHandlerType, nil, nil, err + } + + if len(data) < 1 { + return UnknownHandlerType, nil, nil, &ErrToken{"no data"} + } + + if data[0] != tokenVersion2 { + return UnknownHandlerType, nil, nil, &ErrToken{fmt.Sprintf("unsupported token version: %v", data[0])} + } + + var userID int64 + var hmac []byte + var payload []byte + if err := util.UnpackData(data[1:], &userID, &hmac, &payload); err != nil { + return UnknownHandlerType, nil, nil, err + } + + user, err := user_model.GetUserByID(ctx, userID) + if err != nil { + return UnknownHandlerType, nil, nil, err + } + + if !crypto_hmac.Equal(hmac, generateHmac([]byte(user.Rands), payload)) { + return UnknownHandlerType, nil, nil, &ErrToken{"verification failed"} + } + + var expiresUnix int64 + var handlerType HandlerType + var innerPayload []byte + if err := util.UnpackData(payload, &expiresUnix, &handlerType, &innerPayload); err != nil { + return UnknownHandlerType, nil, nil, err + } + + if time.Unix(expiresUnix, 0).Before(time.Now()) { + return UnknownHandlerType, nil, nil, &ErrToken{"token expired"} + } + + return handlerType, user, innerPayload, nil +} + +// generateHmac creates a trunkated HMAC for the given payload +func generateHmac(secret, payload []byte) []byte { + mac := crypto_hmac.New(sha256.New, secret) + mac.Write(payload) + hmac := mac.Sum(nil) + + // RFC2104 section 5 recommends that if you do HMAC truncation, you should use + // the max(80, hash_len/2) of the leftmost bits. + // For SHA256 this works out to using 128 of the leftmost bits. + return hmac[:16] +} |