From 84414404a1d3032564fea9eb139fac14c72f045c Mon Sep 17 00:00:00 2001 From: Jesse Hallam Date: Mon, 24 Aug 2026 17:56:59 -0300 Subject: [PATCH 1/3] Fix nil context panic in TestDoSetupSessionAttributesProperties (#38123) UpdatePropertyFields panics when passed a nil context because RequestContextWithMaster dereferences it. Use SystemCallerContext to match the other sub-tests in the same function. --- server/channels/app/migrations_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/channels/app/migrations_test.go b/server/channels/app/migrations_test.go index 412e70c8c7e5..336a16193c67 100644 --- a/server/channels/app/migrations_test.go +++ b/server/channels/app/migrations_test.go @@ -545,7 +545,7 @@ func TestDoSetupSessionAttributesProperties(t *testing.T) { } require.Len(t, trimmed, len(persisted)-1) field.Attrs[model.PropertyFieldAttributeOptions] = trimmed - _, _, _, err := th.Server.propertyService.UpdatePropertyFields(nil, group.ID, []*model.PropertyField{field}) + _, _, _, err := th.Server.propertyService.UpdatePropertyFields(properties.SystemCallerContext(th.Context), group.ID, []*model.PropertyField{field}) require.NoError(t, err) before := sessionAttributeOptionIDsByName(t, sessionAttributeFieldByName(t, th, group.ID, model.SessionAttributesPropertyFieldUserAgentPlatform)) From 2021503fd7b9275147c11337aecdd02fd4883caf Mon Sep 17 00:00:00 2001 From: Jesse Hallam Date: Mon, 24 Aug 2026 18:04:23 -0300 Subject: [PATCH 2/3] Log file IDs instead of filenames during file upload and content extraction (#37987) --- server/channels/app/file.go | 16 +++-- .../channels/jobs/extract_content/worker.go | 4 -- .../platform/services/docextractor/archive.go | 4 +- .../services/docextractor/archive_test.go | 64 +++++++++++++++++++ .../platform/services/docextractor/combine.go | 3 +- 5 files changed, 79 insertions(+), 12 deletions(-) diff --git a/server/channels/app/file.go b/server/channels/app/file.go index d63f6c9e44b0..faa170216813 100644 --- a/server/channels/app/file.go +++ b/server/channels/app/file.go @@ -808,7 +808,6 @@ func (a *App) UploadFileX(rctx request.CTX, channelID, name string, input io.Rea } rctx = rctx.WithLogFields( - mlog.String("file_name", name), mlog.String("channel_id", channelID), mlog.String("user_id", t.UserId), ) @@ -822,6 +821,10 @@ func (a *App) UploadFileX(rctx request.CTX, channelID, name string, input io.Rea t.init(a) + rctx = rctx.WithLogFields( + mlog.String("file_info_id", t.fileinfo.Id), + ) + var aerr *model.AppError if !t.Raw && t.fileinfo.IsImage() { aerr = t.preprocessImage() @@ -879,10 +882,10 @@ func (a *App) UploadFileX(rctx request.CTX, channelID, name string, input io.Rea if !a.Srv().GoExtraction(func() { err := a.ExtractContentFromFileInfo(rctx, &infoCopy) if err != nil { - rctx.Logger().Error("Failed to extract file content", mlog.Err(err), mlog.String("file_info_id", infoCopy.Id)) + rctx.Logger().Error("Failed to extract file content", mlog.Err(err)) } }) { - rctx.Logger().Warn("Content extraction queue is full, skipping inline extraction; this file's content will not be searchable until the scheduled content extraction catch-up job runs or an admin runs a content extraction job (e.g. mmctl extract)", mlog.String("file_info_id", infoCopy.Id)) + rctx.Logger().Warn("Content extraction queue is full, skipping inline extraction; this file's content will not be searchable until the scheduled content extraction catch-up job runs or an admin runs a content extraction job (e.g. mmctl extract)") } } @@ -1713,6 +1716,9 @@ func (a *App) ExtractContentFromFileInfo(rctx request.CTX, fileInfo *model.FileI return nil } + logger := rctx.Logger().With(mlog.String("file_info_id", fileInfo.Id)) + logger.Debug("Extracting content from file", mlog.String("extension", fileInfo.Extension)) + file, aerr := a.FileReader(fileInfo.Path) if aerr != nil { return errors.Wrap(aerr, "failed to open file for extract file content") @@ -1721,7 +1727,7 @@ func (a *App) ExtractContentFromFileInfo(rctx request.CTX, fileInfo *model.FileI // ReaderCloser: with a timeout configured, extraction may continue on a // detached goroutine after Extract returns, so closing the file here would // race with that goroutine still reading it. - text, err := docextractor.Extract(rctx.Logger(), fileInfo.Name, file, docextractor.ExtractSettings{ + text, err := docextractor.Extract(logger, fileInfo.Name, file, docextractor.ExtractSettings{ Ctx: rctx.Context(), ArchiveRecursion: *a.Config().FileSettings.ArchiveRecursion, MaxFileSize: *a.Config().FileSettings.MaxFileSize, @@ -1740,7 +1746,7 @@ func (a *App) ExtractContentFromFileInfo(rctx request.CTX, fileInfo *model.FileI } reloadFileInfo, storeErr := a.Srv().Store().FileInfo().Get(fileInfo.Id) if storeErr != nil { - rctx.Logger().Warn("Failed to invalidate the fileInfo cache.", mlog.Err(storeErr), mlog.String("file_info_id", fileInfo.Id)) + logger.Warn("Failed to invalidate the fileInfo cache.", mlog.Err(storeErr)) } else { a.Srv().Store().FileInfo().InvalidateFileInfosForPostCache(reloadFileInfo.PostId, false) } diff --git a/server/channels/jobs/extract_content/worker.go b/server/channels/jobs/extract_content/worker.go index 1a085b0122f4..1cf683ac4e7e 100644 --- a/server/channels/jobs/extract_content/worker.go +++ b/server/channels/jobs/extract_content/worker.go @@ -74,8 +74,6 @@ func runCatchupExtraction(logger mlog.LoggerIFace, job *model.Job, jobServer *jo continue } - logger.Debug("Extracting file", mlog.String("filename", fileInfo.Name), mlog.String("filepath", fileInfo.Path)) - err = app.ExtractContentFromFileInfo(request.EmptyContext(logger), fileInfo) if err != nil { logger.Warn("Failed to extract file content", mlog.Err(err), mlog.String("file_info_id", fileInfo.Id)) @@ -135,8 +133,6 @@ func runRangeExtraction(logger mlog.LoggerIFace, job *model.Job, jobServer *jobs } for _, fileInfo := range fileInfos { if !ignoredFiles[fileInfo.Extension] { - logger.Debug("Extracting file", mlog.String("filename", fileInfo.Name), mlog.String("filepath", fileInfo.Path)) - err = app.ExtractContentFromFileInfo(request.EmptyContext(logger), fileInfo) if err != nil { logger.Warn("Failed to extract file content", mlog.Err(err), mlog.String("file_info_id", fileInfo.Id)) diff --git a/server/platform/services/docextractor/archive.go b/server/platform/services/docextractor/archive.go index c66d3d65e283..f80e777e0652 100644 --- a/server/platform/services/docextractor/archive.go +++ b/server/platform/services/docextractor/archive.go @@ -100,14 +100,14 @@ func (ae *archiveExtractor) Extract(ctx context.Context, name string, r io.ReadS data, err := io.ReadAll(reader) if err != nil { - return fmt.Errorf("error reading archive entry %s: %w", path, err) + return fmt.Errorf("error reading archive entry: %w", err) } subtext, extractErr := ae.SubExtractor.Extract(ctx, filename, bytes.NewReader(data), maxFileSize) if extractErr == nil { text.WriteString(subtext + " ") } else if errors.Is(extractErr, context.Canceled) || errors.Is(extractErr, context.DeadlineExceeded) { - return fmt.Errorf("error extracting %q: %w", filename, extractErr) + return fmt.Errorf("error extracting archive entry: %w", extractErr) } } return nil diff --git a/server/platform/services/docextractor/archive_test.go b/server/platform/services/docextractor/archive_test.go index b1eecb36c092..778a9cfe1ef8 100644 --- a/server/platform/services/docextractor/archive_test.go +++ b/server/platform/services/docextractor/archive_test.go @@ -4,8 +4,11 @@ package docextractor import ( + "archive/zip" "bytes" "context" + "io" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -51,3 +54,64 @@ func TestArchiveExtractorSkips7zip(t *testing.T) { assert.Error(t, err) // fails to extract as any valid archive format }) } + +// contextErrorExtractor fails every extraction with the given context error. +type contextErrorExtractor struct { + err error +} + +func (ce *contextErrorExtractor) Name() string { + return "contextErrorExtractor" +} + +func (ce *contextErrorExtractor) Match(filename string) bool { + return true +} + +func (ce *contextErrorExtractor) Extract(_ context.Context, _ string, _ io.ReadSeeker, _ int64) (string, error) { + return "", ce.err +} + +func TestArchiveExtractorErrorOmitsEntryName(t *testing.T) { + // The entry name is transformed before reaching the nested extraction error + // (separators become spaces), so assert on the stem token as well: it + // survives that transformation and would appear in either leaky error. + const entryName = "confidential-customer-list.txt" + const entryStem = "confidential" + + var archive bytes.Buffer + zw := zip.NewWriter(&archive) + entry, err := zw.Create(entryName) + require.NoError(t, err) + _, err = entry.Write([]byte(strings.Repeat("a", 1024))) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + requireNoEntryName := func(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + assert.NotContains(t, err.Error(), entryName) + assert.NotContains(t, err.Error(), entryStem) + } + + t.Run("entry read failure", func(t *testing.T) { + ae := &archiveExtractor{SubExtractor: &plainExtractor{}} + + // A maxFileSize below the entry size fails the entry read. + _, err := ae.Extract(context.Background(), "archive.zip", bytes.NewReader(archive.Bytes()), 8) + requireNoEntryName(t, err) + }) + + for name, contextErr := range map[string]error{ + "cancelled": context.Canceled, + "deadline exceeded": context.DeadlineExceeded, + } { + t.Run("nested extraction "+name, func(t *testing.T) { + ae := &archiveExtractor{SubExtractor: &contextErrorExtractor{err: contextErr}} + + _, err := ae.Extract(context.Background(), "archive.zip", bytes.NewReader(archive.Bytes()), 0) + requireNoEntryName(t, err) + assert.ErrorIs(t, err, contextErr) + }) + } +} diff --git a/server/platform/services/docextractor/combine.go b/server/platform/services/docextractor/combine.go index 6b0708d13041..16cf2cf69a56 100644 --- a/server/platform/services/docextractor/combine.go +++ b/server/platform/services/docextractor/combine.go @@ -44,7 +44,8 @@ func (ce *combineExtractor) Extract(ctx context.Context, filename string, r io.R if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return "", err } - ce.logger.Warn("Unable to extract file content", mlog.String("file_name", filename), mlog.String("extractor", extractor.Name()), mlog.Err(err)) + + ce.logger.Warn("Unable to extract file content", mlog.String("extractor", extractor.Name()), mlog.Err(err)) continue } return text, nil From 4608b024513c2faaaad978499c395619a9d90861 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:02:27 -0400 Subject: [PATCH 3/3] [MM-70291] Add Global Relay custom EML header setting (#38010) --- .../compliance-configuration-settings.mdx | 30 + .../support/api/cloud_default_config.json | 4 +- .../support/api/on_prem_default_config.json | 4 +- .../lib/src/server/default_config.ts | 2 + server/i18n/en.json | 16 + server/public/model/config.go | 73 +++ server/public/model/config_test.go | 172 +++++- .../message_export_settings.test.tsx.snap | 529 ++++++++++++++++++ .../message_export_settings.test.tsx | 131 +++++ .../admin_console/message_export_settings.tsx | 80 ++- webapp/channels/src/i18n/en.json | 6 + webapp/platform/types/src/config.ts | 2 + 12 files changed, 1037 insertions(+), 12 deletions(-) diff --git a/docs/main/administration-guide/configure/compliance-configuration-settings.mdx b/docs/main/administration-guide/configure/compliance-configuration-settings.mdx index c9e93343423c..915f91aa3ee2 100644 --- a/docs/main/administration-guide/configure/compliance-configuration-settings.mdx +++ b/docs/main/administration-guide/configure/compliance-configuration-settings.mdx @@ -412,6 +412,36 @@ The SMTP server port that will receive your Global Relay EML file when a [custom +### Custom header name + +An optional custom header added to each exported EML file when a [custom customer account type](#global-relay-customer-account) is configured. Both the custom header name and value must be set, or both left blank to omit. A save is rejected if only one of the two is set, or if the name reuses a header the export already writes (such as `From`, `To`, `Subject`, or `X-GlobalRelay-MsgType`). + + +++ + + + + + +
This feature's config.json setting is ".MessageExportSettings.GlobalRelaySettings.CustomHeaderName": "" with string input.
+ +### Custom header value + +The value sent with the custom header when a [custom customer account type](#global-relay-customer-account) is configured. Both the custom header name and value must be set, or both left blank to omit. A save is rejected if only one of the two is set, or if the name reuses a header the export already writes. + + +++ + + + + + +
This feature's config.json setting is ".MessageExportSettings.GlobalRelaySettings.CustomHeaderValue": "" with string input.
+ ### Message export batch size This setting isn't available in the System Console and can only be set in `config.json`. diff --git a/e2e-tests/cypress/tests/support/api/cloud_default_config.json b/e2e-tests/cypress/tests/support/api/cloud_default_config.json index c3232d671986..6c73b405b815 100644 --- a/e2e-tests/cypress/tests/support/api/cloud_default_config.json +++ b/e2e-tests/cypress/tests/support/api/cloud_default_config.json @@ -383,7 +383,9 @@ "EmailAddress": "", "SMTPServerTimeout": 1800, "CustomSMTPServerName": "", - "CustomSMTPPort": "25" + "CustomSMTPPort": "25", + "CustomHeaderName": "", + "CustomHeaderValue": "" } }, "PluginSettings": { diff --git a/e2e-tests/cypress/tests/support/api/on_prem_default_config.json b/e2e-tests/cypress/tests/support/api/on_prem_default_config.json index 4a16df0ad944..8eb7bb2504a4 100644 --- a/e2e-tests/cypress/tests/support/api/on_prem_default_config.json +++ b/e2e-tests/cypress/tests/support/api/on_prem_default_config.json @@ -522,7 +522,9 @@ "EmailAddress": "", "SMTPServerTimeout": 1800, "CustomSMTPServerName": "", - "CustomSMTPPort": "25" + "CustomSMTPPort": "25", + "CustomHeaderName": "", + "CustomHeaderValue": "" } }, "JobSettings": { diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index 8febb0f8cfdc..b9a3216b9b16 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -719,6 +719,8 @@ const defaultServerConfig: AdminConfig = { SMTPServerTimeout: 1800, CustomSMTPServerName: '', CustomSMTPPort: '25', + CustomHeaderName: '', + CustomHeaderValue: '', }, }, JobSettings: { diff --git a/server/i18n/en.json b/server/i18n/en.json index d4c500f462d2..28ab15d31f39 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -12282,6 +12282,22 @@ "id": "model.config.is_valid.message_export.global_relay.config_missing.app_error", "translation": "Message export job ExportFormat is set to 'globalrelay', but GlobalRelaySettings are missing." }, + { + "id": "model.config.is_valid.message_export.global_relay.custom_header_incomplete.app_error", + "translation": "Message export GlobalRelaySettings.CustomHeaderName and CustomHeaderValue must both be set, or both left empty." + }, + { + "id": "model.config.is_valid.message_export.global_relay.custom_header_name.app_error", + "translation": "Message export GlobalRelaySettings.CustomHeaderName must be a valid header name with no spaces, colons, separators, or line breaks." + }, + { + "id": "model.config.is_valid.message_export.global_relay.custom_header_reserved.app_error", + "translation": "Message export GlobalRelaySettings.CustomHeaderName must not reuse a header that the Global Relay export already writes." + }, + { + "id": "model.config.is_valid.message_export.global_relay.custom_header_value.app_error", + "translation": "Message export GlobalRelaySettings.CustomHeaderValue must not contain control characters or line breaks." + }, { "id": "model.config.is_valid.message_export.global_relay.customer_type.app_error", "translation": "Message export GlobalRelaySettings.CustomerType must be set to one of either 'A9', 'A10' or 'CUSTOM'." diff --git a/server/public/model/config.go b/server/public/model/config.go index a2cdb15f0db0..f285a391201e 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -23,6 +23,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/mattermost/ldap" "github.com/pkg/errors" + "golang.org/x/net/http/httpguts" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/utils" @@ -286,6 +287,11 @@ const ( GlobalrelayCustomerTypeA10 = "A10" GlobalrelayCustomerTypeCustom = "CUSTOM" + GlobalRelayMsgTypeHeader = "X-GlobalRelay-MsgType" + GlobalRelayChannelNameHeader = "X-Mattermost-ChannelName" + GlobalRelayChannelIDHeader = "X-Mattermost-ChannelID" + GlobalRelayChannelTypeHeader = "X-Mattermost-ChannelType" + ImageProxyTypeLocal = "local" ImageProxyTypeLegacyAtmosCamo = "atmos/camo" @@ -335,6 +341,31 @@ func GetDefaultAppCustomURLSchemes() []string { return []string{"mmauth://", "mmauthbeta://"} } +// globalRelayReservedHeaders are the headers the Global Relay EML export writes itself. +// A custom header may not reuse any of them: delivery re-parses From out of the generated +// EML to set the SMTP envelope sender, Global Relay routes on X-GlobalRelay-MsgType, and a +// duplicate Content-Type or Mime-Version makes the message unparseable. +var globalRelayReservedHeaders = map[string]struct{}{ + "from": {}, + "to": {}, + "subject": {}, + "content-transfer-encoding": {}, + "auto-submitted": {}, + "precedence": {}, + strings.ToLower(GlobalRelayMsgTypeHeader): {}, + strings.ToLower(GlobalRelayChannelNameHeader): {}, + strings.ToLower(GlobalRelayChannelIDHeader): {}, + strings.ToLower(GlobalRelayChannelTypeHeader): {}, + "date": {}, + "mime-version": {}, + "content-type": {}, +} + +func IsGlobalRelayReservedHeader(name string) bool { + _, ok := globalRelayReservedHeaders[strings.ToLower(name)] + return ok +} + var ServerTLSSupportedCiphers = map[string]uint16{ "TLS_RSA_WITH_RC4_128_SHA": tls.TLS_RSA_WITH_RC4_128_SHA, "TLS_RSA_WITH_3DES_EDE_CBC_SHA": tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, @@ -3830,6 +3861,8 @@ type GlobalRelayMessageExportSettings struct { SMTPServerTimeout *int `access:"compliance_compliance_export"` CustomSMTPServerName *string `access:"compliance_compliance_export"` CustomSMTPPort *string `access:"compliance_compliance_export"` + CustomHeaderName *string `access:"compliance_compliance_export"` // optional custom header name added to each exported EML + CustomHeaderValue *string `access:"compliance_compliance_export"` // value sent with the custom header } func (s *GlobalRelayMessageExportSettings) SetDefaults() { @@ -3854,6 +3887,38 @@ func (s *GlobalRelayMessageExportSettings) SetDefaults() { if s.CustomSMTPPort == nil { s.CustomSMTPPort = new("25") } + if s.CustomHeaderName == nil { + s.CustomHeaderName = new("") + } + if s.CustomHeaderValue == nil { + s.CustomHeaderValue = new("") + } +} + +// The custom header name is written verbatim into the exported EML, so it must be a +// valid header field name; the value must be free of control characters that could +// inject additional headers. +func (s *GlobalRelayMessageExportSettings) isValidCustomHeader() *AppError { + name := SafeDereference(s.CustomHeaderName) + value := strings.TrimSpace(SafeDereference(s.CustomHeaderValue)) + + // No custom header configured. + if name == "" && value == "" { + return nil + } + if name == "" || value == "" { + return NewAppError("Config.IsValid", "model.config.is_valid.message_export.global_relay.custom_header_incomplete.app_error", nil, "", http.StatusBadRequest) + } + if !httpguts.ValidHeaderFieldName(name) { + return NewAppError("Config.IsValid", "model.config.is_valid.message_export.global_relay.custom_header_name.app_error", nil, "", http.StatusBadRequest) + } + if IsGlobalRelayReservedHeader(name) { + return NewAppError("Config.IsValid", "model.config.is_valid.message_export.global_relay.custom_header_reserved.app_error", nil, "", http.StatusBadRequest) + } + if !httpguts.ValidHeaderFieldValue(value) { + return NewAppError("Config.IsValid", "model.config.is_valid.message_export.global_relay.custom_header_value.app_error", nil, "", http.StatusBadRequest) + } + return nil } type MessageExportSettings struct { @@ -5251,6 +5316,14 @@ func (s *MessageExportSettings) isValid() *AppError { return NewAppError("Config.IsValid", "model.config.is_valid.message_export.global_relay.smtp_password.app_error", nil, "", http.StatusBadRequest) } } + + if (*s.ExportFormat == ComplianceExportTypeGlobalrelay || *s.ExportFormat == ComplianceExportTypeGlobalrelayZip) && + s.GlobalRelaySettings != nil && + SafeDereference(s.GlobalRelaySettings.CustomerType) == GlobalrelayCustomerTypeCustom { + if appErr := s.GlobalRelaySettings.isValidCustomHeader(); appErr != nil { + return appErr + } + } } return nil } diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go index 0a328061f8f2..47b0f58c3109 100644 --- a/server/public/model/config_test.go +++ b/server/public/model/config_test.go @@ -1113,11 +1113,25 @@ func TestMessageExportSettingsIsValidGlobalRelaySettingsInvalidCustomerType(t *t } // func TestMessageExportSettingsIsValidGlobalRelaySettingsInvalidEmailAddress(t *testing.T) { +func customRelaySettings(name, value string) *GlobalRelayMessageExportSettings { + return &GlobalRelayMessageExportSettings{ + CustomerType: new(GlobalrelayCustomerTypeCustom), + EmailAddress: new("valid@mattermost.com"), + SMTPUsername: new("SomeUsername"), + SMTPPassword: new("SomePassword"), + CustomSMTPServerName: new("feeds.example.com"), + CustomSMTPPort: new("25"), + CustomHeaderName: new(name), + CustomHeaderValue: new(value), + } +} + func TestMessageExportSettingsGlobalRelaySettings(t *testing.T) { tests := []struct { name string value *GlobalRelayMessageExportSettings success bool + errorId string }{ { "Invalid email address", @@ -1128,6 +1142,7 @@ func TestMessageExportSettingsGlobalRelaySettings(t *testing.T) { SMTPPassword: new("SomePassword"), }, false, + "", }, { "Missing smtp username", @@ -1137,6 +1152,7 @@ func TestMessageExportSettingsGlobalRelaySettings(t *testing.T) { SMTPPassword: new("SomePassword"), }, false, + "", }, { "Invalid smtp username", @@ -1147,6 +1163,7 @@ func TestMessageExportSettingsGlobalRelaySettings(t *testing.T) { SMTPPassword: new("SomePassword"), }, false, + "", }, { "Invalid smtp password", @@ -1157,6 +1174,7 @@ func TestMessageExportSettingsGlobalRelaySettings(t *testing.T) { SMTPPassword: new(""), }, false, + "", }, { "Valid data", @@ -1167,6 +1185,127 @@ func TestMessageExportSettingsGlobalRelaySettings(t *testing.T) { SMTPPassword: new("SomePassword"), }, true, + "", + }, + { + "A9 with only custom header name set is ignored", + &GlobalRelayMessageExportSettings{ + CustomerType: new(GlobalrelayCustomerTypeA9), + EmailAddress: new("valid@mattermost.com"), + SMTPUsername: new("SomeUsername"), + SMTPPassword: new("SomePassword"), + CustomHeaderName: new("X-Custom"), + }, + true, + "", + }, + { + "Valid custom header", + customRelaySettings("X-ProofpointArchiveMediaType", "Message"), + true, + "", + }, + { + "Custom header name with CRLF", + customRelaySettings("X-Custom\r\nInjected", "Message"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_name.app_error", + }, + { + "Custom header value with CRLF", + customRelaySettings("X-Custom", "Message\r\nInjected: evil"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_value.app_error", + }, + { + "Custom header name with invalid character", + customRelaySettings("X-Custom:Header", "Message"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_name.app_error", + }, + { + "Custom header name with a space", + customRelaySettings("X Custom", "Message"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_name.app_error", + }, + { + "Custom header value may contain spaces and colons", + customRelaySettings("X-Custom", "some value: with punctuation"), + true, + "", + }, + { + "Custom header name set without a value", + customRelaySettings("X-Custom", ""), + false, + "model.config.is_valid.message_export.global_relay.custom_header_incomplete.app_error", + }, + { + "Custom header value set without a name", + customRelaySettings("", "Message"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_incomplete.app_error", + }, + { + "Custom header both empty", + customRelaySettings("", ""), + true, + "", + }, + { + "Custom header value may contain non-ASCII", + customRelaySettings("X-Custom", "Café Meeting"), + true, + "", + }, + { + "Custom header name with a non-token character", + customRelaySettings("X-Custom(Foo)", "Message"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_name.app_error", + }, + { + "Custom header name reserved: From", + customRelaySettings("From", "attacker@example.com"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_reserved.app_error", + }, + { + "Custom header name reserved: to (case-insensitive)", + customRelaySettings("to", "attacker@example.com"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_reserved.app_error", + }, + { + "Custom header name reserved: X-GlobalRelay-MsgType", + customRelaySettings(GlobalRelayMsgTypeHeader, "NotMattermost"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_reserved.app_error", + }, + { + "Custom header name reserved: Content-Type", + customRelaySettings("Content-Type", "text/plain"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_reserved.app_error", + }, + { + "Custom header name reserved: mixed-case fRoM", + customRelaySettings("fRoM", "attacker@example.com"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_reserved.app_error", + }, + { + "Custom header name reserved: mixed-case x-globalrelay-MSGTYPE", + customRelaySettings("x-globalrelay-MSGTYPE", "NotMattermost"), + false, + "model.config.is_valid.message_export.global_relay.custom_header_reserved.app_error", + }, + { + "Custom header value is whitespace-only", + customRelaySettings("X-Custom", " "), + false, + "model.config.is_valid.message_export.global_relay.custom_header_incomplete.app_error", }, } @@ -1184,7 +1323,11 @@ func TestMessageExportSettingsGlobalRelaySettings(t *testing.T) { if tt.success { require.Nil(t, mes.isValid()) } else { - require.NotNil(t, mes.isValid()) + appErr := mes.isValid() + require.NotNil(t, appErr) + if tt.errorId != "" { + require.Equal(t, tt.errorId, appErr.Id) + } } }) } @@ -1201,6 +1344,33 @@ func TestMessageExportSetDefaults(t *testing.T) { require.Equal(t, ComplianceExportTypeActiance, *mes.ExportFormat) } +func TestGlobalRelayMessageExportSetDefaultsCustomHeader(t *testing.T) { + grs := &GlobalRelayMessageExportSettings{} + grs.SetDefaults() + + require.Equal(t, "", *grs.CustomHeaderName) + require.Equal(t, "", *grs.CustomHeaderValue) +} + +func TestMessageExportSettingsGlobalRelayZipCustomHeader(t *testing.T) { + mes := &MessageExportSettings{ + EnableExport: new(true), + ExportFormat: new(ComplianceExportTypeGlobalrelayZip), + ExportFromTimestamp: new(int64(0)), + DailyRunTime: new("15:04"), + BatchSize: new(100), + GlobalRelaySettings: &GlobalRelayMessageExportSettings{ + CustomerType: new(GlobalrelayCustomerTypeCustom), + CustomHeaderName: new("X-Custom\r\nInjected"), + CustomHeaderValue: new("Message"), + }, + } + + appErr := mes.isValid() + require.NotNil(t, appErr) + require.Equal(t, "model.config.is_valid.message_export.global_relay.custom_header_name.app_error", appErr.Id) +} + func TestMessageExportSetDefaultsExportEnabledExportFromTimestampNil(t *testing.T) { // Test retained as protection against regression of MM-13185 mes := &MessageExportSettings{ diff --git a/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.tsx.snap index 0c5cd6d35271..fc3f9300198d 100644 --- a/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.tsx.snap @@ -1295,3 +1295,532 @@ exports[`components/MessageExportSettings should match snapshot, enabled, global `; + +exports[`components/MessageExportSettings should match snapshot, enabled, globalrelay, custom customer type 1`] = ` +
+
+
+
+ Compliance Export +
+
+
+
+ + Enable Compliance Export: + +
+ + +
+ When true, Mattermost will export all messages that were posted in the last 24 hours. The export task is scheduled to run once per day. See + + the documentation + + to learn more. +
+
+
+
+ +
+ +
+ Set the start time of the daily scheduled compliance export job. Choose a time when fewer people are using your system. Must be a 24-hour time stamp in the form HH:MM. +
+
+
+
+ +
+ +
+

+ Format of the compliance export. Corresponds to the system that you want to import the data into. +

+

+ For Actiance XML, compliance export files are written to the exports subdirectory of the configured + + Local Storage Directory + + . For Global Relay EML, they are emailed to the configured email address. +

+
+
+
+
+
+
+ +
+ + + +
+ The type of GlobalRelay customer account that your organization has. +
+
+
+
+ +
+ +
+ The username that is used to authenticate against the GlobalRelay SMTP server. +
+
+
+
+ +
+ +
+ The password that is used to authenticate against the GlobalRelay SMTP server. +
+
+
+
+ +
+ +
+ The email address that your GlobalRelay server monitors for incoming Compliance Exports. +
+
+
+
+ +
+ +
+ The SMTP server name that will receive your Global Relay EML. +
+
+
+
+ +
+ +
+ The SMTP server port that will receive your Global Relay EML. +
+
+
+
+ +
+ +
+ An optional custom header added to each exported EML. Both the name and value must be set, or both left blank to omit. +
+
+
+
+ +
+ +
+ The value sent with the custom header. Both the name and value must be set, or both left blank to omit. +
+
+
+
+
+
+
+
+ +
+
+ Initiates a Compliance Export job immediately. +
+
+
+ + + + + + + + + + +
+ Status + + Finish Time + + Run Time + + Details + +
+
+
+
+
+
+ +
+
+
+ +
+`; diff --git a/webapp/channels/src/components/admin_console/message_export_settings.test.tsx b/webapp/channels/src/components/admin_console/message_export_settings.test.tsx index aa33822a20e5..6986cac34163 100644 --- a/webapp/channels/src/components/admin_console/message_export_settings.test.tsx +++ b/webapp/channels/src/components/admin_console/message_export_settings.test.tsx @@ -1,8 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {fireEvent, screen} from '@testing-library/react'; import React from 'react'; +import type {AdminConfig} from '@mattermost/types/config'; import type {Job} from '@mattermost/types/jobs'; import MessageExportSettingsDefault, {MessageExportSettings} from 'components/admin_console/message_export_settings'; @@ -104,6 +106,135 @@ describe('components/MessageExportSettings', () => { ); expect(container).toMatchSnapshot(); }); + + test('should match snapshot, enabled, globalrelay, custom customer type', () => { + const config = { + MessageExportSettings: { + EnableExport: true, + ExportFormat: 'globalrelay', + DailyRunTime: '01:00', + ExportFromTimestamp: 12345678, + BatchSize: 10000, + GlobalRelaySettings: { + CustomerType: 'CUSTOM', + SMTPUsername: 'globalRelayUser', + SMTPPassword: 'globalRelayPassword', + EmailAddress: 'globalRelay@mattermost.com', + CustomSMTPServerName: 'feeds.globalrelay.com', + CustomSMTPPort: '25', + CustomHeaderName: 'X-ProofpointArchiveMediaType', + CustomHeaderValue: 'Message', + }, + }, + }; + + const {container} = renderWithContext( + , + ); + expect(container).toMatchSnapshot(); + }); + + test('should render the custom header fields with their configured values for the CUSTOM customer type', () => { + const config = { + MessageExportSettings: { + EnableExport: true, + ExportFormat: 'globalrelay', + DailyRunTime: '01:00', + ExportFromTimestamp: 12345678, + BatchSize: 10000, + GlobalRelaySettings: { + CustomerType: 'CUSTOM', + SMTPUsername: 'globalRelayUser', + SMTPPassword: 'globalRelayPassword', + EmailAddress: 'globalRelay@mattermost.com', + CustomSMTPServerName: 'feeds.globalrelay.com', + CustomSMTPPort: '25', + CustomHeaderName: 'X-ProofpointArchiveMediaType', + CustomHeaderValue: 'Message', + }, + }, + }; + + renderWithContext( + , + ); + + expect(screen.getByTestId('globalRelayCustomHeaderNameinput')).toHaveValue('X-ProofpointArchiveMediaType'); + expect(screen.getByTestId('globalRelayCustomHeaderValueinput')).toHaveValue('Message'); + }); + + test('should not render the custom header fields for non-CUSTOM customer types', () => { + const config = { + MessageExportSettings: { + EnableExport: true, + ExportFormat: 'globalrelay', + DailyRunTime: '01:00', + ExportFromTimestamp: 12345678, + BatchSize: 10000, + GlobalRelaySettings: { + CustomerType: 'A10', + SMTPUsername: 'globalRelayUser', + SMTPPassword: 'globalRelayPassword', + EmailAddress: 'globalRelay@mattermost.com', + CustomSMTPServerName: '', + CustomSMTPPort: '25', + CustomHeaderName: 'X-ProofpointArchiveMediaType', + CustomHeaderValue: 'Message', + }, + }, + }; + + renderWithContext( + , + ); + + expect(screen.queryByTestId('globalRelayCustomHeaderNameinput')).not.toBeInTheDocument(); + expect(screen.queryByTestId('globalRelayCustomHeaderValueinput')).not.toBeInTheDocument(); + }); + + test('should carry edited custom header values into the saved config', () => { + const config = { + MessageExportSettings: { + EnableExport: true, + ExportFormat: 'globalrelay', + DailyRunTime: '01:00', + ExportFromTimestamp: 12345678, + BatchSize: 10000, + GlobalRelaySettings: { + CustomerType: 'CUSTOM', + SMTPUsername: 'globalRelayUser', + SMTPPassword: 'globalRelayPassword', + EmailAddress: 'globalRelay@mattermost.com', + CustomSMTPServerName: 'feeds.globalrelay.com', + CustomSMTPPort: '25', + CustomHeaderName: '', + CustomHeaderValue: '', + }, + }, + } as unknown as AdminConfig; + + const ref = React.createRef(); + renderWithContext( + , + ); + + fireEvent.change(screen.getByTestId('globalRelayCustomHeaderNameinput'), {target: {value: 'X-ProofpointArchiveMediaType'}}); + fireEvent.change(screen.getByTestId('globalRelayCustomHeaderValueinput'), {target: {value: 'Message'}}); + + const savedConfig = ref.current!.getConfigFromState(config); + expect(savedConfig.MessageExportSettings.GlobalRelaySettings.CustomHeaderName).toBe('X-ProofpointArchiveMediaType'); + expect(savedConfig.MessageExportSettings.GlobalRelaySettings.CustomHeaderValue).toBe('Message'); + }); }); describe('components/MessageExportSettings/getJobDetails', () => { diff --git a/webapp/channels/src/components/admin_console/message_export_settings.tsx b/webapp/channels/src/components/admin_console/message_export_settings.tsx index cdb3b60045e4..be0a5c0850e9 100644 --- a/webapp/channels/src/components/admin_console/message_export_settings.tsx +++ b/webapp/channels/src/components/admin_console/message_export_settings.tsx @@ -34,6 +34,8 @@ interface State extends BaseState { globalRelayCustomSMTPServerName: AdminConfig['MessageExportSettings']['GlobalRelaySettings']['CustomSMTPServerName']; globalRelayCustomSMTPPort: AdminConfig['MessageExportSettings']['GlobalRelaySettings']['CustomSMTPPort']; globalRelaySMTPServerTimeout: AdminConfig['MessageExportSettings']['GlobalRelaySettings']['SMTPServerTimeout']; + globalRelayCustomHeaderName: AdminConfig['MessageExportSettings']['GlobalRelaySettings']['CustomHeaderName']; + globalRelayCustomHeaderValue: AdminConfig['MessageExportSettings']['GlobalRelaySettings']['CustomHeaderValue']; } const messages = defineMessages({ @@ -97,6 +99,8 @@ export class MessageExportSettings extends OLDAdminSettings} value={this.state.globalRelayCustomerType ? this.state.globalRelayCustomerType : ''} onChange={this.handleChange} - setByEnv={this.isSetByEnv('DataRetentionSettings.GlobalRelaySettings.CustomerType')} + setByEnv={this.isSetByEnv('MessageExportSettings.GlobalRelaySettings.CustomerType')} disabled={this.props.isDisabled || !this.state.enableComplianceExport} /> ); @@ -224,7 +232,7 @@ export class MessageExportSettings extends OLDAdminSettings} value={this.state.globalRelaySMTPUsername ? this.state.globalRelaySMTPUsername : ''} onChange={this.handleChange} - setByEnv={this.isSetByEnv('DataRetentionSettings.GlobalRelaySettings.SMTPUsername')} + setByEnv={this.isSetByEnv('MessageExportSettings.GlobalRelaySettings.SMTPUsername')} disabled={this.props.isDisabled || !this.state.enableComplianceExport} /> ); @@ -237,7 +245,7 @@ export class MessageExportSettings extends OLDAdminSettings} value={this.state.globalRelaySMTPPassword ? this.state.globalRelaySMTPPassword : ''} onChange={this.handleChange} - setByEnv={this.isSetByEnv('DataRetentionSettings.GlobalRelaySettings.SMTPPassword')} + setByEnv={this.isSetByEnv('MessageExportSettings.GlobalRelaySettings.SMTPPassword')} disabled={this.props.isDisabled || !this.state.enableComplianceExport} /> ); @@ -250,7 +258,7 @@ export class MessageExportSettings extends OLDAdminSettings} value={this.state.globalRelayEmailAddress ? this.state.globalRelayEmailAddress : ''} onChange={this.handleChange} - setByEnv={this.isSetByEnv('DataRetentionSettings.GlobalRelaySettings.EmailAddress')} + setByEnv={this.isSetByEnv('MessageExportSettings.GlobalRelaySettings.EmailAddress')} disabled={this.props.isDisabled || !this.state.enableComplianceExport} /> ); @@ -273,7 +281,7 @@ export class MessageExportSettings extends OLDAdminSettings ); @@ -296,7 +304,53 @@ export class MessageExportSettings extends OLDAdminSettings + ); + + const globalRelayCustomHeaderName = ( + + } + placeholder={defineMessage({id: 'admin.complianceExport.globalRelayCustomHeaderName.example', defaultMessage: 'E.g.: "X-ProofpointArchiveMediaType"'})} + helpText={ + + } + value={this.state.globalRelayCustomHeaderName ? this.state.globalRelayCustomHeaderName : ''} + onChange={this.handleChange} + setByEnv={this.isSetByEnv('MessageExportSettings.GlobalRelaySettings.CustomHeaderName')} + disabled={this.props.isDisabled || !this.state.enableComplianceExport} + /> + ); + + const globalRelayCustomHeaderValue = ( + + } + placeholder={defineMessage({id: 'admin.complianceExport.globalRelayCustomHeaderValue.example', defaultMessage: 'E.g.: "Message"'})} + helpText={ + + } + value={this.state.globalRelayCustomHeaderValue ? this.state.globalRelayCustomHeaderValue : ''} + onChange={this.handleChange} + setByEnv={this.isSetByEnv('MessageExportSettings.GlobalRelaySettings.CustomHeaderValue')} disabled={this.props.isDisabled || !this.state.enableComplianceExport} /> ); @@ -315,6 +369,14 @@ export class MessageExportSettings extends OLDAdminSettings ); } @@ -363,7 +425,7 @@ export class MessageExportSettings extends OLDAdminSettings @@ -374,7 +436,7 @@ export class MessageExportSettings extends OLDAdminSettings} value={this.state.exportJobStartTime} onChange={this.handleChange} - setByEnv={this.isSetByEnv('DataRetentionSettings.DailyRunTime')} + setByEnv={this.isSetByEnv('MessageExportSettings.DailyRunTime')} disabled={this.props.isDisabled || !this.state.enableComplianceExport} /> @@ -385,7 +447,7 @@ export class MessageExportSettings extends OLDAdminSettings diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index d34b82d60fc1..88865b5db245 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -946,6 +946,12 @@ "admin.complianceExport.globalRelayCustomerType.custom.description": "Custom", "admin.complianceExport.globalRelayCustomerType.description": "The type of GlobalRelay customer account that your organization has.", "admin.complianceExport.globalRelayCustomerType.title": "Customer Type:", + "admin.complianceExport.globalRelayCustomHeaderName.description": "An optional custom header added to each exported EML. Both the name and value must be set, or both left blank to omit.", + "admin.complianceExport.globalRelayCustomHeaderName.example": "E.g.: \"X-ProofpointArchiveMediaType\"", + "admin.complianceExport.globalRelayCustomHeaderName.title": "Custom Header Name:", + "admin.complianceExport.globalRelayCustomHeaderValue.description": "The value sent with the custom header. Both the name and value must be set, or both left blank to omit.", + "admin.complianceExport.globalRelayCustomHeaderValue.example": "E.g.: \"Message\"", + "admin.complianceExport.globalRelayCustomHeaderValue.title": "Custom Header Value:", "admin.complianceExport.globalRelayCustomSMTPPort.description": "The SMTP server port that will receive your Global Relay EML.", "admin.complianceExport.globalRelayCustomSMTPPort.example": "E.g.: \"25\"", "admin.complianceExport.globalRelayCustomSMTPPort.title": "SMTP Server Port:", diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index b584c891890a..165712851c26 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -976,6 +976,8 @@ export type MessageExportSettings = { SMTPServerTimeout: number; CustomSMTPServerName: string; CustomSMTPPort: string; + CustomHeaderName: string; + CustomHeaderValue: string; }; };