初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package incoming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
net_mail "net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/services/mailer/token"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
)
|
||||
|
||||
func Init(ctx context.Context) error {
|
||||
if !setting.IncomingEmail.Enabled {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
r := msg.GetBody(section)
|
||||
if r == nil {
|
||||
return errors.New("could not get body from message")
|
||||
}
|
||||
|
||||
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.DecodeToken(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
|
||||
}
|
||||
|
||||
// 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 != ""
|
||||
}
|
||||
|
||||
func extractToken(s, tokenPrefix, tokenSuffix string) string {
|
||||
if len(s) <= len(tokenPrefix)+len(tokenSuffix) {
|
||||
return ""
|
||||
}
|
||||
prefix, suffix := s[0:len(tokenPrefix)], s[len(s)-len(tokenSuffix):]
|
||||
if strings.EqualFold(prefix, tokenPrefix) && strings.EqualFold(suffix, tokenSuffix) {
|
||||
return s[len(tokenPrefix) : len(s)-len(tokenSuffix)]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// searchTokenInHeaders looks for the token in To, Delivered-To and References
|
||||
func searchTokenInHeaders(env *enmime.Envelope) string {
|
||||
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
|
||||
}
|
||||
t := extractToken(references[begin:end], "reply-", "@"+setting.Domain)
|
||||
if t != "" {
|
||||
return t
|
||||
}
|
||||
|
||||
references = references[end+1:]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// searchTokenInAddresses looks for the token in an address
|
||||
func searchTokenInAddresses(addresses []*net_mail.Address) string {
|
||||
tokenPrefix, tokenSuffix, _ := strings.Cut(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmailTokenPlaceholder)
|
||||
if tokenSuffix == "" {
|
||||
return ""
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if t := extractToken(address.Address, tokenPrefix, tokenSuffix); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
return &MailContent{
|
||||
Content: extractReply(env.Text),
|
||||
Attachments: attachments,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package incoming
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
attachment_service "gitea.dev/services/attachment"
|
||||
"gitea.dev/services/context/upload"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
incoming_payload "gitea.dev/services/mailer/incoming/payload"
|
||||
"gitea.dev/services/mailer/token"
|
||||
pull_service "gitea.dev/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.GetDoerRepoPermission(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
|
||||
}
|
||||
|
||||
attachmentIDs := make([]string, 0, len(content.Attachments))
|
||||
if setting.Attachment.Enabled {
|
||||
for _, attachment := range content.Attachments {
|
||||
attachmentBuf := bytes.NewReader(attachment.Content)
|
||||
uploaderFile := attachment_service.NewLimitedUploaderKnownSize(attachmentBuf, attachmentBuf.Size())
|
||||
a, err := attachment_service.UploadAttachmentForIssue(ctx, uploaderFile, &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
|
||||
}
|
||||
if errors.Is(err, util.ErrContentTooLarge) {
|
||||
log.Info("Skipping attachment exceeding size limit: %s", attachment.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
attachmentIDs = append(attachmentIDs, a.UUID)
|
||||
}
|
||||
}
|
||||
|
||||
if content.Content == "" && len(attachmentIDs) == 0 {
|
||||
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.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:
|
||||
_, err := issue_service.CreateIssueComment(ctx, doer, issue.Repo, issue, content.Content, attachmentIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("CreateIssueComment failed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
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.GetDoerRepoPermission(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)
|
||||
}
|
||||
|
||||
return fmt.Errorf("unsupported unsubscribe reference: %v", ref)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package incoming
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
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()
|
||||
assert.NoError(t, err)
|
||||
env, err := enmime.EnvelopeFromPart(root)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, c.Expected, isAutomaticReply(env))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTokenInHeadersCaseInsensitive(t *testing.T) {
|
||||
setting.IncomingEmail.ReplyToAddress = "InComing+%{token}@ExAmPle.com"
|
||||
setting.Domain = "DoMain.com"
|
||||
mkEnv := func(s string) *enmime.Envelope {
|
||||
env, _ := enmime.ReadEnvelope(strings.NewReader(s + "\r\n\r\n"))
|
||||
return env
|
||||
}
|
||||
assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("To: incoming+abc@EXAMPLE.COM")))
|
||||
assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("Delivered-To: INCOMING+abc@example.com")))
|
||||
assert.Equal(t, "abc", searchTokenInHeaders(mkEnv("References: <ReplY-abc@DomaiN.COM>")))
|
||||
}
|
||||
|
||||
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))
|
||||
assert.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/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))
|
||||
assert.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))
|
||||
assert.NoError(t, err)
|
||||
content = getContentFromMailReader(env)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "mail content without signature", content.Content)
|
||||
assert.Empty(t, content.Attachments)
|
||||
}
|
||||
|
||||
func TestExtractReply(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"plain text", "Email with only text.", "Email with only text."},
|
||||
{"crlf normalized", "line one\r\nline two\r\n", "line one\nline two"},
|
||||
{"trim blank lines", "\n\n\nactual reply\n\n\n", "actual reply"},
|
||||
{"signature delimiter", "the reply\n--\nJohn Doe\nAcme", "the reply"},
|
||||
{"rfc signature delimiter", "the reply\n-- \nJohn Doe", "the reply"},
|
||||
{"mobile signature", "My answer is yes.\n\nSent from my iPhone", "My answer is yes."},
|
||||
{"quote only kept", "> Email with only quote.", "> Email with only quote."},
|
||||
{"leading quote kept", "> This is a quote.\n\nAnd this is some text.", "> This is a quote.\n\nAnd this is some text."},
|
||||
{"trailing quote stripped", "My reply.\n\n> original line 1\n> original line 2", "My reply."},
|
||||
{"attribution and quote", "Looks good.\n\nOn Mon, Jan 1, 2024 John <j@x.com> wrote:\n> please review", "Looks good."},
|
||||
{"attribution without quote marks", "My reply.\n\nOn Wed, Sep 25, 2013, richard wrote:\noriginal text", "My reply."},
|
||||
{"original message separator", "Foo\n\n-------- Original Message --------\n\nTHE END.", "Foo"},
|
||||
{"outlook header block", "This is the actual reply.\n\nFrom: Some One <a@b.com>\nSent: Monday\nTo: Someone\nSubject: hi\n\nquoted body", "This is the actual reply."},
|
||||
{"french attribution", "C'est super !\n\nLe 4 janv. 2016 19:03, \"Neil\" <a@b.com> a écrit :\n> quoted", "C'est super !"},
|
||||
{"german attribution", "Hey :)\n\nAm 03.02.2016 3:35 schrieb Max <a@b.com>:\n> quoted", "Hey :)"},
|
||||
{"cyrillic wrote verb", "Yes.\n\n6 октября 2014 lidel написал:\n> quoted", "Yes."},
|
||||
{"localized signature", "My answer.\n\nEnvoyé depuis mon iPhone", "My answer."},
|
||||
{"swedish header block", "Hi everyone!\n\nFrån: Foo <a@b.com>\nSkickat: den 5 juni\nTill: x@y.com\nÄmne: hi\n\nbody", "Hi everyone!"},
|
||||
{"attribution only is empty", "On Mon, Jan 1, 2024 at 10:00 John <j@x.com> wrote:\n> please review", ""},
|
||||
{"prose ending in wrote kept", "Hi Bob,\nThanks for the report you wrote\nI'll fix it.", "Hi Bob,\nThanks for the report you wrote\nI'll fix it."},
|
||||
{"on with year and no time kept", "Hi,\nOn the 2024 roadmap we have three items.\nPlease review.", "Hi,\nOn the 2024 roadmap we have three items.\nPlease review."},
|
||||
{"date prose kept", "Notes:\n5 issues 2024 fixed at 9:15 today\nmore notes", "Notes:\n5 issues 2024 fixed at 9:15 today\nmore notes"},
|
||||
{"header needs from first", "Quick note:\nTo: which server?\nFrom: tests pass.\nThanks", "Quick note:\nTo: which server?\nFrom: tests pass.\nThanks"},
|
||||
{"indented header block", "Reply text.\n\n From: A <a@b.com>\n Sent: Monday\n To: x\n Subject: hi\n\nbody", "Reply text."},
|
||||
{"chinese signature", "回复内容\n\n發自我的iPhone", "回复内容"},
|
||||
{"japanese signature", "返信します\n\niPhoneから送信", "返信します"},
|
||||
{"chinese header block", "回复内容\n\n发件人:张三\n收件人:李四\n主题:你好\n\n原文", "回复内容"},
|
||||
{"japanese header block", "本文です\n\n差出人:山田\n宛先:田中\n件名:こんにちは\n\n原文", "本文です"},
|
||||
{"name-first attribution", "Okay.\n\nErlend <meta@x.com> schrieb am Di., 16. Aug. 2016\num 12:52 Uhr:\n> quoted", "Okay."},
|
||||
{"chinese attribution", "你好,谢谢回复。\n\n在 2024年1月1日,张三 <z@x.com> 写道:\n> 原始内容", "你好,谢谢回复。"},
|
||||
{"japanese attribution", "了解しました。\n\n田中さんは書きました:\n> 引用", "了解しました。"},
|
||||
{"korean attribution", "감사합니다.\n\n홍길동님이 작성:\n> 인용", "감사합니다."},
|
||||
{"email mention kept", "I asked Bob <bob@x.com> and he wrote back yes.\nSo we proceed.", "I asked Bob <bob@x.com> and he wrote back yes.\nSo we proceed."},
|
||||
{"trailing mailbox glyph", "My reply here.\n\nᐧ", "My reply here."},
|
||||
{"on with year and time prose kept", "On the 2024 roadmap we should meet at 10:00.\nI'll send invites.", "On the 2024 roadmap we should meet at 10:00.\nI'll send invites."},
|
||||
{"spanish year and time prose kept", "El informe del 2024 estará listo a las 10:00.\nGracias.", "El informe del 2024 estará listo a las 10:00.\nGracias."},
|
||||
{"chinese prose kept", "谢谢,已测试。\n发自我的内心的感谢", "谢谢,已测试。\n发自我的内心的感谢"},
|
||||
{"korean prose kept", "확인했습니다.\n이 문서는 회사에서 보냄", "확인했습니다.\n이 문서는 회사에서 보냄"},
|
||||
{"japanese prose kept", "了解しました。\n資料は会議から送信", "了解しました。\n資料は会議から送信"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.expected, extractReply(c.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package payload
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package incoming
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
const (
|
||||
yearToken = `\b\d{4}\b` // 4-digit year
|
||||
timeToken = `\b\d{1,2}[:.]\d{2}\b` // HH:MM or HH.MM
|
||||
// "wrote" verbs ending an attribution line; CJK ones are matched without a
|
||||
// preceding word-separator since those scripts don't space their words
|
||||
wroteVerbs = `wrote|writes|schrieb|skrev|napisał|escreveu|escribió|написал|пише|a écrit`
|
||||
cjkWroteVerbs = `写道|寫道|書きました|작성`
|
||||
// device names anchoring CJK mobile signatures, so prose isn't mistaken for one
|
||||
cjkDevice = `iphone|ipad|ipod|android|galaxy|手机|手機|平板`
|
||||
)
|
||||
|
||||
// forwarded-mail header fields across the common mail clients/locales. headerFromFields
|
||||
// (the "From"-equivalents) must begin a block; headerFields is the full set allowed to
|
||||
// follow. Matched as a prefix by headerLine, so adding a locale is a one-line change.
|
||||
var (
|
||||
headerFromFields = []string{
|
||||
"from", "fra", "de", "von", "da", "van", "från", "expéditeur",
|
||||
"发件人", "寄件者", "差出人", "보낸사람",
|
||||
}
|
||||
headerFields = append([]string{
|
||||
"to", "cc", "bcc", "sent", "date", "subject", "reply-to",
|
||||
"til", "emne", "an", "betreff", "gesendet", "para", "assunto", "asunto",
|
||||
"risposta", "inviato", "oggetto", "destinataire", "objet", "répondre à",
|
||||
"aan", "onderwerp", "beantwoorden", "skickat", "till", "ämne",
|
||||
"收件人", "主题", "主旨", "主題", "收件者", "抄送", "日期", "宛先", "件名", "받는사람", "제목",
|
||||
}, headerFromFields...)
|
||||
)
|
||||
|
||||
// patterns are compiled on first use so the incoming-mail feature adds nothing to startup.
|
||||
var patterns = sync.OnceValue(func() (ret struct {
|
||||
signature, attribution, separator *regexp.Regexp
|
||||
},
|
||||
) {
|
||||
// "-- " delimiter and common mobile footers with frequent localizations. The CJK
|
||||
// forms require a device name so ordinary prose like "发自我的内心" or "会議から送信"
|
||||
// is not mistaken for a signature.
|
||||
ret.signature = regexp.MustCompile(`(?i)^(--|__|—` +
|
||||
`|sent (from|via|with) .+|get outlook for .+` +
|
||||
`|envoyé depuis mon .+|sendt fra min .+|von meinem .+|verzonden (met|vanaf) .+` +
|
||||
`|(發|发)自我的.*(` + cjkDevice + `).*` +
|
||||
`|.*(` + cjkDevice + `).*(から送信|에서 보냄|傳送|发送))$`)
|
||||
|
||||
// attribution introducing quoted history: a line ending in a "wrote:" verb
|
||||
// (Latin/Cyrillic or CJK), a "Name <email> wrote" line, a lead word directly
|
||||
// followed by a day number or weekday plus a year and a time, or an ISO-date-led
|
||||
// line. The date phrasing, trailing colon and the email before the verb guard
|
||||
// against prose (so "On the 2024 roadmap … at 10:00" is not an attribution).
|
||||
ret.attribution = regexp.MustCompile(`(?i)^>*\s*(` +
|
||||
`.*[\s">'](` + wroteVerbs + `)\s*[::]` +
|
||||
`|.*(` + cjkWroteVerbs + `)\s*[::]` +
|
||||
`|.*<\S+@\S+>\s+(` + wroteVerbs + `)\b.*` +
|
||||
`|(on|at|le|am|el|em|den|il|op|dnia|w dniu)\b[\s,]*(\d|(?:mon|tue|wed|thu|fri|sat|sun)\b).*` + yearToken + `.*` + timeToken + `.*` +
|
||||
`|\d{4}-\d{2}-\d{2}\b.*` + timeToken + `.*` +
|
||||
`)$`)
|
||||
|
||||
// a dash/underscore rule line, or text fenced by dashes such as
|
||||
// "-------- Original Message --------" or "-----Mensaje original-----"
|
||||
ret.separator = regexp.MustCompile(`(?i)^\s*\*?\s*([-_]{5,}|-{2,}.+-{2,}|original message|forwarded message)\s*\*?\s*$`)
|
||||
return ret
|
||||
})
|
||||
|
||||
// extractReply returns the user-written part of a plain-text email body, dropping
|
||||
// quoted history, the reply attribution, signatures and forwarded headers. It is a
|
||||
// slim, dependency-free reimplementation based on github.com/dimiro1/reply (MIT),
|
||||
// covering the common mail-client formats and languages; bottom posting and
|
||||
// forwarded bodies are not handled.
|
||||
func extractReply(text string) string {
|
||||
p := patterns()
|
||||
lines := strings.Split(util.NormalizeStringEOL(text), "\n")
|
||||
|
||||
// cut at the first line that begins quoted history, a signature or a header block
|
||||
for i := range lines {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if p.signature.MatchString(trimmed) || p.attribution.MatchString(trimmed) ||
|
||||
p.separator.MatchString(trimmed) || headerBlock(trimmed, lines[i+1:]) {
|
||||
lines = lines[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// drop the trailing block of quoted/blank lines, unless the whole body is quoted
|
||||
end := len(lines)
|
||||
for end > 0 {
|
||||
// "ᐧ" is the trailing marker some mobile clients (Mailbox) append
|
||||
if t := strings.TrimSpace(lines[end-1]); t != "" && t != "ᐧ" && !strings.HasPrefix(t, ">") {
|
||||
break
|
||||
}
|
||||
end--
|
||||
}
|
||||
if end > 0 {
|
||||
lines = lines[:end]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// headerBlock reports whether a forwarded-mail header block starts here: the
|
||||
// (already-trimmed) first line is a "From" field and the next non-blank line is
|
||||
// another field, so a lone "Subject:" sentence is not a boundary.
|
||||
func headerBlock(first string, rest []string) bool {
|
||||
if !headerLine(first, headerFromFields) {
|
||||
return false
|
||||
}
|
||||
for _, next := range rest {
|
||||
if t := strings.TrimSpace(next); t != "" {
|
||||
return headerLine(t, headerFields)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// headerLine reports whether the already-trimmed line is a "Field:" header for one
|
||||
// of fields. An ASCII colon must be followed by a space so prose like "To:do this"
|
||||
// is ignored; the CJK fullwidth colon ":" needs no space.
|
||||
func headerLine(line string, fields []string) bool {
|
||||
lower := strings.ToLower(line)
|
||||
for _, field := range fields {
|
||||
if rest, ok := strings.CutPrefix(lower, field); ok &&
|
||||
(strings.HasPrefix(rest, ": ") || strings.HasPrefix(rest, ":")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// 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"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"mime"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const mailMaxSubjectRunes = 256 // There's no actual limit for subject in RFC 5322
|
||||
|
||||
var subjectRemoveSpaces = regexp.MustCompile(`[\s]+`)
|
||||
|
||||
func LoadedTemplates() *templates.MailRender {
|
||||
return templates.MailRenderer()
|
||||
}
|
||||
|
||||
// SendTestMail sends a test mail
|
||||
func SendTestMail(email string) error {
|
||||
if setting.MailService == nil {
|
||||
// No mail service configured
|
||||
return nil
|
||||
}
|
||||
return sender_service.Send(sender, sender_service.NewMessage(email, "Gitea Test Email!", "Gitea Test Email!"))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
type mailAttachmentBase64Embedder struct {
|
||||
doer *user_model.User
|
||||
repo *repo_model.Repository
|
||||
maxSize int64
|
||||
estimateSize int64
|
||||
}
|
||||
|
||||
func newMailAttachmentBase64Embedder(doer *user_model.User, repo *repo_model.Repository, maxSize int64) *mailAttachmentBase64Embedder {
|
||||
return &mailAttachmentBase64Embedder{doer: doer, repo: repo, maxSize: maxSize}
|
||||
}
|
||||
|
||||
func (b64embedder *mailAttachmentBase64Embedder) Base64InlineImages(ctx context.Context, body template.HTML) (template.HTML, error) {
|
||||
doc, err := html.Parse(strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("html.Parse failed: %w", err)
|
||||
}
|
||||
|
||||
b64embedder.estimateSize = int64(len(string(body)))
|
||||
|
||||
var processNode func(*html.Node)
|
||||
processNode = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode {
|
||||
if n.Data == "img" {
|
||||
for i, attr := range n.Attr {
|
||||
if attr.Key == "src" {
|
||||
attachmentSrc := attr.Val
|
||||
dataURI, err := b64embedder.AttachmentSrcToBase64DataURI(ctx, attachmentSrc)
|
||||
if err != nil {
|
||||
// Not an error, just skip. This is probably an image from outside the gitea instance.
|
||||
log.Trace("Unable to embed attachment %q to mail body: %v", attachmentSrc, err)
|
||||
} else {
|
||||
n.Attr[i].Val = dataURI
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
processNode(c)
|
||||
}
|
||||
}
|
||||
|
||||
processNode(doc)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = html.Render(&buf, doc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("html.Render failed: %w", err)
|
||||
}
|
||||
return template.HTML(buf.String()), nil
|
||||
}
|
||||
|
||||
func (b64embedder *mailAttachmentBase64Embedder) AttachmentSrcToBase64DataURI(ctx context.Context, attachmentSrc string) (string, error) {
|
||||
parsedSrc := httplib.ParseGiteaSiteURL(ctx, attachmentSrc)
|
||||
var attachmentUUID string
|
||||
if parsedSrc != nil {
|
||||
var ok bool
|
||||
attachmentUUID, ok = strings.CutPrefix(parsedSrc.RoutePath, "/attachments/")
|
||||
if !ok {
|
||||
attachmentUUID, ok = strings.CutPrefix(parsedSrc.RepoSubPath, "/attachments/")
|
||||
}
|
||||
if !ok {
|
||||
return "", errors.New("not an attachment")
|
||||
}
|
||||
}
|
||||
attachment, err := repo_model.GetAttachmentByUUID(ctx, attachmentUUID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if attachment.RepoID != b64embedder.repo.ID {
|
||||
return "", errors.New("attachment does not belong to the repository")
|
||||
}
|
||||
if attachment.Size+b64embedder.estimateSize > b64embedder.maxSize {
|
||||
return "", errors.New("total embedded images exceed max limit")
|
||||
}
|
||||
|
||||
fr, err := storage.Attachments.Open(attachment.RelativePath())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer fr.Close()
|
||||
|
||||
lr := &io.LimitedReader{R: fr, N: b64embedder.maxSize + 1}
|
||||
content, err := io.ReadAll(lr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LimitedReader ReadAll: %w", err)
|
||||
}
|
||||
|
||||
mimeType := typesniffer.DetectContentType(content)
|
||||
if !mimeType.IsImage() {
|
||||
return "", errors.New("not an image")
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(content)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType.GetMimeType(), encoded)
|
||||
b64embedder.estimateSize += int64(len(dataURI))
|
||||
return dataURI, nil
|
||||
}
|
||||
|
||||
func fromDisplayName(u *user_model.User) string {
|
||||
if setting.MailService.FromDisplayNameFormatTemplate != nil {
|
||||
var buf bytes.Buffer
|
||||
err := setting.MailService.FromDisplayNameFormatTemplate.Execute(&buf, map[string]any{
|
||||
"DisplayName": u.DisplayName(),
|
||||
"AppName": setting.AppName,
|
||||
"Domain": setting.Domain,
|
||||
})
|
||||
if err == nil {
|
||||
return mime.QEncoding.Encode("utf-8", buf.String())
|
||||
}
|
||||
log.Error("fromDisplayName: %w", err)
|
||||
}
|
||||
def := u.Name
|
||||
if fullName := strings.TrimSpace(u.FullName); fullName != "" {
|
||||
// use "Full Name (username)" for email's sender name if Full Name is not empty
|
||||
def = fullName + " (" + u.Name + ")"
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func generateMetadataHeaders(repo *repo_model.Repository) map[string]string {
|
||||
return map[string]string{
|
||||
// https://datatracker.ietf.org/doc/html/rfc2919
|
||||
"List-ID": fmt.Sprintf("%s <%s.%s.%s>", repo.FullName(), repo.Name, repo.OwnerName, setting.Domain),
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2369
|
||||
"List-Archive": fmt.Sprintf("<%s>", repo.HTMLURL()),
|
||||
|
||||
"X-Mailer": "Gitea",
|
||||
|
||||
"X-Gitea-Repository": repo.Name,
|
||||
"X-Gitea-Repository-Path": repo.FullName(),
|
||||
"X-Gitea-Repository-Link": repo.HTMLURL(),
|
||||
|
||||
"X-GitLab-Project": repo.Name,
|
||||
"X-GitLab-Project-Path": repo.FullName(),
|
||||
}
|
||||
}
|
||||
|
||||
func generateSenderRecipientHeaders(doer, recipient *user_model.User) map[string]string {
|
||||
return map[string]string{
|
||||
"X-Gitea-Sender": doer.Name,
|
||||
"X-Gitea-Recipient": recipient.Name,
|
||||
"X-Gitea-Recipient-Address": recipient.Email,
|
||||
"X-GitHub-Sender": doer.Name,
|
||||
"X-GitHub-Recipient": recipient.Name,
|
||||
"X-GitHub-Recipient-Address": recipient.Email,
|
||||
}
|
||||
}
|
||||
|
||||
func generateReasonHeaders(reason string) map[string]string {
|
||||
return map[string]string{
|
||||
"X-Gitea-Reason": reason,
|
||||
"X-GitHub-Reason": reason,
|
||||
"X-GitLab-NotificationReason": reason,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/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(ctx,
|
||||
&mailComment{
|
||||
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(ctx,
|
||||
&mailComment{
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
const MailBatchSize = 100 // batch size used in mailIssueCommentBatch
|
||||
|
||||
// mailIssueCommentToParticipants can be used for both new issue creation and comment.
|
||||
// This function sends two list of emails:
|
||||
// 1. Repository watchers (except for WIP pull requests) and users who are participated in comments.
|
||||
// 2. Users who are not in 1. but get mentioned in current issue/comment.
|
||||
func mailIssueCommentToParticipants(ctx context.Context, comment *mailComment, mentions []*user_model.User) error {
|
||||
// Required by the mail composer; make sure to load these before calling the async function
|
||||
if err := comment.Issue.LoadRepo(ctx); err != nil {
|
||||
return fmt.Errorf("LoadRepo: %w", err)
|
||||
}
|
||||
if err := comment.Issue.LoadPoster(ctx); err != nil {
|
||||
return fmt.Errorf("LoadPoster: %w", err)
|
||||
}
|
||||
if err := comment.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] = comment.Issue.PosterID
|
||||
|
||||
// =========== Assignees ===========
|
||||
ids, err := issues_model.GetAssigneeIDsByIssue(ctx, comment.Issue.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetAssigneeIDsByIssue(%d): %w", comment.Issue.ID, err)
|
||||
}
|
||||
unfiltered = append(unfiltered, ids...)
|
||||
|
||||
// =========== Participants (i.e. commenters, reviewers) ===========
|
||||
ids, err = issues_model.GetParticipantsIDsByIssueID(ctx, comment.Issue.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetParticipantsIDsByIssueID(%d): %w", comment.Issue.ID, err)
|
||||
}
|
||||
unfiltered = append(unfiltered, ids...)
|
||||
|
||||
// =========== Issue watchers ===========
|
||||
ids, err = issues_model.GetIssueWatchersIDs(ctx, comment.Issue.ID, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetIssueWatchersIDs(%d): %w", comment.Issue.ID, err)
|
||||
}
|
||||
unfiltered = append(unfiltered, ids...)
|
||||
|
||||
// =========== Repo watchers ===========
|
||||
// Make repo watchers last, since it's likely the list with the most users
|
||||
if !(comment.Issue.IsPull && comment.Issue.PullRequest.IsWorkInProgress(ctx) && comment.ActionType != activities_model.ActionCreatePullRequest) {
|
||||
ids, err = repo_model.GetRepoWatchersIDs(ctx, comment.Issue.RepoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetRepoWatchersIDs(%d): %w", comment.Issue.RepoID, err)
|
||||
}
|
||||
unfiltered = append(ids, unfiltered...)
|
||||
}
|
||||
|
||||
visited := make(container.Set[int64], len(unfiltered)+len(mentions)+1)
|
||||
|
||||
// Avoid mailing the doer
|
||||
if comment.Doer.EmailNotificationsPreference != user_model.EmailNotificationsAndYourOwn && !comment.ForceDoerNotification {
|
||||
visited.Add(comment.Doer.ID)
|
||||
}
|
||||
|
||||
// =========== Mentions ===========
|
||||
if err = mailIssueCommentBatch(ctx, comment, mentions, visited, true); err != nil {
|
||||
return fmt.Errorf("mailIssueCommentBatch() mentions: %w", err)
|
||||
}
|
||||
|
||||
// Avoid mailing explicit unwatched
|
||||
ids, err = issues_model.GetIssueWatchersIDs(ctx, comment.Issue.ID, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetIssueWatchersIDs(%d): %w", comment.Issue.ID, err)
|
||||
}
|
||||
visited.AddMultiple(ids...)
|
||||
|
||||
unfilteredUsers, err := user_model.GetMailableUsersByIDs(ctx, unfiltered, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = mailIssueCommentBatch(ctx, comment, unfilteredUsers, visited, false); err != nil {
|
||||
return fmt.Errorf("mailIssueCommentBatch(): %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailIssueCommentBatch(ctx context.Context, comment *mailComment, users []*user_model.User, visited container.Set[int64], fromMention bool) error {
|
||||
checkUnit := unit.TypeIssues
|
||||
if comment.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, comment.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, comment, 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(ctx,
|
||||
&mailComment{
|
||||
Issue: issue,
|
||||
Doer: doer,
|
||||
ActionType: opType,
|
||||
Content: content,
|
||||
Comment: nil,
|
||||
ForceDoerNotification: forceDoerNotification,
|
||||
}, mentions); err != nil {
|
||||
log.Error("mailIssueCommentToParticipants: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendIssueAssignedMail composes and sends issue assigned email
|
||||
func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string, comment *issues_model.Comment, recipients []*user_model.User) error {
|
||||
if setting.MailService == nil {
|
||||
// No mail service configured
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := issue.LoadRepo(ctx); err != nil {
|
||||
log.Error("Unable to load repo [%d] for issue #%d [%d]. Error: %v", issue.RepoID, issue.Index, issue.ID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
langMap := make(map[string][]*user_model.User)
|
||||
for _, user := range recipients {
|
||||
if !user.IsActive {
|
||||
// don't send emails to inactive users
|
||||
continue
|
||||
}
|
||||
langMap[user.Language] = append(langMap[user.Language], user)
|
||||
}
|
||||
|
||||
for lang, tos := range langMap {
|
||||
msgs, err := composeIssueCommentMessages(ctx, &mailComment{
|
||||
Issue: issue,
|
||||
Doer: doer,
|
||||
ActionType: activities_model.ActionType(0),
|
||||
Content: content,
|
||||
Comment: comment,
|
||||
}, lang, tos, false, "issue assigned")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
SendAsync(msgs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
activities_model "gitea.dev/models/activities"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/renderhelper"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/emoji"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
incoming_payload "gitea.dev/services/mailer/incoming/payload"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
"gitea.dev/services/mailer/token"
|
||||
)
|
||||
|
||||
// maxEmailBodySize is the approximate maximum size of an email body in bytes
|
||||
// Many e-mail service providers have limitations on the size of the email body, it's usually from 10MB to 25MB
|
||||
const maxEmailBodySize = 9_000_000
|
||||
|
||||
func fallbackIssueMailSubject(issue *issues_model.Issue) string {
|
||||
return fmt.Sprintf("[%s] %s (#%d)", issue.Repo.FullName(), issue.Title, issue.Index)
|
||||
}
|
||||
|
||||
type mailComment struct {
|
||||
Issue *issues_model.Issue
|
||||
Doer *user_model.User
|
||||
ActionType activities_model.ActionType
|
||||
Content string
|
||||
Comment *issues_model.Comment
|
||||
ForceDoerNotification bool
|
||||
}
|
||||
|
||||
func composeIssueCommentMessages(ctx context.Context, comment *mailComment, lang string, recipients []*user_model.User, fromMention bool, info string) ([]*sender_service.Message, error) {
|
||||
var (
|
||||
subject string
|
||||
link string
|
||||
prefix string
|
||||
// Fall back subject for bad templates, make sure subject is never empty
|
||||
fallback string
|
||||
reviewComments []*issues_model.Comment
|
||||
)
|
||||
|
||||
commentType := issues_model.CommentTypeComment
|
||||
if comment.Comment != nil {
|
||||
commentType = comment.Comment.Type
|
||||
link = comment.Issue.HTMLURL(ctx) + "#" + comment.Comment.HashTag()
|
||||
} else {
|
||||
link = comment.Issue.HTMLURL(ctx)
|
||||
}
|
||||
|
||||
reviewType := issues_model.ReviewTypeComment
|
||||
if comment.Comment != nil && comment.Comment.Review != nil {
|
||||
reviewType = comment.Comment.Review.Type
|
||||
}
|
||||
|
||||
// This is the body of the new issue or comment, not the mail body
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, comment.Issue.Repo).WithUseAbsoluteLink(true)
|
||||
body, err := markdown.RenderString(rctx, comment.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if setting.MailService.EmbedAttachmentImages {
|
||||
attEmbedder := newMailAttachmentBase64Embedder(comment.Doer, comment.Issue.Repo, maxEmailBodySize)
|
||||
bodyAfterEmbedding, err := attEmbedder.Base64InlineImages(ctx, body)
|
||||
if err != nil {
|
||||
log.Error("Failed to embed images in mail body: %v", err)
|
||||
} else {
|
||||
body = bodyAfterEmbedding
|
||||
}
|
||||
}
|
||||
actType, actName, tplName := actionToTemplate(comment.Issue, comment.ActionType, commentType, reviewType)
|
||||
|
||||
if actName != "new" {
|
||||
prefix = "Re: "
|
||||
}
|
||||
fallback = prefix + fallbackIssueMailSubject(comment.Issue)
|
||||
|
||||
if comment.Comment != nil && comment.Comment.Review != nil {
|
||||
reviewComments = make([]*issues_model.Comment, 0, 10)
|
||||
for _, lines := range comment.Comment.Review.CodeComments {
|
||||
for _, comments := range lines {
|
||||
reviewComments = append(reviewComments, comments...)
|
||||
}
|
||||
}
|
||||
}
|
||||
locale := translation.NewLocale(lang)
|
||||
|
||||
mailMeta := map[string]any{
|
||||
"locale": locale,
|
||||
"FallbackSubject": fallback,
|
||||
"Body": body,
|
||||
"Link": link,
|
||||
"Issue": comment.Issue,
|
||||
"Comment": comment.Comment,
|
||||
"IsPull": comment.Issue.IsPull,
|
||||
"User": comment.Issue.Repo.MustOwner(ctx),
|
||||
"Repo": comment.Issue.Repo.FullName(),
|
||||
"Doer": comment.Doer,
|
||||
"IsMention": fromMention,
|
||||
"SubjectPrefix": prefix,
|
||||
"ActionType": actType,
|
||||
"ActionName": actName,
|
||||
"ReviewComments": reviewComments,
|
||||
"Language": locale.Language(),
|
||||
"CanReply": setting.IncomingEmail.Enabled && commentType != issues_model.CommentTypePullRequestPush,
|
||||
}
|
||||
|
||||
var mailSubject bytes.Buffer
|
||||
if err := LoadedTemplates().SubjectTemplates.ExecuteTemplate(&mailSubject, tplName, mailMeta); err == nil {
|
||||
subject = sanitizeSubject(mailSubject.String())
|
||||
subject = util.IfZero(subject, fallback)
|
||||
} else {
|
||||
log.Error("ExecuteTemplate [%s]: %v", tplName+"/subject", err)
|
||||
}
|
||||
|
||||
subject = emoji.ReplaceAliases(subject)
|
||||
|
||||
mailMeta["Subject"] = subject
|
||||
|
||||
var mailBody bytes.Buffer
|
||||
|
||||
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, tplName, mailMeta); err != nil {
|
||||
log.Error("ExecuteTemplate [%s]: %v", tplName+"/body", err)
|
||||
}
|
||||
|
||||
// Make sure to compose independent messages to avoid leaking user emails
|
||||
msgID := generateMessageIDForIssue(comment.Issue, comment.Comment, comment.ActionType)
|
||||
reference := generateMessageIDForIssue(comment.Issue, nil, activities_model.ActionType(0))
|
||||
|
||||
var replyPayload []byte
|
||||
if comment.Comment != nil {
|
||||
if comment.Comment.Type.HasMailReplySupport() {
|
||||
replyPayload, err = incoming_payload.CreateReferencePayload(comment.Comment)
|
||||
}
|
||||
} else {
|
||||
replyPayload, err = incoming_payload.CreateReferencePayload(comment.Issue)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
unsubscribePayload, err := incoming_payload.CreateReferencePayload(comment.Issue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msgs := make([]*sender_service.Message, 0, len(recipients))
|
||||
for _, recipient := range recipients {
|
||||
msg := sender_service.NewMessageFrom(
|
||||
recipient.Email,
|
||||
fromDisplayName(comment.Doer),
|
||||
setting.MailService.FromEmail,
|
||||
subject,
|
||||
mailBody.String(),
|
||||
)
|
||||
msg.Info = fmt.Sprintf("Subject: %s, %s", subject, info)
|
||||
|
||||
msg.SetHeader("Message-ID", msgID)
|
||||
msg.SetHeader("In-Reply-To", reference)
|
||||
|
||||
references := []string{reference}
|
||||
listUnsubscribe := []string{"<" + comment.Issue.HTMLURL(ctx) + ">"}
|
||||
|
||||
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.IncomingEmailTokenPlaceholder, 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.IncomingEmailTokenPlaceholder, token, 1)
|
||||
listUnsubscribe = append(listUnsubscribe, "<mailto:"+unsubAddress+">")
|
||||
}
|
||||
}
|
||||
|
||||
msg.SetHeader("References", references...)
|
||||
msg.SetHeader("List-Unsubscribe", listUnsubscribe...)
|
||||
|
||||
for key, value := range generateAdditionalHeadersForIssue(ctx, comment, actType, recipient) {
|
||||
msg.SetHeader(key, value)
|
||||
}
|
||||
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// actionToTemplate returns the type and name of the action facing the user
|
||||
// (slightly different from activities_model.ActionType) and the name of the template to use (based on availability)
|
||||
func actionToTemplate(issue *issues_model.Issue, actionType activities_model.ActionType,
|
||||
commentType issues_model.CommentType, reviewType issues_model.ReviewType,
|
||||
) (typeName, name, template string) {
|
||||
if issue.IsPull {
|
||||
typeName = "pull"
|
||||
} else {
|
||||
typeName = "issue"
|
||||
}
|
||||
switch actionType {
|
||||
case activities_model.ActionCreateIssue, activities_model.ActionCreatePullRequest:
|
||||
name = "new"
|
||||
case activities_model.ActionCommentIssue, activities_model.ActionCommentPull:
|
||||
name = "comment"
|
||||
case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
|
||||
name = "close"
|
||||
case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
|
||||
name = "reopen"
|
||||
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
|
||||
name = "merge"
|
||||
case activities_model.ActionPullReviewDismissed:
|
||||
name = "review_dismissed"
|
||||
case activities_model.ActionPullRequestReadyForReview:
|
||||
name = "ready_for_review"
|
||||
default:
|
||||
switch commentType {
|
||||
case issues_model.CommentTypeReview:
|
||||
switch reviewType {
|
||||
case issues_model.ReviewTypeApprove:
|
||||
name = "approve"
|
||||
case issues_model.ReviewTypeReject:
|
||||
name = "reject"
|
||||
default:
|
||||
name = "review"
|
||||
}
|
||||
case issues_model.CommentTypeCode:
|
||||
name = "code"
|
||||
case issues_model.CommentTypeAssignees:
|
||||
name = "assigned"
|
||||
case issues_model.CommentTypePullRequestPush:
|
||||
name = "push"
|
||||
default:
|
||||
name = "default"
|
||||
}
|
||||
}
|
||||
|
||||
template = "repo/" + typeName + "/" + name
|
||||
ok := LoadedTemplates().BodyTemplates.HasTemplate(template)
|
||||
if !ok && typeName != "issue" {
|
||||
template = "repo/issue/" + name
|
||||
ok = LoadedTemplates().BodyTemplates.HasTemplate(template)
|
||||
}
|
||||
if !ok {
|
||||
template = "repo/" + typeName + "/default"
|
||||
ok = LoadedTemplates().BodyTemplates.HasTemplate(template)
|
||||
}
|
||||
if !ok {
|
||||
template = "repo/issue/default"
|
||||
}
|
||||
return typeName, name, template
|
||||
}
|
||||
|
||||
func generateMessageIDForIssue(issue *issues_model.Issue, comment *issues_model.Comment, actionType activities_model.ActionType) string {
|
||||
var path string
|
||||
if issue.IsPull {
|
||||
path = "pulls"
|
||||
} else {
|
||||
path = "issues"
|
||||
}
|
||||
|
||||
var extra string
|
||||
if comment != nil {
|
||||
extra = fmt.Sprintf("/comment/%d", comment.ID)
|
||||
} else {
|
||||
switch actionType {
|
||||
case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest:
|
||||
extra = fmt.Sprintf("/close/%d", time.Now().UnixNano()/1e6)
|
||||
case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest:
|
||||
extra = fmt.Sprintf("/reopen/%d", time.Now().UnixNano()/1e6)
|
||||
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest:
|
||||
extra = fmt.Sprintf("/merge/%d", time.Now().UnixNano()/1e6)
|
||||
case activities_model.ActionPullRequestReadyForReview:
|
||||
extra = fmt.Sprintf("/ready/%d", time.Now().UnixNano()/1e6)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("<%s/%s/%d%s@%s>", issue.Repo.FullName(), path, issue.Index, extra, setting.Domain)
|
||||
}
|
||||
|
||||
func generateAdditionalHeadersForIssue(ctx context.Context, comment *mailComment, reason string, recipient *user_model.User) map[string]string {
|
||||
repo := comment.Issue.Repo
|
||||
|
||||
issueID := strconv.FormatInt(comment.Issue.Index, 10)
|
||||
headers := generateMetadataHeaders(repo)
|
||||
|
||||
maps.Copy(headers, generateSenderRecipientHeaders(comment.Doer, recipient))
|
||||
maps.Copy(headers, generateReasonHeaders(reason))
|
||||
|
||||
headers["X-Gitea-Issue-ID"] = issueID
|
||||
headers["X-Gitea-Issue-Link"] = comment.Issue.HTMLURL(ctx)
|
||||
headers["X-GitLab-Issue-IID"] = issueID
|
||||
|
||||
return headers
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
"gitea.dev/models/renderhelper"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/translation"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
)
|
||||
|
||||
const tplNewReleaseMail templates.TplName = "repo/release"
|
||||
|
||||
func generateMessageIDForRelease(release *repo_model.Release) string {
|
||||
return fmt.Sprintf("<%s/releases/%d@%s>", release.Repo.FullName(), release.ID, setting.Domain)
|
||||
}
|
||||
|
||||
// MailNewRelease send new release notify to all repo watchers.
|
||||
func MailNewRelease(ctx context.Context, rel *repo_model.Release) {
|
||||
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.GetMailableUsersByIDs(ctx, watcherIDList, false)
|
||||
if err != nil {
|
||||
log.Error("user_model.GetMailableUsersByIDs: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := rel.LoadRepo(ctx); err != nil {
|
||||
log.Error("rel.LoadRepo: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// delete publisher or any users with no permission
|
||||
recipients = slices.DeleteFunc(recipients, func(u *user_model.User) bool {
|
||||
return u.ID == rel.PublisherID || !access_model.CheckRepoUnitUser(ctx, rel.Repo, u, unit.TypeReleases)
|
||||
})
|
||||
|
||||
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
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, rel.Repo).WithUseAbsoluteLink(true)
|
||||
rel.RenderedNote, err = markdown.RenderString(rctx,
|
||||
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 := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplNewReleaseMail), mailMeta); err != nil {
|
||||
log.Error("ExecuteTemplate [%s]: %v", string(tplNewReleaseMail)+"/body", err)
|
||||
return
|
||||
}
|
||||
|
||||
msgs := make([]*sender_service.Message, 0, len(tos))
|
||||
publisherName := fromDisplayName(rel.Publisher)
|
||||
msgID := generateMessageIDForRelease(rel)
|
||||
for _, to := range tos {
|
||||
msg := sender_service.NewMessageFrom(to.EmailTo(), publisherName, setting.MailService.FromEmail, subject, mailBody.String())
|
||||
msg.Info = subject
|
||||
msg.SetHeader("Message-ID", msgID)
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
|
||||
SendAsync(msgs...)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMailNewReleaseFiltersUnauthorizedWatchers(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
defer test.MockVariableValue(&setting.MailService)()
|
||||
defer test.MockVariableValue(&setting.Domain)()
|
||||
defer test.MockVariableValue(&setting.AppName)()
|
||||
defer test.MockVariableValue(&setting.AppURL)()
|
||||
|
||||
setting.MailService = &setting.Mailer{
|
||||
From: "Gitea",
|
||||
FromEmail: "noreply@example.com",
|
||||
}
|
||||
setting.Domain = "example.com"
|
||||
setting.AppName = "Gitea"
|
||||
setting.AppURL = "https://example.com/"
|
||||
defer mockMailTemplates(string(tplNewReleaseMail), "{{.Subject}}", "<p>{{.Release.TagName}}</p>")()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
require.True(t, repo.IsPrivate)
|
||||
|
||||
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
unauthorized := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
|
||||
assert.NoError(t, repo_model.WatchRepo(t.Context(), admin, repo, true))
|
||||
assert.NoError(t, repo_model.WatchRepo(t.Context(), unauthorized, repo, true))
|
||||
|
||||
rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 11})
|
||||
rel.Repo = nil
|
||||
rel.Publisher = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: rel.PublisherID})
|
||||
|
||||
var sent []*sender_service.Message
|
||||
origSend := SendAsync
|
||||
SendAsync = func(msgs ...*sender_service.Message) {
|
||||
sent = append(sent, msgs...)
|
||||
}
|
||||
defer func() {
|
||||
SendAsync = origSend
|
||||
}()
|
||||
|
||||
MailNewRelease(t.Context(), rel)
|
||||
|
||||
require.Len(t, sent, 1)
|
||||
assert.Equal(t, admin.EmailTo(), sent[0].To)
|
||||
assert.NotEqual(t, unauthorized.EmailTo(), sent[0].To)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/translation"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
)
|
||||
|
||||
const (
|
||||
mailNotifyCollaborator templates.TplName = "repo/collaborator"
|
||||
mailRepoTransferNotify templates.TplName = "repo/transfer"
|
||||
)
|
||||
|
||||
// SendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created
|
||||
func SendRepoTransferNotifyMail(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) error {
|
||||
if setting.MailService == nil {
|
||||
// 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 := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, to := range emailTos {
|
||||
msg := sender_service.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
|
||||
}
|
||||
|
||||
// SendCollaboratorMail sends mail notification to new collaborator.
|
||||
func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) {
|
||||
if setting.MailService == nil || !u.IsActive {
|
||||
return
|
||||
}
|
||||
locale := translation.NewLocale(u.Language)
|
||||
repoName := repo.FullName()
|
||||
|
||||
subject := locale.TrString("mail.repo.collaborator.added.subject", doer.DisplayName(), repoName)
|
||||
data := map[string]any{
|
||||
"locale": locale,
|
||||
"Subject": subject,
|
||||
"RepoName": repoName,
|
||||
"Link": repo.HTMLURL(),
|
||||
"Language": locale.Language(),
|
||||
}
|
||||
|
||||
var content bytes.Buffer
|
||||
|
||||
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailNotifyCollaborator), data); err != nil {
|
||||
log.Error("Template: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
|
||||
msg.Info = fmt.Sprintf("UID: %d, add collaborator", u.ID)
|
||||
|
||||
SendAsync(msg)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
org_model "gitea.dev/models/organization"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/translation"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
)
|
||||
|
||||
const tplTeamInviteMail templates.TplName = "org/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 errors.New("login is prohibited for the invited user")
|
||||
}
|
||||
|
||||
inviteRedirect := url.QueryEscape("/org/invite/" + invite.Token)
|
||||
inviteURL := fmt.Sprintf("%suser/sign_up?redirect_to=%s", setting.AppURL, inviteRedirect)
|
||||
|
||||
if (err == nil && user != nil) || setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration {
|
||||
// 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 := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplTeamInviteMail), mailMeta); err != nil {
|
||||
log.Error("ExecuteTemplate [%s]: %v", string(tplTeamInviteMail)+"/body", err)
|
||||
return err
|
||||
}
|
||||
|
||||
msg := sender_service.NewMessage(invite.Email, subject, mailBody.String())
|
||||
msg.Info = subject
|
||||
|
||||
SendAsync(msg)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"mime/quotedprintable"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
texttmpl "text/template"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
activities_model "gitea.dev/models/activities"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/services/attachment"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
|
||||
"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" />
|
||||
<title>{{.Subject}}</title>
|
||||
</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) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
setting.MailService = &setting.Mailer{From: "test@gitea.com"}
|
||||
setting.Domain = "localhost"
|
||||
setting.AppURL = "https://try.gitea.io/"
|
||||
|
||||
doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, Owner: doer})
|
||||
issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1, Repo: repo, Poster: doer})
|
||||
comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2, Issue: issue})
|
||||
require.NoError(t, issue.LoadRepo(t.Context()))
|
||||
return doer, repo, issue, comment
|
||||
}
|
||||
|
||||
func prepareMailerBase64Test(t *testing.T) (doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, att1, att2 *repo_model.Attachment) {
|
||||
user, repo, issue, comment := prepareMailerTest(t)
|
||||
setting.MailService.EmbedAttachmentImages = true
|
||||
|
||||
att1, err := attachment.NewAttachment(t.Context(), &repo_model.Attachment{
|
||||
RepoID: repo.ID,
|
||||
IssueID: issue.ID,
|
||||
UploaderID: user.ID,
|
||||
CommentID: comment.ID,
|
||||
Name: "test.png",
|
||||
}, bytes.NewReader([]byte("\x89\x50\x4e\x47\x0d\x0a\x1a\x0a")), 8)
|
||||
require.NoError(t, err)
|
||||
|
||||
att2, err = attachment.NewAttachment(t.Context(), &repo_model.Attachment{
|
||||
RepoID: repo.ID,
|
||||
IssueID: issue.ID,
|
||||
UploaderID: user.ID,
|
||||
CommentID: comment.ID,
|
||||
Name: "test.png",
|
||||
}, bytes.NewReader([]byte("\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"+strings.Repeat("\x00", 1024))), 8+1024)
|
||||
require.NoError(t, err)
|
||||
|
||||
return user, repo, issue, att1, att2
|
||||
}
|
||||
|
||||
func mockMailTemplates(name, subjectTmpl, bodyTmpl string) func() {
|
||||
return templates.MailRenderer().MockTemplate(name, subjectTmpl, bodyTmpl)
|
||||
}
|
||||
|
||||
func TestComposeIssueComment(t *testing.T) {
|
||||
doer, _, issue, comment := prepareMailerTest(t)
|
||||
|
||||
markup.Init(&markup.RenderHelperFuncs{
|
||||
IsUsernameMentionable: func(ctx context.Context, username string) bool {
|
||||
return username == doer.Name
|
||||
},
|
||||
})
|
||||
|
||||
defer test.MockVariableValue(&setting.IncomingEmail.Enabled, true)()
|
||||
defer mockMailTemplates("repo/issue/comment", subjectTpl, bodyTpl)()
|
||||
|
||||
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
|
||||
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
|
||||
Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
|
||||
Content: fmt.Sprintf("test @%s %s#%d body", doer.Name, issue.Repo.FullName(), issue.Index),
|
||||
Comment: comment,
|
||||
}, "en-US", recipients, false, "issue comment")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, msgs, 2)
|
||||
gomailMsg := msgs[0].ToMessage()
|
||||
replyTo := gomailMsg.GetGenHeader("Reply-To")[0]
|
||||
subject := gomailMsg.GetGenHeader("Subject")[0]
|
||||
|
||||
assert.Len(t, gomailMsg.GetAddrHeader("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.GetGenHeader("In-Reply-To")[0], "In-Reply-To header doesn't match")
|
||||
assert.ElementsMatch(t, []string{"<user2/repo1/issues/1@localhost>", "<reply-" + token + "@localhost>"}, gomailMsg.GetGenHeader("References"), "References header doesn't match")
|
||||
assert.Equal(t, "<user2/repo1/issues/1/comment/2@localhost>", gomailMsg.GetGenHeader("Message-ID")[0], "Message-ID header doesn't match")
|
||||
assert.Equal(t, "<mailto:"+replyTo+">", gomailMsg.GetGenHeader("List-Post")[0])
|
||||
assert.Len(t, gomailMsg.GetGenHeader("List-Unsubscribe"), 2) // url + mailto
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = gomailMsg.WriteTo(&buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := io.ReadAll(quotedprintable.NewReader(&buf))
|
||||
assert.NoError(t, err)
|
||||
|
||||
// text/plain
|
||||
assert.Contains(t, string(b), fmt.Sprintf(`( %s )`, doer.HTMLURL(t.Context())))
|
||||
assert.Contains(t, string(b), fmt.Sprintf(`( %s )`, issue.HTMLURL(t.Context())))
|
||||
|
||||
// text/html
|
||||
assert.Contains(t, string(b), fmt.Sprintf(`href="%s"`, doer.HTMLURL(t.Context())))
|
||||
assert.Contains(t, string(b), fmt.Sprintf(`href="%s"`, issue.HTMLURL(t.Context())))
|
||||
}
|
||||
|
||||
func TestMailMentionsComment(t *testing.T) {
|
||||
doer, _, issue, comment := prepareMailerTest(t)
|
||||
comment.Poster = doer
|
||||
defer mockMailTemplates("repo/issue/comment", subjectTpl, bodyTpl)()
|
||||
mails := 0
|
||||
|
||||
defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) {
|
||||
mails = len(msgs)
|
||||
})()
|
||||
|
||||
err := MailParticipantsComment(t.Context(), comment, activities_model.ActionCommentIssue, issue, []*user_model.User{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, mails)
|
||||
}
|
||||
|
||||
func TestComposeIssueMessage(t *testing.T) {
|
||||
doer, _, issue, _ := prepareMailerTest(t)
|
||||
|
||||
defer mockMailTemplates("repo/issue/new", subjectTpl, bodyTpl)()
|
||||
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}, {Name: "Test2", Email: "test2@gitea.com"}}
|
||||
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
|
||||
Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
|
||||
Content: "test body",
|
||||
}, "en-US", recipients, false, "issue create")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, msgs, 2)
|
||||
|
||||
gomailMsg := msgs[0].ToMessage()
|
||||
mailto := gomailMsg.GetAddrHeader("To")
|
||||
subject := gomailMsg.GetGenHeader("Subject")
|
||||
messageID := gomailMsg.GetGenHeader("Message-ID")
|
||||
inReplyTo := gomailMsg.GetGenHeader("In-Reply-To")
|
||||
references := gomailMsg.GetGenHeader("References")
|
||||
|
||||
assert.Len(t, mailto, 1, "exactly one recipient is expected in the To field")
|
||||
assert.Equal(t, "[user2/repo1] @user2 #1 - issue1", 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.GetGenHeader("List-Post")) // incoming mail feature disabled
|
||||
assert.Len(t, gomailMsg.GetGenHeader("List-Unsubscribe"), 1) // url without mailto
|
||||
}
|
||||
|
||||
func TestTemplateSelection(t *testing.T) {
|
||||
doer, repo, issue, comment := prepareMailerTest(t)
|
||||
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
|
||||
|
||||
defer mockMailTemplates("repo/issue/default", "repo/issue/default/subject", "repo/issue/default/body")()
|
||||
defer mockMailTemplates("repo/issue/new", "repo/issue/new/subject", "repo/issue/new/body")()
|
||||
defer mockMailTemplates("repo/pull/comment", "repo/pull/comment/subject", "repo/pull/comment/body")()
|
||||
defer mockMailTemplates("repo/issue/close", "", "repo/issue/close/body")() // Must default to a fallback subject
|
||||
|
||||
expect := func(t *testing.T, msg *sender_service.Message, expSubject, expBody string) {
|
||||
subject := msg.ToMessage().GetGenHeader("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, &mailComment{
|
||||
Issue: issue, Doer: doer, ActionType: activities_model.ActionCreateIssue,
|
||||
Content: "test body",
|
||||
}, recipients, false, "TestTemplateSelection")
|
||||
expect(t, msg, "repo/issue/new/subject", "repo/issue/new/body")
|
||||
|
||||
msg = testComposeIssueCommentMessage(t, &mailComment{
|
||||
Issue: issue, Doer: doer, ActionType: activities_model.ActionCommentIssue,
|
||||
Content: "test body", Comment: comment,
|
||||
}, recipients, false, "TestTemplateSelection")
|
||||
expect(t, msg, "repo/issue/default/subject", "repo/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, &mailComment{
|
||||
Issue: pull, Doer: doer, ActionType: activities_model.ActionCommentPull,
|
||||
Content: "test body", Comment: comment,
|
||||
}, recipients, false, "TestTemplateSelection")
|
||||
expect(t, msg, "repo/pull/comment/subject", "repo/pull/comment/body")
|
||||
|
||||
msg = testComposeIssueCommentMessage(t, &mailComment{
|
||||
Issue: issue, Doer: doer, ActionType: activities_model.ActionCloseIssue,
|
||||
Content: "test body", Comment: comment,
|
||||
}, recipients, false, "TestTemplateSelection")
|
||||
expect(t, msg, "Re: [user2/repo1] issue1 (#1)", "repo/issue/close/body")
|
||||
}
|
||||
|
||||
func TestTemplateServices(t *testing.T) {
|
||||
doer, _, issue, comment := prepareMailerTest(t)
|
||||
assert.NoError(t, issue.LoadRepo(t.Context()))
|
||||
|
||||
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,
|
||||
) {
|
||||
defer mockMailTemplates("repo/issue/default", tplSubject, tplBody)()
|
||||
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
|
||||
msg := testComposeIssueCommentMessage(t, &mailComment{
|
||||
Issue: issue, Doer: doer, ActionType: actionType,
|
||||
Content: "test body", Comment: comment,
|
||||
}, recipients, fromMention, "TestTemplateServices")
|
||||
|
||||
subject := msg.ToMessage().GetGenHeader("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 *mailComment, recipients []*user_model.User, fromMention bool, info string) *sender_service.Message {
|
||||
msgs, err := composeIssueCommentMessages(t.Context(), ctx, "en-US", recipients, fromMention, info)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, msgs, 1)
|
||||
return msgs[0]
|
||||
}
|
||||
|
||||
func TestGenerateAdditionalHeadersForIssue(t *testing.T) {
|
||||
doer, _, issue, _ := prepareMailerTest(t)
|
||||
|
||||
comment := &mailComment{Issue: issue, Doer: doer}
|
||||
recipient := &user_model.User{Name: "test", Email: "test@gitea.com"}
|
||||
|
||||
headers := generateAdditionalHeadersForIssue(t.Context(), comment, "dummy-reason", recipient)
|
||||
|
||||
expected := map[string]string{
|
||||
"List-ID": "user2/repo1 <repo1.user2.localhost>",
|
||||
"List-Archive": "<https://try.gitea.io/user2/repo1>",
|
||||
"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",
|
||||
}
|
||||
|
||||
for key, value := range expected {
|
||||
if assert.Contains(t, headers, key) {
|
||||
assert.Equal(t, value, headers[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMessageIDForIssue(t *testing.T) {
|
||||
_, _, 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 := generateMessageIDForIssue(tt.args.issue, tt.args.comment, tt.args.actionType)
|
||||
assert.True(t, strings.HasPrefix(got, tt.prefix), "%v, want %v", got, tt.prefix)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMessageIDForRelease(t *testing.T) {
|
||||
msgID := generateMessageIDForRelease(&repo_model.Release{
|
||||
ID: 1,
|
||||
Repo: &repo_model.Repository{OwnerName: "owner", Name: "repo"},
|
||||
})
|
||||
assert.Equal(t, "<owner/repo/releases/1@localhost>", msgID)
|
||||
}
|
||||
|
||||
func TestGenerateMessageIDForActionsWorkflowRunStatusEmail(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 795, RepoID: repo.ID})
|
||||
assert.NoError(t, run.LoadAttributes(t.Context()))
|
||||
msgID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run)
|
||||
assert.Equal(t, "<user2/repo2/actions/runs/191@localhost>", msgID)
|
||||
}
|
||||
|
||||
func TestFromDisplayName(t *testing.T) {
|
||||
tmpl, err := texttmpl.New("mailFrom").Parse("{{ .DisplayName }}")
|
||||
assert.NoError(t, err)
|
||||
setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: tmpl}
|
||||
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.Equal(t, tc.fromDisplayName, got)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("template with all available vars", func(t *testing.T) {
|
||||
tmpl, err = texttmpl.New("mailFrom").Parse("{{ .DisplayName }} (by {{ .AppName }} on [{{ .Domain }}])")
|
||||
assert.NoError(t, err)
|
||||
setting.MailService = &setting.Mailer{FromDisplayNameFormatTemplate: tmpl}
|
||||
oldAppName := setting.AppName
|
||||
setting.AppName = "Code IT"
|
||||
oldDomain := setting.Domain
|
||||
setting.Domain = "code.it"
|
||||
defer func() {
|
||||
setting.AppName = oldAppName
|
||||
setting.Domain = oldDomain
|
||||
}()
|
||||
|
||||
assert.Equal(t, "Mister X (by Code IT on [code.it])", fromDisplayName(&user_model.User{FullName: "Mister X", Name: "tmp"}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEmbedBase64Images(t *testing.T) {
|
||||
user, repo, issue, att1, att2 := prepareMailerBase64Test(t)
|
||||
// comment := &mailComment{Issue: issue, Doer: user}
|
||||
|
||||
imgExternalURL := "https://via.placeholder.com/10"
|
||||
imgExternalImg := fmt.Sprintf(`<img src="%s"/>`, imgExternalURL)
|
||||
|
||||
att1URL := setting.AppURL + repo.Owner.Name + "/" + repo.Name + "/attachments/" + att1.UUID
|
||||
att1Img := fmt.Sprintf(`<img src="%s"/>`, att1URL)
|
||||
att1Base64 := "data:image/png;base64,iVBORw0KGgo="
|
||||
att1ImgBase64 := fmt.Sprintf(`<img src="%s"/>`, att1Base64)
|
||||
|
||||
att2URL := setting.AppURL + repo.Owner.Name + "/" + repo.Name + "/attachments/" + att2.UUID
|
||||
att2Img := fmt.Sprintf(`<img src="%s"/>`, att2URL)
|
||||
att2File, err := storage.Attachments.Open(att2.RelativePath())
|
||||
require.NoError(t, err)
|
||||
defer att2File.Close()
|
||||
att2Bytes, err := io.ReadAll(att2File)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(att2Bytes), 1024)
|
||||
att2Base64 := "data:image/png;base64," + base64.StdEncoding.EncodeToString(att2Bytes)
|
||||
att2ImgBase64 := fmt.Sprintf(`<img src="%s"/>`, att2Base64)
|
||||
|
||||
t.Run("ComposeMessage", func(t *testing.T) {
|
||||
defer mockMailTemplates("repo/issue/new", subjectTpl, bodyTpl)()
|
||||
|
||||
issue.Content = fmt.Sprintf(`MSG-BEFORE <image src="attachments/%s"> MSG-AFTER`, att1.UUID)
|
||||
require.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "content"))
|
||||
|
||||
recipients := []*user_model.User{{Name: "Test", Email: "test@gitea.com"}}
|
||||
msgs, err := composeIssueCommentMessages(t.Context(), &mailComment{
|
||||
Issue: issue,
|
||||
Doer: user,
|
||||
ActionType: activities_model.ActionCreateIssue,
|
||||
Content: issue.Content,
|
||||
}, "en-US", recipients, false, "issue create")
|
||||
require.NoError(t, err)
|
||||
|
||||
mailBody := msgs[0].Body
|
||||
assert.Regexp(t, `MSG-BEFORE <a[^>]+><img src="data:image/png;base64,iVBORw0KGgo=".*/></a> MSG-AFTER`, mailBody)
|
||||
})
|
||||
|
||||
t.Run("EmbedInstanceImageSkipExternalImage", func(t *testing.T) {
|
||||
mailBody := "<html><head></head><body><p>Test1</p>" + imgExternalImg + "<p>Test2</p>" + att1Img + "<p>Test3</p></body></html>"
|
||||
expectedMailBody := "<html><head></head><body><p>Test1</p>" + imgExternalImg + "<p>Test2</p>" + att1ImgBase64 + "<p>Test3</p></body></html>"
|
||||
b64embedder := newMailAttachmentBase64Embedder(user, repo, 1024)
|
||||
resultMailBody, err := b64embedder.Base64InlineImages(t.Context(), template.HTML(mailBody))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedMailBody, string(resultMailBody))
|
||||
})
|
||||
|
||||
t.Run("LimitedEmailBodySize", func(t *testing.T) {
|
||||
mailBody := fmt.Sprintf("<html><head></head><body>%s%s</body></html>", att1Img, att2Img)
|
||||
b64embedder := newMailAttachmentBase64Embedder(user, repo, 1024)
|
||||
resultMailBody, err := b64embedder.Base64InlineImages(t.Context(), template.HTML(mailBody))
|
||||
require.NoError(t, err)
|
||||
expected := fmt.Sprintf("<html><head></head><body>%s%s</body></html>", att1ImgBase64, att2Img)
|
||||
assert.Equal(t, expected, string(resultMailBody))
|
||||
|
||||
b64embedder = newMailAttachmentBase64Embedder(user, repo, 4096)
|
||||
resultMailBody, err = b64embedder.Base64InlineImages(t.Context(), template.HTML(mailBody))
|
||||
require.NoError(t, err)
|
||||
expected = fmt.Sprintf("<html><head></head><body>%s%s</body></html>", att1ImgBase64, att2ImgBase64)
|
||||
assert.Equal(t, expected, string(resultMailBody))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/translation"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
)
|
||||
|
||||
const (
|
||||
mailAuthActivate templates.TplName = "user/auth/activate"
|
||||
mailAuthActivateEmail templates.TplName = "user/auth/activate_email"
|
||||
mailAuthResetPassword templates.TplName = "user/auth/reset_passwd"
|
||||
mailAuthRegisterNotify templates.TplName = "user/auth/register_notify"
|
||||
)
|
||||
|
||||
// sendUserMail sends a mail to the user
|
||||
func sendUserMail(language string, u *user_model.User, tpl templates.TplName, code, subject, info string) {
|
||||
locale := translation.NewLocale(language)
|
||||
data := map[string]any{
|
||||
"locale": locale,
|
||||
"DisplayName": u.DisplayName(),
|
||||
"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
|
||||
"ResetPwdCodeLives": timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, locale),
|
||||
"Code": code,
|
||||
"Language": locale.Language(),
|
||||
}
|
||||
|
||||
var content bytes.Buffer
|
||||
|
||||
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
|
||||
log.Error("Template: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
|
||||
msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info)
|
||||
|
||||
SendAsync(msg)
|
||||
}
|
||||
|
||||
// SendActivateAccountMail sends an activation mail to the user (new user registration)
|
||||
func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
|
||||
if setting.MailService == nil {
|
||||
// No mail service configured
|
||||
return
|
||||
}
|
||||
opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}
|
||||
sendUserMail(locale.Language(), u, mailAuthActivate, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.activate_account"), "activate account")
|
||||
}
|
||||
|
||||
// SendResetPasswordMail sends a password reset mail to the user
|
||||
func SendResetPasswordMail(u *user_model.User) {
|
||||
if setting.MailService == nil {
|
||||
// No mail service configured
|
||||
return
|
||||
}
|
||||
locale := translation.NewLocale(u.Language)
|
||||
opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}
|
||||
sendUserMail(u.Language, u, mailAuthResetPassword, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.reset_password"), "recover account")
|
||||
}
|
||||
|
||||
// SendActivateEmailMail sends confirmation email to confirm new email address
|
||||
func SendActivateEmailMail(u *user_model.User, email string) {
|
||||
if setting.MailService == nil {
|
||||
// No mail service configured
|
||||
return
|
||||
}
|
||||
locale := translation.NewLocale(u.Language)
|
||||
opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateEmail, NewEmail: email}
|
||||
data := map[string]any{
|
||||
"locale": locale,
|
||||
"DisplayName": u.DisplayName(),
|
||||
"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale),
|
||||
"Code": user_model.GenerateUserTimeLimitCode(opts, u),
|
||||
"Email": email,
|
||||
"Language": locale.Language(),
|
||||
}
|
||||
|
||||
var content bytes.Buffer
|
||||
|
||||
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
|
||||
log.Error("Template: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
msg := sender_service.NewMessage(email, locale.TrString("mail.activate_email"), content.String())
|
||||
msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
|
||||
|
||||
SendAsync(msg)
|
||||
}
|
||||
|
||||
// SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
|
||||
func SendRegisterNotifyMail(u *user_model.User) {
|
||||
if setting.MailService == nil || !u.IsActive {
|
||||
// No mail service configured OR user is inactive
|
||||
return
|
||||
}
|
||||
locale := translation.NewLocale(u.Language)
|
||||
|
||||
data := map[string]any{
|
||||
"locale": locale,
|
||||
"DisplayName": u.DisplayName(),
|
||||
"Username": u.Name,
|
||||
"Language": locale.Language(),
|
||||
}
|
||||
|
||||
var content bytes.Buffer
|
||||
|
||||
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
|
||||
log.Error("Template: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
msg := sender_service.NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String())
|
||||
msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID)
|
||||
|
||||
SendAsync(msg)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/services/convert"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
)
|
||||
|
||||
const tplWorkflowRun templates.TplName = "repo/actions/workflow_run"
|
||||
|
||||
type convertedWorkflowJob struct {
|
||||
HTMLURL string
|
||||
Name string
|
||||
Status actions_model.Status
|
||||
Attempt int64
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
func generateMessageIDForActionsWorkflowRunStatusEmail(repo *repo_model.Repository, run *actions_model.ActionRun) string {
|
||||
return fmt.Sprintf("<%s/actions/runs/%d@%s>", repo.FullName(), run.Index, setting.Domain)
|
||||
}
|
||||
|
||||
func composeAndSendActionsWorkflowRunStatusEmail(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, sender *user_model.User, recipients []*user_model.User) error {
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, repo.ID, run.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsDone() {
|
||||
log.Debug("composeAndSendActionsWorkflowRunStatusEmail: A job is not done. Will not compose and send actions email.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var subjectTrString string
|
||||
switch run.Status {
|
||||
case actions_model.StatusFailure:
|
||||
subjectTrString = "mail.repo.actions.run.failed"
|
||||
case actions_model.StatusCancelled:
|
||||
subjectTrString = "mail.repo.actions.run.cancelled"
|
||||
case actions_model.StatusSuccess:
|
||||
subjectTrString = "mail.repo.actions.run.succeeded"
|
||||
}
|
||||
displayName := fromDisplayName(sender)
|
||||
messageID := generateMessageIDForActionsWorkflowRunStatusEmail(repo, run)
|
||||
metadataHeaders := generateMetadataHeaders(repo)
|
||||
|
||||
sort.SliceStable(jobs, func(i, j int) bool {
|
||||
si, sj := jobs[i].Status, jobs[j].Status
|
||||
/*
|
||||
If both i and j are/are not success, leave it to si < sj.
|
||||
If i is success and j is not, since the desired is j goes "smaller" and i goes "bigger", this func should return false.
|
||||
If j is success and i is not, since the desired is i goes "smaller" and j goes "bigger", this func should return true.
|
||||
*/
|
||||
if si.IsSuccess() != sj.IsSuccess() {
|
||||
return !si.IsSuccess()
|
||||
}
|
||||
return si < sj
|
||||
})
|
||||
|
||||
convertedJobs := make([]convertedWorkflowJob, 0, len(jobs))
|
||||
for _, job := range jobs {
|
||||
converted0, err := convert.ToActionWorkflowJob(ctx, repo, nil, job)
|
||||
if err != nil {
|
||||
log.Error("convert.ToActionWorkflowJob: %v", err)
|
||||
continue
|
||||
}
|
||||
convertedJobs = append(convertedJobs, convertedWorkflowJob{
|
||||
HTMLURL: converted0.HTMLURL,
|
||||
Name: converted0.Name,
|
||||
Status: job.Status,
|
||||
Attempt: converted0.RunAttempt,
|
||||
Duration: job.Duration(),
|
||||
})
|
||||
}
|
||||
|
||||
langMap := make(map[string][]*user_model.User)
|
||||
for _, user := range recipients {
|
||||
langMap[user.Language] = append(langMap[user.Language], user)
|
||||
}
|
||||
for lang, tos := range langMap {
|
||||
locale := translation.NewLocale(lang)
|
||||
var runStatusTrString string
|
||||
switch run.Status {
|
||||
case actions_model.StatusSuccess:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_succeeded"
|
||||
case actions_model.StatusFailure:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_failed"
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsFailure() {
|
||||
runStatusTrString = "mail.repo.actions.jobs.some_not_successful"
|
||||
break
|
||||
}
|
||||
}
|
||||
case actions_model.StatusCancelled:
|
||||
runStatusTrString = "mail.repo.actions.jobs.all_cancelled"
|
||||
}
|
||||
subject := fmt.Sprintf("%s: %s (%s)", locale.TrString(subjectTrString), run.WorkflowID, base.ShortSha(run.CommitSHA))
|
||||
var mailBody bytes.Buffer
|
||||
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&mailBody, string(tplWorkflowRun), map[string]any{
|
||||
"Subject": subject,
|
||||
"Repo": repo,
|
||||
"Run": run,
|
||||
"RunStatusText": locale.TrString(runStatusTrString),
|
||||
"Jobs": convertedJobs,
|
||||
"locale": locale,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
msgs := make([]*sender_service.Message, 0, len(tos))
|
||||
for _, rec := range tos {
|
||||
log.Trace("Sending actions email to %s (UID: %d)", rec.Name, rec.ID)
|
||||
msg := sender_service.NewMessageFrom(
|
||||
rec.Email,
|
||||
displayName,
|
||||
setting.MailService.FromEmail,
|
||||
subject,
|
||||
mailBody.String(),
|
||||
)
|
||||
msg.Info = subject
|
||||
for k, v := range generateSenderRecipientHeaders(sender, rec) {
|
||||
msg.SetHeader(k, v)
|
||||
}
|
||||
for k, v := range metadataHeaders {
|
||||
msg.SetHeader(k, v)
|
||||
}
|
||||
msg.SetHeader("Message-ID", messageID)
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
SendAsync(msgs...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func MailActionsTrigger(ctx context.Context, recipient *user_model.User, repo *repo_model.Repository, run *actions_model.ActionRun) error {
|
||||
if setting.MailService == nil {
|
||||
return nil
|
||||
}
|
||||
if !run.Status.IsDone() || run.Status.IsSkipped() {
|
||||
return nil
|
||||
}
|
||||
if !recipient.IsMailable() {
|
||||
return nil
|
||||
}
|
||||
|
||||
notifyPref, err := user_model.GetUserSetting(ctx, recipient.ID,
|
||||
user_model.SettingsKeyEmailNotificationGiteaActions, user_model.SettingEmailNotificationGiteaActionsFailureOnly)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// "disabled" never sends
|
||||
if notifyPref == user_model.SettingEmailNotificationGiteaActionsDisabled {
|
||||
return nil
|
||||
}
|
||||
// "failure-only" skips non-failure runs
|
||||
if notifyPref != user_model.SettingEmailNotificationGiteaActionsAll && !run.Status.IsFailure() {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug("MailActionsTrigger: Initiate email composition")
|
||||
return composeAndSendActionsWorkflowRunStatusEmail(ctx, repo, run, recipient, []*user_model.User{recipient})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/queue"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
sender_service "gitea.dev/services/mailer/sender"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
var mailQueue *queue.WorkerPoolQueue[*sender_service.Message]
|
||||
|
||||
// sender sender for sending mail synchronously
|
||||
var sender sender_service.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 = &sender_service.SendmailSender{}
|
||||
case "dummy":
|
||||
sender = &sender_service.DummySender{}
|
||||
default:
|
||||
sender = &sender_service.SMTPSender{}
|
||||
}
|
||||
|
||||
_ = templates.MailRenderer()
|
||||
|
||||
mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*sender_service.Message) []*sender_service.Message {
|
||||
for _, msg := range items {
|
||||
gomailMsg := msg.ToMessage()
|
||||
log.Trace("New e-mail sending request %s: %s", gomailMsg.GetGenHeader("To"), msg.Info)
|
||||
if err := sender_service.Send(sender, msg); err != nil {
|
||||
log.Error("Failed to send emails %s: %s - %v", gomailMsg.GetGenHeader("To"), msg.Info, err)
|
||||
} else {
|
||||
log.Trace("E-mails sent %s: %s", gomailMsg.GetGenHeader("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 ...*sender_service.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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
_ "gitea.dev/models"
|
||||
_ "gitea.dev/models/actions"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
activities_model "gitea.dev/models/activities"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
notify_service "gitea.dev/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
|
||||
switch comment.Type {
|
||||
case issues_model.CommentTypeClose:
|
||||
act = activities_model.ActionCloseIssue
|
||||
case issues_model.CommentTypeReopen:
|
||||
act = activities_model.ActionReopenIssue
|
||||
case issues_model.CommentTypeComment:
|
||||
act = activities_model.ActionCommentIssue
|
||||
case issues_model.CommentTypeCode:
|
||||
act = activities_model.ActionCommentIssue
|
||||
case 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
|
||||
switch comment.Type {
|
||||
case issues_model.CommentTypeClose:
|
||||
act = activities_model.ActionCloseIssue
|
||||
case issues_model.CommentTypeReopen:
|
||||
act = activities_model.ActionReopenIssue
|
||||
case 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(ctx))
|
||||
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 := issue_service.LoadCommentPushCommits(ctx, comment); 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) WorkflowRunStatusUpdate(ctx context.Context, repo *repo_model.Repository, sender *user_model.User, run *actions_model.ActionRun) {
|
||||
if err := MailActionsTrigger(ctx, sender, repo, run); err != nil {
|
||||
log.Error("MailActionsTrigger: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// DummySender Sender sendmail mail sender
|
||||
type DummySender struct{}
|
||||
|
||||
var _ Sender = &DummySender{}
|
||||
|
||||
// Send send email
|
||||
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.Debug("Mail From: %s To: %v Body: %s", from, to, buf.String())
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/Necoro/html2text"
|
||||
gomail "github.com/wneessen/go-mail"
|
||||
)
|
||||
|
||||
// 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.Msg {
|
||||
msg := gomail.NewMsg()
|
||||
addr := mail.Address{Name: m.FromDisplayName, Address: m.FromAddress}
|
||||
_ = msg.SetAddrHeader("From", addr.String())
|
||||
_ = msg.SetAddrHeader("To", m.To)
|
||||
if m.ReplyTo != "" {
|
||||
msg.SetGenHeader("Reply-To", m.ReplyTo)
|
||||
}
|
||||
for header := range m.Headers {
|
||||
msg.SetGenHeader(gomail.Header(header), m.Headers[header]...)
|
||||
}
|
||||
|
||||
if setting.MailService.SubjectPrefix != "" {
|
||||
msg.SetGenHeader("Subject", setting.MailService.SubjectPrefix+" "+m.Subject)
|
||||
} else {
|
||||
msg.SetGenHeader("Subject", m.Subject)
|
||||
}
|
||||
msg.SetDateWithValue(m.Date)
|
||||
msg.SetGenHeader("X-Auto-Response-Suppress", "All")
|
||||
|
||||
plainBody, err := html2text.FromString(m.Body)
|
||||
if err != nil || setting.MailService.SendAsPlainText {
|
||||
if strings.Contains(util.TruncateRunes(m.Body, 100), "<html>") {
|
||||
log.Warn("Mail contains HTML but configured to send as plain text.")
|
||||
}
|
||||
msg.SetBodyString("text/plain", plainBody)
|
||||
} else {
|
||||
msg.SetBodyString("text/plain", plainBody)
|
||||
msg.AddAlternativeString("text/html", m.Body)
|
||||
}
|
||||
|
||||
if len(msg.GetGenHeader("Message-ID")) == 0 {
|
||||
msg.SetGenHeader("Message-ID", m.generateAutoMessageID())
|
||||
}
|
||||
|
||||
for k, v := range setting.MailService.OverrideHeader {
|
||||
if len(msg.GetGenHeader(gomail.Header(k))) != 0 {
|
||||
log.Debug("Mailer override header '%s' as per config", k)
|
||||
}
|
||||
msg.SetGenHeader(gomail.Header(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)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGenerateMessageID(t *testing.T) {
|
||||
mailService := setting.Mailer{
|
||||
From: "test@gitea.com",
|
||||
}
|
||||
|
||||
setting.MailService = &mailService
|
||||
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.GetGenHeader("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.GetGenHeader("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.GetGenHeader("Message-ID")[0])
|
||||
}
|
||||
|
||||
func TestToMessage(t *testing.T) {
|
||||
oldConf := setting.MailService
|
||||
defer func() {
|
||||
setting.MailService = oldConf
|
||||
}()
|
||||
setting.MailService = &setting.Mailer{
|
||||
From: "test@gitea.com",
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
assertHeaders := func(t *testing.T, expected, header map[string]string) {
|
||||
for k, v := range expected {
|
||||
assert.Equal(t, v, header[k], "Header %s should be %s but got %s", k, v, header[k])
|
||||
}
|
||||
}
|
||||
|
||||
buf := &strings.Builder{}
|
||||
_, err := m1.ToMessage().WriteTo(buf)
|
||||
assert.NoError(t, err)
|
||||
header, _ := extractMailHeaderAndContent(t, buf.String())
|
||||
assertHeaders(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)
|
||||
assert.NoError(t, err)
|
||||
header, _ = extractMailHeaderAndContent(t, buf.String())
|
||||
assertHeaders(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.SplitSeq(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
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
type Sender interface {
|
||||
Send(from string, to []string, msg io.WriterTo) error
|
||||
}
|
||||
|
||||
var Send = send
|
||||
|
||||
func send(sender Sender, msg *Message) error {
|
||||
m := msg.ToMessage()
|
||||
froms := m.GetFrom()
|
||||
to, err := m.GetRecipients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: implement sending from multiple addresses
|
||||
if len(froms) == 0 {
|
||||
// FIXME: no idea why sometimes the "froms" can be empty, need to figure out the root problem
|
||||
return errors.New("no FROM specified")
|
||||
}
|
||||
return sender.Send(froms[0].Address, to, m)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// SendmailSender Sender sendmail mail sender
|
||||
type SendmailSender struct{}
|
||||
|
||||
var _ Sender = &SendmailSender{}
|
||||
|
||||
// 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...)
|
||||
for _, recipient := range to {
|
||||
smtpTo, err := sanitizeEmailAddress(recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid recipient address %q: %w", recipient, err)
|
||||
}
|
||||
args = append(args, smtpTo)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// SMTPSender Sender SMTP mail sender
|
||||
type SMTPSender struct{}
|
||||
|
||||
var _ Sender = &SMTPSender{}
|
||||
|
||||
// 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 errors.New("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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fromAddr := from
|
||||
if opts.OverrideEnvelopeFrom && opts.EnvelopeFrom != "" {
|
||||
fromAddr = opts.EnvelopeFrom
|
||||
}
|
||||
smtpFrom, err := sanitizeEmailAddress(fromAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid envelope from address: %w", err)
|
||||
}
|
||||
if err = client.Mail(smtpFrom); err != nil {
|
||||
return fmt.Errorf("failed to issue MAIL command: %w", err)
|
||||
}
|
||||
|
||||
for _, rec := range to {
|
||||
smtpTo, err := sanitizeEmailAddress(rec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid recipient address %q: %w", rec, err)
|
||||
}
|
||||
if err = client.Rcpt(smtpTo); 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)
|
||||
}
|
||||
|
||||
err = client.Quit()
|
||||
if err != nil {
|
||||
log.Error("Quit client failed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeEmailAddress(raw string) (string, error) {
|
||||
addr, err := mail.ParseAddress(strings.TrimSpace(strings.Trim(raw, "<>")))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return addr.Address, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
|
||||
"github.com/Azure/go-ntlmssp"
|
||||
)
|
||||
|
||||
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 string
|
||||
}
|
||||
|
||||
// NtlmAuth SMTP AUTH NTLM Auth Handler
|
||||
func NtlmAuth(username, password string) smtp.Auth {
|
||||
return &ntlmAuth{username, password}
|
||||
}
|
||||
|
||||
// Start starts SMTP NTLM Auth
|
||||
func (a *ntlmAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
negotiateMessage, err := ntlmssp.NewNegotiateMessage("", "")
|
||||
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, errors.New("ntlm ChallengeMessage is empty")
|
||||
}
|
||||
authenticateMessage, err := ntlmssp.NewAuthenticateMessage(fromServer, a.username, a.password, nil)
|
||||
return authenticateMessage, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sender
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeEmailAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
hasError bool
|
||||
}{
|
||||
{"abc@gitea.com", "abc@gitea.com", false},
|
||||
{"<abc@gitea.com>", "abc@gitea.com", false},
|
||||
{"ssss.com", "", true},
|
||||
{"<invalid-email>", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result, err := sanitizeEmailAddress(tt.input)
|
||||
if (err != nil) != tt.hasError {
|
||||
t.Errorf("sanitizeEmailAddress(%q) unexpected error status: got %v, want error: %v", tt.input, err != nil, tt.hasError)
|
||||
continue
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("sanitizeEmailAddress(%q) = %q; want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/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 |
|
||||
|
||||
const (
|
||||
tokenVersion1 byte = 1
|
||||
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{tokenVersion1}, packagedData...)), nil
|
||||
}
|
||||
|
||||
// DecodeToken decodes the handler, user and payload from the token and verifies the content
|
||||
func DecodeToken(ctx context.Context, token string) (HandlerType, *user_model.User, []byte, error) {
|
||||
// MTAs are permitted to alter the case of the local-part (RFC 5321 §2.4), so normalize
|
||||
// to the base32 alphabet before decoding to survive a lowercased reply-to address.
|
||||
data, err := encodingWithoutPadding.DecodeString(strings.ToUpper(token))
|
||||
if err != nil {
|
||||
return UnknownHandlerType, nil, nil, err
|
||||
}
|
||||
|
||||
if len(data) < 1 {
|
||||
return UnknownHandlerType, nil, nil, &ErrToken{"no data"}
|
||||
}
|
||||
|
||||
if data[0] != tokenVersion1 {
|
||||
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 truncated 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)
|
||||
|
||||
return hmac[:10] // RFC2104 recommends not using less than 80 bits
|
||||
}
|
||||
Reference in New Issue
Block a user