mirror of https://github.com/go-gitea/gitea.git
Add missing comment reply handling (#32050)
Fixes #31937 - Add missing comment reply handling - Use `onGiteaRun` in the test because the fixtures are not present otherwise (did this behaviour change?) Compare without whitespaces.
This commit is contained in:
parent
8cdf4e29c4
commit
55f1fcf0ad
|
@ -82,43 +82,40 @@ func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *u
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attachmentIDs := make([]string, 0, len(content.Attachments))
|
||||||
|
if setting.Attachment.Enabled {
|
||||||
|
for _, attachment := range content.Attachments {
|
||||||
|
a, err := attachment_service.UploadAttachment(ctx, bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, int64(len(attachment.Content)), &repo_model.Attachment{
|
||||||
|
Name: attachment.Name,
|
||||||
|
UploaderID: doer.ID,
|
||||||
|
RepoID: issue.Repo.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if upload.IsErrFileTypeForbidden(err) {
|
||||||
|
log.Info("Skipping disallowed attachment type: %s", attachment.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
attachmentIDs = append(attachmentIDs, a.UUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.Content == "" && len(attachmentIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
switch r := ref.(type) {
|
switch r := ref.(type) {
|
||||||
case *issues_model.Issue:
|
case *issues_model.Issue:
|
||||||
attachmentIDs := make([]string, 0, len(content.Attachments))
|
_, err := issue_service.CreateIssueComment(ctx, doer, issue.Repo, issue, content.Content, attachmentIDs)
|
||||||
if setting.Attachment.Enabled {
|
|
||||||
for _, attachment := range content.Attachments {
|
|
||||||
a, err := attachment_service.UploadAttachment(ctx, bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, int64(len(attachment.Content)), &repo_model.Attachment{
|
|
||||||
Name: attachment.Name,
|
|
||||||
UploaderID: doer.ID,
|
|
||||||
RepoID: issue.Repo.ID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
if upload.IsErrFileTypeForbidden(err) {
|
|
||||||
log.Info("Skipping disallowed attachment type: %s", attachment.Name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
attachmentIDs = append(attachmentIDs, a.UUID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if content.Content == "" && len(attachmentIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = issue_service.CreateIssueComment(ctx, doer, issue.Repo, issue, content.Content, attachmentIDs)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("CreateIssueComment failed: %w", err)
|
return fmt.Errorf("CreateIssueComment failed: %w", err)
|
||||||
}
|
}
|
||||||
case *issues_model.Comment:
|
case *issues_model.Comment:
|
||||||
comment := r
|
comment := r
|
||||||
|
|
||||||
if content.Content == "" {
|
switch comment.Type {
|
||||||
return nil
|
case issues_model.CommentTypeCode:
|
||||||
}
|
|
||||||
|
|
||||||
if comment.Type == issues_model.CommentTypeCode {
|
|
||||||
_, err := pull_service.CreateCodeComment(
|
_, err := pull_service.CreateCodeComment(
|
||||||
ctx,
|
ctx,
|
||||||
doer,
|
doer,
|
||||||
|
@ -130,11 +127,16 @@ func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *u
|
||||||
false, // not pending review but a single review
|
false, // not pending review but a single review
|
||||||
comment.ReviewID,
|
comment.ReviewID,
|
||||||
"",
|
"",
|
||||||
nil,
|
attachmentIDs,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("CreateCodeComment failed: %w", err)
|
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
|
return nil
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
@ -26,187 +27,190 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIncomingEmail(t *testing.T) {
|
func TestIncomingEmail(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||||
|
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
t.Run("Payload", func(t *testing.T) {
|
||||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
t.Run("Payload", func(t *testing.T) {
|
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
|
||||||
defer tests.PrintCurrentTest(t)()
|
|
||||||
|
|
||||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
|
_, err := incoming_payload.CreateReferencePayload(user)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
_, err := incoming_payload.CreateReferencePayload(user)
|
issuePayload, err := incoming_payload.CreateReferencePayload(issue)
|
||||||
assert.Error(t, err)
|
assert.NoError(t, err)
|
||||||
|
commentPayload, err := incoming_payload.CreateReferencePayload(comment)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
issuePayload, err := incoming_payload.CreateReferencePayload(issue)
|
_, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, []byte{1, 2, 3})
|
||||||
assert.NoError(t, err)
|
assert.Error(t, err)
|
||||||
commentPayload, err := incoming_payload.CreateReferencePayload(comment)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, []byte{1, 2, 3})
|
ref, err := incoming_payload.GetReferenceFromPayload(db.DefaultContext, issuePayload)
|
||||||
assert.Error(t, err)
|
assert.NoError(t, err)
|
||||||
|
assert.IsType(t, ref, new(issues_model.Issue))
|
||||||
|
assert.EqualValues(t, issue.ID, ref.(*issues_model.Issue).ID)
|
||||||
|
|
||||||
ref, err := incoming_payload.GetReferenceFromPayload(db.DefaultContext, issuePayload)
|
ref, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, commentPayload)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.IsType(t, ref, new(issues_model.Issue))
|
assert.IsType(t, ref, new(issues_model.Comment))
|
||||||
assert.EqualValues(t, issue.ID, ref.(*issues_model.Issue).ID)
|
assert.EqualValues(t, comment.ID, ref.(*issues_model.Comment).ID)
|
||||||
|
})
|
||||||
|
|
||||||
ref, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, commentPayload)
|
t.Run("Token", func(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
defer tests.PrintCurrentTest(t)()
|
||||||
assert.IsType(t, ref, new(issues_model.Comment))
|
|
||||||
assert.EqualValues(t, comment.ID, ref.(*issues_model.Comment).ID)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Token", func(t *testing.T) {
|
payload := []byte{1, 2, 3, 4, 5}
|
||||||
defer tests.PrintCurrentTest(t)()
|
|
||||||
|
|
||||||
payload := []byte{1, 2, 3, 4, 5}
|
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, token)
|
||||||
|
|
||||||
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
|
ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, token)
|
assert.Equal(t, token_service.ReplyHandlerType, ht)
|
||||||
|
assert.Equal(t, user.ID, u.ID)
|
||||||
|
assert.Equal(t, payload, p)
|
||||||
|
})
|
||||||
|
|
||||||
ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token)
|
t.Run("Handler", func(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
t.Run("Reply", func(t *testing.T) {
|
||||||
assert.Equal(t, token_service.ReplyHandlerType, ht)
|
t.Run("Comment", func(t *testing.T) {
|
||||||
assert.Equal(t, user.ID, u.ID)
|
defer tests.PrintCurrentTest(t)()
|
||||||
assert.Equal(t, payload, p)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Handler", func(t *testing.T) {
|
handler := &incoming.ReplyHandler{}
|
||||||
t.Run("Reply", func(t *testing.T) {
|
|
||||||
t.Run("Comment", func(t *testing.T) {
|
payload, err := incoming_payload.CreateReferencePayload(issue)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Error(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, nil, payload))
|
||||||
|
assert.NoError(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, user, payload))
|
||||||
|
|
||||||
|
content := &incoming.MailContent{
|
||||||
|
Content: "reply by mail",
|
||||||
|
Attachments: []*incoming.Attachment{
|
||||||
|
{
|
||||||
|
Name: "attachment.txt",
|
||||||
|
Content: []byte("test"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload))
|
||||||
|
|
||||||
|
comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{
|
||||||
|
IssueID: issue.ID,
|
||||||
|
Type: issues_model.CommentTypeComment,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, comments)
|
||||||
|
comment := comments[len(comments)-1]
|
||||||
|
assert.Equal(t, user.ID, comment.PosterID)
|
||||||
|
assert.Equal(t, content.Content, comment.Content)
|
||||||
|
assert.NoError(t, comment.LoadAttachments(db.DefaultContext))
|
||||||
|
assert.Len(t, comment.Attachments, 1)
|
||||||
|
attachment := comment.Attachments[0]
|
||||||
|
assert.Equal(t, content.Attachments[0].Name, attachment.Name)
|
||||||
|
assert.EqualValues(t, 4, attachment.Size)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CodeComment", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 6})
|
||||||
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
|
||||||
|
|
||||||
|
handler := &incoming.ReplyHandler{}
|
||||||
|
content := &incoming.MailContent{
|
||||||
|
Content: "code reply by mail",
|
||||||
|
Attachments: []*incoming.Attachment{
|
||||||
|
{
|
||||||
|
Name: "attachment.txt",
|
||||||
|
Content: []byte("test"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := incoming_payload.CreateReferencePayload(comment)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload))
|
||||||
|
|
||||||
|
comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{
|
||||||
|
IssueID: issue.ID,
|
||||||
|
Type: issues_model.CommentTypeCode,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, comments)
|
||||||
|
comment = comments[len(comments)-1]
|
||||||
|
assert.Equal(t, user.ID, comment.PosterID)
|
||||||
|
assert.Equal(t, content.Content, comment.Content)
|
||||||
|
assert.NoError(t, comment.LoadAttachments(db.DefaultContext))
|
||||||
|
assert.Len(t, comment.Attachments, 1)
|
||||||
|
attachment := comment.Attachments[0]
|
||||||
|
assert.Equal(t, content.Attachments[0].Name, attachment.Name)
|
||||||
|
assert.EqualValues(t, 4, attachment.Size)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Unsubscribe", func(t *testing.T) {
|
||||||
defer tests.PrintCurrentTest(t)()
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
handler := &incoming.ReplyHandler{}
|
watching, err := issues_model.CheckIssueWatch(db.DefaultContext, user, issue)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, watching)
|
||||||
|
|
||||||
|
handler := &incoming.UnsubscribeHandler{}
|
||||||
|
|
||||||
|
content := &incoming.MailContent{
|
||||||
|
Content: "unsub me",
|
||||||
|
}
|
||||||
|
|
||||||
payload, err := incoming_payload.CreateReferencePayload(issue)
|
payload, err := incoming_payload.CreateReferencePayload(issue)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Error(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, nil, payload))
|
|
||||||
assert.NoError(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, user, payload))
|
|
||||||
|
|
||||||
content := &incoming.MailContent{
|
|
||||||
Content: "reply by mail",
|
|
||||||
Attachments: []*incoming.Attachment{
|
|
||||||
{
|
|
||||||
Name: "attachment.txt",
|
|
||||||
Content: []byte("test"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload))
|
assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload))
|
||||||
|
|
||||||
comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{
|
watching, err = issues_model.CheckIssueWatch(db.DefaultContext, user, issue)
|
||||||
IssueID: issue.ID,
|
|
||||||
Type: issues_model.CommentTypeComment,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, comments)
|
assert.False(t, watching)
|
||||||
comment := comments[len(comments)-1]
|
|
||||||
assert.Equal(t, user.ID, comment.PosterID)
|
|
||||||
assert.Equal(t, content.Content, comment.Content)
|
|
||||||
assert.NoError(t, comment.LoadAttachments(db.DefaultContext))
|
|
||||||
assert.Len(t, comment.Attachments, 1)
|
|
||||||
attachment := comment.Attachments[0]
|
|
||||||
assert.Equal(t, content.Attachments[0].Name, attachment.Name)
|
|
||||||
assert.EqualValues(t, 4, attachment.Size)
|
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("CodeComment", func(t *testing.T) {
|
if setting.IncomingEmail.Enabled {
|
||||||
|
// This test connects to the configured email server and is currently only enabled for MySql integration tests.
|
||||||
|
// It sends a reply to create a comment. If the comment is not detected after 10 seconds the test fails.
|
||||||
|
t.Run("IMAP", func(t *testing.T) {
|
||||||
defer tests.PrintCurrentTest(t)()
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 6})
|
payload, err := incoming_payload.CreateReferencePayload(issue)
|
||||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
|
assert.NoError(t, err)
|
||||||
|
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
|
||||||
handler := &incoming.ReplyHandler{}
|
|
||||||
content := &incoming.MailContent{
|
|
||||||
Content: "code reply by mail",
|
|
||||||
Attachments: []*incoming.Attachment{
|
|
||||||
{
|
|
||||||
Name: "attachment.txt",
|
|
||||||
Content: []byte("test"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := incoming_payload.CreateReferencePayload(comment)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload))
|
msg := gomail.NewMessage()
|
||||||
|
msg.SetHeader("To", strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1))
|
||||||
comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{
|
msg.SetHeader("From", user.Email)
|
||||||
IssueID: issue.ID,
|
msg.SetBody("text/plain", token)
|
||||||
Type: issues_model.CommentTypeCode,
|
err = gomail.Send(&smtpTestSender{}, msg)
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotEmpty(t, comments)
|
|
||||||
comment = comments[len(comments)-1]
|
assert.Eventually(t, func() bool {
|
||||||
assert.Equal(t, user.ID, comment.PosterID)
|
comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{
|
||||||
assert.Equal(t, content.Content, comment.Content)
|
IssueID: issue.ID,
|
||||||
assert.NoError(t, comment.LoadAttachments(db.DefaultContext))
|
Type: issues_model.CommentTypeComment,
|
||||||
assert.Empty(t, comment.Attachments)
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, comments)
|
||||||
|
|
||||||
|
comment := comments[len(comments)-1]
|
||||||
|
|
||||||
|
return comment.PosterID == user.ID && comment.Content == token
|
||||||
|
}, 10*time.Second, 1*time.Second)
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
t.Run("Unsubscribe", func(t *testing.T) {
|
|
||||||
defer tests.PrintCurrentTest(t)()
|
|
||||||
|
|
||||||
watching, err := issues_model.CheckIssueWatch(db.DefaultContext, user, issue)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.True(t, watching)
|
|
||||||
|
|
||||||
handler := &incoming.UnsubscribeHandler{}
|
|
||||||
|
|
||||||
content := &incoming.MailContent{
|
|
||||||
Content: "unsub me",
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := incoming_payload.CreateReferencePayload(issue)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload))
|
|
||||||
|
|
||||||
watching, err = issues_model.CheckIssueWatch(db.DefaultContext, user, issue)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.False(t, watching)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if setting.IncomingEmail.Enabled {
|
|
||||||
// This test connects to the configured email server and is currently only enabled for MySql integration tests.
|
|
||||||
// It sends a reply to create a comment. If the comment is not detected after 10 seconds the test fails.
|
|
||||||
t.Run("IMAP", func(t *testing.T) {
|
|
||||||
defer tests.PrintCurrentTest(t)()
|
|
||||||
|
|
||||||
payload, err := incoming_payload.CreateReferencePayload(issue)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
msg := gomail.NewMessage()
|
|
||||||
msg.SetHeader("To", strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1))
|
|
||||||
msg.SetHeader("From", user.Email)
|
|
||||||
msg.SetBody("text/plain", token)
|
|
||||||
err = gomail.Send(&smtpTestSender{}, msg)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Eventually(t, func() bool {
|
|
||||||
comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{
|
|
||||||
IssueID: issue.ID,
|
|
||||||
Type: issues_model.CommentTypeComment,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotEmpty(t, comments)
|
|
||||||
|
|
||||||
comment := comments[len(comments)-1]
|
|
||||||
|
|
||||||
return comment.PosterID == user.ID && comment.Content == token
|
|
||||||
}, 10*time.Second, 1*time.Second)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A simple SMTP mail sender used for integration tests.
|
// A simple SMTP mail sender used for integration tests.
|
||||||
|
|
Loading…
Reference in New Issue