snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
with-expecter: false
packages:
github.com/mattermost/mattermost/server/v8/channels/app/email:
config:
dir: "channels/app/email/mocks"
filename: "{{.InterfaceName}}.go"
mockname: "{{.InterfaceName}}"
outpkg: mocks
note: "Regenerate this file using `make email-mocks`."
interfaces:
ServiceInterface:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,388 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"bytes"
"fmt"
"html/template"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
const (
EmailBatchingTaskName = "Email Batching"
)
type postData struct {
SenderName string
ChannelName string
Message template.HTML
MessageURL string
SenderPhoto string
PostPhoto string
Time string
ShowChannelIcon bool
OtherChannelMembersCount int
MessageAttachments []*EmailMessageAttachment
}
func (es *Service) InitEmailBatching() {
if *es.config().EmailSettings.EnableEmailBatching {
if es.EmailBatching == nil {
es.EmailBatching = NewEmailBatchingJob(es, *es.config().EmailSettings.EmailBatchingBufferSize)
}
// note that we don't support changing EmailBatchingBufferSize without restarting the server
es.EmailBatching.Start()
}
}
func (es *Service) AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError {
if !*es.config().EmailSettings.EnableEmailBatching {
return model.NewAppError("AddNotificationEmailToBatch", "api.email_batching.add_notification_email_to_batch.disabled.app_error", nil, "", http.StatusNotImplemented)
}
if !es.EmailBatching.Add(user, post, team) {
mlog.Error("Email batching job's receiving buffer was full. Please increase the EmailBatchingBufferSize. Falling back to sending immediate mail.")
return model.NewAppError("AddNotificationEmailToBatch", "api.email_batching.add_notification_email_to_batch.channel_full.app_error", nil, "", http.StatusInternalServerError)
}
return nil
}
type batchedNotification struct {
userID string
post *model.Post
teamName string
}
type EmailBatchingJob struct {
config func() *model.Config
service *Service
newNotifications chan *batchedNotification
pendingNotifications map[string][]*batchedNotification
task *model.ScheduledTask
taskMutex sync.Mutex
}
func NewEmailBatchingJob(es *Service, bufferSize int) *EmailBatchingJob {
return &EmailBatchingJob{
config: es.config,
service: es,
newNotifications: make(chan *batchedNotification, bufferSize),
pendingNotifications: make(map[string][]*batchedNotification),
}
}
func (job *EmailBatchingJob) Start() {
mlog.Debug("Email batching job starting. Checking for pending emails periodically.", mlog.Int("interval_in_seconds", *job.config().EmailSettings.EmailBatchingInterval))
newTask := model.CreateRecurringTask(EmailBatchingTaskName, job.CheckPendingEmails, time.Duration(*job.config().EmailSettings.EmailBatchingInterval)*time.Second)
job.taskMutex.Lock()
oldTask := job.task
job.task = newTask
job.taskMutex.Unlock()
if oldTask != nil {
oldTask.Cancel()
}
}
// Stop will cancel the task properly, flushing out any pending notifications.
// Although this still won't send those notifications which are yet to be sent
// due to a user's PreferenceNameEmailInterval.
func (job *EmailBatchingJob) Stop() {
job.taskMutex.Lock()
if task := job.task; task != nil {
task.Cancel()
}
job.taskMutex.Unlock()
}
func (job *EmailBatchingJob) Add(user *model.User, post *model.Post, team *model.Team) bool {
notification := &batchedNotification{
userID: user.Id,
post: post,
teamName: team.Name,
}
select {
case job.newNotifications <- notification:
return true
default:
// return false if we couldn't queue the email notification so that we can send an immediate email
return false
}
}
func (job *EmailBatchingJob) CheckPendingEmails() {
job.handleNewNotifications()
// it's a bit weird to pass the send email function through here, but it makes it so that we can test
// without actually sending emails
job.checkPendingNotifications(time.Now(), job.service.sendBatchedEmailNotification)
mlog.Debug("Email batching job ran. Notifications might be still pending.", mlog.Int("number_of_users", len(job.pendingNotifications)))
}
func (job *EmailBatchingJob) handleNewNotifications() {
receiving := true
// read in new notifications to send
for receiving {
select {
case notification := <-job.newNotifications:
userID := notification.userID
if _, ok := job.pendingNotifications[userID]; !ok {
job.pendingNotifications[userID] = []*batchedNotification{notification}
} else {
job.pendingNotifications[userID] = append(job.pendingNotifications[userID], notification)
}
default:
receiving = false
}
}
}
func (job *EmailBatchingJob) checkPendingNotifications(now time.Time, handler func(string, []*batchedNotification)) {
for userID, notifications := range job.pendingNotifications {
// Defensive code.
if len(notifications) == 0 {
mlog.Warn("Unexpected result. Got 0 pending notifications for batched email.", mlog.String("user_id", userID))
continue
}
// get how long we need to wait to send notifications to the user
var interval int64
preference, err := job.service.store.Preference().Get(userID, model.PreferenceCategoryNotifications, model.PreferenceNameEmailInterval)
if err != nil {
// use the default batching interval if an error occurs while fetching user preferences
interval, _ = strconv.ParseInt(model.PreferenceEmailIntervalBatchingSeconds, 10, 64)
} else {
if value, err := strconv.ParseInt(preference.Value, 10, 64); err != nil {
// // use the default batching interval if an error occurs while deserializing user preferences
interval, _ = strconv.ParseInt(model.PreferenceEmailIntervalBatchingSeconds, 10, 64)
} else {
interval = value
}
}
batchStartTime := notifications[0].post.CreateAt
// Ignore if it isn't time yet to send.
if now.Sub(time.UnixMilli(batchStartTime)) <= time.Duration(interval)*time.Second {
continue
}
// If the user has viewed any channels in this team since the notification was queued, delete
// all queued notifications
inspectedTeamNames := make(map[string]string)
for _, notification := range notifications {
// at most, we'll do one check for each team that notifications were sent for
if inspectedTeamNames[notification.teamName] != "" {
continue
}
team, nErr := job.service.store.Team().GetByName(notifications[0].teamName)
if nErr != nil {
mlog.Error("Unable to find Team id for notification", mlog.Err(nErr))
continue
}
if team != nil {
inspectedTeamNames[notification.teamName] = team.Id
}
channelMembers, err := job.service.store.Channel().GetMembersForUser(inspectedTeamNames[notification.teamName], userID)
if err != nil {
mlog.Error("Unable to find ChannelMembers for user", mlog.Err(err))
continue
}
deleted := false
for _, channelMember := range channelMembers {
if channelMember.LastViewedAt >= batchStartTime {
mlog.Debug("Deleted notifications for user", mlog.String("user_id", userID))
delete(job.pendingNotifications, userID)
deleted = true
break
}
}
if deleted {
break
}
}
// The notifications might have been cleared from the above step.
// We need to check again.
if len(job.pendingNotifications[userID]) == 0 {
continue
}
handler(userID, job.pendingNotifications[userID])
delete(job.pendingNotifications, userID)
}
}
/**
* If the name is longer than i characters, replace remaining characters with ...
*/
func truncateUserNames(name string, i int) string {
runes := []rune(name)
if len(runes) > i {
newString := string(runes[:i])
return newString + "..."
}
return name
}
func (es *Service) sendBatchedEmailNotification(userID string, notifications []*batchedNotification) {
user, err := es.userService.GetUser(request.EmptyContext(es.logger), userID)
if err != nil {
mlog.Warn("Unable to find recipient for batched email notification")
return
}
translateFunc := i18n.GetUserTranslations(user.Locale)
displayNameFormat := *es.config().TeamSettings.TeammateNameDisplay
siteURL := *es.config().ServiceSettings.SiteURL
postsData := make([]*postData, 0 /* len */, len(notifications) /* cap */)
embeddedFiles := make(map[string]io.Reader)
emailNotificationContentsType := model.EmailNotificationContentsFull
if license := es.license(); license != nil && *license.Features.EmailNotificationContents {
emailNotificationContentsType = *es.config().EmailSettings.EmailNotificationContentsType
}
// check if user has CRT set to ON
appCRT := *es.config().ServiceSettings.CollapsedThreads
threadsEnabled := appCRT == model.CollapsedThreadsAlwaysOn
if !threadsEnabled && appCRT != model.CollapsedThreadsDisabled {
threadsEnabled = appCRT == model.CollapsedThreadsDefaultOn
// check if a participant has overridden collapsed threads settings
if preference, errCrt := es.store.Preference().Get(userID, model.PreferenceCategoryDisplaySettings, model.PreferenceNameCollapsedThreadsEnabled); errCrt == nil {
threadsEnabled = preference.Value == "on"
}
}
var useMilitaryTime bool
if data, err := es.store.Preference().Get(user.Id, model.PreferenceCategoryDisplaySettings, model.PreferenceNameUseMilitaryTime); err != nil {
useMilitaryTime = false
} else {
useMilitaryTime = data.Value == "true"
}
if emailNotificationContentsType == model.EmailNotificationContentsFull {
for i, notification := range notifications {
sender, errSender := es.userService.GetUser(request.EmptyContext(es.logger), notification.post.UserId)
if errSender != nil {
mlog.Warn("Unable to find sender of post for batched email notification")
}
channel, errCh := es.store.Channel().Get(notification.post.ChannelId, true)
if errCh != nil {
mlog.Warn("Unable to find channel of post for batched email notification")
}
senderProfileImage, _, errProfileImage := es.userService.GetProfileImage(sender)
if errProfileImage != nil {
mlog.Warn("Unable to get the sender user profile image.", mlog.String("user_id", sender.Id), mlog.Err(errProfileImage))
}
senderPhoto := fmt.Sprintf("user-avatar-%d.png", i)
if senderProfileImage != nil {
embeddedFiles[senderPhoto] = bytes.NewReader(senderProfileImage)
}
formattedTime := utils.GetFormattedPostTime(user, notification.post, useMilitaryTime, translateFunc)
t := translateFunc("api.email_batching.send_batched_email_notification.time", formattedTime)
MessageURL := siteURL + "/" + notification.teamName + "/pl/" + notification.post.Id
channelDisplayName := channel.DisplayName
showChannelIcon := true
otherChannelMembersCount := 0
if threadsEnabled && notification.post.RootId != "" {
props := map[string]any{"channelName": channelDisplayName}
channelDisplayName = translateFunc("api.push_notification.title.collapsed_threads", props)
if channel.Type == model.ChannelTypeDirect {
channelDisplayName = translateFunc("api.push_notification.title.collapsed_threads_dm")
}
}
if channel.Type == model.ChannelTypeGroup {
otherChannelMembersCount = len(strings.Split(channelDisplayName, ",")) - 1
showChannelIcon = false
channelDisplayName = truncateUserNames(channel.DisplayName, 11)
}
postMessage := es.GetMessageForNotification(notification.post, notification.teamName, siteURL, translateFunc)
postsData = append(postsData, &postData{
SenderPhoto: senderPhoto,
SenderName: truncateUserNames(sender.GetDisplayName(displayNameFormat), 22),
Time: t,
ChannelName: channelDisplayName,
Message: template.HTML(postMessage),
MessageURL: MessageURL,
ShowChannelIcon: showChannelIcon,
OtherChannelMembersCount: otherChannelMembersCount,
MessageAttachments: ProcessMessageAttachments(notification.post, siteURL),
})
}
}
formattedTime := utils.GetFormattedPostTime(user, notifications[0].post, useMilitaryTime, translateFunc)
subject := translateFunc("api.email_batching.send_batched_email_notification.subject", len(notifications), map[string]any{
"SiteName": es.config().TeamSettings.SiteName,
"Year": formattedTime.Year,
"Month": formattedTime.Month,
"Day": formattedTime.Day,
})
data := es.NewEmailTemplateData(user.Locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = translateFunc("api.email_batching.send_batched_email_notification.title", len(notifications)-1)
data.Props["SubTitle"] = translateFunc("api.email_batching.send_batched_email_notification.subTitle")
data.Props["Button"] = translateFunc("api.email_batching.send_batched_email_notification.button")
data.Props["ButtonURL"] = siteURL
data.Props["Posts"] = postsData
data.Props["MessageButton"] = translateFunc("api.email_batching.send_batched_email_notification.messageButton")
data.Props["NotificationFooterTitle"] = translateFunc("app.notification.footer.title")
data.Props["NotificationFooterInfoLogin"] = translateFunc("app.notification.footer.infoLogin")
data.Props["NotificationFooterInfo"] = translateFunc("app.notification.footer.info")
renderedPage, renderErr := es.templatesContainer.RenderToString("messages_notification", data)
if renderErr != nil {
mlog.Error("Unable to render email", mlog.Err(renderErr))
}
if nErr := es.SendMailWithEmbeddedFiles(user.Email, subject, renderedPage, embeddedFiles, "", "", "", "BatchedEmailNotification"); nErr != nil {
mlog.Warn("Unable to send batched email notification", mlog.String("email", user.Email), mlog.Err(nErr))
return
}
// Only track post deliveries if email notifications are configured to send the post contents in the email
if es.postDeliveryRecorder != nil && emailNotificationContentsType == model.EmailNotificationContentsFull {
for _, notification := range notifications {
es.postDeliveryRecorder(userID, notification.post)
}
}
}

View File

@@ -0,0 +1,285 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestHandleNewNotifications(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
id1 := model.NewId()
id2 := model.NewId()
id3 := model.NewId()
// test queueing of received posts by user
job := NewEmailBatchingJob(th.service, 128)
job.handleNewNotifications()
require.Empty(t, job.pendingNotifications, "shouldn't have added any pending notifications")
job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"})
require.Empty(t, job.pendingNotifications, "shouldn't have added any pending notifications")
job.handleNewNotifications()
require.Len(t, job.pendingNotifications, 1, "should have received posts for 1 user")
require.Len(t, job.pendingNotifications[id1], 1, "should have received 1 post for user")
job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"})
job.handleNewNotifications()
require.Len(t, job.pendingNotifications, 1, "should have received posts for 1 user")
require.Len(t, job.pendingNotifications[id1], 2, "should have received 2 posts for user1")
job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"})
job.handleNewNotifications()
require.Len(t, job.pendingNotifications, 2, "should have received posts for 2 users")
require.Len(t, job.pendingNotifications[id1], 2, "should have received 2 posts for user1")
require.Len(t, job.pendingNotifications[id2], 1, "should have received 1 post for user2")
job.Add(&model.User{Id: id2}, &model.Post{UserId: id2, Message: "test"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id1}, &model.Post{UserId: id3, Message: "test"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id3}, &model.Post{UserId: id3, Message: "test"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id2}, &model.Post{UserId: id2, Message: "test"}, &model.Team{Name: "team"})
job.handleNewNotifications()
require.Len(t, job.pendingNotifications, 3, "should have received posts for 3 users")
require.Len(t, job.pendingNotifications[id1], 3, "should have received 3 posts for user1")
require.Len(t, job.pendingNotifications[id2], 3, "should have received 3 posts for user2")
require.Len(t, job.pendingNotifications[id3], 1, "should have received 1 post for user3")
// test ordering of received posts
job = NewEmailBatchingJob(th.service, 128)
job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test1"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test2"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test3"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test4"}, &model.Team{Name: "team"})
job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test5"}, &model.Team{Name: "team"})
job.handleNewNotifications()
assert.Equal(t, job.pendingNotifications[id1][0].post.Message, "test1", "incorrect order of received posts for user1")
assert.Equal(t, job.pendingNotifications[id1][1].post.Message, "test2", "incorrect order of received posts for user1")
assert.Equal(t, job.pendingNotifications[id1][2].post.Message, "test4", "incorrect order of received posts for user1")
assert.Equal(t, job.pendingNotifications[id2][0].post.Message, "test3", "incorrect order of received posts for user2")
assert.Equal(t, job.pendingNotifications[id2][1].post.Message, "test5", "incorrect order of received posts for user2")
}
func TestCheckPendingNotifications(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
job := NewEmailBatchingJob(th.service, 128)
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10000000,
},
teamName: th.BasicTeam.Name,
},
}
channelMember, err := th.store.Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 9999999
_, err = th.store.Channel().UpdateMember(th.Context, channelMember)
require.NoError(t, err)
nErr := th.store.Preference().Save(model.Preferences{{
UserId: th.BasicUser.Id,
Category: model.PreferenceCategoryNotifications,
Name: model.PreferenceNameEmailInterval,
Value: "60",
}})
require.NoError(t, nErr)
// test that notifications aren't sent before interval
job.checkPendingNotifications(time.Unix(10001, 0), func(string, []*batchedNotification) {})
require.NotNil(t, job.pendingNotifications[th.BasicUser.Id])
require.Len(t, job.pendingNotifications[th.BasicUser.Id], 1, "shouldn't have sent queued post")
// test that notifications are cleared if the user has acted
channelMember, err = th.store.Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 10001000
_, err = th.store.Channel().UpdateMember(th.Context, channelMember)
require.NoError(t, err)
// We reset the interval to something shorter
nErr = th.store.Preference().Save(model.Preferences{{
UserId: th.BasicUser.Id,
Category: model.PreferenceCategoryNotifications,
Name: model.PreferenceNameEmailInterval,
Value: "10",
}})
require.NoError(t, nErr)
var wasCalled atomic.Int32
job.checkPendingNotifications(time.Unix(10050, 0), func(string, []*batchedNotification) {
wasCalled.Store(int32(1))
})
// A hack to check whether the handler was called.
// It's not straightforward to just wait for it using a channel because the test should
// NOT call the handler, and it will be called only if the test fails.
time.Sleep(1 * time.Second)
// We do a check outside the email handler, because otherwise, failing from
// inside the handler doesn't let the .Go() function exit cleanly, and it gets
// stuck during server shutdown, trying to wait for the goroutine to exit
require.Equal(t, int32(0), wasCalled.Load(), "email handler should not have been called")
require.Nil(t, job.pendingNotifications[th.BasicUser.Id])
require.Empty(t, job.pendingNotifications[th.BasicUser.Id], "should've remove queued post since user acted")
// test that notifications are sent if enough time passes since the first message
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10060000,
Message: "post1",
},
teamName: th.BasicTeam.Name,
},
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10090000,
Message: "post2",
},
teamName: th.BasicTeam.Name,
},
}
received := make(chan *model.Post, 2)
job.checkPendingNotifications(time.Unix(10130, 0), func(s string, notifications []*batchedNotification) {
for _, notification := range notifications {
received <- notification.post
}
})
require.Nil(t, job.pendingNotifications[th.BasicUser.Id], "shouldn't have sent queued post")
select {
case post := <-received:
require.Equal(t, post.Message, "post1", "should've received post1 first")
case <-time.After(5 * time.Second):
require.Fail(t, "timed out waiting for first post notification")
}
select {
case post := <-received:
require.Equal(t, post.Message, "post2", "should've received post2 second")
case <-time.After(5 * time.Second):
require.Fail(t, "timed out waiting for second post notification")
}
}
/**
* Ensures that email batch interval defaults to 15 minutes for users that haven't explicitly set this preference
*/
func TestCheckPendingNotificationsDefaultInterval(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
job := NewEmailBatchingJob(th.service, 128)
// bypasses recent user activity check
require.NotNil(t, th.store)
require.NotNil(t, th.store.Channel())
require.NotNil(t, th.BasicUser)
require.NotNil(t, th.BasicChannel)
channelMember, err := th.store.Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 9999000
_, err = th.store.Channel().UpdateMember(th.Context, channelMember)
require.NoError(t, err)
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10000000,
},
teamName: th.BasicTeam.Name,
},
}
// notifications should not be sent 1s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10001, 0), func(string, []*batchedNotification) {})
require.NotNil(t, job.pendingNotifications[th.BasicUser.Id])
require.Len(t, job.pendingNotifications[th.BasicUser.Id], 1, "shouldn't have sent queued post")
// notifications should be sent 901s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10901, 0), func(string, []*batchedNotification) {})
require.Nil(t, job.pendingNotifications[th.BasicUser.Id])
require.Empty(t, job.pendingNotifications[th.BasicUser.Id], "should have sent queued post")
}
/**
* Ensures that email batch interval defaults to 15 minutes if user preference is invalid
*/
func TestCheckPendingNotificationsCantParseInterval(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
job := NewEmailBatchingJob(th.service, 128)
require.NotNil(t, th.store)
require.NotNil(t, th.store.Channel())
require.NotNil(t, th.BasicChannel)
require.NotNil(t, th.BasicUser)
// bypasses recent user activity check
channelMember, err := th.store.Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 9999000
_, err = th.store.Channel().UpdateMember(th.Context, channelMember)
require.NoError(t, err)
// preference value is not an integer, so we'll fall back to the default 15min value
nErr := th.store.Preference().Save(model.Preferences{{
UserId: th.BasicUser.Id,
Category: model.PreferenceCategoryNotifications,
Name: model.PreferenceNameEmailInterval,
Value: "notAnIntegerValue",
}})
require.NoError(t, nErr)
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10000000,
},
teamName: th.BasicTeam.Name,
},
}
// notifications should not be sent 1s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10001, 0), func(string, []*batchedNotification) {})
require.NotNil(t, job.pendingNotifications[th.BasicUser.Id])
require.Len(t, job.pendingNotifications[th.BasicUser.Id], 1, "shouldn't have sent queued post")
// notifications should be sent 901s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10901, 0), func(string, []*batchedNotification) {})
require.Nil(t, job.pendingNotifications[th.BasicUser.Id], "should have sent queued post")
}

View File

@@ -0,0 +1,486 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"html"
"net/url"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/platform/shared/mail"
)
func TestCondenseSiteURL(t *testing.T) {
mainHelper.Parallel(t)
require.Equal(t, "", condenseSiteURL(""))
require.Equal(t, "mattermost.com", condenseSiteURL("mattermost.com"))
require.Equal(t, "mattermost.com", condenseSiteURL("mattermost.com/"))
require.Equal(t, "chat.mattermost.com", condenseSiteURL("chat.mattermost.com"))
require.Equal(t, "chat.mattermost.com", condenseSiteURL("chat.mattermost.com/"))
require.Equal(t, "mattermost.com/subpath", condenseSiteURL("mattermost.com/subpath"))
require.Equal(t, "mattermost.com/subpath", condenseSiteURL("mattermost.com/subpath/"))
require.Equal(t, "chat.mattermost.com/subpath", condenseSiteURL("chat.mattermost.com/subpath"))
require.Equal(t, "chat.mattermost.com/subpath", condenseSiteURL("chat.mattermost.com/subpath/"))
require.Equal(t, "mattermost.com:8080", condenseSiteURL("http://mattermost.com:8080"))
require.Equal(t, "mattermost.com:8080", condenseSiteURL("http://mattermost.com:8080/"))
require.Equal(t, "chat.mattermost.com:8080", condenseSiteURL("http://chat.mattermost.com:8080"))
require.Equal(t, "chat.mattermost.com:8080", condenseSiteURL("http://chat.mattermost.com:8080/"))
require.Equal(t, "mattermost.com:8080/subpath", condenseSiteURL("http://mattermost.com:8080/subpath"))
require.Equal(t, "mattermost.com:8080/subpath", condenseSiteURL("http://mattermost.com:8080/subpath/"))
require.Equal(t, "chat.mattermost.com:8080/subpath", condenseSiteURL("http://chat.mattermost.com:8080/subpath"))
require.Equal(t, "chat.mattermost.com:8080/subpath", condenseSiteURL("http://chat.mattermost.com:8080/subpath/"))
}
func TestGetLicenseSkuName(t *testing.T) {
tests := []struct {
name string
license *model.License
expectedSku string
expectedPrefixed string
}{
{
name: "nil license",
license: nil,
expectedSku: "Mattermost",
expectedPrefixed: "Mattermost",
},
{
name: "empty sku name",
license: &model.License{SkuName: ""},
expectedSku: "Mattermost",
expectedPrefixed: "Mattermost",
},
{
name: "Professional",
license: &model.License{SkuName: "Professional"},
expectedSku: "Professional",
expectedPrefixed: "Mattermost Professional",
},
{
name: "Enterprise",
license: &model.License{SkuName: "Enterprise"},
expectedSku: "Enterprise",
expectedPrefixed: "Mattermost Enterprise",
},
{
name: "Enterprise Advanced",
license: &model.License{SkuName: "Enterprise Advanced"},
expectedSku: "Enterprise Advanced",
expectedPrefixed: "Mattermost Enterprise Advanced",
},
{
name: "Entry",
license: &model.License{SkuName: "Entry"},
expectedSku: "Entry",
expectedPrefixed: "Mattermost Entry",
},
{
name: "Mattermost Entry (prefixed by license server)",
license: &model.License{SkuName: "Mattermost Entry"},
expectedSku: "Entry",
expectedPrefixed: "Mattermost Entry",
},
{
name: "E10",
license: &model.License{SkuName: "E10"},
expectedSku: "E10",
expectedPrefixed: "Mattermost E10",
},
{
name: "E20",
license: &model.License{SkuName: "E20"},
expectedSku: "E20",
expectedPrefixed: "Mattermost E20",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
es := &Service{
license: func() *model.License { return tt.license },
}
require.Equal(t, tt.expectedSku, es.getLicenseSkuName())
require.Equal(t, tt.expectedPrefixed, es.getPrefixedLicenseSkuName())
})
}
}
func TestSendInviteEmails(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.ConfigureInbucketMail(t)
emailTo := strings.ToLower(model.NewId()) + "@example.com"
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
newInviteData := func() InviteEmailData {
return InviteEmailData{
Team: th.BasicTeam,
SenderName: "test-user",
SenderUserID: th.BasicUser.Id,
Invites: []string{emailTo},
SiteURL: "http://testserver",
}
}
retrieveEmail := func(t *testing.T) mail.JSONMessageInbucket {
t.Helper()
var resultsMailbox mail.JSONMessageHeaderInbucket
err2 := mail.RetryInbucket(5, func() error {
var err error
resultsMailbox, err = mail.GetMailBox(emailTo)
return err
})
if err2 != nil {
t.Skipf("No email was received, maybe due load on the server: %v", err2)
}
require.Len(t, resultsMailbox, 1)
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
return resultsEmail
}
verifyMailbox := func(t *testing.T) {
t.Helper()
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "http://testserver", "Wrong received message %s", email.Body.Text)
require.Contains(t, email.Body.HTML, "test-user", "Wrong received message %s", email.Body.Text)
require.Contains(t, email.Body.Text, "http://testserver", "Wrong received message %s", email.Body.Text)
require.Contains(t, email.Body.Text, "test-user", "Wrong received message %s", email.Body.Text)
}
th.UpdateConfig(t, func(cfg *model.Config) {
*cfg.ServiceSettings.EnableEmailInvitations = true
*cfg.EmailSettings.SendEmailNotifications = false
})
t.Run("SendInviteEmails", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendInviteEmails(th.Context, newInviteData())
require.NoError(t, err)
verifyMailbox(t)
})
t.Run("SendInviteEmails can return error when SMTP connection fails", func(t *testing.T) {
originalPort := *th.service.config().EmailSettings.SMTPPort
originalTimeout := *th.service.config().EmailSettings.SMTPServerTimeout
th.UpdateConfig(t, func(cfg *model.Config) {
*cfg.EmailSettings.SMTPPort = "5432"
*cfg.EmailSettings.SMTPServerTimeout = 4
})
defer th.UpdateConfig(t, func(cfg *model.Config) {
*cfg.EmailSettings.SMTPPort = originalPort
*cfg.EmailSettings.SMTPServerTimeout = originalTimeout
})
inviteData := newInviteData()
inviteData.ErrorWhenNotSent = true
err := th.service.SendInviteEmails(th.Context, inviteData)
require.Error(t, err)
err = th.service.SendInviteEmails(th.Context, newInviteData())
require.NoError(t, err)
})
t.Run("SendGuestInviteEmails", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendGuestInviteEmails(
th.Context,
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
"hello world",
false,
false,
false,
false,
)
require.NoError(t, err)
verifyMailbox(t)
})
t.Run("SendGuestInviteEmail can return error when SMTP connection fails", func(t *testing.T) {
originalTimeout := *th.service.config().EmailSettings.SMTPServerTimeout
originalPort := *th.service.config().EmailSettings.SMTPPort
th.UpdateConfig(t, func(cfg *model.Config) {
*cfg.EmailSettings.SMTPPort = "5432"
*cfg.EmailSettings.SMTPServerTimeout = 4
})
defer th.UpdateConfig(t, func(cfg *model.Config) {
*cfg.EmailSettings.SMTPPort = originalPort
*cfg.EmailSettings.SMTPServerTimeout = originalTimeout
})
err := th.service.SendGuestInviteEmails(
th.Context,
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
"hello world",
false,
false,
false,
false,
)
require.NoError(t, err)
err = th.service.SendGuestInviteEmails(
th.Context,
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
"hello world",
true,
false,
false,
false,
)
require.Error(t, err)
})
t.Run("SendGuestInviteEmails should sanitize HTML input", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
message := `<a href="http://testserver">sanitized message</a>`
err = th.service.SendGuestInviteEmails(
th.Context,
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
message,
false,
false,
false,
false,
)
require.NoError(t, err)
email := retrieveEmail(t)
require.NotContains(t, email.Body.HTML, message)
require.Contains(t, email.Body.HTML, "sanitized message")
require.Contains(t, email.Body.Text, "sanitized message")
})
t.Run("SendInviteEmails should contain button URL with 'started by role' param for system user", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendInviteEmails(th.Context, newInviteData())
require.NoError(t, err)
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "&amp;sbr=su")
})
t.Run("SendInviteEmails should contain button URL with 'started by role' param for system admin", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
inviteData := newInviteData()
inviteData.IsSystemAdmin = true
err = th.service.SendInviteEmails(th.Context, inviteData)
require.NoError(t, err)
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "&amp;sbr=sa")
})
t.Run("SendInviteEmails should contain button URL with 'started by role' param for first system admin", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
inviteData := newInviteData()
inviteData.IsSystemAdmin = true
inviteData.IsFirstAdmin = true
err = th.service.SendInviteEmails(th.Context, inviteData)
require.NoError(t, err)
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "&amp;sbr=fa")
})
t.Run("SendInviteEmails with profiles should put profile fields into token extra and link data", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
profiles := map[string]*model.MemberInviteProfile{
emailTo: {
Email: emailTo,
Username: "dave.roberts",
FirstName: "Dave",
LastName: "Roberts",
},
}
inviteData := newInviteData()
inviteData.Profiles = profiles
err = th.service.SendInviteEmails(th.Context, inviteData)
require.NoError(t, err)
email := retrieveEmail(t)
token := findTokenFromEmail(t, th, email.Body.HTML)
tokenData := model.MapFromJSON(strings.NewReader(token.Extra))
require.Equal(t, emailTo, tokenData["email"])
require.Equal(t, "dave.roberts", tokenData["username"])
require.Equal(t, "Dave", tokenData["first_name"])
require.Equal(t, "Roberts", tokenData["last_name"])
linkData := findLinkDataFromEmail(t, email.Body.HTML)
require.Equal(t, "dave.roberts", linkData["username"])
require.Equal(t, "Dave", linkData["first_name"])
require.Equal(t, "Roberts", linkData["last_name"])
})
}
// findSignupQueryFromEmail extracts the signup_user_complete query parameters from an invite email body.
func findSignupQueryFromEmail(t *testing.T, emailHTML string) url.Values {
t.Helper()
re := regexp.MustCompile(`signup_user_complete/\?([^"]*)`)
matches := re.FindStringSubmatch(html.UnescapeString(emailHTML))
require.Len(t, matches, 2, "invite email should contain a signup link")
queryString, err := url.ParseQuery(matches[1])
require.NoError(t, err)
return queryString
}
// findTokenFromEmail loads the invitation token referenced by an invite email body.
func findTokenFromEmail(t *testing.T, th *TestHelper, emailHTML string) *model.Token {
t.Helper()
queryString := findSignupQueryFromEmail(t, emailHTML)
token, err := th.service.store.Token().GetByToken(queryString.Get("t"))
require.NoError(t, err)
return token
}
// findLinkDataFromEmail parses the d prefill param of the signup link in an invite email body.
func findLinkDataFromEmail(t *testing.T, emailHTML string) map[string]string {
t.Helper()
queryString := findSignupQueryFromEmail(t, emailHTML)
return model.MapFromJSON(strings.NewReader(queryString.Get("d")))
}
func TestSendCloudWelcomeEmail(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.ConfigureInbucketMail(t)
emailTo := "testclouduser@example.com"
t.Run("TestSendCloudWelcomeEmail", func(t *testing.T) {
verifyMailbox := func(t *testing.T) {
t.Helper()
var resultsMailbox mail.JSONMessageHeaderInbucket
err2 := mail.RetryInbucket(5, func() error {
var err error
resultsMailbox, err = mail.GetMailBox(emailTo)
return err
})
if err2 != nil {
t.Skipf("No email was received, maybe due load on the server: %v", err2)
}
require.Len(t, resultsMailbox, 1)
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
require.Contains(t, resultsEmail.Subject, "Congratulations!", "Wrong subject message %s", resultsEmail.Subject)
require.Contains(t, resultsEmail.Body.Text, "Your workspace is ready to go!", "Wrong body %s", resultsEmail.Body.Text)
}
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendCloudWelcomeEmail(emailTo, th.BasicUser.Locale, "inviteID", "SomeName", "example.com", "https://example.com")
require.NoError(t, err)
verifyMailbox(t)
})
}
func TestMailServiceConfig(t *testing.T) {
mainHelper.Parallel(t)
configuredReplyTo := "feedbackexample@test.com"
customReplyTo := "customreplyto@test.com"
emailService := Service{
config: func() *model.Config {
return &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: new(""),
},
EmailSettings: model.EmailSettings{
EnableSignUpWithEmail: new(bool),
EnableSignInWithEmail: new(bool),
EnableSignInWithUsername: new(bool),
SendEmailNotifications: new(bool),
UseChannelInEmailNotifications: new(bool),
RequireEmailVerification: new(bool),
FeedbackName: new(string),
FeedbackEmail: new(string),
ReplyToAddress: new(configuredReplyTo),
FeedbackOrganization: new(string),
EnableSMTPAuth: new(bool),
SMTPUsername: new(string),
SMTPPassword: new(string),
SMTPServer: new(string),
SMTPPort: new(string),
SMTPServerTimeout: new(int),
ConnectionSecurity: new(string),
SendPushNotifications: new(bool),
PushNotificationServer: new(string),
PushNotificationContents: new(string),
PushNotificationBuffer: new(int),
EnableEmailBatching: new(bool),
EmailBatchingBufferSize: new(int),
EmailBatchingInterval: new(int),
EnablePreviewModeBanner: new(bool),
SkipServerCertificateVerification: new(bool),
EmailNotificationContentsType: new(string),
},
}
},
}
t.Run("use custom replyto instead of configured replyto", func(t *testing.T) {
mailConfig := emailService.mailServiceConfig(customReplyTo)
require.Equal(t, customReplyTo, mailConfig.ReplyToAddress)
})
t.Run("use configured replyto", func(t *testing.T) {
mailConfig := emailService.mailServiceConfig("")
require.Equal(t, configuredReplyTo, mailConfig.ReplyToAddress)
})
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import "github.com/pkg/errors"
var (
CreateEmailTokenError = errors.New("could not create token")
SaveTokenError = errors.New("could not save token")
NoRateLimiterError = errors.New("the rate limit could not be found")
SetupRateLimiterError = errors.New("the rate limiter could not be set")
RateLimitExceededError = errors.New("the rate limit is exceeded")
SendMailError = errors.New("could not send the email")
)

View File

@@ -0,0 +1,286 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"os"
"path/filepath"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app/users"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
"github.com/stretchr/testify/require"
)
type TestHelper struct {
service *Service
configStore *config.Store
store store.Store
workspace string
BasicTeam *model.Team
BasicChannel *model.Channel
BasicUser *model.User
BasicUser2 *model.User
SystemAdminUser *model.User
Context request.CTX
}
func Setup(tb testing.TB) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
var dbStore store.Store
if mainHelper.Options.RunParallel {
dbStore, _, _, _ = mainHelper.GetNewStores(tb)
tb.Cleanup(func() {
dbStore.Close()
})
} else {
dbStore = mainHelper.GetStore()
dbStore.DropAllTables()
dbStore.MarkSystemRanUnitTests()
mainHelper.PreloadMigrations()
}
return setupTestHelper(dbStore, tb)
}
func SetupWithStoreMock(tb testing.TB) *TestHelper {
mockStore := testlib.GetMockStoreForSetupFunctions()
th := setupTestHelper(mockStore, tb)
statusMock := mocks.StatusStore{}
statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil)
statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil)
statusMock.On("UpdateLastActivityAt", "user1", mock.Anything).Return(nil)
statusMock.On("SaveOrUpdate", mock.AnythingOfType("*model.Status")).Return(nil)
emptyMockStore := mocks.Store{}
emptyMockStore.On("Close").Return(nil)
emptyMockStore.On("Status").Return(&statusMock)
th.service.store = &emptyMockStore
return th
}
func setupTestHelper(s store.Store, tb testing.TB) *TestHelper {
tempWorkspace, err := os.MkdirTemp("", "userservicetest")
require.NoError(tb, err)
configStore := config.NewTestMemoryStore()
config := configStore.Get()
*config.PluginSettings.Directory = filepath.Join(tempWorkspace, "plugins")
*config.PluginSettings.ClientDirectory = filepath.Join(tempWorkspace, "webapp")
*config.PluginSettings.AutomaticPrepackagedPlugins = false
*config.LogSettings.EnableSentry = false // disable error reporting during tests
*config.AnnouncementSettings.AdminNoticesEnabled = false
*config.AnnouncementSettings.UserNoticesEnabled = false
*config.TeamSettings.MaxUsersPerTeam = 50
*config.RateLimitSettings.Enable = false
*config.TeamSettings.EnableOpenServer = true
_, _, err = configStore.Set(config)
require.NoError(tb, err)
licenseFn := func() *model.License { return model.NewTestLicense() }
us, err := users.New(users.ServiceConfig{
UserStore: s.User(),
SessionStore: s.Session(),
OAuthStore: s.OAuth(),
ConfigFn: configStore.Get,
LicenseFn: licenseFn,
})
require.NoError(tb, err)
templatesDir, ok := templates.GetTemplateDirectory()
require.True(tb, ok)
htmlTemplates, err := templates.New(templatesDir)
require.NoError(tb, err)
logger := mlog.CreateConsoleTestLogger(tb)
service := &Service{
store: s,
userService: us,
license: licenseFn,
config: configStore.Get,
logger: logger,
templatesContainer: htmlTemplates,
}
err = service.setUpRateLimiters()
require.NoError(tb, err)
tb.Cleanup(func() {
err := configStore.Close()
require.NoError(tb, err)
s.Close()
if tempWorkspace != "" {
os.RemoveAll(tempWorkspace)
}
})
return &TestHelper{
service: service,
configStore: configStore,
store: s,
workspace: tempWorkspace,
Context: request.TestContext(tb),
}
}
func (th *TestHelper) InitBasic(tb testing.TB) *TestHelper {
var err error
th.BasicTeam = th.CreateTeam(tb)
th.SystemAdminUser = th.CreateUser(tb)
th.SystemAdminUser, err = th.service.userService.GetUser(th.Context, th.SystemAdminUser.Id)
require.NoError(tb, err)
th.addUserToTeam(tb, th.BasicTeam, th.SystemAdminUser)
th.BasicUser = th.CreateUser(tb)
th.BasicUser, err = th.service.userService.GetUser(th.Context, th.BasicUser.Id)
require.NoError(tb, err)
th.addUserToTeam(tb, th.BasicTeam, th.BasicUser)
th.BasicUser2 = th.CreateUser(tb)
th.BasicUser2, err = th.service.userService.GetUser(th.Context, th.BasicUser2.Id)
require.NoError(tb, err)
th.addUserToTeam(tb, th.BasicTeam, th.BasicUser2)
th.BasicChannel = th.createChannel(tb, th.BasicTeam, string(model.ChannelTypeOpen))
th.addUserToChannel(tb, th.BasicChannel, th.SystemAdminUser)
th.addUserToChannel(tb, th.BasicChannel, th.BasicUser)
th.addUserToChannel(tb, th.BasicChannel, th.BasicUser2)
return th
}
func (th *TestHelper) CreateTeam(tb testing.TB) *model.Team {
id := model.NewId()
team := &model.Team{
DisplayName: "dn_" + id,
Name: "name" + id,
Email: "success+" + id + "@simulator.amazonses.com",
Type: model.TeamOpen,
}
team, err := th.store.Team().Save(team)
require.NoError(tb, err)
return team
}
func (th *TestHelper) createChannel(tb testing.TB, team *model.Team, channelType string) *model.Channel {
id := model.NewId()
channel := &model.Channel{
DisplayName: "dn_" + id,
Name: "name_" + id,
Type: model.ChannelType(channelType),
TeamId: team.Id,
CreatorId: th.BasicUser.Id,
}
channel, err := th.store.Channel().Save(th.Context, channel, *th.configStore.Get().TeamSettings.MaxChannelsPerTeam)
require.NoError(tb, err)
return channel
}
func (th *TestHelper) addUserToChannel(tb testing.TB, channel *model.Channel, user *model.User) *model.ChannelMember {
newMember := &model.ChannelMember{
ChannelId: channel.Id,
UserId: user.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
SchemeGuest: user.IsGuest(),
SchemeUser: !user.IsGuest(),
}
newMember, err := th.store.Channel().SaveMember(th.Context, newMember)
require.NoError(tb, err)
return newMember
}
func (th *TestHelper) addUserToTeam(tb testing.TB, team *model.Team, user *model.User) *model.TeamMember {
tm := &model.TeamMember{
TeamId: team.Id,
UserId: user.Id,
SchemeGuest: user.IsGuest(),
SchemeUser: !user.IsGuest(),
}
tm, err := th.store.Team().SaveMember(th.Context, tm, *th.service.config().TeamSettings.MaxUsersPerTeam)
require.NoError(tb, err)
return tm
}
func (th *TestHelper) CreateUser(tb testing.TB) *model.User {
return th.CreateUserOrGuest(tb, false)
}
func (th *TestHelper) CreateGuest(tb testing.TB) *model.User {
return th.CreateUserOrGuest(tb, true)
}
func (th *TestHelper) CreateUserOrGuest(tb testing.TB, guest bool) *model.User {
id := model.NewId()
user := &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: model.NewTestPassword(),
EmailVerified: true,
}
var err error
if guest {
user, err = th.service.userService.CreateUser(th.Context, user, users.UserCreateOptions{Guest: true})
require.NoError(tb, err)
} else {
user, err = th.service.userService.CreateUser(th.Context, user, users.UserCreateOptions{})
require.NoError(tb, err)
}
return user
}
func (th *TestHelper) UpdateConfig(tb testing.TB, f func(*model.Config)) {
if th.configStore.IsReadOnly() {
return
}
old := th.configStore.Get()
updated := old.Clone()
f(updated)
_, _, err := th.configStore.Set(updated)
require.NoError(tb, err)
}
func (th *TestHelper) ConfigureInbucketMail(tb testing.TB) {
inbucket_host := os.Getenv("CI_INBUCKET_HOST")
if inbucket_host == "" {
inbucket_host = "localhost"
}
inbucket_port := os.Getenv("CI_INBUCKET_SMTP_PORT")
if inbucket_port == "" {
inbucket_port = "10025"
}
th.UpdateConfig(tb, func(cfg *model.Config) {
*cfg.EmailSettings.SMTPServer = inbucket_host
*cfg.EmailSettings.SMTPPort = inbucket_port
})
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"flag"
"os"
"strconv"
"testing"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var (
mainHelper *testlib.MainHelper
)
func TestMain(m *testing.M) {
var parallelism int
if f := flag.Lookup("test.parallel"); f != nil {
parallelism, _ = strconv.Atoi(f.Value.String())
}
runParallel := os.Getenv("ENABLE_FULLY_PARALLEL_TESTS") == "true" && parallelism > 1
if runParallel {
mlog.Info("Fully parallel tests enabled", mlog.Int("parallelism", parallelism))
}
options := testlib.HelperOptions{
EnableStore: true,
EnableResources: true,
RunParallel: runParallel,
Parallelism: parallelism,
}
mainHelper = testlib.NewMainHelperWithOptions(&options)
defer mainHelper.Close()
mainHelper.Main(m)
}

View File

@@ -0,0 +1,583 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make email-mocks`.
package mocks
import (
io "io"
model "github.com/mattermost/mattermost/server/public/model"
i18n "github.com/mattermost/mattermost/server/public/shared/i18n"
request "github.com/mattermost/mattermost/server/public/shared/request"
email "github.com/mattermost/mattermost/server/v8/channels/app/email"
store "github.com/mattermost/mattermost/server/v8/channels/store"
templates "github.com/mattermost/mattermost/server/v8/platform/shared/templates"
mock "github.com/stretchr/testify/mock"
)
// ServiceInterface is an autogenerated mock type for the ServiceInterface type
type ServiceInterface struct {
mock.Mock
}
// AddNotificationEmailToBatch provides a mock function with given fields: user, post, team
func (_m *ServiceInterface) AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError {
ret := _m.Called(user, post, team)
if len(ret) == 0 {
panic("no return value specified for AddNotificationEmailToBatch")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(*model.User, *model.Post, *model.Team) *model.AppError); ok {
r0 = rf(user, post, team)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// CreateVerifyEmailToken provides a mock function with given fields: userID, newEmail
func (_m *ServiceInterface) CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, error) {
ret := _m.Called(userID, newEmail)
if len(ret) == 0 {
panic("no return value specified for CreateVerifyEmailToken")
}
var r0 *model.Token
var r1 error
if rf, ok := ret.Get(0).(func(string, string) (*model.Token, error)); ok {
return rf(userID, newEmail)
}
if rf, ok := ret.Get(0).(func(string, string) *model.Token); ok {
r0 = rf(userID, newEmail)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Token)
}
}
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userID, newEmail)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GenerateHyperlinkForChannels provides a mock function with given fields: postMessage, teamName, teamURL
func (_m *ServiceInterface) GenerateHyperlinkForChannels(postMessage string, teamName string, teamURL string) (string, error) {
ret := _m.Called(postMessage, teamName, teamURL)
if len(ret) == 0 {
panic("no return value specified for GenerateHyperlinkForChannels")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(string, string, string) (string, error)); ok {
return rf(postMessage, teamName, teamURL)
}
if rf, ok := ret.Get(0).(func(string, string, string) string); ok {
r0 = rf(postMessage, teamName, teamURL)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
r1 = rf(postMessage, teamName, teamURL)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMessageForNotification provides a mock function with given fields: post, teamName, siteUrl, translateFunc
func (_m *ServiceInterface) GetMessageForNotification(post *model.Post, teamName string, siteUrl string, translateFunc i18n.TranslateFunc) string {
ret := _m.Called(post, teamName, siteUrl, translateFunc)
if len(ret) == 0 {
panic("no return value specified for GetMessageForNotification")
}
var r0 string
if rf, ok := ret.Get(0).(func(*model.Post, string, string, i18n.TranslateFunc) string); ok {
r0 = rf(post, teamName, siteUrl, translateFunc)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// InitEmailBatching provides a mock function with no fields
func (_m *ServiceInterface) InitEmailBatching() {
_m.Called()
}
// NewEmailTemplateData provides a mock function with given fields: locale
func (_m *ServiceInterface) NewEmailTemplateData(locale string) templates.Data {
ret := _m.Called(locale)
if len(ret) == 0 {
panic("no return value specified for NewEmailTemplateData")
}
var r0 templates.Data
if rf, ok := ret.Get(0).(func(string) templates.Data); ok {
r0 = rf(locale)
} else {
r0 = ret.Get(0).(templates.Data)
}
return r0
}
// SendChangeUsernameEmail provides a mock function with given fields: newUsername, _a1, locale, siteURL
func (_m *ServiceInterface) SendChangeUsernameEmail(newUsername string, _a1 string, locale string, siteURL string) error {
ret := _m.Called(newUsername, _a1, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendChangeUsernameEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(newUsername, _a1, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendCloudWelcomeEmail provides a mock function with given fields: userEmail, locale, teamInviteID, workSpaceName, dns, siteURL
func (_m *ServiceInterface) SendCloudWelcomeEmail(userEmail string, locale string, teamInviteID string, workSpaceName string, dns string, siteURL string) error {
ret := _m.Called(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendCloudWelcomeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string, string) error); ok {
r0 = rf(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDeactivateAccountEmail provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendDeactivateAccountEmail(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendDeactivateAccountEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendEmailChangeEmail provides a mock function with given fields: oldEmail, newEmail, locale, siteURL
func (_m *ServiceInterface) SendEmailChangeEmail(oldEmail string, newEmail string, locale string, siteURL string) error {
ret := _m.Called(oldEmail, newEmail, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendEmailChangeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(oldEmail, newEmail, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendEmailChangeVerifyEmail provides a mock function with given fields: newUserEmail, locale, siteURL, token
func (_m *ServiceInterface) SendEmailChangeVerifyEmail(newUserEmail string, locale string, siteURL string, token string) error {
ret := _m.Called(newUserEmail, locale, siteURL, token)
if len(ret) == 0 {
panic("no return value specified for SendEmailChangeVerifyEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(newUserEmail, locale, siteURL, token)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendGuestInviteEmails provides a mock function with given fields: rctx, team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin, isGuestMagicLink
func (_m *ServiceInterface) SendGuestInviteEmails(rctx request.CTX, team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool, isGuestMagicLink bool) error {
ret := _m.Called(rctx, team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin, isGuestMagicLink)
if len(ret) == 0 {
panic("no return value specified for SendGuestInviteEmails")
}
var r0 error
if rf, ok := ret.Get(0).(func(request.CTX, *model.Team, []*model.Channel, string, string, []byte, []string, string, string, bool, bool, bool, bool) error); ok {
r0 = rf(rctx, team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin, isGuestMagicLink)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendIPFiltersChangedEmail provides a mock function with given fields: _a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner
func (_m *ServiceInterface) SendIPFiltersChangedEmail(_a0 string, userWhoChangedFilter *model.User, siteURL string, portalURL string, locale string, isWorkspaceOwner bool) error {
ret := _m.Called(_a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner)
if len(ret) == 0 {
panic("no return value specified for SendIPFiltersChangedEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, *model.User, string, string, string, bool) error); ok {
r0 = rf(_a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendInviteEmails provides a mock function with given fields: rctx, inviteData
func (_m *ServiceInterface) SendInviteEmails(rctx request.CTX, inviteData email.InviteEmailData) error {
ret := _m.Called(rctx, inviteData)
if len(ret) == 0 {
panic("no return value specified for SendInviteEmails")
}
var r0 error
if rf, ok := ret.Get(0).(func(request.CTX, email.InviteEmailData) error); ok {
r0 = rf(rctx, inviteData)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendInviteEmailsToTeamAndChannels provides a mock function with given fields: rctx, inviteData
func (_m *ServiceInterface) SendInviteEmailsToTeamAndChannels(rctx request.CTX, inviteData email.InviteEmailData) ([]*model.EmailInviteWithError, error) {
ret := _m.Called(rctx, inviteData)
if len(ret) == 0 {
panic("no return value specified for SendInviteEmailsToTeamAndChannels")
}
var r0 []*model.EmailInviteWithError
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, email.InviteEmailData) ([]*model.EmailInviteWithError, error)); ok {
return rf(rctx, inviteData)
}
if rf, ok := ret.Get(0).(func(request.CTX, email.InviteEmailData) []*model.EmailInviteWithError); ok {
r0 = rf(rctx, inviteData)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.EmailInviteWithError)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, email.InviteEmailData) error); ok {
r1 = rf(rctx, inviteData)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SendLicenseUpForRenewalEmail provides a mock function with given fields: _a0, locale, daysToExpiration
func (_m *ServiceInterface) SendLicenseUpForRenewalEmail(_a0 string, locale string, daysToExpiration int) error {
ret := _m.Called(_a0, locale, daysToExpiration)
if len(ret) == 0 {
panic("no return value specified for SendLicenseUpForRenewalEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, int) error); ok {
r0 = rf(_a0, locale, daysToExpiration)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendMagicLinkEmailSelfService provides a mock function with given fields: rctx, invite, siteURL
func (_m *ServiceInterface) SendMagicLinkEmailSelfService(rctx request.CTX, invite string, siteURL string) error {
ret := _m.Called(rctx, invite, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendMagicLinkEmailSelfService")
}
var r0 error
if rf, ok := ret.Get(0).(func(request.CTX, string, string) error); ok {
r0 = rf(rctx, invite, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendMailWithEmbeddedFiles provides a mock function with given fields: to, subject, htmlBody, embeddedFiles, messageID, inReplyTo, references, category
func (_m *ServiceInterface) SendMailWithEmbeddedFiles(to string, subject string, htmlBody string, embeddedFiles map[string]io.Reader, messageID string, inReplyTo string, references string, category string) error {
ret := _m.Called(to, subject, htmlBody, embeddedFiles, messageID, inReplyTo, references, category)
if len(ret) == 0 {
panic("no return value specified for SendMailWithEmbeddedFiles")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, map[string]io.Reader, string, string, string, string) error); ok {
r0 = rf(to, subject, htmlBody, embeddedFiles, messageID, inReplyTo, references, category)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendMfaChangeEmail provides a mock function with given fields: _a0, activated, locale, siteURL
func (_m *ServiceInterface) SendMfaChangeEmail(_a0 string, activated bool, locale string, siteURL string) error {
ret := _m.Called(_a0, activated, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendMfaChangeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, bool, string, string) error); ok {
r0 = rf(_a0, activated, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendNotificationMail provides a mock function with given fields: to, subject, htmlBody
func (_m *ServiceInterface) SendNotificationMail(to string, subject string, htmlBody string) error {
ret := _m.Called(to, subject, htmlBody)
if len(ret) == 0 {
panic("no return value specified for SendNotificationMail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(to, subject, htmlBody)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendPasswordChangeEmail provides a mock function with given fields: _a0, method, locale, siteURL
func (_m *ServiceInterface) SendPasswordChangeEmail(_a0 string, method string, locale string, siteURL string) error {
ret := _m.Called(_a0, method, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendPasswordChangeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, method, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendPasswordResetEmail provides a mock function with given fields: _a0, token, locale, siteURL
func (_m *ServiceInterface) SendPasswordResetEmail(_a0 string, token *model.Token, locale string, siteURL string) (bool, error) {
ret := _m.Called(_a0, token, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendPasswordResetEmail")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func(string, *model.Token, string, string) (bool, error)); ok {
return rf(_a0, token, locale, siteURL)
}
if rf, ok := ret.Get(0).(func(string, *model.Token, string, string) bool); ok {
r0 = rf(_a0, token, locale, siteURL)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(string, *model.Token, string, string) error); ok {
r1 = rf(_a0, token, locale, siteURL)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SendRemoveExpiredLicenseEmail provides a mock function with given fields: _a0, locale
func (_m *ServiceInterface) SendRemoveExpiredLicenseEmail(_a0 string, locale string) error {
ret := _m.Called(_a0, locale)
if len(ret) == 0 {
panic("no return value specified for SendRemoveExpiredLicenseEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(_a0, locale)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendSignInChangeEmail provides a mock function with given fields: _a0, method, locale, siteURL
func (_m *ServiceInterface) SendSignInChangeEmail(_a0 string, method string, locale string, siteURL string) error {
ret := _m.Called(_a0, method, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendSignInChangeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, method, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendUserAccessTokenAddedEmail provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendUserAccessTokenAddedEmail(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendUserAccessTokenAddedEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendUserAccessTokenRotatedEmail provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendUserAccessTokenRotatedEmail(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
if len(ret) == 0 {
panic("no return value specified for SendUserAccessTokenRotatedEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendVerifyEmail provides a mock function with given fields: userEmail, locale, siteURL, token, redirect
func (_m *ServiceInterface) SendVerifyEmail(userEmail string, locale string, siteURL string, token string, redirect string) error {
ret := _m.Called(userEmail, locale, siteURL, token, redirect)
if len(ret) == 0 {
panic("no return value specified for SendVerifyEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string) error); ok {
r0 = rf(userEmail, locale, siteURL, token, redirect)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendWelcomeEmail provides a mock function with given fields: userID, _a1, verified, disableWelcomeEmail, locale, siteURL, redirect
func (_m *ServiceInterface) SendWelcomeEmail(userID string, _a1 string, verified bool, disableWelcomeEmail bool, locale string, siteURL string, redirect string) error {
ret := _m.Called(userID, _a1, verified, disableWelcomeEmail, locale, siteURL, redirect)
if len(ret) == 0 {
panic("no return value specified for SendWelcomeEmail")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, string, bool, bool, string, string, string) error); ok {
r0 = rf(userID, _a1, verified, disableWelcomeEmail, locale, siteURL, redirect)
} else {
r0 = ret.Error(0)
}
return r0
}
// SetStore provides a mock function with given fields: st
func (_m *ServiceInterface) SetStore(st store.Store) {
_m.Called(st)
}
// Stop provides a mock function with no fields
func (_m *ServiceInterface) Stop() {
_m.Called()
}
// NewServiceInterface creates a new instance of ServiceInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewServiceInterface(t interface {
mock.TestingT
Cleanup(func())
}) *ServiceInterface {
mock := &ServiceInterface{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"fmt"
"html"
"html/template"
"net/url"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/utils"
)
type FieldRow struct {
Cells []*model.MessageAttachmentField
}
type EmailMessageAttachment struct {
model.MessageAttachment
Pretext template.HTML
Text template.HTML
FieldRows []FieldRow
}
func (es *Service) GetMessageForNotification(post *model.Post, teamName, siteUrl string, translateFunc i18n.TranslateFunc) string {
if strings.TrimSpace(post.Message) != "" || len(post.FileIds) == 0 {
return es.prepareNotificationMessageForEmail(post.Message, teamName, siteUrl)
}
// normalize the filenames and determine what type of files are attached
infos, err := es.store.FileInfo().GetForPost(post.Id, true, false, true)
if err != nil {
mlog.Warn("Encountered error when getting files for notification message", mlog.String("post_id", post.Id), mlog.Err(err))
}
filenames := make([]string, len(infos))
onlyImages := true
for i, info := range infos {
filename := info.Name
if unescaped, err := url.QueryUnescape(filename); err == nil {
filename = unescaped
}
filenames[i] = filename
onlyImages = onlyImages && info.IsImage()
}
props := map[string]any{"Filenames": strings.Join(filenames, ", ")}
if onlyImages {
return string(i18n.TranslateAsHTML(translateFunc, "api.post.get_message_for_notification.images_sent", map[string]any{
"Count": len(filenames),
"Filenames": props["Filenames"],
}))
}
return string(i18n.TranslateAsHTML(translateFunc, "api.post.get_message_for_notification.files_sent", map[string]any{
"Count": len(filenames),
"Filenames": props["Filenames"],
}))
}
func ProcessMessageAttachments(post *model.Post, siteURL string) []*EmailMessageAttachment {
emailMessageAttachments := []*EmailMessageAttachment{}
for _, messageAttachment := range post.Attachments() {
emailMessageAttachment := &EmailMessageAttachment{
MessageAttachment: *messageAttachment,
Pretext: prepareTextForEmail(messageAttachment.Pretext, siteURL),
Text: prepareTextForEmail(messageAttachment.Text, siteURL),
}
stripedTitle, err := utils.StripMarkdown(emailMessageAttachment.Title)
if err != nil {
mlog.Warn("Failed parse to markdown from messageatatchment title", mlog.String("post_id", post.Id), mlog.Err(err))
stripedTitle = ""
}
emailMessageAttachment.Title = stripedTitle
shortFieldRow := FieldRow{}
for i := range messageAttachment.Fields {
// Create a new instance to avoid altering the original pointer reference
// We update field value to parse markdown.
// If we do that on the original pointer, the rendered text in mattermost
// becomes invalid as its no longer a markdown string, but rather an HTML string.
field := &model.MessageAttachmentField{
Title: messageAttachment.Fields[i].Title,
Value: messageAttachment.Fields[i].Value,
Short: messageAttachment.Fields[i].Short,
}
if stringValue, ok := field.Value.(string); ok {
field.Value = prepareTextForEmail(stringValue, siteURL)
}
if !field.Short {
if len(shortFieldRow.Cells) > 0 {
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow)
shortFieldRow = FieldRow{}
}
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, FieldRow{[]*model.MessageAttachmentField{field}})
} else {
shortFieldRow.Cells = append(shortFieldRow.Cells, field)
if len(shortFieldRow.Cells) == 2 {
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow)
shortFieldRow = FieldRow{}
}
}
}
// collect any leftover short fields
if len(shortFieldRow.Cells) > 0 {
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow)
shortFieldRow = FieldRow{}
}
emailMessageAttachments = append(emailMessageAttachments, emailMessageAttachment)
}
return emailMessageAttachments
}
func prepareTextForEmail(text, siteURL string) template.HTML {
escapedText := html.EscapeString(text)
markdownText, err := utils.MarkdownToHTML(escapedText, siteURL)
if err != nil {
mlog.Warn("Encountered error while converting markdown to HTML", mlog.Err(err))
return template.HTML(text)
}
return template.HTML(markdownText)
}
func (es *Service) prepareNotificationMessageForEmail(postMessage, teamName, siteURL string) string {
postMessage = html.EscapeString(postMessage)
mdPostMessage, mdErr := utils.MarkdownToHTML(postMessage, siteURL)
if mdErr != nil {
mlog.Warn("Encountered error while converting markdown to HTML", mlog.Err(mdErr))
mdPostMessage = postMessage
}
landingURL := siteURL + "/landing#/" + teamName
normalizedPostMessage, err := es.GenerateHyperlinkForChannels(mdPostMessage, teamName, landingURL)
if err != nil {
mlog.Warn("Encountered error while generating hyperlink for channels", mlog.String("team_name", teamName), mlog.Err(err))
normalizedPostMessage = mdPostMessage
}
return normalizedPostMessage
}
func (es *Service) GenerateHyperlinkForChannels(postMessage, teamName, landingURL string) (string, error) {
channelNames := model.ChannelMentions(postMessage)
if len(channelNames) == 0 {
return postMessage, nil
}
team, err := es.Store().Team().GetByName(teamName)
if err != nil {
mlog.Error("Team not found with the name", mlog.String("team_name", teamName), mlog.Err(err))
return postMessage, nil
}
channels, err := es.store.Channel().GetByNames(team.Id, channelNames, true)
if err != nil {
return "", err
}
visited := make(map[string]bool)
for _, ch := range channels {
if !visited[ch.Id] && ch.Type == model.ChannelTypeOpen {
channelURL := landingURL + "/channels/" + ch.Name
channelHyperLink := fmt.Sprintf("<a href='%s'>%s</a>", channelURL, "~"+ch.Name)
postMessage = strings.Replace(postMessage, "~"+ch.Name, channelHyperLink, -1)
visited[ch.Id] = true
}
}
return postMessage, nil
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestProcessMessageAttachments(t *testing.T) {
mainHelper.Parallel(t)
post := &model.Post{
Message: "This is the message",
}
messageAttachments := []*model.MessageAttachment{
{
Color: "#FF0000",
Pretext: "message attachment 1 pretext",
AuthorName: "author name",
AuthorLink: "https://example.com/slack_attachment_1/author_link",
AuthorIcon: "https://example.com/slack_attachment_1/author_icon",
Title: "message attachment 1 title",
TitleLink: "https://example.com/slack_attachment_1/title_link",
Text: "message attachment 1 text",
ImageURL: "https://example.com/slack_attachment_1/image",
ThumbURL: "https://example.com/slack_attachment_1/thumb",
Fields: []*model.MessageAttachmentField{
{
Short: true,
Title: "message attachment 1 field 1 title",
Value: "message attachment 1 field 1 value",
},
{
Short: false,
Title: "message attachment 1 field 2 title",
Value: "message attachment 1 field 2 value",
},
{
Short: true,
Title: "message attachment 1 field 3 title",
Value: "message attachment 1 field 3 value",
},
{
Short: true,
Title: "message attachment 1 field 4 title",
Value: "message attachment 1 field 4 value",
},
},
},
{
Color: "#FF0000",
Pretext: "message attachment 2 pretext",
AuthorName: "author name 2",
Text: "message attachment 2 text",
},
}
model.ParseMessageAttachment(post, messageAttachments)
processedAttachmentsPost := ProcessMessageAttachments(post, "https://example.com")
require.NotNil(t, processedAttachmentsPost)
require.Len(t, processedAttachmentsPost, 2)
require.Equal(t, processedAttachmentsPost[0].Color, "#FF0000")
require.Equal(t, processedAttachmentsPost[0].FieldRows[0].Cells[0].Title, "message attachment 1 field 1 title")
require.Equal(t, processedAttachmentsPost[1].Color, "#FF0000")
}

View File

@@ -0,0 +1,225 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"io"
"net/url"
"path"
"strings"
"github.com/pkg/errors"
"github.com/throttled/throttled/v2"
"github.com/throttled/throttled/v2/store/memstore"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app/users"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/platform/shared/templates"
)
const (
emailRateLimitingMemstoreSize = 65536
emailRateLimitingPerMinute = 60
emailRateLimitingPerHour = 20
emailRateLimitingMaxBurst = 20
TokenTypePasswordRecovery = "password_recovery"
TokenTypeVerifyEmail = "verify_email"
TokenTypeTeamInvitation = "team_invitation"
TokenTypeGuestInvitation = "guest_invitation"
TokenTypeCWSAccess = "cws_access_token"
TokenTypeGuestMagicLinkInvitation = "guest_magic_link_invitation"
TokenTypeGuestMagicLink = "guest_magic_link"
)
func condenseSiteURL(siteURL string) string {
parsedSiteURL, _ := url.Parse(siteURL)
if parsedSiteURL.Path == "" || parsedSiteURL.Path == "/" {
return parsedSiteURL.Host
}
return path.Join(parsedSiteURL.Host, parsedSiteURL.Path)
}
type Service struct {
config func() *model.Config
license func() *model.License
logger mlog.LoggerIFace
userService *users.UserService
store store.Store
templatesContainer *templates.Container
perMinuteEmailRateLimiter *throttled.GCRARateLimiterCtx
perHourEmailRateLimiter *throttled.GCRARateLimiterCtx
EmailBatching *EmailBatchingJob
postDeliveryRecorder func(userID string, post *model.Post)
}
type ServiceConfig struct {
ConfigFn func() *model.Config
LicenseFn func() *model.License
TemplatesContainer *templates.Container
UserService *users.UserService
Store store.Store
Logger mlog.LoggerIFace
PostDeliveryRecorderFn func(userID string, post *model.Post)
}
func NewService(config ServiceConfig) (*Service, error) {
if err := config.validate(); err != nil {
return nil, err
}
service := &Service{
config: config.ConfigFn,
templatesContainer: config.TemplatesContainer,
license: config.LicenseFn,
logger: config.Logger,
store: config.Store,
userService: config.UserService,
postDeliveryRecorder: config.PostDeliveryRecorderFn,
}
if err := service.setUpRateLimiters(); err != nil {
return nil, err
}
service.InitEmailBatching()
return service, nil
}
func (es *Service) Stop() {
mlog.Info("Shutting down Email batching service...")
if es.EmailBatching != nil {
es.EmailBatching.Stop()
}
}
func (c *ServiceConfig) validate() error {
if c.ConfigFn == nil || c.Store == nil || c.LicenseFn == nil || c.TemplatesContainer == nil {
return errors.New("invalid service config")
}
return nil
}
func (es *Service) setUpRateLimiters() error {
store, err := memstore.NewCtx(emailRateLimitingMemstoreSize)
if err != nil {
return errors.Wrap(err, "Unable to setup email rate limiting memstore.")
}
perMinuteQuota := throttled.RateQuota{
MaxRate: throttled.PerMin(emailRateLimitingPerMinute),
MaxBurst: emailRateLimitingMaxBurst,
}
perHourQuota := throttled.RateQuota{
MaxRate: throttled.PerHour(emailRateLimitingPerHour),
MaxBurst: emailRateLimitingMaxBurst,
}
perMinuteRateLimiter, err := throttled.NewGCRARateLimiterCtx(store, perMinuteQuota)
if err != nil {
return errors.Wrap(err, "Unable to setup email rate limiting GCRA rate limiter.")
}
perHourRateLimiter, err := throttled.NewGCRARateLimiterCtx(store, perHourQuota)
if err != nil {
return errors.Wrap(err, "Unable to setup email rate limiting GCRA rate limiter.")
}
es.perMinuteEmailRateLimiter = perMinuteRateLimiter
es.perHourEmailRateLimiter = perHourRateLimiter
return nil
}
type InviteEmailData struct {
Team *model.Team
Channels []*model.Channel
SenderName string
SenderUserID string
SenderProfileImage []byte
Invites []string
Profiles map[string]*model.MemberInviteProfile
SiteURL string
ReminderData *model.TeamInviteReminderData
Message string
ErrorWhenNotSent bool
IsSystemAdmin bool
IsFirstAdmin bool
}
type ServiceInterface interface {
NewEmailTemplateData(locale string) templates.Data
SendEmailChangeVerifyEmail(newUserEmail, locale, siteURL, token string) error
SendEmailChangeEmail(oldEmail, newEmail, locale, siteURL string) error
SendVerifyEmail(userEmail, locale, siteURL, token, redirect string) error
SendSignInChangeEmail(email, method, locale, siteURL string) error
SendWelcomeEmail(userID string, email string, verified bool, disableWelcomeEmail bool, locale, siteURL, redirect string) error
SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error
SendPasswordChangeEmail(email, method, locale, siteURL string) error
SendUserAccessTokenAddedEmail(email, locale, siteURL string) error
SendUserAccessTokenRotatedEmail(email, locale, siteURL string) error
SendPasswordResetEmail(email string, token *model.Token, locale, siteURL string) (bool, error)
SendMfaChangeEmail(email string, activated bool, locale, siteURL string) error
SendInviteEmails(rctx request.CTX, inviteData InviteEmailData) error
SendGuestInviteEmails(rctx request.CTX, team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool, isGuestMagicLink bool) error
SendMagicLinkEmailSelfService(rctx request.CTX, invite string, siteURL string) error
SendInviteEmailsToTeamAndChannels(rctx request.CTX, inviteData InviteEmailData) ([]*model.EmailInviteWithError, error)
SendDeactivateAccountEmail(email string, locale, siteURL string) error
SendNotificationMail(to, subject, htmlBody string) error
SendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader, messageID string, inReplyTo string, references string, category string) error
SendLicenseUpForRenewalEmail(email, locale string, daysToExpiration int) error
SendRemoveExpiredLicenseEmail(email, locale string) error
AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError
GetMessageForNotification(post *model.Post, teamName, siteUrl string, translateFunc i18n.TranslateFunc) string
GenerateHyperlinkForChannels(postMessage, teamName, teamURL string) (string, error)
InitEmailBatching()
SendChangeUsernameEmail(newUsername, email, locale, siteURL string) error
CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, error)
SendIPFiltersChangedEmail(email string, userWhoChangedFilter *model.User, siteURL, portalURL, locale string, isWorkspaceOwner bool) error
SetStore(st store.Store)
Stop()
}
// getLicenseSkuName returns the license tier descriptor (e.g. "Professional",
// "Entry", "E20"), stripping the "Mattermost " prefix if the license server
// included it. Falls back to "Mattermost" when the SKU name is absent so
// that body text like "Your {{.SkuName}} license..." still reads naturally.
func (es *Service) getLicenseSkuName() string {
if license := es.license(); license != nil && license.SkuName != "" {
return strings.TrimPrefix(license.SkuName, "Mattermost ")
}
return "Mattermost"
}
// getPrefixedLicenseSkuName returns the full product name including "Mattermost"
// (e.g. "Mattermost Professional"), suitable for email subjects. Falls back
// to "Mattermost" when no license exists or the SKU name is empty.
func (es *Service) getPrefixedLicenseSkuName() string {
skuName := es.getLicenseSkuName()
if skuName == "Mattermost" {
return "Mattermost"
}
return "Mattermost " + skuName
}
func (es *Service) getConfigSiteName() string {
if siteName := *es.config().TeamSettings.SiteName; siteName != "" {
return siteName
}
return "Mattermost"
}
func (es *Service) Store() store.Store {
return es.store
}
func (es *Service) SetStore(st store.Store) {
es.store = st
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/platform/shared/mail"
)
func (es *Service) mailServiceConfig(replyToAddress string) *mail.SMTPConfig {
emailSettings := es.config().EmailSettings
hostname := utils.GetHostnameFromSiteURL(*es.config().ServiceSettings.SiteURL)
if replyToAddress == "" {
replyToAddress = *emailSettings.ReplyToAddress
}
cfg := mail.SMTPConfig{
Hostname: hostname,
ConnectionSecurity: *emailSettings.ConnectionSecurity,
SkipServerCertificateVerification: *emailSettings.SkipServerCertificateVerification,
ServerName: *emailSettings.SMTPServer,
Server: *emailSettings.SMTPServer,
Port: *emailSettings.SMTPPort,
ServerTimeout: *emailSettings.SMTPServerTimeout,
Username: *emailSettings.SMTPUsername,
Password: *emailSettings.SMTPPassword,
EnableSMTPAuth: *emailSettings.EnableSMTPAuth,
SendEmailNotifications: *emailSettings.SendEmailNotifications,
FeedbackName: *emailSettings.FeedbackName,
FeedbackEmail: *emailSettings.FeedbackEmail,
ReplyToAddress: replyToAddress,
}
return &cfg
}
func (es *Service) GetTrackFlowStartedByRole(isFirstAdmin bool, isSystemAdmin bool) string {
trackFlowStartedByRole := "su"
if isFirstAdmin {
trackFlowStartedByRole = "fa"
} else if isSystemAdmin {
trackFlowStartedByRole = "sa"
}
return trackFlowStartedByRole
}