notification.go 33.5 KB
Newer Older
1
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
2 3 4 5 6 7 8 9 10 11 12 13 14 15
// See License.txt for license information.

package app

import (
	"fmt"
	"html"
	"html/template"
	"net/http"
	"net/url"
	"path/filepath"
	"sort"
	"strings"
	"time"
16
	"unicode"
17 18

	l4g "github.com/alecthomas/log4go"
Christopher Speller's avatar
Christopher Speller committed
19 20 21
	"github.com/mattermost/mattermost-server/model"
	"github.com/mattermost/mattermost-server/store"
	"github.com/mattermost/mattermost-server/utils"
22
	"github.com/mattermost/mattermost-server/utils/markdown"
23 24 25
	"github.com/nicksnyder/go-i18n/i18n"
)

Chris's avatar
Chris committed
26 27 28
func (a *App) SendNotifications(post *model.Post, team *model.Team, channel *model.Channel, sender *model.User, parentPostList *model.PostList) ([]string, *model.AppError) {
	pchan := a.Srv.Store.User().GetAllProfilesInChannel(channel.Id, true)
	cmnchan := a.Srv.Store.Channel().GetAllChannelMembersNotifyPropsForChannel(channel.Id, true)
29 30 31
	var fchan store.StoreChannel

	if len(post.FileIds) != 0 {
Chris's avatar
Chris committed
32
		fchan = a.Srv.Store.FileInfo().GetForPost(post.Id, true, true)
33
	}
34

35 36 37
	var profileMap map[string]*model.User
	if result := <-pchan; result.Err != nil {
		return nil, result.Err
38
	} else {
39 40
		profileMap = result.Data.(map[string]*model.User)
	}
41

42 43 44 45 46 47 48
	var channelMemberNotifyPropsMap map[string]model.StringMap
	if result := <-cmnchan; result.Err != nil {
		return nil, result.Err
	} else {
		channelMemberNotifyPropsMap = result.Data.(map[string]model.StringMap)
	}

49 50 51 52 53 54 55 56 57 58 59
	mentionedUserIds := make(map[string]bool)
	allActivityPushUserIds := []string{}
	hereNotification := false
	channelNotification := false
	allNotification := false
	updateMentionChans := []store.StoreChannel{}

	if channel.Type == model.CHANNEL_DIRECT {
		var otherUserId string
		if userIds := strings.Split(channel.Name, "__"); userIds[0] == post.UserId {
			otherUserId = userIds[1]
60
		} else {
61
			otherUserId = userIds[0]
62 63
		}

64 65 66 67
		if _, ok := profileMap[otherUserId]; ok {
			mentionedUserIds[otherUserId] = true
		}

68 69
		if post.Props["from_webhook"] == "true" {
			mentionedUserIds[post.UserId] = true
70
		}
71
	} else {
Chris's avatar
Chris committed
72
		keywords := a.GetMentionKeywordsInChannel(profileMap, post.Type != model.POST_HEADER_CHANGE && post.Type != model.POST_PURPOSE_CHANGE)
73

74 75
		m := GetExplicitMentions(post.Message, keywords)
		mentionedUserIds, hereNotification, channelNotification, allNotification = m.MentionedUserIds, m.HereMentioned, m.ChannelMentioned, m.AllMentioned
76

77
		// get users that have comment thread mentions enabled
78 79 80 81 82
		if len(post.RootId) > 0 && parentPostList != nil {
			for _, threadPost := range parentPostList.Posts {
				profile := profileMap[threadPost.UserId]
				if profile != nil && (profile.NotifyProps["comments"] == "any" || (profile.NotifyProps["comments"] == "root" && threadPost.Id == parentPostList.Order[0])) {
					mentionedUserIds[threadPost.UserId] = true
83 84
				}
			}
85
		}
86

87 88 89 90
		// prevent the user from mentioning themselves
		if post.Props["from_webhook"] != "true" {
			delete(mentionedUserIds, post.UserId)
		}
91

92 93
		if len(m.OtherPotentialMentions) > 0 {
			if result := <-a.Srv.Store.User().GetProfilesByUsernames(m.OtherPotentialMentions, team.Id); result.Err == nil {
94
				outOfChannelMentions := result.Data.([]*model.User)
95
				if channel.Type != model.CHANNEL_GROUP {
Chris's avatar
Chris committed
96
					a.Go(func() {
97
						a.sendOutOfChannelMentions(sender, post, channel.Type, outOfChannelMentions)
Chris's avatar
Chris committed
98
					})
99
				}
100
			}
101
		}
102

103 104
		// find which users in the channel are set up to always receive mobile notifications
		for _, profile := range profileMap {
105 106
			if (profile.NotifyProps[model.PUSH_NOTIFY_PROP] == model.USER_NOTIFY_ALL ||
				channelMemberNotifyPropsMap[profile.Id][model.PUSH_NOTIFY_PROP] == model.CHANNEL_NOTIFY_ALL) &&
107 108 109
				(post.UserId != profile.Id || post.Props["from_webhook"] == "true") &&
				!post.IsSystemMessage() {
				allActivityPushUserIds = append(allActivityPushUserIds, profile.Id)
110 111
			}
		}
112
	}
113

114 115 116
	mentionedUsersList := make([]string, 0, len(mentionedUserIds))
	for id := range mentionedUserIds {
		mentionedUsersList = append(mentionedUsersList, id)
Chris's avatar
Chris committed
117
		updateMentionChans = append(updateMentionChans, a.Srv.Store.Channel().IncrementMentionCount(post.ChannelId, id))
118
	}
119

120 121 122 123 124 125 126
	senderName := ""
	channelName := ""
	if post.IsSystemMessage() {
		senderName = utils.T("system.message.name")
	} else {
		if value, ok := post.Props["override_username"]; ok && post.Props["from_webhook"] == "true" {
			senderName = value.(string)
127
		} else {
128
			senderName = sender.Username
129
		}
130
	}
131

132 133 134 135 136
	if channel.Type == model.CHANNEL_GROUP {
		userList := []*model.User{}
		for _, u := range profileMap {
			if u.Id != sender.Id {
				userList = append(userList, u)
137 138
			}
		}
139 140 141 142
		userList = append(userList, sender)
		channelName = model.GetGroupDisplayNameFromUsers(userList, false)
	} else {
		channelName = channel.DisplayName
143
	}
144

145 146 147 148
	var senderUsername string
	if value, ok := post.Props["override_username"]; ok && post.Props["from_webhook"] == "true" {
		senderUsername = value.(string)
	} else {
149
		senderUsername = sender.Username
150
	}
151

Chris's avatar
Chris committed
152
	if a.Config().EmailSettings.SendEmailNotifications {
153
		for _, id := range mentionedUsersList {
154 155 156 157 158 159
			userAllowsEmails := profileMap[id].NotifyProps[model.EMAIL_NOTIFY_PROP] != "false"
			if channelEmail, ok := channelMemberNotifyPropsMap[id][model.EMAIL_NOTIFY_PROP]; ok {
				if channelEmail != model.CHANNEL_NOTIFY_DEFAULT {
					userAllowsEmails = channelEmail != "false"
				}
			}
160

161
			//If email verification is required and user email is not verified don't send email.
Chris's avatar
Chris committed
162
			if a.Config().EmailSettings.RequireEmailVerification && !profileMap[id].EmailVerified {
163 164 165 166
				l4g.Error("Skipped sending notification email to %v, address not verified. [details: user_id=%v]", profileMap[id].Email, id)
				continue
			}

167 168
			var status *model.Status
			var err *model.AppError
Chris's avatar
Chris committed
169
			if status, err = a.GetStatus(id); err != nil {
170 171 172 173 174 175
				status = &model.Status{
					UserId:         id,
					Status:         model.STATUS_OFFLINE,
					Manual:         false,
					LastActivityAt: 0,
					ActiveChannel:  "",
176
				}
177
			}
178

179
			if userAllowsEmails && status.Status != model.STATUS_ONLINE && profileMap[id].DeleteAt == 0 {
Chris's avatar
Chris committed
180
				a.sendNotificationEmail(post, profileMap[id], channel, team, senderName, sender)
181 182
			}
		}
183
	}
184

185
	T := utils.GetUserTranslations(sender.Locale)
186 187

	// If the channel has more than 1K users then @here is disabled
Chris's avatar
Chris committed
188
	if hereNotification && int64(len(profileMap)) > *a.Config().TeamSettings.MaxNotificationsPerChannel {
189
		hereNotification = false
190
		a.SendEphemeralPost(
191 192 193
			post.UserId,
			&model.Post{
				ChannelId: post.ChannelId,
Chris's avatar
Chris committed
194
				Message:   T("api.post.disabled_here", map[string]interface{}{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
195 196 197 198
				CreateAt:  post.CreateAt + 1,
			},
		)
	}
199

200
	// If the channel has more than 1K users then @channel is disabled
Chris's avatar
Chris committed
201
	if channelNotification && int64(len(profileMap)) > *a.Config().TeamSettings.MaxNotificationsPerChannel {
202
		a.SendEphemeralPost(
203 204 205
			post.UserId,
			&model.Post{
				ChannelId: post.ChannelId,
Chris's avatar
Chris committed
206
				Message:   T("api.post.disabled_channel", map[string]interface{}{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
207 208 209 210
				CreateAt:  post.CreateAt + 1,
			},
		)
	}
211

212
	// If the channel has more than 1K users then @all is disabled
Chris's avatar
Chris committed
213
	if allNotification && int64(len(profileMap)) > *a.Config().TeamSettings.MaxNotificationsPerChannel {
214
		a.SendEphemeralPost(
215 216 217
			post.UserId,
			&model.Post{
				ChannelId: post.ChannelId,
Chris's avatar
Chris committed
218
				Message:   T("api.post.disabled_all", map[string]interface{}{"Users": *a.Config().TeamSettings.MaxNotificationsPerChannel}),
219 220 221 222
				CreateAt:  post.CreateAt + 1,
			},
		)
	}
223

224 225 226 227 228 229
	// Make sure all mention updates are complete to prevent race
	// Probably better to batch these DB updates in the future
	// MUST be completed before push notifications send
	for _, uchan := range updateMentionChans {
		if result := <-uchan; result.Err != nil {
			l4g.Warn(utils.T("api.post.update_mention_count_and_forget.update_error"), post.Id, post.ChannelId, result.Err)
230
		}
231
	}
232

233
	sendPushNotifications := false
Chris's avatar
Chris committed
234 235
	if *a.Config().EmailSettings.SendPushNotifications {
		pushServer := *a.Config().EmailSettings.PushNotificationServer
236
		if license := a.License(); pushServer == model.MHPNS && (license == nil || !*license.Features.MHPNS) {
237 238 239 240 241 242 243 244 245 246 247
			l4g.Warn(utils.T("api.post.send_notifications_and_forget.push_notification.mhpnsWarn"))
			sendPushNotifications = false
		} else {
			sendPushNotifications = true
		}
	}

	if sendPushNotifications {
		for _, id := range mentionedUsersList {
			var status *model.Status
			var err *model.AppError
Chris's avatar
Chris committed
248
			if status, err = a.GetStatus(id); err != nil {
249
				status = &model.Status{UserId: id, Status: model.STATUS_OFFLINE, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
250 251
			}

252
			if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], true, status, post) {
Chris's avatar
Chris committed
253
				a.sendPushNotification(post, profileMap[id], channel, senderName, channelName, true)
254 255 256
			}
		}

257 258
		for _, id := range allActivityPushUserIds {
			if _, ok := mentionedUserIds[id]; !ok {
259 260
				var status *model.Status
				var err *model.AppError
Chris's avatar
Chris committed
261
				if status, err = a.GetStatus(id); err != nil {
262
					status = &model.Status{UserId: id, Status: model.STATUS_OFFLINE, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
263 264
				}

265
				if ShouldSendPushNotification(profileMap[id], channelMemberNotifyPropsMap[id], false, status, post) {
Chris's avatar
Chris committed
266
					a.sendPushNotification(post, profileMap[id], channel, senderName, channelName, false)
267 268 269 270 271 272
				}
			}
		}
	}

	message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_POSTED, "", post.ChannelId, "", nil)
Chris's avatar
Chris committed
273
	message.Add("post", a.PostWithProxyAddedToImageURLs(post).ToJson())
274
	message.Add("channel_type", channel.Type)
275
	message.Add("channel_display_name", channelName)
276 277 278 279
	message.Add("channel_name", channel.Name)
	message.Add("sender_name", senderUsername)
	message.Add("team_id", team.Id)

280
	if len(post.FileIds) != 0 && fchan != nil {
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301
		message.Add("otherFile", "true")

		var infos []*model.FileInfo
		if result := <-fchan; result.Err != nil {
			l4g.Warn(utils.T("api.post.send_notifications.files.error"), post.Id, result.Err)
		} else {
			infos = result.Data.([]*model.FileInfo)
		}

		for _, info := range infos {
			if info.IsImage() {
				message.Add("image", "true")
				break
			}
		}
	}

	if len(mentionedUsersList) != 0 {
		message.Add("mentions", model.ArrayToJson(mentionedUsersList))
	}

302
	a.Publish(message)
303 304 305
	return mentionedUsersList, nil
}

Chris's avatar
Chris committed
306
func (a *App) sendNotificationEmail(post *model.Post, user *model.User, channel *model.Channel, team *model.Team, senderName string, sender *model.User) *model.AppError {
307
	if channel.IsGroupOrDirect() {
Chris's avatar
Chris committed
308
		if result := <-a.Srv.Store.Team().GetTeamsByUserId(user.Id); result.Err != nil {
309 310 311 312 313 314 315 316 317 318 319 320 321
			return result.Err
		} else {
			// if the recipient isn't in the current user's team, just pick one
			teams := result.Data.([]*model.Team)
			found := false

			for i := range teams {
				if teams[i].Id == team.Id {
					found = true
					break
				}
			}

322 323 324 325
			if !found && len(teams) > 0 {
				team = teams[0]
			} else {
				// in case the user hasn't joined any teams we send them to the select_team page
Chris's avatar
Chris committed
326
				team = &model.Team{Name: "select_team", DisplayName: a.Config().TeamSettings.SiteName}
327 328 329
			}
		}
	}
Chris's avatar
Chris committed
330
	if *a.Config().EmailSettings.EnableEmailBatching {
331
		var sendBatched bool
Chris's avatar
Chris committed
332
		if result := <-a.Srv.Store.Preference().Get(user.Id, model.PREFERENCE_CATEGORY_NOTIFICATIONS, model.PREFERENCE_NAME_EMAIL_INTERVAL); result.Err != nil {
333 334
			// if the call fails, assume that the interval has not been explicitly set and batch the notifications
			sendBatched = true
335
		} else {
336 337
			// if the user has chosen to receive notifications immediately, don't batch them
			sendBatched = result.Data.(model.Preference).Value != model.PREFERENCE_EMAIL_INTERVAL_NO_BATCHING_SECONDS
338 339 340
		}

		if sendBatched {
341
			if err := a.AddNotificationEmailToBatch(user, post, team); err == nil {
342 343 344 345 346 347 348
				return nil
			}
		}

		// fall back to sending a single email if we can't batch it for some reason
	}

349 350
	translateFunc := utils.GetUserTranslations(user.Locale)

351
	var subjectText string
352
	if channel.Type == model.CHANNEL_DIRECT {
Chris's avatar
Chris committed
353
		subjectText = getDirectMessageNotificationEmailSubject(post, translateFunc, a.Config().TeamSettings.SiteName, senderName)
354 355
	} else if *a.Config().EmailSettings.UseChannelInEmailNotifications {
		subjectText = getNotificationEmailSubject(post, translateFunc, a.Config().TeamSettings.SiteName, team.DisplayName+" ("+channel.DisplayName+")")
356
	} else {
Chris's avatar
Chris committed
357
		subjectText = getNotificationEmailSubject(post, translateFunc, a.Config().TeamSettings.SiteName, team.DisplayName)
358 359 360
	}

	emailNotificationContentsType := model.EMAIL_NOTIFICATION_CONTENTS_FULL
361
	if license := a.License(); license != nil && *license.Features.EmailNotificationContents {
Chris's avatar
Chris committed
362
		emailNotificationContentsType = *a.Config().EmailSettings.EmailNotificationContentsType
363
	}
364

365
	teamURL := utils.GetSiteURL() + "/" + team.Name
Chris's avatar
Chris committed
366
	var bodyText = a.getNotificationEmailBody(user, post, channel, senderName, team.Name, teamURL, emailNotificationContentsType, translateFunc)
367

Chris's avatar
Chris committed
368
	a.Go(func() {
Chris's avatar
Chris committed
369
		if err := a.SendMail(user.Email, html.UnescapeString(subjectText), bodyText); err != nil {
370 371
			l4g.Error(utils.T("api.post.send_notifications_and_forget.send.error"), user.Email, err)
		}
Chris's avatar
Chris committed
372
	})
373

Chris's avatar
Chris committed
374 375
	if a.Metrics != nil {
		a.Metrics.IncrementPostSentEmail()
376
	}
377

378 379
	return nil
}
380

381 382 383 384 385 386 387 388 389 390 391 392 393 394
/**
 * Computes the subject line for direct notification email messages
 */
func getDirectMessageNotificationEmailSubject(post *model.Post, translateFunc i18n.TranslateFunc, siteName string, senderName string) string {
	t := getFormattedPostTime(post, translateFunc)
	var subjectParameters = map[string]interface{}{
		"SiteName":          siteName,
		"SenderDisplayName": senderName,
		"Month":             t.Month,
		"Day":               t.Day,
		"Year":              t.Year,
	}
	return translateFunc("app.notification.subject.direct.full", subjectParameters)
}
395

396 397 398 399 400 401 402 403 404 405 406 407 408 409
/**
 * Computes the subject line for group, public, and private email messages
 */
func getNotificationEmailSubject(post *model.Post, translateFunc i18n.TranslateFunc, siteName string, teamName string) string {
	t := getFormattedPostTime(post, translateFunc)
	var subjectParameters = map[string]interface{}{
		"SiteName": siteName,
		"TeamName": teamName,
		"Month":    t.Month,
		"Day":      t.Day,
		"Year":     t.Year,
	}
	return translateFunc("app.notification.subject.notification.full", subjectParameters)
}
410

411 412 413
/**
 * Computes the email body for notification messages
 */
Chris's avatar
Chris committed
414
func (a *App) getNotificationEmailBody(recipient *model.User, post *model.Post, channel *model.Channel, senderName string, teamName string, teamURL string, emailNotificationContentsType string, translateFunc i18n.TranslateFunc) string {
415 416 417
	// only include message contents in notification email if email notification contents type is set to full
	var bodyPage *utils.HTMLTemplate
	if emailNotificationContentsType == model.EMAIL_NOTIFICATION_CONTENTS_FULL {
Chris's avatar
Chris committed
418
		bodyPage = a.NewEmailTemplate("post_body_full", recipient.Locale)
Chris's avatar
Chris committed
419
		bodyPage.Props["PostMessage"] = a.GetMessageForNotification(post, translateFunc)
420
	} else {
Chris's avatar
Chris committed
421
		bodyPage = a.NewEmailTemplate("post_body_generic", recipient.Locale)
422 423
	}

424
	bodyPage.Props["SiteURL"] = utils.GetSiteURL()
425
	if teamName != "select_team" {
426 427 428 429 430
		bodyPage.Props["TeamLink"] = teamURL + "/pl/" + post.Id
	} else {
		bodyPage.Props["TeamLink"] = teamURL
	}

431 432 433 434 435
	var channelName = channel.DisplayName
	if channel.Type == model.CHANNEL_GROUP {
		channelName = translateFunc("api.templates.channel_name.group")
	}
	t := getFormattedPostTime(post, translateFunc)
436

437
	var bodyText string
438
	var info template.HTML
439 440 441
	if channel.Type == model.CHANNEL_DIRECT {
		if emailNotificationContentsType == model.EMAIL_NOTIFICATION_CONTENTS_FULL {
			bodyText = translateFunc("app.notification.body.intro.direct.full")
442
			info = utils.TranslateAsHtml(translateFunc, "app.notification.body.text.direct.full",
443 444 445 446 447 448 449 450 451 452 453 454
				map[string]interface{}{
					"SenderName": senderName,
					"Hour":       t.Hour,
					"Minute":     t.Minute,
					"TimeZone":   t.TimeZone,
					"Month":      t.Month,
					"Day":        t.Day,
				})
		} else {
			bodyText = translateFunc("app.notification.body.intro.direct.generic", map[string]interface{}{
				"SenderName": senderName,
			})
455
			info = utils.TranslateAsHtml(translateFunc, "app.notification.body.text.direct.generic",
456 457 458 459 460 461 462 463 464 465 466
				map[string]interface{}{
					"Hour":     t.Hour,
					"Minute":   t.Minute,
					"TimeZone": t.TimeZone,
					"Month":    t.Month,
					"Day":      t.Day,
				})
		}
	} else {
		if emailNotificationContentsType == model.EMAIL_NOTIFICATION_CONTENTS_FULL {
			bodyText = translateFunc("app.notification.body.intro.notification.full")
467
			info = utils.TranslateAsHtml(translateFunc, "app.notification.body.text.notification.full",
468 469 470 471 472 473 474 475 476 477 478 479 480
				map[string]interface{}{
					"ChannelName": channelName,
					"SenderName":  senderName,
					"Hour":        t.Hour,
					"Minute":      t.Minute,
					"TimeZone":    t.TimeZone,
					"Month":       t.Month,
					"Day":         t.Day,
				})
		} else {
			bodyText = translateFunc("app.notification.body.intro.notification.generic", map[string]interface{}{
				"SenderName": senderName,
			})
481
			info = utils.TranslateAsHtml(translateFunc, "app.notification.body.text.notification.generic",
482 483 484 485 486 487 488
				map[string]interface{}{
					"Hour":     t.Hour,
					"Minute":   t.Minute,
					"TimeZone": t.TimeZone,
					"Month":    t.Month,
					"Day":      t.Day,
				})
489
		}
490 491
	}

492
	bodyPage.Props["BodyText"] = bodyText
493
	bodyPage.Html["Info"] = info
494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521
	bodyPage.Props["Button"] = translateFunc("api.templates.post_body.button")

	return bodyPage.Render()
}

type formattedPostTime struct {
	Time     time.Time
	Year     string
	Month    string
	Day      string
	Hour     string
	Minute   string
	TimeZone string
}

func getFormattedPostTime(post *model.Post, translateFunc i18n.TranslateFunc) formattedPostTime {
	tm := time.Unix(post.CreateAt/1000, 0)
	zone, _ := tm.Zone()

	return formattedPostTime{
		Time:     tm,
		Year:     fmt.Sprintf("%d", tm.Year()),
		Month:    translateFunc(tm.Month().String()),
		Day:      fmt.Sprintf("%d", tm.Day()),
		Hour:     fmt.Sprintf("%02d", tm.Hour()),
		Minute:   fmt.Sprintf("%02d", tm.Minute()),
		TimeZone: zone,
	}
522 523
}

Chris's avatar
Chris committed
524
func (a *App) GetMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string {
525 526 527 528 529 530
	if len(strings.TrimSpace(post.Message)) != 0 || len(post.FileIds) == 0 {
		return post.Message
	}

	// extract the filenames from their paths and determine what type of files are attached
	var infos []*model.FileInfo
Chris's avatar
Chris committed
531
	if result := <-a.Srv.Store.FileInfo().GetForPost(post.Id, true, true); result.Err != nil {
532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558
		l4g.Warn(utils.T("api.post.get_message_for_notification.get_files.error"), post.Id, result.Err)
	} else {
		infos = result.Data.([]*model.FileInfo)
	}

	filenames := make([]string, len(infos))
	onlyImages := true
	for i, info := range infos {
		if escaped, err := url.QueryUnescape(filepath.Base(info.Name)); err != nil {
			// this should never error since filepath was escaped using url.QueryEscape
			filenames[i] = escaped
		} else {
			filenames[i] = info.Name
		}

		onlyImages = onlyImages && info.IsImage()
	}

	props := map[string]interface{}{"Filenames": strings.Join(filenames, ", ")}

	if onlyImages {
		return translateFunc("api.post.get_message_for_notification.images_sent", len(filenames), props)
	} else {
		return translateFunc("api.post.get_message_for_notification.files_sent", len(filenames), props)
	}
}

Chris's avatar
Chris committed
559 560
func (a *App) sendPushNotification(post *model.Post, user *model.User, channel *model.Channel, senderName, channelName string, wasMentioned bool) *model.AppError {
	sessions, err := a.getMobileAppSessions(user.Id)
561 562 563 564 565 566 567 568 569
	if err != nil {
		return err
	}

	if channel.Type == model.CHANNEL_DIRECT {
		channelName = senderName
	}

	msg := model.PushNotification{}
Chris's avatar
Chris committed
570
	if badge := <-a.Srv.Store.User().GetUnreadCount(user.Id); badge.Err != nil {
571 572 573 574 575
		msg.Badge = 1
		l4g.Error(utils.T("store.sql_user.get_unread_count.app_error"), user.Id, badge.Err)
	} else {
		msg.Badge = int(badge.Data.(int64))
	}
576

577 578 579
	msg.Type = model.PUSH_TYPE_MESSAGE
	msg.TeamId = channel.TeamId
	msg.ChannelId = channel.Id
580 581
	msg.PostId = post.Id
	msg.RootId = post.RootId
582
	msg.ChannelName = channel.Name
583
	msg.SenderId = post.UserId
584

585 586
	if ou, ok := post.Props["override_username"].(string); ok {
		msg.OverrideUsername = ou
587 588
	}

589 590
	if oi, ok := post.Props["override_icon_url"].(string); ok {
		msg.OverrideIconUrl = oi
591 592
	}

593 594
	if fw, ok := post.Props["from_webhook"].(string); ok {
		msg.FromWebhook = fw
595
	}
596

597 598
	userLocale := utils.GetUserTranslations(user.Locale)
	hasFiles := post.FileIds != nil && len(post.FileIds) > 0
599

600
	msg.Message, msg.Category = a.getPushNotificationMessage(post.Message, wasMentioned, hasFiles, senderName, channelName, channel.Type, userLocale)
601 602 603 604

	for _, session := range sessions {
		tmpMessage := *model.PushNotificationFromJson(strings.NewReader(msg.ToJson()))
		tmpMessage.SetDeviceIdAndPlatform(session.DeviceId)
605 606 607

		l4g.Debug("Sending push notification to device %v for user %v with msg of '%v'", tmpMessage.DeviceId, user.Id, msg.Message)

Chris's avatar
Chris committed
608 609 610 611 612
		a.Go(func(session *model.Session) func() {
			return func() {
				a.sendToPushProxy(tmpMessage, session)
			}
		}(session))
613

Chris's avatar
Chris committed
614 615
		if a.Metrics != nil {
			a.Metrics.IncrementPostSentPush()
616 617 618 619 620 621
		}
	}

	return nil
}

622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658
func (a *App) getPushNotificationMessage(postMessage string, wasMentioned bool, hasFiles bool, senderName string, channelName string, channelType string, userLocale i18n.TranslateFunc) (string, string) {
	message := ""
	category := ""

	if *a.Config().EmailSettings.PushNotificationContents == model.FULL_NOTIFICATION {
		category = model.CATEGORY_CAN_REPLY

		if channelType == model.CHANNEL_DIRECT {
			message = senderName + ": " + model.ClearMentionTags(postMessage)
		} else {
			message = senderName + userLocale("api.post.send_notifications_and_forget.push_in") + channelName + ": " + model.ClearMentionTags(postMessage)
		}
	} else if *a.Config().EmailSettings.PushNotificationContents == model.GENERIC_NO_CHANNEL_NOTIFICATION {
		if channelType == model.CHANNEL_DIRECT {
			category = model.CATEGORY_CAN_REPLY

			message = senderName + userLocale("api.post.send_notifications_and_forget.push_message")
		} else if wasMentioned {
			message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention_no_channel")
		} else {
			message = senderName + userLocale("api.post.send_notifications_and_forget.push_non_mention_no_channel")
		}
	} else {
		if channelType == model.CHANNEL_DIRECT {
			category = model.CATEGORY_CAN_REPLY

			message = senderName + userLocale("api.post.send_notifications_and_forget.push_message")
		} else if wasMentioned {
			category = model.CATEGORY_CAN_REPLY

			message = senderName + userLocale("api.post.send_notifications_and_forget.push_mention") + channelName
		} else {
			message = senderName + userLocale("api.post.send_notifications_and_forget.push_non_mention") + channelName
		}
	}

	// If the post only has images then push an appropriate message
659
	if len(postMessage) == 0 && hasFiles {
660 661 662 663 664 665 666 667 668 669
		if channelType == model.CHANNEL_DIRECT {
			message = senderName + userLocale("api.post.send_notifications_and_forget.push_image_only_dm")
		} else {
			message = senderName + userLocale("api.post.send_notifications_and_forget.push_image_only") + channelName
		}
	}

	return message, category
}

670
func (a *App) ClearPushNotification(userId string, channelId string) {
Chris's avatar
Chris committed
671
	a.Go(func() {
672 673 674 675 676 677 678 679 680 681 682
		// Sleep is to allow the read replicas a chance to fully sync
		// the unread count for sending an accurate count.
		// Delaying a little doesn't hurt anything and is cheaper than
		// attempting to read from master.
		time.Sleep(time.Second * 5)

		sessions, err := a.getMobileAppSessions(userId)
		if err != nil {
			l4g.Error(err.Error())
			return
		}
683

684 685 686 687 688 689 690 691 692 693
		msg := model.PushNotification{}
		msg.Type = model.PUSH_TYPE_CLEAR
		msg.ChannelId = channelId
		msg.ContentAvailable = 0
		if badge := <-a.Srv.Store.User().GetUnreadCount(userId); badge.Err != nil {
			msg.Badge = 0
			l4g.Error(utils.T("store.sql_user.get_unread_count.app_error"), userId, badge.Err)
		} else {
			msg.Badge = int(badge.Data.(int64))
		}
694

695
		l4g.Debug(utils.T("api.post.send_notifications_and_forget.clear_push_notification.debug"), msg.DeviceId, msg.ChannelId)
696

697 698 699
		for _, session := range sessions {
			tmpMessage := *model.PushNotificationFromJson(strings.NewReader(msg.ToJson()))
			tmpMessage.SetDeviceIdAndPlatform(session.DeviceId)
Chris's avatar
Chris committed
700 701 702
			a.Go(func() {
				a.sendToPushProxy(tmpMessage, session)
			})
703
		}
Chris's avatar
Chris committed
704
	})
705 706
}

Chris's avatar
Chris committed
707
func (a *App) sendToPushProxy(msg model.PushNotification, session *model.Session) {
708
	msg.ServerId = a.DiagnosticId()
709

Chris's avatar
Chris committed
710
	request, _ := http.NewRequest("POST", *a.Config().EmailSettings.PushNotificationServer+model.API_URL_SUFFIX_V1+"/send_push", strings.NewReader(msg.ToJson()))
711

Chris's avatar
Chris committed
712
	if resp, err := a.HTTPClient(true).Do(request); err != nil {
713
		l4g.Error("Device push reported as error for UserId=%v SessionId=%v message=%v", session.UserId, session.Id, err.Error())
714
	} else {
715
		pushResponse := model.PushResponseFromJson(resp.Body)
716
		if resp.Body != nil {
717
			consumeAndClose(resp)
718 719
		}

720
		if pushResponse[model.PUSH_STATUS] == model.PUSH_STATUS_REMOVE {
721
			l4g.Info("Device was reported as removed for UserId=%v SessionId=%v removing push for this session", session.UserId, session.Id)
Chris's avatar
Chris committed
722
			a.AttachDeviceId(session.Id, "", session.ExpiresAt)
Chris's avatar
Chris committed
723
			a.ClearSessionCacheForUser(session.UserId)
724
		}
725

726 727 728 729
		if pushResponse[model.PUSH_STATUS] == model.PUSH_STATUS_FAIL {
			l4g.Error("Device push reported as error for UserId=%v SessionId=%v message=%v", session.UserId, session.Id, pushResponse[model.PUSH_STATUS_ERROR_MSG])
		}
	}
730 731
}

Chris's avatar
Chris committed
732 733
func (a *App) getMobileAppSessions(userId string) ([]*model.Session, *model.AppError) {
	if result := <-a.Srv.Store.Session().GetSessionsWithActiveDeviceIds(userId); result.Err != nil {
734 735 736 737 738 739
		return nil, result.Err
	} else {
		return result.Data.([]*model.Session), nil
	}
}

740
func (a *App) sendOutOfChannelMentions(sender *model.User, post *model.Post, channelType string, users []*model.User) *model.AppError {
741
	if len(users) == 0 {
742 743 744 745
		return nil
	}

	var usernames []string
746
	for _, user := range users {
747 748 749 750
		usernames = append(usernames, user.Username)
	}
	sort.Strings(usernames)

751 752 753 754 755
	var userIds []string
	for _, user := range users {
		userIds = append(userIds, user.Id)
	}

756
	T := utils.GetUserTranslations(sender.Locale)
757

758 759 760 761 762 763 764 765
	var localePhrase string
	if channelType == model.CHANNEL_OPEN {
		localePhrase = T("api.post.check_for_out_of_channel_mentions.link.public")
	} else if channelType == model.CHANNEL_PRIVATE {
		localePhrase = T("api.post.check_for_out_of_channel_mentions.link.private")
	}

	ephemeralPostId := model.NewId()
766
	var message string
767
	if len(users) == 1 {
768 769
		message = T("api.post.check_for_out_of_channel_mentions.message.one", map[string]interface{}{
			"Username": usernames[0],
770
			"Phrase":   localePhrase,
771 772 773
		})
	} else {
		message = T("api.post.check_for_out_of_channel_mentions.message.multiple", map[string]interface{}{
774
			"Usernames":    strings.Join(usernames[:len(usernames)-1], ", @"),
775
			"LastUsername": usernames[len(usernames)-1],
776
			"Phrase":       localePhrase,
777 778 779
		})
	}

780 781 782 783 784 785 786 787
	props := model.StringInterface{
		model.PROPS_ADD_CHANNEL_MEMBER: model.StringInterface{
			"post_id":   ephemeralPostId,
			"usernames": usernames,
			"user_ids":  userIds,
		},
	}

788
	a.SendEphemeralPost(
789 790
		post.UserId,
		&model.Post{
791
			Id:        ephemeralPostId,
792
			RootId:    post.RootId,
793 794 795
			ChannelId: post.ChannelId,
			Message:   message,
			CreateAt:  post.CreateAt + 1,
796
			Props:     props,
797 798 799 800 801 802
		},
	)

	return nil
}

803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820
type ExplicitMentions struct {
	// MentionedUserIds contains a key for each user mentioned by keyword.
	MentionedUserIds map[string]bool

	// OtherPotentialMentions contains a list of strings that looked like mentions, but didn't have
	// a corresponding keyword.
	OtherPotentialMentions []string

	// HereMentioned is true if the message contained @here.
	HereMentioned bool

	// AllMentioned is true if the message contained @all.
	AllMentioned bool

	// ChannelMentioned is true if the message contained @channel.
	ChannelMentioned bool
}

821 822
// 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.
823 824 825 826
func GetExplicitMentions(message string, keywords map[string][]string) *ExplicitMentions {
	ret := &ExplicitMentions{
		MentionedUserIds: make(map[string]bool),
	}
827 828 829 830
	systemMentions := map[string]bool{"@here": true, "@channel": true, "@all": true}

	addMentionedUsers := func(ids []string) {
		for _, id := range ids {
831
			ret.MentionedUserIds[id] = true
832 833
		}
	}
834 835
	checkForMention := func(word string) bool {
		isMention := false
836

837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862
		if word == "@here" {
			ret.HereMentioned = true
		}

		if word == "@channel" {
			ret.ChannelMentioned = true
		}

		if word == "@all" {
			ret.AllMentioned = true
		}

		// Non-case-sensitive check for regular keys
		if ids, match := keywords[strings.ToLower(word)]; match {
			addMentionedUsers(ids)
			isMention = true
		}

		// Case-sensitive check for first name
		if ids, match := keywords[word]; match {
			addMentionedUsers(ids)
			isMention = true
		}

		return isMention
	}
863 864 865 866 867 868 869 870 871
	processText := func(text string) {
		for _, word := range strings.FieldsFunc(text, func(c rune) bool {
			// Split on any whitespace or punctuation that can't be part of an at mention or emoji pattern
			return !(c == ':' || c == '.' || c == '-' || c == '_' || c == '@' || unicode.IsLetter(c) || unicode.IsNumber(c))
		}) {
			// skip word with format ':word:' with an assumption that it is an emoji format only
			if word[0] == ':' && word[len(word)-1] == ':' {
				continue
			}
872

873 874
			if checkForMention(word) {
				continue
875
			}
876

877 878 879
			// remove trailing '.', as that is the end of a sentence
			word = strings.TrimSuffix(word, ".")
			if checkForMention(word) {
880 881
				continue
			}
882

883 884 885 886 887
			if strings.ContainsAny(word, ".-:") {
				// This word contains a character that may be the end of a sentence, so split further
				splitWords := strings.FieldsFunc(word, func(c rune) bool {
					return c == '.' || c == '-' || c == ':'
				})
888

889
				for _, splitWord := range splitWords {
890 891
					if checkForMention(splitWord) {
						continue
892
					}
893
					if _, ok := systemMentions[splitWord]; !ok && strings.HasPrefix(splitWord, "@") {
894 895 896
						username := splitWord[1:]
						ret.OtherPotentialMentions = append(ret.OtherPotentialMentions, username)
					}
897 898
				}
			}
899