summaryrefslogtreecommitdiffstats
path: root/services/mailer
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /services/mailer
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'services/mailer')
-rw-r--r--services/mailer/incoming/incoming.go394
-rw-r--r--services/mailer/incoming/incoming_handler.go187
-rw-r--r--services/mailer/incoming/incoming_test.go191
-rw-r--r--services/mailer/incoming/payload/payload.go70
-rw-r--r--services/mailer/mail.go751
-rw-r--r--services/mailer/mail_admin_new_user.go78
-rw-r--r--services/mailer/mail_admin_new_user_test.go79
-rw-r--r--services/mailer/mail_auth_test.go62
-rw-r--r--services/mailer/mail_comment.go63
-rw-r--r--services/mailer/mail_issue.go201
-rw-r--r--services/mailer/mail_release.go98
-rw-r--r--services/mailer/mail_repo.go89
-rw-r--r--services/mailer/mail_team_invite.go76
-rw-r--r--services/mailer/mail_test.go540
-rw-r--r--services/mailer/mailer.go448
-rw-r--r--services/mailer/mailer_test.go128
-rw-r--r--services/mailer/main_test.go48
-rw-r--r--services/mailer/notify.go208
-rw-r--r--services/mailer/token/token.go138
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]
+}