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
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:
11
server/channels/app/email/.mockery.yaml
Normal file
11
server/channels/app/email/.mockery.yaml
Normal 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:
|
||||
1115
server/channels/app/email/email.go
Normal file
1115
server/channels/app/email/email.go
Normal file
File diff suppressed because it is too large
Load Diff
388
server/channels/app/email/email_batching.go
Normal file
388
server/channels/app/email/email_batching.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
285
server/channels/app/email/email_batching_test.go
Normal file
285
server/channels/app/email/email_batching_test.go
Normal 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")
|
||||
}
|
||||
486
server/channels/app/email/email_test.go
Normal file
486
server/channels/app/email/email_test.go
Normal 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, "&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, "&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, "&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)
|
||||
})
|
||||
}
|
||||
15
server/channels/app/email/errors.go
Normal file
15
server/channels/app/email/errors.go
Normal 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")
|
||||
)
|
||||
286
server/channels/app/email/helper_test.go
Normal file
286
server/channels/app/email/helper_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
41
server/channels/app/email/main_test.go
Normal file
41
server/channels/app/email/main_test.go
Normal 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)
|
||||
}
|
||||
583
server/channels/app/email/mocks/ServiceInterface.go
Normal file
583
server/channels/app/email/mocks/ServiceInterface.go
Normal 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
|
||||
}
|
||||
186
server/channels/app/email/notification_email.go
Normal file
186
server/channels/app/email/notification_email.go
Normal 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
|
||||
}
|
||||
72
server/channels/app/email/notification_email_test.go
Normal file
72
server/channels/app/email/notification_email_test.go
Normal 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")
|
||||
}
|
||||
225
server/channels/app/email/service.go
Normal file
225
server/channels/app/email/service.go
Normal 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
|
||||
}
|
||||
48
server/channels/app/email/utils.go
Normal file
48
server/channels/app/email/utils.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user