mattermost-community-enterp.../channels/app/notification.go
Claude ec1f89217a Merge: Complete Mattermost Server with Community Enterprise
Full Mattermost server source with integrated Community Enterprise features.
Includes vendor directory for offline/air-gapped builds.

Structure:
- enterprise-impl/: Enterprise feature implementations
- enterprise-community/: Init files that register implementations
- enterprise/: Bridge imports (community_imports.go)
- vendor/: All dependencies for offline builds

Build (online):
  go build ./cmd/mattermost

Build (offline/air-gapped):
  go build -mod=vendor ./cmd/mattermost

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 23:59:07 +09:00

1804 lines
66 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"encoding/json"
"net/http"
"sort"
"strings"
"sync"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/markdown"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
func (a *App) canSendPushNotifications() bool {
if !*a.Config().EmailSettings.SendPushNotifications {
a.Log().LogM(mlog.MlvlNotificationDebug, "Push notifications are disabled - server config",
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("reason", "push_disabled"),
)
return false
}
pushServer := *a.Config().EmailSettings.PushNotificationServer
// Check for MHPNS servers (both current and legacy DNS aliases)
isMHPNSServer := pushServer == model.MHPNS ||
pushServer == model.MHPNSLegacyUS ||
pushServer == model.MHPNSLegacyDE ||
pushServer == model.MHPNSGlobal ||
pushServer == model.MHPNSUS ||
pushServer == model.MHPNSEU ||
pushServer == model.MHPNSAP
if license := a.Srv().License(); isMHPNSServer && (license == nil || !*license.Features.MHPNS) {
a.Log().LogM(mlog.MlvlNotificationWarn, "Push notifications are disabled - license missing",
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("reason", "push_disabled_license"),
)
mlog.Warn("Push notifications have been disabled. Update your license or go to System Console > Environment > Push Notification Server to use a different server")
return false
}
return true
}
func (a *App) SendNotifications(rctx request.CTX, post *model.Post, team *model.Team, channel *model.Channel, sender *model.User, parentPostList *model.PostList, setOnline bool) ([]string, error) {
// Do not send notifications in archived channels
if channel.DeleteAt > 0 {
return []string{}, nil
}
isCRTAllowed := *a.Config().ServiceSettings.CollapsedThreads != model.CollapsedThreadsDisabled
pchan := make(chan store.StoreResult[map[string]*model.User], 1)
go func() {
props, err := a.Srv().Store().User().GetAllProfilesInChannel(context.Background(), channel.Id, true)
pchan <- store.StoreResult[map[string]*model.User]{Data: props, NErr: err}
close(pchan)
}()
cmnchan := make(chan store.StoreResult[map[string]model.StringMap], 1)
go func() {
props, err := a.Srv().Store().Channel().GetAllChannelMembersNotifyPropsForChannel(channel.Id, true)
cmnchan <- store.StoreResult[map[string]model.StringMap]{Data: props, NErr: err}
close(cmnchan)
}()
var gchan chan store.StoreResult[map[string]*model.Group]
if a.allowGroupMentions(rctx, post) {
gchan = make(chan store.StoreResult[map[string]*model.Group], 1)
go func() {
groupsMap, err := a.getGroupsAllowedForReferenceInChannel(channel, team)
gchan <- store.StoreResult[map[string]*model.Group]{Data: groupsMap, NErr: err}
close(gchan)
}()
}
var fchan chan store.StoreResult[[]*model.FileInfo]
if len(post.FileIds) != 0 {
fchan = make(chan store.StoreResult[[]*model.FileInfo], 1)
go func() {
fileInfos, err := a.Srv().Store().FileInfo().GetForPost(post.Id, true, false, true)
fchan <- store.StoreResult[[]*model.FileInfo]{Data: fileInfos, NErr: err}
close(fchan)
}()
}
var tchan chan store.StoreResult[[]string]
if isCRTAllowed && post.RootId != "" {
tchan = make(chan store.StoreResult[[]string], 1)
go func() {
followers, err := a.Srv().Store().Thread().GetThreadFollowers(post.RootId, true)
tchan <- store.StoreResult[[]string]{Data: followers, NErr: err}
close(tchan)
}()
}
pResult := <-pchan
if pResult.NErr != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeAll, model.NotificationReasonFetchError, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Error fetching profiles",
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonFetchError),
mlog.Err(pResult.NErr),
)
return nil, pResult.NErr
}
profileMap := pResult.Data
cmnResult := <-cmnchan
if cmnResult.NErr != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeAll, model.NotificationReasonFetchError, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Error fetching notify props",
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonFetchError),
mlog.Err(cmnResult.NErr),
)
return nil, cmnResult.NErr
}
channelMemberNotifyPropsMap := cmnResult.Data
followers := make(model.StringSet, 0)
if tchan != nil {
tResult := <-tchan
if tResult.NErr != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeAll, model.NotificationReasonFetchError, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Error fetching thread followers",
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonFetchError),
mlog.Err(tResult.NErr),
)
return nil, tResult.NErr
}
for _, v := range tResult.Data {
followers.Add(v)
}
}
groups := make(map[string]*model.Group)
if gchan != nil {
gResult := <-gchan
if gResult.NErr != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeAll, model.NotificationReasonFetchError, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Error fetching group mentions",
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonFetchError),
mlog.Err(gResult.NErr),
)
return nil, gResult.NErr
}
groups = gResult.Data
}
rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Successfully fetched all profiles",
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
)
mentions, keywords := a.getExplicitMentionsAndKeywords(rctx, post, channel, profileMap, groups, channelMemberNotifyPropsMap, parentPostList)
var allActivityPushUserIds []string
if channel.Type != model.ChannelTypeDirect {
// Iterate through all groups that were mentioned and insert group members into the list of mentions or potential mentions
for groupID := range mentions.GroupMentions {
group := groups[groupID]
anyUsersMentionedByGroup, err := a.insertGroupMentions(sender.Id, group, channel, profileMap, mentions)
if err != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeAll, model.NotificationReasonFetchError, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Failed to populate group mentions",
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonFetchError),
mlog.Err(err),
)
return nil, err
}
if !anyUsersMentionedByGroup {
a.sendNoUsersNotifiedByGroupInChannel(rctx, sender, post, channel, groups[groupID])
}
}
go func() {
_, err := a.sendOutOfChannelMentions(rctx, sender, post, channel, mentions.OtherPotentialMentions)
if err != nil {
rctx.Logger().LogM(mlog.MlvlNotificationWarn, "Failed to send warning for out of channel mentions",
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", "failed_to_send_out_of_channel"),
mlog.Err(err),
)
rctx.Logger().Error("Failed to send warning for out of channel mentions", mlog.String("user_id", sender.Id), mlog.String("post_id", post.Id), mlog.Err(err))
}
}()
// find which users in the channel are set up to always receive mobile notifications
// excludes CRT users since those should be added in notificationsForCRT
for _, profile := range profileMap {
if (profile.NotifyProps[model.PushNotifyProp] == model.UserNotifyAll ||
channelMemberNotifyPropsMap[profile.Id][model.PushNotifyProp] == model.ChannelNotifyAll) &&
(post.UserId != profile.Id || post.GetProp(model.PostPropsFromWebhook) == "true") &&
!post.IsSystemMessage() &&
!(a.IsCRTEnabledForUser(rctx, profile.Id) && post.RootId != "") {
allActivityPushUserIds = append(allActivityPushUserIds, profile.Id)
}
}
}
mentionedUsersList := make(model.StringArray, 0, len(mentions.Mentions))
mentionAutofollowChans := []chan *model.AppError{}
threadParticipants := map[string]bool{post.UserId: true}
newParticipants := map[string]bool{}
participantMemberships := map[string]*model.ThreadMembership{}
membershipsMutex := &sync.Mutex{}
followersMutex := &sync.Mutex{}
if *a.Config().ServiceSettings.ThreadAutoFollow && post.RootId != "" {
var rootMentions *MentionResults
if parentPostList != nil {
rootPost := parentPostList.Posts[parentPostList.Order[0]]
if rootPost.GetProp(model.PostPropsFromWebhook) != "true" {
if _, ok := profileMap[rootPost.UserId]; ok {
threadParticipants[rootPost.UserId] = true
}
}
if channel.Type != model.ChannelTypeDirect {
rootMentions = getExplicitMentions(rootPost, keywords)
for id := range rootMentions.Mentions {
threadParticipants[id] = true
}
}
}
for id := range mentions.Mentions {
threadParticipants[id] = true
}
if channel.Type != model.ChannelTypeDirect {
for id, propsMap := range channelMemberNotifyPropsMap {
if ok := followers.Has(id); !ok && propsMap[model.ChannelAutoFollowThreads] == model.ChannelAutoFollowThreadsOn {
threadParticipants[id] = true
}
}
}
// sema is a counting semaphore to throttle the number of concurrent DB requests.
// A concurrency of 8 should be sufficient.
// We don't want to set a higher limit which can bring down the DB.
sema := make(chan struct{}, 8)
// for each mention, make sure to update thread autofollow (if enabled) and update increment mention count
for id := range threadParticipants {
mac := make(chan *model.AppError, 1)
// Get token.
sema <- struct{}{}
go func(userID string) {
defer func() {
close(mac)
// Release token.
<-sema
}()
mentionType, incrementMentions := mentions.Mentions[userID]
// if the user was not explicitly mentioned, check if they explicitly unfollowed the thread
if !incrementMentions {
membership, err := a.Srv().Store().Thread().GetMembershipForUser(userID, post.RootId)
var nfErr *store.ErrNotFound
if err != nil && !errors.As(err, &nfErr) {
mac <- model.NewAppError("SendNotifications", "app.channel.autofollow.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if membership != nil && !membership.Following {
return
}
}
updateFollowing := *a.Config().ServiceSettings.ThreadAutoFollow
if mentionType == ThreadMention || mentionType == CommentMention {
incrementMentions = false
updateFollowing = false
}
opts := store.ThreadMembershipOpts{
Following: true,
IncrementMentions: incrementMentions,
UpdateFollowing: updateFollowing,
UpdateViewedTimestamp: false,
UpdateParticipants: userID == post.UserId,
}
threadMembership, err := a.Srv().Store().Thread().MaintainMembership(userID, post.RootId, opts)
if err != nil {
mac <- model.NewAppError("SendNotifications", "app.channel.autofollow.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
followersMutex.Lock()
// add new followers to existing followers
if ok := followers.Has(userID); !ok && threadMembership.Following {
followers.Add(userID)
newParticipants[userID] = true
}
followersMutex.Unlock()
membershipsMutex.Lock()
participantMemberships[userID] = threadMembership
membershipsMutex.Unlock()
mac <- nil
}(id)
mentionAutofollowChans = append(mentionAutofollowChans, mac)
}
}
for id := range mentions.Mentions {
mentionedUsersList = append(mentionedUsersList, id)
}
nErr := a.Srv().Store().Channel().IncrementMentionCount(post.ChannelId, mentionedUsersList, post.RootId == "", post.IsUrgent())
if nErr != nil {
rctx.Logger().Warn(
"Failed to update mention count",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
mlog.Err(nErr),
)
}
rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Finished processing mentions",
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
)
// Log the problems that might have occurred while auto following the thread
for _, mac := range mentionAutofollowChans {
if err := <-mac; err != nil {
rctx.Logger().Warn(
"Failed to update thread autofollow from mention",
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
mlog.Err(err),
)
}
}
notificationsForCRT := &CRTNotifiers{}
if isCRTAllowed && post.RootId != "" {
for uid := range followers {
profile := profileMap[uid]
if profile == nil || !a.IsCRTEnabledForUser(rctx, uid) {
continue
}
if post.GetProp(model.PostPropsFromWebhook) != "true" && uid == post.UserId {
continue
}
// add user id to notificationsForCRT depending on threads notify props
notificationsForCRT.addFollowerToNotify(profile, mentions, channelMemberNotifyPropsMap[profile.Id], channel)
}
}
notification := &PostNotification{
Post: post.Clone(),
Channel: channel,
ProfileMap: profileMap,
Sender: sender,
}
if *a.Config().EmailSettings.SendEmailNotifications {
rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Begin sending email notifications",
mlog.String("type", model.NotificationTypeEmail),
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
)
emailRecipients := append(mentionedUsersList, notificationsForCRT.Email...)
emailRecipients = model.RemoveDuplicateStrings(emailRecipients)
for _, id := range emailRecipients {
if profileMap[id] == nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeEmail, model.NotificationReasonMissingProfile, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing profile",
mlog.String("type", model.NotificationTypeEmail),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("reason", model.NotificationReasonMissingProfile),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", id),
)
continue
}
// If email verification is required and user email is not verified don't send email.
if *a.Config().EmailSettings.RequireEmailVerification && !profileMap[id].EmailVerified {
a.CountNotificationReason(model.NotificationStatusNotSent, model.NotificationTypeEmail, model.NotificationReasonEmailNotVerified, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationDebug, "Email not verified",
mlog.String("type", model.NotificationTypeEmail),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("reason", model.NotificationReasonEmailNotVerified),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", id),
)
rctx.Logger().Debug("Skipped sending notification email, address not verified.", mlog.String("user_email", profileMap[id].Email), mlog.String("user_id", id))
continue
}
if a.userAllowsEmail(rctx, profileMap[id], channelMemberNotifyPropsMap[id], post) {
senderProfileImage, _, err := a.GetProfileImage(sender)
if err != nil {
rctx.Logger().Warn("Unable to get the sender user profile image.", mlog.String("user_id", sender.Id), mlog.Err(err))
}
a.Srv().Go(func() {
if _, err := a.sendNotificationEmail(rctx, notification, profileMap[id], team, senderProfileImage); err != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeEmail, model.NotificationReasonEmailSendError, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Error sending email notification",
mlog.String("type", model.NotificationTypeEmail),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonEmailSendError),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", id),
mlog.Err(err),
)
rctx.Logger().Warn("Unable to send notification email.", mlog.Err(err))
}
})
} else {
rctx.Logger().LogM(mlog.MlvlNotificationDebug, "Email disallowed by user",
mlog.String("type", model.NotificationTypeEmail),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("reason", "email_disallowed_by_user"),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", id),
)
}
}
rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Finished sending email notifications",
mlog.String("type", model.NotificationTypeEmail),
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
)
}
// Check for channel-wide mentions in channels that have too many members for those to work
if int64(len(profileMap)) > *a.Config().TeamSettings.MaxNotificationsPerChannel {
a.CountNotificationReason(model.NotificationStatusNotSent, model.NotificationTypeAll, model.NotificationReasonTooManyUsersInChannel, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationDebug, "Too many users to notify - will send ephemeral message",
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("reason", model.NotificationReasonTooManyUsersInChannel),
)
T := i18n.GetUserTranslations(sender.Locale)
if mentions.HereMentioned {
a.SendEphemeralPost(
rctx,
post.UserId,
&model.Post{
ChannelId: post.ChannelId,
Message: T("api.post.disabled_here", map[string]any{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
CreateAt: post.CreateAt + 1,
},
)
}
if mentions.ChannelMentioned {
a.SendEphemeralPost(
rctx,
post.UserId,
&model.Post{
ChannelId: post.ChannelId,
Message: T("api.post.disabled_channel", map[string]any{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
CreateAt: post.CreateAt + 1,
},
)
}
if mentions.AllMentioned {
a.SendEphemeralPost(
rctx,
post.UserId,
&model.Post{
ChannelId: post.ChannelId,
Message: T("api.post.disabled_all", map[string]any{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
CreateAt: post.CreateAt + 1,
},
)
}
}
if a.canSendPushNotifications() {
rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Begin sending push notifications",
mlog.String("type", model.NotificationTypePush),
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
)
for _, id := range mentionedUsersList {
if profileMap[id] == nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypePush, model.NotificationReasonMissingProfile, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing profile",
mlog.String("type", model.NotificationTypePush),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("reason", model.NotificationReasonMissingProfile),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", id),
)
continue
}
if notificationsForCRT.Push.Contains(id) {
rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Skipped direct push notification - will send as CRT notification",
mlog.String("type", model.NotificationTypePush),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("sender_id", sender.Id),
)
continue
}
var status *model.Status
var err *model.AppError
if status, err = a.GetStatus(id); err != nil {
status = &model.Status{UserId: id, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
isExplicitlyMentioned := mentions.Mentions[id] > GMMention
isGM := channel.Type == model.ChannelTypeGroup
if a.ShouldSendPushNotification(rctx, profileMap[id], channelMemberNotifyPropsMap[id], isExplicitlyMentioned, status, post, isGM) {
mentionType := mentions.Mentions[id]
replyToThreadType := ""
if mentionType == ThreadMention {
replyToThreadType = model.CommentsNotifyAny
} else if mentionType == CommentMention {
replyToThreadType = model.CommentsNotifyRoot
}
a.sendPushNotification(
notification,
profileMap[id],
mentionType == KeywordMention || mentionType == ChannelMention || mentionType == DMMention,
mentionType == ChannelMention,
replyToThreadType,
)
}
}
for _, id := range allActivityPushUserIds {
if profileMap[id] == nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypePush, model.NotificationReasonMissingProfile, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing profile",
mlog.String("type", model.NotificationTypePush),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonMissingProfile),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", id),
)
continue
}
if notificationsForCRT.Push.Contains(id) {
rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Skipped direct push notification - will send as CRT notification",
mlog.String("type", model.NotificationTypePush),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("sender_id", sender.Id),
)
continue
}
if _, ok := mentions.Mentions[id]; !ok {
var status *model.Status
var err *model.AppError
if status, err = a.GetStatus(id); err != nil {
status = &model.Status{UserId: id, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
isGM := channel.Type == model.ChannelTypeGroup
if a.ShouldSendPushNotification(rctx, profileMap[id], channelMemberNotifyPropsMap[id], false, status, post, isGM) {
a.sendPushNotification(
notification,
profileMap[id],
false,
false,
"",
)
}
}
}
for _, id := range notificationsForCRT.Push {
if profileMap[id] == nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypePush, model.NotificationReasonMissingProfile, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing profile",
mlog.String("type", model.NotificationTypePush),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonMissingProfile),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", id),
)
continue
}
var status *model.Status
var err *model.AppError
if status, err = a.GetStatus(id); err != nil {
status = &model.Status{UserId: id, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
if statusReason := doesStatusAllowPushNotification(profileMap[id].NotifyProps, status, post.ChannelId, true); statusReason == "" {
a.sendPushNotification(
notification,
profileMap[id],
false,
false,
model.CommentsNotifyCRT,
)
} else {
a.CountNotificationReason(model.NotificationStatusNotSent, model.NotificationTypePush, statusReason, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationDebug, "Notification not sent - status",
mlog.String("type", model.NotificationTypePush),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("reason", statusReason),
mlog.String("status_reason", statusReason),
mlog.String("sender_id", post.UserId),
mlog.String("receiver_id", id),
mlog.String("receiver_status", status.Status),
)
}
}
rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Finished sending push notifications",
mlog.String("type", model.NotificationTypePush),
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
)
}
rctx.Logger().LogM(mlog.MlvlNotificationTrace, "Begin sending websocket notifications",
mlog.String("type", model.NotificationTypeWebsocket),
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
)
message := model.NewWebSocketEvent(model.WebsocketEventPosted, "", post.ChannelId, "", nil, "")
message.Add("channel_type", channel.Type)
message.Add("channel_display_name", notification.GetChannelName(model.ShowUsername, ""))
message.Add("channel_name", channel.Name)
message.Add("sender_name", notification.GetSenderName(model.ShowUsername, *a.Config().ServiceSettings.EnablePostUsernameOverride))
message.Add("team_id", team.Id)
message.Add("set_online", setOnline)
if len(post.FileIds) != 0 && fchan != nil {
message.Add("otherFile", "true")
var infos []*model.FileInfo
if fResult := <-fchan; fResult.NErr != nil {
rctx.Logger().Warn("Unable to get fileInfo for push notifications.", mlog.String("post_id", post.Id), mlog.Err(fResult.NErr))
} else {
infos = fResult.Data
}
for _, info := range infos {
if info.IsImage() {
message.Add("image", "true")
break
}
}
}
if len(mentionedUsersList) > 0 {
useAddMentionsHook(message, mentionedUsersList)
}
if len(notificationsForCRT.Desktop) > 0 {
useAddFollowersHook(message, notificationsForCRT.Desktop)
}
// Collect user IDs of whom we want to acknowledge the websocket event for notification metrics
usersToAck := []string{}
for id, profile := range profileMap {
userNotificationLevel := profile.NotifyProps[model.DesktopNotifyProp]
channelNotificationLevel := channelMemberNotifyPropsMap[id][model.DesktopNotifyProp]
if shouldAckWebsocketNotification(channel.Type, userNotificationLevel, channelNotificationLevel) {
usersToAck = append(usersToAck, id)
}
}
usePostedAckHook(message, post.UserId, channel.Type, usersToAck)
appErr := a.publishWebsocketEventForPost(rctx, post, message)
if appErr != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonFetchError, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Couldn't send websocket notification for permalink post",
mlog.String("type", model.NotificationTypeWebsocket),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonFetchError),
mlog.String("sender_id", sender.Id),
mlog.Err(appErr),
)
return nil, appErr
}
// If this is a reply in a thread, notify participants
if isCRTAllowed && post.RootId != "" {
for uid := range followers {
// A user following a thread but had left the channel won't get a notification
// https://mattermost.atlassian.net/browse/MM-36769
if profileMap[uid] == nil {
// This also sometimes happens when bots, which will never show up in the map, reply to threads
// Their own post goes through this and they get "notified", which we don't need to count as an error if they can't
if uid != post.UserId {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonMissingProfile, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing profile",
mlog.String("type", model.NotificationTypeWebsocket),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonMissingProfile),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", uid),
)
}
continue
}
if a.IsCRTEnabledForUser(rctx, uid) {
message := model.NewWebSocketEvent(model.WebsocketEventThreadUpdated, team.Id, "", uid, nil, "")
threadMembership := participantMemberships[uid]
if threadMembership == nil {
tm, err := a.Srv().Store().Thread().GetMembershipForUser(uid, post.RootId)
if err != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonFetchError, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing thread membership",
mlog.String("type", model.NotificationTypeWebsocket),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonFetchError),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", uid),
mlog.Err(err),
)
return nil, errors.Wrapf(err, "Missing thread membership for participant in notifications. user_id=%q thread_id=%q", uid, post.RootId)
}
if tm == nil {
a.CountNotificationReason(model.NotificationStatusNotSent, model.NotificationTypeWebsocket, model.NotificationReasonMissingThreadMembership, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationWarn, "Missing thread membership",
mlog.String("type", model.NotificationTypeWebsocket),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusNotSent),
mlog.String("reason", model.NotificationReasonMissingThreadMembership),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", uid),
)
continue
}
threadMembership = tm
}
userThread, err := a.Srv().Store().Thread().GetThreadForUser(rctx, threadMembership, true, a.IsPostPriorityEnabled())
if err != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonFetchError, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Missing thread",
mlog.String("type", model.NotificationTypeWebsocket),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonFetchError),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", uid),
mlog.Err(err),
)
return nil, errors.Wrapf(err, "cannot get thread %q for user %q", post.RootId, uid)
}
if userThread != nil {
previousUnreadMentions := int64(0)
previousUnreadReplies := int64(0)
// if it's not a newly followed thread, calculate previous unread values.
if !newParticipants[uid] {
previousUnreadMentions = userThread.UnreadMentions
previousUnreadReplies = max(userThread.UnreadReplies-1, 0)
if mentions.isUserMentioned(uid) {
previousUnreadMentions = max(userThread.UnreadMentions-1, 0)
}
}
// set LastViewed to now for commenter
if uid == post.UserId {
opts := store.ThreadMembershipOpts{
UpdateViewedTimestamp: true,
}
// should set unread mentions, and unread replies to 0
_, err = a.Srv().Store().Thread().MaintainMembership(uid, post.RootId, opts)
if err != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonFetchError, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Failed to update thread membership",
mlog.String("type", model.NotificationTypeWebsocket),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonFetchError),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", uid),
mlog.Err(err),
)
return nil, errors.Wrapf(err, "cannot maintain thread membership %q for user %q", post.RootId, uid)
}
userThread.UnreadMentions = 0
userThread.UnreadReplies = 0
}
a.sanitizeProfiles(userThread.Participants, false)
userThread.Post.SanitizeProps()
sanitizedPost, err := a.SanitizePostMetadataForUser(rctx, userThread.Post, uid)
if err != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonParseError, model.NotificationNoPlatform)
rctx.Logger().LogM(mlog.MlvlNotificationError, "Failed to sanitize metadata",
mlog.String("type", model.NotificationTypeWebsocket),
mlog.String("post_id", post.Id),
mlog.String("status", model.NotificationStatusError),
mlog.String("reason", model.NotificationReasonParseError),
mlog.String("sender_id", sender.Id),
mlog.String("receiver_id", uid),
mlog.Err(err),
)
return nil, err
}
userThread.Post = sanitizedPost
payload, jsonErr := json.Marshal(userThread)
if jsonErr != nil {
rctx.Logger().Warn("Failed to encode thread to JSON")
}
message.Add("thread", string(payload))
message.Add("previous_unread_mentions", previousUnreadMentions)
message.Add("previous_unread_replies", previousUnreadReplies)
a.Publish(message)
}
}
}
}
a.Log().LogM(mlog.MlvlNotificationTrace, "Finish sending websocket notifications",
mlog.String("type", model.NotificationTypeWebsocket),
mlog.String("sender_id", sender.Id),
mlog.String("post_id", post.Id),
)
return mentionedUsersList, nil
}
func (a *App) RemoveNotifications(rctx request.CTX, post *model.Post, channel *model.Channel) error {
isCRTAllowed := *a.Config().ServiceSettings.CollapsedThreads != model.CollapsedThreadsDisabled
// CRT is the main issue in this case as notifications indicator are not updated when accessing threads from the sidebar.
if isCRTAllowed && post.RootId != "" {
var team *model.Team
if channel.TeamId != "" {
t, err1 := a.Srv().Store().Team().Get(channel.TeamId)
if err1 != nil {
return model.NewAppError("RemoveNotifications", "app.post.delete_post.get_team.app_error", nil, "", http.StatusInternalServerError).Wrap(err1)
}
team = t
} else {
// Blank team for DMs
team = &model.Team{}
}
pCh := make(chan store.StoreResult[map[string]*model.User], 1)
go func() {
props, err := a.Srv().Store().User().GetAllProfilesInChannel(context.Background(), channel.Id, true)
pCh <- store.StoreResult[map[string]*model.User]{Data: props, NErr: err}
close(pCh)
}()
cmnCh := make(chan store.StoreResult[map[string]model.StringMap], 1)
go func() {
props, err := a.Srv().Store().Channel().GetAllChannelMembersNotifyPropsForChannel(channel.Id, true)
cmnCh <- store.StoreResult[map[string]model.StringMap]{Data: props, NErr: err}
close(cmnCh)
}()
var gCh chan store.StoreResult[map[string]*model.Group]
if a.allowGroupMentions(rctx, post) {
gCh = make(chan store.StoreResult[map[string]*model.Group], 1)
go func() {
groupsMap, err := a.getGroupsAllowedForReferenceInChannel(channel, team)
gCh <- store.StoreResult[map[string]*model.Group]{Data: groupsMap, NErr: err}
close(gCh)
}()
}
resultP := <-pCh
if resultP.NErr != nil {
return resultP.NErr
}
profileMap := resultP.Data
resultCmn := <-cmnCh
if resultCmn.NErr != nil {
return resultCmn.NErr
}
channelMemberNotifyPropsMap := resultCmn.Data
groups := make(map[string]*model.Group)
if gCh != nil {
resultG := <-gCh
if resultG.NErr != nil {
return resultG.NErr
}
groups = resultG.Data
}
mentions, _ := a.getExplicitMentionsAndKeywords(rctx, post, channel, profileMap, groups, channelMemberNotifyPropsMap, nil)
userIDs := []string{}
for groupID := range mentions.GroupMentions {
for page := 0; ; page++ {
groupMemberPage, count, appErr := a.GetGroupMemberUsersPage(groupID, page, 100, &model.ViewUsersRestrictions{Channels: []string{channel.Id}})
if appErr != nil {
return appErr
}
for _, user := range groupMemberPage {
userIDs = append(userIDs, user.Id)
}
// count is the total number of users that match the filter criteria.
// When we've processed `count` number of users, we know there aren't
// any more users left to query and we can break the loop
if len(userIDs) == count {
break
}
}
}
for userID := range mentions.Mentions {
userIDs = append(userIDs, userID)
}
for _, userID := range userIDs {
threadMembership, appErr := a.GetThreadMembershipForUser(userID, post.RootId)
if appErr != nil {
return appErr
}
// If the user has viewed the thread or there are no unread mentions, skip.
if threadMembership.LastViewed > post.CreateAt || threadMembership.UnreadMentions == 0 {
continue
}
threadMembership.UnreadMentions -= 1
if _, err := a.Srv().Store().Thread().UpdateMembership(threadMembership); err != nil {
return err
}
userThread, err := a.Srv().Store().Thread().GetThreadForUser(rctx, threadMembership, true, a.IsPostPriorityEnabled())
if err != nil {
return err
}
if userThread != nil {
previousUnreadMentions := int64(0)
previousUnreadReplies := int64(0)
a.sanitizeProfiles(userThread.Participants, false)
userThread.Post.SanitizeProps()
sanitizedPost, err1 := a.SanitizePostMetadataForUser(rctx, userThread.Post, userID)
if err1 != nil {
return err1
}
userThread.Post = sanitizedPost
payload, jsonErr := json.Marshal(userThread)
if jsonErr != nil {
rctx.Logger().Warn("Failed to encode thread to JSON")
}
message := model.NewWebSocketEvent(model.WebsocketEventThreadUpdated, team.Id, "", userID, nil, "")
message.Add("thread", string(payload))
message.Add("previous_unread_mentions", previousUnreadMentions)
message.Add("previous_unread_replies", previousUnreadReplies)
a.Publish(message)
}
}
}
return nil
}
func (a *App) getExplicitMentionsAndKeywords(rctx request.CTX, post *model.Post, channel *model.Channel, profileMap map[string]*model.User, groups map[string]*model.Group, channelMemberNotifyPropsMap map[string]model.StringMap, parentPostList *model.PostList) (*MentionResults, MentionKeywords) {
mentions := &MentionResults{}
var allowChannelMentions bool
var keywords MentionKeywords
if channel.Type == model.ChannelTypeDirect {
isWebhook := post.GetProp(model.PostPropsFromWebhook) == "true"
// A bot can post in a DM where it doesn't belong to.
// Therefore, we cannot "guess" who is the other user,
// so we add the mention to any user that is not the
// poster unless the post comes from a webhook.
user1, user2 := channel.GetBothUsersForDM()
if (post.UserId != user1) || isWebhook {
if _, ok := profileMap[user1]; ok {
mentions.addMention(user1, DMMention)
} else {
a.Log().Debug("missing profile: DM user not in profiles", mlog.String("userId", user1), mlog.String("channelId", channel.Id))
}
}
if user2 != "" {
if (post.UserId != user2) || isWebhook {
if _, ok := profileMap[user2]; ok {
mentions.addMention(user2, DMMention)
} else {
a.Log().Debug("missing profile: DM user not in profiles", mlog.String("userId", user2), mlog.String("channelId", channel.Id))
}
}
}
} else {
allowChannelMentions = a.allowChannelMentions(rctx, post, len(profileMap))
keywords = a.getMentionKeywordsInChannel(profileMap, allowChannelMentions, channelMemberNotifyPropsMap, groups)
mentions = getExplicitMentions(post, keywords)
// Add a GM mention to all members of a GM channel
if channel.Type == model.ChannelTypeGroup {
for id := range channelMemberNotifyPropsMap {
if _, ok := profileMap[id]; ok {
mentions.addMention(id, GMMention)
} else {
a.Log().Debug("missing profile: GM user not in profiles", mlog.String("userId", id), mlog.String("channelId", channel.Id))
}
}
}
// Add an implicit mention when a user is added to a channel
// even if the user has set 'username mentions' to false in account settings.
if post.Type == model.PostTypeAddToChannel {
if addedUserId, ok := post.GetProp(model.PostPropsAddedUserId).(string); ok {
if _, ok := profileMap[addedUserId]; ok {
mentions.addMention(addedUserId, KeywordMention)
} else {
a.Log().Debug("missing profile: user added to channel not in profiles", mlog.String("userId", addedUserId), mlog.String("channelId", channel.Id))
}
}
}
// Get users that have comment thread mentions enabled
if post.RootId != "" && parentPostList != nil {
for _, threadPost := range parentPostList.Posts {
profile := profileMap[threadPost.UserId]
if profile == nil {
// Not logging missing profile since this is relatively expected
continue
}
// If this is the root post and it was posted by an OAuth bot, don't notify the user
if threadPost.Id == parentPostList.Order[0] && threadPost.IsFromOAuthBot() {
continue
}
if a.IsCRTEnabledForUser(rctx, profile.Id) {
continue
}
if profile.NotifyProps[model.CommentsNotifyProp] == model.CommentsNotifyAny || (profile.NotifyProps[model.CommentsNotifyProp] == model.CommentsNotifyRoot && threadPost.Id == parentPostList.Order[0]) {
mentionType := ThreadMention
if threadPost.Id == parentPostList.Order[0] {
mentionType = CommentMention
}
mentions.addMention(threadPost.UserId, mentionType)
}
}
}
// Prevent the user from mentioning themselves
if post.GetProp(model.PostPropsFromWebhook) != "true" {
mentions.removeMention(post.UserId)
}
}
return mentions, keywords
}
func (a *App) userAllowsEmail(rctx request.CTX, user *model.User, channelMemberNotificationProps model.StringMap, post *model.Post) bool {
// if user is a bot account or remote, then we do not send email
if user.IsBot || user.IsRemote() {
return false
}
userAllowsEmails := user.NotifyProps[model.EmailNotifyProp] != "false"
// if CRT is ON for user and the post is a reply disregard the channelEmail setting
if channelEmail, ok := channelMemberNotificationProps[model.EmailNotifyProp]; ok && !(a.IsCRTEnabledForUser(rctx, user.Id) && post.RootId != "") {
if channelEmail != model.ChannelNotifyDefault {
userAllowsEmails = channelEmail != "false"
}
}
// Remove the user as recipient when the user has muted the channel.
if channelMuted, ok := channelMemberNotificationProps[model.MarkUnreadNotifyProp]; ok {
if channelMuted == model.ChannelMarkUnreadMention {
rctx.Logger().Debug("Channel muted for user", mlog.String("user_id", user.Id), mlog.String("channel_mute", channelMuted))
userAllowsEmails = false
}
}
var status *model.Status
var err *model.AppError
if status, err = a.GetStatus(user.Id); err != nil {
status = &model.Status{
UserId: user.Id,
Status: model.StatusOffline,
Manual: false,
LastActivityAt: 0,
ActiveChannel: "",
}
}
autoResponderRelated := status.Status == model.StatusOutOfOffice || post.Type == model.PostTypeAutoResponder
emailNotificationsAllowedForStatus := status.Status != model.StatusOnline && status.Status != model.StatusDnd
return userAllowsEmails && emailNotificationsAllowedForStatus && user.DeleteAt == 0 && !autoResponderRelated
}
func (a *App) sendNoUsersNotifiedByGroupInChannel(rctx request.CTX, sender *model.User, post *model.Post, channel *model.Channel, group *model.Group) {
T := i18n.GetUserTranslations(sender.Locale)
ephemeralPost := &model.Post{
UserId: sender.Id,
RootId: post.RootId,
ChannelId: channel.Id,
Message: T("api.post.check_for_out_of_channel_group_users.message.none", model.StringInterface{"GroupName": group.Name}),
}
a.SendEphemeralPost(rctx, post.UserId, ephemeralPost)
}
// sendOutOfChannelMentions sends an ephemeral post to the sender of a post if any of the given potential mentions
// are outside of the post's channel. Returns whether or not an ephemeral post was sent.
func (a *App) sendOutOfChannelMentions(rctx request.CTX, sender *model.User, post *model.Post, channel *model.Channel, potentialMentions []string) (bool, error) {
outOfTeamUsers, outOfChannelUsers, outOfGroupsUsers, err := a.filterOutOfChannelMentions(rctx, sender, post, channel, potentialMentions)
if err != nil {
return false, err
}
if len(outOfTeamUsers) == 0 && len(outOfChannelUsers) == 0 && len(outOfGroupsUsers) == 0 {
return false, nil
}
if len(outOfChannelUsers) != 0 || len(outOfGroupsUsers) != 0 {
a.SendEphemeralPost(rctx, post.UserId, makeOutOfChannelMentionPost(sender, post, outOfChannelUsers, outOfGroupsUsers))
}
if len(outOfTeamUsers) != 0 {
a.SendEphemeralPost(rctx, post.UserId, makeOutOfTeamMentionPost(sender, post, outOfTeamUsers))
}
return true, nil
}
func (a *App) FilterUsersByVisible(rctx request.CTX, viewer *model.User, otherUsers []*model.User) ([]*model.User, *model.AppError) {
result := []*model.User{}
for _, user := range otherUsers {
canSee, err := a.UserCanSeeOtherUser(rctx, viewer.Id, user.Id)
if err != nil {
return nil, err
}
if canSee {
result = append(result, user)
}
}
return result, nil
}
func (a *App) filterOutOfChannelMentions(rctx request.CTX, sender *model.User, post *model.Post, channel *model.Channel, potentialMentions []string) ([]*model.User, []*model.User, []*model.User, error) {
if post.IsSystemMessage() {
return nil, nil, nil, nil
}
if channel.TeamId == "" || channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
return nil, nil, nil, nil
}
if len(potentialMentions) == 0 {
return nil, nil, nil, nil
}
mentionedUsersInTheTeam, err := a.Srv().Store().User().GetProfilesByUsernames(potentialMentions, &model.ViewUsersRestrictions{Teams: []string{channel.TeamId}})
if err != nil {
return nil, nil, nil, err
}
// Filter out inactive users and bots
teamUsers := model.UserSlice(mentionedUsersInTheTeam).FilterByActive(true)
teamUsers = teamUsers.FilterWithoutBots()
teamUsers, appErr := a.FilterUsersByVisible(rctx, sender, teamUsers)
if appErr != nil {
return nil, nil, nil, appErr
}
allMentionedUsers, err := a.Srv().Store().User().GetProfilesByUsernames(potentialMentions, nil)
if err != nil {
return nil, nil, nil, err
}
outOfTeamUsers := model.UserSlice(allMentionedUsers).FilterWithoutID(teamUsers.IDs())
outOfTeamUsers = outOfTeamUsers.FilterByActive(true)
outOfTeamUsers = outOfTeamUsers.FilterWithoutBots()
outOfTeamUsers, appErr = a.FilterUsersByVisible(rctx, sender, outOfTeamUsers)
if appErr != nil {
return nil, nil, nil, appErr
}
if len(teamUsers) == 0 {
return outOfTeamUsers, nil, nil, nil
}
// Differentiate between mentionedUsersInTheTeam who can and can't be added to the channel
var outOfChannelUsers model.UserSlice
var outOfGroupsUsers model.UserSlice
if channel.IsGroupConstrained() {
nonMemberIDs, err := a.FilterNonGroupChannelMembers(rctx, teamUsers.IDs(), channel)
if err != nil {
return nil, nil, nil, err
}
outOfChannelUsers = teamUsers.FilterWithoutID(nonMemberIDs)
outOfGroupsUsers = teamUsers.FilterByID(nonMemberIDs)
} else {
outOfChannelUsers = teamUsers
}
return outOfTeamUsers, outOfChannelUsers, outOfGroupsUsers, nil
}
func makeOutOfChannelMentionPost(sender *model.User, post *model.Post, outOfChannelUsers, outOfGroupsUsers []*model.User) *model.Post {
allUsers := model.UserSlice(append(outOfChannelUsers, outOfGroupsUsers...))
ocUsers := model.UserSlice(outOfChannelUsers)
ocUsernames := ocUsers.Usernames()
ocUserIDs := ocUsers.IDs()
ogUsers := model.UserSlice(outOfGroupsUsers)
ogUsernames := ogUsers.Usernames()
T := i18n.GetUserTranslations(sender.Locale)
ephemeralPostId := model.NewId()
var message string
// Generate message for users who can be invited
if len(outOfChannelUsers) == 1 {
message = T("api.post.check_for_out_of_channel_mentions.message.one", map[string]any{
"Username": ocUsernames[0],
})
} else if len(outOfChannelUsers) > 1 {
preliminary, final := splitAtFinal(ocUsernames)
message = T("api.post.check_for_out_of_channel_mentions.message.multiple", map[string]any{
"Usernames": strings.Join(preliminary, ", @"),
"LastUsername": final,
})
}
if len(outOfGroupsUsers) == 1 {
if message != "" {
message += "\n"
}
message += T("api.post.check_for_out_of_channel_groups_mentions.message.one", map[string]any{
"Username": ogUsernames[0],
})
} else if len(outOfGroupsUsers) > 1 {
preliminary, final := splitAtFinal(ogUsernames)
if message != "" {
message += "\n"
}
message += T("api.post.check_for_out_of_channel_groups_mentions.message.multiple", map[string]any{
"Usernames": strings.Join(preliminary, ", @"),
"LastUsername": final,
})
}
props := model.StringInterface{
model.PropsAddChannelMember: model.StringInterface{
"post_id": ephemeralPostId,
"usernames": allUsers.Usernames(), // Kept for backwards compatibility of mobile app.
"not_in_channel_usernames": ocUsernames,
"user_ids": allUsers.IDs(), // Kept for backwards compatibility of mobile app.
"not_in_channel_user_ids": ocUserIDs,
"not_in_groups_usernames": ogUsernames,
"not_in_groups_user_ids": ogUsers.IDs(),
},
}
return &model.Post{
Id: ephemeralPostId,
RootId: post.RootId,
ChannelId: post.ChannelId,
Message: message,
CreateAt: post.CreateAt + 1,
Props: props,
}
}
func makeOutOfTeamMentionPost(sender *model.User, post *model.Post, outOfTeamUsers []*model.User) *model.Post {
otUsers := model.UserSlice(outOfTeamUsers)
otUsernames := otUsers.Usernames()
T := i18n.GetUserTranslations(sender.Locale)
ephemeralPostId := model.NewId()
var message string
if len(outOfTeamUsers) == 1 {
message += T("api.post.check_for_out_of_team_mentions.message.one", map[string]any{
"Username": otUsernames[0],
})
} else if len(outOfTeamUsers) > 1 {
preliminary, final := splitAtFinal(otUsernames)
message += T("api.post.check_for_out_of_team_mentions.message.multiple", map[string]any{
"Usernames": strings.Join(preliminary, ", @"),
"LastUsername": final,
})
}
return &model.Post{
Id: ephemeralPostId,
RootId: post.RootId,
ChannelId: post.ChannelId,
Message: message,
CreateAt: post.CreateAt + 1,
}
}
func splitAtFinal(items []string) (preliminary []string, final string) {
if len(items) == 0 {
return
}
preliminary = items[:len(items)-1]
final = items[len(items)-1]
return
}
// Given a message and a map mapping mention keywords to the users who use them, returns a map of mentioned
// users and a slice of potential mention users not in the channel and whether or not @here was mentioned.
func getExplicitMentions(post *model.Post, keywords MentionKeywords) *MentionResults {
parser := makeStandardMentionParser(keywords)
buf := ""
mentionsEnabledFields := getMentionsEnabledFields(post)
for _, message := range mentionsEnabledFields {
// Parse the text as Markdown, combining adjacent Text nodes into a single string for processing
markdown.Inspect(message, func(node any) bool {
text, ok := node.(*markdown.Text)
if !ok {
// This node isn't a string so process any accumulated text in the buffer
if buf != "" {
parser.ProcessText(buf)
}
buf = ""
return true
}
// This node is a string, so add it to buf and continue onto the next node to see if it's more text
buf += text.Text
return false
})
}
// Process any left over text
if buf != "" {
parser.ProcessText(buf)
}
return parser.Results()
}
// Given a post returns the values of the fields in which mentions are possible.
// post.message, preText and text in the attachment are enabled.
func getMentionsEnabledFields(post *model.Post) model.StringArray {
ret := []string{}
ret = append(ret, post.Message)
for _, attachment := range post.Attachments() {
if attachment.Pretext != "" {
ret = append(ret, attachment.Pretext)
}
if attachment.Text != "" {
ret = append(ret, attachment.Text)
}
for _, field := range attachment.Fields {
if valueString, ok := field.Value.(string); ok && valueString != "" {
ret = append(ret, valueString)
}
}
}
return ret
}
// allowChannelMentions returns whether or not the channel mentions are allowed for the given post.
func (a *App) allowChannelMentions(rctx request.CTX, post *model.Post, numProfiles int) bool {
if !a.HasPermissionToChannel(rctx, post.UserId, post.ChannelId, model.PermissionUseChannelMentions) {
return false
}
if post.Type == model.PostTypeHeaderChange || post.Type == model.PostTypePurposeChange {
return false
}
if int64(numProfiles) >= *a.Config().TeamSettings.MaxNotificationsPerChannel {
return false
}
return true
}
// allowGroupMentions returns whether or not the group mentions are allowed for the given post.
func (a *App) allowGroupMentions(rctx request.CTX, post *model.Post) bool {
if !model.MinimumProfessionalLicense(a.Srv().License()) {
return false
}
if !a.HasPermissionToChannel(rctx, post.UserId, post.ChannelId, model.PermissionUseGroupMentions) {
return false
}
if post.Type == model.PostTypeHeaderChange || post.Type == model.PostTypePurposeChange {
return false
}
return true
}
// getGroupsAllowedForReferenceInChannel returns a map of groups allowed for reference in a given channel and team.
func (a *App) getGroupsAllowedForReferenceInChannel(channel *model.Channel, team *model.Team) (map[string]*model.Group, error) {
var err error
groupsMap := make(map[string]*model.Group)
opts := model.GroupSearchOpts{FilterAllowReference: true, IncludeMemberCount: true}
if channel.IsGroupConstrained() || (team != nil && team.IsGroupConstrained()) {
var groups []*model.GroupWithSchemeAdmin
if channel.IsGroupConstrained() {
groups, err = a.Srv().Store().Group().GetGroupsByChannel(channel.Id, opts)
} else {
groups, err = a.Srv().Store().Group().GetGroupsByTeam(team.Id, opts)
}
if err != nil {
return nil, errors.Wrap(err, "unable to get groups")
}
for _, group := range groups {
if group.Group.Name != nil {
groupsMap[group.Id] = &group.Group
}
}
opts.Source = model.GroupSourceCustom
var customgroups []*model.Group
customgroups, err = a.Srv().Store().Group().GetGroups(0, 0, opts, nil)
if err != nil {
return nil, errors.Wrap(err, "unable to get custom groups")
}
for _, group := range customgroups {
if group.Name != nil {
groupsMap[group.Id] = group
}
}
return groupsMap, nil
}
groups, err := a.Srv().Store().Group().GetGroups(0, 0, opts, nil)
if err != nil {
return nil, errors.Wrap(err, "unable to get groups")
}
for _, group := range groups {
if group.Name != nil {
groupsMap[group.Id] = group
}
}
return groupsMap, nil
}
// Given a map of user IDs to profiles, returns a list of mention
// keywords for all users in the channel.
func (a *App) getMentionKeywordsInChannel(profiles map[string]*model.User, allowChannelMentions bool, channelMemberNotifyPropsMap map[string]model.StringMap, groups map[string]*model.Group) MentionKeywords {
keywords := make(MentionKeywords)
for _, profile := range profiles {
keywords.AddUser(
profile,
channelMemberNotifyPropsMap[profile.Id],
a.GetStatusFromCache(profile.Id),
allowChannelMentions,
)
}
keywords.AddGroupsMap(groups)
return keywords
}
// insertGroupMentions adds group members in the channel to Mentions, adds group members not in the channel to OtherPotentialMentions
// returns false if no group members present in the team that the channel belongs to
func (a *App) insertGroupMentions(senderID string, group *model.Group, channel *model.Channel, profileMap map[string]*model.User, mentions *MentionResults) (bool, *model.AppError) {
var err error
var groupMembers []*model.User
outOfChannelGroupMembers := []*model.User{}
isGroupOrDirect := channel.IsGroupOrDirect()
if isGroupOrDirect {
groupMembers, err = a.Srv().Store().Group().GetMemberUsers(group.Id)
} else {
groupMembers, err = a.Srv().Store().Group().GetMemberUsersInTeam(group.Id, channel.TeamId)
}
if err != nil {
return false, model.NewAppError("insertGroupMentions", "app.select_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if mentions.Mentions == nil {
mentions.Mentions = make(map[string]MentionType)
}
for _, member := range groupMembers {
if member.Id != senderID {
if _, ok := profileMap[member.Id]; ok {
mentions.Mentions[member.Id] = GroupMention
} else {
outOfChannelGroupMembers = append(outOfChannelGroupMembers, member)
}
}
}
potentialGroupMembersMentioned := []string{}
for _, user := range outOfChannelGroupMembers {
potentialGroupMembersMentioned = append(potentialGroupMembersMentioned, user.Username)
}
if mentions.OtherPotentialMentions == nil {
mentions.OtherPotentialMentions = potentialGroupMembersMentioned
} else {
mentions.OtherPotentialMentions = append(mentions.OtherPotentialMentions, potentialGroupMembersMentioned...)
}
return isGroupOrDirect || len(groupMembers) > 0, nil
}
// Represents either an email or push notification and contains the fields required to send it to any user.
type PostNotification struct {
Channel *model.Channel
Post *model.Post
ProfileMap map[string]*model.User
Sender *model.User
}
// Returns the name of the channel for this notification. For direct messages, this is the sender's name
// preceded by an at sign. For group messages, this is a comma-separated list of the members of the
// channel, with an option to exclude the recipient of the message from that list.
func (n *PostNotification) GetChannelName(userNameFormat, excludeId string) string {
switch n.Channel.Type {
case model.ChannelTypeDirect:
return n.Sender.GetDisplayNameWithPrefix(userNameFormat, "@")
case model.ChannelTypeGroup:
names := []string{}
for _, user := range n.ProfileMap {
if user.Id != excludeId {
names = append(names, user.GetDisplayName(userNameFormat))
}
}
sort.Strings(names)
return strings.Join(names, ", ")
default:
return n.Channel.DisplayName
}
}
// Returns the name of the sender of this notification, accounting for things like system messages
// and whether or not the username has been overridden by an integration.
func (n *PostNotification) GetSenderName(userNameFormat string, overridesAllowed bool) string {
if n.Post.IsSystemMessage() {
return i18n.T("system.message.name")
}
if overridesAllowed && n.Channel.Type != model.ChannelTypeDirect {
if value := n.Post.GetProp(model.PostPropsOverrideUsername); value != nil && n.Post.GetProp(model.PostPropsFromWebhook) == "true" {
if s, ok := value.(string); ok {
return s
}
}
}
return n.Sender.GetDisplayNameWithPrefix(userNameFormat, "@")
}
func (a *App) GetNotificationNameFormat(user *model.User) string {
if !*a.Config().PrivacySettings.ShowFullName {
return model.ShowUsername
}
data, err := a.Srv().Store().Preference().Get(user.Id, model.PreferenceCategoryDisplaySettings, model.PreferenceNameNameFormat)
if err != nil {
return *a.Config().TeamSettings.TeammateNameDisplay
}
return data.Value
}
type CRTNotifiers struct {
// Desktop contains the user IDs of thread followers to receive desktop notification
Desktop model.StringArray
// Email contains the user IDs of thread followers to receive email notification
Email model.StringArray
// Push contains the user IDs of thread followers to receive push notification
Push model.StringArray
}
func (c *CRTNotifiers) addFollowerToNotify(user *model.User, mentions *MentionResults, channelMemberNotificationProps model.StringMap, channel *model.Channel) {
_, userWasMentioned := mentions.Mentions[user.Id]
notifyDesktop, notifyPush, notifyEmail := shouldUserNotifyCRT(user, userWasMentioned)
notifyChannelDesktop, notifyChannelPush := shouldChannelMemberNotifyCRT(user.NotifyProps, channelMemberNotificationProps, userWasMentioned)
// respect the user global notify props when there are no channel specific ones (default)
// otherwise respect the channel member's notify props
if (channelMemberNotificationProps[model.DesktopNotifyProp] == model.ChannelNotifyDefault && notifyDesktop) || notifyChannelDesktop {
c.Desktop = append(c.Desktop, user.Id)
}
if notifyEmail {
c.Email = append(c.Email, user.Id)
}
// respect the user global notify props when there are no channel specific ones (default)
// otherwise respect the channel member's notify props
if (channelMemberNotificationProps[model.PushNotifyProp] == model.ChannelNotifyDefault && notifyPush) || notifyChannelPush {
c.Push = append(c.Push, user.Id)
}
}
// user global settings check for desktop, email, and push notifications
func shouldUserNotifyCRT(user *model.User, isMentioned bool) (notifyDesktop, notifyPush, notifyEmail bool) {
notifyDesktop = false
notifyPush = false
notifyEmail = false
desktop := user.NotifyProps[model.DesktopNotifyProp]
push := user.NotifyProps[model.PushNotifyProp]
shouldEmail := user.NotifyProps[model.EmailNotifyProp] == "true"
desktopThreads := user.NotifyProps[model.DesktopThreadsNotifyProp]
emailThreads := user.NotifyProps[model.EmailThreadsNotifyProp]
pushThreads := user.NotifyProps[model.PushThreadsNotifyProp]
// user should be notified via desktop notification in the case the notify prop is not set as no notify
// and either the user was mentioned or the CRT notify prop for desktop is set to all
if desktop != model.UserNotifyNone && (isMentioned || desktopThreads == model.UserNotifyAll || desktop == model.UserNotifyAll) {
notifyDesktop = true
}
// user should be notified via email when emailing is enabled and
// either the user was mentioned, or the CRT notify prop for email is set to all
if shouldEmail && (isMentioned || emailThreads == model.UserNotifyAll) {
notifyEmail = true
}
// user should be notified via push in the case the notify prop is not set as no notify
// and either the user was mentioned or the CRT push notify prop is set to all
if push != model.UserNotifyNone && (isMentioned || pushThreads == model.UserNotifyAll || push == model.UserNotifyAll) {
notifyPush = true
}
return
}
// channel specific settings check for desktop and push notifications
func shouldChannelMemberNotifyCRT(userNotifyProps model.StringMap, channelMemberNotifyProps model.StringMap, isMentioned bool) (notifyDesktop, notifyPush bool) {
notifyDesktop = false
notifyPush = false
desktop := channelMemberNotifyProps[model.DesktopNotifyProp]
push := channelMemberNotifyProps[model.PushNotifyProp]
desktopThreads := channelMemberNotifyProps[model.DesktopThreadsNotifyProp]
userDesktopThreads := userNotifyProps[model.DesktopThreadsNotifyProp]
pushThreads := channelMemberNotifyProps[model.PushThreadsNotifyProp]
// user should be notified via desktop notification in the case the notify prop is not set as no notify or default
// and either the user was mentioned or the CRT notify prop for desktop is set to all
if desktop != model.ChannelNotifyDefault && desktop != model.ChannelNotifyNone && (isMentioned || (desktopThreads == model.ChannelNotifyAll && userDesktopThreads != model.UserNotifyMention) || desktop == model.ChannelNotifyAll) {
notifyDesktop = true
}
// user should be notified via push in the case the notify prop is not set as no notify or default
// and either the user was mentioned or the CRT push notify prop is set to all
if push != model.ChannelNotifyDefault && push != model.ChannelNotifyNone && (isMentioned || pushThreads == model.ChannelNotifyAll || push == model.ChannelNotifyAll) {
notifyPush = true
}
return
}
func shouldAckWebsocketNotification(channelType model.ChannelType, userNotificationLevel, channelNotificationLevel string) bool {
if channelNotificationLevel == model.ChannelNotifyAll {
// Should ACK on if we notify for all messages in the channel
return true
} else if channelNotificationLevel == model.ChannelNotifyDefault && userNotificationLevel == model.UserNotifyAll {
// Should ACK on if we notify for all messages and the channel settings are unchanged
return true
} else if channelType == model.ChannelTypeGroup &&
((channelNotificationLevel == model.ChannelNotifyDefault && userNotificationLevel == model.UserNotifyMention) ||
channelNotificationLevel == model.ChannelNotifyMention) {
// Should ACK for group channels where default settings are in place (should be notified)
return true
}
return false
}
func (a *App) CountNotification(notificationType model.NotificationType, platform string) {
if a.notificationMetricsDisabled() {
return
}
a.Metrics().IncrementNotificationCounter(notificationType, platform)
}
func (a *App) CountNotificationAck(notificationType model.NotificationType, platform string) {
if a.notificationMetricsDisabled() {
return
}
a.Metrics().IncrementNotificationAckCounter(notificationType, platform)
}
func (a *App) CountNotificationReason(
notificationStatus model.NotificationStatus,
notificationType model.NotificationType,
notificationReason model.NotificationReason,
platform string,
) {
if a.notificationMetricsDisabled() {
return
}
switch notificationStatus {
case model.NotificationStatusSuccess:
a.Metrics().IncrementNotificationSuccessCounter(notificationType, platform)
case model.NotificationStatusError:
a.Metrics().IncrementNotificationErrorCounter(notificationType, notificationReason, platform)
case model.NotificationStatusNotSent:
a.Metrics().IncrementNotificationNotSentCounter(notificationType, notificationReason, platform)
case model.NotificationStatusUnsupported:
a.Metrics().IncrementNotificationUnsupportedCounter(notificationType, notificationReason, platform)
}
}
func (a *App) notificationMetricsDisabled() bool {
if a.Metrics() == nil {
return true
}
if a.Config().FeatureFlags.NotificationMonitoring && *a.Config().MetricsSettings.EnableNotificationMetrics {
return false
}
return true
}