diff --git a/internal/bridge/smtp_backend.go b/internal/bridge/smtp_backend.go index 5cf3d576..68723256 100644 --- a/internal/bridge/smtp_backend.go +++ b/internal/bridge/smtp_backend.go @@ -60,6 +60,9 @@ func (s *smtpSession) AuthPlain(username, password string) error { if strings.Contains(s.Bridge.GetCurrentUserAgent(), useragent.DefaultUserAgent) { s.Bridge.setUserAgent(useragent.UnknownClient, useragent.DefaultVersion) } + + user.SendConfigStatusSuccess() + return nil } diff --git a/internal/bridge/user.go b/internal/bridge/user.go index e39f5fd2..d6f5bd1b 100644 --- a/internal/bridge/user.go +++ b/internal/bridge/user.go @@ -534,6 +534,7 @@ func (bridge *Bridge) addUserWithVault( bridge.vault.GetShowAllMail(), bridge.vault.GetMaxSyncMemory(), statsPath, + bridge, ) if err != nil { return fmt.Errorf("failed to create user: %w", err) diff --git a/internal/configstatus/config_status.go b/internal/configstatus/config_status.go index d73ea198..2c8cffd3 100644 --- a/internal/configstatus/config_status.go +++ b/internal/configstatus/config_status.go @@ -86,7 +86,14 @@ func (status *ConfigurationStatus) Save() error { return os.Rename(temp, status.FilePath) } -func (status *ConfigurationStatus) Success() error { +func (status *ConfigurationStatus) IsPending() bool { + status.DataLock.RLock() + defer status.DataLock.RUnlock() + + return !status.Data.DataV1.PendingSince.IsZero() +} + +func (status *ConfigurationStatus) ApplySuccess() error { status.DataLock.Lock() defer status.DataLock.Unlock() @@ -95,7 +102,7 @@ func (status *ConfigurationStatus) Success() error { return status.Save() } -func (status *ConfigurationStatus) Failure(err string) error { +func (status *ConfigurationStatus) ApplyFailure(err string) error { status.DataLock.Lock() defer status.DataLock.Unlock() @@ -104,7 +111,7 @@ func (status *ConfigurationStatus) Failure(err string) error { return status.Save() } -func (status *ConfigurationStatus) Progress() error { +func (status *ConfigurationStatus) ApplyProgress() error { status.DataLock.Lock() defer status.DataLock.Unlock() diff --git a/internal/telemetry/configuration_abort.go b/internal/configstatus/configuration_abort.go similarity index 97% rename from internal/telemetry/configuration_abort.go rename to internal/configstatus/configuration_abort.go index a79a7d37..8d6bd2fc 100644 --- a/internal/telemetry/configuration_abort.go +++ b/internal/configstatus/configuration_abort.go @@ -15,6 +15,6 @@ // You should have received a copy of the GNU General Public License // along with Proton Mail Bridge. If not, see . -package telemetry +package configstatus // GODT-2711 diff --git a/internal/telemetry/configuration_progress.go b/internal/configstatus/configuration_progress.go similarity index 97% rename from internal/telemetry/configuration_progress.go rename to internal/configstatus/configuration_progress.go index 42963f5a..6329aa35 100644 --- a/internal/telemetry/configuration_progress.go +++ b/internal/configstatus/configuration_progress.go @@ -15,6 +15,6 @@ // You should have received a copy of the GNU General Public License // along with Proton Mail Bridge. If not, see . -package telemetry +package configstatus // GODT-2713 diff --git a/internal/telemetry/configuration_recovery.go b/internal/configstatus/configuration_recovery.go similarity index 97% rename from internal/telemetry/configuration_recovery.go rename to internal/configstatus/configuration_recovery.go index 7c1a5055..74fa09ce 100644 --- a/internal/telemetry/configuration_recovery.go +++ b/internal/configstatus/configuration_recovery.go @@ -15,6 +15,6 @@ // You should have received a copy of the GNU General Public License // along with Proton Mail Bridge. If not, see . -package telemetry +package configstatus // GODT-2714 diff --git a/internal/configstatus/configuration_success.go b/internal/configstatus/configuration_success.go new file mode 100644 index 00000000..2089a16e --- /dev/null +++ b/internal/configstatus/configuration_success.go @@ -0,0 +1,58 @@ +// Copyright (c) 2023 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +package configstatus + +import ( + "time" +) + +type ConfigSuccessValues struct { + Duration int `json:"duration"` +} + +type ConfigSuccessDimensions struct { + Autoconf string `json:"autoconf"` + ReportClick interface{} `json:"report_click"` + ReportSent interface{} `json:"report_sent"` + ClickedLink uint64 `json:"clicked_link"` +} + +type ConfigSuccessData struct { + MeasurementGroup string + Event string + Values ConfigSuccessValues + Dimensions ConfigSuccessDimensions +} + +type ConfigSuccessBuilder struct{} + +func (*ConfigSuccessBuilder) New(data *ConfigurationStatusData) ConfigSuccessData { + return ConfigSuccessData{ + MeasurementGroup: "bridge.any.configuration", + Event: "bridge_config_success", + Values: ConfigSuccessValues{ + Duration: int(time.Since(data.DataV1.PendingSince).Minutes()), + }, + Dimensions: ConfigSuccessDimensions{ + Autoconf: data.DataV1.Autoconf, + ReportClick: data.DataV1.ReportClick, + ReportSent: data.DataV1.ReportSent, + ClickedLink: data.DataV1.ClickedLink, + }, + } +} diff --git a/internal/configstatus/types_config_status.go b/internal/configstatus/types_config_status.go index 032c63ca..f9188e00 100644 --- a/internal/configstatus/types_config_status.go +++ b/internal/configstatus/types_config_status.go @@ -32,7 +32,7 @@ type MetadataOnly struct { } type DataV1 struct { - PendingSince time.Time `json:"auto_update"` + PendingSince time.Time `json:"pending_since"` LastProgress time.Time `json:"last_progress"` Autoconf string `json:"auto_conf"` ClickedLink uint64 `json:"clicked_link"` diff --git a/internal/telemetry/configuration_success.go b/internal/telemetry/configuration_success.go deleted file mode 100644 index 134e2909..00000000 --- a/internal/telemetry/configuration_success.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2023 Proton AG -// -// This file is part of Proton Mail Bridge. -// -// Proton Mail Bridge is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// Proton Mail Bridge is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Proton Mail Bridge. If not, see . - -package telemetry - -// GODT-2710 diff --git a/internal/telemetry/types_heartbeat.go b/internal/telemetry/types_heartbeat.go index a10f42b1..35abf3a7 100644 --- a/internal/telemetry/types_heartbeat.go +++ b/internal/telemetry/types_heartbeat.go @@ -32,8 +32,12 @@ const ( dimensionStartTLS = "starttls" ) -type HeartbeatManager interface { +type Availability interface { IsTelemetryAvailable() bool +} + +type HeartbeatManager interface { + Availability SendHeartbeat(heartbeat *HeartbeatData) bool GetLastHeartbeatSent() time.Time SetLastHeartbeatSent(time.Time) error diff --git a/internal/user/config_status.go b/internal/user/config_status.go new file mode 100644 index 00000000..b1fa53a7 --- /dev/null +++ b/internal/user/config_status.go @@ -0,0 +1,62 @@ +// Copyright (c) 2023 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +package user + +import ( + "context" + "encoding/json" + + "github.com/ProtonMail/gluon/reporter" + "github.com/ProtonMail/proton-bridge/v3/internal/configstatus" +) + +func (user *User) SendConfigStatusSuccess() { + if !user.telemetryManager.IsTelemetryAvailable() { + return + } + + if !user.configStatus.IsPending() { + return + } + + var builder configstatus.ConfigSuccessBuilder + success := builder.New(user.configStatus.Data) + data, err := json.Marshal(success) + if err != nil { + if err := user.reporter.ReportMessageWithContext("Cannot parse config_success data.", reporter.Context{ + "error": err, + }); err != nil { + user.log.WithError(err).Error("Failed to parse config_success data.") + } + } + + if err := user.SendTelemetry(context.Background(), data); err == nil { + if err := user.configStatus.ApplySuccess(); err != nil { + user.log.WithError(err).Error("Failed to ApplySuccess on config_status.") + } + } +} + +func (user *User) SendConfigStatusAbort() { +} + +func (user *User) SendConfigStatusRecovery() { +} + +func (user *User) SendConfigStatusProgress() { +} diff --git a/internal/user/imap.go b/internal/user/imap.go index 2b7c3bd0..6ca3dc45 100644 --- a/internal/user/imap.go +++ b/internal/user/imap.go @@ -86,6 +86,8 @@ func (conn *imapConnector) Authorize(username string, password []byte) bool { return false } + conn.User.SendConfigStatusSuccess() + return true } diff --git a/internal/user/user.go b/internal/user/user.go index d9e898b2..f4799180 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -40,6 +40,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/logging" "github.com/ProtonMail/proton-bridge/v3/internal/safe" + "github.com/ProtonMail/proton-bridge/v3/internal/telemetry" "github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/pkg/algo" "github.com/bradenaw/juniper/xslices" @@ -95,7 +96,8 @@ type User struct { panicHandler async.PanicHandler - configStatus *configstatus.ConfigurationStatus + configStatus *configstatus.ConfigurationStatus + telemetryManager telemetry.Availability } // New returns a new user. @@ -108,7 +110,8 @@ func New( crashHandler async.PanicHandler, showAllMail bool, maxSyncMemory uint64, - cacheDir string, + statsDir string, + telemetryManager telemetry.Availability, ) (*User, error) { logrus.WithField("userID", apiUser.ID).Info("Creating new user") @@ -130,7 +133,7 @@ func New( "numLabels": len(apiLabels), }).Info("Creating user object") - configStatusFile := filepath.Join(cacheDir, apiUser.ID+".json") + configStatusFile := filepath.Join(statsDir, apiUser.ID+".json") configStatus, err := configstatus.LoadConfigurationStatus(configStatusFile) if err != nil { return nil, fmt.Errorf("failed to init configuration status file: %w", err) @@ -169,7 +172,8 @@ func New( panicHandler: crashHandler, - configStatus: configStatus, + configStatus: configStatus, + telemetryManager: telemetryManager, } // Initialize the user's update channels for its current address mode. diff --git a/internal/user/user_test.go b/internal/user/user_test.go index 99bd048e..594e49c0 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -26,8 +26,10 @@ import ( "github.com/ProtonMail/go-proton-api/server" "github.com/ProtonMail/go-proton-api/server/backend" "github.com/ProtonMail/proton-bridge/v3/internal/certs" + "github.com/ProtonMail/proton-bridge/v3/internal/telemetry/mocks" "github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/tests" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" "go.uber.org/goleak" ) @@ -143,7 +145,10 @@ func withUser(tb testing.TB, ctx context.Context, _ *server.Server, m *proton.Ma vaultUser, err := v.AddUser(apiUser.ID, username, username+"@pm.me", apiAuth.UID, apiAuth.RefreshToken, saltedKeyPass) require.NoError(tb, err) - user, err := New(ctx, vaultUser, client, nil, apiUser, nil, true, vault.DefaultMaxSyncMemory, tb.TempDir()) + ctl := gomock.NewController(tb) + defer ctl.Finish() + manager := mocks.NewMockHeartbeatManager(ctl) + user, err := New(ctx, vaultUser, client, nil, apiUser, nil, true, vault.DefaultMaxSyncMemory, tb.TempDir(), manager) require.NoError(tb, err) defer user.Close() diff --git a/tests/bdd_test.go b/tests/bdd_test.go index def2ac15..90cfb91f 100644 --- a/tests/bdd_test.go +++ b/tests/bdd_test.go @@ -245,6 +245,9 @@ func TestFeatures(testingT *testing.T) { ctx.Step(`^bridge needs to send heartbeat`, s.bridgeNeedsToSendHeartbeat) ctx.Step(`^bridge do not need to send heartbeat`, s.bridgeDoNotNeedToSendHeartbeat) ctx.Step(`^heartbeat is not whitelisted`, s.heartbeatIsNotwhitelisted) + ctx.Step(`^config status file exist for user "([^"]*)"$`, s.configStatusFileExistForUser) + ctx.Step(`^config status is pending for user "([^"]*)"$`, s.configStatusIsPendingForUser) + ctx.Step(`^config status succeed for user "([^"]*)"$`, s.configStatusSucceedForUser) }, Options: &godog.Options{ Format: "pretty", diff --git a/tests/config_status_test.go b/tests/config_status_test.go new file mode 100644 index 00000000..ec541a9b --- /dev/null +++ b/tests/config_status_test.go @@ -0,0 +1,95 @@ +// Copyright (c) 2023 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +package tests + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/ProtonMail/proton-bridge/v3/internal/configstatus" +) + +func (s *scenario) configStatusFileExistForUser(username string) error { + configStatusFile, err := getConfigStatusFile(s.t, username) + if err != nil { + return err + } + if _, err := os.Stat(configStatusFile); err != nil { + return err + } + return nil +} + +func (s *scenario) configStatusIsPendingForUser(username string) error { + configStatusFile, err := getConfigStatusFile(s.t, username) + if err != nil { + return err + } + data, err := loadConfigStatusFile(configStatusFile) + if err != nil { + return err + } + if data.DataV1.PendingSince.IsZero() { + return fmt.Errorf("expected ConfigStatus pending but got success instead") + } + + return nil +} + +func (s *scenario) configStatusSucceedForUser(username string) error { + configStatusFile, err := getConfigStatusFile(s.t, username) + if err != nil { + return err + } + data, err := loadConfigStatusFile(configStatusFile) + if err != nil { + return err + } + if !data.DataV1.PendingSince.IsZero() { + return fmt.Errorf("expected ConfigStatus success but got pending since %s", data.DataV1.PendingSince) + } + + return nil +} + +func getConfigStatusFile(t *testCtx, username string) (string, error) { + userID := t.getUserByName(username).getUserID() + statsDir, err := t.locator.ProvideStatsPath() + if err != nil { + return "", fmt.Errorf("failed to get Statistics directory: %w", err) + } + return filepath.Join(statsDir, userID+".json"), nil +} + +func loadConfigStatusFile(filepath string) (configstatus.ConfigurationStatusData, error) { + data := configstatus.ConfigurationStatusData{} + if _, err := os.Stat(filepath); err != nil { + return data, err + } + + f, err := os.Open(filepath) + if err != nil { + return data, err + } + defer func() { _ = f.Close() }() + + err = json.NewDecoder(f).Decode(&data) + return data, err +} diff --git a/tests/features/bridge/config_status.feature b/tests/features/bridge/config_status.feature new file mode 100644 index 00000000..364f5074 --- /dev/null +++ b/tests/features/bridge/config_status.feature @@ -0,0 +1,28 @@ +Feature: Configuration Status Telemetry + Background: + Given there exists an account with username "[user:user]" and password "password" + Then it succeeds + When bridge starts + Then it succeeds + + Scenario: Init config status on user addition + Then bridge telemetry feature is enabled + When the user logs in with username "[user:user]" and password "password" + Then config status file exist for user "[user:user]" + And config status is pending for user "[user:user]" + + Scenario: Config Status Success on IMAP + Then bridge telemetry feature is enabled + When the user logs in with username "[user:user]" and password "password" + Then config status file exist for user "[user:user]" + And config status is pending for user "[user:user]" + When user "[user:user]" connects and authenticates IMAP client "1" + Then config status succeed for user "[user:user]" + + Scenario: Config Status Success on SMTP + Then bridge telemetry feature is enabled + When the user logs in with username "[user:user]" and password "password" + Then config status file exist for user "[user:user]" + And config status is pending for user "[user:user]" + When user "[user:user]" connects and authenticates SMTP client "1" + Then config status succeed for user "[user:user]" \ No newline at end of file