diff --git a/app/diagnostics.go b/app/diagnostics.go index 809d9ff1e4..6d83d3a890 100644 --- a/app/diagnostics.go +++ b/app/diagnostics.go @@ -501,6 +501,7 @@ func (a *App) trackConfig() { a.SendDiagnostic(TRACK_CONFIG_MESSAGE_EXPORT, map[string]interface{}{ "enable_message_export": *cfg.MessageExportSettings.EnableExport, + "export_format": *cfg.MessageExportSettings.ExportFormat, "daily_run_time": *cfg.MessageExportSettings.DailyRunTime, "default_export_from_timestamp": *cfg.MessageExportSettings.ExportFromTimestamp, "batch_size": *cfg.MessageExportSettings.BatchSize, diff --git a/i18n/en.json b/i18n/en.json index d983e88556..1a04aeeed2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -131,10 +131,6 @@ "id": "api.admin.upload_brand_image.too_large.app_error", "translation": "Unable to upload file. File is too large." }, - { - "id": "api.api.init.parsing_templates.debug", - "translation": "Parsing server templates at %v" - }, { "id": "api.api.init.parsing_templates.error", "translation": "Failed to parse server templates %v" @@ -4858,6 +4854,14 @@ "id": "model.config.is_valid.message_export.batch_size.app_error", "translation": "Message export job BatchSize must be a positive integer" }, + { + "id": "model.config.is_valid.message_export.export_type.app_error", + "translation": "Message export job ExportFormat must be one of either 'actiance' or 'globalrelay'" + }, + { + "id": "model.config.is_valid.message_export.global_relay_email_address.app_error", + "translation": "Message export job GlobalRelayEmailAddress must be set to a valid email address" + }, { "id": "model.config.is_valid.message_export.daily_runtime.app_error", "translation": "Message export job DailyRuntime must be a 24-hour time stamp in the form HH:MM." @@ -7082,6 +7086,10 @@ "id": "utils.mail.new_client.auth.app_error", "translation": "Failed to authenticate on SMTP server" }, + { + "id": "utils.mail.sendMail.attachments.write_error", + "translation": "Failed to write attachment to email" + }, { "id": "utils.mail.new_client.helo.error", "translation": "Failed to to set the HELO to SMTP server %v" @@ -7158,10 +7166,6 @@ "id": "web.create_dir.error", "translation": "Failed to create directory watcher %v" }, - { - "id": "web.dir_fail.error", - "translation": "Failed in directory watcher %v" - }, { "id": "web.do_load_channel.error", "translation": "Error in getting users profile for id=%v forcing logout" @@ -7266,18 +7270,10 @@ "id": "web.parsing_templates.debug", "translation": "Parsing templates at %v" }, - { - "id": "web.parsing_templates.error", - "translation": "Failed to parse templates %v" - }, { "id": "web.post_permalink.app_error", "translation": "Invalid Post ID" }, - { - "id": "web.reparse_templates.info", - "translation": "Re-parsing templates because of modified file %v" - }, { "id": "web.reset_password.expired_link.app_error", "translation": "The password reset link has expired" diff --git a/model/channel_member_history.go b/model/channel_member_history.go index bc71b580a7..47c59d54eb 100644 --- a/model/channel_member_history.go +++ b/model/channel_member_history.go @@ -6,7 +6,10 @@ package model type ChannelMemberHistory struct { ChannelId string UserId string - UserEmail string `db:"Email"` JoinTime int64 LeaveTime *int64 + + // these two fields are never set in the database - when we SELECT, we join on Users to get them + UserEmail string `db:"Email"` + Username string } diff --git a/model/config.go b/model/config.go index 20011f7cb1..9010eaeae5 100644 --- a/model/config.go +++ b/model/config.go @@ -158,6 +158,9 @@ const ( PLUGIN_SETTINGS_DEFAULT_DIRECTORY = "./plugins" PLUGIN_SETTINGS_DEFAULT_CLIENT_DIRECTORY = "./client/plugins" + + COMPLIANCE_EXPORT_TYPE_ACTIANCE = "actiance" + COMPLIANCE_EXPORT_TYPE_GLOBALRELAY = "globalrelay" ) type ServiceSettings struct { @@ -1623,9 +1626,13 @@ func (s *PluginSettings) SetDefaults() { type MessageExportSettings struct { EnableExport *bool + ExportFormat *string DailyRunTime *string ExportFromTimestamp *int64 BatchSize *int + + // formatter-specific settings - these are only expected to be non-nil if ExportFormat is set to the associated format + GlobalRelayEmailAddress *string } func (s *MessageExportSettings) SetDefaults() { @@ -1633,6 +1640,10 @@ func (s *MessageExportSettings) SetDefaults() { s.EnableExport = NewBool(false) } + if s.ExportFormat == nil { + s.ExportFormat = NewString(COMPLIANCE_EXPORT_TYPE_ACTIANCE) + } + if s.DailyRunTime == nil { s.DailyRunTime = NewString("01:00") } @@ -2170,6 +2181,16 @@ func (mes *MessageExportSettings) isValid(fs FileSettings) *AppError { return NewAppError("Config.IsValid", "model.config.is_valid.message_export.daily_runtime.app_error", nil, err.Error(), http.StatusBadRequest) } else if mes.BatchSize == nil || *mes.BatchSize < 0 { return NewAppError("Config.IsValid", "model.config.is_valid.message_export.batch_size.app_error", nil, "", http.StatusBadRequest) + } else if mes.ExportFormat == nil || (*mes.ExportFormat != COMPLIANCE_EXPORT_TYPE_ACTIANCE && *mes.ExportFormat != COMPLIANCE_EXPORT_TYPE_GLOBALRELAY) { + return NewAppError("Config.IsValid", "model.config.is_valid.message_export.export_type.app_error", nil, "", http.StatusBadRequest) + } + + if *mes.ExportFormat == COMPLIANCE_EXPORT_TYPE_GLOBALRELAY { + // validating email addresses is hard - just make sure it contains an '@' sign + // see https://stackoverflow.com/questions/201323/using-a-regular-expression-to-validate-an-email-address + if mes.GlobalRelayEmailAddress == nil || !strings.Contains(*mes.GlobalRelayEmailAddress, "@") { + return NewAppError("Config.IsValid", "model.config.is_valid.message_export.global_relay_email_address.app_error", nil, "", http.StatusBadRequest) + } } } return nil diff --git a/model/config_test.go b/model/config_test.go index 5510c40d04..919f73fd78 100644 --- a/model/config_test.go +++ b/model/config_test.go @@ -136,7 +136,7 @@ func TestMessageExportSettingsIsValidBatchSizeInvalid(t *testing.T) { require.Error(t, mes.isValid(*fs)) } -func TestMessageExportSettingsIsValid(t *testing.T) { +func TestMessageExportSettingsIsValidExportFormatInvalid(t *testing.T) { fs := &FileSettings{ DriverName: NewString("foo"), // bypass file location check } @@ -147,6 +147,55 @@ func TestMessageExportSettingsIsValid(t *testing.T) { BatchSize: NewInt(100), } + // should fail fast because export format isn't set + require.Error(t, mes.isValid(*fs)) +} + +func TestMessageExportSettingsIsValidGlobalRelayEmailAddressInvalid(t *testing.T) { + fs := &FileSettings{ + DriverName: NewString("foo"), // bypass file location check + } + mes := &MessageExportSettings{ + EnableExport: NewBool(true), + ExportFormat: NewString(COMPLIANCE_EXPORT_TYPE_GLOBALRELAY), + ExportFromTimestamp: NewInt64(0), + DailyRunTime: NewString("15:04"), + BatchSize: NewInt(100), + } + + // should fail fast because global relay email address isn't set + require.Error(t, mes.isValid(*fs)) +} + +func TestMessageExportSettingsIsValidActiance(t *testing.T) { + fs := &FileSettings{ + DriverName: NewString("foo"), // bypass file location check + } + mes := &MessageExportSettings{ + EnableExport: NewBool(true), + ExportFormat: NewString(COMPLIANCE_EXPORT_TYPE_ACTIANCE), + ExportFromTimestamp: NewInt64(0), + DailyRunTime: NewString("15:04"), + BatchSize: NewInt(100), + } + + // should pass because everything is valid + require.Nil(t, mes.isValid(*fs)) +} + +func TestMessageExportSettingsIsValidGlobalRelay(t *testing.T) { + fs := &FileSettings{ + DriverName: NewString("foo"), // bypass file location check + } + mes := &MessageExportSettings{ + EnableExport: NewBool(true), + ExportFormat: NewString(COMPLIANCE_EXPORT_TYPE_GLOBALRELAY), + ExportFromTimestamp: NewInt64(0), + DailyRunTime: NewString("15:04"), + BatchSize: NewInt(100), + GlobalRelayEmailAddress: NewString("test@mattermost.com"), + } + // should pass because everything is valid require.Nil(t, mes.isValid(*fs)) } @@ -159,6 +208,7 @@ func TestMessageExportSetDefaults(t *testing.T) { require.Equal(t, "01:00", *mes.DailyRunTime) require.Equal(t, int64(0), *mes.ExportFromTimestamp) require.Equal(t, 10000, *mes.BatchSize) + require.Equal(t, COMPLIANCE_EXPORT_TYPE_ACTIANCE, *mes.ExportFormat) } func TestMessageExportSetDefaultsExportEnabledExportFromTimestampNil(t *testing.T) { diff --git a/model/message_export.go b/model/message_export.go index b59b114d46..22641deee3 100644 --- a/model/message_export.go +++ b/model/message_export.go @@ -9,6 +9,7 @@ type MessageExport struct { UserId *string UserEmail *string + Username *string PostId *string PostCreateAt *int64 diff --git a/store/sqlstore/channel_member_history_store.go b/store/sqlstore/channel_member_history_store.go index 182f37ce99..0b86aac282 100644 --- a/store/sqlstore/channel_member_history_store.go +++ b/store/sqlstore/channel_member_history_store.go @@ -110,7 +110,8 @@ func (s SqlChannelMemberHistoryStore) getFromChannelMemberHistoryTable(startTime query := ` SELECT cmh.*, - u.Email + u.Email, + u.Username FROM ChannelMemberHistory cmh INNER JOIN Users u ON cmh.UserId = u.Id WHERE cmh.ChannelId = :ChannelId @@ -130,9 +131,10 @@ func (s SqlChannelMemberHistoryStore) getFromChannelMemberHistoryTable(startTime func (s SqlChannelMemberHistoryStore) getFromChannelMembersTable(startTime int64, endTime int64, channelId string) ([]*model.ChannelMemberHistory, error) { query := ` SELECT DISTINCT - ch.ChannelId, - ch.UserId, - u.email + ch.ChannelId, + ch.UserId, + u.Email, + u.Username FROM ChannelMembers AS ch INNER JOIN Users AS u ON ch.UserId = u.id WHERE ch.ChannelId = :ChannelId` @@ -158,7 +160,7 @@ func (s SqlChannelMemberHistoryStore) PermanentDeleteBatch(endTime int64, limit query = `DELETE FROM ChannelMemberHistory WHERE ctid IN ( - SELECT ctid FROM ChannelMemberHistory + SELECT ctid FROM ChannelMemberHistory WHERE LeaveTime IS NOT NULL AND LeaveTime <= :EndTime LIMIT :Limit diff --git a/store/sqlstore/compliance_store.go b/store/sqlstore/compliance_store.go index a25b01548f..03d92d5e14 100644 --- a/store/sqlstore/compliance_store.go +++ b/store/sqlstore/compliance_store.go @@ -225,7 +225,8 @@ func (s SqlComplianceStore) MessageExport(after int64, limit int) store.StoreCha Channels.Id AS ChannelId, Channels.DisplayName AS ChannelDisplayName, Users.Id AS UserId, - Users.Email AS UserEmail + Users.Email AS UserEmail, + Users.Username FROM Posts LEFT OUTER JOIN Channels ON Posts.ChannelId = Channels.Id diff --git a/store/storetest/channel_member_history_store.go b/store/storetest/channel_member_history_store.go index 6fe73478cf..fa2e7a8faf 100644 --- a/store/storetest/channel_member_history_store.go +++ b/store/storetest/channel_member_history_store.go @@ -35,6 +35,7 @@ func testLogJoinEvent(t *testing.T, ss store.Store) { user := model.User{ Email: model.NewId() + "@mattermost.com", Nickname: model.NewId(), + Username: model.NewId(), } user = *store.Must(ss.User().Save(&user)).(*model.User) @@ -57,6 +58,7 @@ func testLogLeaveEvent(t *testing.T, ss store.Store) { user := model.User{ Email: model.NewId() + "@mattermost.com", Nickname: model.NewId(), + Username: model.NewId(), } user = *store.Must(ss.User().Save(&user)).(*model.User) @@ -82,6 +84,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, ss store.Store) { user := model.User{ Email: model.NewId() + "@mattermost.com", Nickname: model.NewId(), + Username: model.NewId(), } user = *store.Must(ss.User().Save(&user)).(*model.User) @@ -108,6 +111,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, ss store.Store) { assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, user.Username, channelMembers[0].Username) assert.Equal(t, joinTime, channelMembers[0].JoinTime) assert.Nil(t, channelMembers[0].LeaveTime) @@ -117,6 +121,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, ss store.Store) { assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, user.Username, channelMembers[0].Username) assert.Equal(t, joinTime, channelMembers[0].JoinTime) assert.Nil(t, channelMembers[0].LeaveTime) @@ -129,6 +134,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, ss store.Store) { assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, user.Username, channelMembers[0].Username) assert.Equal(t, joinTime, channelMembers[0].JoinTime) assert.Equal(t, leaveTime, *channelMembers[0].LeaveTime) @@ -138,6 +144,7 @@ func testGetUsersInChannelAtChannelMemberHistory(t *testing.T, ss store.Store) { assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, user.Username, channelMembers[0].Username) assert.Equal(t, joinTime, channelMembers[0].JoinTime) assert.Equal(t, leaveTime, *channelMembers[0].LeaveTime) @@ -160,6 +167,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { user := model.User{ Email: model.NewId() + "@mattermost.com", Nickname: model.NewId(), + Username: model.NewId(), } user = *store.Must(ss.User().Save(&user)).(*model.User) @@ -192,6 +200,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, user.Username, channelMembers[0].Username) assert.Equal(t, joinTime-500, channelMembers[0].JoinTime) assert.Equal(t, joinTime-100, *channelMembers[0].LeaveTime) @@ -201,6 +210,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, user.Username, channelMembers[0].Username) assert.Equal(t, joinTime-100, channelMembers[0].JoinTime) assert.Equal(t, joinTime+500, *channelMembers[0].LeaveTime) @@ -210,6 +220,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, user.Username, channelMembers[0].Username) assert.Equal(t, joinTime+100, channelMembers[0].JoinTime) assert.Equal(t, joinTime+500, *channelMembers[0].LeaveTime) @@ -219,6 +230,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, user.Username, channelMembers[0].Username) assert.Equal(t, joinTime+100, channelMembers[0].JoinTime) assert.Equal(t, leaveTime-100, *channelMembers[0].LeaveTime) @@ -228,6 +240,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, user.Username, channelMembers[0].Username) assert.Equal(t, joinTime-100, channelMembers[0].JoinTime) assert.Equal(t, leaveTime+100, *channelMembers[0].LeaveTime) @@ -237,6 +250,7 @@ func testGetUsersInChannelAtChannelMembers(t *testing.T, ss store.Store) { assert.Equal(t, channel.Id, channelMembers[0].ChannelId) assert.Equal(t, user.Id, channelMembers[0].UserId) assert.Equal(t, user.Email, channelMembers[0].UserEmail) + assert.Equal(t, user.Username, channelMembers[0].Username) assert.Equal(t, leaveTime+100, channelMembers[0].JoinTime) assert.Equal(t, leaveTime+200, *channelMembers[0].LeaveTime) } @@ -255,12 +269,14 @@ func testPermanentDeleteBatch(t *testing.T, ss store.Store) { user := model.User{ Email: model.NewId() + "@mattermost.com", Nickname: model.NewId(), + Username: model.NewId(), } user = *store.Must(ss.User().Save(&user)).(*model.User) user2 := model.User{ Email: model.NewId() + "@mattermost.com", Nickname: model.NewId(), + Username: model.NewId(), } user2 = *store.Must(ss.User().Save(&user2)).(*model.User) diff --git a/store/storetest/compliance_store.go b/store/storetest/compliance_store.go index c5bd60f053..eb29bedc7d 100644 --- a/store/storetest/compliance_store.go +++ b/store/storetest/compliance_store.go @@ -341,7 +341,8 @@ func testComplianceMessageExport(t *testing.T, ss store.Store) { // and two users that are a part of that team user1 := &model.User{ - Email: model.NewId(), + Email: model.NewId(), + Username: model.NewId(), } user1 = store.Must(ss.User().Save(user1)).(*model.User) store.Must(ss.Team().SaveMember(&model.TeamMember{ @@ -350,7 +351,8 @@ func testComplianceMessageExport(t *testing.T, ss store.Store) { }, -1)) user2 := &model.User{ - Email: model.NewId(), + Email: model.NewId(), + Username: model.NewId(), } user2 = store.Must(ss.User().Save(user2)).(*model.User) store.Must(ss.Team().SaveMember(&model.TeamMember{ @@ -415,6 +417,7 @@ func testComplianceMessageExport(t *testing.T, ss store.Store) { assert.Equal(t, channel.DisplayName, *messageExportMap[post1.Id].ChannelDisplayName) assert.Equal(t, user1.Id, *messageExportMap[post1.Id].UserId) assert.Equal(t, user1.Email, *messageExportMap[post1.Id].UserEmail) + assert.Equal(t, user1.Username, *messageExportMap[post1.Id].Username) // post2 was made by user1 in channel1 and team1 assert.Equal(t, post2.Id, *messageExportMap[post2.Id].PostId) @@ -424,6 +427,7 @@ func testComplianceMessageExport(t *testing.T, ss store.Store) { assert.Equal(t, channel.DisplayName, *messageExportMap[post2.Id].ChannelDisplayName) assert.Equal(t, user1.Id, *messageExportMap[post2.Id].UserId) assert.Equal(t, user1.Email, *messageExportMap[post2.Id].UserEmail) + assert.Equal(t, user1.Username, *messageExportMap[post2.Id].Username) // post3 is a DM between user1 and user2 assert.Equal(t, post3.Id, *messageExportMap[post3.Id].PostId) @@ -432,4 +436,5 @@ func testComplianceMessageExport(t *testing.T, ss store.Store) { assert.Equal(t, directMessageChannel.Id, *messageExportMap[post3.Id].ChannelId) assert.Equal(t, user1.Id, *messageExportMap[post3.Id].UserId) assert.Equal(t, user1.Email, *messageExportMap[post3.Id].UserEmail) + assert.Equal(t, user1.Username, *messageExportMap[post3.Id].Username) } diff --git a/templates/globalrelay_compliance_export.html b/templates/globalrelay_compliance_export.html new file mode 100644 index 0000000000..91028d11cf --- /dev/null +++ b/templates/globalrelay_compliance_export.html @@ -0,0 +1,91 @@ +{{define "globalrelay_compliance_export"}} + + +
| Username |
+ Joined | +Left | +Duration | + +
|---|
Exported on {{.Props.ExportDate}}
+{{end}} \ No newline at end of file diff --git a/templates/globalrelay_compliance_export_message.html b/templates/globalrelay_compliance_export_message.html new file mode 100644 index 0000000000..3a47b29b70 --- /dev/null +++ b/templates/globalrelay_compliance_export_message.html @@ -0,0 +1,8 @@ +{{define "globalrelay_compliance_export_message"}} + +{{end}} diff --git a/templates/globalrelay_compliance_export_participant_row.html b/templates/globalrelay_compliance_export_participant_row.html new file mode 100644 index 0000000000..7a61e23eb7 --- /dev/null +++ b/templates/globalrelay_compliance_export_participant_row.html @@ -0,0 +1,10 @@ +{{define "globalrelay_compliance_export_participant_row"}} +