diff --git a/internal/bridge/types.go b/internal/bridge/types.go index 958f19a3..c72cd515 100644 --- a/internal/bridge/types.go +++ b/internal/bridge/types.go @@ -28,6 +28,7 @@ type Locator interface { ProvideLogsPath() (string, error) ProvideGluonCachePath() (string, error) ProvideGluonDataPath() (string, error) + ProvideStatsPath() (string, error) GetLicenseFilePath() string GetDependencyLicensesLink() string Clear(...string) error diff --git a/internal/bridge/user.go b/internal/bridge/user.go index 3c8e15e4..e39f5fd2 100644 --- a/internal/bridge/user.go +++ b/internal/bridge/user.go @@ -519,6 +519,11 @@ func (bridge *Bridge) addUserWithVault( apiUser proton.User, vault *vault.User, ) error { + statsPath, err := bridge.locator.ProvideStatsPath() + if err != nil { + return fmt.Errorf("failed to get Statistics directory: %w", err) + } + user, err := user.New( ctx, vault, @@ -528,6 +533,7 @@ func (bridge *Bridge) addUserWithVault( bridge.panicHandler, bridge.vault.GetShowAllMail(), bridge.vault.GetMaxSyncMemory(), + statsPath, ) if err != nil { return fmt.Errorf("failed to create user: %w", err) diff --git a/internal/locations/locations.go b/internal/locations/locations.go index 8a5c14d5..39ca4099 100644 --- a/internal/locations/locations.go +++ b/internal/locations/locations.go @@ -188,6 +188,16 @@ func (l *Locations) ProvideUpdatesPath() (string, error) { return l.getUpdatesPath(), nil } +// ProvideStatsPath returns a location for statistics files (e.g. ~/.local/share///stats). +// It creates it if it doesn't already exist. +func (l *Locations) ProvideStatsPath() (string, error) { + if err := os.MkdirAll(l.getStatsPath(), 0o700); err != nil { + return "", err + } + + return l.getStatsPath(), nil +} + func (l *Locations) getGluonCachePath() string { return filepath.Join(l.userData, "gluon") } @@ -216,6 +226,10 @@ func (l *Locations) getUpdatesPath() string { return filepath.Join(l.userData, "updates") } +func (l *Locations) getStatsPath() string { + return filepath.Join(l.userData, "stats") +} + // Clear removes everything except the lock and update files. func (l *Locations) Clear(except ...string) error { return files.Remove( diff --git a/internal/telemetry/configuration_abort.go b/internal/telemetry/configuration_abort.go new file mode 100644 index 00000000..4e6566cf --- /dev/null +++ b/internal/telemetry/configuration_abort.go @@ -0,0 +1,18 @@ +// 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 diff --git a/internal/telemetry/configuration_progress.go b/internal/telemetry/configuration_progress.go new file mode 100644 index 00000000..4e6566cf --- /dev/null +++ b/internal/telemetry/configuration_progress.go @@ -0,0 +1,18 @@ +// 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 diff --git a/internal/telemetry/configuration_recovery.go b/internal/telemetry/configuration_recovery.go new file mode 100644 index 00000000..4e6566cf --- /dev/null +++ b/internal/telemetry/configuration_recovery.go @@ -0,0 +1,18 @@ +// 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 diff --git a/internal/telemetry/configuration_success.go b/internal/telemetry/configuration_success.go new file mode 100644 index 00000000..4e6566cf --- /dev/null +++ b/internal/telemetry/configuration_success.go @@ -0,0 +1,18 @@ +// 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 diff --git a/internal/user/config_status.go b/internal/user/config_status.go new file mode 100644 index 00000000..c18d4e34 --- /dev/null +++ b/internal/user/config_status.go @@ -0,0 +1,127 @@ +// 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 ( + "encoding/json" + "os" + "time" +) + +type ConfigurationStatusData struct { + PendingSince time.Time `json:"auto_update"` + LastProgress time.Time `json:"last_progress"` + Autoconf string `json:"auto_conf"` + ClickedLink uint64 `json:"clicked_link"` + ReportSent bool `json:"report_sent"` + ReportClick bool `json:"report_click"` + FailureDetails string `json:"failure_details"` +} + +type ConfigurationStatus struct { + FilePath string + Data ConfigurationStatusData +} + +func LoadConfigurationStatus(filepath string) (*ConfigurationStatus, error) { + status := ConfigurationStatus{ + FilePath: filepath, + Data: ConfigurationStatusData{}, + } + if _, err := os.Stat(filepath); err == nil { + if err := status.Data.load(filepath); err == nil { + return &status, nil + } + } else { + status.Data.init() + if err := status.save(); err == nil { + return &status, nil + } + } + return &status, nil +} + +func (status *ConfigurationStatus) Success() error { + status.Data.init() + status.Data.PendingSince = time.Time{} + return status.save() +} + +func (status *ConfigurationStatus) Failure(err string) error { + status.Data.init() + status.Data.FailureDetails = err + return status.save() +} + +func (status *ConfigurationStatus) Progress() error { + status.Data.LastProgress = time.Now() + return status.save() +} + +func (status *ConfigurationStatus) RecordLinkClicked(link uint) error { + if !status.Data.hasLinkClicked(link) { + status.Data.setClickedLink(link) + return status.save() + } + return nil +} + +func (status *ConfigurationStatus) save() error { + return status.Data.save(status.FilePath) +} + +func (data *ConfigurationStatusData) init() { + data.PendingSince = time.Now() + data.LastProgress = time.Time{} + data.Autoconf = "" + data.ClickedLink = 0 + data.ReportSent = false + data.ReportClick = false + data.FailureDetails = "" +} + +func (data *ConfigurationStatusData) load(filepath string) error { + f, err := os.Open(filepath) // nolint: gosec + if err != nil { + return err + } + + defer func() { _ = f.Close() }() + + return json.NewDecoder(f).Decode(data) +} + +func (data *ConfigurationStatusData) save(filepath string) error { + f, err := os.Create(filepath) // nolint: gosec + if err != nil { + return err + } + + defer func() { _ = f.Close() }() + + return json.NewEncoder(f).Encode(data) +} + +func (data *ConfigurationStatusData) setClickedLink(pos uint) { + data.ClickedLink |= 1 << pos +} + +func (data *ConfigurationStatusData) hasLinkClicked(pos uint) bool { + val := data.ClickedLink & (1 << pos) + return val > 0 +} diff --git a/internal/user/user.go b/internal/user/user.go index 3d7077ba..69b14d58 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -25,6 +25,7 @@ import ( "fmt" "io" "net" + "path/filepath" "strings" "sync/atomic" "time" @@ -92,6 +93,8 @@ type User struct { maxSyncMemory uint64 panicHandler async.PanicHandler + + configStatus *ConfigurationStatus } // New returns a new user. @@ -104,6 +107,7 @@ func New( crashHandler async.PanicHandler, showAllMail bool, maxSyncMemory uint64, + cacheDir string, ) (*User, error) { logrus.WithField("userID", apiUser.ID).Info("Creating new user") @@ -125,6 +129,12 @@ func New( "numLabels": len(apiLabels), }).Info("Creating user object") + configStatusFile := filepath.Join(cacheDir, apiUser.ID+".json") + configStatus, err := LoadConfigurationStatus(configStatusFile) + if err != nil { + return nil, fmt.Errorf("failed to init configuration status file: %w", err) + } + // Create the user object. user := &User{ log: logrus.WithField("userID", apiUser.ID), @@ -157,6 +167,8 @@ func New( maxSyncMemory: maxSyncMemory, panicHandler: crashHandler, + + configStatus: configStatus, } // 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 cf3388d5..99bd048e 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -143,7 +143,7 @@ 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) + user, err := New(ctx, vaultUser, client, nil, apiUser, nil, true, vault.DefaultMaxSyncMemory, tb.TempDir()) require.NoError(tb, err) defer user.Close()